Skip to main content

prov_graph/
fixity.rs

1//! Fixity — content checksums that let prov detect *bit-rot*, not just
2//! broken links.
3//!
4//! Link validation in the higher-level `prov` crate answers "does the graph
5//! still hold together?"; fixity answers the other archival question: "are the
6//! bytes still the bytes?" A stored hash, recomputed on read and compared,
7//! catches the silent corruption an archive most fears — a flipped bit in a
8//! decade-old attachment that no link check would ever notice.
9//!
10//! ## Why this sits in the read core
11//!
12//! Everything here is a pure function of its inputs: a policy enum, a digest,
13//! two predicates over a recorded string, and one over a parsed document's
14//! shape. None of it opens a file, and none of it can change one — the same
15//! reason [`identity`](crate::identity) sits here rather than above the read
16//! boundary. The *writes* that record a digest (`attach`, `save`, the manifest
17//! verbs) live in `prov`, and the pass that reads bytes back to compare them is
18//! `prov`'s `check`.
19//!
20//! ## Why SHA-256
21//!
22//! The algorithm is **SHA-256**, and a hash is recorded as `sha256:<hex>` — the
23//! prefix names the algorithm, so the field is self-describing and a future one
24//! can be added without ambiguity. SHA-256 is the archival lingua franca: a
25//! prov workspace's fixity is verifiable by *anyone*, with standard tools
26//! (`sha256sum`, BagIt validators), not only by prov — the same
27//! tool-agnostic, self-describing ethos the whole crate is built on.
28//!
29//! The compression function comes from `sha2` rather than being written out
30//! here. This module did once carry its own, on the reasoning that guards
31//! [`exec::block_on`](crate::exec::block_on) and the journal's FNV checksum —
32//! keep the dependency surface tiny and WASM-clean. It is the one place that
33//! reasoning loses: `sha2` is pure Rust and `no_std`-capable, so it costs no
34//! build toolchain and compiles on `wasm32-unknown-unknown` like everything
35//! else here, while a hand-written loop cannot reach the hardware path — `sha2`
36//! dispatches to SHA-NI on x86-64 and to the ARMv8 crypto extensions on
37//! aarch64, and `stamp --all` hashes every covered file in the workspace.
38//!
39//! What does not change is that correctness here is *checked*, not trusted:
40//! SHA-256 is a fully specified, deterministic function with published test
41//! vectors, and the tests below pin this module's output to the NIST vectors
42//! and to what `sha256sum` produces — now testing the binding rather than a
43//! local compression loop, which is exactly what they are for.
44
45use sha2::{Digest, Sha256};
46
47/// Whether a workspace records content checksums.
48///
49/// Not a coverage scale, though it was one — `off | attachments | all`, where
50/// `all` additionally checksummed a combined document's *body*. What a checksum
51/// covers is now read off the document's shape instead, and the shape answers
52/// better than a setting could, so all that is left to configure is whether
53/// checksums are written at all. [`covers`](Fixity::covers) is the rule and why.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub enum Fixity {
56    /// No content checksums are recorded.
57    Off,
58    /// Every node whose content is a file of its own records one.
59    #[default]
60    On,
61}
62
63impl Fixity {
64    /// Whether a `content_hash` is written for `doc`: fixity is on, **and** the
65    /// hash would cover a file other than the one recording it — an attachment's
66    /// payload, or a separated document's prose body.
67    ///
68    /// That second half is the whole rule, and it is a claim about what a
69    /// checksum is *worth*, not about how much work to do. A hash covering a
70    /// sibling file is one artifact vouching for another, whole-file:
71    /// `sha256sum body.md` reproduces it by hand, which is the tool-agnostic
72    /// verifiability this module's choice of SHA-256 exists to buy. A hash of a
73    /// combined document's own body buys none of it. It covers
74    /// [`Document::body`](crate::document::Document::body), a parsed substring —
75    /// and not reliably a contiguous one, since a metadata block need not sit at
76    /// a file's edge and the body is then the prose from both sides of it,
77    /// concatenated. There is no file to hand `sha256sum`, and no rule to state
78    /// to an outside verifier short of reimplementing prov's parser. A guarantee
79    /// that silently changed strength with the carrier is what retired the tier.
80    ///
81    /// A **manifest node** is not covered here, and is not thereby exempt: its
82    /// checksum pins the manifest document it declares, so it is recorded and
83    /// refreshed by the manifest verbs — the only ones that know what rebuilding
84    /// it costs. See [`manifest`](crate::manifest).
85    pub fn covers(self, doc: &crate::document::Document) -> bool {
86        self == Self::On && doc.content_attr().is_some()
87    }
88
89    /// Whether checksums are recorded at all — the axis on its own, for a caller
90    /// with no parsed document to ask about: a sidecar being minted, whose shape
91    /// is not in question because the verb is what is giving it one.
92    pub fn is_on(self) -> bool {
93        self == Self::On
94    }
95
96    /// Parse the configuration spelling; unknown values return `None`.
97    ///
98    /// `attachments` is still read. It was this axis's default spelling while
99    /// coverage was tiered, so it is written into every `prov.yaml` predating
100    /// this, and it names a subset of what `on` now covers — nothing an author
101    /// asked for is lost by taking it at its word. `all` is deliberately *not*
102    /// read, though it was the other live spelling: what it asked for was body
103    /// checksums, which is precisely the thing that went away, and a workspace
104    /// that asked for them is owed the news rather than something quietly
105    /// narrower. It lands as an invalid value on a recognized axis — the default
106    /// is kept and `check` reports it, listing the spellings that remain.
107    pub fn from_config_str(value: &str) -> Option<Self> {
108        match value {
109            "off" => Some(Self::Off),
110            "on" | "attachments" => Some(Self::On),
111            _ => None,
112        }
113    }
114
115    /// Return the configuration spelling.
116    pub fn as_config_str(self) -> &'static str {
117        match self {
118            Self::Off => "off",
119            Self::On => "on",
120        }
121    }
122}
123
124/// The fixity digest of `bytes`, spelled `sha256:<lowercase-hex>` — the form
125/// recorded in an attachment sidecar, a manifest row, or a node's frontmatter, and
126/// the form [`verify`] checks against. The `sha256:` prefix names the algorithm,
127/// so the record is self-describing and a future digest can be distinguished.
128pub fn digest(bytes: &[u8]) -> String {
129    let mut s = String::with_capacity(7 + 64);
130    s.push_str("sha256:");
131    for byte in Sha256::digest(bytes) {
132        s.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
133        s.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
134    }
135    s
136}
137
138/// Whether `bytes` still hash to the `recorded` digest. `true` when the recorded
139/// value is empty — nothing was ever recorded, so there is nothing to contradict
140/// (a document predating fixity is not "corrupt"). A recorded value prov
141/// cannot recognize (a future algorithm) is treated as *unverifiable*, which is
142/// also `true`: fixity never raises a false alarm over a hash it does not
143/// understand, it simply cannot vouch for it.
144pub fn verify(bytes: &[u8], recorded: &str) -> bool {
145    match recorded.strip_prefix("sha256:") {
146        Some(_) => digest(bytes) == recorded,
147        None if recorded.is_empty() => true,
148        None => true,
149    }
150}
151
152/// Whether `recorded` is a fixity digest prov can actually check — the
153/// predicate that separates "verified" from "unverifiable" so a caller can tell
154/// a matching hash from one it had to take on faith.
155pub fn is_recognized(recorded: &str) -> bool {
156    recorded.starts_with("sha256:")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // The NIST / FIPS 180-4 known-answer vectors. If these pass, the
164    // implementation is SHA-256 — correctness is checked, not trusted.
165    #[test]
166    fn matches_the_published_sha256_vectors() {
167        assert_eq!(
168            digest(b""),
169            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
170        );
171        assert_eq!(
172            digest(b"abc"),
173            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
174        );
175        assert_eq!(
176            digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
177            "sha256:248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
178        );
179    }
180
181    #[test]
182    fn crosses_a_block_boundary_correctly() {
183        // 1,000,000 'a's — the classic long vector that exercises multi-block
184        // compression and the length padding.
185        let million_a = vec![b'a'; 1_000_000];
186        assert_eq!(
187            digest(&million_a),
188            "sha256:cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
189        );
190    }
191
192    #[test]
193    fn verify_accepts_the_matching_digest_and_rejects_a_changed_byte() {
194        let recorded = digest(b"the original bytes");
195        assert!(verify(b"the original bytes", &recorded));
196        assert!(!verify(b"the corrupted bytes", &recorded));
197    }
198
199    #[cfg(feature = "yaml")]
200    #[test]
201    fn covers_exactly_the_documents_whose_hash_would_name_another_file() {
202        use crate::document::Document;
203        let parse = |p: &str, t: &str| Document::parse(p, t).unwrap();
204
205        // A separated node and an attachment sidecar both point `content` at a
206        // sibling — one file vouching for another, which is the covered shape.
207        let separated = parse("notes/a.yaml", "title: A\ncontent: a.md\n");
208        let sidecar = parse("photo.jpg.yaml", "title: Photo\ncontent: photo.jpg\n");
209        assert!(Fixity::On.covers(&separated));
210        assert!(Fixity::On.covers(&sidecar));
211
212        // A combined document's hash could only cover its own parsed body. That
213        // is the coverage this axis dropped, so it is not covered at any setting.
214        let combined = parse("note.md", "---\ntitle: Note\n---\nhello\n");
215        assert!(!Fixity::On.covers(&combined));
216
217        // A manifest node pins its manifest, but through the manifest verbs.
218        let node = parse(
219            "photos.yaml",
220            "title: Photos\nmanifest: photos.manifest.yaml\n",
221        );
222        assert!(!Fixity::On.covers(&node));
223
224        // `off` covers nothing, whatever the shape.
225        assert!(!Fixity::Off.covers(&separated));
226        assert!(!Fixity::Off.covers(&sidecar));
227    }
228
229    #[test]
230    fn reads_the_retired_default_spelling_but_not_the_retired_tier() {
231        // `attachments` named a subset of what `on` covers, so it is honored.
232        assert_eq!(Fixity::from_config_str("attachments"), Some(Fixity::On));
233        assert_eq!(Fixity::from_config_str("on"), Some(Fixity::On));
234        assert_eq!(Fixity::from_config_str("off"), Some(Fixity::Off));
235        // `all` asked for body checksums, which is the thing that went away —
236        // it is reported, not silently reinterpreted.
237        assert_eq!(Fixity::from_config_str("all"), None);
238        // What is written back is always the current spelling.
239        assert_eq!(Fixity::On.as_config_str(), "on");
240        assert_eq!(Fixity::Off.as_config_str(), "off");
241    }
242
243    #[test]
244    fn verify_never_cries_wolf_over_an_unrecorded_or_unknown_digest() {
245        // Nothing recorded → nothing to contradict.
246        assert!(verify(b"anything", ""));
247        // A digest from an algorithm prov does not know → unverifiable, not
248        // corrupt. `is_recognized` is how a caller tells the two apart.
249        assert!(verify(b"anything", "blake3:deadbeef"));
250        assert!(!is_recognized("blake3:deadbeef"));
251        assert!(is_recognized("sha256:e3b0c442"));
252    }
253}