use serde::{Deserialize, Serialize};
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DeterminismReport {
pub schema_version: u32,
pub anodize_version: String,
pub commit: String,
pub commit_timestamp: i64,
pub runs: u32,
pub stages_under_test: Vec<String>,
pub allowlist: AllowList,
pub artifacts: Vec<ArtifactRow>,
pub drift: Vec<DriftRow>,
pub drift_count: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct AllowList {
pub compile_time: Vec<AllowListEntry>,
pub runtime: Vec<AllowListEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AllowListEntry {
pub artifact: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ArtifactRow {
pub name: String,
pub path: String,
pub size_bytes: u64,
pub stage: String,
pub deterministic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub nondeterministic_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hashes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DriftRow {
pub artifact: String,
pub hashes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub differing_bytes_summary: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_report() -> DeterminismReport {
DeterminismReport {
schema_version: CURRENT_SCHEMA_VERSION,
anodize_version: "0.2.1".into(),
commit: "abc123".into(),
commit_timestamp: 1_715_000_000,
runs: 2,
stages_under_test: vec!["archive".into(), "checksum".into()],
allowlist: AllowList {
compile_time: vec![AllowListEntry {
artifact: "anodizer-0.2.1.crate".into(),
reason: "cargo package non-determinism".into(),
}],
runtime: vec![],
},
artifacts: vec![
ArtifactRow {
name: "anodizer_0.2.1_linux_amd64.tar.gz".into(),
path: "dist/anodizer_0.2.1_linux_amd64.tar.gz".into(),
size_bytes: 5_242_880,
stage: "archive".into(),
deterministic: true,
nondeterministic_reason: None,
hash: Some("sha256:abc".into()),
hashes: vec![],
},
ArtifactRow {
name: "anodizer-0.2.1.crate".into(),
path: "dist/anodizer-0.2.1.crate".into(),
size_bytes: 1_048_576,
stage: "cargo-package".into(),
deterministic: false,
nondeterministic_reason: Some("cargo package non-determinism".into()),
hash: None,
hashes: vec!["sha256:a".into(), "sha256:b".into()],
},
],
drift: vec![],
drift_count: 0,
}
}
#[test]
fn report_roundtrips_through_json() {
let r = sample_report();
let s = serde_json::to_string(&r).unwrap();
let back: DeterminismReport = serde_json::from_str(&s).unwrap();
assert_eq!(back, r);
}
#[test]
fn schema_version_constant_is_one() {
assert_eq!(CURRENT_SCHEMA_VERSION, 1);
}
#[test]
fn deterministic_row_skips_hashes_array_in_json() {
let r = sample_report();
let s = serde_json::to_string(&r).unwrap();
let first = &r.artifacts[0];
assert!(first.hashes.is_empty());
assert!(
!s.contains("\"hashes\":[]"),
"deterministic rows must omit empty hashes array, got: {}",
s
);
}
#[test]
fn nondeterministic_row_skips_singular_hash_field_in_json() {
let r = sample_report();
let second = &r.artifacts[1];
assert!(second.hash.is_none());
let s = serde_json::to_string(&r).unwrap();
let second_segment = s.split("anodizer-0.2.1.crate").nth(1).unwrap();
assert!(
!second_segment.contains("\"hash\":null"),
"nondeterministic rows must omit null hash field, got: {}",
s
);
}
#[test]
fn unknown_fields_are_rejected() {
let s = r#"{
"schema_version": 1,
"anodize_version": "0.2.1",
"commit": "abc",
"commit_timestamp": 0,
"runs": 1,
"stages_under_test": [],
"allowlist": { "compile_time": [], "runtime": [] },
"artifacts": [],
"drift": [],
"drift_count": 0,
"bogus_field": "should reject"
}"#;
let res: Result<DeterminismReport, _> = serde_json::from_str(s);
assert!(
res.is_err(),
"deny_unknown_fields must reject the bogus_field"
);
}
#[test]
fn unknown_fields_rejected_on_allowlist_entry() {
let s = r#"{
"schema_version": 1,
"anodize_version": "0.2.1",
"commit": "abc",
"commit_timestamp": 0,
"runs": 1,
"stages_under_test": [],
"allowlist": {
"compile_time": [
{"artifact": "x", "reason": "y", "extra": "boom"}
],
"runtime": []
},
"artifacts": [],
"drift": [],
"drift_count": 0
}"#;
let res: Result<DeterminismReport, _> = serde_json::from_str(s);
assert!(res.is_err(), "AllowListEntry must reject unknown fields");
}
#[test]
fn drift_row_with_optional_summary_serializes() {
let d = DriftRow {
artifact: "foo.tar.gz".into(),
hashes: vec!["sha256:1".into(), "sha256:2".into()],
differing_bytes_summary: Some("tar mtime offset 0x100".into()),
};
let s = serde_json::to_string(&d).unwrap();
assert!(s.contains("differing_bytes_summary"));
let back: DriftRow = serde_json::from_str(&s).unwrap();
assert_eq!(back, d);
}
#[test]
fn drift_row_omits_summary_when_none() {
let d = DriftRow {
artifact: "foo.tar.gz".into(),
hashes: vec!["sha256:1".into(), "sha256:2".into()],
differing_bytes_summary: None,
};
let s = serde_json::to_string(&d).unwrap();
assert!(!s.contains("differing_bytes_summary"));
}
}