1use std::path::{Path, PathBuf};
47
48use sha2::{Digest, Sha256};
49
50use contextgraph_types::{ContextFrame, Provenance};
51
52#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DigestVerification {
57 Verified,
59 Mismatch { expected: String, actual: String },
65 Unreadable { reason: String },
71 NotFileProvenance,
74}
75
76impl DigestVerification {
77 pub fn is_verified(&self) -> bool {
79 matches!(self, DigestVerification::Verified)
80 }
81}
82
83pub fn verify_provenance_digest(provenance: &Provenance) -> DigestVerification {
105 if !provenance.is_file_provenance() {
106 return DigestVerification::NotFileProvenance;
107 }
108 let Some(declared) = provenance.digest.as_deref() else {
109 return DigestVerification::Unreadable {
110 reason: "file provenance carries no digest to verify (§F5)".to_string(),
111 };
112 };
113 let Some(uri) = provenance.uri.as_deref() else {
114 return DigestVerification::Unreadable {
115 reason: "file provenance carries no uri to re-read".to_string(),
116 };
117 };
118 let path = match file_uri_to_path(uri) {
119 Ok(path) => path,
120 Err(reason) => return DigestVerification::Unreadable { reason },
121 };
122 let bytes = match addressed_bytes(&path, provenance.range.as_deref()) {
123 Ok(bytes) => bytes,
124 Err(reason) => return DigestVerification::Unreadable { reason },
125 };
126 let actual = sha256_digest(&bytes);
127 if actual == declared {
128 DigestVerification::Verified
129 } else {
130 DigestVerification::Mismatch {
131 expected: declared.to_string(),
132 actual,
133 }
134 }
135}
136
137pub fn verify_file_provenance(frame: &ContextFrame) -> Vec<(usize, DigestVerification)> {
146 frame
147 .provenance
148 .iter()
149 .enumerate()
150 .filter(|(_, provenance)| provenance.is_file_provenance())
151 .map(|(index, provenance)| (index, verify_provenance_digest(provenance)))
152 .collect()
153}
154
155fn addressed_bytes(path: &Path, range: Option<&str>) -> Result<Vec<u8>, String> {
159 let bytes = std::fs::read(path)
160 .map_err(|error| format!("cannot read `{}`: {error}", path.display()))?;
161 match range {
162 None => Ok(bytes),
163 Some(spec) => extract_line_range(&bytes, spec),
164 }
165}
166
167fn extract_line_range(bytes: &[u8], spec: &str) -> Result<Vec<u8>, String> {
172 let digits = spec
173 .strip_prefix('L')
174 .ok_or_else(|| unsupported_range(spec))?;
175 let (start, end) = match digits.split_once('-') {
176 Some((first, last)) => (parse_line(first, spec)?, parse_line(last, spec)?),
177 None => {
178 let single = parse_line(digits, spec)?;
179 (single, single)
180 }
181 };
182 if start == 0 || end < start {
183 return Err(format!("range `{spec}` is empty or inverted"));
184 }
185
186 let mut line_spans: Vec<(usize, usize)> = Vec::new();
190 let mut line_start = 0usize;
191 for (i, &byte) in bytes.iter().enumerate() {
192 if byte == b'\n' {
193 line_spans.push((line_start, i + 1));
194 line_start = i + 1;
195 }
196 }
197 if line_start < bytes.len() {
198 line_spans.push((line_start, bytes.len()));
199 }
200
201 let count = line_spans.len();
202 if start > count {
203 return Err(format!(
204 "range `{spec}` starts at line {start} but the resource has {count} line(s)"
205 ));
206 }
207 let end = end.min(count);
210 let from = line_spans[start - 1].0;
211 let to = line_spans[end - 1].1;
212 Ok(bytes[from..to].to_vec())
213}
214
215fn parse_line(field: &str, spec: &str) -> Result<usize, String> {
216 field.parse::<usize>().map_err(|_| unsupported_range(spec))
217}
218
219fn unsupported_range(spec: &str) -> String {
220 format!(
221 "unsupported range `{spec}`; expected a line range `L<start>` or `L<start>-<end>` (§6.2)"
222 )
223}
224
225fn file_uri_to_path(uri: &str) -> Result<PathBuf, String> {
230 let rest = uri.strip_prefix("file://").ok_or_else(|| {
231 format!("provenance uri `{uri}` is not a `file://` uri; only local file provenance is re-readable (§6.2)")
232 })?;
233 let (authority, path_part) = match rest.find('/') {
234 Some(0) => ("", rest),
235 Some(index) => (&rest[..index], &rest[index..]),
236 None => return Err(format!("`file://` uri `{uri}` has no absolute path")),
237 };
238 if !authority.is_empty() && authority != "localhost" {
239 return Err(format!(
240 "`file://` uri `{uri}` names a non-local host `{authority}`; only local files are re-readable"
241 ));
242 }
243 let decoded = percent_decode(path_part);
244 #[cfg(unix)]
245 {
246 use std::os::unix::ffi::OsStrExt;
247 Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&decoded)))
248 }
249 #[cfg(not(unix))]
250 {
251 Ok(PathBuf::from(
252 String::from_utf8_lossy(&decoded).into_owned(),
253 ))
254 }
255}
256
257fn percent_decode(s: &str) -> Vec<u8> {
260 let bytes = s.as_bytes();
261 let mut out = Vec::with_capacity(bytes.len());
262 let mut i = 0;
263 while i < bytes.len() {
264 if bytes[i] == b'%' && i + 2 < bytes.len() {
265 let hi = (bytes[i + 1] as char).to_digit(16);
266 let lo = (bytes[i + 2] as char).to_digit(16);
267 if let (Some(hi), Some(lo)) = (hi, lo) {
268 out.push((hi * 16 + lo) as u8);
269 i += 3;
270 continue;
271 }
272 }
273 out.push(bytes[i]);
274 i += 1;
275 }
276 out
277}
278
279fn sha256_digest(bytes: &[u8]) -> String {
283 let hash = Sha256::digest(bytes);
284 let mut out = String::with_capacity("sha256:".len() + 64);
285 out.push_str("sha256:");
286 for byte in hash {
287 out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
288 out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
289 }
290 out
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use contextgraph_types::{ContextFrame, FrameKind};
297 use std::path::PathBuf;
298 use std::sync::atomic::{AtomicU64, Ordering};
299
300 struct TempFile {
303 path: PathBuf,
304 }
305
306 impl TempFile {
307 fn with_bytes(bytes: &[u8]) -> Self {
308 static NEXT: AtomicU64 = AtomicU64::new(0);
309 let mut path = std::env::temp_dir();
310 path.push(format!(
311 "cgp-verify-{}-{}.bin",
312 std::process::id(),
313 NEXT.fetch_add(1, Ordering::Relaxed)
314 ));
315 std::fs::write(&path, bytes).expect("temp file must be writable");
316 Self { path }
317 }
318
319 fn file_uri(&self) -> String {
322 format!("file://{}", self.path.display())
323 }
324 }
325
326 impl Drop for TempFile {
327 fn drop(&mut self) {
328 let _ = std::fs::remove_file(&self.path);
329 }
330 }
331
332 fn file_provenance(uri: &str, range: Option<&str>, digest: &str) -> Provenance {
333 Provenance {
334 kind: "file".to_string(),
335 uri: Some(uri.to_string()),
336 range: range.map(str::to_string),
337 digest: Some(digest.to_string()),
338 method: None,
339 by: None,
340 }
341 }
342
343 #[test]
344 fn sha256_digest_matches_the_standard_known_answer_vectors() {
345 assert_eq!(
352 sha256_digest(b"abc"),
353 "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
354 );
355 assert_eq!(
356 sha256_digest(b""),
357 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
358 );
359 }
360
361 #[test]
362 fn a_digest_matching_the_whole_file_bytes_verifies() {
363 let content = b"the exact bytes on disk, no more\n";
364 let file = TempFile::with_bytes(content);
365 let digest = sha256_digest(content);
366 let provenance = file_provenance(&file.file_uri(), None, &digest);
367 assert_eq!(
368 verify_provenance_digest(&provenance),
369 DigestVerification::Verified
370 );
371 }
372
373 #[test]
374 fn a_tampered_digest_is_a_mismatch_carrying_both_sides() {
375 let content = b"the real source bytes\n";
376 let file = TempFile::with_bytes(content);
377 let wrong = sha256_digest(b"bytes the provider never served\n");
380 let provenance = file_provenance(&file.file_uri(), None, &wrong);
381 match verify_provenance_digest(&provenance) {
382 DigestVerification::Mismatch { expected, actual } => {
383 assert_eq!(expected, wrong, "the declared digest is echoed back");
384 assert_eq!(actual, sha256_digest(content), "actual is the bytes' hash");
385 assert_ne!(expected, actual);
386 }
387 other => panic!("expected a Mismatch, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn a_line_scoped_digest_verifies_over_exactly_that_span() {
393 let lines = ["line one", "line two", "line three", "line four"];
395 let content = format!("{}\n", lines.join("\n"));
396 let file = TempFile::with_bytes(content.as_bytes());
397
398 let expected_span = format!("{}\n", lines[1..3].join("\n"));
402 assert_eq!(expected_span, "line two\nline three\n");
403 let digest = sha256_digest(expected_span.as_bytes());
404
405 let provenance = file_provenance(&file.file_uri(), Some("L2-3"), &digest);
406 assert_eq!(
407 verify_provenance_digest(&provenance),
408 DigestVerification::Verified
409 );
410
411 let single = sha256_digest(b"line one\n");
413 let provenance = file_provenance(&file.file_uri(), Some("L1"), &single);
414 assert_eq!(
415 verify_provenance_digest(&provenance),
416 DigestVerification::Verified
417 );
418 }
419
420 #[test]
421 fn a_missing_file_is_unreadable_not_a_silent_pass() {
422 let file = TempFile::with_bytes(b"gone in a moment\n");
423 let uri = file.file_uri();
424 let digest = sha256_digest(b"gone in a moment\n");
425 drop(file); let provenance = file_provenance(&uri, None, &digest);
427 match verify_provenance_digest(&provenance) {
428 DigestVerification::Unreadable { reason } => {
429 assert!(
430 reason.contains("cannot read"),
431 "reason names the failure: {reason}"
432 );
433 }
434 other => panic!("expected Unreadable for a missing file, got {other:?}"),
435 }
436 }
437
438 #[test]
439 fn no_line_ending_translation_is_applied_to_the_digested_bytes() {
440 let content = b"first\r\nsecond\nthird\r\n";
444 let file = TempFile::with_bytes(content);
445 let digest = sha256_digest(content);
446 let provenance = file_provenance(&file.file_uri(), None, &digest);
447 assert_eq!(
448 verify_provenance_digest(&provenance),
449 DigestVerification::Verified,
450 "the exact on-disk bytes, carriage returns included, must be what is hashed"
451 );
452 }
453
454 #[test]
455 fn non_file_provenance_is_reported_as_not_bound_by_f5() {
456 let provenance = Provenance {
458 kind: "derivation".to_string(),
459 uri: None,
460 range: None,
461 digest: None,
462 method: Some("paste".to_string()),
463 by: Some("contextgraph-ingest".to_string()),
464 };
465 assert_eq!(
466 verify_provenance_digest(&provenance),
467 DigestVerification::NotFileProvenance
468 );
469 }
470
471 #[test]
472 fn an_unrecognized_range_grammar_is_unreadable_never_a_whole_file_fallback() {
473 let content = b"one\ntwo\nthree\n";
474 let file = TempFile::with_bytes(content);
475 let provenance = file_provenance(&file.file_uri(), Some("0-5"), &sha256_digest(content));
478 match verify_provenance_digest(&provenance) {
479 DigestVerification::Unreadable { reason } => {
480 assert!(reason.contains("unsupported range"), "reason: {reason}");
481 }
482 other => panic!("expected Unreadable for an unknown range grammar, got {other:?}"),
483 }
484 }
485
486 #[test]
487 fn a_non_file_uri_is_unreadable() {
488 let provenance = file_provenance(
489 "context://provider/artifacts/abc",
490 None,
491 &sha256_digest(b"x"),
492 );
493 assert!(matches!(
494 verify_provenance_digest(&provenance),
495 DigestVerification::Unreadable { .. }
496 ));
497 }
498
499 #[test]
500 fn a_percent_encoded_path_resolves_to_the_real_file() {
501 let content = b"space in the name\n";
503 let mut path = std::env::temp_dir();
504 path.push(format!("cgp verify {}.bin", std::process::id()));
505 std::fs::write(&path, content).expect("writable");
506 let encoded_uri = format!("file://{}", path.display()).replace(' ', "%20");
507 let provenance = file_provenance(&encoded_uri, None, &sha256_digest(content));
508 let outcome = verify_provenance_digest(&provenance);
509 let _ = std::fs::remove_file(&path);
510 assert_eq!(outcome, DigestVerification::Verified);
511 }
512
513 #[test]
514 fn the_frame_level_api_returns_one_result_per_file_link_in_order() {
515 let content = b"framed bytes\n";
516 let file = TempFile::with_bytes(content);
517 let good = sha256_digest(content);
518
519 let mut frame = ContextFrame::full("frm_1", FrameKind::Snippet, "t", "c", 0.5, 1);
520 frame.provenance = vec![
521 Provenance {
523 kind: "derivation".to_string(),
524 uri: None,
525 range: None,
526 digest: None,
527 method: None,
528 by: None,
529 },
530 file_provenance(&file.file_uri(), None, &good),
531 file_provenance(&file.file_uri(), None, &sha256_digest(b"different\n")),
532 ];
533
534 let results = verify_file_provenance(&frame);
535 assert_eq!(results.len(), 2, "only the two file links are checked");
536 assert_eq!(results[0].0, 1, "index is into frame.provenance");
537 assert_eq!(results[0].1, DigestVerification::Verified);
538 assert_eq!(results[1].0, 2);
539 assert!(matches!(results[1].1, DigestVerification::Mismatch { .. }));
540
541 let mut bare = frame.clone();
543 bare.provenance.clear();
544 assert!(verify_file_provenance(&bare).is_empty());
545 }
546}