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 for artifacts that are non-deterministic BY NATURE (a
9//! spec-mandated unique id or a tool's un-pinnable wall-clock anodizer
10//! cannot fix) — e.g. SBOM serial/namespace UUIDs, the flatpak OSTree
11//! commit. Formats proven byte-reproducible at a fixed SOURCE_DATE_EPOCH
12//! are NOT listed here; they are gated so a real regression is caught.
13//! - `runtime_allowlist`: operator-supplied opt-outs via the
14//! `--allow-nondeterministic <name>=<reason>` CLI flag.
15//!
16//! Both lists are surfaced into the run-summary JSON
17//! (`determinism_allowlist.compile_time` and `.runtime`) and the
18//! per-artifact `PublishEvidence.nondeterministic` field. On collision
19//! between the two lists, the compile-time reason wins on the per-
20//! artifact field; both entries still appear in the report so the
21//! audit trail is complete.
22
23use anyhow::Result;
24use serde::{Deserialize, Serialize};
25use std::process::Command;
26
27/// MSVC-only RUSTFLAGS tokens that make a `*-pc-windows-msvc` binary
28/// byte-reproducible across rebuilds.
29///
30/// This is the single source of truth for the Windows-MSVC determinism flag
31/// set. It is referenced by both the build stage (per-target RUSTFLAGS for
32/// every `anodizer release --reproducible` Windows build) and the
33/// determinism harness's child-subprocess env construction. The static
34/// `[target.*-pc-windows-msvc] rustflags` block in `.cargo/config.toml`
35/// duplicates this list verbatim (a cargo config file cannot import a Rust
36/// const); a comment there points back here so the two cannot silently drift.
37///
38/// Each token:
39/// - `-C codegen-units=1` — single codegen unit so cross-CU function
40/// ordering does not shuffle the object's symbol/section layout.
41/// - `-C link-arg=/Brepro` — substitute the PE COFF `TimeDateStamp`
42/// (offset 0x108) with a content hash instead of wall-clock time.
43/// - `-C link-arg=/OPT:NOICF` — disable Identical COMDAT Folding, whose
44/// fold decisions depend on input-file presentation order.
45/// - `-C link-arg=/INCREMENTAL:NO` — disable incremental linking
46/// (`/Brepro` is incompatible with it).
47/// - `-C link-arg=/DEBUG:NONE` — emit no PDB / CodeView debug record.
48/// - `-C strip=symbols` — drop the COFF symbol table.
49///
50/// `/Brepro` and the `/...` link args are MSVC-linker-only; applying them to
51/// a non-MSVC target makes lld / ld error, so callers gate on
52/// [`crate::target::is_windows_msvc`] (target-keyed) or
53/// [`host_is_windows_msvc`] (host-keyed) before merging these in.
54pub const MSVC_DETERMINISM_RUSTFLAGS: &[&str] = &[
55 "-C codegen-units=1",
56 "-C link-arg=/Brepro",
57 "-C link-arg=/OPT:NOICF",
58 "-C link-arg=/INCREMENTAL:NO",
59 "-C link-arg=/DEBUG:NONE",
60 "-C strip=symbols",
61];
62
63/// Merge [`MSVC_DETERMINISM_RUSTFLAGS`] into an existing space-delimited
64/// RUSTFLAGS string, skipping any token already present so a value
65/// inherited from config or a prior merge is not duplicated.
66///
67/// Returns the merged string. `base` may be empty.
68pub fn merge_msvc_determinism_rustflags(base: &str) -> String {
69 // Token-window comparison rather than a raw `contains`: each flag is a
70 // multi-token unit (`-C link-arg=/Brepro`), and a substring test would
71 // both false-positive on an unrelated prefix and miss the token-boundary
72 // a real RUSTFLAGS split obeys.
73 let mut out = base.trim().to_string();
74 for &flag in MSVC_DETERMINISM_RUSTFLAGS {
75 let flag_tokens: Vec<&str> = flag.split_whitespace().collect();
76 let present = out
77 .split_whitespace()
78 .collect::<Vec<_>>()
79 .windows(flag_tokens.len())
80 .any(|w| w == flag_tokens.as_slice());
81 if present {
82 continue;
83 }
84 if !out.is_empty() {
85 out.push(' ');
86 }
87 out.push_str(flag);
88 }
89 out
90}
91
92/// `true` when the detected host target triple is a Windows-MSVC triple.
93///
94/// Runtime host detection (via `rustc -vV` in
95/// [`crate::partial::detect_host_target`]) — NOT a compile-time
96/// `cfg!(windows)` check. The determinism harness binary may be built on a
97/// different OS than the one it runs on (and a consumer may run
98/// `anodizer check determinism` locally on Windows from any binary), so the
99/// global-RUSTFLAGS `/Brepro` injection that keeps the host (`--target`-less)
100/// build reproducible must key off the real running host, not the compile
101/// target. A detection failure conservatively returns `false` (no MSVC flags
102/// injected — correct for the overwhelmingly-common non-Windows host), and is
103/// logged at `warn` so a swallowed `rustc` failure on a genuine windows-msvc
104/// host (where skipping the flags silently regresses determinism) is auditable
105/// rather than invisible.
106pub fn host_is_windows_msvc() -> bool {
107 match crate::partial::detect_host_target() {
108 Ok(h) => crate::target::is_windows_msvc(&h),
109 Err(e) => {
110 // A bare `rustc` failure here is benign on the common non-Windows
111 // host, but on windows-msvc it silently drops the reproducibility
112 // flags — surface it so the skipped-determinism decision is traceable.
113 tracing::warn!(
114 error = %e,
115 "host-target detection failed; treating host as non-windows-msvc and skipping MSVC determinism flags"
116 );
117 false
118 }
119 }
120}
121
122/// `true` when the detected host target triple is a macOS (Darwin) triple.
123///
124/// Runtime host detection (via `rustc -vV` in
125/// [`crate::partial::detect_host_target`]) — NOT a compile-time
126/// `cfg!(target_os = "macos")` check, for the same cross-host reason as
127/// [`host_is_windows_msvc`]. Used by [`DeterminismState::seed_from_commit`]
128/// to gate the `*.pkg` allow-list entry: only the macOS-native `pkgbuild`
129/// path (which runs on the macOS determinism shard) is not yet proven
130/// reproducible, whereas the Linux flat-package path (xar/mkbom/cpio) is
131/// proven byte-stable and must stay gated. A detection failure
132/// conservatively returns `false` — the overwhelmingly-common host is
133/// Linux, where the `.pkg` is the proven xar path and must NOT be
134/// allow-listed.
135pub fn host_is_macos() -> bool {
136 match crate::partial::detect_host_target() {
137 Ok(h) => crate::target::is_darwin(&h),
138 Err(e) => {
139 // On a non-macOS host this is the right answer; on a genuine
140 // macOS host a swallowed `rustc` failure would un-gate the
141 // unproven pkgbuild `.pkg` and risk a false drift finding —
142 // surface it so the decision is auditable.
143 tracing::warn!(
144 error = %e,
145 "host-target detection failed; treating host as non-macOS (Linux xar .pkg path is gated, not allow-listed)"
146 );
147 false
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct DeterminismState {
154 pub sde: i64,
155 pub compile_time_allowlist: Vec<(String, String)>,
156 pub runtime_allowlist: Vec<(String, String)>,
157}
158
159impl DeterminismState {
160 /// Seed from a commit timestamp (seconds since UNIX epoch). All built-
161 /// in compile-time allow-list entries listed in the spec's contract
162 /// table are added here.
163 ///
164 /// Returns `Err` when `commit_ts` is negative — a negative epoch would
165 /// propagate a bogus `SOURCE_DATE_EPOCH` into child processes (where
166 /// shells / build tools may misinterpret it) and almost always
167 /// indicates a corrupted commit graph or a test passing a sentinel
168 /// like `-1`. Fail-fast is the correct UX for a determinism API.
169 ///
170 /// ## Compile-time allow-list scope
171 ///
172 /// Each entry below corresponds to an artifact pattern the
173 /// [`crate::determinism_report`] verification harness will actually
174 /// see in `dist/`. Entries are matched by `*.ext` suffix or exact
175 /// filename against the basename of every file the harness walks
176 /// under the per-run worktree's `dist/` tree. Pattern names that do
177 /// not match any real emitter output are dead code (silently never
178 /// resolve) — keep this list aligned with what stages actually drop
179 /// into `dist/`.
180 ///
181 /// Notably absent (and intentionally so):
182 ///
183 /// - `docker-manifest-descriptor` / `docker-image-blob`: the docker
184 /// stage is in [`crate::determinism_runner::SIDE_EFFECT_STAGES`]
185 /// and skipped by the harness; the only docker file that lands in
186 /// `dist/` is a `.digest` text file written by buildx (a
187 /// deterministic sha256). No need for an allow-list entry.
188 /// - `apple-notarization-receipt`: the notarize stage mutates
189 /// existing artifacts in-place (staples) rather than emitting new
190 /// files; no separate "receipt" artifact lands in `dist/`.
191 /// - The NSIS installer (`*-setup.exe` / `*_setup.exe`) is GATED, not
192 /// allow-listed. makensis honors `SOURCE_DATE_EPOCH` for the embedded
193 /// build timestamp, so two builds over identical inputs are
194 /// byte-identical — proven by
195 /// stage-nsis::nsis_setup_is_byte_reproducible_across_time. Under the
196 /// A′ shard routing the installer now lands in `dist/windows/` on the
197 /// Windows determinism shard (it builds the windows-msvc payload
198 /// binary), so the harness DOES see and byte-compare it. The classifier
199 /// keys on the `setup.exe` name tail so the installer is attributed to
200 /// `nsis` while the raw `anodize.exe` binary is not (see
201 /// `determinism_harness::artifacts::infer_stage_from_path`). A real
202 /// drift here is a regression, not an excused non-determinism.
203 /// - The macOS `.app` bundle is GATED, not allow-listed. It is pure
204 /// file assembly (Info.plist + binary copy) at a fixed mtime /
205 /// `SOURCE_DATE_EPOCH`, so it is byte-reproducible — proven by
206 /// stage-appbundle::appbundle_is_byte_reproducible_across_time. Under
207 /// A′ it is produced on the macOS determinism shard (which holds the
208 /// darwin payload binary), so the harness byte-compares it directly.
209 pub fn seed_from_commit(commit_ts: i64) -> Result<Self> {
210 if commit_ts < 0 {
211 anyhow::bail!(
212 "commit_ts must be non-negative (got {}); a corrupted commit graph or future-bug? \
213 Negative SOURCE_DATE_EPOCH would propagate to child processes and be \
214 misinterpreted by shells/build tools.",
215 commit_ts
216 );
217 }
218 // Installer formats whose non-determinism is intrinsic (a tool's
219 // wall-clock or spec-mandated unique id anodizer cannot pin) AND
220 // empirically proven by a two-build `cmp` test. Reproducible formats
221 // are NOT here — they are gated so a real regression is caught.
222 // Each entry carries its `.sha256` sidecar: the sidecar hashes a
223 // non-deterministic source so it is itself non-deterministic, but
224 // not an independent finding worth surfacing.
225 //
226 // `.deb` / `.rpm` are allow-listed BECAUSE the real config GPG-signs
227 // them (`deb.signature.key_file` / `rpm.signature.key_file` driven by
228 // `GPG_KEY_PATH`), and the determinism harness provisions an ephemeral
229 // GPG key so signing runs. The GPG signature embeds a non-pinnable
230 // creation time / randomized salt that is not byte-reproducible even at
231 // a fixed SOURCE_DATE_EPOCH (the same intrinsic-signature class as the
232 // cosign `.sig` files). The package BODY is fully byte-reproducible —
233 // proven by stage-nfpm::signed_{deb,rpm}_body_is_byte_reproducible_across_time
234 // — and the signature is verified cryptographically (gpg --verify), not
235 // by byte-equality. An earlier UNSIGNED reproducibility proof had
236 // removed them from this list; that missed the signed real-config path.
237 //
238 // Conspicuously gated, each byte-identical across two builds at a fixed
239 // SOURCE_DATE_EPOCH (the value the harness exports), so the harness must
240 // count any drift as a real regression:
241 // - `.crate`, `.snap`, the Linux xar `.pkg` — UNSIGNED, so the whole
242 // artifact must be byte-reproducible.
243 // - `.apk` — SIGNED, but unlike the deb/rpm GPG signature nfpm's apk
244 // RSA signature is DETERMINISTIC (PKCS#1, no salt / no embedded
245 // timestamp), so the whole signed artifact is byte-reproducible —
246 // proven by stage-nfpm::signed_apk_is_byte_reproducible_across_time.
247 // Hence gated, NOT allow-listed (contrast `.deb`/`.rpm`, whose GPG
248 // signature is non-reproducible).
249 // See the gating tests cited per format below in the unit-test module.
250 let mut installer_allow: Vec<(&str, &str)> = vec![
251 (
252 "*.msi",
253 "WiX candle/light regenerates a random SummaryInformation PackageCode GUID and stamps wall-clock Created/LastModified into the MSI summary-information stream, independent of SOURCE_DATE_EPOCH (wixtoolset/issues#8978); not byte-reproducible — proven by stage-msi::msi_is_byte_reproducible_across_time. Built natively on the windows determinism shard under A′.",
254 ),
255 (
256 "*.dmg",
257 "hdiutil writes a fresh per-segment SegmentID GUID into the UDIF koly trailer every run and pins no SOURCE_DATE_EPOCH; not byte-reproducible — proven by stage-dmg::dmg_is_byte_reproducible_across_time. Built natively on the macos determinism shard under A′.",
258 ),
259 (
260 "*.flatpak",
261 "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",
262 ),
263 (
264 "*.deb",
265 "nfpm GPG-signs the deb (_gpgorigin ar member); the signature embeds a non-pinnable creation time / randomized salt and is not byte-reproducible even at a fixed SOURCE_DATE_EPOCH. The package body (debian-binary, control.tar.gz, data.tar.gz) IS byte-reproducible — gated by stage-nfpm::signed_deb_body_is_byte_reproducible_across_time — and the signature is verified cryptographically (gpg --verify), not by byte-equality.",
266 ),
267 (
268 "*.rpm",
269 "nfpm GPG-signs the rpm (RPM signature header); the signature embeds a non-pinnable creation time / randomized salt and is not byte-reproducible even at a fixed SOURCE_DATE_EPOCH. The package body (main header + cpio payload, i.e. everything after the signature header) IS byte-reproducible — gated by stage-nfpm::signed_rpm_body_is_byte_reproducible_across_time — and the signature is verified cryptographically (gpg --verify), not by byte-equality.",
270 ),
271 ];
272 // `*.pkg` is host-keyed. Only one producer runs per determinism
273 // shard: Linux runs the xar/mkbom/cpio flat-package path, proven
274 // byte-reproducible (stage-pkg::test_flat_pkg_is_byte_reproducible_across_time)
275 // — so on Linux the `.pkg` is GATED, never allow-listed. macOS runs
276 // the native `pkgbuild` path, not yet proven; on a macOS host the
277 // `.pkg` is allow-listed pending the macos-shard proof.
278 if host_is_macos() {
279 installer_allow.push((
280 "*.pkg",
281 "macOS-native pkgbuild stamps a wall-clock xar TOC and ignores SOURCE_DATE_EPOCH; not byte-reproducible — proven by stage-pkg::native_pkgbuild_pkg_is_byte_reproducible_across_time. Built natively on the macos determinism shard under A′ (the Linux xar/mkbom/cpio .pkg path is proven reproducible and gated, never allow-listed)",
282 ));
283 }
284 // SBOMs embed identifiers that are non-reproducible by nature:
285 // CycloneDX carries a random `serialNumber` UUID plus a generation
286 // `metadata.timestamp`, and SPDX carries a `documentNamespace` UUID
287 // plus a `created` timestamp. syft does not honor SOURCE_DATE_EPOCH
288 // for the document timestamp and (per the CycloneDX/SPDX specs) the
289 // serial/namespace must be unique per document, so two runs over
290 // byte-identical inputs still produce differing SBOM bytes. These
291 // SBOMs are excluded from the reproducibility
292 // guarantee. Surfaced in the report, excluded from `drift_count`.
293 // Extensions mirror `infer_stage_from_path`'s `sbom` classifier.
294 let sbom_allow: &[(&str, &str)] = &[
295 (
296 "*.cdx.json",
297 "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",
298 ),
299 (
300 "*.spdx.json",
301 "SPDX SBOM embeds a documentNamespace UUID and a created timestamp; not byte-reproducible across runs",
302 ),
303 (
304 "*.sbom.json",
305 "SBOM document embeds a per-document unique identifier and generation timestamp; not byte-reproducible across runs",
306 ),
307 ];
308 let mut compile_time_allowlist: Vec<(String, String)> = Vec::new();
309 for (pattern, reason) in installer_allow.iter().chain(sbom_allow) {
310 compile_time_allowlist.push(((*pattern).into(), (*reason).into()));
311 compile_time_allowlist.push((
312 format!("{}.sha256", pattern),
313 format!("derivative of {pattern}: {reason}"),
314 ));
315 }
316 // `artifacts.json` is anodize's own dist manifest: it records the
317 // `size` and `sha256` of every produced artifact. Its byte-stability
318 // is exactly the conjunction of all indexed artifacts', so it can
319 // only drift when (a) a real build output drifted — already caught
320 // directly on that artifact — or (b) an allow-listed non-deterministic
321 // artifact (SBOM, signature) drifted — intentionally excluded. It
322 // therefore carries no independent determinism signal; comparing its
323 // bytes only re-surfaces drift already accounted for. Exact-match so
324 // no other `.json` is swept in.
325 compile_time_allowlist.push((
326 "artifacts.json".into(),
327 "anodize dist manifest aggregating every artifact's size+digest \
328 (including allow-listed non-deterministic SBOMs/signatures); a derivative \
329 signal — each indexed artifact is drift-checked independently"
330 .into(),
331 ));
332
333 Ok(Self {
334 sde: commit_ts,
335 compile_time_allowlist,
336 runtime_allowlist: Vec::new(),
337 })
338 }
339
340 /// Export SOURCE_DATE_EPOCH onto a `std::process::Command` so
341 /// child subprocesses (cargo, tar, sbom tools, etc.) see the
342 /// reproducible epoch.
343 pub fn export_env(&self, cmd: &mut Command) {
344 cmd.env("SOURCE_DATE_EPOCH", self.sde.to_string());
345 }
346
347 /// Resolve the allow-list reason for an artifact name. Compile-time
348 /// entries win on collision per the spec's "Operator escape /
349 /// Precedence on collision" section. Returns None when the artifact
350 /// is not in either list.
351 pub fn resolve_reason(&self, artifact: &str) -> Option<&str> {
352 // Compile-time first
353 for (name, reason) in &self.compile_time_allowlist {
354 if matches_artifact_pattern(name, artifact) {
355 return Some(reason.as_str());
356 }
357 }
358 // Then runtime
359 for (name, reason) in &self.runtime_allowlist {
360 if matches_artifact_pattern(name, artifact) {
361 return Some(reason.as_str());
362 }
363 }
364 None
365 }
366
367 /// Append a runtime allow-list entry. Caller is the CLI flag
368 /// handler for `--allow-nondeterministic <name>=<reason>`.
369 pub fn append_runtime(&mut self, artifact: String, reason: String) {
370 self.runtime_allowlist.push((artifact, reason));
371 }
372}
373
374/// Simple glob: `*.ext` matches any artifact ending in `.ext`;
375/// exact-match otherwise. Avoids pulling a globbing crate for this
376/// narrow case.
377fn matches_artifact_pattern(pattern: &str, artifact: &str) -> bool {
378 if let Some(suffix) = pattern.strip_prefix('*') {
379 return artifact.ends_with(suffix);
380 }
381 pattern == artifact
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn msvc_rustflags_const_matches_cargo_config() {
390 // The static `.cargo/config.toml` block is the only other home for
391 // this flag set (it can't import a Rust const). Keep them aligned:
392 // the config writes each `-C <arg>` as a two-element TOML array pair,
393 // so the joined form must equal this const verbatim.
394 let cargo_config = include_str!("../../../.cargo/config.toml");
395 for &flag in MSVC_DETERMINISM_RUSTFLAGS {
396 // `-C codegen-units=1` -> `"-C", "codegen-units=1"`
397 let (lead, rest) = flag.split_once(' ').expect("each flag is two tokens");
398 let toml_pair = format!("\"{lead}\", \"{rest}\"");
399 assert!(
400 cargo_config.contains(&toml_pair),
401 ".cargo/config.toml must carry `{toml_pair}` to stay aligned with \
402 MSVC_DETERMINISM_RUSTFLAGS; the two drifted"
403 );
404 }
405 }
406
407 #[test]
408 fn merge_msvc_into_empty_yields_full_set() {
409 let merged = merge_msvc_determinism_rustflags("");
410 for &flag in MSVC_DETERMINISM_RUSTFLAGS {
411 assert!(
412 merged.contains(flag),
413 "merged set must contain `{flag}`. got={merged}"
414 );
415 }
416 assert!(
417 merged.contains("/Brepro"),
418 "the COFF TimeDateStamp fix must be present"
419 );
420 }
421
422 #[test]
423 fn merge_msvc_preserves_existing_flags_and_appends() {
424 let base = "-C linker=link.exe --remap-path-prefix=/w=/build";
425 let merged = merge_msvc_determinism_rustflags(base);
426 assert!(
427 merged.starts_with(base),
428 "existing flags must be preserved verbatim. got={merged}"
429 );
430 assert!(
431 merged.contains("-C link-arg=/Brepro"),
432 "MSVC flags must be appended. got={merged}"
433 );
434 }
435
436 #[test]
437 fn merge_msvc_is_idempotent_no_duplicate_brepro() {
438 let once = merge_msvc_determinism_rustflags("");
439 let twice = merge_msvc_determinism_rustflags(&once);
440 assert_eq!(
441 once, twice,
442 "merging an already-merged set must not duplicate flags"
443 );
444 assert_eq!(
445 twice.matches("/Brepro").count(),
446 1,
447 "/Brepro must appear exactly once after a double merge. got={twice}"
448 );
449 }
450
451 #[test]
452 fn host_is_windows_msvc_matches_real_host() {
453 // On this Linux CI box the real host triple is non-MSVC, so the
454 // runtime probe must report false (the bug class this guards is a
455 // compile-time check that would report the wrong answer on a
456 // cross-built binary).
457 let expected = crate::partial::detect_host_target()
458 .map(|h| crate::target::is_windows_msvc(&h))
459 .unwrap_or(false);
460 assert_eq!(host_is_windows_msvc(), expected);
461 }
462
463 #[test]
464 fn sde_from_commit_timestamp_is_idempotent() {
465 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
466 assert_eq!(s.sde, 1_715_000_000);
467 let s2 = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
468 assert_eq!(s, s2);
469 }
470
471 #[test]
472 fn cargo_crate_is_gated_not_allowlisted() {
473 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
474 // `cargo package` is byte-identical across two builds at a fixed
475 // SOURCE_DATE_EPOCH (the harness exports it; cargo normalizes the
476 // source tarball — sorted paths, pinned mtime). The `.crate` must be
477 // gated so a real regression is caught, NOT allow-listed. The
478 // per-crate-rendered name must not resolve in any of the three modes.
479 assert!(s.resolve_reason("anodizer-0.2.1.crate").is_none());
480 assert!(s.resolve_reason("anodizer-core-0.11.3.crate").is_none());
481 // And its sidecar must not be allow-listed as a derivative either.
482 assert!(
483 s.resolve_reason("anodizer-core-0.11.3.crate.sha256")
484 .is_none()
485 );
486 }
487
488 #[test]
489 fn rpm_and_deb_are_allowlisted_due_to_gpg_signing() {
490 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
491 // The real config GPG-signs both formats; the signature is intrinsically
492 // non-byte-reproducible even at a fixed SOURCE_DATE_EPOCH while the body
493 // IS reproducible (proven by stage-nfpm::signed_{deb,rpm}_body_is_byte_-
494 // reproducible_across_time). Both — and their sidecars — are allow-listed.
495 let rpm = s.resolve_reason("foo-1.0.rpm").expect("matches *.rpm");
496 assert!(
497 rpm.contains("signature") || rpm.contains("GPG"),
498 "rpm reason must cite the signature: {rpm}"
499 );
500 let deb = s
501 .resolve_reason("foo_1.0_amd64.deb")
502 .expect("matches *.deb");
503 assert!(
504 deb.contains("signature") || deb.contains("GPG"),
505 "deb reason must cite the signature: {deb}"
506 );
507 assert!(s.resolve_reason("foo-1.0.rpm.sha256").is_some());
508 assert!(s.resolve_reason("foo_1.0_amd64.deb.sha256").is_some());
509 }
510
511 #[test]
512 fn snap_is_gated_not_allowlisted() {
513 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
514 // snapcraft pack (mksquashfs) honors SOURCE_DATE_EPOCH for the
515 // squashfs mod_time, so .snap is byte-identical across two builds
516 // (proven by stage-snapcraft::snap_is_byte_reproducible_across_time).
517 // Gated, never allow-listed as "defense-in-depth".
518 assert!(s.resolve_reason("probe_1.2.3_amd64.snap").is_none());
519 assert!(s.resolve_reason("probe_1.2.3_amd64.snap.sha256").is_none());
520 }
521
522 #[test]
523 fn apk_is_signed_but_gated() {
524 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
525 // nfpm RSA-signs the apk, but the signature is deterministic (PKCS#1,
526 // no salt / no embedded timestamp) so the whole signed artifact is
527 // byte-identical across two builds at a fixed SOURCE_DATE_EPOCH —
528 // proven by stage-nfpm::signed_apk_is_byte_reproducible_across_time.
529 // It must be GATED (real drift = regression), NOT allow-listed like
530 // the GPG-signed deb/rpm whose signature is non-reproducible.
531 assert!(s.resolve_reason("foo_1.0_amd64.apk").is_none());
532 assert!(s.resolve_reason("foo_1.0_amd64.apk.sha256").is_none());
533 }
534
535 #[test]
536 fn compile_time_allowlist_resolves_for_flatpak() {
537 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
538 // flatpak build-bundle wraps a non-byte-stable OSTree commit; the
539 // harness must not count a `.flatpak` as drift.
540 let reason = s
541 .resolve_reason("anodizer_0.9.1_linux_amd64.flatpak")
542 .expect("matches *.flatpak");
543 assert!(reason.contains("OSTree"));
544 // The `.sha256` sidecar over a non-deterministic bundle is itself
545 // non-deterministic — allow-listed as a derivative.
546 assert!(
547 s.resolve_reason("anodizer_0.9.1_linux_amd64.flatpak.sha256")
548 .is_some()
549 );
550 }
551
552 #[test]
553 fn pkg_allowlist_is_host_keyed() {
554 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
555 // `.pkg` is allow-listed ONLY on a macOS host (the unproven native
556 // pkgbuild path); on a Linux host the `.pkg` is the proven xar/mkbom/
557 // cpio flat-package path and must be gated. The seed reflects the
558 // running host, so the resolved verdict must match it.
559 let resolved = s.resolve_reason("anodizer-0.2.1.pkg").is_some();
560 assert_eq!(
561 resolved,
562 host_is_macos(),
563 "`.pkg` must be allow-listed iff the host is macOS (Linux xar path is gated)"
564 );
565 // This CI box is Linux, so confirm the gated direction concretely:
566 // the proven Linux path must NOT be excused.
567 if !host_is_macos() {
568 assert!(
569 s.resolve_reason("anodizer-0.2.1.pkg").is_none(),
570 "Linux xar .pkg path is proven reproducible and must be gated"
571 );
572 assert!(s.resolve_reason("anodizer-0.2.1.pkg.sha256").is_none());
573 }
574 }
575
576 #[test]
577 fn appbundle_is_gated_not_allowlisted() {
578 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
579 // The macOS `.app` bundle is pure file assembly at a fixed mtime /
580 // SOURCE_DATE_EPOCH, so it is byte-reproducible — proven by
581 // stage-appbundle::appbundle_is_byte_reproducible_across_time. Under A′
582 // it is produced on the macOS determinism shard and byte-compared, so
583 // it must be GATED (real drift = regression), NEVER allow-listed.
584 assert!(s.resolve_reason("anodizer_arm64.app").is_none());
585 assert!(s.resolve_reason("anodizer_amd64.app").is_none());
586 // A `.app` is a directory, but a `.sha256` over it (if one is ever
587 // emitted) must likewise not be excused as a derivative.
588 assert!(s.resolve_reason("anodizer_arm64.app.sha256").is_none());
589 }
590
591 #[test]
592 fn nsis_installer_is_gated_not_allowlisted() {
593 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
594 // makensis honors SOURCE_DATE_EPOCH, so the NSIS installer is byte-
595 // reproducible — proven by
596 // stage-nsis::nsis_setup_is_byte_reproducible_across_time. Under A′ it
597 // lands in dist/windows/ on the Windows determinism shard and is byte-
598 // compared, so it must be GATED, NEVER allow-listed. Neither the
599 // configured `-setup.exe` name nor the stage-default `_setup.exe` tail
600 // may resolve to an allow-list reason.
601 assert!(s.resolve_reason("anodizer_x64-setup.exe").is_none());
602 assert!(s.resolve_reason("anodizer_x64_setup.exe").is_none());
603 assert!(
604 s.resolve_reason("anodizer_arm64-setup.exe.sha256")
605 .is_none()
606 );
607 }
608
609 #[test]
610 fn raw_windows_binary_is_not_allowlisted_or_misclassified() {
611 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
612 // The raw windows binary `anodizer.exe` is the load-bearing build
613 // output — it must be byte-reproducible (the /Brepro RUSTFLAGS make it
614 // so) and is therefore GATED. It must never be swept into an installer
615 // allow-list class by a bare `*.exe` pattern (there is none; only the
616 // intrinsically-non-reproducible native installers are allow-listed).
617 assert!(s.resolve_reason("anodizer.exe").is_none());
618 assert!(s.resolve_reason("anodizer.exe.sha256").is_none());
619 }
620
621 #[test]
622 fn msi_and_dmg_reasons_cite_pending_shard_proof() {
623 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
624 // These run on the windows/macos shards (not provable on this Linux
625 // box); their reasons must cite the sibling pending shard tests
626 // rather than a "deferred to follow-up" hedge.
627 let msi = s
628 .resolve_reason("anodizer-0.2.1.msi")
629 .expect("matches *.msi");
630 assert!(
631 msi.contains("msi_is_byte_reproducible_across_time"),
632 "msi reason must cite the pending shard test: {msi}"
633 );
634 let dmg = s
635 .resolve_reason("anodizer-0.2.1.dmg")
636 .expect("matches *.dmg");
637 assert!(
638 dmg.contains("dmg_is_byte_reproducible_across_time"),
639 "dmg reason must cite the pending shard test: {dmg}"
640 );
641 }
642
643 #[test]
644 fn compile_time_allowlist_resolves_for_sbom_documents() {
645 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
646 // syft-generated CycloneDX/SPDX SBOMs carry a random serial/namespace
647 // UUID + generation timestamp and can never be byte-identical across
648 // runs; the harness must not count them as drift.
649 for name in [
650 "cfgd-0.4.0-linux-amd64.tar.gz.cdx.json",
651 "cfgd-0.4.0-linux-amd64.tar.gz.spdx.json",
652 "cfgd-0.4.0-linux-amd64.tar.gz.sbom.json",
653 ] {
654 assert!(
655 s.resolve_reason(name).is_some(),
656 "SBOM document {name} must be allow-listed"
657 );
658 }
659 }
660
661 #[test]
662 fn compile_time_allowlist_resolves_for_sbom_checksum_sidecars() {
663 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
664 // The `.sha256` sidecar hashes the non-deterministic SBOM, so it is
665 // itself non-deterministic — allow-listed as a derivative.
666 let reason = s
667 .resolve_reason("cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256")
668 .expect("matches *.cdx.json.sha256");
669 assert!(reason.contains("derivative of"));
670 }
671
672 #[test]
673 fn compile_time_allowlist_resolves_for_artifacts_manifest() {
674 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
675 assert!(
676 s.resolve_reason("artifacts.json").is_some(),
677 "the dist manifest aggregates non-deterministic artifact sizes/digests"
678 );
679 // Exact-match: must not sweep in unrelated `.json` files.
680 assert!(s.resolve_reason("config.json").is_none());
681 assert!(s.resolve_reason("metadata.json").is_none());
682 }
683
684 #[test]
685 fn nondeterministic_allowlist_compile_time_wins_on_collision() {
686 let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
687 // Runtime entry shadowing a compile-time pattern. Compile-time
688 // wins so the report shows the deeper rationale. `*.flatpak` is a
689 // permanently-allow-listed compile-time pattern (intrinsically
690 // non-reproducible OSTree commit), so it is the stable collision
691 // anchor regardless of host.
692 s.append_runtime(
693 "*.flatpak".into(),
694 "operator escape (wrong runtime reason)".into(),
695 );
696 let reason = s
697 .resolve_reason("anodizer_0.9.1_linux_amd64.flatpak")
698 .unwrap();
699 assert!(
700 reason.contains("OSTree"),
701 "compile-time reason takes precedence"
702 );
703 }
704
705 #[test]
706 fn nondeterministic_allowlist_serializes_with_both_categories() {
707 let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
708 s.append_runtime("foo.bin".into(), "tool-bug-1234".into());
709 let json = serde_json::to_string(&s).unwrap();
710 assert!(json.contains("compile_time_allowlist"));
711 assert!(json.contains("runtime_allowlist"));
712 assert!(json.contains("foo.bin"));
713 }
714
715 #[test]
716 fn export_env_sets_source_date_epoch() {
717 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
718 let mut cmd = Command::new("true");
719 s.export_env(&mut cmd);
720 let env_vars: Vec<(_, _)> = cmd
721 .get_envs()
722 .filter_map(|(k, v)| v.map(|v| (k.to_owned(), v.to_owned())))
723 .collect();
724 let sde_entry = env_vars.iter().find(|(k, _)| k == "SOURCE_DATE_EPOCH");
725 assert!(sde_entry.is_some());
726 assert_eq!(sde_entry.unwrap().1, "1715000000");
727 }
728
729 #[test]
730 fn resolve_reason_returns_none_for_unrecognized() {
731 let s = DeterminismState::seed_from_commit(0).expect("non-negative");
732 assert!(s.resolve_reason("unrelated.txt").is_none());
733 }
734
735 #[test]
736 fn seed_from_commit_accepts_zero() {
737 // Epoch zero (1970-01-01) is a legitimate sentinel — some
738 // determinism modes anchor SDE to UNIX epoch when the commit
739 // graph isn't usable. Must not be rejected.
740 let s = DeterminismState::seed_from_commit(0).expect("zero is non-negative");
741 assert_eq!(s.sde, 0);
742 }
743
744 #[test]
745 fn seed_from_commit_accepts_positive() {
746 // Typical real-world commit timestamp.
747 let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
748 assert_eq!(s.sde, 1_715_000_000);
749 }
750
751 #[test]
752 fn seed_from_commit_rejects_negative() {
753 let err = DeterminismState::seed_from_commit(-1).expect_err("negative must error");
754 let msg = format!("{err:#}");
755 assert!(
756 msg.contains("non-negative") && msg.contains("-1"),
757 "error must name the bad input and the constraint: {msg}"
758 );
759 }
760}