Skip to main content

ic_host_artifacts/artifact/tree/
mod.rs

1//! Pure directory identities from exact relative paths and caller-admitted digests.
2//!
3//! Extracted from IC Backup's maintained directory checksum implementation.
4//! This module performs no IO and proves no tree completeness, byte custody or durability.
5
6use super::{Digest as _, Sha256, Sha256Digest};
7use std::{
8    fmt,
9    path::{Component, PathBuf},
10};
11
12/// Compose a directory checksum from sorted relative-path/file-digest pairs.
13///
14/// Paths must be exact UTF-8, slash-separated normal components, without NUL,
15/// empty components, aliases or duplicates. Newlines and Unix backslashes retain
16/// their literal meaning. Sorting uses `PathBuf` component ordering, not complete
17/// text-byte ordering. Hash each path, NUL, lowercase SHA-256 hex and newline;
18/// an empty set hashes empty bytes. Inputs are declarations, not authenticated bytes.
19/// Callers own file-count/memory limits and native path semantics.
20///
21/// # Errors
22/// Rejects non-UTF-8, malformed and duplicate relative identities.
23pub fn checksum_relative_files(
24    mut files: Vec<(PathBuf, Sha256Digest)>,
25) -> Result<Sha256Digest, DirectoryChecksumError> {
26    files.sort_by(|left, right| left.0.cmp(&right.0));
27    let mut hasher = Sha256::new();
28    let mut previous = None;
29    for (relative, checksum) in &files {
30        let name = relative
31            .to_str()
32            .ok_or_else(|| DirectoryChecksumError::NonUtf8Path {
33                path: relative.clone(),
34            })?;
35        if name.contains('\0')
36            || name.split('/').any(|part| matches!(part, "" | "." | ".."))
37            || !relative
38                .components()
39                .all(|part| matches!(part, Component::Normal(_)))
40        {
41            return Err(DirectoryChecksumError::InvalidRelativePath {
42                path: relative.clone(),
43            });
44        }
45        if previous == Some(relative) {
46            return Err(DirectoryChecksumError::DuplicatePath {
47                path: relative.clone(),
48            });
49        }
50        previous = Some(relative);
51        hasher.update(name.as_bytes());
52        hasher.update([0]);
53        hasher.update(checksum.hex_bytes());
54        hasher.update(b"\n");
55    }
56    Ok(Sha256Digest::from_bytes(hasher.finalize().into()))
57}
58
59/// Invalid declared file identity; no filesystem operation has occurred.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub enum DirectoryChecksumError {
62    /// The exact identity cannot be represented in UTF-8.
63    NonUtf8Path {
64        /// Rejected path, without lossy conversion.
65        path: PathBuf,
66    },
67    /// The path is absolute, malformed or contains normalization aliases.
68    InvalidRelativePath {
69        /// Rejected path, without normalization.
70        path: PathBuf,
71    },
72    /// A canonical relative identity occurs twice, even with equal digests.
73    DuplicatePath {
74        /// Repeated path.
75        path: PathBuf,
76    },
77}
78
79impl fmt::Display for DirectoryChecksumError {
80    #[expect(
81        clippy::unnecessary_debug_formatting,
82        reason = "retain escaped exact path identities, including control characters and non-UTF-8 bytes"
83    )]
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::NonUtf8Path { path } => {
87                write!(formatter, "directory checksum path is not UTF-8: {path:?}")
88            }
89            Self::InvalidRelativePath { path } => write!(
90                formatter,
91                "directory checksum path is not canonical and relative: {path:?}"
92            ),
93            Self::DuplicatePath { path } => {
94                write!(formatter, "duplicate directory checksum path: {path:?}")
95            }
96        }
97    }
98}
99impl std::error::Error for DirectoryChecksumError {}
100
101#[cfg(test)]
102mod tests;