use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use contextgraph_types::{ContextFrame, Provenance};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestVerification {
Verified,
Mismatch { expected: String, actual: String },
Unreadable { reason: String },
NotFileProvenance,
}
impl DigestVerification {
pub fn is_verified(&self) -> bool {
matches!(self, DigestVerification::Verified)
}
}
pub fn verify_provenance_digest(provenance: &Provenance) -> DigestVerification {
if !provenance.is_file_provenance() {
return DigestVerification::NotFileProvenance;
}
let Some(declared) = provenance.digest.as_deref() else {
return DigestVerification::Unreadable {
reason: "file provenance carries no digest to verify (§F5)".to_string(),
};
};
let Some(uri) = provenance.uri.as_deref() else {
return DigestVerification::Unreadable {
reason: "file provenance carries no uri to re-read".to_string(),
};
};
let path = match file_uri_to_path(uri) {
Ok(path) => path,
Err(reason) => return DigestVerification::Unreadable { reason },
};
let bytes = match addressed_bytes(&path, provenance.range.as_deref()) {
Ok(bytes) => bytes,
Err(reason) => return DigestVerification::Unreadable { reason },
};
let actual = sha256_digest(&bytes);
if actual == declared {
DigestVerification::Verified
} else {
DigestVerification::Mismatch {
expected: declared.to_string(),
actual,
}
}
}
pub fn verify_file_provenance(frame: &ContextFrame) -> Vec<(usize, DigestVerification)> {
frame
.provenance
.iter()
.enumerate()
.filter(|(_, provenance)| provenance.is_file_provenance())
.map(|(index, provenance)| (index, verify_provenance_digest(provenance)))
.collect()
}
fn addressed_bytes(path: &Path, range: Option<&str>) -> Result<Vec<u8>, String> {
let bytes = std::fs::read(path)
.map_err(|error| format!("cannot read `{}`: {error}", path.display()))?;
match range {
None => Ok(bytes),
Some(spec) => extract_line_range(&bytes, spec),
}
}
fn extract_line_range(bytes: &[u8], spec: &str) -> Result<Vec<u8>, String> {
let digits = spec
.strip_prefix('L')
.ok_or_else(|| unsupported_range(spec))?;
let (start, end) = match digits.split_once('-') {
Some((first, last)) => (parse_line(first, spec)?, parse_line(last, spec)?),
None => {
let single = parse_line(digits, spec)?;
(single, single)
}
};
if start == 0 || end < start {
return Err(format!("range `{spec}` is empty or inverted"));
}
let mut line_spans: Vec<(usize, usize)> = Vec::new();
let mut line_start = 0usize;
for (i, &byte) in bytes.iter().enumerate() {
if byte == b'\n' {
line_spans.push((line_start, i + 1));
line_start = i + 1;
}
}
if line_start < bytes.len() {
line_spans.push((line_start, bytes.len()));
}
let count = line_spans.len();
if start > count {
return Err(format!(
"range `{spec}` starts at line {start} but the resource has {count} line(s)"
));
}
let end = end.min(count);
let from = line_spans[start - 1].0;
let to = line_spans[end - 1].1;
Ok(bytes[from..to].to_vec())
}
fn parse_line(field: &str, spec: &str) -> Result<usize, String> {
field.parse::<usize>().map_err(|_| unsupported_range(spec))
}
fn unsupported_range(spec: &str) -> String {
format!(
"unsupported range `{spec}`; expected a line range `L<start>` or `L<start>-<end>` (§6.2)"
)
}
fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
let rest = uri.strip_prefix("file://").ok_or_else(|| {
format!("provenance uri `{uri}` is not a `file://` uri; only local file provenance is re-readable (§6.2)")
})?;
let (authority, path_part) = match rest.find('/') {
Some(0) => ("", rest),
Some(index) => (&rest[..index], &rest[index..]),
None => return Err(format!("`file://` uri `{uri}` has no absolute path")),
};
if !authority.is_empty() && authority != "localhost" {
return Err(format!(
"`file://` uri `{uri}` names a non-local host `{authority}`; only local files are re-readable"
));
}
let decoded = percent_decode(path_part);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&decoded)))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
String::from_utf8_lossy(&decoded).into_owned(),
))
}
}
fn percent_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
fn sha256_digest(bytes: &[u8]) -> String {
let hash = Sha256::digest(bytes);
let mut out = String::with_capacity("sha256:".len() + 64);
out.push_str("sha256:");
for byte in hash {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use contextgraph_types::{ContextFrame, FrameKind};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
struct TempFile {
path: PathBuf,
}
impl TempFile {
fn with_bytes(bytes: &[u8]) -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"cgp-verify-{}-{}.bin",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&path, bytes).expect("temp file must be writable");
Self { path }
}
fn file_uri(&self) -> String {
format!("file://{}", self.path.display())
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn file_provenance(uri: &str, range: Option<&str>, digest: &str) -> Provenance {
Provenance {
kind: "file".to_string(),
uri: Some(uri.to_string()),
range: range.map(str::to_string),
digest: Some(digest.to_string()),
method: None,
by: None,
}
}
#[test]
fn sha256_digest_matches_the_standard_known_answer_vectors() {
assert_eq!(
sha256_digest(b"abc"),
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_digest(b""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_digest_matching_the_whole_file_bytes_verifies() {
let content = b"the exact bytes on disk, no more\n";
let file = TempFile::with_bytes(content);
let digest = sha256_digest(content);
let provenance = file_provenance(&file.file_uri(), None, &digest);
assert_eq!(
verify_provenance_digest(&provenance),
DigestVerification::Verified
);
}
#[test]
fn a_tampered_digest_is_a_mismatch_carrying_both_sides() {
let content = b"the real source bytes\n";
let file = TempFile::with_bytes(content);
let wrong = sha256_digest(b"bytes the provider never served\n");
let provenance = file_provenance(&file.file_uri(), None, &wrong);
match verify_provenance_digest(&provenance) {
DigestVerification::Mismatch { expected, actual } => {
assert_eq!(expected, wrong, "the declared digest is echoed back");
assert_eq!(actual, sha256_digest(content), "actual is the bytes' hash");
assert_ne!(expected, actual);
}
other => panic!("expected a Mismatch, got {other:?}"),
}
}
#[test]
fn a_line_scoped_digest_verifies_over_exactly_that_span() {
let lines = ["line one", "line two", "line three", "line four"];
let content = format!("{}\n", lines.join("\n"));
let file = TempFile::with_bytes(content.as_bytes());
let expected_span = format!("{}\n", lines[1..3].join("\n"));
assert_eq!(expected_span, "line two\nline three\n");
let digest = sha256_digest(expected_span.as_bytes());
let provenance = file_provenance(&file.file_uri(), Some("L2-3"), &digest);
assert_eq!(
verify_provenance_digest(&provenance),
DigestVerification::Verified
);
let single = sha256_digest(b"line one\n");
let provenance = file_provenance(&file.file_uri(), Some("L1"), &single);
assert_eq!(
verify_provenance_digest(&provenance),
DigestVerification::Verified
);
}
#[test]
fn a_missing_file_is_unreadable_not_a_silent_pass() {
let file = TempFile::with_bytes(b"gone in a moment\n");
let uri = file.file_uri();
let digest = sha256_digest(b"gone in a moment\n");
drop(file); let provenance = file_provenance(&uri, None, &digest);
match verify_provenance_digest(&provenance) {
DigestVerification::Unreadable { reason } => {
assert!(
reason.contains("cannot read"),
"reason names the failure: {reason}"
);
}
other => panic!("expected Unreadable for a missing file, got {other:?}"),
}
}
#[test]
fn no_line_ending_translation_is_applied_to_the_digested_bytes() {
let content = b"first\r\nsecond\nthird\r\n";
let file = TempFile::with_bytes(content);
let digest = sha256_digest(content);
let provenance = file_provenance(&file.file_uri(), None, &digest);
assert_eq!(
verify_provenance_digest(&provenance),
DigestVerification::Verified,
"the exact on-disk bytes, carriage returns included, must be what is hashed"
);
}
#[test]
fn non_file_provenance_is_reported_as_not_bound_by_f5() {
let provenance = Provenance {
kind: "derivation".to_string(),
uri: None,
range: None,
digest: None,
method: Some("paste".to_string()),
by: Some("contextgraph-ingest".to_string()),
};
assert_eq!(
verify_provenance_digest(&provenance),
DigestVerification::NotFileProvenance
);
}
#[test]
fn an_unrecognized_range_grammar_is_unreadable_never_a_whole_file_fallback() {
let content = b"one\ntwo\nthree\n";
let file = TempFile::with_bytes(content);
let provenance = file_provenance(&file.file_uri(), Some("0-5"), &sha256_digest(content));
match verify_provenance_digest(&provenance) {
DigestVerification::Unreadable { reason } => {
assert!(reason.contains("unsupported range"), "reason: {reason}");
}
other => panic!("expected Unreadable for an unknown range grammar, got {other:?}"),
}
}
#[test]
fn a_non_file_uri_is_unreadable() {
let provenance = file_provenance(
"context://provider/artifacts/abc",
None,
&sha256_digest(b"x"),
);
assert!(matches!(
verify_provenance_digest(&provenance),
DigestVerification::Unreadable { .. }
));
}
#[test]
fn a_percent_encoded_path_resolves_to_the_real_file() {
let content = b"space in the name\n";
let mut path = std::env::temp_dir();
path.push(format!("cgp verify {}.bin", std::process::id()));
std::fs::write(&path, content).expect("writable");
let encoded_uri = format!("file://{}", path.display()).replace(' ', "%20");
let provenance = file_provenance(&encoded_uri, None, &sha256_digest(content));
let outcome = verify_provenance_digest(&provenance);
let _ = std::fs::remove_file(&path);
assert_eq!(outcome, DigestVerification::Verified);
}
#[test]
fn the_frame_level_api_returns_one_result_per_file_link_in_order() {
let content = b"framed bytes\n";
let file = TempFile::with_bytes(content);
let good = sha256_digest(content);
let mut frame = ContextFrame::full("frm_1", FrameKind::Snippet, "t", "c", 0.5, 1);
frame.provenance = vec![
Provenance {
kind: "derivation".to_string(),
uri: None,
range: None,
digest: None,
method: None,
by: None,
},
file_provenance(&file.file_uri(), None, &good),
file_provenance(&file.file_uri(), None, &sha256_digest(b"different\n")),
];
let results = verify_file_provenance(&frame);
assert_eq!(results.len(), 2, "only the two file links are checked");
assert_eq!(results[0].0, 1, "index is into frame.provenance");
assert_eq!(results[0].1, DigestVerification::Verified);
assert_eq!(results[1].0, 2);
assert!(matches!(results[1].1, DigestVerification::Mismatch { .. }));
let mut bare = frame.clone();
bare.provenance.clear();
assert!(verify_file_provenance(&bare).is_empty());
}
}