mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
git-utils: integrate filter guards with repository authority
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
71
codex-rs/git-utils/src/filter_sentinel.rs
Normal file
71
codex-rs/git-utils/src/filter_sentinel.rs
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")))
|
||||
}
|
||||
|
||||
140
codex-rs/git-utils/src/git_config_sources.rs
Normal file
140
codex-rs/git-utils/src/git_config_sources.rs
Normal 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;
|
||||
120
codex-rs/git-utils/src/git_config_sources/include_graph.rs
Normal file
120
codex-rs/git-utils/src/git_config_sources/include_graph.rs
Normal 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))
|
||||
}
|
||||
56
codex-rs/git-utils/src/git_config_sources/path_safety.rs
Normal file
56
codex-rs/git-utils/src/git_config_sources/path_safety.rs
Normal 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)
|
||||
}
|
||||
272
codex-rs/git-utils/src/git_config_sources/primary_sources.rs
Normal file
272
codex-rs/git-utils/src/git_config_sources/primary_sources.rs
Normal 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
|
||||
}
|
||||
1569
codex-rs/git-utils/src/git_config_sources_tests.rs
Normal file
1569
codex-rs/git-utils/src/git_config_sources_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user