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 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}