use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
use super::ir::SourceRef;
#[derive(Debug, Error)]
pub enum EvidenceError {
#[error("not a git repository root: {0}")]
NotGitRoot(String),
#[error("revision not found: {0}")]
RevisionNotFound(String),
#[error("source '{path}' failed verification: {reason}")]
SourceRejected {
path: String,
reason: String,
},
#[error("git command failed: {0}")]
Git(String),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct VerifiedSource {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct EvidenceReport {
pub verified: bool,
pub repository: Option<String>,
pub revision: Option<String>,
pub references: Vec<VerifiedSource>,
}
pub fn sanitize_rel_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("empty path".to_string());
}
if path.contains('\\') {
return Err("backslash in path".to_string());
}
if path.chars().any(char::is_control) {
return Err("control character in path".to_string());
}
let mut segments = Path::new(path).components();
for component in segments.by_ref() {
match component {
std::path::Component::Normal(name) => {
if name == ".git" {
return Err(".git component in path".to_string());
}
}
std::path::Component::ParentDir => {
return Err(".. segment in path".to_string());
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err("absolute path".to_string());
}
std::path::Component::CurDir => {}
}
}
Ok(())
}
fn run_git(repo_root: &Path, args: &[&str]) -> Result<String, EvidenceError> {
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(args)
.output()
.map_err(|e| EvidenceError::Git(format!("spawn: {e}")))?;
if !output.status.success() {
return Err(EvidenceError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn blob_href(origin: &str, revision: &str, source: &SourceRef) -> Option<String> {
let url = origin.trim_end_matches('/');
let rest = url.strip_prefix("https://github.com/")?;
if rest.is_empty() {
return None;
}
let mut href = format!("{url}/blob/{revision}/{}", source.path);
match (source.line, source.end_line) {
(Some(line), Some(end)) => {
href.push_str(&format!("#L{line}-L{end}"));
}
(Some(line), None) => href.push_str(&format!("#L{line}")),
_ => {}
}
Some(href)
}
pub fn verify(
repo_root: &Path,
revision: &str,
sources: &[SourceRef],
expected_origin: &str,
) -> Result<EvidenceReport, EvidenceError> {
if sources.is_empty() {
return Ok(EvidenceReport {
verified: true,
repository: None,
revision: Some(revision.to_string()),
references: Vec::new(),
});
}
let toplevel = run_git(repo_root, &["rev-parse", "--show-toplevel"])?;
let toplevel_path = PathBuf::from(&toplevel);
let canonical_root = repo_root
.canonicalize()
.map_err(|e| EvidenceError::Git(format!("canonicalize root: {e}")))?;
if toplevel_path != canonical_root {
return Err(EvidenceError::NotGitRoot(repo_root.display().to_string()));
}
run_git(
repo_root,
&["cat-file", "-e", &format!("{revision}^{{commit}}")],
)
.map_err(|_| EvidenceError::RevisionNotFound(revision.to_string()))?;
let origin = run_git(repo_root, &["remote", "get-url", "origin"]).ok();
if !expected_origin.is_empty()
&& origin.as_deref() != Some(expected_origin.trim_end_matches('/'))
{
return Err(EvidenceError::SourceRejected {
path: "(origin)".to_string(),
reason: format!("declared origin {expected_origin:?} does not match remote {origin:?}"),
});
}
let origin_for_links = expected_origin
.is_empty()
.then_some(origin.clone())
.flatten()
.or_else(|| Some(expected_origin.to_string()).filter(|s| !s.is_empty()));
let mut references = Vec::with_capacity(sources.len());
for source in sources {
sanitize_rel_path(&source.path).map_err(|reason| EvidenceError::SourceRejected {
path: source.path.clone(),
reason,
})?;
let object = format!("{}:{}", revision, source.path);
let kind = run_git(repo_root, &["cat-file", "-t", &object]).map_err(|_| {
EvidenceError::SourceRejected {
path: source.path.clone(),
reason: format!("missing at revision {revision}"),
}
})?;
if kind != "blob" {
return Err(EvidenceError::SourceRejected {
path: source.path.clone(),
reason: format!("not a blob ({kind})"),
});
}
if source.line.is_some() || source.end_line.is_some() {
let content = run_git(repo_root, &["show", &object])?;
let line_count = content.lines().count() as u32;
let end = source.end_line.or(source.line).unwrap_or(0);
if end > line_count {
return Err(EvidenceError::SourceRejected {
path: source.path.clone(),
reason: format!("line range ends at {end} but file has {line_count} lines"),
});
}
}
references.push(VerifiedSource {
path: source.path.clone(),
label: source.label.clone(),
line: source.line,
end_line: source.end_line,
href: origin_for_links
.as_deref()
.and_then(|o| blob_href(o, revision, source)),
});
}
Ok(EvidenceReport {
verified: true,
repository: origin,
revision: Some(revision.to_string()),
references,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn source(path: &str) -> SourceRef {
SourceRef {
path: path.to_string(),
line: None,
end_line: None,
label: None,
}
}
#[test]
fn sanitize_rejects_escape_and_malformed_paths() {
assert!(sanitize_rel_path("src/main.rs").is_ok());
assert!(sanitize_rel_path("a/../..").is_err(), ".. must be rejected");
assert!(
sanitize_rel_path(".git/config").is_err(),
".git must be rejected"
);
assert!(
sanitize_rel_path("src\\windows.rs").is_err(),
"backslash must be rejected"
);
assert!(
sanitize_rel_path("bad\u{7}path").is_err(),
"control chars must be rejected"
);
assert!(sanitize_rel_path("").is_err());
assert!(sanitize_rel_path("/abs/path").is_err());
}
fn init_repo_with_file() -> (tempfile::TempDir, PathBuf, String) {
let dir = tempfile::TempDir::new().expect("tempdir");
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join("src")).expect("mkdir");
std::fs::write(root.join("src/lib.rs"), "line1\nline2\nline3\n").expect("write");
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&root)
.args(args)
.output()
.expect("git spawn")
};
assert!(git(&["init", "-q"]).status.success());
assert!(
git(&["-c", "user.email=t@t", "-c", "user.name=t", "add", "."])
.status
.success()
);
assert!(git(&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init"
])
.status
.success());
let rev = {
let out = git(&["rev-parse", "HEAD"]);
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
(dir, root, rev)
}
#[test]
fn verify_accepts_committed_sources_with_line_ranges() {
let (_guard, root, rev) = init_repo_with_file();
let sources = vec![SourceRef {
path: "src/lib.rs".to_string(),
line: Some(1),
end_line: Some(3),
label: Some("entry".to_string()),
}];
let report = verify(&root, &rev, &sources, "").expect("verify should pass");
assert!(report.verified);
assert_eq!(report.references.len(), 1);
assert!(report.references[0].href.is_none(), "no origin, no link");
}
#[test]
fn verify_rejects_missing_files_and_out_of_range_lines() {
let (_guard, root, rev) = init_repo_with_file();
let missing = verify(&root, &rev, &[source("src/ghost.rs")], "");
assert!(
matches!(missing, Err(EvidenceError::SourceRejected { .. })),
"{missing:?}"
);
let out_of_range = verify(
&root,
&rev,
&[SourceRef {
path: "src/lib.rs".to_string(),
line: Some(1),
end_line: Some(99),
label: None,
}],
"",
);
assert!(matches!(
out_of_range,
Err(EvidenceError::SourceRejected { .. })
));
}
#[test]
fn verify_rejects_unknown_revision() {
let (_guard, root, _rev) = init_repo_with_file();
let dead = "0".repeat(40);
let err = verify(&root, &dead, &[source("src/lib.rs")], "");
assert!(
matches!(err, Err(EvidenceError::RevisionNotFound(_))),
"{err:?}"
);
}
}