Skip to main content

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