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