Skip to main content

ic_build/artifacts/evidence/
mod.rs

1//! Retained Wasm acquisition and bounded evidence export for host consumers.
2use super::{
3    CapturedArtifact, ResolvedCargoBuildInputs, WasmBuildOutcome, WasmBuildSpec,
4    build_wasm_canisters_cached, resolve_cargo_build_inputs,
5};
6use ic_host_artifacts::artifact::hash_reader;
7use ic_host_process::child::OwnedChild;
8use serde::Serialize;
9use std::{fs, io, path::Path, process::Command};
10
11/// Build and source identities retained through caller-owned measurement.
12///
13/// Callers must hold source custody throughout acquisition and consumption.
14/// Freshness checks detect persistent changes, not edits reverted between checks.
15/// Keep this owner alive through measurement, then call `verify_current` before
16/// accepting results. No process deadline, recipe or report publication is added.
17#[derive(Debug)]
18pub struct WasmBuildEvidence {
19    spec: WasmBuildSpec,
20    inputs: ResolvedCargoBuildInputs,
21    build: WasmBuildOutcome,
22}
23
24/// Provenance for the exact exported Wasm bytes; not deployment approval.
25#[derive(Debug, Serialize)]
26pub struct WasmArtifactReceipt {
27    /// SHA-256 of the captured output.
28    pub wasm_sha256: String,
29    /// Cache identity of the retained build.
30    pub build_fingerprint: String,
31    /// Semantic source identity used by the build.
32    pub input_digest: String,
33    /// Full input observation used by freshness validation.
34    pub validation_digest: String,
35}
36
37impl WasmBuildEvidence {
38    /// Acquire one retained build and reject source changes during acquisition.
39    ///
40    /// The caller selects child custody through `spawn`, as with the underlying
41    /// Wasm build. No signal handler, retry or elapsed-time deadline is added.
42    ///
43    /// # Errors
44    /// Returns build/input errors as typed I/O sources, or a freshness refusal.
45    pub fn acquire(
46        spec: WasmBuildSpec,
47        spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
48    ) -> io::Result<Self> {
49        let inputs = resolve_cargo_build_inputs(&spec).map_err(io::Error::other)?;
50        let build = build_wasm_canisters_cached(&spec, spawn).map_err(io::Error::other)?;
51        let evidence = Self {
52            spec,
53            inputs,
54            build,
55        };
56        evidence.verify_current()?;
57        Ok(evidence)
58    }
59
60    /// Refuse changed source or a build not matching the retained input identity.
61    ///
62    /// # Errors
63    /// Returns input-observation errors or `InvalidData` for mismatched evidence.
64    pub fn verify_current(&self) -> io::Result<()> {
65        let current = resolve_cargo_build_inputs(&self.spec).map_err(io::Error::other)?;
66        if self.build.record().fingerprint() != self.inputs.fingerprint()
67            || current.fingerprint() != self.inputs.fingerprint()
68            || current.validation_digest() != self.inputs.validation_digest()
69        {
70            return Err(io::Error::new(
71                io::ErrorKind::InvalidData,
72                "build inputs changed",
73            ));
74        }
75        Ok(())
76    }
77
78    /// Capture a selected artifact under a byte cap and return its provenance.
79    ///
80    /// The caller owns the destination parent and custody against other writers.
81    /// Existing destinations follow `CapturedArtifact::retain_to` replacement
82    /// semantics. The destination must be outside this build's exact cache entry.
83    /// Copies stream through bounded scratch; no full-Wasm allocation is needed.
84    /// Keep this owner alive and verify freshness after measurement as well.
85    ///
86    /// # Errors
87    /// Returns invalid selection/path, freshness, capture, hash or retention errors.
88    /// Failed retention preserves its typed recovery paths; existing evidence is
89    /// unchanged on admission, capture, hash or freshness refusal.
90    pub fn export_artifact(
91        &self,
92        index: usize,
93        destination: &Path,
94        maximum_bytes: usize,
95    ) -> io::Result<WasmArtifactReceipt> {
96        self.verify_current()?;
97        let record = self.build.record();
98        let source = record.artifacts().get(index).ok_or_else(|| {
99            io::Error::new(io::ErrorKind::InvalidInput, "artifact index out of range")
100        })?;
101        let parent = destination.parent().ok_or_else(|| {
102            io::Error::new(io::ErrorKind::InvalidInput, "missing destination parent")
103        })?;
104        if parent
105            .canonicalize()?
106            .starts_with(record.exact_cache_path().canonicalize()?)
107        {
108            return Err(io::Error::new(
109                io::ErrorKind::InvalidInput,
110                "destination is inside retained cache",
111            ));
112        }
113        let captured = CapturedArtifact::capture(source, parent, maximum_bytes)?;
114        let identity = hash_reader(fs::File::open(captured.path())?, maximum_bytes as u64)
115            .map_err(io::Error::from)?;
116        self.verify_current()?;
117        captured.retain_to(destination).map_err(io::Error::other)?;
118        Ok(WasmArtifactReceipt {
119            wasm_sha256: identity.sha256.to_string(),
120            build_fingerprint: record.fingerprint().to_string(),
121            input_digest: self.inputs.input_digest().to_string(),
122            validation_digest: self.inputs.validation_digest().to_string(),
123        })
124    }
125}
126
127#[cfg(all(test, unix))]
128mod tests;