git-utils: integrate filter guards with repository authority

This commit is contained in:
Chris Bookholt
2026-07-02 00:46:29 -07:00
parent 801018897f
commit e8b30bd7d3
19 changed files with 3197 additions and 173 deletions

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());

View File

@@ -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"] {

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)
}

View File

@@ -3,6 +3,7 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use crate::errors::GitReadError;
#[cfg(test)]
@@ -57,6 +58,16 @@ impl GitCommand {
self.inner.env(key, value);
self
}
pub(crate) fn env_remove(&mut self, key: impl AsRef<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 {
@@ -97,6 +108,15 @@ impl GitRunner {
Ok(command)
}
pub(crate) fn ensure_config_source_is_not_worktree_controlled(
&self,
path: &Path,
description: &str,
) -> io::Result<()> {
self.authority
.ensure_config_source_is_not_worktree_controlled(path, description)
}
pub(crate) fn output(&self, mut command: GitCommand) -> io::Result<std::process::Output> {
self.revalidate_active_repository_metadata()?;
isolate_git_command_environment(&mut command.inner);

View File

@@ -153,6 +153,16 @@ fn locations_for_root(root: &Path) -> RepositoryAuthority {
.expect("test repository authority")
}
fn config_authority_for_root(root: &Path) -> RepositoryAuthority {
let mut roots = vec![root.to_path_buf()];
let canonical = std::fs::canonicalize(root).expect("canonical root");
if !roots.contains(&canonical) {
roots.push(canonical);
}
RepositoryAuthority::from_test_locations(roots.clone(), roots, vec![root.join(".git")])
.expect("config repository authority")
}
fn raw_parent_traversal(root: &Path, sibling: &str) -> PathBuf {
let separator = std::path::MAIN_SEPARATOR.to_string();
let mut path = root.as_os_str().to_os_string();
@@ -292,6 +302,97 @@ fn git_metadata_marker_parser_preserves_leading_path_space() {
assert!(parse_git_marker_path(b"gitdir:/missing-space\n", b"gitdir: ").is_err());
}
#[test]
fn config_source_authority_requires_an_absolute_raw_path() {
let fixture = tempdir_for_native_git();
let root = fixture.path().join("repo");
std::fs::create_dir_all(&root).expect("create repository");
run_git(&root, &["init", "-q"]);
let authority = config_authority_for_root(&root);
let path = Path::new("relative/config");
let error = authority
.ensure_config_source_is_not_worktree_controlled(path, "test config")
.expect_err("relative config path");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
assert!(error.to_string().contains("test config"), "{error}");
assert!(error.to_string().contains("relative/config"), "{error}");
}
#[test]
fn config_source_authority_preserves_and_rejects_a_worktree_crossing_spelling() {
let fixture = tempdir_for_native_git();
let root = fixture.path().join("repo");
let safe = fixture.path().join("safe");
std::fs::create_dir_all(root.join("nested")).expect("create repository descendant");
std::fs::create_dir_all(&safe).expect("create safe directory");
run_git(&root, &["init", "-q"]);
let authority = config_authority_for_root(&root);
let raw = root
.join("nested")
.join("..")
.join("..")
.join("safe/config");
let error = authority
.ensure_config_source_is_not_worktree_controlled(&raw, "crossing config")
.expect_err("worktree-crossing config path");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
assert!(error.to_string().contains("crossing config"), "{error}");
assert!(
error.to_string().contains(&raw.display().to_string()),
"{error}"
);
}
#[test]
fn config_source_authority_allows_protected_metadata_and_unrelated_external_paths() {
let fixture = tempdir_for_native_git();
let root = fixture.path().join("repo");
let external = fixture.path().join("external/config");
std::fs::create_dir_all(&root).expect("create repository");
std::fs::create_dir_all(external.parent().expect("external parent"))
.expect("create external directory");
run_git(&root, &["init", "-q"]);
let authority = config_authority_for_root(&root);
authority
.ensure_config_source_is_not_worktree_controlled(
&root.join(".git/config"),
"protected metadata config",
)
.expect("protected metadata config");
authority
.ensure_config_source_is_not_worktree_controlled(&external, "external config")
.expect("unrelated external config");
}
#[test]
fn config_source_authority_rejects_an_unregistered_related_repository_root() {
let fixture = tempdir_for_native_git();
let root = fixture.path().join("repo");
let alias = fixture.path().join("unregistered-related-root");
std::fs::create_dir_all(&root).expect("create repository");
std::fs::create_dir_all(&alias).expect("create related root");
run_git(&root, &["init", "-q"]);
std::fs::write(
alias.join(".git"),
format!("gitdir: {}\n", root.join(".git").display()),
)
.expect("write related metadata marker");
let authority = config_authority_for_root(&root);
let path = alias.join("config");
let error = authority
.ensure_config_source_is_not_worktree_controlled(&path, "related config")
.expect_err("unregistered related repository config");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
assert!(error.to_string().contains("related config"), "{error}");
}
fn selected_git(locations: &RepositoryAuthority, directories: &[&Path]) -> PathBuf {
let search_path = std::env::join_paths(directories).expect("PATH");
select_git_executable(locations, &search_path)

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}")))
}

View File

@@ -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;

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -0,0 +1,272 @@
use std::io;
use std::path::Path;
use std::path::PathBuf;
use super::path_safety::CONFIG_PATH_KEY;
use super::path_safety::git_var_path_from_bytes;
use super::path_safety::invalid_config_source;
use crate::git_command::GitRunner;
pub(super) fn default_system_config_source_candidates(
git: &GitRunner,
cwd: &Path,
) -> io::Result<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::<i32>()
.map(|value| value != 0)
.map_err(|_| invalid_config_source("invalid Git boolean environment value")),
}
}
#[cfg(windows)]
pub(super) fn is_disabled_primary_config_path(path: &Path) -> bool {
path.as_os_str()
.to_str()
.is_some_and(|path| path.eq_ignore_ascii_case("NUL"))
}
#[cfg(not(windows))]
pub(super) fn is_disabled_primary_config_path(_path: &Path) -> bool {
false
}

File diff suppressed because it is too large Load Diff

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(),
})

View File

@@ -6,6 +6,7 @@ mod errors;
mod fsmonitor;
mod git_command;
mod git_config;
mod git_config_sources;
mod git_executable;
mod info;
mod local_only;

View File

@@ -9,16 +9,23 @@ use crate::apply::safe_git_config_parts;
use crate::apply::write_temp_patch;
use crate::git_command::GitRunner;
use crate::git_config::path_is_within;
use crate::git_config_sources::ensure_no_worktree_config_sources;
/// Extract paths with Git from a cwd whose config sources have already been
/// authorized for `git_config_args`.
pub(crate) fn extract_effective_paths_from_patch(
git: &GitRunner,
authorized_cwd: &Path,
patch_path: &Path,
revert: bool,
git_config_args: &[String],
) -> io::Result<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,
@@ -44,27 +51,38 @@ pub(crate) fn extract_effective_paths_from_patch(
/// Security-sensitive callers must use the fallible internal extractor so an
/// invalid or ambiguous patch is rejected instead of becoming an empty list.
pub fn extract_paths_from_patch(diff_text: &str) -> Vec<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 command_cwd = patch_path.parent().unwrap_or_else(|| Path::new("."));
let mut cmd = git.command_for_cwd(command_cwd)?;
let mut cmd = git.command_for_cwd(authorized_cwd)?;
cmd.args(git_config_args);
cmd.args(["apply", "--numstat", "-z"]);
if revert {
cmd.arg("-R");
@@ -226,10 +244,18 @@ fn invalid_windows_patch_component(component: &str) -> bool {
/// Stage only the files that actually exist on disk for the given diff.
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
let git = GitRunner::for_cwd_io(git_root)?;
let git_config_args = safe_git_config_parts();
ensure_no_worktree_config_sources(&git, git_root, &git_config_args)?;
let (tmpdir, patch_path) = write_temp_patch(diff)?;
let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?;
let paths = extract_effective_paths_from_patch(
&git,
git_root,
&patch_path,
/*revert*/ true,
&git_config_args,
)?;
let _guard = tmpdir;
stage_effective_paths(&git, git_root, &paths, &safe_git_config_parts())
stage_effective_paths(&git, git_root, &paths, &git_config_args)
}
pub(crate) fn stage_effective_paths(

View File

@@ -62,13 +62,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 = [
@@ -127,7 +137,7 @@ fn effective_paths_cover_supported_patch_headers() {
"{name}, revert={revert}"
);
}
assert_eq!(extract_paths_from_patch(diff), expected, "{name}");
assert_eq!(best_effort_paths(diff), expected, "{name}");
}
let nul_rename_paths = parse_numstat_paths(b"0\t0\t\0old name.txt\0new name.txt\0")
@@ -150,7 +160,7 @@ fn effective_paths_follow_git_for_mismatched_headers() {
effective_paths(mismatch, /*revert*/ true).unwrap(),
expected
);
assert_eq!(extract_paths_from_patch(mismatch), expected);
assert_eq!(best_effort_paths(mismatch), expected);
}
#[test]

View File

@@ -18,7 +18,7 @@ use route_walker::invalid_data;
use route_walker::observe_route;
#[cfg(any(windows, test))]
pub(crate) use windows_path::windows_authority_path_is_ambiguous;
#[cfg(windows)]
#[cfg(any(windows, test))]
pub(crate) use windows_path::windows_path_is_ambiguous;
#[derive(Clone, Copy, Eq, PartialEq)]

View File

@@ -33,6 +33,29 @@ impl RepositoryAuthority {
Ok(canonical)
}
pub(crate) fn ensure_config_source_is_not_worktree_controlled(
&self,
path: &Path,
description: &str,
) -> io::Result<()> {
if !path.is_absolute() {
return Err(worktree_controlled_config_source(path, description));
}
let inspection = self.route_boundaries.inspect_route(path)?;
if inspection.crosses_worktree {
return Err(worktree_controlled_config_source(path, description));
}
for observed in inspection.observed_paths {
if self.route_boundaries.contains_known_boundary(&observed)? {
continue;
}
if self.has_related_repository_ancestor(&observed)? {
return Err(worktree_controlled_config_source(path, description));
}
}
Ok(())
}
pub(super) fn path_is_untrusted_for_executable_result(&self, path: &Path) -> io::Result<bool> {
let inspection = self.route_boundaries.inspect_route(path)?;
if inspection.touches_worktree || inspection.crosses_metadata {
@@ -145,3 +168,13 @@ impl RepositoryAuthority {
Ok(())
}
}
fn worktree_controlled_config_source(path: &Path, description: &str) -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"refusing to use worktree-controlled {description}: {}",
path.display()
),
)
}

View File

@@ -9,15 +9,21 @@ use std::process::Stdio;
use crate::git_command::GitRunner;
use crate::git_config::GitConfigEntry;
use crate::git_config::parse_effective_config;
use crate::git_config::parse_effective_config_with_origins;
use crate::git_config::read_effective_config_with_fallback as read_effective_config_unchecked;
use crate::git_config_sources::ensure_no_worktree_config_sources;
#[path = "filter_sentinel.rs"]
mod filter_sentinel;
pub(crate) use filter_sentinel::SentinelFilterProbeBudget;
pub(crate) use filter_sentinel::SentinelFilterProbeResolution;
pub(crate) use filter_sentinel::classify_sentinel_filter_probes;
pub(crate) use filter_sentinel::sentinel_filter_probe_config_args;
pub(crate) const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
pub(crate) const EXECUTABLE_FILTER_CONFIG_PATTERN: &str =
r"^filter\..*\.(clean|smudge|process|required)$";
#[derive(Debug, Clone, PartialEq, Eq)]
enum FilterAttributeValue {
pub(crate) enum FilterAttributeValue {
Driver(String),
AmbiguousSentinel(String),
}
@@ -82,13 +88,20 @@ pub(crate) fn ensure_no_selected_executable_git_filters(
filter_config: entries,
});
}
let guard = executable_filter_guard(git, cwd, entries, &executable_drivers)?;
let paths = paths
.iter()
.map(|path| path.as_bytes().to_vec())
.collect::<Vec<_>>();
let attributes =
read_filter_attributes(git, cwd, &paths, git_config_args, &executable_drivers)?;
if let Some((driver, path)) = selected_executable_filter(&entries, &attributes)? {
let attributes = read_filter_attributes(
git,
cwd,
&paths,
git_config_args,
&executable_drivers,
&guard,
)?;
if let Some((driver, path)) = selected_executable_filter(&guard.filter_config, &attributes)? {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
@@ -97,7 +110,7 @@ pub(crate) fn ensure_no_selected_executable_git_filters(
),
));
}
executable_filter_guard(git, cwd, entries, &executable_drivers)
Ok(guard)
}
fn executable_filter_guard(
@@ -146,12 +159,12 @@ impl GitFilterNeutralization {
name: &str,
value: &str,
) -> io::Result<()> {
let mut command = git.command();
command
.args(["config", "--file"])
.arg(config_path)
.args(["--add", &format!("filter.{driver}.{name}"), value])
.current_dir(cwd);
let mut command = git.command_for_cwd(cwd)?;
command.args(["config", "--file"]).arg(config_path).args([
"--add",
&format!("filter.{driver}.{name}"),
value,
]);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
@@ -185,56 +198,8 @@ pub(crate) fn read_effective_config_with_fallback(
pattern: &str,
probe: &str,
) -> io::Result<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(
@@ -243,6 +208,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());
@@ -251,7 +217,7 @@ fn read_filter_attributes(
write_nul_paths(&mut input, paths)?;
input.rewind()?;
let mut command = git.command();
let mut command = git.command_for_cwd(cwd)?;
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
@@ -265,7 +231,6 @@ fn read_filter_attributes(
"-z",
"filter",
])
.current_dir(cwd)
.stdin(Stdio::from(input));
let output = git.output(command)?;
if !output.status.success() {
@@ -276,7 +241,14 @@ fn read_filter_attributes(
)));
}
let attributes = parse_filter_attributes(&output.stdout, paths)?;
resolve_filter_attribute_sentinels(git, cwd, attributes, git_config_args, executable_drivers)
resolve_filter_attribute_sentinels(
git,
cwd,
attributes,
git_config_args,
executable_drivers,
neutralization,
)
}
fn resolve_filter_attribute_sentinels(
@@ -285,8 +257,10 @@ fn resolve_filter_attribute_sentinels(
attributes: BTreeMap<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) => {
@@ -300,6 +274,8 @@ fn resolve_filter_attribute_sentinels(
&path,
&driver,
git_config_args,
neutralization,
&mut probe_budget,
)?
{
resolved.insert(path, driver);
@@ -310,39 +286,37 @@ fn resolve_filter_attribute_sentinels(
Ok(resolved)
}
/// `git check-attr` serializes both its three special states and literal
/// driver names with the same `set`, `unset`, and `unspecified` strings. Ask
/// Git to resolve the ambiguity with every command for that driver overridden
/// to empty. A required literal driver fails while a special state succeeds.
/// Retrying with the driver optional distinguishes that expected failure from
/// an unrelated probe error. No filter process or shell is started.
/// Disambiguate Git's sentinel spellings with required/optional probes. The
/// shared guard blanks every known executable driver before either probe.
fn sentinel_spelling_selects_filter_driver(
git: &GitRunner,
cwd: &Path,
path: &[u8],
driver: &str,
git_config_args: &[String],
neutralization: &GitFilterNeutralization,
probe_budget: &mut SentinelFilterProbeBudget,
) -> io::Result<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!(
@@ -353,39 +327,47 @@ fn sentinel_spelling_selects_filter_driver(
)))
}
fn run_sentinel_selection_probe(
git: &GitRunner,
cwd: &Path,
path: &[u8],
driver: &str,
git_config_args: &[String],
required: bool,
) -> io::Result<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)]
@@ -415,7 +397,7 @@ fn selected_executable_filter(
Ok(None)
}
fn executable_filter_drivers(
pub(crate) fn executable_filter_drivers(
entries: &BTreeMap<String, GitConfigEntry>,
) -> io::Result<BTreeSet<String>> {
let mut executable_drivers = BTreeSet::new();
@@ -456,7 +438,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>> {

View File

@@ -4,6 +4,55 @@ use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
use std::process::Output;
const FILTER_MARKER_KEY: &str = "codex.sentinelprobe-ran";
const FILTER_MARKER_COMMAND: &str =
"git config codex.sentinelprobe-ran true && git hash-object --stdin";
fn run_git(cwd: &Path, args: &[&str]) -> Output {
let mut command = std::process::Command::new("git");
isolate_git_command_environment(&mut command);
command
.current_dir(cwd)
.args(args)
.output()
.expect("run Git")
}
fn run_git_success(cwd: &Path, args: &[&str]) -> Output {
let output = run_git(cwd, args);
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn init_filter_repo() -> tempfile::TempDir {
let repo = tempfile::tempdir().expect("tempdir");
run_git_success(repo.path(), &["init"]);
std::fs::write(repo.path().join("file.txt"), "content\n").expect("write file");
repo
}
fn configure_marker_filter(cwd: &Path, driver: &str) {
run_git_success(
cwd,
&[
"config",
&format!("filter.{driver}.clean"),
FILTER_MARKER_COMMAND,
],
);
}
fn marker_filter_ran(cwd: &Path) -> bool {
run_git(cwd, &["config", "--get", FILTER_MARKER_KEY])
.status
.success()
}
#[test]
fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() {
@@ -12,6 +61,7 @@ fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() {
std::fs::write(&config, "").expect("config file");
for scope in [
GitConfigScope::Unknown,
GitConfigScope::System,
GitConfigScope::Global,
GitConfigScope::Local,
@@ -137,6 +187,211 @@ fn filter_attribute_parser_rejects_malformed_or_unexpected_records() {
}
}
#[test]
fn sentinel_probe_primitives_preserve_order_budget_and_truth_table() {
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ true, /*optional_succeeded*/ None,
),
SentinelFilterProbeResolution::SpecialAttributeState
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false, /*optional_succeeded*/ None,
),
SentinelFilterProbeResolution::NeedsOptionalProbe
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false,
/*optional_succeeded*/ Some(true),
),
SentinelFilterProbeResolution::LiteralDriver
);
assert_eq!(
classify_sentinel_filter_probes(
/*required_succeeded*/ false,
/*optional_succeeded*/ Some(false),
),
SentinelFilterProbeResolution::ProbeFailure
);
let neutralization = vec![
"-c".to_string(),
"include.path=/private/filter-neutralization.gitconfig".to_string(),
];
assert_eq!(
sentinel_filter_probe_config_args(&neutralization, "set", /*required*/ true)
.expect("sentinel config args"),
vec![
"-c",
"include.path=/private/filter-neutralization.gitconfig",
"-c",
"filter.set.required=true",
]
);
assert_eq!(
sentinel_filter_probe_config_args(&neutralization, "ordinary", /*required*/ true)
.expect_err("reject non-sentinel config argument")
.kind(),
io::ErrorKind::InvalidInput
);
let mut budget = SentinelFilterProbeBudget::default();
assert_eq!(SentinelFilterProbeBudget::max_probes(), 16);
for _ in 0..SentinelFilterProbeBudget::max_probes() {
budget.ensure_probe_available().expect("probe in budget");
budget.record_completed_probe();
}
assert_eq!(
budget
.ensure_probe_available()
.expect_err("hard probe budget")
.to_string(),
"refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)"
);
}
#[test]
fn sentinel_special_states_and_literal_driver_names_remain_distinct() {
for (driver, special_rule) in [
("set", "file.txt filter\n"),
("unset", "file.txt -filter\n"),
("unspecified", ""),
] {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, driver);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
std::fs::write(root.join(".gitattributes"), special_rule).expect("write special attribute");
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[])
.expect("allow special attribute state");
assert!(!marker_filter_ran(root), "special {driver}");
std::fs::write(
root.join(".gitattributes"),
format!("file.txt filter={driver}\n"),
)
.expect("write literal attribute");
let result =
ensure_no_selected_executable_git_filters(&git, root, &["file.txt".to_string()], &[]);
let error = match result {
Ok(_) => panic!("accepted literal sentinel-named driver {driver}"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{driver}");
assert!(!marker_filter_ran(root), "literal {driver}");
}
}
#[test]
fn sentinel_probe_neutralizes_every_known_driver_after_attribute_swap() {
for alternate_driver in ["race", "x=y"] {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "set");
configure_marker_filter(root, alternate_driver);
std::fs::write(root.join(".gitattributes"), "file.txt filter\n")
.expect("write initial attribute");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let output = run_git_success(root, &["check-attr", "-z", "filter", "--", "file.txt"]);
let attributes = parse_filter_attributes(&output.stdout, &[b"file.txt".to_vec()])
.expect("initial attribute snapshot");
std::fs::write(
root.join(".gitattributes"),
format!("file.txt filter={alternate_driver}\n"),
)
.expect("swap attribute after snapshot");
let resolved = resolve_filter_attribute_sentinels(
&git,
root,
attributes,
&[],
&executable_drivers,
&neutralization,
)
.expect("resolve stale sentinel snapshot safely");
assert!(resolved.is_empty(), "{alternate_driver}");
assert!(!marker_filter_ran(root), "{alternate_driver}");
}
}
#[test]
fn high_cardinality_ordinary_sentinels_stop_at_hard_child_probe_budget() {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "unspecified");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let attributes = (0..=SentinelFilterProbeBudget::max_probes())
.map(|index| {
(
format!("ordinary-{index}.txt").into_bytes(),
FilterAttributeValue::AmbiguousSentinel("unspecified".to_string()),
)
})
.collect();
let error = resolve_filter_attribute_sentinels(
&git,
root,
attributes,
&[],
&executable_drivers,
&neutralization,
)
.expect_err("refuse sentinel work beyond hard child-probe budget");
assert_eq!(
error.to_string(),
"refusing to continue Git filter sentinel disambiguation after 16 child probes (hard limit: 16)"
);
assert!(!marker_filter_ran(root));
}
#[test]
fn unrelated_sentinel_probe_failures_remain_generic_and_fail_closed() {
let repo = init_filter_repo();
let root = repo.path();
configure_marker_filter(root, "set");
std::fs::write(root.join(".gitattributes"), "file.txt filter\n")
.expect("write special attribute");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let entries = read_filter_config(&git, root, &[]).expect("filter config");
let executable_drivers = executable_filter_drivers(&entries).expect("executable drivers");
let neutralization = executable_filter_guard(&git, root, entries, &executable_drivers)
.expect("filter neutralization");
let mut budget = SentinelFilterProbeBudget::default();
let malformed_config = ["-c".to_string(), "=".to_string()];
let error = sentinel_spelling_selects_filter_driver(
&git,
root,
b"file.txt",
"set",
&malformed_config,
&neutralization,
&mut budget,
)
.expect_err("malformed config must fail both probes");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert!(
error
.to_string()
.starts_with("git filter attribute selection probe failed with required status")
);
assert!(!marker_filter_ran(root));
}
fn filter_entries(
scope: GitConfigScope,
origin: &Path,
@@ -144,9 +399,9 @@ fn filter_entries(
value: &str,
) -> BTreeMap<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(),