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