Skip to main content

ic_backup/ops/artifacts/
mod.rs

1//! Stream artifact bytes through descriptor-based no-follow traversal.
2
3#[cfg(test)]
4mod regressions;
5mod secure;
6#[cfg(test)]
7mod tests;
8
9use crate::model::artifacts::ArtifactChecksumRecord;
10use crate::model::evidence_digest::EvidenceDigest;
11#[cfg(any(unix, test))]
12use std::io::Read;
13use std::{
14    io,
15    path::{Component, Path, PathBuf},
16};
17use thiserror::Error;
18
19/// Checksum a directory using sorted relative-path/file-digest pairs.
20///
21/// # Errors
22/// Rejects unsafe entry types, unsupported platforms and filesystem failures.
23pub fn checksum_directory(path: &Path) -> Result<ArtifactChecksumRecord, ArtifactError> {
24    secure::checksum_directory(path)
25}
26
27#[cfg(any(unix, test))]
28/// Stream an already-open reader using a bounded transfer buffer.
29///
30/// Interrupted reads retry internally. The caller owns blocking and timeouts;
31/// no network or paid-operation retry is performed.
32///
33/// # Errors
34/// Returns other reader IO failures unchanged; impossible byte counts reject as
35/// [`io::ErrorKind::InvalidData`] rather than indexing outside the transfer buffer.
36pub(crate) fn checksum_reader(
37    reader: &mut impl Read,
38) -> Result<ArtifactChecksumRecord, ArtifactError> {
39    let identity =
40        ic_host_artifacts::artifact::hash_reader(reader, u64::MAX).map_err(io::Error::from)?;
41    Ok(ArtifactChecksumRecord::from_digest(
42        *identity.sha256.as_bytes(),
43    ))
44}
45
46/// Compose the maintained directory checksum from already-admitted file checksums.
47///
48/// Names must be exact UTF-8 relative paths with nonempty normal components,
49/// separated by `/`, without NUL bytes, normalization aliases or duplicates.
50/// Unix filename bytes such as newlines and backslashes retain their exact meaning.
51/// Entries sort by [`PathBuf`] ordering, then hash UTF-8 path bytes, NUL,
52/// the canonical lowercase file digest and newline. An empty set hashes empty bytes.
53///
54/// Performs no filesystem IO. The caller owns descriptor/byte custody, completeness,
55/// synchronization and publication; declared checksums prove none of these.
56///
57/// The checksums below stand for bytes already admitted by the caller. A
58/// descriptor-owning consumer may supply its retained records instead.
59///
60/// ```
61/// use ic_backup::{
62///     model::artifacts::ArtifactChecksumRecord,
63///     ops::artifacts::{DirectoryChecksumError, checksum_relative_files},
64/// };
65///
66/// let checksum = checksum_relative_files(vec![
67///     ("a.txt".into(), ArtifactChecksumRecord::from_bytes(b"a")),
68///     ("nested/b.txt".into(), ArtifactChecksumRecord::from_bytes(b"b")),
69/// ])?;
70/// assert_eq!(
71///     checksum.hash(),
72///     "e4d330f138b8f1b3044e84b5dcbe4fd1cb7e043d0c20810c083d791b6de01266",
73/// );
74/// # Ok::<(), DirectoryChecksumError>(())
75/// ```
76///
77/// # Errors
78/// Returns a typed refusal for non-UTF-8, malformed or duplicate identities.
79pub fn checksum_relative_files(
80    mut files: Vec<(PathBuf, ArtifactChecksumRecord)>,
81) -> Result<ArtifactChecksumRecord, DirectoryChecksumError> {
82    files.sort_by(|left, right| left.0.cmp(&right.0));
83    let mut hasher = EvidenceDigest::new(&[]);
84    let mut previous = None;
85    for (relative, checksum) in &files {
86        let name = relative
87            .to_str()
88            .ok_or_else(|| DirectoryChecksumError::NonUtf8Path {
89                path: relative.clone(),
90            })?;
91        if name.contains('\0')
92            || name.split('/').any(|part| matches!(part, "" | "." | ".."))
93            || !relative
94                .components()
95                .all(|part| matches!(part, Component::Normal(_)))
96        {
97            return Err(DirectoryChecksumError::InvalidRelativePath {
98                path: relative.clone(),
99            });
100        }
101        if previous == Some(relative) {
102            return Err(DirectoryChecksumError::DuplicatePath {
103                path: relative.clone(),
104            });
105        }
106        previous = Some(relative);
107        hasher.append(name.as_bytes());
108        hasher.byte(0);
109        hasher.append(checksum.hash().as_bytes());
110        hasher.append(b"\n");
111    }
112    Ok(hasher.finish())
113}
114
115/// Invalid declared file identities at the I/O-free directory checksum boundary.
116#[derive(Clone, Debug, Eq, Error, PartialEq)]
117pub enum DirectoryChecksumError {
118    /// The supplied path cannot retain exact UTF-8 identity.
119    #[error("directory checksum path is not UTF-8: {path:?}")]
120    NonUtf8Path {
121        /// Exact rejected path.
122        path: PathBuf,
123    },
124    /// The path contains invalid bytes or noncanonical/unnormalized components.
125    #[error("directory checksum path is not canonical and relative: {path:?}")]
126    InvalidRelativePath {
127        /// Exact rejected path, without normalization.
128        path: PathBuf,
129    },
130    /// Two entries declare the same canonical path, even if their digests agree.
131    #[error("duplicate directory checksum path: {path:?}")]
132    DuplicatePath {
133        /// Exact repeated path.
134        path: PathBuf,
135    },
136}
137
138impl From<DirectoryChecksumError> for ArtifactError {
139    fn from(error: DirectoryChecksumError) -> Self {
140        match error {
141            DirectoryChecksumError::NonUtf8Path { path } => Self::NonUtf8Path { path },
142            error => Self::Io(io::Error::new(io::ErrorKind::InvalidData, error)),
143        }
144    }
145}
146
147/// Typed artifact traversal or IO failure.
148#[derive(Debug, Error)]
149pub enum ArtifactError {
150    /// A path cannot be represented exactly in the maintained UTF-8 tree digest.
151    #[error("artifact path is not UTF-8: {path:?}")]
152    NonUtf8Path {
153        /// Exact rejected filesystem path.
154        path: PathBuf,
155    },
156    /// A filesystem operation or stream failed.
157    #[error(transparent)]
158    Io(#[from] io::Error),
159    /// A tree entry is neither a regular file nor a directory.
160    #[error("unsupported artifact entry at {path}: {kind}")]
161    UnsupportedEntry {
162        /// Entry path for diagnostics.
163        path: String,
164        /// Observed filesystem entry kind.
165        kind: String,
166    },
167    /// Secure descriptor traversal is unavailable on this host.
168    #[error("secure artifact traversal is unsupported on platform {0}")]
169    UnsupportedPlatform(&'static str),
170}