Skip to main content

anodizer_core/
determinism_report.rs

1//! Determinism Harness report types.
2//!
3//! `DeterminismReport` is the canonical JSON shape emitted by
4//! `anodizer check determinism` at
5//! `dist/run-<commit>/determinism.json`. The shape is fixed by the
6//! release-resilience spec ([determinism harness report]) — every
7//! field is consumed by downstream CI parsers, so the serde contract is
8//! fixed:
9//!
10//! - `schema_version: 2` (constant; bump only on a breaking shape change).
11//! - `#[serde(deny_unknown_fields)]` enforced on every struct so a typo'd
12//!   field in a downstream-edited report fails loudly instead of being
13//!   silently dropped.
14//!
15//! These types live in `anodizer-core` (not the CLI crate) so future CI
16//! parsers can deserialize the report without pulling in the entire CLI
17//! dependency tree.
18
19use serde::{Deserialize, Serialize};
20
21/// Current schema version emitted by the harness. Bump on any breaking
22/// field rename or removal; deserialization callers should match on this
23/// before consuming the rest of the payload.
24///
25/// v2 renames the tool-version field from `anodize_version` to
26/// `anodizer_version`; a reader accepts either spelling, a writer emits the
27/// new one.
28pub const CURRENT_SCHEMA_VERSION: u32 = 2;
29
30/// Top-level determinism report shape.
31///
32/// Emitted at `dist/run-<commit>/determinism.json` after every
33/// `anodizer check determinism` run. Non-zero exit accompanies a non-empty
34/// `drift` list.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(deny_unknown_fields)]
37pub struct DeterminismReport {
38    /// Schema version. See [`CURRENT_SCHEMA_VERSION`] for the value this
39    /// release emits.
40    pub schema_version: u32,
41    /// The anodizer version that produced the report. Read also under the
42    /// pre-v2 spelling `anodize_version`, so a report written by an older
43    /// release still parses.
44    #[serde(alias = "anodize_version")]
45    pub anodizer_version: String,
46    /// Full commit SHA of HEAD at harness invocation time.
47    pub commit: String,
48    /// Committer timestamp (seconds since UNIX epoch) of `commit`. In
49    /// `--snapshot` mode this is the resolved snapshot-SDE, which may
50    /// differ from the raw commit timestamp when the tree is dirty.
51    pub commit_timestamp: i64,
52    /// Number of from-clean rebuilds the harness performed.
53    pub runs: u32,
54    /// Ordered list of stage names actually exercised (e.g.
55    /// `["build", "archive", "sbom", "sign", "checksum"]`).
56    pub stages_under_test: Vec<String>,
57    /// Compile-time and runtime allow-lists carried through from
58    /// [`crate::DeterminismState`].
59    pub allowlist: AllowList,
60    /// Per-artifact row, one entry per distinct artifact name seen across
61    /// any run. Includes both deterministic and drifting artifacts.
62    pub artifacts: Vec<ArtifactRow>,
63    /// Drift rows — one entry per artifact whose SHA256 differed across
64    /// runs AND was NOT covered by `allowlist`. Empty when the harness
65    /// passes.
66    pub drift: Vec<DriftRow>,
67    /// `drift.len() as u32`, hoisted to a top-level field so CI parsers
68    /// can short-circuit on the integer without walking the array.
69    pub drift_count: u32,
70}
71
72/// Compile-time + runtime allow-list pair, mirroring
73/// [`crate::DeterminismState::compile_time_allowlist`] /
74/// [`crate::DeterminismState::runtime_allowlist`].
75///
76/// `#[serde(default)]` so an absent `allowlist` field deserializes to an
77/// empty pair instead of erroring; harness emits the field always.
78#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(default, deny_unknown_fields)]
80pub struct AllowList {
81    /// Compile-time entries seeded by [`crate::DeterminismState::seed_from_commit`].
82    pub compile_time: Vec<AllowListEntry>,
83    /// Runtime entries added via `anodizer release --allow-nondeterministic`.
84    pub runtime: Vec<AllowListEntry>,
85}
86
87/// One allow-list entry: an artifact name (or `*.ext` glob) and the
88/// operator-facing reason it is exempt from drift counting.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(deny_unknown_fields)]
91pub struct AllowListEntry {
92    /// Artifact name or `*.ext` glob (see
93    /// [`crate::DeterminismState`] for pattern semantics).
94    pub artifact: String,
95    /// Human-readable reason surfaced into the report so consumers can
96    /// audit the rationale alongside the SHA256SUMS file.
97    pub reason: String,
98}
99
100/// One row per emitted artifact.
101///
102/// `deterministic=true` artifacts carry a single `hash`; drifting
103/// artifacts carry the per-run array under `hashes` (and may still have
104/// `nondeterministic_reason` set when allow-listed).
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(deny_unknown_fields)]
107pub struct ArtifactRow {
108    /// Dist-root-relative path of the artifact (forward-slash-normalized,
109    /// `dist/` prefix stripped). Multi-arch artifacts sharing a basename
110    /// (e.g. per-target makeself scratch dirs) get distinct entries here.
111    /// Raw cargo binaries discovered under `<worktree>/.det-tmp/target/`
112    /// instead get a `target/<triple>/release/<bin>` key so they are not
113    /// confused with same-basename `dist/` artifacts.
114    pub name: String,
115    /// Path as seen by the harness — workspace-relative when possible,
116    /// absolute otherwise.
117    pub path: String,
118    /// Size in bytes, taken from the last run that produced the artifact.
119    pub size_bytes: u64,
120    /// Stage name responsible for the artifact (e.g. `archive`, `sbom`).
121    /// Best-effort — the harness infers from output path conventions and
122    /// falls back to `"unknown"` when it cannot attribute.
123    pub stage: String,
124    /// `true` when every run produced an identical SHA256.
125    pub deterministic: bool,
126    /// Set when the artifact is on the allow-list. Drives the
127    /// "allowlist excluded this from drift_count" UX.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub nondeterministic_reason: Option<String>,
130    /// Single hash when the artifact is deterministic; `None` otherwise.
131    /// Mutually exclusive with `hashes`.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub hash: Option<String>,
134    /// Per-run hash array when the artifact drifted (length == runs).
135    /// `skip_serializing_if = "Vec::is_empty"` keeps the JSON compact for
136    /// deterministic rows.
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub hashes: Vec<String>,
139}
140
141/// One drift entry. Mirrors the spec's example shape:
142/// `{ artifact, hashes, differing_bytes_summary? }`.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(deny_unknown_fields)]
145pub struct DriftRow {
146    /// Artifact name (matches the corresponding `ArtifactRow.name`).
147    pub artifact: String,
148    /// Per-run SHA256 hashes that differed.
149    pub hashes: Vec<String>,
150    /// Optional human-readable summary of where the bytes diverge (e.g.
151    /// `"tar entry mtimes differ at offset 0x1234"`). Heuristic; the
152    /// harness emits `None` when it cannot localize the drift.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub differing_bytes_summary: Option<String>,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn sample_report() -> DeterminismReport {
162        DeterminismReport {
163            schema_version: CURRENT_SCHEMA_VERSION,
164            anodizer_version: "0.2.1".into(),
165            commit: "abc123".into(),
166            commit_timestamp: 1_715_000_000,
167            runs: 2,
168            stages_under_test: vec!["archive".into(), "checksum".into()],
169            allowlist: AllowList {
170                compile_time: vec![AllowListEntry {
171                    artifact: "*.flatpak".into(),
172                    reason: "flatpak build-bundle OSTree commit metadata not byte-stable".into(),
173                }],
174                runtime: vec![],
175            },
176            artifacts: vec![
177                ArtifactRow {
178                    name: "anodizer_0.2.1_linux_amd64.tar.gz".into(),
179                    path: "dist/anodizer_0.2.1_linux_amd64.tar.gz".into(),
180                    size_bytes: 5_242_880,
181                    stage: "archive".into(),
182                    deterministic: true,
183                    nondeterministic_reason: None,
184                    hash: Some("sha256:abc".into()),
185                    hashes: vec![],
186                },
187                ArtifactRow {
188                    name: "anodizer_0.2.1_linux_amd64.flatpak".into(),
189                    path: "dist/anodizer_0.2.1_linux_amd64.flatpak".into(),
190                    size_bytes: 1_048_576,
191                    stage: "flatpak".into(),
192                    deterministic: false,
193                    nondeterministic_reason: Some(
194                        "flatpak build-bundle OSTree commit metadata not byte-stable".into(),
195                    ),
196                    hash: None,
197                    hashes: vec!["sha256:a".into(), "sha256:b".into()],
198                },
199            ],
200            drift: vec![],
201            drift_count: 0,
202        }
203    }
204
205    #[test]
206    fn report_roundtrips_through_json() {
207        let r = sample_report();
208        let s = serde_json::to_string(&r).unwrap();
209        let back: DeterminismReport = serde_json::from_str(&s).unwrap();
210        assert_eq!(back, r);
211    }
212
213    #[test]
214    fn schema_version_constant_is_two() {
215        assert_eq!(CURRENT_SCHEMA_VERSION, 2);
216    }
217
218    /// The tool spells its own name on the wire: a report written today
219    /// carries `anodizer_version` and never the old spelling.
220    #[test]
221    fn a_report_written_today_names_the_tool_version_anodizer_version() {
222        let text = serde_json::to_string(&sample_report()).expect("serialize");
223        assert!(
224            text.contains(r#""anodizer_version":"0.2.1""#),
225            "a written report carries the renamed field: {text}"
226        );
227        assert!(
228            !text.contains(r#""anodize_version""#),
229            "the old spelling must not be written: {text}"
230        );
231    }
232
233    /// The rename is a read-compatible one: a `determinism.json` a previous
234    /// release wrote still parses, so a consumer holding an old report is not
235    /// stranded by the schema bump.
236    #[test]
237    fn a_report_written_before_the_rename_still_reads() {
238        let pre_rename = r#"{
239            "schema_version": 1,
240            "anodize_version": "0.25.1",
241            "commit": "abc",
242            "commit_timestamp": 0,
243            "runs": 1,
244            "stages_under_test": [],
245            "allowlist": { "compile_time": [], "runtime": [] },
246            "artifacts": [],
247            "drift": [],
248            "drift_count": 0
249        }"#;
250        let parsed: DeterminismReport =
251            serde_json::from_str(pre_rename).expect("a pre-rename report parses");
252        assert_eq!(parsed.anodizer_version, "0.25.1");
253        assert_eq!(parsed.schema_version, 1);
254    }
255
256    #[test]
257    fn deterministic_row_skips_hashes_array_in_json() {
258        let r = sample_report();
259        let s = serde_json::to_string(&r).unwrap();
260        // First artifact is deterministic — should NOT serialize a
261        // `hashes` array (the array would imply per-run drift).
262        let first = &r.artifacts[0];
263        assert!(first.hashes.is_empty());
264        assert!(
265            !s.contains("\"hashes\":[]"),
266            "deterministic rows must omit empty hashes array, got: {}",
267            s
268        );
269    }
270
271    #[test]
272    fn nondeterministic_row_skips_singular_hash_field_in_json() {
273        let r = sample_report();
274        // Second artifact (nondeterministic) has `hash: None`.
275        let second = &r.artifacts[1];
276        assert!(second.hash.is_none());
277        let s = serde_json::to_string(&r).unwrap();
278        // The `hash` key must not appear with a null value on the second
279        // artifact.
280        let second_segment = s
281            .split("anodizer_0.2.1_linux_amd64.flatpak")
282            .nth(1)
283            .unwrap();
284        assert!(
285            !second_segment.contains("\"hash\":null"),
286            "nondeterministic rows must omit null hash field, got: {}",
287            s
288        );
289    }
290
291    #[test]
292    fn unknown_fields_are_rejected() {
293        let s = r#"{
294            "schema_version": 1,
295            "anodizer_version": "0.2.1",
296            "commit": "abc",
297            "commit_timestamp": 0,
298            "runs": 1,
299            "stages_under_test": [],
300            "allowlist": { "compile_time": [], "runtime": [] },
301            "artifacts": [],
302            "drift": [],
303            "drift_count": 0,
304            "bogus_field": "should reject"
305        }"#;
306        let res: Result<DeterminismReport, _> = serde_json::from_str(s);
307        assert!(
308            res.is_err(),
309            "deny_unknown_fields must reject the bogus_field"
310        );
311    }
312
313    #[test]
314    fn unknown_fields_rejected_on_allowlist_entry() {
315        let s = r#"{
316            "schema_version": 1,
317            "anodizer_version": "0.2.1",
318            "commit": "abc",
319            "commit_timestamp": 0,
320            "runs": 1,
321            "stages_under_test": [],
322            "allowlist": {
323                "compile_time": [
324                    {"artifact": "x", "reason": "y", "extra": "boom"}
325                ],
326                "runtime": []
327            },
328            "artifacts": [],
329            "drift": [],
330            "drift_count": 0
331        }"#;
332        let res: Result<DeterminismReport, _> = serde_json::from_str(s);
333        assert!(res.is_err(), "AllowListEntry must reject unknown fields");
334    }
335
336    #[test]
337    fn drift_row_with_optional_summary_serializes() {
338        let d = DriftRow {
339            artifact: "foo.tar.gz".into(),
340            hashes: vec!["sha256:1".into(), "sha256:2".into()],
341            differing_bytes_summary: Some("tar mtime offset 0x100".into()),
342        };
343        let s = serde_json::to_string(&d).unwrap();
344        assert!(s.contains("differing_bytes_summary"));
345        let back: DriftRow = serde_json::from_str(&s).unwrap();
346        assert_eq!(back, d);
347    }
348
349    #[test]
350    fn drift_row_omits_summary_when_none() {
351        let d = DriftRow {
352            artifact: "foo.tar.gz".into(),
353            hashes: vec!["sha256:1".into(), "sha256:2".into()],
354            differing_bytes_summary: None,
355        };
356        let s = serde_json::to_string(&d).unwrap();
357        assert!(!s.contains("differing_bytes_summary"));
358    }
359}