Skip to main content

ic_host_tools/artifact/
mod.rs

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