Skip to main content

ic_host_tools/archive/
mod.rs

1//! Verify a gzip-compressed tar archive and select one admitted regular member.
2//!
3//! This boundary performs no filesystem writes, downloads, execution or
4//! installation. Consumers supply both digests, the exact member and all limits.
5//! GNU/PAX extension records, sparse files, links and special files are rejected;
6//! only ordinary regular files and directories are accepted. Native distribution
7//! compatibility must be qualified before replacing a consumer's installer.
8
9#[cfg(test)]
10mod tests;
11
12use std::{collections::TryReserveError, fmt, io};
13
14use crate::artifact::{self, ArtifactError, ArtifactIdentity, Sha256Digest};
15
16/// Independent input, inflation, traversal and selected-payload limits.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct ArchiveLimits {
19    /// Maximum compressed input bytes, checked before decompression.
20    pub archive_bytes: usize,
21    /// Maximum complete inflated tar bytes, including metadata and padding.
22    pub decompressed_bytes: usize,
23    /// Maximum raw tar records, including directories.
24    pub members: u32,
25    /// Maximum bytes in the selected regular-file payload.
26    pub member_bytes: usize,
27}
28
29/// A resource whose caller-supplied bound was exceeded.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum ArchiveResource {
32    /// Number of raw tar records.
33    Members,
34    /// Selected regular-file payload size.
35    MemberBytes,
36}
37
38/// Typed archive admission and decoding failures.
39#[derive(Debug)]
40pub enum ArchiveError {
41    /// The requested member is not a canonical relative POSIX path.
42    InvalidMember,
43    /// Compressed bytes failed bounded digest verification.
44    Archive(ArtifactError),
45    /// Gzip decoding, its integrity check or its inflated byte bound failed.
46    Decompression(ArtifactError),
47    /// Bytes follow the single gzip stream, including another gzip stream.
48    TrailingCompressedData,
49    /// Tar header framing, checksum or numeric decoding failed.
50    Tar(io::Error),
51    /// A raw record exceeded its caller-owned resource bound.
52    LimitExceeded {
53        /// Exhausted resource.
54        resource: ArchiveResource,
55        /// Caller's bound.
56        limit: u64,
57    },
58    /// Unsupported metadata, link, sparse or special-file record.
59    UnsupportedEntry {
60        /// One-based raw record number.
61        entry: u32,
62        /// Raw tar entry-type byte.
63        kind: u8,
64    },
65    /// An entry's declared payload/padding extends beyond the inflated bytes.
66    TruncatedEntry {
67        /// One-based raw record number.
68        entry: u32,
69    },
70    /// Tar end markers or the remaining zero padding are malformed.
71    InvalidPadding,
72    /// No regular member has the exact requested name.
73    MissingMember,
74    /// More than one record has the exact requested name.
75    DuplicateMember,
76    /// Selected bytes differ from the consumer's admitted executable digest.
77    Member(ArtifactError),
78    /// Fallible selected-payload allocation failed.
79    Allocation(TryReserveError),
80}
81
82impl fmt::Display for ArchiveError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::InvalidMember => {
86                f.write_str("archive member must be a canonical relative POSIX path")
87            }
88            Self::Archive(_) => f.write_str("compressed archive admission failed"),
89            Self::Decompression(_) => f.write_str("bounded gzip decoding failed"),
90            Self::TrailingCompressedData => f.write_str("bytes follow the single gzip stream"),
91            Self::Tar(_) => f.write_str("tar decoding failed"),
92            Self::LimitExceeded { resource, limit } => {
93                write!(f, "archive {resource:?} limit {limit} exceeded")
94            }
95            Self::UnsupportedEntry { entry, kind } => {
96                write!(f, "unsupported tar type {kind} at record {entry}")
97            }
98            Self::TruncatedEntry { entry } => write!(f, "truncated tar record {entry}"),
99            Self::InvalidPadding => f.write_str("invalid tar end markers or padding"),
100            Self::MissingMember => f.write_str("selected tar member is missing"),
101            Self::DuplicateMember => f.write_str("selected tar member is duplicated"),
102            Self::Member(_) => f.write_str("selected tar member admission failed"),
103            Self::Allocation(_) => f.write_str("selected tar member allocation failed"),
104        }
105    }
106}
107
108impl std::error::Error for ArchiveError {
109    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
110        match self {
111            Self::Archive(source) | Self::Decompression(source) | Self::Member(source) => {
112                Some(source)
113            }
114            Self::Tar(source) => Some(source),
115            Self::Allocation(source) => Some(source),
116            _ => None,
117        }
118    }
119}
120
121/// Admitted selected bytes with both observed raw identities.
122///
123/// Byte ownership does not admit a published file or verify an executable's
124/// version; consumers must retain their staged [`crate::tool`] admission on Unix.
125pub struct ExtractedMember {
126    /// Selected regular-file payload, with no archive metadata applied.
127    pub bytes: Vec<u8>,
128    /// Complete compressed archive identity.
129    pub archive_identity: ArtifactIdentity,
130    /// Selected raw payload identity.
131    pub member_identity: ArtifactIdentity,
132}
133
134impl fmt::Debug for ExtractedMember {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.debug_struct("ExtractedMember")
137            .field("archive_identity", &self.archive_identity)
138            .field("member_identity", &self.member_identity)
139            .finish_non_exhaustive()
140    }
141}
142
143/// Verify compressed bytes before decoding and return one exact admitted member.
144///
145/// The input is immutable for this call. The complete inflated tar and selected
146/// payload use bounded, fallible owned buffers; peak payload storage includes
147/// both buffers plus caller-owned compressed bytes. One gzip stream is accepted,
148/// including its CRC/length checks, with no compressed trailing data. The entire
149/// tar is scanned to reject duplicate members and unsupported records; end markers
150/// require at least two zero blocks followed only by whole zero blocks.
151///
152/// No member path is used as a filesystem destination. Unselected regular files
153/// and directories need not have safe paths because they are never unpacked.
154/// Requested paths are canonical and matched exactly, without normalization.
155/// Archive permissions, ownership and timestamps are never applied.
156///
157/// # Errors
158/// Returns typed digest, decoding, resource, unsupported-format, member-selection
159/// and allocation failures. Caller input/evidence remains untouched on failure.
160pub fn extract_tar_gz(
161    bytes: &[u8],
162    archive_sha256: Sha256Digest,
163    member: &str,
164    member_sha256: Sha256Digest,
165    limits: ArchiveLimits,
166) -> Result<ExtractedMember, ArchiveError> {
167    if !valid_member(member) {
168        return Err(ArchiveError::InvalidMember);
169    }
170    let archive_identity =
171        artifact::verify_reader(bytes, limits.archive_bytes as u64, archive_sha256)
172            .map_err(ArchiveError::Archive)?;
173    let mut decoder = flate2::bufread::GzDecoder::new(bytes);
174    let inflated = artifact::read_reader(&mut decoder, limits.decompressed_bytes)
175        .map_err(ArchiveError::Decompression)?;
176    if !decoder.into_inner().is_empty() {
177        return Err(ArchiveError::TrailingCompressedData);
178    }
179    let payload = select_member(&inflated, member, limits)?;
180    let member_identity =
181        artifact::verify_reader(payload, limits.member_bytes as u64, member_sha256)
182            .map_err(ArchiveError::Member)?;
183    let mut selected = Vec::new();
184    selected
185        .try_reserve_exact(payload.len())
186        .map_err(ArchiveError::Allocation)?;
187    selected.extend_from_slice(payload);
188    Ok(ExtractedMember {
189        bytes: selected,
190        archive_identity,
191        member_identity,
192    })
193}
194
195fn valid_member(member: &str) -> bool {
196    !member.is_empty()
197        && !member.bytes().any(|byte| matches!(byte, 0 | b'\\' | b':'))
198        && member
199            .split('/')
200            .all(|component| !matches!(component, "" | "." | ".."))
201}
202
203fn select_member<'a>(
204    bytes: &'a [u8],
205    member: &str,
206    limits: ArchiveLimits,
207) -> Result<&'a [u8], ArchiveError> {
208    let mut archive = tar::Archive::new(bytes);
209    let entries = archive.entries().map_err(ArchiveError::Tar)?.raw(true);
210    let mut next = 0_usize;
211    let mut selected = None;
212    for (index, entry) in entries.enumerate() {
213        let entry = entry.map_err(ArchiveError::Tar)?;
214        let count = u32::try_from(index)
215            .ok()
216            .and_then(|count| count.checked_add(1))
217            .filter(|count| *count <= limits.members)
218            .ok_or_else(|| ArchiveError::LimitExceeded {
219                resource: ArchiveResource::Members,
220                limit: u64::from(limits.members),
221            })?;
222        let kind = entry.header().entry_type();
223        if !kind.is_file() && !kind.is_dir() {
224            return Err(ArchiveError::UnsupportedEntry {
225                entry: count,
226                kind: kind.as_byte(),
227            });
228        }
229        let start = usize::try_from(entry.raw_file_position())
230            .map_err(|_| ArchiveError::TruncatedEntry { entry: count })?;
231        let size = usize::try_from(entry.size())
232            .map_err(|_| ArchiveError::TruncatedEntry { entry: count })?;
233        let end = start
234            .checked_add(size)
235            .ok_or(ArchiveError::TruncatedEntry { entry: count })?;
236        next = end
237            .checked_add(511)
238            .map(|value| value & !511)
239            .filter(|value| *value <= bytes.len())
240            .ok_or(ArchiveError::TruncatedEntry { entry: count })?;
241        if header_matches(entry.header(), member.as_bytes()) {
242            if selected.is_some() {
243                return Err(ArchiveError::DuplicateMember);
244            }
245            if !kind.is_file() {
246                return Err(ArchiveError::UnsupportedEntry {
247                    entry: count,
248                    kind: kind.as_byte(),
249                });
250            }
251            if size > limits.member_bytes {
252                return Err(ArchiveError::LimitExceeded {
253                    resource: ArchiveResource::MemberBytes,
254                    limit: limits.member_bytes as u64,
255                });
256            }
257            selected = Some(
258                bytes
259                    .get(start..end)
260                    .ok_or(ArchiveError::TruncatedEntry { entry: count })?,
261            );
262        }
263    }
264    let padding = &bytes[next..];
265    if padding.len() < 1024
266        || !padding.len().is_multiple_of(512)
267        || padding.iter().any(|byte| *byte != 0)
268    {
269        return Err(ArchiveError::InvalidPadding);
270    }
271    selected.ok_or(ArchiveError::MissingMember)
272}
273
274fn header_matches(header: &tar::Header, member: &[u8]) -> bool {
275    // Header::path_bytes normalizes USTAR backslashes. Compare exposed raw
276    // fields instead so member selection never silently changes spelling.
277    let name = header
278        .as_old()
279        .name
280        .split(|byte| *byte == 0)
281        .next()
282        .unwrap_or(&[]);
283    if let Some(ustar) = header.as_ustar() {
284        let prefix = ustar.prefix.split(|byte| *byte == 0).next().unwrap_or(&[]);
285        if !prefix.is_empty() {
286            return member
287                .strip_prefix(prefix)
288                .and_then(|rest| rest.strip_prefix(b"/"))
289                == Some(name);
290        }
291    }
292    member == name
293}