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