//! Effective patch-path discovery and safe staging guards. use std::collections::BTreeSet; use std::io; use std::path::Component; use std::path::Path; use std::path::PathBuf; use crate::apply::write_temp_patch; use crate::exact_staging::update_index_exact_paths_from_apply; use crate::exact_staging::update_index_exact_paths_standalone; use crate::git_command::GitRunner; use crate::git_config::path_is_within; use crate::guarded_config::GuardedGitConfig; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PatchPathInventory { pub(crate) primary_paths: Vec, pub(crate) effective_paths: Vec, } /// Extract requested-orientation and effective paths through one bound /// operation configuration. pub(crate) fn extract_patch_path_inventory_guarded( config: &GuardedGitConfig<'_>, patch_path: &Path, revert: bool, ) -> io::Result { let primary_paths = git_apply_numstat_paths_guarded(config, patch_path, revert)?; // `git apply --numstat` reports only the destination of a rename. Parse the // opposite orientation too so both endpoints are included in the result. let opposite_paths = git_apply_numstat_paths_guarded(config, patch_path, !revert)?; if primary_paths.len() != opposite_paths.len() { return Err(io::Error::new( io::ErrorKind::InvalidData, "forward and reverse patch parsing returned different path counts", )); } let primary_paths = primary_paths .into_iter() .map(validate_patch_path) .collect::>>()?; let opposite_paths = opposite_paths .into_iter() .map(validate_patch_path) .collect::>>()?; let effective_paths = primary_paths .iter() .cloned() .chain(opposite_paths) .collect::>(); if effective_paths.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "patch does not identify any paths", )); } Ok(PatchPathInventory { primary_paths: primary_paths.into_iter().collect(), effective_paths: effective_paths.into_iter().collect(), }) } #[cfg(test)] enum StagePathsHookPoint { BindRunner = 0, ResolvePhysicalCwd = 1, PreSpawn = 2, } #[cfg(test)] std::thread_local! { #[allow(clippy::type_complexity)] static STAGE_PATHS_HOOKS: std::cell::RefCell<[Option>; 3]> = std::cell::RefCell::new([None, None, None]); } #[cfg(test)] #[cfg_attr(not(unix), allow(dead_code))] fn set_stage_paths_hook(point: StagePathsHookPoint, hook: impl FnOnce() + 'static) { STAGE_PATHS_HOOKS.with(|slots| { let prior = slots.borrow_mut()[point as usize].replace(Box::new(hook)); assert!(prior.is_none()); }); } #[cfg(test)] fn run_stage_paths_hook(point: StagePathsHookPoint) { let hook = STAGE_PATHS_HOOKS.with(|slots| slots.borrow_mut()[point as usize].take()); if let Some(hook) = hook { hook(); } } /// Extract effective patch paths through a bound operation configuration. pub(crate) fn extract_effective_paths_from_patch_guarded( config: &GuardedGitConfig<'_>, patch_path: &Path, revert: bool, ) -> io::Result> { Ok(extract_patch_path_inventory_guarded(config, patch_path, revert)?.effective_paths) } fn git_apply_numstat_paths_guarded( config: &GuardedGitConfig<'_>, patch_path: &Path, revert: bool, ) -> io::Result> { let mut command = config.apply_command()?; #[cfg(test)] run_stage_paths_hook(StagePathsHookPoint::PreSpawn); command.args(["--numstat", "-z"]); if revert { command.arg("-R"); } command.arg("--").arg(patch_path); let output = command.output()?; if !output.status.success() { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!( "failed to parse patch paths: {}", String::from_utf8_lossy(&output.stderr).trim() ), )); } parse_numstat_paths(&output.stdout) } /// Best-effort extraction of the paths Git would apply. /// /// Security-sensitive callers must use the fallible internal extractor so an /// invalid or ambiguous patch is rejected instead of becoming an empty list. pub fn extract_paths_from_patch(diff_text: &str) -> Vec { let Ok(cwd) = std::env::current_dir() else { return Vec::new(); }; extract_paths_from_patch_from_cwd(diff_text, &cwd) } fn extract_paths_from_patch_from_cwd(diff_text: &str, cwd: &Path) -> Vec { let Ok((tmpdir, patch_path)) = write_temp_patch(diff_text) else { return Vec::new(); }; let paths = (|| -> io::Result> { let git = GitRunner::for_cwd_io(cwd)?; let requested_cwd = std::fs::canonicalize(cwd)?; let git_root = crate::get_git_repo_root(&requested_cwd) .ok_or_else(|| io::Error::other("not a Git repository"))?; let git_root = std::fs::canonicalize(git_root)?; let config = GuardedGitConfig::authorize(&git, &git_root, Vec::new())?; extract_effective_paths_from_patch_guarded(&config, &patch_path, /*revert*/ false) })() .unwrap_or_default(); drop(tmpdir); paths } fn parse_numstat_paths(output: &[u8]) -> io::Result> { if !output.is_empty() && !output.ends_with(&[0]) { return Err(io::Error::new( io::ErrorKind::InvalidData, "git apply returned an unterminated numstat path record", )); } let mut paths = Vec::new(); let mut records = output.split(|byte| *byte == 0).peekable(); while let Some(record) = records.next() { if record.is_empty() && records.peek().is_none() { break; } let mut fields = record.splitn(3, |byte| *byte == b'\t'); let _added = fields.next().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned an ambiguous numstat path record", ) })?; let _deleted = fields.next().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned an ambiguous numstat path record", ) })?; let path = fields.next().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned an ambiguous numstat path record", ) })?; if path.is_empty() { let _old = records .next() .filter(|path| !path.is_empty()) .ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned an incomplete rename path record", ) })?; let new = records .next() .filter(|path| !path.is_empty()) .ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned an incomplete rename path record", ) })?; insert_numstat_path(&mut paths, new)?; } else { insert_numstat_path(&mut paths, path)?; } } Ok(paths) } fn insert_numstat_path(paths: &mut Vec, path: &[u8]) -> io::Result<()> { let path = std::str::from_utf8(path).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, "git apply returned a non-UTF-8 patch path", ) })?; paths.push(path.to_string()); Ok(()) } pub(crate) fn validate_patch_path(path: String) -> io::Result { if path.starts_with('/') || path.ends_with('/') || invalid_platform_patch_path(&path) || path .split('/') .any(|component| component.is_empty() || component == "." || component == "..") { return Err(io::Error::new( io::ErrorKind::InvalidInput, "patch path is not a normalized repository-relative path", )); } Ok(path) } #[cfg(windows)] fn invalid_platform_patch_path(path: &str) -> bool { invalid_windows_patch_path(path) } #[cfg(not(windows))] fn invalid_platform_patch_path(_path: &str) -> bool { false } #[cfg(any(windows, test))] fn invalid_windows_patch_path(path: &str) -> bool { path.split('/').any(invalid_windows_patch_component) } #[cfg(any(windows, test))] fn invalid_windows_patch_component(component: &str) -> bool { if component.bytes().any(|byte| { byte <= 0x1f || matches!(byte, b'\\' | b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*') }) || matches!(component.as_bytes().last(), Some(b'.' | b' ')) { return true; } let reserved_suffix = |suffix: &str| { let suffix = suffix.trim_start_matches(' '); suffix.is_empty() || matches!(suffix.as_bytes().first(), Some(b'.' | b':')) }; if ["AUX", "CON", "CONIN$", "CONOUT$", "NUL", "PRN"] .iter() .any(|reserved| { component .get(..reserved.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(reserved)) && component.get(reserved.len()..).is_some_and(reserved_suffix) }) { return true; } [b"COM", b"LPT"].iter().any(|reserved| { let Some(rest) = component.get(3..) else { return false; }; let mut chars = rest.chars(); component.as_bytes()[..3].eq_ignore_ascii_case(*reserved) && matches!(chars.next(), Some('1'..='9' | '¹' | '²' | '³')) && reserved_suffix(chars.as_str()) }) } /// Stage only the files that actually exist on disk for the given diff. pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { let immutable_traversal = !git_root.is_absolute() && git_root.components().next().is_some() && git_root .components() .all(|component| matches!(component, Component::CurDir | Component::ParentDir)); let (guarded_root, bound_git) = if git_root.is_absolute() { (git_root.to_path_buf(), None) } else if immutable_traversal { let git = GitRunner::for_cwd_io(Path::new("."))?; #[cfg(test)] run_stage_paths_hook(StagePathsHookPoint::BindRunner); let resolve_immutable_traversal = || -> io::Result { let mut root = std::fs::canonicalize(Path::new("."))?; for component in git_root.components() { match component { Component::CurDir => {} Component::ParentDir => { root.pop(); } _ => unreachable!("non-traversal component"), } } Ok(root) }; let initial_root = resolve_immutable_traversal()?; git.ensure_active_worktree_root(&initial_root)?; #[cfg(test)] run_stage_paths_hook(StagePathsHookPoint::ResolvePhysicalCwd); let live_root = resolve_immutable_traversal()?; git.ensure_active_worktree_root(&live_root)?; (live_root, Some(git)) } else { (std::env::current_dir()?.join(git_root), None) }; if !guarded_root.is_absolute() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "repository root route could not be anchored to an absolute path", )); } let git = match bound_git { Some(git) => git, None => GitRunner::for_cwd_io(&guarded_root)?, }; git.ensure_repository_root_route(&guarded_root)?; let canonical_root = std::fs::canonicalize(&guarded_root)?; let mut config = GuardedGitConfig::authorize(&git, &canonical_root, Vec::new())?; let (tmpdir, patch_path) = write_temp_patch(diff)?; let paths = extract_effective_paths_from_patch_guarded(&config, &patch_path, /*revert*/ true)?; let _guard = tmpdir; stage_effective_paths_standalone(&mut config, &paths) } pub(crate) fn stage_effective_paths_from_apply( config: &mut GuardedGitConfig<'_>, paths: &[String], ) -> io::Result<()> { let (existing, content_filter_paths) = classify_exact_staging_leaves(config, paths)?; let _result = update_index_exact_paths_from_apply(config, &existing, &content_filter_paths)?; // Preserve the public helper's historical best-effort treatment of a // non-zero staging command. Security and probe failures still propagate. Ok(()) } fn stage_effective_paths_standalone( config: &mut GuardedGitConfig<'_>, paths: &[String], ) -> io::Result<()> { let (existing, content_filter_paths) = classify_exact_staging_leaves(config, paths)?; let _result = update_index_exact_paths_standalone(config, &existing, &content_filter_paths)?; // Preserve the public helper's historical best-effort treatment of a // non-zero staging command. Security and probe failures still propagate. Ok(()) } fn classify_exact_staging_leaves( config: &GuardedGitConfig<'_>, paths: &[String], ) -> io::Result<(Vec, Vec)> { let confined = confine_patch_paths_guarded(config, paths)?; let mut existing = Vec::new(); let mut content_filter_paths = Vec::new(); for path in confined.into_exact_leaves()? { let joined = config.canonical_root().join(&path); if let Ok(metadata) = std::fs::symlink_metadata(&joined) { let file_type = metadata.file_type(); if leaf_is_traversable_directory(file_type) { return Err(containment_error( "refusing to recursively stage a directory patch path", )); } if leaf_may_run_git_content_filter(file_type) { content_filter_paths.push(path.clone()); } existing.push(path); } } Ok((existing, content_filter_paths)) } #[cfg(not(windows))] pub(crate) fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool { file_type.is_dir() } #[cfg(unix)] pub(crate) fn leaf_may_run_git_content_filter(file_type: std::fs::FileType) -> bool { // Git stages an exact Unix symlink as a mode-120000 blob containing the // link target. The whole-command neutralizer covers unrelated racy index // entries while this target is omitted from the selected-filter refusal. !file_type.is_symlink() } #[cfg(not(unix))] pub(crate) fn leaf_may_run_git_content_filter(_file_type: std::fs::FileType) -> bool { // Keep the conservative policy on platforms whose symlink staging // behavior can depend on repository and host configuration. true } #[cfg(windows)] pub(crate) fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool { use std::os::windows::fs::FileTypeExt; // Git traverses junctions and container-mapped directory symlinks. Refuse // all directory-valued reparse leaves; true file symlinks remain allowed. file_type.is_dir() || file_type.is_symlink_dir() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConfinedPathRole { StrictAncestor, Leaf, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConfinedPathOrigin { Raw, Canonical, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConfinedPathCandidate { pub(crate) path: String, pub(crate) origin: ConfinedPathOrigin, pub(crate) role: ConfinedPathRole, pub(crate) depth: usize, } impl ConfinedPathCandidate { fn new(path: String, origin: ConfinedPathOrigin, role: ConfinedPathRole) -> Self { let depth = path.split('/').count(); Self { path, origin, role, depth, } } } #[derive(Debug)] pub(crate) struct ConfinedPatchPath { pub(crate) exact_leaf: String, pub(crate) candidates: Vec, } #[derive(Debug)] pub(crate) struct ConfinedPatchPaths { pub(crate) entries: Vec, } impl ConfinedPatchPaths { pub(crate) fn into_exact_leaves(self) -> io::Result> { self.entries .into_iter() .map(|entry| { let expected_depth = entry.exact_leaf.split('/').count(); entry .candidates .into_iter() .find(|candidate| { candidate.origin == ConfinedPathOrigin::Raw && candidate.role == ConfinedPathRole::Leaf && candidate.depth == expected_depth && candidate.path == entry.exact_leaf }) .map(|candidate| candidate.path) .ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "confined patch path is missing its exact raw leaf", ) }) }) .collect() } } #[cfg(test)] pub(crate) fn confine_patch_paths( git: &GitRunner, git_root: &Path, paths: &[String], ) -> io::Result { let canonical_root = std::fs::canonicalize(git_root)?; let config = GuardedGitConfig::authorize(git, &canonical_root, Vec::new())?; confine_patch_paths_guarded(&config, paths) } pub(crate) fn confine_patch_paths_guarded( config: &GuardedGitConfig<'_>, paths: &[String], ) -> io::Result { let canonical_root = std::fs::canonicalize(config.canonical_root())?; if paths.is_empty() || paths.iter().all(|path| !path.contains('/')) { return confine_patch_paths_with_metadata(&canonical_root, paths, &[]); } let metadata_dirs = canonical_git_metadata_dirs_guarded(config)?; confine_patch_paths_with_metadata(&canonical_root, paths, &metadata_dirs) } fn confine_patch_paths_with_metadata( canonical_root: &Path, paths: &[String], metadata_dirs: &[PathBuf], ) -> io::Result { if paths.is_empty() { return Ok(ConfinedPatchPaths { entries: Vec::new(), }); } if paths.iter().all(|path| !path.contains('/')) { return Ok(ConfinedPatchPaths { entries: paths .iter() .map(|leaf| ConfinedPatchPath { exact_leaf: leaf.clone(), candidates: [ConfinedPathOrigin::Raw, ConfinedPathOrigin::Canonical] .map(|origin| { ConfinedPathCandidate::new(leaf.clone(), origin, ConfinedPathRole::Leaf) }) .into(), }) .collect(), }); } let mut entries = Vec::with_capacity(paths.len()); let mut prefix_cache = std::collections::BTreeMap::new(); for leaf in paths { let components = leaf.split('/').collect::>(); let mut candidates = Vec::new(); insert_candidate_prefixes( components.iter().copied(), ConfinedPathOrigin::Raw, &mut candidates, ); let (existing_len, mut projected) = longest_existing_strict_prefix( canonical_root, &components, metadata_dirs, &mut prefix_cache, )?; projected.extend( components[existing_len..] .iter() .map(|component| (*component).to_string()), ); insert_candidate_prefixes( projected.iter().map(String::as_str), ConfinedPathOrigin::Canonical, &mut candidates, ); entries.push(ConfinedPatchPath { exact_leaf: leaf.clone(), candidates, }); } Ok(ConfinedPatchPaths { entries }) } fn longest_existing_strict_prefix( canonical_root: &Path, components: &[&str], metadata_dirs: &[PathBuf], prefix_cache: &mut std::collections::BTreeMap>>, ) -> io::Result<(usize, Vec)> { let mut longest = (0, Vec::new()); for existing_len in 1..components.len() { let prefix = components[..existing_len].join("/"); if let Some(cached) = prefix_cache.get(&prefix) { if let Some(relative) = cached { longest = (existing_len, relative.clone()); } continue; } match std::fs::canonicalize(canonical_root.join(&prefix)) { Ok(resolved) => { let relative = confined_relative_components(&resolved, canonical_root, metadata_dirs)?; prefix_cache.insert(prefix, Some(relative.clone())); longest = (existing_len, relative); } Err(error) if error.kind() == io::ErrorKind::NotFound => { prefix_cache.insert(prefix, None); } Err(error) => return Err(error), } } Ok(longest) } fn confined_relative_components( resolved: &Path, canonical_root: &Path, metadata_dirs: &[PathBuf], ) -> io::Result> { if metadata_dirs .iter() .any(|metadata_dir| path_is_within(resolved, metadata_dir)) { return Err(containment_error( "patch path alias resolves into Git repository metadata", )); } let relative = resolved .strip_prefix(canonical_root) .map_err(|_| containment_error("patch path alias resolves outside the Git worktree"))?; if relative.as_os_str().is_empty() { return Err(containment_error( "patch path alias resolves to the Git worktree root", )); } relative .components() .map(|component| { component .as_os_str() .to_str() .map(str::to_string) .ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "patch path alias is not valid UTF-8", ) }) }) .collect() } fn containment_error(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::PermissionDenied, message) } fn canonical_git_metadata_dirs_guarded(config: &GuardedGitConfig<'_>) -> io::Result> { let queries = [ ["rev-parse", "--absolute-git-dir"], ["rev-parse", "--git-common-dir"], ]; let mut metadata_dirs = std::collections::BTreeSet::new(); for args in queries { #[cfg(test)] CONTAINMENT_METADATA_QUERY_COUNT.with(|count| count.set(count.get() + 1)); let mut command = config.rev_parse_command()?; command.args(&args[1..]); let output = command.output()?; if !output.status.success() { return Err(io::Error::other(format!( "failed to resolve Git repository metadata (status {}): {}", output.status, String::from_utf8_lossy(&output.stderr).trim() ))); } let path = String::from_utf8_lossy(&output.stdout); let path = path.trim_end_matches(['\r', '\n']); if path.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidData, "Git returned an empty repository metadata path", )); } let path = PathBuf::from(path); let absolute = if path.is_absolute() { path } else { config.canonical_root().join(path) }; metadata_dirs.insert(std::fs::canonicalize(absolute)?); } Ok(metadata_dirs.into_iter().collect()) } #[cfg(test)] thread_local! { static CONTAINMENT_METADATA_QUERY_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[cfg(test)] pub(crate) fn reset_containment_metadata_query_count() { CONTAINMENT_METADATA_QUERY_COUNT.with(|count| count.set(0)); } #[cfg(test)] pub(crate) fn containment_metadata_query_count() -> usize { CONTAINMENT_METADATA_QUERY_COUNT.with(std::cell::Cell::get) } fn insert_candidate_prefixes<'a>( components: impl IntoIterator, origin: ConfinedPathOrigin, candidates: &mut Vec, ) { let components = components.into_iter().collect::>(); let mut path = String::new(); for (index, component) in components.iter().enumerate() { if !path.is_empty() { path.push('/'); } path.push_str(component); candidates.push(ConfinedPathCandidate::new( path.clone(), origin, if index + 1 == components.len() { ConfinedPathRole::Leaf } else { ConfinedPathRole::StrictAncestor }, )); } } #[cfg(test)] #[path = "patch_paths_tests.rs"] mod tests;