Skip to main content

ic_build/cargo/packages/
mod.rs

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