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}