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