Skip to main content

ic_build/cargo/packages/
mod.rs

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