Skip to main content

ic_host_fs/read/tree/checksum/
mod.rs

1//! Directory hashing over the existing no-follow descriptor walker.
2//!
3//! Extracted from IC Backup's directory checksum visitor; product staging,
4//! publication, fixed layouts and recovery remain with Backup.
5
6use super::{TreeVisitor, walk_tree};
7use crate::read::{OpenEntryError, OpenedEntry, open_directory_no_follow, open_entry_beneath};
8use ic_host_artifacts::artifact::{
9    ArtifactError, Sha256Digest, hash_reader,
10    tree::{DirectoryChecksumError, checksum_relative_files},
11};
12use rustix::{
13    fd::{AsFd, OwnedFd},
14    fs::FileType,
15};
16use std::{
17    fs::File,
18    io,
19    path::{Component, Path, PathBuf},
20};
21
22/// Hash a directory selected by an exact normal-component path without following links.
23///
24/// Absolute paths open from `/`, relative paths from `.`. Empty, root-only,
25/// non-UTF-8 and non-normal components reject. Every child uses the existing
26/// no-follow/nonblocking descriptor walker. Each file stream is bounded by
27/// `max_file_bytes`; callers own total bytes, entry count, depth, memory and time.
28/// No files are written or synced. Hashes do not freeze bytes or path identities;
29/// callers must exclude concurrent content/namespace mutation when needed.
30///
31/// # Errors
32/// Rejects unsafe paths/entries, over-limit streams and IO failures.
33pub fn checksum_directory(
34    path: &Path,
35    max_file_bytes: u64,
36) -> Result<Sha256Digest, TreeChecksumError> {
37    let (root, relative) = if path.is_absolute() {
38        (
39            Path::new("/"),
40            path.strip_prefix("/").map_err(io::Error::other)?,
41        )
42    } else {
43        (Path::new("."), path)
44    };
45    if relative.as_os_str().is_empty()
46        || !relative
47            .components()
48            .all(|part| matches!(part, Component::Normal(name) if name.to_str().is_some()))
49    {
50        return Err(TreeChecksumError::InvalidPath {
51            path: path.to_path_buf(),
52        });
53    }
54    let root = open_directory_no_follow(root)?;
55    let directory = match open_entry_beneath(root.as_fd(), relative) {
56        Ok(OpenedEntry::Directory(file)) => OwnedFd::from(file),
57        Ok(OpenedEntry::RegularFile(_)) => {
58            return Err(TreeChecksumError::UnsupportedEntry {
59                path: path.to_path_buf(),
60                kind: FileType::RegularFile,
61            });
62        }
63        Err(OpenEntryError::UnsupportedEntry { kind }) => {
64            return Err(TreeChecksumError::UnsupportedEntry {
65                path: path.to_path_buf(),
66                kind,
67            });
68        }
69        Err(OpenEntryError::Io(error)) => return Err(error.into()),
70    };
71    checksum_directory_at(&directory, path, max_file_bytes)
72}
73
74/// Hash beneath a caller-held root using exact sorted path/NUL/digest/newline framing.
75///
76/// The root descriptor selects an inode; `display_root` is diagnostic only. Root
77/// provenance, namespace/content exclusion, total tree budgets and future custody
78/// remain caller-owned. Per-file stream limits apply independently. Files are
79/// freshly opened by the walker; this operation neither synchronizes nor publishes.
80///
81/// # Errors
82/// Rejects unsupported/non-UTF-8 children, file limits and descriptor/stream errors.
83pub fn checksum_directory_at(
84    directory: &OwnedFd,
85    display_root: &Path,
86    max_file_bytes: u64,
87) -> Result<Sha256Digest, TreeChecksumError> {
88    let mut visitor = ChecksumVisitor::new(max_file_bytes);
89    walk_tree(directory, Path::new(""), display_root, &mut visitor)?;
90    visitor.finish()
91}
92
93pub(crate) struct ChecksumVisitor {
94    checksums: Vec<(PathBuf, Sha256Digest)>,
95    max_file_bytes: u64,
96}
97impl ChecksumVisitor {
98    pub(crate) const fn new(max_file_bytes: u64) -> Self {
99        Self {
100            checksums: Vec::new(),
101            max_file_bytes,
102        }
103    }
104
105    pub(crate) fn record_file(
106        &mut self,
107        file: File,
108        relative: &Path,
109    ) -> Result<(), TreeChecksumError> {
110        let identity = hash_reader(file, self.max_file_bytes).map_err(TreeChecksumError::Input)?;
111        self.checksums
112            .push((relative.to_path_buf(), identity.sha256));
113        Ok(())
114    }
115
116    pub(crate) fn finish(self) -> Result<Sha256Digest, TreeChecksumError> {
117        checksum_relative_files(self.checksums).map_err(TreeChecksumError::Declaration)
118    }
119}
120
121impl TreeVisitor for ChecksumVisitor {
122    type Error = TreeChecksumError;
123    fn file(&mut self, fd: &OwnedFd, relative: &Path, _display: &Path) -> Result<(), Self::Error> {
124        self.record_file(File::from(fd.try_clone()?), relative)
125    }
126    fn enter_directory(&mut self, _relative: &Path, _display: &Path) -> Result<(), Self::Error> {
127        Ok(())
128    }
129    fn leave_directory(
130        &mut self,
131        _fd: &OwnedFd,
132        _relative: &Path,
133        _display: &Path,
134    ) -> Result<(), Self::Error> {
135        Ok(())
136    }
137    fn unsupported(&self, display: &Path, kind: FileType) -> Self::Error {
138        TreeChecksumError::UnsupportedEntry {
139            path: display.to_path_buf(),
140            kind,
141        }
142    }
143    fn non_utf8(&self, display: &Path) -> Self::Error {
144        TreeChecksumError::NonUtf8Path {
145            path: display.to_path_buf(),
146        }
147    }
148}
149
150/// Directory-path, descriptor, bounded-stream or declared-identity failure.
151#[derive(Debug, thiserror::Error)]
152pub enum TreeChecksumError {
153    /// A filesystem observation or descriptor operation failed.
154    #[error("{0}")]
155    Io(#[from] io::Error),
156    /// A regular-file stream failed or exceeded its caller-selected bound.
157    #[error("{0}")]
158    Input(#[from] ArtifactError),
159    /// Declared file identities cannot produce the canonical checksum.
160    #[error("{0}")]
161    Declaration(#[from] DirectoryChecksumError),
162    /// A selected path cannot be opened through normal UTF-8 components.
163    #[error("invalid directory checksum path: {path:?}")]
164    InvalidPath {
165        /// Exact rejected path.
166        path: PathBuf,
167    },
168    /// A child name is not UTF-8.
169    #[error("directory checksum path is not UTF-8: {path:?}")]
170    NonUtf8Path {
171        /// Exact rejected path.
172        path: PathBuf,
173    },
174    /// A tree entry is not a regular file or directory.
175    #[error("unsupported directory checksum entry at {path:?}: {kind:?}")]
176    UnsupportedEntry {
177        /// Rejected entry.
178        path: PathBuf,
179        /// Observed filesystem entry type.
180        kind: FileType,
181    },
182}
183
184#[cfg(test)]
185mod tests;