Skip to main content

contextgraph_host/
verify.rs

1//! Host-side end-to-end provenance-digest verification (`SPEC.md` §6.2, §F5;
2//! issue #12) — the *bytes* half of F5.
3//!
4//! `contextgraph-types`' [`is_well_formed_digest`](contextgraph_types::is_well_formed_digest)
5//! and the provider-facing `frame-validity` check together enforce F5's
6//! **grammar** (`sha256:<64 lowercase hex>`). Neither can say whether a digest
7//! actually matches the bytes it claims to cover: that requires re-reading the
8//! source, which only a host can do. This module is that verifier.
9//!
10//! It is **not** [`Host::verify_frames`](crate::Host::verify_frames): that asks
11//! the *provider* whether a held frame is still current (`context/verify`, §9).
12//! This re-reads local bytes the host can see and hashes them itself, trusting
13//! no one — the two are different guarantees that happen to share the verb.
14//!
15//! ## Digested bytes (§6.2)
16//!
17//! The digest covers the exact UTF-8 source bytes addressed by `uri` + `range`
18//! at read time, with **no normalization**: no line-ending translation, no
19//! trailing-newline adjustment. Provenance without a `range` digests the whole
20//! resource. Only `file` provenance is held to F5 — a `derivation` or `episode`
21//! link has no addressable bytes.
22//!
23//! ## Range grammar
24//!
25//! `SPEC.md` §6.2 does not fix a `range` grammar; the only convention in this
26//! codebase is line ranges, `L<start>` or `L<start>-<end>` (1-indexed,
27//! inclusive), which this verifier supports. A line's bytes **include** its
28//! terminating `\n` as it appears on disk (host-defined, since the spec is
29//! silent on terminator inclusion); no `\r` is ever stripped, honoring the
30//! "no line-ending translation" clause. An unrecognized range grammar is an
31//! honest [`Unreadable`](DigestVerification::Unreadable), never a silent
32//! whole-file fallback that would digest the wrong bytes.
33//!
34//! ## Scope and safety
35//!
36//! This is a **host API a host invokes deliberately**, over sources it trusts —
37//! not an automatic re-read of any `uri` a *provider* names. Re-reading a
38//! provider-supplied path is a capability decision (path confinement, consent)
39//! the host runtime does not yet make (see the filesystem-confinement note in
40//! the crate docs), so this is deliberately *not* wired into
41//! [`Host::query_all`](crate::Host::query_all). Wiring it into an end-to-end
42//! host-side conformance gate — with confinement — is tracked by the host-side
43//! harness (issue #14). It is a synchronous utility; a caller on an async path
44//! wraps it in `spawn_blocking`.
45
46use std::path::{Path, PathBuf};
47
48use sha2::{Digest, Sha256};
49
50use contextgraph_types::{ContextFrame, Provenance};
51
52/// The outcome of verifying one `file`-provenance digest against the bytes it
53/// addresses (`SPEC.md` §6.2). Evidence-carrying rather than a bare bool, so a
54/// failure says exactly what diverged.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DigestVerification {
57    /// The declared digest equals the sha256 of the addressed bytes.
58    Verified,
59    /// The addressed bytes are readable, but their sha256 does **not** match the
60    /// declared digest — tampering, a source that changed, or a mis-declared
61    /// range. Carries both digests so a report can show the divergence.
62    /// `expected` is what the provider declared; `actual` is what the bytes
63    /// hash to now.
64    Mismatch { expected: String, actual: String },
65    /// The addressed bytes could not be read at all: a missing file, a `uri`
66    /// that is not a resolvable local `file://`, a range the grammar does not
67    /// define, or file provenance carrying no `uri`/`digest` to check. Carries a
68    /// human-readable reason. Silence is not validity — an unreadable source is
69    /// a failure to confirm, never a pass.
70    Unreadable { reason: String },
71    /// The provenance does not address file bytes (`type` != `"file"`), so there
72    /// is nothing on disk to re-read and F5 does not bind it (§6.2).
73    NotFileProvenance,
74}
75
76impl DigestVerification {
77    /// Whether the addressed bytes hashed exactly to the declared digest.
78    pub fn is_verified(&self) -> bool {
79        matches!(self, DigestVerification::Verified)
80    }
81}
82
83/// Re-read the bytes one `file`-provenance entry addresses and check their
84/// sha256 against its declared `digest` (`SPEC.md` §6.2, §F5).
85///
86/// Returns [`NotFileProvenance`](DigestVerification::NotFileProvenance) for a
87/// non-`file` link (F5 does not bind it), [`Unreadable`](DigestVerification::Unreadable)
88/// when the addressed bytes cannot be read, and otherwise
89/// [`Verified`](DigestVerification::Verified) or
90/// [`Mismatch`](DigestVerification::Mismatch). A grammar-malformed declared
91/// digest simply cannot equal a well-formed hash, so it surfaces as `Mismatch`;
92/// digest *grammar* is the provider-facing `frame-validity` check's job, not
93/// this one's.
94///
95/// ```text
96/// use contextgraph_host::verify::{verify_provenance_digest, DigestVerification};
97/// // provenance: file:///repo/src/net.rs, range L120-160, digest sha256:<64 hex>
98/// match verify_provenance_digest(&provenance) {
99///     DigestVerification::Verified => { /* the bytes still hash as claimed */ }
100///     DigestVerification::Mismatch { expected, actual } => { /* tampered or moved */ }
101///     other => { /* not a file link, or unreadable */ }
102/// }
103/// ```
104pub 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
137/// Verify every `file`-provenance digest a frame declares against the bytes on
138/// disk, returning one `(provenance index, outcome)` per `file` entry in
139/// provenance order.
140///
141/// Non-`file` provenance is omitted (F5 does not bind it), so an **empty** result
142/// means the frame declares no file provenance to check — *not* that it passed.
143/// The index is into `frame.provenance`, so a host can name the exact offending
144/// link in a report.
145pub 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
155/// Read the exact bytes a `file` provenance addresses: the whole resource when
156/// `range` is absent, else the addressed line span (§6.2). No normalization is
157/// applied — the bytes are returned exactly as they sit on disk.
158fn 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
167/// Extract the byte span of an `L<start>[-<end>]` line range (1-indexed,
168/// inclusive; each line's trailing `\n` included). An unrecognized grammar is an
169/// error, not a whole-file fallback — digesting bytes the range never named is
170/// exactly the silent wrongness this check exists to prevent.
171fn 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    // Per-line byte spans, each including its terminating `\n`. A trailing `\n`
187    // does not open an extra empty line (matches `str::lines()` line counting),
188    // and no `\r` is stripped (no line-ending translation, §6.2).
189    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    // Clamp the end to EOF: a range that reaches past the last line addresses
208    // through the end of the resource.
209    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
225/// Resolve a `file://` uri to a local path. Accepts an empty authority
226/// (`file:///path`) or `localhost`; a non-local host is not re-readable. Percent
227/// escapes in the path are decoded, so a `uri` naming a path with spaces or
228/// other reserved bytes resolves correctly.
229fn 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
257/// Decode `%XX` percent escapes into raw bytes; any other byte passes through.
258/// A `%` not followed by two hex digits is left literal.
259fn 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
279/// A protocol content digest over `bytes`: `sha256:<64 lowercase hex>` (§F5).
280/// Lowercase is mandated so a byte-for-byte comparison never yields a spurious
281/// case-only mismatch.
282fn 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    /// A real temp file with Drop-cleanup — `tempfile` is not a dependency, so
301    /// this uses `std::env::temp_dir()` and removes itself even if a test panics.
302    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        /// A `file://` uri for this file. The temp path is absolute and ASCII, so
320        /// no percent-encoding is needed to round-trip it.
321        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        // Anchor the primitive to ground truth, not just to itself: the whole
346        // point of this verifier is that its digest equals what any conforming
347        // SHA-256 (an external provider, `sha256sum`) computes for the same
348        // bytes. Every other test compares two outputs of this same helper, so
349        // a nibble-swapped or uppercase digest would slip past them all — but
350        // not past these NIST vectors.
351        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        // A well-formed digest of *different* bytes — what a tampered or stale
378        // claim looks like. Both sides of the mismatch are valid sha256.
379        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        // Four newline-terminated lines; the range addresses lines 2-3.
394        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        // Independently compute the expected sub-range bytes — lines 2 and 3,
399        // each with its trailing newline — and hash them directly, so this is
400        // not a tautology against the verifier's own range logic.
401        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        // A single-line range works too.
412        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); // remove the file, then verify against its now-dead uri
426        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        // The normative §6.2 clause, on the whole-file path: a CRLF/mixed file's
441        // exact bytes are digested. A verifier that normalized `\r\n` to `\n`
442        // would compute a different hash and spuriously report a mismatch.
443        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        // A `derivation` link has no addressable bytes to re-read (§6.2).
457        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        // A byte-offset grammar the spec never defined must not silently digest
476        // the whole file — that would confirm bytes the range never named.
477        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        // A path with a space, addressed by a correctly percent-encoded uri.
502        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            // A non-file link is skipped, so it does not shift the reported index.
522            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        // No file provenance ⇒ empty result: "nothing to check", not a pass.
542        let mut bare = frame.clone();
543        bare.provenance.clear();
544        assert!(verify_file_provenance(&bare).is_empty());
545    }
546}