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