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