Skip to main content

ftts_artifacts/
sha256.rs

1//! SHA-256 (FIPS 180-4), for `.fttsq` section digests.
2//!
3//! # Why a hand-rolled implementation still lives here
4//!
5//! It no longer computes the digests that gate loading — [`digest`] delegates those to `sha2`,
6//! which dispatches the ARMv8 and x86 SHA extensions at runtime and turns whole-artifact
7//! verification from seconds into a rounding error. What remains is the STREAMING state, and it
8//! remains because the writer hashes payload as it is produced and never holds a whole section:
9//! a one-shot API cannot express that without a second copy of a multi-hundred-megabyte buffer.
10//!
11//! An earlier revision of this note argued against the direct dependency on the grounds that it
12//! moves `Cargo.lock`, and `cargo check --locked` is a hard gate every concurrent agent in this
13//! workspace runs. That reasoning was sound and the constraint is real; the resolution is simply
14//! that the Cargo.toml edit and the regenerated lockfile land in the SAME commit, which is what
15//! happened. `sha2` was already resolved transitively, so the lock moved by one line.
16//!
17//! The two implementations must agree exactly — the writer produces a digest the verifier checks,
18//! so a disagreement would fail every artifact, or accept a corrupt one. That is pinned by
19//! `one_shot_matches_the_streaming_implementation` rather than assumed from both being "SHA-256".
20//!
21//! # Scope
22//!
23//! Digests here are **integrity** checks — they detect truncation, bit-flips, and mismatched
24//! sections. They are not a signature scheme and prove nothing about *who* produced an artifact.
25//! Verified against the FIPS 180-4 vectors in the tests below.
26
27/// Round constants: the first 32 bits of the fractional parts of the cube roots of the first 64
28/// primes (FIPS 180-4 §4.2.2).
29const K: [u32; 64] = [
30    0x428a_2f98,
31    0x7137_4491,
32    0xb5c0_fbcf,
33    0xe9b5_dba5,
34    0x3956_c25b,
35    0x59f1_11f1,
36    0x923f_82a4,
37    0xab1c_5ed5,
38    0xd807_aa98,
39    0x1283_5b01,
40    0x2431_85be,
41    0x550c_7dc3,
42    0x72be_5d74,
43    0x80de_b1fe,
44    0x9bdc_06a7,
45    0xc19b_f174,
46    0xe49b_69c1,
47    0xefbe_4786,
48    0x0fc1_9dc6,
49    0x240c_a1cc,
50    0x2de9_2c6f,
51    0x4a74_84aa,
52    0x5cb0_a9dc,
53    0x76f9_88da,
54    0x983e_5152,
55    0xa831_c66d,
56    0xb003_27c8,
57    0xbf59_7fc7,
58    0xc6e0_0bf3,
59    0xd5a7_9147,
60    0x06ca_6351,
61    0x1429_2967,
62    0x27b7_0a85,
63    0x2e1b_2138,
64    0x4d2c_6dfc,
65    0x5338_0d13,
66    0x650a_7354,
67    0x766a_0abb,
68    0x81c2_c92e,
69    0x9272_2c85,
70    0xa2bf_e8a1,
71    0xa81a_664b,
72    0xc24b_8b70,
73    0xc76c_51a3,
74    0xd192_e819,
75    0xd699_0624,
76    0xf40e_3585,
77    0x106a_a070,
78    0x19a4_c116,
79    0x1e37_6c08,
80    0x2748_774c,
81    0x34b0_bcb5,
82    0x391c_0cb3,
83    0x4ed8_aa4a,
84    0x5b9c_ca4f,
85    0x682e_6ff3,
86    0x748f_82ee,
87    0x78a5_636f,
88    0x84c8_7814,
89    0x8cc7_0208,
90    0x90be_fffa,
91    0xa450_6ceb,
92    0xbef9_a3f7,
93    0xc671_78f2,
94];
95
96/// Initial hash value: the first 32 bits of the fractional parts of the square roots of the first
97/// eight primes (FIPS 180-4 §5.3.3).
98const H0: [u32; 8] = [
99    0x6a09_e667,
100    0xbb67_ae85,
101    0x3c6e_f372,
102    0xa54f_f53a,
103    0x510e_527f,
104    0x9b05_688c,
105    0x1f83_d9ab,
106    0x5be0_cd19,
107];
108
109/// Streaming SHA-256 state.
110///
111/// Streaming rather than one-shot because a `.fttsq` section is hundreds of megabytes: the digest
112/// must be computable over a borrowed slice in blocks, without a second copy of the payload.
113#[derive(Clone, Debug)]
114pub struct Sha256 {
115    state: [u32; 8],
116    /// Partial block awaiting a full 64 bytes.
117    buffer: [u8; 64],
118    /// Bytes currently held in `buffer`.
119    buffered: usize,
120    /// Total message length in bytes, for the length suffix.
121    length: u64,
122}
123
124impl Default for Sha256 {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl Sha256 {
131    /// Starts a new digest.
132    #[must_use]
133    pub const fn new() -> Self {
134        Self {
135            state: H0,
136            buffer: [0; 64],
137            buffered: 0,
138            length: 0,
139        }
140    }
141
142    /// Absorbs more message bytes.
143    pub fn update(&mut self, mut input: &[u8]) {
144        self.length = self.length.wrapping_add(input.len() as u64);
145
146        if self.buffered > 0 {
147            let want = 64 - self.buffered;
148            let take = want.min(input.len());
149            self.buffer[self.buffered..self.buffered + take].copy_from_slice(&input[..take]);
150            self.buffered += take;
151            input = &input[take..];
152            if self.buffered == 64 {
153                let block = self.buffer;
154                self.compress(&block);
155                self.buffered = 0;
156            } else {
157                return;
158            }
159        }
160
161        let (blocks, tail) = input.as_chunks::<64>();
162        for block in blocks {
163            self.compress(block);
164        }
165
166        self.buffer[..tail.len()].copy_from_slice(tail);
167        self.buffered = tail.len();
168    }
169
170    /// Finishes the digest and returns the 32 raw bytes.
171    #[must_use]
172    pub fn finish(mut self) -> [u8; 32] {
173        // Padding: 0x80, then zeros, then the bit length as a big-endian u64.
174        let bit_length = self.length.wrapping_mul(8);
175        self.update_no_count(&[0x80]);
176        while self.buffered != 56 {
177            self.update_no_count(&[0x00]);
178        }
179        self.update_no_count(&bit_length.to_be_bytes());
180
181        let mut out = [0_u8; 32];
182        for (chunk, word) in out.as_chunks_mut::<4>().0.iter_mut().zip(self.state) {
183            *chunk = word.to_be_bytes();
184        }
185        out
186    }
187
188    /// Absorbs padding bytes without advancing the message-length counter.
189    fn update_no_count(&mut self, input: &[u8]) {
190        for &byte in input {
191            self.buffer[self.buffered] = byte;
192            self.buffered += 1;
193            if self.buffered == 64 {
194                let block = self.buffer;
195                self.compress(&block);
196                self.buffered = 0;
197            }
198        }
199    }
200
201    /// One 64-byte block through the compression function (FIPS 180-4 §6.2.2).
202    fn compress(&mut self, block: &[u8; 64]) {
203        let mut w = [0_u32; 64];
204        for (slot, chunk) in w.iter_mut().zip(block.as_chunks::<4>().0) {
205            *slot = u32::from_be_bytes(*chunk);
206        }
207        for index in 16..64 {
208            let s0 = w[index - 15].rotate_right(7)
209                ^ w[index - 15].rotate_right(18)
210                ^ (w[index - 15] >> 3);
211            let s1 = w[index - 2].rotate_right(17)
212                ^ w[index - 2].rotate_right(19)
213                ^ (w[index - 2] >> 10);
214            w[index] = w[index - 16]
215                .wrapping_add(s0)
216                .wrapping_add(w[index - 7])
217                .wrapping_add(s1);
218        }
219
220        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state;
221
222        for index in 0..64 {
223            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
224            let ch = (e & f) ^ ((!e) & g);
225            let temp1 = h
226                .wrapping_add(s1)
227                .wrapping_add(ch)
228                .wrapping_add(K[index])
229                .wrapping_add(w[index]);
230            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
231            let maj = (a & b) ^ (a & c) ^ (b & c);
232            let temp2 = s0.wrapping_add(maj);
233
234            h = g;
235            g = f;
236            f = e;
237            e = d.wrapping_add(temp1);
238            d = c;
239            c = b;
240            b = a;
241            a = temp1.wrapping_add(temp2);
242        }
243
244        for (slot, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
245            *slot = slot.wrapping_add(value);
246        }
247    }
248}
249
250/// Digests a file by streaming fixed-size reads, returning lowercase hex.
251///
252/// Exists for the same reason [`Sha256`] streams: the files this verifies (downloaded model
253/// checkpoints) are hundreds of megabytes to gigabytes, and reading one into memory just to hash
254/// it would double the peak footprint of a verification pass.
255///
256/// # Errors
257///
258/// Propagates the underlying [`std::io::Error`] from opening or reading the file.
259pub fn hex_digest_file(path: &std::path::Path) -> std::io::Result<String> {
260    use std::io::Read as _;
261
262    let mut file = std::fs::File::open(path)?;
263    let mut hasher = Sha256::new();
264    // 1 MiB: large enough that syscall overhead is negligible, small enough to stay cache-polite.
265    let mut buffer = vec![0_u8; 1 << 20];
266    loop {
267        match file.read(&mut buffer) {
268            Ok(0) => break,
269            Ok(read) => hasher.update(&buffer[..read]),
270            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
271            Err(error) => return Err(error),
272        }
273    }
274    Ok(to_hex(&hasher.finish()))
275}
276
277/// Digests a byte slice, returning lowercase hex.
278#[must_use]
279pub fn digest(bytes: &[u8]) -> [u8; 32] {
280    // Hardware SHA-256 where the CPU has it, which is every Apple Silicon Mac, every recent x86,
281    // and the phones. Verifying a 1.3 GB artifact with this crate's portable implementation
282    // measured 3.6 s — pure latency before a single sample, paid on every load.
283    //
284    // Only the one-shot path delegates. The streaming writer keeps `Sha256` below, because it
285    // hashes payload as it is produced and never holds the whole section. Both compute SHA-256,
286    // so they agree by definition; `one_shot_matches_the_streaming_implementation` pins that
287    // rather than trusting it.
288    use sha2::Digest as _;
289    sha2::Sha256::digest(bytes).into()
290}
291
292/// Lowercase-hex SHA-256 of a complete byte slice.
293#[must_use]
294pub fn hex_digest(bytes: &[u8]) -> String {
295    to_hex(&digest(bytes))
296}
297
298/// Renders raw digest bytes as lowercase hex.
299#[must_use]
300pub fn to_hex(digest: &[u8; 32]) -> String {
301    let mut out = String::with_capacity(64);
302    for byte in digest {
303        // Hand-rolled rather than `format!("{byte:02x}")` per byte: this runs over every section
304        // digest and the formatting machinery dominates otherwise.
305        const HEX: &[u8; 16] = b"0123456789abcdef";
306        out.push(HEX[usize::from(byte >> 4)] as char);
307        out.push(HEX[usize::from(byte & 0x0f)] as char);
308    }
309    out
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// FIPS 180-4 / NIST CAVP known-answer vectors.
317    ///
318    /// A hash implementation that has not been checked against published vectors is an assumption,
319    /// and this one gates artifact integrity — a wrong digest either rejects every good artifact or
320    /// accepts every corrupted one.
321    #[test]
322    fn matches_the_published_nist_vectors() {
323        assert_eq!(
324            hex_digest(b""),
325            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
326        );
327        assert_eq!(
328            hex_digest(b"abc"),
329            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
330        );
331        assert_eq!(
332            hex_digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
333            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
334        );
335        assert_eq!(
336            hex_digest(
337                b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu"
338            ),
339            "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1"
340        );
341        // One million 'a' — exercises the length counter past a single block group.
342        let mut hasher = Sha256::new();
343        for _ in 0..1000 {
344            hasher.update(&[b'a'; 1000]);
345        }
346        assert_eq!(
347            to_hex(&hasher.finish()),
348            "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
349        );
350    }
351
352    /// The streaming path must agree with the one-shot path at every chunk boundary.
353    ///
354    /// Section digests are fed in whatever chunks the caller has; a boundary bug would produce
355    /// digests that depend on read size, which is the worst possible failure — intermittent.
356    #[test]
357    fn streaming_in_any_chunking_matches_one_shot() {
358        let message: Vec<u8> = (0..1000_u32).map(|i| (i % 251) as u8).collect();
359        let expected = hex_digest(&message);
360        for chunk_size in [1_usize, 2, 7, 31, 63, 64, 65, 127, 128, 999, 1000] {
361            let mut hasher = Sha256::new();
362            for chunk in message.chunks(chunk_size) {
363                hasher.update(chunk);
364            }
365            assert_eq!(
366                to_hex(&hasher.finish()),
367                expected,
368                "digest changed with chunk size {chunk_size}"
369            );
370        }
371    }
372
373    /// The file path must agree with the in-memory path — it is the same algorithm behind a
374    /// different read loop, and a divergence would verify downloads against the wrong contract.
375    #[test]
376    fn file_digest_matches_the_in_memory_digest() {
377        let dir = std::env::temp_dir().join(format!("ftts-sha256-file-{}", std::process::id()));
378        std::fs::create_dir_all(&dir).expect("temp dir");
379        let path = dir.join("digest-input.bin");
380        // Larger than one read buffer would be unnecessary; larger than one hash block matters.
381        let message: Vec<u8> = (0..70_000_u32).map(|i| (i % 251) as u8).collect();
382        std::fs::write(&path, &message).expect("write temp file");
383        assert_eq!(
384            hex_digest_file(&path).expect("file digest"),
385            hex_digest(&message)
386        );
387    }
388
389    /// A single flipped bit must change the digest — the property section verification relies on.
390    #[test]
391    fn one_shot_matches_the_streaming_implementation() {
392        // Two SHA-256 implementations now live here: the accelerated one-shot path used for
393        // verification, and this crate's portable streaming one used by the writer. An artifact
394        // is hashed by the writer and checked by the verifier, so a disagreement would not be a
395        // wrong number in a test — it would be every artifact failing to load, or worse, a
396        // corrupt one accepted. Pinned across sizes that straddle the 64-byte block boundary and
397        // the length-padding edge.
398        for size in [0_usize, 1, 55, 56, 63, 64, 65, 1000, 1 << 16, (1 << 16) + 7] {
399            let bytes: Vec<u8> = (0..size).map(|i| (i * 31 + 7) as u8).collect();
400            let mut streaming = Sha256::new();
401            streaming.update(&bytes);
402            assert_eq!(
403                digest(&bytes),
404                streaming.finish(),
405                "one-shot and streaming disagree at {size} bytes"
406            );
407        }
408        // And chunked updates must agree with both, since the writer feeds arbitrary slices.
409        let bytes: Vec<u8> = (0..5000).map(|i| (i % 251) as u8).collect();
410        let mut chunked = Sha256::new();
411        for chunk in bytes.chunks(37) {
412            chunked.update(chunk);
413        }
414        assert_eq!(digest(&bytes), chunked.finish());
415    }
416
417    #[test]
418    fn a_single_bit_flip_changes_the_digest() {
419        let mut message = vec![0_u8; 256];
420        let clean = hex_digest(&message);
421        message[128] ^= 0x01;
422        assert_ne!(hex_digest(&message), clean);
423    }
424}