From e8b30bd7d321b011306fb938607906f39a330c72 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Thu, 2 Jul 2026 00:46:29 -0700 Subject: [PATCH] git-utils: integrate filter guards with repository authority --- codex-rs/git-utils/src/apply.rs | 9 +- codex-rs/git-utils/src/apply_filter_tests.rs | 140 ++ codex-rs/git-utils/src/filter_sentinel.rs | 71 + codex-rs/git-utils/src/git_command.rs | 20 + codex-rs/git-utils/src/git_command_tests.rs | 101 ++ codex-rs/git-utils/src/git_config.rs | 232 ++- codex-rs/git-utils/src/git_config_sources.rs | 140 ++ .../src/git_config_sources/include_graph.rs | 120 ++ .../src/git_config_sources/path_safety.rs | 56 + .../src/git_config_sources/primary_sources.rs | 272 +++ .../git-utils/src/git_config_sources_tests.rs | 1569 +++++++++++++++++ codex-rs/git-utils/src/git_config_tests.rs | 51 +- codex-rs/git-utils/src/lib.rs | 1 + codex-rs/git-utils/src/patch_paths.rs | 52 +- codex-rs/git-utils/src/patch_paths_tests.rs | 20 +- codex-rs/git-utils/src/path_authority.rs | 2 +- .../repository_authority/authority/policy.rs | 33 + codex-rs/git-utils/src/safe_git.rs | 222 ++- codex-rs/git-utils/src/safe_git_tests.rs | 259 ++- 19 files changed, 3197 insertions(+), 173 deletions(-) create mode 100644 codex-rs/git-utils/src/filter_sentinel.rs create mode 100644 codex-rs/git-utils/src/git_config_sources.rs create mode 100644 codex-rs/git-utils/src/git_config_sources/include_graph.rs create mode 100644 codex-rs/git-utils/src/git_config_sources/path_safety.rs create mode 100644 codex-rs/git-utils/src/git_config_sources/primary_sources.rs create mode 100644 codex-rs/git-utils/src/git_config_sources_tests.rs diff --git a/codex-rs/git-utils/src/apply.rs b/codex-rs/git-utils/src/apply.rs index 18cf45aea4..d9c68fc2ae 100644 --- a/codex-rs/git-utils/src/apply.rs +++ b/codex-rs/git-utils/src/apply.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; 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::patch_paths::extract_effective_paths_from_patch; use crate::patch_paths::stage_effective_paths; use crate::safe_git::DISABLED_HOOKS_PATH; @@ -48,13 +49,19 @@ pub struct ApplyGitResult { pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { 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)?; + } // 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_paths = extract_effective_paths_from_patch(&git, &patch_path, req.revert)?; + let patch_paths = + extract_effective_paths_from_patch(&git, &git_root, &patch_path, req.revert, &cfg_parts)?; let filter_guard = ensure_no_selected_executable_git_filters(&git, &git_root, &patch_paths, &cfg_parts)?; cfg_parts.extend(safe_git_config_parts()); diff --git a/codex-rs/git-utils/src/apply_filter_tests.rs b/codex-rs/git-utils/src/apply_filter_tests.rs index 8536f9617c..12da828fa6 100644 --- a/codex-rs/git-utils/src/apply_filter_tests.rs +++ b/codex-rs/git-utils/src/apply_filter_tests.rs @@ -4,6 +4,9 @@ use std::ffi::OsStr; use std::fs::File; use std::fs::FileTimes; use std::path::Path; +use std::thread; +use std::time::Duration; +use std::time::Instant; use std::time::UNIX_EPOCH; const PATCH: &str = @@ -46,6 +49,7 @@ fn init_repo() -> tempfile::TempDir { run_success(root, &["init"]); run_success(root, &["config", "user.email", "codex@example.com"]); run_success(root, &["config", "user.name", "Codex"]); + run_success(root, &["config", "core.autocrlf", "false"]); std::fs::write(root.join("file.txt"), "orig\n").expect("write file"); run_success(root, &["add", "file.txt"]); run_success(root, &["commit", "-m", "seed"]); @@ -200,6 +204,21 @@ fn run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) { ); } +fn wait_for_config_source_probe(trace: &Path) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let contents = std::fs::read_to_string(trace).unwrap_or_default(); + if contents + .find("^include") + .is_some_and(|offset| contents[offset..].contains("\"event\":\"exit\"")) + { + return true; + } + thread::yield_now(); + } + false +} + #[test] fn reverse_staging_uses_command_scoped_filter_override() { if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() { @@ -235,6 +254,127 @@ fn reverse_staging_uses_command_scoped_filter_override() { ); } +#[cfg(unix)] +#[test] +fn apply_rejects_worktree_primary_config_fifo_before_any_git_launch() { + use std::io::Write as _; + + const TEST_NAME: &str = + "apply::filter_tests::apply_rejects_worktree_primary_config_fifo_before_any_git_launch"; + if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() { + let repo = init_repo(); + let primary_config = repo.path().join("worktree-global.fifo"); + let status = std::process::Command::new("mkfifo") + .arg(&primary_config) + .status() + .expect("run mkfifo"); + assert!(status.success(), "mkfifo failed: {status}"); + let trace = tempfile::NamedTempFile::new().expect("trace file"); + run_isolated_test( + TEST_NAME, + &[ + ("CODEX_APPLY_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", primary_config.as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("GIT_TRACE2_EVENT", trace.path().as_os_str()), + ], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_APPLY_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + let trace = PathBuf::from(std::env::var_os("GIT_TRACE2_EVENT").expect("trace path")); + let primary_config = + PathBuf::from(std::env::var_os("GIT_CONFIG_GLOBAL").expect("worktree primary config FIFO")); + // Keep the FIFO readable and prefill a valid config so a regression starts + // and finishes instead of hanging the test process while opening it. + let mut fifo_guard = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(primary_config) + .expect("open worktree primary config FIFO"); + fifo_guard + .write_all(b"[user]\n\tname = worktree-controlled\n") + .expect("prefill worktree primary config FIFO"); + let release_fifo = thread::spawn(move || { + thread::sleep(Duration::from_secs(1)); + drop(fifo_guard); + }); + let error = apply_git_patch(&request( + &root, /*revert*/ false, /*preflight*/ true, + )) + .expect_err("reject worktree-controlled primary config"); + release_fifo.join().expect("release config FIFO"); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("worktree-controlled"), "{error}"); + assert_eq!( + std::fs::read_to_string(trace).expect("read Git trace"), + "", + "config authorization must reject before rev-parse or numstat starts Git", + ); + assert_eq!( + std::fs::read_to_string(root.join("file.txt")).expect("read worktree file"), + "orig\n" + ); +} + +#[test] +fn apply_rejects_unseen_driver_added_through_worktree_include_after_source_probe() { + const TEST_NAME: &str = "apply::filter_tests::apply_rejects_unseen_driver_added_through_worktree_include_after_source_probe"; + if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() { + let trace = tempfile::NamedTempFile::new().expect("trace file"); + run_isolated_test(TEST_NAME, &[("GIT_TRACE2_EVENT", trace.path().as_os_str())]); + return; + } + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(".gitattributes"), "file.txt filter=fresh\n") + .expect("write filter attributes"); + run_success(root, &["add", ".gitattributes"]); + run_success(root, &["commit", "-m", "filter target"]); + let included = root.join("driver-config"); + std::fs::write(&included, "").expect("write initially empty included config"); + run_success(root, &["config", "include.path", "../driver-config"]); + + let trace = PathBuf::from(std::env::var_os("GIT_TRACE2_EVENT").expect("trace path")); + std::fs::write(&trace, "").expect("clear fixture trace"); + let watcher_trace = trace.clone(); + let watcher = thread::spawn(move || { + let observed = wait_for_config_source_probe(&watcher_trace); + if observed { + std::fs::write( + included, + format!("[filter \"fresh\"]\n\tclean = {FILTER_COMMAND}\n\trequired = true\n"), + ) + .expect("add previously unseen filter driver"); + } + observed + }); + + let error = apply_git_patch(&request( + root, /*revert*/ false, /*preflight*/ false, + )) + .expect_err("reject worktree config source before final apply"); + assert!(watcher.join().expect("config watcher")); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(!configured_filter_ran(root)); + assert_eq!(index_contents(root, "file.txt"), "orig\n"); + assert_eq!( + std::fs::read_to_string(root.join("file.txt")).expect("read worktree file"), + "orig\n" + ); + assert!( + !std::fs::read_to_string(trace) + .expect("read completed trace") + .contains("--3way") + ); +} + #[test] fn apply_treats_empty_name_filter_as_selected_only() { for command in ["clean", "smudge", "process"] { diff --git a/codex-rs/git-utils/src/filter_sentinel.rs b/codex-rs/git-utils/src/filter_sentinel.rs new file mode 100644 index 0000000000..7847ac3e63 --- /dev/null +++ b/codex-rs/git-utils/src/filter_sentinel.rs @@ -0,0 +1,71 @@ +use std::io; + +const MAX_SENTINEL_FILTER_CHILD_PROBES: usize = 16; + +#[derive(Debug, Default)] +pub(crate) struct SentinelFilterProbeBudget { + completed_child_probes: usize, +} + +impl SentinelFilterProbeBudget { + #[cfg(test)] + pub(crate) const fn max_probes() -> usize { + MAX_SENTINEL_FILTER_CHILD_PROBES + } + + pub(crate) fn ensure_probe_available(&self) -> io::Result<()> { + if self.completed_child_probes >= MAX_SENTINEL_FILTER_CHILD_PROBES { + return Err(io::Error::other(format!( + "refusing to continue Git filter sentinel disambiguation after {} child probes (hard limit: {})", + self.completed_child_probes, MAX_SENTINEL_FILTER_CHILD_PROBES + ))); + } + Ok(()) + } + + pub(crate) fn record_completed_probe(&mut self) { + debug_assert!(self.completed_child_probes < MAX_SENTINEL_FILTER_CHILD_PROBES); + self.completed_child_probes += 1; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SentinelFilterProbeResolution { + SpecialAttributeState, + NeedsOptionalProbe, + LiteralDriver, + ProbeFailure, +} + +pub(crate) fn classify_sentinel_filter_probes( + required_succeeded: bool, + optional_succeeded: Option, +) -> SentinelFilterProbeResolution { + if required_succeeded { + SentinelFilterProbeResolution::SpecialAttributeState + } else { + match optional_succeeded { + None => SentinelFilterProbeResolution::NeedsOptionalProbe, + Some(true) => SentinelFilterProbeResolution::LiteralDriver, + Some(false) => SentinelFilterProbeResolution::ProbeFailure, + } + } +} + +pub(crate) fn sentinel_filter_probe_config_args( + neutralization_args: &[String], + driver: &str, + required: bool, +) -> io::Result> { + if !matches!(driver, "set" | "unset" | "unspecified") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Git filter sentinel probe requested for a non-sentinel driver", + )); + } + let mut args = Vec::with_capacity(neutralization_args.len() + 2); + args.extend_from_slice(neutralization_args); + args.push("-c".to_string()); + args.push(format!("filter.{driver}.required={required}")); + Ok(args) +} diff --git a/codex-rs/git-utils/src/git_command.rs b/codex-rs/git-utils/src/git_command.rs index e78420b842..5519a006f2 100644 --- a/codex-rs/git-utils/src/git_command.rs +++ b/codex-rs/git-utils/src/git_command.rs @@ -3,6 +3,7 @@ use std::io; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::process::Stdio; use crate::errors::GitReadError; #[cfg(test)] @@ -57,6 +58,16 @@ impl GitCommand { self.inner.env(key, value); self } + + pub(crate) fn env_remove(&mut self, key: impl AsRef) -> &mut Self { + self.inner.env_remove(key); + self + } + + pub(crate) fn stdin(&mut self, config: impl Into) -> &mut Self { + self.inner.stdin(config); + self + } } impl GitRunner { @@ -97,6 +108,15 @@ impl GitRunner { Ok(command) } + pub(crate) fn ensure_config_source_is_not_worktree_controlled( + &self, + path: &Path, + description: &str, + ) -> io::Result<()> { + self.authority + .ensure_config_source_is_not_worktree_controlled(path, description) + } + pub(crate) fn output(&self, mut command: GitCommand) -> io::Result { self.revalidate_active_repository_metadata()?; isolate_git_command_environment(&mut command.inner); diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 2679f744fc..77366c0633 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -153,6 +153,16 @@ fn locations_for_root(root: &Path) -> RepositoryAuthority { .expect("test repository authority") } +fn config_authority_for_root(root: &Path) -> RepositoryAuthority { + let mut roots = vec![root.to_path_buf()]; + let canonical = std::fs::canonicalize(root).expect("canonical root"); + if !roots.contains(&canonical) { + roots.push(canonical); + } + RepositoryAuthority::from_test_locations(roots.clone(), roots, vec![root.join(".git")]) + .expect("config repository authority") +} + fn raw_parent_traversal(root: &Path, sibling: &str) -> PathBuf { let separator = std::path::MAIN_SEPARATOR.to_string(); let mut path = root.as_os_str().to_os_string(); @@ -292,6 +302,97 @@ fn git_metadata_marker_parser_preserves_leading_path_space() { assert!(parse_git_marker_path(b"gitdir:/missing-space\n", b"gitdir: ").is_err()); } +#[test] +fn config_source_authority_requires_an_absolute_raw_path() { + let fixture = tempdir_for_native_git(); + let root = fixture.path().join("repo"); + std::fs::create_dir_all(&root).expect("create repository"); + run_git(&root, &["init", "-q"]); + let authority = config_authority_for_root(&root); + let path = Path::new("relative/config"); + + let error = authority + .ensure_config_source_is_not_worktree_controlled(path, "test config") + .expect_err("relative config path"); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!(error.to_string().contains("test config"), "{error}"); + assert!(error.to_string().contains("relative/config"), "{error}"); +} + +#[test] +fn config_source_authority_preserves_and_rejects_a_worktree_crossing_spelling() { + let fixture = tempdir_for_native_git(); + let root = fixture.path().join("repo"); + let safe = fixture.path().join("safe"); + std::fs::create_dir_all(root.join("nested")).expect("create repository descendant"); + std::fs::create_dir_all(&safe).expect("create safe directory"); + run_git(&root, &["init", "-q"]); + let authority = config_authority_for_root(&root); + let raw = root + .join("nested") + .join("..") + .join("..") + .join("safe/config"); + + let error = authority + .ensure_config_source_is_not_worktree_controlled(&raw, "crossing config") + .expect_err("worktree-crossing config path"); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!(error.to_string().contains("crossing config"), "{error}"); + assert!( + error.to_string().contains(&raw.display().to_string()), + "{error}" + ); +} + +#[test] +fn config_source_authority_allows_protected_metadata_and_unrelated_external_paths() { + let fixture = tempdir_for_native_git(); + let root = fixture.path().join("repo"); + let external = fixture.path().join("external/config"); + std::fs::create_dir_all(&root).expect("create repository"); + std::fs::create_dir_all(external.parent().expect("external parent")) + .expect("create external directory"); + run_git(&root, &["init", "-q"]); + let authority = config_authority_for_root(&root); + + authority + .ensure_config_source_is_not_worktree_controlled( + &root.join(".git/config"), + "protected metadata config", + ) + .expect("protected metadata config"); + authority + .ensure_config_source_is_not_worktree_controlled(&external, "external config") + .expect("unrelated external config"); +} + +#[test] +fn config_source_authority_rejects_an_unregistered_related_repository_root() { + let fixture = tempdir_for_native_git(); + let root = fixture.path().join("repo"); + let alias = fixture.path().join("unregistered-related-root"); + std::fs::create_dir_all(&root).expect("create repository"); + std::fs::create_dir_all(&alias).expect("create related root"); + run_git(&root, &["init", "-q"]); + std::fs::write( + alias.join(".git"), + format!("gitdir: {}\n", root.join(".git").display()), + ) + .expect("write related metadata marker"); + let authority = config_authority_for_root(&root); + let path = alias.join("config"); + + let error = authority + .ensure_config_source_is_not_worktree_controlled(&path, "related config") + .expect_err("unregistered related repository config"); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!(error.to_string().contains("related config"), "{error}"); +} + fn selected_git(locations: &RepositoryAuthority, directories: &[&Path]) -> PathBuf { let search_path = std::env::join_paths(directories).expect("PATH"); select_git_executable(locations, &search_path) diff --git a/codex-rs/git-utils/src/git_config.rs b/codex-rs/git-utils/src/git_config.rs index a08bdc8d31..d476dea374 100644 --- a/codex-rs/git-utils/src/git_config.rs +++ b/codex-rs/git-utils/src/git_config.rs @@ -3,8 +3,11 @@ use std::io; use std::path::Component; use std::path::Path; +use crate::git_command::GitRunner; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum GitConfigScope { + Unknown, System, Global, Local, @@ -15,16 +18,30 @@ pub(crate) enum GitConfigScope { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct GitConfigEntry { pub(crate) scope: GitConfigScope, - pub(crate) origin: String, + pub(crate) origin: GitConfigOrigin, pub(crate) key: String, pub(crate) value: String, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum GitConfigOrigin { + CommandLine, + File(std::path::PathBuf), +} + +#[cfg(test)] pub(crate) fn parse_effective_config( output: &[u8], ) -> io::Result> { + Ok(parse_config_entries(output)? + .into_iter() + .map(|entry| (entry.key.clone(), entry)) + .collect()) +} + +pub(crate) fn parse_config_entries(output: &[u8]) -> io::Result> { if output.is_empty() { - return Ok(BTreeMap::new()); + return Ok(Vec::new()); } let Some(body) = output.strip_suffix(&[0]) else { return Err(invalid_config_output("unterminated Git config output")); @@ -34,13 +51,10 @@ pub(crate) fn parse_effective_config( return Err(invalid_config_output("incomplete Git config record")); } - let mut effective = BTreeMap::new(); + let mut entries = Vec::new(); for record in fields.chunks_exact(3) { let scope = parse_scope(record[0])?; - let origin = parse_utf8_field(record[1], "config origin")?; - if origin.is_empty() { - return Err(invalid_config_output("empty Git config origin")); - } + let origin = parse_config_origin(record[1])?; let entry = parse_utf8_field(record[2], "config key/value")?; let Some((key, value)) = entry.split_once('\n') else { return Err(invalid_config_output( @@ -52,24 +66,32 @@ pub(crate) fn parse_effective_config( } let entry = GitConfigEntry { scope, - origin: origin.to_string(), + origin, key: key.to_string(), value: value.to_string(), }; - effective.insert(key.to_string(), entry); + entries.push(entry); } - Ok(effective) + Ok(entries) } /// Parse the `--show-origin` form used by Git versions that predate /// `config --show-scope`. The record order still reflects effective config /// precedence, which is what helper selection relies on. Scope is retained as /// best-effort metadata only; command-line entries remain distinguishable. +#[cfg(test)] pub(crate) fn parse_effective_config_with_origins( output: &[u8], ) -> io::Result> { + Ok(parse_config_entries_with_origins(output)? + .into_iter() + .map(|entry| (entry.key.clone(), entry)) + .collect()) +} + +pub(crate) fn parse_config_entries_with_origins(output: &[u8]) -> io::Result> { if output.is_empty() { - return Ok(BTreeMap::new()); + return Ok(Vec::new()); } let Some(body) = output.strip_suffix(&[0]) else { return Err(invalid_config_output("unterminated Git config output")); @@ -79,12 +101,9 @@ pub(crate) fn parse_effective_config_with_origins( return Err(invalid_config_output("incomplete Git config record")); } - let mut effective = BTreeMap::new(); + let mut entries = Vec::new(); for record in fields.chunks_exact(2) { - let origin = parse_utf8_field(record[0], "config origin")?; - if origin.is_empty() { - return Err(invalid_config_output("empty Git config origin")); - } + let origin = parse_config_origin(record[0])?; let entry = parse_utf8_field(record[1], "config key/value")?; let Some((key, value)) = entry.split_once('\n') else { return Err(invalid_config_output( @@ -94,24 +113,156 @@ pub(crate) fn parse_effective_config_with_origins( if key.is_empty() { return Err(invalid_config_output("empty Git config key")); } - let scope = if origin == "command line:" { + let scope = if origin == GitConfigOrigin::CommandLine { GitConfigScope::Command } else { // Older Git does not expose the scope. Selection depends on the // emitted precedence order, not on this informational label. GitConfigScope::Local }; - effective.insert( - key.to_string(), - GitConfigEntry { - scope, - origin: origin.to_string(), - key: key.to_string(), - value: value.to_string(), - }, - ); + entries.push(GitConfigEntry { + scope, + origin, + key: key.to_string(), + value: value.to_string(), + }); } - Ok(effective) + Ok(entries) +} + +pub(crate) fn read_effective_config_entries_with_fallback( + git: &GitRunner, + cwd: &Path, + git_config_args: &[String], + pattern: &str, + probe: &str, +) -> io::Result> { + read_config_entries_with_fallback( + git, + cwd, + git_config_args, + pattern, + probe, + /*follow_includes*/ true, + /*config_file*/ None, + ) +} + +pub(crate) fn read_config_entries_without_includes( + git: &GitRunner, + cwd: &Path, + git_config_args: &[String], + pattern: &str, + probe: &str, + config_file: Option<&Path>, +) -> io::Result> { + read_config_entries_with_fallback( + git, + cwd, + git_config_args, + pattern, + probe, + /*follow_includes*/ false, + config_file, + ) +} + +fn read_config_entries_with_fallback( + git: &GitRunner, + cwd: &Path, + git_config_args: &[String], + pattern: &str, + probe: &str, + follow_includes: bool, + config_file: Option<&Path>, +) -> io::Result> { + let scoped = run_effective_config_query( + git, + cwd, + git_config_args, + pattern, + /*show_scope*/ true, + follow_includes, + config_file, + )?; + if scoped + .status + .code() + .is_some_and(|code| code == 0 || code == 1) + { + return parse_config_entries(&scoped.stdout); + } + + let legacy = run_effective_config_query( + git, + cwd, + git_config_args, + pattern, + /*show_scope*/ false, + follow_includes, + config_file, + )?; + if !legacy + .status + .code() + .is_some_and(|code| code == 0 || code == 1) + { + return Err(io::Error::other(format!( + "git {probe} config probe failed with status {}: {}", + legacy.status, + String::from_utf8_lossy(&legacy.stderr).trim() + ))); + } + parse_config_entries_with_origins(&legacy.stdout) +} + +pub(crate) fn read_effective_config_with_fallback( + git: &GitRunner, + cwd: &Path, + git_config_args: &[String], + pattern: &str, + probe: &str, +) -> io::Result> { + Ok( + read_effective_config_entries_with_fallback(git, cwd, git_config_args, pattern, probe)? + .into_iter() + .map(|entry| (entry.key.clone(), entry)) + .collect(), + ) +} + +fn run_effective_config_query( + git: &GitRunner, + cwd: &Path, + git_config_args: &[String], + pattern: &str, + show_scope: bool, + follow_includes: bool, + config_file: Option<&Path>, +) -> io::Result { + let mut command = git.command_for_cwd(cwd)?; + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + .arg("config"); + if let Some(config_file) = config_file { + command.arg("--file").arg(config_file); + } + command.arg("--null"); + if show_scope { + command.arg("--show-scope"); + } + command.args([ + "--show-origin", + if follow_includes { + "--includes" + } else { + "--no-includes" + }, + "--get-regexp", + pattern, + ]); + git.output(command) } pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool { @@ -141,6 +292,7 @@ fn components_equal(left: Component<'_>, right: Component<'_>) -> bool { fn parse_scope(scope: &[u8]) -> io::Result { match scope { + b"unknown" => Ok(GitConfigScope::Unknown), b"system" => Ok(GitConfigScope::System), b"global" => Ok(GitConfigScope::Global), b"local" => Ok(GitConfigScope::Local), @@ -150,6 +302,32 @@ fn parse_scope(scope: &[u8]) -> io::Result { } } +fn parse_config_origin(origin: &[u8]) -> io::Result { + if origin == b"command line:" { + return Ok(GitConfigOrigin::CommandLine); + } + let path = origin + .strip_prefix(b"file:") + .ok_or_else(|| invalid_config_output("unsupported Git config origin"))?; + if path.is_empty() || path.contains(&0) { + return Err(invalid_config_output("empty Git config origin")); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + Ok(GitConfigOrigin::File(std::path::PathBuf::from( + std::ffi::OsString::from_vec(path.to_vec()), + ))) + } + #[cfg(not(unix))] + { + Ok(GitConfigOrigin::File(std::path::PathBuf::from( + parse_utf8_field(path, "Git config origin path")?, + ))) + } +} + fn parse_utf8_field<'a>(field: &'a [u8], name: &str) -> io::Result<&'a str> { std::str::from_utf8(field).map_err(|_| invalid_config_output(&format!("non-UTF-8 {name}"))) } diff --git a/codex-rs/git-utils/src/git_config_sources.rs b/codex-rs/git-utils/src/git_config_sources.rs new file mode 100644 index 0000000000..d67665d064 --- /dev/null +++ b/codex-rs/git-utils/src/git_config_sources.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeSet; +use std::io; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +use crate::git_command::GitRunner; +use crate::git_config::read_config_entries_without_includes; + +mod include_graph; +mod path_safety; +mod primary_sources; + +use include_graph::validate_include_entries; +use path_safety::normalize_absolute_path; +use path_safety::resolve_literal_path; +use primary_sources::default_system_config_source_candidates; +use primary_sources::is_disabled_primary_config_path; +use primary_sources::legacy_primary_config_source_candidates; +use primary_sources::selected_git_home_config_candidates; +use primary_sources::selected_git_prefix_system_candidate; + +const INCLUDE_CONFIG_PATTERN: &str = r"^include(\.path|if\..*\.path)$"; +const MAX_CONFIG_INCLUDE_DEPTH: usize = 10; +const MAX_CONFIG_INCLUDE_FILES: usize = 1024; + +/// Reject configuration that an untrusted worktree writer can change between +/// a policy probe and the Git command it guards. +pub(crate) fn ensure_no_worktree_config_sources( + git: &GitRunner, + git_root: &Path, + git_config_args: &[String], +) -> io::Result<()> { + let git_root = normalize_absolute_path(std::fs::canonicalize(git_root)?)?; + + // Environment, HOME, and XDG paths are attacker-controlled byte strings. + // Classify their exact OsString spelling before `git var` can open or + // newline-delimit one of them. + for (description, candidate) in legacy_primary_config_source_candidates()? { + if !is_disabled_primary_config_path(&candidate) { + reject_source(git, &git_root, &candidate, description)?; + } + } + for candidate in selected_git_home_config_candidates(git, &git_root)? { + reject_source(git, &git_root, &candidate, "selected Git HOME config")?; + } + if let Some(candidate) = selected_git_prefix_system_candidate(git, &git_root)? { + reject_source( + git, + &git_root, + &candidate, + "selected Git prefix system config", + )?; + } + let entries = read_config_entries_without_includes( + git, + &git_root, + git_config_args, + INCLUDE_CONFIG_PATTERN, + "include", + /*config_file*/ None, + )?; + let mut pending = Vec::new(); + validate_include_entries(git, &git_root, entries, /*depth*/ 1, &mut pending)?; + let mut visited = BTreeSet::new(); + while let Some((config_path, depth)) = pending.pop() { + if depth > MAX_CONFIG_INCLUDE_DEPTH { + return Err(path_safety::invalid_config_source( + "Git config include depth exceeded", + )); + } + match std::fs::canonicalize(&config_path) { + Ok(_) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + continue; + } + Err(error) => return Err(error), + } + // The same file reached through two spellings can resolve a relative + // child include differently. Deduplicate only the exact source + // spelling, not its canonical target. + if !visited.insert(config_path.clone()) { + continue; + } + if visited.len() > MAX_CONFIG_INCLUDE_FILES { + return Err(path_safety::invalid_config_source( + "too many Git config include files", + )); + } + let entries = read_config_entries_without_includes( + git, + &git_root, + &[], + INCLUDE_CONFIG_PATTERN, + "include", + Some(&config_path), + )?; + validate_include_entries(git, &git_root, entries, depth + 1, &mut pending)?; + } + // `git var` loads repository config before reporting modern default-system + // paths. Delay that supplemental probe until the complete no-includes + // source graph above has been classified, so it cannot be tricked into + // opening an untrusted include or FIFO first. + for (description, candidate) in default_system_config_source_candidates(git, &git_root)? { + if !is_disabled_primary_config_path(&candidate) { + reject_source(git, &git_root, &candidate, description)?; + } + } + Ok(()) +} + +fn reject_source( + git: &GitRunner, + cwd: &Path, + candidate: &Path, + description: &str, +) -> io::Result<()> { + let absolute = resolve_literal_path(candidate, cwd); + git.ensure_config_source_is_not_worktree_controlled(&absolute, description) +} + +#[cfg(test)] +use crate::git_config::GitConfigEntry; +#[cfg(test)] +use codex_utils_absolute_path::AbsolutePathBuf; +#[cfg(test)] +use include_graph::expand_git_config_path; +#[cfg(test)] +use include_graph::resolve_include_path; +#[cfg(test)] +use path_safety::windows_config_path_is_ambiguous; + +#[cfg(test)] +#[path = "git_config_sources_tests.rs"] +mod tests; diff --git a/codex-rs/git-utils/src/git_config_sources/include_graph.rs b/codex-rs/git-utils/src/git_config_sources/include_graph.rs new file mode 100644 index 0000000000..da267505f5 --- /dev/null +++ b/codex-rs/git-utils/src/git_config_sources/include_graph.rs @@ -0,0 +1,120 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use super::path_safety::CONFIG_PATH_KEY; +use super::path_safety::invalid_config_source; +use super::path_safety::reject_raw_ambiguous_windows_config_path; +use super::path_safety::resolve_literal_path; +use super::reject_source; +use crate::git_command::GitRunner; +use crate::git_config::GitConfigEntry; +use crate::git_config::GitConfigOrigin; + +pub(super) fn validate_include_entries( + git: &GitRunner, + git_root: &Path, + entries: Vec, + depth: usize, + pending: &mut Vec<(PathBuf, usize)>, +) -> io::Result<()> { + for entry in entries { + if !is_include_path(&entry.key) { + return Err(invalid_config_source("unexpected Git config include key")); + } + if let Some(origin) = config_file_origin(&entry, git_root)? { + reject_source(git, git_root, &origin, "Git config origin")?; + } + let include = resolve_include_path(git, git_root, &entry)?; + reject_source(git, git_root, &include, "Git config include")?; + pending.push((include, depth)); + } + Ok(()) +} + +fn config_file_origin(entry: &GitConfigEntry, cwd: &Path) -> io::Result> { + match &entry.origin { + GitConfigOrigin::CommandLine => Ok(None), + GitConfigOrigin::File(path) => Ok(Some(resolve_literal_path(path, cwd))), + } +} + +fn is_include_path(key: &str) -> bool { + key == "include.path" || key.starts_with("includeif.") && key.ends_with(".path") +} + +pub(super) fn resolve_include_path( + git: &GitRunner, + cwd: &Path, + entry: &GitConfigEntry, +) -> io::Result { + let raw = entry.value.as_str(); + if raw.is_empty() { + return Err(invalid_config_source("empty Git config include path")); + } + // Unlike generic `git config --path`, include.path treats `:(...)` as + // literal path text. Bypass the generic path expander for those spellings + // so the validated path exactly matches Git's include loader. + let expanded = if raw.starts_with(":(") { + PathBuf::from(raw) + } else { + expand_git_config_path(git, cwd, raw)? + }; + reject_raw_ambiguous_windows_config_path( + expanded + .to_str() + .ok_or_else(|| invalid_config_source("non-UTF-8 Git include path"))?, + )?; + let base = match config_file_origin(entry, cwd)? { + Some(origin) => origin + .parent() + .ok_or_else(|| invalid_config_source("Git config origin has no parent"))? + .to_path_buf(), + None if expanded.is_absolute() => cwd.to_path_buf(), + None => { + return Err(invalid_config_source( + "relative Git config include has no file origin", + )); + } + }; + Ok(resolve_literal_path(expanded, &base)) +} + +pub(super) fn expand_git_config_path( + git: &GitRunner, + cwd: &Path, + raw: &str, +) -> io::Result { + let mut command = git.command_for_cwd(cwd)?; + command + .arg("-c") + .arg(format!("{CONFIG_PATH_KEY}={raw}")) + .args([ + "config", + "--null", + "--no-includes", + "--path", + "--get", + CONFIG_PATH_KEY, + ]); + let output = git.output(command)?; + if !output.status.success() { + return Err(io::Error::other(format!( + "git include path expansion failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let value = output + .stdout + .strip_suffix(&[0]) + .ok_or_else(|| invalid_config_source("unterminated Git include path expansion"))?; + if value.is_empty() || value.contains(&0) { + return Err(invalid_config_source( + "ambiguous Git include path expansion", + )); + } + let value = std::str::from_utf8(value) + .map_err(|_| invalid_config_source("non-UTF-8 Git include path expansion"))?; + Ok(PathBuf::from(value)) +} diff --git a/codex-rs/git-utils/src/git_config_sources/path_safety.rs b/codex-rs/git-utils/src/git_config_sources/path_safety.rs new file mode 100644 index 0000000000..42509affdf --- /dev/null +++ b/codex-rs/git-utils/src/git_config_sources/path_safety.rs @@ -0,0 +1,56 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +pub(super) const CONFIG_PATH_KEY: &str = "codex.config-source.path"; + +#[cfg(unix)] +pub(super) fn git_var_path_from_bytes(path: &[u8]) -> io::Result { + use std::os::unix::ffi::OsStringExt; + + Ok(PathBuf::from(std::ffi::OsString::from_vec(path.to_vec()))) +} + +#[cfg(not(unix))] +pub(super) fn git_var_path_from_bytes(path: &[u8]) -> io::Result { + Ok(PathBuf::from(std::str::from_utf8(path).map_err(|_| { + invalid_config_source("non-UTF-8 Git config source path") + })?)) +} + +pub(super) fn resolve_literal_path(path: impl AsRef, base: &Path) -> PathBuf { + let path = path.as_ref(); + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +pub(super) fn normalize_absolute_path(path: impl AsRef) -> io::Result { + Ok(AbsolutePathBuf::from_absolute_path(path)?.into_path_buf()) +} + +#[cfg(windows)] +pub(super) fn reject_raw_ambiguous_windows_config_path(path: &str) -> io::Result<()> { + if windows_config_path_is_ambiguous(path) { + return Err(invalid_config_source("ambiguous Windows Git config path")); + } + Ok(()) +} + +#[cfg(not(windows))] +pub(super) fn reject_raw_ambiguous_windows_config_path(_path: &str) -> io::Result<()> { + Ok(()) +} + +#[cfg(any(windows, test))] +pub(super) fn windows_config_path_is_ambiguous(path: &str) -> bool { + crate::path_authority::windows_path_is_ambiguous(path) +} + +pub(super) fn invalid_config_source(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/codex-rs/git-utils/src/git_config_sources/primary_sources.rs b/codex-rs/git-utils/src/git_config_sources/primary_sources.rs new file mode 100644 index 0000000000..252e09314e --- /dev/null +++ b/codex-rs/git-utils/src/git_config_sources/primary_sources.rs @@ -0,0 +1,272 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use super::path_safety::CONFIG_PATH_KEY; +use super::path_safety::git_var_path_from_bytes; +use super::path_safety::invalid_config_source; +use crate::git_command::GitRunner; + +pub(super) fn default_system_config_source_candidates( + git: &GitRunner, + cwd: &Path, +) -> io::Result> { + if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() { + return Ok(Vec::new()); + } + // `GIT_CONFIG_SYSTEM` was added to `git var` in Git 2.42. The PSEC-4394 + // boundary treats the selected Git installation and its non-environment + // compile-time system config as host-owned trusted inputs. For older Git, + // the exact custom ETC_GITCONFIG path is therefore a documented residual; + // the derivable prefix/ProgramData paths are still checked separately and + // the no-includes graph validates every directive the system file exposes. + let Some(paths) = git_var_config_paths(git, cwd, "GIT_CONFIG_SYSTEM")? else { + return Ok(Vec::new()); + }; + Ok(paths + .into_iter() + .map(|path| ("GIT_CONFIG_SYSTEM", path)) + .collect()) +} + +pub(super) fn selected_git_home_config_candidates( + git: &GitRunner, + cwd: &Path, +) -> io::Result> { + if std::env::var_os("GIT_CONFIG_GLOBAL").is_some() { + return Ok(Vec::new()); + } + #[cfg(not(windows))] + if std::env::var_os("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()) { + let home = dot_gitconfig + .parent() + .ok_or_else(|| invalid_config_source("selected Git HOME has no parent"))?; + candidates.push(home.join(".config/git/config")); + } + Ok(candidates) +} + +pub(super) fn selected_git_prefix_system_candidate( + git: &GitRunner, + cwd: &Path, +) -> io::Result> { + if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() { + return Ok(None); + } + selected_git_path_candidate(git, cwd, "%(prefix)/etc/gitconfig").map(Some) +} + +fn selected_git_path_candidate(git: &GitRunner, cwd: &Path, raw: &str) -> io::Result { + let tempdir = tempfile::tempdir()?; + let nonexistent_git_dir = tempdir.path().join("nonexistent-git-dir"); + let disabled_config = if cfg!(windows) { "NUL" } else { "/dev/null" }; + let mut command = git.command_for_cwd(cwd)?; + command + .env("GIT_CONFIG_GLOBAL", disabled_config) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_COUNT", "0") + .env_remove("GIT_CONFIG_PARAMETERS") + .arg("--git-dir") + .arg(&nonexistent_git_dir) + .arg("-c") + .arg(format!("{CONFIG_PATH_KEY}={raw}")) + .args([ + "config", + "--null", + "--no-includes", + "--path", + "--get", + CONFIG_PATH_KEY, + ]); + let output = git.output(command)?; + if !output.status.success() { + return Err(io::Error::other(format!( + "isolated selected Git path expansion failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let value = output + .stdout + .strip_suffix(&[0]) + .ok_or_else(|| invalid_config_source("unterminated selected Git path"))?; + if value.is_empty() || value.contains(&0) { + return Err(invalid_config_source("ambiguous selected Git path")); + } + git_var_path_from_bytes(value) +} + +fn git_var_config_paths( + git: &GitRunner, + cwd: &Path, + variable: &str, +) -> io::Result>> { + let mut command = git.command_for_cwd(cwd)?; + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(["var", variable]); + let output = git.output(command)?; + parse_git_var_config_paths_result( + output.status.code(), + &output.stdout, + &output.stderr, + variable, + ) +} + +pub(super) fn parse_git_var_config_paths_result( + status_code: Option, + stdout: &[u8], + stderr: &[u8], + variable: &str, +) -> io::Result>> { + match status_code { + Some(0) => parse_git_var_paths(stdout).map(Some), + Some(1) if stdout.is_empty() && stderr.is_empty() => Ok(Some(Vec::new())), + // These variables were added in Git 2.42. Older Git reports usage + // status 129; fall back to its documented environment/home sources. + Some(129) => Ok(None), + _ => Err(io::Error::other(format!( + "git {variable} source probe failed with status {status_code:?}: {}", + String::from_utf8_lossy(stderr).trim() + ))), + } +} + +fn parse_git_var_paths(output: &[u8]) -> io::Result> { + output + .split(|byte| *byte == b'\n') + .filter(|path| !path.is_empty()) + .map(|path| { + let path = path.strip_suffix(b"\r").unwrap_or(path); + git_var_path_from_bytes(path) + }) + .collect() +} + +pub(super) fn legacy_primary_config_source_candidates() -> io::Result> +{ + let mut candidates = Vec::new(); + match std::env::var_os("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") { + 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") + && !app_data.is_empty() + { + candidates.push(( + "APPDATA Git config", + PathBuf::from(app_data).join("Git/config"), + )); + } + for home in &homes { + candidates.push(( + "HOME XDG Git config", + home_config_path(home, ".config/git/config"), + )); + } + } + } + for home in &homes { + candidates.push(("HOME Git config", home_config_path(home, ".gitconfig"))); + } + } + } + if !git_env_bool("GIT_CONFIG_NOSYSTEM")? { + match std::env::var_os("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") + && !program_data.is_empty() + { + candidates.push(( + "PROGRAMDATA Git config", + PathBuf::from(program_data).join("Git/config"), + )); + } + } + } + } + Ok(candidates) +} + +fn git_home_directories() -> Vec { + if let Some(home) = std::env::var_os("HOME") { + return vec![home]; + } + #[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; + home.push(path); + homes.push(home); + } + if let Some(profile) = std::env::var_os("USERPROFILE") + && !homes.iter().any(|home| *home == profile) + { + homes.push(profile); + } + return homes; + } + #[cfg(not(windows))] + Vec::new() +} + +fn home_config_path(home: &std::ffi::OsStr, suffix: &str) -> PathBuf { + if home.is_empty() { + PathBuf::from(std::path::MAIN_SEPARATOR.to_string()).join(suffix) + } else { + PathBuf::from(home).join(suffix) + } +} + +fn git_env_bool(name: &str) -> io::Result { + let Some(value) = std::env::var_os(name) else { + return Ok(false); + }; + let value = value + .to_str() + .ok_or_else(|| invalid_config_source("non-UTF-8 Git boolean environment value"))?; + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "" | "0" | "false" | "no" | "off" => Ok(false), + value => value + .parse::() + .map(|value| value != 0) + .map_err(|_| invalid_config_source("invalid Git boolean environment value")), + } +} + +#[cfg(windows)] +pub(super) fn is_disabled_primary_config_path(path: &Path) -> bool { + path.as_os_str() + .to_str() + .is_some_and(|path| path.eq_ignore_ascii_case("NUL")) +} + +#[cfg(not(windows))] +pub(super) fn is_disabled_primary_config_path(_path: &Path) -> bool { + false +} diff --git a/codex-rs/git-utils/src/git_config_sources_tests.rs b/codex-rs/git-utils/src/git_config_sources_tests.rs new file mode 100644 index 0000000000..c86ae4bb26 --- /dev/null +++ b/codex-rs/git-utils/src/git_config_sources_tests.rs @@ -0,0 +1,1569 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::ffi::OsStr; + +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut command = std::process::Command::new("git"); + crate::safe_git::isolate_git_command_environment(&mut command); + let output = command + .args(args) + .current_dir(cwd) + .output() + .expect("run Git"); + ( + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +fn run_success(cwd: &Path, args: &[&str]) -> String { + let (code, stdout, stderr) = run(cwd, args); + assert_eq!(code, 0, "git {args:?}: {stderr}"); + stdout.trim().to_string() +} + +fn init_repo() -> tempfile::TempDir { + let repo = tempfile::tempdir().expect("tempdir"); + init_repo_at(repo.path()); + repo +} + +fn init_repo_at(root: &Path) { + std::fs::create_dir_all(root).expect("create repository directory"); + run_success(root, &["init"]); + run_success(root, &["config", "user.email", "codex@example.com"]); + run_success(root, &["config", "user.name", "Codex"]); + std::fs::write(root.join("file.txt"), "orig\n").expect("write file"); + run_success(root, &["add", "file.txt"]); + run_success(root, &["commit", "-m", "seed"]); +} + +#[cfg(windows)] +fn create_junction(path: &Path, target: &Path) { + let output = std::process::Command::new("cmd.exe") + .args(["/D", "/C", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("create junction"); + assert!( + output.status.success(), + "mklink failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn guard(root: &Path) -> io::Result<()> { + let git = GitRunner::for_cwd_io(root)?; + ensure_no_worktree_config_sources(&git, root, &[]) +} + +fn add_include(root: &Path, key: &str, value: &str) { + run_success(root, &["config", "--add", key, value]); +} + +fn assert_worktree_rejection(error: io::Error) { + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!(error.to_string().contains("worktree-controlled"), "{error}"); +} + +fn run_isolated_source_test(test_name: &str, env: &[(&str, &OsStr)], removed: &[&str]) { + let mut command = std::process::Command::new(std::env::current_exe().expect("test binary")); + crate::safe_git::isolate_git_command_environment(&mut command); + command + .arg(test_name) + .arg("--exact") + .arg("--nocapture") + .env("CODEX_GIT_CONFIG_SOURCE_CHILD", "1") + .env("RUST_TEST_THREADS", "1"); + for (name, value) in env { + command.env(name, value); + } + for name in removed { + command.env_remove(name); + } + let output = command.output().expect("run isolated source test"); + 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 allows_protected_metadata_and_external_config_sources() { + let repo = init_repo(); + let root = repo.path(); + guard(root).expect("ordinary metadata config"); + + std::fs::write( + root.join(".git/protected.gitconfig"), + "[user]\nname = protected\n", + ) + .expect("write protected config"); + add_include(root, "include.path", "protected.gitconfig"); + guard(root).expect("protected metadata include"); + std::fs::create_dir(root.join(".git/subdir")).expect("create metadata subdirectory"); + add_include(root, "include.path", "subdir/../protected.gitconfig"); + guard(root).expect("metadata-local parent traversal"); + + let external = tempfile::tempdir().expect("external config directory"); + let external_config = external.path().join("safe.gitconfig"); + std::fs::write(&external_config, "[user]\nemail = safe@example.com\n") + .expect("write external config"); + add_include( + root, + "include.path", + external_config.to_str().expect("UTF-8 external path"), + ); + guard(root).expect("external include"); + add_include( + root, + "include.path", + external + .path() + .join("missing.gitconfig") + .to_str() + .expect("UTF-8 missing external path"), + ); + guard(root).expect("missing external include"); +} + +#[test] +fn rejects_empty_primary_global_and_system_sources_in_the_worktree() { + const TEST_NAME: &str = "git_config_sources::tests::rejects_empty_primary_global_and_system_sources_in_the_worktree"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let root = repo.path(); + let unsafe_config = root.join("empty-primary.gitconfig"); + std::fs::write(&unsafe_config, "").expect("write empty primary config"); + let safe = tempfile::NamedTempFile::new().expect("safe external config"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", root.as_os_str()), + ("GIT_CONFIG_GLOBAL", unsafe_config.as_os_str()), + ("GIT_CONFIG_SYSTEM", safe.path().as_os_str()), + ], + &[], + ); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", root.as_os_str()), + ("GIT_CONFIG_GLOBAL", safe.path().as_os_str()), + ("GIT_CONFIG_SYSTEM", unsafe_config.as_os_str()), + ], + &[], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + assert_worktree_rejection(guard(&root).expect_err("unsafe primary config source")); +} + +#[test] +fn rejects_missing_home_and_xdg_sources_in_the_worktree() { + const TEST_NAME: &str = + "git_config_sources::tests::rejects_missing_home_and_xdg_sources_in_the_worktree"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let root = repo.path(); + let xdg = root.join("xdg"); + std::fs::create_dir(&xdg).expect("create XDG root"); + let safe_system = tempfile::NamedTempFile::new().expect("safe system config"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", root.as_os_str()), + ("HOME", root.as_os_str()), + ("XDG_CONFIG_HOME", xdg.as_os_str()), + ("GIT_CONFIG_SYSTEM", safe_system.path().as_os_str()), + ], + &["GIT_CONFIG_GLOBAL"], + ); + return; + } + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + assert_worktree_rejection(guard(&root).expect_err("unsafe HOME/XDG source")); +} + +#[test] +fn allows_explicitly_disabled_global_and_system_sources() { + const TEST_NAME: &str = + "git_config_sources::tests::allows_explicitly_disabled_global_and_system_sources"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", OsStr::new("")), + ("GIT_CONFIG_SYSTEM", OsStr::new("")), + ], + &[], + ); + return; + } + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + guard(&root).expect("disabled primary config sources"); +} + +#[cfg(unix)] +#[test] +fn allows_unset_home_when_git_has_no_global_source() { + const TEST_NAME: &str = + "git_config_sources::tests::allows_unset_home_when_git_has_no_global_source"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ], + &[ + "HOME", + "XDG_CONFIG_HOME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + ], + ); + return; + } + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + guard(&root).expect("Git without HOME or global config"); +} + +#[test] +fn supports_pre_242_git_without_system_var_enumeration() { + assert_eq!( + primary_sources::parse_git_var_config_paths_result( + Some(129), + b"", + b"usage: git var (-l | )\n", + "GIT_CONFIG_SYSTEM", + ) + .expect("pre-2.42 fallback"), + None + ); +} + +#[cfg(unix)] +#[test] +fn rejects_newline_bearing_raw_global_source_before_git_normalizes_it() { + const TEST_NAME: &str = "git_config_sources::tests::rejects_newline_bearing_raw_global_source_before_git_normalizes_it"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let parent = tempfile::tempdir().expect("fixture parent"); + let root = parent.path().join("repo"); + init_repo_at(&root); + let newline_dir = parent.path().join("external\n"); + std::fs::create_dir(&newline_dir).expect("create newline path component"); + let unsafe_config = root.join("global.gitconfig"); + std::fs::write(&unsafe_config, "").expect("write unsafe global config"); + let raw = newline_dir.join("../repo/global.gitconfig"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", root.as_os_str()), + ("GIT_CONFIG_GLOBAL", raw.as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ], + &["GIT_CONFIG_SYSTEM"], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + assert_worktree_rejection(guard(&root).expect_err("newline raw global source")); +} + +#[cfg(unix)] +#[test] +fn ignores_non_utf8_values_for_unrelated_global_keys() { + const TEST_NAME: &str = + "git_config_sources::tests::ignores_non_utf8_values_for_unrelated_global_keys"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let global = tempfile::NamedTempFile::new().expect("external global config"); + std::fs::write(global.path(), b"[user]\nname = \xff\n") + .expect("write non-UTF-8 unrelated config value"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", global.path().as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ], + &["GIT_CONFIG_SYSTEM"], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + guard(&root).expect("unrelated non-UTF-8 global value"); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn preserves_non_utf8_unix_repository_and_config_origin_paths() { + use std::os::unix::ffi::OsStringExt; + + const TEST_NAME: &str = + "git_config_sources::tests::preserves_non_utf8_unix_repository_and_config_origin_paths"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let parent = tempfile::tempdir().expect("non-UTF-8 fixture parent"); + let root = parent + .path() + .join(std::ffi::OsString::from_vec(b"repo-\xff".to_vec())); + init_repo_at(&root); + let safe = tempfile::NamedTempFile::new().expect("safe included config"); + add_include( + &root, + "include.path", + safe.path().to_str().expect("UTF-8 safe include"), + ); + let global = parent + .path() + .join(std::ffi::OsString::from_vec(b"global-\xff".to_vec())); + std::fs::write( + &global, + format!("[include]\npath = {}\n", safe.path().display()), + ) + .expect("write non-UTF-8 global path"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", root.as_os_str()), + ("GIT_CONFIG_GLOBAL", global.as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ], + &["GIT_CONFIG_SYSTEM"], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + guard(&root).expect("non-UTF-8 repository and config origins"); +} + +#[cfg(unix)] +#[test] +fn rejects_worktree_fifo_primary_source_without_opening_it() { + const TEST_NAME: &str = + "git_config_sources::tests::rejects_worktree_fifo_primary_source_without_opening_it"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let fifo = repo.path().join("global.fifo"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .expect("run mkfifo"); + assert!(status.success(), "mkfifo failed: {status}"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", fifo.as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ], + &["GIT_CONFIG_SYSTEM"], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + assert_worktree_rejection(guard(&root).expect_err("worktree FIFO config source")); +} + +#[test] +fn git_config_nosystem_matches_git_integer_and_text_boolean_grammar() { + const TEST_NAME: &str = "git_config_sources::tests::git_config_nosystem_matches_git_integer_and_text_boolean_grammar"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let unsafe_system = repo.path().join("system.gitconfig"); + std::fs::write(&unsafe_system, "").expect("write unsafe system config"); + for (value, expected) in [ + ("1", "ignored"), + ("2", "ignored"), + ("-1", "ignored"), + ("01", "ignored"), + ("+1", "ignored"), + ("2147483647", "ignored"), + ("-2147483648", "ignored"), + ("true", "ignored"), + ("yes", "ignored"), + ("on", "ignored"), + ("", "rejected"), + ("0", "rejected"), + ("-0", "rejected"), + ("false", "rejected"), + ("no", "rejected"), + ("off", "rejected"), + ("not-a-bool", "invalid"), + ("2147483648", "invalid"), + ("-2147483649", "invalid"), + ] { + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("CODEX_GIT_CONFIG_SOURCE_EXPECTED", OsStr::new(expected)), + ("GIT_CONFIG_GLOBAL", OsStr::new("")), + ("GIT_CONFIG_SYSTEM", unsafe_system.as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new(value)), + ], + &[], + ); + } + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + match std::env::var("CODEX_GIT_CONFIG_SOURCE_EXPECTED") + .expect("expected child outcome") + .as_str() + { + "ignored" => guard(&root).expect("system source disabled"), + "rejected" => assert_worktree_rejection(guard(&root).expect_err("system source enabled")), + "invalid" => assert_eq!( + guard(&root).expect_err("invalid NOSYSTEM value").kind(), + io::ErrorKind::InvalidData + ), + expected => panic!("unexpected child outcome {expected}"), + } +} + +#[cfg(windows)] +#[test] +fn rejects_both_windows_home_candidates_when_home_is_absent() { + const TEST_NAME: &str = + "git_config_sources::tests::rejects_both_windows_home_candidates_when_home_is_absent"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + let external_profile = tempfile::tempdir().expect("external USERPROFILE"); + for home in [repo.path().to_path_buf(), repo.path().join("future-home")] { + let home = home.to_str().expect("UTF-8 Windows test path"); + assert!(home.len() >= 3 && home.as_bytes()[1] == b':', "{home}"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("HOMEDRIVE", OsStr::new(&home[..2])), + ("HOMEPATH", OsStr::new(&home[2..])), + ("USERPROFILE", external_profile.path().as_os_str()), + ], + &[ + "HOME", + "XDG_CONFIG_HOME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + ], + ); + } + let external_home = external_profile + .path() + .to_str() + .expect("UTF-8 external Windows home"); + assert!( + external_home.len() >= 3 && external_home.as_bytes()[1] == b':', + "{external_home}" + ); + for profile in [ + repo.path().to_path_buf(), + repo.path().join("future-profile"), + ] { + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("HOMEDRIVE", OsStr::new(&external_home[..2])), + ("HOMEPATH", OsStr::new(&external_home[2..])), + ("USERPROFILE", profile.as_os_str()), + ], + &[ + "HOME", + "XDG_CONFIG_HOME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + ], + ); + } + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + assert_worktree_rejection(guard(&root).expect_err("Windows synthesized HOME source")); +} + +#[cfg(windows)] +#[test] +fn allows_exact_windows_nul_for_both_primary_config_channels() { + const TEST_NAME: &str = + "git_config_sources::tests::allows_exact_windows_nul_for_both_primary_config_channels"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + let repo = init_repo(); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", OsStr::new("NUL")), + ("GIT_CONFIG_SYSTEM", OsStr::new("NUL")), + ], + &["GIT_CONFIG_NOSYSTEM"], + ); + return; + } + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + guard(&root).expect("exact NUL disables primary config channels"); +} + +#[cfg(windows)] +#[test] +fn windows_appdata_global_config_matches_native_git_selection_and_precedence() { + const TEST_NAME: &str = "git_config_sources::tests::windows_appdata_global_config_matches_native_git_selection_and_precedence"; + if std::env::var_os("CODEX_GIT_CONFIG_SOURCE_CHILD").is_none() { + for xdg in [None, Some(OsStr::new(""))] { + for existing in [true, false] { + let repo = init_repo(); + let appdata = repo.path().join("appdata"); + if existing { + let config = appdata.join("Git/config"); + std::fs::create_dir_all(config.parent().expect("APPDATA config parent")) + .expect("create APPDATA config parent"); + std::fs::write(&config, "[codex]\nappdata-marker = selected\n") + .expect("write APPDATA config"); + } + let external_home = tempfile::tempdir().expect("external HOME"); + let mut env = vec![ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ( + "CODEX_GIT_CONFIG_SOURCE_EXPECTED", + OsStr::new(if existing { + "reject-existing-appdata" + } else { + "reject-missing-appdata" + }), + ), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("HOME", external_home.path().as_os_str()), + ("APPDATA", appdata.as_os_str()), + ]; + if let Some(xdg) = xdg { + env.push(("XDG_CONFIG_HOME", xdg)); + } + let mut removed = vec![ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "HOMEDRIVE", + "HOMEPATH", + "USERPROFILE", + ]; + if xdg.is_none() { + removed.push("XDG_CONFIG_HOME"); + } + run_isolated_source_test(TEST_NAME, &env, &removed); + } + } + + let repo = init_repo(); + let appdata = repo.path().join("appdata"); + let appdata_config = appdata.join("Git/config"); + std::fs::create_dir_all(appdata_config.parent().expect("APPDATA config parent")) + .expect("create APPDATA config parent"); + std::fs::write(&appdata_config, "[codex]\nappdata-marker = appdata\n") + .expect("write APPDATA config"); + let external_home = tempfile::tempdir().expect("external HOME"); + let external_xdg = tempfile::tempdir().expect("external XDG_CONFIG_HOME"); + let xdg_config = external_xdg.path().join("git/config"); + std::fs::create_dir_all(xdg_config.parent().expect("XDG config parent")) + .expect("create XDG config parent"); + std::fs::write(&xdg_config, "[codex]\nappdata-marker = xdg\n").expect("write XDG config"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ("CODEX_GIT_CONFIG_SOURCE_EXPECTED", OsStr::new("allow-xdg")), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("HOME", external_home.path().as_os_str()), + ("APPDATA", appdata.as_os_str()), + ("XDG_CONFIG_HOME", external_xdg.path().as_os_str()), + ], + &[ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "HOMEDRIVE", + "HOMEPATH", + "USERPROFILE", + ], + ); + + let external_global = tempfile::NamedTempFile::new().expect("external global config"); + std::fs::write( + external_global.path(), + "[codex]\nappdata-marker = explicit\n", + ) + .expect("write explicit global config"); + run_isolated_source_test( + TEST_NAME, + &[ + ("CODEX_GIT_CONFIG_SOURCE_ROOT", repo.path().as_os_str()), + ( + "CODEX_GIT_CONFIG_SOURCE_EXPECTED", + OsStr::new("allow-explicit-global"), + ), + ("GIT_CONFIG_NOSYSTEM", OsStr::new("1")), + ("HOME", repo.path().as_os_str()), + ("APPDATA", appdata.as_os_str()), + ("XDG_CONFIG_HOME", repo.path().as_os_str()), + ("GIT_CONFIG_GLOBAL", external_global.path().as_os_str()), + ], + &["GIT_CONFIG_SYSTEM", "HOMEDRIVE", "HOMEPATH", "USERPROFILE"], + ); + return; + } + + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_CONFIG_SOURCE_ROOT").expect("fixture repository root"), + ); + let expected = + std::env::var("CODEX_GIT_CONFIG_SOURCE_EXPECTED").expect("expected child outcome"); + if expected != "reject-missing-appdata" { + let output = std::process::Command::new("git") + .args(["config", "--global", "--get", "codex.appdata-marker"]) + .current_dir(&root) + .output() + .expect("run native Git global config query"); + assert!( + output.status.success(), + "native Git global query failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let actual = String::from_utf8_lossy(&output.stdout); + let native_expected = match expected.as_str() { + "reject-existing-appdata" => "selected", + "allow-xdg" => "xdg", + "allow-explicit-global" => "explicit", + expected => panic!("unexpected child outcome {expected}"), + }; + assert_eq!( + actual.trim(), + native_expected, + "native Git global selection" + ); + } + match expected.as_str() { + "reject-existing-appdata" | "reject-missing-appdata" => { + assert_worktree_rejection(guard(&root).expect_err("worktree APPDATA config")); + } + "allow-xdg" => guard(&root).expect("nonempty XDG suppresses APPDATA"), + "allow-explicit-global" => { + guard(&root).expect("explicit global config suppresses HOME, XDG, and APPDATA") + } + expected => panic!("unexpected child outcome {expected}"), + } +} + +#[test] +fn rejects_empty_nonempty_absolute_missing_and_conditional_worktree_includes() { + for (name, body, include_key, include_value) in [ + ( + "empty", + "", + "include.path".to_string(), + "../driver-config".to_string(), + ), + ( + "nonempty", + "[user]\nname = worktree\n", + "include.path".to_string(), + "../driver-config".to_string(), + ), + ( + "missing", + "", + "include.path".to_string(), + "../future.gitconfig".to_string(), + ), + ( + "inactive includeIf", + "", + "includeIf.gitdir:/definitely/not/this/repository/**.path".to_string(), + "../driver-config".to_string(), + ), + ( + "case-insensitive gitdir includeIf", + "", + "includeIf.gitdir/i:/definitely/not/this/repository/**.path".to_string(), + "../driver-config".to_string(), + ), + ( + "onbranch includeIf", + "", + "includeIf.onbranch:definitely-never/**.path".to_string(), + "../driver-config".to_string(), + ), + ( + "hasconfig includeIf", + "", + "includeIf.hasconfig:remote.*.url:https://never.example/**.path".to_string(), + "../driver-config".to_string(), + ), + ] { + let repo = init_repo(); + let root = repo.path(); + if name != "missing" { + std::fs::write(root.join("driver-config"), body).expect("write config fixture"); + } + add_include(root, &include_key, &include_value); + assert_worktree_rejection(guard(root).expect_err(name)); + } + + let repo = init_repo(); + let root = repo.path(); + let config = root.join("absolute.gitconfig"); + std::fs::write(&config, "").expect("write absolute config"); + add_include( + root, + "include.path", + config.to_str().expect("UTF-8 config path"), + ); + assert_worktree_rejection(guard(root).expect_err("absolute include")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write conditional config"); + let git_dir = run_success(root, &["rev-parse", "--absolute-git-dir"]).replace('\\', "/"); + add_include( + root, + &format!("includeIf.gitdir:{git_dir}/.path"), + "../driver-config", + ); + assert_worktree_rejection(guard(root).expect_err("active includeIf")); +} + +#[test] +fn command_scoped_include_paths_follow_the_same_boundary() { + let repo = init_repo(); + let root = repo.path(); + let git = GitRunner::for_cwd_io(root).expect("Git runner"); + let unsafe_config = root.join("command.gitconfig"); + std::fs::write(&unsafe_config, "").expect("write worktree config"); + let unsafe_args = vec![ + "-c".to_string(), + format!("include.path={}", unsafe_config.display()), + ]; + assert_worktree_rejection( + ensure_no_worktree_config_sources(&git, root, &unsafe_args) + .expect_err("command-scoped worktree include"), + ); + + let external = tempfile::NamedTempFile::new().expect("safe external config"); + let safe_args = vec![ + "-c".to_string(), + format!("include.path={}", external.path().display()), + ]; + ensure_no_worktree_config_sources(&git, root, &safe_args) + .expect("command-scoped external include"); + + let relative_args = vec![ + "-c".to_string(), + "include.path=relative.gitconfig".to_string(), + ]; + assert!( + ensure_no_worktree_config_sources(&git, root, &relative_args).is_err(), + "relative command includes must fail closed" + ); +} + +#[test] +fn rejects_every_duplicate_and_nested_include_target() { + for unsafe_first in [true, false] { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write unsafe config"); + let external = tempfile::NamedTempFile::new().expect("safe external config"); + let safe = external.path().to_str().expect("UTF-8 safe path"); + let values = if unsafe_first { + ["../driver-config", safe] + } else { + [safe, "../driver-config"] + }; + for value in values { + add_include(root, "include.path", value); + } + assert_worktree_rejection(guard(root).expect_err("duplicate include")); + } + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write nested target"); + let external = tempfile::tempdir().expect("external config directory"); + let outer = external.path().join("outer.gitconfig"); + std::fs::write( + &outer, + format!( + "[include]\npath = {}\n", + root.join("driver-config").display() + ), + ) + .expect("write outer config"); + add_include( + root, + "include.path", + outer.to_str().expect("UTF-8 outer path"), + ); + assert_worktree_rejection(guard(root).expect_err("nested include")); +} + +#[cfg(unix)] +#[test] +fn rejects_symlink_aliases_across_the_worktree_and_metadata_boundaries() { + use std::os::unix::fs::symlink; + + let repo = init_repo(); + let root = repo.path(); + let external = tempfile::tempdir().expect("external directory"); + std::fs::write(external.path().join("safe.gitconfig"), "").expect("write safe config"); + symlink(external.path(), root.join("inside-link")).expect("inside to outside link"); + add_include(root, "include.path", "../inside-link/safe.gitconfig"); + assert_worktree_rejection(guard(root).expect_err("worktree symlink alias")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write worktree target"); + let external = tempfile::tempdir().expect("external directory"); + symlink(root, external.path().join("outside-link")).expect("outside to inside link"); + add_include( + root, + "include.path", + external + .path() + .join("outside-link/driver-config") + .to_str() + .expect("UTF-8 alias path"), + ); + assert_worktree_rejection(guard(root).expect_err("external symlink alias")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write escaped target"); + symlink(root, root.join(".git/config-link")).expect("metadata escape link"); + add_include(root, "include.path", "config-link/driver-config"); + assert_worktree_rejection(guard(root).expect_err("metadata symlink escape")); + + let repo = init_repo(); + let root = repo.path(); + let external = tempfile::tempdir().expect("external directory"); + symlink( + root.join("future-config"), + external.path().join("dangling-link"), + ) + .expect("dangling link into worktree"); + add_include( + root, + "include.path", + external + .path() + .join("dangling-link/config") + .to_str() + .expect("UTF-8 dangling alias"), + ); + assert_worktree_rejection(guard(root).expect_err("dangling symlink alias")); + + let repo = init_repo(); + let root = repo.path(); + let entry = tempfile::tempdir().expect("external entry directory"); + let exit = tempfile::tempdir().expect("external exit directory"); + std::fs::write(exit.path().join("safe.gitconfig"), "").expect("write exit config"); + symlink(root, entry.path().join("through-worktree")).expect("link into worktree"); + symlink(exit.path(), root.join("back-out")).expect("worktree-controlled exit link"); + add_include( + root, + "include.path", + entry + .path() + .join("through-worktree/back-out/safe.gitconfig") + .to_str() + .expect("UTF-8 nested alias"), + ); + assert_worktree_rejection(guard(root).expect_err("nested symlink traversal")); + + let repo = init_repo(); + let root = repo.path(); + let entry = tempfile::tempdir().expect("external entry directory"); + let exit = tempfile::tempdir().expect("external exit directory"); + std::fs::write(exit.path().join("safe.gitconfig"), "").expect("write chained config"); + symlink(exit.path(), root.join("workspace-link")).expect("workspace link out"); + symlink( + root.join("workspace-link"), + entry.path().join("direct-chain"), + ) + .expect("external link to workspace link"); + add_include( + root, + "include.path", + entry + .path() + .join("direct-chain/safe.gitconfig") + .to_str() + .expect("UTF-8 direct chain"), + ); + assert_worktree_rejection(guard(root).expect_err("direct symlink chain")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::create_dir(root.join("subdir")).expect("create symlink target"); + std::fs::write(root.join("evil.gitconfig"), "[user]\nname = worktree\n") + .expect("write worktree config"); + std::fs::write( + root.join(".git/evil.gitconfig"), + "[user]\nname = metadata\n", + ) + .expect("write metadata decoy"); + symlink(root.join("subdir"), root.join(".git/link")).expect("metadata link to worktree"); + add_include(root, "include.path", "link/../evil.gitconfig"); + assert_worktree_rejection(guard(root).expect_err("symlink parent traversal")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::create_dir(root.join("pivot")).expect("create worktree pivot"); + std::fs::write(root.join(".git/protected.gitconfig"), "") + .expect("write protected metadata config"); + add_include( + root, + "include.path", + root.join("pivot/../.git/protected.gitconfig") + .to_str() + .expect("UTF-8 pivot path"), + ); + assert_worktree_rejection(guard(root).expect_err("raw path pivots through worktree")); +} + +#[cfg(unix)] +#[test] +fn rejects_external_symlink_target_with_hidden_worktree_pivot_to_protected_config() { + use std::os::unix::fs::symlink; + + let repo = init_repo(); + let root = std::fs::canonicalize(repo.path()).expect("canonical repository"); + let external = tempfile::tempdir().expect("external alias directory"); + let protected = root.join(".git/protected.gitconfig"); + std::fs::write(&protected, "[codex]\n\tpivot = protected\n").expect("write protected config"); + std::fs::create_dir(root.join("pivot")).expect("create ordinary worktree pivot"); + let entry = external.path().join("entry"); + symlink(root.join("pivot/../.git/protected.gitconfig"), &entry) + .expect("external symlink target with raw worktree pivot"); + add_include( + &root, + "include.path", + entry.to_str().expect("UTF-8 entry path"), + ); + assert_eq!( + run_success(&root, &["config", "--includes", "--get", "codex.pivot"]), + "protected" + ); + assert_worktree_rejection( + guard(&root).expect_err("hidden pivot inside external symlink target"), + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn rejects_apfs_alias_symlink_target_with_hidden_worktree_pivot() { + use std::os::unix::fs::symlink; + + let temp = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp directory"); + let parent = tempfile::tempdir_in(temp).expect("Data-volume fixture"); + let root = parent.path().join("repo"); + init_repo_at(&root); + let root = std::fs::canonicalize(root).expect("canonical repository"); + let data_alias = PathBuf::from("/System/Volumes/Data") + .join(root.strip_prefix("/").expect("absolute repository path")); + if std::fs::metadata(&data_alias).is_err() { + eprintln!("APFS Data alias unavailable; skipping native pivot assertion"); + return; + } + let protected = root.join(".git/protected.gitconfig"); + std::fs::write(&protected, "[codex]\n\tpivot = protected\n").expect("write protected config"); + std::fs::create_dir(root.join("pivot")).expect("create ordinary worktree pivot"); + let external = tempfile::tempdir().expect("external alias directory"); + let entry = external.path().join("entry"); + symlink(data_alias.join("pivot/../.git/protected.gitconfig"), &entry) + .expect("APFS alias target with raw worktree pivot"); + add_include( + &root, + "include.path", + entry.to_str().expect("UTF-8 entry path"), + ); + assert_eq!( + run_success(&root, &["config", "--includes", "--get", "codex.pivot"]), + "protected" + ); + assert_worktree_rejection(guard(&root).expect_err("APFS hidden pivot inside symlink target")); +} + +#[cfg(unix)] +#[test] +fn rejects_unregistered_same_common_worktree_used_only_as_alias_intermediate() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().expect("fixture"); + let main = parent.path().join("main"); + let linked = parent.path().join("linked"); + let stale = parent.path().join("stale-unregistered"); + let external = parent.path().join("external"); + let safe = external.join("safe.gitconfig"); + let entry = external.join("entry"); + init_repo_at(&main); + run_success( + &main, + &[ + "worktree", + "add", + "-b", + "same-common-active", + linked.to_str().expect("UTF-8 linked path"), + ], + ); + std::fs::create_dir_all(&stale).expect("create stale worktree"); + std::fs::write( + stale.join(".git"), + format!("gitdir: {}\n", main.join(".git").display()), + ) + .expect("write stale same-common marker"); + std::fs::create_dir_all(&external).expect("create external directory"); + std::fs::write(&safe, "[codex]\n\tsameCommon = protected\n") + .expect("write external safe config"); + symlink(&safe, stale.join("switch")).expect("stale worktree controlled switch"); + symlink(stale.join("switch"), &entry).expect("external entry through stale worktree"); + add_include( + &linked, + "include.path", + entry.to_str().expect("UTF-8 entry path"), + ); + assert_eq!( + run_success( + &linked, + &["config", "--includes", "--get", "codex.sameCommon"] + ), + "protected" + ); + assert!( + guard(&linked).is_err(), + "unregistered same-common route intermediate must fail closed" + ); +} + +#[cfg(windows)] +#[test] +fn rejects_windows_junction_aliases_across_worktree_and_metadata_boundaries() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write worktree config"); + let external = tempfile::tempdir().expect("external directory"); + create_junction(&external.path().join("into-worktree"), root); + add_include( + root, + "include.path", + external + .path() + .join("into-worktree/driver-config") + .to_str() + .expect("UTF-8 junction path"), + ); + assert_worktree_rejection(guard(root).expect_err("external junction into worktree")); + + let repo = init_repo(); + let root = repo.path(); + let external = tempfile::tempdir().expect("external directory"); + std::fs::write(external.path().join("safe.gitconfig"), "").expect("write external config"); + create_junction(&root.join("out-of-worktree"), external.path()); + add_include(root, "include.path", "../out-of-worktree/safe.gitconfig"); + assert_worktree_rejection(guard(root).expect_err("worktree junction to external")); + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write worktree config"); + create_junction(&root.join(".git/metadata-escape"), root); + add_include(root, "include.path", "metadata-escape/driver-config"); + assert_worktree_rejection(guard(root).expect_err("metadata junction escape")); + + let repo = init_repo(); + let root = repo.path(); + let entry = tempfile::tempdir().expect("external entry directory"); + let exit = tempfile::tempdir().expect("external exit directory"); + std::fs::write(exit.path().join("safe.gitconfig"), "").expect("write external config"); + create_junction(&entry.path().join("through-worktree"), root); + create_junction(&root.join("back-out"), exit.path()); + add_include( + root, + "include.path", + entry + .path() + .join("through-worktree/back-out/safe.gitconfig") + .to_str() + .expect("UTF-8 multi-hop junction path"), + ); + assert_worktree_rejection(guard(root).expect_err("multi-hop junction traversal")); + + let repo = init_repo(); + let root = repo.path(); + let external = tempfile::tempdir().expect("external directory"); + create_junction(&external.path().join("into-worktree"), root); + add_include( + root, + "include.path", + external + .path() + .join("into-worktree/future.gitconfig") + .to_str() + .expect("UTF-8 missing junction target"), + ); + assert_worktree_rejection(guard(root).expect_err("junction missing suffix")); +} + +#[test] +fn linked_worktree_metadata_is_allowed_but_linked_worktree_config_is_rejected() { + let parent = tempfile::tempdir().expect("worktree parent"); + let main = parent.path().join("main"); + let linked = parent.path().join("linked"); + init_repo_at(&main); + run_success( + &main, + &[ + "worktree", + "add", + "-b", + "config-guard-linked", + linked.to_str().expect("UTF-8 linked path"), + ], + ); + + let git_dir = PathBuf::from(run_success(&linked, &["rev-parse", "--absolute-git-dir"])); + let protected = git_dir.join("protected.gitconfig"); + std::fs::write(&protected, "[user]\nname = linked\n").expect("write linked metadata config"); + add_include( + &linked, + "include.path", + protected.to_str().expect("UTF-8 protected path"), + ); + guard(&linked).expect("linked metadata include"); + run_success(&linked, &["config", "--unset-all", "include.path"]); + + std::fs::write(main.join("driver-config"), "").expect("write main-worktree config"); + add_include( + &linked, + "include.path", + main.join("driver-config") + .to_str() + .expect("UTF-8 main-worktree config"), + ); + assert_worktree_rejection(guard(&linked).expect_err("main worktree include")); + run_success(&linked, &["config", "--unset-all", "include.path"]); + add_include( + &linked, + "include.path", + main.join("future.gitconfig") + .to_str() + .expect("UTF-8 missing main-worktree config"), + ); + assert_worktree_rejection(guard(&linked).expect_err("missing main worktree include")); + run_success(&linked, &["config", "--unset-all", "include.path"]); + + let unsafe_config = linked.join("driver-config"); + std::fs::write(&unsafe_config, "").expect("write linked worktree config"); + add_include( + &linked, + "include.path", + unsafe_config.to_str().expect("UTF-8 unsafe path"), + ); + assert_worktree_rejection(guard(&linked).expect_err("linked worktree include")); +} + +#[test] +fn enclosing_repository_sibling_worktrees_are_always_untrusted() { + let parent = tempfile::tempdir().expect("fixture parent"); + let outer = parent.path().join("outer"); + let sibling = parent.path().join("sibling"); + init_repo_at(&outer); + run_success( + &outer, + &[ + "worktree", + "add", + "-b", + "config-guard-sibling", + sibling.to_str().expect("UTF-8 sibling worktree"), + ], + ); + let nested = outer.join("nested"); + init_repo_at(&nested); + + std::fs::write(sibling.join("driver-config"), "").expect("write sibling config"); + add_include( + &nested, + "include.path", + sibling + .join("driver-config") + .to_str() + .expect("UTF-8 sibling config"), + ); + assert_worktree_rejection(guard(&nested).expect_err("enclosing repo sibling config")); + + run_success(&nested, &["config", "--unset-all", "include.path"]); + std::fs::remove_dir_all(&sibling).expect("remove sibling but retain registry entry"); + add_include( + &nested, + "include.path", + sibling + .join("future.gitconfig") + .to_str() + .expect("UTF-8 missing sibling config"), + ); + assert_worktree_rejection(guard(&nested).expect_err("missing enclosing repo sibling config")); + + let nested_primary = outer.join("inner-primary"); + let inner_linked = parent.path().join("inner-linked"); + init_repo_at(&nested_primary); + run_success( + &nested_primary, + &[ + "worktree", + "add", + "-b", + "inner-linked-from-outer", + inner_linked.to_str().expect("UTF-8 inner linked worktree"), + ], + ); + for candidate in [ + outer.join("outer.txt"), + outer.join("future-outer.gitconfig"), + ] { + if candidate.file_name() == Some(OsStr::new("outer.txt")) { + std::fs::write(&candidate, "").expect("write outer config source"); + } + add_include( + &inner_linked, + "include.path", + candidate.to_str().expect("UTF-8 outer config source"), + ); + assert_worktree_rejection( + guard(&inner_linked).expect_err("enclosing primary config source"), + ); + run_success(&inner_linked, &["config", "--unset-all", "include.path"]); + } + add_include( + &inner_linked, + "include.path", + sibling + .join("future-from-inner.gitconfig") + .to_str() + .expect("UTF-8 enclosing sibling source"), + ); + assert_worktree_rejection( + guard(&inner_linked).expect_err("enclosing sibling from linked inner worktree"), + ); + run_success(&inner_linked, &["config", "--unset-all", "include.path"]); +} + +#[cfg(unix)] +#[test] +fn preserves_symlinked_caller_ancestry_and_each_include_spelling() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().expect("fixture parent"); + let outer = parent.path().join("outer"); + let nested = parent.path().join("external-nested"); + init_repo_at(&outer); + init_repo_at(&nested); + std::fs::write(outer.join("driver-config"), "").expect("write outer config"); + symlink(&nested, outer.join("nested-link")).expect("link nested cwd through outer repo"); + add_include( + &nested, + "include.path", + outer + .join("driver-config") + .to_str() + .expect("UTF-8 outer config"), + ); + let git = GitRunner::for_cwd_io(&outer.join("nested-link")) + .expect("Git runner from symlinked nested cwd"); + assert_worktree_rejection( + ensure_no_worktree_config_sources(&git, &nested, &[]) + .expect_err("logical outer ancestry config"), + ); + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("driver-config"), "").expect("write unsafe child config"); + let external = tempfile::tempdir().expect("external include tree"); + let shared = external.path().join("shared.gitconfig"); + std::fs::write(&shared, "[include]\npath = child.gitconfig\n") + .expect("write shared parent config"); + let unsafe_dir = external.path().join("unsafe-spelling"); + let safe_dir = external.path().join("safe-spelling"); + std::fs::create_dir(&unsafe_dir).expect("create unsafe spelling directory"); + std::fs::create_dir(&safe_dir).expect("create safe spelling directory"); + symlink(&shared, unsafe_dir.join("parent.gitconfig")) + .expect("unsafe spelling of shared parent"); + symlink(&shared, safe_dir.join("parent.gitconfig")).expect("safe spelling of shared parent"); + symlink( + root.join("driver-config"), + unsafe_dir.join("child.gitconfig"), + ) + .expect("unsafe relative child"); + std::fs::write(safe_dir.join("child.gitconfig"), "").expect("safe relative child"); + add_include( + root, + "include.path", + unsafe_dir + .join("parent.gitconfig") + .to_str() + .expect("UTF-8 unsafe spelling"), + ); + add_include( + root, + "include.path", + safe_dir + .join("parent.gitconfig") + .to_str() + .expect("UTF-8 safe spelling"), + ); + assert_worktree_rejection(guard(root).expect_err("unsafe relative alias child")); + + let parent = tempfile::tempdir().expect("linked-main dangling fixture"); + let main = parent.path().join("main"); + let linked = parent.path().join("linked"); + init_repo_at(&main); + run_success( + &main, + &[ + "worktree", + "add", + "-b", + "config-guard-dangling-main", + linked.to_str().expect("UTF-8 linked worktree"), + ], + ); + let external = tempfile::tempdir().expect("external dangling alias directory"); + symlink( + main.join("future.gitconfig"), + external.path().join("dangling-main-config"), + ) + .expect("dangling alias into main worktree"); + add_include( + &linked, + "include.path", + external + .path() + .join("dangling-main-config") + .to_str() + .expect("UTF-8 dangling main alias"), + ); + assert_worktree_rejection(guard(&linked).expect_err("dangling alias into main worktree")); +} + +#[cfg(target_os = "macos")] +#[test] +fn rejects_apfs_data_firmlink_aliases_for_existing_and_missing_worktree_configs() { + let data_root = Path::new("/System/Volumes/Data"); + if !data_root.is_dir() { + return; + } + let home = PathBuf::from(std::env::var_os("HOME").expect("HOME")); + let temp_parent = home.join(".cache/codex-git-utils-tests"); + std::fs::create_dir_all(&temp_parent).expect("create test temp parent"); + + for exists in [true, false] { + let repo = tempfile::Builder::new() + .prefix("firmlink-config-") + .tempdir_in(&temp_parent) + .expect("home tempdir"); + let root = repo.path(); + init_repo_at(root); + if exists { + std::fs::write(root.join("driver-config"), "").expect("write aliased config"); + } + let alias = data_root + .join(root.strip_prefix("/").expect("absolute repository path")) + .join("driver-config"); + add_include( + root, + "include.path", + alias.to_str().expect("UTF-8 firmlink alias"), + ); + assert_worktree_rejection(guard(root).expect_err("APFS firmlink alias")); + } + + let family = tempfile::Builder::new() + .prefix("firmlink-linked-family-") + .tempdir_in(&temp_parent) + .expect("home linked-family tempdir"); + let main = family.path().join("main"); + let linked = family.path().join("linked"); + init_repo_at(&main); + run_success( + &main, + &[ + "worktree", + "add", + "-b", + "firmlink-linked-main", + linked.to_str().expect("UTF-8 linked path"), + ], + ); + for exists in [true, false] { + let name = if exists { + "driver-config" + } else { + "future.gitconfig" + }; + if exists { + std::fs::write(main.join(name), "").expect("write main config"); + } + let alias = data_root + .join(main.strip_prefix("/").expect("absolute main path")) + .join(name); + add_include( + &linked, + "include.path", + alias.to_str().expect("UTF-8 main firmlink alias"), + ); + assert_worktree_rejection(guard(&linked).expect_err("main firmlink alias")); + run_success(&linked, &["config", "--unset-all", "include.path"]); + } + std::fs::write(main.join(".git/protected.gitconfig"), "") + .expect("write protected main metadata config"); + let protected_alias = data_root + .join(main.strip_prefix("/").expect("absolute main path")) + .join(".git/protected.gitconfig"); + add_include( + &linked, + "include.path", + protected_alias + .to_str() + .expect("UTF-8 protected metadata alias"), + ); + guard(&linked).expect("protected metadata firmlink alias"); +} + +#[test] +fn include_path_expansion_preserves_colon_parentheses_as_literal_text() { + let repo = init_repo(); + let root = repo.path(); + let git = GitRunner::for_cwd_io(root).expect("Git runner"); + let prefix = + expand_git_config_path(&git, root, "%(prefix)/etc/gitconfig").expect("expand Git prefix"); + assert!(prefix.is_absolute()); + + let optional = GitConfigEntry { + scope: crate::git_config::GitConfigScope::Local, + origin: crate::git_config::GitConfigOrigin::File(".git/config".into()), + key: "include.path".to_string(), + value: ":(optional)../future.gitconfig".to_string(), + }; + let optional_path = resolve_include_path(&git, root, &optional).expect("resolve optional path"); + assert_eq!( + AbsolutePathBuf::resolve_path_against_base(optional_path, root).as_path(), + root.join(".git/:(optional)../future.gitconfig") + ); + + let unknown = GitConfigEntry { + value: ":(unknown)../future.gitconfig".to_string(), + ..optional + }; + let unknown_path = + resolve_include_path(&git, root, &unknown).expect("resolve literal unknown path"); + assert_eq!( + AbsolutePathBuf::resolve_path_against_base(unknown_path, root).as_path(), + root.join(".git/:(unknown)../future.gitconfig") + ); + + for spelling in [":(optional)", ":(unknown)"] { + let external = tempfile::tempdir().expect("external literal config directory"); + let literal_dir = external.path().join(spelling); + std::fs::create_dir(&literal_dir).expect("create literal include directory"); + std::fs::write(literal_dir.join("child"), "[probe]\nvalue = literal\n") + .expect("write literal include child"); + let parent = external.path().join("parent.gitconfig"); + std::fs::write(&parent, format!("[include]\npath = {spelling}/child\n")) + .expect("write literal parent config"); + let value = run_success( + root, + &[ + "config", + "--file", + parent.to_str().expect("UTF-8 parent path"), + "--includes", + "--get", + "probe.value", + ], + ); + assert_eq!(value, "literal"); + add_include( + root, + "include.path", + parent.to_str().expect("UTF-8 parent path"), + ); + guard(root).expect("literal include path is external"); + run_success(root, &["config", "--unset-all", "include.path"]); + } +} + +#[test] +fn windows_path_validator_rejects_namespaces_streams_and_alias_components() { + for path in [ + r"\??\C:\repo\config", + r"\\?\GLOBALROOT\Device\config", + r"\\.\pipe\config", + r"C:\repo\config:stream", + r"C:relative\config", + r"C:\repo\NUL.gitconfig", + r"C:\repo\COM¹.gitconfig", + r"C:\repo\LPT³.gitconfig", + r"C:\repo\trailing.\config", + ] { + assert!(windows_config_path_is_ambiguous(path), "{path:?}"); + } + for path in [ + r"C:\external\config", + r"\\server\share\config", + r"\\?\C:\repo\.git\config", + r"\\.\C:\repo\.git\config", + r"\\?\UNC\server\share\config", + r"\\.\UNC\server\share\config", + r"..\driver-config", + r".\driver-config", + ] { + assert!(!windows_config_path_is_ambiguous(path), "{path:?}"); + } +} diff --git a/codex-rs/git-utils/src/git_config_tests.rs b/codex-rs/git-utils/src/git_config_tests.rs index 0e2bae1a8a..77a055997a 100644 --- a/codex-rs/git-utils/src/git_config_tests.rs +++ b/codex-rs/git-utils/src/git_config_tests.rs @@ -12,7 +12,7 @@ command\0command line:\0merge.Name.driver\nhelper %A %B\0"; entries.get("filter.demo.clean"), Some(&GitConfigEntry { scope: GitConfigScope::Local, - origin: "file:/repo/.git/config".to_string(), + origin: GitConfigOrigin::File("/repo/.git/config".into()), key: "filter.demo.clean".to_string(), value: String::new(), }) @@ -21,13 +21,56 @@ command\0command line:\0merge.Name.driver\nhelper %A %B\0"; entries.get("merge.Name.driver"), Some(&GitConfigEntry { scope: GitConfigScope::Command, - origin: "command line:".to_string(), + origin: GitConfigOrigin::CommandLine, key: "merge.Name.driver".to_string(), value: "helper %A %B".to_string(), }) ); } +#[test] +fn entry_parsers_preserve_duplicate_include_directives_in_order() { + let scoped = b"local\0file:.git/config\0include.path\n../unsafe.gitconfig\0\ +local\0file:.git/config\0include.path\n/absolute/external.gitconfig\0"; + let legacy = b"file:.git/config\0include.path\n../unsafe.gitconfig\0\ +file:.git/config\0include.path\n/absolute/external.gitconfig\0"; + let expected_scoped = vec![ + GitConfigEntry { + scope: GitConfigScope::Local, + origin: GitConfigOrigin::File(".git/config".into()), + key: "include.path".to_string(), + value: "../unsafe.gitconfig".to_string(), + }, + GitConfigEntry { + scope: GitConfigScope::Local, + origin: GitConfigOrigin::File(".git/config".into()), + key: "include.path".to_string(), + value: "/absolute/external.gitconfig".to_string(), + }, + ]; + assert_eq!( + parse_config_entries(scoped).expect("scoped entries"), + expected_scoped + ); + assert_eq!( + parse_config_entries_with_origins(legacy).expect("legacy entries"), + expected_scoped + ); +} + +#[cfg(unix)] +#[test] +fn config_origin_parser_preserves_non_utf8_unix_path_bytes() { + use std::os::unix::ffi::OsStrExt; + + let entries = parse_config_entries(b"local\0file:/tmp/config-\xff\0include.path\n/tmp/safe\0") + .expect("non-UTF-8 config origin"); + let GitConfigOrigin::File(path) = &entries[0].origin else { + panic!("expected file origin"); + }; + assert_eq!(path.as_os_str().as_bytes(), b"/tmp/config-\xff"); +} + #[test] fn rejects_malformed_config_records() { for output in [ @@ -51,7 +94,7 @@ command line:\0merge.Name.driver\nhelper %A %B\0"; entries.get("filter.demo.clean"), Some(&GitConfigEntry { scope: GitConfigScope::Local, - origin: "file:/repo/.git/config".to_string(), + origin: GitConfigOrigin::File("/repo/.git/config".into()), key: "filter.demo.clean".to_string(), value: String::new(), }) @@ -60,7 +103,7 @@ command line:\0merge.Name.driver\nhelper %A %B\0"; entries.get("merge.Name.driver"), Some(&GitConfigEntry { scope: GitConfigScope::Command, - origin: "command line:".to_string(), + origin: GitConfigOrigin::CommandLine, key: "merge.Name.driver".to_string(), value: "helper %A %B".to_string(), }) diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 9309328e0b..b63bddd518 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -6,6 +6,7 @@ mod errors; mod fsmonitor; mod git_command; mod git_config; +mod git_config_sources; mod git_executable; mod info; mod local_only; diff --git a/codex-rs/git-utils/src/patch_paths.rs b/codex-rs/git-utils/src/patch_paths.rs index 0cabc8a791..b42defd914 100644 --- a/codex-rs/git-utils/src/patch_paths.rs +++ b/codex-rs/git-utils/src/patch_paths.rs @@ -9,16 +9,23 @@ use crate::apply::safe_git_config_parts; use crate::apply::write_temp_patch; use crate::git_command::GitRunner; use crate::git_config::path_is_within; +use crate::git_config_sources::ensure_no_worktree_config_sources; +/// Extract paths with Git from a cwd whose config sources have already been +/// authorized for `git_config_args`. pub(crate) fn extract_effective_paths_from_patch( git: &GitRunner, + authorized_cwd: &Path, patch_path: &Path, revert: bool, + git_config_args: &[String], ) -> io::Result> { - let forward_paths = git_apply_numstat_paths(git, patch_path, revert)?; + let forward_paths = + git_apply_numstat_paths(git, authorized_cwd, patch_path, revert, git_config_args)?; // `git apply --numstat` reports only the destination of a rename. Parse the // opposite orientation too so both endpoints are included in the result. - let reverse_paths = git_apply_numstat_paths(git, patch_path, !revert)?; + let reverse_paths = + git_apply_numstat_paths(git, authorized_cwd, patch_path, !revert, git_config_args)?; if forward_paths.len() != reverse_paths.len() { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -44,27 +51,38 @@ pub(crate) fn extract_effective_paths_from_patch( /// Security-sensitive callers must use the fallible internal extractor so an /// invalid or ambiguous patch is rejected instead of becoming an empty list. pub fn extract_paths_from_patch(diff_text: &str) -> Vec { + let Ok(cwd) = std::env::current_dir() else { + return Vec::new(); + }; + extract_paths_from_patch_from_cwd(diff_text, &cwd) +} + +fn extract_paths_from_patch_from_cwd(diff_text: &str, cwd: &Path) -> Vec { let Ok((tmpdir, patch_path)) = write_temp_patch(diff_text) else { return Vec::new(); }; - let paths = std::env::current_dir() - .ok() - .and_then(|cwd| GitRunner::for_cwd(&cwd).ok()) - .and_then(|git| { - extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ false).ok() - }) - .unwrap_or_default(); + let paths = (|| -> io::Result> { + let git = GitRunner::for_cwd_io(cwd)?; + 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, &[])?; + extract_effective_paths_from_patch(&git, &git_root, &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> { - let command_cwd = patch_path.parent().unwrap_or_else(|| Path::new(".")); - let mut cmd = git.command_for_cwd(command_cwd)?; + let mut cmd = git.command_for_cwd(authorized_cwd)?; + cmd.args(git_config_args); cmd.args(["apply", "--numstat", "-z"]); if revert { cmd.arg("-R"); @@ -226,10 +244,18 @@ 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)?; let (tmpdir, patch_path) = write_temp_patch(diff)?; - let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?; + let paths = extract_effective_paths_from_patch( + &git, + git_root, + &patch_path, + /*revert*/ true, + &git_config_args, + )?; let _guard = tmpdir; - stage_effective_paths(&git, git_root, &paths, &safe_git_config_parts()) + stage_effective_paths(&git, git_root, &paths, &git_config_args) } pub(crate) fn stage_effective_paths( diff --git a/codex-rs/git-utils/src/patch_paths_tests.rs b/codex-rs/git-utils/src/patch_paths_tests.rs index e805c2d75b..8f72e3841e 100644 --- a/codex-rs/git-utils/src/patch_paths_tests.rs +++ b/codex-rs/git-utils/src/patch_paths_tests.rs @@ -62,13 +62,23 @@ fn read_file_normalized(path: &Path) -> String { fn effective_paths(diff: &str, revert: bool) -> io::Result> { let (tmpdir, patch_path) = write_temp_patch(diff)?; - let cwd = std::env::current_dir()?; - let git = GitRunner::for_cwd_io(&cwd)?; - let paths = extract_effective_paths_from_patch(&git, &patch_path, revert)?; + let repo = init_repo(); + let cwd = repo.path(); + let git = GitRunner::for_cwd_io(cwd)?; + 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, &[])?; drop(tmpdir); Ok(paths) } +fn best_effort_paths(diff: &str) -> Vec { + let repo = init_repo(); + extract_paths_from_patch_from_cwd(diff, repo.path()) +} + #[test] fn effective_paths_cover_supported_patch_headers() { let cases = [ @@ -127,7 +137,7 @@ fn effective_paths_cover_supported_patch_headers() { "{name}, revert={revert}" ); } - assert_eq!(extract_paths_from_patch(diff), expected, "{name}"); + assert_eq!(best_effort_paths(diff), expected, "{name}"); } let nul_rename_paths = parse_numstat_paths(b"0\t0\t\0old name.txt\0new name.txt\0") @@ -150,7 +160,7 @@ fn effective_paths_follow_git_for_mismatched_headers() { effective_paths(mismatch, /*revert*/ true).unwrap(), expected ); - assert_eq!(extract_paths_from_patch(mismatch), expected); + assert_eq!(best_effort_paths(mismatch), expected); } #[test] diff --git a/codex-rs/git-utils/src/path_authority.rs b/codex-rs/git-utils/src/path_authority.rs index b567df0368..526dce750b 100644 --- a/codex-rs/git-utils/src/path_authority.rs +++ b/codex-rs/git-utils/src/path_authority.rs @@ -18,7 +18,7 @@ use route_walker::invalid_data; use route_walker::observe_route; #[cfg(any(windows, test))] pub(crate) use windows_path::windows_authority_path_is_ambiguous; -#[cfg(windows)] +#[cfg(any(windows, test))] pub(crate) use windows_path::windows_path_is_ambiguous; #[derive(Clone, Copy, Eq, PartialEq)] diff --git a/codex-rs/git-utils/src/repository_authority/authority/policy.rs b/codex-rs/git-utils/src/repository_authority/authority/policy.rs index e77afde247..e5f2379460 100644 --- a/codex-rs/git-utils/src/repository_authority/authority/policy.rs +++ b/codex-rs/git-utils/src/repository_authority/authority/policy.rs @@ -33,6 +33,29 @@ impl RepositoryAuthority { Ok(canonical) } + pub(crate) fn ensure_config_source_is_not_worktree_controlled( + &self, + path: &Path, + description: &str, + ) -> io::Result<()> { + if !path.is_absolute() { + return Err(worktree_controlled_config_source(path, description)); + } + let inspection = self.route_boundaries.inspect_route(path)?; + if inspection.crosses_worktree { + return Err(worktree_controlled_config_source(path, description)); + } + for observed in inspection.observed_paths { + if self.route_boundaries.contains_known_boundary(&observed)? { + continue; + } + if self.has_related_repository_ancestor(&observed)? { + return Err(worktree_controlled_config_source(path, description)); + } + } + Ok(()) + } + pub(super) fn path_is_untrusted_for_executable_result(&self, path: &Path) -> io::Result { let inspection = self.route_boundaries.inspect_route(path)?; if inspection.touches_worktree || inspection.crosses_metadata { @@ -145,3 +168,13 @@ impl RepositoryAuthority { Ok(()) } } + +fn worktree_controlled_config_source(path: &Path, description: &str) -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "refusing to use worktree-controlled {description}: {}", + path.display() + ), + ) +} diff --git a/codex-rs/git-utils/src/safe_git.rs b/codex-rs/git-utils/src/safe_git.rs index bb362f9e34..e7904c001b 100644 --- a/codex-rs/git-utils/src/safe_git.rs +++ b/codex-rs/git-utils/src/safe_git.rs @@ -9,15 +9,21 @@ use std::process::Stdio; use crate::git_command::GitRunner; use crate::git_config::GitConfigEntry; -use crate::git_config::parse_effective_config; -use crate::git_config::parse_effective_config_with_origins; +use crate::git_config::read_effective_config_with_fallback as read_effective_config_unchecked; +use crate::git_config_sources::ensure_no_worktree_config_sources; +#[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; +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 = r"^filter\..*\.(clean|smudge|process|required)$"; #[derive(Debug, Clone, PartialEq, Eq)] -enum FilterAttributeValue { +pub(crate) enum FilterAttributeValue { Driver(String), AmbiguousSentinel(String), } @@ -82,13 +88,20 @@ pub(crate) fn ensure_no_selected_executable_git_filters( filter_config: entries, }); } + let guard = executable_filter_guard(git, cwd, entries, &executable_drivers)?; let paths = paths .iter() .map(|path| path.as_bytes().to_vec()) .collect::>(); - let attributes = - read_filter_attributes(git, cwd, &paths, git_config_args, &executable_drivers)?; - if let Some((driver, path)) = selected_executable_filter(&entries, &attributes)? { + let attributes = read_filter_attributes( + git, + cwd, + &paths, + git_config_args, + &executable_drivers, + &guard, + )?; + if let Some((driver, path)) = selected_executable_filter(&guard.filter_config, &attributes)? { return Err(io::Error::new( io::ErrorKind::Unsupported, format!( @@ -97,7 +110,7 @@ pub(crate) fn ensure_no_selected_executable_git_filters( ), )); } - executable_filter_guard(git, cwd, entries, &executable_drivers) + Ok(guard) } fn executable_filter_guard( @@ -146,12 +159,12 @@ impl GitFilterNeutralization { name: &str, value: &str, ) -> io::Result<()> { - let mut command = git.command(); - command - .args(["config", "--file"]) - .arg(config_path) - .args(["--add", &format!("filter.{driver}.{name}"), value]) - .current_dir(cwd); + let mut command = git.command_for_cwd(cwd)?; + command.args(["config", "--file"]).arg(config_path).args([ + "--add", + &format!("filter.{driver}.{name}"), + value, + ]); let output = git.output(command)?; if !output.status.success() { return Err(io::Error::other(format!( @@ -185,56 +198,8 @@ pub(crate) fn read_effective_config_with_fallback( pattern: &str, probe: &str, ) -> io::Result> { - let scoped = - run_effective_config_query(git, cwd, git_config_args, pattern, /*show_scope*/ true)?; - if scoped - .status - .code() - .is_some_and(|code| code == 0 || code == 1) - { - return parse_effective_config(&scoped.stdout); - } - - let legacy = run_effective_config_query( - git, - cwd, - git_config_args, - pattern, - /*show_scope*/ false, - )?; - if !legacy - .status - .code() - .is_some_and(|code| code == 0 || code == 1) - { - return Err(io::Error::other(format!( - "git {probe} config probe failed with status {}: {}", - legacy.status, - String::from_utf8_lossy(&legacy.stderr).trim() - ))); - } - parse_effective_config_with_origins(&legacy.stdout) -} - -fn run_effective_config_query( - git: &GitRunner, - cwd: &Path, - git_config_args: &[String], - pattern: &str, - show_scope: bool, -) -> io::Result { - let mut command = git.command(); - command - .env("GIT_OPTIONAL_LOCKS", "0") - .args(git_config_args) - .args(["config", "--null"]); - if show_scope { - command.arg("--show-scope"); - } - command - .args(["--show-origin", "--includes", "--get-regexp", pattern]) - .current_dir(cwd); - git.output(command) + ensure_no_worktree_config_sources(git, cwd, git_config_args)?; + read_effective_config_unchecked(git, cwd, git_config_args, pattern, probe) } fn read_filter_attributes( @@ -243,6 +208,7 @@ fn read_filter_attributes( paths: &[Vec], git_config_args: &[String], executable_drivers: &BTreeSet, + neutralization: &GitFilterNeutralization, ) -> io::Result, String>> { if paths.is_empty() { return Ok(BTreeMap::new()); @@ -251,7 +217,7 @@ fn read_filter_attributes( write_nul_paths(&mut input, paths)?; input.rewind()?; - let mut command = git.command(); + let mut command = git.command_for_cwd(cwd)?; command .env("GIT_OPTIONAL_LOCKS", "0") .args(git_config_args) @@ -265,7 +231,6 @@ fn read_filter_attributes( "-z", "filter", ]) - .current_dir(cwd) .stdin(Stdio::from(input)); let output = git.output(command)?; if !output.status.success() { @@ -276,7 +241,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) + resolve_filter_attribute_sentinels( + git, + cwd, + attributes, + git_config_args, + executable_drivers, + neutralization, + ) } fn resolve_filter_attribute_sentinels( @@ -285,8 +257,10 @@ fn resolve_filter_attribute_sentinels( attributes: BTreeMap, FilterAttributeValue>, git_config_args: &[String], executable_drivers: &BTreeSet, + neutralization: &GitFilterNeutralization, ) -> io::Result, String>> { let mut resolved = BTreeMap::new(); + let mut probe_budget = SentinelFilterProbeBudget::default(); for (path, attribute) in attributes { match attribute { FilterAttributeValue::Driver(driver) => { @@ -300,6 +274,8 @@ fn resolve_filter_attribute_sentinels( &path, &driver, git_config_args, + neutralization, + &mut probe_budget, )? { resolved.insert(path, driver); @@ -310,39 +286,37 @@ fn resolve_filter_attribute_sentinels( Ok(resolved) } -/// `git check-attr` serializes both its three special states and literal -/// driver names with the same `set`, `unset`, and `unspecified` strings. Ask -/// Git to resolve the ambiguity with every command for that driver overridden -/// to empty. A required literal driver fails while a special state succeeds. -/// Retrying with the driver optional distinguishes that expected failure from -/// an unrelated probe error. No filter process or shell is started. +/// 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, path: &[u8], driver: &str, git_config_args: &[String], + neutralization: &GitFilterNeutralization, + probe_budget: &mut SentinelFilterProbeBudget, ) -> io::Result { - let required = run_sentinel_selection_probe( + let probe = SentinelSelectionProbe { git, cwd, path, driver, git_config_args, - /*required*/ true, - )?; - if required.status.success() { + neutralization, + }; + let required = probe.run(/*required*/ true, probe_budget)?; + if classify_sentinel_filter_probes(required.status.success(), /*optional_succeeded*/ None) + == SentinelFilterProbeResolution::SpecialAttributeState + { return Ok(false); } - let optional = run_sentinel_selection_probe( - git, - cwd, - path, - driver, - git_config_args, - /*required*/ false, - )?; - if optional.status.success() { + let optional = probe.run(/*required*/ false, probe_budget)?; + if classify_sentinel_filter_probes( + required.status.success(), + /*optional_succeeded*/ Some(optional.status.success()), + ) == SentinelFilterProbeResolution::LiteralDriver + { return Ok(true); } Err(io::Error::other(format!( @@ -353,39 +327,47 @@ fn sentinel_spelling_selects_filter_driver( ))) } -fn run_sentinel_selection_probe( - git: &GitRunner, - cwd: &Path, - path: &[u8], - driver: &str, - git_config_args: &[String], - required: bool, -) -> io::Result { - let mut command = git.command(); - command - .env("GIT_OPTIONAL_LOCKS", "0") - .args(git_config_args) - .args([ - "-c", - &format!("core.hooksPath={DISABLED_HOOKS_PATH}"), - "-c", - "core.fsmonitor=false", - "-c", - &format!("filter.{driver}.required={required}"), - "-c", - &format!("filter.{driver}.clean="), - "-c", - &format!("filter.{driver}.smudge="), - "-c", - &format!("filter.{driver}.process="), - "hash-object", - "--stdin", - ]) - .arg("--path") - .arg(git_path_argument(path)?) - .current_dir(cwd) - .stdin(Stdio::null()); - git.output(command) +struct SentinelSelectionProbe<'a> { + git: &'a GitRunner, + cwd: &'a Path, + path: &'a [u8], + driver: &'a str, + git_config_args: &'a [String], + neutralization: &'a GitFilterNeutralization, +} + +impl SentinelSelectionProbe<'_> { + fn run( + &self, + required: bool, + probe_budget: &mut SentinelFilterProbeBudget, + ) -> io::Result { + let probe_config_args = sentinel_filter_probe_config_args( + self.neutralization.git_config_args(), + 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"]) + .arg("--path") + .arg(path) + .stdin(Stdio::null()); + probe_budget.ensure_probe_available()?; + let output = self.git.output(command)?; + probe_budget.record_completed_probe(); + Ok(output) + } } #[cfg(unix)] @@ -415,7 +397,7 @@ fn selected_executable_filter( Ok(None) } -fn executable_filter_drivers( +pub(crate) fn executable_filter_drivers( entries: &BTreeMap, ) -> io::Result> { let mut executable_drivers = BTreeSet::new(); @@ -456,7 +438,7 @@ fn write_nul_paths(input: &mut std::fs::File, paths: &[Vec]) -> io::Result<( Ok(()) } -fn parse_filter_attributes( +pub(crate) fn parse_filter_attributes( output: &[u8], expected_paths: &[Vec], ) -> io::Result, FilterAttributeValue>> { diff --git a/codex-rs/git-utils/src/safe_git_tests.rs b/codex-rs/git-utils/src/safe_git_tests.rs index 12fcf008f1..b04cd9d6f5 100644 --- a/codex-rs/git-utils/src/safe_git_tests.rs +++ b/codex-rs/git-utils/src/safe_git_tests.rs @@ -4,6 +4,55 @@ use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; +use std::process::Output; + +const FILTER_MARKER_KEY: &str = "codex.sentinelprobe-ran"; +const FILTER_MARKER_COMMAND: &str = + "git config codex.sentinelprobe-ran true && git hash-object --stdin"; + +fn run_git(cwd: &Path, args: &[&str]) -> Output { + let mut command = std::process::Command::new("git"); + isolate_git_command_environment(&mut command); + command + .current_dir(cwd) + .args(args) + .output() + .expect("run Git") +} + +fn run_git_success(cwd: &Path, args: &[&str]) -> Output { + let output = run_git(cwd, args); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn init_filter_repo() -> tempfile::TempDir { + let repo = tempfile::tempdir().expect("tempdir"); + run_git_success(repo.path(), &["init"]); + std::fs::write(repo.path().join("file.txt"), "content\n").expect("write file"); + repo +} + +fn configure_marker_filter(cwd: &Path, driver: &str) { + run_git_success( + cwd, + &[ + "config", + &format!("filter.{driver}.clean"), + FILTER_MARKER_COMMAND, + ], + ); +} + +fn marker_filter_ran(cwd: &Path) -> bool { + run_git(cwd, &["config", "--get", FILTER_MARKER_KEY]) + .status + .success() +} #[test] fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() { @@ -12,6 +61,7 @@ fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() { std::fs::write(&config, "").expect("config file"); for scope in [ + GitConfigScope::Unknown, GitConfigScope::System, GitConfigScope::Global, GitConfigScope::Local, @@ -137,6 +187,211 @@ fn filter_attribute_parser_rejects_malformed_or_unexpected_records() { } } +#[test] +fn sentinel_probe_primitives_preserve_order_budget_and_truth_table() { + assert_eq!( + classify_sentinel_filter_probes( + /*required_succeeded*/ true, /*optional_succeeded*/ None, + ), + SentinelFilterProbeResolution::SpecialAttributeState + ); + assert_eq!( + classify_sentinel_filter_probes( + /*required_succeeded*/ false, /*optional_succeeded*/ None, + ), + SentinelFilterProbeResolution::NeedsOptionalProbe + ); + assert_eq!( + classify_sentinel_filter_probes( + /*required_succeeded*/ false, + /*optional_succeeded*/ Some(true), + ), + SentinelFilterProbeResolution::LiteralDriver + ); + assert_eq!( + classify_sentinel_filter_probes( + /*required_succeeded*/ false, + /*optional_succeeded*/ Some(false), + ), + SentinelFilterProbeResolution::ProbeFailure + ); + + let neutralization = vec![ + "-c".to_string(), + "include.path=/private/filter-neutralization.gitconfig".to_string(), + ]; + assert_eq!( + sentinel_filter_probe_config_args(&neutralization, "set", /*required*/ true) + .expect("sentinel config args"), + vec![ + "-c", + "include.path=/private/filter-neutralization.gitconfig", + "-c", + "filter.set.required=true", + ] + ); + assert_eq!( + sentinel_filter_probe_config_args(&neutralization, "ordinary", /*required*/ true) + .expect_err("reject non-sentinel config argument") + .kind(), + io::ErrorKind::InvalidInput + ); + + let mut budget = SentinelFilterProbeBudget::default(); + assert_eq!(SentinelFilterProbeBudget::max_probes(), 16); + for _ in 0..SentinelFilterProbeBudget::max_probes() { + budget.ensure_probe_available().expect("probe in budget"); + budget.record_completed_probe(); + } + assert_eq!( + budget + .ensure_probe_available() + .expect_err("hard probe budget") + .to_string(), + "refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)" + ); +} + +#[test] +fn sentinel_special_states_and_literal_driver_names_remain_distinct() { + for (driver, special_rule) in [ + ("set", "file.txt filter\n"), + ("unset", "file.txt -filter\n"), + ("unspecified", ""), + ] { + let repo = init_filter_repo(); + let root = repo.path(); + configure_marker_filter(root, driver); + let git = GitRunner::for_cwd_io(root).expect("trusted Git"); + + std::fs::write(root.join(".gitattributes"), special_rule).expect("write special attribute"); + ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[]) + .expect("allow special attribute state"); + assert!(!marker_filter_ran(root), "special {driver}"); + + std::fs::write( + root.join(".gitattributes"), + 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 error = match result { + Ok(_) => panic!("accepted literal sentinel-named driver {driver}"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{driver}"); + assert!(!marker_filter_ran(root), "literal {driver}"); + } +} + +#[test] +fn sentinel_probe_neutralizes_every_known_driver_after_attribute_swap() { + for alternate_driver in ["race", "x=y"] { + let repo = init_filter_repo(); + let root = repo.path(); + configure_marker_filter(root, "set"); + configure_marker_filter(root, alternate_driver); + std::fs::write(root.join(".gitattributes"), "file.txt filter\n") + .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 executable_drivers = executable_filter_drivers(&entries).expect("executable drivers"); + let neutralization = executable_filter_guard(&git, root, entries, &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()]) + .expect("initial attribute snapshot"); + + std::fs::write( + root.join(".gitattributes"), + format!("file.txt filter={alternate_driver}\n"), + ) + .expect("swap attribute after snapshot"); + let resolved = resolve_filter_attribute_sentinels( + &git, + root, + attributes, + &[], + &executable_drivers, + &neutralization, + ) + .expect("resolve stale sentinel snapshot safely"); + assert!(resolved.is_empty(), "{alternate_driver}"); + assert!(!marker_filter_ran(root), "{alternate_driver}"); + } +} + +#[test] +fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() { + let repo = init_filter_repo(); + 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 executable_drivers = executable_filter_drivers(&entries).expect("executable drivers"); + let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers) + .expect("filter neutralization"); + let attributes = (0..=SentinelFilterProbeBudget::max_probes()) + .map(|index| { + ( + format!("ordinary-{index}.txt").into_bytes(), + FilterAttributeValue::AmbiguousSentinel("unspecified".to_string()), + ) + }) + .collect(); + + let error = resolve_filter_attribute_sentinels( + &git, + root, + attributes, + &[], + &executable_drivers, + &neutralization, + ) + .expect_err("refuse sentinel work beyond hard child-probe budget"); + assert_eq!( + error.to_string(), + "refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)" + ); + assert!(!marker_filter_ran(root)); +} + +#[test] +fn unrelated_sentinel_probe_failures_remain_generic_and_fail_closed() { + 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 executable_drivers = executable_filter_drivers(&entries).expect("executable drivers"); + let neutralization = executable_filter_guard(&git, root, entries, &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, + b"file.txt", + "set", + &malformed_config, + &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") + ); + assert!(!marker_filter_ran(root)); +} + fn filter_entries( scope: GitConfigScope, origin: &Path, @@ -144,9 +399,9 @@ fn filter_entries( value: &str, ) -> BTreeMap { let origin = if origin == Path::new("command line:") { - "command line:".to_string() + crate::git_config::GitConfigOrigin::CommandLine } else { - format!("file:{}", origin.display()) + crate::git_config::GitConfigOrigin::File(origin.to_path_buf()) }; BTreeMap::from([( key.to_string(),