Skip to main content

git_policy_cli/
content_digest.rs

1//! What:
2//!  The length and a 64-bit FNV-1a hash of a file's bytes,
3//!  the evidence a landing record keeps about each index snapshot.
4//! Why:
5//!  The index snapshots of a landing are written without a sync.
6//!  A power loss can leave one of them with fewer bytes than were written,
7//!  or with bytes that never reached the disk,
8//!  while the synced landing record and the moved branch survive.
9//!  Recovery installs the post-index as the real index,
10//!  so it compares the file with this evidence first and never installs a snapshot that differs.
11//!  FNV-1a is specified by its two constants and needs no dependency,
12//!  and it is stable across Rust versions and builds,
13//!  which `std::hash::DefaultHasher` is not;
14//!  it detects damage and is not meant to resist someone who edits the file on purpose.
15//!  Measured in a release build over a 521,631-byte index (5,000 entries): 0.38 ms per file.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const digest = { length: bytes.length, fnv1a64: fnv1a64(bytes).toString(16).padStart(16, '0') };
20//! ```
21
22/// `Read` pulls bytes from a file in chunks.
23use std::io::Read;
24/// `Path` is a borrowed filesystem path.
25use std::path::Path;
26
27/// The FNV-1a 64-bit offset basis.
28const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
29
30/// The FNV-1a 64-bit prime.
31const PRIME: u64 = 0x0000_0100_0000_01b3;
32
33/// How many bytes one read takes,
34///  so a snapshot of any size is hashed in bounded memory.
35const CHUNK_BYTES: usize = 64 * 1024;
36
37/// What:
38///  The length and hash of one file's bytes.
39/// Why:
40///   A truncated file differs in length and a file with unwritten or foreign bytes differs in hash.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type ContentDigest = { length: number; fnv1a64: bigint };
45/// ```
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct ContentDigest {
48    /// Number of bytes.
49    pub length: u64,
50    /// FNV-1a 64-bit hash of the bytes.
51    pub fnv1a64: u64,
52}
53
54impl ContentDigest {
55    /// What:
56    ///  The hash as the 16 lowercase hexadecimal digits a record stores.
57    /// Why:
58    ///   JSON numbers cannot carry 64 bits exactly.
59    ///
60    /// In TS you'd write (pseudocode):
61    /// ```ts
62    /// digest.fnv1a64.toString(16).padStart(16, '0')
63    /// ```
64    pub fn hash_text(&self) -> String {
65        return format!("{:016x}", self.fnv1a64);
66    }
67
68    /// What:
69    ///  The digest a record's length and hexadecimal hash text describe,
70    ///  or nothing when the text is not exactly 16 lowercase hexadecimal digits.
71    /// Why:
72    ///   A record that spells the hash any other way is damaged,
73    ///  and the caller reports it as a malformed record.
74    ///
75    /// In TS you'd write (pseudocode):
76    /// ```ts
77    /// /^[0-9a-f]{16}$/.test(text) ? { length, fnv1a64: BigInt(`0x${text}`) } : undefined
78    /// ```
79    pub fn from_record(length: u64, hash_text: &str) -> Option<ContentDigest> {
80        let well_formed: bool = hash_text.len() == 16
81            && hash_text
82                .bytes()
83                .all(|byte| return byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
84        if !well_formed {
85            return None;
86        }
87        let fnv1a64: u64 = u64::from_str_radix(hash_text, 16).ok()?;
88        return Some(ContentDigest { length, fnv1a64 });
89    }
90}
91
92/// What:
93///  Fold more bytes into a running FNV-1a hash.
94/// Why:
95///   A file is hashed chunk by chunk with the same result as hashing it whole.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// for (const byte of bytes) { hash ^= BigInt(byte); hash = (hash * PRIME) & MASK_64; }
100/// ```
101fn fold(hash: u64, bytes: &[u8]) -> u64 {
102    let mut running: u64 = hash;
103    for byte in bytes {
104        running ^= u64::from(*byte);
105        running = running.wrapping_mul(PRIME);
106    }
107    return running;
108}
109
110/// What:
111///  The digest of in-memory bytes.
112/// Why:
113///   Tests pin the published FNV-1a vectors through it,
114///  and the file reader below must agree with it.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// const digest = digestBytes(bytes);
119/// ```
120pub fn digest_bytes(bytes: &[u8]) -> ContentDigest {
121    return ContentDigest {
122        length: bytes.len() as u64,
123        fnv1a64: fold(OFFSET_BASIS, bytes),
124    };
125}
126
127/// What:
128///  The digest of a file's current bytes,
129///  read in bounded chunks.
130/// Why:
131///   The landing records it for both snapshots after they are written,
132///  and recovery recomputes it from the pinned snapshot before installing anything.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// const digest = await digestFile(path);
137/// ```
138pub fn digest_file(path: &Path) -> std::io::Result<ContentDigest> {
139    let mut file: std::fs::File = std::fs::File::open(path)?;
140    let mut buffer: Vec<u8> = vec![0; CHUNK_BYTES];
141    let mut hash: u64 = OFFSET_BASIS;
142    let mut length: u64 = 0;
143    loop {
144        let read: usize = match file.read(buffer.as_mut_slice()) {
145            Ok(count) => count,
146            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
147            Err(error) => return Err(error),
148        };
149        if read == 0 {
150            break;
151        }
152        hash = fold(hash, &buffer[..read]);
153        length += read as u64;
154    }
155    return Ok(ContentDigest {
156        length,
157        fnv1a64: hash,
158    });
159}
160
161/// Digest controls stay out of the release executable.
162#[cfg(test)]
163#[path = "content_digest_tests.rs"]
164mod tests;