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