Skip to main content

ic_host_artifacts/artifact/
mod.rs

1//! Bounded streams and raw SHA-256 identities for artifact bytes.
2//!
3//! Consumers own trusted paths, admitted digests, and byte limits. An identity
4//! describes the bytes read; it does not freeze a path for later execution or
5//! certify an executable's version. Copying writes only to a caller-owned sink;
6//! paths, confinement, synchronization, publication and cleanup remain local.
7
8use sha2::{Digest, Sha256};
9use std::{
10    fmt,
11    io::{self, Read},
12    str::FromStr,
13};
14
15mod chunks;
16mod copy;
17#[cfg(feature = "gzip")]
18mod gzip;
19mod hashing;
20mod matching;
21#[cfg(test)]
22mod tests;
23mod writer;
24
25pub use chunks::{ChunkDigestError, chunk_digests};
26pub use copy::{CopyError, copy_reader};
27#[cfg(feature = "gzip")]
28pub use gzip::{GzipError, decode_gzip, encode_gzip, gzip_matches, hash_gzip, hash_gzip_or_raw};
29pub use hashing::HashingWriter;
30pub use matching::MatchingWriter;
31pub use writer::{BoundedWriter, WriterError};
32
33/// Raw SHA-256 identity, without a product-specific prefix or wire format.
34#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
35pub struct Sha256Digest([u8; 32]);
36
37impl Sha256Digest {
38    /// Construct an identity from exact digest bytes.
39    #[must_use]
40    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
41        Self(bytes)
42    }
43
44    /// Borrow the digest bytes.
45    #[must_use]
46    pub const fn as_bytes(&self) -> &[u8; 32] {
47        &self.0
48    }
49
50    /// Hash bytes already held by the caller.
51    #[must_use]
52    pub fn compute(bytes: &[u8]) -> Self {
53        Self(Sha256::digest(bytes).into())
54    }
55}
56
57impl fmt::Display for Sha256Digest {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        for byte in self.0 {
60            write!(f, "{byte:02x}")?;
61        }
62        Ok(())
63    }
64}
65
66/// Invalid lowercase hexadecimal digest authority.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum DigestParseError {
69    /// The digest does not contain exactly 64 ASCII bytes.
70    Length {
71        /// Observed byte length.
72        actual: usize,
73    },
74    /// A byte is not a lowercase hexadecimal digit.
75    Digit {
76        /// Offset of the invalid byte.
77        offset: usize,
78    },
79}
80
81impl fmt::Display for DigestParseError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Length { actual } => {
85                write!(f, "SHA-256 requires 64 hex bytes, received {actual}")
86            }
87            Self::Digit { offset } => write!(f, "invalid lowercase SHA-256 digit at byte {offset}"),
88        }
89    }
90}
91impl std::error::Error for DigestParseError {}
92
93impl FromStr for Sha256Digest {
94    type Err = DigestParseError;
95
96    fn from_str(text: &str) -> Result<Self, Self::Err> {
97        if text.len() != 64 {
98            return Err(DigestParseError::Length { actual: text.len() });
99        }
100        let mut bytes = [0; 32];
101        for (offset, digit) in text.bytes().enumerate() {
102            let nibble = match digit {
103                b'0'..=b'9' => digit - b'0',
104                b'a'..=b'f' => digit - b'a' + 10,
105                _ => return Err(DigestParseError::Digit { offset }),
106            };
107            bytes[offset / 2] |= nibble << if offset % 2 == 0 { 4 } else { 0 };
108        }
109        Ok(Self(bytes))
110    }
111}
112
113/// Exact size and digest of one bounded byte stream.
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub struct ArtifactIdentity {
116    /// Number of bytes successfully read.
117    pub bytes: u64,
118    /// SHA-256 of those bytes.
119    pub sha256: Sha256Digest,
120}
121
122impl ArtifactIdentity {
123    /// Compare this observed digest with caller-selected authority.
124    ///
125    /// Does not read, hash, freeze or validate the origin of the identity.
126    /// Size admission and selection of the expected digest remain caller-owned.
127    ///
128    /// # Errors
129    /// Returns a typed mismatch retaining the expected digest and this identity.
130    pub fn verify(self, expected: Sha256Digest) -> Result<Self, ArtifactError> {
131        if self.sha256 != expected {
132            return Err(ArtifactError::DigestMismatch {
133                expected,
134                actual: self,
135            });
136        }
137        Ok(self)
138    }
139}
140
141/// A bounded read or digest verification failed.
142#[derive(Debug)]
143pub enum ArtifactError {
144    /// The reader or filesystem failed. No partial identity is returned.
145    Io(io::Error),
146    /// The selected path is not a regular file.
147    NotRegularFile,
148    /// A stream exceeded the caller's byte allowance.
149    LimitExceeded {
150        /// Maximum permitted bytes.
151        limit: u64,
152    },
153    /// The complete bounded stream does not match the admitted digest.
154    DigestMismatch {
155        /// Caller-selected authority.
156        expected: Sha256Digest,
157        /// Observed size and digest.
158        actual: ArtifactIdentity,
159    },
160    /// Storage could not be allocated for a bounded file read.
161    Allocation(std::collections::TryReserveError),
162}
163
164impl fmt::Display for ArtifactError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::Io(source) => write!(f, "artifact read failed: {source}"),
168            Self::NotRegularFile => f.write_str("artifact path is not a regular file"),
169            Self::LimitExceeded { limit } => write!(f, "artifact exceeds {limit} bytes"),
170            Self::DigestMismatch { expected, actual } => write!(
171                f,
172                "artifact SHA-256 is {}, expected {expected}",
173                actual.sha256
174            ),
175            Self::Allocation(source) => write!(f, "artifact allocation failed: {source}"),
176        }
177    }
178}
179impl std::error::Error for ArtifactError {
180    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
181        match self {
182            Self::Io(source) => Some(source),
183            Self::Allocation(source) => Some(source),
184            _ => None,
185        }
186    }
187}
188impl From<io::Error> for ArtifactError {
189    fn from(source: io::Error) -> Self {
190        Self::Io(source)
191    }
192}
193
194/// Preserve native I/O identity or retain the typed artifact cause at an I/O boundary.
195impl From<ArtifactError> for io::Error {
196    fn from(error: ArtifactError) -> Self {
197        match error {
198            ArtifactError::Io(source) => source,
199            other => Self::other(other),
200        }
201    }
202}
203
204/// Hash one stream with constant working memory and no reliance on metadata.
205///
206/// Reads at most `max_bytes + 1` bytes, using the extra byte to detect overflow.
207/// Retries interrupted reads only; no subprocess or network retry is performed.
208/// A blocking reader's timeouts remain the caller's responsibility.
209///
210/// # Errors
211/// Returns [`ArtifactError::LimitExceeded`] or the underlying read error.
212/// An impossible reader byte count returns IO [`io::ErrorKind::InvalidData`].
213pub fn hash_reader(
214    mut reader: impl Read,
215    max_bytes: u64,
216) -> Result<ArtifactIdentity, ArtifactError> {
217    let mut hasher = Sha256::new();
218    let bytes = visit_reader::<ArtifactError>(&mut reader, max_bytes, |chunk| {
219        hasher.update(chunk);
220        Ok(())
221    })?;
222    Ok(ArtifactIdentity {
223        bytes,
224        sha256: Sha256Digest(hasher.finalize().into()),
225    })
226}
227
228/// Verify a complete bounded stream before the caller extracts or uses its bytes.
229///
230/// # Errors
231/// Returns read/limit failures or a typed mismatch with the observed identity.
232pub fn verify_reader(
233    reader: impl Read,
234    max_bytes: u64,
235    expected: Sha256Digest,
236) -> Result<ArtifactIdentity, ArtifactError> {
237    hash_reader(reader, max_bytes)?.verify(expected)
238}
239
240/// Read one stream into bounded, fallibly allocated storage.
241///
242/// Observes at most `max_bytes + 1` bytes to detect overflow. A blocking reader's
243/// deadline remains caller-owned. Only interrupted reads are retried.
244///
245/// # Errors
246/// Returns read, allocation, or byte-limit failures. An impossible reader byte
247/// count returns IO [`io::ErrorKind::InvalidData`].
248pub fn read_reader(mut reader: impl Read, max_bytes: usize) -> Result<Vec<u8>, ArtifactError> {
249    let mut bytes = Vec::new();
250    visit_reader::<ArtifactError>(&mut reader, max_bytes as u64, |chunk| {
251        reserve_bounded(&mut bytes, chunk.len(), max_bytes).map_err(ArtifactError::Allocation)?;
252        bytes.extend_from_slice(chunk);
253        Ok(())
254    })?;
255    Ok(bytes)
256}
257
258// Callers admit the additional elements against `limit` before reserving.
259// Grow geometrically without requesting capacity beyond that element budget.
260fn reserve_bounded<T>(
261    items: &mut Vec<T>,
262    additional: usize,
263    limit: usize,
264) -> Result<(), std::collections::TryReserveError> {
265    let required = items.len() + additional;
266    if required > items.capacity() {
267        let capacity = items.capacity().saturating_mul(2).max(required).min(limit);
268        items.try_reserve_exact(capacity - items.len())?;
269    }
270    Ok(())
271}
272
273fn visit_reader<E: From<ArtifactError>>(
274    reader: &mut impl Read,
275    limit: u64,
276    mut visit: impl FnMut(&[u8]) -> Result<(), E>,
277) -> Result<u64, E> {
278    let mut bytes = 0_u64;
279    let mut buffer = [0_u8; 16 * 1024];
280    loop {
281        let remaining = usize::try_from(limit - bytes).unwrap_or(usize::MAX);
282        let allowance = remaining.saturating_add(1).min(buffer.len());
283        let count = match reader.read(&mut buffer[..allowance]) {
284            Ok(count) => count,
285            Err(source) if source.kind() == io::ErrorKind::Interrupted => continue,
286            Err(source) => return Err(ArtifactError::Io(source).into()),
287        };
288        if count > allowance {
289            return Err(ArtifactError::Io(io::Error::new(
290                io::ErrorKind::InvalidData,
291                "reader returned more bytes than its buffer can hold",
292            ))
293            .into());
294        }
295        if count == 0 {
296            return Ok(bytes);
297        }
298        if count as u64 > limit - bytes {
299            return Err(ArtifactError::LimitExceeded { limit }.into());
300        }
301        visit(&buffer[..count])?;
302        bytes += count as u64;
303    }
304}