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