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