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 (
103 "*.pkg",
104 "pkgbuild reproducibility deferred to determinism-installers follow-up",
105 ),
106 (
107 "*.deb",
108 "dpkg-deb reproducibility varies by version; tracked in determinism-installers",
109 ),
110 (
111 "*.snap",
112 "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",
113 ),
114 ];
115 // SBOMs embed identifiers that are non-reproducible by nature:
116 // CycloneDX carries a random `serialNumber` UUID plus a generation
117 // `metadata.timestamp`, and SPDX carries a `documentNamespace` UUID
118 // plus a `created` timestamp. syft does not honor SOURCE_DATE_EPOCH
119 // for the document timestamp and (per the CycloneDX/SPDX specs) the
120 // serial/namespace must be unique per document, so two runs over
121 // byte-identical inputs still produce differing SBOM bytes. These
122 // SBOMs are excluded from the reproducibility
123 // guarantee. Surfaced in the report, excluded from `drift_count`.
124 // Extensions mirror `infer_stage_from_path`'s `sbom` classifier.
125 let sbom_allow: &[(&str, &str)] = &[
126 (
127 "*.cdx.json",
128 "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",
129 ),
130 (
131 "*.spdx.json",
132 "SPDX SBOM embeds a documentNamespace UUID and a created timestamp; not byte-reproducible across runs",
133 ),
134 (
135 "*.sbom.json",
136 "SBOM document embeds a per-document unique identifier and generation timestamp; not byte-reproducible across runs",
137 ),
138 ];
139 let mut compile_time_allowlist: Vec<(String, String)> = Vec::new();
140 for (pattern, reason) in installer_allow.iter().chain(sbom_allow) {
141 compile_time_allowlist.push(((*pattern).into(), (*reason).into()));
142 compile_time_allowlist.push((
143 format!("{}.sha256", pattern),
144 format!("derivative of {pattern}: {reason}"),
145 ));
146 }
147 // `artifacts.json` is anodize's own dist manifest: it records the
148 // `size` and `sha256` of every produced artifact. Its byte-stability
149 // is exactly the conjunction of all indexed artifacts', so it can
150 // only drift when (a) a real build output drifted — already caught
151 // directly on that artifact — or (b) an allow-listed non-deterministic
152 // artifact (SBOM, signature) drifted — intentionally excluded. It
153 // therefore carries no independent determinism signal; comparing its
154 // bytes only re-surfaces drift already accounted for. Exact-match so
155 // no other `.json` is swept in.
156 compile_time_allowlist.push((
157 "artifacts.json".into(),
158 "anodize dist manifest aggregating every artifact's size+digest \
159 (including allow-listed non-deterministic SBOMs/signatures); a derivative \
160 signal — each indexed artifact is drift-checked independently"
161 .into(),
162 ));
163
164 Ok(Self {
165 sde: commit_ts,
166 compile_time_allowlist,
167 runtime_allowlist: Vec::new(),
168 })
169 }
170
171 /// Export SOURCE_DATE_EPOCH onto a `std::process::Command` so
172 /// child subprocesses (cargo, tar, sbom tools, etc.) see the
173 /// reproducible epoch.
174 pub fn export_env(&self, cmd: &mut Command) {
175 cmd.env("SOURCE_DATE_EPOCH", self.sde.to_string());
176 }
177
178 /// Resolve the allow-list reason for an artifact name. Compile-time
179 /// entries win on collision per the spec's "Operator escape /
180 /// Precedence on collision" section. Returns None when the artifact
181 /// is not in either list.
182 pub fn resolve_reason(&self, artifact: &str) -> Option<&str> {
183 // Compile-time first
184 for (name, reason) in &self.compile_time_allowlist {
185 if matches_artifact_pattern(name, artifact) {
186 return Some(reason.as_str());
187 }
188 }
189 // Then runtime
190 for (name, reason) in &self.runtime_allowlist {
191 if matches_artifact_pattern(name, artifact) {
192 return Some(reason.as_str());
193 }
194 }
195 None
196 }
197
198 /// Append a runtime allow-list entry. Caller is the CLI flag
199 /// handler for `--allow-nondeterministic <name>=<reason>`.
200 pub fn append_runtime(&mut self, artifact: String, reason: String) {
201 self.runtime_allowlist.push((artifact, reason));
202 }
203}
204
205/// Simple glob: `*.ext` matches any artifact ending in `.ext`;
206/// exact-match otherwise. Avoids pulling a globbing crate for this
207/// narrow case.
208fn matches_artifact_pattern(pattern: &str, artifact: &str) -> bool {
209 if let Some(suffix) = pattern.strip_prefix('*') {
210 return artifact.ends_with(suffix);
211 }
212 pattern == artifact
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn sde_from_commit_timestamp_is_idempotent() {
221 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
222 assert_eq!(s.sde, 1_715_000_000);
223 let s2 = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
224 assert_eq!(s, s2);
225 }
226
227 #[test]
228 fn compile_time_allowlist_resolves_for_cargo_crate() {
229 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
230 let reason = s
231 .resolve_reason("anodizer-0.2.1.crate")
232 .expect("matches *.crate");
233 assert!(reason.contains("cargo package"));
234 }
235
236 #[test]
237 fn compile_time_allowlist_resolves_for_rpm() {
238 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
239 assert!(s.resolve_reason("foo-1.0.rpm").is_some());
240 }
241
242 #[test]
243 fn compile_time_allowlist_resolves_for_sbom_documents() {
244 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
245 // syft-generated CycloneDX/SPDX SBOMs carry a random serial/namespace
246 // UUID + generation timestamp and can never be byte-identical across
247 // runs; the harness must not count them as drift.
248 for name in [
249 "cfgd-0.4.0-linux-amd64.tar.gz.cdx.json",
250 "cfgd-0.4.0-linux-amd64.tar.gz.spdx.json",
251 "cfgd-0.4.0-linux-amd64.tar.gz.sbom.json",
252 ] {
253 assert!(
254 s.resolve_reason(name).is_some(),
255 "SBOM document {name} must be allow-listed"
256 );
257 }
258 }
259
260 #[test]
261 fn compile_time_allowlist_resolves_for_sbom_checksum_sidecars() {
262 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
263 // The `.sha256` sidecar hashes the non-deterministic SBOM, so it is
264 // itself non-deterministic — allow-listed as a derivative.
265 let reason = s
266 .resolve_reason("cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256")
267 .expect("matches *.cdx.json.sha256");
268 assert!(reason.contains("derivative of"));
269 }
270
271 #[test]
272 fn compile_time_allowlist_resolves_for_artifacts_manifest() {
273 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
274 assert!(
275 s.resolve_reason("artifacts.json").is_some(),
276 "the dist manifest aggregates non-deterministic artifact sizes/digests"
277 );
278 // Exact-match: must not sweep in unrelated `.json` files.
279 assert!(s.resolve_reason("config.json").is_none());
280 assert!(s.resolve_reason("metadata.json").is_none());
281 }
282
283 #[test]
284 fn nondeterministic_allowlist_compile_time_wins_on_collision() {
285 let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
286 // Runtime entry shadowing a compile-time pattern. Compile-time
287 // wins so the report shows the deeper rationale.
288 s.append_runtime(
289 "*.crate".into(),
290 "operator escape (wrong runtime reason)".into(),
291 );
292 let reason = s.resolve_reason("anodizer-0.2.1.crate").unwrap();
293 assert!(
294 reason.contains("cargo package"),
295 "compile-time reason takes precedence"
296 );
297 }
298
299 #[test]
300 fn nondeterministic_allowlist_serializes_with_both_categories() {
301 let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
302 s.append_runtime("foo.bin".into(), "tool-bug-1234".into());
303 let json = serde_json::to_string(&s).unwrap();
304 assert!(json.contains("compile_time_allowlist"));
305 assert!(json.contains("runtime_allowlist"));
306 assert!(json.contains("foo.bin"));
307 }
308
309 #[test]
310 fn export_env_sets_source_date_epoch() {
311 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
312 let mut cmd = Command::new("true");
313 s.export_env(&mut cmd);
314 let env_vars: Vec<(_, _)> = cmd
315 .get_envs()
316 .filter_map(|(k, v)| v.map(|v| (k.to_owned(), v.to_owned())))
317 .collect();
318 let sde_entry = env_vars.iter().find(|(k, _)| k == "SOURCE_DATE_EPOCH");
319 assert!(sde_entry.is_some());
320 assert_eq!(sde_entry.unwrap().1, "1715000000");
321 }
322
323 #[test]
324 fn resolve_reason_returns_none_for_unrecognized() {
325 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
326 assert!(s.resolve_reason("unrelated.txt").is_none());
327 }
328
329 #[test]
330 fn seed_from_commit_accepts_zero() {
331 // Epoch zero (1970-01-01) is a legitimate sentinel — some
332 // determinism modes anchor SDE to UNIX epoch when the commit
333 // graph isn't usable. Must not be rejected.
334 let s = DeterminismState::seed_from_commit(0).expect("zero is non-negative");
335 assert_eq!(s.sde, 0);
336 }
337
338 #[test]
339 fn seed_from_commit_accepts_positive() {
340 // Typical real-world commit timestamp.
341 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
342 assert_eq!(s.sde, 1_715_000_000);
343 }
344
345 #[test]
346 fn seed_from_commit_rejects_negative() {
347 let err = DeterminismState::seed_from_commit(-1).expect_err("negative must error");
348 let msg = format!("{err:#}");
349 assert!(
350 msg.contains("non-negative") && msg.contains("-1"),
351 "error must name the bad input and the constraint: {msg}"
352 );
353 }
354}