mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Merge commit '8cc31d9b53027d74e5cf07f0212326785e0aa847' into codex/psec-4396-git-07-status-guard
# Conflicts: # codex-rs/git-utils/src/errors.rs # codex-rs/git-utils/src/git_command.rs # codex-rs/git-utils/src/safe_git.rs # codex-rs/git-utils/src/safe_git_tests.rs
This commit is contained in:
+160
-97
@@ -10,15 +10,15 @@ use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::FsmonitorOverride;
|
||||
use crate::apply_output::parse_git_apply_output;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources;
|
||||
use crate::merge_driver::ensure_no_selected_merge_drivers;
|
||||
use crate::patch_paths::extract_patch_path_inventory;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
use crate::patch_paths::extract_patch_path_inventory_guarded;
|
||||
use crate::reverse_staging::stage_effective_paths_for_reverse;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
use crate::safe_git::ensure_no_selected_executable_git_filters;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
|
||||
@@ -48,26 +48,25 @@ pub struct ApplyGitResult {
|
||||
/// When [`ApplyGitRequest::preflight`] is `true`, this behaves like `git apply --check` and
|
||||
/// leaves the working tree untouched while still parsing the command output for diagnostics.
|
||||
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
let cfg_parts = configured_git_config_parts();
|
||||
// Construct from the caller's exact route before canonicalization so
|
||||
// repository authority retains lexical enclosing worktrees.
|
||||
let git = GitRunner::for_cwd_io(&req.cwd)?;
|
||||
let mut cfg_parts = configured_git_config_parts();
|
||||
ensure_no_worktree_config_sources(&git, &req.cwd, &cfg_parts)?;
|
||||
let requested_cwd = std::fs::canonicalize(&req.cwd)?;
|
||||
let git_root = resolve_git_root(&git, &req.cwd, &cfg_parts)?;
|
||||
if git_root != requested_cwd {
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &cfg_parts)?;
|
||||
}
|
||||
let expected_root = crate::get_git_repo_root(&requested_cwd)
|
||||
.ok_or_else(|| io::Error::other("not a Git repository"))
|
||||
.and_then(std::fs::canonicalize)?;
|
||||
let mut config = GuardedGitConfig::authorize(&git, &expected_root, cfg_parts)?;
|
||||
resolve_git_root(&config, &requested_cwd)?;
|
||||
|
||||
// Write unified diff into a temporary file
|
||||
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_path_inventory =
|
||||
extract_patch_path_inventory(&git, &git_root, &patch_path, req.revert, &cfg_parts)?;
|
||||
extract_patch_path_inventory_guarded(&config, &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());
|
||||
config.authorize_filter_paths(patch_paths)?;
|
||||
let patch_arg = patch_path.to_string_lossy().to_string();
|
||||
|
||||
// Optional preflight: dry-run only; do not modify working tree
|
||||
@@ -77,8 +76,8 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
check_args.push("-R".to_string());
|
||||
}
|
||||
check_args.push(patch_arg);
|
||||
let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
|
||||
let (c_code, c_out, c_err) = run_git(&git, &git_root, &cfg_parts, &check_args)?;
|
||||
let rendered = config.render_command_for_log(&check_args)?;
|
||||
let (c_code, c_out, c_err) = run_guarded_apply(&config, &check_args)?;
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||||
parse_git_apply_output(&c_out, &c_err);
|
||||
applied_paths.sort();
|
||||
@@ -110,33 +109,27 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
plain_check_args.push("-R".to_string());
|
||||
}
|
||||
plain_check_args.push(patch_arg.clone());
|
||||
let (plain_check_code, _, _) = run_git(&git, &git_root, &cfg_parts, &plain_check_args)?;
|
||||
let (plain_check_code, _, _) = run_guarded_apply(&config, &plain_check_args)?;
|
||||
|
||||
let mut args = vec!["apply".to_string()];
|
||||
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());
|
||||
}
|
||||
if plain_check_code != 0 {
|
||||
config.install_three_way_merge_policy()?;
|
||||
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)?;
|
||||
stage_effective_paths_for_reverse(&mut config, patch_paths)?;
|
||||
}
|
||||
if req.revert {
|
||||
args.push("-R".to_string());
|
||||
}
|
||||
args.push(patch_arg);
|
||||
|
||||
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 cmd_for_log = config.render_command_for_log(&args)?;
|
||||
let (code, stdout, stderr) = run_guarded_apply(&config, &args)?;
|
||||
|
||||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = if code == 0 {
|
||||
(patch_path_inventory.primary_paths, Vec::new(), Vec::new())
|
||||
@@ -161,18 +154,10 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_git_root(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<PathBuf> {
|
||||
let requested_cwd = std::fs::canonicalize(cwd)?;
|
||||
let mut command = git.command_for_cwd(&requested_cwd)?;
|
||||
command
|
||||
.args(git_config_args)
|
||||
.arg("rev-parse")
|
||||
.arg("--show-toplevel");
|
||||
let out = git.output(command)?;
|
||||
fn resolve_git_root(config: &GuardedGitConfig<'_>, requested_cwd: &Path) -> io::Result<PathBuf> {
|
||||
let mut command = config.rev_parse_command()?;
|
||||
command.arg("--show-toplevel");
|
||||
let out = command.output()?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
if code != 0 {
|
||||
return Err(io::Error::other(format!(
|
||||
@@ -183,18 +168,7 @@ fn resolve_git_root(
|
||||
}
|
||||
let reported_root = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
|
||||
let root = std::fs::canonicalize(&reported_root)?;
|
||||
let expected_root = crate::get_git_repo_root(&requested_cwd)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"refusing to apply a patch because Git resolved worktree {} without a .git marker above requested cwd {}",
|
||||
root.display(),
|
||||
requested_cwd.display()
|
||||
),
|
||||
)
|
||||
})
|
||||
.and_then(std::fs::canonicalize)?;
|
||||
let expected_root = config.canonical_root();
|
||||
if root != expected_root {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
@@ -231,26 +205,33 @@ pub(crate) fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, Pat
|
||||
Ok((dir, path))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_cfg: &[String],
|
||||
fn run_guarded_apply(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
args: &[String],
|
||||
) -> io::Result<(i32, String, String)> {
|
||||
let mut cmd = git.command_for_cwd(cwd)?;
|
||||
for p in git_cfg {
|
||||
cmd.arg(p);
|
||||
let Some((subcommand, args)) = args.split_first() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"missing guarded Git subcommand",
|
||||
));
|
||||
};
|
||||
if subcommand != "apply" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"unexpected guarded Git subcommand",
|
||||
));
|
||||
}
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
let out = git.output(cmd)?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
Ok((code, stdout, stderr))
|
||||
let mut command = config.apply_command()?;
|
||||
command.args(args);
|
||||
let output = command.output()?;
|
||||
Ok((
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn safe_git_config_parts() -> Vec<String> {
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
@@ -260,33 +241,6 @@ pub(crate) fn safe_git_config_parts() -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
fn quote_shell(s: &str) -> String {
|
||||
let simple = s
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
|
||||
if simple {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push("git".to_string());
|
||||
for a in git_cfg {
|
||||
parts.push(quote_shell(a));
|
||||
}
|
||||
for a in args {
|
||||
parts.push(quote_shell(a));
|
||||
}
|
||||
format!(
|
||||
"(cd {} && {})",
|
||||
quote_shell(&cwd.display().to_string()),
|
||||
parts.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "apply_transport_tests.rs"]
|
||||
mod transport_tests;
|
||||
@@ -302,6 +256,14 @@ mod reverse_apply_tests;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::guarded_config::config_source_authorization_count;
|
||||
use crate::guarded_config::merge_attribute_read_count;
|
||||
use crate::guarded_config::merge_config_read_count;
|
||||
use crate::guarded_config::reset_config_source_authorization_count;
|
||||
use crate::guarded_config::reset_merge_policy_counts;
|
||||
use crate::safe_git::filter_policy_overlay_count;
|
||||
use crate::safe_git::filter_policy_read_count;
|
||||
use crate::safe_git::reset_filter_policy_counts;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
@@ -446,13 +408,38 @@ mod tests {
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let r = apply_git_patch(&req).expect("run apply");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_uses_one_authorization_and_skips_merge_policy() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let request = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: "diff --git a/preflight.txt b/preflight.txt\nnew file mode 100644\n--- /dev/null\n+++ b/preflight.txt\n@@ -0,0 +1 @@\n+preflight\n".to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
reset_merge_policy_counts();
|
||||
let result = apply_git_patch(&request).expect("preflight");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(merge_config_read_count(), 0);
|
||||
assert_eq!(merge_attribute_read_count(), 0);
|
||||
assert!(!root.join("preflight.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_uses_cwd_repo_despite_inherited_repository_selectors() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
@@ -576,6 +563,41 @@ mod tests {
|
||||
assert!(!marker.exists(), "enclosing Git shim must not run");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn apply_uses_physical_repository_for_symlinked_nested_cwd() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let target = fixture.path().join("target");
|
||||
let nested = target.join("nested");
|
||||
std::fs::create_dir_all(&outer).expect("outer");
|
||||
std::fs::create_dir_all(&nested).expect("nested");
|
||||
let (outer_init, _, outer_error) = run(&outer, &["git", "init", "-q"]);
|
||||
assert_eq!(outer_init, 0, "outer init: {outer_error}");
|
||||
let (target_init, _, target_error) = run(&target, &["git", "init", "-q"]);
|
||||
assert_eq!(target_init, 0, "target init: {target_error}");
|
||||
let lexical_cwd = outer.join("linked-nested");
|
||||
std::os::unix::fs::symlink(&nested, &lexical_cwd).expect("nested cwd symlink");
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: lexical_cwd,
|
||||
diff: "diff --git a/physical.txt b/physical.txt\nnew file mode 100644\n--- /dev/null\n+++ b/physical.txt\n@@ -0,0 +1 @@\n+physical\n".to_string(),
|
||||
revert: false,
|
||||
preflight: false,
|
||||
})
|
||||
.expect("apply through symlinked nested cwd");
|
||||
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(
|
||||
read_file_normalized(&target.join("physical.txt")),
|
||||
"physical\n"
|
||||
);
|
||||
assert!(!outer.join("physical.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_modify_conflict() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
@@ -619,6 +641,22 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_then_revert_success() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_ENV_CHILD").is_none() {
|
||||
let config_dir = tempfile::tempdir().expect("config tempdir");
|
||||
let global_config = config_dir.path().join("global.gitconfig");
|
||||
let system_config = config_dir.path().join("system.gitconfig");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
run_isolated_test(
|
||||
"apply::tests::apply_then_revert_success",
|
||||
&[
|
||||
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
|
||||
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
// Seed file and commit original content
|
||||
@@ -634,7 +672,9 @@ mod tests {
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||||
let after_apply = read_file_normalized(&root.join("file.txt"));
|
||||
assert_eq!(after_apply, "ORIG\n");
|
||||
@@ -646,7 +686,16 @@ mod tests {
|
||||
revert: true,
|
||||
preflight: false,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
reset_filter_policy_counts();
|
||||
let res_revert = apply_git_patch(&revert_req).expect("revert ok");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(
|
||||
filter_policy_read_count(),
|
||||
1,
|
||||
"reverse staging must skip a fresh Git-add policy when the index already matches"
|
||||
);
|
||||
assert_eq!(filter_policy_overlay_count(), 0);
|
||||
assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
|
||||
let after_revert = read_file_normalized(&root.join("file.txt"));
|
||||
assert_eq!(after_revert, "orig\n");
|
||||
@@ -683,7 +732,9 @@ mod tests {
|
||||
revert: true,
|
||||
preflight: true,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
|
||||
let (_code_after, staged_after, _stderr_after) =
|
||||
run(root, &["git", "diff", "--cached", "--name-only"]);
|
||||
@@ -713,7 +764,9 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let r1 = apply_git_patch(&req1).expect("preflight apply");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_ne!(r1.exit_code, 0, "preflight reports failure");
|
||||
assert!(
|
||||
!root.join("ok.txt").exists(),
|
||||
@@ -731,7 +784,9 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
revert: false,
|
||||
preflight: false,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let r2 = apply_git_patch(&req2).expect("direct apply");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
|
||||
assert!(
|
||||
!r2.cmd_for_log.contains("--check"),
|
||||
@@ -759,7 +814,13 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
revert,
|
||||
preflight,
|
||||
};
|
||||
reset_config_source_authorization_count();
|
||||
let error = apply_git_patch(&request).expect_err("reject configured filter");
|
||||
assert_eq!(
|
||||
config_source_authorization_count(),
|
||||
1,
|
||||
"{revert} {preflight}"
|
||||
);
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(!configured_filter_ran(root));
|
||||
assert_eq!(read_file_normalized(&root.join("file.txt")), "orig\n");
|
||||
@@ -847,8 +908,10 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
assert_eq!(config_code, 0, "configure core.worktree: {config_err}");
|
||||
|
||||
let git = GitRunner::for_cwd_io(&attacker).expect("trusted Git");
|
||||
let guarded = GuardedGitConfig::authorize(&git, &attacker, Vec::new())
|
||||
.expect("authorize attacker repository config");
|
||||
let error =
|
||||
resolve_git_root(&git, &attacker, &[]).expect_err("reject redirected worktree");
|
||||
resolve_git_root(&guarded, &attacker).expect_err("reject redirected worktree");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(error.to_string().contains("instead of expected worktree"));
|
||||
}
|
||||
|
||||
@@ -536,6 +536,38 @@ fn command_scoped_offpath_filter_is_neutralized_after_local_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_override_rejects_process_temp_directory_inside_worktree() {
|
||||
let root_name = "CODEX_GIT_UTILS_WORKTREE_TMP_ROOT";
|
||||
if let Some(root) = std::env::var_os(root_name) {
|
||||
let root = std::path::PathBuf::from(root);
|
||||
let error = apply_git_patch(&request(
|
||||
&root, /*revert*/ false, /*preflight*/ true,
|
||||
))
|
||||
.expect_err("reject worktree-owned filter override");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(!configured_filter_ran(&root));
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
configure_filter(root, "demo");
|
||||
let worktree_temp = root.join("process-temp");
|
||||
std::fs::create_dir(&worktree_temp).expect("worktree temp directory");
|
||||
run_isolated_test(
|
||||
"apply::filter_tests::filter_override_rejects_process_temp_directory_inside_worktree",
|
||||
&[
|
||||
(root_name, root.as_os_str()),
|
||||
("TMPDIR", worktree_temp.as_os_str()),
|
||||
#[cfg(windows)]
|
||||
("TEMP", worktree_temp.as_os_str()),
|
||||
#[cfg(windows)]
|
||||
("TMP", worktree_temp.as_os_str()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_neutralizes_filter_activated_for_racy_offpath_by_same_patch() {
|
||||
for (name, command) in [
|
||||
|
||||
@@ -2,10 +2,11 @@ use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::GitToolingError;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::operations::ensure_git_repository;
|
||||
use crate::operations::resolve_head;
|
||||
use crate::operations::resolve_repository_root;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
use crate::operations::run_git_for_stdout_with_runner;
|
||||
|
||||
/// Returns the merge-base commit between `HEAD` and the latest version between local
|
||||
/// and remote of the provided branch, if both exist.
|
||||
@@ -16,25 +17,28 @@ pub fn merge_base_with_head(
|
||||
repo_path: &Path,
|
||||
branch: &str,
|
||||
) -> Result<Option<String>, GitToolingError> {
|
||||
ensure_git_repository(repo_path)?;
|
||||
let repo_root = resolve_repository_root(repo_path)?;
|
||||
let head = match resolve_head(repo_root.as_path())? {
|
||||
let git = GitRunner::for_cwd_io(repo_path)?;
|
||||
ensure_git_repository(&git, repo_path)?;
|
||||
let repo_root = resolve_repository_root(&git, repo_path)?;
|
||||
let head = match resolve_head(&git, repo_root.as_path())? {
|
||||
Some(head) => head,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let Some(branch_ref) = resolve_branch_ref(repo_root.as_path(), branch)? else {
|
||||
let Some(branch_ref) = resolve_branch_ref(&git, repo_root.as_path(), branch)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let preferred_ref =
|
||||
if let Some(upstream) = resolve_upstream_if_remote_ahead(repo_root.as_path(), branch)? {
|
||||
resolve_branch_ref(repo_root.as_path(), &upstream)?.unwrap_or(branch_ref)
|
||||
} else {
|
||||
branch_ref
|
||||
};
|
||||
let preferred_ref = if let Some(upstream) =
|
||||
resolve_upstream_if_remote_ahead(&git, repo_root.as_path(), branch)?
|
||||
{
|
||||
resolve_branch_ref(&git, repo_root.as_path(), &upstream)?.unwrap_or(branch_ref)
|
||||
} else {
|
||||
branch_ref
|
||||
};
|
||||
|
||||
let merge_base = run_git_for_stdout(
|
||||
let merge_base = run_git_for_stdout_with_runner(
|
||||
&git,
|
||||
repo_root.as_path(),
|
||||
vec![
|
||||
OsString::from("merge-base"),
|
||||
@@ -47,8 +51,13 @@ pub fn merge_base_with_head(
|
||||
Ok(Some(merge_base))
|
||||
}
|
||||
|
||||
fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result<Option<String>, GitToolingError> {
|
||||
let rev = run_git_for_stdout(
|
||||
fn resolve_branch_ref(
|
||||
git: &GitRunner,
|
||||
repo_root: &Path,
|
||||
branch: &str,
|
||||
) -> Result<Option<String>, GitToolingError> {
|
||||
let rev = run_git_for_stdout_with_runner(
|
||||
git,
|
||||
repo_root,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
@@ -66,10 +75,12 @@ fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result<Option<String>,
|
||||
}
|
||||
|
||||
fn resolve_upstream_if_remote_ahead(
|
||||
git: &GitRunner,
|
||||
repo_root: &Path,
|
||||
branch: &str,
|
||||
) -> Result<Option<String>, GitToolingError> {
|
||||
let upstream = match run_git_for_stdout(
|
||||
let upstream = match run_git_for_stdout_with_runner(
|
||||
git,
|
||||
repo_root,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
@@ -90,7 +101,8 @@ fn resolve_upstream_if_remote_ahead(
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
|
||||
let counts = match run_git_for_stdout(
|
||||
let counts = match run_git_for_stdout_with_runner(
|
||||
git,
|
||||
repo_root,
|
||||
vec![
|
||||
OsString::from("rev-list"),
|
||||
@@ -120,7 +132,17 @@ fn resolve_upstream_if_remote_ahead(
|
||||
mod tests {
|
||||
use super::merge_base_with_head;
|
||||
use crate::GitToolingError;
|
||||
#[cfg(unix)]
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_command::git_runner_construction_count;
|
||||
use crate::git_command::reset_git_runner_construction_count;
|
||||
#[cfg(unix)]
|
||||
use crate::operations::ensure_git_repository;
|
||||
#[cfg(unix)]
|
||||
use crate::operations::resolve_repository_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(unix)]
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
@@ -187,8 +209,14 @@ mod tests {
|
||||
run_git_in(repo, &["checkout", "feature"]);
|
||||
|
||||
let expected = run_git_stdout(repo, &["merge-base", "HEAD", "main"]);
|
||||
reset_git_runner_construction_count();
|
||||
let merge_base = merge_base_with_head(repo, "main")?;
|
||||
assert_eq!(merge_base, Some(expected));
|
||||
assert_eq!(
|
||||
git_runner_construction_count(),
|
||||
1,
|
||||
"one high-level merge-base call must retain one Git runner"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -253,4 +281,67 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_base_returns_none_when_head_is_unborn() -> Result<(), GitToolingError> {
|
||||
let temp = tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
let merge_base = merge_base_with_head(repo, "main")?;
|
||||
assert_eq!(merge_base, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_base_reports_non_repository() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path();
|
||||
|
||||
let error = merge_base_with_head(path, "main").expect_err("non-repository must fail");
|
||||
let GitToolingError::NotAGitRepository { path: error_path } = error else {
|
||||
panic!("expected non-repository error, got {error:?}");
|
||||
};
|
||||
assert_eq!(error_path, path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn retained_runner_refuses_gitdir_retarget_between_subcommands() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let fixture = tempdir().expect("fixture");
|
||||
let repo = fixture.path().join("repo");
|
||||
let admin = fixture.path().join("external-admin");
|
||||
std::fs::create_dir_all(&repo).expect("create repository");
|
||||
run_git_in(
|
||||
fixture.path(),
|
||||
&[
|
||||
"init",
|
||||
"--separate-git-dir",
|
||||
admin.to_str().expect("UTF-8 admin path"),
|
||||
repo.to_str().expect("UTF-8 repository path"),
|
||||
],
|
||||
);
|
||||
|
||||
reset_git_runner_construction_count();
|
||||
let git = GitRunner::for_cwd(&repo).expect("operation-scoped Git runner");
|
||||
ensure_git_repository(&git, &repo).expect("first merge-base subcommand");
|
||||
|
||||
symlink(&admin, repo.join("switch")).expect("Git-dir route switch");
|
||||
std::fs::write(repo.join(".git"), "gitdir: switch\n").expect("retarget Git-dir marker");
|
||||
|
||||
let error = resolve_repository_root(&git, &repo)
|
||||
.expect_err("retained authority must refuse the second subcommand");
|
||||
let GitToolingError::Io(error) = error else {
|
||||
panic!("expected metadata revalidation error, got {error:?}");
|
||||
};
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
|
||||
assert_eq!(
|
||||
git_runner_construction_count(),
|
||||
1,
|
||||
"subcommands must not rediscover authority after metadata retargeting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ pub enum GitReadError {
|
||||
FilterSelectionProbeLimitExceeded { max_probes: usize },
|
||||
#[error("executable filter {driver:?} is selected for {path:?}")]
|
||||
SelectedExecutableFilter { driver: String, path: String },
|
||||
#[error("invalid Git configuration environment: {reason}")]
|
||||
InvalidConfigEnvironment {
|
||||
#[serde(rename = "details")]
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Path fields in caller-visible diagnostic metadata use a deliberately lossy
|
||||
@@ -133,9 +138,9 @@ impl GitReadError {
|
||||
| Self::AuthorityRefused { .. }
|
||||
| Self::FilterSelectionProbeLimitExceeded { .. }
|
||||
| Self::SelectedExecutableFilter { .. } => std::io::ErrorKind::PermissionDenied,
|
||||
Self::InvalidRepositoryMetadata { .. } | Self::InvalidOutput { .. } => {
|
||||
std::io::ErrorKind::InvalidData
|
||||
}
|
||||
Self::InvalidRepositoryMetadata { .. }
|
||||
| Self::InvalidOutput { .. }
|
||||
| Self::InvalidConfigEnvironment { .. } => std::io::ErrorKind::InvalidData,
|
||||
Self::CommandTimedOut { .. } => std::io::ErrorKind::TimedOut,
|
||||
Self::CommandFailed { .. } => std::io::ErrorKind::Other,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::exact_filesystem_policy::filesystem_spelling_conflicts;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ExactIndexPolicy {
|
||||
@@ -90,16 +90,14 @@ impl<'a> IndexEvidence<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_exact_index_policy(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<ExactIndexPolicy> {
|
||||
let output = read_index(git, git_root, git_config_args)?;
|
||||
let output = read_index(config)?;
|
||||
let entries = parse_index_entries(&output)?;
|
||||
let ignore_case =
|
||||
effective_git_bool(git, git_root, git_config_args, "core.ignoreCase")?.unwrap_or(false);
|
||||
let ignore_case = config.read_bool("core.ignoreCase")?.unwrap_or(false);
|
||||
let git_root = config.canonical_root();
|
||||
|
||||
let (paths, content_filter_paths) = if ignore_case {
|
||||
let mapped = match map_ignore_case_paths(git_root, &entries, paths, content_filter_paths) {
|
||||
@@ -171,15 +169,14 @@ pub(crate) fn resolve_exact_index_policy(
|
||||
})
|
||||
}
|
||||
|
||||
fn read_index(git: &GitRunner, git_root: &Path, git_config_args: &[String]) -> io::Result<Vec<u8>> {
|
||||
fn read_index(config: &GuardedGitConfig<'_>) -> 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_for_cwd(git_root)?;
|
||||
let mut command = config.ls_files_command()?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["ls-files", "--cached", "-v", "-z"]);
|
||||
let output = git.output(command)?;
|
||||
.disable_optional_locks()
|
||||
.args(["--cached", "-v", "-z"]);
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git exact-index probe failed with status {}: {}",
|
||||
@@ -423,38 +420,6 @@ 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_for_cwd(git_root)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["config", "--type=bool", "--get", key]);
|
||||
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()
|
||||
|
||||
@@ -4,17 +4,12 @@ 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::git_config_sources::ensure_no_worktree_config_sources;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
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 {
|
||||
@@ -22,6 +17,12 @@ pub(crate) struct StagePathsResult {
|
||||
pub(crate) stderr: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum StagingProvenance {
|
||||
FromApply,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
/// Stage only the literal paths supplied by a caller that has already applied
|
||||
/// the operation-specific containment and filesystem policy.
|
||||
///
|
||||
@@ -35,19 +36,38 @@ pub(crate) struct StagePathsResult {
|
||||
/// 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,
|
||||
pub(crate) fn update_index_exact_paths_from_apply(
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
update_index_exact_paths_common(
|
||||
config,
|
||||
paths,
|
||||
content_filter_paths,
|
||||
StagingProvenance::FromApply,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn update_index_exact_paths_standalone(
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
) -> io::Result<StagePathsResult> {
|
||||
update_index_exact_paths_common(
|
||||
config,
|
||||
paths,
|
||||
content_filter_paths,
|
||||
StagingProvenance::Standalone,
|
||||
)
|
||||
}
|
||||
|
||||
fn update_index_exact_paths_common(
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
content_filter_paths: &[String],
|
||||
provenance: StagingProvenance,
|
||||
) -> io::Result<StagePathsResult> {
|
||||
for path in paths {
|
||||
if path.as_bytes().contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
@@ -67,29 +87,34 @@ pub(crate) fn update_index_exact_paths(
|
||||
"content-filter staging path is not in the exact staging set",
|
||||
));
|
||||
}
|
||||
if matches!(provenance, StagingProvenance::FromApply) {
|
||||
config.ensure_apply_filter_path_subset(content_filter_paths)?;
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 0,
|
||||
stderr: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
ensure_no_worktree_config_sources(git, git_root, git_config_args)?;
|
||||
let (paths, content_filter_paths) =
|
||||
match resolve_exact_index_policy(config, paths, content_filter_paths)? {
|
||||
ExactIndexPolicy::Proceed {
|
||||
paths,
|
||||
content_filter_paths,
|
||||
} => (paths, content_filter_paths),
|
||||
ExactIndexPolicy::Refuse { stderr } => {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 1,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
};
|
||||
if matches!(provenance, StagingProvenance::FromApply) {
|
||||
config.ensure_apply_filter_path_subset(&content_filter_paths)?;
|
||||
}
|
||||
|
||||
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)?;
|
||||
let ignored = ignored_untracked_paths(config, &paths)?;
|
||||
if !ignored.is_empty() {
|
||||
return Ok(StagePathsResult {
|
||||
exit_code: 1,
|
||||
@@ -99,32 +124,26 @@ pub(crate) fn update_index_exact_paths(
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(result) = sparse_checkout_policy(git, git_root, &paths, git_config_args)? {
|
||||
if let Some(result) = sparse_checkout_policy(config, &paths)? {
|
||||
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 })
|
||||
config.authorize_git_add_filter_paths(&content_filter_paths)?;
|
||||
let mut command = config.update_index_literal_pathspecs_command()?;
|
||||
command
|
||||
.disable_optional_locks()
|
||||
.args(["--add", "--remove", "--"])
|
||||
.args(&paths);
|
||||
let output = command.output()?;
|
||||
Ok(StagePathsResult {
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ignored_untracked_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeSet::new());
|
||||
@@ -138,15 +157,14 @@ fn ignored_untracked_paths(
|
||||
.map(|path| format!("./{path}"))
|
||||
.collect::<Vec<_>>();
|
||||
let input = nul_path_input(&probe_paths)?;
|
||||
let mut command = git.command_for_cwd(git_root)?;
|
||||
let mut command = config.check_ignore_command()?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.disable_optional_locks()
|
||||
// `check-ignore --stdin` consumes pathnames rather than pathspecs and
|
||||
// rejects the global literal-pathspec mode as unsupported magic.
|
||||
.args(["check-ignore", "--stdin", "-z"])
|
||||
.args(["--stdin", "-z"])
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
let output = command.output()?;
|
||||
match output.status.code() {
|
||||
Some(0) => {
|
||||
let ignored = parse_reported_paths(&output.stdout, &probe_paths, "ignored-path")?
|
||||
@@ -171,13 +189,10 @@ fn ignored_untracked_paths(
|
||||
}
|
||||
|
||||
fn sparse_checkout_policy(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
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)
|
||||
{
|
||||
if !config.read_bool("core.sparseCheckout")?.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -185,12 +200,7 @@ fn sparse_checkout_policy(
|
||||
// `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_for_cwd(git_root)?;
|
||||
builtins_command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.arg("--list-cmds=builtins");
|
||||
let builtins = git.output(builtins_command)?;
|
||||
let builtins = config.list_builtin_commands()?;
|
||||
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"));
|
||||
@@ -203,13 +213,12 @@ fn sparse_checkout_policy(
|
||||
}
|
||||
|
||||
let input = nul_path_input(paths)?;
|
||||
let mut command = git.command_for_cwd(git_root)?;
|
||||
let mut command = config.sparse_checkout_command()?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["sparse-checkout", "check-rules", "-z"])
|
||||
.disable_optional_locks()
|
||||
.args(["check-rules", "-z"])
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Ok(Some(StagePathsResult {
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) fn classify_sentinel_filter_probes(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn sentinel_filter_probe_config_args(
|
||||
neutralization_args: &[String],
|
||||
driver: &str,
|
||||
|
||||
@@ -9,6 +9,7 @@ use tokio::io::AsyncRead;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::errors::GitReadError;
|
||||
use crate::git_config_environment::GitConfigEnvironmentSnapshot;
|
||||
#[cfg(test)]
|
||||
use crate::git_executable::git_executable_name;
|
||||
use crate::git_executable::harden_git_launch_environment;
|
||||
@@ -36,6 +37,7 @@ pub(crate) struct GitRunner {
|
||||
argv0: PathBuf,
|
||||
safe_path: std::ffi::OsString,
|
||||
authority: RepositoryAuthority,
|
||||
config_environment: GitConfigEnvironmentSnapshot,
|
||||
}
|
||||
|
||||
/// A Git command that can only be spawned through [`GitRunner::output`],
|
||||
@@ -117,6 +119,8 @@ impl GitAsyncCommand {
|
||||
|
||||
impl GitRunner {
|
||||
pub(crate) fn for_cwd(cwd: &Path) -> Result<Self, GitReadError> {
|
||||
#[cfg(test)]
|
||||
GIT_RUNNER_CONSTRUCTION_COUNT.with(|count| count.set(count.get() + 1));
|
||||
let authority = repository_authority_for_cwd(cwd)?;
|
||||
let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?;
|
||||
Self::from_search_path(authority, &search_path)
|
||||
@@ -138,6 +142,7 @@ impl GitRunner {
|
||||
command.current_dir(parent);
|
||||
}
|
||||
harden_git_launch_environment(&mut command, &self.safe_path);
|
||||
self.config_environment.apply_to(&mut command);
|
||||
GitCommand { inner: command }
|
||||
}
|
||||
|
||||
@@ -180,6 +185,18 @@ impl GitRunner {
|
||||
self.authority.active_worktree_root()
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_active_worktree_root(&self, root: &Path) -> io::Result<()> {
|
||||
self.authority.ensure_active_worktree_root(root)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_repository_root_route(&self, root: &Path) -> io::Result<()> {
|
||||
self.authority.ensure_repository_root_route(root)
|
||||
}
|
||||
|
||||
pub(crate) fn config_environment_value(&self, name: &str) -> Option<&OsStr> {
|
||||
self.config_environment.value(name)
|
||||
}
|
||||
|
||||
pub(crate) fn output(&self, mut command: GitCommand) -> io::Result<std::process::Output> {
|
||||
self.prepare_command_for_launch(&mut command.inner)?;
|
||||
command.inner.output()
|
||||
@@ -259,12 +276,18 @@ impl GitRunner {
|
||||
) -> Result<Self, GitReadError> {
|
||||
authority.ensure_primary_authority()?;
|
||||
let selected = select_git_executable(&authority, search_path)?;
|
||||
let config_environment = GitConfigEnvironmentSnapshot::capture().map_err(|error| {
|
||||
GitReadError::InvalidConfigEnvironment {
|
||||
reason: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
executable: selected.executable,
|
||||
#[cfg(any(unix, test))]
|
||||
argv0: selected.argv0,
|
||||
safe_path: selected.safe_path,
|
||||
authority,
|
||||
config_environment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,11 +298,17 @@ impl GitRunner {
|
||||
) -> Result<Self, GitReadError> {
|
||||
let authority = repository_authority_for_cwd(cwd)?;
|
||||
let safe_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?;
|
||||
let config_environment = GitConfigEnvironmentSnapshot::capture().map_err(|error| {
|
||||
GitReadError::InvalidConfigEnvironment {
|
||||
reason: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
argv0: executable.clone(),
|
||||
executable,
|
||||
safe_path,
|
||||
authority,
|
||||
config_environment,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -305,6 +334,21 @@ async fn read_bounded_output(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static GIT_RUNNER_CONSTRUCTION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_git_runner_construction_count() {
|
||||
GIT_RUNNER_CONSTRUCTION_COUNT.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn git_runner_construction_count() -> usize {
|
||||
GIT_RUNNER_CONSTRUCTION_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn repository_authority_for_cwd(
|
||||
cwd: &Path,
|
||||
) -> Result<RepositoryAuthority, GitReadError> {
|
||||
|
||||
@@ -94,6 +94,110 @@ fn run_git_stdout(cwd: &Path, args: &[&str]) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runner_binds_config_environment_across_ambient_mutation() {
|
||||
const CHILD: &str = "CODEX_GIT_CONFIG_BINDING_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let repo = fixture.path().join("repo");
|
||||
let safe_home = fixture.path().join("safe-home");
|
||||
let malicious_home = repo.join("malicious-home");
|
||||
std::fs::create_dir_all(&repo).expect("repo");
|
||||
std::fs::create_dir_all(&safe_home).expect("safe home");
|
||||
std::fs::create_dir_all(&malicious_home).expect("malicious home");
|
||||
run_git(&repo, &["init", "-q"]);
|
||||
std::fs::write(
|
||||
safe_home.join(".gitconfig"),
|
||||
"[codex]\n\tbound = safe-home\n",
|
||||
)
|
||||
.expect("safe home config");
|
||||
let malicious_global = repo.join("malicious.gitconfig");
|
||||
std::fs::write(&malicious_global, "[codex]\n\tbound = malicious\n")
|
||||
.expect("malicious global config");
|
||||
std::fs::write(
|
||||
malicious_home.join(".gitconfig"),
|
||||
"[codex]\n\tbound = malicious-home\n",
|
||||
)
|
||||
.expect("malicious home config");
|
||||
|
||||
let mut command = Command::new(std::env::current_exe().expect("test executable"));
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.arg("git_command::tests::runner_binds_config_environment_across_ambient_mutation")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env(CHILD, "1")
|
||||
.env("RUST_TEST_THREADS", "1")
|
||||
.env("CODEX_GIT_CONFIG_BINDING_ROOT", &repo)
|
||||
.env("CODEX_GIT_CONFIG_BINDING_MALICIOUS", &malicious_global)
|
||||
.env("CODEX_GIT_CONFIG_BINDING_MALICIOUS_HOME", &malicious_home)
|
||||
.env("HOME", &safe_home)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "codex.count")
|
||||
.env("GIT_CONFIG_VALUE_0", "safe-count")
|
||||
.env_remove("GIT_CONFIG_GLOBAL")
|
||||
.env_remove("GIT_CONFIG_SYSTEM")
|
||||
.env_remove("GIT_CONFIG_PARAMETERS")
|
||||
.output()
|
||||
.expect("isolated config binding test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated config binding test failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = PathBuf::from(std::env::var_os("CODEX_GIT_CONFIG_BINDING_ROOT").expect("root"));
|
||||
let malicious =
|
||||
std::env::var_os("CODEX_GIT_CONFIG_BINDING_MALICIOUS").expect("malicious config");
|
||||
let malicious_home =
|
||||
std::env::var_os("CODEX_GIT_CONFIG_BINDING_MALICIOUS_HOME").expect("malicious home");
|
||||
let runner = GitRunner::for_cwd_io(&root).expect("runner with captured config environment");
|
||||
|
||||
// SAFETY: the parent starts an isolated test process that runs only this
|
||||
// exact test with one harness thread. No other application thread reads or
|
||||
// writes these variables, and the process exits after the assertions.
|
||||
unsafe {
|
||||
std::env::set_var("GIT_CONFIG_GLOBAL", malicious);
|
||||
std::env::set_var("HOME", malicious_home);
|
||||
std::env::set_var("GIT_CONFIG_VALUE_0", "malicious-count");
|
||||
std::env::set_var(
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"'codex.count'='malicious-parameter'",
|
||||
);
|
||||
}
|
||||
|
||||
for (key, expected) in [("codex.bound", "safe-home"), ("codex.count", "safe-count")] {
|
||||
let mut command = runner.command_for_cwd(&root).expect("bound command");
|
||||
command.args(["config", "--get", key]);
|
||||
let output = runner.output(command).expect("bound config read");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{key}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected);
|
||||
|
||||
let mut command = runner
|
||||
.async_command_for_cwd(&root)
|
||||
.expect("bound async command");
|
||||
command.args(["config", "--get", key]);
|
||||
let output = runner
|
||||
.output_async_bounded(command, MAX_INTERNAL_GIT_OUTPUT_BYTES)
|
||||
.await
|
||||
.expect("bound async config read");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"async {key}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_all(cwd: &Path, message: &str) {
|
||||
run_git(
|
||||
cwd,
|
||||
@@ -547,6 +651,55 @@ fn git_read_error_io_kind_table_is_exhaustive() {
|
||||
},
|
||||
io::ErrorKind::InvalidData,
|
||||
),
|
||||
(
|
||||
GitReadError::InvalidConfigEnvironment {
|
||||
reason: "malformed GIT_CONFIG_COUNT".to_string(),
|
||||
},
|
||||
io::ErrorKind::InvalidData,
|
||||
),
|
||||
(
|
||||
GitReadError::RepositoryRootMismatch {
|
||||
expected_root: PathBuf::from("expected"),
|
||||
reported_root: PathBuf::from("reported"),
|
||||
},
|
||||
io::ErrorKind::PermissionDenied,
|
||||
),
|
||||
(
|
||||
GitReadError::CommandTimedOut {
|
||||
operation: "status".to_string(),
|
||||
},
|
||||
io::ErrorKind::TimedOut,
|
||||
),
|
||||
(
|
||||
GitReadError::CommandFailed {
|
||||
operation: "status".to_string(),
|
||||
exit_code: Some(1),
|
||||
},
|
||||
io::ErrorKind::Other,
|
||||
),
|
||||
(
|
||||
GitReadError::InvalidOutput {
|
||||
operation: "status".to_string(),
|
||||
},
|
||||
io::ErrorKind::InvalidData,
|
||||
),
|
||||
(
|
||||
GitReadError::AuthorityRefused {
|
||||
operation: "status".to_string(),
|
||||
},
|
||||
io::ErrorKind::PermissionDenied,
|
||||
),
|
||||
(
|
||||
GitReadError::FilterSelectionProbeLimitExceeded { max_probes: 16 },
|
||||
io::ErrorKind::PermissionDenied,
|
||||
),
|
||||
(
|
||||
GitReadError::SelectedExecutableFilter {
|
||||
driver: "demo".to_string(),
|
||||
path: "file.txt".to_string(),
|
||||
},
|
||||
io::ErrorKind::PermissionDenied,
|
||||
),
|
||||
] {
|
||||
assert_eq!(error.io_kind(), expected, "{error}");
|
||||
assert_eq!(error.into_io_error().kind(), expected);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
|
||||
const FIXED_CONFIG_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_CONFIG_NOSYSTEM",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
#[cfg(windows)]
|
||||
"APPDATA",
|
||||
#[cfg(windows)]
|
||||
"PROGRAMDATA",
|
||||
#[cfg(windows)]
|
||||
"USERPROFILE",
|
||||
#[cfg(windows)]
|
||||
"HOMEDRIVE",
|
||||
#[cfg(windows)]
|
||||
"HOMEPATH",
|
||||
];
|
||||
|
||||
// Git config command entries are an untrusted process input. Keep capture
|
||||
// bounded rather than allocating attacker-selected amounts of memory before
|
||||
// Git gets a chance to reject an unreasonable count.
|
||||
const MAX_CONFIG_ENVIRONMENT_ENTRIES: usize = 1024;
|
||||
|
||||
/// Exact config-relevant process environment bound to one
|
||||
/// [`GitRunner`](crate::git_command::GitRunner).
|
||||
///
|
||||
/// Every ordinary Git child receives these captured values (including explicit
|
||||
/// removals for variables that were absent). Commands that intentionally
|
||||
/// isolate a config source may override them after command construction.
|
||||
pub(crate) struct GitConfigEnvironmentSnapshot {
|
||||
entries: Box<[(OsString, Option<OsString>)]>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GitConfigEnvironmentSnapshot {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GitConfigEnvironmentSnapshot")
|
||||
.field("captured_entries", &self.entries.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitConfigEnvironmentSnapshot {
|
||||
pub(crate) fn capture() -> io::Result<Self> {
|
||||
let environment = std::env::vars_os().collect::<BTreeMap<_, _>>();
|
||||
Self::capture_from(|name| captured_environment_value(&environment, name))
|
||||
}
|
||||
|
||||
fn capture_from(mut value_for: impl FnMut(&OsStr) -> Option<OsString>) -> io::Result<Self> {
|
||||
let mut entries = FIXED_CONFIG_ENVIRONMENT_KEYS
|
||||
.iter()
|
||||
.map(|name| (OsString::from(name), value_for(OsStr::new(name))))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(raw_count) = value_for(OsStr::new("GIT_CONFIG_COUNT")) {
|
||||
let count = raw_count
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid_environment("non-UTF-8 GIT_CONFIG_COUNT"))?
|
||||
.parse::<usize>()
|
||||
.map_err(|_| invalid_environment("invalid GIT_CONFIG_COUNT"))?;
|
||||
if count > MAX_CONFIG_ENVIRONMENT_ENTRIES {
|
||||
return Err(invalid_environment(
|
||||
"GIT_CONFIG_COUNT exceeds the supported safety bound",
|
||||
));
|
||||
}
|
||||
for index in 0..count {
|
||||
for prefix in ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"] {
|
||||
let name = OsString::from(format!("{prefix}{index}"));
|
||||
let value = value_for(&name);
|
||||
entries.push((name, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
entries: entries.into_boxed_slice(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn apply_to(&self, command: &mut Command) {
|
||||
for (name, value) in &self.entries {
|
||||
match value {
|
||||
Some(value) => {
|
||||
command.env(name, value);
|
||||
}
|
||||
None => {
|
||||
command.env_remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value(&self, name: &str) -> Option<&OsStr> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|(candidate, value)| (candidate == name).then_some(value.as_deref()))
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn captured_environment_value(
|
||||
environment: &BTreeMap<OsString, OsString>,
|
||||
name: &OsStr,
|
||||
) -> Option<OsString> {
|
||||
environment.get(name).cloned()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn captured_environment_value(
|
||||
environment: &BTreeMap<OsString, OsString>,
|
||||
name: &OsStr,
|
||||
) -> Option<OsString> {
|
||||
environment.iter().find_map(|(candidate, value)| {
|
||||
candidate
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&name.to_string_lossy())
|
||||
.then(|| value.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_environment(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_config_environment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn snapshot_binds_present_empty_and_absent_fixed_and_indexed_values() {
|
||||
let values = BTreeMap::from([
|
||||
(OsString::from("GIT_CONFIG_GLOBAL"), OsString::from("")),
|
||||
(
|
||||
OsString::from("GIT_CONFIG_PARAMETERS"),
|
||||
OsString::from("'safe.parameter'='present'"),
|
||||
),
|
||||
(OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")),
|
||||
(OsString::from("HOME"), OsString::from("/safe/home")),
|
||||
(
|
||||
OsString::from("XDG_CONFIG_HOME"),
|
||||
OsString::from("/safe/xdg"),
|
||||
),
|
||||
(OsString::from("GIT_CONFIG_COUNT"), OsString::from("2")),
|
||||
(
|
||||
OsString::from("GIT_CONFIG_KEY_0"),
|
||||
OsString::from("safe.one"),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_CONFIG_VALUE_0"),
|
||||
OsString::from("present"),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_CONFIG_KEY_1"),
|
||||
OsString::from("safe.two"),
|
||||
),
|
||||
]);
|
||||
let snapshot = GitConfigEnvironmentSnapshot::capture_from(|name| values.get(name).cloned())
|
||||
.expect("capture environment");
|
||||
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_GLOBAL"), Some(OsStr::new("")));
|
||||
assert_eq!(snapshot.value("HOME"), Some(OsStr::new("/safe/home")));
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_SYSTEM"), None);
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_NOSYSTEM"), Some(OsStr::new("1")));
|
||||
assert_eq!(
|
||||
snapshot.value("GIT_CONFIG_PARAMETERS"),
|
||||
Some(OsStr::new("'safe.parameter'='present'"))
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.value("GIT_CONFIG_KEY_0"),
|
||||
Some(OsStr::new("safe.one"))
|
||||
);
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_VALUE_1"), None);
|
||||
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.env("GIT_CONFIG_SYSTEM", "/later/system")
|
||||
.env("GIT_CONFIG_VALUE_1", "later");
|
||||
snapshot.apply_to(&mut command);
|
||||
let child_environment = command
|
||||
.get_envs()
|
||||
.map(|(name, value)| (name.to_owned(), value.map(OsStr::to_owned)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert_eq!(
|
||||
child_environment.get(OsStr::new("GIT_CONFIG_SYSTEM")),
|
||||
Some(&None)
|
||||
);
|
||||
assert_eq!(
|
||||
child_environment.get(OsStr::new("GIT_CONFIG_VALUE_1")),
|
||||
Some(&None)
|
||||
);
|
||||
assert_eq!(
|
||||
child_environment.get(OsStr::new("HOME")),
|
||||
Some(&Some(OsString::from("/safe/home")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_malformed_or_unbounded_command_config_count() {
|
||||
for count in [OsString::from("invalid"), OsString::from("1025")] {
|
||||
let error = GitConfigEnvironmentSnapshot::capture_from(|name| {
|
||||
(name == "GIT_CONFIG_COUNT").then(|| count.clone())
|
||||
})
|
||||
.expect_err("reject count");
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn snapshot_preserves_non_utf8_config_path_bytes() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let raw = OsString::from_vec(vec![b'/', b't', b'm', b'p', b'/', 0xff]);
|
||||
let snapshot = GitConfigEnvironmentSnapshot::capture_from(|name| {
|
||||
(name == "GIT_CONFIG_GLOBAL").then(|| raw.clone())
|
||||
})
|
||||
.expect("capture non-UTF-8 path");
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_GLOBAL"), Some(raw.as_os_str()));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn snapshot_matches_mixed_case_windows_environment_names() {
|
||||
let environment = BTreeMap::from([
|
||||
(OsString::from("git_config_count"), OsString::from("1")),
|
||||
(
|
||||
OsString::from("Git_Config_Key_0"),
|
||||
OsString::from("safe.key"),
|
||||
),
|
||||
(
|
||||
OsString::from("gIt_CoNfIg_VaLuE_0"),
|
||||
OsString::from("safe-value"),
|
||||
),
|
||||
(OsString::from("Home"), OsString::from(r"C:\safe-home")),
|
||||
]);
|
||||
let snapshot = GitConfigEnvironmentSnapshot::capture_from(|name| {
|
||||
captured_environment_value(&environment, name)
|
||||
})
|
||||
.expect("capture mixed-case Windows environment");
|
||||
|
||||
assert_eq!(snapshot.value("GIT_CONFIG_COUNT"), Some(OsStr::new("1")));
|
||||
assert_eq!(
|
||||
snapshot.value("GIT_CONFIG_KEY_0"),
|
||||
Some(OsStr::new("safe.key"))
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.value("GIT_CONFIG_VALUE_0"),
|
||||
Some(OsStr::new("safe-value"))
|
||||
);
|
||||
assert_eq!(snapshot.value("HOME"), Some(OsStr::new(r"C:\safe-home")));
|
||||
}
|
||||
@@ -237,7 +237,7 @@ async fn ensure_no_worktree_config_sources_impl(
|
||||
// Environment, HOME, and XDG paths are attacker-controlled byte strings.
|
||||
// Classify their exact OsString spelling before selected Git can open or
|
||||
// newline-delimit one of them.
|
||||
for (description, candidate) in legacy_primary_config_source_candidates()? {
|
||||
for (description, candidate) in legacy_primary_config_source_candidates(git)? {
|
||||
if !is_disabled_primary_config_path(&candidate) {
|
||||
reject_source(git, &git_root, &candidate, description)?;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -12,7 +13,9 @@ pub(super) fn default_system_config_source_candidates(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Vec<(&'static str, PathBuf)>> {
|
||||
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
|
||||
if git_env_bool(git, "GIT_CONFIG_NOSYSTEM")?
|
||||
|| git.config_environment_value("GIT_CONFIG_SYSTEM").is_some()
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// `GIT_CONFIG_SYSTEM` was added to `git var` in Git 2.42. The PSEC-4394
|
||||
@@ -34,7 +37,9 @@ pub(super) async fn default_system_config_source_candidates_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Vec<(&'static str, PathBuf)>> {
|
||||
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
|
||||
if git_env_bool(git, "GIT_CONFIG_NOSYSTEM")?
|
||||
|| git.config_environment_value("GIT_CONFIG_SYSTEM").is_some()
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(paths) = git_var_config_paths_async(git, cwd, "GIT_CONFIG_SYSTEM").await? else {
|
||||
@@ -50,16 +55,19 @@ pub(super) fn selected_git_home_config_candidates(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Vec<PathBuf>> {
|
||||
if std::env::var_os("GIT_CONFIG_GLOBAL").is_some() {
|
||||
if git.config_environment_value("GIT_CONFIG_GLOBAL").is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
if std::env::var_os("HOME").is_none() {
|
||||
if git.config_environment_value("HOME").is_none() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let dot_gitconfig = selected_git_path_candidate(git, cwd, "~/.gitconfig")?;
|
||||
let mut candidates = vec![dot_gitconfig.clone()];
|
||||
if std::env::var_os("XDG_CONFIG_HOME").is_none_or(|path| path.is_empty()) {
|
||||
if git
|
||||
.config_environment_value("XDG_CONFIG_HOME")
|
||||
.is_none_or(OsStr::is_empty)
|
||||
{
|
||||
let home = dot_gitconfig
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_config_source("selected Git HOME has no parent"))?;
|
||||
@@ -72,16 +80,19 @@ pub(super) async fn selected_git_home_config_candidates_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Vec<PathBuf>> {
|
||||
if std::env::var_os("GIT_CONFIG_GLOBAL").is_some() {
|
||||
if git.config_environment_value("GIT_CONFIG_GLOBAL").is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
if std::env::var_os("HOME").is_none() {
|
||||
if git.config_environment_value("HOME").is_none() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let dot_gitconfig = selected_git_path_candidate_async(git, cwd, "~/.gitconfig").await?;
|
||||
let mut candidates = vec![dot_gitconfig.clone()];
|
||||
if std::env::var_os("XDG_CONFIG_HOME").is_none_or(|path| path.is_empty()) {
|
||||
if git
|
||||
.config_environment_value("XDG_CONFIG_HOME")
|
||||
.is_none_or(OsStr::is_empty)
|
||||
{
|
||||
let home = dot_gitconfig
|
||||
.parent()
|
||||
.ok_or_else(|| invalid_config_source("selected Git HOME has no parent"))?;
|
||||
@@ -94,7 +105,9 @@ pub(super) fn selected_git_prefix_system_candidate(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
|
||||
if git_env_bool(git, "GIT_CONFIG_NOSYSTEM")?
|
||||
|| git.config_environment_value("GIT_CONFIG_SYSTEM").is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
selected_git_path_candidate(git, cwd, "%(prefix)/etc/gitconfig").map(Some)
|
||||
@@ -104,7 +117,9 @@ pub(super) async fn selected_git_prefix_system_candidate_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> io::Result<Option<PathBuf>> {
|
||||
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
|
||||
if git_env_bool(git, "GIT_CONFIG_NOSYSTEM")?
|
||||
|| git.config_environment_value("GIT_CONFIG_SYSTEM").is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
selected_git_path_candidate_async(git, cwd, "%(prefix)/etc/gitconfig")
|
||||
@@ -262,24 +277,25 @@ fn parse_git_var_paths(output: &[u8]) -> io::Result<Vec<PathBuf>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn legacy_primary_config_source_candidates() -> io::Result<Vec<(&'static str, PathBuf)>>
|
||||
{
|
||||
pub(super) fn legacy_primary_config_source_candidates(
|
||||
git: &GitRunner,
|
||||
) -> io::Result<Vec<(&'static str, PathBuf)>> {
|
||||
let mut candidates = Vec::new();
|
||||
match std::env::var_os("GIT_CONFIG_GLOBAL") {
|
||||
match git.config_environment_value("GIT_CONFIG_GLOBAL") {
|
||||
Some(path) if !path.is_empty() => {
|
||||
candidates.push(("GIT_CONFIG_GLOBAL", PathBuf::from(path)));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let homes = git_home_directories();
|
||||
match std::env::var_os("XDG_CONFIG_HOME") {
|
||||
let homes = git_home_directories(git);
|
||||
match git.config_environment_value("XDG_CONFIG_HOME") {
|
||||
Some(xdg) if !xdg.is_empty() => candidates.push((
|
||||
"XDG_CONFIG_HOME Git config",
|
||||
PathBuf::from(xdg).join("git/config"),
|
||||
)),
|
||||
_ => {
|
||||
#[cfg(windows)]
|
||||
if let Some(app_data) = std::env::var_os("APPDATA")
|
||||
if let Some(app_data) = git.config_environment_value("APPDATA")
|
||||
&& !app_data.is_empty()
|
||||
{
|
||||
candidates.push((
|
||||
@@ -300,15 +316,15 @@ pub(super) fn legacy_primary_config_source_candidates() -> io::Result<Vec<(&'sta
|
||||
}
|
||||
}
|
||||
}
|
||||
if !git_env_bool("GIT_CONFIG_NOSYSTEM")? {
|
||||
match std::env::var_os("GIT_CONFIG_SYSTEM") {
|
||||
if !git_env_bool(git, "GIT_CONFIG_NOSYSTEM")? {
|
||||
match git.config_environment_value("GIT_CONFIG_SYSTEM") {
|
||||
Some(path) if !path.is_empty() => {
|
||||
candidates.push(("GIT_CONFIG_SYSTEM", PathBuf::from(path)));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
#[cfg(windows)]
|
||||
if let Some(program_data) = std::env::var_os("PROGRAMDATA")
|
||||
if let Some(program_data) = git.config_environment_value("PROGRAMDATA")
|
||||
&& !program_data.is_empty()
|
||||
{
|
||||
candidates.push((
|
||||
@@ -322,24 +338,25 @@ pub(super) fn legacy_primary_config_source_candidates() -> io::Result<Vec<(&'sta
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn git_home_directories() -> Vec<std::ffi::OsString> {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return vec![home];
|
||||
fn git_home_directories(git: &GitRunner) -> Vec<std::ffi::OsString> {
|
||||
if let Some(home) = git.config_environment_value("HOME") {
|
||||
return vec![home.to_owned()];
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut homes = Vec::new();
|
||||
if let (Some(drive), Some(path)) =
|
||||
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
{
|
||||
let mut home = drive;
|
||||
if let (Some(drive), Some(path)) = (
|
||||
git.config_environment_value("HOMEDRIVE"),
|
||||
git.config_environment_value("HOMEPATH"),
|
||||
) {
|
||||
let mut home = drive.to_owned();
|
||||
home.push(path);
|
||||
homes.push(home);
|
||||
}
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE")
|
||||
&& !homes.contains(&profile)
|
||||
if let Some(profile) = git.config_environment_value("USERPROFILE")
|
||||
&& !homes.iter().any(|home| home == profile)
|
||||
{
|
||||
homes.push(profile);
|
||||
homes.push(profile.to_owned());
|
||||
}
|
||||
homes
|
||||
}
|
||||
@@ -355,8 +372,8 @@ fn home_config_path(home: &std::ffi::OsStr, suffix: &str) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_env_bool(name: &str) -> io::Result<bool> {
|
||||
let Some(value) = std::env::var_os(name) else {
|
||||
fn git_env_bool(git: &GitRunner, name: &str) -> io::Result<bool> {
|
||||
let Some(value) = git.config_environment_value(name) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let value = value
|
||||
|
||||
@@ -811,6 +811,33 @@ fn command_scoped_include_paths_follow_the_same_boundary() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_and_async_source_authorization_share_the_same_include_policy() {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let git = GitRunner::for_cwd_io(root).expect("Git runner");
|
||||
|
||||
ensure_no_worktree_config_sources(&git, root, &[]).expect("blocking safe source graph");
|
||||
ensure_no_worktree_config_sources_async(&git, root, &[])
|
||||
.await
|
||||
.expect("async safe source graph");
|
||||
|
||||
let worktree_include = root.join("attacker.gitconfig");
|
||||
std::fs::write(&worktree_include, "[codex]\n\tvalue = attacker\n").expect("worktree include");
|
||||
add_include(
|
||||
root,
|
||||
"include.path",
|
||||
worktree_include.to_str().expect("UTF-8 fixture path"),
|
||||
);
|
||||
let blocking = ensure_no_worktree_config_sources(&git, root, &[])
|
||||
.expect_err("blocking authorization must reject worktree include");
|
||||
let asynchronous = ensure_no_worktree_config_sources_async(&git, root, &[])
|
||||
.await
|
||||
.expect_err("async authorization must reject worktree include");
|
||||
assert_eq!(blocking.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(asynchronous.kind(), blocking.kind());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_every_duplicate_and_nested_include_target() {
|
||||
for unsafe_first in [true, false] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::BoundSubcommand;
|
||||
use super::CapabilityIdentity;
|
||||
use super::GuardedGitConfig;
|
||||
use crate::git_config::GitConfigEntry;
|
||||
|
||||
const MERGE_CONFIG_PATTERN: &str = r"^(merge\.default|merge\..*\.driver)$";
|
||||
|
||||
/// A complete, fresh merge-config read bound to one authorized operation.
|
||||
///
|
||||
/// The fields are private so callers cannot mint a partial driver inventory
|
||||
/// and then ask the capability to treat its neutralizer as complete.
|
||||
struct MergeConfigSnapshot {
|
||||
owner: Arc<CapabilityIdentity>,
|
||||
entries: BTreeMap<String, GitConfigEntry>,
|
||||
}
|
||||
|
||||
impl MergeConfigSnapshot {
|
||||
fn entries(&self) -> &BTreeMap<String, GitConfigEntry> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
fn ensure_owner(&self, owner: &Arc<CapabilityIdentity>) -> io::Result<()> {
|
||||
if !Arc::ptr_eq(&self.owner, owner) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"merge config snapshot belongs to another operation",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nonempty_driver_keys(&self) -> io::Result<Vec<&str>> {
|
||||
let mut keys = Vec::new();
|
||||
for entry in self.entries.values() {
|
||||
if entry.key == "merge.default" {
|
||||
continue;
|
||||
}
|
||||
let _driver = entry
|
||||
.key
|
||||
.strip_prefix("merge.")
|
||||
.and_then(|key| key.strip_suffix(".driver"))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"malformed effective Git merge-driver key",
|
||||
)
|
||||
})?;
|
||||
if !entry.value.is_empty() {
|
||||
keys.push(entry.key.as_str());
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sealed merge-driver-only include bound to one operation.
|
||||
///
|
||||
/// Construction accepts only a complete `MergeConfigSnapshot`, and the type
|
||||
/// exposes neither raw keys nor its include argument outside this module.
|
||||
pub(super) struct SealedMergeConfigOverride {
|
||||
owner: Arc<CapabilityIdentity>,
|
||||
include_arg: String,
|
||||
_config_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl SealedMergeConfigOverride {
|
||||
pub(super) fn append_rendered_args(
|
||||
&self,
|
||||
owner: &Arc<CapabilityIdentity>,
|
||||
args: &mut Vec<String>,
|
||||
) -> io::Result<()> {
|
||||
self.ensure_owner(owner)?;
|
||||
args.push("-c".to_string());
|
||||
args.push(self.include_arg.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_owner(&self, owner: &Arc<CapabilityIdentity>) -> io::Result<()> {
|
||||
if !Arc::ptr_eq(&self.owner, owner) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"sealed Git merge override belongs to another operation",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn include_arg(&self) -> &str {
|
||||
&self.include_arg
|
||||
}
|
||||
}
|
||||
|
||||
impl<'git> GuardedGitConfig<'git> {
|
||||
/// Install the complete fallback merge policy in one non-bypassable step.
|
||||
/// This is the only crate-visible merge-overlay API: callers cannot mark
|
||||
/// the policy complete without the fixed fresh config and attribute reads.
|
||||
pub(crate) fn install_three_way_merge_policy(&mut self) -> io::Result<()> {
|
||||
let paths = self.apply_filter_paths()?;
|
||||
let snapshot = self.read_merge_config_snapshot()?;
|
||||
let input = merge_attribute_input(&paths)?;
|
||||
let output = self.query_merge_attributes(&snapshot, input)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git merge attribute probe failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let attributes = crate::merge_driver::parse_merge_attributes(&output.stdout, &paths)?;
|
||||
if let Some((driver, path)) =
|
||||
crate::merge_driver::untrusted_driver_selection(snapshot.entries(), &attributes)?
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing to run an internal Git three-way apply with merge driver {driver:?} selected for {path:?}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let neutralizer = self.build_merge_override(&snapshot)?;
|
||||
self.attach_merge_override(neutralizer)
|
||||
}
|
||||
|
||||
/// Read merge-driver policy from the frozen, authorized base invocation.
|
||||
/// Attached neutralizers are deliberately excluded so this is a fresh
|
||||
/// view of the user's effective policy at fallback time.
|
||||
fn read_merge_config_snapshot(&self) -> io::Result<MergeConfigSnapshot> {
|
||||
let _ = self.apply_filter_paths()?;
|
||||
if self.merge_policy_installed {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"merge policy may be read only before its neutralizer is attached",
|
||||
));
|
||||
}
|
||||
#[cfg(test)]
|
||||
MERGE_CONFIG_READ_COUNT.with(|count| count.set(count.get() + 1));
|
||||
Ok(MergeConfigSnapshot {
|
||||
owner: Arc::clone(&self.identity),
|
||||
entries: self.sources.read_effective(MERGE_CONFIG_PATTERN, "merge")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the one fixed fresh merge-attribute query while the merge
|
||||
/// neutralizer is still absent. Existing apply-filter policy remains
|
||||
/// attached, and callers cannot change the framing or attribute name.
|
||||
fn query_merge_attributes(
|
||||
&self,
|
||||
snapshot: &MergeConfigSnapshot,
|
||||
input: std::fs::File,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let _ = self.apply_filter_paths()?;
|
||||
snapshot.ensure_owner(&self.identity)?;
|
||||
if self.merge_policy_installed {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"merge attributes must be read before attaching the merge neutralizer",
|
||||
));
|
||||
}
|
||||
#[cfg(test)]
|
||||
MERGE_ATTRIBUTE_READ_COUNT.with(|count| count.set(count.get() + 1));
|
||||
let mut command = self.command_with_attached_overlays()?;
|
||||
BoundSubcommand::CheckAttr.append_to(&mut command);
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(["--stdin", "-z", "merge"])
|
||||
.stdin(Stdio::from(input));
|
||||
self.sources.git.output(command)
|
||||
}
|
||||
|
||||
fn build_merge_override(
|
||||
&self,
|
||||
snapshot: &MergeConfigSnapshot,
|
||||
) -> io::Result<Option<SealedMergeConfigOverride>> {
|
||||
snapshot.ensure_owner(&self.identity)?;
|
||||
if self.merge_policy_installed {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"a merge neutralizer is already attached",
|
||||
));
|
||||
}
|
||||
let driver_keys = snapshot.nonempty_driver_keys()?;
|
||||
if driver_keys.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let config_dir = tempfile::tempdir()?;
|
||||
let config_path = config_dir
|
||||
.path()
|
||||
.join("merge-driver-neutralization.gitconfig");
|
||||
self.ensure_owned_config_path(&config_path, "owned Git merge-driver neutralization")?;
|
||||
std::fs::write(&config_path, [])?;
|
||||
for key in driver_keys {
|
||||
self.write_merge_override_value(&config_path, key)?;
|
||||
}
|
||||
let config_path = config_path.to_str().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 merge guard path")
|
||||
})?;
|
||||
#[cfg(test)]
|
||||
MERGE_OVERLAY_COUNT.with(|count| count.set(count.get() + 1));
|
||||
Ok(Some(SealedMergeConfigOverride {
|
||||
owner: Arc::clone(&self.identity),
|
||||
include_arg: format!("include.path={config_path}"),
|
||||
_config_dir: config_dir,
|
||||
}))
|
||||
}
|
||||
|
||||
fn attach_merge_override(
|
||||
&mut self,
|
||||
neutralizer: Option<SealedMergeConfigOverride>,
|
||||
) -> io::Result<()> {
|
||||
if self.merge_policy_installed {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"a second merge neutralizer is not permitted",
|
||||
));
|
||||
}
|
||||
let [apply] = self.filters.as_slice() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"merge policy requires exactly one apply filter snapshot",
|
||||
));
|
||||
};
|
||||
if apply.role() != crate::safe_git::FilterPolicyRole::Apply {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"merge policy requires an apply filter snapshot",
|
||||
));
|
||||
}
|
||||
if let Some(neutralizer) = &neutralizer {
|
||||
neutralizer.ensure_owner(&self.identity)?;
|
||||
}
|
||||
self.merge = neutralizer;
|
||||
self.merge_policy_installed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_merge_override_value(&self, config_path: &Path, key: &str) -> io::Result<()> {
|
||||
let mut command = self
|
||||
.sources
|
||||
.git
|
||||
.command_for_cwd(&self.sources.canonical_root)?;
|
||||
command
|
||||
.args(&self.sources.base_config_args)
|
||||
.args(["config", "--file"])
|
||||
.arg(config_path)
|
||||
.args(["--add", key, ""]);
|
||||
let output = self.sources.git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to write Git merge-driver neutralization for {key:?} (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_attribute_input(paths: &[String]) -> io::Result<std::fs::File> {
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
|
||||
if paths.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"refusing to inspect merge attributes for an empty patch path set",
|
||||
));
|
||||
}
|
||||
let mut input = tempfile::tempfile()?;
|
||||
for path in paths {
|
||||
if path.as_bytes().contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"merge attribute path contains NUL",
|
||||
));
|
||||
}
|
||||
input.write_all(path.as_bytes())?;
|
||||
input.write_all(&[0])?;
|
||||
}
|
||||
input.rewind()?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static MERGE_CONFIG_READ_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static MERGE_ATTRIBUTE_READ_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static MERGE_OVERLAY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_merge_policy_counts() {
|
||||
MERGE_CONFIG_READ_COUNT.with(|count| count.set(0));
|
||||
MERGE_ATTRIBUTE_READ_COUNT.with(|count| count.set(0));
|
||||
MERGE_OVERLAY_COUNT.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn merge_config_read_count() -> usize {
|
||||
MERGE_CONFIG_READ_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn merge_attribute_read_count() -> usize {
|
||||
MERGE_ATTRIBUTE_READ_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn merge_overlay_count() -> usize {
|
||||
MERGE_OVERLAY_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "merge_overlay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::*;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let mut command = std::process::Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command.current_dir(cwd).args(args).output().expect("Git");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_merge_override_rejects_another_operation_identity() {
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
run_git(repo.path(), &["config", "merge.unused.driver", "false"]);
|
||||
let first_git = GitRunner::for_cwd_io(repo.path()).expect("first runner");
|
||||
let second_git = GitRunner::for_cwd_io(repo.path()).expect("second runner");
|
||||
let mut first =
|
||||
GuardedGitConfig::authorize(&first_git, repo.path(), Vec::new()).expect("first config");
|
||||
let mut second =
|
||||
GuardedGitConfig::authorize(&second_git, repo.path(), Vec::new()).expect("second config");
|
||||
first
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("first apply snapshot");
|
||||
second
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("second apply snapshot");
|
||||
|
||||
let snapshot = first.read_merge_config_snapshot().expect("merge snapshot");
|
||||
let neutralizer = first
|
||||
.build_merge_override(&snapshot)
|
||||
.expect("build merge override")
|
||||
.expect("physical merge override");
|
||||
let error = second
|
||||
.attach_merge_override(Some(neutralizer))
|
||||
.expect_err("cross-operation override must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::io;
|
||||
|
||||
use super::GuardedGitConfig;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum IntentToAddView {
|
||||
Invisible,
|
||||
Visible,
|
||||
}
|
||||
|
||||
impl<'git> GuardedGitConfig<'git> {
|
||||
pub(crate) fn reverse_index_stage_output(
|
||||
&self,
|
||||
paths: &[String],
|
||||
) -> io::Result<std::process::Output> {
|
||||
let mut command = self.command_with_attached_overlays()?;
|
||||
command.args([
|
||||
"--literal-pathspecs",
|
||||
"ls-files",
|
||||
"-v",
|
||||
"--stage",
|
||||
"-z",
|
||||
"--",
|
||||
]);
|
||||
command.args(paths);
|
||||
self.sources.git.output(command)
|
||||
}
|
||||
|
||||
pub(crate) fn reverse_cached_name_status_invisible_output(
|
||||
&self,
|
||||
paths: &[String],
|
||||
) -> io::Result<std::process::Output> {
|
||||
self.reverse_cached_name_status_output(paths, IntentToAddView::Invisible)
|
||||
}
|
||||
|
||||
pub(crate) fn reverse_cached_name_status_visible_output(
|
||||
&self,
|
||||
paths: &[String],
|
||||
) -> io::Result<std::process::Output> {
|
||||
self.reverse_cached_name_status_output(paths, IntentToAddView::Visible)
|
||||
}
|
||||
|
||||
fn reverse_cached_name_status_output(
|
||||
&self,
|
||||
paths: &[String],
|
||||
view: IntentToAddView,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let intent_to_add = match view {
|
||||
IntentToAddView::Invisible => "--ita-invisible-in-index",
|
||||
IntentToAddView::Visible => "--ita-visible-in-index",
|
||||
};
|
||||
let mut command = self.command_with_attached_overlays()?;
|
||||
command.args([
|
||||
"--literal-pathspecs",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
intent_to_add,
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--ignore-submodules=none",
|
||||
"--",
|
||||
]);
|
||||
command.args(paths);
|
||||
self.sources.git.output(command)
|
||||
}
|
||||
|
||||
pub(crate) fn reverse_worktree_changed_paths_output(
|
||||
&self,
|
||||
paths: &[String],
|
||||
) -> io::Result<std::process::Output> {
|
||||
let mut command = self.command_with_attached_overlays()?;
|
||||
#[cfg(unix)]
|
||||
command.args(["-c", "core.filemode=true"]);
|
||||
command.args([
|
||||
"--literal-pathspecs",
|
||||
"diff-files",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--ignore-submodules=none",
|
||||
"--",
|
||||
]);
|
||||
command.args(paths);
|
||||
self.sources.git.output(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
use super::*;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) {
|
||||
let mut command = std::process::Command::new("git");
|
||||
isolate_git_command_environment(&mut command);
|
||||
let output = command.current_dir(cwd).args(args).output().expect("Git");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn run_isolated_config_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");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
|
||||
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_GUARDED_CONFIG_ENV_CHILD", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", &global_config)
|
||||
.env("GIT_CONFIG_SYSTEM", &system_config)
|
||||
.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)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_filter_snapshots_retain_multiple_owned_overrides() {
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
std::fs::write(repo.path().join("second.txt"), "second\n").expect("write sink input");
|
||||
run_git(
|
||||
repo.path(),
|
||||
&["config", "filter.demo.clean", "git hash-object --stdin"],
|
||||
);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("authorized config");
|
||||
|
||||
config
|
||||
.authorize_filter_paths(&["first.txt".to_string()])
|
||||
.expect("first filter snapshot");
|
||||
config
|
||||
.authorize_git_add_filter_paths(&["second.txt".to_string()])
|
||||
.expect("second filter snapshot");
|
||||
|
||||
assert_eq!(config.filters.len(), 2);
|
||||
assert!(
|
||||
config
|
||||
.filters
|
||||
.iter()
|
||||
.all(|filter| filter.neutralizer().is_some())
|
||||
);
|
||||
|
||||
let apply_include = config.filters[0]
|
||||
.neutralizer()
|
||||
.expect("apply overlay")
|
||||
.include_arg
|
||||
.clone();
|
||||
let staging_include = config.filters[1]
|
||||
.neutralizer()
|
||||
.expect("staging overlay")
|
||||
.include_arg
|
||||
.clone();
|
||||
assert_ne!(apply_include, staging_include);
|
||||
let apply_path = PathBuf::from(
|
||||
apply_include
|
||||
.strip_prefix("include.path=")
|
||||
.expect("apply include path"),
|
||||
);
|
||||
let staging_path = PathBuf::from(
|
||||
staging_include
|
||||
.strip_prefix("include.path=")
|
||||
.expect("staging include path"),
|
||||
);
|
||||
assert!(apply_path.is_file());
|
||||
assert!(staging_path.is_file());
|
||||
|
||||
let rendered = config
|
||||
.render_command_for_log(&["update-index".to_string()])
|
||||
.expect("render ordered overlays");
|
||||
assert!(
|
||||
rendered.find(&apply_include).expect("render apply overlay")
|
||||
< rendered
|
||||
.find(&staging_include)
|
||||
.expect("render staging overlay")
|
||||
);
|
||||
|
||||
let mut sink = config
|
||||
.update_index_literal_pathspecs_command()
|
||||
.expect("bound sink");
|
||||
sink.disable_optional_locks()
|
||||
.args(["--add", "--remove", "--", "second.txt"]);
|
||||
let output = sink.output().expect("run sink");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(apply_path.is_file(), "apply overlay must outlive the sink");
|
||||
assert!(
|
||||
staging_path.is_file(),
|
||||
"staging overlay must outlive the sink"
|
||||
);
|
||||
|
||||
drop(config);
|
||||
assert!(!apply_path.exists());
|
||||
assert!(!staging_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_staging_subset_proof_requires_exactly_one_apply_snapshot() {
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("authorized config");
|
||||
|
||||
let error = config
|
||||
.ensure_apply_filter_path_subset(&[])
|
||||
.expect_err("zero snapshots must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
config
|
||||
.authorize_filter_paths(&["allowed.txt".to_string()])
|
||||
.expect("apply snapshot");
|
||||
config
|
||||
.ensure_apply_filter_path_subset(&["allowed.txt".to_string()])
|
||||
.expect("authorized subset");
|
||||
let error = config
|
||||
.ensure_apply_filter_path_subset(&["outside.txt".to_string()])
|
||||
.expect_err("off-universe path must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
config
|
||||
.authorize_git_add_filter_paths(&[])
|
||||
.expect("Git-add snapshot");
|
||||
let error = config
|
||||
.ensure_apply_filter_path_subset(&[])
|
||||
.expect_err("two snapshots must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_driver_merge_policy_still_occupies_the_single_install_slot() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_GUARDED_CONFIG_ENV_CHILD").is_none() {
|
||||
run_isolated_config_test(
|
||||
"guarded_config::tests::zero_driver_merge_policy_still_occupies_the_single_install_slot",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("authorized config");
|
||||
|
||||
let error = config
|
||||
.install_three_way_merge_policy()
|
||||
.expect_err("merge policy without apply snapshot must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("apply snapshot");
|
||||
config
|
||||
.install_three_way_merge_policy()
|
||||
.expect("zero-driver merge policy");
|
||||
assert!(config.merge_policy_installed);
|
||||
assert!(config.merge.is_none());
|
||||
|
||||
let error = config
|
||||
.install_three_way_merge_policy()
|
||||
.expect_err("second merge policy must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_order_is_apply_filter_then_merge_then_git_add_filter() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_GUARDED_CONFIG_ENV_CHILD").is_none() {
|
||||
run_isolated_config_test(
|
||||
"guarded_config::tests::command_order_is_apply_filter_then_merge_then_git_add_filter",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
std::fs::write(repo.path().join("file.txt"), "file\n").expect("write file");
|
||||
run_git(
|
||||
repo.path(),
|
||||
&["config", "filter.demo.clean", "git hash-object --stdin"],
|
||||
);
|
||||
run_git(repo.path(), &["config", "merge.unused.driver", "false"]);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("authorized config");
|
||||
|
||||
config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("apply filter policy");
|
||||
config
|
||||
.install_three_way_merge_policy()
|
||||
.expect("merge policy");
|
||||
config
|
||||
.authorize_git_add_filter_paths(&["file.txt".to_string()])
|
||||
.expect("Git-add filter policy");
|
||||
|
||||
let apply_include = config.filters[0]
|
||||
.neutralizer()
|
||||
.expect("apply filter overlay")
|
||||
.include_arg
|
||||
.clone();
|
||||
let merge_include = config
|
||||
.merge_include_arg()
|
||||
.expect("merge overlay")
|
||||
.to_string();
|
||||
let git_add_include = config.filters[1]
|
||||
.neutralizer()
|
||||
.expect("Git-add filter overlay")
|
||||
.include_arg
|
||||
.clone();
|
||||
let overlay_paths = [&apply_include, &merge_include, &git_add_include]
|
||||
.map(|include| PathBuf::from(include.strip_prefix("include.path=").expect("include path")));
|
||||
|
||||
let rendered = config
|
||||
.render_command_for_log(&["ls-files".to_string()])
|
||||
.expect("render command");
|
||||
let apply_offset = rendered.find(&apply_include).expect("apply include");
|
||||
let merge_offset = rendered.find(&merge_include).expect("merge include");
|
||||
let git_add_offset = rendered.find(&git_add_include).expect("Git-add include");
|
||||
assert!(apply_offset < merge_offset && merge_offset < git_add_offset);
|
||||
assert_eq!(rendered.matches(&merge_include).count(), 1);
|
||||
|
||||
let output = config
|
||||
.ls_files_command()
|
||||
.expect("bound command")
|
||||
.output()
|
||||
.expect("execute bound command");
|
||||
assert!(output.status.success());
|
||||
assert!(overlay_paths.iter().all(|path| path.is_file()));
|
||||
|
||||
drop(config);
|
||||
assert!(overlay_paths.iter().all(|path| !path.exists()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_builtin_query_is_a_fixed_no_argument_operation() {
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let config =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("authorized config");
|
||||
|
||||
// The capability exposes only this no-argument execution method; callers
|
||||
// never receive the global-only command and therefore cannot append a
|
||||
// second global option or an external command name.
|
||||
let output = config
|
||||
.list_builtin_commands()
|
||||
.expect("fixed builtin-command query");
|
||||
assert!(output.status.success());
|
||||
let output = std::str::from_utf8(&output.stdout).expect("UTF-8 builtin names");
|
||||
assert!(output.lines().any(|name| name == "add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_rejects_untyped_global_config_arguments() {
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
|
||||
for args in [
|
||||
vec!["--config-env=include.path=UNSNAPSHOTTED".to_string()],
|
||||
vec!["--git-dir=elsewhere".to_string()],
|
||||
vec!["-c".to_string(), "missing-value".to_string()],
|
||||
vec!["-c".to_string()],
|
||||
] {
|
||||
let error = match GuardedGitConfig::authorize(&git, repo.path(), args) {
|
||||
Ok(_) => panic!("accepted untyped base config arguments"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_rejects_another_runner_repository_and_nested_or_prefix_roots() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("repo");
|
||||
let nested = outer.join("nested");
|
||||
let prefix = fixture.path().join("repo-evil");
|
||||
std::fs::create_dir_all(&nested).expect("nested");
|
||||
std::fs::create_dir_all(&prefix).expect("prefix");
|
||||
run_git(&outer, &["init", "-q"]);
|
||||
run_git(&nested, &["init", "-q"]);
|
||||
run_git(&prefix, &["init", "-q"]);
|
||||
let git = GitRunner::for_cwd_io(&outer).expect("outer runner");
|
||||
|
||||
for wrong_root in [&nested, &prefix] {
|
||||
let error = match GuardedGitConfig::authorize(&git, wrong_root, Vec::new()) {
|
||||
Ok(_) => panic!("accepted another repository root"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_filter_override_cannot_cross_operation_capabilities() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let first_root = fixture.path().join("first");
|
||||
let second_root = fixture.path().join("second");
|
||||
std::fs::create_dir_all(&first_root).expect("first");
|
||||
std::fs::create_dir_all(&second_root).expect("second");
|
||||
run_git(&first_root, &["init", "-q"]);
|
||||
run_git(&second_root, &["init", "-q"]);
|
||||
run_git(
|
||||
&first_root,
|
||||
&["config", "filter.demo.clean", "git hash-object --stdin"],
|
||||
);
|
||||
let first_git = GitRunner::for_cwd_io(&first_root).expect("first runner");
|
||||
let second_git = GitRunner::for_cwd_io(&second_root).expect("second runner");
|
||||
let mut first =
|
||||
GuardedGitConfig::authorize(&first_git, &first_root, Vec::new()).expect("first capability");
|
||||
let second = GuardedGitConfig::authorize(&second_git, &second_root, Vec::new())
|
||||
.expect("second capability");
|
||||
first
|
||||
.authorize_filter_paths(&["unselected.txt".to_string()])
|
||||
.expect("first filter policy");
|
||||
let sealed = first.filters[0].neutralizer().expect("first override");
|
||||
|
||||
let error =
|
||||
match second.command_for_sentinel_filter_probe(sealed, "set", /*required*/ true) {
|
||||
Ok(_) => panic!("accepted another operation's sealed override"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_driver_status_occupies_its_slot_and_excludes_every_mutation_policy() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_GUARDED_CONFIG_ENV_CHILD").is_none() {
|
||||
run_isolated_config_test(
|
||||
"guarded_config::tests::zero_driver_status_occupies_its_slot_and_excludes_every_mutation_policy",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
reset_config_source_authorization_count();
|
||||
crate::git_command::reset_git_runner_construction_count();
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config = GuardedGitConfig::authorize_status_async(&git)
|
||||
.await
|
||||
.expect("authorized status capability");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(crate::git_command::git_runner_construction_count(), 1);
|
||||
config
|
||||
.verify_status_root_async(repo.path())
|
||||
.await
|
||||
.expect("matching selected-Git root");
|
||||
let error = config
|
||||
.status_output_async()
|
||||
.await
|
||||
.expect_err("status sink requires an installed policy");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
config
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.expect("zero-driver status policy");
|
||||
assert_eq!(status_filter_policy_read_count(), 1);
|
||||
let status = config.status.as_ref().expect("occupied status slot");
|
||||
assert!(status.neutralizer.is_none());
|
||||
assert_eq!(status.fsmonitor, None);
|
||||
|
||||
let error = config
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.expect_err("second status installation must fail");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(status_filter_policy_read_count(), 1);
|
||||
let error = config
|
||||
.authorize_filter_paths(&[])
|
||||
.expect_err("status plus Apply must fail");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
let error = config
|
||||
.authorize_git_add_filter_paths(&[])
|
||||
.expect_err("status plus GitAdd must fail");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
let error = config
|
||||
.install_three_way_merge_policy()
|
||||
.expect_err("status plus merge must fail");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
let error = config
|
||||
.status_output_async()
|
||||
.await
|
||||
.expect_err("status sink requires a retained fsmonitor decision");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
let first = config.detect_status_fsmonitor_async().await;
|
||||
let second = config.detect_status_fsmonitor_async().await;
|
||||
assert_eq!(first, second);
|
||||
let output = config
|
||||
.status_output_async()
|
||||
.await
|
||||
.expect("closed status sink");
|
||||
assert!(output.status.success());
|
||||
|
||||
let mut mutation =
|
||||
GuardedGitConfig::authorize(&git, repo.path(), Vec::new()).expect("mutation capability");
|
||||
mutation
|
||||
.authorize_filter_paths(&[])
|
||||
.expect("Apply filter policy");
|
||||
let reads_before = status_filter_policy_read_count();
|
||||
let error = mutation
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.expect_err("Apply plus status must fail before a status read");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(status_filter_policy_read_count(), reads_before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_uses_the_shared_four_entry_overlay_and_retains_it_through_the_sink() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_GUARDED_CONFIG_ENV_CHILD").is_none() {
|
||||
run_isolated_config_test(
|
||||
"guarded_config::tests::status_uses_the_shared_four_entry_overlay_and_retains_it_through_the_sink",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let repo = tempfile::tempdir().expect("repo");
|
||||
run_git(repo.path(), &["init", "-q"]);
|
||||
std::fs::write(repo.path().join("file.txt"), "contents\n").expect("tracked file");
|
||||
std::fs::write(repo.path().join(".gitattributes"), "file.txt filter=demo\n")
|
||||
.expect("attributes");
|
||||
run_git(repo.path(), &["add", "."]);
|
||||
run_git(repo.path(), &["config", "filter.demo.smudge", "cat"]);
|
||||
|
||||
let git = GitRunner::for_cwd_io(repo.path()).expect("runner");
|
||||
let mut config = GuardedGitConfig::authorize_status_async(&git)
|
||||
.await
|
||||
.expect("authorized status capability");
|
||||
config
|
||||
.verify_status_root_async(repo.path())
|
||||
.await
|
||||
.expect("matching root");
|
||||
config
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.expect("optional smudge policy");
|
||||
let neutralizer = config
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|status| status.neutralizer.as_ref())
|
||||
.expect("status neutralizer");
|
||||
let overlay = PathBuf::from(
|
||||
neutralizer
|
||||
.include_arg
|
||||
.strip_prefix("include.path=")
|
||||
.expect("sealed include path"),
|
||||
);
|
||||
assert!(overlay.is_file());
|
||||
let mut actual = Vec::new();
|
||||
for (name, expected) in FILTER_NEUTRALIZATION_PLAN {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["config", "--file"])
|
||||
.arg(&overlay)
|
||||
.args(["--get", &format!("filter.demo.{name}")])
|
||||
.output()
|
||||
.expect("read neutralization entry");
|
||||
assert!(output.status.success(), "read {name}");
|
||||
actual.push((
|
||||
name,
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||
));
|
||||
assert_eq!(actual.last().map(|entry| entry.1.as_str()), Some(expected));
|
||||
}
|
||||
assert_eq!(actual.len(), FILTER_NEUTRALIZATION_PLAN.len());
|
||||
|
||||
config.detect_status_fsmonitor_async().await;
|
||||
let output = config.status_output_async().await.expect("guarded status");
|
||||
assert!(output.status.success());
|
||||
assert!(overlay.is_file(), "overlay must outlive the final child");
|
||||
drop(config);
|
||||
assert!(
|
||||
!overlay.exists(),
|
||||
"overlay must be removed with the capability"
|
||||
);
|
||||
}
|
||||
+45
-135
@@ -15,17 +15,18 @@ use futures::future::join_all;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::GitReadError;
|
||||
use crate::GitSha;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_command::MAX_INTERNAL_GIT_OUTPUT_BYTES;
|
||||
use crate::repository_authority::is_authority_refusal;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
use crate::safe_git::GIT_COMMAND_TIMEOUT;
|
||||
use crate::status_guard::prepare_status_filter_guard_within_deadline;
|
||||
use crate::status_guard::detect_status_fsmonitor;
|
||||
use crate::status_guard::prepare_status_config;
|
||||
use crate::status_guard::read_status;
|
||||
|
||||
/// Return `true` if the project folder specified by the `Config` is inside a
|
||||
/// Git repository.
|
||||
@@ -345,29 +346,15 @@ async fn try_get_has_changes_within_deadline(
|
||||
cwd: &Path,
|
||||
phase: &AtomicU8,
|
||||
) -> Result<bool, GitReadError> {
|
||||
let git = GitRunner::for_cwd(cwd)?;
|
||||
let (git_root, filter_guard) =
|
||||
Box::pin(prepare_status_filter_guard_within_deadline(&git, cwd)).await?;
|
||||
let requested_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?;
|
||||
let git = GitRunner::for_cwd(&requested_cwd)?;
|
||||
let mut config = Box::pin(prepare_status_config(&git, &requested_cwd)).await?;
|
||||
phase.store(StatusReadPhase::Fsmonitor as u8, Ordering::Relaxed);
|
||||
let fsmonitor = detect_local_fsmonitor_override(&git, &git_root).await;
|
||||
detect_status_fsmonitor(&mut config).await;
|
||||
phase.store(StatusReadPhase::Status as u8, Ordering::Relaxed);
|
||||
let output = try_run_git_command_with_timeout_from(
|
||||
&git,
|
||||
&["status", "--porcelain", "--ignore-submodules=dirty"],
|
||||
&git_root,
|
||||
fsmonitor,
|
||||
filter_guard.git_config_args(),
|
||||
"status",
|
||||
)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(GitReadError::CommandFailed {
|
||||
operation: "status".to_string(),
|
||||
exit_code: output.status.code(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(!output.stdout.is_empty())
|
||||
read_status(&config).await
|
||||
}
|
||||
|
||||
fn parse_git_remote_urls(stdout: &str) -> Option<BTreeMap<String, String>> {
|
||||
@@ -475,12 +462,17 @@ pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
||||
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
// These callers only inspect repository metadata. Worktree workflows probe
|
||||
// once and pass their override directly to the lower-level runner.
|
||||
let git = GitRunner::for_cwd(cwd).ok()?;
|
||||
run_git_command_with_timeout_from(&git, args, cwd, crate::FsmonitorOverride::Disabled).await
|
||||
run_git_command_with_timeout_from(
|
||||
Path::new("git"),
|
||||
args,
|
||||
cwd,
|
||||
crate::FsmonitorOverride::Disabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
struct LocalFsmonitorProbeRunner<'a> {
|
||||
git: &'a GitRunner,
|
||||
git: &'a Path,
|
||||
cwd: &'a Path,
|
||||
}
|
||||
|
||||
@@ -488,94 +480,44 @@ impl crate::FsmonitorProbeRunner for LocalFsmonitorProbeRunner<'_> {
|
||||
async fn run_probe(&mut self, args: &[&str]) -> Option<Vec<u8>> {
|
||||
// Both probes are fast, bounded metadata queries that do not inspect the
|
||||
// worktree or index, so do not reduce the requested command's timeout.
|
||||
let mut command = self.git.async_command_for_cwd(self.cwd).ok()?;
|
||||
command.args(args);
|
||||
match timeout(
|
||||
GIT_COMMAND_TIMEOUT,
|
||||
self.git
|
||||
.output_async_bounded(command, MAX_INTERNAL_GIT_OUTPUT_BYTES),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mut command = Command::new(self.git);
|
||||
command
|
||||
.envs(crate::local_only_git_env())
|
||||
.args(args)
|
||||
.current_dir(self.cwd)
|
||||
.kill_on_drop(true);
|
||||
match timeout(GIT_COMMAND_TIMEOUT, command.output()).await {
|
||||
Ok(Ok(output)) if output.status.success() => Some(output.stdout),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn detect_local_fsmonitor_override(git: &GitRunner, cwd: &Path) -> crate::FsmonitorOverride {
|
||||
async fn detect_local_fsmonitor_override(git: &Path, cwd: &Path) -> crate::FsmonitorOverride {
|
||||
let mut runner = LocalFsmonitorProbeRunner { git, cwd };
|
||||
crate::detect_fsmonitor_override(&mut runner).await
|
||||
}
|
||||
|
||||
async fn run_git_command_with_timeout_from(
|
||||
git: &GitRunner,
|
||||
git: &Path,
|
||||
args: &[&str],
|
||||
cwd: &Path,
|
||||
fsmonitor: crate::FsmonitorOverride,
|
||||
) -> Option<std::process::Output> {
|
||||
try_run_git_command_with_timeout_from(git, args, cwd, fsmonitor, &[], "gitMetadata")
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn try_run_git_command_with_timeout_from(
|
||||
git: &GitRunner,
|
||||
args: &[&str],
|
||||
cwd: &Path,
|
||||
fsmonitor: crate::FsmonitorOverride,
|
||||
extra_git_config_args: &[String],
|
||||
operation: &str,
|
||||
) -> Result<std::process::Output, GitReadError> {
|
||||
let disabled_hooks = format!("core.hooksPath={DISABLED_HOOKS_PATH}");
|
||||
let mut command = git.async_command_for_cwd(cwd).map_err(|error| {
|
||||
if is_authority_refusal(&error) {
|
||||
return GitReadError::AuthorityRefused {
|
||||
operation: operation.to_string(),
|
||||
};
|
||||
}
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::InvalidData => GitReadError::InvalidOutput {
|
||||
operation: operation.to_string(),
|
||||
},
|
||||
_ => GitReadError::CommandFailed {
|
||||
operation: operation.to_string(),
|
||||
exit_code: None,
|
||||
},
|
||||
}
|
||||
})?;
|
||||
let mut command = Command::new(git);
|
||||
command
|
||||
.envs(crate::local_only_git_env())
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
// Keep internal Git commands independent of repository-selected hooks
|
||||
// while preserving built-in fsmonitor acceleration.
|
||||
.args(["-c", &disabled_hooks])
|
||||
// and fsmonitor helpers while preserving built-in fsmonitor acceleration.
|
||||
.args(["-c", &format!("core.hooksPath={DISABLED_HOOKS_PATH}")])
|
||||
.args(["-c", fsmonitor.git_config_arg()])
|
||||
// Keep the owning guard alive in the caller and append its private
|
||||
// include last so it wins inherited and repository filter config.
|
||||
.args(extra_git_config_args)
|
||||
.args(args);
|
||||
match timeout(
|
||||
GIT_COMMAND_TIMEOUT,
|
||||
git.output_async_bounded(command, MAX_INTERNAL_GIT_OUTPUT_BYTES),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_) => Err(GitReadError::CommandTimedOut {
|
||||
operation: operation.to_string(),
|
||||
}),
|
||||
Ok(Err(error)) if is_authority_refusal(&error) => Err(GitReadError::AuthorityRefused {
|
||||
operation: operation.to_string(),
|
||||
}),
|
||||
Ok(Err(error)) if error.kind() == std::io::ErrorKind::InvalidData => {
|
||||
Err(GitReadError::InvalidOutput {
|
||||
operation: operation.to_string(),
|
||||
})
|
||||
}
|
||||
Ok(Err(_)) => Err(GitReadError::CommandFailed {
|
||||
operation: operation.to_string(),
|
||||
exit_code: None,
|
||||
}),
|
||||
Ok(Ok(output)) => Ok(output),
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.kill_on_drop(true);
|
||||
match timeout(GIT_COMMAND_TIMEOUT, command.output()).await {
|
||||
Ok(Ok(output)) => Some(output),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,10 +798,10 @@ async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -
|
||||
}
|
||||
|
||||
async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
||||
let git = GitRunner::for_cwd(cwd).ok()?;
|
||||
let fsmonitor = detect_local_fsmonitor_override(&git, cwd).await;
|
||||
let git = Path::new("git");
|
||||
let fsmonitor = detect_local_fsmonitor_override(git, cwd).await;
|
||||
let output = run_git_command_with_timeout_from(
|
||||
&git,
|
||||
git,
|
||||
&["diff", "--no-textconv", "--no-ext-diff", &sha.0],
|
||||
cwd,
|
||||
fsmonitor,
|
||||
@@ -874,7 +816,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
||||
let mut diff = String::from_utf8(output.stdout).ok()?;
|
||||
|
||||
if let Some(untracked_output) = run_git_command_with_timeout_from(
|
||||
&git,
|
||||
git,
|
||||
&["ls-files", "--others", "--exclude-standard"],
|
||||
cwd,
|
||||
fsmonitor,
|
||||
@@ -892,7 +834,6 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
||||
if !untracked.is_empty() {
|
||||
// Use platform-appropriate null device and guard paths with `--`.
|
||||
let null_device: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||
let git = &git;
|
||||
let futures_iter = untracked.into_iter().map(|file| async move {
|
||||
let file_owned = file;
|
||||
let args_vec: Vec<&str> = vec![
|
||||
@@ -1052,25 +993,6 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolved_git_executable() -> PathBuf {
|
||||
let output = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "command -v git"])
|
||||
.output()
|
||||
.expect("resolve real Git executable");
|
||||
assert!(output.status.success(), "resolve real Git executable");
|
||||
PathBuf::from(
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("real Git path should be UTF-8")
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_quote_path(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn commit_all(cwd: &Path, message: &str) {
|
||||
run_git(
|
||||
@@ -1227,11 +1149,6 @@ mod tests {
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&git, permissions).expect("mark fake Git executable");
|
||||
|
||||
// The config response mirrors:
|
||||
// git -c core.fsmonitor=/tmp/fsmonitor-helper \
|
||||
// config --null --get core.fsmonitor
|
||||
let git = GitRunner::from_executable_for_test(temp_dir.path(), git)
|
||||
.expect("authority-bound fake Git");
|
||||
let fsmonitor = detect_local_fsmonitor_override(&git, temp_dir.path()).await;
|
||||
let output = run_git_command_with_timeout_from(
|
||||
&git,
|
||||
@@ -1278,22 +1195,17 @@ mod tests {
|
||||
let git = temp_dir.path().join("git");
|
||||
let global_config = temp_dir.path().join("git.global");
|
||||
let log = temp_dir.path().join("git.log");
|
||||
let real_git = shell_quote_path(&resolved_git_executable());
|
||||
std::fs::write(
|
||||
&git,
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
cwd=.\n\
|
||||
if [ \"$1\" = -C ]; then cwd=$2; shift 2; fi\n\
|
||||
"#!/bin/sh\n\
|
||||
printf '%s\\n' \"$*\" >>\"$0.log\"\n\
|
||||
case \"$1\" in\n\
|
||||
config)\n\
|
||||
GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=\"$0.global\" exec {real_git} -C \"$cwd\" \"$@\"\n\
|
||||
GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=\"$0.global\" exec git \"$@\"\n\
|
||||
;;\n\
|
||||
version) printf 'feature: fsmonitor--daemon\\n' ;;\n\
|
||||
*) printf 'worktree output\\n' ;;\n\
|
||||
esac\n"
|
||||
),
|
||||
esac\n",
|
||||
)
|
||||
.expect("write layered-config Git");
|
||||
let mut permissions = std::fs::metadata(&git)
|
||||
@@ -1328,8 +1240,6 @@ mod tests {
|
||||
"write local built-in fsmonitor config"
|
||||
);
|
||||
|
||||
let git =
|
||||
GitRunner::from_executable_for_test(&repo, git).expect("authority-bound layered Git");
|
||||
let fsmonitor = detect_local_fsmonitor_override(&git, repo.as_path()).await;
|
||||
let output = run_git_command_with_timeout_from(
|
||||
&git,
|
||||
|
||||
@@ -9,8 +9,10 @@ mod exact_staging;
|
||||
mod fsmonitor;
|
||||
mod git_command;
|
||||
mod git_config;
|
||||
mod git_config_environment;
|
||||
mod git_config_sources;
|
||||
mod git_executable;
|
||||
mod guarded_config;
|
||||
mod info;
|
||||
mod local_only;
|
||||
mod merge_driver;
|
||||
|
||||
@@ -1,104 +1,11 @@
|
||||
use crate::git_config::GitConfigEntry;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use std::collections::BTreeMap;
|
||||
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::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;
|
||||
|
||||
const MERGE_CONFIG_PATTERN: &str = r"^(merge\.default|merge\..*\.driver)$";
|
||||
|
||||
pub(crate) fn ensure_no_selected_merge_drivers(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> 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)? {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing to run an internal Git three-way apply with merge driver {driver:?} selected for {path:?}"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
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(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
read_effective_config_with_fallback(git, cwd, git_config_args, MERGE_CONFIG_PATTERN, "merge")
|
||||
}
|
||||
|
||||
fn read_merge_attributes(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, String>> {
|
||||
if paths.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"refusing to inspect merge attributes for an empty patch path set",
|
||||
));
|
||||
}
|
||||
let mut input = tempfile::tempfile()?;
|
||||
for path in paths {
|
||||
input.write_all(path.as_bytes())?;
|
||||
input.write_all(&[0])?;
|
||||
}
|
||||
input.rewind()?;
|
||||
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["check-attr", "--stdin", "-z", "merge"])
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git merge attribute probe failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
parse_merge_attributes(&output.stdout, paths)
|
||||
}
|
||||
|
||||
fn parse_merge_attributes(
|
||||
pub(crate) fn parse_merge_attributes(
|
||||
output: &[u8],
|
||||
expected_paths: &[String],
|
||||
) -> io::Result<BTreeMap<String, String>> {
|
||||
@@ -145,7 +52,7 @@ fn parse_merge_attributes(
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn untrusted_driver_selection(
|
||||
pub(crate) fn untrusted_driver_selection(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
attributes: &BTreeMap<String, String>,
|
||||
) -> io::Result<Option<(String, String)>> {
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::apply::ApplyGitRequest;
|
||||
use crate::apply::apply_git_patch;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
use super::*;
|
||||
use crate::apply::ApplyGitRequest;
|
||||
use crate::apply::apply_git_patch;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::GitConfigOrigin;
|
||||
use crate::git_config::GitConfigScope;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
use crate::guarded_config::config_source_authorization_count;
|
||||
use crate::guarded_config::merge_attribute_read_count;
|
||||
use crate::guarded_config::merge_config_read_count;
|
||||
use crate::guarded_config::merge_overlay_count;
|
||||
use crate::guarded_config::reset_config_source_authorization_count;
|
||||
use crate::guarded_config::reset_merge_policy_counts;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn apply_allows_unused_global_merge_driver() {
|
||||
@@ -113,6 +123,8 @@ fn apply_rejects_global_merge_driver_before_three_way() {
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "ours"]);
|
||||
assert_eq!(commit_code, 0, "commit ours: {commit_err}");
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
reset_merge_policy_counts();
|
||||
let error = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff,
|
||||
@@ -120,6 +132,10 @@ fn apply_rejects_global_merge_driver_before_three_way() {
|
||||
preflight: false,
|
||||
})
|
||||
.expect_err("reject global merge driver");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(merge_config_read_count(), 1);
|
||||
assert_eq!(merge_attribute_read_count(), 1);
|
||||
assert_eq!(merge_overlay_count(), 0);
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
let (marker_code, _, _) = run(root, &["git", "config", "--get", "codex.mergeran"]);
|
||||
assert_ne!(marker_code, 0, "merge driver must not run");
|
||||
@@ -165,6 +181,8 @@ fn three_way_apply_allows_unrelated_local_merge_driver() {
|
||||
"configure unused local driver: {config_err}"
|
||||
);
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
reset_merge_policy_counts();
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff,
|
||||
@@ -172,6 +190,10 @@ fn three_way_apply_allows_unrelated_local_merge_driver() {
|
||||
preflight: false,
|
||||
})
|
||||
.expect("allow unrelated local merge driver during three-way fallback");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(merge_config_read_count(), 1);
|
||||
assert_eq!(merge_attribute_read_count(), 1);
|
||||
assert_eq!(merge_overlay_count(), 1);
|
||||
assert_eq!(result.exit_code, 0, "three-way apply: {}", result.stderr);
|
||||
assert!(result.cmd_for_log.contains("--3way"));
|
||||
assert_eq!(
|
||||
@@ -206,6 +228,7 @@ fn apply_allows_clean_patch_with_selected_merge_driver() {
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure merge driver: {config_err}");
|
||||
|
||||
reset_merge_policy_counts();
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(),
|
||||
@@ -215,6 +238,9 @@ fn apply_allows_clean_patch_with_selected_merge_driver() {
|
||||
.expect("allow clean patch with selected merge driver");
|
||||
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert_eq!(merge_config_read_count(), 0);
|
||||
assert_eq!(merge_attribute_read_count(), 0);
|
||||
assert_eq!(merge_overlay_count(), 0);
|
||||
assert!(!result.cmd_for_log.contains("--3way"));
|
||||
let contents = std::fs::read_to_string(root.join("file.txt")).expect("read file");
|
||||
assert!(
|
||||
@@ -315,6 +341,7 @@ fn preflight_does_not_probe_or_run_selected_merge_driver() {
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure merge driver: {config_err}");
|
||||
|
||||
reset_merge_policy_counts();
|
||||
let result = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(),
|
||||
@@ -323,6 +350,9 @@ fn preflight_does_not_probe_or_run_selected_merge_driver() {
|
||||
})
|
||||
.expect("preflight must not probe merge drivers");
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert_eq!(merge_config_read_count(), 0);
|
||||
assert_eq!(merge_attribute_read_count(), 0);
|
||||
assert_eq!(merge_overlay_count(), 0);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("file.txt")).expect("read file"),
|
||||
"old\n"
|
||||
@@ -331,6 +361,61 @@ fn preflight_does_not_probe_or_run_selected_merge_driver() {
|
||||
assert_ne!(marker_code, 0, "preflight must not run merge driver");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_override_rejects_process_temp_directory_inside_worktree() {
|
||||
let root_name = "CODEX_GIT_UTILS_MERGE_WORKTREE_TMP_ROOT";
|
||||
if let Some(root) = std::env::var_os(root_name) {
|
||||
let root = std::path::PathBuf::from(root);
|
||||
let patch = std::fs::read_to_string(root.join("fixture.patch")).expect("read patch");
|
||||
let before_tree = run(&root, &["git", "write-tree"]).1;
|
||||
let before_contents = std::fs::read_to_string(root.join("file.txt")).expect("read ours");
|
||||
let error = apply_git_patch(&ApplyGitRequest {
|
||||
cwd: root.clone(),
|
||||
diff: patch,
|
||||
revert: false,
|
||||
preflight: false,
|
||||
})
|
||||
.expect_err("reject worktree-owned merge override");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(run(&root, &["git", "write-tree"]).1, before_tree);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("file.txt")).expect("read after"),
|
||||
before_contents
|
||||
);
|
||||
let (marker_code, _, _) = run(&root, &["git", "config", "--get", "codex.mergeran"]);
|
||||
assert_ne!(marker_code, 0, "merge driver must not run");
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let patch = build_three_way_fixture(root);
|
||||
std::fs::write(root.join("fixture.patch"), patch).expect("write fixture patch");
|
||||
let (config_code, _, config_err) = run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"config",
|
||||
"merge.unused.driver",
|
||||
"git config codex.mergeran true && false",
|
||||
],
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure merge driver: {config_err}");
|
||||
let worktree_temp = root.join("process-temp");
|
||||
std::fs::create_dir(&worktree_temp).expect("worktree temp directory");
|
||||
run_isolated_merge_test(
|
||||
"merge_driver::tests::merge_override_rejects_process_temp_directory_inside_worktree",
|
||||
&[
|
||||
(root_name, root.as_os_str()),
|
||||
("TMPDIR", worktree_temp.as_os_str()),
|
||||
#[cfg(windows)]
|
||||
("TEMP", worktree_temp.as_os_str()),
|
||||
#[cfg(windows)]
|
||||
("TMP", worktree_temp.as_os_str()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_driver_follows_effective_git_selection() {
|
||||
let repo = init_repo();
|
||||
@@ -343,16 +428,14 @@ fn empty_name_driver_follows_effective_git_selection() {
|
||||
&["git", "config", "merge..driver", "empty-name helper"],
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure empty-name driver: {config_err}");
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let paths = ["file.txt".to_string()];
|
||||
|
||||
ensure_no_selected_merge_drivers(&git, root, &paths, &[])
|
||||
.expect("allow unused empty-name driver");
|
||||
install_merge_policy(root, &paths).expect("allow unused empty-name driver");
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt merge=\n")
|
||||
.expect("select empty-name driver");
|
||||
let selected_error = ensure_no_selected_merge_drivers(&git, root, &paths, &[])
|
||||
.expect_err("reject selected empty-name driver");
|
||||
let selected_error =
|
||||
install_merge_policy(root, &paths).expect_err("reject selected empty-name driver");
|
||||
assert_eq!(selected_error.kind(), io::ErrorKind::Unsupported);
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "").expect("clear attributes");
|
||||
@@ -361,18 +444,16 @@ fn empty_name_driver_follows_effective_git_selection() {
|
||||
default_code, 0,
|
||||
"configure empty merge.default: {default_err}"
|
||||
);
|
||||
let default_error = ensure_no_selected_merge_drivers(&git, root, &paths, &[])
|
||||
let default_error = install_merge_policy(root, &paths)
|
||||
.expect_err("reject empty-name driver selected by empty default");
|
||||
assert_eq!(default_error.kind(), io::ErrorKind::Unsupported);
|
||||
|
||||
let (empty_code, _, empty_err) = run(root, &["git", "config", "merge..driver", ""]);
|
||||
assert_eq!(empty_code, 0, "empty effective driver: {empty_err}");
|
||||
ensure_no_selected_merge_drivers(&git, root, &paths, &[])
|
||||
.expect("allow selected empty command through empty default");
|
||||
install_merge_policy(root, &paths).expect("allow selected empty command through empty default");
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt merge=\n")
|
||||
.expect("select empty-name driver");
|
||||
ensure_no_selected_merge_drivers(&git, root, &paths, &[])
|
||||
.expect("allow explicitly selected empty command");
|
||||
install_merge_policy(root, &paths).expect("allow explicitly selected empty command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -524,6 +605,43 @@ fn config_entries<const N: usize>(values: [(&str, &str); N]) -> BTreeMap<String,
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn install_merge_policy(root: &Path, paths: &[String]) -> io::Result<()> {
|
||||
let git = GitRunner::for_cwd_io(root)?;
|
||||
let mut config = GuardedGitConfig::authorize(&git, root, Vec::new())?;
|
||||
config.authorize_filter_paths(paths)?;
|
||||
config.install_three_way_merge_policy()
|
||||
}
|
||||
|
||||
fn build_three_way_fixture(root: &Path) -> String {
|
||||
std::fs::write(root.join("file.txt"), "base\n").expect("write base");
|
||||
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
|
||||
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
|
||||
let base = run(root, &["git", "rev-parse", "HEAD"]).1;
|
||||
std::fs::write(root.join("file.txt"), "theirs\n").expect("write theirs");
|
||||
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
|
||||
assert_eq!(run(root, &["git", "commit", "-m", "theirs"]).0, 0);
|
||||
let patch = run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"diff",
|
||||
"--full-index",
|
||||
base.trim(),
|
||||
"HEAD",
|
||||
"--",
|
||||
"file.txt",
|
||||
],
|
||||
);
|
||||
assert_eq!(patch.0, 0, "create patch: {}", patch.2);
|
||||
assert_eq!(
|
||||
run(root, &["git", "checkout", "-b", "ours", base.trim()]).0,
|
||||
0
|
||||
);
|
||||
std::fs::write(root.join("file.txt"), "ours\n").expect("write ours");
|
||||
assert_eq!(run(root, &["git", "commit", "-am", "ours"]).0, 0);
|
||||
patch.1
|
||||
}
|
||||
|
||||
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
|
||||
let mut command = std::process::Command::new(args[0]);
|
||||
isolate_git_command_environment(&mut command);
|
||||
|
||||
@@ -7,8 +7,9 @@ use crate::GitToolingError;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
|
||||
pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> {
|
||||
match run_git_for_stdout(
|
||||
pub(crate) fn ensure_git_repository(git: &GitRunner, path: &Path) -> Result<(), GitToolingError> {
|
||||
match run_git_for_stdout_with_runner(
|
||||
git,
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
@@ -29,8 +30,12 @@ pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError>
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_head(path: &Path) -> Result<Option<String>, GitToolingError> {
|
||||
match run_git_for_stdout(
|
||||
pub(crate) fn resolve_head(
|
||||
git: &GitRunner,
|
||||
path: &Path,
|
||||
) -> Result<Option<String>, GitToolingError> {
|
||||
match run_git_for_stdout_with_runner(
|
||||
git,
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
@@ -45,8 +50,12 @@ pub(crate) fn resolve_head(path: &Path) -> Result<Option<String>, GitToolingErro
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repository_root(path: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let root = run_git_for_stdout(
|
||||
pub(crate) fn resolve_repository_root(
|
||||
git: &GitRunner,
|
||||
path: &Path,
|
||||
) -> Result<PathBuf, GitToolingError> {
|
||||
let root = run_git_for_stdout_with_runner(
|
||||
git,
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
@@ -70,6 +79,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_git_for_stdout<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
@@ -79,7 +89,21 @@ where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let run = run_git(dir, args, env)?;
|
||||
let git = GitRunner::for_cwd_io(dir)?;
|
||||
run_git_for_stdout_with_runner(&git, dir, args, env)
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_for_stdout_with_runner<I, S>(
|
||||
git: &GitRunner,
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<String, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let run = run_git_with_runner(git, dir, args, env)?;
|
||||
String::from_utf8(run.output.stdout)
|
||||
.map(|value| value.trim().to_string())
|
||||
.map_err(|source| GitToolingError::GitOutputUtf8 {
|
||||
@@ -93,6 +117,20 @@ fn run_git<I, S>(
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<GitRun, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let git = GitRunner::for_cwd_io(dir)?;
|
||||
run_git_with_runner(&git, dir, args, env)
|
||||
}
|
||||
|
||||
fn run_git_with_runner<I, S>(
|
||||
git: &GitRunner,
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<GitRun, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
@@ -109,7 +147,6 @@ where
|
||||
args_vec.push(OsString::from(arg.as_ref()));
|
||||
}
|
||||
let command_string = build_command_string(&args_vec);
|
||||
let git = GitRunner::for_cwd_io(dir)?;
|
||||
let mut command = git.command_for_cwd(dir)?;
|
||||
if let Some(envs) = env {
|
||||
for (key, value) in envs {
|
||||
|
||||
@@ -5,13 +5,11 @@ use std::io;
|
||||
use std::path::Path;
|
||||
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::exact_staging::update_index_exact_paths_standalone;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::path_is_within;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PatchPathInventory {
|
||||
@@ -19,21 +17,17 @@ pub(crate) struct PatchPathInventory {
|
||||
pub(crate) effective_paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extract requested-orientation and effective paths with Git from a cwd whose
|
||||
/// config sources have already been authorized for `git_config_args`.
|
||||
pub(crate) fn extract_patch_path_inventory(
|
||||
git: &GitRunner,
|
||||
authorized_cwd: &Path,
|
||||
/// Extract requested-orientation and effective paths through one bound
|
||||
/// operation configuration.
|
||||
pub(crate) fn extract_patch_path_inventory_guarded(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<PatchPathInventory> {
|
||||
let primary_paths =
|
||||
git_apply_numstat_paths(git, authorized_cwd, patch_path, revert, git_config_args)?;
|
||||
let primary_paths = git_apply_numstat_paths_guarded(config, 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 opposite_paths =
|
||||
git_apply_numstat_paths(git, authorized_cwd, patch_path, !revert, git_config_args)?;
|
||||
let opposite_paths = git_apply_numstat_paths_guarded(config, patch_path, !revert)?;
|
||||
if primary_paths.len() != opposite_paths.len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -65,17 +59,37 @@ pub(crate) fn extract_patch_path_inventory(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn extract_effective_paths_from_patch(
|
||||
git: &GitRunner,
|
||||
authorized_cwd: &Path,
|
||||
/// Extract effective patch paths through a bound operation configuration.
|
||||
pub(crate) fn extract_effective_paths_from_patch_guarded(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<Vec<String>> {
|
||||
Ok(
|
||||
extract_patch_path_inventory(git, authorized_cwd, patch_path, revert, git_config_args)?
|
||||
.effective_paths,
|
||||
)
|
||||
Ok(extract_patch_path_inventory_guarded(config, patch_path, revert)?.effective_paths)
|
||||
}
|
||||
|
||||
fn git_apply_numstat_paths_guarded(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let mut command = config.apply_command()?;
|
||||
command.args(["--numstat", "-z"]);
|
||||
if revert {
|
||||
command.arg("-R");
|
||||
}
|
||||
command.arg("--").arg(patch_path);
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"failed to parse patch paths: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
));
|
||||
}
|
||||
parse_numstat_paths(&output.stdout)
|
||||
}
|
||||
|
||||
/// Best-effort extraction of the paths Git would apply.
|
||||
@@ -95,45 +109,18 @@ fn extract_paths_from_patch_from_cwd(diff_text: &str, cwd: &Path) -> Vec<String>
|
||||
};
|
||||
let paths = (|| -> io::Result<Vec<String>> {
|
||||
let git = GitRunner::for_cwd_io(cwd)?;
|
||||
let git_root = crate::get_git_repo_root(cwd)
|
||||
let requested_cwd = std::fs::canonicalize(cwd)?;
|
||||
let git_root = crate::get_git_repo_root(&requested_cwd)
|
||||
.ok_or_else(|| io::Error::other("not a Git repository"))?;
|
||||
let git_root = std::fs::canonicalize(git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
|
||||
extract_effective_paths_from_patch(&git, &git_root, &patch_path, /*revert*/ false, &[])
|
||||
let config = GuardedGitConfig::authorize(&git, &git_root, Vec::new())?;
|
||||
extract_effective_paths_from_patch_guarded(&config, &patch_path, /*revert*/ false)
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
drop(tmpdir);
|
||||
paths
|
||||
}
|
||||
|
||||
fn git_apply_numstat_paths(
|
||||
git: &GitRunner,
|
||||
authorized_cwd: &Path,
|
||||
patch_path: &Path,
|
||||
revert: bool,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<Vec<String>> {
|
||||
let mut cmd = git.command_for_cwd(authorized_cwd)?;
|
||||
cmd.args(git_config_args);
|
||||
cmd.args(["apply", "--numstat", "-z"]);
|
||||
if revert {
|
||||
cmd.arg("-R");
|
||||
}
|
||||
cmd.arg("--").arg(patch_path);
|
||||
let out = git.output(cmd)?;
|
||||
if !out.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"failed to parse patch paths: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
parse_numstat_paths(&out.stdout)
|
||||
}
|
||||
|
||||
fn parse_numstat_paths(output: &[u8]) -> io::Result<Vec<String>> {
|
||||
if !output.is_empty() && !output.ends_with(&[0]) {
|
||||
return Err(io::Error::new(
|
||||
@@ -275,31 +262,36 @@ fn invalid_windows_patch_component(component: &str) -> bool {
|
||||
/// Stage only the files that actually exist on disk for the given diff.
|
||||
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
|
||||
let git = GitRunner::for_cwd_io(git_root)?;
|
||||
let git_config_args = safe_git_config_parts();
|
||||
ensure_no_worktree_config_sources(&git, git_root, &git_config_args)?;
|
||||
git.ensure_repository_root_route(git_root)?;
|
||||
let canonical_root = std::fs::canonicalize(git_root)?;
|
||||
let mut config = GuardedGitConfig::authorize(&git, &canonical_root, Vec::new())?;
|
||||
let (tmpdir, patch_path) = write_temp_patch(diff)?;
|
||||
let paths = extract_effective_paths_from_patch(
|
||||
&git,
|
||||
git_root,
|
||||
&patch_path,
|
||||
/*revert*/ true,
|
||||
&git_config_args,
|
||||
)?;
|
||||
let paths =
|
||||
extract_effective_paths_from_patch_guarded(&config, &patch_path, /*revert*/ true)?;
|
||||
let _guard = tmpdir;
|
||||
stage_effective_paths(&git, git_root, &paths, &git_config_args)
|
||||
stage_effective_paths_standalone(&mut config, &paths)
|
||||
}
|
||||
|
||||
pub(crate) fn stage_effective_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
fn stage_effective_paths_standalone(
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let confined = confine_patch_paths(git, git_root, paths)?;
|
||||
let (existing, content_filter_paths) = classify_exact_staging_leaves(config, paths)?;
|
||||
let _result = update_index_exact_paths_standalone(config, &existing, &content_filter_paths)?;
|
||||
// Preserve the public helper's historical best-effort treatment of a
|
||||
// non-zero staging command. Security and probe failures still propagate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn classify_exact_staging_leaves(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
) -> io::Result<(Vec<String>, Vec<String>)> {
|
||||
let confined = confine_patch_paths_guarded(config, 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);
|
||||
let joined = config.canonical_root().join(&path);
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&joined) {
|
||||
let file_type = metadata.file_type();
|
||||
if leaf_is_traversable_directory(file_type) {
|
||||
@@ -313,19 +305,7 @@ pub(crate) fn stage_effective_paths(
|
||||
existing.push(path);
|
||||
}
|
||||
}
|
||||
if existing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
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(())
|
||||
Ok((existing, content_filter_paths))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
@@ -427,10 +407,33 @@ impl ConfinedPatchPaths {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn confine_patch_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
paths: &[String],
|
||||
) -> io::Result<ConfinedPatchPaths> {
|
||||
let canonical_root = std::fs::canonicalize(git_root)?;
|
||||
let config = GuardedGitConfig::authorize(git, &canonical_root, Vec::new())?;
|
||||
confine_patch_paths_guarded(&config, paths)
|
||||
}
|
||||
|
||||
pub(crate) fn confine_patch_paths_guarded(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
) -> io::Result<ConfinedPatchPaths> {
|
||||
let canonical_root = std::fs::canonicalize(config.canonical_root())?;
|
||||
if paths.is_empty() || paths.iter().all(|path| !path.contains('/')) {
|
||||
return confine_patch_paths_with_metadata(&canonical_root, paths, &[]);
|
||||
}
|
||||
let metadata_dirs = canonical_git_metadata_dirs_guarded(config)?;
|
||||
confine_patch_paths_with_metadata(&canonical_root, paths, &metadata_dirs)
|
||||
}
|
||||
|
||||
fn confine_patch_paths_with_metadata(
|
||||
canonical_root: &Path,
|
||||
paths: &[String],
|
||||
metadata_dirs: &[PathBuf],
|
||||
) -> io::Result<ConfinedPatchPaths> {
|
||||
if paths.is_empty() {
|
||||
return Ok(ConfinedPatchPaths {
|
||||
@@ -453,8 +456,6 @@ pub(crate) fn confine_patch_paths(
|
||||
});
|
||||
}
|
||||
|
||||
let canonical_root = std::fs::canonicalize(git_root)?;
|
||||
let metadata_dirs = canonical_git_metadata_dirs(git, &canonical_root)?;
|
||||
let mut entries = Vec::with_capacity(paths.len());
|
||||
let mut prefix_cache = std::collections::BTreeMap::new();
|
||||
|
||||
@@ -468,9 +469,9 @@ pub(crate) fn confine_patch_paths(
|
||||
);
|
||||
|
||||
let (existing_len, mut projected) = longest_existing_strict_prefix(
|
||||
&canonical_root,
|
||||
canonical_root,
|
||||
&components,
|
||||
&metadata_dirs,
|
||||
metadata_dirs,
|
||||
&mut prefix_cache,
|
||||
)?;
|
||||
projected.extend(
|
||||
@@ -565,22 +566,27 @@ fn containment_error(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
fn canonical_git_metadata_dirs(git: &GitRunner, git_root: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let config_parts = safe_git_config_parts();
|
||||
fn canonical_git_metadata_dirs_guarded(config: &GuardedGitConfig<'_>) -> io::Result<Vec<PathBuf>> {
|
||||
let queries = [
|
||||
vec!["rev-parse".to_string(), "--absolute-git-dir".to_string()],
|
||||
vec!["rev-parse".to_string(), "--git-common-dir".to_string()],
|
||||
["rev-parse", "--absolute-git-dir"],
|
||||
["rev-parse", "--git-common-dir"],
|
||||
];
|
||||
let mut metadata_dirs = std::collections::BTreeSet::new();
|
||||
for args in queries {
|
||||
let (code, stdout, stderr) = run_git(git, git_root, &config_parts, &args)?;
|
||||
if code != 0 {
|
||||
#[cfg(test)]
|
||||
CONTAINMENT_METADATA_QUERY_COUNT.with(|count| count.set(count.get() + 1));
|
||||
let mut command = config.rev_parse_command()?;
|
||||
command.args(&args[1..]);
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to resolve Git repository metadata (exit {code}): {}",
|
||||
stderr.trim()
|
||||
"failed to resolve Git repository metadata (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let path = stdout.trim_end_matches(['\r', '\n']);
|
||||
let path = String::from_utf8_lossy(&output.stdout);
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
if path.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -591,13 +597,28 @@ fn canonical_git_metadata_dirs(git: &GitRunner, git_root: &Path) -> io::Result<V
|
||||
let absolute = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
git_root.join(path)
|
||||
config.canonical_root().join(path)
|
||||
};
|
||||
metadata_dirs.insert(std::fs::canonicalize(absolute)?);
|
||||
}
|
||||
Ok(metadata_dirs.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static CONTAINMENT_METADATA_QUERY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_containment_metadata_query_count() {
|
||||
CONTAINMENT_METADATA_QUERY_COUNT.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn containment_metadata_query_count() -> usize {
|
||||
CONTAINMENT_METADATA_QUERY_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
fn insert_candidate_prefixes<'a>(
|
||||
components: impl IntoIterator<Item = &'a str>,
|
||||
origin: ConfinedPathOrigin,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
use super::*;
|
||||
use crate::apply::ApplyGitRequest;
|
||||
use crate::apply::apply_git_patch;
|
||||
use crate::apply::safe_git_config_parts;
|
||||
use crate::exact_staging::StagePathsResult;
|
||||
use crate::exact_staging::update_index_exact_paths;
|
||||
use crate::exact_staging::update_index_exact_paths_from_apply;
|
||||
use crate::exact_staging::update_index_exact_paths_standalone;
|
||||
use crate::guarded_config::config_source_authorization_count;
|
||||
use crate::guarded_config::reset_config_source_authorization_count;
|
||||
use crate::safe_git::filter_policy_overlay_count;
|
||||
use crate::safe_git::filter_policy_read_count;
|
||||
use crate::safe_git::reset_filter_policy_counts;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
@@ -70,8 +77,8 @@ fn effective_paths(diff: &str, revert: bool) -> io::Result<Vec<String>> {
|
||||
let git_root =
|
||||
crate::get_git_repo_root(cwd).ok_or_else(|| io::Error::other("not a Git repository"))?;
|
||||
let git_root = std::fs::canonicalize(git_root)?;
|
||||
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
|
||||
let paths = extract_effective_paths_from_patch(&git, &git_root, &patch_path, revert, &[])?;
|
||||
let config = GuardedGitConfig::authorize(&git, &git_root, Vec::new())?;
|
||||
let paths = extract_effective_paths_from_patch_guarded(&config, &patch_path, revert)?;
|
||||
drop(tmpdir);
|
||||
Ok(paths)
|
||||
}
|
||||
@@ -81,6 +88,152 @@ fn best_effort_paths(diff: &str) -> Vec<String> {
|
||||
extract_paths_from_patch_from_cwd(diff, repo.path())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn best_effort_extraction_selects_the_physical_repo_from_a_symlinked_nested_cwd() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let target = fixture.path().join("target");
|
||||
let nested = target.join("nested");
|
||||
std::fs::create_dir_all(&outer).expect("outer");
|
||||
std::fs::create_dir_all(&nested).expect("nested");
|
||||
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
|
||||
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
|
||||
let lexical_cwd = outer.join("linked-nested");
|
||||
std::os::unix::fs::symlink(&nested, &lexical_cwd).expect("symlink nested cwd");
|
||||
|
||||
assert_eq!(
|
||||
extract_paths_from_patch_from_cwd(&new_file_diff("physical.txt"), &lexical_cwd),
|
||||
vec!["physical.txt".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn standalone_stage_paths_preserves_a_symlinked_repository_route() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let target = fixture.path().join("target");
|
||||
std::fs::create_dir_all(&outer).expect("outer");
|
||||
std::fs::create_dir_all(&target).expect("target");
|
||||
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
|
||||
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
|
||||
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
|
||||
let lexical_root = fixture.path().join("linked-root");
|
||||
std::os::unix::fs::symlink(&target, &lexical_root).expect("symlink repository root");
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
stage_paths(&lexical_root, &new_file_diff("routed.txt")).expect("stage through symlink route");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(
|
||||
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
|
||||
"routed.txt\n"
|
||||
);
|
||||
assert!(
|
||||
run(&outer, &["git", "diff", "--cached", "--name-only"])
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn standalone_stage_paths_preserves_a_junction_repository_route() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let outer = fixture.path().join("outer");
|
||||
let target = fixture.path().join("target");
|
||||
std::fs::create_dir_all(&outer).expect("outer");
|
||||
std::fs::create_dir_all(&target).expect("target");
|
||||
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
|
||||
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
|
||||
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
|
||||
let lexical_root = fixture.path().join("linked-root");
|
||||
let _junction = create_dir_alias(&target, &lexical_root);
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
stage_paths(&lexical_root, &new_file_diff("routed.txt")).expect("stage through junction route");
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(
|
||||
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
|
||||
"routed.txt\n"
|
||||
);
|
||||
assert!(
|
||||
run(&outer, &["git", "diff", "--cached", "--name-only"])
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn standalone_stage_paths_preserves_an_independent_repository_alias() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let independent = fixture.path().join("independent");
|
||||
let target = fixture.path().join("target");
|
||||
std::fs::create_dir_all(&independent).expect("independent");
|
||||
std::fs::create_dir_all(&target).expect("target");
|
||||
assert_eq!(run(&independent, &["git", "init", "-q"]).0, 0);
|
||||
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
|
||||
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
|
||||
let lexical_root = independent.join("linked-root");
|
||||
let _route = create_dir_alias(&target, &lexical_root);
|
||||
|
||||
stage_paths(&lexical_root, &new_file_diff("routed.txt"))
|
||||
.expect("independent repository alias remains supported");
|
||||
assert_eq!(
|
||||
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
|
||||
"routed.txt\n"
|
||||
);
|
||||
assert!(
|
||||
run(&independent, &["git", "diff", "--cached", "--name-only"])
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn standalone_stage_paths_rejects_a_route_crossing_a_related_worktree() {
|
||||
let fixture = tempfile::tempdir().expect("fixture");
|
||||
let primary = fixture.path().join("primary");
|
||||
let linked = fixture.path().join("linked");
|
||||
std::fs::create_dir_all(&primary).expect("primary");
|
||||
assert_eq!(run(&primary, &["git", "init", "-q"]).0, 0);
|
||||
assert_eq!(
|
||||
run(
|
||||
&primary,
|
||||
&["git", "config", "user.email", "codex@example.com"]
|
||||
)
|
||||
.0,
|
||||
0
|
||||
);
|
||||
assert_eq!(run(&primary, &["git", "config", "user.name", "Codex"]).0, 0);
|
||||
std::fs::write(primary.join("seed.txt"), "seed\n").expect("write seed");
|
||||
assert_eq!(run(&primary, &["git", "add", "seed.txt"]).0, 0);
|
||||
assert_eq!(run(&primary, &["git", "commit", "-m", "seed"]).0, 0);
|
||||
let linked_arg = linked.to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
run(
|
||||
&primary,
|
||||
&["git", "worktree", "add", "--detach", &linked_arg]
|
||||
)
|
||||
.0,
|
||||
0
|
||||
);
|
||||
std::fs::write(linked.join("routed.txt"), "routed\n").expect("write linked file");
|
||||
let crossing_route = primary.join("linked-route");
|
||||
let _route = create_dir_alias(&linked, &crossing_route);
|
||||
|
||||
let error = stage_paths(&crossing_route, &new_file_diff("routed.txt"))
|
||||
.expect_err("related-worktree crossing must reject");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
run(&linked, &["git", "diff", "--cached", "--name-only"])
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_paths_cover_supported_patch_headers() {
|
||||
let cases = [
|
||||
@@ -530,9 +683,11 @@ fn index_entry(root: &Path, path: &str) -> String {
|
||||
|
||||
fn exact_stage(root: &Path, paths: &[&str], content_filter_paths: &[&str]) -> StagePathsResult {
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
update_index_exact_paths(
|
||||
&git,
|
||||
root,
|
||||
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorized config");
|
||||
update_index_exact_paths_standalone(
|
||||
&mut config,
|
||||
&paths
|
||||
.iter()
|
||||
.map(|path| (*path).to_string())
|
||||
@@ -541,11 +696,251 @@ fn exact_stage(root: &Path, paths: &[&str], content_filter_paths: &[&str]) -> St
|
||||
.iter()
|
||||
.map(|path| (*path).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
&safe_git_config_parts(),
|
||||
)
|
||||
.expect("run exact staging primitive")
|
||||
}
|
||||
|
||||
fn stage_effective_paths(
|
||||
git: &GitRunner,
|
||||
root: &Path,
|
||||
paths: &[String],
|
||||
legacy_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let _ = legacy_config_args;
|
||||
let canonical_root = std::fs::canonicalize(root)?;
|
||||
let mut config = GuardedGitConfig::authorize(git, &canonical_root, Vec::new())?;
|
||||
stage_effective_paths_standalone(&mut config, paths)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_stage_paths_uses_one_source_authorization_and_one_filter_read() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_none() {
|
||||
let config_dir = tempfile::tempdir().expect("config tempdir");
|
||||
let global_config = config_dir.path().join("global.gitconfig");
|
||||
let system_config = config_dir.path().join("system.gitconfig");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
run_isolated_test(
|
||||
"patch_paths::tests::standalone_stage_paths_uses_one_source_authorization_and_one_filter_read",
|
||||
&[
|
||||
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
|
||||
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("single.txt"), "single\n").expect("write staged file");
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
reset_filter_policy_counts();
|
||||
stage_paths(root, &new_file_diff("single.txt")).expect("standalone exact staging");
|
||||
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(filter_policy_read_count(), 1);
|
||||
assert_eq!(filter_policy_overlay_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_staging_freshly_observes_a_new_clean_driver_and_attribute() {
|
||||
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_none() {
|
||||
let config_dir = tempfile::tempdir().expect("config tempdir");
|
||||
let global_config = config_dir.path().join("global.gitconfig");
|
||||
let system_config = config_dir.path().join("system.gitconfig");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
run_isolated_test(
|
||||
"patch_paths::tests::composed_staging_freshly_observes_a_new_clean_driver_and_attribute",
|
||||
&[
|
||||
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
|
||||
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("file.txt"), "base\n").expect("write base file");
|
||||
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
|
||||
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
|
||||
std::fs::write(root.join("file.txt"), "changed\n").expect("modify file");
|
||||
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
|
||||
reset_config_source_authorization_count();
|
||||
reset_filter_policy_counts();
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
|
||||
config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("apply snapshot without filters");
|
||||
|
||||
assert_eq!(
|
||||
run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"config",
|
||||
"filter.fresh.clean",
|
||||
"git config codex.stage-filter-ran true && git hash-object --stdin",
|
||||
],
|
||||
)
|
||||
.0,
|
||||
0
|
||||
);
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=fresh\n")
|
||||
.expect("select fresh filter");
|
||||
|
||||
let error = update_index_exact_paths_from_apply(
|
||||
&mut config,
|
||||
&["file.txt".to_string()],
|
||||
&["file.txt".to_string()],
|
||||
)
|
||||
.expect_err("fresh clean driver must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(filter_policy_read_count(), 2);
|
||||
assert_eq!(filter_policy_overlay_count(), 1);
|
||||
assert_ne!(
|
||||
run(root, &["git", "config", "--get", "codex.stage-filter-ran"]).0,
|
||||
0,
|
||||
"fresh policy must not execute the filter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_staging_required_read_ignores_the_apply_overlay_and_is_fresh() {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("file.txt"), "base\n").expect("write base file");
|
||||
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
|
||||
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
|
||||
std::fs::write(root.join("file.txt"), "changed\n").expect("modify file");
|
||||
assert_eq!(
|
||||
run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"config",
|
||||
"filter.demo.smudge",
|
||||
"git config codex.stage-smudge-ran true && git hash-object --stdin",
|
||||
],
|
||||
)
|
||||
.0,
|
||||
0
|
||||
);
|
||||
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
|
||||
reset_config_source_authorization_count();
|
||||
reset_filter_policy_counts();
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
|
||||
config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("unselected apply filter snapshot");
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter=demo\n")
|
||||
.expect("select smudge-only filter");
|
||||
assert_eq!(
|
||||
run(root, &["git", "config", "filter.demo.required", "true"]).0,
|
||||
0
|
||||
);
|
||||
|
||||
let error = update_index_exact_paths_from_apply(
|
||||
&mut config,
|
||||
&["file.txt".to_string()],
|
||||
&["file.txt".to_string()],
|
||||
)
|
||||
.expect_err("fresh required=true must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(filter_policy_read_count(), 2);
|
||||
assert_eq!(filter_policy_overlay_count(), 2);
|
||||
assert_ne!(
|
||||
run(root, &["git", "config", "--get", "codex.stage-smudge-ran"]).0,
|
||||
0,
|
||||
"smudge helper must remain neutralized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_exact_staging_refuses_requested_and_mapped_paths_outside_apply_universe() {
|
||||
let requested_repo = init_repo();
|
||||
let requested_root = requested_repo.path();
|
||||
let requested_git = GitRunner::for_cwd_io(requested_root).expect("trusted Git");
|
||||
let mut requested_config = GuardedGitConfig::authorize(
|
||||
&requested_git,
|
||||
&std::fs::canonicalize(requested_root).expect("canonical root"),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("authorization");
|
||||
requested_config
|
||||
.authorize_filter_paths(&["allowed.txt".to_string()])
|
||||
.expect("apply snapshot");
|
||||
let requested_error = update_index_exact_paths_from_apply(
|
||||
&mut requested_config,
|
||||
&["allowed.txt".to_string(), "outside.txt".to_string()],
|
||||
&["outside.txt".to_string()],
|
||||
)
|
||||
.expect_err("requested off-universe path must refuse");
|
||||
assert_eq!(requested_error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
let mapped_repo = init_repo();
|
||||
let mapped_root = mapped_repo.path();
|
||||
std::fs::write(mapped_root.join("File.txt"), "tracked\n").expect("write tracked spelling");
|
||||
assert_eq!(run(mapped_root, &["git", "add", "File.txt"]).0, 0);
|
||||
assert_eq!(run(mapped_root, &["git", "commit", "-m", "base"]).0, 0);
|
||||
std::fs::write(mapped_root.join("file.txt"), "alias\n").expect("write alias spelling");
|
||||
assert_eq!(
|
||||
run(mapped_root, &["git", "config", "core.ignoreCase", "true"]).0,
|
||||
0
|
||||
);
|
||||
let mapped_git = GitRunner::for_cwd_io(mapped_root).expect("trusted Git");
|
||||
let mut mapped_config = GuardedGitConfig::authorize(
|
||||
&mapped_git,
|
||||
&std::fs::canonicalize(mapped_root).expect("canonical root"),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("authorization");
|
||||
mapped_config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("narrow apply snapshot");
|
||||
let mapped_error = update_index_exact_paths_from_apply(
|
||||
&mut mapped_config,
|
||||
&["File.txt".to_string(), "file.txt".to_string()],
|
||||
&["file.txt".to_string()],
|
||||
)
|
||||
.expect_err("mapped off-universe path must refuse");
|
||||
assert_eq!(mapped_error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_exact_staging_refuses_zero_or_two_prior_snapshots_even_when_empty() {
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
|
||||
|
||||
let mut zero =
|
||||
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
|
||||
let error = update_index_exact_paths_from_apply(&mut zero, &[], &[])
|
||||
.expect_err("zero snapshots must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
let mut two =
|
||||
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
|
||||
two.authorize_filter_paths(&[]).expect("apply snapshot");
|
||||
two.authorize_git_add_filter_paths(&[])
|
||||
.expect("Git-add snapshot");
|
||||
let error = update_index_exact_paths_from_apply(&mut two, &[], &[])
|
||||
.expect_err("two snapshots must refuse");
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
fn assert_exact_refused_unchanged(
|
||||
root: &Path,
|
||||
paths: &[&str],
|
||||
@@ -598,6 +993,38 @@ impl Drop for DirectoryAlias {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confinement_skips_metadata_queries_only_for_paths_without_ancestors() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let git = GitRunner::for_cwd_io(root).expect("Git runner");
|
||||
let config = GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorization");
|
||||
|
||||
reset_containment_metadata_query_count();
|
||||
confine_patch_paths_guarded(&config, &["leaf".to_string()]).expect("single leaf");
|
||||
assert_eq!(containment_metadata_query_count(), 0);
|
||||
|
||||
reset_containment_metadata_query_count();
|
||||
confine_patch_paths_guarded(&config, &["nested/leaf".to_string()]).expect("nested leaf");
|
||||
assert_eq!(
|
||||
containment_metadata_query_count(),
|
||||
2,
|
||||
"nested paths must still resolve private and common Git metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_component_git_directory_is_refused_without_metadata_queries() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let before = git_index_bytes(repo.path());
|
||||
|
||||
reset_containment_metadata_query_count();
|
||||
assert_stage_refused(repo.path(), ".git", &before);
|
||||
assert_eq!(containment_metadata_query_count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
fn assert_longest_existing_alias_prefix_carries_unresolved_suffix() {
|
||||
let repo = init_repo();
|
||||
|
||||
@@ -146,6 +146,20 @@ impl RepositoryAuthority {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_active_worktree_root(&self, root: &Path) -> io::Result<()> {
|
||||
let canonical_root = std::fs::canonicalize(root)?;
|
||||
let same_root = canonical_root == self.active_worktree_root
|
||||
|| same_file::is_same_file(&canonical_root, &self.active_worktree_root)?;
|
||||
if !same_root {
|
||||
return Err(authority_refusal(format!(
|
||||
"guarded Git repository root {} does not match runner repository {}",
|
||||
canonical_root.display(),
|
||||
self.active_worktree_root.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_test_locations(
|
||||
roots: Vec<PathBuf>,
|
||||
|
||||
@@ -17,6 +17,37 @@ use super::super::repository_common_dir_for_candidate_root;
|
||||
use super::RepositoryAuthority;
|
||||
|
||||
impl RepositoryAuthority {
|
||||
pub(crate) fn ensure_repository_root_route(&self, root: &Path) -> io::Result<()> {
|
||||
self.ensure_active_worktree_root(root)?;
|
||||
let crosses_worktree = self.route_boundaries.inspect_route(root)?.crosses_worktree;
|
||||
let crosses_related_worktree = match root.parent() {
|
||||
Some(parent) => self.has_active_common_dir_ancestor(parent)?,
|
||||
None => false,
|
||||
};
|
||||
if crosses_worktree && crosses_related_worktree {
|
||||
return Err(authority_refusal(format!(
|
||||
"refusing repository root route that crosses a worktree boundary: {}",
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_active_common_dir_ancestor(&self, path: &Path) -> io::Result<bool> {
|
||||
let Some(active) = &self.active_metadata else {
|
||||
return Ok(false);
|
||||
};
|
||||
for ancestor in path.ancestors() {
|
||||
let Some(common_dir) = repository_common_dir_for_candidate_root(ancestor)? else {
|
||||
continue;
|
||||
};
|
||||
if directories_refer_to_same_location(&common_dir, &active.common_dir)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_command_cwd(&self, cwd: &Path) -> io::Result<PathBuf> {
|
||||
let canonical = std::fs::canonicalize(cwd)?;
|
||||
let inspection = self.route_boundaries.inspect_route(cwd)?;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use super::*;
|
||||
use crate::guarded_config::config_source_authorization_count;
|
||||
use crate::guarded_config::merge_attribute_read_count;
|
||||
use crate::guarded_config::merge_config_read_count;
|
||||
use crate::guarded_config::reset_config_source_authorization_count;
|
||||
use crate::guarded_config::reset_merge_policy_counts;
|
||||
use crate::safe_git::filter_policy_read_count;
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
use crate::safe_git::reset_filter_policy_counts;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
@@ -576,3 +583,77 @@ fn direct_success_paths_are_machine_readable_in_each_orientation() {
|
||||
assert_eq!(result.applied_paths, vec![expected]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_three_way_with_staging_candidate_uses_one_authorization() {
|
||||
let child_marker = "CODEX_GIT_UTILS_REVERSE_THREE_WAY_TRACE_CHILD";
|
||||
if std::env::var_os(child_marker).is_none() {
|
||||
let environment = tempfile::tempdir().expect("isolated Git environment");
|
||||
let global_config = environment.path().join("global.gitconfig");
|
||||
let system_config = environment.path().join("system.gitconfig");
|
||||
std::fs::write(&global_config, "").expect("empty global config");
|
||||
std::fs::write(&system_config, "").expect("empty system config");
|
||||
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::reverse_apply_tests::reverse_three_way_with_staging_candidate_uses_one_authorization")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env(child_marker, "1")
|
||||
.env("GIT_CONFIG_GLOBAL", &global_config)
|
||||
.env("GIT_CONFIG_SYSTEM", &system_config)
|
||||
.env("RUST_TEST_THREADS", "1")
|
||||
.output()
|
||||
.expect("run isolated reverse three-way test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated reverse three-way test failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
let base_contents = "01\n02\n03\n04\n05\n06\n07\n08\n09\n10\nbase\n12\n13\n14\n15\n";
|
||||
let theirs_contents = "01\n02\n03\n04\n05\n06\n07\n08\n09\n10\ntheirs\n12\n13\n14\n15\n";
|
||||
let independently_edited =
|
||||
"01\n02\n03\n04\n05\n06\n07\nEIGHT\n09\n10\ntheirs\n12\n13\n14\n15\n";
|
||||
let expected = "01\n02\n03\n04\n05\n06\n07\nEIGHT\n09\n10\nbase\n12\n13\n14\n15\n";
|
||||
std::fs::write(root.join("file.txt"), base_contents).expect("write base");
|
||||
run_success(root, &["add", "file.txt"]);
|
||||
run_success(root, &["commit", "-qm", "base"]);
|
||||
let base = String::from_utf8(run_success(root, &["rev-parse", "HEAD"])).expect("base OID");
|
||||
std::fs::write(root.join("file.txt"), theirs_contents).expect("write theirs");
|
||||
run_success(root, &["add", "file.txt"]);
|
||||
run_success(root, &["commit", "-qm", "theirs"]);
|
||||
let patch = String::from_utf8(run_success(
|
||||
root,
|
||||
&[
|
||||
"diff",
|
||||
"--full-index",
|
||||
base.trim(),
|
||||
"HEAD",
|
||||
"--",
|
||||
"file.txt",
|
||||
],
|
||||
))
|
||||
.expect("patch");
|
||||
// Change a context line so a plain reverse check fails, while a three-way
|
||||
// reverse can preserve the independent edit after exact staging.
|
||||
std::fs::write(root.join("file.txt"), independently_edited).expect("write independent edit");
|
||||
|
||||
reset_config_source_authorization_count();
|
||||
reset_filter_policy_counts();
|
||||
reset_merge_policy_counts();
|
||||
let result = apply_git_patch(&request(root, &patch)).expect("reverse three-way apply");
|
||||
|
||||
assert_eq!(result.exit_code, 0, "{}", result.stderr);
|
||||
assert!(result.cmd_for_log.contains("--3way"));
|
||||
assert_eq!(config_source_authorization_count(), 1);
|
||||
assert_eq!(filter_policy_read_count(), 2);
|
||||
assert_eq!(merge_config_read_count(), 1);
|
||||
assert_eq!(merge_attribute_read_count(), 1);
|
||||
assert_eq!(read_file_normalized(&root.join("file.txt")), expected);
|
||||
}
|
||||
|
||||
@@ -3,29 +3,21 @@
|
||||
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::exact_staging::update_index_exact_paths_from_apply;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
use crate::patch_paths::confine_patch_paths_guarded;
|
||||
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,
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let confined = confine_patch_paths(git, git_root, paths)?;
|
||||
let confined = confine_patch_paths_guarded(config, paths)?;
|
||||
let mut worktree_paths = BTreeMap::new();
|
||||
for path in confined.into_exact_leaves()? {
|
||||
let joined = git_root.join(&path);
|
||||
let joined = config.canonical_root().join(&path);
|
||||
let state = match std::fs::symlink_metadata(&joined) {
|
||||
Ok(metadata) => {
|
||||
let file_type = metadata.file_type();
|
||||
@@ -58,7 +50,7 @@ pub(crate) fn stage_effective_paths_for_reverse(
|
||||
}
|
||||
|
||||
let exact_paths = worktree_paths.keys().cloned().collect::<Vec<_>>();
|
||||
let index_entries = read_reverse_index_entries(git, git_root, &exact_paths, git_config_args)?;
|
||||
let index_entries = read_reverse_index_entries(config, &exact_paths)?;
|
||||
for path in &exact_paths {
|
||||
let entries = index_entries.get(path).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if !matches!(
|
||||
@@ -75,43 +67,12 @@ pub(crate) fn stage_effective_paths_for_reverse(
|
||||
}
|
||||
|
||||
let cached_invisible_status = read_name_status(
|
||||
git,
|
||||
git_root,
|
||||
config,
|
||||
&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",
|
||||
ReverseCachedView::IntentToAddInvisible,
|
||||
)?;
|
||||
let cached_visible_status =
|
||||
read_name_status(config, &exact_paths, ReverseCachedView::IntentToAddVisible)?;
|
||||
if cached_invisible_status != cached_visible_status {
|
||||
let path = exact_paths
|
||||
.iter()
|
||||
@@ -121,24 +82,7 @@ pub(crate) fn stage_effective_paths_for_reverse(
|
||||
"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 worktree_changed_paths = read_changed_paths(config, &exact_paths)?;
|
||||
|
||||
let mut staging_candidates = Vec::new();
|
||||
for path in &exact_paths {
|
||||
@@ -178,13 +122,8 @@ pub(crate) fn stage_effective_paths_for_reverse(
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let result = update_index_exact_paths(
|
||||
git,
|
||||
git_root,
|
||||
&staging_candidates,
|
||||
&content_filter_paths,
|
||||
git_config_args,
|
||||
)?;
|
||||
let result =
|
||||
update_index_exact_paths_from_apply(config, &staging_candidates, &content_filter_paths)?;
|
||||
if result.exit_code == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -209,24 +148,10 @@ struct ReverseIndexEntry {
|
||||
}
|
||||
|
||||
fn read_reverse_index_entries(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, Vec<ReverseIndexEntry>>> {
|
||||
let mut command = git.command_for_cwd(git_root)?;
|
||||
command
|
||||
.args(git_config_args)
|
||||
.args([
|
||||
"--literal-pathspecs",
|
||||
"ls-files",
|
||||
"-v",
|
||||
"--stage",
|
||||
"-z",
|
||||
"--",
|
||||
])
|
||||
.args(paths);
|
||||
let output = git.output(command)?;
|
||||
let output = config.reverse_index_stage_output(paths)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to inspect reverse patch index entries: {}",
|
||||
@@ -295,17 +220,35 @@ fn read_reverse_index_entries(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ReverseCachedView {
|
||||
IntentToAddInvisible,
|
||||
IntentToAddVisible,
|
||||
}
|
||||
|
||||
fn read_name_status(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
args: &[&str],
|
||||
operation: &str,
|
||||
view: ReverseCachedView,
|
||||
) -> 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)?;
|
||||
let (output, operation) = match view {
|
||||
ReverseCachedView::IntentToAddInvisible => (
|
||||
config.reverse_cached_name_status_invisible_output(paths)?,
|
||||
"inspect reverse paths with intent-to-add entries hidden",
|
||||
),
|
||||
ReverseCachedView::IntentToAddVisible => (
|
||||
config.reverse_cached_name_status_visible_output(paths)?,
|
||||
"inspect reverse paths with intent-to-add entries visible",
|
||||
),
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to {operation} (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let fields = nul_fields(&output.stdout, operation)?;
|
||||
if fields.len() % 2 != 0 {
|
||||
return Err(invalid_reverse_index_output(
|
||||
"git diff returned an incomplete name-status record",
|
||||
@@ -331,26 +274,21 @@ fn read_name_status(
|
||||
}
|
||||
|
||||
fn read_changed_paths(
|
||||
git: &GitRunner,
|
||||
git_root: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
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 operation = "compare reverse path index entries with the worktree";
|
||||
let output = config.reverse_worktree_changed_paths_output(paths)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to {operation} (status {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let expected = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
let mut changed = BTreeSet::new();
|
||||
for path in nul_fields(&output, operation)? {
|
||||
for path in nul_fields(&output.stdout, operation)? {
|
||||
let path = parse_expected_path(path, &expected, operation)?;
|
||||
if !changed.insert(path) {
|
||||
return Err(invalid_reverse_index_output(
|
||||
@@ -361,33 +299,6 @@ fn read_changed_paths(
|
||||
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_for_cwd(git_root)?;
|
||||
command
|
||||
.args(git_config_args)
|
||||
.args(extra_git_args)
|
||||
.args(args)
|
||||
.arg("--")
|
||||
.args(paths);
|
||||
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())
|
||||
}
|
||||
|
||||
+183
-417
@@ -1,28 +1,22 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_command::MAX_INTERNAL_GIT_OUTPUT_BYTES;
|
||||
use crate::git_config::GitConfigEntry;
|
||||
use crate::git_config::read_effective_config_with_fallback as read_effective_config_unchecked;
|
||||
use crate::git_config::read_effective_config_with_fallback_async as read_effective_config_unchecked_async;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources;
|
||||
use crate::git_config_sources::ensure_no_worktree_config_sources_async;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
use crate::guarded_config::SealedFilterConfigOverride;
|
||||
|
||||
#[path = "filter_sentinel.rs"]
|
||||
mod filter_sentinel;
|
||||
pub(crate) use filter_sentinel::SentinelFilterProbeBudget;
|
||||
pub(crate) use filter_sentinel::SentinelFilterProbeResolution;
|
||||
pub(crate) use filter_sentinel::classify_sentinel_filter_probes;
|
||||
#[cfg(test)]
|
||||
pub(crate) use filter_sentinel::sentinel_filter_probe_config_args;
|
||||
pub(crate) const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||
pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str =
|
||||
@@ -30,119 +24,62 @@ pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str =
|
||||
pub(crate) const MAX_EXECUTABLE_FILTER_DRIVERS: usize = 256;
|
||||
/// 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_for_cwd(cwd)?;
|
||||
command.args(self.add_value_args(key, value));
|
||||
let output = git.output(command)?;
|
||||
Self::check_add_value_output(output, description)
|
||||
}
|
||||
|
||||
pub(crate) async fn add_value_async(
|
||||
&self,
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
key: &str,
|
||||
value: &str,
|
||||
description: &str,
|
||||
) -> io::Result<()> {
|
||||
let mut command = git.async_command_for_cwd(cwd)?;
|
||||
command.args(self.add_value_args(key, value));
|
||||
let output = git
|
||||
.output_async_bounded(command, MAX_INTERNAL_GIT_OUTPUT_BYTES)
|
||||
.await?;
|
||||
Self::check_add_value_output(output, description)
|
||||
}
|
||||
|
||||
fn add_value_args(&self, key: &str, value: &str) -> [OsString; 6] {
|
||||
[
|
||||
OsString::from("config"),
|
||||
OsString::from("--file"),
|
||||
self.config_path.as_os_str().to_os_string(),
|
||||
OsString::from("--add"),
|
||||
OsString::from(key),
|
||||
OsString::from(value),
|
||||
]
|
||||
}
|
||||
|
||||
fn check_add_value_output(output: std::process::Output, description: &str) -> io::Result<()> {
|
||||
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)]
|
||||
pub(crate) enum FilterAttributeValue {
|
||||
Driver(String),
|
||||
AmbiguousSentinel(String),
|
||||
}
|
||||
|
||||
pub(crate) struct GitFilterNeutralization {
|
||||
config_override: Option<GitConfigOverrideFile>,
|
||||
filter_config: BTreeMap<String, GitConfigEntry>,
|
||||
/// Complete executable-filter inventory derived from one authorized effective
|
||||
/// config read. The private field prevents a caller from minting an incomplete
|
||||
/// inventory and treating its neutralizer as complete.
|
||||
pub(crate) struct ExecutableFilterDrivers(BTreeSet<String>);
|
||||
|
||||
impl ExecutableFilterDrivers {
|
||||
pub(crate) fn iter(&self) -> impl Iterator<Item = &String> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, driver: &str) -> bool {
|
||||
self.0.contains(driver)
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitFilterNeutralization {
|
||||
pub(crate) fn git_config_args(&self) -> &[String] {
|
||||
self.config_override
|
||||
.as_ref()
|
||||
.map(GitConfigOverrideFile::git_config_args)
|
||||
.unwrap_or_default()
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FilterPolicyRole {
|
||||
Apply,
|
||||
GitAdd,
|
||||
}
|
||||
|
||||
pub(crate) struct FilterPolicySnapshot {
|
||||
role: FilterPolicyRole,
|
||||
checked_paths: BTreeSet<String>,
|
||||
neutralizer: Option<SealedFilterConfigOverride>,
|
||||
}
|
||||
|
||||
impl FilterPolicySnapshot {
|
||||
pub(crate) fn neutralizer(&self) -> Option<&SealedFilterConfigOverride> {
|
||||
self.neutralizer.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn filter_value(&self, driver: &str, name: &str) -> Option<&str> {
|
||||
effective_filter_value(&self.filter_config, driver, name)
|
||||
pub(crate) fn role(&self) -> FilterPolicyRole {
|
||||
self.role
|
||||
}
|
||||
|
||||
pub(crate) fn selected_filter_policy(
|
||||
&self,
|
||||
driver: &str,
|
||||
required: Option<bool>,
|
||||
) -> SelectedFilterPolicy {
|
||||
classify_selected_filter(&self.filter_config, driver, required)
|
||||
pub(crate) fn contains_checked_path(&self, path: &str) -> bool {
|
||||
self.checked_paths.contains(path)
|
||||
}
|
||||
|
||||
pub(crate) fn checked_paths(&self) -> Vec<String> {
|
||||
self.checked_paths.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,12 +90,21 @@ pub(crate) enum SelectedFilterPolicy {
|
||||
Allowed,
|
||||
}
|
||||
|
||||
const FILTER_NEUTRALIZATION_PLAN: [(&str, &str); 4] = [
|
||||
("clean", ""),
|
||||
("smudge", ""),
|
||||
("process", ""),
|
||||
("required", "false"),
|
||||
];
|
||||
pub(crate) fn validate_executable_driver_count(
|
||||
executable_drivers: &ExecutableFilterDrivers,
|
||||
) -> io::Result<()> {
|
||||
if executable_drivers.len() > MAX_EXECUTABLE_FILTER_DRIVERS {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Git filter driver count {} exceeds the status guard limit {}",
|
||||
executable_drivers.len(),
|
||||
MAX_EXECUTABLE_FILTER_DRIVERS
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ISOLATED_GIT_ENVIRONMENT: [&str; 11] = [
|
||||
"GIT_DIR",
|
||||
@@ -187,44 +133,12 @@ pub(crate) fn isolate_git_command_environment(command: &mut Command) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_no_selected_executable_git_filters(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
pub(crate) fn build_filter_policy_snapshot(
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> 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).map_err(|error| {
|
||||
) -> io::Result<FilterPolicySnapshot> {
|
||||
let entries = read_filter_config(config).map_err(|error| {
|
||||
if matches!(execution, FilterExecution::GitAdd)
|
||||
&& error.kind() == io::ErrorKind::InvalidData
|
||||
{
|
||||
@@ -237,25 +151,26 @@ fn ensure_no_selected_executable_git_filters_for(
|
||||
}
|
||||
})?;
|
||||
let executable_drivers = executable_filter_drivers(&entries)?;
|
||||
let role = match execution {
|
||||
FilterExecution::AnyWorktreeOperation => FilterPolicyRole::Apply,
|
||||
FilterExecution::GitAdd => FilterPolicyRole::GitAdd,
|
||||
};
|
||||
let checked_paths = paths.iter().cloned().collect();
|
||||
if executable_drivers.is_empty() {
|
||||
return Ok(GitFilterNeutralization {
|
||||
config_override: None,
|
||||
filter_config: entries,
|
||||
return Ok(FilterPolicySnapshot {
|
||||
role,
|
||||
checked_paths,
|
||||
neutralizer: None,
|
||||
});
|
||||
}
|
||||
let guard = executable_filter_guard(git, cwd, entries, &executable_drivers)?;
|
||||
let neutralizer = config.build_filter_override(&executable_drivers)?;
|
||||
#[cfg(test)]
|
||||
FILTER_POLICY_OVERLAY_COUNT.with(|count| count.set(count.get() + 1));
|
||||
let paths = paths
|
||||
.iter()
|
||||
.map(|path| path.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
let attributes = read_filter_attributes(
|
||||
git,
|
||||
cwd,
|
||||
&paths,
|
||||
git_config_args,
|
||||
&executable_drivers,
|
||||
&guard,
|
||||
)?;
|
||||
let attributes = read_filter_attributes(config, &paths, &executable_drivers, &neutralizer)?;
|
||||
let mut required_cache = BTreeMap::new();
|
||||
for (path, driver) in &attributes {
|
||||
if !executable_drivers.contains(driver) {
|
||||
@@ -263,14 +178,9 @@ fn ensure_no_selected_executable_git_filters_for(
|
||||
}
|
||||
let refused = match execution {
|
||||
FilterExecution::AnyWorktreeOperation => true,
|
||||
FilterExecution::GitAdd => git_add_filter_is_refused(
|
||||
git,
|
||||
cwd,
|
||||
&guard.filter_config,
|
||||
driver,
|
||||
git_config_args,
|
||||
&mut required_cache,
|
||||
)?,
|
||||
FilterExecution::GitAdd => {
|
||||
git_add_filter_is_refused(config, &entries, driver, &mut required_cache)?
|
||||
}
|
||||
};
|
||||
if refused {
|
||||
return Err(io::Error::new(
|
||||
@@ -282,15 +192,17 @@ fn ensure_no_selected_executable_git_filters_for(
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(guard)
|
||||
Ok(FilterPolicySnapshot {
|
||||
role,
|
||||
checked_paths,
|
||||
neutralizer: Some(neutralizer),
|
||||
})
|
||||
}
|
||||
|
||||
fn git_add_filter_is_refused(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
required_cache: &mut BTreeMap<String, bool>,
|
||||
) -> io::Result<bool> {
|
||||
let required = required_cache.get(driver).copied();
|
||||
@@ -299,7 +211,17 @@ fn git_add_filter_is_refused(
|
||||
SelectedFilterPolicy::Allowed => return Ok(false),
|
||||
SelectedFilterPolicy::NeedsRequiredValue => {}
|
||||
}
|
||||
let required = git_filter_required(git, cwd, driver, git_config_args)?;
|
||||
let required = config
|
||||
.read_bool(&format!("filter.{driver}.required"))
|
||||
.map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing selected Git filter {driver:?} with malformed required value: {error}"
|
||||
),
|
||||
)
|
||||
})?
|
||||
.unwrap_or(false);
|
||||
required_cache.insert(driver.to_string(), required);
|
||||
Ok(matches!(
|
||||
classify_selected_filter(entries, driver, Some(required)),
|
||||
@@ -307,73 +229,8 @@ fn git_add_filter_is_refused(
|
||||
))
|
||||
}
|
||||
|
||||
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")?;
|
||||
let mut guard = GitFilterNeutralization {
|
||||
config_override: None,
|
||||
filter_config,
|
||||
};
|
||||
for driver in executable_drivers {
|
||||
debug_assert_executable_filter_driver(&guard.filter_config, driver);
|
||||
let description = format!("Git filter neutralization for {driver:?}");
|
||||
for (key, value) in filter_neutralization_entries(driver) {
|
||||
config_override.add_value(git, cwd, &key, value, &description)?;
|
||||
}
|
||||
}
|
||||
guard.config_override = Some(config_override);
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
pub(crate) async fn executable_filter_guard_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
filter_config: BTreeMap<String, GitConfigEntry>,
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
) -> io::Result<GitFilterNeutralization> {
|
||||
validate_executable_driver_count(executable_drivers)?;
|
||||
if executable_drivers.is_empty() {
|
||||
return Ok(GitFilterNeutralization {
|
||||
config_override: None,
|
||||
filter_config,
|
||||
});
|
||||
}
|
||||
let config_override = GitConfigOverrideFile::new("filter-neutralization.gitconfig")?;
|
||||
for driver in executable_drivers {
|
||||
debug_assert_executable_filter_driver(&filter_config, driver);
|
||||
let description = format!("Git filter neutralization for {driver:?}");
|
||||
for (key, value) in filter_neutralization_entries(driver) {
|
||||
config_override
|
||||
.add_value_async(git, cwd, &key, value, &description)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(GitFilterNeutralization {
|
||||
config_override: Some(config_override),
|
||||
filter_config,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_executable_driver_count(executable_drivers: &BTreeSet<String>) -> io::Result<()> {
|
||||
if executable_drivers.len() > MAX_EXECUTABLE_FILTER_DRIVERS {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Git filter driver count {} exceeds the status guard limit {}",
|
||||
executable_drivers.len(),
|
||||
MAX_EXECUTABLE_FILTER_DRIVERS
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FilterExecution {
|
||||
pub(crate) enum FilterExecution {
|
||||
AnyWorktreeOperation,
|
||||
GitAdd,
|
||||
}
|
||||
@@ -386,53 +243,18 @@ enum FilterCommand {
|
||||
}
|
||||
|
||||
fn read_filter_config(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
config: &GuardedGitConfig<'_>,
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
read_effective_config_with_fallback(
|
||||
git,
|
||||
cwd,
|
||||
git_config_args,
|
||||
EXECUTABLE_FILTER_CONFIG_PATTERN,
|
||||
"filter",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_filter_config_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
ensure_no_worktree_config_sources_async(git, cwd, git_config_args).await?;
|
||||
read_effective_config_unchecked_async(
|
||||
git,
|
||||
cwd,
|
||||
git_config_args,
|
||||
EXECUTABLE_FILTER_CONFIG_PATTERN,
|
||||
"filter",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn read_effective_config_with_fallback(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
pattern: &str,
|
||||
probe: &str,
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
ensure_no_worktree_config_sources(git, cwd, git_config_args)?;
|
||||
read_effective_config_unchecked(git, cwd, git_config_args, pattern, probe)
|
||||
#[cfg(test)]
|
||||
FILTER_POLICY_READ_COUNT.with(|count| count.set(count.get() + 1));
|
||||
config.read_effective(EXECUTABLE_FILTER_CONFIG_PATTERN, "filter")
|
||||
}
|
||||
|
||||
fn read_filter_attributes(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
paths: &[Vec<u8>],
|
||||
git_config_args: &[String],
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
neutralization: &GitFilterNeutralization,
|
||||
executable_drivers: &ExecutableFilterDrivers,
|
||||
neutralization: &SealedFilterConfigOverride,
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
@@ -441,22 +263,12 @@ fn read_filter_attributes(
|
||||
write_nul_paths(&mut input, paths)?;
|
||||
input.rewind()?;
|
||||
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
let mut command = config.pending_filter_attribute_command(neutralization)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"check-attr",
|
||||
"--stdin",
|
||||
"-z",
|
||||
"filter",
|
||||
])
|
||||
.disable_optional_locks()
|
||||
.args(["--stdin", "-z", "filter"])
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git filter attribute probe failed with status {}: {}",
|
||||
@@ -465,23 +277,14 @@ fn read_filter_attributes(
|
||||
)));
|
||||
}
|
||||
let attributes = parse_filter_attributes(&output.stdout, paths)?;
|
||||
resolve_filter_attribute_sentinels(
|
||||
git,
|
||||
cwd,
|
||||
attributes,
|
||||
git_config_args,
|
||||
executable_drivers,
|
||||
neutralization,
|
||||
)
|
||||
resolve_filter_attribute_sentinels(config, attributes, executable_drivers, neutralization)
|
||||
}
|
||||
|
||||
fn resolve_filter_attribute_sentinels(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
attributes: BTreeMap<Vec<u8>, FilterAttributeValue>,
|
||||
git_config_args: &[String],
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
neutralization: &GitFilterNeutralization,
|
||||
executable_drivers: &ExecutableFilterDrivers,
|
||||
neutralization: &SealedFilterConfigOverride,
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
let mut resolved = BTreeMap::new();
|
||||
let mut probe_budget = SentinelFilterProbeBudget::default();
|
||||
@@ -493,11 +296,9 @@ fn resolve_filter_attribute_sentinels(
|
||||
FilterAttributeValue::AmbiguousSentinel(driver) => {
|
||||
if executable_drivers.contains(&driver)
|
||||
&& sentinel_spelling_selects_filter_driver(
|
||||
git,
|
||||
cwd,
|
||||
config,
|
||||
&path,
|
||||
&driver,
|
||||
git_config_args,
|
||||
neutralization,
|
||||
&mut probe_budget,
|
||||
)?
|
||||
@@ -513,20 +314,16 @@ fn resolve_filter_attribute_sentinels(
|
||||
/// Disambiguate Git's sentinel spellings with required/optional probes. The
|
||||
/// shared guard blanks every known executable driver before either probe.
|
||||
fn sentinel_spelling_selects_filter_driver(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
config: &GuardedGitConfig<'_>,
|
||||
path: &[u8],
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
neutralization: &GitFilterNeutralization,
|
||||
neutralization: &SealedFilterConfigOverride,
|
||||
probe_budget: &mut SentinelFilterProbeBudget,
|
||||
) -> io::Result<bool> {
|
||||
let probe = SentinelSelectionProbe {
|
||||
git,
|
||||
cwd,
|
||||
config,
|
||||
path,
|
||||
driver,
|
||||
git_config_args,
|
||||
neutralization,
|
||||
};
|
||||
let required = probe.run(/*required*/ true, probe_budget)?;
|
||||
@@ -552,12 +349,10 @@ fn sentinel_spelling_selects_filter_driver(
|
||||
}
|
||||
|
||||
struct SentinelSelectionProbe<'a> {
|
||||
git: &'a GitRunner,
|
||||
cwd: &'a Path,
|
||||
config: &'a GuardedGitConfig<'a>,
|
||||
path: &'a [u8],
|
||||
driver: &'a str,
|
||||
git_config_args: &'a [String],
|
||||
neutralization: &'a GitFilterNeutralization,
|
||||
neutralization: &'a SealedFilterConfigOverride,
|
||||
}
|
||||
|
||||
impl SentinelSelectionProbe<'_> {
|
||||
@@ -566,29 +361,26 @@ impl SentinelSelectionProbe<'_> {
|
||||
required: bool,
|
||||
probe_budget: &mut SentinelFilterProbeBudget,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let probe_config_args = sentinel_filter_probe_config_args(
|
||||
self.neutralization.git_config_args(),
|
||||
if !matches!(self.driver, "set" | "unset" | "unspecified") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Git filter sentinel probe requested for a non-sentinel driver",
|
||||
));
|
||||
}
|
||||
let path = git_path_argument(self.path)?;
|
||||
let mut command = self.config.command_for_sentinel_filter_probe(
|
||||
self.neutralization,
|
||||
self.driver,
|
||||
required,
|
||||
)?;
|
||||
let path = git_path_argument(self.path)?;
|
||||
let mut command = self.git.command_for_cwd(self.cwd)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(self.git_config_args)
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
])
|
||||
.args(&probe_config_args)
|
||||
.args(["hash-object", "--stdin"])
|
||||
.disable_optional_locks()
|
||||
.arg("--stdin")
|
||||
.arg("--path")
|
||||
.arg(path)
|
||||
.stdin(Stdio::null());
|
||||
probe_budget.ensure_probe_available()?;
|
||||
let output = self.git.output(command)?;
|
||||
let output = command.output()?;
|
||||
probe_budget.record_completed_probe();
|
||||
Ok(output)
|
||||
}
|
||||
@@ -619,7 +411,29 @@ fn selected_executable_filter_for(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn selected_filter(
|
||||
fn executable_filter_drivers_for(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
execution: FilterExecution,
|
||||
) -> io::Result<BTreeSet<String>> {
|
||||
let mut drivers = BTreeSet::new();
|
||||
for entry in entries.values() {
|
||||
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() {
|
||||
drivers.insert(driver);
|
||||
}
|
||||
}
|
||||
Ok(drivers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn selected_filter(
|
||||
drivers: &BTreeSet<String>,
|
||||
attributes: &BTreeMap<Vec<u8>, String>,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
@@ -641,29 +455,18 @@ fn selected_executable_filter(
|
||||
|
||||
pub(crate) 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>> {
|
||||
) -> io::Result<ExecutableFilterDrivers> {
|
||||
let mut executable_drivers = BTreeSet::new();
|
||||
for entry in entries.values() {
|
||||
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() {
|
||||
let (driver, _command) = filter_driver_and_command(&entry.key)?;
|
||||
if !entry.value.is_empty() {
|
||||
executable_drivers.insert(driver);
|
||||
}
|
||||
}
|
||||
Ok(executable_drivers)
|
||||
Ok(ExecutableFilterDrivers(executable_drivers))
|
||||
}
|
||||
|
||||
fn effective_filter_value<'a>(
|
||||
@@ -676,21 +479,7 @@ fn effective_filter_value<'a>(
|
||||
.map(|entry| entry.value.as_str())
|
||||
}
|
||||
|
||||
fn filter_neutralization_entries(
|
||||
driver: &str,
|
||||
) -> impl Iterator<Item = (String, &'static str)> + '_ {
|
||||
FILTER_NEUTRALIZATION_PLAN
|
||||
.into_iter()
|
||||
.map(move |(name, value)| (format!("filter.{driver}.{name}"), value))
|
||||
}
|
||||
|
||||
fn debug_assert_executable_filter_driver(entries: &BTreeMap<String, GitConfigEntry>, driver: &str) {
|
||||
debug_assert!(["clean", "smudge", "process"].into_iter().any(|name| {
|
||||
effective_filter_value(entries, driver, name).is_some_and(|value| !value.is_empty())
|
||||
}));
|
||||
}
|
||||
|
||||
fn classify_selected_filter(
|
||||
pub(crate) fn classify_selected_filter(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
driver: &str,
|
||||
required: Option<bool>,
|
||||
@@ -706,51 +495,6 @@ fn classify_selected_filter(
|
||||
Some(false) => SelectedFilterPolicy::Allowed,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_filter_required(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
driver: &str,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<bool> {
|
||||
let mut command = git.command_for_cwd(cwd)?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(git_config_args)
|
||||
.args(["config", "--type=bool", "--get"])
|
||||
.arg(format!("filter.{driver}.required"));
|
||||
let output = git.output(command)?;
|
||||
parse_git_filter_required_output(&output, driver)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_filter_required_output(
|
||||
output: &std::process::Output,
|
||||
driver: &str,
|
||||
) -> io::Result<bool> {
|
||||
if output.status.code() == Some(1) && output.stdout.is_empty() && 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)
|
||||
@@ -859,6 +603,28 @@ fn invalid_filter_output(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FILTER_POLICY_READ_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static FILTER_POLICY_OVERLAY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_filter_policy_counts() {
|
||||
FILTER_POLICY_READ_COUNT.with(|count| count.set(0));
|
||||
FILTER_POLICY_OVERLAY_COUNT.with(|count| count.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn filter_policy_read_count() -> usize {
|
||||
FILTER_POLICY_READ_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn filter_policy_overlay_count() -> usize {
|
||||
FILTER_POLICY_OVERLAY_COUNT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "safe_git_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,15 +3,13 @@ use crate::GitReadError;
|
||||
use crate::apply::ApplyGitRequest;
|
||||
use crate::apply::apply_git_patch;
|
||||
use crate::get_has_changes;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_config::GitConfigScope;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
#[cfg(unix)]
|
||||
use crate::patch_paths::stage_paths;
|
||||
#[cfg(unix)]
|
||||
use crate::status_guard::git_root_from_stdout;
|
||||
#[cfg(unix)]
|
||||
use crate::status_guard::prepare_status_filter_guard;
|
||||
#[cfg(unix)]
|
||||
use crate::status_guard::resolve_git_root_async;
|
||||
use crate::status_guard::prepare_status_config;
|
||||
use crate::try_get_has_changes;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -33,11 +31,11 @@ fn status_filter_driver_limit_is_exact() {
|
||||
let at_limit = (0..MAX_EXECUTABLE_FILTER_DRIVERS)
|
||||
.map(|index| format!("driver-{index}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(validate_executable_driver_count(&at_limit).is_ok());
|
||||
assert!(validate_executable_driver_count(&ExecutableFilterDrivers(at_limit.clone())).is_ok());
|
||||
|
||||
let mut over_limit = at_limit;
|
||||
over_limit.insert("one-too-many".to_string());
|
||||
let error = validate_executable_driver_count(&over_limit)
|
||||
let error = validate_executable_driver_count(&ExecutableFilterDrivers(over_limit))
|
||||
.expect_err("driver after exact limit must be refused");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
@@ -187,40 +185,6 @@ fn selected_filter_checkin_policy_table_is_canonical() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_and_async_filter_guards_share_the_neutralization_plan() {
|
||||
let repo = init_filter_repo();
|
||||
let root = repo.path();
|
||||
let git = GitRunner::for_cwd_io(root).expect("Git runner");
|
||||
let entries = filter_entries(
|
||||
GitConfigScope::Local,
|
||||
Path::new(".git/config"),
|
||||
"filter.demo.smudge",
|
||||
"helper-command",
|
||||
);
|
||||
let drivers = BTreeSet::from(["demo".to_string()]);
|
||||
|
||||
let sync_guard = executable_filter_guard(&git, root, entries.clone(), &drivers)
|
||||
.expect("sync neutralization");
|
||||
let async_guard = executable_filter_guard_async(&git, root, entries, &drivers)
|
||||
.await
|
||||
.expect("async neutralization");
|
||||
let read_plan = |guard: &GitFilterNeutralization| {
|
||||
read_filter_config(&git, root, guard.git_config_args())
|
||||
.expect("read effective neutralization")
|
||||
.into_iter()
|
||||
.filter(|(key, _entry)| key.starts_with("filter.demo."))
|
||||
.map(|(key, entry)| (key, entry.value))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
let expected = filter_neutralization_entries("demo")
|
||||
.map(|(key, value)| (key, value.to_string()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(read_plan(&sync_guard), expected);
|
||||
assert_eq!(read_plan(&async_guard), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_snapshot_retains_required_without_treating_it_as_executable() {
|
||||
let mut entries = filter_entries(
|
||||
@@ -236,7 +200,9 @@ fn filter_snapshot_retains_required_without_treating_it_as_executable() {
|
||||
"true",
|
||||
));
|
||||
assert_eq!(
|
||||
executable_filter_drivers(&entries).expect("executable drivers"),
|
||||
executable_filter_drivers(&entries)
|
||||
.expect("executable drivers")
|
||||
.0,
|
||||
BTreeSet::from(["demo".to_string()])
|
||||
);
|
||||
let selected = BTreeMap::from([(b"file.txt".to_vec(), "demo".to_string())]);
|
||||
@@ -245,13 +211,11 @@ fn filter_snapshot_retains_required_without_treating_it_as_executable() {
|
||||
.expect("Git add filter policy"),
|
||||
None
|
||||
);
|
||||
let neutralization = GitFilterNeutralization {
|
||||
config_override: None,
|
||||
filter_config: entries,
|
||||
};
|
||||
assert_eq!(
|
||||
neutralization.filter_value("demo", "required"),
|
||||
Some("true")
|
||||
entries
|
||||
.get("filter.demo.required")
|
||||
.map(|entry| entry.value.as_str()),
|
||||
Some("true"),
|
||||
);
|
||||
|
||||
let required_only = filter_entries(
|
||||
@@ -381,9 +345,12 @@ fn sentinel_special_states_and_literal_driver_names_remain_distinct() {
|
||||
let root = repo.path();
|
||||
configure_marker_filter(root, driver);
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let mut config =
|
||||
GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorized config sources");
|
||||
|
||||
std::fs::write(root.join(".gitattributes"), special_rule).expect("write special attribute");
|
||||
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[])
|
||||
config
|
||||
.authorize_filter_paths(&["file.txt".to_string()])
|
||||
.expect("allow special attribute state");
|
||||
assert!(!marker_filter_ran(root), "special {driver}");
|
||||
|
||||
@@ -392,8 +359,9 @@ fn sentinel_special_states_and_literal_driver_names_remain_distinct() {
|
||||
format!("file.txt filter={driver}\n"),
|
||||
)
|
||||
.expect("write literal attribute");
|
||||
let result =
|
||||
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[]);
|
||||
let mut literal_config = GuardedGitConfig::authorize(&git, root, Vec::new())
|
||||
.expect("authorize literal-attribute operation");
|
||||
let result = literal_config.authorize_filter_paths(&["file.txt".to_string()]);
|
||||
let error = match result {
|
||||
Ok(_) => panic!("accepted literal sentinel-named driver {driver}"),
|
||||
Err(error) => error,
|
||||
@@ -414,9 +382,12 @@ fn sentinel_probe_neutralizes_every_known_driver_after_attribute_swap() {
|
||||
.expect("write initial attribute");
|
||||
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let entries = read_filter_config(&git, root, &[]).expect("filter config");
|
||||
let config =
|
||||
GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorized config sources");
|
||||
let entries = read_filter_config(&config).expect("filter config");
|
||||
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
|
||||
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
|
||||
let neutralization = config
|
||||
.build_filter_override(&executable_drivers)
|
||||
.expect("filter neutralization");
|
||||
let output = run_git_success(root, &["check-attr", "-z", "filter", "--", "file.txt"]);
|
||||
let attributes = parse_filter_attributes(&output.stdout, &[b"file.txt".to_vec()])
|
||||
@@ -428,10 +399,8 @@ fn sentinel_probe_neutralizes_every_known_driver_after_attribute_swap() {
|
||||
)
|
||||
.expect("swap attribute after snapshot");
|
||||
let resolved = resolve_filter_attribute_sentinels(
|
||||
&git,
|
||||
root,
|
||||
&config,
|
||||
attributes,
|
||||
&[],
|
||||
&executable_drivers,
|
||||
&neutralization,
|
||||
)
|
||||
@@ -447,9 +416,12 @@ fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() {
|
||||
let root = repo.path();
|
||||
configure_marker_filter(root, "unspecified");
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let entries = read_filter_config(&git, root, &[]).expect("filter config");
|
||||
let config =
|
||||
GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorized config sources");
|
||||
let entries = read_filter_config(&config).expect("filter config");
|
||||
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
|
||||
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
|
||||
let neutralization = config
|
||||
.build_filter_override(&executable_drivers)
|
||||
.expect("filter neutralization");
|
||||
let attributes = (0..=SentinelFilterProbeBudget::max_probes())
|
||||
.map(|index| {
|
||||
@@ -461,10 +433,8 @@ fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() {
|
||||
.collect();
|
||||
|
||||
let error = resolve_filter_attribute_sentinels(
|
||||
&git,
|
||||
root,
|
||||
&config,
|
||||
attributes,
|
||||
&[],
|
||||
&executable_drivers,
|
||||
&neutralization,
|
||||
)
|
||||
@@ -477,36 +447,31 @@ fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_sentinel_probe_failures_remain_generic_and_fail_closed() {
|
||||
fn sentinel_probe_rejects_non_sentinel_driver_before_launch() {
|
||||
let repo = init_filter_repo();
|
||||
let root = repo.path();
|
||||
configure_marker_filter(root, "set");
|
||||
std::fs::write(root.join(".gitattributes"), "file.txt filter\n")
|
||||
.expect("write special attribute");
|
||||
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
|
||||
let entries = read_filter_config(&git, root, &[]).expect("filter config");
|
||||
let config =
|
||||
GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorized config sources");
|
||||
let entries = read_filter_config(&config).expect("filter config");
|
||||
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
|
||||
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
|
||||
let neutralization = config
|
||||
.build_filter_override(&executable_drivers)
|
||||
.expect("filter neutralization");
|
||||
let mut budget = SentinelFilterProbeBudget::default();
|
||||
let malformed_config = ["-c".to_string(), "=".to_string()];
|
||||
|
||||
let error = sentinel_spelling_selects_filter_driver(
|
||||
&git,
|
||||
root,
|
||||
&config,
|
||||
b"file.txt",
|
||||
"set",
|
||||
&malformed_config,
|
||||
"ordinary",
|
||||
&neutralization,
|
||||
&mut budget,
|
||||
)
|
||||
.expect_err("malformed config must fail both probes");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Other);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.starts_with("git filter attribute selection probe failed with required status")
|
||||
);
|
||||
.expect_err("reject non-sentinel probe");
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(!marker_filter_ran(root));
|
||||
}
|
||||
|
||||
@@ -781,15 +746,6 @@ async fn checked_has_changes_accepts_non_utf8_repository_root() {
|
||||
assert_eq!(try_get_has_changes(&repo_path).await, Ok(false));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn git_root_output_preserves_non_utf8_bytes() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let path = git_root_from_stdout(b"/tmp/repo-\xff\n".to_vec()).expect("parse raw Git path");
|
||||
assert_eq!(path.as_os_str().as_bytes(), b"/tmp/repo-\xff");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn root_probe_distinguishes_repository_command_failure() {
|
||||
@@ -798,7 +754,25 @@ async fn root_probe_distinguishes_repository_command_failure() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
let failing_git = temp_dir.path().join("failing-git");
|
||||
std::fs::write(&failing_git, "#!/bin/sh\nexit 42\n").expect("write failing Git");
|
||||
let resolved_git = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "command -v git"])
|
||||
.output()
|
||||
.expect("resolve real Git");
|
||||
assert!(resolved_git.status.success(), "resolve real Git");
|
||||
let real_git = format!(
|
||||
"'{}'",
|
||||
String::from_utf8(resolved_git.stdout)
|
||||
.expect("Git path UTF-8")
|
||||
.trim()
|
||||
.replace('\'', "'\\''")
|
||||
);
|
||||
std::fs::write(
|
||||
&failing_git,
|
||||
format!(
|
||||
"#!/bin/sh\ncase \" $* \" in *\" rev-parse --show-toplevel \"*) exit 42 ;; esac\nexec {real_git} \"$@\"\n"
|
||||
),
|
||||
)
|
||||
.expect("write failing Git");
|
||||
let mut permissions = std::fs::metadata(&failing_git)
|
||||
.expect("read failing Git metadata")
|
||||
.permissions();
|
||||
@@ -808,7 +782,7 @@ async fn root_probe_distinguishes_repository_command_failure() {
|
||||
let git = GitRunner::from_executable_for_test(&repo_path, failing_git)
|
||||
.expect("authority-bound failing Git");
|
||||
assert_eq!(
|
||||
resolve_git_root_async(&git, &repo_path).await,
|
||||
prepare_status_config(&git, &repo_path).await.map(|_| ()),
|
||||
Err(GitReadError::CommandFailed {
|
||||
operation: "resolveGitRoot".to_string(),
|
||||
exit_code: Some(42),
|
||||
@@ -860,9 +834,7 @@ async fn legacy_git_without_show_scope_uses_origin_fallback() {
|
||||
let git = GitRunner::from_executable_for_test(&repo_path, wrapper)
|
||||
.expect("authority-bound legacy Git");
|
||||
assert_eq!(
|
||||
prepare_status_filter_guard(&git, &repo_path)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
prepare_status_config(&git, &repo_path).await.map(|_| ()),
|
||||
Err(GitReadError::SelectedExecutableFilter {
|
||||
driver: "legacy".to_string(),
|
||||
path: "test.txt".to_string(),
|
||||
@@ -898,6 +870,13 @@ async fn get_has_changes_rejects_core_worktree_redirection_before_running_filter
|
||||
.await;
|
||||
|
||||
assert_eq!(get_has_changes(&repo_path).await, None);
|
||||
assert_eq!(
|
||||
try_get_has_changes(&repo_path).await,
|
||||
Err(GitReadError::RepositoryRootMismatch {
|
||||
expected_root: std::fs::canonicalize(&repo_path).expect("canonical repository"),
|
||||
reported_root: std::fs::canonicalize(&redirected).expect("canonical redirection"),
|
||||
})
|
||||
);
|
||||
assert!(!configured_filter_ran(&repo_path).await);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,384 +1,105 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::io::Seek;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::FsmonitorOverride;
|
||||
use crate::GitReadError;
|
||||
use crate::git_command::GitAsyncCommand;
|
||||
use crate::git_command::GitRunner;
|
||||
use crate::git_command::MAX_INTERNAL_GIT_OUTPUT_BYTES;
|
||||
use crate::guarded_config::GuardedGitConfig;
|
||||
use crate::guarded_config::NoActiveStatusWorktree;
|
||||
use crate::guarded_config::SelectedStatusFilterRefusal;
|
||||
use crate::guarded_config::StatusFilterProbeLimitExceeded;
|
||||
use crate::guarded_config::StatusPolicyCommandFailure;
|
||||
use crate::guarded_config::StatusRootMismatch;
|
||||
use crate::repository_authority::is_authority_refusal;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
use crate::safe_git::FilterAttributeValue;
|
||||
#[cfg(all(test, unix))]
|
||||
use crate::safe_git::GIT_COMMAND_TIMEOUT;
|
||||
use crate::safe_git::GitFilterNeutralization;
|
||||
use crate::safe_git::SelectedFilterPolicy;
|
||||
use crate::safe_git::SentinelFilterProbeBudget;
|
||||
use crate::safe_git::SentinelFilterProbeResolution;
|
||||
use crate::safe_git::classify_sentinel_filter_probes;
|
||||
use crate::safe_git::executable_filter_drivers;
|
||||
use crate::safe_git::executable_filter_guard_async;
|
||||
use crate::safe_git::git_path_argument;
|
||||
use crate::safe_git::parse_filter_attributes;
|
||||
use crate::safe_git::parse_git_filter_required_output;
|
||||
use crate::safe_git::parse_nul_paths;
|
||||
use crate::safe_git::read_filter_config_async;
|
||||
use crate::safe_git::sentinel_filter_probe_config_args;
|
||||
use crate::safe_git::write_nul_paths;
|
||||
|
||||
const MAX_STATUS_TRACKED_PATHS: usize = 250_000;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) async fn prepare_status_filter_guard(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> Result<(PathBuf, GitFilterNeutralization), GitReadError> {
|
||||
timeout(
|
||||
GIT_COMMAND_TIMEOUT,
|
||||
Box::pin(prepare_status_filter_guard_within_deadline(git, cwd)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| GitReadError::CommandTimedOut {
|
||||
operation: "statusFilterPreparation".to_string(),
|
||||
})?
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_status_filter_guard_within_deadline(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> Result<(PathBuf, GitFilterNeutralization), GitReadError> {
|
||||
let requested_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
|
||||
path: cwd.to_path_buf(),
|
||||
})?;
|
||||
let authorized_root = git
|
||||
.active_worktree_root()
|
||||
.ok_or_else(|| GitReadError::NotRepository {
|
||||
path: requested_cwd.clone(),
|
||||
})?
|
||||
.to_path_buf();
|
||||
// Source authorization precedes the first config-consuming Git child and
|
||||
// is performed once for the retained active repository authority.
|
||||
let entries = read_filter_config_async(git, &authorized_root, &[])
|
||||
.await
|
||||
.map_err(|error| map_io_error("filterConfig", error))?;
|
||||
let git_root = resolve_git_root_async(git, &requested_cwd).await?;
|
||||
let executable_drivers =
|
||||
executable_filter_drivers(&entries).map_err(|_| invalid_output("filterConfig"))?;
|
||||
let filter_guard = executable_filter_guard_async(git, &git_root, entries, &executable_drivers)
|
||||
.await
|
||||
.map_err(|error| map_io_error("filterNeutralization", error))?;
|
||||
if executable_drivers.is_empty() {
|
||||
return Ok((git_root, filter_guard));
|
||||
}
|
||||
|
||||
let paths = read_tracked_paths_async(git, &git_root).await?;
|
||||
let attributes = read_filter_attributes_async(git, &git_root, &paths).await?;
|
||||
let attributes = resolve_filter_attribute_sentinels_async(
|
||||
git,
|
||||
&git_root,
|
||||
attributes,
|
||||
&executable_drivers,
|
||||
&filter_guard,
|
||||
)
|
||||
.await?;
|
||||
let mut required_cache = BTreeMap::new();
|
||||
for (path, driver) in attributes {
|
||||
if !executable_drivers.contains(&driver) {
|
||||
continue;
|
||||
}
|
||||
let mut policy =
|
||||
filter_guard.selected_filter_policy(&driver, required_cache.get(&driver).copied());
|
||||
if policy == SelectedFilterPolicy::NeedsRequiredValue {
|
||||
let required = git_filter_required_async(git, &git_root, &driver).await?;
|
||||
required_cache.insert(driver.clone(), required);
|
||||
policy = filter_guard.selected_filter_policy(&driver, Some(required));
|
||||
}
|
||||
if policy == SelectedFilterPolicy::Refused {
|
||||
return Err(GitReadError::SelectedExecutableFilter {
|
||||
driver,
|
||||
path: String::from_utf8_lossy(&path).into_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok((git_root, filter_guard))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_git_root_async(
|
||||
git: &GitRunner,
|
||||
/// One retained capability owns source authorization, filter policy, fsmonitor
|
||||
/// selection, and the final bounded status child. The caller supplies the
|
||||
/// phase transitions so the one outer deadline can report precise metadata.
|
||||
pub(crate) async fn prepare_status_config<'git>(
|
||||
git: &'git GitRunner,
|
||||
requested_cwd: &Path,
|
||||
) -> Result<PathBuf, GitReadError> {
|
||||
let expected_root = git
|
||||
.active_worktree_root()
|
||||
.ok_or_else(|| GitReadError::NotRepository {
|
||||
path: requested_cwd.to_path_buf(),
|
||||
})?
|
||||
.to_path_buf();
|
||||
let mut command = git
|
||||
.async_command_for_cwd(requested_cwd)
|
||||
) -> Result<GuardedGitConfig<'git>, GitReadError> {
|
||||
let mut config = GuardedGitConfig::authorize_status_async(git)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error
|
||||
.get_ref()
|
||||
.is_some_and(|source| source.is::<NoActiveStatusWorktree>())
|
||||
{
|
||||
GitReadError::NotRepository {
|
||||
path: requested_cwd.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
map_io_error("statusFilterPreparation", error)
|
||||
}
|
||||
})?;
|
||||
config
|
||||
.verify_status_root_async(requested_cwd)
|
||||
.await
|
||||
.map_err(|error| map_io_error("resolveGitRoot", error))?;
|
||||
command.env("GIT_OPTIONAL_LOCKS", "0").args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
]);
|
||||
let output = command_output(git, command, "resolveGitRoot").await?;
|
||||
config
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.map_err(|error| map_io_error("statusFilterPreparation", error))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) async fn detect_status_fsmonitor(
|
||||
config: &mut GuardedGitConfig<'_>,
|
||||
) -> FsmonitorOverride {
|
||||
config.detect_status_fsmonitor_async().await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_status(config: &GuardedGitConfig<'_>) -> Result<bool, GitReadError> {
|
||||
let output = config
|
||||
.status_output_async()
|
||||
.await
|
||||
.map_err(|error| map_io_error("status", error))?;
|
||||
if !output.status.success() {
|
||||
return Err(command_failed("resolveGitRoot", output.status.code()));
|
||||
}
|
||||
let reported_root = git_root_from_stdout(output.stdout).and_then(|path| {
|
||||
std::fs::canonicalize(path).map_err(|_| invalid_output("resolveGitRoot"))
|
||||
})?;
|
||||
if reported_root != expected_root {
|
||||
return Err(GitReadError::RepositoryRootMismatch {
|
||||
expected_root,
|
||||
reported_root,
|
||||
return Err(GitReadError::CommandFailed {
|
||||
operation: "status".to_string(),
|
||||
exit_code: output.status.code(),
|
||||
});
|
||||
}
|
||||
Ok(reported_root)
|
||||
}
|
||||
|
||||
pub(crate) fn git_root_from_stdout(output: Vec<u8>) -> Result<PathBuf, GitReadError> {
|
||||
let output = output.strip_suffix(b"\n").unwrap_or(&output);
|
||||
#[cfg(windows)]
|
||||
let output = output.strip_suffix(b"\r").unwrap_or(output);
|
||||
if output.is_empty() {
|
||||
return Err(invalid_output("resolveGitRoot"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
Ok(PathBuf::from(OsString::from_vec(output.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
String::from_utf8(output.to_vec())
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| invalid_output("resolveGitRoot"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_tracked_paths_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
) -> Result<Vec<Vec<u8>>, GitReadError> {
|
||||
let mut command = git
|
||||
.async_command_for_cwd(cwd)
|
||||
.map_err(|error| map_io_error("trackedPaths", error))?;
|
||||
command.env("GIT_OPTIONAL_LOCKS", "0").args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--cached",
|
||||
]);
|
||||
let output = command_output(git, command, "trackedPaths").await?;
|
||||
if !output.status.success() {
|
||||
return Err(command_failed("trackedPaths", output.status.code()));
|
||||
}
|
||||
let paths = parse_nul_paths(&output.stdout).map_err(|_| invalid_output("trackedPaths"))?;
|
||||
if paths.len() > MAX_STATUS_TRACKED_PATHS {
|
||||
return Err(invalid_output("trackedPaths"));
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
async fn read_filter_attributes_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[Vec<u8>],
|
||||
) -> Result<BTreeMap<Vec<u8>, FilterAttributeValue>, GitReadError> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let mut input =
|
||||
tempfile::tempfile().map_err(|error| map_io_error("filterAttributes", error))?;
|
||||
write_nul_paths(&mut input, paths).map_err(|_| invalid_output("filterAttributes"))?;
|
||||
input
|
||||
.rewind()
|
||||
.map_err(|error| map_io_error("filterAttributes", error))?;
|
||||
|
||||
let mut command = git
|
||||
.async_command_for_cwd(cwd)
|
||||
.map_err(|error| map_io_error("filterAttributes", error))?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"check-attr",
|
||||
"--stdin",
|
||||
"-z",
|
||||
"filter",
|
||||
])
|
||||
.stdin(Stdio::from(input));
|
||||
let output = command_output(git, command, "filterAttributes").await?;
|
||||
if !output.status.success() {
|
||||
return Err(command_failed("filterAttributes", output.status.code()));
|
||||
}
|
||||
parse_filter_attributes(&output.stdout, paths).map_err(|_| invalid_output("filterAttributes"))
|
||||
}
|
||||
|
||||
async fn resolve_filter_attribute_sentinels_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
attributes: BTreeMap<Vec<u8>, FilterAttributeValue>,
|
||||
executable_drivers: &BTreeSet<String>,
|
||||
filter_guard: &GitFilterNeutralization,
|
||||
) -> Result<BTreeMap<Vec<u8>, String>, GitReadError> {
|
||||
let mut resolved = BTreeMap::new();
|
||||
let mut probe_budget = SentinelFilterProbeBudget::default();
|
||||
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_async(
|
||||
git,
|
||||
cwd,
|
||||
&path,
|
||||
&driver,
|
||||
filter_guard,
|
||||
&mut probe_budget,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
resolved.insert(path, driver);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn sentinel_spelling_selects_filter_driver_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
path: &[u8],
|
||||
driver: &str,
|
||||
filter_guard: &GitFilterNeutralization,
|
||||
probe_budget: &mut SentinelFilterProbeBudget,
|
||||
) -> Result<bool, GitReadError> {
|
||||
let required = run_sentinel_selection_probe_async(
|
||||
git,
|
||||
cwd,
|
||||
path,
|
||||
driver,
|
||||
/*required*/ true,
|
||||
filter_guard,
|
||||
probe_budget,
|
||||
)
|
||||
.await?;
|
||||
if classify_sentinel_filter_probes(required.status.success(), /*optional_succeeded*/ None)
|
||||
== SentinelFilterProbeResolution::SpecialAttributeState
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let optional = run_sentinel_selection_probe_async(
|
||||
git,
|
||||
cwd,
|
||||
path,
|
||||
driver,
|
||||
/*required*/ false,
|
||||
filter_guard,
|
||||
probe_budget,
|
||||
)
|
||||
.await?;
|
||||
if classify_sentinel_filter_probes(required.status.success(), Some(optional.status.success()))
|
||||
== SentinelFilterProbeResolution::LiteralDriver
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
Err(command_failed(
|
||||
"filterAttributeSelection",
|
||||
optional.status.code(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_sentinel_selection_probe_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
path: &[u8],
|
||||
driver: &str,
|
||||
required: bool,
|
||||
filter_guard: &GitFilterNeutralization,
|
||||
probe_budget: &mut SentinelFilterProbeBudget,
|
||||
) -> Result<std::process::Output, GitReadError> {
|
||||
probe_budget.ensure_probe_available().map_err(|_| {
|
||||
GitReadError::FilterSelectionProbeLimitExceeded {
|
||||
max_probes: SentinelFilterProbeBudget::max_probes(),
|
||||
}
|
||||
})?;
|
||||
let path = git_path_argument(path).map_err(|_| invalid_output("filterAttributeSelection"))?;
|
||||
let probe_config_args =
|
||||
sentinel_filter_probe_config_args(filter_guard.git_config_args(), driver, required)
|
||||
.map_err(|_| invalid_output("filterAttributeSelection"))?;
|
||||
let mut command = git
|
||||
.async_command_for_cwd(cwd)
|
||||
.map_err(|error| map_io_error("filterAttributeSelection", error))?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args([
|
||||
"-c",
|
||||
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
])
|
||||
.args(&probe_config_args)
|
||||
.args(["hash-object", "--stdin"])
|
||||
.arg("--path")
|
||||
.arg(path)
|
||||
.stdin(Stdio::null());
|
||||
let output = command_output(git, command, "filterAttributeSelection").await?;
|
||||
probe_budget.record_completed_probe();
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn git_filter_required_async(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
driver: &str,
|
||||
) -> Result<bool, GitReadError> {
|
||||
let mut command = git
|
||||
.async_command_for_cwd(cwd)
|
||||
.map_err(|error| map_io_error("filterRequired", error))?;
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(["config", "--type=bool", "--get"])
|
||||
.arg(format!("filter.{driver}.required"));
|
||||
let output = command_output(git, command, "filterRequired").await?;
|
||||
parse_git_filter_required_output(&output, driver)
|
||||
.map_err(|error| map_io_error("filterRequired", error))
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
git: &GitRunner,
|
||||
command: GitAsyncCommand,
|
||||
operation: &str,
|
||||
) -> Result<std::process::Output, GitReadError> {
|
||||
git.output_async_bounded(command, MAX_INTERNAL_GIT_OUTPUT_BYTES)
|
||||
.await
|
||||
.map_err(|error| map_io_error(operation, error))
|
||||
Ok(!output.stdout.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn map_io_error(operation: &str, error: io::Error) -> GitReadError {
|
||||
if let Some(refusal) = error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<SelectedStatusFilterRefusal>())
|
||||
{
|
||||
return GitReadError::SelectedExecutableFilter {
|
||||
driver: refusal.driver().to_string(),
|
||||
path: String::from_utf8_lossy(refusal.path()).into_owned(),
|
||||
};
|
||||
}
|
||||
if let Some(mismatch) = error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<StatusRootMismatch>())
|
||||
{
|
||||
return GitReadError::RepositoryRootMismatch {
|
||||
expected_root: mismatch.expected().to_path_buf(),
|
||||
reported_root: mismatch.reported().to_path_buf(),
|
||||
};
|
||||
}
|
||||
if let Some(limit) = error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<StatusFilterProbeLimitExceeded>())
|
||||
{
|
||||
return GitReadError::FilterSelectionProbeLimitExceeded {
|
||||
max_probes: limit.max_probes(),
|
||||
};
|
||||
}
|
||||
if let Some(failure) = error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<StatusPolicyCommandFailure>())
|
||||
{
|
||||
return GitReadError::CommandFailed {
|
||||
operation: operation.to_string(),
|
||||
exit_code: failure.exit_code(),
|
||||
};
|
||||
}
|
||||
if is_authority_refusal(&error) {
|
||||
return GitReadError::AuthorityRefused {
|
||||
operation: operation.to_string(),
|
||||
@@ -388,21 +109,13 @@ pub(crate) fn map_io_error(operation: &str, error: io::Error) -> GitReadError {
|
||||
io::ErrorKind::TimedOut => GitReadError::CommandTimedOut {
|
||||
operation: operation.to_string(),
|
||||
},
|
||||
io::ErrorKind::InvalidData => invalid_output(operation),
|
||||
_ => command_failed(operation, /*exit_code*/ None),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_failed(operation: &str, exit_code: Option<i32>) -> GitReadError {
|
||||
GitReadError::CommandFailed {
|
||||
operation: operation.to_string(),
|
||||
exit_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_output(operation: &str) -> GitReadError {
|
||||
GitReadError::InvalidOutput {
|
||||
operation: operation.to_string(),
|
||||
io::ErrorKind::InvalidData => GitReadError::InvalidOutput {
|
||||
operation: operation.to_string(),
|
||||
},
|
||||
_ => GitReadError::CommandFailed {
|
||||
operation: operation.to_string(),
|
||||
exit_code: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use pretty_assertions::assert_eq;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::*;
|
||||
use crate::safe_git::SentinelFilterProbeBudget;
|
||||
|
||||
async fn run_git(cwd: &Path, args: &[&str]) -> std::process::Output {
|
||||
let output = Command::new("git")
|
||||
@@ -124,8 +125,11 @@ async fn dropping_status_preparation_kills_the_active_git_child() {
|
||||
);
|
||||
let task_git = std::sync::Arc::clone(&git);
|
||||
let task_repo = repo.clone();
|
||||
let task =
|
||||
tokio::spawn(async move { prepare_status_filter_guard(&task_git, &task_repo).await });
|
||||
let task = tokio::spawn(async move {
|
||||
prepare_status_config(&task_git, &task_repo)
|
||||
.await
|
||||
.map(|_| ())
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while !pid_file.exists() {
|
||||
@@ -193,23 +197,14 @@ async fn prepared_guard_neutralizes_filter_selected_after_the_probe() {
|
||||
.await;
|
||||
|
||||
let git = GitRunner::for_cwd(&repo).expect("trusted Git");
|
||||
let (root, guard) = prepare_status_filter_guard(&git, &repo)
|
||||
let mut guard = prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("prepare status guard");
|
||||
std::fs::write(repo.join(".gitattributes"), "test.txt filter=race\n")
|
||||
.expect("select filter after probe");
|
||||
|
||||
let mut command = git
|
||||
.async_command_for_cwd(&root)
|
||||
.expect("authorized status cwd");
|
||||
command
|
||||
.args(["-c", &format!("core.hooksPath={DISABLED_HOOKS_PATH}")])
|
||||
.args(["-c", "core.fsmonitor=false"])
|
||||
.args(guard.git_config_args())
|
||||
.args(["status", "--porcelain"]);
|
||||
let output = git.output_async(command).await.expect("run guarded status");
|
||||
|
||||
assert!(output.status.success(), "guarded status succeeds");
|
||||
detect_status_fsmonitor(&mut guard).await;
|
||||
assert!(read_status(&guard).await.is_ok(), "guarded status succeeds");
|
||||
assert!(!filter_marker_is_set(&repo, "codex.race-ran").await);
|
||||
}
|
||||
|
||||
@@ -232,14 +227,14 @@ async fn optional_smudge_only_filter_is_allowed_but_required_is_refused() {
|
||||
.await;
|
||||
|
||||
let git = GitRunner::for_cwd(&repo).expect("trusted Git");
|
||||
prepare_status_filter_guard(&git, &repo)
|
||||
prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("allow optional smudge-only filter");
|
||||
assert!(!filter_marker_is_set(&repo, "codex.smudge-ran").await);
|
||||
|
||||
run_git(&repo, &["config", "filter.smudge-only.required", "true"]).await;
|
||||
assert_eq!(
|
||||
prepare_status_filter_guard(&git, &repo).await.map(|_| ()),
|
||||
prepare_status_config(&git, &repo).await.map(|_| ()),
|
||||
Err(GitReadError::SelectedExecutableFilter {
|
||||
driver: "smudge-only".to_string(),
|
||||
path: "test.txt".to_string(),
|
||||
@@ -281,7 +276,7 @@ async fn optional_smudge_required_value_is_queried_once_per_driver() {
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
|
||||
prepare_status_filter_guard(&git, &repo)
|
||||
prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("allow optional smudge-only filter");
|
||||
let required_queries = std::fs::read_to_string(log)
|
||||
@@ -324,7 +319,7 @@ async fn sentinel_probe_neutralizes_a_race_selected_known_driver() {
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
|
||||
prepare_status_filter_guard(&git, &repo)
|
||||
prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("probe remains non-executing across attribute race");
|
||||
assert!(!filter_marker_is_set(&repo, "codex.probe-filter-ran").await);
|
||||
@@ -359,7 +354,7 @@ async fn sentinel_probe_budget_fails_closed_at_the_exact_process_limit() {
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
Box::pin(prepare_status_filter_guard_within_deadline(&git, &repo)),
|
||||
Box::pin(prepare_status_config(&git, &repo)),
|
||||
)
|
||||
.await
|
||||
.expect("sentinel budget test should complete");
|
||||
@@ -402,9 +397,11 @@ async fn ordinary_driver_uses_one_bulk_path_and_attribute_probe() {
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
|
||||
prepare_status_filter_guard(&git, &repo)
|
||||
let mut config = prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("prepare ordinary-driver guard");
|
||||
detect_status_fsmonitor(&mut config).await;
|
||||
read_status(&config).await.expect("final guarded status");
|
||||
let lines = std::fs::read_to_string(log).expect("read wrapper log");
|
||||
assert_eq!(
|
||||
lines
|
||||
@@ -427,5 +424,152 @@ async fn ordinary_driver_uses_one_bulk_path_and_attribute_probe() {
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let status = lines
|
||||
.lines()
|
||||
.find(|line| line.contains(" status --porcelain --ignore-submodules=dirty"))
|
||||
.expect("final status command");
|
||||
let hooks = status
|
||||
.find("core.hooksPath=")
|
||||
.expect("hooks override before status");
|
||||
let fsmonitor = status
|
||||
.find("core.fsmonitor=")
|
||||
.expect("fsmonitor override before status");
|
||||
let filter = status
|
||||
.find("include.path=")
|
||||
.expect("sealed filter include before status");
|
||||
let status_command = status
|
||||
.find(" status --porcelain")
|
||||
.expect("status subcommand");
|
||||
assert!(hooks < fsmonitor && fsmonitor < filter && filter < status_command);
|
||||
assert_eq!(status.matches("include.path=").count(), 1);
|
||||
assert!(!filter_marker_is_set(&repo, "codex.ordinary-filter-ran").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fsmonitor_probe_is_base_only_and_the_sealed_decision_controls_final_order() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo = init_repo(&temp_dir, /*file_count*/ 0).await;
|
||||
run_git(&repo, &["config", "core.fsmonitor", "true"]).await;
|
||||
|
||||
let wrapper = temp_dir.path().join("git-wrapper");
|
||||
let log = temp_dir.path().join("git-wrapper.log");
|
||||
let real_git = shell_quote(&real_git());
|
||||
write_wrapper(
|
||||
&wrapper,
|
||||
&format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >>{}\ncase \" $* \" in *\" version --build-options \"*) printf 'feature: fsmonitor--daemon\\n'; exit 0 ;; esac\nexec {real_git} \"$@\"\n",
|
||||
shell_quote(&log)
|
||||
),
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
let mut config = prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("prepare status capability");
|
||||
std::fs::write(&log, "").expect("clear preparation log");
|
||||
|
||||
assert_eq!(
|
||||
detect_status_fsmonitor(&mut config).await,
|
||||
FsmonitorOverride::BuiltIn
|
||||
);
|
||||
assert_eq!(
|
||||
detect_status_fsmonitor(&mut config).await,
|
||||
FsmonitorOverride::BuiltIn,
|
||||
"the retained decision must avoid a second probe"
|
||||
);
|
||||
read_status(&config).await.expect("final status");
|
||||
|
||||
let lines = std::fs::read_to_string(&log)
|
||||
.expect("read wrapper log")
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
3,
|
||||
"one config probe, one feature probe, one status"
|
||||
);
|
||||
assert!(lines[0].ends_with("config --null --get core.fsmonitor"));
|
||||
assert!(!lines[0].contains("core.fsmonitor=false"));
|
||||
assert!(lines[1].ends_with("version --build-options"));
|
||||
assert!(!lines[1].contains("core.fsmonitor=false"));
|
||||
let hooks = format!("core.hooksPath={}", crate::safe_git::DISABLED_HOOKS_PATH);
|
||||
let hooks_offset = lines[2].find(&hooks).expect("hooks override");
|
||||
let fsmonitor_offset = lines[2]
|
||||
.find("core.fsmonitor=true")
|
||||
.expect("typed fsmonitor override");
|
||||
let status_offset = lines[2].find("status --porcelain").expect("status command");
|
||||
assert!(hooks_offset < fsmonitor_offset && fsmonitor_offset < status_offset);
|
||||
assert!(lines[2].ends_with("status --porcelain --ignore-submodules=dirty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_driver_status_skips_tracked_paths_attributes_and_overlay_writes() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo = init_repo(&temp_dir, /*file_count*/ 0).await;
|
||||
let wrapper = temp_dir.path().join("git-wrapper");
|
||||
let log = temp_dir.path().join("git-wrapper.log");
|
||||
let real_git = shell_quote(&real_git());
|
||||
write_wrapper(
|
||||
&wrapper,
|
||||
&format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >>{}\nexec {real_git} \"$@\"\n",
|
||||
shell_quote(&log)
|
||||
),
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
|
||||
prepare_status_config(&git, &repo)
|
||||
.await
|
||||
.expect("zero-driver status capability");
|
||||
let lines = std::fs::read_to_string(log).expect("read wrapper log");
|
||||
assert!(!lines.contains(" ls-files -z --cached"), "{lines}");
|
||||
assert!(!lines.contains(" check-attr --stdin -z filter"), "{lines}");
|
||||
assert!(!lines.contains(" config --file "), "{lines}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_driver_limit_fails_before_paths_or_overlay_writes() {
|
||||
use std::io::Write;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let repo = init_repo(&temp_dir, /*file_count*/ 0).await;
|
||||
let mut local_config = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(repo.join(".git/config"))
|
||||
.expect("open local config");
|
||||
for index in 0..=crate::safe_git::MAX_EXECUTABLE_FILTER_DRIVERS {
|
||||
writeln!(local_config, "[filter \"driver-{index}\"]\n\tclean = cat")
|
||||
.expect("append filter driver");
|
||||
}
|
||||
drop(local_config);
|
||||
|
||||
let wrapper = temp_dir.path().join("git-wrapper");
|
||||
let log = temp_dir.path().join("git-wrapper.log");
|
||||
let real_git = shell_quote(&real_git());
|
||||
write_wrapper(
|
||||
&wrapper,
|
||||
&format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >>{}\nexec {real_git} \"$@\"\n",
|
||||
shell_quote(&log)
|
||||
),
|
||||
);
|
||||
let git = GitRunner::from_executable_for_test(&repo, wrapper).expect("test Git runner");
|
||||
let mut config = GuardedGitConfig::authorize_status_async(&git)
|
||||
.await
|
||||
.expect("authorized status capability");
|
||||
config
|
||||
.verify_status_root_async(&repo)
|
||||
.await
|
||||
.expect("matching root");
|
||||
std::fs::write(&log, "").expect("clear authorization log");
|
||||
|
||||
let error = config
|
||||
.install_status_policy_async()
|
||||
.await
|
||||
.expect_err("257th driver must fail closed");
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||
let lines = std::fs::read_to_string(log).expect("read wrapper log");
|
||||
assert!(!lines.contains(" ls-files -z --cached"), "{lines}");
|
||||
assert!(!lines.contains(" check-attr --stdin -z filter"), "{lines}");
|
||||
assert!(!lines.contains(" config --file "), "{lines}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user