Skip to main content

ic_build/cargo/packages/
archive.rs

1//! Unix archive qualification over Host descriptor-based traversal.
2
3use super::external_lock::admit_lock;
4use super::source_inventory::sources;
5use crate::cargo::observation::schema::{Metadata, Package};
6use ic_host_artifacts::artifact::Sha256Digest;
7use ic_host_process::tool::{CompletedExecution, capture_command};
8use ic_host_process::tool::{OutputLimits, ToolError};
9#[cfg(test)]
10use serde_json::{Value, json};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    fmt::Write as _,
14    fs,
15    path::Path,
16    process::Command,
17};
18use std::{error::Error, fmt, io, path::PathBuf};
19
20pub(super) type Cause = Box<dyn Error>;
21
22/// One consumer-selected package and its expected archive identity.
23pub struct PackageArchiveSpec<'a> {
24    /// Cargo package name.
25    pub name: &'a str,
26    /// Exact expected Cargo version; coordinated versions remain caller policy.
27    pub version: &'a str,
28    /// Patch registry references to this extracted package during qualification only.
29    pub patch_registry: bool,
30}
31
32/// Caller-selected bounds for process output and source admission.
33#[derive(Clone, Copy)]
34pub struct PackageQualificationLimits {
35    /// Cargo/extractor output and optional per-command elapsed-time limit.
36    pub process: OutputLimits,
37    /// Maximum bytes in each admitted manifest, lock, archive or source file.
38    pub file_bytes: usize,
39    /// Maximum total directory and regular-file entries across extracted packages.
40    pub source_entries: usize,
41    /// Maximum directory depth beneath each extracted package root.
42    pub source_depth: usize,
43}
44
45/// Command failure preserves execution separately from diagnostic-retention failures.
46#[derive(Debug)]
47pub struct PackageCommandError {
48    /// Original process/capture failure, or `None` if only log retention failed.
49    pub execution: Option<ToolError>,
50    /// Independent failures writing the retained stdout/stderr records.
51    pub retention: Vec<io::Error>,
52}
53impl fmt::Display for PackageCommandError {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        if let Some(error) = &self.execution {
56            error.fmt(f)
57        } else {
58            f.write_str("package command diagnostic retention failed")
59        }
60    }
61}
62impl Error for PackageCommandError {
63    fn source(&self) -> Option<&(dyn Error + 'static)> {
64        self.execution
65            .as_ref()
66            .map(|error| error as &dyn Error)
67            .or_else(|| self.retention.first().map(|error| error as &dyn Error))
68    }
69}
70
71/// Failed qualification retains its original cause and evidence directory.
72#[derive(Debug)]
73pub struct PackageQualificationError {
74    /// The caller-selected directory, retained even on failure.
75    pub evidence: PathBuf,
76    /// Original I/O, encoding, process or admission failure.
77    pub cause: Cause,
78}
79impl fmt::Display for PackageQualificationError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        self.cause.fmt(f)
82    }
83}
84impl Error for PackageQualificationError {
85    fn source(&self) -> Option<&(dyn Error + 'static)> {
86        Some(self.cause.as_ref())
87    }
88}
89
90/// Package a selection and test its exact extracted graph with frozen external selections.
91///
92/// Available on Unix; the pure [`super::verify_external_lock_subset`] remains portable.
93///
94/// `evidence` must be an existing empty private directory with trusted ancestors.
95/// It and all archives/logs are retained on success or failure. `archive_root`
96/// must match the selected Cargo packaging target. Cargo and extraction commands
97/// are supplied by the caller, including their environment and executable custody.
98/// The extractor receives a Cargo-produced archive and the evidence destination;
99/// it must safely extract that archive without selecting other inputs or effects.
100/// This API does not admit untrusted third-party tar files or sandbox tools.
101/// Exclude concurrent writers to original inputs, archives and extracted sources.
102///
103/// Each attempt uses its own `evidence/target`: fixed archive mtimes must never
104/// permit another same-version payload's artifacts to satisfy this verification.
105/// Only the temporary verification workspace receives path patches. Original
106/// manifests/lock, archive bytes, extracted source bytes/modes and admitted
107/// verification manifest/lock are compared after all-target and documentation tests.
108/// Commands execute once, offline; resolution may update only the verification lock
109/// before admission. Every external package/checksum must already be frozen in the
110/// original lock. No install, publication, source mutation, cleanup or retry occurs.
111///
112/// # Errors
113/// Retains exact process failure (including status/diagnostics), missing inputs,
114/// bounded decoding, graph/lock refusals and final input drift in the evidence root.
115pub fn qualify_package_archives(
116    workspace: &Path,
117    archive_root: &Path,
118    evidence: &Path,
119    packages: &[PackageArchiveSpec<'_>],
120    limits: PackageQualificationLimits,
121    mut cargo: impl FnMut() -> Command,
122    mut extractor: impl FnMut(&Path, &Path) -> Command,
123) -> Result<(), PackageQualificationError> {
124    qualify(
125        workspace,
126        archive_root,
127        evidence,
128        packages,
129        limits,
130        &mut cargo,
131        &mut extractor,
132    )
133    .map_err(|cause| PackageQualificationError {
134        evidence: evidence.to_owned(),
135        cause,
136    })
137}
138
139#[expect(
140    clippy::too_many_lines,
141    reason = "ordered archive qualification retains one before/after custody boundary"
142)]
143fn qualify(
144    workspace: &Path,
145    archive_root: &Path,
146    evidence: &Path,
147    packages: &[PackageArchiveSpec<'_>],
148    limits: PackageQualificationLimits,
149    cargo: &mut impl FnMut() -> Command,
150    extractor: &mut impl FnMut(&Path, &Path) -> Command,
151) -> Result<(), Cause> {
152    if packages.is_empty() || limits.file_bytes == 0 || limits.source_entries == 0 {
153        return Err("empty package selection or admission budget".into());
154    }
155    let mut names = BTreeSet::new();
156    for package in packages {
157        if !identifier(package.name) || !identifier(package.version) || !names.insert(package.name)
158        {
159            return Err("invalid or duplicate package archive identity".into());
160        }
161    }
162    let workspace = workspace.canonicalize()?;
163    let archive_root = archive_root.canonicalize()?;
164    let evidence = evidence.canonicalize()?;
165    for path in [&workspace, &archive_root, &evidence] {
166        if path
167            .as_os_str()
168            .as_encoded_bytes()
169            .iter()
170            .any(|b| matches!(b, b'\r' | b'\n'))
171        {
172            return Err("invalid directory namespace".into());
173        }
174    }
175    if fs::read_dir(&evidence)?.next().is_some() {
176        return Err("evidence directory is not empty".into());
177    }
178    let lock = workspace.join("Cargo.lock");
179    let entry_lock = read(&lock, limits.file_bytes)?;
180    fs::write(evidence.join("entry-Cargo.lock"), &entry_lock)?;
181    let mut command = cargo();
182    command.current_dir(&workspace).args([
183        "metadata",
184        "--offline",
185        "--locked",
186        "--no-deps",
187        "--format-version",
188        "1",
189    ]);
190    let metadata = execute(&mut command, &evidence, "entry-metadata", limits.process)?;
191    let metadata = serde_json::from_slice(&metadata)?;
192    let mut originals = BTreeMap::new();
193    originals.insert(
194        workspace.join("Cargo.toml"),
195        read(&workspace.join("Cargo.toml"), limits.file_bytes)?,
196    );
197    for spec in packages {
198        let selected = package(&metadata, spec.name)?;
199        if selected.version.to_string() != spec.version {
200            return Err("entry package version differs from selection".into());
201        }
202        let path = selected.manifest_path.clone().into_std_path_buf();
203        originals.insert(path.clone(), read(&path, limits.file_bytes)?);
204    }
205    for (index, bytes) in originals.values().enumerate() {
206        fs::write(evidence.join(format!("entry-manifest-{index}")), bytes)?;
207    }
208    let mut command = cargo();
209    command.current_dir(&workspace).args([
210        "package",
211        "--offline",
212        "--locked",
213        "--allow-dirty",
214        "--no-verify",
215    ]);
216    for spec in packages {
217        command.args(["-p", spec.name]);
218    }
219    execute(&mut command, &evidence, "package", limits.process)?;
220    let mut archives = BTreeMap::new();
221    for (index, spec) in packages.iter().enumerate() {
222        let path = archive_root.join(format!("{}-{}.crate", spec.name, spec.version));
223        archives.insert(path.clone(), hash(&path, limits.file_bytes)?);
224        execute(
225            &mut extractor(&path, &evidence),
226            &evidence,
227            &format!("extract-{index}"),
228            limits.process,
229        )?;
230    }
231    let mut archive_hashes = String::new();
232    for digest in archives.values() {
233        writeln!(archive_hashes, "{digest}")?;
234    }
235    fs::write(evidence.join("archive.sha256"), archive_hashes)?;
236    let sources = sources(&evidence, packages, limits)?;
237    fs::write(
238        evidence.join("entry-sources.json"),
239        serde_json::to_vec(&sources)?,
240    )?;
241    let members = packages
242        .iter()
243        .map(|p| format!("{}-{}", p.name, p.version))
244        .collect::<Vec<_>>();
245    let mut manifest = format!(
246        "[workspace]\nresolver = \"3\"\nmembers = {}\n[patch.crates-io]\n",
247        serde_json::to_string(&members)?
248    );
249    for spec in packages.iter().filter(|p| p.patch_registry) {
250        writeln!(
251            manifest,
252            "{} = {{ path = \"{}-{}\" }}",
253            spec.name, spec.name, spec.version
254        )?;
255    }
256    let manifest_path = evidence.join("Cargo.toml");
257    let payload_lock = evidence.join("Cargo.lock");
258    fs::write(&manifest_path, &manifest)?;
259    fs::write(&payload_lock, &entry_lock)?;
260    let mut command = cargo();
261    command
262        .current_dir(&evidence)
263        .env("CARGO_TARGET_DIR", evidence.join("target"))
264        .args([
265            "metadata",
266            "--offline",
267            "--format-version",
268            "1",
269            "--manifest-path",
270        ])
271        .arg(&manifest_path);
272    let metadata = execute(&mut command, &evidence, "metadata", limits.process)?;
273    admit_graph(&serde_json::from_slice(&metadata)?, &evidence, packages)?;
274    let admitted_lock = read(&payload_lock, limits.file_bytes)?;
275    admit_lock(&entry_lock, &admitted_lock)?;
276    fs::write(evidence.join("admitted-Cargo.toml"), &manifest)?;
277    fs::write(evidence.join("admitted-Cargo.lock"), &admitted_lock)?;
278    for (label, selection) in [("test-targets", "--all-targets"), ("test-docs", "--doc")] {
279        let mut command = cargo();
280        command
281            .current_dir(&evidence)
282            .env("CARGO_TARGET_DIR", evidence.join("target"))
283            .args(["test", "--offline", "--locked", "--manifest-path"])
284            .arg(&manifest_path)
285            .args(["--workspace", selection]);
286        execute(&mut command, &evidence, label, limits.process)?;
287    }
288    if read(&lock, limits.file_bytes)? != entry_lock {
289        return Err("original lock changed".into());
290    }
291    for (path, bytes) in originals {
292        if read(&path, limits.file_bytes)? != bytes {
293            return Err("original manifest changed".into());
294        }
295    }
296    for (path, digest) in archives {
297        if hash(&path, limits.file_bytes)? != digest {
298            return Err("package archive changed".into());
299        }
300    }
301    let final_sources = self::sources(&evidence, packages, limits)?;
302    fs::write(
303        evidence.join("final-sources.json"),
304        serde_json::to_vec(&final_sources)?,
305    )?;
306    if final_sources != sources {
307        return Err("extracted package sources changed".into());
308    }
309    if read(&manifest_path, limits.file_bytes)? != manifest.as_bytes()
310        || read(&payload_lock, limits.file_bytes)? != admitted_lock
311    {
312        return Err("admitted verification manifest or lock changed".into());
313    }
314    Ok(())
315}
316
317fn identifier(value: &str) -> bool {
318    !value.is_empty()
319        && value
320            .bytes()
321            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
322}
323
324fn read(path: &Path, limit: usize) -> Result<Vec<u8>, Cause> {
325    Ok(ic_host_fs::read::read_file_no_follow(path, limit)?)
326}
327fn hash(path: &Path, limit: usize) -> Result<Sha256Digest, Cause> {
328    Ok(ic_host_fs::read::hash_file_no_follow(path, u64::try_from(limit)?)?.sha256)
329}
330
331fn execute(
332    command: &mut Command,
333    evidence: &Path,
334    label: &str,
335    limits: OutputLimits,
336) -> Result<Vec<u8>, Cause> {
337    let result = capture_command(command, limits)
338        .and_then(CompletedExecution::require_success)
339        .and_then(CompletedExecution::require_complete);
340    let (stdout, stderr) = match &result {
341        Ok(output) => (&output.stdout, &output.stderr),
342        Err(ToolError::Execution(error)) => (&error.evidence.stdout, &error.evidence.stderr),
343        Err(_) => (&Vec::new(), &Vec::new()),
344    };
345    let retention = [
346        fs::write(evidence.join(format!("{label}.json")), stdout),
347        fs::write(evidence.join(format!("{label}.stderr")), stderr),
348    ]
349    .into_iter()
350    .filter_map(Result::err)
351    .collect::<Vec<_>>();
352    match result {
353        Ok(output) if retention.is_empty() => Ok(output.stdout),
354        other => Err(Box::new(PackageCommandError {
355            execution: other.err(),
356            retention,
357        })),
358    }
359}
360
361fn package<'a>(metadata: &'a Metadata, name: &str) -> Result<&'a Package, Cause> {
362    let mut matching = metadata.packages.iter().filter(|p| p.name == name);
363    match (matching.next(), matching.next()) {
364        (Some(package), None) => Ok(package),
365        _ => Err(format!("expected one extracted package: {name}").into()),
366    }
367}
368
369fn admit_graph(
370    metadata: &Metadata,
371    root: &Path,
372    packages: &[PackageArchiveSpec<'_>],
373) -> Result<(), Cause> {
374    let members = &metadata.workspace_members;
375    let mut selected = BTreeSet::new();
376    for spec in packages {
377        let package = package(metadata, spec.name)?;
378        let manifest = root
379            .join(format!("{}-{}/Cargo.toml", spec.name, spec.version))
380            .canonicalize()?;
381        if package.version.to_string() != spec.version
382            || package.source.is_some()
383            || package.manifest_path.as_std_path() != manifest
384            || !members.contains(&package.id)
385        {
386            return Err(format!(
387                "resolved package is not the exact extracted payload: {}",
388                spec.name
389            )
390            .into());
391        }
392        selected.insert(&package.id);
393    }
394    if members.len() != packages.len() || selected.len() != members.len() {
395        return Err("unexpected extracted workspace member".into());
396    }
397    Ok(())
398}
399
400#[cfg(test)]
401#[path = "tests/mod.rs"]
402mod tests;