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 sha2::{Digest, Sha256};
11#[cfg(unix)]
12use std::io::Write;
13use std::{
14    io::{self, Read},
15    path::{Component, Path, PathBuf},
16};
17use thiserror::Error;
18
19/// Checksum one regular filesystem file without following path symlinks.
20///
21/// # Errors
22/// Rejects unsafe entry types, unsupported platforms and filesystem failures.
23pub fn checksum_file(path: &Path) -> Result<ArtifactChecksumRecord, ArtifactError> {
24    secure::checksum_path(path, secure::ExpectedArtifactType::File)
25}
26
27/// Checksum one file or a deterministic directory listing.
28///
29/// # Errors
30/// Rejects unsafe entry types, unsupported platforms and filesystem failures.
31pub fn checksum_path(path: &Path) -> Result<ArtifactChecksumRecord, ArtifactError> {
32    secure::checksum_path(path, secure::ExpectedArtifactType::Any)
33}
34
35/// Checksum a directory using sorted relative-path/file-digest pairs.
36///
37/// # Errors
38/// Rejects unsafe entry types, unsupported platforms and filesystem failures.
39pub fn checksum_directory(path: &Path) -> Result<ArtifactChecksumRecord, ArtifactError> {
40    secure::checksum_path(path, secure::ExpectedArtifactType::Directory)
41}
42
43/// Stream an already-open reader using a bounded transfer buffer.
44///
45/// Interrupted reads retry internally. The caller owns blocking and timeouts;
46/// no network or paid-operation retry is performed.
47///
48/// # Errors
49/// Returns other reader IO failures unchanged; impossible byte counts reject as
50/// [`io::ErrorKind::InvalidData`] rather than indexing outside the transfer buffer.
51pub fn checksum_reader(reader: &mut impl Read) -> Result<ArtifactChecksumRecord, ArtifactError> {
52    let identity =
53        ic_host_artifacts::artifact::hash_reader(reader, u64::MAX).map_err(io::Error::from)?;
54    Ok(ArtifactChecksumRecord::from_digest(
55        *identity.sha256.as_bytes(),
56    ))
57}
58
59#[cfg(unix)]
60pub(crate) fn copy_from_reader(
61    reader: &mut impl Read,
62    writer: &mut impl Write,
63) -> Result<ArtifactChecksumRecord, ArtifactError> {
64    use ic_host_artifacts::artifact::CopyError;
65
66    // Artifacts have no total-size ceiling here. Descriptor admission, private
67    // staging, retained checksum comparison and publication remain local.
68    let identity =
69        ic_host_artifacts::artifact::copy_reader(reader, writer, u64::MAX).map_err(|error| {
70            match error {
71                CopyError::Input(error) => ArtifactError::Io(error.into()),
72                CopyError::Output(error) => ArtifactError::Io(error),
73            }
74        })?;
75    Ok(ArtifactChecksumRecord::from_digest(
76        *identity.sha256.as_bytes(),
77    ))
78}
79
80/// Compose the maintained directory checksum from already-admitted file checksums.
81///
82/// Names must be exact UTF-8 relative paths with nonempty normal components,
83/// separated by `/`, without NUL bytes, normalization aliases or duplicates.
84/// Unix filename bytes such as newlines and backslashes retain their exact meaning.
85/// Entries sort by [`PathBuf`] ordering, then hash UTF-8 path bytes, NUL,
86/// the canonical lowercase file digest and newline. An empty set hashes empty bytes.
87///
88/// Performs no filesystem IO. The caller owns descriptor/byte custody, completeness,
89/// synchronization and publication; declared checksums prove none of these.
90///
91/// The checksums below stand for bytes already admitted by the caller. A
92/// descriptor-owning consumer may supply its retained records instead.
93///
94/// ```
95/// use ic_backup::{
96///     model::artifacts::ArtifactChecksumRecord,
97///     ops::artifacts::{DirectoryChecksumError, checksum_relative_files},
98/// };
99///
100/// let checksum = checksum_relative_files(vec![
101///     ("a.txt".into(), ArtifactChecksumRecord::from_bytes(b"a")),
102///     ("nested/b.txt".into(), ArtifactChecksumRecord::from_bytes(b"b")),
103/// ])?;
104/// assert_eq!(
105///     checksum.hash(),
106///     "e4d330f138b8f1b3044e84b5dcbe4fd1cb7e043d0c20810c083d791b6de01266",
107/// );
108/// # Ok::<(), DirectoryChecksumError>(())
109/// ```
110///
111/// # Errors
112/// Returns a typed refusal for non-UTF-8, malformed or duplicate identities.
113pub fn checksum_relative_files(
114    mut files: Vec<(PathBuf, ArtifactChecksumRecord)>,
115) -> Result<ArtifactChecksumRecord, DirectoryChecksumError> {
116    files.sort_by(|left, right| left.0.cmp(&right.0));
117    let mut hasher = Sha256::new();
118    let mut previous = None;
119    for (relative, checksum) in &files {
120        let name = relative
121            .to_str()
122            .ok_or_else(|| DirectoryChecksumError::NonUtf8Path {
123                path: relative.clone(),
124            })?;
125        if name.contains('\0')
126            || name.split('/').any(|part| matches!(part, "" | "." | ".."))
127            || !relative
128                .components()
129                .all(|part| matches!(part, Component::Normal(_)))
130        {
131            return Err(DirectoryChecksumError::InvalidRelativePath {
132                path: relative.clone(),
133            });
134        }
135        if previous == Some(relative) {
136            return Err(DirectoryChecksumError::DuplicatePath {
137                path: relative.clone(),
138            });
139        }
140        previous = Some(relative);
141        hasher.update(name.as_bytes());
142        hasher.update([0]);
143        hasher.update(checksum.hash().as_bytes());
144        hasher.update(*b"\n");
145    }
146    Ok(ArtifactChecksumRecord::from_digest(
147        hasher.finalize().into(),
148    ))
149}
150
151/// Invalid declared file identities at the I/O-free directory checksum boundary.
152#[derive(Clone, Debug, Eq, Error, PartialEq)]
153pub enum DirectoryChecksumError {
154    /// The supplied path cannot retain exact UTF-8 identity.
155    #[error("directory checksum path is not UTF-8: {path:?}")]
156    NonUtf8Path {
157        /// Exact rejected path.
158        path: PathBuf,
159    },
160    /// The path contains invalid bytes or noncanonical/unnormalized components.
161    #[error("directory checksum path is not canonical and relative: {path:?}")]
162    InvalidRelativePath {
163        /// Exact rejected path, without normalization.
164        path: PathBuf,
165    },
166    /// Two entries declare the same canonical path, even if their digests agree.
167    #[error("duplicate directory checksum path: {path:?}")]
168    DuplicatePath {
169        /// Exact repeated path.
170        path: PathBuf,
171    },
172}
173
174impl From<DirectoryChecksumError> for ArtifactError {
175    fn from(error: DirectoryChecksumError) -> Self {
176        match error {
177            DirectoryChecksumError::NonUtf8Path { path } => Self::NonUtf8Path { path },
178            error => Self::Io(io::Error::new(io::ErrorKind::InvalidData, error)),
179        }
180    }
181}
182
183#[cfg(unix)]
184fn require_utf8_tree_name(
185    name: &std::ffi::OsStr,
186    display_root: &Path,
187) -> Result<(), ArtifactError> {
188    if name.to_str().is_none() {
189        return Err(ArtifactError::NonUtf8Path {
190            path: display_root.join(name),
191        });
192    }
193    Ok(())
194}
195
196/// Checksum a normal relative path beneath an operator-selected root.
197///
198/// # Errors
199/// Rejects traversal, symlinks, special entries and IO failures.
200pub fn checksum_relative_path(
201    root: &Path,
202    relative: &Path,
203) -> Result<ArtifactChecksumRecord, ArtifactError> {
204    secure::checksum_relative_path(root, relative)
205}
206
207/// Stage exact source bytes in a new private file or directory and checksum them.
208///
209/// The caller owns a trusted destination parent. This copy is not durable
210/// publication; use the persistence operation after verifying its digest.
211///
212/// # Errors
213/// Rejects source traversal/symlinks, existing destinations and IO failures.
214pub fn stage_relative_path(
215    root: &Path,
216    relative: &Path,
217    destination: &Path,
218) -> Result<ArtifactChecksumRecord, ArtifactError> {
219    secure::stage_relative_path(root, relative, destination)
220}
221
222/// Typed artifact traversal or IO failure.
223#[derive(Debug, Error)]
224pub enum ArtifactError {
225    /// A path cannot be represented exactly in the maintained UTF-8 tree digest.
226    #[error("artifact path is not UTF-8: {path:?}")]
227    NonUtf8Path {
228        /// Exact rejected filesystem path.
229        path: PathBuf,
230    },
231    /// A filesystem operation or stream failed.
232    #[error(transparent)]
233    Io(#[from] io::Error),
234    /// A tree entry is neither a regular file nor a directory.
235    #[error("unsupported artifact entry at {path}: {kind}")]
236    UnsupportedEntry {
237        /// Entry path for diagnostics.
238        path: String,
239        /// Observed filesystem entry kind.
240        kind: String,
241    },
242    /// Secure descriptor traversal is unavailable on this host.
243    #[error("secure artifact traversal is unsupported on platform {0}")]
244    UnsupportedPlatform(&'static str),
245}