diff --git a/codex-rs/git-utils/src/apply.rs b/codex-rs/git-utils/src/apply.rs index 46348fc284..4dd850b435 100644 --- a/codex-rs/git-utils/src/apply.rs +++ b/codex-rs/git-utils/src/apply.rs @@ -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 { // 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"); diff --git a/codex-rs/git-utils/src/git_config.rs b/codex-rs/git-utils/src/git_config.rs index 236ab89ef7..a08bdc8d31 100644 --- a/codex-rs/git-utils/src/git_config.rs +++ b/codex-rs/git-utils/src/git_config.rs @@ -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> { + 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::>(); + 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> { + 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::>(); + 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 { + 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; diff --git a/codex-rs/git-utils/src/git_config_tests.rs b/codex-rs/git-utils/src/git_config_tests.rs index 3795caf2f8..0e2bae1a8a 100644 --- a/codex-rs/git-utils/src/git_config_tests.rs +++ b/codex-rs/git-utils/src/git_config_tests.rs @@ -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); +} diff --git a/codex-rs/git-utils/src/safe_git.rs b/codex-rs/git-utils/src/safe_git.rs index 8d45a33552..4d28c631c3 100644 --- a/codex-rs/git-utils/src/safe_git.rs +++ b/codex-rs/git-utils/src/safe_git.rs @@ -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::>(); + 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> { + 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> { + 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 { + 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], + git_config_args: &[String], +) -> io::Result, 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, + attributes: &BTreeMap, String>, +) -> io::Result)>> { + 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 { + 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]) -> 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], +) -> io::Result, String>> { + let expected = expected_paths + .iter() + .map(Vec::as_slice) + .collect::>(); + 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::>(); + 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; diff --git a/codex-rs/git-utils/src/safe_git_tests.rs b/codex-rs/git-utils/src/safe_git_tests.rs new file mode 100644 index 0000000000..7f5dca8a83 --- /dev/null +++ b/codex-rs/git-utils/src/safe_git_tests.rs @@ -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 { + 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(), + }, + )]) +}