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