use std::path::{Component, Path, PathBuf};
pub(super) fn repo_relative_path(repo_root: &Path, candidate: &str) -> Result<PathBuf, String> {
for comp in Path::new(candidate).components() {
match comp {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => {
return Err(format!(
"refusing to touch '{candidate}': path escapes the repository via '..'"
));
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!(
"refusing to touch '{candidate}': absolute paths are not repo-relative"
));
}
}
}
let joined = repo_root.join(candidate);
if let Ok(canon) = joined.canonicalize() {
let root = repo_root.canonicalize().map_err(|_| {
format!("refusing to touch '{candidate}': repository root could not be canonicalized")
})?;
if !canon.starts_with(&root) {
return Err(format!(
"refusing to touch '{candidate}': resolves outside the repository"
));
}
}
Ok(joined)
}
pub(super) fn validate_diff_targets(diff: &str, expected_file_path: &str) -> Result<(), String> {
let expected = canonical_rel(expected_file_path);
let reject = |why: String| -> Result<(), String> {
Err(format!(
"refusing to apply patch for '{expected_file_path}': {why}"
))
};
let mut saw_old = false;
let mut saw_new = false;
for raw_line in diff.lines() {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.starts_with("diff --git ")
|| line.starts_with("index ")
|| line.starts_with("old mode")
|| line.starts_with("new mode")
|| line.starts_with("new file")
|| line.starts_with("deleted file")
|| line.starts_with("rename ")
|| line.starts_with("copy ")
|| line.starts_with("similarity ")
|| line.starts_with("dissimilarity ")
|| line.starts_with("GIT binary patch")
|| line.starts_with("Binary files ")
{
return reject(
"diff uses a git-extended/binary header; only a plain unified single-file edit is allowed".to_owned(),
);
}
let (marker, want_prefix) = if line.starts_with("--- ") {
("---", "a/")
} else if line.starts_with("+++ ") {
("+++", "b/")
} else {
continue;
};
let path = line[4..].split('\t').next().unwrap_or(&line[4..]);
if path == "/dev/null" {
return reject("diff creates or deletes a file".to_owned());
}
let Some(target) = path.strip_prefix(want_prefix) else {
return reject(format!(
"diff header '{marker} {path}' lacks the required '{want_prefix}' prefix"
));
};
if !is_safe_repo_relative(target) {
return reject(format!("diff targets an unsafe path '{target}'"));
}
if canonical_rel(target) != expected {
return reject(format!("diff targets a different file '{target}'"));
}
if marker == "---" {
saw_old = true;
} else {
saw_new = true;
}
}
if !saw_old || !saw_new {
return reject(
"diff has no complete `--- a/<file>` + `+++ b/<file>` header for the expected file"
.to_owned(),
);
}
Ok(())
}
fn is_safe_repo_relative(path: &str) -> bool {
let normalized = path.replace('\\', "/");
Path::new(&normalized)
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
fn canonical_rel(path: &str) -> String {
Path::new(&path.replace('\\', "/"))
.components()
.filter_map(|c| match c {
Component::Normal(seg) => Some(seg.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_relative_rejects_traversal_and_absolute() {
let root = Path::new("/repo");
assert!(repo_relative_path(root, "../../etc/passwd").is_err());
assert!(repo_relative_path(root, "src/../../secret").is_err());
assert!(repo_relative_path(root, "/etc/passwd").is_err());
let ok = repo_relative_path(root, "src/foo.rs").expect("relative path ok");
assert_eq!(ok, Path::new("/repo/src/foo.rs"));
assert!(repo_relative_path(root, "./src/foo.rs").is_ok());
}
#[test]
fn validate_diff_accepts_a_single_file_modification() {
let diff = "--- a/src/foo.rs\n+++ b/src/foo.rs\n@@ -1 +1 @@\n-old\n+new\n";
assert!(validate_diff_targets(diff, "src/foo.rs").is_ok());
assert!(validate_diff_targets(diff, "./src/foo.rs").is_ok());
}
#[test]
fn validate_diff_rejects_a_different_target_file() {
let diff = "--- a/src/foo.rs\n+++ b/src/evil.rs\n@@ -1 +1 @@\n-old\n+new\n";
assert!(validate_diff_targets(diff, "src/foo.rs").is_err());
}
#[test]
fn validate_diff_rejects_traversal_absolute_devnull_and_rename() {
let traversal = "--- a/src/foo.rs\n+++ b/../../etc/cron.d/x\n@@ -1 +1 @@\n-a\n+b\n";
assert!(validate_diff_targets(traversal, "src/foo.rs").is_err());
let absolute = "--- a/src/foo.rs\n+++ /etc/passwd\n@@ -1 +1 @@\n-a\n+b\n";
assert!(validate_diff_targets(absolute, "src/foo.rs").is_err());
let create = "--- /dev/null\n+++ b/src/foo.rs\n@@ -0,0 +1 @@\n+new\n";
assert!(validate_diff_targets(create, "src/foo.rs").is_err());
let rename =
"diff --git a/src/foo.rs b/src/bar.rs\nrename from src/foo.rs\nrename to src/bar.rs\n";
assert!(validate_diff_targets(rename, "src/foo.rs").is_err());
}
#[test]
fn validate_diff_rejects_unprefixed_headers_that_p1_would_misroute() {
let diff = "--- src/foo.rs\n+++ src/foo.rs\n@@ -1 +1 @@\n-a\n+b\n";
assert!(validate_diff_targets(diff, "src/foo.rs").is_err());
}
#[test]
fn validate_diff_rejects_git_extended_and_smuggled_sections() {
let new_file = "diff --git a/evil b/evil\nnew file mode 100644\nindex 0000000..abc\n--- /dev/null\n+++ b/evil\n@@ -0,0 +1 @@\n+x\n";
assert!(validate_diff_targets(new_file, "src/foo.rs").is_err());
let binary = "diff --git a/x b/x\nindex 0..1 100644\nGIT binary patch\nliteral 4\n";
assert!(validate_diff_targets(binary, "src/foo.rs").is_err());
let smuggled = "--- a/src/foo.rs\n+++ b/src/foo.rs\n@@ -1 +1 @@\n-a\n+b\ndiff --git a/secret b/secret\ndeleted file mode 100644\n";
assert!(validate_diff_targets(smuggled, "src/foo.rs").is_err());
}
}