From bcf026cbcac3b15ecab8ae442605fd6169471220 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 21:19:17 -0700 Subject: [PATCH 1/9] git-utils: centralize repository authority and trusted Git launch --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/git-utils/Cargo.toml | 1 + codex-rs/git-utils/src/apply.rs | 35 +- codex-rs/git-utils/src/errors.rs | 28 +- codex-rs/git-utils/src/git_command.rs | 416 +--- codex-rs/git-utils/src/git_command_tests.rs | 2078 ++++++++++++++++- codex-rs/git-utils/src/git_executable.rs | 283 +++ codex-rs/git-utils/src/lib.rs | 3 + codex-rs/git-utils/src/operations.rs | 3 +- codex-rs/git-utils/src/patch_paths.rs | 7 +- codex-rs/git-utils/src/path_authority.rs | 476 ++++ .../src/path_authority/cancellation.rs | 161 ++ .../src/path_authority/route_walker.rs | 221 ++ .../src/path_authority/windows_path.rs | 69 + .../git-utils/src/repository_authority.rs | 492 ++++ .../src/repository_authority/authority.rs | 400 ++++ .../repository_authority/authority/policy.rs | 147 ++ .../src/repository_authority/helpers.rs | 89 + .../src/repository_authority/plain_config.rs | 137 ++ .../src/repository_authority_tests.rs | 255 ++ 21 files changed, 4867 insertions(+), 436 deletions(-) create mode 100644 codex-rs/git-utils/src/git_executable.rs create mode 100644 codex-rs/git-utils/src/path_authority.rs create mode 100644 codex-rs/git-utils/src/path_authority/cancellation.rs create mode 100644 codex-rs/git-utils/src/path_authority/route_walker.rs create mode 100644 codex-rs/git-utils/src/path_authority/windows_path.rs create mode 100644 codex-rs/git-utils/src/repository_authority.rs create mode 100644 codex-rs/git-utils/src/repository_authority/authority.rs create mode 100644 codex-rs/git-utils/src/repository_authority/authority/policy.rs create mode 100644 codex-rs/git-utils/src/repository_authority/helpers.rs create mode 100644 codex-rs/git-utils/src/repository_authority/plain_config.rs create mode 100644 codex-rs/git-utils/src/repository_authority_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 88601e938407..3be76a371104 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3137,6 +3137,7 @@ dependencies = [ "once_cell", "pretty_assertions", "regex", + "same-file", "schemars 0.8.22", "serde", "similar", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 34097b56aad5..736f0b04b259 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -381,6 +381,7 @@ rustls = { version = "0.23", default-features = false, features = [ ] } rustls-native-certs = "0.8.3" rustls-pki-types = "1.14.0" +same-file = "1.0.6" schemars = "0.8.22" seccompiler = "0.5.0" semver = "1.0" diff --git a/codex-rs/git-utils/Cargo.toml b/codex-rs/git-utils/Cargo.toml index 18ba4882529f..9f76e14424f5 100644 --- a/codex-rs/git-utils/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -19,6 +19,7 @@ futures = { workspace = true, features = ["alloc"] } gix = { workspace = true } once_cell = { workspace = true } regex = "1" +same-file = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } similar = { workspace = true } diff --git a/codex-rs/git-utils/src/apply.rs b/codex-rs/git-utils/src/apply.rs index 0d2f651596ff..2b9d76e959c7 100644 --- a/codex-rs/git-utils/src/apply.rs +++ b/codex-rs/git-utils/src/apply.rs @@ -127,12 +127,11 @@ fn resolve_git_root( git_config_args: &[String], ) -> io::Result { let requested_cwd = std::fs::canonicalize(cwd)?; - let mut command = git.command(); + let mut command = git.command_for_cwd(&requested_cwd)?; command .args(git_config_args) .arg("rev-parse") - .arg("--show-toplevel") - .current_dir(&requested_cwd); + .arg("--show-toplevel"); let out = git.output(command)?; let code = out.status.code().unwrap_or(-1); if code != 0 { @@ -198,14 +197,13 @@ pub(crate) fn run_git( git_cfg: &[String], args: &[String], ) -> io::Result<(i32, String, String)> { - let mut cmd = git.command(); + let mut cmd = git.command_for_cwd(cwd)?; for p in git_cfg { cmd.arg(p); } for a in args { cmd.arg(a); } - cmd.current_dir(cwd); let out = git.output(cmd)?; let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); @@ -666,4 +664,31 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1 assert!(error.to_string().contains("instead of expected worktree")); } } + + #[cfg(unix)] + #[test] + fn apply_propagates_unsafe_repository_metadata_before_git_launch() { + use std::os::unix::fs::symlink; + + let repo = init_repo(); + let root = repo.path(); + let external = tempfile::tempdir().expect("external metadata parent"); + let admin = external.path().join("admin"); + std::fs::rename(root.join(".git"), &admin).expect("move metadata external"); + symlink(&admin, root.join("switch")).expect("worktree metadata switch"); + std::fs::write(root.join(".git"), "gitdir: switch\n").expect("write gitdir marker"); + let (code, _, stderr) = run(root, &["git", "rev-parse", "--absolute-git-dir"]); + assert_eq!(code, 0, "native Git fixture: {stderr}"); + + let request = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: String::new(), + revert: false, + preflight: true, + }; + let error = apply_git_patch(&request).expect_err("unsafe metadata route"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("Git metadata route crosses")); + assert!(!error.to_string().contains("PATH")); + } } diff --git a/codex-rs/git-utils/src/errors.rs b/codex-rs/git-utils/src/errors.rs index 0615a394216c..574cfc311abf 100644 --- a/codex-rs/git-utils/src/errors.rs +++ b/codex-rs/git-utils/src/errors.rs @@ -36,8 +36,34 @@ pub enum GitToolingError { #[derive(Clone, Debug, Error, PartialEq, Eq)] pub(crate) enum GitReadError { - #[error("no trusted Git executable is available")] + #[error( + "no trusted native Git executable is available; script-based and non-native Git wrappers are skipped, so install a native Git binary outside the repository and place its directory on PATH" + )] NoTrustedGit, + #[error( + "refusing non-bare linked worktree because its primary worktree cannot be proven from Git metadata: {common_dir}; run the operation from the primary worktree, or use a standard linked-worktree or plain bare-backed layout" + )] + UnprovenPrimaryAuthority { common_dir: String }, + #[error("unsafe Git repository metadata at {path:?}: {reason}")] + UnsafeRepositoryMetadata { path: PathBuf, reason: String }, + #[error("invalid or unsupported Git repository metadata at {path:?}: {reason}")] + InvalidRepositoryMetadata { path: PathBuf, reason: String }, #[error("{path:?} is not a Git repository")] NotRepository { path: PathBuf }, } + +impl GitReadError { + pub(crate) fn io_kind(&self) -> std::io::ErrorKind { + match self { + Self::NoTrustedGit | Self::NotRepository { .. } => std::io::ErrorKind::NotFound, + Self::UnprovenPrimaryAuthority { .. } | Self::UnsafeRepositoryMetadata { .. } => { + std::io::ErrorKind::PermissionDenied + } + Self::InvalidRepositoryMetadata { .. } => std::io::ErrorKind::InvalidData, + } + } + + pub(crate) fn into_io_error(self) -> std::io::Error { + std::io::Error::new(self.io_kind(), self) + } +} diff --git a/codex-rs/git-utils/src/git_command.rs b/codex-rs/git-utils/src/git_command.rs index 7e68c547c4f9..786c448d6131 100644 --- a/codex-rs/git-utils/src/git_command.rs +++ b/codex-rs/git-utils/src/git_command.rs @@ -1,368 +1,132 @@ use std::ffi::OsStr; use std::io; -#[cfg(windows)] -use std::path::Component; use std::path::Path; use std::path::PathBuf; -#[cfg(windows)] -use std::path::Prefix; use std::process::Command; use crate::errors::GitReadError; -use crate::git_config::path_is_within; +#[cfg(test)] +use crate::git_executable::git_executable_name; +use crate::git_executable::harden_git_launch_environment; +#[cfg(test)] +use crate::git_executable::path_is_untrusted; +#[cfg(test)] +use crate::git_executable::search_directory_is_untrusted; +use crate::git_executable::select_git_executable; +#[cfg(all(test, windows))] +use crate::git_executable::windows_path_requires_fail_closed; +use crate::repository_authority::RepositoryAuthority; +#[cfg(test)] +use crate::repository_authority::parse_marker_path as parse_git_marker_path; use crate::safe_git::isolate_git_command_environment; /// A Git executable outside the repository-controlled roots for one operation. -#[derive(Clone, Debug)] +#[derive(Debug)] pub(crate) struct GitRunner { + /// Canonical executable target pinned at selection time. Never execute the + /// mutable PATH spelling after validation. executable: PathBuf, + argv0: PathBuf, + safe_path: std::ffi::OsString, + authority: RepositoryAuthority, } -struct UntrustedGitLocations { - roots: Vec, - common_dirs: Vec, -} - -impl GitRunner { - pub(crate) fn for_cwd(cwd: &Path) -> Result { - let locations = untrusted_git_locations_for_cwd(cwd)?; - let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?; - Self::from_search_path(&locations, &search_path) - } - - pub(crate) fn for_cwd_io(cwd: &Path) -> io::Result { - Self::for_cwd(cwd).map_err(|error| io::Error::new(io::ErrorKind::NotFound, error)) - } - - pub(crate) fn command(&self) -> Command { - Command::new(&self.executable) - } - - pub(crate) fn output(&self, mut command: Command) -> io::Result { - isolate_git_command_environment(&mut command); - command.envs(crate::local_only_git_env()); - command.output() - } - - fn from_search_path( - untrusted: &UntrustedGitLocations, - search_path: &OsStr, - ) -> Result { - for directory in std::env::split_paths(search_path) { - if !directory.is_absolute() { - continue; - } - // Check the raw PATH spelling before canonicalization. A search - // directory can traverse through an untrusted repository and - // resolve outside it; checking only the resolved parent or - // candidate would lose that provenance. - if search_directory_is_untrusted(&directory, untrusted) { - continue; - } - let candidate = directory.join(git_executable_name()); - if path_is_untrusted(&candidate, untrusted) { - continue; - } - let Ok(canonical_parent) = std::fs::canonicalize(&directory) else { - continue; - }; - if path_is_untrusted(&canonical_parent, untrusted) { - continue; - } - let Ok(canonical_candidate) = std::fs::canonicalize(&candidate) else { - continue; - }; - if path_is_untrusted(&canonical_candidate, untrusted) - || !is_native_executable_file(&canonical_candidate) - { - continue; - } - return Ok(Self { - // Preserve multicall spelling because argv[0] may select mode. - executable: candidate, - }); - } - Err(GitReadError::NoTrustedGit) - } +/// A Git command that can only be spawned through [`GitRunner::output`], +/// keeping metadata revalidation and launch hardening at one choke point. +pub(crate) struct GitCommand { + inner: Command, } -fn untrusted_git_locations_for_cwd(cwd: &Path) -> Result { - let lexical_cwd = if cwd.is_absolute() { - cwd.to_path_buf() - } else { - std::env::current_dir() - .map_err(|_| GitReadError::NotRepository { - path: cwd.to_path_buf(), - })? - .join(cwd) - }; - let canonical_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository { - path: cwd.to_path_buf(), - })?; - let worktree_root = crate::get_git_repo_root(&canonical_cwd) - .and_then(|root| std::fs::canonicalize(root).ok()) - .unwrap_or_else(|| canonical_cwd.clone()); - let mut locations = UntrustedGitLocations { - roots: Vec::new(), - common_dirs: Vec::new(), - }; - record_repository_ancestry(&worktree_root, &mut locations)?; - - // Canonicalization can erase a repository-controlled symlink prefix. Walk - // the requested spelling too, deliberately retaining symlink and `..` - // components so every lexical enclosing checkout remains untrusted. - let lexical_base = if lexical_cwd.is_dir() { - lexical_cwd - } else { - lexical_cwd - .parent() - .ok_or_else(|| GitReadError::NotRepository { - path: cwd.to_path_buf(), - })? - .to_path_buf() - }; - record_repository_ancestry(&lexical_base, &mut locations)?; - - // Callers commonly obtain their default cwd from `current_dir()`, which - // can already have erased a symlink spelling. Recover the standard logical - // process cwd only when it is absolute and resolves to both the requested - // cwd and the process cwd. Treating extra roots as untrusted cannot widen - // executable selection. - if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) { - record_repository_ancestry(&logical_cwd, &mut locations)?; +impl GitCommand { + pub(crate) fn arg(&mut self, arg: impl AsRef) -> &mut Self { + self.inner.arg(arg); + self } - Ok(locations) -} -fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option { - let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?; - if !paths_equal(&process_cwd, canonical_cwd) { - return None; - } - let logical_cwd = PathBuf::from(std::env::var_os("PWD")?); - if !logical_cwd.is_absolute() { - return None; + pub(crate) fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + self.inner.args(args); + self } - let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?; - paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd) -} -fn record_repository_ancestry( - start: &Path, - locations: &mut UntrustedGitLocations, -) -> Result<(), GitReadError> { - push_unique(&mut locations.roots, start.to_path_buf()); - record_repository_marker(start, locations)?; - for ancestor in start.parent().into_iter().flat_map(Path::ancestors) { - let dot_git = ancestor.join(".git"); - match std::fs::symlink_metadata(&dot_git) { - Ok(_) => { - push_unique(&mut locations.roots, ancestor.to_path_buf()); - let canonical_root = - std::fs::canonicalize(ancestor).map_err(|_| GitReadError::NoTrustedGit)?; - push_unique(&mut locations.roots, canonical_root.clone()); - record_repository_marker(ancestor, locations)?; - } - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(_) => return Err(GitReadError::NoTrustedGit), - } + pub(crate) fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.inner.env(key, value); + self } - Ok(()) } -fn record_repository_marker( - worktree_root: &Path, - locations: &mut UntrustedGitLocations, -) -> Result<(), GitReadError> { - let dot_git = worktree_root.join(".git"); - let common_dir = match std::fs::symlink_metadata(&dot_git) { - Ok(_) => resolve_common_git_dir(&dot_git).map_err(|()| GitReadError::NoTrustedGit)?, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(_) => return Err(GitReadError::NoTrustedGit), - }; - if !path_is_within(&common_dir, worktree_root) { - push_unique(&mut locations.roots, common_dir.clone()); +impl GitRunner { + pub(crate) fn for_cwd(cwd: &Path) -> Result { + let authority = repository_authority_for_cwd(cwd)?; + let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?; + Self::from_search_path(authority, &search_path) } - push_unique(&mut locations.common_dirs, common_dir); - Ok(()) -} -fn push_unique(paths: &mut Vec, path: PathBuf) { - if !paths.iter().any(|existing| paths_equal(existing, &path)) { - paths.push(path); + pub(crate) fn for_cwd_io(cwd: &Path) -> io::Result { + Self::for_cwd(cwd).map_err(GitReadError::into_io_error) } -} -fn path_is_untrusted(path: &Path, locations: &UntrustedGitLocations) -> bool { - if locations - .roots - .iter() - .any(|root| path_is_within(path, root)) - { - return true; - } - locations - .common_dirs - .iter() - .any(|common_dir| path_is_in_worktree_for_common_dir(path, common_dir)) -} + pub(crate) fn command(&self) -> GitCommand { + let mut command = Command::new(&self.executable); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; -fn search_directory_is_untrusted(directory: &Path, locations: &UntrustedGitLocations) -> bool { - #[cfg(windows)] - if windows_path_requires_fail_closed(directory) - || windows_path_has_untrusted_canonical_ancestor(directory, locations) - { - return true; + command.arg0(&self.argv0); + } + if let Some(parent) = self.executable.parent() { + command.current_dir(parent); + } + harden_git_launch_environment(&mut command, &self.safe_path); + GitCommand { inner: command } } - path_is_untrusted(directory, locations) -} - -#[cfg(windows)] -fn windows_path_requires_fail_closed(path: &Path) -> bool { - let mut components = path.components(); - let supported_namespace = match components.next() { - Some(Component::Prefix(prefix)) => match prefix.kind() { - Prefix::Disk(_) - | Prefix::VerbatimDisk(_) - | Prefix::UNC(_, _) - | Prefix::VerbatimUNC(_, _) => true, - Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device), - Prefix::Verbatim(namespace) => namespace - .to_str() - .is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")), - }, - _ => false, - }; - !supported_namespace || components.any(|component| matches!(component, Component::ParentDir)) -} -#[cfg(windows)] -fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool { - let bytes = device.as_encoded_bytes(); - bytes.eq_ignore_ascii_case(b"UNC") - || matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic()) -} - -#[cfg(windows)] -fn windows_path_has_untrusted_canonical_ancestor( - path: &Path, - locations: &UntrustedGitLocations, -) -> bool { - let Ok(canonical_path) = std::fs::canonicalize(path) else { - return true; - }; - if path_is_untrusted(&canonical_path, locations) { - return true; - } - for ancestor in path.ancestors().skip(1) { - let canonical_ancestor = match std::fs::canonicalize(ancestor) { - Ok(canonical_ancestor) => canonical_ancestor, - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::InvalidInput - ) => - { - continue; - } - Err(_) => return true, + pub(crate) fn command_for_cwd(&self, cwd: &Path) -> io::Result { + let cwd = if cwd.is_absolute() { + cwd.to_path_buf() + } else { + std::env::current_dir()?.join(cwd) }; - if path_is_untrusted(&canonical_ancestor, locations) { - return true; - } + let cwd = self.authority.canonical_command_cwd(&cwd)?; + let mut command = self.command(); + command.arg("-C").arg(cwd); + Ok(command) } - false -} -fn path_is_in_worktree_for_common_dir(path: &Path, expected_common_dir: &Path) -> bool { - let path = if path.is_dir() { - path - } else { - path.parent().unwrap_or(path) - }; - for ancestor in path.ancestors() { - let dot_git = ancestor.join(".git"); - match std::fs::symlink_metadata(&dot_git) { - Ok(_) => match resolve_common_git_dir(&dot_git) { - Ok(common_dir) if paths_equal(&common_dir, expected_common_dir) => return true, - Ok(_) => {} - Err(()) => return true, - }, - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(_) => return true, - } + pub(crate) fn output(&self, mut command: GitCommand) -> io::Result { + self.revalidate_active_repository_metadata()?; + isolate_git_command_environment(&mut command.inner); + command.inner.envs(crate::local_only_git_env()); + harden_git_launch_environment(&mut command.inner, &self.safe_path); + command.inner.output() } - false -} - -fn paths_equal(left: &Path, right: &Path) -> bool { - path_is_within(left, right) && path_is_within(right, left) -} -fn resolve_common_git_dir(dot_git: &Path) -> Result { - if dot_git.is_dir() { - return std::fs::canonicalize(dot_git).map_err(|_| ()); + fn revalidate_active_repository_metadata(&self) -> io::Result<()> { + self.authority.revalidate_active_repository_metadata() } - let contents = std::fs::read_to_string(dot_git).map_err(|_| ())?; - let git_dir = contents - .trim() - .strip_prefix("gitdir:") - .map(str::trim) - .filter(|path| !path.is_empty()) - .ok_or(())?; - let git_dir = canonicalize_from(dot_git.parent().ok_or(())?, git_dir)?; - let commondir = git_dir.join("commondir"); - if commondir.is_file() { - let common_dir = std::fs::read_to_string(commondir).map_err(|_| ())?; - let common_dir = common_dir.trim(); - if common_dir.is_empty() { - return Err(()); - } - return canonicalize_from(&git_dir, common_dir); - } - if git_dir - .parent() - .is_some_and(|parent| parent.file_name() == Some(OsStr::new("worktrees"))) - { - return std::fs::canonicalize(git_dir.parent().and_then(Path::parent).ok_or(())?) - .map_err(|_| ()); - } - Ok(git_dir) -} - -fn canonicalize_from(base: &Path, path: &str) -> Result { - std::fs::canonicalize(base.join(path)).map_err(|_| ()) -} - -#[cfg(windows)] -fn git_executable_name() -> &'static str { - "git.exe" -} -#[cfg(not(windows))] -fn git_executable_name() -> &'static str { - "git" -} - -#[cfg(unix)] -fn is_native_executable_file(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - std::fs::metadata(path) - .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn is_native_executable_file(path: &Path) -> bool { - path.extension() - .and_then(OsStr::to_str) - .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) - && std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) + fn from_search_path( + authority: RepositoryAuthority, + search_path: &OsStr, + ) -> Result { + authority.ensure_primary_authority()?; + let selected = select_git_executable(&authority, search_path)?; + Ok(Self { + executable: selected.executable, + argv0: selected.argv0, + safe_path: selected.safe_path, + authority, + }) + } } -#[cfg(not(any(unix, windows)))] -fn is_native_executable_file(path: &Path) -> bool { - std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) +pub(crate) fn repository_authority_for_cwd( + cwd: &Path, +) -> Result { + RepositoryAuthority::discover(cwd) } #[cfg(test)] diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 4347e71d50c4..5b2c0b2302b4 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -33,6 +33,25 @@ fn run_git(cwd: &Path, args: &[&str]) { assert!(status.success(), "git {args:?} failed"); } +fn run_git_stdout(cwd: &Path, args: &[&str]) -> String { + let mut command = Command::new("git"); + isolate_git_command_environment(&mut command); + let output = command + .args(args) + .current_dir(cwd) + .output() + .expect("run real Git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("UTF-8 Git output") + .trim() + .to_string() +} + fn commit_all(cwd: &Path, message: &str) { run_git( cwd, @@ -50,119 +69,1718 @@ fn commit_all(cwd: &Path, message: &str) { ); } -fn write_git_candidate(directory: &Path) { - std::fs::create_dir_all(directory).expect("create candidate directory"); - let candidate = directory.join(git_executable_name()); - #[cfg(unix)] - write_executable(&candidate, "#!/bin/sh\nexit 0\n"); - #[cfg(windows)] - std::fs::write(candidate, b"MZ").expect("write native executable fixture"); - #[cfg(not(any(unix, windows)))] - std::fs::write(candidate, b"git fixture").expect("write executable fixture"); +fn write_git_candidate(directory: &Path) { + std::fs::create_dir_all(directory).expect("create candidate directory"); + let candidate = directory.join(git_executable_name()); + std::fs::copy(native_git_fixture(), candidate).expect("copy native Git fixture"); +} + +fn native_git_fixture() -> PathBuf { + let path = std::env::var_os("PATH").expect("PATH"); + for directory in std::env::split_paths(&path) { + let candidate = directory.join(git_executable_name()); + if let Ok(candidate) = std::fs::canonicalize(candidate) + && crate::git_executable::is_native_executable_file(&candidate) + { + return candidate; + } + } + panic!("no native Git fixture in PATH") +} + +#[cfg(windows)] +fn create_junction(path: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/D", "/C", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("create junction"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "mklink failed: stdout={stdout} stderr={stderr}" + ); +} + +fn locations_for_root(root: &Path) -> 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::new()) + .expect("test 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(); + path.push(&separator); + path.push(".."); + path.push(&separator); + path.push(sibling); + path.into() +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("UTF-8 fixture path") +} + +struct OverlappingRegisteredRoute { + main: PathBuf, + nested: PathBuf, + linked: PathBuf, + nested_admin: PathBuf, + linked_admin: PathBuf, + registry_marker: PathBuf, + raw_marker: PathBuf, +} + +fn overlapping_registered_route(fixture: &Path) -> OverlappingRegisteredRoute { + let main = fixture.join("main"); + let nested = main.join("nested"); + let linked = main.join("linked"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&nested)]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let nested_admin = PathBuf::from(run_git_stdout( + &nested, + &["rev-parse", "--absolute-git-dir"], + )); + let linked_admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let registry_marker = linked_admin.join("gitdir"); + let raw_marker = nested.join("..").join("linked/.git"); + std::fs::write(®istry_marker, format!("{}\n", raw_marker.display())) + .expect("write overlapping registry route"); + OverlappingRegisteredRoute { + main, + nested, + linked, + nested_admin, + linked_admin, + registry_marker, + raw_marker, + } +} + +struct MetadataAliasRegisteredRoute { + main: PathBuf, + linked: PathBuf, + linked_admin: PathBuf, + pivot: PathBuf, + registry_marker: PathBuf, + raw_marker: PathBuf, +} + +fn metadata_alias_registered_route( + fixture: &Path, + main_name: &str, + route_main_name: &str, +) -> MetadataAliasRegisteredRoute { + let main = fixture.join(main_name); + let linked = main.join("linked"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let linked_admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let pivot = main.join("pivot"); + let registry_marker = linked_admin.join("gitdir"); + let raw_marker = fixture + .join(route_main_name) + .join("pivot") + .join("..") + .join("linked/.git"); + std::fs::write(®istry_marker, format!("{}\n", raw_marker.display())) + .expect("write metadata-alias registry route"); + MetadataAliasRegisteredRoute { + main, + linked, + linked_admin, + pivot, + registry_marker, + raw_marker, + } +} + +#[test] +fn git_metadata_marker_parser_preserves_leading_path_space() { + assert_eq!( + parse_git_marker_path(b"gitdir: relative/path \r\n", b"gitdir: ") + .expect("parse .git marker"), + PathBuf::from(" relative/path") + ); + assert_eq!( + parse_git_marker_path(b" relative/common \n", b"").expect("parse commondir marker"), + PathBuf::from(" relative/common") + ); + assert!(parse_git_marker_path(b"gitdir:/missing-space\n", b"gitdir: ").is_err()); +} + +fn selected_git(locations: &RepositoryAuthority, directories: &[&Path]) -> PathBuf { + let search_path = std::env::join_paths(directories).expect("PATH"); + select_git_executable(locations, &search_path) + .expect("trusted Git") + .argv0 +} + +fn assert_unsafe_metadata_route(result: Result, marker: &Path) { + match result { + Err(GitReadError::UnsafeRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, marker); + assert_eq!(reason, "Git metadata route crosses a repository worktree"); + } + other => panic!("expected unsafe repository metadata, got {other:?}"), + } +} + +fn assert_unsafe_registry_route(result: Result, marker: &Path) { + match result { + Err(GitReadError::UnsafeRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, marker); + assert_eq!( + reason, + "Git worktree registry route crosses a repository worktree" + ); + } + other => panic!("expected unsafe worktree registry route, got {other:?}"), + } +} + +fn assert_same_affected_path(actual: &Path, expected: &Path) { + assert_eq!(actual.file_name(), expected.file_name()); + let actual_parent = actual.parent().expect("actual marker parent"); + let expected_parent = expected.parent().expect("expected marker parent"); + assert!( + same_file::is_same_file(actual_parent, expected_parent).expect("compare marker parents"), + "marker parent differs: actual={} expected={}", + actual_parent.display(), + expected_parent.display() + ); +} + +#[test] +fn git_read_error_io_kind_table_is_exhaustive() { + let path = PathBuf::from("repository/.git"); + for (error, expected) in [ + (GitReadError::NoTrustedGit, io::ErrorKind::NotFound), + ( + GitReadError::NotRepository { path: path.clone() }, + io::ErrorKind::NotFound, + ), + ( + GitReadError::UnprovenPrimaryAuthority { + common_dir: "repository.git".to_string(), + }, + io::ErrorKind::PermissionDenied, + ), + ( + GitReadError::UnsafeRepositoryMetadata { + path: path.clone(), + reason: "crosses worktree".to_string(), + }, + io::ErrorKind::PermissionDenied, + ), + ( + GitReadError::InvalidRepositoryMetadata { + path, + reason: "malformed marker".to_string(), + }, + io::ErrorKind::InvalidData, + ), + ] { + assert_eq!(error.io_kind(), expected, "{error}"); + assert_eq!(error.into_io_error().kind(), expected); + } +} + +#[test] +fn malformed_metadata_preserves_path_reason_and_invalid_data_mapping() { + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repository"); + std::fs::create_dir_all(&root).expect("create repository root"); + let marker = root.join(".git"); + std::fs::write(&marker, "not-a-gitdir-marker\n").expect("write malformed marker"); + + match GitRunner::for_cwd(&root) { + Err(GitReadError::InvalidRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, &marker); + assert!(reason.contains("malformed Git metadata marker"), "{reason}"); + } + other => panic!("expected invalid repository metadata, got {other:?}"), + } + let error = GitRunner::for_cwd_io(&root).expect_err("malformed metadata"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("malformed Git metadata marker"), + "{error}" + ); + assert!(!error.to_string().contains("PATH"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn cyclic_metadata_route_is_invalid_not_unsafe() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repository"); + let external = fixture.path().join("external"); + std::fs::create_dir_all(&root).expect("create repository root"); + std::fs::create_dir_all(&external).expect("create external directory"); + let route = external.join("entry"); + symlink("entry", &route).expect("create route cycle"); + let marker = root.join(".git"); + std::fs::write(&marker, format!("gitdir: {}\n", route.display())).expect("write cyclic marker"); + + match GitRunner::for_cwd(&root) { + Err(GitReadError::InvalidRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, &marker); + assert!(!reason.is_empty()); + } + other => panic!("expected invalid repository metadata, got {other:?}"), + } + assert_eq!( + GitRunner::for_cwd_io(&root) + .expect_err("cyclic metadata route") + .kind(), + io::ErrorKind::InvalidData + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_directory_metadata_marker_is_invalid() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repository"); + let external = fixture.path().join("external-metadata"); + std::fs::create_dir_all(&root).expect("create repository root"); + run_git(&root, &["init", "-q"]); + std::fs::rename(root.join(".git"), &external).expect("move metadata directory"); + symlink(&external, root.join(".git")).expect("symlink metadata marker"); + + match GitRunner::for_cwd(&root) { + Err(GitReadError::InvalidRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, &root.join(".git")); + assert_eq!(reason, "symlinked Git metadata marker"); + } + other => panic!("expected invalid symlinked metadata marker, got {other:?}"), + } + assert_eq!( + GitRunner::for_cwd_io(&root) + .expect_err("symlinked metadata marker") + .kind(), + io::ErrorKind::InvalidData + ); +} + +#[cfg(windows)] +#[test] +fn directory_git_junction_to_external_metadata_is_rejected() { + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repository"); + let external = fixture.path().join("external-metadata"); + std::fs::create_dir_all(&root).expect("create repository root"); + run_git(&root, &["init", "-q"]); + std::fs::rename(root.join(".git"), &external).expect("move metadata directory"); + create_junction(&root.join(".git"), &external); + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + + let error = GitRunner::for_cwd(&root).expect_err("nonstandard directory metadata route"); + match error { + GitReadError::UnsafeRepositoryMetadata { path, reason } => { + assert_same_affected_path(&path, &root.join(".git")); + assert_eq!(reason, "nonstandard Git metadata directory"); + } + GitReadError::InvalidRepositoryMetadata { path, reason } => { + assert_same_affected_path(&path, &root.join(".git")); + assert_eq!(reason, "symlinked Git metadata marker"); + } + other => panic!("expected rejected directory metadata junction, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn resolver_skips_untrusted_path_entries_and_runs_external_candidate() { + let fixture = tempfile::tempdir().expect("fixture"); + let repo = fixture.path().join("repo"); + let repo_bin = repo.join("bin"); + let outside = fixture.path().join("outside"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&repo_bin).expect("repo bin"); + std::fs::create_dir_all(&outside).expect("outside bin"); + std::fs::create_dir_all(&trusted_bin).expect("trusted bin"); + write_git_candidate(&repo_bin); + std::os::unix::fs::symlink(repo_bin.join("git"), outside.join("git")) + .expect("outside symlink into repository"); + write_git_candidate(&trusted_bin); + + let path = std::env::join_paths([ + PathBuf::from("relative"), + repo_bin, + outside, + trusted_bin.clone(), + ]) + .expect("PATH"); + let locations = locations_for_root(&repo); + let runner = GitRunner::from_search_path(locations, &path).expect("trusted Git"); + assert_eq!(runner.argv0, trusted_bin.join("git")); + let mut command = runner.command(); + command.arg("--version"); + let output = runner.output(command).expect("run trusted Git"); + assert!(output.status.success()); + assert!(output.stdout.starts_with(b"git version ")); +} + +#[cfg(unix)] +#[test] +fn resolver_skips_env_absolute_and_no_shebang_git_scripts() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let repo = fixture.path().join("repo"); + let trusted = fixture.path().join("trusted"); + std::fs::create_dir_all(&repo).expect("create repository"); + write_git_candidate(&trusted); + + let mut script_dirs = Vec::new(); + for (name, prefix) in [ + ("env-shebang", "#!/usr/bin/env sh\n"), + ("absolute-shebang", "#!/bin/sh\n"), + ("no-shebang", ""), + ] { + let directory = fixture.path().join(name); + let marker = fixture.path().join(format!("{name}-ran")); + std::fs::create_dir_all(&directory).expect("create script directory"); + write_executable( + &directory.join("git"), + &format!("{prefix}touch '{}'\n", marker.display()), + ); + script_dirs.push((directory, marker)); + } + + let locations = locations_for_root(&repo); + let mut path_entries = script_dirs + .iter() + .map(|(directory, _)| directory.as_path()) + .collect::>(); + let scripts_only = std::env::join_paths(&path_entries).expect("scripts-only PATH"); + let error = match select_git_executable(&locations, &scripts_only) { + Ok(_) => panic!("selected a non-native Git wrapper"), + Err(error) => error, + }; + assert_eq!(error, GitReadError::NoTrustedGit); + let message = error.to_string(); + assert!(message.contains("native Git"), "{message}"); + assert!( + message.contains("script-based and non-native Git wrappers"), + "{message}" + ); + assert!(message.contains("outside the repository"), "{message}"); + assert!(message.contains("PATH"), "{message}"); + assert_eq!(error.io_kind(), io::ErrorKind::NotFound); + for (_, marker) in &script_dirs { + assert!( + !marker.exists(), + "non-native Git script executed: {marker:?}" + ); + } + path_entries.push(&trusted); + let path = std::env::join_paths(path_entries).expect("PATH"); + let runner = GitRunner::from_search_path(locations, &path).expect("native Git fallback"); + assert_eq!(runner.argv0, trusted.join("git")); + let mut command = runner.command(); + command.arg("--version"); + assert!( + runner + .output(command) + .expect("run native Git") + .status + .success() + ); + for (_, marker) in script_dirs { + assert!( + !marker.exists(), + "non-native Git script executed: {marker:?}" + ); + } +} + +#[cfg(unix)] +#[test] +fn selected_git_uses_sanitized_path_and_strips_loader_injection_environment() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let repo = fixture.path().join("repo"); + let unsafe_bin = repo.join("bin"); + let trusted = fixture.path().join("trusted"); + std::fs::create_dir_all(&unsafe_bin).expect("create unsafe bin"); + std::fs::create_dir_all(&trusted).expect("create trusted bin"); + std::fs::copy( + std::env::current_exe().expect("current test executable"), + trusted.join("git"), + ) + .expect("copy native environment probe fixture"); + + let locations = locations_for_root(&repo); + let path = std::env::join_paths([&unsafe_bin, &trusted]).expect("PATH"); + let runner = GitRunner::from_search_path(locations, &path).expect("trusted native env"); + let mut command = runner.command(); + let injected = repo.join("inject"); + command + .arg("git_command::tests::native_environment_probe_child") + .arg("--exact") + .arg("--nocapture") + .env("CODEX_GIT_ENVIRONMENT_PROBE_CHILD", "1") + .env( + "CODEX_GIT_EXPECTED_SAFE_PATH", + std::fs::canonicalize(&trusted).expect("canonical trusted bin"), + ); + for name in [ + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "LD_AUDIT", + "LIBPATH", + "SHLIB_PATH", + "GCONV_PATH", + "NIX_LD", + "NIX_LD_LIBRARY_PATH", + "CORECLR_ENABLE_PROFILING", + "COR_ENABLE_PROFILING", + "DOTNET_STARTUP_HOOKS", + ] { + command.env(name, &injected); + } + command.env("PATH", &path); + let output = runner.output(command).expect("run sanitized native env"); + assert!( + output.status.success(), + "native env failed with {}: stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[cfg(unix)] +#[test] +fn native_environment_probe_child() { + if std::env::var_os("CODEX_GIT_ENVIRONMENT_PROBE_CHILD").is_none() { + return; + } + for name in [ + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "LD_AUDIT", + "LIBPATH", + "SHLIB_PATH", + "GCONV_PATH", + "NIX_LD", + "NIX_LD_LIBRARY_PATH", + "CORECLR_ENABLE_PROFILING", + "COR_ENABLE_PROFILING", + "DOTNET_STARTUP_HOOKS", + ] { + assert!(std::env::var_os(name).is_none(), "{name} survived"); + } + assert_eq!( + std::env::var_os("PATH").expect("sanitized PATH"), + std::env::var_os("CODEX_GIT_EXPECTED_SAFE_PATH").expect("expected safe PATH") + ); +} + +#[test] +fn command_for_relative_cwd_uses_original_process_directory() { + let current = std::fs::canonicalize(std::env::current_dir().expect("current dir")) + .expect("canonical current dir"); + let fixture = tempfile::tempdir_in(¤t).expect("fixture"); + let repo = fixture.path().join("repo"); + std::fs::create_dir_all(&repo).expect("create repository"); + run_git(&repo, &["init", "-q"]); + let relative = repo.strip_prefix(¤t).expect("relative repository"); + let locations = repository_authority_for_cwd(&repo).expect("untrusted locations"); + let path = std::env::var_os("PATH").expect("PATH"); + let runner = GitRunner::from_search_path(locations, &path).expect("trusted Git"); + let mut command = runner + .command_for_cwd(relative) + .expect("relative command cwd"); + command.args(["rev-parse", "--show-toplevel"]); + crate::repository_authority::reset_bounded_marker_read_count(); + let output = runner.output(command).expect("run Git from relative cwd"); + assert!(output.status.success()); + assert_eq!( + std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .expect("canonical Git root"), + std::fs::canonicalize(&repo).expect("canonical expected root") + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 0, + "standard repository should not reread marker bodies" + ); +} + +#[cfg(unix)] +#[test] +fn selected_runner_executes_pinned_target_after_path_hop_is_retargeted() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let repo = fixture.path().join("repo"); + let switch = fixture.path().join("switch"); + let trusted = fixture.path().join("trusted"); + let attacker = fixture.path().join("attacker"); + let entry = fixture.path().join("entry"); + std::fs::create_dir_all(&repo).expect("create repository"); + std::fs::create_dir_all(&trusted).expect("create trusted target"); + std::fs::create_dir_all(&attacker).expect("create attacker target"); + std::fs::create_dir_all(&entry).expect("create PATH entry"); + std::fs::copy("/usr/bin/true", trusted.join("git")).expect("copy trusted native target"); + std::fs::copy("/usr/bin/false", attacker.join("git")).expect("copy attacker native target"); + std::os::unix::fs::symlink(&trusted, &switch).expect("trusted PATH hop"); + std::os::unix::fs::symlink(switch.join("git"), entry.join("git")) + .expect("external candidate through PATH hop"); + + let locations = locations_for_root(&repo); + let path = std::env::join_paths([&entry]).expect("PATH"); + let runner = GitRunner::from_search_path(locations, &path).expect("initial trusted target"); + std::fs::remove_file(&switch).expect("remove trusted PATH hop"); + std::os::unix::fs::symlink(&attacker, &switch).expect("retarget PATH hop"); + + let output = runner.output(runner.command()).expect("run selected Git"); + assert!(output.status.success(), "selected Git failed"); +} + +#[cfg(unix)] +#[test] +fn resolver_rejects_same_identity_symlink_alias_to_repository() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let repo = fixture.path().join("repo"); + let repo_bin = repo.join("bin"); + let alias = fixture.path().join("repo-alias"); + let alias_bin = alias.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&repo).expect("create repository"); + write_git_candidate(&repo_bin); + write_git_candidate(&trusted_bin); + std::os::unix::fs::symlink(&repo, &alias).expect("symlink repository alias"); + + let locations = locations_for_root(&repo); + assert_eq!( + selected_git(&locations, &[&alias_bin, &trusted_bin]), + trusted_bin.join(git_executable_name()) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn resolver_rejects_apfs_data_firmlink_alias_to_repository() { + let data_volume_temp = + std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp directory"); + let fixture = tempfile::tempdir_in(&data_volume_temp).expect("fixture under Data volume"); + let repo = fixture.path().join("repo"); + let repo_bin = repo.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&repo).expect("create repository"); + write_git_candidate(&repo_bin); + write_git_candidate(&trusted_bin); + + let data_alias = PathBuf::from("/System/Volumes/Data") + .join(repo.strip_prefix("/").expect("absolute repository path")); + let alias_bin = data_alias.join("bin"); + if std::fs::metadata(&data_alias).is_err() { + eprintln!("APFS Data firmlink alias is unavailable; skipping native alias assertion"); + return; + } + assert!( + same_file::is_same_file(&data_alias, &repo).expect("compare APFS Data alias identity"), + "APFS Data spelling does not identify the fixture repository" + ); + + let locations = locations_for_root(&repo); + assert_eq!( + selected_git(&locations, &[&alias_bin, &trusted_bin]), + trusted_bin.join(git_executable_name()) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn metadata_routes_distinguish_apfs_worktree_hop_from_direct_metadata_alias() { + use std::os::unix::fs::symlink; + + let data_volume_temp = + std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp directory"); + let fixture = tempfile::tempdir_in(&data_volume_temp).expect("fixture under Data volume"); + let outer = fixture.path().join("outer"); + let root = outer.join("nested"); + let admin = outer.join(".git/modules/nested"); + std::fs::create_dir_all(&root).expect("create nested repository"); + run_git(&outer, &["init", "-q"]); + run_git(&root, &["init", "-q"]); + std::fs::create_dir_all(admin.parent().expect("module metadata parent")) + .expect("create module metadata parent"); + std::fs::rename(root.join(".git"), &admin).expect("move metadata into outer .git"); + let data_alias = PathBuf::from("/System/Volumes/Data") + .join(outer.strip_prefix("/").expect("absolute repository path")); + if std::fs::metadata(&data_alias).is_err() { + eprintln!("APFS Data firmlink alias is unavailable; skipping native route assertion"); + return; + } + assert!(same_file::is_same_file(&data_alias, &outer).expect("compare APFS root alias")); + + // A direct alternate spelling of proven outer metadata remains compatible. + std::fs::write( + root.join(".git"), + format!( + "gitdir: {}\n", + data_alias.join(".git/modules/nested").display() + ), + ) + .expect("write direct metadata alias marker"); + let runner = GitRunner::for_cwd(&root).expect("direct APFS metadata alias"); + let mut command = runner + .command_for_cwd(&root) + .expect("direct APFS alias command"); + command.args(["rev-parse", "--absolute-git-dir"]); + assert!( + runner + .output(command) + .expect("direct APFS alias rev-parse") + .status + .success() + ); + + // A different symlink entry reached through the same root identity is a + // mutable worktree hop even when it exits to the same external metadata. + symlink(&admin, outer.join("switch")).expect("worktree route switch"); + std::fs::write( + root.join(".git"), + format!("gitdir: {}\n", data_alias.join("switch").display()), + ) + .expect("write APFS worktree-hop marker"); + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + assert_unsafe_metadata_route(repository_authority_for_cwd(&root), &root.join(".git")); +} + +#[cfg(windows)] +#[test] +fn resolver_rejects_same_identity_junction_alias_to_repository() { + let fixture = tempfile::tempdir().expect("fixture"); + let repo = fixture.path().join("repo"); + let repo_bin = repo.join("bin"); + let alias = fixture.path().join("repo-alias"); + let alias_bin = alias.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&repo).expect("create repository"); + write_git_candidate(&repo_bin); + write_git_candidate(&trusted_bin); + create_junction(&alias, &repo); + + let locations = locations_for_root(&repo); + assert_eq!( + selected_git(&locations, &[&alias_bin, &trusted_bin]), + trusted_bin.join(git_executable_name()) + ); +} + +#[cfg(unix)] +#[test] +fn gitdir_route_through_current_worktree_is_denied_before_path_selection() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + let external = fixture.path().join("external"); + let admin = external.join("admin"); + std::fs::create_dir_all(&root).expect("create repository"); + std::fs::create_dir_all(&external).expect("create external directory"); + run_git(&root, &["init", "-q"]); + std::fs::rename(root.join(".git"), &admin).expect("move metadata external"); + symlink(&admin, root.join("switch")).expect("worktree metadata switch"); + std::fs::write(root.join(".git"), "gitdir: switch\n").expect("write gitdir marker"); + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + + assert_unsafe_metadata_route(repository_authority_for_cwd(&root), &root.join(".git")); + let error = GitRunner::for_cwd_io(&root).expect_err("unsafe metadata route"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("Git metadata route crosses")); + assert!(!error.to_string().contains("PATH")); +} + +#[cfg(unix)] +#[test] +fn gitdir_route_that_enters_and_exits_worktree_by_identity_is_denied() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + let external = fixture.path().join("external"); + let admin = external.join("admin"); + let entry = external.join("entry"); + std::fs::create_dir_all(&root).expect("create repository"); + std::fs::create_dir_all(&external).expect("create external directory"); + run_git(&root, &["init", "-q"]); + std::fs::rename(root.join(".git"), &admin).expect("move metadata external"); + symlink(&root, &entry).expect("external entry into worktree"); + symlink(&admin, root.join("switch")).expect("worktree exit to metadata"); + std::fs::write( + root.join(".git"), + format!("gitdir: {}\n", entry.join("switch").display()), + ) + .expect("write identity-hop gitdir marker"); + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + + assert_unsafe_metadata_route(repository_authority_for_cwd(&root), &root.join(".git")); +} + +#[cfg(unix)] +#[test] +fn gitdir_symlink_target_cannot_hide_mutable_worktree_pivot_before_parent_traversal() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let primary = fixture.path().join("primary"); + let current = fixture.path().join("current"); + let attacker = fixture.path().join("attacker"); + let external = fixture.path().join("external"); + std::fs::create_dir_all(primary.join("pivot")).expect("create worktree pivot"); + std::fs::create_dir_all(¤t).expect("create current worktree"); + std::fs::create_dir_all(&attacker).expect("create attacker repository"); + std::fs::create_dir_all(&external).expect("create external directory"); + run_git(&primary, &["init", "-q"]); + run_git(&attacker, &["init", "-q"]); + symlink("../primary/pivot/../.git", external.join("entry")) + .expect("external entry with hidden worktree pivot"); + std::fs::write( + current.join(".git"), + format!("gitdir: {}\n", external.join("entry").display()), + ) + .expect("write current gitdir marker"); + assert_eq!( + std::fs::canonicalize(run_git_stdout( + ¤t, + &["rev-parse", "--absolute-git-dir"] + )) + .expect("canonical initial Git dir"), + std::fs::canonicalize(primary.join(".git")).expect("canonical primary Git dir") + ); + + assert_unsafe_metadata_route( + repository_authority_for_cwd(¤t), + ¤t.join(".git"), + ); + + std::fs::remove_dir(primary.join("pivot")).expect("remove ordinary pivot"); + std::fs::create_dir_all(attacker.join("nested")).expect("create attacker nested directory"); + symlink(attacker.join("nested"), primary.join("pivot")).expect("retarget worktree pivot"); + assert_eq!( + std::fs::canonicalize(run_git_stdout( + ¤t, + &["rev-parse", "--absolute-git-dir"] + )) + .expect("canonical retargeted Git dir"), + std::fs::canonicalize(attacker.join(".git")).expect("canonical attacker Git dir") + ); + assert_unsafe_metadata_route( + repository_authority_for_cwd(¤t), + ¤t.join(".git"), + ); +} + +#[cfg(unix)] +#[test] +fn standard_directory_cwd_cannot_hide_mutable_worktree_pivot() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let outer = fixture.path().join("outer"); + let original = fixture.path().join("external-standard-repo"); + let attacker = fixture.path().join("attacker"); + let attacker_repo = attacker.join("external-standard-repo"); + std::fs::create_dir_all(outer.join("pivot")).expect("create worktree pivot"); + std::fs::create_dir_all(&original).expect("create original repository"); + std::fs::create_dir_all(&attacker_repo).expect("create attacker repository"); + run_git(&outer, &["init", "-q"]); + run_git(&original, &["init", "-q"]); + run_git(&attacker_repo, &["init", "-q"]); + + let raw = outer + .join("pivot") + .join("..") + .join("..") + .join("external-standard-repo"); + assert_eq!( + std::fs::canonicalize(run_git_stdout(&raw, &["rev-parse", "--show-toplevel"])) + .expect("canonical initial Git root"), + std::fs::canonicalize(&original).expect("canonical original repository") + ); + let exact_root_route = outer.join("..").join("external-standard-repo"); + repository_authority_for_cwd(&exact_root_route) + .expect("exact worktree root traversal remains valid"); + assert_unsafe_metadata_route(repository_authority_for_cwd(&raw), &raw.join(".git")); + + std::fs::remove_dir(outer.join("pivot")).expect("remove ordinary pivot"); + let attacker_target = attacker.join("deep/nested"); + std::fs::create_dir_all(&attacker_target).expect("create attacker pivot target"); + symlink(&attacker_target, outer.join("pivot")).expect("retarget worktree pivot"); + assert_eq!( + std::fs::canonicalize(run_git_stdout(&raw, &["rev-parse", "--show-toplevel"])) + .expect("canonical retargeted Git root"), + std::fs::canonicalize(&attacker_repo).expect("canonical attacker repository") + ); + assert_unsafe_metadata_route(repository_authority_for_cwd(&raw), &raw.join(".git")); +} + +#[cfg(unix)] +#[test] +fn command_cwd_is_bound_before_worktree_symlink_retarget() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let outer = fixture.path().join("outer"); + let original = fixture.path().join("original"); + let attacker = fixture.path().join("attacker"); + let alias = outer.join("nested"); + for repository in [&outer, &original, &attacker] { + std::fs::create_dir_all(repository).expect("create repository"); + run_git(repository, &["init", "-q"]); + } + symlink(&original, &alias).expect("create worktree-controlled cwd alias"); + + let runner = GitRunner::for_cwd(&alias).expect("runner for cwd alias"); + let mut pinned = runner + .command_for_cwd(&alias) + .expect("command pinned before retarget"); + pinned.args(["rev-parse", "--show-toplevel"]); + + std::fs::remove_file(&alias).expect("remove original cwd alias"); + symlink(&attacker, &alias).expect("retarget cwd alias"); + let error = match runner.command_for_cwd(&alias) { + Ok(_) => panic!("retargeted cwd alias was accepted"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!( + error + .to_string() + .contains("no longer resolves within the selected worktree"), + "{error}" + ); + + let output = runner.output(pinned).expect("run pinned cwd command"); + assert!(output.status.success()); + assert_eq!( + std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .expect("canonical pinned Git root"), + std::fs::canonicalize(&original).expect("canonical original repository") + ); +} + +#[cfg(windows)] +#[test] +fn command_cwd_is_bound_before_worktree_junction_retarget() { + let fixture = tempfile::tempdir().expect("fixture"); + let outer = fixture.path().join("outer"); + let original = fixture.path().join("original"); + let attacker = fixture.path().join("attacker"); + let alias = outer.join("nested"); + for repository in [&outer, &original, &attacker] { + std::fs::create_dir_all(repository).expect("create repository"); + run_git(repository, &["init", "-q"]); + } + create_junction(&alias, &original); + + let runner = GitRunner::for_cwd(&alias).expect("runner for cwd junction"); + let mut pinned = runner + .command_for_cwd(&alias) + .expect("command pinned before retarget"); + pinned.args(["rev-parse", "--show-toplevel"]); + + std::fs::remove_dir(&alias).expect("remove original cwd junction"); + create_junction(&alias, &attacker); + let error = match runner.command_for_cwd(&alias) { + Ok(_) => panic!("retargeted cwd junction was accepted"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + + let output = runner.output(pinned).expect("run pinned cwd command"); + assert!(output.status.success()); + assert_eq!( + std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .expect("canonical pinned Git root"), + std::fs::canonicalize(&original).expect("canonical original repository") + ); +} + +#[test] +fn gitdir_terminal_worktree_root_is_never_promoted_to_metadata() { + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + run_git(fixture.path(), &["init", "--bare", path_text(&root)]); + std::fs::write(root.join(".git"), "gitdir: .\n").expect("write self-root marker"); + assert_eq!( + std::fs::canonicalize(run_git_stdout(&root, &["rev-parse", "--absolute-git-dir"])) + .expect("canonical native self-root Git dir"), + std::fs::canonicalize(&root).expect("canonical worktree root") + ); + assert_unsafe_metadata_route(repository_authority_for_cwd(&root), &root.join(".git")); +} + +#[cfg(unix)] +#[test] +fn commondir_routes_through_worktree_are_denied_before_path_selection() { + use std::os::unix::fs::symlink; + + for identity_entry in [false, true] { + let fixture = tempfile::tempdir().expect("fixture"); + let primary = fixture.path().join("primary"); + let linked = fixture.path().join("linked"); + let entry = fixture.path().join("entry"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git(&primary, &["init", "-q"]); + run_git( + &primary, + &["worktree", "add", "--orphan", path_text(&linked)], + ); + let admin = PathBuf::from({ + let mut command = Command::new("git"); + isolate_git_command_environment(&mut command); + let output = command + .args(["rev-parse", "--absolute-git-dir"]) + .current_dir(&linked) + .output() + .expect("resolve linked admin"); + assert!(output.status.success()); + String::from_utf8(output.stdout) + .expect("UTF-8 linked admin") + .trim() + .to_string() + }); + symlink(primary.join(".git"), linked.join("switch")).expect("worktree common-dir switch"); + let route = if identity_entry { + symlink(&linked, &entry).expect("external entry into linked worktree"); + entry.join("switch") + } else { + linked.join("switch") + }; + std::fs::write(admin.join("commondir"), format!("{}\n", route.display())) + .expect("rewrite commondir route"); + run_git(&linked, &["rev-parse", "--git-common-dir"]); + + assert_unsafe_metadata_route(repository_authority_for_cwd(&linked), &linked.join(".git")); + } +} + +#[cfg(unix)] +#[test] +fn active_gitdir_route_is_revalidated_before_every_child() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + let admin = fixture.path().join("external-admin"); + std::fs::create_dir_all(&root).expect("create repository"); + run_git( + fixture.path(), + &[ + "init", + "--separate-git-dir", + path_text(&admin), + path_text(&root), + ], + ); + let runner = GitRunner::for_cwd(&root).expect("runner for direct external gitdir"); + let mut first = runner.command_for_cwd(&root).expect("first Git command"); + first.args(["rev-parse", "--absolute-git-dir"]); + assert!( + runner + .output(first) + .expect("first Git child") + .status + .success() + ); + + symlink(&admin, root.join("switch")).expect("worktree gitdir switch"); + std::fs::write(root.join(".git"), "gitdir: switch\n").expect("retarget gitdir marker"); + let sentinel = fixture.path().join("git-child-ran"); + let mut second = runner.command(); + second + .args(["config", "--file"]) + .arg(&sentinel) + .args(["probe.value", "ran"]); + let error = runner + .output(second) + .expect_err("retargeted gitdir must block second child"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!( + !sentinel.exists(), + "Git child executed after gitdir retarget" + ); +} + +#[cfg(unix)] +#[test] +fn active_commondir_route_is_revalidated_before_every_child() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let primary = fixture.path().join("primary"); + let linked = fixture.path().join("linked"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git(&primary, &["init", "-q"]); + run_git( + &primary, + &["worktree", "add", "--orphan", path_text(&linked)], + ); + let runner = GitRunner::for_cwd(&linked).expect("runner for linked worktree"); + let admin = runner + .authority + .active_git_dir() + .expect("active linked metadata") + .to_path_buf(); + let mut first = runner.command_for_cwd(&linked).expect("first Git command"); + first.args(["rev-parse", "--git-common-dir"]); + assert!( + runner + .output(first) + .expect("first Git child") + .status + .success() + ); + + symlink(primary.join(".git"), linked.join("switch")).expect("worktree common-dir switch"); + std::fs::write( + admin.join("commondir"), + format!("{}\n", linked.join("switch").display()), + ) + .expect("retarget commondir marker"); + let sentinel = fixture.path().join("git-child-ran"); + let mut second = runner.command(); + second + .args(["config", "--file"]) + .arg(&sentinel) + .args(["probe.value", "ran"]); + let error = runner + .output(second) + .expect_err("retargeted commondir must block second child"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!( + !sentinel.exists(), + "Git child executed after commondir retarget" + ); +} + +#[cfg(unix)] +#[test] +fn direct_external_separate_git_dir_absolute_and_relative_routes_execute_git() { + for relative in [false, true] { + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + let admin = fixture.path().join("external-admin"); + std::fs::create_dir_all(&root).expect("create repository"); + run_git( + fixture.path(), + &[ + "init", + "--separate-git-dir", + path_text(&admin), + path_text(&root), + ], + ); + if relative { + std::fs::write(root.join(".git"), "gitdir: ../external-admin\n") + .expect("write relative gitdir marker"); + } + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + let runner = GitRunner::for_cwd(&root).expect("separate-git-dir runner"); + let mut command = runner + .command_for_cwd(&root) + .expect("separate-git-dir command"); + command.args(["rev-parse", "--absolute-git-dir"]); + crate::repository_authority::reset_bounded_marker_read_count(); + assert!( + runner + .output(command) + .expect("separate-git-dir rev-parse") + .status + .success(), + "relative={relative}" + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 1, + "separate Git dir should reread only the active .git marker" + ); + } +} + +#[cfg(unix)] +#[test] +fn trusted_external_gitdir_symlink_chain_executes_git() { + use std::os::unix::fs::symlink; + + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + let external = fixture.path().join("external"); + let admin = external.join("admin"); + let alias_two = external.join("alias-two"); + let alias_one = external.join("alias-one"); + std::fs::create_dir_all(&root).expect("create repository"); + std::fs::create_dir_all(&external).expect("create external directory"); + run_git(&root, &["init", "-q"]); + std::fs::rename(root.join(".git"), &admin).expect("move metadata external"); + symlink(&admin, &alias_two).expect("second trusted alias"); + symlink(&alias_two, &alias_one).expect("first trusted alias"); + std::fs::write( + root.join(".git"), + format!("gitdir: {}\n", alias_one.display()), + ) + .expect("write trusted-chain marker"); + run_git(&root, &["rev-parse", "--absolute-git-dir"]); + + let runner = GitRunner::for_cwd(&root).expect("trusted-chain runner"); + let mut command = runner + .command_for_cwd(&root) + .expect("trusted-chain command"); + command.args(["rev-parse", "--absolute-git-dir"]); + assert!( + runner + .output(command) + .expect("trusted-chain rev-parse") + .status + .success() + ); +} + +#[test] +fn displaced_linked_admin_file_and_directory_routes_execute_git() { + for directory_marker in [false, true] { + for relative_common in [false, true] { + let fixture = tempfile::tempdir().expect("fixture"); + let primary = fixture.path().join("primary"); + let linked = fixture.path().join("linked"); + let displaced = fixture.path().join("external/admin"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git(&primary, &["init", "-q"]); + run_git( + &primary, + &["worktree", "add", "--orphan", path_text(&linked)], + ); + let original_admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let admin = if directory_marker { + std::fs::remove_file(linked.join(".git")).expect("remove linked marker"); + std::fs::rename(&original_admin, linked.join(".git")) + .expect("move admin to directory marker"); + linked.join(".git") + } else { + std::fs::create_dir_all(displaced.parent().expect("displaced parent")) + .expect("create displaced parent"); + std::fs::rename(&original_admin, &displaced).expect("displace linked admin"); + std::fs::write( + linked.join(".git"), + format!("gitdir: {}\n", displaced.display()), + ) + .expect("redirect linked marker"); + displaced.clone() + }; + let common = if relative_common { + "../../primary/.git".to_string() + } else { + primary.join(".git").display().to_string() + }; + std::fs::write(admin.join("commondir"), format!("{common}\n")) + .expect("rewrite displaced commondir"); + run_git(&linked, &["rev-parse", "--git-common-dir"]); + + let runner = GitRunner::for_cwd(&linked).expect("displaced-admin runner"); + let mut command = runner + .command_for_cwd(&linked) + .expect("displaced-admin command"); + command.args(["rev-parse", "--git-common-dir"]); + crate::repository_authority::reset_bounded_marker_read_count(); + assert!( + runner + .output(command) + .expect("displaced-admin rev-parse") + .status + .success(), + "directory_marker={directory_marker} relative_common={relative_common}" + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + if directory_marker { 1 } else { 2 }, + "displaced active marker read count" + ); + } + } +} + +#[test] +fn linked_worktree_rejects_git_from_main_and_linked_worktrees() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + let linked = fixture.path().join("linked"); + let main_bin = main.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + write_git_candidate(&main_bin); + write_git_candidate(&trusted_bin); + + let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); + assert!(path_is_untrusted( + &main_bin.join(git_executable_name()), + &locations + )); + let runner = GitRunner::from_search_path( + locations, + &std::env::join_paths([&main_bin, &trusted_bin]).expect("PATH"), + ) + .expect("linked-worktree runner"); + let mut command = runner + .command_for_cwd(&linked) + .expect("linked-worktree command"); + command.args(["rev-parse", "--show-toplevel"]); + crate::repository_authority::reset_bounded_marker_read_count(); + assert!( + runner + .output(command) + .expect("linked-worktree rev-parse") + .status + .success() + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 2, + "linked worktree should reread active .git and commondir markers" + ); +} + +#[test] +fn registered_sibling_remains_untrusted_without_its_worktree_marker_or_root() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let main = fixture.path().join("main"); + let sibling = fixture.path().join("sibling"); + let sibling_bin = sibling.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&sibling)]); + write_git_candidate(&sibling_bin); + write_git_candidate(&trusted_bin); + + for state in ["intact", "marker removed", "root recreated"] { + if state == "marker removed" { + std::fs::remove_file(sibling.join(".git")).expect("remove sibling .git marker"); + } else if state == "root recreated" { + std::fs::remove_dir_all(&sibling).expect("remove sibling root"); + write_git_candidate(&sibling_bin); + } + let locations = repository_authority_for_cwd(&main).expect("untrusted locations"); + assert!( + locations.contains_root(&sibling), + "registered sibling root missing in state {state}" + ); + assert_eq!( + selected_git(&locations, &[&sibling_bin, &trusted_bin]), + trusted_bin.join(git_executable_name()), + "registered sibling Git selected in state {state}" + ); + } +} + +#[test] +fn git_generated_relative_registry_route_is_accepted() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + let linked = fixture.path().join("linked"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let relative_marker = PathBuf::from("..") + .join("..") + .join("..") + .join("..") + .join("linked") + .join(".git"); + std::fs::write( + admin.join("gitdir"), + format!("{}\n", relative_marker.display()), + ) + .expect("write Git-style relative registry marker"); + + let authority = repository_authority_for_cwd(&main).expect("relative registry authority"); + assert!( + authority + .contains_root(&std::fs::canonicalize(&linked).expect("canonical linked worktree")) + ); + GitRunner::for_cwd(&linked).expect("relative linked-worktree runner"); +} + +#[test] +fn registered_worktree_route_rejects_canceled_worktree_descendant() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + let linked = fixture.path().join("linked"); + std::fs::create_dir_all(main.join("pivot")).expect("create worktree pivot"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let registry_marker = admin.join("gitdir"); + let raw_marker = main.join("pivot").join("..").join("..").join("linked/.git"); + std::fs::write(®istry_marker, format!("{}\n", raw_marker.display())) + .expect("write registry route through worktree pivot"); + + assert_unsafe_registry_route(repository_authority_for_cwd(&main), ®istry_marker); + let error = GitRunner::for_cwd_io(&main).expect_err("unsafe registry route"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); } -#[cfg(windows)] -fn create_junction(path: &Path, target: &Path) { - let output = Command::new("cmd.exe") - .args(["/D", "/C", "mklink", "/J"]) - .arg(path) - .arg(target) - .output() - .expect("create junction"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "mklink failed: stdout={stdout} stderr={stderr}" +#[test] +fn registered_route_rejects_root_that_is_also_outer_worktree_descendant() { + let fixture = tempfile::tempdir().expect("fixture"); + let route = overlapping_registered_route(fixture.path()); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical linked marker"), + std::fs::canonicalize(route.linked.join(".git")).expect("canonical expected marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, ); } -fn locations_for_root(root: &Path) -> UntrustedGitLocations { - let mut roots = vec![root.to_path_buf()]; - push_unique( - &mut roots, - std::fs::canonicalize(root).expect("canonical root"), +#[test] +fn registered_route_allows_parent_cancellation_inside_protected_metadata() { + let fixture = tempfile::tempdir().expect("fixture"); + let route = metadata_alias_registered_route(fixture.path(), "main", "main"); + let protected_marker = route.main.join(".git").join("..").join("linked/.git"); + std::fs::write( + &route.registry_marker, + format!("{}\n", protected_marker.display()), + ) + .expect("write protected-metadata registry route"); + + assert_eq!( + std::fs::canonicalize(&protected_marker).expect("canonical protected route"), + std::fs::canonicalize(route.linked.join(".git")).expect("canonical linked marker") ); - UntrustedGitLocations { - roots, - common_dirs: Vec::new(), - } + repository_authority_for_cwd(&route.main).expect("protected-metadata registry authority"); + GitRunner::for_cwd(&route.linked).expect("linked runner through protected metadata route"); } -fn raw_parent_traversal(root: &Path, sibling: &str) -> PathBuf { - let separator = std::path::MAIN_SEPARATOR.to_string(); - let mut path = root.as_os_str().to_os_string(); - path.push(&separator); - path.push(".."); - path.push(&separator); - path.push(sibling); - path.into() -} +#[cfg(unix)] +#[test] +fn metadata_alias_registered_route_is_rejected_before_and_after_symlink_retarget() { + use std::os::unix::fs::symlink; -fn path_text(path: &Path) -> &str { - path.to_str().expect("UTF-8 fixture path") + let fixture = tempfile::tempdir().expect("fixture"); + let route = metadata_alias_registered_route(fixture.path(), "main", "main"); + symlink(route.main.join(".git"), &route.pivot).expect("alias pivot to protected metadata"); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical metadata-alias route"), + std::fs::canonicalize(route.linked.join(".git")).expect("canonical linked marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); + + std::fs::remove_file(&route.pivot).expect("remove metadata alias"); + let attacker_target = fixture.path().join("attacker/deep"); + let attacker_linked = fixture.path().join("attacker/linked"); + std::fs::create_dir_all(&attacker_target).expect("create attacker pivot target"); + std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); + std::fs::write( + attacker_linked.join(".git"), + format!("gitdir: {}\n", route.linked_admin.display()), + ) + .expect("write attacker backlink"); + symlink(&attacker_target, &route.pivot).expect("retarget metadata alias"); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted route"), + std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); } -fn selected_git(locations: &UntrustedGitLocations, directories: &[&Path]) -> PathBuf { - let search_path = std::env::join_paths(directories).expect("PATH"); - GitRunner::from_search_path(locations, &search_path) - .expect("trusted Git") - .executable +#[cfg(windows)] +#[test] +fn metadata_alias_registered_route_rejects_unicode_case_junction_retarget() { + let fixture = tempfile::tempdir().expect("fixture"); + let route = metadata_alias_registered_route(fixture.path(), "Répo", "RÉPO"); + create_junction(&route.pivot, &route.main.join(".git")); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical metadata-alias route"), + std::fs::canonicalize(route.linked.join(".git")).expect("canonical linked marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); + + std::fs::remove_dir(&route.pivot).expect("remove metadata junction"); + let attacker_target = fixture.path().join("attacker/deep"); + let attacker_linked = fixture.path().join("attacker/linked"); + std::fs::create_dir_all(&attacker_target).expect("create attacker pivot target"); + std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); + std::fs::write( + attacker_linked.join(".git"), + format!("gitdir: {}\n", route.linked_admin.display()), + ) + .expect("write attacker backlink"); + create_junction(&route.pivot, &attacker_target); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted route"), + std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); } #[cfg(unix)] #[test] -fn resolver_skips_untrusted_path_entries_and_runs_external_candidate() { +fn overlapping_registered_route_remains_rejected_after_symlink_retarget() { + use std::os::unix::fs::symlink; + let fixture = tempfile::tempdir().expect("fixture"); - let repo = fixture.path().join("repo"); - let repo_bin = repo.join("bin"); - let outside = fixture.path().join("outside"); - let trusted_bin = fixture.path().join("trusted-bin"); - std::fs::create_dir_all(&repo_bin).expect("repo bin"); - std::fs::create_dir_all(&outside).expect("outside bin"); - std::fs::create_dir_all(&trusted_bin).expect("trusted bin"); - write_executable(&repo_bin.join("git"), "#!/bin/sh\nexit 1\n"); - std::os::unix::fs::symlink(repo_bin.join("git"), outside.join("git")) - .expect("outside symlink into repository"); - write_executable(&trusted_bin.join("git"), "#!/bin/sh\nprintf 'trusted\\n'\n"); + let route = overlapping_registered_route(fixture.path()); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); - let path = std::env::join_paths([ - PathBuf::from("relative"), - repo_bin, - outside, - trusted_bin.clone(), - ]) - .expect("PATH"); - let locations = locations_for_root(&repo); - let runner = GitRunner::from_search_path(&locations, &path).expect("trusted Git"); - assert_eq!(runner.executable, trusted_bin.join("git")); - let output = runner.output(runner.command()).expect("run trusted Git"); - assert_eq!(output.stdout, b"trusted\n"); + std::fs::remove_dir_all(&route.nested).expect("remove nested worktree"); + let attacker_parent = fixture.path().join("attacker/deep"); + let attacker_nested = attacker_parent.join("nested"); + let attacker_linked = attacker_parent.join("linked"); + std::fs::create_dir_all(&attacker_nested).expect("create attacker nested root"); + std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); + std::fs::write( + attacker_nested.join(".git"), + format!("gitdir: {}\n", route.nested_admin.display()), + ) + .expect("write attacker nested backlink"); + std::fs::write( + attacker_linked.join(".git"), + format!("gitdir: {}\n", route.linked_admin.display()), + ) + .expect("write attacker linked backlink"); + symlink(&attacker_nested, &route.nested).expect("retarget nested worktree root"); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted marker"), + std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); } +#[cfg(windows)] #[test] -fn linked_worktree_rejects_git_from_main_and_linked_worktrees() { +fn overlapping_registered_route_remains_rejected_after_junction_retarget() { + let fixture = tempfile::tempdir().expect("fixture"); + let route = overlapping_registered_route(fixture.path()); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); + + std::fs::remove_dir_all(&route.nested).expect("remove nested worktree"); + let attacker_parent = fixture.path().join("attacker/deep"); + let attacker_nested = attacker_parent.join("nested"); + let attacker_linked = attacker_parent.join("linked"); + std::fs::create_dir_all(&attacker_nested).expect("create attacker nested root"); + std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); + std::fs::write( + attacker_nested.join(".git"), + format!("gitdir: {}\n", route.nested_admin.display()), + ) + .expect("write attacker nested backlink"); + std::fs::write( + attacker_linked.join(".git"), + format!("gitdir: {}\n", route.linked_admin.display()), + ) + .expect("write attacker linked backlink"); + create_junction(&route.nested, &attacker_nested); + + assert_eq!( + std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted marker"), + std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") + ); + assert_unsafe_registry_route( + repository_authority_for_cwd(&route.main), + &route.registry_marker, + ); +} + +#[cfg(unix)] +#[test] +fn registered_worktree_route_remains_rejected_after_pivot_retarget() { + use std::os::unix::fs::symlink; + let fixture = tempfile::tempdir().expect("fixture"); let main = fixture.path().join("main"); let linked = fixture.path().join("linked"); - let git_dir = main.join(".git/worktrees/linked"); - let main_bin = main.join("bin"); - std::fs::create_dir_all(&git_dir).expect("linked Git directory"); - std::fs::create_dir_all(&linked).expect("linked worktree"); + let attacker = fixture.path().join("attacker"); + std::fs::create_dir_all(main.join("pivot")).expect("create worktree pivot"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let admin = PathBuf::from(run_git_stdout( + &linked, + &["rev-parse", "--absolute-git-dir"], + )); + let registry_marker = admin.join("gitdir"); + let raw_marker = main.join("pivot").join("..").join("..").join("linked/.git"); + std::fs::write(®istry_marker, format!("{}\n", raw_marker.display())) + .expect("write registry route through worktree pivot"); + assert_unsafe_registry_route(repository_authority_for_cwd(&main), ®istry_marker); + + std::fs::remove_dir(main.join("pivot")).expect("remove ordinary pivot"); + let attacker_target = attacker.join("deep/nested"); + let attacker_worktree = attacker.join("linked"); + std::fs::create_dir_all(&attacker_target).expect("create attacker pivot target"); + std::fs::create_dir_all(&attacker_worktree).expect("create attacker worktree"); std::fs::write( - linked.join(".git"), - format!("gitdir: {}\n", git_dir.display()), + attacker_worktree.join(".git"), + format!("gitdir: {}\n", admin.display()), ) - .expect("linked .git file"); - write_git_candidate(&main_bin); + .expect("write attacker backlink"); + symlink(&attacker_target, main.join("pivot")).expect("retarget worktree pivot"); - let locations = untrusted_git_locations_for_cwd(&linked).expect("untrusted locations"); - assert!(path_is_untrusted( - &main_bin.join(git_executable_name()), - &locations + assert_unsafe_registry_route(repository_authority_for_cwd(&main), ®istry_marker); + assert!(matches!( + GitRunner::for_cwd(&main), + Err(GitReadError::UnsafeRepositoryMetadata { .. }) )); } +#[test] +fn registered_worktree_backlink_mismatch_is_unsafe() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + let linked = fixture.path().join("linked"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + let marker = linked.join(".git"); + std::fs::write( + &marker, + format!("gitdir: {}\n", main.join(".git").display()), + ) + .expect("rewrite linked-worktree backlink"); + + match repository_authority_for_cwd(&main) { + Err(GitReadError::UnsafeRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, &marker); + assert_eq!(reason, "Git worktree registry backlink mismatch"); + } + other => panic!("expected unsafe registry backlink, got {other:?}"), + } + let error = GitRunner::for_cwd_io(&main).expect_err("mismatched worktree backlink"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(error.to_string().contains("backlink mismatch"), "{error}"); + assert!(!error.to_string().contains("PATH"), "{error}"); +} + +#[test] +fn unsupported_worktree_registry_entry_is_invalid_with_entry_path() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + std::fs::create_dir_all(&main).expect("create main worktree"); + run_git(&main, &["init", "-q"]); + let registry = main.join(".git/worktrees"); + std::fs::create_dir_all(®istry).expect("create worktree registry"); + let entry = registry.join("unsupported-entry"); + std::fs::write(&entry, "not a registry directory\n").expect("write invalid registry entry"); + + match repository_authority_for_cwd(&main) { + Err(GitReadError::InvalidRepositoryMetadata { path, reason }) => { + assert_same_affected_path(&path, &entry); + assert_eq!(reason, "unsupported Git worktree registry entry"); + } + other => panic!("expected invalid registry entry, got {other:?}"), + } + let error = GitRunner::for_cwd_io(&main).expect_err("unsupported registry entry"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error + .to_string() + .contains("unsupported Git worktree registry entry"), + "{error}" + ); + assert!(!error.to_string().contains("PATH"), "{error}"); +} + +#[test] +fn per_child_active_route_revalidation_is_independent_of_registered_sibling_count() { + let fixture = tempfile::tempdir().expect("fixture"); + let main = fixture.path().join("main"); + let active = fixture.path().join("active"); + std::fs::create_dir_all(&main).expect("create primary"); + run_git(&main, &["init", "-q"]); + run_git(&main, &["worktree", "add", "--orphan", path_text(&active)]); + for index in 0..12 { + let sibling = fixture.path().join(format!("sibling-{index}")); + run_git(&main, &["worktree", "add", "--orphan", path_text(&sibling)]); + } + + let runner = GitRunner::for_cwd(&active).expect("runner with many registered siblings"); + crate::repository_authority::reset_bounded_marker_read_count(); + let mut command = runner + .command_for_cwd(&active) + .expect("active worktree command"); + command.args(["rev-parse", "--git-common-dir"]); + assert!( + runner + .output(command) + .expect("active worktree rev-parse") + .status + .success() + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 2, + "per-child validation must read only active .git and commondir markers" + ); +} + #[test] fn nested_repository_rejects_git_from_enclosing_repository() { let fixture = tempfile::tempdir().expect("fixture"); @@ -176,7 +1794,7 @@ fn nested_repository_rejects_git_from_enclosing_repository() { write_git_candidate(&outer_bin); write_git_candidate(&trusted_bin); - let locations = untrusted_git_locations_for_cwd(&nested).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&nested).expect("untrusted locations"); assert!( path_is_untrusted(&outer_bin.join(git_executable_name()), &locations), "Git from an enclosing repository must remain repository-controlled" @@ -205,7 +1823,7 @@ fn symlinked_nested_repository_rejects_git_from_lexical_enclosing_repository() { write_git_candidate(&outer_bin); write_git_candidate(&trusted_bin); - let locations = untrusted_git_locations_for_cwd(&lexical_nested).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&lexical_nested).expect("untrusted locations"); assert!( path_is_untrusted(&outer_bin.join(git_executable_name()), &locations), "Git from the lexical enclosing repository must remain repository-controlled" @@ -232,7 +1850,7 @@ fn nested_repository_rejects_git_from_enclosing_repository_main_worktree() { write_git_candidate(&main_bin); write_git_candidate(&trusted_bin); - let locations = untrusted_git_locations_for_cwd(&nested).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&nested).expect("untrusted locations"); assert!( path_is_untrusted(&main_bin.join(git_executable_name()), &locations), "all worktrees of an enclosing repository must remain repository-controlled" @@ -276,7 +1894,7 @@ fn submodule_rejects_git_from_enclosing_superproject() { write_git_candidate(&outer_bin); write_git_candidate(&trusted_bin); - let locations = untrusted_git_locations_for_cwd(&submodule).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&submodule).expect("untrusted locations"); assert!( path_is_untrusted(&outer_bin.join(git_executable_name()), &locations), "Git from a superproject must remain repository-controlled" @@ -285,6 +1903,28 @@ fn submodule_rejects_git_from_enclosing_superproject() { selected_git(&locations, &[&outer_bin, &trusted_bin]), trusted_bin.join(git_executable_name()) ); + let runner = GitRunner::from_search_path( + locations, + &std::env::join_paths([&outer_bin, &trusted_bin]).expect("PATH"), + ) + .expect("submodule runner"); + let mut command = runner + .command_for_cwd(&submodule) + .expect("submodule command"); + command.args(["rev-parse", "--show-toplevel"]); + crate::repository_authority::reset_bounded_marker_read_count(); + assert!( + runner + .output(command) + .expect("submodule rev-parse") + .status + .success() + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 1, + "submodule should reread only its active .git marker" + ); } #[test] @@ -307,11 +1947,60 @@ fn bare_backed_linked_worktree_allows_external_git_in_sibling_directory() { ); write_git_candidate(&trusted_bin); - let locations = untrusted_git_locations_for_cwd(&linked).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); assert_eq!( selected_git(&locations, &[&trusted_bin]), trusted_bin.join(git_executable_name()) ); + let runner = GitRunner::from_search_path( + locations, + &std::env::join_paths([&trusted_bin]).expect("PATH"), + ) + .expect("bare-backed runner"); + let mut command = runner + .command_for_cwd(&linked) + .expect("bare-backed command"); + command.args(["rev-parse", "--git-common-dir"]); + crate::repository_authority::reset_bounded_marker_read_count(); + assert!( + runner + .output(command) + .expect("bare-backed rev-parse") + .status + .success() + ); + assert_eq!( + crate::repository_authority::bounded_marker_read_count(), + 2, + "bare-backed linked worktree should reread two active markers" + ); +} + +#[test] +fn linked_common_dir_nested_in_another_repo_is_rejected_before_path_selection() { + let fixture = tempfile::tempdir().expect("fixture"); + let outer = fixture.path().join("outer"); + let common = outer.join("nested-common.git"); + let linked = fixture.path().join("linked"); + run_git(fixture.path(), &["init", path_text(&outer)]); + std::fs::write(outer.join("seed.txt"), "seed\n").expect("write outer seed"); + run_git(&outer, &["add", "seed.txt"]); + commit_all(&outer, "outer seed"); + run_git(&outer, &["clone", "--bare", ".", path_text(&common)]); + run_git( + fixture.path(), + &[ + "--git-dir", + path_text(&common), + "worktree", + "add", + "-b", + "nested-common-linked", + path_text(&linked), + "HEAD", + ], + ); + assert_unsafe_metadata_route(repository_authority_for_cwd(&linked), &linked.join(".git")); } #[test] @@ -344,13 +2033,205 @@ fn separate_dot_git_dir_rejects_main_candidate_and_allows_unrelated_repo_candida write_git_candidate(&malformed_bin); std::fs::write(malformed.join(".git"), "not a gitdir").expect("malformed marker"); - let locations = untrusted_git_locations_for_cwd(&linked).expect("untrusted locations"); + let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); assert_eq!( selected_git(&locations, &[&main_bin, &malformed_bin, &unrelated_bin]), unrelated_bin.join(git_executable_name()) ); } +#[cfg(unix)] +#[test] +fn unproven_separate_primary_is_denied_before_any_path_git_executes() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + for authority_config in ["non-bare", "included false", "bare 08", "bare overflow"] { + let fixture = tempfile::tempdir_in(&temp_base).expect("fixture"); + let primary = fixture.path().join("primary"); + let common = fixture.path().join("separate-common"); + let linked = fixture.path().join("linked"); + let primary_bin = primary.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + let executed = fixture.path().join("attacker-git-ran"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git( + fixture.path(), + &[ + "init", + "--separate-git-dir", + path_text(&common), + path_text(&primary), + ], + ); + std::fs::write(primary.join("seed"), "seed\n").expect("write seed"); + run_git(&primary, &["add", "seed"]); + commit_all(&primary, "seed"); + run_git( + &primary, + &[ + "worktree", + "add", + "-b", + "unproven-primary-linked", + path_text(&linked), + ], + ); + if authority_config == "included false" { + let included = primary.join("bare-override.config"); + std::fs::write(&included, "[core]\n\tbare = false\n") + .expect("write included bare override"); + let mut config = + std::fs::read_to_string(common.join("config")).expect("read common config"); + config.push_str(&format!( + "\n[core]\n\tbare = true\n[include]\n\tpath = {}\n", + included.display() + )); + std::fs::write(common.join("config"), config).expect("write ambiguous bare config"); + } else if let Some(value) = match authority_config { + "bare 08" => Some("08"), + "bare overflow" => Some("2147483648"), + _ => None, + } { + let mut config = + std::fs::read_to_string(common.join("config")).expect("read common config"); + config.push_str(&format!("\n[core]\n\tbare = {value}\n")); + std::fs::write(common.join("config"), config).expect("write malformed bare config"); + } + write_git_candidate(&trusted_bin); + std::fs::create_dir_all(&primary_bin).expect("create primary bin"); + std::fs::copy("/usr/bin/touch", primary_bin.join("git")) + .expect("copy attacker native executable"); + std::fs::remove_file(primary.join(".git")).expect("remove primary reverse marker"); + + let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); + let path = std::env::join_paths([&primary_bin, &trusted_bin]).expect("PATH"); + let error = match GitRunner::from_search_path(locations, &path) { + Ok(runner) => { + let mut command = runner.command(); + command.arg(&executed); + let _ = runner.output(command); + panic!("unproven separate primary selected a PATH Git"); + } + Err(error) => error, + }; + assert!(!executed.exists(), "attacker Git executed"); + assert!(matches!( + error, + GitReadError::UnprovenPrimaryAuthority { .. } + )); + } +} + +#[cfg(unix)] +#[test] +fn nested_repo_inside_linked_outer_denies_unproven_outer_primary_before_path_git() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let primary = fixture.path().join("primary"); + let common = fixture.path().join("separate-common"); + let outer = fixture.path().join("outer"); + let nested = outer.join("nested"); + let primary_bin = primary.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + let executed = fixture.path().join("attacker-git-ran"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git( + fixture.path(), + &[ + "init", + "--separate-git-dir", + path_text(&common), + path_text(&primary), + ], + ); + std::fs::write(primary.join("seed"), "seed\n").expect("write seed"); + run_git(&primary, &["add", "seed"]); + commit_all(&primary, "seed"); + run_git( + &primary, + &[ + "worktree", + "add", + "-b", + "nested-unproven-outer", + path_text(&outer), + ], + ); + std::fs::create_dir_all(&nested).expect("create nested repository"); + run_git(&nested, &["init", "-q"]); + write_git_candidate(&trusted_bin); + std::fs::create_dir_all(&primary_bin).expect("create primary bin"); + std::fs::copy("/usr/bin/touch", primary_bin.join("git")) + .expect("copy attacker native executable"); + std::fs::remove_file(primary.join(".git")).expect("remove primary reverse marker"); + + let locations = repository_authority_for_cwd(&nested).expect("untrusted locations"); + let path = std::env::join_paths([&primary_bin, &trusted_bin]).expect("PATH"); + let error = match GitRunner::from_search_path(locations, &path) { + Ok(runner) => { + let mut command = runner.command(); + command.arg(&executed); + let _ = runner.output(command); + panic!("unproven enclosing linked repository selected a PATH Git"); + } + Err(error) => error, + }; + assert!(!executed.exists(), "attacker Git executed"); + assert!(matches!( + error, + GitReadError::UnprovenPrimaryAuthority { .. } + )); +} + +#[test] +fn explicit_absolute_core_worktree_is_recorded_before_path_selection() { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let primary = fixture.path().join("primary"); + let common = fixture.path().join("separate-common"); + let linked = fixture.path().join("linked"); + let primary_bin = primary.join("bin"); + let trusted_bin = fixture.path().join("trusted-bin"); + std::fs::create_dir_all(&primary).expect("create primary"); + run_git( + fixture.path(), + &[ + "init", + "--separate-git-dir", + path_text(&common), + path_text(&primary), + ], + ); + std::fs::write(primary.join("seed"), "seed\n").expect("write seed"); + run_git(&primary, &["add", "seed"]); + commit_all(&primary, "seed"); + run_git( + &primary, + &[ + "worktree", + "add", + "-b", + "explicit-core-worktree-linked", + path_text(&linked), + ], + ); + let mut config = std::fs::read_to_string(common.join("config")).expect("read common config"); + config.push_str(&format!("\n[core]\n\tworktree = {}\n", primary.display())); + std::fs::write(common.join("config"), config).expect("write explicit core.worktree"); + std::fs::remove_file(primary.join(".git")).expect("remove primary reverse marker"); + write_git_candidate(&primary_bin); + write_git_candidate(&trusted_bin); + + let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); + assert!( + locations.contains_root(&primary), + "explicit core.worktree was not recorded" + ); + assert_eq!( + selected_git(&locations, &[&primary_bin, &trusted_bin]), + trusted_bin.join(git_executable_name()) + ); +} + #[test] fn resolver_rejects_parent_traversal_spelled_through_repository() { let fixture = tempfile::tempdir().expect("fixture"); @@ -360,9 +2241,10 @@ fn resolver_rejects_parent_traversal_spelled_through_repository() { write_git_candidate(&trusted_bin); let locations = locations_for_root(&repo); - for root in &locations.roots { - // Construct the PATH entry as raw text so its `..` component survives - // long enough for the resolver to inspect the original spelling. + for root in locations.roots() { + // Append without PathBuf::push: it resolves `..` when `root` has a + // verbatim Windows prefix, before the resolver can inspect the PATH + // spelling. let traversing_path = raw_parent_traversal(root, "trusted-bin"); let search_path = std::env::join_paths([&traversing_path]).expect("PATH"); let split_paths = std::env::split_paths(&search_path).collect::>(); @@ -374,7 +2256,7 @@ fn resolver_rejects_parent_traversal_spelled_through_repository() { assert!( matches!( - GitRunner::from_search_path(&locations, &search_path), + select_git_executable(&locations, &search_path), Err(GitReadError::NoTrustedGit) ), "resolver accepted parent traversal from {root:?}" @@ -448,7 +2330,7 @@ fn resolver_rejects_unicode_case_alias_through_repository_junction() { )); let search_path = std::env::join_paths([verbatim_case_alias]).expect("PATH"); assert!(matches!( - GitRunner::from_search_path(&locations, &search_path), + GitRunner::from_search_path(locations, &search_path), Err(GitReadError::NoTrustedGit) )); } @@ -486,9 +2368,9 @@ fn resolver_selects_native_git_exe_only() { std::fs::create_dir_all(&scripts).expect("scripts"); std::fs::create_dir_all(&native).expect("native"); std::fs::write(scripts.join("git.cmd"), "@exit /b 0\r\n").expect("script"); - std::fs::write(native.join("git.exe"), b"MZ").expect("native executable fixture"); + std::fs::copy(native_git_fixture(), native.join("git.exe")).expect("native executable fixture"); let locations = locations_for_root(&repo); let path = std::env::join_paths([scripts, native.clone()]).expect("PATH"); - let runner = GitRunner::from_search_path(&locations, &path).expect("native Git"); - assert_eq!(runner.executable, native.join("git.exe")); + let runner = GitRunner::from_search_path(locations, &path).expect("native Git"); + assert_eq!(runner.argv0, native.join("git.exe")); } diff --git a/codex-rs/git-utils/src/git_executable.rs b/codex-rs/git-utils/src/git_executable.rs new file mode 100644 index 000000000000..7606b91a87fe --- /dev/null +++ b/codex-rs/git-utils/src/git_executable.rs @@ -0,0 +1,283 @@ +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::errors::GitReadError; +use crate::repository_authority::RepositoryAuthority; +#[cfg(windows)] +use std::path::Component; +#[cfg(windows)] +use std::path::Prefix; + +pub(crate) struct SelectedGitExecutable { + pub(crate) executable: PathBuf, + pub(crate) argv0: PathBuf, + pub(crate) safe_path: OsString, +} + +pub(crate) fn select_git_executable( + authority: &RepositoryAuthority, + search_path: &OsStr, +) -> Result { + let mut safe_directories = Vec::new(); + let mut selected = None; + for directory in std::env::split_paths(search_path) { + if !directory.is_absolute() || search_directory_is_untrusted(&directory, authority) { + continue; + } + let Ok(canonical_directory) = std::fs::canonicalize(&directory) else { + continue; + }; + if path_is_untrusted(&canonical_directory, authority) + || !std::fs::metadata(&canonical_directory).is_ok_and(|metadata| metadata.is_dir()) + { + continue; + } + push_unique_path(&mut safe_directories, canonical_directory); + + if selected.is_some() { + continue; + } + let candidate = directory.join(git_executable_name()); + if path_is_untrusted(&candidate, authority) { + continue; + } + let Ok(canonical_candidate) = std::fs::canonicalize(&candidate) else { + continue; + }; + if path_is_untrusted(&canonical_candidate, authority) + || !is_native_executable_file(&canonical_candidate) + { + continue; + } + if let Some(parent) = canonical_candidate.parent() { + push_unique_path(&mut safe_directories, parent.to_path_buf()); + } + selected = Some((canonical_candidate, candidate)); + } + let (executable, argv0) = selected.ok_or(GitReadError::NoTrustedGit)?; + let safe_path = + std::env::join_paths(safe_directories).map_err(|_| GitReadError::NoTrustedGit)?; + Ok(SelectedGitExecutable { + executable, + argv0, + safe_path, + }) +} + +pub(crate) fn harden_git_launch_environment( + command: &mut std::process::Command, + safe_path: &OsStr, +) { + let mut names = std::env::vars_os() + .map(|(name, _)| name) + .filter(|name| startup_injection_variable(name)) + .collect::>(); + names.extend( + command + .get_envs() + .filter(|&(name, _)| startup_injection_variable(name)) + .map(|(name, _)| name.to_os_string()), + ); + for name in names { + command.env_remove(name); + } + command.env("PATH", safe_path); + #[cfg(windows)] + command.env("NoDefaultCurrentDirectoryInExePath", "1"); +} + +fn startup_injection_variable(name: &OsStr) -> bool { + let name = name.to_string_lossy().to_ascii_uppercase(); + name.starts_with("DYLD_") + || name.starts_with("LD_") + || name == "LIBPATH" + || name == "SHLIB_PATH" + || name.starts_with("CORECLR_") + || name.starts_with("COR_") + || name.starts_with("DOTNET_") + || name == "GCONV_PATH" + || name == "NIX_LD" + || name == "NIX_LD_LIBRARY_PATH" +} + +pub(crate) fn path_is_untrusted(path: &Path, authority: &RepositoryAuthority) -> bool { + authority.path_is_untrusted_for_executable(path) +} + +pub(crate) fn search_directory_is_untrusted( + directory: &Path, + authority: &RepositoryAuthority, +) -> bool { + #[cfg(windows)] + if windows_path_requires_fail_closed(directory) + || windows_path_has_untrusted_canonical_ancestor(directory, authority) + { + return true; + } + path_is_untrusted(directory, authority) +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +#[cfg(windows)] +pub(crate) fn windows_path_requires_fail_closed(path: &Path) -> bool { + let mut components = path.components(); + let supported_namespace = match components.next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(_) + | Prefix::VerbatimDisk(_) + | Prefix::UNC(_, _) + | Prefix::VerbatimUNC(_, _) => true, + Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device), + Prefix::Verbatim(namespace) => namespace + .to_str() + .is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")), + }, + _ => false, + }; + !supported_namespace || components.any(|component| matches!(component, Component::ParentDir)) +} + +#[cfg(windows)] +fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool { + let bytes = device.as_encoded_bytes(); + bytes.eq_ignore_ascii_case(b"UNC") + || matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic()) +} + +#[cfg(windows)] +fn windows_path_has_untrusted_canonical_ancestor( + path: &Path, + authority: &RepositoryAuthority, +) -> bool { + let Ok(canonical_path) = std::fs::canonicalize(path) else { + return true; + }; + if path_is_untrusted(&canonical_path, authority) { + return true; + } + for ancestor in path.ancestors().skip(1) { + let canonical_ancestor = match std::fs::canonicalize(ancestor) { + Ok(canonical_ancestor) => canonical_ancestor, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::InvalidInput + ) => + { + continue; + } + Err(_) => return true, + }; + if path_is_untrusted(&canonical_ancestor, authority) { + return true; + } + } + false +} + +#[cfg(windows)] +pub(crate) fn git_executable_name() -> &'static str { + "git.exe" +} + +#[cfg(not(windows))] +pub(crate) fn git_executable_name() -> &'static str { + "git" +} + +#[cfg(target_os = "macos")] +pub(crate) fn is_native_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + return false; + } + let Ok(bytes) = read_prefix(path, 4) else { + return false; + }; + matches!( + bytes.as_slice(), + [0xfe, 0xed, 0xfa, 0xce] + | [0xfe, 0xed, 0xfa, 0xcf] + | [0xce, 0xfa, 0xed, 0xfe] + | [0xcf, 0xfa, 0xed, 0xfe] + | [0xca, 0xfe, 0xba, 0xbe] + | [0xbe, 0xba, 0xfe, 0xca] + | [0xca, 0xfe, 0xba, 0xbf] + | [0xbf, 0xba, 0xfe, 0xca] + ) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn is_native_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.is_file() + && metadata.permissions().mode() & 0o111 != 0 + && read_prefix(path, 4).is_ok_and(|bytes| bytes == b"\x7fELF") +} + +#[cfg(windows)] +pub(crate) fn is_native_executable_file(path: &Path) -> bool { + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + + if !path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) + { + return false; + } + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() || metadata.len() < 68 { + return false; + } + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + let mut dos = [0_u8; 64]; + if file.read_exact(&mut dos).is_err() || &dos[..2] != b"MZ" { + return false; + } + let offset = u32::from_le_bytes(dos[60..64].try_into().expect("PE offset bytes")) as u64; + if offset > 1024 * 1024 || offset + 4 > metadata.len() { + return false; + } + let mut signature = [0_u8; 4]; + file.seek(SeekFrom::Start(offset)).is_ok() + && file.read_exact(&mut signature).is_ok() + && signature == *b"PE\0\0" +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn is_native_executable_file(_path: &Path) -> bool { + false +} + +#[cfg(unix)] +fn read_prefix(path: &Path, length: usize) -> io::Result> { + use std::io::Read; + + let mut file = std::fs::File::open(path)?; + let mut bytes = vec![0; length]; + file.read_exact(&mut bytes)?; + Ok(bytes) +} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index d5c225a6d109..9309328e0b58 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -6,11 +6,14 @@ mod errors; mod fsmonitor; mod git_command; mod git_config; +mod git_executable; mod info; mod local_only; mod operations; mod patch_paths; +mod path_authority; mod platform; +mod repository_authority; mod safe_git; pub use apply::ApplyGitRequest; diff --git a/codex-rs/git-utils/src/operations.rs b/codex-rs/git-utils/src/operations.rs index 1564b81a1945..27c3e17ec44e 100644 --- a/codex-rs/git-utils/src/operations.rs +++ b/codex-rs/git-utils/src/operations.rs @@ -110,8 +110,7 @@ where } let command_string = build_command_string(&args_vec); let git = GitRunner::for_cwd_io(dir)?; - let mut command = git.command(); - command.current_dir(dir); + let mut command = git.command_for_cwd(dir)?; if let Some(envs) = env { for (key, value) in envs { command.env(key, value); diff --git a/codex-rs/git-utils/src/patch_paths.rs b/codex-rs/git-utils/src/patch_paths.rs index d6889fce89d2..168d9b448fa9 100644 --- a/codex-rs/git-utils/src/patch_paths.rs +++ b/codex-rs/git-utils/src/patch_paths.rs @@ -63,14 +63,13 @@ fn git_apply_numstat_paths( patch_path: &Path, revert: bool, ) -> io::Result> { - let mut cmd = git.command(); + let command_cwd = patch_path.parent().unwrap_or_else(|| Path::new(".")); + let mut cmd = git.command_for_cwd(command_cwd)?; cmd.args(["apply", "--numstat", "-z"]); if revert { cmd.arg("-R"); } - cmd.arg("--") - .arg(patch_path) - .current_dir(patch_path.parent().unwrap_or_else(|| Path::new("."))); + cmd.arg("--").arg(patch_path); let out = git.output(cmd)?; if !out.status.success() { return Err(io::Error::new( diff --git a/codex-rs/git-utils/src/path_authority.rs b/codex-rs/git-utils/src/path_authority.rs new file mode 100644 index 000000000000..ab0cd13ccbbd --- /dev/null +++ b/codex-rs/git-utils/src/path_authority.rs @@ -0,0 +1,476 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use same_file::Handle; + +use crate::git_config::path_is_within; + +mod cancellation; +mod route_walker; +#[cfg(any(windows, test))] +mod windows_path; + +use route_walker::RawRouteObservation; +use route_walker::RouteObservationSnapshot; +use route_walker::observe_route; +#[cfg(any(windows, test))] +pub(crate) use windows_path::windows_authority_path_is_ambiguous; +#[cfg(windows)] +pub(crate) use windows_path::windows_path_is_ambiguous; + +#[derive(Clone, Copy, Eq, PartialEq)] +enum PathBoundary { + Worktree, + Metadata, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum CandidateBoundary { + Outside, + Metadata, + ExactWorktree, + Worktree, +} + +#[derive(Debug, Default)] +pub(crate) struct RepositoryRouteBoundaries { + worktree_roots: Vec, + metadata_dirs: Vec, + worktree_identities: Vec, + metadata_identities: Vec, +} + +pub(crate) struct RouteInspection { + pub(crate) crosses_worktree: bool, + pub(crate) touches_worktree: bool, + pub(crate) crosses_metadata: bool, + pub(crate) observed_paths: Vec, +} + +impl RepositoryRouteBoundaries { + pub(crate) fn inspect_route(&self, route: &Path) -> io::Result { + inspect_route(route, self) + } + + pub(crate) fn contains_known_boundary(&self, path: &Path) -> io::Result { + Ok(classify_candidate( + path, + &self.worktree_roots, + &self.metadata_dirs, + &self.worktree_identities, + &self.metadata_identities, + )? != CandidateBoundary::Outside) + } +} + +pub(crate) fn repository_route_boundaries( + roots: &[PathBuf], + common_dirs: &[PathBuf], +) -> io::Result { + let mut worktree_roots = Vec::new(); + for root in roots { + push_unique_location(&mut worktree_roots, root.clone())?; + } + let worktree_identities = directory_identities(&worktree_roots)?; + + let mut metadata_dirs = Vec::new(); + for root in &worktree_roots { + let marker = root.join(".git"); + let Ok(metadata) = std::fs::symlink_metadata(&marker) else { + continue; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + continue; + } + let canonical_root = std::fs::canonicalize(root)?; + let canonical_marker = std::fs::canonicalize(&marker)?; + if canonical_marker == canonical_root.join(".git") { + push_unique_location(&mut metadata_dirs, canonical_marker)?; + } + } + for common in common_dirs { + let within_standard_metadata = metadata_dirs + .iter() + .any(|metadata| path_is_within(common, metadata)); + let within_worktree = worktree_roots + .iter() + .any(|root| path_is_within(common, root)) + || nearest_identity_boundary(common, &worktree_identities, &[])? + == Some(PathBoundary::Worktree); + if within_standard_metadata || !within_worktree { + push_unique_location(&mut metadata_dirs, common.clone())?; + } + } + + let metadata_identities = directory_identities(&metadata_dirs)?; + Ok(RepositoryRouteBoundaries { + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + }) +} + +fn push_unique_location(paths: &mut Vec, path: PathBuf) -> io::Result<()> { + for existing in paths.iter() { + if paths_refer_to_same_location(existing, &path)? { + return Ok(()); + } + } + paths.push(path); + Ok(()) +} + +fn paths_refer_to_same_location(left: &Path, right: &Path) -> io::Result { + if path_is_within(left, right) && path_is_within(right, left) { + return Ok(true); + } + if let (Ok(left), Ok(right)) = (Handle::from_path(left), Handle::from_path(right)) { + return Ok(left == right); + } + match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { + (Ok(left), Ok(right)) => Ok(left == right), + (Err(left), _) if is_missing(&left) => Ok(false), + (_, Err(right)) if is_missing(&right) => Ok(false), + (Err(error), _) | (_, Err(error)) => Err(error), + } +} + +fn is_missing(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) +} + +fn inspect_route( + route: &Path, + boundaries: &RepositoryRouteBoundaries, +) -> io::Result { + #[cfg(windows)] + { + let route = route + .to_str() + .ok_or_else(|| invalid_data("non-UTF-8 Windows authority path"))?; + if windows_path_is_ambiguous(route) { + return Ok(RouteInspection { + crosses_worktree: true, + touches_worktree: true, + crosses_metadata: true, + observed_paths: Vec::new(), + }); + } + } + + let observation = observe_route(route)?; + let crosses_worktree = observation_crosses_worktree( + &observation, + &boundaries.worktree_roots, + &boundaries.metadata_dirs, + &boundaries.worktree_identities, + &boundaries.metadata_identities, + )?; + let observed_paths = observation.observed_paths(); + let mut touches_worktree = false; + let mut crosses_metadata = false; + for path in &observed_paths { + match classify_candidate( + path, + &boundaries.worktree_roots, + &boundaries.metadata_dirs, + &boundaries.worktree_identities, + &boundaries.metadata_identities, + )? { + CandidateBoundary::Worktree | CandidateBoundary::ExactWorktree => { + touches_worktree = true; + } + CandidateBoundary::Metadata => crosses_metadata = true, + CandidateBoundary::Outside => {} + } + } + Ok(RouteInspection { + crosses_worktree, + touches_worktree, + crosses_metadata, + observed_paths, + }) +} + +fn observation_crosses_worktree( + observation: &RouteObservationSnapshot, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + let raw_pivot = raw_observation_has_worktree_descendant_pivot( + &observation.raw, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?; + let normalized_crosses = strict_candidate_crosses( + &observation.raw.normalized, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?; + let projected_crosses = strict_candidate_crosses( + &observation.raw.projected, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?; + if raw_pivot || normalized_crosses || projected_crosses { + return Ok(true); + } + for hop in &observation.symlink_hops { + // A symlink entry located directly under an exact worktree root is + // already a mutable descendant; classify its parent strictly. + if strict_candidate_crosses( + &hop.parent, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || intermediate_candidate_crosses( + &hop.entry, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || raw_observation_has_worktree_descendant_pivot( + &hop.target, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || intermediate_observation_crosses( + &hop.target, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || raw_observation_has_worktree_descendant_pivot( + &hop.projected, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || terminal_observation_crosses( + &hop.projected, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? { + return Ok(true); + } + } + Ok(false) +} + +fn intermediate_observation_crosses( + observation: &RawRouteObservation, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + Ok(intermediate_candidate_crosses( + &observation.normalized, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || intermediate_candidate_crosses( + &observation.projected, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?) +} + +fn terminal_observation_crosses( + observation: &RawRouteObservation, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + Ok(strict_candidate_crosses( + &observation.normalized, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? || strict_candidate_crosses( + &observation.projected, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?) +} + +fn strict_candidate_crosses( + path: &Path, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + Ok(matches!( + classify_candidate( + path, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?, + CandidateBoundary::ExactWorktree | CandidateBoundary::Worktree + )) +} + +fn intermediate_candidate_crosses( + path: &Path, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + // Exact roots remain valid intermediates for Git-generated active metadata + // routes such as a nested submodule's `nested/../.git/modules/...`. + Ok(classify_candidate( + path, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )? == CandidateBoundary::Worktree) +} + +fn raw_observation_has_worktree_descendant_pivot( + observation: &RawRouteObservation, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + for prefix in &observation.normalized_prefixes { + // Active metadata routes are revalidated before each child, so an + // exact nested root is allowed here. Retained registry and standard + // directory routes use the stricter role-aware cancellation policy. + let boundary = classify_candidate( + prefix, + worktree_roots, + metadata_dirs, + worktree_identities, + metadata_identities, + )?; + if boundary == CandidateBoundary::Worktree { + return Ok(true); + } + } + Ok(false) +} + +fn classify_candidate( + path: &Path, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + if metadata_dirs + .iter() + .any(|metadata| path_is_within(path, metadata)) + { + return Ok(CandidateBoundary::Metadata); + } + if worktree_roots + .iter() + .any(|root| path_is_within(path, root) && path_is_within(root, path)) + { + return Ok(CandidateBoundary::ExactWorktree); + } + if worktree_roots.iter().any(|root| path_is_within(path, root)) { + return Ok(CandidateBoundary::Worktree); + } + for (depth, ancestor) in path.ancestors().enumerate() { + let metadata = match std::fs::metadata(ancestor) { + Ok(metadata) if metadata.is_dir() => metadata, + Ok(_) => continue, + Err(error) if is_missing(&error) => continue, + Err(error) => return Err(error), + }; + let identity = Handle::from_path(ancestor)?; + if metadata_identities.contains(&identity) { + return Ok(CandidateBoundary::Metadata); + } + if worktree_identities.contains(&identity) { + return Ok(if depth == 0 && metadata.is_dir() { + CandidateBoundary::ExactWorktree + } else { + CandidateBoundary::Worktree + }); + } + } + Ok(CandidateBoundary::Outside) +} + +fn directory_identities(paths: &[PathBuf]) -> io::Result> { + paths + .iter() + .filter_map(|path| match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => Some(Handle::from_path(path)), + Ok(_) => None, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + None + } + Err(error) => Some(Err(error)), + }) + .collect() +} + +fn nearest_identity_boundary( + path: &Path, + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result> { + for ancestor in path.ancestors() { + let metadata = match std::fs::metadata(ancestor) { + Ok(metadata) => metadata, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + continue; + } + Err(error) => return Err(error), + }; + if !metadata.is_dir() { + continue; + } + let identity = Handle::from_path(ancestor)?; + if metadata_identities.contains(&identity) { + return Ok(Some(PathBoundary::Metadata)); + } + if worktree_identities.contains(&identity) { + return Ok(Some(PathBoundary::Worktree)); + } + } + Ok(None) +} diff --git a/codex-rs/git-utils/src/path_authority/cancellation.rs b/codex-rs/git-utils/src/path_authority/cancellation.rs new file mode 100644 index 000000000000..d18f9f91bf85 --- /dev/null +++ b/codex-rs/git-utils/src/path_authority/cancellation.rs @@ -0,0 +1,161 @@ +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use same_file::Handle; + +use crate::git_config::path_is_within; + +use super::CandidateBoundary; +use super::RepositoryRouteBoundaries; +use super::classify_candidate; +use super::is_missing; +use super::route_walker::RouteObservationSnapshot; +use super::route_walker::observe_route; +#[cfg(windows)] +use super::windows_path_is_ambiguous; + +impl RepositoryRouteBoundaries { + pub(crate) fn route_cancels_worktree_descendant(&self, route: &Path) -> io::Result { + if route_requires_fail_closed(route)? { + return Ok(true); + } + observation_cancels_worktree_descendant(&observe_route(route)?, self) + } + + pub(crate) fn retained_route_is_untrusted(&self, route: &Path) -> io::Result { + if route_requires_fail_closed(route)? { + return Ok(true); + } + let observation = observe_route(route)?; + if observation_cancels_worktree_descendant(&observation, self)? { + return Ok(true); + } + // Unlike active metadata, retained registry routes are not rebound + // before every child. A symlink or junction entry below a worktree is + // therefore mutable authority even when it currently aliases metadata. + // Parent identity classification covers Windows Unicode case aliases. + for hop in &observation.symlink_hops { + if matches!( + classify_candidate( + &hop.parent, + &self.worktree_roots, + &self.metadata_dirs, + &self.worktree_identities, + &self.metadata_identities, + )?, + CandidateBoundary::ExactWorktree | CandidateBoundary::Worktree + ) { + return Ok(true); + } + } + Ok(false) + } +} + +fn observation_cancels_worktree_descendant( + observation: &RouteObservationSnapshot, + boundaries: &RepositoryRouteBoundaries, +) -> io::Result { + if raw_spelling_cancels_worktree_descendant(&observation.raw.spelling, boundaries)? { + return Ok(true); + } + for hop in &observation.symlink_hops { + if raw_spelling_cancels_worktree_descendant(&hop.target.spelling, boundaries)? + || raw_spelling_cancels_worktree_descendant(&hop.projected.spelling, boundaries)? + { + return Ok(true); + } + } + Ok(false) +} + +fn route_requires_fail_closed(route: &Path) -> io::Result { + #[cfg(windows)] + { + let route = route.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 Windows authority path", + ) + })?; + Ok(windows_path_is_ambiguous(route)) + } + #[cfg(not(windows))] + { + let _ = route; + Ok(false) + } +} + +fn raw_spelling_cancels_worktree_descendant( + spelling: &Path, + boundaries: &RepositoryRouteBoundaries, +) -> io::Result { + let mut prefix = PathBuf::new(); + for component in spelling.components() { + match component { + Component::ParentDir => { + if candidate_is_proper_worktree_descendant( + &prefix, + &boundaries.worktree_roots, + &boundaries.metadata_dirs, + &boundaries.worktree_identities, + &boundaries.metadata_identities, + )? { + return Ok(true); + } + prefix.pop(); + } + Component::CurDir => {} + _ => prefix.push(component.as_os_str()), + } + } + Ok(false) +} + +pub(super) fn candidate_is_proper_worktree_descendant( + path: &Path, + worktree_roots: &[PathBuf], + metadata_dirs: &[PathBuf], + worktree_identities: &[Handle], + metadata_identities: &[Handle], +) -> io::Result { + if metadata_dirs + .iter() + .any(|metadata| path_is_within(path, metadata)) + { + return Ok(false); + } + + // A worktree-controlled spelling cannot become trusted merely because a + // mutable symlink or junction currently aliases protected metadata. Keep + // genuinely metadata-spelled paths exempt above, but reject lexical + // worktree descendants before following identities. + if worktree_roots + .iter() + .any(|root| path_is_within(path, root) && !path_is_within(root, path)) + { + return Ok(true); + } + + let mut ancestor_identities = Vec::new(); + for (depth, ancestor) in path.ancestors().enumerate() { + match std::fs::metadata(ancestor) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => continue, + Err(error) if is_missing(&error) => continue, + Err(error) => return Err(error), + } + let identity = Handle::from_path(ancestor)?; + if metadata_identities.contains(&identity) { + return Ok(false); + } + ancestor_identities.push((depth, identity)); + } + + Ok(ancestor_identities + .into_iter() + .any(|(depth, identity)| depth > 0 && worktree_identities.contains(&identity))) +} diff --git a/codex-rs/git-utils/src/path_authority/route_walker.rs b/codex-rs/git-utils/src/path_authority/route_walker.rs new file mode 100644 index 000000000000..a8c11e5beb83 --- /dev/null +++ b/codex-rs/git-utils/src/path_authority/route_walker.rs @@ -0,0 +1,221 @@ +use std::collections::BTreeSet; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +#[cfg(windows)] +use super::windows_path::windows_path_is_ambiguous; + +#[derive(Clone, Debug)] +pub(super) struct RawRouteObservation { + pub(super) spelling: PathBuf, + pub(super) normalized_prefixes: Vec, + pub(super) normalized: PathBuf, + pub(super) projected: PathBuf, +} + +#[derive(Clone, Debug)] +pub(super) struct SymlinkRouteObservation { + pub(super) entry: PathBuf, + pub(super) parent: PathBuf, + pub(super) target: RawRouteObservation, + pub(super) projected: RawRouteObservation, +} + +#[derive(Clone, Debug)] +pub(super) struct RouteObservationSnapshot { + pub(super) raw: RawRouteObservation, + pub(super) symlink_hops: Vec, +} + +impl RouteObservationSnapshot { + pub(super) fn observed_paths(&self) -> Vec { + let mut paths = Vec::new(); + append_raw_observation_paths(&mut paths, &self.raw); + for hop in &self.symlink_hops { + push_unique_spelling(&mut paths, hop.parent.clone()); + push_unique_spelling(&mut paths, hop.entry.clone()); + append_raw_observation_paths(&mut paths, &hop.target); + append_raw_observation_paths(&mut paths, &hop.projected); + } + paths + } +} + +#[derive(Clone, Debug)] +struct SymlinkRouteHop { + entry: PathBuf, + parent: PathBuf, + target: PathBuf, + projected: PathBuf, +} + +pub(super) fn observe_route(path: &Path) -> io::Result { + let raw = observe_raw_route(path)?; + let symlink_hops = symlink_route_hops(path)? + .into_iter() + .map(|hop| { + Ok(SymlinkRouteObservation { + entry: hop.entry, + parent: hop.parent, + target: observe_raw_route(&hop.target)?, + projected: observe_raw_route(&hop.projected)?, + }) + }) + .collect::>>()?; + Ok(RouteObservationSnapshot { raw, symlink_hops }) +} + +fn observe_raw_route(route: &Path) -> io::Result { + let base = route + .ancestors() + .last() + .ok_or_else(|| invalid_data("authority path has no root"))?; + let normalized = AbsolutePathBuf::resolve_path_against_base(route, base).into_path_buf(); + let normalized_prefixes = route + .ancestors() + .map(|prefix| AbsolutePathBuf::resolve_path_against_base(prefix, base).into_path_buf()) + .collect(); + let projected = project_through_longest_existing_ancestor(route)?; + Ok(RawRouteObservation { + spelling: route.to_path_buf(), + normalized_prefixes, + normalized, + projected, + }) +} + +fn append_raw_observation_paths(paths: &mut Vec, observation: &RawRouteObservation) { + for prefix in &observation.normalized_prefixes { + push_unique_spelling(paths, prefix.clone()); + } + push_unique_spelling(paths, observation.projected.clone()); +} + +fn push_unique_spelling(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +fn symlink_route_hops(path: &Path) -> io::Result> { + let mut hops = Vec::new(); + let mut seen = BTreeSet::new(); + collect_symlink_route_hops(path, 0, &mut seen, &mut hops)?; + Ok(hops) +} + +fn collect_symlink_route_hops( + path: &Path, + depth: usize, + seen: &mut BTreeSet, + hops: &mut Vec, +) -> io::Result<()> { + if depth > 40 { + return Err(invalid_data("authority path symlink cycle")); + } + if !seen.insert(path.to_path_buf()) { + return Ok(()); + } + for ancestor in path.ancestors() { + match std::fs::symlink_metadata(ancestor) { + Ok(_) => match std::fs::read_link(ancestor) { + Ok(target) => { + let parent = ancestor + .parent() + .ok_or_else(|| invalid_data("authority symlink has no parent"))?; + let target = resolve_literal_path(target, parent); + #[cfg(windows)] + if target.to_str().is_none_or(windows_path_is_ambiguous) { + return Err(invalid_data("ambiguous Windows authority symlink")); + } + let suffix = path + .strip_prefix(ancestor) + .map_err(|_| invalid_data("failed to project authority symlink path"))?; + let projected = resolve_literal_path(suffix, &target); + hops.push(SymlinkRouteHop { + entry: ancestor.to_path_buf(), + parent: parent.to_path_buf(), + target, + projected: projected.clone(), + }); + collect_symlink_route_hops(&projected, depth + 1, seen, hops)?; + } + Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + }, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn project_through_longest_existing_ancestor(path: &Path) -> io::Result { + project_path(path, 0) +} + +fn project_path(path: &Path, symlink_depth: usize) -> io::Result { + if symlink_depth > 40 { + return Err(invalid_data("too many authority path symlinks")); + } + if let Ok(canonical) = std::fs::canonicalize(path) { + return Ok(canonical); + } + for ancestor in path.ancestors() { + if std::fs::symlink_metadata(ancestor).is_ok() + && let Ok(target) = std::fs::read_link(ancestor) + { + let parent = ancestor + .parent() + .ok_or_else(|| invalid_data("authority symlink has no parent"))?; + let target = resolve_literal_path(target, parent); + #[cfg(windows)] + if target.to_str().is_none_or(windows_path_is_ambiguous) { + return Err(invalid_data("ambiguous Windows authority symlink")); + } + let suffix = path + .strip_prefix(ancestor) + .map_err(|_| invalid_data("failed to project authority path"))?; + return project_path(&resolve_literal_path(suffix, &target), symlink_depth + 1); + } + } + for ancestor in path.ancestors() { + match std::fs::canonicalize(ancestor) { + Ok(canonical) => { + let suffix = path + .strip_prefix(ancestor) + .map_err(|_| invalid_data("failed to project authority path"))?; + return Ok( + AbsolutePathBuf::resolve_path_against_base(suffix, canonical).into_path_buf(), + ); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(error), + } + } + Err(invalid_data("authority path has no existing ancestor")) +} + +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 invalid_data(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/codex-rs/git-utils/src/path_authority/windows_path.rs b/codex-rs/git-utils/src/path_authority/windows_path.rs new file mode 100644 index 000000000000..46b8ddcd8a23 --- /dev/null +++ b/codex-rs/git-utils/src/path_authority/windows_path.rs @@ -0,0 +1,69 @@ +pub(crate) fn windows_path_is_ambiguous(path: &str) -> bool { + let path = path.replace('/', "\\"); + if path.starts_with(r"\??\") { + return true; + } + let path = if path + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(r"\\?\UNC\")) + || path + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(r"\\.\UNC\")) + { + format!(r"\\{}", &path[8..]) + } else if path.starts_with(r"\\?\") || path.starts_with(r"\\.\") { + let path = &path[4..]; + if !matches!(path.as_bytes(), [drive, b':', b'\\', ..] if drive.is_ascii_alphabetic()) { + return true; + } + path.to_string() + } else { + path + }; + let bytes = path.as_bytes(); + let remainder = if matches!(bytes, [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && *separator == b'\\') + { + &path[3..] + } else { + path.as_str() + }; + if remainder.contains(':') { + return true; + } + remainder.split('\\').any(|component| { + !matches!(component, "." | "..") + && (component.ends_with(['.', ' ']) || windows_reserved_component(component)) + }) +} + +pub(crate) fn windows_authority_path_is_ambiguous(path: &str) -> bool { + windows_path_is_ambiguous(path) + || path + .replace('/', "\\") + .split('\\') + .any(|component| component == "..") +} + +fn windows_reserved_component(component: &str) -> bool { + let stem = component + .trim_end_matches(['.', ' ']) + .split_once('.') + .map_or(component, |(stem, _)| stem); + stem.eq_ignore_ascii_case("AUX") + || stem.eq_ignore_ascii_case("CON") + || stem.eq_ignore_ascii_case("CONIN$") + || stem.eq_ignore_ascii_case("CONOUT$") + || stem.eq_ignore_ascii_case("NUL") + || stem.eq_ignore_ascii_case("PRN") + || ["COM", "LPT"].iter().any(|prefix| { + let Some(rest) = stem.get(3..) else { + return false; + }; + let mut chars = rest.chars(); + stem.get(..3) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) + && matches!(chars.next(), Some('1'..='9' | '¹' | '²' | '³')) + && chars.as_str().is_empty() + }) +} diff --git a/codex-rs/git-utils/src/repository_authority.rs b/codex-rs/git-utils/src/repository_authority.rs new file mode 100644 index 000000000000..7b1f27f860fe --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority.rs @@ -0,0 +1,492 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; +use same_file::Handle; + +use helpers::RegisteredWorktree; +use helpers::RegisteredWorktreeReadError; +use helpers::registry_error; + +mod authority; +mod helpers; +mod plain_config; +pub(crate) use authority::RepositoryAuthority; +pub(crate) use plain_config::CommonConfigAuthority; +pub(crate) use plain_config::inspect_plain_common_config_authority; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepositoryMetadataKind { + StandardPrimary, + SeparatePrimary, + Linked, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedRepositoryMetadata { + pub(crate) marker: PathBuf, + pub(crate) git_dir: PathBuf, + pub(crate) common_dir: PathBuf, + pub(crate) kind: RepositoryMetadataKind, + pub(crate) routes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RepositoryMetadataRoute { + pub(crate) spelling: PathBuf, + pub(crate) target: PathBuf, + pub(crate) kind: RepositoryMetadataRouteKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepositoryMetadataRouteKind { + Traversal, + StandardDirectory, +} + +pub(crate) fn read_bounded_marker(path: &Path) -> io::Result> { + #[cfg(test)] + BOUNDED_MARKER_READ_COUNT.with(|count| count.set(count.get() + 1)); + read_bounded_file(path, 64 * 1024, "Git metadata marker is too large") +} + +#[cfg(test)] +thread_local! { + static BOUNDED_MARKER_READ_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_bounded_marker_read_count() { + BOUNDED_MARKER_READ_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn bounded_marker_read_count() -> usize { + BOUNDED_MARKER_READ_COUNT.with(std::cell::Cell::get) +} + +pub(crate) fn parse_marker_path(contents: &[u8], prefix: &[u8]) -> io::Result { + let contents = contents + .strip_prefix(prefix) + .ok_or_else(|| invalid_data("malformed Git metadata marker"))?; + let contents = trim_trailing_ascii_whitespace(contents); + if contents.is_empty() || contents.contains(&0) { + return Err(invalid_data("empty Git metadata marker path")); + } + bytes_to_path(contents) +} + +pub(crate) fn resolve_repository_metadata( + dot_git: &Path, +) -> io::Result { + let metadata = std::fs::symlink_metadata(dot_git)?; + if metadata.file_type().is_symlink() { + return Err(invalid_data("symlinked Git metadata marker")); + } + let marker_is_directory = metadata.is_dir(); + let (git_dir, git_dir_route, default_kind) = if marker_is_directory { + ( + std::fs::canonicalize(dot_git)?, + Some(( + dot_git.to_path_buf(), + RepositoryMetadataRouteKind::StandardDirectory, + )), + RepositoryMetadataKind::StandardPrimary, + ) + } else if metadata.is_file() { + let contents = read_bounded_marker(dot_git)?; + let route = raw_path_against( + parse_marker_path(&contents, b"gitdir: ")?, + dot_git + .parent() + .ok_or_else(|| invalid_data("Git metadata marker has no parent"))?, + ); + let git_dir = std::fs::canonicalize(&route)?; + ( + git_dir, + Some((route, RepositoryMetadataRouteKind::Traversal)), + RepositoryMetadataKind::SeparatePrimary, + ) + } else { + return Err(invalid_data("unsupported Git metadata marker")); + }; + let commondir = git_dir.join("commondir"); + match std::fs::symlink_metadata(&commondir) { + Ok(metadata) => { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(invalid_data("unsupported Git common-dir marker")); + } + let contents = read_bounded_marker(&commondir)?; + let route = raw_path_against(parse_marker_path(&contents, b"")?, &git_dir); + let common_dir = std::fs::canonicalize(&route)?; + let mut routes = git_dir_route + .map(|(spelling, kind)| RepositoryMetadataRoute { + spelling, + target: git_dir.clone(), + kind, + }) + .into_iter() + .collect::>(); + routes.push(RepositoryMetadataRoute { + spelling: route, + target: common_dir.clone(), + kind: RepositoryMetadataRouteKind::Traversal, + }); + return Ok(ResolvedRepositoryMetadata { + marker: dot_git.to_path_buf(), + git_dir, + common_dir, + kind: RepositoryMetadataKind::Linked, + routes, + }); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(error), + } + + if git_dir + .parent() + .is_some_and(|parent| parent.file_name() == Some(std::ffi::OsStr::new("worktrees"))) + { + let common_dir = git_dir + .parent() + .and_then(Path::parent) + .ok_or_else(|| invalid_data("linked Git admin dir has no common parent"))?; + let common_dir = std::fs::canonicalize(common_dir)?; + let routes = git_dir_route + .map(|(spelling, kind)| RepositoryMetadataRoute { + spelling, + target: git_dir.clone(), + kind, + }) + .into_iter() + .collect(); + return Ok(ResolvedRepositoryMetadata { + marker: dot_git.to_path_buf(), + git_dir, + common_dir, + kind: RepositoryMetadataKind::Linked, + routes, + }); + } + + Ok(ResolvedRepositoryMetadata { + marker: dot_git.to_path_buf(), + common_dir: git_dir.clone(), + git_dir: git_dir.clone(), + kind: default_kind, + routes: git_dir_route + .map(|(spelling, kind)| RepositoryMetadataRoute { + spelling, + target: git_dir, + kind, + }) + .into_iter() + .collect(), + }) +} + +pub(crate) fn repository_common_dir_for_candidate_root(root: &Path) -> io::Result> { + let marker = root.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(_) => resolve_repository_metadata(&marker).map(|metadata| Some(metadata.common_dir)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +pub(crate) fn linked_common_dir_for_root(root: &Path) -> io::Result> { + let marker = root.join(".git"); + let metadata = match std::fs::symlink_metadata(&marker) { + Ok(metadata) => metadata, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(None); + } + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() { + return Err(invalid_data("symlinked Git metadata marker")); + } + if !metadata.is_file() && !metadata.is_dir() { + return Ok(None); + } + let resolved = resolve_repository_metadata(&marker)?; + Ok((resolved.kind == RepositoryMetadataKind::Linked).then_some(resolved.common_dir)) +} + +fn registered_worktrees( + common_dir: &Path, +) -> Result, RegisteredWorktreeReadError> { + let worktrees = common_dir.join("worktrees"); + match std::fs::symlink_metadata(&worktrees) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(registry_error( + &worktrees, + invalid_data("unsupported Git worktree registry"), + )); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(Vec::new()); + } + Err(error) => return Err(registry_error(&worktrees, error)), + } + + let mut registrations = Vec::new(); + let entries = + std::fs::read_dir(&worktrees).map_err(|error| registry_error(&worktrees, error))?; + for entry in entries { + let admin_dir = entry + .map_err(|error| registry_error(&worktrees, error))? + .path(); + let metadata = std::fs::symlink_metadata(&admin_dir) + .map_err(|error| registry_error(&admin_dir, error))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(registry_error( + &admin_dir, + invalid_data("unsupported Git worktree registry entry"), + )); + } + let gitdir_marker = admin_dir.join("gitdir"); + let metadata = std::fs::symlink_metadata(&gitdir_marker) + .map_err(|error| registry_error(&gitdir_marker, error))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(registry_error( + &gitdir_marker, + invalid_data("unsupported Git worktree gitdir marker"), + )); + } + let contents = read_bounded_marker(&gitdir_marker) + .map_err(|error| registry_error(&gitdir_marker, error))?; + let marker_route = raw_path_against( + parse_marker_path(&contents, b"") + .map_err(|error| registry_error(&gitdir_marker, error))?, + &admin_dir, + ); + #[cfg(windows)] + if marker_route + .to_str() + .is_none_or(crate::path_authority::windows_path_is_ambiguous) + { + return Err(registry_error( + &gitdir_marker, + invalid_data("ambiguous Windows Git worktree path"), + )); + } + // Git writes this protected registry marker from real paths (either + // absolute or relative). Normalize it only after retaining the raw + // route long enough to apply platform path grammar. + let marker = resolve_path_against(marker_route.clone(), &admin_dir); + if marker.file_name() != Some(std::ffi::OsStr::new(".git")) { + return Err(registry_error( + &gitdir_marker, + invalid_data("malformed Git worktree gitdir marker"), + )); + } + let root = marker + .parent() + .ok_or_else(|| { + registry_error( + &gitdir_marker, + invalid_data("Git worktree marker has no parent"), + ) + })? + .to_path_buf(); + registrations.push(RegisteredWorktree { + root, + admin_dir, + marker_route, + }); + } + Ok(registrations) +} + +pub(crate) fn primary_authority_is_proven( + current_root: &Path, + common_dir: &Path, + roots: &[PathBuf], +) -> io::Result { + for root in roots { + if directories_refer_to_same_location(root, current_root).unwrap_or(false) { + continue; + } + let marker = root.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(_) => { + let resolved = resolve_repository_metadata(&marker)?; + if resolved.kind != RepositoryMetadataKind::Linked + && directories_refer_to_same_location(&resolved.common_dir, common_dir)? + { + return Ok(true); + } + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(error), + } + } + Ok(false) +} + +pub(crate) fn directories_refer_to_same_location(left: &Path, right: &Path) -> io::Result { + let left_metadata = match std::fs::metadata(left) { + Ok(metadata) => metadata, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(false); + } + Err(error) => return Err(error), + }; + let right_metadata = match std::fs::metadata(right) { + Ok(metadata) => metadata, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(false); + } + Err(error) => return Err(error), + }; + if !left_metadata.is_dir() || !right_metadata.is_dir() { + return Ok(false); + } + Ok(Handle::from_path(left)? == Handle::from_path(right)?) +} + +pub(crate) fn path_has_untrusted_root_identity_ancestor(path: &Path, roots: &[PathBuf]) -> bool { + let mut root_identities = Vec::new(); + for root in roots { + match std::fs::metadata(root) { + Ok(metadata) if metadata.is_dir() => match Handle::from_path(root) { + Ok(identity) => root_identities.push(identity), + Err(_) => return true, + }, + Ok(_) => return true, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(_) => return true, + } + } + if root_identities.is_empty() { + return false; + } + + let start = match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => path, + Ok(_) => path.parent().unwrap_or(path), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + path.parent().unwrap_or(path) + } + Err(_) => return true, + }; + for ancestor in start.ancestors() { + match std::fs::metadata(ancestor) { + Ok(metadata) if metadata.is_dir() => match Handle::from_path(ancestor) { + Ok(identity) if root_identities.contains(&identity) => return true, + Ok(_) => {} + Err(_) => return true, + }, + Ok(_) => continue, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(_) => return true, + } + } + false +} + +fn read_bounded_file(path: &Path, max_bytes: u64, too_large: &str) -> io::Result> { + use std::io::Read; + + let file = std::fs::File::open(path)?; + let mut contents = Vec::new(); + file.take(max_bytes + 1).read_to_end(&mut contents)?; + if contents.len() as u64 > max_bytes { + return Err(invalid_data(too_large)); + } + Ok(contents) +} + +fn resolve_path_against(path: PathBuf, base: &Path) -> PathBuf { + AbsolutePathBuf::resolve_path_against_base(path, base).into_path_buf() +} + +fn raw_path_against(path: PathBuf, base: &Path) -> PathBuf { + if path.is_absolute() { + path + } else { + base.join(path) + } +} + +fn bytes_to_path(bytes: &[u8]) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))) + } + #[cfg(not(unix))] + { + Ok(PathBuf::from(std::str::from_utf8(bytes).map_err(|_| { + invalid_data("non-UTF-8 Git filesystem path") + })?)) + } +} + +fn trim_trailing_ascii_whitespace(mut value: &[u8]) -> &[u8] { + while value.last().is_some_and(u8::is_ascii_whitespace) { + value = &value[..value.len() - 1]; + } + value +} + +fn invalid_data(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +#[path = "repository_authority_tests.rs"] +mod tests; diff --git a/codex-rs/git-utils/src/repository_authority/authority.rs b/codex-rs/git-utils/src/repository_authority/authority.rs new file mode 100644 index 000000000000..68e92f6d5f6a --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority/authority.rs @@ -0,0 +1,400 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::errors::GitReadError; +use crate::git_config::path_is_within; +use crate::path_authority::RepositoryRouteBoundaries; +use crate::path_authority::repository_route_boundaries; + +use super::CommonConfigAuthority; +use super::RegisteredWorktree; +use super::ResolvedRepositoryMetadata; +use super::directories_refer_to_same_location; +use super::helpers::common_dir_is_within_untrusted_root; +use super::helpers::invalid_metadata; +use super::helpers::paths_equal; +use super::helpers::push_unique; +use super::helpers::validated_logical_process_cwd; +use super::inspect_plain_common_config_authority; +use super::linked_common_dir_for_root; +use super::primary_authority_is_proven; +use super::registered_worktrees; +use super::resolve_repository_metadata; + +mod policy; + +/// Repository and filesystem authority retained for the lifetime of one +/// trusted Git runner. +#[derive(Debug)] +pub(crate) struct RepositoryAuthority { + active_worktree_root: PathBuf, + roots: Vec, + worktree_roots: Vec, + common_dirs: Vec, + unproven_primary_common_dir: Option, + metadata_snapshots: Vec, + registered_worktrees: Vec, + active_metadata: Option, + route_boundaries: RepositoryRouteBoundaries, +} + +impl RepositoryAuthority { + pub(crate) fn discover(cwd: &Path) -> Result { + let lexical_cwd = if cwd.is_absolute() { + cwd.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| GitReadError::NotRepository { + path: cwd.to_path_buf(), + })? + .join(cwd) + }; + let canonical_cwd = + std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository { + path: cwd.to_path_buf(), + })?; + let worktree_root = crate::get_git_repo_root(&canonical_cwd) + .and_then(|root| std::fs::canonicalize(root).ok()) + .unwrap_or_else(|| canonical_cwd.clone()); + let mut authority = Self { + active_worktree_root: worktree_root.clone(), + roots: Vec::new(), + worktree_roots: Vec::new(), + common_dirs: Vec::new(), + unproven_primary_common_dir: None, + metadata_snapshots: Vec::new(), + registered_worktrees: Vec::new(), + active_metadata: None, + route_boundaries: RepositoryRouteBoundaries::default(), + }; + authority.record_repository_ancestry(&worktree_root)?; + + // Canonicalization can erase a repository-controlled symlink prefix. + // Retain the requested spelling so lexical enclosing repositories stay + // in the authority boundary. + let lexical_base = if lexical_cwd.is_dir() { + lexical_cwd + } else { + lexical_cwd + .parent() + .ok_or_else(|| GitReadError::NotRepository { + path: cwd.to_path_buf(), + })? + .to_path_buf() + }; + authority.record_repository_ancestry(&lexical_base)?; + if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) { + authority.record_repository_ancestry(&logical_cwd)?; + } + authority.record_preselection_primary_authorities()?; + authority.active_metadata = authority + .metadata_snapshots + .iter() + .find(|snapshot| snapshot.marker == worktree_root.join(".git")) + .cloned(); + authority.route_boundaries = + repository_route_boundaries(&authority.worktree_roots, &authority.common_dirs) + .map_err(|error| invalid_metadata(&worktree_root, error))?; + authority.validate_registered_worktree_routes()?; + authority.validate_metadata_routes()?; + Ok(authority) + } + + pub(crate) fn ensure_primary_authority(&self) -> Result<(), GitReadError> { + if let Some(common_dir) = &self.unproven_primary_common_dir { + return Err(GitReadError::UnprovenPrimaryAuthority { + common_dir: common_dir.display().to_string(), + }); + } + Ok(()) + } + + pub(crate) fn path_is_untrusted_for_executable(&self, path: &Path) -> bool { + self.path_is_untrusted_for_executable_result(path) + .unwrap_or(true) + } + + pub(crate) fn revalidate_active_repository_metadata(&self) -> io::Result<()> { + let Some(expected) = &self.active_metadata else { + return Ok(()); + }; + let actual = resolve_repository_metadata(&expected.marker).map_err(|error| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "repository metadata changed during Git operation at {}: {error}", + expected.marker.display() + ), + ) + })?; + if &actual != expected { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "repository metadata changed during Git operation at {}", + expected.marker.display() + ), + )); + } + self.validate_snapshot_routes(&actual).map_err(|error| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "repository metadata changed during Git operation at {}: {error}", + expected.marker.display() + ), + ) + }) + } + + #[cfg(test)] + pub(crate) fn from_test_locations( + roots: Vec, + worktree_roots: Vec, + common_dirs: Vec, + ) -> Result { + let route_boundaries = repository_route_boundaries(&worktree_roots, &common_dirs) + .map_err(|error| invalid_metadata(Path::new(""), error))?; + Ok(Self { + active_worktree_root: worktree_roots + .first() + .or_else(|| roots.first()) + .cloned() + .unwrap_or_default(), + roots, + worktree_roots, + common_dirs, + unproven_primary_common_dir: None, + metadata_snapshots: Vec::new(), + registered_worktrees: Vec::new(), + active_metadata: None, + route_boundaries, + }) + } + + #[cfg(test)] + pub(crate) fn active_git_dir(&self) -> Option<&Path> { + self.active_metadata + .as_ref() + .map(|metadata| metadata.git_dir.as_path()) + } + + #[cfg(test)] + pub(crate) fn contains_root(&self, root: &Path) -> bool { + self.roots + .iter() + .any(|candidate| paths_equal(candidate, root)) + } + + #[cfg(test)] + pub(crate) fn roots(&self) -> &[PathBuf] { + &self.roots + } + + fn record_preselection_primary_authorities(&mut self) -> Result<(), GitReadError> { + let mut inspected_common_dirs: Vec = Vec::new(); + loop { + let roots = self.roots.clone(); + let mut next = None; + for root in roots { + let Some(common_dir) = linked_common_dir_for_root(&root) + .map_err(|error| invalid_metadata(&root.join(".git"), error))? + else { + continue; + }; + let already_inspected = inspected_common_dirs.iter().try_fold( + false, + |found, inspected| -> Result { + Ok(found + || directories_refer_to_same_location(inspected, &common_dir) + .map_err(|error| invalid_metadata(&common_dir, error))?) + }, + )?; + if !already_inspected { + next = Some((root, common_dir)); + break; + } + } + let Some((linked_root, common_dir)) = next else { + return Ok(()); + }; + inspected_common_dirs.push(common_dir.clone()); + if primary_authority_is_proven(&linked_root, &common_dir, &self.roots) + .map_err(|error| invalid_metadata(&common_dir, error))? + { + continue; + } + if common_dir_is_within_untrusted_root(&common_dir, &self.roots) { + self.unproven_primary_common_dir.get_or_insert(common_dir); + continue; + } + match inspect_plain_common_config_authority(&common_dir) + .map_err(|error| invalid_metadata(&common_dir, error))? + { + CommonConfigAuthority::Bare => {} + CommonConfigAuthority::Worktree(root) => { + self.record_repository_ancestry(&root)?; + } + CommonConfigAuthority::Unproven => { + self.unproven_primary_common_dir.get_or_insert(common_dir); + } + } + } + } + + fn record_repository_ancestry(&mut self, start: &Path) -> Result<(), GitReadError> { + push_unique(&mut self.roots, start.to_path_buf()); + push_unique(&mut self.worktree_roots, start.to_path_buf()); + self.record_repository_marker(start)?; + for ancestor in start.parent().into_iter().flat_map(Path::ancestors) { + let marker = ancestor.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(_) => { + push_unique(&mut self.roots, ancestor.to_path_buf()); + push_unique(&mut self.worktree_roots, ancestor.to_path_buf()); + let canonical_root = std::fs::canonicalize(ancestor) + .map_err(|error| invalid_metadata(&marker, error))?; + push_unique(&mut self.roots, canonical_root.clone()); + push_unique(&mut self.worktree_roots, canonical_root); + self.record_repository_marker(ancestor)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(invalid_metadata(&marker, error)), + } + } + Ok(()) + } + + fn record_repository_marker(&mut self, worktree_root: &Path) -> Result<(), GitReadError> { + let marker = worktree_root.join(".git"); + let snapshot = match std::fs::symlink_metadata(&marker) { + Ok(_) => resolve_repository_metadata(&marker) + .map_err(|error| invalid_metadata(&marker, error))?, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(invalid_metadata(&marker, error)), + }; + let common_dir = snapshot.common_dir.clone(); + if !self + .metadata_snapshots + .iter() + .any(|known| known.marker == snapshot.marker) + { + self.metadata_snapshots.push(snapshot); + } + if !path_is_within(&common_dir, worktree_root) { + push_unique(&mut self.roots, common_dir.clone()); + } + push_unique(&mut self.common_dirs, common_dir.clone()); + self.record_registered_worktrees(&common_dir)?; + self.record_common_dir_ancestry(common_dir) + } + + fn record_registered_worktrees(&mut self, common_dir: &Path) -> Result<(), GitReadError> { + let registrations = registered_worktrees(common_dir) + .map_err(|error| invalid_metadata(&error.path, error.source))?; + for registration in registrations { + if !self + .registered_worktrees + .iter() + .any(|known| known.admin_dir == registration.admin_dir) + { + self.registered_worktrees.push(registration.clone()); + } + let already_known = self + .roots + .iter() + .any(|known| paths_equal(known, ®istration.root)); + push_unique(&mut self.roots, registration.root.clone()); + push_unique(&mut self.worktree_roots, registration.root.clone()); + if let Ok(canonical_root) = std::fs::canonicalize(®istration.root) { + push_unique(&mut self.roots, canonical_root.clone()); + push_unique(&mut self.worktree_roots, canonical_root); + } + self.validate_registered_worktree_backlink(®istration)?; + if !already_known { + self.record_repository_ancestry(®istration.root)?; + } + } + Ok(()) + } + + fn validate_registered_worktree_backlink( + &self, + registration: &RegisteredWorktree, + ) -> Result<(), GitReadError> { + match std::fs::metadata(®istration.root) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(GitReadError::InvalidRepositoryMetadata { + path: registration.root.clone(), + reason: "registered Git worktree is not a directory".to_string(), + }); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(()); + } + Err(error) => return Err(invalid_metadata(®istration.root, error)), + } + let marker = registration.root.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(GitReadError::InvalidRepositoryMetadata { + path: marker, + reason: "unsupported registered Git worktree marker".to_string(), + }); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(()); + } + Err(error) => return Err(invalid_metadata(&marker, error)), + } + let resolved = resolve_repository_metadata(&marker) + .map_err(|error| invalid_metadata(&marker, error))?; + if !directories_refer_to_same_location(&resolved.git_dir, ®istration.admin_dir) + .map_err(|error| invalid_metadata(&marker, error))? + { + return Err(GitReadError::UnsafeRepositoryMetadata { + path: marker, + reason: "Git worktree registry backlink mismatch".to_string(), + }); + } + Ok(()) + } + + fn record_common_dir_ancestry(&mut self, common_dir: PathBuf) -> Result<(), GitReadError> { + for ancestor in common_dir.parent().into_iter().flat_map(Path::ancestors) { + let marker = ancestor.join(".git"); + match std::fs::symlink_metadata(&marker) { + Ok(_) => { + let canonical_root = std::fs::canonicalize(ancestor) + .map_err(|error| invalid_metadata(&marker, error))?; + let already_known = self.roots.iter().any(|root| { + paths_equal(root, ancestor) || paths_equal(root, &canonical_root) + }); + push_unique(&mut self.roots, ancestor.to_path_buf()); + push_unique(&mut self.worktree_roots, ancestor.to_path_buf()); + push_unique(&mut self.roots, canonical_root.clone()); + push_unique(&mut self.worktree_roots, canonical_root); + if !already_known { + self.record_repository_marker(ancestor)?; + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(invalid_metadata(&marker, error)), + } + } + Ok(()) + } +} diff --git a/codex-rs/git-utils/src/repository_authority/authority/policy.rs b/codex-rs/git-utils/src/repository_authority/authority/policy.rs new file mode 100644 index 000000000000..e77afde24747 --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority/authority/policy.rs @@ -0,0 +1,147 @@ +use std::collections::BTreeSet; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::errors::GitReadError; +use crate::git_config::path_is_within; + +use super::super::RepositoryMetadataRouteKind; +use super::super::ResolvedRepositoryMetadata; +use super::super::directories_refer_to_same_location; +use super::super::helpers::canonical_existing_ancestors; +use super::super::helpers::invalid_metadata; +use super::super::helpers::paths_equal; +use super::super::repository_common_dir_for_candidate_root; +use super::RepositoryAuthority; + +impl RepositoryAuthority { + pub(crate) fn canonical_command_cwd(&self, cwd: &Path) -> io::Result { + let canonical = std::fs::canonicalize(cwd)?; + let inspection = self.route_boundaries.inspect_route(cwd)?; + if (inspection.touches_worktree || inspection.crosses_worktree) + && !path_is_within(&canonical, &self.active_worktree_root) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "refusing Git command cwd that no longer resolves within the selected worktree: {}", + cwd.display() + ), + )); + } + Ok(canonical) + } + + 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 { + return Ok(true); + } + for candidate in inspection.observed_paths { + if self.route_boundaries.contains_known_boundary(&candidate)? { + continue; + } + if self.has_related_repository_ancestor(&candidate)? { + return Ok(true); + } + } + Ok(false) + } + + fn has_related_repository_ancestor(&self, path: &Path) -> io::Result { + let mut ancestors = path.ancestors().map(Path::to_path_buf).collect::>(); + ancestors.extend(canonical_existing_ancestors(path)?); + let mut seen = BTreeSet::new(); + for ancestor in ancestors { + if !seen.insert(ancestor.clone()) { + continue; + } + let Some(common_dir) = repository_common_dir_for_candidate_root(&ancestor)? else { + continue; + }; + for protected in &self.common_dirs { + if directories_refer_to_same_location(protected, &common_dir)? { + return Ok(true); + } + } + } + Ok(false) + } + + pub(super) fn validate_metadata_routes(&self) -> Result<(), GitReadError> { + for snapshot in &self.metadata_snapshots { + self.validate_snapshot_routes(snapshot)?; + } + Ok(()) + } + + pub(super) fn validate_registered_worktree_routes(&self) -> Result<(), GitReadError> { + for registration in &self.registered_worktrees { + if self + .route_boundaries + .retained_route_is_untrusted(®istration.marker_route) + .map_err(|error| invalid_metadata(®istration.admin_dir.join("gitdir"), error))? + { + return Err(GitReadError::UnsafeRepositoryMetadata { + path: registration.admin_dir.join("gitdir"), + reason: "Git worktree registry route crosses a repository worktree".to_string(), + }); + } + } + Ok(()) + } + + pub(super) fn validate_snapshot_routes( + &self, + snapshot: &ResolvedRepositoryMetadata, + ) -> Result<(), GitReadError> { + for route in &snapshot.routes { + if route.kind == RepositoryMetadataRouteKind::StandardDirectory { + let root = route.spelling.parent().ok_or_else(|| { + GitReadError::InvalidRepositoryMetadata { + path: route.spelling.clone(), + reason: "Git metadata directory has no worktree parent".to_string(), + } + })?; + let expected = std::fs::canonicalize(root) + .map_err(|error| invalid_metadata(root, error))? + .join(".git"); + if !paths_equal(&route.target, &expected) { + return Err(GitReadError::UnsafeRepositoryMetadata { + path: snapshot.marker.clone(), + reason: "nonstandard Git metadata directory".to_string(), + }); + } + if self + .route_boundaries + .route_cancels_worktree_descendant(&route.spelling) + .map_err(|error| invalid_metadata(&route.spelling, error))? + { + return Err(GitReadError::UnsafeRepositoryMetadata { + path: snapshot.marker.clone(), + reason: "Git metadata route crosses a repository worktree".to_string(), + }); + } + continue; + } + let spelling_crosses = self + .route_boundaries + .inspect_route(&route.spelling) + .map_err(|error| invalid_metadata(&route.spelling, error))? + .crosses_worktree; + let target_crosses = self + .route_boundaries + .inspect_route(&route.target) + .map_err(|error| invalid_metadata(&route.target, error))? + .crosses_worktree; + if spelling_crosses || target_crosses { + return Err(GitReadError::UnsafeRepositoryMetadata { + path: snapshot.marker.clone(), + reason: "Git metadata route crosses a repository worktree".to_string(), + }); + } + } + Ok(()) + } +} diff --git a/codex-rs/git-utils/src/repository_authority/helpers.rs b/codex-rs/git-utils/src/repository_authority/helpers.rs new file mode 100644 index 000000000000..e05969ce5274 --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority/helpers.rs @@ -0,0 +1,89 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::errors::GitReadError; +use crate::git_config::path_is_within; + +use super::directories_refer_to_same_location; +use super::path_has_untrusted_root_identity_ancestor; + +#[derive(Clone, Debug)] +pub(super) struct RegisteredWorktree { + pub(super) root: PathBuf, + pub(super) admin_dir: PathBuf, + pub(super) marker_route: PathBuf, +} + +#[derive(Debug)] +pub(super) struct RegisteredWorktreeReadError { + pub(super) path: PathBuf, + pub(super) source: io::Error, +} + +pub(super) fn registry_error(path: &Path, source: io::Error) -> RegisteredWorktreeReadError { + RegisteredWorktreeReadError { + path: path.to_path_buf(), + source, + } +} + +pub(super) fn common_dir_is_within_untrusted_root(common_dir: &Path, roots: &[PathBuf]) -> bool { + for root in roots { + if directories_refer_to_same_location(common_dir, root).unwrap_or(false) { + continue; + } + if path_is_within(common_dir, root) + || path_has_untrusted_root_identity_ancestor(common_dir, std::slice::from_ref(root)) + { + return true; + } + } + false +} + +pub(super) fn canonical_existing_ancestors(path: &Path) -> io::Result> { + let mut canonical = Vec::new(); + for ancestor in path.ancestors() { + match std::fs::canonicalize(ancestor) { + Ok(path) => canonical.push(path), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(error), + } + } + Ok(canonical) +} + +pub(super) fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option { + let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?; + if !paths_equal(&process_cwd, canonical_cwd) { + return None; + } + let logical_cwd = PathBuf::from(std::env::var_os("PWD")?); + if !logical_cwd.is_absolute() { + return None; + } + let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?; + paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd) +} + +pub(super) fn push_unique(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| paths_equal(existing, &path)) { + paths.push(path); + } +} + +pub(super) fn paths_equal(left: &Path, right: &Path) -> bool { + path_is_within(left, right) && path_is_within(right, left) +} + +pub(super) fn invalid_metadata(path: &Path, error: io::Error) -> GitReadError { + GitReadError::InvalidRepositoryMetadata { + path: path.to_path_buf(), + reason: error.to_string(), + } +} diff --git a/codex-rs/git-utils/src/repository_authority/plain_config.rs b/codex-rs/git-utils/src/repository_authority/plain_config.rs new file mode 100644 index 000000000000..b3a4253b77e6 --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority/plain_config.rs @@ -0,0 +1,137 @@ +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use super::bytes_to_path; +use super::read_bounded_file; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum CommonConfigAuthority { + Bare, + Worktree(PathBuf), + Unproven, +} + +pub(crate) fn inspect_plain_common_config_authority( + common_dir: &Path, +) -> io::Result { + let config_path = common_dir.join("config"); + let metadata = match std::fs::symlink_metadata(&config_path) { + Ok(metadata) => metadata, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(CommonConfigAuthority::Unproven); + } + Err(error) => return Err(error), + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Ok(CommonConfigAuthority::Unproven); + } + let bytes = read_bounded_file( + &config_path, + 1024 * 1024, + "Git common config is too large for authority proof", + )?; + let config = match gix::config::File::from_bytes_no_includes( + &bytes, + gix::config::file::Metadata::default(), + gix::config::file::init::Options::default(), + ) { + Ok(config) => config, + Err(_) => return Ok(CommonConfigAuthority::Unproven), + }; + if config.sections_by_name("include").is_some() + || config.sections_by_name("includeIf").is_some() + { + return Ok(CommonConfigAuthority::Unproven); + } + let bare = match unique_explicit_value(&config, "core", "bare") { + Ok(Some(value)) => match explicit_boolean(value.as_ref()) { + Some(value) => Some(value), + None => return Ok(CommonConfigAuthority::Unproven), + }, + Ok(None) => None, + Err(()) => return Ok(CommonConfigAuthority::Unproven), + }; + match unique_explicit_value(&config, "extensions", "worktreeConfig") { + Ok(Some(value)) if explicit_boolean(value.as_ref()) == Some(false) => {} + Ok(None) => {} + Ok(Some(_)) | Err(()) => return Ok(CommonConfigAuthority::Unproven), + } + let worktree = match unique_explicit_value(&config, "core", "worktree") { + Ok(Some(value)) => Some(value), + Ok(None) => None, + Err(()) => return Ok(CommonConfigAuthority::Unproven), + }; + if let Some(worktree) = worktree { + if bare == Some(true) { + return Ok(CommonConfigAuthority::Unproven); + } + let worktree = bytes_to_path(&worktree)?; + if worktree.as_os_str().is_empty() || !worktree.is_absolute() { + return Ok(CommonConfigAuthority::Unproven); + } + #[cfg(windows)] + if worktree + .to_str() + .is_none_or(crate::path_authority::windows_authority_path_is_ambiguous) + { + return Ok(CommonConfigAuthority::Unproven); + } + return Ok(CommonConfigAuthority::Worktree(worktree)); + } + Ok(if bare == Some(true) { + CommonConfigAuthority::Bare + } else { + CommonConfigAuthority::Unproven + }) +} + +fn unique_explicit_value( + config: &gix::config::File<'_>, + section_name: &str, + value_name: &str, +) -> Result>, ()> { + let Some(sections) = config.sections_by_name(section_name) else { + return Ok(None); + }; + let mut occurrences = 0usize; + let mut explicit_values = Vec::new(); + for section in sections { + if section.header().subsection_name().is_some() { + return Err(()); + } + occurrences += section + .value_names() + .filter(|name| { + let name: &str = name.as_ref(); + name.eq_ignore_ascii_case(value_name) + }) + .count(); + explicit_values.extend( + section + .values(value_name) + .into_iter() + .map(|value| value.as_ref().to_vec()), + ); + } + match (occurrences, explicit_values.len()) { + (0, 0) => Ok(None), + (1, 1) => Ok(explicit_values.pop()), + _ => Err(()), + } +} + +fn explicit_boolean(value: &[u8]) -> Option { + if value.eq_ignore_ascii_case(b"true") { + Some(true) + } else if value.eq_ignore_ascii_case(b"false") { + Some(false) + } else { + None + } +} diff --git a/codex-rs/git-utils/src/repository_authority_tests.rs b/codex-rs/git-utils/src/repository_authority_tests.rs new file mode 100644 index 000000000000..eb8e007bbeac --- /dev/null +++ b/codex-rs/git-utils/src/repository_authority_tests.rs @@ -0,0 +1,255 @@ +use super::*; + +#[test] +fn unreadable_metadata_is_invalid_not_a_proven_policy_crossing() { + let marker = PathBuf::from("repository/.git/commondir"); + let error = super::helpers::invalid_metadata( + &marker, + io::Error::new( + io::ErrorKind::PermissionDenied, + "metadata marker is unreadable", + ), + ); + assert_eq!( + error, + crate::errors::GitReadError::InvalidRepositoryMetadata { + path: marker, + reason: "metadata marker is unreadable".to_string(), + } + ); + assert_eq!(error.io_kind(), io::ErrorKind::InvalidData); +} + +fn write_common_config(body: &[u8]) -> tempfile::TempDir { + let common = tempfile::tempdir().expect("common dir"); + std::fs::write(common.path().join("config"), body).expect("write common config"); + common +} + +fn native_bare_value(config: &Path, includes: bool) -> io::Result> { + let mut command = std::process::Command::new("git"); + crate::safe_git::isolate_git_command_environment(&mut command); + command + .args(["config", "--file"]) + .arg(config) + .arg(if includes { + "--includes" + } else { + "--no-includes" + }) + .args(["--type=bool", "--get", "core.bare"]); + let output = command.output()?; + match output.status.code() { + Some(0) => Ok(Some( + String::from_utf8_lossy(&output.stdout).trim() == "true", + )), + Some(1) if output.stdout.is_empty() => Ok(None), + _ => Err(io::Error::other(format!( + "native core.bare query failed: {}", + String::from_utf8_lossy(&output.stderr) + ))), + } +} + +#[test] +fn plain_common_bare_parser_matches_native_boolean_syntax_and_is_stricter_on_duplicates() { + for (name, body, expected, native) in [ + ( + "explicit true", + b"[core]\n\tbare = true\n".as_slice(), + CommonConfigAuthority::Bare, + Some(true), + ), + ( + "implicit true", + b"[core]\n\tbare\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(true), + ), + ( + "quoted true", + b"[core]\n\tbare = \"true\"\n".as_slice(), + CommonConfigAuthority::Bare, + Some(true), + ), + ( + "false", + b"[core]\n\tbare = false\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(false), + ), + ( + "duplicate last true", + b"[core]\n\tbare = false\n\tbare = true\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(true), + ), + ( + "duplicate last false", + b"[core]\n\tbare = true\n\tbare = false\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(false), + ), + ( + "mixed implicit then explicit", + b"[core]\n\tbare\n\tbare = true\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(true), + ), + ( + "mixed explicit then implicit", + b"[core]\n\tbare = false\n\tbare\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(true), + ), + ( + "mixed case duplicate", + b"[core]\n\tbare = false\n\tBaRe = true\n".as_slice(), + CommonConfigAuthority::Unproven, + Some(true), + ), + ] { + let common = write_common_config(body); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect(name), + expected, + "authority result for {name}" + ); + assert_eq!( + native_bare_value(&common.path().join("config"), false).expect(name), + native, + "native result for {name}" + ); + } +} + +#[test] +fn common_bare_proof_accepts_only_explicit_boolean_literals() { + for (name, body) in [ + ("empty", b"[core]\n\tbare =\n".as_slice()), + ("yes", b"[core]\n\tbare = yes\n".as_slice()), + ("on", b"[core]\n\tbare = on\n".as_slice()), + ("one", b"[core]\n\tbare = 1\n".as_slice()), + ("leading zero", b"[core]\n\tbare = 08\n".as_slice()), + ( + "positive overflow", + b"[core]\n\tbare = 2147483648\n".as_slice(), + ), + ( + "negative overflow", + b"[core]\n\tbare = -2147483649\n".as_slice(), + ), + ( + "i64 max", + b"[core]\n\tbare = 9223372036854775807\n".as_slice(), + ), + ] { + let common = write_common_config(body); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect(name), + CommonConfigAuthority::Unproven, + "authority result for {name}" + ); + } +} + +#[test] +fn common_bare_proof_rejects_includes_worktree_config_and_relative_worktree() { + let include_target = tempfile::NamedTempFile::new().expect("included config"); + std::fs::write(include_target.path(), "[core]\n\tbare = false\n") + .expect("write included config"); + let common = write_common_config( + format!( + "[core]\n\tbare = true\n[include]\n\tpath = {}\n", + include_target.path().display() + ) + .as_bytes(), + ); + assert_eq!( + native_bare_value(&common.path().join("config"), false).expect("direct native bare"), + Some(true) + ); + assert_eq!( + native_bare_value(&common.path().join("config"), true).expect("included native bare"), + Some(false) + ); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect("include authority"), + CommonConfigAuthority::Unproven + ); + + for body in [ + b"[core]\n\tbare = true\n[includeIf \"gitdir:**\"]\n\tpath = /tmp/ignored\n".as_slice(), + b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig = true\n".as_slice(), + b"[core]\n\tbare = true\n\tworktree = relative/path\n".as_slice(), + b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig\n".as_slice(), + b"[core]\n\tbare = true\n[extensions]\n\tworktreeConfig = false\n\tworktreeConfig\n" + .as_slice(), + b"[core]\n\tbare = true\n\tworktree\n".as_slice(), + b"[core]\n\tbare = true\n\tworktree = /tmp/one\n\tworktree\n".as_slice(), + b"[core \"unexpected\"]\n\tbare = true\n".as_slice(), + b"[extensions \"unexpected\"]\n\tworktreeConfig = false\n[core]\n\tbare = true\n" + .as_slice(), + ] { + let common = write_common_config(body); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect("ambiguous authority"), + CommonConfigAuthority::Unproven + ); + } +} + +#[test] +fn common_config_absolute_worktree_is_returned_as_authority() { + let worktree = tempfile::tempdir().expect("worktree"); + let common = write_common_config( + format!("[core]\n\tworktree = {}\n", worktree.path().display()).as_bytes(), + ); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect("worktree authority"), + CommonConfigAuthority::Worktree(worktree.path().to_path_buf()) + ); +} + +#[test] +fn common_config_rejects_contradictory_or_malformed_bare_with_absolute_worktree() { + let worktree = tempfile::tempdir().expect("worktree"); + for (name, bare) in [("contradictory bare", "true"), ("malformed bare", "08")] { + let common = write_common_config( + format!( + "[core]\n\tbare = {bare}\n\tworktree = {}\n", + worktree.path().display() + ) + .as_bytes(), + ); + assert_eq!( + inspect_plain_common_config_authority(common.path()).expect(name), + CommonConfigAuthority::Unproven, + "authority result for {name}" + ); + } +} + +#[test] +fn windows_authority_path_grammar_rejects_ambiguous_primary_spellings() { + for path in [ + r"C:\primary.\repo", + r"C:\primary \repo", + r"C:\primary:stream\repo", + r"C:\NUL\repo", + r"\\?\GLOBALROOT\Device\HarddiskVolume1\repo", + r"\??\C:\primary\repo", + r"C:\outside\..\primary", + ] { + assert!( + crate::path_authority::windows_authority_path_is_ambiguous(path), + "ambiguous Windows authority path accepted: {path:?}" + ); + } + for path in [r"C:\primary\repo", r"\\server\share\primary\repo"] { + assert!( + !crate::path_authority::windows_authority_path_is_ambiguous(path), + "ordinary Windows authority path rejected: {path:?}" + ); + } +} From daca14580c72b5b3efc5eb704c32599862609359 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 21:30:49 -0700 Subject: [PATCH 2/9] git-utils: fix Windows authority build --- codex-rs/git-utils/src/git_command_tests.rs | 8 ++++---- codex-rs/git-utils/src/path_authority.rs | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 5b2c0b2302b4..38e977de1131 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -2356,10 +2356,10 @@ fn resolver_fails_closed_for_unsupported_windows_device_namespaces() { #[cfg(windows)] #[test] fn resolver_selects_native_git_exe_only() { - assert!(paths_equal( - Path::new(r"C:\Repo\.git"), - Path::new(r"c:\repo\.GIT") - )); + let mixed_case = Path::new(r"C:\Repo\.git"); + let lower_case = Path::new(r"c:\repo\.GIT"); + assert!(crate::git_config::path_is_within(mixed_case, lower_case)); + assert!(crate::git_config::path_is_within(lower_case, mixed_case)); let fixture = tempfile::tempdir().expect("fixture"); let repo = fixture.path().join("repo"); let scripts = fixture.path().join("scripts"); diff --git a/codex-rs/git-utils/src/path_authority.rs b/codex-rs/git-utils/src/path_authority.rs index ab0cd13ccbbd..b567df036887 100644 --- a/codex-rs/git-utils/src/path_authority.rs +++ b/codex-rs/git-utils/src/path_authority.rs @@ -13,6 +13,8 @@ mod windows_path; use route_walker::RawRouteObservation; use route_walker::RouteObservationSnapshot; +#[cfg(windows)] +use route_walker::invalid_data; use route_walker::observe_route; #[cfg(any(windows, test))] pub(crate) use windows_path::windows_authority_path_is_ambiguous; From bf70395505b66c5d4b8aafa103951d72d935ed21 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 21:42:21 -0700 Subject: [PATCH 3/9] git-utils: make registry retarget test deterministic --- codex-rs/git-utils/src/git_command_tests.rs | 56 +++++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 38e977de1131..b81a9f73c0b7 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -255,18 +255,50 @@ fn assert_unsafe_registry_route(result: Result, + markers: &[&Path], +) { + match result { + Err(GitReadError::UnsafeRepositoryMetadata { path, reason }) => { + assert_eq!( + reason, + "Git worktree registry route crosses a repository worktree" + ); + assert!( + markers + .iter() + .any(|marker| affected_paths_match(&path, marker)), + "unexpected affected registry path: {}", + path.display() + ); + } + other => panic!("expected unsafe worktree registry route, got {other:?}"), + } +} + fn assert_same_affected_path(actual: &Path, expected: &Path) { - assert_eq!(actual.file_name(), expected.file_name()); - let actual_parent = actual.parent().expect("actual marker parent"); - let expected_parent = expected.parent().expect("expected marker parent"); assert!( - same_file::is_same_file(actual_parent, expected_parent).expect("compare marker parents"), - "marker parent differs: actual={} expected={}", - actual_parent.display(), - expected_parent.display() + affected_paths_match(actual, expected), + "affected paths differ: actual={} expected={}", + actual.display(), + expected.display() ); } +fn affected_paths_match(actual: &Path, expected: &Path) -> bool { + if actual.file_name() != expected.file_name() { + return false; + } + let Some(actual_parent) = actual.parent() else { + return false; + }; + let Some(expected_parent) = expected.parent() else { + return false; + }; + same_file::is_same_file(actual_parent, expected_parent).unwrap_or(false) +} + #[test] fn git_read_error_io_kind_table_is_exhaustive() { let path = PathBuf::from("repository/.git"); @@ -1606,9 +1638,10 @@ fn overlapping_registered_route_remains_rejected_after_symlink_retarget() { std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted marker"), std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") ); - assert_unsafe_registry_route( + let nested_registry_marker = route.nested_admin.join("gitdir"); + assert_unsafe_registry_route_at_one_of( repository_authority_for_cwd(&route.main), - &route.registry_marker, + &[&route.registry_marker, &nested_registry_marker], ); } @@ -1644,9 +1677,10 @@ fn overlapping_registered_route_remains_rejected_after_junction_retarget() { std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted marker"), std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") ); - assert_unsafe_registry_route( + let nested_registry_marker = route.nested_admin.join("gitdir"); + assert_unsafe_registry_route_at_one_of( repository_authority_for_cwd(&route.main), - &route.registry_marker, + &[&route.registry_marker, &nested_registry_marker], ); } From 9c0e48eba6dc437436830d4059d437774c121c0d Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 22:27:54 -0700 Subject: [PATCH 4/9] git-utils: fix Windows repository authority checks --- codex-rs/git-utils/src/git_command.rs | 2 + codex-rs/git-utils/src/git_command_tests.rs | 70 +++++++++++++++++-- codex-rs/git-utils/src/git_executable.rs | 17 +++-- .../src/path_authority/route_walker.rs | 36 ++++++++-- .../src/path_authority/route_walker_tests.rs | 18 +++++ .../src/repository_authority/authority.rs | 2 +- .../src/repository_authority_tests.rs | 27 +++++-- 7 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 codex-rs/git-utils/src/path_authority/route_walker_tests.rs diff --git a/codex-rs/git-utils/src/git_command.rs b/codex-rs/git-utils/src/git_command.rs index 786c448d6131..e78420b84270 100644 --- a/codex-rs/git-utils/src/git_command.rs +++ b/codex-rs/git-utils/src/git_command.rs @@ -26,6 +26,7 @@ pub(crate) struct GitRunner { /// Canonical executable target pinned at selection time. Never execute the /// mutable PATH spelling after validation. executable: PathBuf, + #[cfg(any(unix, test))] argv0: PathBuf, safe_path: std::ffi::OsString, authority: RepositoryAuthority, @@ -116,6 +117,7 @@ impl GitRunner { let selected = select_git_executable(&authority, search_path)?; Ok(Self { executable: selected.executable, + #[cfg(any(unix, test))] argv0: selected.argv0, safe_path: selected.safe_path, authority, diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index b81a9f73c0b7..5edf90ea68e0 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -128,6 +128,29 @@ fn path_text(path: &Path) -> &str { path.to_str().expect("UTF-8 fixture path") } +fn tempdir_for_native_git() -> tempfile::TempDir { + #[cfg(windows)] + { + // Git for Windows rejects the `\\?\` spelling returned when the + // process temp directory is canonicalized. + tempfile::tempdir().expect("fixture") + } + #[cfg(not(windows))] + { + let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); + tempfile::tempdir_in(temp_base).expect("fixture") + } +} + +fn git_config_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(windows)] + let path = path.replace('\\', "/"); + #[cfg(not(windows))] + let path = path.into_owned(); + format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"")) +} + struct OverlappingRegisteredRoute { main: PathBuf, nested: PathBuf, @@ -1056,6 +1079,34 @@ fn command_cwd_is_bound_before_worktree_junction_retarget() { ); } +#[cfg(windows)] +#[test] +fn command_for_cwd_executes_from_a_canonical_windows_path() { + let fixture = tempfile::tempdir().expect("fixture"); + let root = fixture.path().join("repo"); + std::fs::create_dir_all(&root).expect("create repository"); + run_git(&root, &["init", "-q"]); + let canonical_root = std::fs::canonicalize(&root).expect("canonical repository"); + + let runner = GitRunner::for_cwd(&root).expect("runner for canonical cwd"); + let mut command = runner + .command_for_cwd(&canonical_root) + .expect("command for canonical cwd"); + command.args(["rev-parse", "--show-toplevel"]); + let output = runner.output(command).expect("run Git from canonical cwd"); + assert!( + output.status.success(), + "Git rejected canonical cwd {}: {}", + canonical_root.display(), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .expect("canonical Git root"), + canonical_root + ); +} + #[test] fn gitdir_terminal_worktree_root_is_never_promoted_to_metadata() { let fixture = tempfile::tempdir().expect("fixture"); @@ -1406,8 +1457,7 @@ fn linked_worktree_rejects_git_from_main_and_linked_worktrees() { #[test] fn registered_sibling_remains_untrusted_without_its_worktree_marker_or_root() { - let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); - let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let fixture = tempdir_for_native_git(); let main = fixture.path().join("main"); let sibling = fixture.path().join("sibling"); let sibling_bin = sibling.join("bin"); @@ -2218,8 +2268,7 @@ fn nested_repo_inside_linked_outer_denies_unproven_outer_primary_before_path_git #[test] fn explicit_absolute_core_worktree_is_recorded_before_path_selection() { - let temp_base = std::fs::canonicalize(std::env::temp_dir()).expect("canonical temp dir"); - let fixture = tempfile::tempdir_in(temp_base).expect("fixture"); + let fixture = tempdir_for_native_git(); let primary = fixture.path().join("primary"); let common = fixture.path().join("separate-common"); let linked = fixture.path().join("linked"); @@ -2249,7 +2298,10 @@ fn explicit_absolute_core_worktree_is_recorded_before_path_selection() { ], ); let mut config = std::fs::read_to_string(common.join("config")).expect("read common config"); - config.push_str(&format!("\n[core]\n\tworktree = {}\n", primary.display())); + config.push_str(&format!( + "\n[core]\n\tworktree = {}\n", + git_config_path(&primary) + )); std::fs::write(common.join("config"), config).expect("write explicit core.worktree"); std::fs::remove_file(primary.join(".git")).expect("remove primary reverse marker"); write_git_candidate(&primary_bin); @@ -2355,8 +2407,8 @@ fn resolver_rejects_unicode_case_alias_through_repository_junction() { let locations = locations_for_root(&repo); assert!( - !path_is_untrusted(&verbatim_case_alias, &locations), - "fixture must exercise the Unicode alias before canonical ancestry" + path_is_untrusted(&verbatim_case_alias, &locations), + "route observation missed the Unicode repository alias" ); assert!(search_directory_is_untrusted( &verbatim_case_alias, @@ -2407,4 +2459,8 @@ fn resolver_selects_native_git_exe_only() { let path = std::env::join_paths([scripts, native.clone()]).expect("PATH"); let runner = GitRunner::from_search_path(locations, &path).expect("native Git"); assert_eq!(runner.argv0, native.join("git.exe")); + assert_eq!( + runner.executable, + std::fs::canonicalize(native.join("git.exe")).expect("canonical native Git") + ); } diff --git a/codex-rs/git-utils/src/git_executable.rs b/codex-rs/git-utils/src/git_executable.rs index 7606b91a87fe..656ff1254e3b 100644 --- a/codex-rs/git-utils/src/git_executable.rs +++ b/codex-rs/git-utils/src/git_executable.rs @@ -13,6 +13,7 @@ use std::path::Prefix; pub(crate) struct SelectedGitExecutable { pub(crate) executable: PathBuf, + #[cfg(any(unix, test))] pub(crate) argv0: PathBuf, pub(crate) safe_path: OsString, } @@ -55,13 +56,21 @@ pub(crate) fn select_git_executable( if let Some(parent) = canonical_candidate.parent() { push_unique_path(&mut safe_directories, parent.to_path_buf()); } - selected = Some((canonical_candidate, candidate)); + #[cfg(any(unix, test))] + let selected_candidate = (canonical_candidate, candidate); + #[cfg(not(any(unix, test)))] + let selected_candidate = canonical_candidate; + selected = Some(selected_candidate); } + #[cfg(any(unix, test))] let (executable, argv0) = selected.ok_or(GitReadError::NoTrustedGit)?; + #[cfg(not(any(unix, test)))] + let executable = selected.ok_or(GitReadError::NoTrustedGit)?; let safe_path = std::env::join_paths(safe_directories).map_err(|_| GitReadError::NoTrustedGit)?; Ok(SelectedGitExecutable { executable, + #[cfg(any(unix, test))] argv0, safe_path, }) @@ -203,7 +212,7 @@ pub(crate) fn is_native_executable_file(path: &Path) -> bool { if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { return false; } - let Ok(bytes) = read_prefix(path, 4) else { + let Ok(bytes) = read_prefix(path, /*length*/ 4) else { return false; }; matches!( @@ -228,7 +237,7 @@ pub(crate) fn is_native_executable_file(path: &Path) -> bool { }; metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 - && read_prefix(path, 4).is_ok_and(|bytes| bytes == b"\x7fELF") + && read_prefix(path, /*length*/ 4).is_ok_and(|bytes| bytes == b"\x7fELF") } #[cfg(windows)] @@ -257,7 +266,7 @@ pub(crate) fn is_native_executable_file(path: &Path) -> bool { if file.read_exact(&mut dos).is_err() || &dos[..2] != b"MZ" { return false; } - let offset = u32::from_le_bytes(dos[60..64].try_into().expect("PE offset bytes")) as u64; + let offset = u32::from_le_bytes([dos[60], dos[61], dos[62], dos[63]]) as u64; if offset > 1024 * 1024 || offset + 4 > metadata.len() { return false; } diff --git a/codex-rs/git-utils/src/path_authority/route_walker.rs b/codex-rs/git-utils/src/path_authority/route_walker.rs index a8c11e5beb83..12f14df2734f 100644 --- a/codex-rs/git-utils/src/path_authority/route_walker.rs +++ b/codex-rs/git-utils/src/path_authority/route_walker.rs @@ -103,7 +103,7 @@ fn push_unique_spelling(paths: &mut Vec, path: PathBuf) { fn symlink_route_hops(path: &Path) -> io::Result> { let mut hops = Vec::new(); let mut seen = BTreeSet::new(); - collect_symlink_route_hops(path, 0, &mut seen, &mut hops)?; + collect_symlink_route_hops(path, /*depth*/ 0, &mut seen, &mut hops)?; Ok(hops) } @@ -121,8 +121,8 @@ fn collect_symlink_route_hops( } for ancestor in path.ancestors() { match std::fs::symlink_metadata(ancestor) { - Ok(_) => match std::fs::read_link(ancestor) { - Ok(target) => { + Ok(_) => match read_link_if_symlink(ancestor) { + Ok(Some(target)) => { let parent = ancestor .parent() .ok_or_else(|| invalid_data("authority symlink has no parent"))?; @@ -143,7 +143,7 @@ fn collect_symlink_route_hops( }); collect_symlink_route_hops(&projected, depth + 1, seen, hops)?; } - Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Ok(None) => {} Err(error) => return Err(error), }, Err(error) @@ -157,8 +157,30 @@ fn collect_symlink_route_hops( Ok(()) } +fn read_link_if_symlink(path: &Path) -> io::Result> { + match std::fs::read_link(path) { + Ok(target) => Ok(Some(target)), + Err(error) if read_link_error_means_not_symlink(&error) => Ok(None), + Err(error) => Err(error), + } +} + +fn read_link_error_means_not_symlink(error: &io::Error) -> bool { + #[cfg(windows)] + { + // Win32 reports this for ordinary files and directories passed to + // `std::fs::read_link`; it is the Windows equivalent of EINVAL here. + const ERROR_NOT_A_REPARSE_POINT: i32 = 4390; + error.raw_os_error() == Some(ERROR_NOT_A_REPARSE_POINT) + } + #[cfg(not(windows))] + { + error.kind() == io::ErrorKind::InvalidInput + } +} + fn project_through_longest_existing_ancestor(path: &Path) -> io::Result { - project_path(path, 0) + project_path(path, /*symlink_depth*/ 0) } fn project_path(path: &Path, symlink_depth: usize) -> io::Result { @@ -219,3 +241,7 @@ fn resolve_literal_path(path: impl AsRef, base: &Path) -> PathBuf { pub(super) fn invalid_data(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } + +#[cfg(test)] +#[path = "route_walker_tests.rs"] +mod tests; diff --git a/codex-rs/git-utils/src/path_authority/route_walker_tests.rs b/codex-rs/git-utils/src/path_authority/route_walker_tests.rs new file mode 100644 index 000000000000..1b7604d93a37 --- /dev/null +++ b/codex-rs/git-utils/src/path_authority/route_walker_tests.rs @@ -0,0 +1,18 @@ +use pretty_assertions::assert_eq; + +use super::read_link_if_symlink; + +#[test] +fn ordinary_paths_are_not_treated_as_failed_symlink_reads() { + let fixture = tempfile::tempdir().expect("fixture"); + let directory = fixture.path().join("directory"); + let file = fixture.path().join("file"); + std::fs::create_dir(&directory).expect("directory"); + std::fs::write(&file, b"ordinary file").expect("file"); + + assert_eq!( + read_link_if_symlink(&directory).expect("ordinary directory"), + None + ); + assert_eq!(read_link_if_symlink(&file).expect("ordinary file"), None); +} diff --git a/codex-rs/git-utils/src/repository_authority/authority.rs b/codex-rs/git-utils/src/repository_authority/authority.rs index 68e92f6d5f6a..05b792d3573f 100644 --- a/codex-rs/git-utils/src/repository_authority/authority.rs +++ b/codex-rs/git-utils/src/repository_authority/authority.rs @@ -173,7 +173,7 @@ impl RepositoryAuthority { }) } - #[cfg(test)] + #[cfg(all(test, unix))] pub(crate) fn active_git_dir(&self) -> Option<&Path> { self.active_metadata .as_ref() diff --git a/codex-rs/git-utils/src/repository_authority_tests.rs b/codex-rs/git-utils/src/repository_authority_tests.rs index eb8e007bbeac..0447fa0d829f 100644 --- a/codex-rs/git-utils/src/repository_authority_tests.rs +++ b/codex-rs/git-utils/src/repository_authority_tests.rs @@ -26,6 +26,15 @@ fn write_common_config(body: &[u8]) -> tempfile::TempDir { common } +fn git_config_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(windows)] + let path = path.replace('\\', "/"); + #[cfg(not(windows))] + let path = path.into_owned(); + format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"")) +} + fn native_bare_value(config: &Path, includes: bool) -> io::Result> { let mut command = std::process::Command::new("git"); crate::safe_git::isolate_git_command_environment(&mut command); @@ -116,7 +125,7 @@ fn plain_common_bare_parser_matches_native_boolean_syntax_and_is_stricter_on_dup "authority result for {name}" ); assert_eq!( - native_bare_value(&common.path().join("config"), false).expect(name), + native_bare_value(&common.path().join("config"), /*includes*/ false).expect(name), native, "native result for {name}" ); @@ -161,16 +170,18 @@ fn common_bare_proof_rejects_includes_worktree_config_and_relative_worktree() { let common = write_common_config( format!( "[core]\n\tbare = true\n[include]\n\tpath = {}\n", - include_target.path().display() + git_config_path(include_target.path()) ) .as_bytes(), ); assert_eq!( - native_bare_value(&common.path().join("config"), false).expect("direct native bare"), + native_bare_value(&common.path().join("config"), /*includes*/ false) + .expect("direct native bare"), Some(true) ); assert_eq!( - native_bare_value(&common.path().join("config"), true).expect("included native bare"), + native_bare_value(&common.path().join("config"), /*includes*/ true) + .expect("included native bare"), Some(false) ); assert_eq!( @@ -203,7 +214,11 @@ fn common_bare_proof_rejects_includes_worktree_config_and_relative_worktree() { fn common_config_absolute_worktree_is_returned_as_authority() { let worktree = tempfile::tempdir().expect("worktree"); let common = write_common_config( - format!("[core]\n\tworktree = {}\n", worktree.path().display()).as_bytes(), + format!( + "[core]\n\tworktree = {}\n", + git_config_path(worktree.path()) + ) + .as_bytes(), ); assert_eq!( inspect_plain_common_config_authority(common.path()).expect("worktree authority"), @@ -218,7 +233,7 @@ fn common_config_rejects_contradictory_or_malformed_bare_with_absolute_worktree( let common = write_common_config( format!( "[core]\n\tbare = {bare}\n\tworktree = {}\n", - worktree.path().display() + git_config_path(worktree.path()) ) .as_bytes(), ); From 4430c510395ead741fdfb46587f4e319b7373345 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 23:07:18 -0700 Subject: [PATCH 5/9] git-utils: fix Windows repository authority tests --- codex-rs/git-utils/BUILD.bazel | 1 + codex-rs/git-utils/src/git_command_tests.rs | 93 +++++++++++++++------ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/codex-rs/git-utils/BUILD.bazel b/codex-rs/git-utils/BUILD.bazel index 346fd3f8f4c6..ffbc1f042dd7 100644 --- a/codex-rs/git-utils/BUILD.bazel +++ b/codex-rs/git-utils/BUILD.bazel @@ -3,4 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "git-utils", crate_name = "codex_git_utils", + unit_test_timeout = "long", ) diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 5edf90ea68e0..1b7deb33f9e7 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -72,9 +72,43 @@ fn commit_all(cwd: &Path, message: &str) { fn write_git_candidate(directory: &Path) { std::fs::create_dir_all(directory).expect("create candidate directory"); let candidate = directory.join(git_executable_name()); + #[cfg(windows)] + { + let mut pe = [0_u8; 68]; + pe[..2].copy_from_slice(b"MZ"); + pe[60..64].copy_from_slice(&64_u32.to_le_bytes()); + pe[64..].copy_from_slice(b"PE\0\0"); + std::fs::write(candidate, pe).expect("write native PE fixture"); + } + #[cfg(not(windows))] std::fs::copy(native_git_fixture(), candidate).expect("copy native Git fixture"); } +fn write_runnable_git_candidate(directory: &Path) { + #[cfg(windows)] + { + std::fs::create_dir_all(directory.parent().expect("candidate parent")) + .expect("create candidate parent"); + create_junction(directory, &native_git_search_directory()); + } + #[cfg(not(windows))] + write_git_candidate(directory); +} + +#[cfg(windows)] +fn native_git_search_directory() -> PathBuf { + let path = std::env::var_os("PATH").expect("PATH"); + for directory in std::env::split_paths(&path) { + let candidate = directory.join(git_executable_name()); + if let Ok(candidate) = std::fs::canonicalize(candidate) + && crate::git_executable::is_native_executable_file(&candidate) + { + return directory; + } + } + panic!("no native Git directory in PATH") +} + fn native_git_fixture() -> PathBuf { let path = std::env::var_os("PATH").expect("PATH"); for directory in std::env::split_paths(&path) { @@ -1424,7 +1458,7 @@ fn linked_worktree_rejects_git_from_main_and_linked_worktrees() { run_git(&main, &["init", "-q"]); run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); write_git_candidate(&main_bin); - write_git_candidate(&trusted_bin); + write_runnable_git_candidate(&trusted_bin); let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); assert!(path_is_untrusted( @@ -1477,8 +1511,8 @@ fn registered_sibling_remains_untrusted_without_its_worktree_marker_or_root() { } let locations = repository_authority_for_cwd(&main).expect("untrusted locations"); assert!( - locations.contains_root(&sibling), - "registered sibling root missing in state {state}" + path_is_untrusted(&sibling_bin.join(git_executable_name()), &locations), + "registered sibling Git became trusted in state {state}" ); assert_eq!( selected_git(&locations, &[&sibling_bin, &trusted_bin]), @@ -1618,13 +1652,19 @@ fn metadata_alias_registered_route_is_rejected_before_and_after_symlink_retarget #[cfg(windows)] #[test] -fn metadata_alias_registered_route_rejects_unicode_case_junction_retarget() { +fn registered_route_rejects_unicode_case_junction_retarget() { let fixture = tempfile::tempdir().expect("fixture"); let route = metadata_alias_registered_route(fixture.path(), "Répo", "RÉPO"); - create_junction(&route.pivot, &route.main.join(".git")); + let raw_marker = fixture.path().join("RÉPO").join("pivot").join(".git"); + std::fs::write( + &route.registry_marker, + format!("{}\n", raw_marker.display()), + ) + .expect("write direct junction registry route"); + create_junction(&route.pivot, &route.linked); assert_eq!( - std::fs::canonicalize(&route.raw_marker).expect("canonical metadata-alias route"), + std::fs::canonicalize(&raw_marker).expect("canonical Unicode junction route"), std::fs::canonicalize(route.linked.join(".git")).expect("canonical linked marker") ); assert_unsafe_registry_route( @@ -1633,19 +1673,17 @@ fn metadata_alias_registered_route_rejects_unicode_case_junction_retarget() { ); std::fs::remove_dir(&route.pivot).expect("remove metadata junction"); - let attacker_target = fixture.path().join("attacker/deep"); let attacker_linked = fixture.path().join("attacker/linked"); - std::fs::create_dir_all(&attacker_target).expect("create attacker pivot target"); std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); std::fs::write( attacker_linked.join(".git"), format!("gitdir: {}\n", route.linked_admin.display()), ) .expect("write attacker backlink"); - create_junction(&route.pivot, &attacker_target); + create_junction(&route.pivot, &attacker_linked); assert_eq!( - std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted route"), + std::fs::canonicalize(&raw_marker).expect("canonical retargeted route"), std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") ); assert_unsafe_registry_route( @@ -1700,37 +1738,42 @@ fn overlapping_registered_route_remains_rejected_after_symlink_retarget() { fn overlapping_registered_route_remains_rejected_after_junction_retarget() { let fixture = tempfile::tempdir().expect("fixture"); let route = overlapping_registered_route(fixture.path()); + let pivot = route.nested.join("pivot"); + let raw_marker = pivot.join("linked/.git"); + std::fs::write( + &route.registry_marker, + format!("{}\n", raw_marker.display()), + ) + .expect("write direct overlapping registry route"); + create_junction(&pivot, &route.main); + + assert_eq!( + std::fs::canonicalize(&raw_marker).expect("canonical overlapping junction route"), + std::fs::canonicalize(route.linked.join(".git")).expect("canonical linked marker") + ); assert_unsafe_registry_route( repository_authority_for_cwd(&route.main), &route.registry_marker, ); - std::fs::remove_dir_all(&route.nested).expect("remove nested worktree"); + std::fs::remove_dir(&pivot).expect("remove overlapping junction"); let attacker_parent = fixture.path().join("attacker/deep"); - let attacker_nested = attacker_parent.join("nested"); let attacker_linked = attacker_parent.join("linked"); - std::fs::create_dir_all(&attacker_nested).expect("create attacker nested root"); std::fs::create_dir_all(&attacker_linked).expect("create attacker linked root"); - std::fs::write( - attacker_nested.join(".git"), - format!("gitdir: {}\n", route.nested_admin.display()), - ) - .expect("write attacker nested backlink"); std::fs::write( attacker_linked.join(".git"), format!("gitdir: {}\n", route.linked_admin.display()), ) .expect("write attacker linked backlink"); - create_junction(&route.nested, &attacker_nested); + create_junction(&pivot, &attacker_parent); assert_eq!( - std::fs::canonicalize(&route.raw_marker).expect("canonical retargeted marker"), + std::fs::canonicalize(&raw_marker).expect("canonical retargeted marker"), std::fs::canonicalize(attacker_linked.join(".git")).expect("canonical attacker marker") ); - let nested_registry_marker = route.nested_admin.join("gitdir"); - assert_unsafe_registry_route_at_one_of( + assert_unsafe_registry_route( repository_authority_for_cwd(&route.main), - &[&route.registry_marker, &nested_registry_marker], + &route.registry_marker, ); } @@ -1976,7 +2019,7 @@ fn submodule_rejects_git_from_enclosing_superproject() { ], ); write_git_candidate(&outer_bin); - write_git_candidate(&trusted_bin); + write_runnable_git_candidate(&trusted_bin); let locations = repository_authority_for_cwd(&submodule).expect("untrusted locations"); assert!( @@ -2029,7 +2072,7 @@ fn bare_backed_linked_worktree_allows_external_git_in_sibling_directory() { path_text(&linked), ], ); - write_git_candidate(&trusted_bin); + write_runnable_git_candidate(&trusted_bin); let locations = repository_authority_for_cwd(&linked).expect("untrusted locations"); assert_eq!( From 5a95b65f576c4766fb8555c03a96132ecd3a93af Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 23:19:38 -0700 Subject: [PATCH 6/9] git-utils: gate Unix-only authority fixtures --- codex-rs/git-utils/src/git_command_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 1b7deb33f9e7..63142d4bebb7 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -189,6 +189,7 @@ struct OverlappingRegisteredRoute { main: PathBuf, nested: PathBuf, linked: PathBuf, + #[cfg(unix)] nested_admin: PathBuf, linked_admin: PathBuf, registry_marker: PathBuf, @@ -203,6 +204,7 @@ fn overlapping_registered_route(fixture: &Path) -> OverlappingRegisteredRoute { run_git(&main, &["init", "-q"]); run_git(&main, &["worktree", "add", "--orphan", path_text(&nested)]); run_git(&main, &["worktree", "add", "--orphan", path_text(&linked)]); + #[cfg(unix)] let nested_admin = PathBuf::from(run_git_stdout( &nested, &["rev-parse", "--absolute-git-dir"], @@ -219,6 +221,7 @@ fn overlapping_registered_route(fixture: &Path) -> OverlappingRegisteredRoute { main, nested, linked, + #[cfg(unix)] nested_admin, linked_admin, registry_marker, @@ -232,6 +235,7 @@ struct MetadataAliasRegisteredRoute { linked_admin: PathBuf, pivot: PathBuf, registry_marker: PathBuf, + #[cfg(unix)] raw_marker: PathBuf, } @@ -264,6 +268,7 @@ fn metadata_alias_registered_route( linked_admin, pivot, registry_marker, + #[cfg(unix)] raw_marker, } } @@ -312,6 +317,7 @@ fn assert_unsafe_registry_route(result: Result, markers: &[&Path], From 7a4648aff42a157208136b8b229fe46b7f9ba5d5 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 23:42:37 -0700 Subject: [PATCH 7/9] git-utils: normalize junction fixture paths --- codex-rs/git-utils/src/git_command_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/git-utils/src/git_command_tests.rs b/codex-rs/git-utils/src/git_command_tests.rs index 63142d4bebb7..2679f744fcef 100644 --- a/codex-rs/git-utils/src/git_command_tests.rs +++ b/codex-rs/git-utils/src/git_command_tests.rs @@ -124,6 +124,11 @@ fn native_git_fixture() -> PathBuf { #[cfg(windows)] fn create_junction(path: &Path, target: &Path) { + // Bazel's GNU Windows runner can surface temporary paths with `/` + // separators. `mklink` treats those separators as option prefixes, so + // pass native separators to the cmd.exe built-in. + let path = path.as_os_str().to_string_lossy().replace('/', "\\"); + let target = target.as_os_str().to_string_lossy().replace('/', "\\"); let output = Command::new("cmd.exe") .args(["/D", "/C", "mklink", "/J"]) .arg(path) From d43fab946464264a052f3e62f2af11f327d53502 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Thu, 2 Jul 2026 04:33:41 -0700 Subject: [PATCH 8/9] fix(git-utils): reuse runner for merge-base --- codex-rs/git-utils/src/branch.rs | 119 ++++++++++++++++++++++---- codex-rs/git-utils/src/git_command.rs | 17 ++++ codex-rs/git-utils/src/operations.rs | 53 ++++++++++-- 3 files changed, 165 insertions(+), 24 deletions(-) diff --git a/codex-rs/git-utils/src/branch.rs b/codex-rs/git-utils/src/branch.rs index fd5d1d05a1c4..13cad88f7b4e 100644 --- a/codex-rs/git-utils/src/branch.rs +++ b/codex-rs/git-utils/src/branch.rs @@ -2,10 +2,11 @@ use std::ffi::OsString; use std::path::Path; use crate::GitToolingError; +use crate::git_command::GitRunner; use crate::operations::ensure_git_repository; use crate::operations::resolve_head; use crate::operations::resolve_repository_root; -use crate::operations::run_git_for_stdout; +use crate::operations::run_git_for_stdout_with_runner; /// Returns the merge-base commit between `HEAD` and the latest version between local /// and remote of the provided branch, if both exist. @@ -16,25 +17,28 @@ pub fn merge_base_with_head( repo_path: &Path, branch: &str, ) -> Result, GitToolingError> { - ensure_git_repository(repo_path)?; - let repo_root = resolve_repository_root(repo_path)?; - let head = match resolve_head(repo_root.as_path())? { + let git = GitRunner::for_cwd_io(repo_path)?; + ensure_git_repository(&git, repo_path)?; + let repo_root = resolve_repository_root(&git, repo_path)?; + let head = match resolve_head(&git, repo_root.as_path())? { Some(head) => head, None => return Ok(None), }; - let Some(branch_ref) = resolve_branch_ref(repo_root.as_path(), branch)? else { + let Some(branch_ref) = resolve_branch_ref(&git, repo_root.as_path(), branch)? else { return Ok(None); }; - let preferred_ref = - if let Some(upstream) = resolve_upstream_if_remote_ahead(repo_root.as_path(), branch)? { - resolve_branch_ref(repo_root.as_path(), &upstream)?.unwrap_or(branch_ref) - } else { - branch_ref - }; + let preferred_ref = if let Some(upstream) = + resolve_upstream_if_remote_ahead(&git, repo_root.as_path(), branch)? + { + resolve_branch_ref(&git, repo_root.as_path(), &upstream)?.unwrap_or(branch_ref) + } else { + branch_ref + }; - let merge_base = run_git_for_stdout( + let merge_base = run_git_for_stdout_with_runner( + &git, repo_root.as_path(), vec![ OsString::from("merge-base"), @@ -47,8 +51,13 @@ pub fn merge_base_with_head( Ok(Some(merge_base)) } -fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result, GitToolingError> { - let rev = run_git_for_stdout( +fn resolve_branch_ref( + git: &GitRunner, + repo_root: &Path, + branch: &str, +) -> Result, GitToolingError> { + let rev = run_git_for_stdout_with_runner( + git, repo_root, vec![ OsString::from("rev-parse"), @@ -66,10 +75,12 @@ fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result, } fn resolve_upstream_if_remote_ahead( + git: &GitRunner, repo_root: &Path, branch: &str, ) -> Result, GitToolingError> { - let upstream = match run_git_for_stdout( + let upstream = match run_git_for_stdout_with_runner( + git, repo_root, vec![ OsString::from("rev-parse"), @@ -90,7 +101,8 @@ fn resolve_upstream_if_remote_ahead( Err(other) => return Err(other), }; - let counts = match run_git_for_stdout( + let counts = match run_git_for_stdout_with_runner( + git, repo_root, vec![ OsString::from("rev-list"), @@ -120,7 +132,13 @@ fn resolve_upstream_if_remote_ahead( mod tests { use super::merge_base_with_head; use crate::GitToolingError; + use crate::git_command::GitRunner; + use crate::git_command::git_runner_construction_count; + use crate::git_command::reset_git_runner_construction_count; + use crate::operations::ensure_git_repository; + use crate::operations::resolve_repository_root; use pretty_assertions::assert_eq; + use std::io; use std::path::Path; use std::process::Command; use tempfile::tempdir; @@ -187,8 +205,14 @@ mod tests { run_git_in(repo, &["checkout", "feature"]); let expected = run_git_stdout(repo, &["merge-base", "HEAD", "main"]); + reset_git_runner_construction_count(); let merge_base = merge_base_with_head(repo, "main")?; assert_eq!(merge_base, Some(expected)); + assert_eq!( + git_runner_construction_count(), + 1, + "one high-level merge-base call must retain one Git runner" + ); Ok(()) } @@ -253,4 +277,67 @@ mod tests { Ok(()) } + + #[test] + fn merge_base_returns_none_when_head_is_unborn() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + let merge_base = merge_base_with_head(repo, "main")?; + assert_eq!(merge_base, None); + + Ok(()) + } + + #[test] + fn merge_base_reports_non_repository() { + let temp = tempdir().expect("tempdir"); + let path = temp.path(); + + let error = merge_base_with_head(path, "main").expect_err("non-repository must fail"); + let GitToolingError::NotAGitRepository { path: error_path } = error else { + panic!("expected non-repository error, got {error:?}"); + }; + assert_eq!(error_path, path); + } + + #[cfg(unix)] + #[test] + fn retained_runner_refuses_gitdir_retarget_between_subcommands() { + use std::os::unix::fs::symlink; + + let fixture = tempdir().expect("fixture"); + let repo = fixture.path().join("repo"); + let admin = fixture.path().join("external-admin"); + std::fs::create_dir_all(&repo).expect("create repository"); + run_git_in( + fixture.path(), + &[ + "init", + "--separate-git-dir", + admin.to_str().expect("UTF-8 admin path"), + repo.to_str().expect("UTF-8 repository path"), + ], + ); + + reset_git_runner_construction_count(); + let git = GitRunner::for_cwd(&repo).expect("operation-scoped Git runner"); + ensure_git_repository(&git, &repo).expect("first merge-base subcommand"); + + symlink(&admin, repo.join("switch")).expect("Git-dir route switch"); + std::fs::write(repo.join(".git"), "gitdir: switch\n").expect("retarget Git-dir marker"); + + let error = resolve_repository_root(&git, &repo) + .expect_err("retained authority must refuse the second subcommand"); + let GitToolingError::Io(error) = error else { + panic!("expected metadata revalidation error, got {error:?}"); + }; + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert_eq!( + git_runner_construction_count(), + 1, + "subcommands must not rediscover authority after metadata retargeting" + ); + } } diff --git a/codex-rs/git-utils/src/git_command.rs b/codex-rs/git-utils/src/git_command.rs index e78420b84270..924ed3238518 100644 --- a/codex-rs/git-utils/src/git_command.rs +++ b/codex-rs/git-utils/src/git_command.rs @@ -61,6 +61,8 @@ impl GitCommand { impl GitRunner { pub(crate) fn for_cwd(cwd: &Path) -> Result { + #[cfg(test)] + GIT_RUNNER_CONSTRUCTION_COUNT.with(|count| count.set(count.get() + 1)); let authority = repository_authority_for_cwd(cwd)?; let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?; Self::from_search_path(authority, &search_path) @@ -125,6 +127,21 @@ impl GitRunner { } } +#[cfg(test)] +thread_local! { + static GIT_RUNNER_CONSTRUCTION_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_git_runner_construction_count() { + GIT_RUNNER_CONSTRUCTION_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn git_runner_construction_count() -> usize { + GIT_RUNNER_CONSTRUCTION_COUNT.with(std::cell::Cell::get) +} + pub(crate) fn repository_authority_for_cwd( cwd: &Path, ) -> Result { diff --git a/codex-rs/git-utils/src/operations.rs b/codex-rs/git-utils/src/operations.rs index 27c3e17ec44e..24123d1ea4bd 100644 --- a/codex-rs/git-utils/src/operations.rs +++ b/codex-rs/git-utils/src/operations.rs @@ -7,8 +7,9 @@ use crate::GitToolingError; use crate::git_command::GitRunner; use crate::safe_git::DISABLED_HOOKS_PATH; -pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> { - match run_git_for_stdout( +pub(crate) fn ensure_git_repository(git: &GitRunner, path: &Path) -> Result<(), GitToolingError> { + match run_git_for_stdout_with_runner( + git, path, vec![ OsString::from("rev-parse"), @@ -29,8 +30,12 @@ pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> } } -pub(crate) fn resolve_head(path: &Path) -> Result, GitToolingError> { - match run_git_for_stdout( +pub(crate) fn resolve_head( + git: &GitRunner, + path: &Path, +) -> Result, GitToolingError> { + match run_git_for_stdout_with_runner( + git, path, vec![ OsString::from("rev-parse"), @@ -45,8 +50,12 @@ pub(crate) fn resolve_head(path: &Path) -> Result, GitToolingErro } } -pub(crate) fn resolve_repository_root(path: &Path) -> Result { - let root = run_git_for_stdout( +pub(crate) fn resolve_repository_root( + git: &GitRunner, + path: &Path, +) -> Result { + let root = run_git_for_stdout_with_runner( + git, path, vec![ OsString::from("rev-parse"), @@ -70,6 +79,7 @@ where Ok(()) } +#[cfg(test)] pub(crate) fn run_git_for_stdout( dir: &Path, args: I, @@ -79,7 +89,21 @@ where I: IntoIterator, S: AsRef, { - let run = run_git(dir, args, env)?; + let git = GitRunner::for_cwd_io(dir)?; + run_git_for_stdout_with_runner(&git, dir, args, env) +} + +pub(crate) fn run_git_for_stdout_with_runner( + git: &GitRunner, + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let run = run_git_with_runner(git, dir, args, env)?; String::from_utf8(run.output.stdout) .map(|value| value.trim().to_string()) .map_err(|source| GitToolingError::GitOutputUtf8 { @@ -93,6 +117,20 @@ fn run_git( args: I, env: Option<&[(OsString, OsString)]>, ) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let git = GitRunner::for_cwd_io(dir)?; + run_git_with_runner(&git, dir, args, env) +} + +fn run_git_with_runner( + git: &GitRunner, + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result where I: IntoIterator, S: AsRef, @@ -109,7 +147,6 @@ where args_vec.push(OsString::from(arg.as_ref())); } let command_string = build_command_string(&args_vec); - let git = GitRunner::for_cwd_io(dir)?; let mut command = git.command_for_cwd(dir)?; if let Some(envs) = env { for (key, value) in envs { From 10948913cd50a6790da012da5d02993befe4dd97 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Thu, 2 Jul 2026 04:48:32 -0700 Subject: [PATCH 9/9] test(git-utils): gate Unix-only merge-base imports --- codex-rs/git-utils/src/branch.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/git-utils/src/branch.rs b/codex-rs/git-utils/src/branch.rs index 13cad88f7b4e..8d5c8f1e03cd 100644 --- a/codex-rs/git-utils/src/branch.rs +++ b/codex-rs/git-utils/src/branch.rs @@ -132,12 +132,16 @@ fn resolve_upstream_if_remote_ahead( mod tests { use super::merge_base_with_head; use crate::GitToolingError; + #[cfg(unix)] use crate::git_command::GitRunner; use crate::git_command::git_runner_construction_count; use crate::git_command::reset_git_runner_construction_count; + #[cfg(unix)] use crate::operations::ensure_git_repository; + #[cfg(unix)] use crate::operations::resolve_repository_root; use pretty_assertions::assert_eq; + #[cfg(unix)] use std::io; use std::path::Path; use std::process::Command;