mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Merge commit 'f751377d1e3c9007e1ffa57fd9e4dccfc5c47f91' into codex/psec-4396-git-07-status-guard
This commit is contained in:
+119
-19
@@ -14,8 +14,8 @@ use crate::FsmonitorOverride;
|
||||
use crate::apply_output::parse_git_apply_output;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::merge_driver::ensure_no_selected_merge_drivers;
|
||||
use crate::patch_paths::extract_effective_paths_from_patch;
|
||||
use crate::patch_paths::stage_effective_paths;
|
||||
use crate::patch_paths::extract_patch_path_inventory;
|
||||
use crate::reverse_staging::stage_effective_paths_for_reverse;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
use crate::safe_git::ensure_no_selected_executable_git_filters;
|
||||
#[cfg(test)]
|
||||
@@ -55,10 +55,12 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
|
||||
// Keep tmpdir alive until function end to ensure the file exists
|
||||
let _guard = tmpdir;
|
||||
let patch_paths = extract_effective_paths_from_patch(&git, &patch_path, req.revert)?;
|
||||
ensure_no_selected_executable_git_filters(&git, &git_root, &patch_paths, &cfg_parts)?;
|
||||
|
||||
let patch_path_inventory = extract_patch_path_inventory(&git, &patch_path, req.revert)?;
|
||||
let patch_paths = &patch_path_inventory.effective_paths;
|
||||
let filter_guard =
|
||||
ensure_no_selected_executable_git_filters(&git, &git_root, patch_paths, &cfg_parts)?;
|
||||
cfg_parts.extend(safe_git_config_parts());
|
||||
cfg_parts.extend_from_slice(filter_guard.git_config_args());
|
||||
let patch_arg = patch_path.to_string_lossy().to_string();
|
||||
|
||||
// Optional preflight: dry-run only; do not modify working tree
|
||||
@@ -89,13 +91,14 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid three-way machinery entirely when the patch applies cleanly.
|
||||
// A selected merge driver is relevant only to the three-way fallback.
|
||||
let mut plain_check_args = vec![
|
||||
"apply".to_string(),
|
||||
"--check".to_string(),
|
||||
"--index".to_string(),
|
||||
];
|
||||
// Avoid three-way machinery entirely when the patch applies cleanly. A
|
||||
// reverse check must inspect the working tree before staging: requiring
|
||||
// the old index to match would misclassify a clean undo as a three-way
|
||||
// fallback. Forward direct application still requires index agreement.
|
||||
let mut plain_check_args = vec!["apply".to_string(), "--check".to_string()];
|
||||
if !req.revert {
|
||||
plain_check_args.push("--index".to_string());
|
||||
}
|
||||
if req.revert {
|
||||
plain_check_args.push("-R".to_string());
|
||||
}
|
||||
@@ -103,15 +106,21 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
let (plain_check_code, _, _) = run_git(&git, &git_root, &cfg_parts, &plain_check_args)?;
|
||||
|
||||
let mut args = vec!["apply".to_string()];
|
||||
if plain_check_code != 0 {
|
||||
ensure_no_selected_merge_drivers(&git, &git_root, &patch_paths, &cfg_parts)?;
|
||||
if req.revert {
|
||||
// Stage WT paths first to avoid index mismatch on three-way revert.
|
||||
stage_effective_paths(&git, &git_root, &patch_paths)?;
|
||||
let merge_guard = if plain_check_code != 0 {
|
||||
let guard = ensure_no_selected_merge_drivers(&git, &git_root, patch_paths, &cfg_parts)?;
|
||||
if let Some(guard) = &guard {
|
||||
cfg_parts.extend_from_slice(guard.git_config_args());
|
||||
}
|
||||
args.push("--3way".to_string());
|
||||
guard
|
||||
} else {
|
||||
args.push("--index".to_string());
|
||||
None
|
||||
};
|
||||
if req.revert {
|
||||
// Stage only after the worktree-only applicability decision. The
|
||||
// guarded config is shared with the final Git command.
|
||||
stage_effective_paths_for_reverse(&git, &git_root, patch_paths, &cfg_parts)?;
|
||||
}
|
||||
if req.revert {
|
||||
args.push("-R".to_string());
|
||||
@@ -120,9 +129,13 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
|
||||
let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
|
||||
let (code, stdout, stderr) = run_git(&git, &git_root, &cfg_parts, &args)?;
|
||||
drop(merge_guard);
|
||||
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||||
parse_git_apply_output(&stdout, &stderr);
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = if code == 0 {
|
||||
(patch_path_inventory.primary_paths, Vec::new(), Vec::new())
|
||||
} else {
|
||||
parse_git_apply_output(&stdout, &stderr)
|
||||
};
|
||||
applied_paths.sort();
|
||||
applied_paths.dedup();
|
||||
skipped_paths.sort();
|
||||
@@ -273,6 +286,14 @@ fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> St
|
||||
#[path = "apply_transport_tests.rs"]
|
||||
mod transport_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "apply_filter_tests.rs"]
|
||||
mod filter_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "reverse_apply_tests.rs"]
|
||||
mod reverse_apply_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -389,6 +410,13 @@ mod tests {
|
||||
code == 0
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn trusted_git_directory() -> PathBuf {
|
||||
std::env::split_paths(&std::env::var_os("PATH").expect("PATH"))
|
||||
.find(|directory| directory.is_absolute() && directory.join("git").is_file())
|
||||
.expect("trusted Git directory")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_unescapes_quoted_paths() {
|
||||
let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
|
||||
@@ -415,6 +443,7 @@ mod tests {
|
||||
};
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_eq!(r.exit_code, 0, "exit code 0");
|
||||
assert_eq!(r.applied_paths, vec!["hello.txt"]);
|
||||
// File exists now
|
||||
assert!(root.join("hello.txt").exists());
|
||||
}
|
||||
@@ -471,6 +500,77 @@ mod tests {
|
||||
assert_eq!(read_file_normalized(&root.join("file.txt")), "new\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn apply_uses_logical_process_cwd_to_reject_enclosing_git() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let _g = env_lock().lock().unwrap();
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_CHILD").is_none() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let physical_nested = fixture.path().join("physical-nested");
|
||||
let lexical_nested = outer.join("nested");
|
||||
let outer_bin = outer.join("bin");
|
||||
let outer_git = outer_bin.join("git");
|
||||
let marker = outer_bin.join("git.ran");
|
||||
std::fs::create_dir_all(&outer_bin).expect("outer Git directory");
|
||||
std::fs::create_dir_all(&physical_nested).expect("physical nested repository");
|
||||
let (outer_init, _, outer_err) = run(&outer, &["git", "init", "-q"]);
|
||||
assert_eq!(outer_init, 0, "init outer repository: {outer_err}");
|
||||
let (nested_init, _, nested_err) = run(&physical_nested, &["git", "init", "-q"]);
|
||||
assert_eq!(nested_init, 0, "init nested repository: {nested_err}");
|
||||
std::os::unix::fs::symlink(&physical_nested, &lexical_nested)
|
||||
.expect("symlink nested repository");
|
||||
std::fs::write(&outer_git, "#!/bin/sh\nprintf ran >\"$0.ran\"\nexit 1\n")
|
||||
.expect("outer Git shim");
|
||||
let mut permissions = std::fs::metadata(&outer_git)
|
||||
.expect("outer Git metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&outer_git, permissions).expect("executable outer Git");
|
||||
let path = std::env::join_paths([outer_bin, trusted_git_directory()]).expect("PATH");
|
||||
|
||||
let mut command =
|
||||
std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.arg("apply::tests::apply_uses_logical_process_cwd_to_reject_enclosing_git")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.current_dir(&lexical_nested)
|
||||
.env("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_CHILD", "1")
|
||||
.env("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_MARKER", &marker)
|
||||
.env("PWD", &lexical_nested)
|
||||
.env("PATH", path)
|
||||
.env("RUST_TEST_THREADS", "1")
|
||||
.output()
|
||||
.expect("run isolated logical-cwd test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated logical-cwd test failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(!marker.exists(), "enclosing Git shim must not run");
|
||||
return;
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().expect("physical process cwd");
|
||||
let marker = PathBuf::from(
|
||||
std::env::var_os("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_MARKER").expect("marker path"),
|
||||
);
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd,
|
||||
diff: "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1 @@\n+hello\n".to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
})
|
||||
.expect("preflight through trusted Git");
|
||||
assert_eq!(result.exit_code, 0, "preflight should succeed");
|
||||
assert!(!marker.exists(), "enclosing Git shim must not run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modify_conflict() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::fs::FileTimes;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
const PATCH: &str =
|
||||
"diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-orig\n+next\n";
|
||||
const FILTER_COMMAND: &str = "git config codex.filterran true && git hash-object --stdin";
|
||||
const PROCESS_FILTER_COMMAND: &str =
|
||||
"git config codex.filterran true && git rev-parse --verify refs/codex-filter-must-not-run";
|
||||
const COMMAND_SCOPED_FILTER_CONFIG: &str =
|
||||
"filter.demo.clean=git config codex.filterran true && git hash-object --stdin";
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PatchKind {
|
||||
Direct,
|
||||
ThreeWay,
|
||||
}
|
||||
|
||||
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
|
||||
let mut command = std::process::Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("run Git");
|
||||
(
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_success(cwd: &Path, args: &[&str]) {
|
||||
let (code, _, stderr) = run(cwd, args);
|
||||
assert_eq!(code, 0, "git {args:?}: {stderr}");
|
||||
}
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let repo = tempfile::tempdir().expect("tempdir");
|
||||
let root = repo.path();
|
||||
run_success(root, &["init"]);
|
||||
run_success(root, &["config", "user.email", "codex@example.com"]);
|
||||
run_success(root, &["config", "user.name", "Codex"]);
|
||||
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
|
||||
run_success(root, &["add", "file.txt"]);
|
||||
run_success(root, &["commit", "-m", "seed"]);
|
||||
repo
|
||||
}
|
||||
|
||||
fn configure_filter(root: &Path, driver: &str) {
|
||||
configure_filter_command(root, driver, "clean", FILTER_COMMAND);
|
||||
}
|
||||
|
||||
fn configure_filter_command(root: &Path, driver: &str, name: &str, command: &str) {
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("filter.{driver}.{name}"), command],
|
||||
);
|
||||
}
|
||||
|
||||
fn make_index_racy(root: &Path) {
|
||||
let index = File::options()
|
||||
.write(true)
|
||||
.open(root.join(".git/index"))
|
||||
.expect("open index");
|
||||
index
|
||||
.set_times(FileTimes::new().set_modified(UNIX_EPOCH))
|
||||
.expect("make index entries racy");
|
||||
}
|
||||
|
||||
fn build_patch(root: &Path, kind: PatchKind, paths: &[&str]) -> String {
|
||||
if matches!(kind, PatchKind::Direct) && paths == ["file.txt"] {
|
||||
return PATCH.to_string();
|
||||
}
|
||||
std::fs::write(root.join("file.txt"), "next\n").expect("write target postimage");
|
||||
let mut args = vec!["diff"];
|
||||
if matches!(kind, PatchKind::ThreeWay) {
|
||||
args.extend(["--full-index", "--binary"]);
|
||||
}
|
||||
args.push("--");
|
||||
args.extend_from_slice(paths);
|
||||
let (code, stdout, stderr) = run(root, &args);
|
||||
assert_eq!(code, 0, "generate patch: {stderr}");
|
||||
let mut checkout = vec!["checkout", "-q", "--"];
|
||||
checkout.extend_from_slice(paths);
|
||||
run_success(root, &checkout);
|
||||
stdout
|
||||
}
|
||||
|
||||
fn index_contents(root: &Path, path: &str) -> String {
|
||||
let (code, stdout, stderr) = run(root, &["show", &format!(":{path}")]);
|
||||
assert_eq!(code, 0, "read index {path}: {stderr}");
|
||||
stdout
|
||||
}
|
||||
|
||||
fn setup_racy_offpath_filter(
|
||||
driver: &str,
|
||||
name: &str,
|
||||
command: &str,
|
||||
kind: PatchKind,
|
||||
) -> (tempfile::TempDir, String, String) {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("outside.txt"), "outside\n").expect("write off-path file");
|
||||
std::fs::write(
|
||||
root.join(".gitattributes"),
|
||||
format!("outside.txt filter={driver}\n"),
|
||||
)
|
||||
.expect("write off-path attributes");
|
||||
run_success(root, &["add", ".gitattributes", "outside.txt"]);
|
||||
run_success(root, &["commit", "-m", "off-path filter target"]);
|
||||
let patch = build_patch(root, kind, &["file.txt"]);
|
||||
configure_filter_command(root, driver, name, command);
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("filter.{driver}.required"), "true"],
|
||||
);
|
||||
let outside_index = index_contents(root, "outside.txt");
|
||||
make_index_racy(root);
|
||||
(repo, patch, outside_index)
|
||||
}
|
||||
|
||||
fn assert_guarded_round_trip(
|
||||
repo: &tempfile::TempDir,
|
||||
patch: String,
|
||||
outside_index: &str,
|
||||
context: &str,
|
||||
) {
|
||||
let root = repo.path();
|
||||
let forward = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: patch.clone(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
})
|
||||
.expect("guard forward apply");
|
||||
assert_eq!(forward.exit_code, 0, "{context}: {}", forward.stderr);
|
||||
assert!(!configured_filter_ran(root), "{context}: forward helper");
|
||||
assert_eq!(index_contents(root, "file.txt"), "next\n", "{context}");
|
||||
assert_eq!(
|
||||
index_contents(root, "outside.txt"),
|
||||
outside_index,
|
||||
"{context}"
|
||||
);
|
||||
|
||||
make_index_racy(root);
|
||||
let reverse = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: patch,
|
||||
revert: true,
|
||||
preflight: false,
|
||||
})
|
||||
.expect("guard reverse apply");
|
||||
assert_eq!(reverse.exit_code, 0, "{context}: {}", reverse.stderr);
|
||||
assert!(!configured_filter_ran(root), "{context}: reverse helper");
|
||||
assert_eq!(index_contents(root, "file.txt"), "orig\n", "{context}");
|
||||
assert_eq!(
|
||||
index_contents(root, "outside.txt"),
|
||||
outside_index,
|
||||
"{context}"
|
||||
);
|
||||
}
|
||||
|
||||
fn configured_filter_ran(root: &Path) -> bool {
|
||||
run(root, &["config", "--get", "codex.filterran"]).0 == 0
|
||||
}
|
||||
|
||||
fn request(root: &Path, revert: bool, preflight: bool) -> ApplyGitRequest {
|
||||
ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: PATCH.to_string(),
|
||||
revert,
|
||||
preflight,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||||
isolate_git_command_environment(&mut command);
|
||||
command
|
||||
.arg(test_name)
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD", "1")
|
||||
.env("RUST_TEST_THREADS", "1");
|
||||
for (name, value) in env {
|
||||
command.env(name, value);
|
||||
}
|
||||
let output = command.output().expect("run isolated test process");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated test {test_name} failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_staging_uses_command_scoped_filter_override() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"apply::filter_tests::reverse_staging_uses_command_scoped_filter_override",
|
||||
&[("CODEX_APPLY_GIT_CFG", OsStr::new("filter.demo.clean="))],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=demo\n")
|
||||
.expect("write attributes");
|
||||
run_success(root, &["add", ".gitattributes"]);
|
||||
run_success(root, &["commit", "-m", "attributes"]);
|
||||
configure_filter(root, "demo");
|
||||
|
||||
let forward = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ false,
|
||||
))
|
||||
.expect("apply with disabled command-scoped filter");
|
||||
assert_eq!(forward.exit_code, 0);
|
||||
let reverse = apply_git_patch(&request(
|
||||
root, /*revert*/ true, /*preflight*/ false,
|
||||
))
|
||||
.expect("reverse with disabled command-scoped filter");
|
||||
assert_eq!(reverse.exit_code, 0);
|
||||
assert!(!configured_filter_ran(root));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("file.txt")).expect("read file"),
|
||||
"orig\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_treats_empty_name_filter_as_selected_only() {
|
||||
for command in ["clean", "smudge", "process"] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("filter..{command}"), FILTER_COMMAND],
|
||||
);
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=other\n")
|
||||
.expect("write unrelated attributes");
|
||||
let unused = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect("allow unused empty-name filter");
|
||||
assert_eq!(unused.exit_code, 0, "{command}");
|
||||
assert!(!configured_filter_ran(root), "{command}");
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=\n")
|
||||
.expect("select empty-name filter");
|
||||
let selected = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect_err("reject selected empty-name filter");
|
||||
assert_eq!(selected.kind(), io::ErrorKind::Unsupported, "{command}");
|
||||
assert!(!configured_filter_ran(root), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_distinguishes_filter_sentinels_from_literal_driver_names() {
|
||||
for (driver, sentinel_attribute) in
|
||||
[("set", "filter"), ("unset", "-filter"), ("unspecified", "")]
|
||||
{
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
configure_filter(root, driver);
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("filter.{driver}.smudge"), FILTER_COMMAND],
|
||||
);
|
||||
run_success(
|
||||
root,
|
||||
&[
|
||||
"config",
|
||||
&format!("filter.{driver}.process"),
|
||||
FILTER_COMMAND,
|
||||
],
|
||||
);
|
||||
|
||||
let attributes = if sentinel_attribute.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("file.txt {sentinel_attribute}\n")
|
||||
};
|
||||
std::fs::write(root.join(".gitattributes"), attributes).expect("write sentinel attribute");
|
||||
let sentinel = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect("allow attribute sentinel");
|
||||
assert_eq!(sentinel.exit_code, 0, "{driver}");
|
||||
assert!(!configured_filter_ran(root), "{driver}");
|
||||
|
||||
std::fs::write(
|
||||
root.join(".gitattributes"),
|
||||
format!("file.txt filter={driver}\n"),
|
||||
)
|
||||
.expect("write literal driver attribute");
|
||||
let selected = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect_err("reject literal sentinel-named driver");
|
||||
assert_eq!(selected.kind(), io::ErrorKind::Unsupported, "{driver}");
|
||||
assert!(!configured_filter_ran(root), "{driver}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_allows_effectively_empty_empty_name_filter() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"apply::filter_tests::apply_allows_effectively_empty_empty_name_filter",
|
||||
&[(
|
||||
"CODEX_APPLY_GIT_CFG",
|
||||
OsStr::new("filter..clean=,filter..smudge=,filter..process="),
|
||||
)],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=\n")
|
||||
.expect("select empty-name filter");
|
||||
for command in ["clean", "smudge", "process"] {
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("filter..{command}"), FILTER_COMMAND],
|
||||
);
|
||||
}
|
||||
run_success(root, &["config", "filter.unused.clean", "false"]);
|
||||
|
||||
let result = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect("allow effectively empty empty-name filter");
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert!(!configured_filter_ran(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_neutralizes_racy_offpath_clean_and_process_filters() {
|
||||
for (name, command) in [
|
||||
("clean", FILTER_COMMAND),
|
||||
("process", PROCESS_FILTER_COMMAND),
|
||||
] {
|
||||
for kind in [PatchKind::Direct, PatchKind::ThreeWay] {
|
||||
let (repo, patch, outside_index) =
|
||||
setup_racy_offpath_filter("demo", name, command, kind);
|
||||
assert_guarded_round_trip(&repo, patch, &outside_index, &format!("{name} {kind:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_neutralizes_empty_and_equals_filter_driver_names() {
|
||||
for (driver, name, command, kind) in [
|
||||
("", "clean", FILTER_COMMAND, PatchKind::Direct),
|
||||
(
|
||||
"x=y",
|
||||
"process",
|
||||
PROCESS_FILTER_COMMAND,
|
||||
PatchKind::ThreeWay,
|
||||
),
|
||||
] {
|
||||
let (repo, patch, outside_index) = setup_racy_offpath_filter(driver, name, command, kind);
|
||||
assert_guarded_round_trip(
|
||||
&repo,
|
||||
patch,
|
||||
&outside_index,
|
||||
&format!("driver {driver:?} {name}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_scoped_offpath_filter_is_neutralized_after_local_config() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"apply::filter_tests::command_scoped_offpath_filter_is_neutralized_after_local_config",
|
||||
&[(
|
||||
"CODEX_APPLY_GIT_CFG",
|
||||
OsStr::new(COMMAND_SCOPED_FILTER_CONFIG),
|
||||
)],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("outside.txt"), "outside\n").expect("write off-path file");
|
||||
std::fs::write(root.join(".gitattributes"), "outside.txt filter=demo\n")
|
||||
.expect("write off-path attributes");
|
||||
run_success(root, &["add", ".gitattributes", "outside.txt"]);
|
||||
run_success(root, &["commit", "-m", "off-path filter target"]);
|
||||
run_success(root, &["config", "filter.demo.clean", ""]);
|
||||
run_success(root, &["config", "filter.demo.required", "true"]);
|
||||
let outside_index = index_contents(root, "outside.txt");
|
||||
make_index_racy(root);
|
||||
assert_guarded_round_trip(
|
||||
&repo,
|
||||
PATCH.to_string(),
|
||||
&outside_index,
|
||||
"command-scoped clean",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_neutralizes_filter_activated_for_racy_offpath_by_same_patch() {
|
||||
for (name, command) in [
|
||||
("clean", FILTER_COMMAND),
|
||||
("process", PROCESS_FILTER_COMMAND),
|
||||
] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("outside.txt"), "outside\n").expect("write off-path file");
|
||||
std::fs::write(root.join(".gitattributes"), "outside.txt -filter\n")
|
||||
.expect("write preimage attributes");
|
||||
run_success(root, &["add", ".gitattributes", "outside.txt"]);
|
||||
run_success(root, &["commit", "-m", "off-path filter preimage"]);
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "outside.txt filter=demo\n")
|
||||
.expect("write postimage attributes");
|
||||
let patch = build_patch(root, PatchKind::ThreeWay, &[".gitattributes", "file.txt"]);
|
||||
configure_filter_command(root, "demo", name, command);
|
||||
run_success(root, &["config", "filter.demo.required", "true"]);
|
||||
let outside_index = index_contents(root, "outside.txt");
|
||||
make_index_racy(root);
|
||||
|
||||
assert_guarded_round_trip(
|
||||
&repo,
|
||||
patch,
|
||||
&outside_index,
|
||||
&format!("dynamic off-path {name}"),
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(".gitattributes")).expect("read restored attributes"),
|
||||
"outside.txt -filter\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_still_refuses_clean_and_process_filters_selected_for_target() {
|
||||
for (name, command) in [
|
||||
("clean", FILTER_COMMAND),
|
||||
("process", PROCESS_FILTER_COMMAND),
|
||||
] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=demo\n")
|
||||
.expect("write target attributes");
|
||||
run_success(root, &["add", ".gitattributes"]);
|
||||
run_success(root, &["commit", "-m", "target filter"]);
|
||||
configure_filter_command(root, "demo", name, command);
|
||||
run_success(root, &["config", "filter.demo.required", "true"]);
|
||||
|
||||
let error = apply_git_patch(&request(
|
||||
root, /*revert*/ false, /*preflight*/ false,
|
||||
))
|
||||
.expect_err("refuse selected target filter");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{name}");
|
||||
assert!(!configured_filter_ran(root), "{name}");
|
||||
assert_eq!(index_contents(root, "file.txt"), "orig\n", "{name}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("file.txt")).expect("read target"),
|
||||
"orig\n",
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Filesystem-spelling policy for exact staging.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Find requested paths that are neither exact index names nor composed of
|
||||
/// physically exact directory-entry spellings.
|
||||
///
|
||||
/// Git's byte-oriented ignore-case matching does not cover every alias that a
|
||||
/// case-insensitive filesystem recognizes. Exact index names remain valid for
|
||||
/// tracked updates and deletions. Other paths must exist under their requested
|
||||
/// spelling instead of merely resolving through a case or normalization alias;
|
||||
/// unresolved paths are refused before a mixed update can partially stage.
|
||||
pub(crate) fn filesystem_spelling_conflicts(
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
exact_index_paths: &BTreeSet<&[u8]>,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
let mut cache = BTreeMap::<PathBuf, Option<BTreeSet<OsString>>>::new();
|
||||
let mut conflicts = BTreeSet::new();
|
||||
for path in paths {
|
||||
if exact_index_paths.contains(path.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
if !has_exact_directory_entry_spelling(git_root, path, &mut cache)? {
|
||||
conflicts.insert(path.clone());
|
||||
}
|
||||
}
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
fn has_exact_directory_entry_spelling(
|
||||
git_root: &Path,
|
||||
path: &str,
|
||||
cache: &mut BTreeMap<PathBuf, Option<BTreeSet<OsString>>>,
|
||||
) -> io::Result<bool> {
|
||||
let mut parent = git_root.to_path_buf();
|
||||
for component in path.split('/') {
|
||||
if !cache.contains_key(&parent) {
|
||||
let entries = match std::fs::read_dir(&parent) {
|
||||
Ok(entries) => Some(
|
||||
entries
|
||||
.map(|entry| entry.map(|entry| entry.file_name()))
|
||||
.collect::<io::Result<BTreeSet<_>>>()?,
|
||||
),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
cache.insert(parent.clone(), entries);
|
||||
}
|
||||
let Some(Some(entries)) = cache.get(&parent) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !entries.contains(OsStr::new(component)) {
|
||||
return Ok(false);
|
||||
}
|
||||
parent.push(component);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Index-name and flag policy for exact staging.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::exact_filesystem_policy::filesystem_spelling_conflicts;
|
||||
use crate::git_command::GitRunner;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ExactIndexPolicy {
|
||||
Proceed {
|
||||
paths: Vec<String>,
|
||||
content_filter_paths: Vec<String>,
|
||||
},
|
||||
Refuse {
|
||||
stderr: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct IndexEntry<'a> {
|
||||
tag: u8,
|
||||
path: &'a [u8],
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CaseConflict {
|
||||
reason: &'static str,
|
||||
paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl CaseConflict {
|
||||
fn new(reason: &'static str, paths: impl IntoIterator<Item = String>) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
paths: paths.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_index_paths<'a>(&mut self, paths: impl IntoIterator<Item = &'a [u8]>) {
|
||||
self.paths.extend(
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| String::from_utf8_lossy(path).into_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
fn message(&self) -> String {
|
||||
format!(
|
||||
"refusing {} under core.ignoreCase: {}",
|
||||
self.reason,
|
||||
quote_paths(&self.paths)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct IndexEvidence<'a> {
|
||||
full_paths: BTreeMap<Vec<u8>, BTreeSet<&'a [u8]>>,
|
||||
directory_prefixes: BTreeMap<Vec<u8>, BTreeSet<&'a [u8]>>,
|
||||
}
|
||||
|
||||
impl<'a> IndexEvidence<'a> {
|
||||
fn collect(entries: &[IndexEntry<'a>], requested: &[String]) -> Self {
|
||||
let relevant = requested
|
||||
.iter()
|
||||
.flat_map(|path| path_prefixes(path.as_bytes()))
|
||||
.map(ascii_fold)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut full_paths = BTreeMap::<Vec<u8>, BTreeSet<&[u8]>>::new();
|
||||
let mut directory_prefixes = BTreeMap::<Vec<u8>, BTreeSet<&[u8]>>::new();
|
||||
for entry in entries {
|
||||
let folded = ascii_fold(entry.path);
|
||||
if relevant.contains(&folded) {
|
||||
full_paths.entry(folded).or_default().insert(entry.path);
|
||||
}
|
||||
for prefix in non_leaf_prefixes(entry.path) {
|
||||
let folded = ascii_fold(prefix);
|
||||
if relevant.contains(&folded) {
|
||||
directory_prefixes.entry(folded).or_default().insert(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
full_paths,
|
||||
directory_prefixes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_exact_index_policy(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<ExactIndexPolicy> {
|
||||
let output = read_index(git, git_root, git_config_args)?;
|
||||
let entries = parse_index_entries(&output)?;
|
||||
let ignore_case =
|
||||
effective_git_bool(git, git_root, git_config_args, "core.ignoreCase")?.unwrap_or(false);
|
||||
|
||||
let (paths, content_filter_paths) = if ignore_case {
|
||||
let mapped = match map_ignore_case_paths(git_root, &entries, paths, content_filter_paths) {
|
||||
Ok(mapped) => mapped,
|
||||
Err(conflict) => {
|
||||
return Ok(ExactIndexPolicy::Refuse {
|
||||
stderr: conflict.message(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let exact_index_paths = entries
|
||||
.iter()
|
||||
.map(|entry| entry.path)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let conflicts = filesystem_spelling_conflicts(git_root, &mapped.0, &exact_index_paths)?;
|
||||
if !conflicts.is_empty() {
|
||||
return Ok(ExactIndexPolicy::Refuse {
|
||||
stderr: CaseConflict::new(
|
||||
"filesystem spelling aliases or unresolved paths",
|
||||
conflicts,
|
||||
)
|
||||
.message(),
|
||||
});
|
||||
}
|
||||
mapped
|
||||
} else {
|
||||
(paths.to_vec(), content_filter_paths.to_vec())
|
||||
};
|
||||
|
||||
let exact = paths.iter().map(String::as_bytes).collect::<BTreeSet<_>>();
|
||||
let mut skipped = BTreeSet::new();
|
||||
let mut assumed_unchanged = BTreeSet::new();
|
||||
for entry in &entries {
|
||||
if !exact.contains(entry.path) {
|
||||
continue;
|
||||
}
|
||||
let path = std::str::from_utf8(entry.path).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"non-UTF-8 exact path in Git index output",
|
||||
)
|
||||
})?;
|
||||
if entry.tag == b'S' {
|
||||
skipped.insert(path.to_string());
|
||||
}
|
||||
if entry.tag.is_ascii_lowercase() {
|
||||
assumed_unchanged.insert(path.to_string());
|
||||
}
|
||||
}
|
||||
if skipped.is_empty() && assumed_unchanged.is_empty() {
|
||||
return Ok(ExactIndexPolicy::Proceed {
|
||||
paths,
|
||||
content_filter_paths,
|
||||
});
|
||||
}
|
||||
|
||||
let mut reasons = Vec::new();
|
||||
if !skipped.is_empty() {
|
||||
reasons.push(format!("skip-worktree path(s): {}", quote_paths(&skipped)));
|
||||
}
|
||||
if !assumed_unchanged.is_empty() {
|
||||
reasons.push(format!(
|
||||
"assume-unchanged path(s): {}",
|
||||
quote_paths(&assumed_unchanged)
|
||||
));
|
||||
}
|
||||
Ok(ExactIndexPolicy::Refuse {
|
||||
stderr: format!("refusing to stage {}", reasons.join("; ")),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_index(git: &GitRunner, git_root: &Path, git_config_args: &[String]) -> io::Result<Vec<u8>> {
|
||||
// One full, byte-oriented scan avoids invoking Git once per patch path and
|
||||
// lets one parse enforce aliases, directory prefixes, and flags together.
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["ls-files", "--cached", "-v", "-z"])
|
||||
.current_dir(git_root);
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git exact-index probe failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn parse_index_entries(output: &[u8]) -> io::Result<Vec<IndexEntry<'_>>> {
|
||||
if output.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unterminated Git exact-index output",
|
||||
));
|
||||
};
|
||||
body.split(|byte| *byte == 0)
|
||||
.map(|record| {
|
||||
let [tag, b' ', path @ ..] = record else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"malformed Git exact-index output",
|
||||
));
|
||||
};
|
||||
if !valid_index_tag(*tag) || path.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unexpected Git exact-index output",
|
||||
));
|
||||
}
|
||||
Ok(IndexEntry { tag: *tag, path })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_index_tag(tag: u8) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
b'H' | b'S' | b'M' | b'R' | b'C' | b'K' | b'h' | b's' | b'm' | b'r' | b'c' | b'k' | b'?'
|
||||
)
|
||||
}
|
||||
|
||||
fn map_ignore_case_paths(
|
||||
git_root: &Path,
|
||||
entries: &[IndexEntry<'_>],
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
) -> Result<(Vec<String>, Vec<String>), CaseConflict> {
|
||||
let evidence = IndexEvidence::collect(entries, paths);
|
||||
let mut groups = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
|
||||
for path in paths {
|
||||
groups
|
||||
.entry(ascii_fold(path.as_bytes()))
|
||||
.or_default()
|
||||
.insert(path.clone());
|
||||
}
|
||||
|
||||
let mut mapping = BTreeMap::new();
|
||||
for (folded, spellings) in groups {
|
||||
let aliases = evidence
|
||||
.full_paths
|
||||
.get(&folded)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let canonical = match (spellings.len(), aliases.len()) {
|
||||
(1, 0) => {
|
||||
let Some(spelling) = spellings.first() else {
|
||||
return Err(CaseConflict::new("empty request group", []));
|
||||
};
|
||||
spelling.clone()
|
||||
}
|
||||
(1, 1) => {
|
||||
let Some(alias) = aliases.first() else {
|
||||
return Err(CaseConflict::new("missing index alias", spellings));
|
||||
};
|
||||
let alias = std::str::from_utf8(alias)
|
||||
.map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?;
|
||||
if !spellings.contains(alias) {
|
||||
let mut conflict =
|
||||
CaseConflict::new("index full-path alias", spellings.clone());
|
||||
conflict.add_index_paths(aliases.iter().copied());
|
||||
return Err(conflict);
|
||||
}
|
||||
alias.to_string()
|
||||
}
|
||||
(_, 1) => {
|
||||
let Some(alias) = aliases.first() else {
|
||||
return Err(CaseConflict::new("missing index alias", spellings));
|
||||
};
|
||||
let alias = std::str::from_utf8(alias)
|
||||
.map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?;
|
||||
if !spellings.contains(alias) {
|
||||
let mut conflict =
|
||||
CaseConflict::new("index full-path alias", spellings.clone());
|
||||
conflict.add_index_paths(aliases.iter().copied());
|
||||
return Err(conflict);
|
||||
}
|
||||
if let Some(missing) = spellings.iter().find(|spelling| {
|
||||
*spelling != alias
|
||||
&& std::fs::symlink_metadata(git_root.join(spelling)).is_err()
|
||||
}) {
|
||||
return Err(CaseConflict::new(
|
||||
"missing requested case alias",
|
||||
[missing.clone(), alias.to_string()],
|
||||
));
|
||||
}
|
||||
alias.to_string()
|
||||
}
|
||||
(1, _) => {
|
||||
let mut conflict = CaseConflict::new("ambiguous index aliases", spellings);
|
||||
conflict.add_index_paths(aliases.iter().copied());
|
||||
return Err(conflict);
|
||||
}
|
||||
_ if aliases.is_empty() => {
|
||||
return Err(CaseConflict::new(
|
||||
"request-set full-path aliases",
|
||||
spellings,
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
let mut conflict = CaseConflict::new("ambiguous index aliases", spellings);
|
||||
conflict.add_index_paths(aliases.iter().copied());
|
||||
return Err(conflict);
|
||||
}
|
||||
};
|
||||
for spelling in spellings {
|
||||
mapping.insert(spelling, canonical.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let paths = map_and_dedupe(paths, &mapping)
|
||||
.ok_or_else(|| CaseConflict::new("unmapped requested path", paths.to_vec()))?;
|
||||
let content_filter_paths = map_and_dedupe(content_filter_paths, &mapping)
|
||||
.ok_or_else(|| CaseConflict::new("unmapped content-filter path", paths.clone()))?;
|
||||
if let Some(conflict) = requested_paths_conflict(&paths) {
|
||||
return Err(conflict);
|
||||
}
|
||||
if let Some(conflict) = index_paths_conflict(&paths, &evidence) {
|
||||
return Err(conflict);
|
||||
}
|
||||
Ok((paths, content_filter_paths))
|
||||
}
|
||||
|
||||
fn map_and_dedupe(paths: &[String], mapping: &BTreeMap<String, String>) -> Option<Vec<String>> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut mapped = Vec::new();
|
||||
for path in paths {
|
||||
let path = mapping.get(path)?.clone();
|
||||
if seen.insert(path.clone()) {
|
||||
mapped.push(path);
|
||||
}
|
||||
}
|
||||
Some(mapped)
|
||||
}
|
||||
|
||||
fn requested_paths_conflict(paths: &[String]) -> Option<CaseConflict> {
|
||||
let mut full = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
|
||||
let mut directories = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
|
||||
for path in paths {
|
||||
full.entry(ascii_fold(path.as_bytes()))
|
||||
.or_default()
|
||||
.insert(path.clone());
|
||||
for prefix in non_leaf_prefixes(path.as_bytes()) {
|
||||
directories
|
||||
.entry(ascii_fold(prefix))
|
||||
.or_default()
|
||||
.insert(String::from_utf8_lossy(prefix).into_owned());
|
||||
}
|
||||
}
|
||||
if let Some(spellings) = full.values().find(|spellings| spellings.len() > 1) {
|
||||
return Some(CaseConflict::new(
|
||||
"request-set full-path aliases",
|
||||
spellings.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(spellings) = directories.values().find(|spellings| spellings.len() > 1) {
|
||||
return Some(CaseConflict::new(
|
||||
"request-set directory-prefix aliases",
|
||||
spellings.clone(),
|
||||
));
|
||||
}
|
||||
for (folded, leaf) in full {
|
||||
if let Some(prefixes) = directories.get(&folded) {
|
||||
let paths = leaf.into_iter().chain(prefixes.iter().cloned());
|
||||
return Some(CaseConflict::new(
|
||||
"request-set file/directory aliases",
|
||||
paths,
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn index_paths_conflict(paths: &[String], evidence: &IndexEvidence<'_>) -> Option<CaseConflict> {
|
||||
for path in paths {
|
||||
for prefix in non_leaf_prefixes(path.as_bytes()) {
|
||||
let folded = ascii_fold(prefix);
|
||||
if let Some(ancestors) = evidence.full_paths.get(&folded) {
|
||||
let mut conflict =
|
||||
CaseConflict::new("index file/requested-directory alias", [path.clone()]);
|
||||
conflict.add_index_paths(ancestors.iter().copied());
|
||||
return Some(conflict);
|
||||
}
|
||||
if let Some(index_prefixes) = evidence.directory_prefixes.get(&folded)
|
||||
&& index_prefixes.iter().any(|indexed| *indexed != prefix)
|
||||
{
|
||||
let mut conflict =
|
||||
CaseConflict::new("index directory-prefix alias", [path.clone()]);
|
||||
conflict.add_index_paths(index_prefixes.iter().copied());
|
||||
return Some(conflict);
|
||||
}
|
||||
}
|
||||
if let Some(descendants) = evidence
|
||||
.directory_prefixes
|
||||
.get(&ascii_fold(path.as_bytes()))
|
||||
{
|
||||
let mut conflict =
|
||||
CaseConflict::new("requested file/index-directory alias", [path.clone()]);
|
||||
conflict.add_index_paths(descendants.iter().copied());
|
||||
return Some(conflict);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn path_prefixes(path: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
non_leaf_prefixes(path).chain(std::iter::once(path))
|
||||
}
|
||||
|
||||
fn non_leaf_prefixes(path: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
path.iter()
|
||||
.enumerate()
|
||||
.filter_map(move |(index, byte)| (*byte == b'/').then_some(&path[..index]))
|
||||
}
|
||||
|
||||
fn ascii_fold(value: &[u8]) -> Vec<u8> {
|
||||
value.iter().map(u8::to_ascii_lowercase).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn effective_git_bool(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
git_config_args: &[String],
|
||||
key: &str,
|
||||
) -> io::Result<Option<bool>> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["config", "--type=bool", "--get", key])
|
||||
.current_dir(git_root);
|
||||
let output = git.output(command)?;
|
||||
if output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git boolean config probe for {key} failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
match String::from_utf8_lossy(&output.stdout).trim() {
|
||||
"true" => Ok(Some(true)),
|
||||
"false" => Ok(Some(false)),
|
||||
value => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected normalized Git boolean value for {key}: {value:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_paths(paths: &BTreeSet<String>) -> String {
|
||||
let shown = paths
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|path| {
|
||||
let mut characters = path.chars();
|
||||
let prefix = characters.by_ref().take(160).collect::<String>();
|
||||
if characters.next().is_some() {
|
||||
format!("{prefix}...")
|
||||
} else {
|
||||
path.clone()
|
||||
}
|
||||
})
|
||||
.map(|path| format!("{path:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if paths.len() > 8 {
|
||||
format!("{shown}, ...")
|
||||
} else {
|
||||
shown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Non-recursive staging for already-confined repository paths.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::apply::run_git;
|
||||
use crate::exact_index_policy::ExactIndexPolicy;
|
||||
use crate::exact_index_policy::effective_git_bool;
|
||||
use crate::exact_index_policy::resolve_exact_index_policy;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::patch_paths::validate_patch_path;
|
||||
use crate::safe_git::ensure_no_selected_git_add_filters;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StagePathsResult {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stderr: String,
|
||||
}
|
||||
|
||||
/// Stage only the literal paths supplied by a caller that has already applied
|
||||
/// the operation-specific containment and filesystem policy.
|
||||
///
|
||||
/// Unlike `git add`, `update-index` never treats a path that races from a leaf
|
||||
/// to a directory as a recursive request. Callers that require staging to
|
||||
/// succeed can inspect the returned status; the public forward helper retains
|
||||
/// its historical best-effort handling of a non-zero Git command.
|
||||
///
|
||||
/// This is not a transactional filesystem-confinement primitive. A concurrent
|
||||
/// strict-ancestor swap to a symlink or Windows junction can still redirect the
|
||||
/// bytes read for an already-confined index pathname. It cannot make this
|
||||
/// exact command recurse into, or create index entries for, descendant paths;
|
||||
/// the filter neutralizer also remains in force for the entire command.
|
||||
pub(crate) fn update_index_exact_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<StagePathsResult> {
|
||||
if paths.is_empty() {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 0,
|
||||
stderr: String::new(),
|
||||
});
|
||||
}
|
||||
for path in paths {
|
||||
if path.as_bytes().contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"exact staging path contains NUL",
|
||||
));
|
||||
}
|
||||
let _ = validate_patch_path(path.clone())?;
|
||||
}
|
||||
let exact_paths = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
if content_filter_paths
|
||||
.iter()
|
||||
.any(|path| !exact_paths.contains(path.as_str()))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"content-filter staging path is not in the exact staging set",
|
||||
));
|
||||
}
|
||||
|
||||
let (paths, content_filter_paths) = match resolve_exact_index_policy(
|
||||
git,
|
||||
git_root,
|
||||
paths,
|
||||
content_filter_paths,
|
||||
git_config_args,
|
||||
)? {
|
||||
ExactIndexPolicy::Proceed {
|
||||
paths,
|
||||
content_filter_paths,
|
||||
} => (paths, content_filter_paths),
|
||||
ExactIndexPolicy::Refuse { stderr } => {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 1,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let ignored = ignored_untracked_paths(git, git_root, &paths, git_config_args)?;
|
||||
if !ignored.is_empty() {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 1,
|
||||
stderr: format!(
|
||||
"refusing to stage ignored untracked path(s): {}",
|
||||
quote_paths(&ignored)
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(result) = sparse_checkout_policy(git, git_root, &paths, git_config_args)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let filter_guard =
|
||||
ensure_no_selected_git_add_filters(git, git_root, &content_filter_paths, git_config_args)?;
|
||||
let mut guarded_config = git_config_args.to_vec();
|
||||
guarded_config.extend_from_slice(filter_guard.git_config_args());
|
||||
|
||||
let mut update_args = vec![
|
||||
"--literal-pathspecs".to_string(),
|
||||
"update-index".to_string(),
|
||||
"--add".to_string(),
|
||||
"--remove".to_string(),
|
||||
"--".to_string(),
|
||||
];
|
||||
update_args.extend_from_slice(&paths);
|
||||
let (exit_code, _stdout, stderr) = run_git(git, git_root, &guarded_config, &update_args)?;
|
||||
Ok(StagePathsResult { exit_code, stderr })
|
||||
}
|
||||
|
||||
fn ignored_untracked_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeSet::new());
|
||||
}
|
||||
// `check-ignore --stdin` still recognizes a leading `:` as pathspec
|
||||
// magic, while its command mode rejects the global literal-pathspec
|
||||
// override. A leading `./` makes every normalized repository-relative
|
||||
// name unambiguously literal without changing which file it denotes.
|
||||
let probe_paths = paths
|
||||
.iter()
|
||||
.map(|path| format!("./{path}"))
|
||||
.collect::<Vec<_>>();
|
||||
let input = nul_path_input(&probe_paths)?;
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
// `check-ignore --stdin` consumes pathnames rather than pathspecs and
|
||||
// rejects the global literal-pathspec mode as unsupported magic.
|
||||
.args(["check-ignore", "--stdin", "-z"])
|
||||
.current_dir(git_root)
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
match output.status.code() {
|
||||
Some(0) => {
|
||||
let ignored = parse_reported_paths(&output.stdout, &probe_paths, "ignored-path")?
|
||||
.into_iter()
|
||||
.map(|path| path.trim_start_matches("./").to_string())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if ignored.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Git ignored-path probe reported success without a path",
|
||||
));
|
||||
}
|
||||
Ok(ignored)
|
||||
}
|
||||
Some(1) if output.stdout.is_empty() && output.stderr.is_empty() => Ok(BTreeSet::new()),
|
||||
_ => Err(io::Error::other(format!(
|
||||
"git ignored-path probe failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn sparse_checkout_policy(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<Option<StagePathsResult>> {
|
||||
if !effective_git_bool(git, git_root, git_config_args, "core.sparseCheckout")?.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Do not let an older Git fall back to a PATH-resolved
|
||||
// `git-sparse-checkout` helper. Repository-controlled PATH entries are
|
||||
// outside the trusted executable boundary. `--list-cmds=builtins` is a
|
||||
// main-program capability query and cannot dispatch an external command.
|
||||
let mut builtins_command = git.command();
|
||||
builtins_command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.arg("--list-cmds=builtins")
|
||||
.current_dir(git_root);
|
||||
let builtins = git.output(builtins_command)?;
|
||||
let sparse_checkout_is_builtin = builtins.status.success()
|
||||
&& std::str::from_utf8(&builtins.stdout)
|
||||
.is_ok_and(|output| output.lines().any(|command| command == "sparse-checkout"));
|
||||
if !sparse_checkout_is_builtin {
|
||||
return Ok(Some(StagePathsResult {
|
||||
exit_code: builtins.status.code().unwrap_or(-1).max(1),
|
||||
stderr: "unable to verify sparse-checkout staging policy with a built-in Git command"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
let input = nul_path_input(paths)?;
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["sparse-checkout", "check-rules", "-z"])
|
||||
.current_dir(git_root)
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Ok(Some(StagePathsResult {
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
stderr: format!(
|
||||
"unable to verify sparse-checkout staging policy: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
}));
|
||||
}
|
||||
let included = parse_reported_paths(&output.stdout, paths, "sparse-checkout")?;
|
||||
let excluded = paths
|
||||
.iter()
|
||||
.filter(|path| !included.contains(path.as_str()))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if excluded.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(StagePathsResult {
|
||||
exit_code: 1,
|
||||
stderr: format!(
|
||||
"refusing to stage path(s) outside the sparse-checkout definition: {}",
|
||||
quote_paths(&excluded)
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn nul_path_input(paths: &[String]) -> io::Result<std::fs::File> {
|
||||
let mut input = tempfile::tempfile()?;
|
||||
for path in paths {
|
||||
input.write_all(path.as_bytes())?;
|
||||
input.write_all(&[0])?;
|
||||
}
|
||||
input.rewind()?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
fn parse_reported_paths(
|
||||
output: &[u8],
|
||||
expected_paths: &[String],
|
||||
probe: &str,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
if output.is_empty() {
|
||||
return Ok(BTreeSet::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unterminated Git {probe} output"),
|
||||
));
|
||||
};
|
||||
let expected = expected_paths
|
||||
.iter()
|
||||
.map(String::as_bytes)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut reported = BTreeSet::new();
|
||||
for path in body.split(|byte| *byte == 0) {
|
||||
if path.is_empty() || !expected.contains(path) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected Git {probe} output"),
|
||||
));
|
||||
}
|
||||
let path = std::str::from_utf8(path).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("non-UTF-8 Git {probe} output"),
|
||||
)
|
||||
})?;
|
||||
reported.insert(path.to_string());
|
||||
}
|
||||
Ok(reported)
|
||||
}
|
||||
|
||||
fn quote_paths(paths: &BTreeSet<String>) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| format!("{path:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
#[cfg(windows)]
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(windows)]
|
||||
use std::path::Prefix;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
@@ -17,7 +21,7 @@ pub(crate) struct GitRunner {
|
||||
|
||||
struct UntrustedGitLocations {
|
||||
roots: Vec<PathBuf>,
|
||||
common_dir: Option<PathBuf>,
|
||||
common_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl GitRunner {
|
||||
@@ -64,6 +68,13 @@ impl GitRunner {
|
||||
if !directory.is_absolute() {
|
||||
continue;
|
||||
}
|
||||
// Check the PATH spelling before appending `git`. On Windows,
|
||||
// PathBuf::push resolves `..` when its base uses a verbatim prefix,
|
||||
// which would otherwise erase repository traversal before the
|
||||
// first containment check.
|
||||
if search_directory_is_untrusted(&directory, untrusted) {
|
||||
continue;
|
||||
}
|
||||
let candidate = directory.join(git_executable_name());
|
||||
if path_is_untrusted(&candidate, untrusted) {
|
||||
continue;
|
||||
@@ -97,25 +108,110 @@ impl GitRunner {
|
||||
}
|
||||
|
||||
fn untrusted_git_locations_for_cwd(cwd: &Path) -> Result<UntrustedGitLocations, GitReadError> {
|
||||
let lexical_cwd = if cwd.is_absolute() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.join(cwd)
|
||||
};
|
||||
let canonical_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?;
|
||||
let worktree_root = crate::get_git_repo_root(&canonical_cwd)
|
||||
.and_then(|root| std::fs::canonicalize(root).ok())
|
||||
.unwrap_or_else(|| canonical_cwd.clone());
|
||||
let mut roots = vec![worktree_root.clone()];
|
||||
let mut locations = UntrustedGitLocations {
|
||||
roots: Vec::new(),
|
||||
common_dirs: Vec::new(),
|
||||
};
|
||||
record_repository_ancestry(&worktree_root, &mut locations)?;
|
||||
|
||||
// Canonicalization can erase a repository-controlled symlink prefix. Walk
|
||||
// the requested spelling too, deliberately retaining symlink and `..`
|
||||
// components so every lexical enclosing checkout remains untrusted.
|
||||
let lexical_base = if lexical_cwd.is_dir() {
|
||||
lexical_cwd
|
||||
} else {
|
||||
lexical_cwd
|
||||
.parent()
|
||||
.ok_or_else(|| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?
|
||||
.to_path_buf()
|
||||
};
|
||||
record_repository_ancestry(&lexical_base, &mut locations)?;
|
||||
|
||||
// Callers commonly obtain their default cwd from `current_dir()`, which
|
||||
// can already have erased a symlink spelling. Recover the standard logical
|
||||
// process cwd only when it is absolute and resolves to both the requested
|
||||
// cwd and the process cwd. Treating extra roots as untrusted cannot widen
|
||||
// executable selection.
|
||||
if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) {
|
||||
record_repository_ancestry(&logical_cwd, &mut locations)?;
|
||||
}
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option<PathBuf> {
|
||||
let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?;
|
||||
if !paths_equal(&process_cwd, canonical_cwd) {
|
||||
return None;
|
||||
}
|
||||
let logical_cwd = PathBuf::from(std::env::var_os("PWD")?);
|
||||
if !logical_cwd.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?;
|
||||
paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd)
|
||||
}
|
||||
|
||||
fn record_repository_ancestry(
|
||||
start: &Path,
|
||||
locations: &mut UntrustedGitLocations,
|
||||
) -> Result<(), GitReadError> {
|
||||
push_unique(&mut locations.roots, start.to_path_buf());
|
||||
record_repository_marker(start, locations)?;
|
||||
for ancestor in start.parent().into_iter().flat_map(Path::ancestors) {
|
||||
let dot_git = ancestor.join(".git");
|
||||
match std::fs::symlink_metadata(&dot_git) {
|
||||
Ok(_) => {
|
||||
push_unique(&mut locations.roots, ancestor.to_path_buf());
|
||||
let canonical_root =
|
||||
std::fs::canonicalize(ancestor).map_err(|_| GitReadError::NoTrustedGit)?;
|
||||
push_unique(&mut locations.roots, canonical_root.clone());
|
||||
record_repository_marker(ancestor, locations)?;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(_) => return Err(GitReadError::NoTrustedGit),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_repository_marker(
|
||||
worktree_root: &Path,
|
||||
locations: &mut UntrustedGitLocations,
|
||||
) -> Result<(), GitReadError> {
|
||||
let dot_git = worktree_root.join(".git");
|
||||
let common_dir = match std::fs::symlink_metadata(&dot_git) {
|
||||
Ok(_) => Some(resolve_common_git_dir(&dot_git).map_err(|()| GitReadError::NoTrustedGit)?),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
||||
Ok(_) => resolve_common_git_dir(&dot_git).map_err(|()| GitReadError::NoTrustedGit)?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(GitReadError::NoTrustedGit),
|
||||
};
|
||||
if let Some(common_dir) = &common_dir
|
||||
&& !path_is_within(common_dir, &worktree_root)
|
||||
{
|
||||
roots.push(common_dir.clone());
|
||||
if !path_is_within(&common_dir, worktree_root) {
|
||||
push_unique(&mut locations.roots, common_dir.clone());
|
||||
}
|
||||
push_unique(&mut locations.common_dirs, common_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| paths_equal(existing, &path)) {
|
||||
paths.push(path);
|
||||
}
|
||||
Ok(UntrustedGitLocations { roots, common_dir })
|
||||
}
|
||||
|
||||
fn path_is_untrusted(path: &Path, locations: &UntrustedGitLocations) -> bool {
|
||||
@@ -127,9 +223,76 @@ fn path_is_untrusted(path: &Path, locations: &UntrustedGitLocations) -> bool {
|
||||
return true;
|
||||
}
|
||||
locations
|
||||
.common_dir
|
||||
.as_deref()
|
||||
.is_some_and(|common_dir| path_is_in_worktree_for_common_dir(path, common_dir))
|
||||
.common_dirs
|
||||
.iter()
|
||||
.any(|common_dir| path_is_in_worktree_for_common_dir(path, common_dir))
|
||||
}
|
||||
|
||||
fn search_directory_is_untrusted(directory: &Path, locations: &UntrustedGitLocations) -> bool {
|
||||
#[cfg(windows)]
|
||||
if windows_path_requires_fail_closed(directory)
|
||||
|| windows_path_has_untrusted_canonical_ancestor(directory, locations)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_is_untrusted(directory, locations)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_requires_fail_closed(path: &Path) -> bool {
|
||||
let mut components = path.components();
|
||||
let supported_namespace = match components.next() {
|
||||
Some(Component::Prefix(prefix)) => match prefix.kind() {
|
||||
Prefix::Disk(_)
|
||||
| Prefix::VerbatimDisk(_)
|
||||
| Prefix::UNC(_, _)
|
||||
| Prefix::VerbatimUNC(_, _) => true,
|
||||
Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device),
|
||||
Prefix::Verbatim(namespace) => namespace
|
||||
.to_str()
|
||||
.is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")),
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
!supported_namespace || components.any(|component| matches!(component, Component::ParentDir))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool {
|
||||
let bytes = device.as_encoded_bytes();
|
||||
bytes.eq_ignore_ascii_case(b"UNC")
|
||||
|| matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_has_untrusted_canonical_ancestor(
|
||||
path: &Path,
|
||||
locations: &UntrustedGitLocations,
|
||||
) -> bool {
|
||||
let Ok(canonical_path) = std::fs::canonicalize(path) else {
|
||||
return true;
|
||||
};
|
||||
if path_is_untrusted(&canonical_path, locations) {
|
||||
return true;
|
||||
}
|
||||
for ancestor in path.ancestors().skip(1) {
|
||||
let canonical_ancestor = match std::fs::canonicalize(ancestor) {
|
||||
Ok(canonical_ancestor) => canonical_ancestor,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
|
||||
) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(_) => return true,
|
||||
};
|
||||
if path_is_untrusted(&canonical_ancestor, locations) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn path_is_in_worktree_for_common_dir(path: &Path, expected_common_dir: &Path) -> bool {
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_executable(path: &Path, body: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -18,6 +20,12 @@ fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let mut command = Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let status = command
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.status()
|
||||
@@ -25,6 +33,23 @@ fn run_git(cwd: &Path, args: &[&str]) {
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn commit_all(cwd: &Path, message: &str) {
|
||||
run_git(
|
||||
cwd,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Codex Test",
|
||||
"-c",
|
||||
"user.email=codex@example.com",
|
||||
"-c",
|
||||
"commit.gpgSign=false",
|
||||
"commit",
|
||||
"-qam",
|
||||
message,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn write_git_candidate(directory: &Path) {
|
||||
std::fs::create_dir_all(directory).expect("create candidate directory");
|
||||
let candidate = directory.join(git_executable_name());
|
||||
@@ -36,13 +61,44 @@ fn write_git_candidate(directory: &Path) {
|
||||
std::fs::write(candidate, b"git fixture").expect("write executable fixture");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_junction(path: &Path, target: &Path) {
|
||||
let output = Command::new("cmd.exe")
|
||||
.args(["/D", "/C", "mklink", "/J"])
|
||||
.arg(path)
|
||||
.arg(target)
|
||||
.output()
|
||||
.expect("create junction");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"mklink failed: stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
fn locations_for_root(root: &Path) -> UntrustedGitLocations {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
push_unique(
|
||||
&mut roots,
|
||||
std::fs::canonicalize(root).expect("canonical root"),
|
||||
);
|
||||
UntrustedGitLocations {
|
||||
roots: vec![std::fs::canonicalize(root).expect("canonical root")],
|
||||
common_dir: None,
|
||||
roots,
|
||||
common_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_parent_traversal(root: &Path, sibling: &str) -> PathBuf {
|
||||
let separator = std::path::MAIN_SEPARATOR.to_string();
|
||||
let mut path = root.as_os_str().to_os_string();
|
||||
path.push(&separator);
|
||||
path.push("..");
|
||||
path.push(&separator);
|
||||
path.push(sibling);
|
||||
path.into()
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().expect("UTF-8 fixture path")
|
||||
}
|
||||
@@ -107,6 +163,130 @@ fn linked_worktree_rejects_git_from_main_and_linked_worktrees() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repository_rejects_git_from_enclosing_repository() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let nested = outer.join("nested");
|
||||
let outer_bin = outer.join("bin");
|
||||
let trusted_bin = fixture.path().join("trusted-bin");
|
||||
std::fs::create_dir_all(&nested).expect("create nested repository");
|
||||
run_git(&outer, &["init", "-q"]);
|
||||
run_git(&nested, &["init", "-q"]);
|
||||
write_git_candidate(&outer_bin);
|
||||
write_git_candidate(&trusted_bin);
|
||||
|
||||
let locations = untrusted_git_locations_for_cwd(&nested).expect("untrusted locations");
|
||||
assert!(
|
||||
path_is_untrusted(&outer_bin.join(git_executable_name()), &locations),
|
||||
"Git from an enclosing repository must remain repository-controlled"
|
||||
);
|
||||
assert_eq!(
|
||||
selected_git(&locations, &[&outer_bin, &trusted_bin]),
|
||||
trusted_bin.join(git_executable_name())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_nested_repository_rejects_git_from_lexical_enclosing_repository() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let physical_nested = fixture.path().join("physical-nested");
|
||||
let lexical_nested = outer.join("nested");
|
||||
let outer_bin = outer.join("bin");
|
||||
let trusted_bin = fixture.path().join("trusted-bin");
|
||||
std::fs::create_dir_all(&outer).expect("create outer repository");
|
||||
std::fs::create_dir_all(&physical_nested).expect("create physical nested repository");
|
||||
run_git(&outer, &["init", "-q"]);
|
||||
run_git(&physical_nested, &["init", "-q"]);
|
||||
std::os::unix::fs::symlink(&physical_nested, &lexical_nested)
|
||||
.expect("symlink nested repository");
|
||||
write_git_candidate(&outer_bin);
|
||||
write_git_candidate(&trusted_bin);
|
||||
|
||||
let locations = untrusted_git_locations_for_cwd(&lexical_nested).expect("untrusted locations");
|
||||
assert!(
|
||||
path_is_untrusted(&outer_bin.join(git_executable_name()), &locations),
|
||||
"Git from the lexical enclosing repository must remain repository-controlled"
|
||||
);
|
||||
assert_eq!(
|
||||
selected_git(&locations, &[&outer_bin, &trusted_bin]),
|
||||
trusted_bin.join(git_executable_name())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repository_rejects_git_from_enclosing_repository_main_worktree() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let main = fixture.path().join("main");
|
||||
let linked = fixture.path().join("linked");
|
||||
let nested = linked.join("nested");
|
||||
let main_bin = main.join("bin");
|
||||
let trusted_bin = fixture.path().join("trusted-bin");
|
||||
std::fs::create_dir_all(&main).expect("create main worktree");
|
||||
run_git(&main, &["init", "-q"]);
|
||||
run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]);
|
||||
std::fs::create_dir_all(&nested).expect("create nested repository");
|
||||
run_git(&nested, &["init", "-q"]);
|
||||
write_git_candidate(&main_bin);
|
||||
write_git_candidate(&trusted_bin);
|
||||
|
||||
let locations = untrusted_git_locations_for_cwd(&nested).expect("untrusted locations");
|
||||
assert!(
|
||||
path_is_untrusted(&main_bin.join(git_executable_name()), &locations),
|
||||
"all worktrees of an enclosing repository must remain repository-controlled"
|
||||
);
|
||||
assert_eq!(
|
||||
selected_git(&locations, &[&main_bin, &trusted_bin]),
|
||||
trusted_bin.join(git_executable_name())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submodule_rejects_git_from_enclosing_superproject() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let source = fixture.path().join("source");
|
||||
let outer = fixture.path().join("outer");
|
||||
let submodule = outer.join("nested");
|
||||
let outer_bin = outer.join("bin");
|
||||
let trusted_bin = fixture.path().join("trusted-bin");
|
||||
std::fs::create_dir_all(&source).expect("create source repository");
|
||||
std::fs::create_dir_all(&outer).expect("create superproject");
|
||||
run_git(&source, &["init", "-q"]);
|
||||
std::fs::write(source.join("source.txt"), "source\n").expect("write source file");
|
||||
run_git(&source, &["add", "source.txt"]);
|
||||
commit_all(&source, "source");
|
||||
run_git(&outer, &["init", "-q"]);
|
||||
std::fs::write(outer.join("outer.txt"), "outer\n").expect("write outer file");
|
||||
run_git(&outer, &["add", "outer.txt"]);
|
||||
commit_all(&outer, "outer");
|
||||
run_git(
|
||||
&outer,
|
||||
&[
|
||||
"-c",
|
||||
"protocol.file.allow=always",
|
||||
"submodule",
|
||||
"add",
|
||||
"-q",
|
||||
path_text(&source),
|
||||
"nested",
|
||||
],
|
||||
);
|
||||
write_git_candidate(&outer_bin);
|
||||
write_git_candidate(&trusted_bin);
|
||||
|
||||
let locations = untrusted_git_locations_for_cwd(&submodule).expect("untrusted locations");
|
||||
assert!(
|
||||
path_is_untrusted(&outer_bin.join(git_executable_name()), &locations),
|
||||
"Git from a superproject must remain repository-controlled"
|
||||
);
|
||||
assert_eq!(
|
||||
selected_git(&locations, &[&outer_bin, &trusted_bin]),
|
||||
trusted_bin.join(git_executable_name())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_backed_linked_worktree_allows_external_git_in_sibling_directory() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
@@ -171,6 +351,140 @@ fn separate_dot_git_dir_rejects_main_candidate_and_allows_unrelated_repo_candida
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_rejects_parent_traversal_spelled_through_repository() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let repo = fixture.path().join("repo");
|
||||
let trusted_bin = fixture.path().join("trusted-bin");
|
||||
std::fs::create_dir_all(&repo).expect("create repository");
|
||||
write_git_candidate(&trusted_bin);
|
||||
|
||||
let locations = locations_for_root(&repo);
|
||||
for root in &locations.roots {
|
||||
// Append without PathBuf::push: it resolves `..` when `root` has a
|
||||
// verbatim Windows prefix, before the resolver can inspect the PATH
|
||||
// spelling.
|
||||
let traversing_path = raw_parent_traversal(root, "trusted-bin");
|
||||
let search_path = std::env::join_paths([&traversing_path]).expect("PATH");
|
||||
let split_paths = std::env::split_paths(&search_path).collect::<Vec<_>>();
|
||||
assert_eq!(split_paths, vec![traversing_path.clone()]);
|
||||
assert!(
|
||||
search_directory_is_untrusted(&split_paths[0], &locations),
|
||||
"raw PATH traversal was not rejected from {root:?}"
|
||||
);
|
||||
|
||||
#[cfg(windows)]
|
||||
if matches!(
|
||||
root.components().next(),
|
||||
Some(Component::Prefix(prefix)) if prefix.kind().is_verbatim()
|
||||
) {
|
||||
assert_eq!(
|
||||
split_paths[0].join(git_executable_name()),
|
||||
std::fs::canonicalize(&trusted_bin)
|
||||
.expect("canonical trusted bin")
|
||||
.join(git_executable_name())
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
GitRunner::from_search_path(&locations, &search_path),
|
||||
Err(GitReadError::NoTrustedGit)
|
||||
),
|
||||
"resolver accepted parent traversal from {root:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolver_rejects_parent_traversal_across_windows_namespaces() {
|
||||
let traversing = [
|
||||
r"C:\Repo\..\outside",
|
||||
r"\\?\C:\Repo\..\outside",
|
||||
r"\\Server\Share\Repo\..\outside",
|
||||
r"\\?\UNC\Server\Share\Repo\..\outside",
|
||||
r"\\?\unc\Server\Share\Repo\..\outside",
|
||||
r"\\.\C:\Repo\..\outside",
|
||||
r"\\.\UNC\Server\Share\Repo\..\outside",
|
||||
r"\\?\C:\RÉPO\..\outside",
|
||||
];
|
||||
for path in traversing {
|
||||
assert!(
|
||||
windows_path_requires_fail_closed(Path::new(path)),
|
||||
"parent traversal was accepted: {path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_external = [
|
||||
r"C:\outside",
|
||||
r"\\?\C:\outside",
|
||||
r"\\Server\Share\outside",
|
||||
r"\\?\UNC\Server\Share\outside",
|
||||
r"\\?\unc\Server\Share\outside",
|
||||
r"\\.\C:\outside",
|
||||
r"\\.\UNC\Server\Share\outside",
|
||||
];
|
||||
for path in normalized_external {
|
||||
assert!(
|
||||
!windows_path_requires_fail_closed(Path::new(path)),
|
||||
"normalized filesystem path was rejected: {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolver_rejects_unicode_case_alias_through_repository_junction() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let repo = fixture.path().join("Répo");
|
||||
let outside = fixture.path().join("outside");
|
||||
let junction = repo.join("git-bin");
|
||||
std::fs::create_dir_all(&repo).expect("create repository");
|
||||
write_git_candidate(&outside);
|
||||
create_junction(&junction, &outside);
|
||||
|
||||
let case_alias = fixture.path().join("RÉPO").join("git-bin");
|
||||
let verbatim_case_alias = PathBuf::from(format!(r"\\?\{}", case_alias.display()));
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&verbatim_case_alias).expect("canonical alias"),
|
||||
std::fs::canonicalize(&outside).expect("canonical outside")
|
||||
);
|
||||
|
||||
let locations = locations_for_root(&repo);
|
||||
assert!(
|
||||
!path_is_untrusted(&verbatim_case_alias, &locations),
|
||||
"fixture must exercise the Unicode alias before canonical ancestry"
|
||||
);
|
||||
assert!(search_directory_is_untrusted(
|
||||
&verbatim_case_alias,
|
||||
&locations
|
||||
));
|
||||
let search_path = std::env::join_paths([verbatim_case_alias]).expect("PATH");
|
||||
assert!(matches!(
|
||||
GitRunner::from_search_path(&locations, &search_path),
|
||||
Err(GitReadError::NoTrustedGit)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolver_fails_closed_for_unsupported_windows_device_namespaces() {
|
||||
let unsupported = [
|
||||
r"\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1\git.exe",
|
||||
r"\\?\Volume{11111111-1111-1111-1111-111111111111}\git.exe",
|
||||
r"\\.\PhysicalDrive0",
|
||||
r"\\.\pipe\codex-git",
|
||||
];
|
||||
|
||||
for path in unsupported {
|
||||
assert!(
|
||||
windows_path_requires_fail_closed(Path::new(path)),
|
||||
"unsupported namespace was trusted: {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolver_selects_native_git_exe_only() {
|
||||
|
||||
@@ -3,6 +3,9 @@ mod apply_output;
|
||||
mod baseline;
|
||||
mod branch;
|
||||
mod errors;
|
||||
mod exact_filesystem_policy;
|
||||
mod exact_index_policy;
|
||||
mod exact_staging;
|
||||
mod fsmonitor;
|
||||
mod git_command;
|
||||
mod git_config;
|
||||
@@ -12,6 +15,7 @@ mod merge_driver;
|
||||
mod operations;
|
||||
mod patch_paths;
|
||||
mod platform;
|
||||
mod reverse_staging;
|
||||
mod safe_git;
|
||||
|
||||
pub use apply::ApplyGitRequest;
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::process::Stdio;
|
||||
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::GitConfigEntry;
|
||||
use crate::safe_git::GitConfigOverrideFile;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use crate::safe_git::read_effective_config_with_fallback;
|
||||
@@ -19,7 +20,7 @@ pub(crate) fn ensure_no_selected_merge_drivers(
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
) -> io::Result<Option<GitConfigOverrideFile>> {
|
||||
let entries = read_merge_config(git, cwd, git_config_args)?;
|
||||
let attributes = read_merge_attributes(git, cwd, paths, git_config_args)?;
|
||||
if let Some((driver, path)) = untrusted_driver_selection(&entries, &attributes)? {
|
||||
@@ -30,7 +31,27 @@ pub(crate) fn ensure_no_selected_merge_drivers(
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
let driver_keys = entries
|
||||
.values()
|
||||
.filter(|entry| entry.key != "merge.default" && !entry.value.is_empty())
|
||||
.map(|entry| entry.key.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if driver_keys.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let guard = GitConfigOverrideFile::new("merge-driver-neutralization.gitconfig")?;
|
||||
for key in driver_keys {
|
||||
guard.add_value(
|
||||
git,
|
||||
cwd,
|
||||
&key,
|
||||
"",
|
||||
&format!("Git merge-driver neutralization for {key:?}"),
|
||||
)?;
|
||||
}
|
||||
Ok(Some(guard))
|
||||
}
|
||||
|
||||
fn read_merge_config(
|
||||
@@ -176,3 +197,7 @@ fn invalid_config_entry(message: &str) -> io::Error {
|
||||
#[cfg(test)]
|
||||
#[path = "merge_driver_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "merge_driver_race_tests.rs"]
|
||||
mod race_tests;
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
use super::*;
|
||||
use crate::apply::ApplyGitRequest;
|
||||
use crate::apply::apply_git_patch;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const OLD_DRIVER_COMMAND: &str = "git config codex.oldmergeran true && false";
|
||||
const NEW_DRIVER_COMMAND: &str = "git config codex.newmergeran true && false";
|
||||
const TRACE_MUTATION_MARKER: &str = "CODEX_MERGE_DRIVER_TEST_MUTATION";
|
||||
|
||||
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
|
||||
let mut command = std::process::Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("run Git");
|
||||
(
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_success(cwd: &Path, args: &[&str]) -> String {
|
||||
let (code, stdout, stderr) = run(cwd, args);
|
||||
assert_eq!(code, 0, "git {args:?}: {stderr}");
|
||||
stdout
|
||||
}
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let repo = tempfile::tempdir().expect("repo tempdir");
|
||||
let root = repo.path();
|
||||
run_success(root, &["init"]);
|
||||
run_success(root, &["config", "user.email", "codex@example.com"]);
|
||||
run_success(root, &["config", "user.name", "Codex"]);
|
||||
run_success(root, &["config", "core.autocrlf", "false"]);
|
||||
repo
|
||||
}
|
||||
|
||||
fn configure_driver(root: &Path, driver: &str, command: &str) {
|
||||
run_success(
|
||||
root,
|
||||
&["config", &format!("merge.{driver}.driver"), command],
|
||||
);
|
||||
}
|
||||
|
||||
fn configured_marker_exists(root: &Path, marker: &str) -> bool {
|
||||
run(root, &["config", "--get", marker]).0 == 0
|
||||
}
|
||||
|
||||
fn build_conflicting_patch(root: &Path, attributes: &str) -> String {
|
||||
std::fs::write(root.join(".gitattributes"), attributes).expect("write base attributes");
|
||||
std::fs::write(root.join("target.txt"), "top\nbase\nbottom\n").expect("write base");
|
||||
run_success(root, &["add", ".gitattributes", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "base"]);
|
||||
let base = run_success(root, &["rev-parse", "HEAD"]);
|
||||
let base = base.trim();
|
||||
|
||||
std::fs::write(root.join("target.txt"), "top\ntheirs\nbottom\n").expect("write theirs");
|
||||
run_success(root, &["add", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "theirs"]);
|
||||
let patch = run_success(
|
||||
root,
|
||||
&["diff", "--full-index", base, "HEAD", "--", "target.txt"],
|
||||
);
|
||||
|
||||
run_success(root, &["checkout", "-b", "ours", base]);
|
||||
std::fs::write(root.join("target.txt"), "top\nours\nbottom\n").expect("write ours");
|
||||
run_success(root, &["add", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "ours"]);
|
||||
patch
|
||||
}
|
||||
|
||||
fn request(root: &Path, diff: String, revert: bool) -> ApplyGitRequest {
|
||||
ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff,
|
||||
revert,
|
||||
preflight: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn index_tree(root: &Path) -> String {
|
||||
run_success(root, &["write-tree"])
|
||||
}
|
||||
|
||||
fn run_isolated_test(test_name: &str) {
|
||||
let environment = tempfile::tempdir().expect("isolated Git environment");
|
||||
let global_config = environment.path().join("global.gitconfig");
|
||||
let system_config = environment.path().join("system.gitconfig");
|
||||
let trace = environment.path().join("trace.jsonl");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
std::fs::write(&trace, "").expect("empty trace");
|
||||
|
||||
let mut command = std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.arg(test_name)
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("CODEX_GIT_UTILS_MERGE_RACE_CHILD", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", &global_config)
|
||||
.env("GIT_CONFIG_SYSTEM", &system_config)
|
||||
.env("GIT_TRACE2_EVENT", &trace)
|
||||
.env("RUST_TEST_THREADS", "1")
|
||||
.output()
|
||||
.expect("run isolated test process");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated test {test_name} failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn trace_path() -> PathBuf {
|
||||
PathBuf::from(std::env::var_os("GIT_TRACE2_EVENT").expect("trace path"))
|
||||
}
|
||||
|
||||
fn wait_for_merge_attribute_probe(trace: &Path) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let contents = std::fs::read_to_string(trace).unwrap_or_default();
|
||||
if contents
|
||||
.find("check-attr")
|
||||
.is_some_and(|offset| contents[offset..].contains("\"event\":\"exit\""))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn record_trace_mutation(trace: &Path) {
|
||||
let mut trace = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(trace)
|
||||
.expect("open trace for mutation marker");
|
||||
writeln!(trace, "{TRACE_MUTATION_MARKER}").expect("record mutation marker");
|
||||
}
|
||||
|
||||
fn assert_mutation_happened_between_probe_and_three_way(trace: &Path) {
|
||||
let contents = std::fs::read_to_string(trace).expect("read completed trace");
|
||||
let probe_start = contents.find("check-attr").expect("merge attribute probe");
|
||||
let probe_exit = probe_start
|
||||
+ contents[probe_start..]
|
||||
.find("\"event\":\"exit\"")
|
||||
.expect("merge attribute probe exit");
|
||||
let mutation = contents
|
||||
.find(TRACE_MUTATION_MARKER)
|
||||
.expect("mutation trace marker");
|
||||
let three_way = contents.find("--3way").expect("final three-way apply");
|
||||
assert!(probe_exit < mutation, "mutation preceded probe exit");
|
||||
assert!(mutation < three_way, "final Git started before mutation");
|
||||
}
|
||||
|
||||
fn append_driver_config(root: &Path, driver: &str, command: &str) {
|
||||
let mut config = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(root.join(".git/config"))
|
||||
.expect("open local config");
|
||||
writeln!(config, "\n[merge \"{driver}\"]\n\tdriver = {command}")
|
||||
.expect("append replacement merge driver");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_reverse_checks_worktree_before_staging_and_skips_merge_driver() {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt merge=demo\n").expect("write attributes");
|
||||
std::fs::write(root.join("file.txt"), "old\n").expect("write old file");
|
||||
run_success(root, &["add", ".gitattributes", "file.txt"]);
|
||||
run_success(root, &["commit", "-m", "base"]);
|
||||
configure_driver(root, "demo", OLD_DRIVER_COMMAND);
|
||||
|
||||
std::fs::write(root.join("file.txt"), "new\n").expect("write unstaged forward state");
|
||||
let patch = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
let result = apply_git_patch(&request(root, patch.to_string(), /*revert*/ true))
|
||||
.expect("clean reverse apply");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["file.txt"]);
|
||||
assert!(result.cmd_for_log.contains("--index"));
|
||||
assert!(!result.cmd_for_log.contains("--3way"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("file.txt")).expect("read restored file"),
|
||||
"old\n"
|
||||
);
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ReverseTopology {
|
||||
Delete,
|
||||
Rename,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_reverse_stages_missing_delete_and_rename_endpoints() {
|
||||
for topology in [ReverseTopology::Delete, ReverseTopology::Rename] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "*.txt merge=demo\n")
|
||||
.expect("write attributes");
|
||||
std::fs::write(root.join("old.txt"), "old\n").expect("write old file");
|
||||
run_success(root, &["add", ".gitattributes", "old.txt"]);
|
||||
run_success(root, &["commit", "-m", "base"]);
|
||||
configure_driver(root, "demo", OLD_DRIVER_COMMAND);
|
||||
|
||||
match topology {
|
||||
ReverseTopology::Delete => {
|
||||
run_success(root, &["rm", "old.txt"]);
|
||||
}
|
||||
ReverseTopology::Rename => {
|
||||
run_success(root, &["mv", "old.txt", "new.txt"]);
|
||||
}
|
||||
}
|
||||
let patch = run_success(root, &["diff", "--cached", "--full-index", "--binary"]);
|
||||
run_success(root, &["reset", "--hard", "HEAD"]);
|
||||
match topology {
|
||||
ReverseTopology::Delete => {
|
||||
std::fs::remove_file(root.join("old.txt")).expect("delete worktree file");
|
||||
}
|
||||
ReverseTopology::Rename => {
|
||||
std::fs::rename(root.join("old.txt"), root.join("new.txt"))
|
||||
.expect("rename worktree file");
|
||||
}
|
||||
}
|
||||
|
||||
let result = apply_git_patch(&request(root, patch, /*revert*/ true))
|
||||
.expect("clean topology reverse");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{topology:?}: {}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["old.txt"], "{topology:?}");
|
||||
assert!(result.cmd_for_log.contains("--index"), "{topology:?}");
|
||||
assert!(!result.cmd_for_log.contains("--3way"), "{topology:?}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("old.txt")).expect("restored old file"),
|
||||
"old\n",
|
||||
"{topology:?}"
|
||||
);
|
||||
assert!(!root.join("new.txt").exists(), "{topology:?}");
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
assert!(
|
||||
run_success(root, &["status", "--porcelain"]).is_empty(),
|
||||
"{topology:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_empty_and_equals_named_drivers_reject_without_marker_or_index_mutation() {
|
||||
for (driver, attribute) in [
|
||||
("", "target.txt merge=\n"),
|
||||
("x=y", "target.txt merge=x=y\n"),
|
||||
] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let patch = build_conflicting_patch(root, attribute);
|
||||
configure_driver(root, driver, OLD_DRIVER_COMMAND);
|
||||
let before_tree = index_tree(root);
|
||||
let before_contents =
|
||||
std::fs::read_to_string(root.join("target.txt")).expect("read before contents");
|
||||
|
||||
let error = apply_git_patch(&request(root, patch, /*revert*/ false))
|
||||
.expect_err("reject selected merge driver");
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{driver:?}");
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
assert_eq!(index_tree(root), before_tree, "{driver:?}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("target.txt")).expect("read after contents"),
|
||||
before_contents,
|
||||
"{driver:?}"
|
||||
);
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_probe_attribute_change_cannot_run_empty_named_driver() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_MERGE_RACE_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"merge_driver::race_tests::post_probe_attribute_change_cannot_run_empty_named_driver",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let patch = build_conflicting_patch(root, "# initially safe\n");
|
||||
configure_driver(root, "", OLD_DRIVER_COMMAND);
|
||||
let trace = trace_path();
|
||||
std::fs::write(&trace, "").expect("clear fixture trace");
|
||||
let watcher_trace = trace.clone();
|
||||
let attributes = root.join(".gitattributes");
|
||||
let watcher = thread::spawn(move || {
|
||||
let observed = wait_for_merge_attribute_probe(&watcher_trace);
|
||||
if observed {
|
||||
std::fs::write(attributes, "target.txt merge=\n")
|
||||
.expect("select empty-name merge driver");
|
||||
record_trace_mutation(&watcher_trace);
|
||||
}
|
||||
observed
|
||||
});
|
||||
|
||||
let result = apply_git_patch(&request(root, patch, /*revert*/ false))
|
||||
.expect("neutralized three-way apply");
|
||||
assert!(watcher.join().expect("attribute watcher"));
|
||||
|
||||
assert_mutation_happened_between_probe_and_three_way(&trace);
|
||||
assert_ne!(result.exit_code, 0);
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_probe_attribute_and_same_driver_command_change_stays_neutralized() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_MERGE_RACE_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"merge_driver::race_tests::post_probe_attribute_and_same_driver_command_change_stays_neutralized",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let patch = build_conflicting_patch(root, "# initially safe\n");
|
||||
configure_driver(root, "x=y", OLD_DRIVER_COMMAND);
|
||||
let trace = trace_path();
|
||||
std::fs::write(&trace, "").expect("clear fixture trace");
|
||||
let watcher_trace = trace.clone();
|
||||
let watcher_root = root.to_path_buf();
|
||||
let watcher = thread::spawn(move || {
|
||||
let observed = wait_for_merge_attribute_probe(&watcher_trace);
|
||||
if observed {
|
||||
append_driver_config(&watcher_root, "x=y", NEW_DRIVER_COMMAND);
|
||||
std::fs::write(
|
||||
watcher_root.join(".gitattributes"),
|
||||
"target.txt merge=x=y\n",
|
||||
)
|
||||
.expect("select replacement merge driver");
|
||||
record_trace_mutation(&watcher_trace);
|
||||
}
|
||||
observed
|
||||
});
|
||||
|
||||
let result = apply_git_patch(&request(root, patch, /*revert*/ false))
|
||||
.expect("neutralized three-way apply");
|
||||
assert!(watcher.join().expect("config watcher"));
|
||||
|
||||
assert_mutation_happened_between_probe_and_three_way(&trace);
|
||||
assert_ne!(result.exit_code, 0);
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
assert!(!configured_marker_exists(root, "codex.newmergeran"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_patch_attribute_activation_does_not_run_merge_driver() {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitattributes"), "# initially safe\n")
|
||||
.expect("write base attributes");
|
||||
std::fs::write(root.join("target.txt"), "top\nbase\nbottom\n").expect("write base");
|
||||
run_success(root, &["add", ".gitattributes", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "base"]);
|
||||
let base = run_success(root, &["rev-parse", "HEAD"]);
|
||||
let base = base.trim();
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "target.txt merge=x=y\n")
|
||||
.expect("write patch attributes");
|
||||
std::fs::write(root.join("target.txt"), "top\ntheirs\nbottom\n").expect("write theirs");
|
||||
run_success(root, &["add", ".gitattributes", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "theirs"]);
|
||||
let patch = run_success(root, &["diff", "--full-index", base, "HEAD"]);
|
||||
|
||||
run_success(root, &["checkout", "-b", "ours", base]);
|
||||
std::fs::write(root.join("target.txt"), "top\nours\nbottom\n").expect("write ours");
|
||||
run_success(root, &["add", "target.txt"]);
|
||||
run_success(root, &["commit", "-m", "ours"]);
|
||||
configure_driver(root, "x=y", OLD_DRIVER_COMMAND);
|
||||
|
||||
let result = apply_git_patch(&request(root, patch, /*revert*/ false))
|
||||
.expect("same-patch attribute activation remains neutralized");
|
||||
|
||||
assert_ne!(result.exit_code, 0);
|
||||
assert!(!configured_marker_exists(root, "codex.oldmergeran"));
|
||||
}
|
||||
@@ -2,14 +2,10 @@ use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(test)]
|
||||
use std::process::Command;
|
||||
|
||||
use crate::GitToolingError;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
|
||||
pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> {
|
||||
match run_git_for_stdout(
|
||||
@@ -155,70 +151,5 @@ struct GitRun {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let repo = tempfile::tempdir().expect("tempdir");
|
||||
let mut command = Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let status = command
|
||||
.args(["init", "-q"])
|
||||
.current_dir(repo.path())
|
||||
.status()
|
||||
.expect("initialize repository");
|
||||
assert!(status.success());
|
||||
repo
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_env_cannot_restore_repository_or_pathspec_selectors() {
|
||||
let target = init_repo();
|
||||
let alternate = init_repo();
|
||||
std::fs::write(target.path().join("target.txt"), "target\n").expect("target file");
|
||||
std::fs::write(alternate.path().join("alternate.txt"), "alternate\n")
|
||||
.expect("alternate file");
|
||||
for (repo, path) in [(&target, "target.txt"), (&alternate, "alternate.txt")] {
|
||||
let mut command = Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let status = command
|
||||
.args(["add", path])
|
||||
.current_dir(repo.path())
|
||||
.status()
|
||||
.expect("add file");
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
let alternate_git_dir = alternate.path().join(".git");
|
||||
let env = [
|
||||
(
|
||||
OsString::from("GIT_DIR"),
|
||||
alternate_git_dir.as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_WORK_TREE"),
|
||||
alternate.path().as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_COMMON_DIR"),
|
||||
alternate_git_dir.as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_INDEX_FILE"),
|
||||
alternate_git_dir.join("index").into_os_string(),
|
||||
),
|
||||
(OsString::from("GIT_PREFIX"), OsString::from("elsewhere/")),
|
||||
(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_GLOB_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_NOGLOB_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_ICASE_PATHSPECS"), OsString::from("1")),
|
||||
(
|
||||
OsString::from("GIT_CONFIG"),
|
||||
alternate_git_dir.join("config").into_os_string(),
|
||||
),
|
||||
];
|
||||
let output = run_git_for_stdout(target.path(), ["ls-files"], Some(&env))
|
||||
.expect("query cwd-selected index");
|
||||
assert_eq!(output, "target.txt");
|
||||
}
|
||||
}
|
||||
#[path = "operations_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::*;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use std::process::Command;
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let repo = tempfile::tempdir().expect("tempdir");
|
||||
let mut command = Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let status = command
|
||||
.args(["init", "-q"])
|
||||
.current_dir(repo.path())
|
||||
.status()
|
||||
.expect("initialize repository");
|
||||
assert!(status.success());
|
||||
repo
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_env_cannot_restore_repository_or_pathspec_selectors() {
|
||||
let target = init_repo();
|
||||
let alternate = init_repo();
|
||||
std::fs::write(target.path().join("target.txt"), "target\n").expect("target file");
|
||||
std::fs::write(alternate.path().join("alternate.txt"), "alternate\n").expect("alternate file");
|
||||
for (repo, path) in [(&target, "target.txt"), (&alternate, "alternate.txt")] {
|
||||
let mut command = Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let status = command
|
||||
.args(["add", path])
|
||||
.current_dir(repo.path())
|
||||
.status()
|
||||
.expect("add file");
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
let alternate_git_dir = alternate.path().join(".git");
|
||||
let env = [
|
||||
(
|
||||
OsString::from("GIT_DIR"),
|
||||
alternate_git_dir.as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_WORK_TREE"),
|
||||
alternate.path().as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_COMMON_DIR"),
|
||||
alternate_git_dir.as_os_str().into(),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_INDEX_FILE"),
|
||||
alternate_git_dir.join("index").into_os_string(),
|
||||
),
|
||||
(OsString::from("GIT_PREFIX"), OsString::from("elsewhere/")),
|
||||
(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_GLOB_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_NOGLOB_PATHSPECS"), OsString::from("1")),
|
||||
(OsString::from("GIT_ICASE_PATHSPECS"), OsString::from("1")),
|
||||
(
|
||||
OsString::from("GIT_CONFIG"),
|
||||
alternate_git_dir.join("config").into_os_string(),
|
||||
),
|
||||
];
|
||||
let output = run_git_for_stdout(target.path(), ["ls-files"], Some(&env))
|
||||
.expect("query cwd-selected index");
|
||||
assert_eq!(output, "target.txt");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Effective patch-path discovery and safe staging guards.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -7,37 +8,62 @@ use std::path::PathBuf;
|
||||
use crate::apply::run_git;
|
||||
use crate::apply::safe_git_config_parts;
|
||||
use crate::apply::write_temp_patch;
|
||||
use crate::exact_staging::update_index_exact_paths;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::path_is_within;
|
||||
use crate::safe_git::ensure_no_selected_executable_git_filters;
|
||||
|
||||
pub(crate) fn extract_effective_paths_from_patch(
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PatchPathInventory {
|
||||
pub(crate) primary_paths: Vec<String>,
|
||||
pub(crate) effective_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn extract_patch_path_inventory(
|
||||
git: &GitRunner,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let forward_paths = git_apply_numstat_paths(git, patch_path, revert)?;
|
||||
) -> io::Result<PatchPathInventory> {
|
||||
let primary_paths = git_apply_numstat_paths(git, patch_path, revert)?;
|
||||
// `git apply --numstat` reports only the destination of a rename. Parse the
|
||||
// opposite orientation too so both endpoints are included in the result.
|
||||
let reverse_paths = git_apply_numstat_paths(git, patch_path, !revert)?;
|
||||
if forward_paths.len() != reverse_paths.len() {
|
||||
let opposite_paths = git_apply_numstat_paths(git, patch_path, !revert)?;
|
||||
if primary_paths.len() != opposite_paths.len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"forward and reverse patch parsing returned different path counts",
|
||||
));
|
||||
}
|
||||
let effective_paths: std::collections::BTreeSet<String> =
|
||||
forward_paths.into_iter().chain(reverse_paths).collect();
|
||||
let primary_paths = primary_paths
|
||||
.into_iter()
|
||||
.map(validate_patch_path)
|
||||
.collect::<io::Result<BTreeSet<_>>>()?;
|
||||
let opposite_paths = opposite_paths
|
||||
.into_iter()
|
||||
.map(validate_patch_path)
|
||||
.collect::<io::Result<BTreeSet<_>>>()?;
|
||||
let effective_paths = primary_paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(opposite_paths)
|
||||
.collect::<BTreeSet<_>>();
|
||||
if effective_paths.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"patch does not identify any paths",
|
||||
));
|
||||
}
|
||||
effective_paths
|
||||
.into_iter()
|
||||
.map(validate_patch_path)
|
||||
.collect()
|
||||
Ok(PatchPathInventory {
|
||||
primary_paths: primary_paths.into_iter().collect(),
|
||||
effective_paths: effective_paths.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn extract_effective_paths_from_patch(
|
||||
git: &GitRunner,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
) -> io::Result<Vec<String>> {
|
||||
Ok(extract_patch_path_inventory(git, patch_path, revert)?.effective_paths)
|
||||
}
|
||||
|
||||
/// Best-effort extraction of the paths Git would apply.
|
||||
@@ -119,7 +145,7 @@ fn parse_numstat_paths(output: &[u8]) -> io::Result<Vec<String>> {
|
||||
)
|
||||
})?;
|
||||
if path.is_empty() {
|
||||
let old = records
|
||||
let _old = records
|
||||
.next()
|
||||
.filter(|path| !path.is_empty())
|
||||
.ok_or_else(|| {
|
||||
@@ -137,7 +163,6 @@ fn parse_numstat_paths(output: &[u8]) -> io::Result<Vec<String>> {
|
||||
"git apply returned an incomplete rename path record",
|
||||
)
|
||||
})?;
|
||||
insert_numstat_path(&mut paths, old)?;
|
||||
insert_numstat_path(&mut paths, new)?;
|
||||
} else {
|
||||
insert_numstat_path(&mut paths, path)?;
|
||||
@@ -157,7 +182,7 @@ fn insert_numstat_path(paths: &mut Vec<String>, path: &[u8]) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_patch_path(path: String) -> io::Result<String> {
|
||||
pub(crate) fn validate_patch_path(path: String) -> io::Result<String> {
|
||||
if path.starts_with('/')
|
||||
|| path.ends_with('/')
|
||||
|| invalid_platform_patch_path(&path)
|
||||
@@ -231,50 +256,70 @@ pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
|
||||
let (tmpdir, patch_path) = write_temp_patch(diff)?;
|
||||
let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?;
|
||||
let _guard = tmpdir;
|
||||
stage_effective_paths(&git, git_root, &paths)
|
||||
stage_effective_paths(&git, git_root, &paths, &safe_git_config_parts())
|
||||
}
|
||||
|
||||
pub(crate) fn stage_effective_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
ensure_no_selected_executable_git_filters(git, git_root, paths, &[])?;
|
||||
let confined = confine_patch_paths(git, git_root, paths)?;
|
||||
let mut existing = Vec::new();
|
||||
let mut content_filter_paths = Vec::new();
|
||||
for path in confined.into_exact_leaves()? {
|
||||
let joined = git_root.join(&path);
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&joined) {
|
||||
if leaf_is_traversable_directory(metadata.file_type()) {
|
||||
let file_type = metadata.file_type();
|
||||
if leaf_is_traversable_directory(file_type) {
|
||||
return Err(containment_error(
|
||||
"refusing to recursively stage a directory patch path",
|
||||
));
|
||||
}
|
||||
if leaf_may_run_git_content_filter(file_type) {
|
||||
content_filter_paths.push(path.clone());
|
||||
}
|
||||
existing.push(path);
|
||||
}
|
||||
}
|
||||
if existing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut args = vec![
|
||||
"--literal-pathspecs".to_string(),
|
||||
"add".to_string(),
|
||||
"--".to_string(),
|
||||
];
|
||||
args.extend(existing);
|
||||
let config_parts = safe_git_config_parts();
|
||||
let (_code, _, _) = run_git(git, git_root, &config_parts, &args)?;
|
||||
// We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
|
||||
let _result = update_index_exact_paths(
|
||||
git,
|
||||
git_root,
|
||||
&existing,
|
||||
&content_filter_paths,
|
||||
git_config_args,
|
||||
)?;
|
||||
// Preserve the public helper's historical best-effort treatment of a
|
||||
// non-zero staging command. Security and probe failures still propagate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool {
|
||||
pub(crate) fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool {
|
||||
file_type.is_dir()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn leaf_may_run_git_content_filter(file_type: std::fs::FileType) -> bool {
|
||||
// Git stages an exact Unix symlink as a mode-120000 blob containing the
|
||||
// link target. The whole-command neutralizer covers unrelated racy index
|
||||
// entries while this target is omitted from the selected-filter refusal.
|
||||
!file_type.is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn leaf_may_run_git_content_filter(_file_type: std::fs::FileType) -> bool {
|
||||
// Keep the conservative policy on platforms whose symlink staging
|
||||
// behavior can depend on repository and host configuration.
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool {
|
||||
pub(crate) fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
|
||||
// Git traverses junctions and container-mapped directory symlinks. Refuse
|
||||
@@ -326,7 +371,7 @@ pub(crate) struct ConfinedPatchPaths {
|
||||
}
|
||||
|
||||
impl ConfinedPatchPaths {
|
||||
fn into_exact_leaves(self) -> io::Result<Vec<String>> {
|
||||
pub(crate) fn into_exact_leaves(self) -> io::Result<Vec<String>> {
|
||||
self.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,561 @@
|
||||
use super::*;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Topology {
|
||||
Delete,
|
||||
Rename,
|
||||
}
|
||||
|
||||
fn run(cwd: &Path, args: &[&str], input: Option<&[u8]>) -> std::process::Output {
|
||||
let mut command = std::process::Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if input.is_some() {
|
||||
command.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = command.spawn().expect("run Git");
|
||||
if let Some(input) = input {
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("Git stdin")
|
||||
.write_all(input)
|
||||
.expect("write Git stdin");
|
||||
}
|
||||
child.wait_with_output().expect("read Git output")
|
||||
}
|
||||
|
||||
fn run_success(cwd: &Path, args: &[&str]) -> Vec<u8> {
|
||||
let output = run(cwd, args, None);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output.stdout
|
||||
}
|
||||
|
||||
fn init_repo() -> tempfile::TempDir {
|
||||
let repo = tempfile::tempdir().expect("repository");
|
||||
let root = repo.path();
|
||||
run_success(root, &["init", "-q"]);
|
||||
run_success(root, &["config", "user.email", "codex@example.com"]);
|
||||
run_success(root, &["config", "user.name", "Codex"]);
|
||||
run_success(root, &["config", "core.autocrlf", "false"]);
|
||||
run_success(root, &["config", "core.filemode", "true"]);
|
||||
repo
|
||||
}
|
||||
|
||||
fn request(root: &Path, patch: &str) -> ApplyGitRequest {
|
||||
ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: patch.to_string(),
|
||||
revert: true,
|
||||
preflight: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn topology_fixture(topology: Topology) -> (tempfile::TempDir, String) {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("old.txt"), b"old\n").expect("base file");
|
||||
run_success(root, &["add", "old.txt"]);
|
||||
run_success(root, &["commit", "-qm", "base"]);
|
||||
match topology {
|
||||
Topology::Delete => {
|
||||
run_success(root, &["rm", "-q", "old.txt"]);
|
||||
}
|
||||
Topology::Rename => {
|
||||
run_success(root, &["mv", "old.txt", "new.txt"]);
|
||||
}
|
||||
}
|
||||
let patch = String::from_utf8(run_success(
|
||||
root,
|
||||
&["diff", "--cached", "--full-index", "--binary", "-M"],
|
||||
))
|
||||
.expect("UTF-8 patch");
|
||||
run_success(root, &["reset", "--hard", "-q", "HEAD"]);
|
||||
(repo, patch)
|
||||
}
|
||||
|
||||
fn index_snapshot(root: &Path) -> Vec<u8> {
|
||||
let mut snapshot = run_success(root, &["ls-files", "-v", "--stage", "-z"]);
|
||||
snapshot.extend(run_success(root, &["ls-files", "--debug"]));
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LeafSnapshot {
|
||||
Missing,
|
||||
File(Vec<u8>, u32),
|
||||
Symlink(std::path::PathBuf),
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_mode(metadata: &std::fs::Metadata) -> u32 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn file_mode(metadata: &std::fs::Metadata) -> u32 {
|
||||
u32::from(metadata.permissions().readonly())
|
||||
}
|
||||
|
||||
fn leaf_snapshot(root: &Path, path: &str) -> LeafSnapshot {
|
||||
match std::fs::symlink_metadata(root.join(path)) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
LeafSnapshot::Symlink(std::fs::read_link(root.join(path)).expect("symlink target"))
|
||||
}
|
||||
Ok(metadata) => LeafSnapshot::File(
|
||||
std::fs::read(root.join(path)).expect("file bytes"),
|
||||
file_mode(&metadata),
|
||||
),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => LeafSnapshot::Missing,
|
||||
Err(error) => panic!("snapshot {path}: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_refused_without_mutation(root: &Path, patch: &str) {
|
||||
let before_index = index_snapshot(root);
|
||||
let before_leaves = [
|
||||
leaf_snapshot(root, "old.txt"),
|
||||
leaf_snapshot(root, "new.txt"),
|
||||
];
|
||||
let error = apply_git_patch(&request(root, patch)).expect_err("unsafe reverse must fail");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
|
||||
assert_eq!(index_snapshot(root), before_index);
|
||||
assert_eq!(
|
||||
[
|
||||
leaf_snapshot(root, "old.txt"),
|
||||
leaf_snapshot(root, "new.txt")
|
||||
],
|
||||
before_leaves
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_delete_and_rename_preserve_divergent_staged_blobs() {
|
||||
for (topology, endpoint) in [
|
||||
(Topology::Delete, "old.txt"),
|
||||
(Topology::Rename, "old.txt"),
|
||||
(Topology::Rename, "new.txt"),
|
||||
] {
|
||||
let (repo, patch) = topology_fixture(topology);
|
||||
let root = repo.path();
|
||||
if matches!(topology, Topology::Rename) {
|
||||
std::fs::remove_file(root.join("old.txt")).expect("remove old endpoint");
|
||||
std::fs::write(root.join("new.txt"), b"old\n").expect("new endpoint");
|
||||
}
|
||||
std::fs::write(root.join(endpoint), b"user-staged\n").expect("divergent staged blob");
|
||||
run_success(root, &["add", endpoint]);
|
||||
if endpoint == "old.txt" {
|
||||
std::fs::remove_file(root.join(endpoint)).expect("missing old endpoint");
|
||||
} else {
|
||||
std::fs::write(root.join(endpoint), b"old\n").expect("restore worktree endpoint");
|
||||
}
|
||||
assert_refused_without_mutation(root, &patch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_rename_accepts_each_partially_staged_form() {
|
||||
for staged_endpoints in ["old", "new", "both"] {
|
||||
let (repo, patch) = topology_fixture(Topology::Rename);
|
||||
let root = repo.path();
|
||||
std::fs::remove_file(root.join("old.txt")).expect("remove old endpoint");
|
||||
std::fs::write(root.join("new.txt"), b"old\n").expect("new endpoint");
|
||||
match staged_endpoints {
|
||||
"old" => {
|
||||
run_success(root, &["add", "old.txt"]);
|
||||
}
|
||||
"new" => {
|
||||
run_success(root, &["add", "new.txt"]);
|
||||
}
|
||||
"both" => {
|
||||
run_success(root, &["add", "old.txt", "new.txt"]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let result = apply_git_patch(&request(root, &patch)).expect("safe partial rename reverse");
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["old.txt"]);
|
||||
assert_eq!(std::fs::read(root.join("old.txt")).unwrap(), b"old\n");
|
||||
assert!(!root.join("new.txt").exists());
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_staged_delete_succeeds_but_recreated_worktree_is_refused() {
|
||||
let (repo, patch) = topology_fixture(Topology::Delete);
|
||||
let root = repo.path();
|
||||
run_success(root, &["rm", "-q", "old.txt"]);
|
||||
let result = apply_git_patch(&request(root, &patch)).expect("already-staged reverse delete");
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["old.txt"]);
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
|
||||
run_success(root, &["rm", "-q", "old.txt"]);
|
||||
std::fs::write(root.join("old.txt"), b"recreated\n").unwrap();
|
||||
assert_refused_without_mutation(root, &patch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_refuses_intent_to_add_conflicts_and_index_flags() {
|
||||
for condition in [
|
||||
"ita-old",
|
||||
"ita-new",
|
||||
"conflict-old",
|
||||
"conflict-new",
|
||||
"assume",
|
||||
"skip",
|
||||
] {
|
||||
let topology = if matches!(condition, "ita-new" | "conflict-new") {
|
||||
Topology::Rename
|
||||
} else {
|
||||
Topology::Delete
|
||||
};
|
||||
let (repo, patch) = topology_fixture(topology);
|
||||
let root = repo.path();
|
||||
if matches!(topology, Topology::Rename) {
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
std::fs::write(root.join("new.txt"), b"old\n").unwrap();
|
||||
}
|
||||
match condition {
|
||||
"ita-old" => {
|
||||
run_success(root, &["rm", "--cached", "-q", "old.txt"]);
|
||||
run_success(root, &["add", "-N", "old.txt"]);
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
}
|
||||
"ita-new" => {
|
||||
run_success(root, &["add", "-N", "new.txt"]);
|
||||
}
|
||||
"conflict-old" | "conflict-new" => {
|
||||
let path = condition.strip_prefix("conflict-").unwrap();
|
||||
let path = format!("{path}.txt");
|
||||
if path == "old.txt" {
|
||||
std::fs::remove_file(root.join(&path)).unwrap();
|
||||
}
|
||||
let oid =
|
||||
String::from_utf8(run_success(root, &["rev-parse", "HEAD:old.txt"])).unwrap();
|
||||
let input = format!(
|
||||
"0 0000000000000000000000000000000000000000\t{path}\n100644 {} 1\t{path}\n100644 {} 2\t{path}\n100644 {} 3\t{path}\n",
|
||||
oid.trim(),
|
||||
oid.trim(),
|
||||
oid.trim()
|
||||
);
|
||||
let output = run(
|
||||
root,
|
||||
&["update-index", "--index-info"],
|
||||
Some(input.as_bytes()),
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
"assume" => {
|
||||
run_success(root, &["update-index", "--assume-unchanged", "old.txt"]);
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
}
|
||||
"skip" => {
|
||||
run_success(root, &["update-index", "--skip-worktree", "old.txt"]);
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_refused_without_mutation(root, &patch);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reverse_refuses_divergent_staged_modes_and_symlinks_at_every_endpoint() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for (topology, endpoint, symlink) in [
|
||||
(Topology::Delete, "old.txt", false),
|
||||
(Topology::Delete, "old.txt", true),
|
||||
(Topology::Rename, "old.txt", false),
|
||||
(Topology::Rename, "old.txt", true),
|
||||
(Topology::Rename, "new.txt", false),
|
||||
(Topology::Rename, "new.txt", true),
|
||||
] {
|
||||
let (repo, patch) = topology_fixture(topology);
|
||||
let root = repo.path();
|
||||
if matches!(topology, Topology::Rename) {
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
std::fs::write(root.join("new.txt"), b"old\n").unwrap();
|
||||
}
|
||||
let endpoint_path = root.join(endpoint);
|
||||
if symlink {
|
||||
let _ = std::fs::remove_file(&endpoint_path);
|
||||
std::os::unix::fs::symlink("staged-target", &endpoint_path).unwrap();
|
||||
} else {
|
||||
std::fs::write(&endpoint_path, b"old\n").unwrap();
|
||||
let mut permissions = std::fs::metadata(&endpoint_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&endpoint_path, permissions).unwrap();
|
||||
}
|
||||
run_success(root, &["add", endpoint]);
|
||||
std::fs::remove_file(&endpoint_path).unwrap();
|
||||
if endpoint == "new.txt" {
|
||||
std::fs::write(&endpoint_path, b"old\n").unwrap();
|
||||
}
|
||||
if !symlink {
|
||||
run_success(root, &["config", "core.filemode", "false"]);
|
||||
}
|
||||
assert_refused_without_mutation(root, &patch);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reverse_mode_only_change_succeeds_when_repository_ignores_filemode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for (base_mode, forward_mode, expected_index_mode) in
|
||||
[(0o644, 0o755, "100644 "), (0o755, 0o644, "100755 ")]
|
||||
{
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("mode.txt"), b"unchanged\n").unwrap();
|
||||
let mut permissions = std::fs::metadata(root.join("mode.txt"))
|
||||
.unwrap()
|
||||
.permissions();
|
||||
permissions.set_mode(base_mode);
|
||||
std::fs::set_permissions(root.join("mode.txt"), permissions).unwrap();
|
||||
run_success(root, &["add", "mode.txt"]);
|
||||
run_success(root, &["commit", "-qm", "base"]);
|
||||
|
||||
let mut permissions = std::fs::metadata(root.join("mode.txt"))
|
||||
.unwrap()
|
||||
.permissions();
|
||||
permissions.set_mode(forward_mode);
|
||||
std::fs::set_permissions(root.join("mode.txt"), permissions).unwrap();
|
||||
let patch = String::from_utf8(run_success(root, &["diff", "--full-index"])).unwrap();
|
||||
assert!(patch.contains("old mode "), "{patch}");
|
||||
assert!(patch.contains("new mode "), "{patch}");
|
||||
|
||||
run_success(root, &["reset", "--hard", "-q", "HEAD"]);
|
||||
run_success(root, &["config", "core.filemode", "false"]);
|
||||
let mut permissions = std::fs::metadata(root.join("mode.txt"))
|
||||
.unwrap()
|
||||
.permissions();
|
||||
permissions.set_mode(forward_mode);
|
||||
std::fs::set_permissions(root.join("mode.txt"), permissions).unwrap();
|
||||
|
||||
let result =
|
||||
apply_git_patch(&request(root, &patch)).expect("reverse executable-bit change");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["mode.txt"]);
|
||||
let index =
|
||||
String::from_utf8(run_success(root, &["ls-files", "--stage", "mode.txt"])).unwrap();
|
||||
assert!(index.starts_with(expected_index_mode), "{index}");
|
||||
assert_eq!(
|
||||
std::fs::metadata(root.join("mode.txt"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
base_mode
|
||||
);
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reverse_content_only_change_preserves_executable_index_mode_when_filemode_is_ignored() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("script.sh"), b"old\n").unwrap();
|
||||
let mut permissions = std::fs::metadata(root.join("script.sh"))
|
||||
.unwrap()
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(root.join("script.sh"), permissions).unwrap();
|
||||
run_success(root, &["add", "script.sh"]);
|
||||
run_success(root, &["commit", "-qm", "base"]);
|
||||
|
||||
std::fs::write(root.join("script.sh"), b"new\n").unwrap();
|
||||
let patch = String::from_utf8(run_success(root, &["diff", "--full-index"])).unwrap();
|
||||
assert!(!patch.contains("old mode "), "{patch}");
|
||||
assert!(!patch.contains("new mode "), "{patch}");
|
||||
|
||||
run_success(root, &["reset", "--hard", "-q", "HEAD"]);
|
||||
run_success(root, &["config", "core.filemode", "false"]);
|
||||
let mut permissions = std::fs::metadata(root.join("script.sh"))
|
||||
.unwrap()
|
||||
.permissions();
|
||||
permissions.set_mode(0o644);
|
||||
std::fs::set_permissions(root.join("script.sh"), permissions).unwrap();
|
||||
std::fs::write(root.join("script.sh"), b"new\n").unwrap();
|
||||
|
||||
let result = apply_git_patch(&request(root, &patch)).expect("reverse content-only change");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["script.sh"]);
|
||||
assert_eq!(std::fs::read(root.join("script.sh")).unwrap(), b"old\n");
|
||||
let index =
|
||||
String::from_utf8(run_success(root, &["ls-files", "--stage", "script.sh"])).unwrap();
|
||||
assert!(index.starts_with("100755 "), "{index}");
|
||||
assert!(run_success(root, &["status", "--porcelain"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_refuses_ignored_untracked_rename_endpoint_without_mutation() {
|
||||
let (repo, patch) = topology_fixture(Topology::Rename);
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join(".gitignore"), b"new.txt\n").unwrap();
|
||||
std::fs::rename(root.join("old.txt"), root.join("new.txt")).unwrap();
|
||||
let before_index = index_snapshot(root);
|
||||
let before_leaves = [
|
||||
leaf_snapshot(root, "old.txt"),
|
||||
leaf_snapshot(root, "new.txt"),
|
||||
];
|
||||
|
||||
let error = apply_git_patch(&request(root, &patch)).expect_err("ignored endpoint");
|
||||
assert!(error.to_string().contains("ignored"), "{error}");
|
||||
assert_eq!(index_snapshot(root), before_index);
|
||||
assert_eq!(
|
||||
[
|
||||
leaf_snapshot(root, "old.txt"),
|
||||
leaf_snapshot(root, "new.txt")
|
||||
],
|
||||
before_leaves
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_handles_unborn_empty_index_and_refuses_unborn_index_state() {
|
||||
const DELETE_PATCH: &str = "diff --git a/old.txt b/old.txt\ndeleted file mode 100644\n--- a/old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n";
|
||||
|
||||
let clean = init_repo();
|
||||
let result = apply_git_patch(&request(clean.path(), DELETE_PATCH)).expect("unborn reverse add");
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["old.txt"]);
|
||||
assert_eq!(
|
||||
std::fs::read(clean.path().join("old.txt")).unwrap(),
|
||||
b"old\n"
|
||||
);
|
||||
|
||||
for intent_to_add in [false, true] {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("old.txt"), b"staged\n").unwrap();
|
||||
run_success(
|
||||
root,
|
||||
if intent_to_add {
|
||||
&["add", "-N", "old.txt"]
|
||||
} else {
|
||||
&["add", "old.txt"]
|
||||
},
|
||||
);
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
assert_refused_without_mutation(root, DELETE_PATCH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_propagates_index_staging_failure_before_final_apply() {
|
||||
let (repo, patch) = topology_fixture(Topology::Delete);
|
||||
let root = repo.path();
|
||||
std::fs::remove_file(root.join("old.txt")).unwrap();
|
||||
let before = index_snapshot(root);
|
||||
std::fs::write(root.join(".git/index.lock"), b"locked").unwrap();
|
||||
|
||||
let error = apply_git_patch(&request(root, &patch)).expect_err("index staging failure");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to stage reverse patch paths")
|
||||
);
|
||||
std::fs::remove_file(root.join(".git/index.lock")).unwrap();
|
||||
assert_eq!(index_snapshot(root), before);
|
||||
assert!(
|
||||
!root.join("old.txt").exists(),
|
||||
"final reverse apply must not run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_success_paths_are_machine_readable_in_each_orientation() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_REPORTING_LOCALE_CHILD").is_none() {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.arg("apply::reverse_apply_tests::direct_success_paths_are_machine_readable_in_each_orientation")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("CODEX_GIT_UTILS_REPORTING_LOCALE_CHILD", "1")
|
||||
.env("LC_ALL", OsStr::new("fr_FR.UTF-8"))
|
||||
.env("RUST_TEST_THREADS", "1")
|
||||
.output()
|
||||
.expect("localized test child");
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
for path in ["first.txt", "second.txt"] {
|
||||
std::fs::write(root.join(path), b"old\n").unwrap();
|
||||
}
|
||||
run_success(root, &["add", "."]);
|
||||
run_success(root, &["commit", "-qm", "base"]);
|
||||
for path in ["first.txt", "second.txt"] {
|
||||
std::fs::write(root.join(path), b"new\n").unwrap();
|
||||
}
|
||||
run_success(root, &["add", "."]);
|
||||
let patch =
|
||||
String::from_utf8(run_success(root, &["diff", "--cached", "--full-index"])).unwrap();
|
||||
run_success(root, &["reset", "--hard", "-q", "HEAD"]);
|
||||
for revert in [false, true] {
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: patch.clone(),
|
||||
revert,
|
||||
preflight: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec!["first.txt", "second.txt"]);
|
||||
assert!(!result.cmd_for_log.contains("--verbose"));
|
||||
}
|
||||
|
||||
run_success(root, &["mv", "first.txt", "renamed.txt"]);
|
||||
let rename = String::from_utf8(run_success(
|
||||
root,
|
||||
&["diff", "--cached", "--full-index", "-M"],
|
||||
))
|
||||
.unwrap();
|
||||
run_success(root, &["reset", "--hard", "-q", "HEAD"]);
|
||||
for (revert, expected) in [(false, "renamed.txt"), (true, "first.txt")] {
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: rename.clone(),
|
||||
revert,
|
||||
preflight: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(result.applied_paths, vec![expected]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Safe preparatory index staging for direct reverse patch application.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::exact_staging::update_index_exact_paths;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::patch_paths::confine_patch_paths;
|
||||
use crate::patch_paths::leaf_is_traversable_directory;
|
||||
use crate::patch_paths::leaf_may_run_git_content_filter;
|
||||
|
||||
#[cfg(unix)]
|
||||
const WORKTREE_FILEMODE_CONFIG_ARGS: &[&str] = &["-c", "core.filemode=true"];
|
||||
#[cfg(not(unix))]
|
||||
const WORKTREE_FILEMODE_CONFIG_ARGS: &[&str] = &[];
|
||||
|
||||
pub(crate) fn stage_effective_paths_for_reverse(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let confined = confine_patch_paths(git, git_root, paths)?;
|
||||
let mut worktree_paths = BTreeMap::new();
|
||||
for path in confined.into_exact_leaves()? {
|
||||
let joined = git_root.join(&path);
|
||||
let state = match std::fs::symlink_metadata(&joined) {
|
||||
Ok(metadata) => {
|
||||
let file_type = metadata.file_type();
|
||||
if leaf_is_traversable_directory(file_type) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"refusing to recursively stage a directory patch path",
|
||||
));
|
||||
}
|
||||
ReverseWorktreePath {
|
||||
exists: true,
|
||||
may_run_content_filter: leaf_may_run_git_content_filter(file_type),
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => ReverseWorktreePath {
|
||||
exists: false,
|
||||
may_run_content_filter: false,
|
||||
},
|
||||
Err(error) => {
|
||||
return Err(io::Error::new(
|
||||
error.kind(),
|
||||
format!(
|
||||
"failed to inspect reverse patch path {}: {error}",
|
||||
joined.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
worktree_paths.insert(path, state);
|
||||
}
|
||||
|
||||
let exact_paths = worktree_paths.keys().cloned().collect::<Vec<_>>();
|
||||
let index_entries = read_reverse_index_entries(git, git_root, &exact_paths, git_config_args)?;
|
||||
for path in &exact_paths {
|
||||
let entries = index_entries.get(path).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if !matches!(
|
||||
entries,
|
||||
[] | [ReverseIndexEntry {
|
||||
tag: b'H',
|
||||
stage: 0
|
||||
}]
|
||||
) {
|
||||
return Err(reverse_staging_error(format!(
|
||||
"refusing to stage reverse patch path {path:?} because its index entry is conflicted or carries assume-unchanged/skip-worktree state"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let cached_invisible_status = read_name_status(
|
||||
git,
|
||||
git_root,
|
||||
&exact_paths,
|
||||
git_config_args,
|
||||
&[
|
||||
"--literal-pathspecs",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--ita-invisible-in-index",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--ignore-submodules=none",
|
||||
],
|
||||
"inspect reverse paths with intent-to-add entries hidden",
|
||||
)?;
|
||||
let cached_visible_status = read_name_status(
|
||||
git,
|
||||
git_root,
|
||||
&exact_paths,
|
||||
git_config_args,
|
||||
&[
|
||||
"--literal-pathspecs",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--ita-visible-in-index",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--ignore-submodules=none",
|
||||
],
|
||||
"inspect reverse paths with intent-to-add entries visible",
|
||||
)?;
|
||||
if cached_invisible_status != cached_visible_status {
|
||||
let path = exact_paths
|
||||
.iter()
|
||||
.find(|path| cached_invisible_status.get(*path) != cached_visible_status.get(*path))
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing intent-to-add path"))?;
|
||||
return Err(reverse_staging_error(format!(
|
||||
"refusing to stage reverse patch path {path:?} because it has intent-to-add index state"
|
||||
)));
|
||||
}
|
||||
let worktree_changed_paths = read_changed_paths(
|
||||
git,
|
||||
git_root,
|
||||
&exact_paths,
|
||||
git_config_args,
|
||||
WORKTREE_FILEMODE_CONFIG_ARGS,
|
||||
&[
|
||||
"--literal-pathspecs",
|
||||
"diff-files",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--ignore-submodules=none",
|
||||
],
|
||||
"compare reverse path index entries with the worktree",
|
||||
)?;
|
||||
|
||||
let mut staging_candidates = Vec::new();
|
||||
for path in &exact_paths {
|
||||
let worktree = worktree_paths
|
||||
.get(path)
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing worktree path state"))?;
|
||||
let entries = index_entries.get(path).map(Vec::as_slice).unwrap_or(&[]);
|
||||
match entries {
|
||||
[] if !worktree.exists => continue,
|
||||
[] => staging_candidates.push(path.clone()),
|
||||
[_] if worktree_changed_paths.contains(path) => {
|
||||
staging_candidates.push(path.clone());
|
||||
}
|
||||
[_] => continue,
|
||||
_ => unreachable!("index entries were validated above"),
|
||||
}
|
||||
}
|
||||
if staging_candidates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(path) = staging_candidates
|
||||
.iter()
|
||||
.find(|path| cached_visible_status.contains_key(*path))
|
||||
{
|
||||
return Err(reverse_staging_error(format!(
|
||||
"refusing to prepare a reverse patch because staging {path:?} would replace existing staged data"
|
||||
)));
|
||||
}
|
||||
|
||||
let content_filter_paths = staging_candidates
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
worktree_paths
|
||||
.get(*path)
|
||||
.is_some_and(|state| state.may_run_content_filter)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let result = update_index_exact_paths(
|
||||
git,
|
||||
git_root,
|
||||
&staging_candidates,
|
||||
&content_filter_paths,
|
||||
git_config_args,
|
||||
)?;
|
||||
if result.exit_code == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"failed to stage reverse patch paths (exit {}): {}",
|
||||
result.exit_code,
|
||||
result.stderr.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ReverseWorktreePath {
|
||||
exists: bool,
|
||||
may_run_content_filter: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ReverseIndexEntry {
|
||||
tag: u8,
|
||||
stage: u8,
|
||||
}
|
||||
|
||||
fn read_reverse_index_entries(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, Vec<ReverseIndexEntry>>> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.args(git_config_args)
|
||||
.args([
|
||||
"--literal-pathspecs",
|
||||
"ls-files",
|
||||
"-v",
|
||||
"--stage",
|
||||
"-z",
|
||||
"--",
|
||||
])
|
||||
.args(paths)
|
||||
.current_dir(git_root);
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to inspect reverse patch index entries: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let records = if output.stdout.is_empty() {
|
||||
&[][..]
|
||||
} else {
|
||||
output
|
||||
.stdout
|
||||
.strip_suffix(&[0])
|
||||
.ok_or_else(|| invalid_reverse_index_output("unterminated git ls-files output"))?
|
||||
};
|
||||
|
||||
let expected = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
let mut entries = BTreeMap::<String, Vec<ReverseIndexEntry>>::new();
|
||||
if records.is_empty() {
|
||||
return Ok(entries);
|
||||
}
|
||||
for record in records.split(|byte| *byte == 0) {
|
||||
let Some(separator) = record.iter().position(|byte| *byte == b'\t') else {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git ls-files returned a malformed stage record",
|
||||
));
|
||||
};
|
||||
let (header, path_with_separator) = record.split_at(separator);
|
||||
let path = &path_with_separator[1..];
|
||||
let path = std::str::from_utf8(path)
|
||||
.map_err(|_| invalid_reverse_index_output("git ls-files returned a non-UTF-8 path"))?;
|
||||
if !expected.contains(path) {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git ls-files returned an unexpected path",
|
||||
));
|
||||
}
|
||||
let header = std::str::from_utf8(header).map_err(|_| {
|
||||
invalid_reverse_index_output("git ls-files returned a non-UTF-8 stage header")
|
||||
})?;
|
||||
let mut fields = header.split_ascii_whitespace();
|
||||
let tag = fields
|
||||
.next()
|
||||
.filter(|tag| tag.len() == 1)
|
||||
.map(|tag| tag.as_bytes()[0])
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing git ls-files status tag"))?;
|
||||
let _mode = fields
|
||||
.next()
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing git ls-files mode"))?;
|
||||
let _object_id = fields
|
||||
.next()
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing git ls-files object ID"))?;
|
||||
let stage = fields
|
||||
.next()
|
||||
.ok_or_else(|| invalid_reverse_index_output("missing git ls-files stage"))?
|
||||
.parse::<u8>()
|
||||
.map_err(|_| invalid_reverse_index_output("invalid git ls-files stage"))?;
|
||||
if fields.next().is_some() {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git ls-files returned an ambiguous stage header",
|
||||
));
|
||||
}
|
||||
entries
|
||||
.entry(path.to_string())
|
||||
.or_default()
|
||||
.push(ReverseIndexEntry { tag, stage });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn read_name_status(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
args: &[&str],
|
||||
operation: &str,
|
||||
) -> io::Result<BTreeMap<String, u8>> {
|
||||
let output =
|
||||
run_path_list_command(git, git_root, paths, git_config_args, &[], args, operation)?;
|
||||
let fields = nul_fields(&output, operation)?;
|
||||
if fields.len() % 2 != 0 {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git diff returned an incomplete name-status record",
|
||||
));
|
||||
}
|
||||
let expected = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
let mut statuses = BTreeMap::new();
|
||||
for record in fields.chunks_exact(2) {
|
||||
let status = record[0];
|
||||
if status.len() != 1 || !status[0].is_ascii_alphabetic() {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git diff returned an invalid name-status value",
|
||||
));
|
||||
}
|
||||
let path = parse_expected_path(record[1], &expected, "git diff")?;
|
||||
if statuses.insert(path, status[0]).is_some() {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git diff returned a duplicate path",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
fn read_changed_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
extra_git_args: &[&str],
|
||||
args: &[&str],
|
||||
operation: &str,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
let output = run_path_list_command(
|
||||
git,
|
||||
git_root,
|
||||
paths,
|
||||
git_config_args,
|
||||
extra_git_args,
|
||||
args,
|
||||
operation,
|
||||
)?;
|
||||
let expected = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
let mut changed = BTreeSet::new();
|
||||
for path in nul_fields(&output, operation)? {
|
||||
let path = parse_expected_path(path, &expected, operation)?;
|
||||
if !changed.insert(path) {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"Git returned a duplicate changed path",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn run_path_list_command(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
extra_git_args: &[&str],
|
||||
args: &[&str],
|
||||
operation: &str,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.args(git_config_args)
|
||||
.args(extra_git_args)
|
||||
.args(args)
|
||||
.arg("--")
|
||||
.args(paths)
|
||||
.current_dir(git_root);
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to {operation} (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn reverse_staging_error(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message.into())
|
||||
}
|
||||
|
||||
fn nul_fields<'a>(output: &'a [u8], operation: &str) -> io::Result<Vec<&'a [u8]>> {
|
||||
if output.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let body = output.strip_suffix(&[0]).ok_or_else(|| {
|
||||
invalid_reverse_index_output(&format!("{operation} returned unterminated output"))
|
||||
})?;
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.iter().any(|field| field.is_empty()) {
|
||||
return Err(invalid_reverse_index_output(&format!(
|
||||
"{operation} returned an empty output field"
|
||||
)));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn parse_expected_path(
|
||||
path: &[u8],
|
||||
expected: &BTreeSet<&str>,
|
||||
operation: &str,
|
||||
) -> io::Result<String> {
|
||||
let path = std::str::from_utf8(path).map_err(|_| {
|
||||
invalid_reverse_index_output(&format!("{operation} returned a non-UTF-8 path"))
|
||||
})?;
|
||||
if !expected.contains(path) {
|
||||
return Err(invalid_reverse_index_output(&format!(
|
||||
"{operation} returned an unexpected path"
|
||||
)));
|
||||
}
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
fn invalid_reverse_index_output(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
@@ -22,6 +22,82 @@ pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|
|
||||
/// Timeout for internal Git commands to prevent freezing on large repositories.
|
||||
pub(crate) const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GitConfigOverrideFile {
|
||||
git_config_args: [String; 2],
|
||||
config_path: PathBuf,
|
||||
_config_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl GitConfigOverrideFile {
|
||||
pub(crate) fn new(file_name: &str) -> io::Result<Self> {
|
||||
let config_dir = tempfile::tempdir()?;
|
||||
let config_path = config_dir.path().join(file_name);
|
||||
std::fs::write(&config_path, [])?;
|
||||
let config_path_arg = config_path.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"non-UTF-8 Git config override path",
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
git_config_args: ["-c".to_string(), format!("include.path={config_path_arg}")],
|
||||
config_path,
|
||||
_config_dir: config_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn git_config_args(&self) -> &[String] {
|
||||
&self.git_config_args
|
||||
}
|
||||
|
||||
pub(crate) fn add_value(
|
||||
&self,
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
key: &str,
|
||||
value: &str,
|
||||
description: &str,
|
||||
) -> io::Result<()> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.args(["config", "--file"])
|
||||
.arg(&self.config_path)
|
||||
.arg("--add")
|
||||
.arg(key)
|
||||
.arg(value)
|
||||
.current_dir(cwd);
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to write {description} (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum FilterAttributeValue {
|
||||
Driver(String),
|
||||
AmbiguousSentinel(String),
|
||||
}
|
||||
|
||||
pub(crate) struct GitFilterNeutralization {
|
||||
config_override: Option<GitConfigOverrideFile>,
|
||||
}
|
||||
|
||||
impl GitFilterNeutralization {
|
||||
pub(crate) fn git_config_args(&self) -> &[String] {
|
||||
self.config_override
|
||||
.as_ref()
|
||||
.map(GitConfigOverrideFile::git_config_args)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
const ISOLATED_GIT_ENVIRONMENT: [&str; 11] = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
@@ -134,26 +210,125 @@ pub(crate) fn ensure_no_selected_executable_git_filters(
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
) -> io::Result<GitFilterNeutralization> {
|
||||
ensure_no_selected_executable_git_filters_for(
|
||||
git,
|
||||
cwd,
|
||||
paths,
|
||||
git_config_args,
|
||||
FilterExecution::AnyWorktreeOperation,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_no_selected_git_add_filters(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<GitFilterNeutralization> {
|
||||
ensure_no_selected_executable_git_filters_for(
|
||||
git,
|
||||
cwd,
|
||||
paths,
|
||||
git_config_args,
|
||||
FilterExecution::GitAdd,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_no_selected_executable_git_filters_for(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
execution: FilterExecution,
|
||||
) -> io::Result<GitFilterNeutralization> {
|
||||
let entries = read_filter_config(git, cwd, git_config_args)?;
|
||||
if !entries.values().any(|entry| !entry.value.is_empty()) {
|
||||
return Ok(());
|
||||
let neutralized_drivers = executable_filter_drivers_for(&entries, execution)?;
|
||||
let target_drivers = match execution {
|
||||
FilterExecution::AnyWorktreeOperation => neutralized_drivers.clone(),
|
||||
FilterExecution::GitAdd => executable_filter_drivers(&entries)?,
|
||||
};
|
||||
if target_drivers.is_empty() {
|
||||
return Ok(GitFilterNeutralization {
|
||||
config_override: None,
|
||||
});
|
||||
}
|
||||
let paths = paths
|
||||
.iter()
|
||||
.map(|path| path.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
let attributes = read_filter_attributes(git, cwd, &paths, git_config_args)?;
|
||||
if let Some((driver, path)) = selected_executable_filter(&entries, &attributes)? {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing to run an internal Git worktree operation with executable filter {driver:?} selected for {}",
|
||||
String::from_utf8_lossy(&path)
|
||||
),
|
||||
));
|
||||
let attributes = read_filter_attributes(git, cwd, &paths, git_config_args, &target_drivers)?;
|
||||
for (path, driver) in &attributes {
|
||||
if !target_drivers.contains(driver) {
|
||||
continue;
|
||||
}
|
||||
let refused = neutralized_drivers.contains(driver)
|
||||
|| matches!(execution, FilterExecution::GitAdd)
|
||||
&& git_filter_required(git, cwd, driver, git_config_args)?;
|
||||
if refused {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing to run an internal Git worktree operation with executable filter {driver:?} selected for {}",
|
||||
String::from_utf8_lossy(path)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
if neutralized_drivers.is_empty() {
|
||||
return Ok(GitFilterNeutralization {
|
||||
config_override: None,
|
||||
});
|
||||
}
|
||||
executable_filter_guard(git, cwd, entries, &neutralized_drivers)
|
||||
}
|
||||
|
||||
fn executable_filter_guard(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
filter_config: BTreeMap<String, GitConfigEntry>,
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
) -> io::Result<GitFilterNeutralization> {
|
||||
let config_override = GitConfigOverrideFile::new("filter-neutralization.gitconfig")?;
|
||||
for driver in executable_drivers {
|
||||
debug_assert!(["clean", "smudge", "process"].into_iter().any(|name| {
|
||||
effective_filter_value(&filter_config, driver, name)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}));
|
||||
let description = format!("Git filter neutralization for {driver:?}");
|
||||
for command in ["clean", "smudge", "process"] {
|
||||
config_override.add_value(
|
||||
git,
|
||||
cwd,
|
||||
&format!("filter.{driver}.{command}"),
|
||||
"",
|
||||
&description,
|
||||
)?;
|
||||
}
|
||||
config_override.add_value(
|
||||
git,
|
||||
cwd,
|
||||
&format!("filter.{driver}.required"),
|
||||
"false",
|
||||
&description,
|
||||
)?;
|
||||
}
|
||||
Ok(GitFilterNeutralization {
|
||||
config_override: Some(config_override),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FilterExecution {
|
||||
AnyWorktreeOperation,
|
||||
GitAdd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum FilterCommand {
|
||||
Clean,
|
||||
Smudge,
|
||||
Process,
|
||||
}
|
||||
|
||||
fn read_filter_config(
|
||||
@@ -386,6 +561,7 @@ fn read_filter_attributes(
|
||||
cwd: &Path,
|
||||
paths: &[Vec<u8>],
|
||||
git_config_args: &[String],
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
@@ -418,38 +594,261 @@ fn read_filter_attributes(
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
parse_filter_attributes(&output.stdout, paths)
|
||||
let attributes = parse_filter_attributes(&output.stdout, paths)?;
|
||||
resolve_filter_attribute_sentinels(git, cwd, attributes, git_config_args, executable_drivers)
|
||||
}
|
||||
|
||||
fn resolve_filter_attribute_sentinels(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
attributes: BTreeMap<Vec<u8>, FilterAttributeValue>,
|
||||
git_config_args: &[String],
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
let mut resolved = BTreeMap::new();
|
||||
for (path, attribute) in attributes {
|
||||
match attribute {
|
||||
FilterAttributeValue::Driver(driver) => {
|
||||
resolved.insert(path, driver);
|
||||
}
|
||||
FilterAttributeValue::AmbiguousSentinel(driver) => {
|
||||
if executable_drivers.contains(&driver)
|
||||
&& sentinel_spelling_selects_filter_driver(
|
||||
git,
|
||||
cwd,
|
||||
&path,
|
||||
&driver,
|
||||
git_config_args,
|
||||
)?
|
||||
{
|
||||
resolved.insert(path, driver);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// `git check-attr` serializes both its three special states and literal
|
||||
/// driver names with the same `set`, `unset`, and `unspecified` strings. Ask
|
||||
/// Git to resolve the ambiguity with every command for that driver overridden
|
||||
/// to empty. A required literal driver fails while a special state succeeds.
|
||||
/// Retrying with the driver optional distinguishes that expected failure from
|
||||
/// an unrelated probe error. No filter process or shell is started.
|
||||
fn sentinel_spelling_selects_filter_driver(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
path: &[u8],
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<bool> {
|
||||
let required = run_sentinel_selection_probe(
|
||||
git,
|
||||
cwd,
|
||||
path,
|
||||
driver,
|
||||
git_config_args,
|
||||
/*required*/ true,
|
||||
)?;
|
||||
if required.status.success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let optional = run_sentinel_selection_probe(
|
||||
git,
|
||||
cwd,
|
||||
path,
|
||||
driver,
|
||||
git_config_args,
|
||||
/*required*/ false,
|
||||
)?;
|
||||
if optional.status.success() {
|
||||
return Ok(true);
|
||||
}
|
||||
Err(io::Error::other(format!(
|
||||
"git filter attribute selection probe failed with required status {} and optional status {}: {}",
|
||||
required.status,
|
||||
optional.status,
|
||||
String::from_utf8_lossy(&optional.stderr).trim()
|
||||
)))
|
||||
}
|
||||
|
||||
fn run_sentinel_selection_probe(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
path: &[u8],
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
required: bool,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
&format!("filter.{driver}.required={required}"),
|
||||
"-c",
|
||||
&format!("filter.{driver}.clean="),
|
||||
"-c",
|
||||
&format!("filter.{driver}.smudge="),
|
||||
"-c",
|
||||
&format!("filter.{driver}.process="),
|
||||
"hash-object",
|
||||
"--stdin",
|
||||
])
|
||||
.arg("--path")
|
||||
.arg(git_path_argument(path)?)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null());
|
||||
git.output(command)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn git_path_argument(path: &[u8]) -> io::Result<std::ffi::OsString> {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
Ok(std::ffi::OsString::from_vec(path.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn git_path_argument(path: &[u8]) -> io::Result<std::ffi::OsString> {
|
||||
let path = std::str::from_utf8(path)
|
||||
.map_err(|_| invalid_filter_output("non-UTF-8 Git filter attribute path"))?;
|
||||
Ok(path.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn selected_executable_filter_for(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
attributes: &BTreeMap<Vec<u8>, String>,
|
||||
execution: FilterExecution,
|
||||
) -> io::Result<Option<(String, Vec<u8>)>> {
|
||||
let executable_drivers = executable_filter_drivers_for(entries, execution)?;
|
||||
Ok(selected_filter(&executable_drivers, attributes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn selected_filter(
|
||||
drivers: &BTreeSet<String>,
|
||||
attributes: &BTreeMap<Vec<u8>, String>,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
for (path, driver) in attributes {
|
||||
if drivers.contains(driver) {
|
||||
return Some((driver.clone(), path.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn selected_executable_filter(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
attributes: &BTreeMap<Vec<u8>, String>,
|
||||
) -> io::Result<Option<(String, Vec<u8>)>> {
|
||||
selected_executable_filter_for(entries, attributes, FilterExecution::AnyWorktreeOperation)
|
||||
}
|
||||
|
||||
fn executable_filter_drivers(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
executable_filter_drivers_for(entries, FilterExecution::AnyWorktreeOperation)
|
||||
}
|
||||
|
||||
fn executable_filter_drivers_for(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
execution: FilterExecution,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
let mut executable_drivers = BTreeSet::new();
|
||||
for entry in entries.values() {
|
||||
let driver = filter_driver_name(&entry.key)?;
|
||||
if !entry.value.is_empty() {
|
||||
if entry.key.ends_with(".required") {
|
||||
continue;
|
||||
}
|
||||
let (driver, command) = filter_driver_and_command(&entry.key)?;
|
||||
let relevant = match execution {
|
||||
FilterExecution::AnyWorktreeOperation => true,
|
||||
FilterExecution::GitAdd => command != FilterCommand::Smudge,
|
||||
};
|
||||
if relevant && !entry.value.is_empty() {
|
||||
executable_drivers.insert(driver);
|
||||
}
|
||||
}
|
||||
for (path, driver) in attributes {
|
||||
if executable_drivers.contains(driver) {
|
||||
return Ok(Some((driver.clone(), path.clone())));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
Ok(executable_drivers)
|
||||
}
|
||||
|
||||
fn effective_filter_value<'a>(
|
||||
entries: &'a BTreeMap<String, GitConfigEntry>,
|
||||
driver: &str,
|
||||
name: &str,
|
||||
) -> Option<&'a str> {
|
||||
entries
|
||||
.get(&format!("filter.{driver}.{name}"))
|
||||
.map(|entry| entry.value.as_str())
|
||||
}
|
||||
|
||||
fn git_filter_required(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<bool> {
|
||||
let mut command = git.command();
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["config", "--type=bool", "--get"])
|
||||
.arg(format!("filter.{driver}.required"))
|
||||
.current_dir(cwd);
|
||||
let output = git.output(command)?;
|
||||
if output.status.code() == Some(1) && output.stderr.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing selected Git filter {driver:?} with malformed required value: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
));
|
||||
}
|
||||
match String::from_utf8_lossy(&output.stdout).trim() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
normalized => Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing selected Git filter {driver:?} with unexpected normalized required value {normalized:?}"
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn filter_driver_name(key: &str) -> io::Result<String> {
|
||||
filter_driver_and_command(key).map(|(driver, _command)| driver)
|
||||
}
|
||||
|
||||
fn filter_driver_and_command(key: &str) -> io::Result<(String, FilterCommand)> {
|
||||
let Some(remainder) = key.strip_prefix("filter.") else {
|
||||
return Err(invalid_filter_output("malformed filter config key"));
|
||||
};
|
||||
let driver = [".clean", ".smudge", ".process"]
|
||||
.into_iter()
|
||||
.find_map(|suffix| remainder.strip_suffix(suffix))
|
||||
.filter(|driver| !driver.is_empty())
|
||||
.ok_or_else(|| invalid_filter_output("malformed filter config key"))?;
|
||||
Ok(driver.to_string())
|
||||
let (driver, command) = [
|
||||
(".clean", FilterCommand::Clean),
|
||||
(".smudge", FilterCommand::Smudge),
|
||||
(".process", FilterCommand::Process),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(suffix, command)| {
|
||||
remainder
|
||||
.strip_suffix(suffix)
|
||||
.map(|driver| (driver, command))
|
||||
})
|
||||
.ok_or_else(|| invalid_filter_output("malformed filter config key"))?;
|
||||
Ok((driver.to_string(), command))
|
||||
}
|
||||
|
||||
fn parse_nul_paths(output: &[u8]) -> io::Result<Vec<Vec<u8>>> {
|
||||
@@ -486,7 +885,7 @@ fn write_nul_paths(input: &mut std::fs::File, paths: &[Vec<u8>]) -> io::Result<(
|
||||
fn parse_filter_attributes(
|
||||
output: &[u8],
|
||||
expected_paths: &[Vec<u8>],
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
) -> io::Result<BTreeMap<Vec<u8>, FilterAttributeValue>> {
|
||||
let expected = expected_paths
|
||||
.iter()
|
||||
.map(Vec::as_slice)
|
||||
@@ -514,10 +913,13 @@ fn parse_filter_attributes(
|
||||
}
|
||||
let driver = std::str::from_utf8(record[2])
|
||||
.map_err(|_| invalid_filter_output("non-UTF-8 Git filter attribute value"))?;
|
||||
if attributes
|
||||
.insert(record[0].to_vec(), driver.to_string())
|
||||
.is_some()
|
||||
{
|
||||
let value = match driver {
|
||||
"set" | "unset" | "unspecified" => {
|
||||
FilterAttributeValue::AmbiguousSentinel(driver.to_string())
|
||||
}
|
||||
_ => FilterAttributeValue::Driver(driver.to_string()),
|
||||
};
|
||||
if attributes.insert(record[0].to_vec(), value).is_some() {
|
||||
return Err(invalid_filter_output(
|
||||
"duplicate Git filter attribute record",
|
||||
));
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::patch_paths::stage_paths;
|
||||
use crate::try_get_has_changes;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
@@ -69,6 +70,52 @@ fn selected_filter_policy_allows_effective_empty_value() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_filter_policy_rejects_clean_and_process_but_allows_smudge_only() {
|
||||
let selected = BTreeMap::from([(b"file.txt".to_vec(), "demo".to_string())]);
|
||||
for (key, rejected) in [
|
||||
("filter.demo.clean", true),
|
||||
("filter.demo.smudge", false),
|
||||
("filter.demo.process", true),
|
||||
] {
|
||||
let entries = filter_entries(
|
||||
GitConfigScope::Local,
|
||||
Path::new(".git/config"),
|
||||
key,
|
||||
"codex-definitely-missing-filter-command",
|
||||
);
|
||||
assert_eq!(
|
||||
selected_executable_filter_for(&entries, &selected, FilterExecution::GitAdd)
|
||||
.expect("Git add filter policy")
|
||||
.is_some(),
|
||||
rejected,
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_config_is_not_treated_as_an_executable_command() {
|
||||
let entries = filter_entries(
|
||||
GitConfigScope::Command,
|
||||
Path::new("command line:"),
|
||||
"filter.demo.required",
|
||||
"true",
|
||||
);
|
||||
assert_eq!(
|
||||
executable_filter_drivers(&entries).expect("executable drivers"),
|
||||
BTreeSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_driver_parser_accepts_empty_name() {
|
||||
assert_eq!(
|
||||
filter_driver_name("filter..clean").expect("empty filter name"),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_attribute_parser_rejects_malformed_or_unexpected_records() {
|
||||
let paths = vec![b"a.txt".to_vec(), b"b.txt".to_vec()];
|
||||
@@ -76,12 +123,14 @@ fn filter_attribute_parser_rejects_malformed_or_unexpected_records() {
|
||||
parse_filter_attributes(b"a.txt\0filter\0unspecified\0b.txt\0filter\0lfs\0", &paths)
|
||||
.expect("parse attributes");
|
||||
assert_eq!(
|
||||
parsed.get(b"a.txt".as_slice()).map(String::as_str),
|
||||
Some("unspecified")
|
||||
parsed.get(b"a.txt".as_slice()),
|
||||
Some(&FilterAttributeValue::AmbiguousSentinel(
|
||||
"unspecified".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get(b"b.txt".as_slice()).map(String::as_str),
|
||||
Some("lfs")
|
||||
parsed.get(b"b.txt".as_slice()),
|
||||
Some(&FilterAttributeValue::Driver("lfs".to_string()))
|
||||
);
|
||||
|
||||
for output in [
|
||||
|
||||
Reference in New Issue
Block a user