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