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::{Context, Result};
24use serde::{Deserialize, Serialize};
25use std::collections::{BTreeMap, BTreeSet};
26use std::process::Command;
27
28/// MSVC-only RUSTFLAGS tokens that make a `*-pc-windows-msvc` binary
29/// byte-reproducible across rebuilds.
30///
31/// This is the single source of truth for the Windows-MSVC determinism flag
32/// set. It is referenced by both the build stage (per-target RUSTFLAGS for
33/// every `anodizer release --reproducible` Windows build) and the
34/// determinism harness's child-subprocess env construction. The static
35/// `[target.*-pc-windows-msvc] rustflags` block in `.cargo/config.toml`
36/// duplicates this list verbatim (a cargo config file cannot import a Rust
37/// const); a comment there points back here so the two cannot silently drift.
38///
39/// Each token:
40/// - `-C codegen-units=1` — single codegen unit so cross-CU function
41///   ordering does not shuffle the object's symbol/section layout.
42/// - `-C link-arg=/Brepro` — substitute the PE COFF `TimeDateStamp`
43///   (offset 0x108) with a content hash instead of wall-clock time.
44/// - `-C link-arg=/OPT:NOICF` — disable Identical COMDAT Folding, whose
45///   fold decisions depend on input-file presentation order.
46/// - `-C link-arg=/INCREMENTAL:NO` — disable incremental linking
47///   (`/Brepro` is incompatible with it).
48/// - `-C link-arg=/DEBUG:NONE` — emit no PDB / CodeView debug record.
49/// - `-C strip=symbols` — drop the COFF symbol table.
50///
51/// `/Brepro` and the `/...` link args are MSVC-linker-only; applying them to
52/// a non-MSVC target makes lld / ld error, so callers gate on
53/// [`crate::target::is_windows_msvc`] (target-keyed) or
54/// [`host_is_windows_msvc`] (host-keyed) before merging these in.
55pub const MSVC_DETERMINISM_RUSTFLAGS: &[&str] = &[
56    "-C codegen-units=1",
57    "-C link-arg=/Brepro",
58    "-C link-arg=/OPT:NOICF",
59    "-C link-arg=/INCREMENTAL:NO",
60    "-C link-arg=/DEBUG:NONE",
61    "-C strip=symbols",
62];
63
64/// Merge [`MSVC_DETERMINISM_RUSTFLAGS`] into an existing space-delimited
65/// RUSTFLAGS string, skipping any token already present so a value
66/// inherited from config or a prior merge is not duplicated.
67///
68/// Returns the merged string. `base` may be empty.
69pub fn merge_msvc_determinism_rustflags(base: &str) -> String {
70    // Token-window comparison rather than a raw `contains`: each flag is a
71    // multi-token unit (`-C link-arg=/Brepro`), and a substring test would
72    // both false-positive on an unrelated prefix and miss the token-boundary
73    // a real RUSTFLAGS split obeys.
74    let mut out = base.trim().to_string();
75    for &flag in MSVC_DETERMINISM_RUSTFLAGS {
76        let flag_tokens: Vec<&str> = flag.split_whitespace().collect();
77        let present = out
78            .split_whitespace()
79            .collect::<Vec<_>>()
80            .windows(flag_tokens.len())
81            .any(|w| w == flag_tokens.as_slice());
82        if present {
83            continue;
84        }
85        if !out.is_empty() {
86            out.push(' ');
87        }
88        out.push_str(flag);
89    }
90    out
91}
92
93/// `true` when the detected host target triple is a Windows-MSVC triple.
94///
95/// Runtime host detection (via `rustc -vV` in
96/// [`crate::partial::detect_host_target`]) — NOT a compile-time
97/// `cfg!(windows)` check. The determinism harness binary may be built on a
98/// different OS than the one it runs on (and a consumer may run
99/// `anodizer check determinism` locally on Windows from any binary), so the
100/// global-RUSTFLAGS `/Brepro` injection that keeps the host (`--target`-less)
101/// build reproducible must key off the real running host, not the compile
102/// target. A detection failure conservatively returns `false` (no MSVC flags
103/// injected — correct for the overwhelmingly-common non-Windows host), and is
104/// logged at `warn` so a swallowed `rustc` failure on a genuine windows-msvc
105/// host (where skipping the flags silently regresses determinism) is auditable
106/// rather than invisible.
107pub fn host_is_windows_msvc() -> bool {
108    match crate::partial::detect_host_target() {
109        Ok(h) => crate::target::is_windows_msvc(&h),
110        Err(e) => {
111            // A bare `rustc` failure here is benign on the common non-Windows
112            // host, but on windows-msvc it silently drops the reproducibility
113            // flags — surface it so the skipped-determinism decision is traceable.
114            tracing::warn!(
115                error = %e,
116                "host-target detection failed; treating host as non-windows-msvc and skipping MSVC determinism flags"
117            );
118            false
119        }
120    }
121}
122
123/// `true` when the detected host target triple is a macOS (Darwin) triple.
124///
125/// Runtime host detection (via `rustc -vV` in
126/// [`crate::partial::detect_host_target`]) — NOT a compile-time
127/// `cfg!(target_os = "macos")` check, for the same cross-host reason as
128/// [`host_is_windows_msvc`]. Used by [`DeterminismState::seed_from_commit`]
129/// to gate the `*.pkg` allow-list entry: only the macOS-native `pkgbuild`
130/// path (which runs on the macOS determinism shard) is not yet proven
131/// reproducible, whereas the Linux flat-package path (xar/mkbom/cpio) is
132/// proven byte-stable and must stay gated. A detection failure
133/// conservatively returns `false` — the overwhelmingly-common host is
134/// Linux, where the `.pkg` is the proven xar path and must NOT be
135/// allow-listed.
136pub fn host_is_macos() -> bool {
137    match crate::partial::detect_host_target() {
138        Ok(h) => crate::target::is_darwin(&h),
139        Err(e) => {
140            // On a non-macOS host this is the right answer; on a genuine
141            // macOS host a swallowed `rustc` failure would un-gate the
142            // unproven pkgbuild `.pkg` and risk a false drift finding —
143            // surface it so the decision is auditable.
144            tracing::warn!(
145                error = %e,
146                "host-target detection failed; treating host as non-macOS (Linux xar .pkg path is gated, not allow-listed)"
147            );
148            false
149        }
150    }
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct DeterminismState {
155    pub sde: i64,
156    pub compile_time_allowlist: Vec<(String, String)>,
157    pub runtime_allowlist: Vec<(String, String)>,
158}
159
160impl DeterminismState {
161    /// Seed from a commit timestamp (seconds since UNIX epoch). All built-
162    /// in compile-time allow-list entries listed in the spec's contract
163    /// table are added here.
164    ///
165    /// Returns `Err` when `commit_ts` is negative — a negative epoch would
166    /// propagate a bogus `SOURCE_DATE_EPOCH` into child processes (where
167    /// shells / build tools may misinterpret it) and almost always
168    /// indicates a corrupted commit graph or a test passing a sentinel
169    /// like `-1`. Fail-fast is the correct UX for a determinism API.
170    ///
171    /// ## Compile-time allow-list scope
172    ///
173    /// Each entry below corresponds to an artifact pattern the
174    /// [`crate::determinism_report`] verification harness will actually
175    /// see in `dist/`. Entries are matched by `*.ext` suffix or exact
176    /// filename against the basename of every file the harness walks
177    /// under the per-run worktree's `dist/` tree. Pattern names that do
178    /// not match any real emitter output are dead code (silently never
179    /// resolve) — keep this list aligned with what stages actually drop
180    /// into `dist/`.
181    ///
182    /// Notably absent (and intentionally so):
183    ///
184    /// - `docker-manifest-descriptor` / `docker-image-blob`: the docker
185    ///   stage is in [`crate::determinism_runner::SIDE_EFFECT_STAGES`]
186    ///   and skipped by the harness; the only docker file that lands in
187    ///   `dist/` is a `.digest` text file written by buildx (a
188    ///   deterministic sha256). No need for an allow-list entry.
189    /// - `apple-notarization-receipt`: the notarize stage mutates
190    ///   existing artifacts in-place (staples) rather than emitting new
191    ///   files; no separate "receipt" artifact lands in `dist/`.
192    /// - The NSIS installer (`*-setup.exe` / `*_setup.exe`) is GATED, not
193    ///   allow-listed. makensis honors `SOURCE_DATE_EPOCH` for the embedded
194    ///   build timestamp, so two builds over identical inputs are
195    ///   byte-identical — proven by
196    ///   stage-nsis::nsis_setup_is_byte_reproducible_across_time. Under the
197    ///   A′ shard routing the installer now lands in `dist/windows/` on the
198    ///   Windows determinism shard (it builds the windows-msvc payload
199    ///   binary), so the harness DOES see and byte-compare it. The classifier
200    ///   keys on the `setup.exe` name tail so the installer is attributed to
201    ///   `nsis` while the raw `anodize.exe` binary is not (see
202    ///   `determinism_harness::artifacts::infer_stage_from_path`). A real
203    ///   drift here is a regression, not an excused non-determinism.
204    /// - The macOS `.app` bundle is GATED, not allow-listed. It is pure
205    ///   file assembly (Info.plist + binary copy) at a fixed mtime /
206    ///   `SOURCE_DATE_EPOCH`, so it is byte-reproducible — proven by
207    ///   stage-appbundle::appbundle_is_byte_reproducible_across_time. Under
208    ///   A′ it is produced on the macOS determinism shard (which holds the
209    ///   darwin payload binary), so the harness byte-compares it directly.
210    pub fn seed_from_commit(commit_ts: i64) -> Result<Self> {
211        if commit_ts < 0 {
212            anyhow::bail!(
213                "commit_ts must be non-negative (got {}); a corrupted commit graph or future-bug? \
214                 Negative SOURCE_DATE_EPOCH would propagate to child processes and be \
215                 misinterpreted by shells/build tools.",
216                commit_ts
217            );
218        }
219        // Installer formats whose non-determinism is intrinsic (a tool's
220        // wall-clock or spec-mandated unique id anodizer cannot pin) AND
221        // empirically proven by a two-build `cmp` test. Reproducible formats
222        // are NOT here — they are gated so a real regression is caught.
223        // Each entry carries its `.sha256` sidecar: the sidecar hashes a
224        // non-deterministic source so it is itself non-deterministic, but
225        // not an independent finding worth surfacing.
226        //
227        // `.deb` / `.rpm` are allow-listed BECAUSE the real config GPG-signs
228        // them (`deb.signature.key_file` / `rpm.signature.key_file` driven by
229        // `GPG_KEY_PATH`), and the determinism harness provisions an ephemeral
230        // GPG key so signing runs. The GPG signature embeds a non-pinnable
231        // creation time / randomized salt that is not byte-reproducible even at
232        // a fixed SOURCE_DATE_EPOCH (the same intrinsic-signature class as the
233        // cosign `.sig` files). The package BODY is fully byte-reproducible —
234        // proven by stage-nfpm::signed_{deb,rpm}_body_is_byte_reproducible_across_time
235        // — and the signature is verified cryptographically (gpg --verify), not
236        // by byte-equality. An earlier UNSIGNED reproducibility proof had
237        // removed them from this list; that missed the signed real-config path.
238        //
239        // Conspicuously gated, each byte-identical across two builds at a fixed
240        // SOURCE_DATE_EPOCH (the value the harness exports), so the harness must
241        // count any drift as a real regression:
242        //   - `.crate`, `.snap`, the Linux xar `.pkg` — UNSIGNED, so the whole
243        //     artifact must be byte-reproducible.
244        //   - `.apk` — SIGNED, but unlike the deb/rpm GPG signature nfpm's apk
245        //     RSA signature is DETERMINISTIC (PKCS#1, no salt / no embedded
246        //     timestamp), so the whole signed artifact is byte-reproducible —
247        //     proven by stage-nfpm::signed_apk_is_byte_reproducible_across_time.
248        //     Hence gated, NOT allow-listed (contrast `.deb`/`.rpm`, whose GPG
249        //     signature is non-reproducible).
250        // See the gating tests cited per format below in the unit-test module.
251        let mut installer_allow: Vec<(&str, &str)> = vec![
252            (
253                "*.msi",
254                "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′.",
255            ),
256            (
257                "*.dmg",
258                "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′.",
259            ),
260            (
261                "*.flatpak",
262                "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",
263            ),
264            (
265                "*.deb",
266                "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.",
267            ),
268            (
269                "*.rpm",
270                "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.",
271            ),
272        ];
273        // `*.pkg` is host-keyed. Only one producer runs per determinism
274        // shard: Linux runs the xar/mkbom/cpio flat-package path, proven
275        // byte-reproducible (stage-pkg::test_flat_pkg_is_byte_reproducible_across_time)
276        // — so on Linux the `.pkg` is GATED, never allow-listed. macOS runs
277        // the native `pkgbuild` path, not yet proven; on a macOS host the
278        // `.pkg` is allow-listed pending the macos-shard proof.
279        if host_is_macos() {
280            installer_allow.push((
281                "*.pkg",
282                "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)",
283            ));
284        }
285        // SBOMs embed identifiers that are non-reproducible by nature:
286        // CycloneDX carries a random `serialNumber` UUID plus a generation
287        // `metadata.timestamp`, and SPDX carries a `documentNamespace` UUID
288        // plus a `created` timestamp. syft does not honor SOURCE_DATE_EPOCH
289        // for the document timestamp and (per the CycloneDX/SPDX specs) the
290        // serial/namespace must be unique per document, so two runs over
291        // byte-identical inputs still produce differing SBOM bytes. These
292        // SBOMs are excluded from the reproducibility
293        // guarantee. Surfaced in the report, excluded from `drift_count`.
294        // Extensions mirror `infer_stage_from_path`'s `sbom` classifier.
295        let sbom_allow: &[(&str, &str)] = &[
296            (
297                "*.cdx.json",
298                "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",
299            ),
300            (
301                "*.spdx.json",
302                "SPDX SBOM embeds a documentNamespace UUID and a created timestamp; not byte-reproducible across runs",
303            ),
304            (
305                "*.sbom.json",
306                "SBOM document embeds a per-document unique identifier and generation timestamp; not byte-reproducible across runs",
307            ),
308        ];
309        let mut compile_time_allowlist: Vec<(String, String)> = Vec::new();
310        for (pattern, reason) in installer_allow.iter().chain(sbom_allow) {
311            compile_time_allowlist.push(((*pattern).into(), (*reason).into()));
312            compile_time_allowlist.push((
313                format!("{}.sha256", pattern),
314                format!("derivative of {pattern}: {reason}"),
315            ));
316        }
317        // `artifacts.json` and the combined `*_checksums.txt` are NOT
318        // blanket-allow-listed here. A blanket entry masks real regressions:
319        // it excuses the manifest even when a GATED (byte-reproducible)
320        // member drifted. They are handled instead by the determinism
321        // harness's aggregate registry (see [`AggregateKind`]), which excuses
322        // an aggregate's drift IFF every differing member is itself
323        // allow-listed — the transitive-derivation rule — and otherwise
324        // surfaces the offending member as a real regression.
325
326        Ok(Self {
327            sde: commit_ts,
328            compile_time_allowlist,
329            runtime_allowlist: Vec::new(),
330        })
331    }
332
333    /// Export SOURCE_DATE_EPOCH onto a `std::process::Command` so
334    /// child subprocesses (cargo, tar, sbom tools, etc.) see the
335    /// reproducible epoch.
336    pub fn export_env(&self, cmd: &mut Command) {
337        cmd.env("SOURCE_DATE_EPOCH", self.sde.to_string());
338    }
339
340    /// Resolve the allow-list reason for an artifact name. Compile-time
341    /// entries win on collision per the spec's "Operator escape /
342    /// Precedence on collision" section. Returns None when the artifact
343    /// is not in either list.
344    pub fn resolve_reason(&self, artifact: &str) -> Option<&str> {
345        // Compile-time first
346        for (name, reason) in &self.compile_time_allowlist {
347            if matches_artifact_pattern(name, artifact) {
348                return Some(reason.as_str());
349            }
350        }
351        // Then runtime
352        for (name, reason) in &self.runtime_allowlist {
353            if matches_artifact_pattern(name, artifact) {
354                return Some(reason.as_str());
355            }
356        }
357        None
358    }
359
360    /// Append a runtime allow-list entry. Caller is the CLI flag
361    /// handler for `--allow-nondeterministic <name>=<reason>`.
362    pub fn append_runtime(&mut self, artifact: String, reason: String) {
363        self.runtime_allowlist.push((artifact, reason));
364    }
365}
366
367/// Simple glob: `*.ext` matches any artifact ending in `.ext`;
368/// exact-match otherwise. Avoids pulling a globbing crate for this
369/// narrow case.
370fn matches_artifact_pattern(pattern: &str, artifact: &str) -> bool {
371    if let Some(suffix) = pattern.strip_prefix('*') {
372        return artifact.ends_with(suffix);
373    }
374    pattern == artifact
375}
376
377// ---------------------------------------------------------------------------
378// Aggregate-artifact registry — the transitive-derivation rule
379// ---------------------------------------------------------------------------
380//
381// An *aggregate* is a file whose bytes are a deterministic function of a set
382// of *member* artifacts (a checksums file lists `<digest>  <name>` per member;
383// `artifacts.json` records each member's path + digest). Such a file drifts
384// whenever ANY listed member drifts — including the allow-listed
385// non-deterministic ones (signed deb/rpm, SBOMs, signatures). A blanket
386// allow-list on the aggregate would therefore MASK a real regression in a
387// gated (byte-reproducible) member.
388//
389// The registry lets the determinism harness reconstruct an aggregate's members
390// from both runs and excuse its drift IFF every *differing* member is itself
391// allow-listed; any differing member that is NOT allow-listed is surfaced as a
392// real regression. The two ids below are the compile-time coupling anchor: each
393// producer references its id (mirroring the `MSVC_DETERMINISM_RUSTFLAGS` ↔
394// `.cargo/config.toml` coupling above), so a producer and its registry entry
395// cannot silently drift apart.
396
397/// Aggregate id of the combined `*_checksums.txt` file produced by
398/// `anodizer_stage_checksum`'s `refresh_combined_checksums`. Referenced by
399/// that producer so the registry entry and the emitter share one symbol.
400pub const COMBINED_CHECKSUMS_AGGREGATE_ID: &str = "combined-checksums";
401
402/// Aggregate id of the `artifacts.json` dist manifest produced by the CLI's
403/// `write_artifacts_and_metadata`. Referenced by that producer so the
404/// registry entry and the emitter share one symbol.
405pub const ARTIFACTS_MANIFEST_AGGREGATE_ID: &str = "artifacts-manifest";
406
407/// A class of aggregate artifact recognized by the determinism harness.
408///
409/// Implementors parse the aggregate's bytes into a `unit_key -> member`
410/// map. A *unit* is one line / one manifest entry; its `unit_key` is
411/// content-sensitive (it folds in the recorded digest) so a value-change
412/// for a stable member identity surfaces as an add/remove pair across
413/// runs. The `member` is the artifact basename the harness resolves
414/// against the determinism allow-list.
415pub trait AggregateKind {
416    /// Stable id (one of the `*_AGGREGATE_ID` consts), the compile-time
417    /// coupling anchor shared with the producing stage.
418    fn id(&self) -> &'static str;
419
420    /// `true` when `name` (a harness artifact key, possibly nested under a
421    /// per-crate subdirectory) is an instance of this aggregate.
422    fn matches(&self, name: &str) -> bool;
423
424    /// Parse `bytes` into `unit_key -> member`. Errors (fail-closed) when
425    /// the bytes are not valid for this aggregate — the caller treats a
426    /// parse failure as real drift, never an excuse.
427    fn members_by_unit(&self, bytes: &[u8]) -> Result<BTreeMap<String, String>>;
428}
429
430/// Basename (last `/`- or `\`-separated component), lowercased — used for
431/// aggregate name matching across single-crate / lockstep / per-crate dist
432/// layouts (per-crate nests the manifest under `<crate>/`).
433fn aggregate_basename(name: &str) -> String {
434    name.rsplit(['/', '\\'])
435        .next()
436        .unwrap_or(name)
437        .to_lowercase()
438}
439
440/// The combined `<name>_<version>_checksums.txt` / `sha256sums` file: one
441/// `<digest>  <filename>` line per primary artifact.
442pub struct CombinedChecksums;
443
444impl AggregateKind for CombinedChecksums {
445    fn id(&self) -> &'static str {
446        COMBINED_CHECKSUMS_AGGREGATE_ID
447    }
448
449    /// SECONDARY heuristic only. The authoritative signal that a file is a
450    /// combined checksums aggregate is its `artifacts.json` entry carrying
451    /// the [`crate::artifact::COMBINED_CHECKSUM_META`] = `"true"` marker —
452    /// resolved by [`combined_checksum_members_from_manifest`]. This
453    /// filename-suffix match is the fallback for callers that lack manifest
454    /// context (or for a combined file emitted with a conventional name);
455    /// it deliberately does NOT recognize an operator-renamed combined file
456    /// such as `SHA512SUMS`, which only the marker can identify.
457    fn matches(&self, name: &str) -> bool {
458        let base = aggregate_basename(name);
459        base.ends_with("checksums.txt")
460            || base.ends_with("sha256sums")
461            || base.ends_with("sha256sum")
462    }
463
464    fn members_by_unit(&self, bytes: &[u8]) -> Result<BTreeMap<String, String>> {
465        let text =
466            std::str::from_utf8(bytes).context("combined checksums file is not valid UTF-8")?;
467        let mut out = BTreeMap::new();
468        for raw in text.lines() {
469            let line = raw.trim_end_matches(['\r', '\n']);
470            if line.trim().is_empty() {
471                continue;
472            }
473            // GNU coreutils format is `<digest>  <filename>` (two spaces).
474            // The filename is the rightmost field; it is the basename the
475            // refresh-combined writer emits, so it resolves directly against
476            // the allow-list.
477            let filename = line
478                .rsplit("  ")
479                .next()
480                .map(str::trim)
481                .filter(|s| !s.is_empty())
482                .with_context(|| format!("checksum line missing a filename field: {line:?}"))?;
483            // unit_key = the whole line: a changed digest yields a new line,
484            // surfaced as an add/remove of the same member across runs.
485            out.insert(line.to_string(), filename.to_string());
486        }
487        Ok(out)
488    }
489}
490
491/// The `artifacts.json` dist manifest: a JSON array of entries, each with a
492/// `path` and (after checksumming) a recorded digest in `metadata`.
493pub struct ArtifactsManifest;
494
495impl AggregateKind for ArtifactsManifest {
496    fn id(&self) -> &'static str {
497        ARTIFACTS_MANIFEST_AGGREGATE_ID
498    }
499
500    fn matches(&self, name: &str) -> bool {
501        aggregate_basename(name) == crate::dist::ARTIFACTS_JSON
502    }
503
504    fn members_by_unit(&self, bytes: &[u8]) -> Result<BTreeMap<String, String>> {
505        let value: serde_json::Value =
506            serde_json::from_slice(bytes).context("parsing artifacts.json dist manifest")?;
507        let entries = value
508            .as_array()
509            .context("artifacts.json dist manifest must be a JSON array")?;
510        let mut out = BTreeMap::new();
511        for entry in entries {
512            let path = entry
513                .get("path")
514                .and_then(serde_json::Value::as_str)
515                .context("artifacts.json entry missing a string `path` field")?;
516            let member = path
517                .rsplit(['/', '\\'])
518                .next()
519                .filter(|s| !s.is_empty())
520                .unwrap_or(path)
521                .to_string();
522            // Content token: the recorded digest if present, else the entire
523            // canonical entry — so ANY drift in the entry (digest, size,
524            // metadata) is attributed to this member rather than silently
525            // excused.
526            let token = entry
527                .get("metadata")
528                .and_then(|m| {
529                    m.get("sha256")
530                        .or_else(|| m.get("SHA256"))
531                        .or_else(|| m.get("Checksum"))
532                })
533                .and_then(serde_json::Value::as_str)
534                .map(str::to_string)
535                .unwrap_or_else(|| serde_json::to_string(entry).unwrap_or_default());
536            // unit_key folds the digest into the path identity so a
537            // value-change (same path, new digest) surfaces as an add/remove
538            // pair judged by `member`'s allow-list status. U+001F is the ASCII
539            // unit separator — it cannot occur in a path or hex digest.
540            let unit_key = format!("{path}\u{1f}{token}");
541            out.insert(unit_key, member);
542        }
543        Ok(out)
544    }
545}
546
547/// Every recognized aggregate kind, in priority order.
548pub fn aggregate_kinds() -> Vec<Box<dyn AggregateKind>> {
549    vec![Box::new(CombinedChecksums), Box::new(ArtifactsManifest)]
550}
551
552/// Return the [`AggregateKind`] that recognizes `name`, if any.
553pub fn aggregate_kind_for(name: &str) -> Option<Box<dyn AggregateKind>> {
554    aggregate_kinds().into_iter().find(|k| k.matches(name))
555}
556
557/// Parse an `artifacts.json` manifest and return the basenames of every entry
558/// flagged as a combined checksums file via the
559/// [`crate::artifact::COMBINED_CHECKSUM_META`] = [`crate::artifact::COMBINED_CHECKSUM_VALUE`]
560/// marker.
561///
562/// This is the AUTHORITATIVE recognizer for the combined-checksums aggregate:
563/// the checksum stage stamps that marker onto the combined file's manifest
564/// entry regardless of the operator's chosen filename, so a renamed
565/// `SHA512SUMS` (which [`CombinedChecksums::matches`]'s filename suffixes
566/// cannot catch) is still recognized. Callers union these basenames with the
567/// filename-suffix heuristic. Returns an error (fail-closed) when the bytes
568/// are not a JSON array of objects — the caller treats that as real drift.
569pub fn combined_checksum_members_from_manifest(bytes: &[u8]) -> Result<BTreeSet<String>> {
570    let value: serde_json::Value =
571        serde_json::from_slice(bytes).context("parsing artifacts.json dist manifest")?;
572    let entries = value
573        .as_array()
574        .context("artifacts.json dist manifest must be a JSON array")?;
575    let mut out = BTreeSet::new();
576    for entry in entries {
577        let is_combined = entry
578            .get("metadata")
579            .and_then(|m| m.get(crate::artifact::COMBINED_CHECKSUM_META))
580            .and_then(serde_json::Value::as_str)
581            .is_some_and(|v| v == crate::artifact::COMBINED_CHECKSUM_VALUE);
582        if !is_combined {
583            continue;
584        }
585        let Some(path) = entry.get("path").and_then(serde_json::Value::as_str) else {
586            continue;
587        };
588        let base = path
589            .rsplit(['/', '\\'])
590            .next()
591            .filter(|s| !s.is_empty())
592            .unwrap_or(path);
593        out.insert(base.to_string());
594    }
595    Ok(out)
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn msvc_rustflags_const_matches_cargo_config() {
604        // The static `.cargo/config.toml` block is the only other home for
605        // this flag set (it can't import a Rust const). Keep them aligned:
606        // the config writes each `-C <arg>` as a two-element TOML array pair,
607        // so the joined form must equal this const verbatim.
608        let cargo_config = include_str!("../../../.cargo/config.toml");
609        for &flag in MSVC_DETERMINISM_RUSTFLAGS {
610            // `-C codegen-units=1` -> `"-C", "codegen-units=1"`
611            let (lead, rest) = flag.split_once(' ').expect("each flag is two tokens");
612            let toml_pair = format!("\"{lead}\", \"{rest}\"");
613            assert!(
614                cargo_config.contains(&toml_pair),
615                ".cargo/config.toml must carry `{toml_pair}` to stay aligned with \
616                 MSVC_DETERMINISM_RUSTFLAGS; the two drifted"
617            );
618        }
619    }
620
621    #[test]
622    fn merge_msvc_into_empty_yields_full_set() {
623        let merged = merge_msvc_determinism_rustflags("");
624        for &flag in MSVC_DETERMINISM_RUSTFLAGS {
625            assert!(
626                merged.contains(flag),
627                "merged set must contain `{flag}`. got={merged}"
628            );
629        }
630        assert!(
631            merged.contains("/Brepro"),
632            "the COFF TimeDateStamp fix must be present"
633        );
634    }
635
636    #[test]
637    fn merge_msvc_preserves_existing_flags_and_appends() {
638        let base = "-C linker=link.exe --remap-path-prefix=/w=/build";
639        let merged = merge_msvc_determinism_rustflags(base);
640        assert!(
641            merged.starts_with(base),
642            "existing flags must be preserved verbatim. got={merged}"
643        );
644        assert!(
645            merged.contains("-C link-arg=/Brepro"),
646            "MSVC flags must be appended. got={merged}"
647        );
648    }
649
650    #[test]
651    fn merge_msvc_is_idempotent_no_duplicate_brepro() {
652        let once = merge_msvc_determinism_rustflags("");
653        let twice = merge_msvc_determinism_rustflags(&once);
654        assert_eq!(
655            once, twice,
656            "merging an already-merged set must not duplicate flags"
657        );
658        assert_eq!(
659            twice.matches("/Brepro").count(),
660            1,
661            "/Brepro must appear exactly once after a double merge. got={twice}"
662        );
663    }
664
665    #[test]
666    fn host_is_windows_msvc_matches_real_host() {
667        // On this Linux CI box the real host triple is non-MSVC, so the
668        // runtime probe must report false (the bug class this guards is a
669        // compile-time check that would report the wrong answer on a
670        // cross-built binary).
671        let expected = crate::partial::detect_host_target()
672            .map(|h| crate::target::is_windows_msvc(&h))
673            .unwrap_or(false);
674        assert_eq!(host_is_windows_msvc(), expected);
675    }
676
677    #[test]
678    fn sde_from_commit_timestamp_is_idempotent() {
679        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
680        assert_eq!(s.sde, 1_715_000_000);
681        let s2 = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
682        assert_eq!(s, s2);
683    }
684
685    #[test]
686    fn cargo_crate_is_gated_not_allowlisted() {
687        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
688        // `cargo package` is byte-identical across two builds at a fixed
689        // SOURCE_DATE_EPOCH (the harness exports it; cargo normalizes the
690        // source tarball — sorted paths, pinned mtime). The `.crate` must be
691        // gated so a real regression is caught, NOT allow-listed. The
692        // per-crate-rendered name must not resolve in any of the three modes.
693        assert!(s.resolve_reason("anodizer-0.2.1.crate").is_none());
694        assert!(s.resolve_reason("anodizer-core-0.11.3.crate").is_none());
695        // And its sidecar must not be allow-listed as a derivative either.
696        assert!(
697            s.resolve_reason("anodizer-core-0.11.3.crate.sha256")
698                .is_none()
699        );
700    }
701
702    #[test]
703    fn rpm_and_deb_are_allowlisted_due_to_gpg_signing() {
704        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
705        // The real config GPG-signs both formats; the signature is intrinsically
706        // non-byte-reproducible even at a fixed SOURCE_DATE_EPOCH while the body
707        // IS reproducible (proven by stage-nfpm::signed_{deb,rpm}_body_is_byte_-
708        // reproducible_across_time). Both — and their sidecars — are allow-listed.
709        let rpm = s.resolve_reason("foo-1.0.rpm").expect("matches *.rpm");
710        assert!(
711            rpm.contains("signature") || rpm.contains("GPG"),
712            "rpm reason must cite the signature: {rpm}"
713        );
714        let deb = s
715            .resolve_reason("foo_1.0_amd64.deb")
716            .expect("matches *.deb");
717        assert!(
718            deb.contains("signature") || deb.contains("GPG"),
719            "deb reason must cite the signature: {deb}"
720        );
721        assert!(s.resolve_reason("foo-1.0.rpm.sha256").is_some());
722        assert!(s.resolve_reason("foo_1.0_amd64.deb.sha256").is_some());
723    }
724
725    #[test]
726    fn snap_is_gated_not_allowlisted() {
727        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
728        // snapcraft pack (mksquashfs) honors SOURCE_DATE_EPOCH for the
729        // squashfs mod_time, so .snap is byte-identical across two builds
730        // (proven by stage-snapcraft::snap_is_byte_reproducible_across_time).
731        // Gated, never allow-listed as "defense-in-depth".
732        assert!(s.resolve_reason("probe_1.2.3_amd64.snap").is_none());
733        assert!(s.resolve_reason("probe_1.2.3_amd64.snap.sha256").is_none());
734    }
735
736    #[test]
737    fn apk_is_signed_but_gated() {
738        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
739        // nfpm RSA-signs the apk, but the signature is deterministic (PKCS#1,
740        // no salt / no embedded timestamp) so the whole signed artifact is
741        // byte-identical across two builds at a fixed SOURCE_DATE_EPOCH —
742        // proven by stage-nfpm::signed_apk_is_byte_reproducible_across_time.
743        // It must be GATED (real drift = regression), NOT allow-listed like
744        // the GPG-signed deb/rpm whose signature is non-reproducible.
745        assert!(s.resolve_reason("foo_1.0_amd64.apk").is_none());
746        assert!(s.resolve_reason("foo_1.0_amd64.apk.sha256").is_none());
747    }
748
749    #[test]
750    fn compile_time_allowlist_resolves_for_flatpak() {
751        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
752        // flatpak build-bundle wraps a non-byte-stable OSTree commit; the
753        // harness must not count a `.flatpak` as drift.
754        let reason = s
755            .resolve_reason("anodizer_0.9.1_linux_amd64.flatpak")
756            .expect("matches *.flatpak");
757        assert!(reason.contains("OSTree"));
758        // The `.sha256` sidecar over a non-deterministic bundle is itself
759        // non-deterministic — allow-listed as a derivative.
760        assert!(
761            s.resolve_reason("anodizer_0.9.1_linux_amd64.flatpak.sha256")
762                .is_some()
763        );
764    }
765
766    #[test]
767    fn pkg_allowlist_is_host_keyed() {
768        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
769        // `.pkg` is allow-listed ONLY on a macOS host (the unproven native
770        // pkgbuild path); on a Linux host the `.pkg` is the proven xar/mkbom/
771        // cpio flat-package path and must be gated. The seed reflects the
772        // running host, so the resolved verdict must match it.
773        let resolved = s.resolve_reason("anodizer-0.2.1.pkg").is_some();
774        assert_eq!(
775            resolved,
776            host_is_macos(),
777            "`.pkg` must be allow-listed iff the host is macOS (Linux xar path is gated)"
778        );
779        // This CI box is Linux, so confirm the gated direction concretely:
780        // the proven Linux path must NOT be excused.
781        if !host_is_macos() {
782            assert!(
783                s.resolve_reason("anodizer-0.2.1.pkg").is_none(),
784                "Linux xar .pkg path is proven reproducible and must be gated"
785            );
786            assert!(s.resolve_reason("anodizer-0.2.1.pkg.sha256").is_none());
787        }
788    }
789
790    #[test]
791    fn appbundle_is_gated_not_allowlisted() {
792        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
793        // The macOS `.app` bundle is pure file assembly at a fixed mtime /
794        // SOURCE_DATE_EPOCH, so it is byte-reproducible — proven by
795        // stage-appbundle::appbundle_is_byte_reproducible_across_time. Under A′
796        // it is produced on the macOS determinism shard and byte-compared, so
797        // it must be GATED (real drift = regression), NEVER allow-listed.
798        assert!(s.resolve_reason("anodizer_arm64.app").is_none());
799        assert!(s.resolve_reason("anodizer_amd64.app").is_none());
800        // A `.app` is a directory, but a `.sha256` over it (if one is ever
801        // emitted) must likewise not be excused as a derivative.
802        assert!(s.resolve_reason("anodizer_arm64.app.sha256").is_none());
803    }
804
805    #[test]
806    fn nsis_installer_is_gated_not_allowlisted() {
807        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
808        // makensis honors SOURCE_DATE_EPOCH, so the NSIS installer is byte-
809        // reproducible — proven by
810        // stage-nsis::nsis_setup_is_byte_reproducible_across_time. Under A′ it
811        // lands in dist/windows/ on the Windows determinism shard and is byte-
812        // compared, so it must be GATED, NEVER allow-listed. Neither the
813        // configured `-setup.exe` name nor the stage-default `_setup.exe` tail
814        // may resolve to an allow-list reason.
815        assert!(s.resolve_reason("anodizer_x64-setup.exe").is_none());
816        assert!(s.resolve_reason("anodizer_x64_setup.exe").is_none());
817        assert!(
818            s.resolve_reason("anodizer_arm64-setup.exe.sha256")
819                .is_none()
820        );
821    }
822
823    #[test]
824    fn raw_windows_binary_is_not_allowlisted_or_misclassified() {
825        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
826        // The raw windows binary `anodizer.exe` is the load-bearing build
827        // output — it must be byte-reproducible (the /Brepro RUSTFLAGS make it
828        // so) and is therefore GATED. It must never be swept into an installer
829        // allow-list class by a bare `*.exe` pattern (there is none; only the
830        // intrinsically-non-reproducible native installers are allow-listed).
831        assert!(s.resolve_reason("anodizer.exe").is_none());
832        assert!(s.resolve_reason("anodizer.exe.sha256").is_none());
833    }
834
835    #[test]
836    fn msi_and_dmg_reasons_cite_pending_shard_proof() {
837        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
838        // These run on the windows/macos shards (not provable on this Linux
839        // box); their reasons must cite the sibling pending shard tests
840        // rather than a "deferred to follow-up" hedge.
841        let msi = s
842            .resolve_reason("anodizer-0.2.1.msi")
843            .expect("matches *.msi");
844        assert!(
845            msi.contains("msi_is_byte_reproducible_across_time"),
846            "msi reason must cite the pending shard test: {msi}"
847        );
848        let dmg = s
849            .resolve_reason("anodizer-0.2.1.dmg")
850            .expect("matches *.dmg");
851        assert!(
852            dmg.contains("dmg_is_byte_reproducible_across_time"),
853            "dmg reason must cite the pending shard test: {dmg}"
854        );
855    }
856
857    #[test]
858    fn compile_time_allowlist_resolves_for_sbom_documents() {
859        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
860        // syft-generated CycloneDX/SPDX SBOMs carry a random serial/namespace
861        // UUID + generation timestamp and can never be byte-identical across
862        // runs; the harness must not count them as drift.
863        for name in [
864            "cfgd-0.4.0-linux-amd64.tar.gz.cdx.json",
865            "cfgd-0.4.0-linux-amd64.tar.gz.spdx.json",
866            "cfgd-0.4.0-linux-amd64.tar.gz.sbom.json",
867        ] {
868            assert!(
869                s.resolve_reason(name).is_some(),
870                "SBOM document {name} must be allow-listed"
871            );
872        }
873    }
874
875    #[test]
876    fn compile_time_allowlist_resolves_for_sbom_checksum_sidecars() {
877        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
878        // The `.sha256` sidecar hashes the non-deterministic SBOM, so it is
879        // itself non-deterministic — allow-listed as a derivative.
880        let reason = s
881            .resolve_reason("cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256")
882            .expect("matches *.cdx.json.sha256");
883        assert!(reason.contains("derivative of"));
884    }
885
886    #[test]
887    fn artifacts_manifest_is_not_blanket_allowlisted() {
888        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
889        // The dist manifest is NO LONGER blanket-allow-listed — a blanket
890        // entry masked drift in gated (byte-reproducible) members. It is now
891        // handled by the aggregate registry's transitive-derivation rule.
892        assert!(
893            s.resolve_reason("artifacts.json").is_none(),
894            "artifacts.json must not be blanket-excused; the registry judges it per-member"
895        );
896        // The combined checksums file is likewise registry-driven, never
897        // blanket-allow-listed.
898        assert!(s.resolve_reason("anodizer_0.12.0_checksums.txt").is_none());
899        assert!(s.resolve_reason("config.json").is_none());
900        assert!(s.resolve_reason("metadata.json").is_none());
901    }
902
903    #[test]
904    fn aggregate_registry_matches_checksums_and_manifest() {
905        // Combined checksums: bare, per-crate-nested, and sha256sums spellings.
906        assert_eq!(
907            aggregate_kind_for("anodizer_0.12.0_checksums.txt").map(|k| k.id()),
908            Some(COMBINED_CHECKSUMS_AGGREGATE_ID)
909        );
910        assert_eq!(
911            aggregate_kind_for("anodizer-core/anodizer-core_0.12.0_checksums.txt").map(|k| k.id()),
912            Some(COMBINED_CHECKSUMS_AGGREGATE_ID)
913        );
914        assert_eq!(
915            aggregate_kind_for("SHA256SUMS").map(|k| k.id()),
916            Some(COMBINED_CHECKSUMS_AGGREGATE_ID)
917        );
918        // artifacts.json (root + per-crate-nested).
919        assert_eq!(
920            aggregate_kind_for("artifacts.json").map(|k| k.id()),
921            Some(ARTIFACTS_MANIFEST_AGGREGATE_ID)
922        );
923        assert_eq!(
924            aggregate_kind_for("anodizer-core/artifacts.json").map(|k| k.id()),
925            Some(ARTIFACTS_MANIFEST_AGGREGATE_ID)
926        );
927        // A per-artifact `.sha256` split sidecar is NOT an aggregate.
928        assert!(aggregate_kind_for("anodizer_0.12.0_linux_amd64.tar.gz.sha256").is_none());
929        // metadata.json is a tracked primary, never an aggregate.
930        assert!(aggregate_kind_for("metadata.json").is_none());
931    }
932
933    #[test]
934    fn combined_checksums_members_by_unit_round_trips() {
935        let bytes = b"aaaa  foo_1.0_amd64.deb\nbbbb  bar-1.0.tar.gz\n\ncccc  baz.cdx.json\n";
936        let units = CombinedChecksums.members_by_unit(bytes).expect("parses");
937        let members: std::collections::BTreeSet<&str> =
938            units.values().map(String::as_str).collect();
939        assert_eq!(
940            members,
941            ["foo_1.0_amd64.deb", "bar-1.0.tar.gz", "baz.cdx.json"]
942                .into_iter()
943                .collect()
944        );
945        // Each unit_key is the full line (content-sensitive): a changed digest
946        // yields a distinct key.
947        assert!(units.contains_key("aaaa  foo_1.0_amd64.deb"));
948        // Blank lines are skipped (3 members, not 4).
949        assert_eq!(units.len(), 3);
950    }
951
952    #[test]
953    fn artifacts_manifest_members_by_unit_round_trips_and_keys_on_digest() {
954        let json = br#"[
955          {"kind":"archive","path":"./dist/foo.tar.gz","name":"foo.tar.gz",
956           "metadata":{"sha256":"deadbeef"}},
957          {"kind":"linux_package","path":"./dist/nfpm/foo_1.0_amd64.deb","name":"foo_1.0_amd64.deb",
958           "metadata":{"Checksum":"sha256:cafef00d"}}
959        ]"#;
960        let units = ArtifactsManifest.members_by_unit(json).expect("parses");
961        let members: std::collections::BTreeSet<&str> =
962            units.values().map(String::as_str).collect();
963        assert_eq!(
964            members,
965            ["foo.tar.gz", "foo_1.0_amd64.deb"].into_iter().collect()
966        );
967        // The recorded digest is folded into the unit_key, so re-hashing the
968        // same path with a different digest produces a different key.
969        let drifted = br#"[
970          {"kind":"archive","path":"./dist/foo.tar.gz","name":"foo.tar.gz",
971           "metadata":{"sha256":"00000000"}}
972        ]"#;
973        let drifted_units = ArtifactsManifest.members_by_unit(drifted).expect("parses");
974        let original_key = units
975            .keys()
976            .find(|k| k.starts_with("./dist/foo.tar.gz"))
977            .unwrap();
978        assert!(
979            !drifted_units.contains_key(original_key),
980            "a changed digest must yield a new unit_key (value-change ⇒ add/remove)"
981        );
982    }
983
984    #[test]
985    fn aggregate_members_by_unit_fail_closed_on_garbage() {
986        // Non-UTF-8 checksums bytes and non-array JSON both error so the
987        // harness fails closed (treats the aggregate as real drift).
988        assert!(
989            CombinedChecksums
990                .members_by_unit(&[0xff, 0xfe, 0x00])
991                .is_err()
992        );
993        assert!(ArtifactsManifest.members_by_unit(b"not json").is_err());
994        assert!(ArtifactsManifest.members_by_unit(b"{}").is_err());
995    }
996
997    #[test]
998    fn aggregate_ids_are_distinct_and_stable() {
999        assert_ne!(
1000            COMBINED_CHECKSUMS_AGGREGATE_ID,
1001            ARTIFACTS_MANIFEST_AGGREGATE_ID
1002        );
1003        assert_eq!(CombinedChecksums.id(), COMBINED_CHECKSUMS_AGGREGATE_ID);
1004        assert_eq!(ArtifactsManifest.id(), ARTIFACTS_MANIFEST_AGGREGATE_ID);
1005    }
1006
1007    #[test]
1008    fn combined_checksum_marker_recognizes_operator_renamed_file() {
1009        // The combined file is named `SHA512SUMS` — a name the filename-suffix
1010        // heuristic deliberately misses — but its manifest entry carries the
1011        // `combined = "true"` marker, so the authoritative recognizer finds it.
1012        let manifest = br#"[
1013          {"kind":"archive","path":"./dist/foo.tar.gz","name":"foo.tar.gz",
1014           "metadata":{"sha256":"aaaa"}},
1015          {"kind":"checksum","path":"./dist/SHA512SUMS","name":"SHA512SUMS",
1016           "metadata":{"combined":"true"}}
1017        ]"#;
1018        let markers = combined_checksum_members_from_manifest(manifest).expect("parses");
1019        assert!(
1020            markers.contains("SHA512SUMS"),
1021            "marker recognizer must flag the renamed combined file: {markers:?}"
1022        );
1023        // The filename-suffix fallback alone does NOT recognize it.
1024        assert!(!CombinedChecksums.matches("SHA512SUMS"));
1025        assert!(aggregate_kind_for("SHA512SUMS").is_none());
1026        // A per-artifact split checksum sidecar (no `combined` marker) is NOT
1027        // flagged.
1028        assert!(!markers.contains("foo.tar.gz"));
1029    }
1030
1031    #[test]
1032    fn combined_checksum_marker_fails_closed_on_non_array() {
1033        assert!(combined_checksum_members_from_manifest(b"not json").is_err());
1034        assert!(combined_checksum_members_from_manifest(b"{}").is_err());
1035        // An empty array is valid and yields no markers.
1036        assert!(
1037            combined_checksum_members_from_manifest(b"[]")
1038                .expect("empty array parses")
1039                .is_empty()
1040        );
1041    }
1042
1043    #[test]
1044    fn nondeterministic_allowlist_compile_time_wins_on_collision() {
1045        let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
1046        // Runtime entry shadowing a compile-time pattern. Compile-time
1047        // wins so the report shows the deeper rationale. `*.flatpak` is a
1048        // permanently-allow-listed compile-time pattern (intrinsically
1049        // non-reproducible OSTree commit), so it is the stable collision
1050        // anchor regardless of host.
1051        s.append_runtime(
1052            "*.flatpak".into(),
1053            "operator escape (wrong runtime reason)".into(),
1054        );
1055        let reason = s
1056            .resolve_reason("anodizer_0.9.1_linux_amd64.flatpak")
1057            .unwrap();
1058        assert!(
1059            reason.contains("OSTree"),
1060            "compile-time reason takes precedence"
1061        );
1062    }
1063
1064    #[test]
1065    fn nondeterministic_allowlist_serializes_with_both_categories() {
1066        let mut s = DeterminismState::seed_from_commit(0).expect("non-negative");
1067        s.append_runtime("foo.bin".into(), "tool-bug-1234".into());
1068        let json = serde_json::to_string(&s).unwrap();
1069        assert!(json.contains("compile_time_allowlist"));
1070        assert!(json.contains("runtime_allowlist"));
1071        assert!(json.contains("foo.bin"));
1072    }
1073
1074    #[test]
1075    fn export_env_sets_source_date_epoch() {
1076        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
1077        let mut cmd = Command::new("true");
1078        s.export_env(&mut cmd);
1079        let env_vars: Vec<(_, _)> = cmd
1080            .get_envs()
1081            .filter_map(|(k, v)| v.map(|v| (k.to_owned(), v.to_owned())))
1082            .collect();
1083        let sde_entry = env_vars.iter().find(|(k, _)| k == "SOURCE_DATE_EPOCH");
1084        assert!(sde_entry.is_some());
1085        assert_eq!(sde_entry.unwrap().1, "1715000000");
1086    }
1087
1088    #[test]
1089    fn resolve_reason_returns_none_for_unrecognized() {
1090        let s = DeterminismState::seed_from_commit(0).expect("non-negative");
1091        assert!(s.resolve_reason("unrelated.txt").is_none());
1092    }
1093
1094    #[test]
1095    fn seed_from_commit_accepts_zero() {
1096        // Epoch zero (1970-01-01) is a legitimate sentinel — some
1097        // determinism modes anchor SDE to UNIX epoch when the commit
1098        // graph isn't usable. Must not be rejected.
1099        let s = DeterminismState::seed_from_commit(0).expect("zero is non-negative");
1100        assert_eq!(s.sde, 0);
1101    }
1102
1103    #[test]
1104    fn seed_from_commit_accepts_positive() {
1105        // Typical real-world commit timestamp.
1106        let s = DeterminismState::seed_from_commit(1_715_000_000).expect("non-negative");
1107        assert_eq!(s.sde, 1_715_000_000);
1108    }
1109
1110    #[test]
1111    fn seed_from_commit_rejects_negative() {
1112        let err = DeterminismState::seed_from_commit(-1).expect_err("negative must error");
1113        let msg = format!("{err:#}");
1114        assert!(
1115            msg.contains("non-negative") && msg.contains("-1"),
1116            "error must name the bad input and the constraint: {msg}"
1117        );
1118    }
1119}