From 2c33a4f66a9b54a3f2ecbacf1356571f5099edbc Mon Sep 17 00:00:00 2001 From: Kriday Dave Date: Sun, 8 Nov 2026 14:51:10 +0530 Subject: [PATCH] fix: reject non-UTF-8 git paths to avoid snapshot collisions (SEC-10) --- src/adapter/git.rs | 54 ++++++++++++++++++++++----------------- tests/git_adapter_test.rs | 25 ++++++++++++++++++ 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/adapter/git.rs b/src/adapter/git.rs index 3740886..dfece4e 100644 --- a/src/adapter/git.rs +++ b/src/adapter/git.rs @@ -158,30 +158,38 @@ impl GitAdapter { if entry.is_empty() { continue; } - let entry_str = String::from_utf8_lossy(entry); - if let Some((meta, path)) = entry_str.split_once('\t') { - let parts: Vec<&str> = meta.split_whitespace().collect(); - if parts.len() >= 3 && parts[1] == "blob" { - // Skip symlinks (mode 120000) - if parts[0] == "120000" { - continue; - } - crate::store::validate_tree_path(path)?; - let blob_sha = parts[2]; - let blob_output = Command::new("git") - .args(["cat-file", "blob", blob_sha]) - .current_dir(&self.repo_root) - .output() - .with_context(|| format!("Failed to fetch blob {blob_sha} for {path}"))?; - - if !blob_output.status.success() { - return Err(anyhow!( - "Failed to read blob {blob_sha} for {path}: {}", - String::from_utf8_lossy(&blob_output.stderr).trim() - )); - } - files.insert(path.to_string(), blob_output.stdout); + let Some(tab) = entry.iter().position(|&b| b == b'\t') else { + continue; + }; + let meta = String::from_utf8_lossy(&entry[..tab]); + let path_bytes = &entry[tab + 1..]; + let path = std::str::from_utf8(path_bytes).map_err(|_| { + anyhow!( + "git tree contains a non-UTF-8 path in '{rev}': {} (rejecting to avoid path collisions)", + String::from_utf8_lossy(path_bytes) + ) + })?; + let parts: Vec<&str> = meta.split_whitespace().collect(); + if parts.len() >= 3 && parts[1] == "blob" { + // Skip symlinks (mode 120000) + if parts[0] == "120000" { + continue; + } + crate::store::validate_tree_path(path)?; + let blob_sha = parts[2]; + let blob_output = Command::new("git") + .args(["cat-file", "blob", blob_sha]) + .current_dir(&self.repo_root) + .output() + .with_context(|| format!("Failed to fetch blob {blob_sha} for {path}"))?; + + if !blob_output.status.success() { + return Err(anyhow!( + "Failed to read blob {blob_sha} for {path}: {}", + String::from_utf8_lossy(&blob_output.stderr).trim() + )); } + files.insert(path.to_string(), blob_output.stdout); } } diff --git a/tests/git_adapter_test.rs b/tests/git_adapter_test.rs index ffe45ed..2949160 100644 --- a/tests/git_adapter_test.rs +++ b/tests/git_adapter_test.rs @@ -507,3 +507,28 @@ fn test_cli_git_adjudication_respects_authors_flag() { "VCS adjudication must honor --authors; stdout was: {stdout}" ); } + +#[cfg(unix)] +#[test] +fn test_git_adapter_rejects_non_utf8_path() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let repo = TempGitRepo::new("non_utf8_path"); + repo.write_file("src/lib.rs", "pub fn ok() {}\n"); + // Invalid UTF-8 in the filename: lossy decoding would collapse distinct + // byte paths to the same key (SEC-10), so extraction must reject it loudly. + let bad_name = OsString::from_vec(b"bad\xff.txt".to_vec()); + fs::write(repo.path.join(&bad_name), b"secret").expect("failed to write non-utf8 file"); + let rev = repo.commit("add non-utf8 path"); + + let adapter = GitAdapter::new(&repo.path).expect("valid git repo"); + let err = adapter + .extract_snapshot(&rev) + .expect_err("non-UTF-8 paths must not be lossily decoded into snapshot keys"); + let msg = err.to_string(); + assert!( + msg.contains("non-UTF-8 path"), + "error should identify the non-UTF-8 path, got: {msg}" + ); +}