anodizer_core/docker_build.rs
1//! `docker buildx build` invocation for the determinism harness.
2//!
3//! Allow-listed entry point for `Command::new("docker")` calls that drive
4//! the BuildKit reproducibility side of the determinism harness. The
5//! harness itself lives in `crates/cli/` which is forbid-listed for
6//! direct subprocess spawn (see `.claude/rules/module-boundaries.md`);
7//! this module owns the call site so the security surface stays small.
8//!
9//! ## What it does
10//!
11//! `oci_build_fixture` invokes
12//!
13//! ```text
14//! docker buildx build --provenance=false --sbom=false \
15//! --output=type=oci,rewrite-timestamp=true,dest=<oci_tar> \
16//! --tag <tag> \
17//! <context_dir>
18//! ```
19//!
20//! into a hermetic OCI tarball on disk, then returns the SHA-256 of the
21//! tarball plus the BuildKit-reported image digest (parsed from the
22//! `--iidfile`). The harness fingerprints both and diffs across runs.
23//!
24//! ## Determinism workarounds applied
25//!
26//! - **File mtimes inside image layers**: the `rewrite-timestamp=true`
27//! attribute on the `--output type=oci,...` exporter (BuildKit ≥ 0.13)
28//! rewrites every layer entry's mtime to `SOURCE_DATE_EPOCH`. The
29//! harness exports `SOURCE_DATE_EPOCH` via its hermetic env block so
30//! the attribute has a value to rewrite to. The attribute is a
31//! BuildKit *output*-side feature, NOT a top-level `--rewrite-timestamp`
32//! flag (early BuildKit drafts considered the flag form but landed on
33//! the exporter attribute; buildx itself does not surface a top-level
34//! flag).
35//! - **Provenance attestation**: `--provenance=false` suppresses BuildKit's
36//! default in-toto provenance attestation, whose body embeds the build
37//! timestamp and BuildKit version — both vary across runs. Operators
38//! who want signed provenance should layer cosign on top after the
39//! harness has proven layer byte-stability.
40//! - **SBOM attestation**: `--sbom=false` suppresses the default SBOM
41//! attestation for the same reason as provenance: the syft scanner
42//! embeds its own scan timestamp.
43//! - **OCI output (no registry push)**: `--output=type=oci,dest=<file>`
44//! captures the image as a tarball on disk so byte-stability is
45//! verifiable without a running daemon or network reach. (`type=docker`
46//! would require a daemon; `type=registry,push=true` would require a
47//! registry — both unsuitable for hermetic harness use.)
48//!
49//! ## Cosign timestamp interplay
50//!
51//! Cosign's default signature flow (`cosign sign <image>`) uploads a
52//! transparency-log entry whose body embeds the signing timestamp, so
53//! signatures are non-deterministic by design. The harness does NOT
54//! invoke cosign on the produced OCI tar — the sign stage owns that path
55//! in production. If a future workflow signs the harness-produced image
56//! for transparency-log inclusion, the operator must pass
57//! `--tlog-upload=false` (and accept the lost transparency property) for
58//! byte-stable signatures.
59
60use anyhow::{Context, Result};
61use sha2::{Digest, Sha256};
62use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64use std::process::Command;
65
66/// Result of a single `oci_build_fixture` invocation.
67#[derive(Debug, Clone)]
68pub struct OciBuildOutput {
69 /// Absolute path to the emitted OCI tarball on disk.
70 pub oci_tar_path: PathBuf,
71 /// `sha256:<hex>` digest of the OCI tarball — the harness's
72 /// byte-stability fingerprint. Stable across runs iff every layer
73 /// (and the manifest index, and every annotation) is reproducible.
74 pub oci_tar_sha256: String,
75 /// BuildKit-reported image digest from `--iidfile`. Independent of
76 /// the tarball hash because the iidfile records the manifest /
77 /// manifest-list digest before serialization, while the tarball hash
78 /// covers the serialized bytes (which include layer tar member
79 /// ordering). Both must be stable across runs for the harness to
80 /// declare the image byte-stable.
81 pub image_digest: Option<String>,
82}
83
84/// Run `docker buildx build` against `context_dir`, producing an OCI
85/// tarball at `<context_dir>/.det-out.tar` (path returned in the result).
86///
87/// `image_tag` is the buildx `--tag` value — used by BuildKit to populate
88/// the manifest's `org.opencontainers.image.ref.name` annotation. The
89/// harness picks a deterministic constant tag so the annotation does not
90/// itself become a drift source.
91///
92/// `env` carries the harness's hermetic env block (`SOURCE_DATE_EPOCH`,
93/// `HOME`, `PATH`, etc.). The function `env_clear`s the child first so
94/// host env vars cannot perturb the build.
95///
96/// Returns `Ok(OciBuildOutput)` on `docker buildx build` exit 0; bubbles
97/// a context-wrapped error otherwise.
98pub fn oci_build_fixture(
99 context_dir: &Path,
100 image_tag: &str,
101 env: &HashMap<String, String>,
102) -> Result<OciBuildOutput> {
103 let oci_tar = context_dir.join(".det-out.tar");
104 let iidfile = context_dir.join(".det-iidfile.txt");
105 // Defensive: a prior aborted invocation can leave stale output behind.
106 // `docker buildx build --output=type=oci,dest=<file>` overwrites the
107 // file, but the iidfile would silently carry the prior run's digest
108 // if buildx fails before writing.
109 let _ = std::fs::remove_file(&oci_tar);
110 let _ = std::fs::remove_file(&iidfile);
111
112 let mut cmd = Command::new("docker");
113 cmd.arg("buildx")
114 .arg("build")
115 // Suppress provenance + SBOM attestations whose bodies embed
116 // wall-clock timestamps and BuildKit version strings.
117 .arg("--provenance=false")
118 .arg("--sbom=false")
119 // BuildKit ≥ 0.13: `rewrite-timestamp=true` on the OCI exporter
120 // rewrites every layer entry's mtime to SOURCE_DATE_EPOCH (which
121 // the caller exports in `env`). The attribute lives on the
122 // `--output` exporter, not as a separate top-level flag — the
123 // top-level form was proposed but never landed in buildx.
124 .arg(format!(
125 "--output=type=oci,rewrite-timestamp=true,dest={}",
126 oci_tar.to_string_lossy()
127 ))
128 .arg(format!("--iidfile={}", iidfile.to_string_lossy()))
129 .arg("--tag")
130 .arg(image_tag)
131 .arg(context_dir);
132 cmd.current_dir(context_dir);
133 cmd.env_clear();
134 for (k, v) in env {
135 cmd.env(k, v);
136 }
137 // `docker buildx build` writes a wall-clock-stamped progress stream to
138 // stderr by default; suppressing it would silence build errors too.
139 // Inherit stdio so failures land in the harness's per-run log.
140 let status = cmd.status().with_context(|| {
141 format!(
142 "spawning `docker buildx build` in {}",
143 context_dir.display()
144 )
145 })?;
146 anyhow::ensure!(
147 status.success(),
148 "`docker buildx build` failed in {} (exit {:?})",
149 context_dir.display(),
150 status.code()
151 );
152
153 anyhow::ensure!(
154 oci_tar.exists(),
155 "buildx exited 0 but no OCI tarball at {}",
156 oci_tar.display()
157 );
158
159 let bytes =
160 std::fs::read(&oci_tar).with_context(|| format!("reading {}", oci_tar.display()))?;
161 let mut hasher = Sha256::new();
162 hasher.update(&bytes);
163 let oci_tar_sha256 = format!("sha256:{:x}", hasher.finalize());
164
165 let image_digest = std::fs::read_to_string(&iidfile)
166 .ok()
167 .map(|s| s.trim().to_string())
168 .filter(|s| !s.is_empty());
169
170 Ok(OciBuildOutput {
171 oci_tar_path: oci_tar,
172 oci_tar_sha256,
173 image_digest,
174 })
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn oci_build_fails_when_context_dir_missing() {
183 let tmp = tempfile::tempdir().unwrap();
184 let nonexistent = tmp.path().join("does-not-exist");
185 let env: HashMap<String, String> = HashMap::new();
186 let res = oci_build_fixture(&nonexistent, "anodize/det:test", &env);
187 assert!(
188 res.is_err(),
189 "buildx against a nonexistent context dir must error"
190 );
191 }
192}