Skip to main content

ic_backup/ops/artifacts/
mod.rs

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