Skip to main content

wowlab_fs/
checksum.rs

1//! Deterministic content checksums and snapshots of reviewed file sets.
2//!
3//! Tree capture intentionally accepts an explicit list instead of discovering a directory.
4//! Callers retain control over ignore rules, discovery symlink policy, and the input boundary.
5
6use std::{fmt, str::FromStr};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
9
10use crate::{
11    directory::{self, EntryKind},
12    error::{Error as FilesystemError, Operation},
13    path::{Component, Path, PathBuf},
14};
15
16const CHECKSUM_BYTES: usize = 32;
17const HEX_PAIR_BYTES: usize = 2;
18const ADJACENT_PAIR: usize = 2;
19const SECOND_ITEM: usize = 1;
20const CHECKSUM_HEX_LENGTH: usize = CHECKSUM_BYTES * HEX_PAIR_BYTES;
21const NIBBLE_BITS: u32 = 4;
22const HEX_ALPHA_OFFSET: u8 = 10;
23const TREE_DOMAIN: &[u8] = b"wowlab-fs:tree-snapshot:v1\0";
24
25/// A BLAKE3 content checksum.
26///
27/// This type identifies cache inputs and generated artifacts.
28/// It is not an authentication primitive.
29/// Do not use it as a message authentication code or password hash.
30#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub struct Checksum([u8; CHECKSUM_BYTES]);
32
33impl Checksum {
34    /// Return the fixed-width binary representation.
35    #[must_use]
36    pub const fn as_bytes(&self) -> &[u8; CHECKSUM_BYTES] {
37        &self.0
38    }
39}
40
41impl fmt::Debug for Checksum {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_tuple("Checksum").field(&self.to_string()).finish()
44    }
45}
46
47impl fmt::Display for Checksum {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        for byte in self.0 {
50            write!(f, "{byte:02x}")?;
51        }
52
53        Ok(())
54    }
55}
56
57impl FromStr for Checksum {
58    type Err = ParseChecksumError;
59
60    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
61        if value.len() != CHECKSUM_HEX_LENGTH {
62            return Err(ParseChecksumError);
63        }
64
65        let mut bytes = [0_u8; CHECKSUM_BYTES];
66
67        for (byte, pair) in bytes
68            .iter_mut()
69            .zip(value.as_bytes().chunks_exact(HEX_PAIR_BYTES))
70        {
71            let high = pair.first().copied().and_then(decode_hex);
72            let low = pair.get(SECOND_ITEM).copied().and_then(decode_hex);
73            let (Some(high), Some(low)) = (high, low) else {
74                return Err(ParseChecksumError);
75            };
76
77            *byte = (high << NIBBLE_BITS) | low;
78        }
79
80        Ok(Self(bytes))
81    }
82}
83
84impl Serialize for Checksum {
85    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
86    where
87        S: Serializer,
88    {
89        serializer.serialize_str(&self.to_string())
90    }
91}
92
93impl<'de> Deserialize<'de> for Checksum {
94    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
95    where
96        D: Deserializer<'de>,
97    {
98        let value = String::deserialize(deserializer)?;
99
100        // #t(rust_map_err_pure_wrap) Serde requires conversion into the deserializer's error type.
101        value.parse().map_err(D::Error::custom)
102    }
103}
104
105/// Failure to parse the canonical hexadecimal representation of a checksum.
106#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
107#[error("a checksum must contain exactly 64 hexadecimal characters")]
108pub struct ParseChecksumError;
109
110/// Compute the BLAKE3 checksum of bytes already in memory.
111#[must_use]
112pub fn bytes(contents: impl AsRef<[u8]>) -> Checksum {
113    Checksum(*blake3::hash(contents.as_ref()).as_bytes())
114}
115
116/// Compute the BLAKE3 checksum of a regular file.
117///
118/// The file is read as a stream rather than loaded entirely into memory.
119///
120/// # Errors
121///
122/// Returns an error when the path is missing, is not a regular file, or cannot be read.
123/// Inspection does not follow the final symbolic link.
124pub fn file(path: &Path) -> Result<Checksum> {
125    checked_file(path).map(|file| file.checksum)
126}
127
128/// Compute the checksum of the currently running executable.
129///
130/// This gives caches a strong identity for the exact tool implementation.
131///
132/// # Errors
133///
134/// Returns an error when the executable path cannot be resolved or read.
135#[cfg(not(target_family = "wasm"))]
136pub fn current_executable() -> Result<Checksum> {
137    let path = match std::env::current_exe() {
138        Ok(path) => PathBuf::from(path),
139        Err(source) => return Err(ErrorKind::CurrentExecutable(source).into()),
140    };
141
142    file(&path)
143}
144
145/// One regular file included in a [`TreeSnapshot`].
146#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
147pub struct TreeEntry {
148    relative_path: PathBuf,
149    len: u64,
150    checksum: Checksum,
151    #[serde(skip)]
152    components: Vec<Box<str>>,
153}
154
155impl TreeEntry {
156    /// Return the normalized path relative to the snapshot root.
157    #[must_use]
158    pub fn relative_path(&self) -> &Path {
159        &self.relative_path
160    }
161
162    /// Return the number of bytes read while checksumming the file.
163    #[must_use]
164    pub const fn len(&self) -> u64 {
165        self.len
166    }
167
168    /// Report whether the file was empty.
169    #[must_use]
170    pub const fn is_empty(&self) -> bool {
171        self.len == 0
172    }
173
174    /// Return the file content checksum.
175    #[must_use]
176    pub const fn checksum(&self) -> Checksum {
177        self.checksum
178    }
179}
180
181/// A deterministic snapshot of an explicitly reviewed set of regular files.
182///
183/// Entries are normalized and sorted by relative path.
184/// The checksum includes a format version, relative paths, lengths, and content checksums.
185/// Input order has no effect.
186/// Adding, deleting, renaming, or editing a file changes the checksum.
187#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188pub struct TreeSnapshot {
189    checksum: Checksum,
190    entries: Vec<TreeEntry>,
191    total_bytes: u64,
192}
193
194impl TreeSnapshot {
195    /// Capture an explicit set of files beneath a lexical root.
196    ///
197    /// Paths normally come from one of [`crate::walk`]'s reviewed traversal policies.
198    /// Relative components must be valid UTF-8 for portable checksums.
199    ///
200    /// Parent-directory symlinks are not resolved.
201    /// This operation establishes cache identity, not a containment boundary.
202    /// Use [`crate::containment`] first for paths originating from untrusted input.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error for invalid, duplicate, missing, non-regular, or unreadable paths.
207    pub fn capture<I, P>(root: &Path, paths: I) -> Result<Self>
208    where
209        I: IntoIterator<Item = P>,
210        P: AsRef<Path>,
211    {
212        let mut candidates = paths
213            .into_iter()
214            .map(|path| Candidate::new(root, path.as_ref()))
215            .collect::<Result<Vec<_>>>()?;
216
217        candidates.sort_by(|left, right| left.sort_key.cmp(&right.sort_key));
218
219        let duplicate = candidates.windows(ADJACENT_PAIR).position(|pair| {
220            pair.first()
221                .zip(pair.get(SECOND_ITEM))
222                .is_some_and(|(left, right)| left.sort_key == right.sort_key)
223        });
224
225        if let Some(index) = duplicate {
226            let duplicate = candidates.remove(index + SECOND_ITEM);
227
228            return Err(ErrorKind::DuplicatePath {
229                path: duplicate.relative_path,
230            }
231            .into());
232        }
233
234        let mut entries = Vec::with_capacity(candidates.len());
235        let mut total_bytes = 0_u64;
236
237        for candidate in candidates {
238            let captured = checked_file(&candidate.path)?;
239
240            total_bytes = total_bytes
241                .checked_add(captured.len)
242                .ok_or(ErrorKind::InputTooLarge)?;
243            entries.push(TreeEntry {
244                relative_path: candidate.relative_path,
245                len: captured.len,
246                checksum: captured.checksum,
247                components: candidate.sort_key,
248            });
249        }
250
251        let checksum = tree_checksum(&entries)?;
252
253        Ok(Self {
254            checksum,
255            entries,
256            total_bytes,
257        })
258    }
259
260    /// Return the checksum of the complete tree snapshot.
261    #[must_use]
262    pub const fn checksum(&self) -> Checksum {
263        self.checksum
264    }
265
266    /// Return entries in normalized relative-path order.
267    #[must_use]
268    pub fn entries(&self) -> &[TreeEntry] {
269        &self.entries
270    }
271
272    /// Return the number of captured files.
273    #[must_use]
274    pub fn file_count(&self) -> usize {
275        self.entries.len()
276    }
277
278    /// Return the sum of the captured file lengths.
279    #[must_use]
280    pub const fn total_bytes(&self) -> u64 {
281        self.total_bytes
282    }
283}
284
285/// Failure to checksum a file or capture a tree snapshot.
286#[derive(Debug, thiserror::Error)]
287#[error(transparent)]
288pub struct Error(#[from] ErrorKind);
289
290#[derive(Debug, thiserror::Error)]
291enum ErrorKind {
292    #[error(transparent)]
293    Filesystem(#[from] FilesystemError),
294    #[cfg(not(target_family = "wasm"))]
295    #[error("failed to resolve the current executable: {0}")]
296    CurrentExecutable(#[source] std::io::Error),
297    #[error("checksum path {path} is outside root {root}: {source}")]
298    OutsideRoot {
299        root: PathBuf,
300        path: PathBuf,
301        #[source]
302        source: crate::path::StripPrefixError,
303    },
304    #[error("checksum path {path} is the snapshot root")]
305    RootPath { path: PathBuf },
306    #[error("checksum path {path} has a non-UTF-8 or non-normal relative component")]
307    InvalidRelativePath { path: PathBuf },
308    #[error("checksum path {path} appears more than once")]
309    DuplicatePath { path: PathBuf },
310    #[error("checksum file {path} does not exist")]
311    MissingFile { path: PathBuf },
312    #[error("checksum path {path} is not a regular file ({kind:?})")]
313    NotRegularFile { path: PathBuf, kind: EntryKind },
314    #[error("checksum input exceeds the supported length")]
315    InputTooLarge,
316}
317
318impl From<FilesystemError> for Error {
319    fn from(source: FilesystemError) -> Self {
320        ErrorKind::Filesystem(source).into()
321    }
322}
323
324/// Result returned by checksum operations.
325pub type Result<T> = std::result::Result<T, Error>;
326
327struct Candidate {
328    path: PathBuf,
329    relative_path: PathBuf,
330    sort_key: Vec<Box<str>>,
331}
332
333impl Candidate {
334    fn new(root: &Path, path: &Path) -> Result<Self> {
335        let relative = path.strip_prefix(root).map_err(|source| {
336            Error::from(ErrorKind::OutsideRoot {
337                root: root.to_path_buf(),
338                path: path.to_path_buf(),
339                source,
340            })
341        })?;
342
343        if relative.is_empty() {
344            return Err(ErrorKind::RootPath {
345                path: path.to_path_buf(),
346            }
347            .into());
348        }
349
350        let mut relative_path = PathBuf::new();
351        let mut sort_key = Vec::new();
352
353        for component in relative.components() {
354            let Component::Normal(value) = component else {
355                return Err(ErrorKind::InvalidRelativePath {
356                    path: path.to_path_buf(),
357                }
358                .into());
359            };
360            let value = value
361                .to_str()
362                .ok_or_else(|| ErrorKind::InvalidRelativePath {
363                    path: path.to_path_buf(),
364                })?;
365
366            relative_path.push(value);
367            sort_key.push(value.into());
368        }
369
370        Ok(Self {
371            path: path.to_path_buf(),
372            relative_path,
373            sort_key,
374        })
375    }
376}
377
378struct CapturedFile {
379    checksum: Checksum,
380    len: u64,
381}
382
383fn checked_file(path: &Path) -> Result<CapturedFile> {
384    let Some(info) = directory::inspect_link(path)? else {
385        return Err(ErrorKind::MissingFile {
386            path: path.to_path_buf(),
387        }
388        .into());
389    };
390
391    if info.kind() != EntryKind::File {
392        return Err(ErrorKind::NotRegularFile {
393            path: path.to_path_buf(),
394            kind: info.kind(),
395        }
396        .into());
397    }
398
399    let file = crate::file::open(path)?;
400    let mut reader = CountingReader::new(file);
401    let mut hasher = blake3::Hasher::new();
402
403    hasher
404        .update_reader(&mut reader)
405        .map_err(|source| FilesystemError::new(Operation::ReadFile, path, source))?;
406
407    Ok(CapturedFile {
408        checksum: Checksum(*hasher.finalize().as_bytes()),
409        len: reader.len(),
410    })
411}
412
413struct CountingReader {
414    file: crate::file::File,
415    len: u64,
416}
417
418impl CountingReader {
419    const fn new(file: crate::file::File) -> Self {
420        Self { file, len: 0 }
421    }
422
423    const fn len(&self) -> u64 {
424        self.len
425    }
426}
427
428impl std::io::Read for CountingReader {
429    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
430        let read = self.file.read(buf)?;
431        let read_len = match u64::try_from(read) {
432            Ok(read_len) => read_len,
433            Err(source) => return Err(std::io::Error::other(source)),
434        };
435
436        self.len = self
437            .len
438            .checked_add(read_len)
439            .ok_or_else(|| std::io::Error::other("checksum input exceeds the supported length"))?;
440
441        Ok(read)
442    }
443}
444
445fn tree_checksum(entries: &[TreeEntry]) -> Result<Checksum> {
446    let mut hasher = blake3::Hasher::new();
447
448    hasher.update(TREE_DOMAIN);
449    update_length(&mut hasher, entries.len())?;
450
451    for entry in entries {
452        update_length(&mut hasher, entry.components.len())?;
453
454        for value in &entry.components {
455            update_length(&mut hasher, value.len())?;
456            hasher.update(value.as_bytes());
457        }
458
459        hasher.update(&entry.len.to_le_bytes());
460        hasher.update(entry.checksum.as_bytes());
461    }
462
463    Ok(Checksum(*hasher.finalize().as_bytes()))
464}
465
466fn update_length(hasher: &mut blake3::Hasher, len: usize) -> Result<()> {
467    let len = match u64::try_from(len) {
468        Ok(len) => len,
469        Err(_source) => return Err(ErrorKind::InputTooLarge.into()),
470    };
471
472    hasher.update(&len.to_le_bytes());
473
474    Ok(())
475}
476
477const fn decode_hex(value: u8) -> Option<u8> {
478    match value {
479        b'0'..=b'9' => Some(value - b'0'),
480        b'a'..=b'f' => Some(value - b'a' + HEX_ALPHA_OFFSET),
481        b'A'..=b'F' => Some(value - b'A' + HEX_ALPHA_OFFSET),
482        _ => None,
483    }
484}