git-utils: integrate exact staging with repository authority

This commit is contained in:
Chris Bookholt
2026-07-02 01:42:30 -07:00
37 changed files with 8370 additions and 707 deletions
+1
View File
@@ -3137,6 +3137,7 @@ dependencies = [
"once_cell",
"pretty_assertions",
"regex",
"same-file",
"schemars 0.8.22",
"serde",
"similar",
+1
View File
@@ -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"
+1
View File
@@ -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",
)
+1
View File
@@ -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 }
+38 -6
View File
@@ -13,6 +13,7 @@ use std::path::PathBuf;
use crate::FsmonitorOverride;
use crate::apply_output::parse_git_apply_output;
use crate::git_command::GitRunner;
use crate::git_config_sources::ensure_no_worktree_config_sources;
use crate::patch_paths::extract_effective_paths_from_patch;
use crate::patch_paths::stage_effective_paths;
use crate::safe_git::DISABLED_HOOKS_PATH;
@@ -48,13 +49,19 @@ pub struct ApplyGitResult {
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
let git = GitRunner::for_cwd_io(&req.cwd)?;
let mut cfg_parts = configured_git_config_parts();
ensure_no_worktree_config_sources(&git, &req.cwd, &cfg_parts)?;
let requested_cwd = std::fs::canonicalize(&req.cwd)?;
let git_root = resolve_git_root(&git, &req.cwd, &cfg_parts)?;
if git_root != requested_cwd {
ensure_no_worktree_config_sources(&git, &git_root, &cfg_parts)?;
}
// Write unified diff into a temporary file
let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
// Keep tmpdir alive until function end to ensure the file exists
let _guard = tmpdir;
let patch_paths = extract_effective_paths_from_patch(&git, &patch_path, req.revert)?;
let patch_paths =
extract_effective_paths_from_patch(&git, &git_root, &patch_path, req.revert, &cfg_parts)?;
let filter_guard =
ensure_no_selected_executable_git_filters(&git, &git_root, &patch_paths, &cfg_parts)?;
cfg_parts.extend(safe_git_config_parts());
@@ -130,12 +137,11 @@ fn resolve_git_root(
git_config_args: &[String],
) -> io::Result<PathBuf> {
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 {
@@ -201,14 +207,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();
@@ -813,4 +818,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"));
}
}
@@ -4,6 +4,9 @@ use std::ffi::OsStr;
use std::fs::File;
use std::fs::FileTimes;
use std::path::Path;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use std::time::UNIX_EPOCH;
const PATCH: &str =
@@ -46,6 +49,7 @@ fn init_repo() -> tempfile::TempDir {
run_success(root, &["init"]);
run_success(root, &["config", "user.email", "codex@example.com"]);
run_success(root, &["config", "user.name", "Codex"]);
run_success(root, &["config", "core.autocrlf", "false"]);
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
run_success(root, &["add", "file.txt"]);
run_success(root, &["commit", "-m", "seed"]);
@@ -200,6 +204,21 @@ fn run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) {
);
}
fn wait_for_config_source_probe(trace: &Path) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let contents = std::fs::read_to_string(trace).unwrap_or_default();
if contents
.find("^include")
.is_some_and(|offset| contents[offset..].contains("\"event\":\"exit\""))
{
return true;
}
thread::yield_now();
}
false
}
#[test]
fn reverse_staging_uses_command_scoped_filter_override() {
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
@@ -235,6 +254,127 @@ fn reverse_staging_uses_command_scoped_filter_override() {
);
}
#[cfg(unix)]
#[test]
fn apply_rejects_worktree_primary_config_fifo_before_any_git_launch() {
use std::io::Write as _;
const TEST_NAME: &str =
"apply::filter_tests::apply_rejects_worktree_primary_config_fifo_before_any_git_launch";
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
let repo = init_repo();
let primary_config = repo.path().join("worktree-global.fifo");
let status = std::process::Command::new("mkfifo")
.arg(&primary_config)
.status()
.expect("run mkfifo");
assert!(status.success(), "mkfifo failed: {status}");
let trace = tempfile::NamedTempFile::new().expect("trace file");
run_isolated_test(
TEST_NAME,
&[
("CODEX_APPLY_CONFIG_SOURCE_ROOT", repo.path().as_os_str()),
("GIT_CONFIG_GLOBAL", primary_config.as_os_str()),
("GIT_CONFIG_NOSYSTEM", OsStr::new("1")),
("GIT_TRACE2_EVENT", trace.path().as_os_str()),
],
);
return;
}
let root = PathBuf::from(
std::env::var_os("CODEX_APPLY_CONFIG_SOURCE_ROOT").expect("fixture repository root"),
);
let trace = PathBuf::from(std::env::var_os("GIT_TRACE2_EVENT").expect("trace path"));
let primary_config =
PathBuf::from(std::env::var_os("GIT_CONFIG_GLOBAL").expect("worktree primary config FIFO"));
// Keep the FIFO readable and prefill a valid config so a regression starts
// and finishes instead of hanging the test process while opening it.
let mut fifo_guard = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(primary_config)
.expect("open worktree primary config FIFO");
fifo_guard
.write_all(b"[user]\n\tname = worktree-controlled\n")
.expect("prefill worktree primary config FIFO");
let release_fifo = thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
drop(fifo_guard);
});
let error = apply_git_patch(&request(
&root, /*revert*/ false, /*preflight*/ true,
))
.expect_err("reject worktree-controlled primary config");
release_fifo.join().expect("release config FIFO");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("worktree-controlled"), "{error}");
assert_eq!(
std::fs::read_to_string(trace).expect("read Git trace"),
"",
"config authorization must reject before rev-parse or numstat starts Git",
);
assert_eq!(
std::fs::read_to_string(root.join("file.txt")).expect("read worktree file"),
"orig\n"
);
}
#[test]
fn apply_rejects_unseen_driver_added_through_worktree_include_after_source_probe() {
const TEST_NAME: &str = "apply::filter_tests::apply_rejects_unseen_driver_added_through_worktree_include_after_source_probe";
if std::env::var_os("CODEX_GIT_UTILS_APPLY_FILTER_ENV_CHILD").is_none() {
let trace = tempfile::NamedTempFile::new().expect("trace file");
run_isolated_test(TEST_NAME, &[("GIT_TRACE2_EVENT", trace.path().as_os_str())]);
return;
}
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitattributes"), "file.txt filter=fresh\n")
.expect("write filter attributes");
run_success(root, &["add", ".gitattributes"]);
run_success(root, &["commit", "-m", "filter target"]);
let included = root.join("driver-config");
std::fs::write(&included, "").expect("write initially empty included config");
run_success(root, &["config", "include.path", "../driver-config"]);
let trace = PathBuf::from(std::env::var_os("GIT_TRACE2_EVENT").expect("trace path"));
std::fs::write(&trace, "").expect("clear fixture trace");
let watcher_trace = trace.clone();
let watcher = thread::spawn(move || {
let observed = wait_for_config_source_probe(&watcher_trace);
if observed {
std::fs::write(
included,
format!("[filter \"fresh\"]\n\tclean = {FILTER_COMMAND}\n\trequired = true\n"),
)
.expect("add previously unseen filter driver");
}
observed
});
let error = apply_git_patch(&request(
root, /*revert*/ false, /*preflight*/ false,
))
.expect_err("reject worktree config source before final apply");
assert!(watcher.join().expect("config watcher"));
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(!configured_filter_ran(root));
assert_eq!(index_contents(root, "file.txt"), "orig\n");
assert_eq!(
std::fs::read_to_string(root.join("file.txt")).expect("read worktree file"),
"orig\n"
);
assert!(
!std::fs::read_to_string(trace)
.expect("read completed trace")
.contains("--3way")
);
}
#[test]
fn apply_treats_empty_name_filter_as_selected_only() {
for command in ["clean", "smudge", "process"] {
+27 -1
View File
@@ -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)
}
}
+4 -6
View File
@@ -174,12 +174,11 @@ pub(crate) fn resolve_exact_index_policy(
fn read_index(git: &GitRunner, git_root: &Path, git_config_args: &[String]) -> io::Result<Vec<u8>> {
// One full, byte-oriented scan avoids invoking Git once per patch path and
// lets one parse enforce aliases, directory prefixes, and flags together.
let mut command = git.command();
let mut command = git.command_for_cwd(git_root)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["ls-files", "--cached", "-v", "-z"])
.current_dir(git_root);
.args(["ls-files", "--cached", "-v", "-z"]);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
@@ -430,12 +429,11 @@ pub(crate) fn effective_git_bool(
git_config_args: &[String],
key: &str,
) -> io::Result<Option<bool>> {
let mut command = git.command();
let mut command = git.command_for_cwd(git_root)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["config", "--type=bool", "--get", key])
.current_dir(git_root);
.args(["config", "--type=bool", "--get", key]);
let output = git.output(command)?;
if output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty() {
return Ok(None);
+7 -7
View File
@@ -12,6 +12,7 @@ use crate::exact_index_policy::ExactIndexPolicy;
use crate::exact_index_policy::effective_git_bool;
use crate::exact_index_policy::resolve_exact_index_policy;
use crate::git_command::GitRunner;
use crate::git_config_sources::ensure_no_worktree_config_sources;
use crate::patch_paths::validate_patch_path;
use crate::safe_git::ensure_no_selected_git_add_filters;
@@ -67,6 +68,8 @@ pub(crate) fn update_index_exact_paths(
));
}
ensure_no_worktree_config_sources(git, git_root, git_config_args)?;
let (paths, content_filter_paths) = match resolve_exact_index_policy(
git,
git_root,
@@ -135,14 +138,13 @@ fn ignored_untracked_paths(
.map(|path| format!("./{path}"))
.collect::<Vec<_>>();
let input = nul_path_input(&probe_paths)?;
let mut command = git.command();
let mut command = git.command_for_cwd(git_root)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
// `check-ignore --stdin` consumes pathnames rather than pathspecs and
// rejects the global literal-pathspec mode as unsupported magic.
.args(["check-ignore", "--stdin", "-z"])
.current_dir(git_root)
.stdin(Stdio::from(input));
let output = git.output(command)?;
match output.status.code() {
@@ -183,12 +185,11 @@ fn sparse_checkout_policy(
// `git-sparse-checkout` helper. Repository-controlled PATH entries are
// outside the trusted executable boundary. `--list-cmds=builtins` is a
// main-program capability query and cannot dispatch an external command.
let mut builtins_command = git.command();
let mut builtins_command = git.command_for_cwd(git_root)?;
builtins_command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.arg("--list-cmds=builtins")
.current_dir(git_root);
.arg("--list-cmds=builtins");
let builtins = git.output(builtins_command)?;
let sparse_checkout_is_builtin = builtins.status.success()
&& std::str::from_utf8(&builtins.stdout)
@@ -202,12 +203,11 @@ fn sparse_checkout_policy(
}
let input = nul_path_input(paths)?;
let mut command = git.command();
let mut command = git.command_for_cwd(git_root)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["sparse-checkout", "check-rules", "-z"])
.current_dir(git_root)
.stdin(Stdio::from(input));
let output = git.output(command)?;
if !output.status.success() {
+71
View File
@@ -0,0 +1,71 @@
use std::io;
const MAX_SENTINEL_FILTER_CHILD_PROBES: usize = 16;
#[derive(Debug, Default)]
pub(crate) struct SentinelFilterProbeBudget {
completed_child_probes: usize,
}
impl SentinelFilterProbeBudget {
#[cfg(test)]
pub(crate) const fn max_probes() -> usize {
MAX_SENTINEL_FILTER_CHILD_PROBES
}
pub(crate) fn ensure_probe_available(&self) -> io::Result<()> {
if self.completed_child_probes >= MAX_SENTINEL_FILTER_CHILD_PROBES {
return Err(io::Error::other(format!(
"refusing to continue Git filter sentinel disambiguation after {} child probes (hard limit: {})",
self.completed_child_probes, MAX_SENTINEL_FILTER_CHILD_PROBES
)));
}
Ok(())
}
pub(crate) fn record_completed_probe(&mut self) {
debug_assert!(self.completed_child_probes < MAX_SENTINEL_FILTER_CHILD_PROBES);
self.completed_child_probes += 1;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SentinelFilterProbeResolution {
SpecialAttributeState,
NeedsOptionalProbe,
LiteralDriver,
ProbeFailure,
}
pub(crate) fn classify_sentinel_filter_probes(
required_succeeded: bool,
optional_succeeded: Option<bool>,
) -> SentinelFilterProbeResolution {
if required_succeeded {
SentinelFilterProbeResolution::SpecialAttributeState
} else {
match optional_succeeded {
None => SentinelFilterProbeResolution::NeedsOptionalProbe,
Some(true) => SentinelFilterProbeResolution::LiteralDriver,
Some(false) => SentinelFilterProbeResolution::ProbeFailure,
}
}
}
pub(crate) fn sentinel_filter_probe_config_args(
neutralization_args: &[String],
driver: &str,
required: bool,
) -> io::Result<Vec<String>> {
if !matches!(driver, "set" | "unset" | "unspecified") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Git filter sentinel probe requested for a non-sentinel driver",
));
}
let mut args = Vec::with_capacity(neutralization_args.len() + 2);
args.extend_from_slice(neutralization_args);
args.push("-c".to_string());
args.push(format!("filter.{driver}.required={required}"));
Ok(args)
}
+117 -331
View File
@@ -1,368 +1,154 @@
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 std::process::Stdio;
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,
#[cfg(any(unix, test))]
argv0: PathBuf,
safe_path: std::ffi::OsString,
authority: RepositoryAuthority,
}
struct UntrustedGitLocations {
roots: Vec<PathBuf>,
common_dirs: Vec<PathBuf>,
/// 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,
}
impl GitCommand {
pub(crate) fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
self.inner.arg(arg);
self
}
pub(crate) fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.inner.args(args);
self
}
pub(crate) fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
self.inner.env(key, value);
self
}
pub(crate) fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
self.inner.env_remove(key);
self
}
pub(crate) fn stdin(&mut self, config: impl Into<Stdio>) -> &mut Self {
self.inner.stdin(config);
self
}
}
impl GitRunner {
pub(crate) fn for_cwd(cwd: &Path) -> Result<Self, GitReadError> {
let locations = untrusted_git_locations_for_cwd(cwd)?;
let authority = repository_authority_for_cwd(cwd)?;
let search_path = std::env::var_os("PATH").ok_or(GitReadError::NoTrustedGit)?;
Self::from_search_path(&locations, &search_path)
Self::from_search_path(authority, &search_path)
}
pub(crate) fn for_cwd_io(cwd: &Path) -> io::Result<Self> {
Self::for_cwd(cwd).map_err(|error| io::Error::new(io::ErrorKind::NotFound, error))
Self::for_cwd(cwd).map_err(GitReadError::into_io_error)
}
pub(crate) fn command(&self) -> Command {
Command::new(&self.executable)
pub(crate) fn command(&self) -> GitCommand {
let mut command = Command::new(&self.executable);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
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 }
}
pub(crate) fn output(&self, mut command: Command) -> io::Result<std::process::Output> {
isolate_git_command_environment(&mut command);
command.envs(crate::local_only_git_env());
command.output()
pub(crate) fn command_for_cwd(&self, cwd: &Path) -> io::Result<GitCommand> {
let cwd = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()?.join(cwd)
};
let cwd = self.authority.canonical_command_cwd(&cwd)?;
let mut command = self.command();
command.arg("-C").arg(cwd);
Ok(command)
}
pub(crate) fn ensure_config_source_is_not_worktree_controlled(
&self,
path: &Path,
description: &str,
) -> io::Result<()> {
self.authority
.ensure_config_source_is_not_worktree_controlled(path, description)
}
pub(crate) fn output(&self, mut command: GitCommand) -> io::Result<std::process::Output> {
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()
}
fn revalidate_active_repository_metadata(&self) -> io::Result<()> {
self.authority.revalidate_active_repository_metadata()
}
fn from_search_path(
untrusted: &UntrustedGitLocations,
authority: RepositoryAuthority,
search_path: &OsStr,
) -> Result<Self, GitReadError> {
for directory in std::env::split_paths(search_path) {
if !directory.is_absolute() {
continue;
}
// Check the PATH spelling before appending `git`. On Windows,
// PathBuf::push resolves `..` when its base uses a verbatim prefix,
// which would otherwise erase repository traversal before the
// first containment check.
if search_directory_is_untrusted(&directory, untrusted) {
continue;
}
let candidate = directory.join(git_executable_name());
if path_is_untrusted(&candidate, untrusted) {
continue;
}
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)
authority.ensure_primary_authority()?;
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,
})
}
}
fn untrusted_git_locations_for_cwd(cwd: &Path) -> Result<UntrustedGitLocations, GitReadError> {
let lexical_cwd = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()
.map_err(|_| GitReadError::NotRepository {
path: cwd.to_path_buf(),
})?
.join(cwd)
};
let canonical_cwd = std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
path: cwd.to_path_buf(),
})?;
let worktree_root = crate::get_git_repo_root(&canonical_cwd)
.and_then(|root| std::fs::canonicalize(root).ok())
.unwrap_or_else(|| canonical_cwd.clone());
let mut locations = UntrustedGitLocations {
roots: Vec::new(),
common_dirs: Vec::new(),
};
record_repository_ancestry(&worktree_root, &mut locations)?;
// Canonicalization can erase a repository-controlled symlink prefix. Walk
// the requested spelling too, deliberately retaining symlink and `..`
// components so every lexical enclosing checkout remains untrusted.
let lexical_base = if lexical_cwd.is_dir() {
lexical_cwd
} else {
lexical_cwd
.parent()
.ok_or_else(|| GitReadError::NotRepository {
path: cwd.to_path_buf(),
})?
.to_path_buf()
};
record_repository_ancestry(&lexical_base, &mut locations)?;
// Callers commonly obtain their default cwd from `current_dir()`, which
// can already have erased a symlink spelling. Recover the standard logical
// process cwd only when it is absolute and resolves to both the requested
// cwd and the process cwd. Treating extra roots as untrusted cannot widen
// executable selection.
if let Some(logical_cwd) = validated_logical_process_cwd(&canonical_cwd) {
record_repository_ancestry(&logical_cwd, &mut locations)?;
}
Ok(locations)
}
fn validated_logical_process_cwd(canonical_cwd: &Path) -> Option<PathBuf> {
let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?;
if !paths_equal(&process_cwd, canonical_cwd) {
return None;
}
let logical_cwd = PathBuf::from(std::env::var_os("PWD")?);
if !logical_cwd.is_absolute() {
return None;
}
let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?;
paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd)
}
fn record_repository_ancestry(
start: &Path,
locations: &mut UntrustedGitLocations,
) -> Result<(), GitReadError> {
push_unique(&mut locations.roots, start.to_path_buf());
record_repository_marker(start, locations)?;
for ancestor in start.parent().into_iter().flat_map(Path::ancestors) {
let dot_git = ancestor.join(".git");
match std::fs::symlink_metadata(&dot_git) {
Ok(_) => {
push_unique(&mut locations.roots, ancestor.to_path_buf());
let canonical_root =
std::fs::canonicalize(ancestor).map_err(|_| GitReadError::NoTrustedGit)?;
push_unique(&mut locations.roots, canonical_root.clone());
record_repository_marker(ancestor, locations)?;
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(_) => return Err(GitReadError::NoTrustedGit),
}
}
Ok(())
}
fn record_repository_marker(
worktree_root: &Path,
locations: &mut UntrustedGitLocations,
) -> Result<(), GitReadError> {
let dot_git = worktree_root.join(".git");
let common_dir = match std::fs::symlink_metadata(&dot_git) {
Ok(_) => 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());
}
push_unique(&mut locations.common_dirs, common_dir);
Ok(())
}
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| paths_equal(existing, &path)) {
paths.push(path);
}
}
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))
}
fn search_directory_is_untrusted(directory: &Path, locations: &UntrustedGitLocations) -> bool {
#[cfg(windows)]
if windows_path_requires_fail_closed(directory)
|| windows_path_has_untrusted_canonical_ancestor(directory, locations)
{
return true;
}
path_is_untrusted(directory, locations)
}
#[cfg(windows)]
fn windows_path_requires_fail_closed(path: &Path) -> bool {
let mut components = path.components();
let supported_namespace = match components.next() {
Some(Component::Prefix(prefix)) => match prefix.kind() {
Prefix::Disk(_)
| Prefix::VerbatimDisk(_)
| Prefix::UNC(_, _)
| Prefix::VerbatimUNC(_, _) => true,
Prefix::DeviceNS(device) => windows_device_namespace_is_filesystem(device),
Prefix::Verbatim(namespace) => namespace
.to_str()
.is_some_and(|namespace| namespace.eq_ignore_ascii_case("UNC")),
},
_ => false,
};
!supported_namespace || components.any(|component| matches!(component, Component::ParentDir))
}
#[cfg(windows)]
fn windows_device_namespace_is_filesystem(device: &OsStr) -> bool {
let bytes = device.as_encoded_bytes();
bytes.eq_ignore_ascii_case(b"UNC")
|| matches!(bytes, [drive, b':'] if drive.is_ascii_alphabetic())
}
#[cfg(windows)]
fn windows_path_has_untrusted_canonical_ancestor(
path: &Path,
locations: &UntrustedGitLocations,
) -> bool {
let Ok(canonical_path) = std::fs::canonicalize(path) else {
return true;
};
if path_is_untrusted(&canonical_path, locations) {
return true;
}
for ancestor in path.ancestors().skip(1) {
let canonical_ancestor = match std::fs::canonicalize(ancestor) {
Ok(canonical_ancestor) => canonical_ancestor,
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
) =>
{
continue;
}
Err(_) => return true,
};
if path_is_untrusted(&canonical_ancestor, locations) {
return true;
}
}
false
}
fn path_is_in_worktree_for_common_dir(path: &Path, expected_common_dir: &Path) -> bool {
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,
}
}
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<PathBuf, ()> {
if dot_git.is_dir() {
return std::fs::canonicalize(dot_git).map_err(|_| ());
}
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<PathBuf, ()> {
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())
}
#[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, GitReadError> {
RepositoryAuthority::discover(cwd)
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+205 -27
View File
@@ -3,8 +3,11 @@ use std::io;
use std::path::Component;
use std::path::Path;
use crate::git_command::GitRunner;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum GitConfigScope {
Unknown,
System,
Global,
Local,
@@ -15,16 +18,30 @@ pub(crate) enum GitConfigScope {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GitConfigEntry {
pub(crate) scope: GitConfigScope,
pub(crate) origin: String,
pub(crate) origin: GitConfigOrigin,
pub(crate) key: String,
pub(crate) value: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum GitConfigOrigin {
CommandLine,
File(std::path::PathBuf),
}
#[cfg(test)]
pub(crate) fn parse_effective_config(
output: &[u8],
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
Ok(parse_config_entries(output)?
.into_iter()
.map(|entry| (entry.key.clone(), entry))
.collect())
}
pub(crate) fn parse_config_entries(output: &[u8]) -> io::Result<Vec<GitConfigEntry>> {
if output.is_empty() {
return Ok(BTreeMap::new());
return Ok(Vec::new());
}
let Some(body) = output.strip_suffix(&[0]) else {
return Err(invalid_config_output("unterminated Git config output"));
@@ -34,13 +51,10 @@ pub(crate) fn parse_effective_config(
return Err(invalid_config_output("incomplete Git config record"));
}
let mut effective = BTreeMap::new();
let mut entries = Vec::new();
for record in fields.chunks_exact(3) {
let scope = parse_scope(record[0])?;
let origin = parse_utf8_field(record[1], "config origin")?;
if origin.is_empty() {
return Err(invalid_config_output("empty Git config origin"));
}
let origin = parse_config_origin(record[1])?;
let entry = parse_utf8_field(record[2], "config key/value")?;
let Some((key, value)) = entry.split_once('\n') else {
return Err(invalid_config_output(
@@ -52,24 +66,32 @@ pub(crate) fn parse_effective_config(
}
let entry = GitConfigEntry {
scope,
origin: origin.to_string(),
origin,
key: key.to_string(),
value: value.to_string(),
};
effective.insert(key.to_string(), entry);
entries.push(entry);
}
Ok(effective)
Ok(entries)
}
/// Parse the `--show-origin` form used by Git versions that predate
/// `config --show-scope`. The record order still reflects effective config
/// precedence, which is what helper selection relies on. Scope is retained as
/// best-effort metadata only; command-line entries remain distinguishable.
#[cfg(test)]
pub(crate) fn parse_effective_config_with_origins(
output: &[u8],
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
Ok(parse_config_entries_with_origins(output)?
.into_iter()
.map(|entry| (entry.key.clone(), entry))
.collect())
}
pub(crate) fn parse_config_entries_with_origins(output: &[u8]) -> io::Result<Vec<GitConfigEntry>> {
if output.is_empty() {
return Ok(BTreeMap::new());
return Ok(Vec::new());
}
let Some(body) = output.strip_suffix(&[0]) else {
return Err(invalid_config_output("unterminated Git config output"));
@@ -79,12 +101,9 @@ pub(crate) fn parse_effective_config_with_origins(
return Err(invalid_config_output("incomplete Git config record"));
}
let mut effective = BTreeMap::new();
let mut entries = Vec::new();
for record in fields.chunks_exact(2) {
let origin = parse_utf8_field(record[0], "config origin")?;
if origin.is_empty() {
return Err(invalid_config_output("empty Git config origin"));
}
let origin = parse_config_origin(record[0])?;
let entry = parse_utf8_field(record[1], "config key/value")?;
let Some((key, value)) = entry.split_once('\n') else {
return Err(invalid_config_output(
@@ -94,24 +113,156 @@ pub(crate) fn parse_effective_config_with_origins(
if key.is_empty() {
return Err(invalid_config_output("empty Git config key"));
}
let scope = if origin == "command line:" {
let scope = if origin == GitConfigOrigin::CommandLine {
GitConfigScope::Command
} else {
// Older Git does not expose the scope. Selection depends on the
// emitted precedence order, not on this informational label.
GitConfigScope::Local
};
effective.insert(
key.to_string(),
GitConfigEntry {
scope,
origin: origin.to_string(),
key: key.to_string(),
value: value.to_string(),
},
);
entries.push(GitConfigEntry {
scope,
origin,
key: key.to_string(),
value: value.to_string(),
});
}
Ok(effective)
Ok(entries)
}
pub(crate) fn read_effective_config_entries_with_fallback(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
probe: &str,
) -> io::Result<Vec<GitConfigEntry>> {
read_config_entries_with_fallback(
git,
cwd,
git_config_args,
pattern,
probe,
/*follow_includes*/ true,
/*config_file*/ None,
)
}
pub(crate) fn read_config_entries_without_includes(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
probe: &str,
config_file: Option<&Path>,
) -> io::Result<Vec<GitConfigEntry>> {
read_config_entries_with_fallback(
git,
cwd,
git_config_args,
pattern,
probe,
/*follow_includes*/ false,
config_file,
)
}
fn read_config_entries_with_fallback(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
probe: &str,
follow_includes: bool,
config_file: Option<&Path>,
) -> io::Result<Vec<GitConfigEntry>> {
let scoped = run_effective_config_query(
git,
cwd,
git_config_args,
pattern,
/*show_scope*/ true,
follow_includes,
config_file,
)?;
if scoped
.status
.code()
.is_some_and(|code| code == 0 || code == 1)
{
return parse_config_entries(&scoped.stdout);
}
let legacy = run_effective_config_query(
git,
cwd,
git_config_args,
pattern,
/*show_scope*/ false,
follow_includes,
config_file,
)?;
if !legacy
.status
.code()
.is_some_and(|code| code == 0 || code == 1)
{
return Err(io::Error::other(format!(
"git {probe} config probe failed with status {}: {}",
legacy.status,
String::from_utf8_lossy(&legacy.stderr).trim()
)));
}
parse_config_entries_with_origins(&legacy.stdout)
}
pub(crate) fn read_effective_config_with_fallback(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
probe: &str,
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
Ok(
read_effective_config_entries_with_fallback(git, cwd, git_config_args, pattern, probe)?
.into_iter()
.map(|entry| (entry.key.clone(), entry))
.collect(),
)
}
fn run_effective_config_query(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
show_scope: bool,
follow_includes: bool,
config_file: Option<&Path>,
) -> io::Result<std::process::Output> {
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.arg("config");
if let Some(config_file) = config_file {
command.arg("--file").arg(config_file);
}
command.arg("--null");
if show_scope {
command.arg("--show-scope");
}
command.args([
"--show-origin",
if follow_includes {
"--includes"
} else {
"--no-includes"
},
"--get-regexp",
pattern,
]);
git.output(command)
}
pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
@@ -141,6 +292,7 @@ fn components_equal(left: Component<'_>, right: Component<'_>) -> bool {
fn parse_scope(scope: &[u8]) -> io::Result<GitConfigScope> {
match scope {
b"unknown" => Ok(GitConfigScope::Unknown),
b"system" => Ok(GitConfigScope::System),
b"global" => Ok(GitConfigScope::Global),
b"local" => Ok(GitConfigScope::Local),
@@ -150,6 +302,32 @@ fn parse_scope(scope: &[u8]) -> io::Result<GitConfigScope> {
}
}
fn parse_config_origin(origin: &[u8]) -> io::Result<GitConfigOrigin> {
if origin == b"command line:" {
return Ok(GitConfigOrigin::CommandLine);
}
let path = origin
.strip_prefix(b"file:")
.ok_or_else(|| invalid_config_output("unsupported Git config origin"))?;
if path.is_empty() || path.contains(&0) {
return Err(invalid_config_output("empty Git config origin"));
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(GitConfigOrigin::File(std::path::PathBuf::from(
std::ffi::OsString::from_vec(path.to_vec()),
)))
}
#[cfg(not(unix))]
{
Ok(GitConfigOrigin::File(std::path::PathBuf::from(
parse_utf8_field(path, "Git config origin path")?,
)))
}
}
fn parse_utf8_field<'a>(field: &'a [u8], name: &str) -> io::Result<&'a str> {
std::str::from_utf8(field).map_err(|_| invalid_config_output(&format!("non-UTF-8 {name}")))
}
@@ -0,0 +1,140 @@
use std::collections::BTreeSet;
use std::io;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use crate::git_command::GitRunner;
use crate::git_config::read_config_entries_without_includes;
mod include_graph;
mod path_safety;
mod primary_sources;
use include_graph::validate_include_entries;
use path_safety::normalize_absolute_path;
use path_safety::resolve_literal_path;
use primary_sources::default_system_config_source_candidates;
use primary_sources::is_disabled_primary_config_path;
use primary_sources::legacy_primary_config_source_candidates;
use primary_sources::selected_git_home_config_candidates;
use primary_sources::selected_git_prefix_system_candidate;
const INCLUDE_CONFIG_PATTERN: &str = r"^include(\.path|if\..*\.path)$";
const MAX_CONFIG_INCLUDE_DEPTH: usize = 10;
const MAX_CONFIG_INCLUDE_FILES: usize = 1024;
/// Reject configuration that an untrusted worktree writer can change between
/// a policy probe and the Git command it guards.
pub(crate) fn ensure_no_worktree_config_sources(
git: &GitRunner,
git_root: &Path,
git_config_args: &[String],
) -> io::Result<()> {
let git_root = normalize_absolute_path(std::fs::canonicalize(git_root)?)?;
// Environment, HOME, and XDG paths are attacker-controlled byte strings.
// Classify their exact OsString spelling before `git var` can open or
// newline-delimit one of them.
for (description, candidate) in legacy_primary_config_source_candidates()? {
if !is_disabled_primary_config_path(&candidate) {
reject_source(git, &git_root, &candidate, description)?;
}
}
for candidate in selected_git_home_config_candidates(git, &git_root)? {
reject_source(git, &git_root, &candidate, "selected Git HOME config")?;
}
if let Some(candidate) = selected_git_prefix_system_candidate(git, &git_root)? {
reject_source(
git,
&git_root,
&candidate,
"selected Git prefix system config",
)?;
}
let entries = read_config_entries_without_includes(
git,
&git_root,
git_config_args,
INCLUDE_CONFIG_PATTERN,
"include",
/*config_file*/ None,
)?;
let mut pending = Vec::new();
validate_include_entries(git, &git_root, entries, /*depth*/ 1, &mut pending)?;
let mut visited = BTreeSet::new();
while let Some((config_path, depth)) = pending.pop() {
if depth > MAX_CONFIG_INCLUDE_DEPTH {
return Err(path_safety::invalid_config_source(
"Git config include depth exceeded",
));
}
match std::fs::canonicalize(&config_path) {
Ok(_) => {}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
continue;
}
Err(error) => return Err(error),
}
// The same file reached through two spellings can resolve a relative
// child include differently. Deduplicate only the exact source
// spelling, not its canonical target.
if !visited.insert(config_path.clone()) {
continue;
}
if visited.len() > MAX_CONFIG_INCLUDE_FILES {
return Err(path_safety::invalid_config_source(
"too many Git config include files",
));
}
let entries = read_config_entries_without_includes(
git,
&git_root,
&[],
INCLUDE_CONFIG_PATTERN,
"include",
Some(&config_path),
)?;
validate_include_entries(git, &git_root, entries, depth + 1, &mut pending)?;
}
// `git var` loads repository config before reporting modern default-system
// paths. Delay that supplemental probe until the complete no-includes
// source graph above has been classified, so it cannot be tricked into
// opening an untrusted include or FIFO first.
for (description, candidate) in default_system_config_source_candidates(git, &git_root)? {
if !is_disabled_primary_config_path(&candidate) {
reject_source(git, &git_root, &candidate, description)?;
}
}
Ok(())
}
fn reject_source(
git: &GitRunner,
cwd: &Path,
candidate: &Path,
description: &str,
) -> io::Result<()> {
let absolute = resolve_literal_path(candidate, cwd);
git.ensure_config_source_is_not_worktree_controlled(&absolute, description)
}
#[cfg(test)]
use crate::git_config::GitConfigEntry;
#[cfg(test)]
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(test)]
use include_graph::expand_git_config_path;
#[cfg(test)]
use include_graph::resolve_include_path;
#[cfg(test)]
use path_safety::windows_config_path_is_ambiguous;
#[cfg(test)]
#[path = "git_config_sources_tests.rs"]
mod tests;
@@ -0,0 +1,120 @@
use std::io;
use std::path::Path;
use std::path::PathBuf;
use super::path_safety::CONFIG_PATH_KEY;
use super::path_safety::invalid_config_source;
use super::path_safety::reject_raw_ambiguous_windows_config_path;
use super::path_safety::resolve_literal_path;
use super::reject_source;
use crate::git_command::GitRunner;
use crate::git_config::GitConfigEntry;
use crate::git_config::GitConfigOrigin;
pub(super) fn validate_include_entries(
git: &GitRunner,
git_root: &Path,
entries: Vec<GitConfigEntry>,
depth: usize,
pending: &mut Vec<(PathBuf, usize)>,
) -> io::Result<()> {
for entry in entries {
if !is_include_path(&entry.key) {
return Err(invalid_config_source("unexpected Git config include key"));
}
if let Some(origin) = config_file_origin(&entry, git_root)? {
reject_source(git, git_root, &origin, "Git config origin")?;
}
let include = resolve_include_path(git, git_root, &entry)?;
reject_source(git, git_root, &include, "Git config include")?;
pending.push((include, depth));
}
Ok(())
}
fn config_file_origin(entry: &GitConfigEntry, cwd: &Path) -> io::Result<Option<PathBuf>> {
match &entry.origin {
GitConfigOrigin::CommandLine => Ok(None),
GitConfigOrigin::File(path) => Ok(Some(resolve_literal_path(path, cwd))),
}
}
fn is_include_path(key: &str) -> bool {
key == "include.path" || key.starts_with("includeif.") && key.ends_with(".path")
}
pub(super) fn resolve_include_path(
git: &GitRunner,
cwd: &Path,
entry: &GitConfigEntry,
) -> io::Result<PathBuf> {
let raw = entry.value.as_str();
if raw.is_empty() {
return Err(invalid_config_source("empty Git config include path"));
}
// Unlike generic `git config --path`, include.path treats `:(...)` as
// literal path text. Bypass the generic path expander for those spellings
// so the validated path exactly matches Git's include loader.
let expanded = if raw.starts_with(":(") {
PathBuf::from(raw)
} else {
expand_git_config_path(git, cwd, raw)?
};
reject_raw_ambiguous_windows_config_path(
expanded
.to_str()
.ok_or_else(|| invalid_config_source("non-UTF-8 Git include path"))?,
)?;
let base = match config_file_origin(entry, cwd)? {
Some(origin) => origin
.parent()
.ok_or_else(|| invalid_config_source("Git config origin has no parent"))?
.to_path_buf(),
None if expanded.is_absolute() => cwd.to_path_buf(),
None => {
return Err(invalid_config_source(
"relative Git config include has no file origin",
));
}
};
Ok(resolve_literal_path(expanded, &base))
}
pub(super) fn expand_git_config_path(
git: &GitRunner,
cwd: &Path,
raw: &str,
) -> io::Result<PathBuf> {
let mut command = git.command_for_cwd(cwd)?;
command
.arg("-c")
.arg(format!("{CONFIG_PATH_KEY}={raw}"))
.args([
"config",
"--null",
"--no-includes",
"--path",
"--get",
CONFIG_PATH_KEY,
]);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
"git include path expansion failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let value = output
.stdout
.strip_suffix(&[0])
.ok_or_else(|| invalid_config_source("unterminated Git include path expansion"))?;
if value.is_empty() || value.contains(&0) {
return Err(invalid_config_source(
"ambiguous Git include path expansion",
));
}
let value = std::str::from_utf8(value)
.map_err(|_| invalid_config_source("non-UTF-8 Git include path expansion"))?;
Ok(PathBuf::from(value))
}
@@ -0,0 +1,56 @@
use std::io;
use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
pub(super) const CONFIG_PATH_KEY: &str = "codex.config-source.path";
#[cfg(unix)]
pub(super) fn git_var_path_from_bytes(path: &[u8]) -> io::Result<PathBuf> {
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(std::ffi::OsString::from_vec(path.to_vec())))
}
#[cfg(not(unix))]
pub(super) fn git_var_path_from_bytes(path: &[u8]) -> io::Result<PathBuf> {
Ok(PathBuf::from(std::str::from_utf8(path).map_err(|_| {
invalid_config_source("non-UTF-8 Git config source path")
})?))
}
pub(super) fn resolve_literal_path(path: impl AsRef<Path>, base: &Path) -> PathBuf {
let path = path.as_ref();
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
pub(super) fn normalize_absolute_path(path: impl AsRef<Path>) -> io::Result<PathBuf> {
Ok(AbsolutePathBuf::from_absolute_path(path)?.into_path_buf())
}
#[cfg(windows)]
pub(super) fn reject_raw_ambiguous_windows_config_path(path: &str) -> io::Result<()> {
if windows_config_path_is_ambiguous(path) {
return Err(invalid_config_source("ambiguous Windows Git config path"));
}
Ok(())
}
#[cfg(not(windows))]
pub(super) fn reject_raw_ambiguous_windows_config_path(_path: &str) -> io::Result<()> {
Ok(())
}
#[cfg(any(windows, test))]
pub(super) fn windows_config_path_is_ambiguous(path: &str) -> bool {
crate::path_authority::windows_path_is_ambiguous(path)
}
pub(super) fn invalid_config_source(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -0,0 +1,277 @@
use std::io;
use std::path::Path;
use std::path::PathBuf;
use super::path_safety::CONFIG_PATH_KEY;
use super::path_safety::git_var_path_from_bytes;
use super::path_safety::invalid_config_source;
use crate::git_command::GitRunner;
pub(super) fn default_system_config_source_candidates(
git: &GitRunner,
cwd: &Path,
) -> io::Result<Vec<(&'static str, PathBuf)>> {
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
return Ok(Vec::new());
}
// `GIT_CONFIG_SYSTEM` was added to `git var` in Git 2.42. The PSEC-4394
// boundary treats the selected Git installation and its non-environment
// compile-time system config as host-owned trusted inputs. For older Git,
// the exact custom ETC_GITCONFIG path is therefore a documented residual;
// the derivable prefix/ProgramData paths are still checked separately and
// the no-includes graph validates every directive the system file exposes.
let Some(paths) = git_var_config_paths(git, cwd, "GIT_CONFIG_SYSTEM")? else {
return Ok(Vec::new());
};
Ok(paths
.into_iter()
.map(|path| ("GIT_CONFIG_SYSTEM", path))
.collect())
}
pub(super) fn selected_git_home_config_candidates(
git: &GitRunner,
cwd: &Path,
) -> io::Result<Vec<PathBuf>> {
if std::env::var_os("GIT_CONFIG_GLOBAL").is_some() {
return Ok(Vec::new());
}
#[cfg(not(windows))]
if std::env::var_os("HOME").is_none() {
return Ok(Vec::new());
}
let dot_gitconfig = selected_git_path_candidate(git, cwd, "~/.gitconfig")?;
let mut candidates = vec![dot_gitconfig.clone()];
if std::env::var_os("XDG_CONFIG_HOME").is_none_or(|path| path.is_empty()) {
let home = dot_gitconfig
.parent()
.ok_or_else(|| invalid_config_source("selected Git HOME has no parent"))?;
candidates.push(home.join(".config/git/config"));
}
Ok(candidates)
}
pub(super) fn selected_git_prefix_system_candidate(
git: &GitRunner,
cwd: &Path,
) -> io::Result<Option<PathBuf>> {
if git_env_bool("GIT_CONFIG_NOSYSTEM")? || std::env::var_os("GIT_CONFIG_SYSTEM").is_some() {
return Ok(None);
}
selected_git_path_candidate(git, cwd, "%(prefix)/etc/gitconfig").map(Some)
}
fn selected_git_path_candidate(git: &GitRunner, cwd: &Path, raw: &str) -> io::Result<PathBuf> {
let tempdir = tempfile::tempdir()?;
let nonexistent_git_dir = tempdir.path().join("nonexistent-git-dir");
let disabled_config = if cfg!(windows) { "NUL" } else { "/dev/null" };
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_CONFIG_GLOBAL", disabled_config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.env_remove("GIT_CONFIG_PARAMETERS")
.arg("--git-dir")
.arg(&nonexistent_git_dir)
.arg("-c")
.arg(format!("{CONFIG_PATH_KEY}={raw}"))
.args([
"config",
"--null",
"--no-includes",
"--path",
"--get",
CONFIG_PATH_KEY,
]);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
"isolated selected Git path expansion failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let value = output
.stdout
.strip_suffix(&[0])
.ok_or_else(|| invalid_config_source("unterminated selected Git path"))?;
if value.is_empty() || value.contains(&0) {
return Err(invalid_config_source("ambiguous selected Git path"));
}
git_var_path_from_bytes(value)
}
fn git_var_config_paths(
git: &GitRunner,
cwd: &Path,
variable: &str,
) -> io::Result<Option<Vec<PathBuf>>> {
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(["var", variable]);
let output = git.output(command)?;
parse_git_var_config_paths_result(
output.status.code(),
&output.stdout,
&output.stderr,
variable,
)
}
pub(super) fn parse_git_var_config_paths_result(
status_code: Option<i32>,
stdout: &[u8],
stderr: &[u8],
variable: &str,
) -> io::Result<Option<Vec<PathBuf>>> {
match status_code {
Some(0) => parse_git_var_paths(stdout).map(Some),
Some(1) if stdout.is_empty() && stderr.is_empty() => Ok(Some(Vec::new())),
// These variables were added in Git 2.42. Older Git reports usage
// status 129; fall back to its documented environment/home sources.
Some(129) => Ok(None),
_ => Err(io::Error::other(format!(
"git {variable} source probe failed with status {status_code:?}: {}",
String::from_utf8_lossy(stderr).trim()
))),
}
}
fn parse_git_var_paths(output: &[u8]) -> io::Result<Vec<PathBuf>> {
output
.split(|byte| *byte == b'\n')
.filter(|path| !path.is_empty())
.map(|path| {
let path = path.strip_suffix(b"\r").unwrap_or(path);
git_var_path_from_bytes(path)
})
.collect()
}
pub(super) fn legacy_primary_config_source_candidates() -> io::Result<Vec<(&'static str, PathBuf)>>
{
let mut candidates = Vec::new();
match std::env::var_os("GIT_CONFIG_GLOBAL") {
Some(path) if !path.is_empty() => {
candidates.push(("GIT_CONFIG_GLOBAL", PathBuf::from(path)));
}
Some(_) => {}
None => {
let homes = git_home_directories();
match std::env::var_os("XDG_CONFIG_HOME") {
Some(xdg) if !xdg.is_empty() => candidates.push((
"XDG_CONFIG_HOME Git config",
PathBuf::from(xdg).join("git/config"),
)),
_ => {
#[cfg(windows)]
if let Some(app_data) = std::env::var_os("APPDATA")
&& !app_data.is_empty()
{
candidates.push((
"APPDATA Git config",
PathBuf::from(app_data).join("Git/config"),
));
}
for home in &homes {
candidates.push((
"HOME XDG Git config",
home_config_path(home, ".config/git/config"),
));
}
}
}
for home in &homes {
candidates.push(("HOME Git config", home_config_path(home, ".gitconfig")));
}
}
}
if !git_env_bool("GIT_CONFIG_NOSYSTEM")? {
match std::env::var_os("GIT_CONFIG_SYSTEM") {
Some(path) if !path.is_empty() => {
candidates.push(("GIT_CONFIG_SYSTEM", PathBuf::from(path)));
}
Some(_) => {}
None => {
#[cfg(windows)]
if let Some(program_data) = std::env::var_os("PROGRAMDATA")
&& !program_data.is_empty()
{
candidates.push((
"PROGRAMDATA Git config",
PathBuf::from(program_data).join("Git/config"),
));
}
}
}
}
Ok(candidates)
}
fn git_home_directories() -> Vec<std::ffi::OsString> {
if let Some(home) = std::env::var_os("HOME") {
return vec![home];
}
#[cfg(windows)]
{
let mut homes = Vec::new();
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut home = drive;
home.push(path);
homes.push(home);
}
if let Some(profile) = std::env::var_os("USERPROFILE")
&& !homes.iter().any(|home| *home == profile)
{
homes.push(profile);
}
return homes;
}
#[cfg(not(windows))]
Vec::new()
}
fn home_config_path(home: &std::ffi::OsStr, suffix: &str) -> PathBuf {
if home.is_empty() {
PathBuf::from(std::path::MAIN_SEPARATOR.to_string()).join(suffix)
} else {
PathBuf::from(home).join(suffix)
}
}
fn git_env_bool(name: &str) -> io::Result<bool> {
let Some(value) = std::env::var_os(name) else {
return Ok(false);
};
let value = value
.to_str()
.ok_or_else(|| invalid_config_source("non-UTF-8 Git boolean environment value"))?;
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"" | "0" | "false" | "no" | "off" => Ok(false),
value => value
.parse::<i64>()
.ok()
// Supported Git releases disagree on whether INT_MIN is a valid
// numeric boolean. Accept only their shared symmetric range so a
// value approved here cannot make the selected Git fail later.
.filter(|value| (-i64::from(i32::MAX)..=i64::from(i32::MAX)).contains(value))
.map(|value| value != 0)
.ok_or_else(|| invalid_config_source("invalid Git boolean environment value")),
}
}
#[cfg(windows)]
pub(super) fn is_disabled_primary_config_path(path: &Path) -> bool {
path.as_os_str()
.to_str()
.is_some_and(|path| path.eq_ignore_ascii_case("NUL"))
}
#[cfg(not(windows))]
pub(super) fn is_disabled_primary_config_path(_path: &Path) -> bool {
false
}
File diff suppressed because it is too large Load Diff
+47 -4
View File
@@ -12,7 +12,7 @@ command\0command line:\0merge.Name.driver\nhelper %A %B\0";
entries.get("filter.demo.clean"),
Some(&GitConfigEntry {
scope: GitConfigScope::Local,
origin: "file:/repo/.git/config".to_string(),
origin: GitConfigOrigin::File("/repo/.git/config".into()),
key: "filter.demo.clean".to_string(),
value: String::new(),
})
@@ -21,13 +21,56 @@ command\0command line:\0merge.Name.driver\nhelper %A %B\0";
entries.get("merge.Name.driver"),
Some(&GitConfigEntry {
scope: GitConfigScope::Command,
origin: "command line:".to_string(),
origin: GitConfigOrigin::CommandLine,
key: "merge.Name.driver".to_string(),
value: "helper %A %B".to_string(),
})
);
}
#[test]
fn entry_parsers_preserve_duplicate_include_directives_in_order() {
let scoped = b"local\0file:.git/config\0include.path\n../unsafe.gitconfig\0\
local\0file:.git/config\0include.path\n/absolute/external.gitconfig\0";
let legacy = b"file:.git/config\0include.path\n../unsafe.gitconfig\0\
file:.git/config\0include.path\n/absolute/external.gitconfig\0";
let expected_scoped = vec![
GitConfigEntry {
scope: GitConfigScope::Local,
origin: GitConfigOrigin::File(".git/config".into()),
key: "include.path".to_string(),
value: "../unsafe.gitconfig".to_string(),
},
GitConfigEntry {
scope: GitConfigScope::Local,
origin: GitConfigOrigin::File(".git/config".into()),
key: "include.path".to_string(),
value: "/absolute/external.gitconfig".to_string(),
},
];
assert_eq!(
parse_config_entries(scoped).expect("scoped entries"),
expected_scoped
);
assert_eq!(
parse_config_entries_with_origins(legacy).expect("legacy entries"),
expected_scoped
);
}
#[cfg(unix)]
#[test]
fn config_origin_parser_preserves_non_utf8_unix_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let entries = parse_config_entries(b"local\0file:/tmp/config-\xff\0include.path\n/tmp/safe\0")
.expect("non-UTF-8 config origin");
let GitConfigOrigin::File(path) = &entries[0].origin else {
panic!("expected file origin");
};
assert_eq!(path.as_os_str().as_bytes(), b"/tmp/config-\xff");
}
#[test]
fn rejects_malformed_config_records() {
for output in [
@@ -51,7 +94,7 @@ command line:\0merge.Name.driver\nhelper %A %B\0";
entries.get("filter.demo.clean"),
Some(&GitConfigEntry {
scope: GitConfigScope::Local,
origin: "file:/repo/.git/config".to_string(),
origin: GitConfigOrigin::File("/repo/.git/config".into()),
key: "filter.demo.clean".to_string(),
value: String::new(),
})
@@ -60,7 +103,7 @@ command line:\0merge.Name.driver\nhelper %A %B\0";
entries.get("merge.Name.driver"),
Some(&GitConfigEntry {
scope: GitConfigScope::Command,
origin: "command line:".to_string(),
origin: GitConfigOrigin::CommandLine,
key: "merge.Name.driver".to_string(),
value: "helper %A %B".to_string(),
})
+292
View File
@@ -0,0 +1,292 @@
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,
#[cfg(any(unix, test))]
pub(crate) argv0: PathBuf,
pub(crate) safe_path: OsString,
}
pub(crate) fn select_git_executable(
authority: &RepositoryAuthority,
search_path: &OsStr,
) -> Result<SelectedGitExecutable, GitReadError> {
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());
}
#[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,
})
}
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::<Vec<_>>();
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<PathBuf>, 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, /*length*/ 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, /*length*/ 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], dos[61], dos[62], dos[63]]) 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<Vec<u8>> {
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)
}
+4
View File
@@ -9,11 +9,15 @@ mod exact_staging;
mod fsmonitor;
mod git_command;
mod git_config;
mod git_config_sources;
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;
+1 -2
View File
@@ -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);
+40 -15
View File
@@ -10,16 +10,23 @@ use crate::apply::write_temp_patch;
use crate::exact_staging::update_index_exact_paths;
use crate::git_command::GitRunner;
use crate::git_config::path_is_within;
use crate::git_config_sources::ensure_no_worktree_config_sources;
/// Extract paths with Git from a cwd whose config sources have already been
/// authorized for `git_config_args`.
pub(crate) fn extract_effective_paths_from_patch(
git: &GitRunner,
authorized_cwd: &Path,
patch_path: &Path,
revert: bool,
git_config_args: &[String],
) -> io::Result<Vec<String>> {
let forward_paths = git_apply_numstat_paths(git, patch_path, revert)?;
let forward_paths =
git_apply_numstat_paths(git, authorized_cwd, patch_path, revert, git_config_args)?;
// `git apply --numstat` reports only the destination of a rename. Parse the
// opposite orientation too so both endpoints are included in the result.
let reverse_paths = git_apply_numstat_paths(git, patch_path, !revert)?;
let reverse_paths =
git_apply_numstat_paths(git, authorized_cwd, patch_path, !revert, git_config_args)?;
if forward_paths.len() != reverse_paths.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -45,33 +52,43 @@ pub(crate) fn extract_effective_paths_from_patch(
/// Security-sensitive callers must use the fallible internal extractor so an
/// invalid or ambiguous patch is rejected instead of becoming an empty list.
pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
extract_paths_from_patch_from_cwd(diff_text, &cwd)
}
fn extract_paths_from_patch_from_cwd(diff_text: &str, cwd: &Path) -> Vec<String> {
let Ok((tmpdir, patch_path)) = write_temp_patch(diff_text) else {
return Vec::new();
};
let paths = std::env::current_dir()
.ok()
.and_then(|cwd| GitRunner::for_cwd(&cwd).ok())
.and_then(|git| {
extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ false).ok()
})
.unwrap_or_default();
let paths = (|| -> io::Result<Vec<String>> {
let git = GitRunner::for_cwd_io(cwd)?;
let git_root = crate::get_git_repo_root(cwd)
.ok_or_else(|| io::Error::other("not a Git repository"))?;
let git_root = std::fs::canonicalize(git_root)?;
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
extract_effective_paths_from_patch(&git, &git_root, &patch_path, /*revert*/ false, &[])
})()
.unwrap_or_default();
drop(tmpdir);
paths
}
fn git_apply_numstat_paths(
git: &GitRunner,
authorized_cwd: &Path,
patch_path: &Path,
revert: bool,
git_config_args: &[String],
) -> io::Result<Vec<String>> {
let mut cmd = git.command();
let mut cmd = git.command_for_cwd(authorized_cwd)?;
cmd.args(git_config_args);
cmd.args(["apply", "--numstat", "-z"]);
if revert {
cmd.arg("-R");
}
cmd.arg("--")
.arg(patch_path)
.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(
@@ -228,10 +245,18 @@ fn invalid_windows_patch_component(component: &str) -> bool {
/// Stage only the files that actually exist on disk for the given diff.
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
let git = GitRunner::for_cwd_io(git_root)?;
let git_config_args = safe_git_config_parts();
ensure_no_worktree_config_sources(&git, git_root, &git_config_args)?;
let (tmpdir, patch_path) = write_temp_patch(diff)?;
let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?;
let paths = extract_effective_paths_from_patch(
&git,
git_root,
&patch_path,
/*revert*/ true,
&git_config_args,
)?;
let _guard = tmpdir;
stage_effective_paths(&git, git_root, &paths, &safe_git_config_parts())
stage_effective_paths(&git, git_root, &paths, &git_config_args)
}
pub(crate) fn stage_effective_paths(
+31 -85
View File
@@ -64,13 +64,23 @@ fn read_file_normalized(path: &Path) -> String {
fn effective_paths(diff: &str, revert: bool) -> io::Result<Vec<String>> {
let (tmpdir, patch_path) = write_temp_patch(diff)?;
let cwd = std::env::current_dir()?;
let git = GitRunner::for_cwd_io(&cwd)?;
let paths = extract_effective_paths_from_patch(&git, &patch_path, revert)?;
let repo = init_repo();
let cwd = repo.path();
let git = GitRunner::for_cwd_io(cwd)?;
let git_root =
crate::get_git_repo_root(cwd).ok_or_else(|| io::Error::other("not a Git repository"))?;
let git_root = std::fs::canonicalize(git_root)?;
ensure_no_worktree_config_sources(&git, &git_root, &[])?;
let paths = extract_effective_paths_from_patch(&git, &git_root, &patch_path, revert, &[])?;
drop(tmpdir);
Ok(paths)
}
fn best_effort_paths(diff: &str) -> Vec<String> {
let repo = init_repo();
extract_paths_from_patch_from_cwd(diff, repo.path())
}
#[test]
fn effective_paths_cover_supported_patch_headers() {
let cases = [
@@ -129,7 +139,7 @@ fn effective_paths_cover_supported_patch_headers() {
"{name}, revert={revert}"
);
}
assert_eq!(extract_paths_from_patch(diff), expected, "{name}");
assert_eq!(best_effort_paths(diff), expected, "{name}");
}
let nul_rename_paths = parse_numstat_paths(b"0\t0\t\0old name.txt\0new name.txt\0")
@@ -152,7 +162,7 @@ fn effective_paths_follow_git_for_mismatched_headers() {
effective_paths(mismatch, /*revert*/ true).unwrap(),
expected
);
assert_eq!(extract_paths_from_patch(mismatch), expected);
assert_eq!(best_effort_paths(mismatch), expected);
}
#[test]
@@ -1480,95 +1490,27 @@ fn exact_staging_preserves_sparse_checkout_policy() {
assert!(!index_entry(root, "inside/untracked.txt").is_empty());
}
#[cfg(unix)]
#[test]
fn sparse_rule_probe_failure_is_closed_only_for_sparse_repositories() {
use std::os::unix::fs::PermissionsExt;
const TEST_NAME: &str =
"patch_paths::tests::sparse_rule_probe_failure_is_closed_only_for_sparse_repositories";
if let Some(mode) = std::env::var_os("CODEX_GIT_UTILS_FAKE_SPARSE_MODE") {
let root = PathBuf::from(
std::env::var_os("CODEX_GIT_UTILS_TARGET_REPO").expect("target repository"),
);
let result = exact_stage(&root, &["file.txt"], &["file.txt"]);
if mode == "sparse" {
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("fake unsupported check-rules"));
assert!(index_entry(&root, "file.txt").is_empty());
} else if mode == "legacy" {
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("built-in Git command"));
assert!(index_entry(&root, "file.txt").is_empty());
assert!(
!PathBuf::from(
std::env::var_os("CODEX_GIT_UTILS_SPARSE_HELPER_MARKER")
.expect("helper marker"),
)
.exists(),
"legacy Git must not dispatch a PATH-resolved sparse helper"
);
} else {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(!index_entry(&root, "file.txt").is_empty());
}
return;
}
let wrapper_dir = tempfile::tempdir().expect("wrapper directory");
let wrapper = wrapper_dir.path().join("git");
std::fs::write(
&wrapper,
"#!/bin/sh\nprevious=\nfor argument in \"$@\"; do\n if [ \"$CODEX_GIT_UTILS_FAKE_SPARSE_MODE\" = legacy ] && [ \"$argument\" = --list-cmds=builtins ]; then\n echo add\n echo config\n exit 0\n fi\n if [ \"$previous\" = sparse-checkout ] && [ \"$argument\" = check-rules ]; then\n echo fake unsupported check-rules >&2\n exit 129\n fi\n previous=$argument\ndone\nexec \"$CODEX_GIT_UTILS_REAL_GIT\" \"$@\"\n",
)
.expect("write Git wrapper");
let mut permissions = std::fs::metadata(&wrapper).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&wrapper, permissions).expect("make wrapper executable");
let sparse_helper = wrapper_dir.path().join("git-sparse-checkout");
std::fs::write(
&sparse_helper,
"#!/bin/sh\n: > \"$CODEX_GIT_UTILS_SPARSE_HELPER_MARKER\"\nexit 0\n",
)
.expect("write malicious sparse helper");
let mut permissions = std::fs::metadata(&sparse_helper).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&sparse_helper, permissions).expect("make sparse helper executable");
let real_git = [
"/usr/bin/git",
"/opt/homebrew/bin/git",
"/usr/local/bin/git",
]
.into_iter()
.map(PathBuf::from)
.find(|path| path.is_file())
.expect("native Git outside the test wrapper");
let wrapper_path = std::env::join_paths([wrapper_dir.path()]).expect("wrapper PATH");
let helper_marker = wrapper_dir.path().join("sparse-helper-ran");
for mode in ["ordinary", "sparse", "legacy"] {
for sparse in [false, true] {
let repo = init_repo();
let root = repo.path();
if matches!(mode, "sparse" | "legacy") {
if sparse {
assert_eq!(
run(root, &["git", "config", "core.sparseCheckout", "true"]).0,
0
);
}
std::fs::write(root.join("file.txt"), "new\n").expect("write staging file");
run_isolated_test(
TEST_NAME,
&[
("CODEX_GIT_UTILS_FAKE_SPARSE_MODE", OsStr::new(mode)),
("CODEX_GIT_UTILS_TARGET_REPO", root.as_os_str()),
("CODEX_GIT_UTILS_REAL_GIT", real_git.as_os_str()),
(
"CODEX_GIT_UTILS_SPARSE_HELPER_MARKER",
helper_marker.as_os_str(),
),
("PATH", wrapper_path.as_os_str()),
],
);
let result = exact_stage(root, &["file.txt"], &["file.txt"]);
if sparse {
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("sparse-checkout"), "{result:?}");
assert!(index_entry(root, "file.txt").is_empty());
} else {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(!index_entry(root, "file.txt").is_empty());
}
}
}
@@ -2052,7 +1994,11 @@ fn staging_allows_optional_smudge_only_but_refuses_required_or_malformed_smudge(
assert_eq!(staged, "selected.txt\n");
} else {
let error = result.expect_err("refuse required or malformed smudge-only target");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
assert_eq!(
error.kind(),
io::ErrorKind::Unsupported,
"required={required}: {error}"
);
assert_eq!(git_index_bytes(root), before);
}
}
+478
View File
@@ -0,0 +1,478 @@
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;
#[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;
#[cfg(any(windows, test))]
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<PathBuf>,
metadata_dirs: Vec<PathBuf>,
worktree_identities: Vec<Handle>,
metadata_identities: Vec<Handle>,
}
pub(crate) struct RouteInspection {
pub(crate) crosses_worktree: bool,
pub(crate) touches_worktree: bool,
pub(crate) crosses_metadata: bool,
pub(crate) observed_paths: Vec<PathBuf>,
}
impl RepositoryRouteBoundaries {
pub(crate) fn inspect_route(&self, route: &Path) -> io::Result<RouteInspection> {
inspect_route(route, self)
}
pub(crate) fn contains_known_boundary(&self, path: &Path) -> io::Result<bool> {
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<RepositoryRouteBoundaries> {
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<PathBuf>, 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<bool> {
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<RouteInspection> {
#[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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
// 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<bool> {
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<CandidateBoundary> {
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<Vec<Handle>> {
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<Option<PathBoundary>> {
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)
}
@@ -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<bool> {
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<bool> {
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<bool> {
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<bool> {
#[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<bool> {
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<bool> {
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)))
}
@@ -0,0 +1,247 @@
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<PathBuf>,
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<SymlinkRouteObservation>,
}
impl RouteObservationSnapshot {
pub(super) fn observed_paths(&self) -> Vec<PathBuf> {
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<RouteObservationSnapshot> {
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::<io::Result<Vec<_>>>()?;
Ok(RouteObservationSnapshot { raw, symlink_hops })
}
fn observe_raw_route(route: &Path) -> io::Result<RawRouteObservation> {
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<PathBuf>, 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<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn symlink_route_hops(path: &Path) -> io::Result<Vec<SymlinkRouteHop>> {
let mut hops = Vec::new();
let mut seen = BTreeSet::new();
collect_symlink_route_hops(path, /*depth*/ 0, &mut seen, &mut hops)?;
Ok(hops)
}
fn collect_symlink_route_hops(
path: &Path,
depth: usize,
seen: &mut BTreeSet<PathBuf>,
hops: &mut Vec<SymlinkRouteHop>,
) -> 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 read_link_if_symlink(ancestor) {
Ok(Some(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)?;
}
Ok(None) => {}
Err(error) => return Err(error),
},
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) => {}
Err(error) => return Err(error),
}
}
Ok(())
}
fn read_link_if_symlink(path: &Path) -> io::Result<Option<PathBuf>> {
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<PathBuf> {
project_path(path, /*symlink_depth*/ 0)
}
fn project_path(path: &Path, symlink_depth: usize) -> io::Result<PathBuf> {
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<Path>, 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)
}
#[cfg(test)]
#[path = "route_walker_tests.rs"]
mod tests;
@@ -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);
}
@@ -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()
})
}
@@ -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<RepositoryMetadataRoute>,
}
#[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<Vec<u8>> {
#[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<usize> = 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<PathBuf> {
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<ResolvedRepositoryMetadata> {
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::<Vec<_>>();
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<Option<PathBuf>> {
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<Option<PathBuf>> {
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<Vec<RegisteredWorktree>, 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<bool> {
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<bool> {
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<Vec<u8>> {
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<PathBuf> {
#[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;
@@ -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<PathBuf>,
worktree_roots: Vec<PathBuf>,
common_dirs: Vec<PathBuf>,
unproven_primary_common_dir: Option<PathBuf>,
metadata_snapshots: Vec<ResolvedRepositoryMetadata>,
registered_worktrees: Vec<RegisteredWorktree>,
active_metadata: Option<ResolvedRepositoryMetadata>,
route_boundaries: RepositoryRouteBoundaries,
}
impl RepositoryAuthority {
pub(crate) fn discover(cwd: &Path) -> Result<Self, GitReadError> {
let lexical_cwd = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()
.map_err(|_| GitReadError::NotRepository {
path: cwd.to_path_buf(),
})?
.join(cwd)
};
let canonical_cwd =
std::fs::canonicalize(cwd).map_err(|_| GitReadError::NotRepository {
path: cwd.to_path_buf(),
})?;
let worktree_root = crate::get_git_repo_root(&canonical_cwd)
.and_then(|root| std::fs::canonicalize(root).ok())
.unwrap_or_else(|| canonical_cwd.clone());
let mut 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<PathBuf>,
worktree_roots: Vec<PathBuf>,
common_dirs: Vec<PathBuf>,
) -> Result<Self, GitReadError> {
let route_boundaries = repository_route_boundaries(&worktree_roots, &common_dirs)
.map_err(|error| invalid_metadata(Path::new("<test>"), 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(all(test, unix))]
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<PathBuf> = 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<bool, GitReadError> {
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, &registration.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(&registration.root) {
push_unique(&mut self.roots, canonical_root.clone());
push_unique(&mut self.worktree_roots, canonical_root);
}
self.validate_registered_worktree_backlink(&registration)?;
if !already_known {
self.record_repository_ancestry(&registration.root)?;
}
}
Ok(())
}
fn validate_registered_worktree_backlink(
&self,
registration: &RegisteredWorktree,
) -> Result<(), GitReadError> {
match std::fs::metadata(&registration.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(&registration.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, &registration.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(())
}
}
@@ -0,0 +1,180 @@
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<PathBuf> {
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(crate) fn ensure_config_source_is_not_worktree_controlled(
&self,
path: &Path,
description: &str,
) -> io::Result<()> {
if !path.is_absolute() {
return Err(worktree_controlled_config_source(path, description));
}
let inspection = self.route_boundaries.inspect_route(path)?;
if inspection.crosses_worktree {
return Err(worktree_controlled_config_source(path, description));
}
for observed in inspection.observed_paths {
if self.route_boundaries.contains_known_boundary(&observed)? {
continue;
}
if self.has_related_repository_ancestor(&observed)? {
return Err(worktree_controlled_config_source(path, description));
}
}
Ok(())
}
pub(super) fn path_is_untrusted_for_executable_result(&self, path: &Path) -> io::Result<bool> {
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<bool> {
let mut ancestors = path.ancestors().map(Path::to_path_buf).collect::<Vec<_>>();
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(&registration.marker_route)
.map_err(|error| invalid_metadata(&registration.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(())
}
}
fn worktree_controlled_config_source(path: &Path, description: &str) -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"refusing to use worktree-controlled {description}: {}",
path.display()
),
)
}
@@ -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<Vec<PathBuf>> {
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<PathBuf> {
let process_cwd = std::fs::canonicalize(std::env::current_dir().ok()?).ok()?;
if !paths_equal(&process_cwd, canonical_cwd) {
return None;
}
let logical_cwd = PathBuf::from(std::env::var_os("PWD")?);
if !logical_cwd.is_absolute() {
return None;
}
let canonical_logical_cwd = std::fs::canonicalize(&logical_cwd).ok()?;
paths_equal(&canonical_logical_cwd, canonical_cwd).then_some(logical_cwd)
}
pub(super) fn push_unique(paths: &mut Vec<PathBuf>, 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(),
}
}
@@ -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<CommonConfigAuthority> {
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<Option<Vec<u8>>, ()> {
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<bool> {
if value.eq_ignore_ascii_case(b"true") {
Some(true)
} else if value.eq_ignore_ascii_case(b"false") {
Some(false)
} else {
None
}
}
@@ -0,0 +1,270 @@
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 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<Option<bool>> {
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"), /*includes*/ 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",
git_config_path(include_target.path())
)
.as_bytes(),
);
assert_eq!(
native_bare_value(&common.path().join("config"), /*includes*/ false)
.expect("direct native bare"),
Some(true)
);
assert_eq!(
native_bare_value(&common.path().join("config"), /*includes*/ 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",
git_config_path(worktree.path())
)
.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",
git_config_path(worktree.path())
)
.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:?}"
);
}
}
+153 -140
View File
@@ -9,14 +9,21 @@ use std::process::Stdio;
use crate::git_command::GitRunner;
use crate::git_config::GitConfigEntry;
use crate::git_config::parse_effective_config;
use crate::git_config::parse_effective_config_with_origins;
use crate::git_config::read_effective_config_with_fallback as read_effective_config_unchecked;
use crate::git_config_sources::ensure_no_worktree_config_sources;
#[path = "filter_sentinel.rs"]
mod filter_sentinel;
pub(crate) use filter_sentinel::SentinelFilterProbeBudget;
pub(crate) use filter_sentinel::SentinelFilterProbeResolution;
pub(crate) use filter_sentinel::classify_sentinel_filter_probes;
pub(crate) use filter_sentinel::sentinel_filter_probe_config_args;
pub(crate) const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|smudge|process)$";
pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str =
r"^filter\..*\.(clean|smudge|process|required)$";
#[derive(Debug, Clone, PartialEq, Eq)]
enum FilterAttributeValue {
pub(crate) enum FilterAttributeValue {
Driver(String),
AmbiguousSentinel(String),
}
@@ -101,31 +108,55 @@ fn ensure_no_selected_executable_git_filters_for(
git_config_args: &[String],
execution: FilterExecution,
) -> io::Result<GitFilterNeutralization> {
let entries = read_filter_config(git, cwd, git_config_args)?;
let neutralized_drivers = executable_filter_drivers_for(&entries, execution)?;
let target_drivers = match execution {
FilterExecution::AnyWorktreeOperation => neutralized_drivers.clone(),
FilterExecution::GitAdd => executable_filter_drivers(&entries)?,
};
if target_drivers.is_empty() {
let entries = read_filter_config(git, cwd, git_config_args).map_err(|error| {
if matches!(execution, FilterExecution::GitAdd)
&& error.kind() == io::ErrorKind::InvalidData
{
io::Error::new(
io::ErrorKind::Unsupported,
format!("refusing malformed Git filter configuration: {error}"),
)
} else {
error
}
})?;
let executable_drivers = executable_filter_drivers(&entries)?;
if executable_drivers.is_empty() {
return Ok(GitFilterNeutralization {
git_config_args: Vec::new(),
_config_dir: None,
filter_config: entries,
});
}
let guard = executable_filter_guard(git, cwd, entries, &executable_drivers)?;
let paths = paths
.iter()
.map(|path| path.as_bytes().to_vec())
.collect::<Vec<_>>();
let attributes = read_filter_attributes(git, cwd, &paths, git_config_args, &target_drivers)?;
let attributes = read_filter_attributes(
git,
cwd,
&paths,
git_config_args,
&executable_drivers,
&guard,
)?;
let mut required_cache = BTreeMap::new();
for (path, driver) in &attributes {
if !target_drivers.contains(driver) {
if !executable_drivers.contains(driver) {
continue;
}
let refused = neutralized_drivers.contains(driver)
|| matches!(execution, FilterExecution::GitAdd)
&& git_filter_required(git, cwd, driver, git_config_args)?;
let refused = match execution {
FilterExecution::AnyWorktreeOperation => true,
FilterExecution::GitAdd => git_add_filter_is_refused(
git,
cwd,
&guard.filter_config,
driver,
git_config_args,
&mut required_cache,
)?,
};
if refused {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
@@ -136,14 +167,28 @@ fn ensure_no_selected_executable_git_filters_for(
));
}
}
if neutralized_drivers.is_empty() {
return Ok(GitFilterNeutralization {
git_config_args: Vec::new(),
_config_dir: None,
filter_config: entries,
});
Ok(guard)
}
fn git_add_filter_is_refused(
git: &GitRunner,
cwd: &Path,
entries: &BTreeMap<String, GitConfigEntry>,
driver: &str,
git_config_args: &[String],
required_cache: &mut BTreeMap<String, bool>,
) -> io::Result<bool> {
if ["clean", "process"].into_iter().any(|name| {
effective_filter_value(entries, driver, name).is_some_and(|value| !value.is_empty())
}) {
return Ok(true);
}
executable_filter_guard(git, cwd, entries, &neutralized_drivers)
if let Some(required) = required_cache.get(driver) {
return Ok(*required);
}
let required = git_filter_required(git, cwd, driver, git_config_args)?;
required_cache.insert(driver.to_string(), required);
Ok(required)
}
fn executable_filter_guard(
@@ -192,12 +237,12 @@ impl GitFilterNeutralization {
name: &str,
value: &str,
) -> io::Result<()> {
let mut command = git.command();
command
.args(["config", "--file"])
.arg(config_path)
.args(["--add", &format!("filter.{driver}.{name}"), value])
.current_dir(cwd);
let mut command = git.command_for_cwd(cwd)?;
command.args(["config", "--file"]).arg(config_path).args([
"--add",
&format!("filter.{driver}.{name}"),
value,
]);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
@@ -244,56 +289,8 @@ pub(crate) fn read_effective_config_with_fallback(
pattern: &str,
probe: &str,
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
let scoped =
run_effective_config_query(git, cwd, git_config_args, pattern, /*show_scope*/ true)?;
if scoped
.status
.code()
.is_some_and(|code| code == 0 || code == 1)
{
return parse_effective_config(&scoped.stdout);
}
let legacy = run_effective_config_query(
git,
cwd,
git_config_args,
pattern,
/*show_scope*/ false,
)?;
if !legacy
.status
.code()
.is_some_and(|code| code == 0 || code == 1)
{
return Err(io::Error::other(format!(
"git {probe} config probe failed with status {}: {}",
legacy.status,
String::from_utf8_lossy(&legacy.stderr).trim()
)));
}
parse_effective_config_with_origins(&legacy.stdout)
}
fn run_effective_config_query(
git: &GitRunner,
cwd: &Path,
git_config_args: &[String],
pattern: &str,
show_scope: bool,
) -> io::Result<std::process::Output> {
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["config", "--null"]);
if show_scope {
command.arg("--show-scope");
}
command
.args(["--show-origin", "--includes", "--get-regexp", pattern])
.current_dir(cwd);
git.output(command)
ensure_no_worktree_config_sources(git, cwd, git_config_args)?;
read_effective_config_unchecked(git, cwd, git_config_args, pattern, probe)
}
fn read_filter_attributes(
@@ -302,6 +299,7 @@ fn read_filter_attributes(
paths: &[Vec<u8>],
git_config_args: &[String],
executable_drivers: &BTreeSet<String>,
neutralization: &GitFilterNeutralization,
) -> io::Result<BTreeMap<Vec<u8>, String>> {
if paths.is_empty() {
return Ok(BTreeMap::new());
@@ -310,7 +308,7 @@ fn read_filter_attributes(
write_nul_paths(&mut input, paths)?;
input.rewind()?;
let mut command = git.command();
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
@@ -324,7 +322,6 @@ fn read_filter_attributes(
"-z",
"filter",
])
.current_dir(cwd)
.stdin(Stdio::from(input));
let output = git.output(command)?;
if !output.status.success() {
@@ -335,7 +332,14 @@ fn read_filter_attributes(
)));
}
let attributes = parse_filter_attributes(&output.stdout, paths)?;
resolve_filter_attribute_sentinels(git, cwd, attributes, git_config_args, executable_drivers)
resolve_filter_attribute_sentinels(
git,
cwd,
attributes,
git_config_args,
executable_drivers,
neutralization,
)
}
fn resolve_filter_attribute_sentinels(
@@ -344,8 +348,10 @@ fn resolve_filter_attribute_sentinels(
attributes: BTreeMap<Vec<u8>, FilterAttributeValue>,
git_config_args: &[String],
executable_drivers: &BTreeSet<String>,
neutralization: &GitFilterNeutralization,
) -> io::Result<BTreeMap<Vec<u8>, String>> {
let mut resolved = BTreeMap::new();
let mut probe_budget = SentinelFilterProbeBudget::default();
for (path, attribute) in attributes {
match attribute {
FilterAttributeValue::Driver(driver) => {
@@ -359,6 +365,8 @@ fn resolve_filter_attribute_sentinels(
&path,
&driver,
git_config_args,
neutralization,
&mut probe_budget,
)?
{
resolved.insert(path, driver);
@@ -369,39 +377,37 @@ fn resolve_filter_attribute_sentinels(
Ok(resolved)
}
/// `git check-attr` serializes both its three special states and literal
/// driver names with the same `set`, `unset`, and `unspecified` strings. Ask
/// Git to resolve the ambiguity with every command for that driver overridden
/// to empty. A required literal driver fails while a special state succeeds.
/// Retrying with the driver optional distinguishes that expected failure from
/// an unrelated probe error. No filter process or shell is started.
/// Disambiguate Git's sentinel spellings with required/optional probes. The
/// shared guard blanks every known executable driver before either probe.
fn sentinel_spelling_selects_filter_driver(
git: &GitRunner,
cwd: &Path,
path: &[u8],
driver: &str,
git_config_args: &[String],
neutralization: &GitFilterNeutralization,
probe_budget: &mut SentinelFilterProbeBudget,
) -> io::Result<bool> {
let required = run_sentinel_selection_probe(
let probe = SentinelSelectionProbe {
git,
cwd,
path,
driver,
git_config_args,
/*required*/ true,
)?;
if required.status.success() {
neutralization,
};
let required = probe.run(/*required*/ true, probe_budget)?;
if classify_sentinel_filter_probes(required.status.success(), /*optional_succeeded*/ None)
== SentinelFilterProbeResolution::SpecialAttributeState
{
return Ok(false);
}
let optional = run_sentinel_selection_probe(
git,
cwd,
path,
driver,
git_config_args,
/*required*/ false,
)?;
if optional.status.success() {
let optional = probe.run(/*required*/ false, probe_budget)?;
if classify_sentinel_filter_probes(
required.status.success(),
/*optional_succeeded*/ Some(optional.status.success()),
) == SentinelFilterProbeResolution::LiteralDriver
{
return Ok(true);
}
Err(io::Error::other(format!(
@@ -412,39 +418,47 @@ fn sentinel_spelling_selects_filter_driver(
)))
}
fn run_sentinel_selection_probe(
git: &GitRunner,
cwd: &Path,
path: &[u8],
driver: &str,
git_config_args: &[String],
required: bool,
) -> io::Result<std::process::Output> {
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args([
"-c",
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
"-c",
"core.fsmonitor=false",
"-c",
&format!("filter.{driver}.required={required}"),
"-c",
&format!("filter.{driver}.clean="),
"-c",
&format!("filter.{driver}.smudge="),
"-c",
&format!("filter.{driver}.process="),
"hash-object",
"--stdin",
])
.arg("--path")
.arg(git_path_argument(path)?)
.current_dir(cwd)
.stdin(Stdio::null());
git.output(command)
struct SentinelSelectionProbe<'a> {
git: &'a GitRunner,
cwd: &'a Path,
path: &'a [u8],
driver: &'a str,
git_config_args: &'a [String],
neutralization: &'a GitFilterNeutralization,
}
impl SentinelSelectionProbe<'_> {
fn run(
&self,
required: bool,
probe_budget: &mut SentinelFilterProbeBudget,
) -> io::Result<std::process::Output> {
let probe_config_args = sentinel_filter_probe_config_args(
self.neutralization.git_config_args(),
self.driver,
required,
)?;
let path = git_path_argument(self.path)?;
let mut command = self.git.command_for_cwd(self.cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(self.git_config_args)
.args([
"-c",
&format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
"-c",
"core.fsmonitor=false",
])
.args(&probe_config_args)
.args(["hash-object", "--stdin"])
.arg("--path")
.arg(path)
.stdin(Stdio::null());
probe_budget.ensure_probe_available()?;
let output = self.git.output(command)?;
probe_budget.record_completed_probe();
Ok(output)
}
}
#[cfg(unix)]
@@ -492,7 +506,7 @@ fn selected_executable_filter(
selected_executable_filter_for(entries, attributes, FilterExecution::AnyWorktreeOperation)
}
fn executable_filter_drivers(
pub(crate) fn executable_filter_drivers(
entries: &BTreeMap<String, GitConfigEntry>,
) -> io::Result<BTreeSet<String>> {
executable_filter_drivers_for(entries, FilterExecution::AnyWorktreeOperation)
@@ -535,15 +549,14 @@ fn git_filter_required(
driver: &str,
git_config_args: &[String],
) -> io::Result<bool> {
let mut command = git.command();
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["config", "--type=bool", "--get"])
.arg(format!("filter.{driver}.required"))
.current_dir(cwd);
.arg(format!("filter.{driver}.required"));
let output = git.output(command)?;
if output.status.code() == Some(1) && output.stderr.is_empty() {
if output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty() {
return Ok(false);
}
if !output.status.success() {
@@ -605,7 +618,7 @@ fn write_nul_paths(input: &mut std::fs::File, paths: &[Vec<u8>]) -> io::Result<(
Ok(())
}
fn parse_filter_attributes(
pub(crate) fn parse_filter_attributes(
output: &[u8],
expected_paths: &[Vec<u8>],
) -> io::Result<BTreeMap<Vec<u8>, FilterAttributeValue>> {
+307 -19
View File
@@ -12,8 +12,57 @@ use std::ffi::OsStr;
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::process::Output;
use tokio::process::Command as TokioCommand;
const FILTER_MARKER_KEY: &str = "codex.sentinelprobe-ran";
const FILTER_MARKER_COMMAND: &str =
"git config codex.sentinelprobe-ran true && git hash-object --stdin";
fn run_git(cwd: &Path, args: &[&str]) -> Output {
let mut command = std::process::Command::new("git");
isolate_git_command_environment(&mut command);
command
.current_dir(cwd)
.args(args)
.output()
.expect("run Git")
}
fn run_git_success(cwd: &Path, args: &[&str]) -> Output {
let output = run_git(cwd, args);
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn init_filter_repo() -> tempfile::TempDir {
let repo = tempfile::tempdir().expect("tempdir");
run_git_success(repo.path(), &["init"]);
std::fs::write(repo.path().join("file.txt"), "content\n").expect("write file");
repo
}
fn configure_marker_filter(cwd: &Path, driver: &str) {
run_git_success(
cwd,
&[
"config",
&format!("filter.{driver}.clean"),
FILTER_MARKER_COMMAND,
],
);
}
fn marker_filter_ran(cwd: &Path) -> bool {
run_git(cwd, &["config", "--get", FILTER_MARKER_KEY])
.status
.success()
}
#[test]
fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() {
let config_dir = tempfile::tempdir().expect("config");
@@ -21,6 +70,7 @@ fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() {
std::fs::write(&config, "").expect("config file");
for scope in [
GitConfigScope::Unknown,
GitConfigScope::System,
GitConfigScope::Global,
GitConfigScope::Local,
@@ -92,16 +142,49 @@ fn git_add_filter_policy_rejects_clean_and_process_but_allows_smudge_only() {
}
#[test]
fn required_config_is_not_treated_as_an_executable_command() {
let entries = filter_entries(
fn filter_snapshot_retains_required_without_treating_it_as_executable() {
let mut entries = filter_entries(
GitConfigScope::Local,
Path::new("config"),
"filter.demo.smudge",
"smudge-command",
);
entries.extend(filter_entries(
GitConfigScope::Command,
Path::new("command line:"),
"filter.demo.required",
"true",
);
));
assert_eq!(
executable_filter_drivers(&entries).expect("executable drivers"),
BTreeSet::new()
BTreeSet::from(["demo".to_string()])
);
let selected = BTreeMap::from([(b"file.txt".to_vec(), "demo".to_string())]);
assert_eq!(
selected_executable_filter_for(&entries, &selected, FilterExecution::GitAdd)
.expect("Git add filter policy"),
None
);
let neutralization = GitFilterNeutralization {
git_config_args: Vec::new(),
_config_dir: None,
filter_config: entries,
};
assert_eq!(
neutralization.filter_value("demo", "required"),
Some("true")
);
let required_only = filter_entries(
GitConfigScope::Local,
Path::new("config"),
"filter.demo.required",
"true",
);
assert!(
executable_filter_drivers(&required_only)
.expect("required-only config")
.is_empty()
);
}
@@ -143,6 +226,211 @@ fn filter_attribute_parser_rejects_malformed_or_unexpected_records() {
}
}
#[test]
fn sentinel_probe_primitives_preserve_order_budget_and_truth_table() {
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ true, /*optional_succeeded*/ None,
),
SentinelFilterProbeResolution::SpecialAttributeState
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false, /*optional_succeeded*/ None,
),
SentinelFilterProbeResolution::NeedsOptionalProbe
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false,
/*optional_succeeded*/ Some(true),
),
SentinelFilterProbeResolution::LiteralDriver
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false,
/*optional_succeeded*/ Some(false),
),
SentinelFilterProbeResolution::ProbeFailure
);
let neutralization = vec![
"-c".to_string(),
"include.path=/private/filter-neutralization.gitconfig".to_string(),
];
assert_eq!(
sentinel_filter_probe_config_args(&neutralization, "set", /*required*/ true)
.expect("sentinel config args"),
vec![
"-c",
"include.path=/private/filter-neutralization.gitconfig",
"-c",
"filter.set.required=true",
]
);
assert_eq!(
sentinel_filter_probe_config_args(&neutralization, "ordinary", /*required*/ true)
.expect_err("reject non-sentinel config argument")
.kind(),
io::ErrorKind::InvalidInput
);
let mut budget = SentinelFilterProbeBudget::default();
assert_eq!(SentinelFilterProbeBudget::max_probes(), 16);
for _ in 0..SentinelFilterProbeBudget::max_probes() {
budget.ensure_probe_available().expect("probe in budget");
budget.record_completed_probe();
}
assert_eq!(
budget
.ensure_probe_available()
.expect_err("hard probe budget")
.to_string(),
"refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)"
);
}
#[test]
fn sentinel_special_states_and_literal_driver_names_remain_distinct() {
for (driver, special_rule) in [
("set", "file.txt filter\n"),
("unset", "file.txt -filter\n"),
("unspecified", ""),
] {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, driver);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
std::fs::write(root.join(".gitattributes"), special_rule).expect("write special attribute");
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[])
.expect("allow special attribute state");
assert!(!marker_filter_ran(root), "special {driver}");
std::fs::write(
root.join(".gitattributes"),
format!("file.txt filter={driver}\n"),
)
.expect("write literal attribute");
let result =
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[]);
let error = match result {
Ok(_) => panic!("accepted literal sentinel-named driver {driver}"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{driver}");
assert!(!marker_filter_ran(root), "literal {driver}");
}
}
#[test]
fn sentinel_probe_neutralizes_every_known_driver_after_attribute_swap() {
for alternate_driver in ["race", "x=y"] {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "set");
configure_marker_filter(root, alternate_driver);
std::fs::write(root.join(".gitattributes"), "file.txt filter\n")
.expect("write initial attribute");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let output = run_git_success(root, &["check-attr", "-z", "filter", "--", "file.txt"]);
let attributes = parse_filter_attributes(&output.stdout, &[b"file.txt".to_vec()])
.expect("initial attribute snapshot");
std::fs::write(
root.join(".gitattributes"),
format!("file.txt filter={alternate_driver}\n"),
)
.expect("swap attribute after snapshot");
let resolved = resolve_filter_attribute_sentinels(
&git,
root,
attributes,
&[],
&executable_drivers,
&neutralization,
)
.expect("resolve stale sentinel snapshot safely");
assert!(resolved.is_empty(), "{alternate_driver}");
assert!(!marker_filter_ran(root), "{alternate_driver}");
}
}
#[test]
fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "unspecified");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let attributes = (0..=SentinelFilterProbeBudget::max_probes())
.map(|index| {
(
format!("ordinary-{index}.txt").into_bytes(),
FilterAttributeValue::AmbiguousSentinel("unspecified".to_string()),
)
})
.collect();
let error = resolve_filter_attribute_sentinels(
&git,
root,
attributes,
&[],
&executable_drivers,
&neutralization,
)
.expect_err("refuse sentinel work beyond hard child-probe budget");
assert_eq!(
error.to_string(),
"refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)"
);
assert!(!marker_filter_ran(root));
}
#[test]
fn unrelated_sentinel_probe_failures_remain_generic_and_fail_closed() {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "set");
std::fs::write(root.join(".gitattributes"), "file.txt filter\n")
.expect("write special attribute");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let mut budget = SentinelFilterProbeBudget::default();
let malformed_config = ["-c".to_string(), "=".to_string()];
let error = sentinel_spelling_selects_filter_driver(
&git,
root,
b"file.txt",
"set",
&malformed_config,
&neutralization,
&mut budget,
)
.expect_err("malformed config must fail both probes");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert!(
error
.to_string()
.starts_with("git filter attribute selection probe failed with required status")
);
assert!(!marker_filter_ran(root));
}
fn filter_entries(
scope: GitConfigScope,
origin: &Path,
@@ -150,9 +438,9 @@ fn filter_entries(
value: &str,
) -> BTreeMap<String, GitConfigEntry> {
let origin = if origin == Path::new("command line:") {
"command line:".to_string()
crate::git_config::GitConfigOrigin::CommandLine
} else {
format!("file:{}", origin.display())
crate::git_config::GitConfigOrigin::File(origin.to_path_buf())
};
BTreeMap::from([(
key.to_string(),
@@ -187,7 +475,7 @@ fn run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) {
);
}
async fn run_git(repo_path: &Path, args: &[&str]) {
async fn run_git_async(repo_path: &Path, args: &[&str]) {
let output = TokioCommand::new("git")
.args(args)
.current_dir(repo_path)
@@ -205,12 +493,12 @@ async fn run_git(repo_path: &Path, args: &[&str]) {
async fn create_test_git_repo(temp_dir: &tempfile::TempDir) -> std::path::PathBuf {
let repo_path = temp_dir.path().join("repo");
std::fs::create_dir(&repo_path).expect("create repo dir");
run_git(&repo_path, &["init"]).await;
run_git(&repo_path, &["config", "user.name", "Test User"]).await;
run_git(&repo_path, &["config", "user.email", "test@example.com"]).await;
run_git_async(&repo_path, &["init"]).await;
run_git_async(&repo_path, &["config", "user.name", "Test User"]).await;
run_git_async(&repo_path, &["config", "user.email", "test@example.com"]).await;
std::fs::write(repo_path.join("test.txt"), "test content").expect("write test file");
run_git(&repo_path, &["add", "."]).await;
run_git(&repo_path, &["commit", "-m", "initial"]).await;
run_git_async(&repo_path, &["add", "."]).await;
run_git_async(&repo_path, &["commit", "-m", "initial"]).await;
repo_path
}
@@ -219,9 +507,9 @@ async fn ordinary_apply_allows_an_unselected_executable_filter() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
std::fs::write(repo_path.join("test.txt"), "old\n").expect("write fixture");
run_git(&repo_path, &["add", "test.txt"]).await;
run_git(&repo_path, &["commit", "-m", "normalize fixture"]).await;
run_git(
run_git_async(&repo_path, &["add", "test.txt"]).await;
run_git_async(&repo_path, &["commit", "-m", "normalize fixture"]).await;
run_git_async(
&repo_path,
&[
"config",
@@ -284,7 +572,7 @@ async fn apply_and_stage_reject_global_relative_filter_without_running_it() {
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(repo_path.join("clean.sh"), permissions).expect("filter executable");
run_git(
run_git_async(
&repo_path,
&[
"-c",
@@ -295,7 +583,7 @@ async fn apply_and_stage_reject_global_relative_filter_without_running_it() {
],
)
.await;
run_git(
run_git_async(
&repo_path,
&["-c", "filter.evil.clean=", "commit", "-m", "fixture"],
)
@@ -336,8 +624,8 @@ async fn nested_cwd_rejects_global_lfs_filter_without_running_it() {
std::fs::create_dir(&repo_bin).expect("repository bin");
std::fs::write(repo_path.join(".gitattributes"), "test.txt filter=lfs\n")
.expect("attributes");
run_git(&repo_path, &["add", ".gitattributes"]).await;
run_git(&repo_path, &["commit", "-m", "attributes"]).await;
run_git_async(&repo_path, &["add", ".gitattributes"]).await;
run_git_async(&repo_path, &["commit", "-m", "attributes"]).await;
std::fs::write(repo_path.join("test.txt"), "changed\n").expect("modify tracked file");
let config_dir = tempfile::tempdir().expect("config tempdir");