diff --git a/codex-rs/git-utils/src/exact_filesystem_policy.rs b/codex-rs/git-utils/src/exact_filesystem_policy.rs new file mode 100644 index 0000000000..53add49ba6 --- /dev/null +++ b/codex-rs/git-utils/src/exact_filesystem_policy.rs @@ -0,0 +1,65 @@ +//! Filesystem-spelling policy for exact staging. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// Find requested paths that are neither exact index names nor composed of +/// physically exact directory-entry spellings. +/// +/// Git's byte-oriented ignore-case matching does not cover every alias that a +/// case-insensitive filesystem recognizes. Exact index names remain valid for +/// tracked updates and deletions. Other paths must exist under their requested +/// spelling instead of merely resolving through a case or normalization alias; +/// unresolved paths are refused before a mixed update can partially stage. +pub(crate) fn filesystem_spelling_conflicts( + git_root: &Path, + paths: &[String], + exact_index_paths: &BTreeSet<&[u8]>, +) -> io::Result> { + let mut cache = BTreeMap::>>::new(); + let mut conflicts = BTreeSet::new(); + for path in paths { + if exact_index_paths.contains(path.as_bytes()) { + continue; + } + if !has_exact_directory_entry_spelling(git_root, path, &mut cache)? { + conflicts.insert(path.clone()); + } + } + Ok(conflicts) +} + +fn has_exact_directory_entry_spelling( + git_root: &Path, + path: &str, + cache: &mut BTreeMap>>, +) -> io::Result { + let mut parent = git_root.to_path_buf(); + for component in path.split('/') { + if !cache.contains_key(&parent) { + let entries = match std::fs::read_dir(&parent) { + Ok(entries) => Some( + entries + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>()?, + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + cache.insert(parent.clone(), entries); + } + let Some(Some(entries)) = cache.get(&parent) else { + return Ok(false); + }; + if !entries.contains(OsStr::new(component)) { + return Ok(false); + } + parent.push(component); + } + Ok(true) +} diff --git a/codex-rs/git-utils/src/exact_index_policy.rs b/codex-rs/git-utils/src/exact_index_policy.rs new file mode 100644 index 0000000000..11677730d2 --- /dev/null +++ b/codex-rs/git-utils/src/exact_index_policy.rs @@ -0,0 +1,481 @@ +//! Index-name and flag policy for exact staging. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::io; +use std::path::Path; + +use crate::exact_filesystem_policy::filesystem_spelling_conflicts; +use crate::git_command::GitRunner; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ExactIndexPolicy { + Proceed { + paths: Vec, + content_filter_paths: Vec, + }, + Refuse { + stderr: String, + }, +} + +#[derive(Clone, Copy, Debug)] +struct IndexEntry<'a> { + tag: u8, + path: &'a [u8], +} + +#[derive(Debug)] +struct CaseConflict { + reason: &'static str, + paths: BTreeSet, +} + +impl CaseConflict { + fn new(reason: &'static str, paths: impl IntoIterator) -> Self { + Self { + reason, + paths: paths.into_iter().collect(), + } + } + + fn add_index_paths<'a>(&mut self, paths: impl IntoIterator) { + self.paths.extend( + paths + .into_iter() + .map(|path| String::from_utf8_lossy(path).into_owned()), + ); + } + + fn message(&self) -> String { + format!( + "refusing {} under core.ignoreCase: {}", + self.reason, + quote_paths(&self.paths) + ) + } +} + +struct IndexEvidence<'a> { + full_paths: BTreeMap, BTreeSet<&'a [u8]>>, + directory_prefixes: BTreeMap, BTreeSet<&'a [u8]>>, +} + +impl<'a> IndexEvidence<'a> { + fn collect(entries: &[IndexEntry<'a>], requested: &[String]) -> Self { + let relevant = requested + .iter() + .flat_map(|path| path_prefixes(path.as_bytes())) + .map(ascii_fold) + .collect::>(); + let mut full_paths = BTreeMap::, BTreeSet<&[u8]>>::new(); + let mut directory_prefixes = BTreeMap::, BTreeSet<&[u8]>>::new(); + for entry in entries { + let folded = ascii_fold(entry.path); + if relevant.contains(&folded) { + full_paths.entry(folded).or_default().insert(entry.path); + } + for prefix in non_leaf_prefixes(entry.path) { + let folded = ascii_fold(prefix); + if relevant.contains(&folded) { + directory_prefixes.entry(folded).or_default().insert(prefix); + } + } + } + Self { + full_paths, + directory_prefixes, + } + } +} + +pub(crate) fn resolve_exact_index_policy( + git: &GitRunner, + git_root: &Path, + paths: &[String], + content_filter_paths: &[String], + git_config_args: &[String], +) -> io::Result { + let output = read_index(git, git_root, git_config_args)?; + let entries = parse_index_entries(&output)?; + let ignore_case = + effective_git_bool(git, git_root, git_config_args, "core.ignoreCase")?.unwrap_or(false); + + let (paths, content_filter_paths) = if ignore_case { + let mapped = match map_ignore_case_paths(git_root, &entries, paths, content_filter_paths) { + Ok(mapped) => mapped, + Err(conflict) => { + return Ok(ExactIndexPolicy::Refuse { + stderr: conflict.message(), + }); + } + }; + let exact_index_paths = entries + .iter() + .map(|entry| entry.path) + .collect::>(); + let conflicts = filesystem_spelling_conflicts(git_root, &mapped.0, &exact_index_paths)?; + if !conflicts.is_empty() { + return Ok(ExactIndexPolicy::Refuse { + stderr: CaseConflict::new( + "filesystem spelling aliases or unresolved paths", + conflicts, + ) + .message(), + }); + } + mapped + } else { + (paths.to_vec(), content_filter_paths.to_vec()) + }; + + let exact = paths.iter().map(String::as_bytes).collect::>(); + let mut skipped = BTreeSet::new(); + let mut assumed_unchanged = BTreeSet::new(); + for entry in &entries { + if !exact.contains(entry.path) { + continue; + } + let path = std::str::from_utf8(entry.path).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "non-UTF-8 exact path in Git index output", + ) + })?; + if entry.tag == b'S' { + skipped.insert(path.to_string()); + } + if entry.tag.is_ascii_lowercase() { + assumed_unchanged.insert(path.to_string()); + } + } + if skipped.is_empty() && assumed_unchanged.is_empty() { + return Ok(ExactIndexPolicy::Proceed { + paths, + content_filter_paths, + }); + } + + let mut reasons = Vec::new(); + if !skipped.is_empty() { + reasons.push(format!("skip-worktree path(s): {}", quote_paths(&skipped))); + } + if !assumed_unchanged.is_empty() { + reasons.push(format!( + "assume-unchanged path(s): {}", + quote_paths(&assumed_unchanged) + )); + } + Ok(ExactIndexPolicy::Refuse { + stderr: format!("refusing to stage {}", reasons.join("; ")), + }) +} + +fn read_index(git: &GitRunner, git_root: &Path, git_config_args: &[String]) -> io::Result> { + // One full, byte-oriented scan avoids invoking Git once per patch path and + // lets one parse enforce aliases, directory prefixes, and flags together. + let mut command = git.command(); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + .args(["ls-files", "--cached", "-v", "-z"]) + .current_dir(git_root); + let output = git.output(command)?; + if !output.status.success() { + return Err(io::Error::other(format!( + "git exact-index probe failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(output.stdout) +} + +fn parse_index_entries(output: &[u8]) -> io::Result>> { + if output.is_empty() { + return Ok(Vec::new()); + } + let Some(body) = output.strip_suffix(&[0]) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unterminated Git exact-index output", + )); + }; + body.split(|byte| *byte == 0) + .map(|record| { + let [tag, b' ', path @ ..] = record else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "malformed Git exact-index output", + )); + }; + if !valid_index_tag(*tag) || path.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected Git exact-index output", + )); + } + Ok(IndexEntry { tag: *tag, path }) + }) + .collect() +} + +fn valid_index_tag(tag: u8) -> bool { + matches!( + tag, + b'H' | b'S' | b'M' | b'R' | b'C' | b'K' | b'h' | b's' | b'm' | b'r' | b'c' | b'k' | b'?' + ) +} + +fn map_ignore_case_paths( + git_root: &Path, + entries: &[IndexEntry<'_>], + paths: &[String], + content_filter_paths: &[String], +) -> Result<(Vec, Vec), CaseConflict> { + let evidence = IndexEvidence::collect(entries, paths); + let mut groups = BTreeMap::, BTreeSet>::new(); + for path in paths { + groups + .entry(ascii_fold(path.as_bytes())) + .or_default() + .insert(path.clone()); + } + + let mut mapping = BTreeMap::new(); + for (folded, spellings) in groups { + let aliases = evidence + .full_paths + .get(&folded) + .cloned() + .unwrap_or_default(); + let canonical = match (spellings.len(), aliases.len()) { + (1, 0) => { + let Some(spelling) = spellings.first() else { + return Err(CaseConflict::new("empty request group", [])); + }; + spelling.clone() + } + (1, 1) => { + let Some(alias) = aliases.first() else { + return Err(CaseConflict::new("missing index alias", spellings)); + }; + let alias = std::str::from_utf8(alias) + .map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?; + if !spellings.contains(alias) { + let mut conflict = + CaseConflict::new("index full-path alias", spellings.clone()); + conflict.add_index_paths(aliases.iter().copied()); + return Err(conflict); + } + alias.to_string() + } + (_, 1) => { + let Some(alias) = aliases.first() else { + return Err(CaseConflict::new("missing index alias", spellings)); + }; + let alias = std::str::from_utf8(alias) + .map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?; + if !spellings.contains(alias) { + let mut conflict = + CaseConflict::new("index full-path alias", spellings.clone()); + conflict.add_index_paths(aliases.iter().copied()); + return Err(conflict); + } + if let Some(missing) = spellings.iter().find(|spelling| { + *spelling != alias + && std::fs::symlink_metadata(git_root.join(spelling)).is_err() + }) { + return Err(CaseConflict::new( + "missing requested case alias", + [missing.clone(), alias.to_string()], + )); + } + alias.to_string() + } + (1, _) => { + let mut conflict = CaseConflict::new("ambiguous index aliases", spellings); + conflict.add_index_paths(aliases.iter().copied()); + return Err(conflict); + } + _ if aliases.is_empty() => { + return Err(CaseConflict::new( + "request-set full-path aliases", + spellings, + )); + } + _ => { + let mut conflict = CaseConflict::new("ambiguous index aliases", spellings); + conflict.add_index_paths(aliases.iter().copied()); + return Err(conflict); + } + }; + for spelling in spellings { + mapping.insert(spelling, canonical.clone()); + } + } + + let paths = map_and_dedupe(paths, &mapping) + .ok_or_else(|| CaseConflict::new("unmapped requested path", paths.to_vec()))?; + let content_filter_paths = map_and_dedupe(content_filter_paths, &mapping) + .ok_or_else(|| CaseConflict::new("unmapped content-filter path", paths.clone()))?; + if let Some(conflict) = requested_paths_conflict(&paths) { + return Err(conflict); + } + if let Some(conflict) = index_paths_conflict(&paths, &evidence) { + return Err(conflict); + } + Ok((paths, content_filter_paths)) +} + +fn map_and_dedupe(paths: &[String], mapping: &BTreeMap) -> Option> { + let mut seen = BTreeSet::new(); + let mut mapped = Vec::new(); + for path in paths { + let path = mapping.get(path)?.clone(); + if seen.insert(path.clone()) { + mapped.push(path); + } + } + Some(mapped) +} + +fn requested_paths_conflict(paths: &[String]) -> Option { + let mut full = BTreeMap::, BTreeSet>::new(); + let mut directories = BTreeMap::, BTreeSet>::new(); + for path in paths { + full.entry(ascii_fold(path.as_bytes())) + .or_default() + .insert(path.clone()); + for prefix in non_leaf_prefixes(path.as_bytes()) { + directories + .entry(ascii_fold(prefix)) + .or_default() + .insert(String::from_utf8_lossy(prefix).into_owned()); + } + } + if let Some(spellings) = full.values().find(|spellings| spellings.len() > 1) { + return Some(CaseConflict::new( + "request-set full-path aliases", + spellings.clone(), + )); + } + if let Some(spellings) = directories.values().find(|spellings| spellings.len() > 1) { + return Some(CaseConflict::new( + "request-set directory-prefix aliases", + spellings.clone(), + )); + } + for (folded, leaf) in full { + if let Some(prefixes) = directories.get(&folded) { + let paths = leaf.into_iter().chain(prefixes.iter().cloned()); + return Some(CaseConflict::new( + "request-set file/directory aliases", + paths, + )); + } + } + None +} + +fn index_paths_conflict(paths: &[String], evidence: &IndexEvidence<'_>) -> Option { + for path in paths { + for prefix in non_leaf_prefixes(path.as_bytes()) { + let folded = ascii_fold(prefix); + if let Some(ancestors) = evidence.full_paths.get(&folded) { + let mut conflict = + CaseConflict::new("index file/requested-directory alias", [path.clone()]); + conflict.add_index_paths(ancestors.iter().copied()); + return Some(conflict); + } + if let Some(index_prefixes) = evidence.directory_prefixes.get(&folded) + && index_prefixes.iter().any(|indexed| *indexed != prefix) + { + let mut conflict = + CaseConflict::new("index directory-prefix alias", [path.clone()]); + conflict.add_index_paths(index_prefixes.iter().copied()); + return Some(conflict); + } + } + if let Some(descendants) = evidence + .directory_prefixes + .get(&ascii_fold(path.as_bytes())) + { + let mut conflict = + CaseConflict::new("requested file/index-directory alias", [path.clone()]); + conflict.add_index_paths(descendants.iter().copied()); + return Some(conflict); + } + } + None +} + +fn path_prefixes(path: &[u8]) -> impl Iterator { + non_leaf_prefixes(path).chain(std::iter::once(path)) +} + +fn non_leaf_prefixes(path: &[u8]) -> impl Iterator { + path.iter() + .enumerate() + .filter_map(move |(index, byte)| (*byte == b'/').then_some(&path[..index])) +} + +fn ascii_fold(value: &[u8]) -> Vec { + value.iter().map(u8::to_ascii_lowercase).collect() +} + +pub(crate) fn effective_git_bool( + git: &GitRunner, + git_root: &Path, + git_config_args: &[String], + key: &str, +) -> io::Result> { + let mut command = git.command(); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + .args(["config", "--type=bool", "--get", key]) + .current_dir(git_root); + let output = git.output(command)?; + if output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty() { + return Ok(None); + } + if !output.status.success() { + return Err(io::Error::other(format!( + "git boolean config probe for {key} failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Ok(Some(true)), + "false" => Ok(Some(false)), + value => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected normalized Git boolean value for {key}: {value:?}"), + )), + } +} + +fn quote_paths(paths: &BTreeSet) -> String { + let shown = paths + .iter() + .take(8) + .map(|path| { + let mut characters = path.chars(); + let prefix = characters.by_ref().take(160).collect::(); + if characters.next().is_some() { + format!("{prefix}...") + } else { + path.clone() + } + }) + .map(|path| format!("{path:?}")) + .collect::>() + .join(", "); + if paths.len() > 8 { + format!("{shown}, ...") + } else { + shown + } +} diff --git a/codex-rs/git-utils/src/exact_staging.rs b/codex-rs/git-utils/src/exact_staging.rs new file mode 100644 index 0000000000..26ac6f41c8 --- /dev/null +++ b/codex-rs/git-utils/src/exact_staging.rs @@ -0,0 +1,294 @@ +//! Non-recursive staging for already-confined repository paths. + +use std::collections::BTreeSet; +use std::io; +use std::io::Seek; +use std::io::Write; +use std::path::Path; +use std::process::Stdio; + +use crate::apply::run_git; +use crate::exact_index_policy::ExactIndexPolicy; +use crate::exact_index_policy::effective_git_bool; +use crate::exact_index_policy::resolve_exact_index_policy; +use crate::git_command::GitRunner; +use crate::patch_paths::validate_patch_path; +use crate::safe_git::ensure_no_selected_git_add_filters; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StagePathsResult { + pub(crate) exit_code: i32, + pub(crate) stderr: String, +} + +/// Stage only the literal paths supplied by a caller that has already applied +/// the operation-specific containment and filesystem policy. +/// +/// Unlike `git add`, `update-index` never treats a path that races from a leaf +/// to a directory as a recursive request. Callers that require staging to +/// succeed can inspect the returned status; the public forward helper retains +/// its historical best-effort handling of a non-zero Git command. +/// +/// This is not a transactional filesystem-confinement primitive. A concurrent +/// strict-ancestor swap to a symlink or Windows junction can still redirect the +/// bytes read for an already-confined index pathname. It cannot make this +/// exact command recurse into, or create index entries for, descendant paths; +/// the filter neutralizer also remains in force for the entire command. +pub(crate) fn update_index_exact_paths( + git: &GitRunner, + git_root: &Path, + paths: &[String], + content_filter_paths: &[String], + git_config_args: &[String], +) -> io::Result { + if paths.is_empty() { + return Ok(StagePathsResult { + exit_code: 0, + stderr: String::new(), + }); + } + for path in paths { + if path.as_bytes().contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "exact staging path contains NUL", + )); + } + let _ = validate_patch_path(path.clone())?; + } + let exact_paths = paths.iter().map(String::as_str).collect::>(); + if content_filter_paths + .iter() + .any(|path| !exact_paths.contains(path.as_str())) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "content-filter staging path is not in the exact staging set", + )); + } + + let (paths, content_filter_paths) = match resolve_exact_index_policy( + git, + git_root, + paths, + content_filter_paths, + git_config_args, + )? { + ExactIndexPolicy::Proceed { + paths, + content_filter_paths, + } => (paths, content_filter_paths), + ExactIndexPolicy::Refuse { stderr } => { + return Ok(StagePathsResult { + exit_code: 1, + stderr, + }); + } + }; + + let ignored = ignored_untracked_paths(git, git_root, &paths, git_config_args)?; + if !ignored.is_empty() { + return Ok(StagePathsResult { + exit_code: 1, + stderr: format!( + "refusing to stage ignored untracked path(s): {}", + quote_paths(&ignored) + ), + }); + } + if let Some(result) = sparse_checkout_policy(git, git_root, &paths, git_config_args)? { + return Ok(result); + } + + let filter_guard = + ensure_no_selected_git_add_filters(git, git_root, &content_filter_paths, git_config_args)?; + let mut guarded_config = git_config_args.to_vec(); + guarded_config.extend_from_slice(filter_guard.git_config_args()); + + let mut update_args = vec![ + "--literal-pathspecs".to_string(), + "update-index".to_string(), + "--add".to_string(), + "--remove".to_string(), + "--".to_string(), + ]; + update_args.extend_from_slice(&paths); + let (exit_code, _stdout, stderr) = run_git(git, git_root, &guarded_config, &update_args)?; + Ok(StagePathsResult { exit_code, stderr }) +} + +fn ignored_untracked_paths( + git: &GitRunner, + git_root: &Path, + paths: &[String], + git_config_args: &[String], +) -> io::Result> { + if paths.is_empty() { + return Ok(BTreeSet::new()); + } + // `check-ignore --stdin` still recognizes a leading `:` as pathspec + // magic, while its command mode rejects the global literal-pathspec + // override. A leading `./` makes every normalized repository-relative + // name unambiguously literal without changing which file it denotes. + let probe_paths = paths + .iter() + .map(|path| format!("./{path}")) + .collect::>(); + let input = nul_path_input(&probe_paths)?; + let mut command = git.command(); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + // `check-ignore --stdin` consumes pathnames rather than pathspecs and + // rejects the global literal-pathspec mode as unsupported magic. + .args(["check-ignore", "--stdin", "-z"]) + .current_dir(git_root) + .stdin(Stdio::from(input)); + let output = git.output(command)?; + match output.status.code() { + Some(0) => { + let ignored = parse_reported_paths(&output.stdout, &probe_paths, "ignored-path")? + .into_iter() + .map(|path| path.trim_start_matches("./").to_string()) + .collect::>(); + if ignored.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Git ignored-path probe reported success without a path", + )); + } + Ok(ignored) + } + Some(1) if output.stdout.is_empty() && output.stderr.is_empty() => Ok(BTreeSet::new()), + _ => Err(io::Error::other(format!( + "git ignored-path probe failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))), + } +} + +fn sparse_checkout_policy( + git: &GitRunner, + git_root: &Path, + paths: &[String], + git_config_args: &[String], +) -> io::Result> { + if !effective_git_bool(git, git_root, git_config_args, "core.sparseCheckout")?.unwrap_or(false) + { + return Ok(None); + } + + // Do not let an older Git fall back to a PATH-resolved + // `git-sparse-checkout` helper. Repository-controlled PATH entries are + // outside the trusted executable boundary. `--list-cmds=builtins` is a + // main-program capability query and cannot dispatch an external command. + let mut builtins_command = git.command(); + builtins_command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + .arg("--list-cmds=builtins") + .current_dir(git_root); + let builtins = git.output(builtins_command)?; + let sparse_checkout_is_builtin = builtins.status.success() + && std::str::from_utf8(&builtins.stdout) + .is_ok_and(|output| output.lines().any(|command| command == "sparse-checkout")); + if !sparse_checkout_is_builtin { + return Ok(Some(StagePathsResult { + exit_code: builtins.status.code().unwrap_or(-1).max(1), + stderr: "unable to verify sparse-checkout staging policy with a built-in Git command" + .to_string(), + })); + } + + let input = nul_path_input(paths)?; + let mut command = git.command(); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(git_config_args) + .args(["sparse-checkout", "check-rules", "-z"]) + .current_dir(git_root) + .stdin(Stdio::from(input)); + let output = git.output(command)?; + if !output.status.success() { + return Ok(Some(StagePathsResult { + exit_code: output.status.code().unwrap_or(-1), + stderr: format!( + "unable to verify sparse-checkout staging policy: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + })); + } + let included = parse_reported_paths(&output.stdout, paths, "sparse-checkout")?; + let excluded = paths + .iter() + .filter(|path| !included.contains(path.as_str())) + .cloned() + .collect::>(); + if excluded.is_empty() { + Ok(None) + } else { + Ok(Some(StagePathsResult { + exit_code: 1, + stderr: format!( + "refusing to stage path(s) outside the sparse-checkout definition: {}", + quote_paths(&excluded) + ), + })) + } +} + +fn nul_path_input(paths: &[String]) -> io::Result { + let mut input = tempfile::tempfile()?; + for path in paths { + input.write_all(path.as_bytes())?; + input.write_all(&[0])?; + } + input.rewind()?; + Ok(input) +} + +fn parse_reported_paths( + output: &[u8], + expected_paths: &[String], + probe: &str, +) -> io::Result> { + if output.is_empty() { + return Ok(BTreeSet::new()); + } + let Some(body) = output.strip_suffix(&[0]) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unterminated Git {probe} output"), + )); + }; + let expected = expected_paths + .iter() + .map(String::as_bytes) + .collect::>(); + let mut reported = BTreeSet::new(); + for path in body.split(|byte| *byte == 0) { + if path.is_empty() || !expected.contains(path) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected Git {probe} output"), + )); + } + let path = std::str::from_utf8(path).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("non-UTF-8 Git {probe} output"), + ) + })?; + reported.insert(path.to_string()); + } + Ok(reported) +} + +fn quote_paths(paths: &BTreeSet) -> String { + paths + .iter() + .map(|path| format!("{path:?}")) + .collect::>() + .join(", ") +} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index d5c225a6d1..fb008cc4ee 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -3,6 +3,9 @@ mod apply_output; mod baseline; mod branch; mod errors; +mod exact_filesystem_policy; +mod exact_index_policy; +mod exact_staging; mod fsmonitor; mod git_command; mod git_config; diff --git a/codex-rs/git-utils/src/patch_paths.rs b/codex-rs/git-utils/src/patch_paths.rs index d8823227b9..7e3ebe1463 100644 --- a/codex-rs/git-utils/src/patch_paths.rs +++ b/codex-rs/git-utils/src/patch_paths.rs @@ -7,9 +7,9 @@ use std::path::PathBuf; use crate::apply::run_git; use crate::apply::safe_git_config_parts; use crate::apply::write_temp_patch; +use crate::exact_staging::update_index_exact_paths; use crate::git_command::GitRunner; use crate::git_config::path_is_within; -use crate::safe_git::ensure_no_selected_git_add_filters; pub(crate) fn extract_effective_paths_from_patch( git: &GitRunner, @@ -157,7 +157,7 @@ fn insert_numstat_path(paths: &mut Vec, path: &[u8]) -> io::Result<()> { Ok(()) } -fn validate_patch_path(path: String) -> io::Result { +pub(crate) fn validate_patch_path(path: String) -> io::Result { if path.starts_with('/') || path.ends_with('/') || invalid_platform_patch_path(&path) @@ -261,18 +261,15 @@ pub(crate) fn stage_effective_paths( if existing.is_empty() { return Ok(()); } - let filter_guard = - ensure_no_selected_git_add_filters(git, git_root, &content_filter_paths, git_config_args)?; - let mut args = vec![ - "--literal-pathspecs".to_string(), - "add".to_string(), - "--".to_string(), - ]; - args.extend(existing); - let mut guarded_config = git_config_args.to_vec(); - guarded_config.extend_from_slice(filter_guard.git_config_args()); - let (_code, _, _) = run_git(git, git_root, &guarded_config, &args)?; - // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. + let _result = update_index_exact_paths( + git, + git_root, + &existing, + &content_filter_paths, + git_config_args, + )?; + // Preserve the public helper's historical best-effort treatment of a + // non-zero staging command. Security and probe failures still propagate. Ok(()) } diff --git a/codex-rs/git-utils/src/patch_paths_tests.rs b/codex-rs/git-utils/src/patch_paths_tests.rs index 889d46dc6f..421ad3f878 100644 --- a/codex-rs/git-utils/src/patch_paths_tests.rs +++ b/codex-rs/git-utils/src/patch_paths_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::apply::ApplyGitRequest; use crate::apply::apply_git_patch; +use crate::exact_staging::StagePathsResult; +use crate::exact_staging::update_index_exact_paths; use std::ffi::OsStr; use std::io; use std::path::Path; @@ -513,13 +515,47 @@ fn init_racy_filter_fixture(driver: &str, command: &str) -> RacyFilterFixture { RacyFilterFixture { repo, marker } } -#[cfg(unix)] fn index_entry(root: &Path, path: &str) -> String { let (code, stdout, stderr) = run(root, &["git", "ls-files", "--stage", "--", path]); assert_eq!(code, 0, "read index entry: {stderr}"); stdout } +fn exact_stage(root: &Path, paths: &[&str], content_filter_paths: &[&str]) -> StagePathsResult { + let git = GitRunner::for_cwd_io(root).expect("trusted Git"); + update_index_exact_paths( + &git, + root, + &paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + &content_filter_paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + &safe_git_config_parts(), + ) + .expect("run exact staging primitive") +} + +fn assert_exact_refused_unchanged( + root: &Path, + paths: &[&str], + content_filter_paths: &[&str], + message_fragment: &str, +) -> StagePathsResult { + let before = git_index_bytes(root); + let result = exact_stage(root, paths, content_filter_paths); + assert_ne!(result.exit_code, 0, "exact staging unexpectedly succeeded"); + assert!( + result.stderr.contains(message_fragment), + "missing {message_fragment:?}: {result:?}" + ); + assert_eq!(git_index_bytes(root), before, "refusal mutated the index"); + result +} + #[cfg(any(unix, windows))] fn create_dir_alias(target: &Path, alias: &Path) -> DirectoryAlias { #[cfg(unix)] @@ -747,6 +783,932 @@ fn stage_allows_ordinary_missing_and_unrelated_unicode_paths() { assert_stage_refused(root, "directory", &before); } +#[test] +fn exact_staging_adds_an_untracked_file_and_removes_a_tracked_deletion() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(".gitignore"), "gone.txt\n").expect("write ignore rule"); + std::fs::write(root.join("gone.txt"), "gone\n").expect("write deletion fixture"); + assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0); + assert_eq!(run(root, &["git", "add", "-f", "gone.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0); + std::fs::remove_file(root.join("gone.txt")).expect("remove tracked file"); + std::fs::write(root.join("fresh.txt"), "fresh\n").expect("write untracked file"); + + let result = exact_stage(root, &["gone.txt", "fresh.txt"], &["fresh.txt"]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + let (code, staged, stderr) = run(root, &["git", "diff", "--cached", "--name-status"]); + assert_eq!(code, 0, "read staged changes: {stderr}"); + assert_eq!(staged, "A\tfresh.txt\nD\tgone.txt\n"); +} + +#[test] +fn exact_staging_preserves_executable_index_mode_when_filemode_is_false() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("mode.txt"), "old\n").expect("write mode fixture"); + assert_eq!(run(root, &["git", "add", "mode.txt"]).0, 0); + assert_eq!( + run(root, &["git", "update-index", "--chmod=+x", "mode.txt"]).0, + 0 + ); + assert_eq!(run(root, &["git", "commit", "-m", "executable"]).0, 0); + assert_eq!(run(root, &["git", "config", "core.filemode", "false"]).0, 0); + std::fs::write(root.join("mode.txt"), "new\n").expect("modify mode fixture"); + + let result = exact_stage(root, &["mode.txt"], &["mode.txt"]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!( + index_entry(root, "mode.txt").starts_with("100755 "), + "exact staging must retain the effective index mode" + ); +} + +#[test] +fn ignored_untracked_path_returns_nonzero_without_partial_staging() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write ignore rule"); + assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "ignore rule"]).0, 0); + std::fs::write(root.join("ignored.txt"), "ignored\n").expect("write ignored file"); + std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary file"); + + let result = exact_stage( + root, + &["ignored.txt", "ordinary.txt"], + &["ignored.txt", "ordinary.txt"], + ); + assert_ne!(result.exit_code, 0); + assert!(result.stderr.contains("ignored.txt"), "{}", result.stderr); + assert!( + run(root, &["git", "diff", "--cached", "--name-only"]) + .1 + .is_empty(), + "the ignored-path preflight must prevent partial staging" + ); + + let git = GitRunner::for_cwd_io(root).expect("trusted Git"); + stage_effective_paths( + &git, + root, + &["ignored.txt".to_string(), "ordinary.txt".to_string()], + &safe_git_config_parts(), + ) + .expect("public staging remains best effort for ignored paths"); + assert!( + run(root, &["git", "diff", "--cached", "--name-only"]) + .1 + .is_empty() + ); +} + +#[test] +fn ignored_missing_untracked_path_returns_nonzero_without_partial_staging() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(".gitignore"), "missing-ignored.txt\n").expect("write ignore rule"); + assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "ignore rule"]).0, 0); + std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary file"); + + let result = exact_stage( + root, + &["missing-ignored.txt", "ordinary.txt"], + &["ordinary.txt"], + ); + assert_ne!(result.exit_code, 0); + assert!( + result.stderr.contains("missing-ignored.txt"), + "{}", + result.stderr + ); + assert!( + run(root, &["git", "diff", "--cached", "--name-only"]) + .1 + .is_empty(), + "the missing ignored-path preflight must prevent partial staging" + ); +} + +#[test] +fn tracked_path_that_matches_an_ignore_rule_still_stages() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(".gitignore"), "tracked.txt\n").expect("write ignore rule"); + std::fs::write(root.join("tracked.txt"), "old\n").expect("write tracked file"); + assert_eq!( + run(root, &["git", "add", "-f", ".gitignore", "tracked.txt"]).0, + 0 + ); + assert_eq!(run(root, &["git", "commit", "-m", "tracked ignored"]).0, 0); + std::fs::write(root.join("tracked.txt"), "new\n").expect("modify tracked file"); + + let result = exact_stage(root, &["tracked.txt"], &["tracked.txt"]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert_eq!( + run(root, &["git", "diff", "--cached", "--name-only"]).1, + "tracked.txt\n" + ); +} + +#[test] +fn exact_staging_refuses_skip_and_assume_unchanged_without_mutation() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("existing.txt"), "old existing\n").expect("write existing fixture"); + std::fs::write(root.join("missing.txt"), "old missing\n").expect("write missing fixture"); + std::fs::write(root.join("assumed.txt"), "old assumed\n").expect("write assumed fixture"); + assert_eq!( + run( + root, + &["git", "add", "assumed.txt", "existing.txt", "missing.txt"] + ) + .0, + 0 + ); + assert_eq!(run(root, &["git", "commit", "-m", "skip base"]).0, 0); + assert_eq!( + run( + root, + &[ + "git", + "update-index", + "--skip-worktree", + "--", + "existing.txt", + "missing.txt", + ], + ) + .0, + 0 + ); + assert_eq!( + run( + root, + &[ + "git", + "update-index", + "--assume-unchanged", + "--", + "assumed.txt", + ], + ) + .0, + 0 + ); + std::fs::write(root.join("existing.txt"), "new existing\n").expect("modify existing fixture"); + std::fs::remove_file(root.join("missing.txt")).expect("remove missing fixture"); + std::fs::remove_file(root.join("assumed.txt")).expect("remove assumed fixture"); + let before = git_index_bytes(root); + + let refused = exact_stage( + root, + &["assumed.txt", "existing.txt", "missing.txt"], + &["existing.txt"], + ); + assert_ne!(refused.exit_code, 0); + assert!(refused.stderr.contains("skip-worktree"), "{refused:?}"); + assert!(refused.stderr.contains("assume-unchanged"), "{refused:?}"); + assert!(refused.stderr.contains("assumed.txt"), "{refused:?}"); + assert!(refused.stderr.contains("existing.txt"), "{refused:?}"); + assert!(refused.stderr.contains("missing.txt"), "{refused:?}"); + assert_eq!(git_index_bytes(root), before); + + let git = GitRunner::for_cwd_io(root).expect("trusted Git"); + stage_effective_paths( + &git, + root, + &["existing.txt".to_string()], + &safe_git_config_parts(), + ) + .expect("public staging remains best effort for skip-worktree paths"); + assert_eq!(git_index_bytes(root), before); + + assert_eq!( + run( + root, + &[ + "git", + "update-index", + "--no-skip-worktree", + "--", + "existing.txt", + "missing.txt", + ], + ) + .0, + 0 + ); + assert_eq!( + run( + root, + &[ + "git", + "update-index", + "--no-assume-unchanged", + "--", + "assumed.txt", + ], + ) + .0, + 0 + ); + let staged = exact_stage( + root, + &["assumed.txt", "existing.txt", "missing.txt"], + &["existing.txt"], + ); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert_eq!( + run(root, &["git", "diff", "--cached", "--name-status"]).1, + "D\tassumed.txt\nM\texisting.txt\nD\tmissing.txt\n" + ); +} + +#[test] +fn exact_staging_refuses_case_mismatched_index_aliases_without_mutation() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("foo.txt"), "old\n").expect("write case fixture"); + assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + assert_eq!( + run( + root, + &["git", "update-index", "--skip-worktree", "--", "foo.txt",], + ) + .0, + 0 + ); + let before = git_index_bytes(root); + + let refused = exact_stage(root, &["FOO.txt"], &["FOO.txt"]); + assert_ne!(refused.exit_code, 0); + assert!(refused.stderr.contains("core.ignoreCase"), "{refused:?}"); + assert!(refused.stderr.contains("FOO.txt"), "{refused:?}"); + assert_eq!(git_index_bytes(root), before); + assert_eq!(run(root, &["git", "ls-files"]).1, "foo.txt\n"); + + std::fs::write(root.join("FOO.txt"), "alias\n").expect("write case alias"); + let mapped_flag = exact_stage(root, &["foo.txt", "FOO.txt"], &["foo.txt", "FOO.txt"]); + assert_ne!(mapped_flag.exit_code, 0); + assert!( + mapped_flag.stderr.contains("skip-worktree"), + "{mapped_flag:?}" + ); + assert_eq!(git_index_bytes(root), before); + + let exact_spelling = exact_stage(root, &["foo.txt"], &["foo.txt"]); + assert_ne!(exact_spelling.exit_code, 0); + assert!( + exact_spelling.stderr.contains("skip-worktree"), + "{exact_spelling:?}" + ); + assert_eq!(git_index_bytes(root), before); + + assert_eq!( + run( + root, + &["git", "update-index", "--no-skip-worktree", "--", "foo.txt",], + ) + .0, + 0 + ); + std::fs::write(root.join("foo.txt"), "new\n").expect("modify case fixture"); + let staged = exact_stage(root, &["foo.txt"], &["foo.txt"]); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert_eq!( + run(root, &["git", "diff", "--cached", "--name-only"]).1, + "foo.txt\n" + ); +} + +#[test] +fn exact_staging_maps_only_explicit_present_case_aliases() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("foo.txt"), "base\n").expect("write base path"); + assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + + std::fs::write(root.join("foo.txt"), "lower\n").expect("modify canonical path"); + std::fs::write(root.join("FOO.txt"), "upper\n").expect("write requested alias"); + let expected = std::fs::read_to_string(root.join("foo.txt")).expect("read canonical path"); + let staged = exact_stage(root, &["foo.txt", "FOO.txt"], &["FOO.txt"]); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert_eq!(run(root, &["git", "ls-files"]).1, "foo.txt\n"); + assert_eq!(run(root, &["git", "show", ":foo.txt"]).1, expected); + + assert_eq!(run(root, &["git", "commit", "-m", "mapped alias"]).0, 0); + std::fs::remove_file(root.join("FOO.txt")).expect("remove requested alias"); + assert_exact_refused_unchanged( + root, + &["foo.txt", "FOO.txt"], + &["foo.txt"], + "missing requested case alias", + ); +} + +#[test] +fn exact_staging_refuses_case_collisions_within_the_request_set() { + let repo = init_repo(); + let root = repo.path(); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::write(root.join("foo.txt"), "lower\n").expect("write lower path"); + std::fs::write(root.join("FOO.txt"), "upper\n").expect("write upper path"); + assert_exact_refused_unchanged( + root, + &["foo.txt", "FOO.txt"], + &["foo.txt", "FOO.txt"], + "request-set full-path aliases", + ); + + std::fs::create_dir_all(root.join("dir")).expect("create lower directory"); + std::fs::create_dir_all(root.join("DIR")).expect("create upper directory"); + std::fs::write(root.join("dir/lower.txt"), "lower\n").expect("write lower child"); + std::fs::write(root.join("DIR/upper.txt"), "upper\n").expect("write upper child"); + assert_exact_refused_unchanged( + root, + &["dir/lower.txt", "DIR/upper.txt"], + &["dir/lower.txt", "DIR/upper.txt"], + "request-set directory-prefix aliases", + ); + + assert_exact_refused_unchanged( + root, + &["Leaf", "leaf/child.txt"], + &[], + "request-set file/directory aliases", + ); +} + +#[test] +fn exact_staging_refuses_ambiguous_and_prefix_colliding_index_paths() { + { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("foo.txt"), "base\n").expect("write base path"); + assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + let oid = run(root, &["git", "rev-parse", ":foo.txt"]).1; + let cacheinfo = format!("100644,{},FOO.txt", oid.trim()); + assert_eq!( + run( + root, + &[ + "git", + "--literal-pathspecs", + "update-index", + "--add", + "--cacheinfo", + &cacheinfo + ], + ) + .0, + 0 + ); + assert_eq!(run(root, &["git", "ls-files"]).1, "FOO.txt\nfoo.txt\n"); + assert_exact_refused_unchanged(root, &["foo.txt"], &["foo.txt"], "ambiguous index aliases"); + } + + { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("Foo"), "leaf\n").expect("write ancestor leaf"); + assert_eq!(run(root, &["git", "add", "Foo"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "ancestor base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::remove_file(root.join("Foo")).expect("remove ancestor leaf"); + std::fs::create_dir(root.join("foo")).expect("create alias directory"); + std::fs::write(root.join("foo/bar.txt"), "child\n").expect("write child"); + assert_exact_refused_unchanged( + root, + &["foo/bar.txt"], + &["foo/bar.txt"], + "index file/requested-directory alias", + ); + } + + { + let repo = init_repo(); + let root = repo.path(); + std::fs::create_dir(root.join("Foo")).expect("create canonical directory"); + std::fs::write(root.join("Foo/base.txt"), "base\n").expect("write indexed sibling"); + assert_eq!(run(root, &["git", "add", "Foo/base.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "directory base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::create_dir_all(root.join("foo")).expect("create alias directory"); + std::fs::write(root.join("foo/new.txt"), "new\n").expect("write new sibling"); + assert_exact_refused_unchanged( + root, + &["foo/new.txt"], + &["foo/new.txt"], + "index directory-prefix alias", + ); + assert_exact_refused_unchanged(root, &["foo"], &[], "requested file/index-directory alias"); + } +} + +#[test] +fn exact_staging_allows_case_distinct_paths_when_ignore_case_is_false() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("lower.txt"), "lower\n").expect("write lower path"); + std::fs::write(root.join("LOWER.txt"), "upper\n").expect("write upper path"); + if std::fs::read(root.join("lower.txt")).unwrap() + == std::fs::read(root.join("LOWER.txt")).unwrap() + { + return; + } + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "false"]).0, + 0 + ); + let staged = exact_stage( + root, + &["lower.txt", "LOWER.txt"], + &["lower.txt", "LOWER.txt"], + ); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert_eq!(run(root, &["git", "ls-files"]).1, "LOWER.txt\nlower.txt\n"); +} + +#[test] +fn exact_staging_handles_non_ascii_filesystem_case_aliases_conservatively() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("Ä.txt"), "upper base\n").expect("write indexed Unicode path"); + std::fs::write(root.join("Δ.txt"), "delete me\n").expect("write Unicode deletion path"); + assert_eq!(run(root, &["git", "add", "Ä.txt", "Δ.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "Unicode base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + + std::fs::write(root.join("Ä.txt"), "upper changed\n").expect("modify exact Unicode path"); + std::fs::remove_file(root.join("Δ.txt")).expect("remove exact Unicode path"); + let exact = exact_stage(root, &["Ä.txt", "Δ.txt"], &["Ä.txt"]); + assert_eq!(exact.exit_code, 0, "{}", exact.stderr); + assert_eq!( + run(root, &["git", "diff", "--cached", "--name-only", "-z"]).1, + "Ä.txt\0Δ.txt\0" + ); + assert_eq!(run(root, &["git", "commit", "-m", "exact Unicode"]).0, 0); + + std::fs::write(root.join("ä.txt"), "lower alias\n").expect("write Unicode case alias"); + let has_distinct_lower_entry = std::fs::read_dir(root) + .expect("read repository entries") + .any(|entry| entry.expect("read repository entry").file_name() == "ä.txt"); + if has_distinct_lower_entry { + let staged = exact_stage(root, &["ä.txt"], &["ä.txt"]); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0ä.txt\0"); + } else { + assert_exact_refused_unchanged( + root, + &["ä.txt"], + &["ä.txt"], + "filesystem spelling aliases or unresolved paths", + ); + assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0"); + + std::fs::rename(root.join("Ä.txt"), root.join("rename.tmp")) + .expect("begin physical case rename"); + std::fs::rename(root.join("rename.tmp"), root.join("ä.txt")) + .expect("finish physical case rename"); + let renamed = exact_stage(root, &["ä.txt"], &["ä.txt"]); + assert_eq!(renamed.exit_code, 0, "{}", renamed.stderr); + assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0ä.txt\0"); + } +} + +#[test] +fn exact_staging_refuses_alias_only_filesystem_lookups_without_an_index_entry() { + let repo = init_repo(); + let root = repo.path(); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::write(root.join("Foo.txt"), "stored spelling\n").expect("write stored spelling"); + assert_exact_refused_unchanged( + root, + &["foo.txt"], + &["foo.txt"], + "filesystem spelling aliases or unresolved paths", + ); +} + +#[test] +fn exact_staging_checks_unicode_aliases_in_both_directions() { + for (indexed, requested) in [ + ("KELVIN.txt", "kelvin.txt"), + ("kelvin.txt", "KELVIN.txt"), + ("ſAFE.txt", "safe.txt"), + ("safe.txt", "ſAFE.txt"), + ("é.txt", "e\u{301}.txt"), + ("e\u{301}.txt", "é.txt"), + ] { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join(indexed), "indexed\n").expect("write indexed spelling"); + assert_eq!(run(root, &["git", "add", indexed]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "spelling base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::write(root.join(requested), "requested\n").expect("write requested spelling"); + let requested_is_physically_exact = std::fs::read_dir(root) + .expect("read repository entries") + .any(|entry| entry.expect("read repository entry").file_name() == requested); + let requested_is_exact_index = run(root, &["git", "ls-files", "-z"]) + .1 + .split('\0') + .any(|path| path == requested); + if requested_is_physically_exact || requested_is_exact_index { + let staged = exact_stage(root, &[requested], &[requested]); + assert_eq!( + staged.exit_code, 0, + "{indexed:?} -> {requested:?}: {staged:?}" + ); + assert!(!index_entry(root, requested).is_empty()); + } else { + let before = git_index_bytes(root); + let refused = exact_stage(root, &[requested], &[requested]); + assert_ne!( + refused.exit_code, 0, + "{indexed:?} -> {requested:?} unexpectedly succeeded: {refused:?}" + ); + assert!( + refused + .stderr + .contains("filesystem spelling aliases or unresolved paths"), + "{indexed:?} -> {requested:?}: {refused:?}" + ); + assert_eq!(git_index_bytes(root), before); + } + } +} + +#[test] +fn exact_staging_allows_a_new_child_under_an_exact_unicode_directory() { + let repo = init_repo(); + let root = repo.path(); + std::fs::create_dir(root.join("Ärea")).expect("create Unicode directory"); + std::fs::write(root.join("Ärea/base.txt"), "base\n").expect("write indexed child"); + assert_eq!(run(root, &["git", "add", "Ärea/base.txt"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "directory base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::write(root.join("Ärea/new.txt"), "new\n").expect("write new child"); + + let staged = exact_stage(root, &["Ärea/new.txt"], &["Ärea/new.txt"]); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert!(!index_entry(root, "Ärea/new.txt").is_empty()); +} + +#[cfg(unix)] +#[test] +fn exact_index_scan_allows_an_unrelated_non_utf8_path() { + use std::io::Write as _; + + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("blob-source"), "unrelated\n").expect("write blob source"); + let oid = run(root, &["git", "hash-object", "-w", "blob-source"]).1; + let mut command = std::process::Command::new("git"); + crate::safe_git::isolate_git_command_environment(&mut command); + let mut child = command + .args(["update-index", "-z", "--index-info"]) + .current_dir(root) + .stdin(std::process::Stdio::piped()) + .spawn() + .expect("spawn raw index update"); + let mut record = format!("100644 {}\t", oid.trim()).into_bytes(); + record.extend_from_slice(b"unrelated-\xff\0"); + child + .stdin + .take() + .expect("raw index stdin") + .write_all(&record) + .expect("write raw index entry"); + let status = child.wait().expect("wait for raw index update"); + assert!(status.success(), "raw index update failed: {status}"); + assert_eq!(run(root, &["git", "commit", "-m", "non-UTF-8 base"]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary path"); + + let staged = exact_stage(root, &["ordinary.txt"], &["ordinary.txt"]); + assert_eq!(staged.exit_code, 0, "{}", staged.stderr); + assert!(!index_entry(root, "ordinary.txt").is_empty()); +} + +#[test] +fn exact_staging_preserves_sparse_checkout_policy() { + let repo = init_repo(); + let root = repo.path(); + std::fs::create_dir_all(root.join("inside")).expect("create included directory"); + std::fs::create_dir_all(root.join("outside")).expect("create excluded directory"); + std::fs::write(root.join("inside/tracked.txt"), "inside\n").expect("write included file"); + std::fs::write(root.join("outside/tracked.txt"), "old\n").expect("write excluded file"); + assert_eq!(run(root, &["git", "add", "."]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "sparse base"]).0, 0); + assert_eq!( + run(root, &["git", "sparse-checkout", "init", "--cone"]).0, + 0 + ); + assert_eq!(run(root, &["git", "sparse-checkout", "set", "inside"]).0, 0); + + std::fs::create_dir_all(root.join("outside")).expect("recreate excluded directory"); + std::fs::write(root.join("outside/tracked.txt"), "new\n") + .expect("modify excluded tracked file"); + std::fs::write(root.join("outside/untracked.txt"), "new\n") + .expect("write excluded untracked file"); + let outside_before = index_entry(root, "outside/tracked.txt"); + let result = exact_stage( + root, + &["outside/tracked.txt", "outside/untracked.txt"], + &["outside/tracked.txt", "outside/untracked.txt"], + ); + assert_ne!(result.exit_code, 0); + assert!( + result.stderr.contains("sparse-checkout"), + "{}", + result.stderr + ); + assert_eq!(index_entry(root, "outside/tracked.txt"), outside_before); + assert!(index_entry(root, "outside/untracked.txt").is_empty()); + assert!( + run(root, &["git", "diff", "--cached", "--name-only"]) + .1 + .is_empty() + ); + + std::fs::write(root.join("inside/untracked.txt"), "new\n") + .expect("write included untracked file"); + let included = exact_stage(root, &["inside/untracked.txt"], &["inside/untracked.txt"]); + assert_eq!(included.exit_code, 0, "{}", included.stderr); + assert!(!index_entry(root, "inside/untracked.txt").is_empty()); +} + +#[cfg(unix)] +#[test] +fn sparse_rule_probe_failure_is_closed_only_for_sparse_repositories() { + use std::os::unix::fs::PermissionsExt; + + const TEST_NAME: &str = + "patch_paths::tests::sparse_rule_probe_failure_is_closed_only_for_sparse_repositories"; + if let Some(mode) = std::env::var_os("CODEX_GIT_UTILS_FAKE_SPARSE_MODE") { + let root = PathBuf::from( + std::env::var_os("CODEX_GIT_UTILS_TARGET_REPO").expect("target repository"), + ); + let result = exact_stage(&root, &["file.txt"], &["file.txt"]); + if mode == "sparse" { + assert_ne!(result.exit_code, 0); + assert!(result.stderr.contains("fake unsupported check-rules")); + assert!(index_entry(&root, "file.txt").is_empty()); + } else if mode == "legacy" { + assert_ne!(result.exit_code, 0); + assert!(result.stderr.contains("built-in Git command")); + assert!(index_entry(&root, "file.txt").is_empty()); + assert!( + !PathBuf::from( + std::env::var_os("CODEX_GIT_UTILS_SPARSE_HELPER_MARKER") + .expect("helper marker"), + ) + .exists(), + "legacy Git must not dispatch a PATH-resolved sparse helper" + ); + } else { + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!(!index_entry(&root, "file.txt").is_empty()); + } + return; + } + + let wrapper_dir = tempfile::tempdir().expect("wrapper directory"); + let wrapper = wrapper_dir.path().join("git"); + std::fs::write( + &wrapper, + "#!/bin/sh\nprevious=\nfor argument in \"$@\"; do\n if [ \"$CODEX_GIT_UTILS_FAKE_SPARSE_MODE\" = legacy ] && [ \"$argument\" = --list-cmds=builtins ]; then\n echo add\n echo config\n exit 0\n fi\n if [ \"$previous\" = sparse-checkout ] && [ \"$argument\" = check-rules ]; then\n echo fake unsupported check-rules >&2\n exit 129\n fi\n previous=$argument\ndone\nexec \"$CODEX_GIT_UTILS_REAL_GIT\" \"$@\"\n", + ) + .expect("write Git wrapper"); + let mut permissions = std::fs::metadata(&wrapper).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&wrapper, permissions).expect("make wrapper executable"); + let sparse_helper = wrapper_dir.path().join("git-sparse-checkout"); + std::fs::write( + &sparse_helper, + "#!/bin/sh\n: > \"$CODEX_GIT_UTILS_SPARSE_HELPER_MARKER\"\nexit 0\n", + ) + .expect("write malicious sparse helper"); + let mut permissions = std::fs::metadata(&sparse_helper).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&sparse_helper, permissions).expect("make sparse helper executable"); + let real_git = [ + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ] + .into_iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + .expect("native Git outside the test wrapper"); + let wrapper_path = std::env::join_paths([wrapper_dir.path()]).expect("wrapper PATH"); + let helper_marker = wrapper_dir.path().join("sparse-helper-ran"); + + for mode in ["ordinary", "sparse", "legacy"] { + let repo = init_repo(); + let root = repo.path(); + if matches!(mode, "sparse" | "legacy") { + assert_eq!( + run(root, &["git", "config", "core.sparseCheckout", "true"]).0, + 0 + ); + } + std::fs::write(root.join("file.txt"), "new\n").expect("write staging file"); + run_isolated_test( + TEST_NAME, + &[ + ("CODEX_GIT_UTILS_FAKE_SPARSE_MODE", OsStr::new(mode)), + ("CODEX_GIT_UTILS_TARGET_REPO", root.as_os_str()), + ("CODEX_GIT_UTILS_REAL_GIT", real_git.as_os_str()), + ( + "CODEX_GIT_UTILS_SPARSE_HELPER_MARKER", + helper_marker.as_os_str(), + ), + ("PATH", wrapper_path.as_os_str()), + ], + ); + } +} + +#[cfg(unix)] +#[test] +fn exact_staging_treats_pathspec_magic_and_newline_as_literal_bytes() { + let repo = init_repo(); + let root = repo.path(); + commit_seed(root); + assert_eq!( + run(root, &["git", "sparse-checkout", "init", "--cone"]).0, + 0 + ); + assert_eq!(run(root, &["git", "sparse-checkout", "set", "."]).0, 0); + assert_eq!( + run(root, &["git", "config", "core.ignoreCase", "true"]).0, + 0 + ); + let path = ":(glob)*[literal]\nname.txt"; + std::fs::write(root.join(path), "literal\n").expect("write unusual path"); + + let result = exact_stage(root, &[path], &[path]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!(String::from_utf8_lossy(&git_index_bytes(root)).contains(path)); +} + +#[test] +fn exact_staging_does_not_recurse_when_a_leaf_becomes_a_directory() { + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("victim"), "old\n").expect("write tracked leaf"); + assert_eq!(run(root, &["git", "add", "victim"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "tracked leaf"]).0, 0); + std::fs::remove_file(root.join("victim")).expect("remove tracked leaf"); + std::fs::create_dir(root.join("victim")).expect("replace leaf with directory"); + std::fs::write(root.join("victim/child.txt"), "child\n").expect("write descendant"); + + // Passing `victim` as filterable content models a classification made + // immediately before the file-to-directory swap. + let result = exact_stage(root, &["victim"], &["victim"]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!(index_entry(root, "victim/child.txt").is_empty()); + assert!(index_entry(root, "victim").is_empty()); +} + +#[cfg(unix)] +#[test] +fn raced_directory_descendant_filter_is_not_run() { + use std::os::unix::fs::PermissionsExt; + + let repo = init_repo(); + let root = repo.path(); + std::fs::write( + root.join(".gitattributes"), + "victim/child.txt filter=descendant\n", + ) + .expect("write descendant attribute"); + std::fs::write(root.join("victim"), "old\n").expect("write tracked leaf"); + assert_eq!(run(root, &["git", "add", ".gitattributes", "victim"]).0, 0); + assert_eq!(run(root, &["git", "commit", "-m", "tracked leaf"]).0, 0); + + let marker = root.join("descendant-filter-ran"); + let helper = root.join("descendant-filter.sh"); + std::fs::write( + &helper, + format!("#!/bin/sh\n: > '{}'\ncat\n", marker.display()), + ) + .expect("write filter helper"); + let mut permissions = std::fs::metadata(&helper).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&helper, permissions).expect("make filter executable"); + assert_eq!( + run( + root, + &[ + "git", + "config", + "filter.descendant.clean", + &helper.to_string_lossy(), + ], + ) + .0, + 0 + ); + assert_eq!( + run( + root, + &["git", "config", "filter.descendant.required", "true"], + ) + .0, + 0 + ); + std::fs::remove_file(root.join("victim")).expect("remove tracked leaf"); + std::fs::create_dir(root.join("victim")).expect("replace leaf with directory"); + std::fs::write(root.join("victim/child.txt"), "child\n").expect("write descendant"); + + let hash = run( + root, + &[ + "git", + "hash-object", + "--path", + "victim/child.txt", + "--", + "victim/child.txt", + ], + ); + assert_eq!(hash.0, 0, "filter control failed: {}", hash.2); + assert!( + marker.exists(), + "control must prove the helper is executable" + ); + std::fs::remove_file(&marker).expect("reset filter marker"); + + let result = exact_stage(root, &["victim"], &["victim"]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!( + !marker.exists(), + "exact staging must not run the descendant filter" + ); + assert!(index_entry(root, "victim/child.txt").is_empty()); +} + +#[cfg(unix)] +#[test] +fn exact_staging_records_a_symlink_as_a_mode_120000_blob() { + use std::os::unix::fs::symlink; + + let repo = init_repo(); + let root = repo.path(); + symlink("literal-target", root.join("link")).expect("create symlink"); + + let result = exact_stage(root, &["link"], &[]); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert!(index_entry(root, "link").starts_with("120000 ")); + let (code, blob, stderr) = run(root, &["git", "show", ":link"]); + assert_eq!(code, 0, "read staged symlink: {stderr}"); + assert_eq!(blob, "literal-target"); +} + #[test] fn containment_does_not_claim_uninitialized_gitlinks() { let _g = env_lock().lock().unwrap(); @@ -928,10 +1890,21 @@ fn staging_neutralizes_off_path_racy_filters_without_changing_their_index_entry( let control_root = control.repo.path(); std::fs::write(control_root.join("target.txt"), "new target\n") .expect("modify control target"); - let _ = run(control_root, &["git", "add", "--", "target.txt"]); + let _ = run( + control_root, + &[ + "git", + "--literal-pathspecs", + "update-index", + "--add", + "--remove", + "--", + "target.txt", + ], + ); assert!( control.marker.exists(), - "raw git add should execute the off-path racy {driver:?} {command} filter" + "raw update-index should execute the off-path racy {driver:?} {command} filter" ); let guarded = init_racy_filter_fixture(driver, command);