Skip to main content

anodizer_core/
determinism.rs

1//! SOURCE_DATE_EPOCH seeding + compile-time / runtime allow-list state.
2//!
3//! `DeterminismState` is the per-run home for:
4//! - `sde`: the SOURCE_DATE_EPOCH value (seconds since epoch) that every
5//!   stage exports into subprocess env so artifacts have deterministic
6//!   timestamps.
7//! - `compile_time_allowlist`: artifact-name -> reason pairs known at
8//!   build time (tool-bug allow-lists for cargo .crate, docker manifest
9//!   descriptors, etc.).
10//! - `runtime_allowlist`: operator-supplied opt-outs via the
11//!   `--allow-nondeterministic <name>=<reason>` CLI flag.
12//!
13//! Both lists are surfaced into the run-summary JSON
14//! (`determinism_allowlist.compile_time` and `.runtime`) and the
15//! per-artifact `PublishEvidence.nondeterministic` field. On collision
16//! between the two lists, the compile-time reason wins on the per-
17//! artifact field; both entries still appear in the report so the
18//! audit trail is complete.
19
20use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use std::process::Command;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct DeterminismState {
26    pub sde: i64,
27    pub compile_time_allowlist: Vec<(String, String)>,
28    pub runtime_allowlist: Vec<(String, String)>,
29}
30
31impl DeterminismState {
32    /// Seed from a commit timestamp (seconds since UNIX epoch). All built-
33    /// in compile-time allow-list entries listed in the spec's contract
34    /// table are added here.
35    ///
36    /// Returns `Err` when `commit_ts` is negative — a negative epoch would
37    /// propagate a bogus `SOURCE_DATE_EPOCH` into child processes (where
38    /// shells / build tools may misinterpret it) and almost always
39    /// indicates a corrupted commit graph or a test passing a sentinel
40    /// like `-1`. Fail-fast is the correct UX for a determinism API.
41    ///
42    /// ## Compile-time allow-list scope
43    ///
44    /// Each entry below corresponds to an artifact pattern the
45    /// [`crate::determinism_report`] verification harness will actually
46    /// see in `dist/`. Entries are matched by `*.ext` suffix or exact
47    /// filename against the basename of every file the harness walks
48    /// under the per-run worktree's `dist/` tree. Pattern names that do
49    /// not match any real emitter output are dead code (silently never
50    /// resolve) — keep this list aligned with what stages actually drop
51    /// into `dist/`.
52    ///
53    /// Notably absent (and intentionally so):
54    ///
55    /// - `docker-manifest-descriptor` / `docker-image-blob`: the docker
56    ///   stage is in [`crate::determinism_runner::SIDE_EFFECT_STAGES`]
57    ///   and skipped by the harness; the only docker file that lands in
58    ///   `dist/` is a `.digest` text file written by buildx (a
59    ///   deterministic sha256). No need for an allow-list entry.
60    /// - `apple-notarization-receipt`: the notarize stage mutates
61    ///   existing artifacts in-place (staples) rather than emitting new
62    ///   files; no separate "receipt" artifact lands in `dist/`.
63    /// - `*.exe-nsis`: makensis writes plain `.exe` files into
64    ///   `dist/windows/`; the suffix `.exe-nsis` matches nothing the
65    ///   harness ever sees. NSIS-built `.exe` files only appear when
66    ///   running on Windows (or under Wine), and operators can use the
67    ///   runtime `--allow-nondeterministic <name>=<reason>` flag on
68    ///   those releases rather than hard-coding a dead sentinel here.
69    pub fn seed_from_commit(commit_ts: i64) -> Result<Self> {
70        if commit_ts < 0 {
71            anyhow::bail!(
72                "commit_ts must be non-negative (got {}); a corrupted commit graph or future-bug? \
73                 Negative SOURCE_DATE_EPOCH would propagate to child processes and be \
74                 misinterpreted by shells/build tools.",
75                commit_ts
76            );
77        }
78        // Per spec contract table: these are the artifacts whose
79        // deeper reproducibility work is deferred. Listed up-front so
80        // every stage that consumes them sees the same allow-list.
81        // Allow-listed installer formats AND their `.sha256` sidecars —
82        // the sidecar hashes a non-deterministic source so the sidecar
83        // itself is non-deterministic, but it's not an independent
84        // determinism finding worth surfacing.
85        let installer_allow: &[(&str, &str)] = &[
86            (
87                "*.crate",
88                "cargo package non-determinism, tracked in determinism-followups",
89            ),
90            (
91                "*.rpm",
92                "rpmbuild reproducibility deferred to determinism-installers follow-up",
93            ),
94            (
95                "*.msi",
96                "wix/candle/light reproducibility deferred to determinism-installers follow-up",
97            ),
98            (
99                "*.dmg",
100                "hdiutil reproducibility deferred to determinism-installers follow-up",
101            ),
102            // The Linux flat-package path (xar/mkbom/cpio) IS byte-reproducible
103            // — cpio dev/ino zeroed, payload mtime-pinned, xar TOC times/inode
104            // normalized and the archive re-sealed (proven by
105            // stage-pkg::test_flat_pkg_is_byte_reproducible_across_time). This
106            // entry remains because the allowlist matches on artifact name, not
107            // on producing tool, and the macOS-native `pkgbuild` path (used on
108            // the macos determinism shard) is not yet proven reproducible;
109            // narrowing the gate to per-tool is the determinism-installers
110            // follow-up. Removing this outright would gate anodizer's release on
111            // an unproven macOS path.
112            (
113                "*.pkg",
114                "Linux flat-pkg path (xar/mkbom/cpio) is byte-reproducible (test_flat_pkg_is_byte_reproducible_across_time), but the allowlist matches on artifact name not producing tool, and the macOS-native pkgbuild path on the macos shard is not yet proven reproducible; per-tool narrowing is the determinism-installers follow-up",
115            ),
116            (
117                "*.deb",
118                "dpkg-deb reproducibility varies by version; tracked in determinism-installers",
119            ),
120            (
121                "*.snap",
122                "snapcraft pack runs deterministically when SOURCE_DATE_EPOCH propagates (harness env exports it; mksquashfs respects it via craft-parts); allowlisted as defense-in-depth in case snapcraft introduces non-mtime variance",
123            ),
124            (
125                "*.flatpak",
126                "flatpak build-bundle wraps an OSTree commit whose metadata (commit object timestamp + per-object headers) is not byte-stable across runs even at a fixed SOURCE_DATE_EPOCH; empirically confirmed non-reproducible via two-build cmp",
127            ),
128        ];
129        // SBOMs embed identifiers that are non-reproducible by nature:
130        // CycloneDX carries a random `serialNumber` UUID plus a generation
131        // `metadata.timestamp`, and SPDX carries a `documentNamespace` UUID
132        // plus a `created` timestamp. syft does not honor SOURCE_DATE_EPOCH
133        // for the document timestamp and (per the CycloneDX/SPDX specs) the
134        // serial/namespace must be unique per document, so two runs over
135        // byte-identical inputs still produce differing SBOM bytes. These
136        // SBOMs are excluded from the reproducibility
137        // guarantee. Surfaced in the report, excluded from `drift_count`.
138        // Extensions mirror `infer_stage_from_path`'s `sbom` classifier.
139        let sbom_allow: &[(&str, &str)] = &[
140            (
141                "*.cdx.json",
142                "CycloneDX SBOM embeds a random serialNumber UUID and a generation timestamp (syft does not honor SOURCE_DATE_EPOCH for it); not byte-reproducible across runs",
143            ),
144            (
145                "*.spdx.json",
146                "SPDX SBOM embeds a documentNamespace UUID and a created timestamp; not byte-reproducible across runs",
147            ),
148            (
149                "*.sbom.json",
150                "SBOM document embeds a per-document unique identifier and generation timestamp; not byte-reproducible across runs",
151            ),
152        ];
153        let mut compile_time_allowlist: Vec<(String, String)> = Vec::new();
154        for (pattern, reason) in installer_allow.iter().chain(sbom_allow) {
155            compile_time_allowlist.push(((*pattern).into(), (*reason).into()));
156            compile_time_allowlist.push((
157                format!("{}.sha256", pattern),
158                format!("derivative of {pattern}: {reason}"),
159            ));
160        }
161        // `artifacts.json` is anodize's own dist manifest: it records the
162        // `size` and `sha256` of every produced artifact. Its byte-stability
163        // is exactly the conjunction of all indexed artifacts', so it can
164        // only drift when (a) a real build output drifted — already caught
165        // directly on that artifact — or (b) an allow-listed non-deterministic
166        // artifact (SBOM, signature) drifted — intentionally excluded. It
167        // therefore carries no independent determinism signal; comparing its
168        // bytes only re-surfaces drift already accounted for. Exact-match so
169        // no other `.json` is swept in.
170        compile_time_allowlist.push((
171            "artifacts.json".into(),
172            "anodize dist manifest aggregating every artifact's size+digest \
173             (including allow-listed non-deterministic SBOMs/signatures); a derivative \
174             signal — each indexed artifact is drift-checked independently"
175                .into(),
176        ));
177
178        Ok(Self {
179            sde: commit_ts,
180            compile_time_allowlist,
181            runtime_allowlist: Vec::new(),
182        })
183    }
184
185    /// Export SOURCE_DATE_EPOCH onto a `std::process::Command` so
186    /// child subprocesses (cargo, tar, sbom tools, etc.) see the
187    /// reproducible epoch.
188    pub fn export_env(&self, cmd: &mut Command) {
189        cmd.env("SOURCE_DATE_EPOCH", self.sde.to_string());
190    }
191
192    /// Resolve the allow-list reason for an artifact name. Compile-time
193    /// entries win on collision per the spec's "Operator escape /
194    /// Precedence on collision" section. Returns None when the artifact
195    /// is not in either list.
196    pub fn resolve_reason(&self, artifact: &str) -> Option<&str> {
197        // Compile-time first
198        for (name, reason) in &self.compile_time_allowlist {
199            if matches_artifact_pattern(name, artifact) {
200                return Some(reason.as_str());
201            }
202        }
203        // Then runtime
204        for (name, reason) in &self.runtime_allowlist {
205            if matches_artifact_pattern(name, artifact) {
206                return Some(reason.as_str());
207            }
208        }
209        None
210    }
211
212    /// Append a runtime allow-list entry. Caller is the CLI flag
213    /// handler for `--allow-nondeterministic <name>=<reason>`.
214    pub fn append_runtime(&mut self, artifact: String, reason: String) {
215        self.runtime_allowlist.push((artifact, reason));
216    }
217}
218
219/// Simple glob: `*.ext` matches any artifact ending in `.ext`;
220/// exact-match otherwise. Avoids pulling a globbing crate for this
221/// narrow case.
222fn matches_artifact_pattern(pattern: &str, artifact: &str) -> bool {
223    if let Some(suffix) = pattern.strip_prefix('*') {
224        return artifact.ends_with(suffix);
225    }
226    pattern == artifact
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn sde_from_commit_timestamp_is_idempotent() {
235        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
236        assert_eq!(s.sde, 1_715_000_000);
237        let s2 = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
238        assert_eq!(s, s2);
239    }
240
241    #[test]
242    fn compile_time_allowlist_resolves_for_cargo_crate() {
243        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
244        let reason = s
245            .resolve_reason("anodizer-0.2.1.crate")
246            .expect("matches *.crate");
247        assert!(reason.contains("cargo package"));
248    }
249
250    #[test]
251    fn compile_time_allowlist_resolves_for_rpm() {
252        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
253        assert!(s.resolve_reason("foo-1.0.rpm").is_some());
254    }
255
256    #[test]
257    fn compile_time_allowlist_resolves_for_flatpak() {
258        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
259        // flatpak build-bundle wraps a non-byte-stable OSTree commit; the
260        // harness must not count a `.flatpak` as drift.
261        let reason = s
262            .resolve_reason("anodizer_0.9.1_linux_amd64.flatpak")
263            .expect("matches *.flatpak");
264        assert!(reason.contains("OSTree"));
265        // The `.sha256` sidecar over a non-deterministic bundle is itself
266        // non-deterministic — allow-listed as a derivative.
267        assert!(
268            s.resolve_reason("anodizer_0.9.1_linux_amd64.flatpak.sha256")
269                .is_some()
270        );
271    }
272
273    #[test]
274    fn compile_time_allowlist_resolves_for_pkg() {
275        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
276        // The macOS-native pkgbuild path on the macos shard is not yet proven
277        // reproducible; the harness must not count its `.pkg` as drift.
278        assert!(s.resolve_reason("anodizer-0.2.1.pkg").is_some());
279    }
280
281    #[test]
282    fn compile_time_allowlist_resolves_for_sbom_documents() {
283        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
284        // syft-generated CycloneDX/SPDX SBOMs carry a random serial/namespace
285        // UUID + generation timestamp and can never be byte-identical across
286        // runs; the harness must not count them as drift.
287        for name in [
288            "cfgd-0.4.0-linux-amd64.tar.gz.cdx.json",
289            "cfgd-0.4.0-linux-amd64.tar.gz.spdx.json",
290            "cfgd-0.4.0-linux-amd64.tar.gz.sbom.json",
291        ] {
292            assert!(
293                s.resolve_reason(name).is_some(),
294                "SBOM document {name} must be allow-listed"
295            );
296        }
297    }
298
299    #[test]
300    fn compile_time_allowlist_resolves_for_sbom_checksum_sidecars() {
301        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
302        // The `.sha256` sidecar hashes the non-deterministic SBOM, so it is
303        // itself non-deterministic — allow-listed as a derivative.
304        let reason = s
305            .resolve_reason("cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256")
306            .expect("matches *.cdx.json.sha256");
307        assert!(reason.contains("derivative of"));
308    }
309
310    #[test]
311    fn compile_time_allowlist_resolves_for_artifacts_manifest() {
312        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
313        assert!(
314            s.resolve_reason("artifacts.json").is_some(),
315            "the dist manifest aggregates non-deterministic artifact sizes/digests"
316        );
317        // Exact-match: must not sweep in unrelated `.json` files.
318        assert!(s.resolve_reason("config.json").is_none());
319        assert!(s.resolve_reason("metadata.json").is_none());
320    }
321
322    #[test]
323    fn nondeterministic_allowlist_compile_time_wins_on_collision() {
324        let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
325        // Runtime entry shadowing a compile-time pattern. Compile-time
326        // wins so the report shows the deeper rationale.
327        s.append_runtime(
328            "*.crate".into(),
329            "operator escape (wrong runtime reason)".into(),
330        );
331        let reason = s.resolve_reason("anodizer-0.2.1.crate").unwrap();
332        assert!(
333            reason.contains("cargo package"),
334            "compile-time reason takes precedence"
335        );
336    }
337
338    #[test]
339    fn nondeterministic_allowlist_serializes_with_both_categories() {
340        let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
341        s.append_runtime("foo.bin".into(), "tool-bug-1234".into());
342        let json = serde_json::to_string(&s).unwrap();
343        assert!(json.contains("compile_time_allowlist"));
344        assert!(json.contains("runtime_allowlist"));
345        assert!(json.contains("foo.bin"));
346    }
347
348    #[test]
349    fn export_env_sets_source_date_epoch() {
350        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
351        let mut cmd = Command::new("true");
352        s.export_env(&mut cmd);
353        let env_vars: Vec<(_, _)> = cmd
354            .get_envs()
355            .filter_map(|(k, v)| v.map(|v| (k.to_owned(), v.to_owned())))
356            .collect();
357        let sde_entry = env_vars.iter().find(|(k, _)| k == "SOURCE_DATE_EPOCH");
358        assert!(sde_entry.is_some());
359        assert_eq!(sde_entry.unwrap().1, "1715000000");
360    }
361
362    #[test]
363    fn resolve_reason_returns_none_for_unrecognized() {
364        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
365        assert!(s.resolve_reason("unrelated.txt").is_none());
366    }
367
368    #[test]
369    fn seed_from_commit_accepts_zero() {
370        // Epoch zero (1970-01-01) is a legitimate sentinel — some
371        // determinism modes anchor SDE to UNIX epoch when the commit
372        // graph isn't usable. Must not be rejected.
373        let s = DeterminismState::seed_from_commit(0).expect("zero is non-negative");
374        assert_eq!(s.sde, 0);
375    }
376
377    #[test]
378    fn seed_from_commit_accepts_positive() {
379        // Typical real-world commit timestamp.
380        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
381        assert_eq!(s.sde, 1_715_000_000);
382    }
383
384    #[test]
385    fn seed_from_commit_rejects_negative() {
386        let err = DeterminismState::seed_from_commit(-1).expect_err("negative must error");
387        let msg = format!("{err:#}");
388        assert!(
389            msg.contains("non-negative") && msg.contains("-1"),
390            "error must name the bad input and the constraint: {msg}"
391        );
392    }
393}