//! Effective patch-path discovery and safe staging guards. use std::io; use std::path::Path; use std::path::PathBuf; use crate::apply::run_git; use crate::apply::safe_git_config_parts; use crate::apply::write_temp_patch; use crate::git_command::GitRunner; use crate::git_config::path_is_within; use crate::safe_git::ensure_no_selected_executable_git_filters; pub(crate) fn extract_effective_paths_from_patch( git: &GitRunner, patch_path: &Path, revert: bool, ) -> io::Result> { let forward_paths = git_apply_numstat_paths(git, 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 reverse_paths = git_apply_numstat_paths(git, patch_path, !revert)?; if forward_paths.len() != reverse_paths.len() { return Err(io::Error::new( io::ErrorKind::InvalidData, "forward and reverse patch parsing returned different path counts", )); } let effective_paths: std::collections::BTreeSet = forward_paths.into_iter().chain(reverse_paths).collect(); if effective_paths.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "patch does not identify any paths", )); } effective_paths .into_iter() .map(validate_patch_path) .collect() } /// 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((tmpdir, patch_path)) = write_temp_patch(diff_text) else { return Vec::new(); }; let paths = std::env::current_dir() .ok() .and_then(|cwd| GitRunner::for_cwd(&cwd).ok()) .and_then(|git| { extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ false).ok() }) .unwrap_or_default(); drop(tmpdir); paths } fn git_apply_numstat_paths( git: &GitRunner, patch_path: &Path, revert: bool, ) -> io::Result> { let mut cmd = git.command(); cmd.args(["apply", "--numstat", "-z"]); if revert { cmd.arg("-R"); } cmd.arg("--") .arg(patch_path) .current_dir(patch_path.parent().unwrap_or_else(|| Path::new("."))); let out = git.output(cmd)?; if !out.status.success() { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!( "failed to parse patch paths: {}", String::from_utf8_lossy(&out.stderr).trim() ), )); } parse_numstat_paths(&out.stdout) } 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, old)?; 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(()) } 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 git = GitRunner::for_cwd_io(git_root)?; let (tmpdir, patch_path) = write_temp_patch(diff)?; let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?; let _guard = tmpdir; stage_effective_paths(&git, git_root, &paths) } pub(crate) fn stage_effective_paths( git: &GitRunner, git_root: &Path, paths: &[String], ) -> io::Result<()> { ensure_no_selected_executable_git_filters(git, git_root, paths, &[])?; let confined = confine_patch_paths(git, git_root, paths)?; let mut existing = Vec::new(); for path in confined.into_exact_leaves()? { let joined = git_root.join(&path); if let Ok(metadata) = std::fs::symlink_metadata(&joined) { if leaf_is_traversable_directory(metadata.file_type()) { return Err(containment_error( "refusing to recursively stage a directory patch path", )); } existing.push(path); } } if existing.is_empty() { return Ok(()); } let mut args = vec![ "--literal-pathspecs".to_string(), "add".to_string(), "--".to_string(), ]; args.extend(existing); let config_parts = safe_git_config_parts(); let (_code, _, _) = run_git(git, git_root, &config_parts, &args)?; // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. Ok(()) } #[cfg(not(windows))] fn leaf_is_traversable_directory(file_type: std::fs::FileType) -> bool { file_type.is_dir() } #[cfg(windows)] 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 { 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() } } pub(crate) fn confine_patch_paths( git: &GitRunner, git_root: &Path, paths: &[String], ) -> 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 canonical_root = std::fs::canonicalize(git_root)?; let metadata_dirs = canonical_git_metadata_dirs(git, &canonical_root)?; 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(git: &GitRunner, git_root: &Path) -> io::Result> { let config_parts = safe_git_config_parts(); let queries = [ vec!["rev-parse".to_string(), "--absolute-git-dir".to_string()], vec!["rev-parse".to_string(), "--git-common-dir".to_string()], ]; let mut metadata_dirs = std::collections::BTreeSet::new(); for args in queries { let (code, stdout, stderr) = run_git(git, git_root, &config_parts, &args)?; if code != 0 { return Err(io::Error::other(format!( "failed to resolve Git repository metadata (exit {code}): {}", stderr.trim() ))); } let path = stdout.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 { git_root.join(path) }; metadata_dirs.insert(std::fs::canonicalize(absolute)?); } Ok(metadata_dirs.into_iter().collect()) } 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;