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