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