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