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;