mirror of
https://github.com/openai/codex.git
synced 2026-09-07 15:40:00 +00:00
git-utils: block selected patch filters
This commit is contained in:
@@ -16,6 +16,7 @@ use crate::git_command::GitRunner;
|
||||
use crate::patch_paths::extract_effective_paths_from_patch;
|
||||
use crate::patch_paths::stage_effective_paths;
|
||||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||||
use crate::safe_git::ensure_no_selected_executable_git_filters;
|
||||
#[cfg(test)]
|
||||
use crate::safe_git::isolate_git_command_environment;
|
||||
|
||||
@@ -54,6 +55,7 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||||
// 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)?;
|
||||
ensure_no_selected_executable_git_filters(&git, &git_root, &patch_paths, &cfg_parts)?;
|
||||
|
||||
if req.revert && !req.preflight {
|
||||
// Stage WT paths first to avoid index mismatch on revert.
|
||||
@@ -318,6 +320,57 @@ mod tests {
|
||||
.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
fn commit_filter_attributes(root: &Path, tracked_path: &str) {
|
||||
std::fs::write(
|
||||
root.join(".gitattributes"),
|
||||
format!("{tracked_path} filter=x=y\n"),
|
||||
)
|
||||
.expect("write attributes");
|
||||
let (add_code, _, add_err) = run(root, &["git", "add", ".gitattributes"]);
|
||||
assert_eq!(add_code, 0, "add attributes: {add_err}");
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "attributes"]);
|
||||
assert_eq!(commit_code, 0, "commit attributes: {commit_err}");
|
||||
}
|
||||
|
||||
fn configure_clean_filter(root: &Path, tracked_path: &str) {
|
||||
commit_filter_attributes(root, tracked_path);
|
||||
let (config_code, _, config_err) = run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"config",
|
||||
"filter.x=y.clean",
|
||||
"git config codex.filterran true && git hash-object --stdin",
|
||||
],
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure filter: {config_err}");
|
||||
}
|
||||
|
||||
fn configure_worktree_clean_filter(root: &Path, tracked_path: &str) {
|
||||
commit_filter_attributes(root, tracked_path);
|
||||
let (extension_code, _, extension_err) = run(
|
||||
root,
|
||||
&["git", "config", "extensions.worktreeConfig", "true"],
|
||||
);
|
||||
assert_eq!(extension_code, 0, "enable worktree config: {extension_err}");
|
||||
let (config_code, _, config_err) = run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"config",
|
||||
"--worktree",
|
||||
"filter.x=y.clean",
|
||||
"git config codex.filterran true && git hash-object --stdin",
|
||||
],
|
||||
);
|
||||
assert_eq!(config_code, 0, "configure worktree filter: {config_err}");
|
||||
}
|
||||
|
||||
fn configured_filter_ran(root: &Path) -> bool {
|
||||
let (code, _, _) = run(root, &["git", "config", "--get", "codex.filterran"]);
|
||||
code == 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_unescapes_quoted_paths() {
|
||||
let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
|
||||
@@ -563,6 +616,95 @@ diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_configured_clean_filter_without_running_it() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
|
||||
let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]);
|
||||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||||
configure_clean_filter(root, "file.txt");
|
||||
|
||||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+next\n";
|
||||
for (revert, preflight) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let request = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: diff.to_string(),
|
||||
revert,
|
||||
preflight,
|
||||
};
|
||||
let error = apply_git_patch(&request).expect_err("reject configured filter");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(!configured_filter_ran(root));
|
||||
assert_eq!(read_file_normalized(&root.join("file.txt")), "orig\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_worktree_scoped_clean_filter_without_running_it() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
|
||||
let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]);
|
||||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||||
configure_worktree_clean_filter(root, "file.txt");
|
||||
|
||||
let request = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+next\n".to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let error = apply_git_patch(&request).expect_err("reject worktree filter");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(!configured_filter_ran(root));
|
||||
assert_eq!(read_file_normalized(&root.join("file.txt")), "orig\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_probe_rejects_command_scoped_clean_filter() {
|
||||
let _g = env_lock().lock().unwrap();
|
||||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_ENV_CHILD").is_none() {
|
||||
run_isolated_test(
|
||||
"apply::tests::apply_probe_rejects_command_scoped_clean_filter",
|
||||
&[(
|
||||
"CODEX_APPLY_GIT_CFG",
|
||||
OsStr::new(
|
||||
"filter.codex-test.clean=git config codex.filterran true && git hash-object --stdin",
|
||||
),
|
||||
)],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let repo = init_repo();
|
||||
let root = repo.path();
|
||||
std::fs::write(root.join("test.txt"), "orig\n").expect("write file");
|
||||
let (add_code, _, add_err) = run(root, &["git", "add", "test.txt"]);
|
||||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||||
std::fs::write(root.join(".gitattributes"), "test.txt filter=codex-test\n")
|
||||
.expect("attributes");
|
||||
|
||||
let request = ApplyGitRequest {
|
||||
cwd: root.to_path_buf(),
|
||||
diff: "diff --git a/test.txt b/test.txt\n--- a/test.txt\n+++ b/test.txt\n@@ -1 +1 @@\n-orig\n+next\n".to_string(),
|
||||
revert: false,
|
||||
preflight: true,
|
||||
};
|
||||
let error = apply_git_patch(&request).expect_err("reject command-scoped filter");
|
||||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(!configured_filter_ran(root));
|
||||
assert_eq!(read_file_normalized(&root.join("test.txt")), "orig\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_git_root_rejects_core_worktree_redirection() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,6 +1,119 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum GitConfigScope {
|
||||
System,
|
||||
Global,
|
||||
Local,
|
||||
Worktree,
|
||||
Command,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GitConfigEntry {
|
||||
pub(crate) scope: GitConfigScope,
|
||||
pub(crate) origin: String,
|
||||
pub(crate) key: String,
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_effective_config(
|
||||
output: &[u8],
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
if output.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(invalid_config_output("unterminated Git config output"));
|
||||
};
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.len() % 3 != 0 {
|
||||
return Err(invalid_config_output("incomplete Git config record"));
|
||||
}
|
||||
|
||||
let mut effective = BTreeMap::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 entry = parse_utf8_field(record[2], "config key/value")?;
|
||||
let Some((key, value)) = entry.split_once('\n') else {
|
||||
return Err(invalid_config_output(
|
||||
"Git config record has no key/value separator",
|
||||
));
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(invalid_config_output("empty Git config key"));
|
||||
}
|
||||
let entry = GitConfigEntry {
|
||||
scope,
|
||||
origin: origin.to_string(),
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
};
|
||||
effective.insert(key.to_string(), entry);
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn parse_effective_config_with_origins(
|
||||
output: &[u8],
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
if output.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(invalid_config_output("unterminated Git config output"));
|
||||
};
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.len() % 2 != 0 {
|
||||
return Err(invalid_config_output("incomplete Git config record"));
|
||||
}
|
||||
|
||||
let mut effective = BTreeMap::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 entry = parse_utf8_field(record[1], "config key/value")?;
|
||||
let Some((key, value)) = entry.split_once('\n') else {
|
||||
return Err(invalid_config_output(
|
||||
"Git config record has no key/value separator",
|
||||
));
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(invalid_config_output("empty Git config key"));
|
||||
}
|
||||
let scope = if origin == "command line:" {
|
||||
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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||
let mut path_components = path.components();
|
||||
for root_component in root.components() {
|
||||
@@ -26,6 +139,25 @@ fn components_equal(left: Component<'_>, right: Component<'_>) -> bool {
|
||||
left == right
|
||||
}
|
||||
|
||||
fn parse_scope(scope: &[u8]) -> io::Result<GitConfigScope> {
|
||||
match scope {
|
||||
b"system" => Ok(GitConfigScope::System),
|
||||
b"global" => Ok(GitConfigScope::Global),
|
||||
b"local" => Ok(GitConfigScope::Local),
|
||||
b"worktree" => Ok(GitConfigScope::Worktree),
|
||||
b"command" => Ok(GitConfigScope::Command),
|
||||
_ => Err(invalid_config_output("unknown Git config scope")),
|
||||
}
|
||||
}
|
||||
|
||||
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}")))
|
||||
}
|
||||
|
||||
fn invalid_config_output(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_config_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,72 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_effective_last_value_with_scope_and_origin() {
|
||||
let output = b"global\0file:/tmp/global\0filter.demo.clean\ngit-lfs clean -- %f\0\
|
||||
local\0file:/repo/.git/config\0filter.demo.clean\n\0\
|
||||
command\0command line:\0merge.Name.driver\nhelper %A %B\0";
|
||||
|
||||
let entries = parse_effective_config(output).expect("parse config");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(
|
||||
entries.get("filter.demo.clean"),
|
||||
Some(&GitConfigEntry {
|
||||
scope: GitConfigScope::Local,
|
||||
origin: "file:/repo/.git/config".to_string(),
|
||||
key: "filter.demo.clean".to_string(),
|
||||
value: String::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
entries.get("merge.Name.driver"),
|
||||
Some(&GitConfigEntry {
|
||||
scope: GitConfigScope::Command,
|
||||
origin: "command line:".to_string(),
|
||||
key: "merge.Name.driver".to_string(),
|
||||
value: "helper %A %B".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_config_records() {
|
||||
for output in [
|
||||
b"global\0file:/tmp/config\0key\nvalue".as_slice(),
|
||||
b"global\0file:/tmp/config\0key\0".as_slice(),
|
||||
b"mystery\0file:/tmp/config\0key\nvalue\0".as_slice(),
|
||||
b"global\0\0key\nvalue\0".as_slice(),
|
||||
b"global\0file:/tmp/config\0\nvalue\0".as_slice(),
|
||||
] {
|
||||
assert!(parse_effective_config(output).is_err(), "{output:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_origin_records_in_effective_order() {
|
||||
let output = b"file:/tmp/global\0filter.demo.clean\nfirst\0\
|
||||
file:/repo/.git/config\0filter.demo.clean\n\0\
|
||||
command line:\0merge.Name.driver\nhelper %A %B\0";
|
||||
let entries = parse_effective_config_with_origins(output).expect("parse legacy config");
|
||||
assert_eq!(
|
||||
entries.get("filter.demo.clean"),
|
||||
Some(&GitConfigEntry {
|
||||
scope: GitConfigScope::Local,
|
||||
origin: "file:/repo/.git/config".to_string(),
|
||||
key: "filter.demo.clean".to_string(),
|
||||
value: String::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
entries.get("merge.Name.driver"),
|
||||
Some(&GitConfigEntry {
|
||||
scope: GitConfigScope::Command,
|
||||
origin: "command line:".to_string(),
|
||||
key: "merge.Name.driver".to_string(),
|
||||
value: "helper %A %B".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_containment_uses_component_boundaries() {
|
||||
let root = Path::new("/repo/root");
|
||||
@@ -8,3 +75,37 @@ fn path_containment_uses_component_boundaries() {
|
||||
assert!(!path_is_within(Path::new("/repo/rooted/config"), root));
|
||||
assert!(!path_is_within(Path::new("/repo"), root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_normalizes_case_insensitive_key_parts_before_effective_parsing() {
|
||||
let mut command = std::process::Command::new("git");
|
||||
crate::safe_git::isolate_git_command_environment(&mut command);
|
||||
let output = command
|
||||
.args([
|
||||
"-c",
|
||||
"FiLtEr.DeMo.ClEaN=first",
|
||||
"-c",
|
||||
"filter.DeMo.clean=second",
|
||||
"config",
|
||||
"--null",
|
||||
"--show-scope",
|
||||
"--show-origin",
|
||||
"--get-regexp",
|
||||
r"^filter\.DeMo\.clean$",
|
||||
])
|
||||
.output()
|
||||
.expect("query mixed-case Git config");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Git config query failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let entries = parse_effective_config(&output.stdout).expect("parse Git-normalized config");
|
||||
assert_eq!(entries.len(), 1);
|
||||
let entry = entries
|
||||
.get("filter.DeMo.clean")
|
||||
.expect("canonical section and variable casing");
|
||||
assert_eq!(entry.value, "second");
|
||||
assert_eq!(entry.scope, GitConfigScope::Command);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io;
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
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;
|
||||
|
||||
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)$";
|
||||
|
||||
const ISOLATED_GIT_ENVIRONMENT: [&str; 11] = [
|
||||
"GIT_DIR",
|
||||
@@ -28,3 +41,242 @@ pub(crate) fn isolate_git_command_environment(command: &mut Command) {
|
||||
command.env_remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_no_selected_executable_git_filters(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[String],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<()> {
|
||||
let entries = read_filter_config(git, cwd, git_config_args)?;
|
||||
if !entries.values().any(|entry| !entry.value.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
let paths = paths
|
||||
.iter()
|
||||
.map(|path| path.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
let attributes = read_filter_attributes(git, cwd, &paths, git_config_args)?;
|
||||
if let Some((driver, path)) = selected_executable_filter(&entries, &attributes)? {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"refusing to run an internal Git worktree operation with executable filter {driver:?} selected for {}",
|
||||
String::from_utf8_lossy(&path)
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_filter_config(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<String, GitConfigEntry>> {
|
||||
read_effective_config_with_fallback(
|
||||
git,
|
||||
cwd,
|
||||
git_config_args,
|
||||
EXECUTABLE_FILTER_CONFIG_PATTERN,
|
||||
"filter",
|
||||
)
|
||||
}
|
||||
|
||||
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>> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn read_filter_attributes(
|
||||
git: &GitRunner,
|
||||
cwd: &Path,
|
||||
paths: &[Vec<u8>],
|
||||
git_config_args: &[String],
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
if paths.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let mut input = tempfile::tempfile()?;
|
||||
write_nul_paths(&mut input, paths)?;
|
||||
input.rewind()?;
|
||||
|
||||
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",
|
||||
"check-attr",
|
||||
"--stdin",
|
||||
"-z",
|
||||
"filter",
|
||||
])
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::from(input));
|
||||
let output = git.output(command)?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"git filter attribute probe failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
parse_filter_attributes(&output.stdout, paths)
|
||||
}
|
||||
|
||||
fn selected_executable_filter(
|
||||
entries: &BTreeMap<String, GitConfigEntry>,
|
||||
attributes: &BTreeMap<Vec<u8>, String>,
|
||||
) -> io::Result<Option<(String, Vec<u8>)>> {
|
||||
let mut executable_drivers = BTreeSet::new();
|
||||
for entry in entries.values() {
|
||||
let driver = filter_driver_name(&entry.key)?;
|
||||
if !entry.value.is_empty() {
|
||||
executable_drivers.insert(driver);
|
||||
}
|
||||
}
|
||||
for (path, driver) in attributes {
|
||||
if executable_drivers.contains(driver) {
|
||||
return Ok(Some((driver.clone(), path.clone())));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn filter_driver_name(key: &str) -> io::Result<String> {
|
||||
let Some(remainder) = key.strip_prefix("filter.") else {
|
||||
return Err(invalid_filter_output("malformed filter config key"));
|
||||
};
|
||||
let driver = [".clean", ".smudge", ".process"]
|
||||
.into_iter()
|
||||
.find_map(|suffix| remainder.strip_suffix(suffix))
|
||||
.filter(|driver| !driver.is_empty())
|
||||
.ok_or_else(|| invalid_filter_output("malformed filter config key"))?;
|
||||
Ok(driver.to_string())
|
||||
}
|
||||
|
||||
fn write_nul_paths(input: &mut std::fs::File, paths: &[Vec<u8>]) -> io::Result<()> {
|
||||
let mut unique = BTreeSet::new();
|
||||
for path in paths {
|
||||
if path.is_empty() || path.contains(&0) {
|
||||
return Err(invalid_filter_output("invalid Git path"));
|
||||
}
|
||||
if unique.insert(path.as_slice()) {
|
||||
input.write_all(path)?;
|
||||
input.write_all(&[0])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_filter_attributes(
|
||||
output: &[u8],
|
||||
expected_paths: &[Vec<u8>],
|
||||
) -> io::Result<BTreeMap<Vec<u8>, String>> {
|
||||
let expected = expected_paths
|
||||
.iter()
|
||||
.map(Vec::as_slice)
|
||||
.collect::<BTreeSet<_>>();
|
||||
if expected.is_empty() && output.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let Some(body) = output.strip_suffix(&[0]) else {
|
||||
return Err(invalid_filter_output(
|
||||
"unterminated Git filter attribute output",
|
||||
));
|
||||
};
|
||||
let fields = body.split(|byte| *byte == 0).collect::<Vec<_>>();
|
||||
if fields.len() % 3 != 0 {
|
||||
return Err(invalid_filter_output(
|
||||
"incomplete Git filter attribute record",
|
||||
));
|
||||
}
|
||||
let mut attributes = BTreeMap::new();
|
||||
for record in fields.chunks_exact(3) {
|
||||
if !expected.contains(record[0]) || record[1] != b"filter" {
|
||||
return Err(invalid_filter_output(
|
||||
"unexpected Git filter attribute record",
|
||||
));
|
||||
}
|
||||
let driver = std::str::from_utf8(record[2])
|
||||
.map_err(|_| invalid_filter_output("non-UTF-8 Git filter attribute value"))?;
|
||||
if attributes
|
||||
.insert(record[0].to_vec(), driver.to_string())
|
||||
.is_some()
|
||||
{
|
||||
return Err(invalid_filter_output(
|
||||
"duplicate Git filter attribute record",
|
||||
));
|
||||
}
|
||||
}
|
||||
if attributes.len() != expected.len() {
|
||||
return Err(invalid_filter_output("missing Git filter attribute record"));
|
||||
}
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn invalid_filter_output(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "safe_git_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
108
codex-rs/git-utils/src/safe_git_tests.rs
Normal file
108
codex-rs/git-utils/src/safe_git_tests.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
use crate::git_config::GitConfigScope;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn selected_filter_policy_allows_unused_and_rejects_selected_at_every_scope() {
|
||||
let config_dir = tempfile::tempdir().expect("config");
|
||||
let config = config_dir.path().join("global.gitconfig");
|
||||
std::fs::write(&config, "").expect("config file");
|
||||
|
||||
for scope in [
|
||||
GitConfigScope::System,
|
||||
GitConfigScope::Global,
|
||||
GitConfigScope::Local,
|
||||
GitConfigScope::Worktree,
|
||||
GitConfigScope::Command,
|
||||
] {
|
||||
for (key, value) in [
|
||||
("filter.demo.clean", "./clean.sh"),
|
||||
("filter.lfs.clean", "git-lfs clean -- %f"),
|
||||
("filter.lfs.smudge", "git-lfs smudge -- %f"),
|
||||
("filter.lfs.process", "git-lfs filter-process"),
|
||||
] {
|
||||
let entries = filter_entries(scope, &config, key, value);
|
||||
let driver = filter_driver_name(key).expect("driver name");
|
||||
let selected = BTreeMap::from([(b"file.txt".to_vec(), driver.clone())]);
|
||||
assert!(
|
||||
selected_executable_filter(&entries, &selected)
|
||||
.expect("selected filter policy")
|
||||
.is_some(),
|
||||
"{scope:?} {key}"
|
||||
);
|
||||
let unused = BTreeMap::from([(b"file.txt".to_vec(), "other".to_string())]);
|
||||
assert_eq!(
|
||||
selected_executable_filter(&entries, &unused).expect("unused filter policy"),
|
||||
None,
|
||||
"{scope:?} {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_filter_policy_allows_effective_empty_value() {
|
||||
let disabled = filter_entries(
|
||||
GitConfigScope::Command,
|
||||
Path::new("command line:"),
|
||||
"filter.demo.clean",
|
||||
"",
|
||||
);
|
||||
let selected = BTreeMap::from([(b"file.txt".to_vec(), "demo".to_string())]);
|
||||
assert_eq!(
|
||||
selected_executable_filter(&disabled, &selected).expect("empty filter policy"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_attribute_parser_rejects_malformed_or_unexpected_records() {
|
||||
let paths = vec![b"a.txt".to_vec(), b"b.txt".to_vec()];
|
||||
let parsed =
|
||||
parse_filter_attributes(b"a.txt\0filter\0unspecified\0b.txt\0filter\0lfs\0", &paths)
|
||||
.expect("parse attributes");
|
||||
assert_eq!(
|
||||
parsed.get(b"a.txt".as_slice()).map(String::as_str),
|
||||
Some("unspecified")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get(b"b.txt".as_slice()).map(String::as_str),
|
||||
Some("lfs")
|
||||
);
|
||||
|
||||
for output in [
|
||||
b"a.txt\0filter\0unspecified".as_slice(),
|
||||
b"a.txt\0merge\0unspecified\0b.txt\0filter\0lfs\0".as_slice(),
|
||||
b"a.txt\0filter\0unspecified\0".as_slice(),
|
||||
b"a.txt\0filter\0unspecified\0a.txt\0filter\0lfs\0".as_slice(),
|
||||
] {
|
||||
assert!(
|
||||
parse_filter_attributes(output, &paths).is_err(),
|
||||
"{output:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_entries(
|
||||
scope: GitConfigScope,
|
||||
origin: &Path,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> BTreeMap<String, GitConfigEntry> {
|
||||
let origin = if origin == Path::new("command line:") {
|
||||
"command line:".to_string()
|
||||
} else {
|
||||
format!("file:{}", origin.display())
|
||||
};
|
||||
BTreeMap::from([(
|
||||
key.to_string(),
|
||||
GitConfigEntry {
|
||||
scope,
|
||||
origin,
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
)])
|
||||
}
|
||||
Reference in New Issue
Block a user