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 bytes: a policy enum, a digest, and
13//! two predicates over a recorded string. None of it opens a file, and none of
14//! it can change one — the same reason [`identity`](crate::identity) sits here
15//! rather than above the read boundary. The *writes* that record a digest
16//! (`attach`, `save`, the manifest verbs) live in `prov`, and the pass that
17//! reads bytes back to compare them is `prov`'s `check`.
18//!
19//! ## Why SHA-256
20//!
21//! The algorithm is **SHA-256**, and a hash is recorded as `sha256:<hex>` — the
22//! prefix names the algorithm, so the field is self-describing and a future one
23//! can be added without ambiguity. SHA-256 is the archival lingua franca: a
24//! prov workspace's fixity is verifiable by *anyone*, with standard tools
25//! (`sha256sum`, BagIt validators), not only by prov — the same
26//! tool-agnostic, self-describing ethos the whole crate is built on.
27//!
28//! The compression function comes from `sha2` rather than being written out
29//! here. This module did once carry its own, on the reasoning that guards
30//! [`exec::block_on`](crate::exec::block_on) and the journal's FNV checksum —
31//! keep the dependency surface tiny and WASM-clean. It is the one place that
32//! reasoning loses: `sha2` is pure Rust and `no_std`-capable, so it costs no
33//! build toolchain and compiles on `wasm32-unknown-unknown` like everything
34//! else here, while a hand-written loop cannot reach the hardware path — `sha2`
35//! dispatches to SHA-NI on x86-64 and to the ARMv8 crypto extensions on
36//! aarch64, and `stamp --all` hashes every covered file in the workspace.
37//!
38//! What does not change is that correctness here is *checked*, not trusted:
39//! SHA-256 is a fully specified, deterministic function with published test
40//! vectors, and the tests below pin this module's output to the NIST vectors
41//! and to what `sha256sum` produces — now testing the binding rather than a
42//! local compression loop, which is exactly what they are for.
43
44use sha2::{Digest, Sha256};
45
46/// How far content checksums cover a workspace.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub enum Fixity {
49    /// No content checksums are recorded or verified.
50    Off,
51    /// Attachment payloads only.
52    #[default]
53    Payloads,
54    /// Attachment payloads and document bodies.
55    Full,
56}
57
58impl Fixity {
59    /// Whether attachment payloads are checksummed.
60    pub fn covers_payloads(self) -> bool {
61        matches!(self, Self::Payloads | Self::Full)
62    }
63
64    /// Whether document bodies are checksummed.
65    pub fn covers_bodies(self) -> bool {
66        matches!(self, Self::Full)
67    }
68
69    /// Parse the configuration spelling; unknown values return `None`.
70    pub fn from_config_str(value: &str) -> Option<Self> {
71        match value {
72            "off" => Some(Self::Off),
73            "attachments" => Some(Self::Payloads),
74            "all" => Some(Self::Full),
75            _ => None,
76        }
77    }
78
79    /// Return the configuration spelling.
80    pub fn as_config_str(self) -> &'static str {
81        match self {
82            Self::Off => "off",
83            Self::Payloads => "attachments",
84            Self::Full => "all",
85        }
86    }
87}
88
89/// The fixity digest of `bytes`, spelled `sha256:<lowercase-hex>` — the form
90/// recorded in a sidecar, a frontmatter field, or a recycle-bin tombstone, and
91/// the form [`verify`] checks against. The `sha256:` prefix names the algorithm,
92/// so the record is self-describing and a future digest can be distinguished.
93pub fn digest(bytes: &[u8]) -> String {
94    let mut s = String::with_capacity(7 + 64);
95    s.push_str("sha256:");
96    for byte in Sha256::digest(bytes) {
97        s.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
98        s.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
99    }
100    s
101}
102
103/// Whether `bytes` still hash to the `recorded` digest. `true` when the recorded
104/// value is empty — nothing was ever recorded, so there is nothing to contradict
105/// (a document predating fixity is not "corrupt"). A recorded value prov
106/// cannot recognize (a future algorithm) is treated as *unverifiable*, which is
107/// also `true`: fixity never raises a false alarm over a hash it does not
108/// understand, it simply cannot vouch for it.
109pub fn verify(bytes: &[u8], recorded: &str) -> bool {
110    match recorded.strip_prefix("sha256:") {
111        Some(_) => digest(bytes) == recorded,
112        None if recorded.is_empty() => true,
113        None => true,
114    }
115}
116
117/// Whether `recorded` is a fixity digest prov can actually check — the
118/// predicate that separates "verified" from "unverifiable" so a caller can tell
119/// a matching hash from one it had to take on faith.
120pub fn is_recognized(recorded: &str) -> bool {
121    recorded.starts_with("sha256:")
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    // The NIST / FIPS 180-4 known-answer vectors. If these pass, the
129    // implementation is SHA-256 — correctness is checked, not trusted.
130    #[test]
131    fn matches_the_published_sha256_vectors() {
132        assert_eq!(
133            digest(b""),
134            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
135        );
136        assert_eq!(
137            digest(b"abc"),
138            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
139        );
140        assert_eq!(
141            digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
142            "sha256:248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
143        );
144    }
145
146    #[test]
147    fn crosses_a_block_boundary_correctly() {
148        // 1,000,000 'a's — the classic long vector that exercises multi-block
149        // compression and the length padding.
150        let million_a = vec![b'a'; 1_000_000];
151        assert_eq!(
152            digest(&million_a),
153            "sha256:cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
154        );
155    }
156
157    #[test]
158    fn verify_accepts_the_matching_digest_and_rejects_a_changed_byte() {
159        let recorded = digest(b"the original bytes");
160        assert!(verify(b"the original bytes", &recorded));
161        assert!(!verify(b"the corrupted bytes", &recorded));
162    }
163
164    #[test]
165    fn verify_never_cries_wolf_over_an_unrecorded_or_unknown_digest() {
166        // Nothing recorded → nothing to contradict.
167        assert!(verify(b"anything", ""));
168        // A digest from an algorithm prov does not know → unverifiable, not
169        // corrupt. `is_recognized` is how a caller tells the two apart.
170        assert!(verify(b"anything", "blake3:deadbeef"));
171        assert!(!is_recognized("blake3:deadbeef"));
172        assert!(is_recognized("sha256:e3b0c442"));
173    }
174}