1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
use super::default_policy_schema_version;
use crate::findings::OperationalContext;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BaselineFile {
#[serde(default = "default_policy_schema_version")]
pub schema_version: String,
/// `#[serde(default)]` keeps load-compat with files that omit the
/// `entries:` key entirely (an empty baseline). Mirrors
/// `PolicyFile.overrides` and the engineering standard in
/// `CLAUDE.md` § "When a fix touches a public type, prefer adding
/// optional fields with serde defaults rather than breaking on-disk
/// caches."
#[serde(default)]
pub entries: Vec<BaselineEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BaselineEntry {
pub fingerprint: String,
pub rule_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_path: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WaiverFile {
#[serde(default = "default_policy_schema_version")]
pub schema_version: String,
/// `#[serde(default)]` keeps load-compat with files that omit the
/// `waivers:` key entirely (an empty waiver file). Mirrors
/// `PolicyFile.overrides`.
#[serde(default)]
pub waivers: Vec<WaiverEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WaiverEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<OperationalContext>,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod serde_default_tests {
use super::*;
use crate::policy::POLICY_SCHEMA_VERSION;
/// # Contract
///
/// `BaselineFile` MUST deserialize successfully from a YAML body
/// that omits the `entries:` key, treating it as an empty baseline.
/// Pre-fix `entries` was a `Vec<BaselineEntry>` without
/// `#[serde(default)]`, so a baseline file written as just
/// `schema_version: ...` (e.g. an emptied baseline kept for audit
/// continuity) failed to load with a missing-field error. Mirrors
/// the engineering standard in `CLAUDE.md` § "prefer optional fields
/// with serde defaults rather than breaking on-disk caches".
#[test]
fn baseline_file_deserializes_without_entries_key() {
let yaml = format!("schema_version: {POLICY_SCHEMA_VERSION}\n");
let parsed: BaselineFile =
serde_yaml::from_str(&yaml).expect("BaselineFile MUST load when `entries:` is omitted");
assert!(
parsed.entries.is_empty(),
"missing `entries:` key MUST default to an empty Vec"
);
assert_eq!(parsed.schema_version, POLICY_SCHEMA_VERSION);
}
/// # Contract
///
/// `WaiverFile` MUST deserialize successfully from a YAML body that
/// omits the `waivers:` key. Same rationale as
/// `baseline_file_deserializes_without_entries_key`. Pinned because
/// `validate_waivers` runs after deserialization and would never see
/// the file otherwise — so silent regression here would surface as
/// a confusing parse error to users emptying their waivers list.
#[test]
fn waiver_file_deserializes_without_waivers_key() {
let yaml = format!("schema_version: {POLICY_SCHEMA_VERSION}\n");
let parsed: WaiverFile =
serde_yaml::from_str(&yaml).expect("WaiverFile MUST load when `waivers:` is omitted");
assert!(
parsed.waivers.is_empty(),
"missing `waivers:` key MUST default to an empty Vec"
);
assert_eq!(parsed.schema_version, POLICY_SCHEMA_VERSION);
}
/// # Contract (positive)
///
/// A well-formed baseline file with explicit `entries:` still loads
/// without regression. Guards against a future refactor that would
/// mistakenly enable `#[serde(default)]` AND `#[serde(skip)]`
/// together (which would silently drop user-authored entries).
#[test]
fn baseline_file_still_loads_explicit_entries() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\n\
entries:\n \
- fingerprint: deadbeef\n \
rule_id: RULE_A\n \
reason: pre-existing\n"
);
let parsed: BaselineFile = serde_yaml::from_str(&yaml).expect("explicit entries must load");
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].fingerprint, "deadbeef");
assert_eq!(parsed.entries[0].rule_id, "RULE_A");
}
/// # Contract
///
/// `BaselineEntry` MUST reject unknown fields so that typos like
/// `fingerprintt` instead of `fingerprint` produce a clear error.
/// Pre-fix, `#[serde(deny_unknown_fields)]` was absent, so a typo
/// silently created a malformed entry with the wrong field missing.
#[test]
fn baseline_entry_rejects_unknown_fields() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\n\
entries:\n \
- fingerprintt: deadbeef\n \
rule_id: RULE_A\n \
reason: pre-existing\n"
);
let result: Result<BaselineFile, _> = serde_yaml::from_str(&yaml);
assert!(
result.is_err(),
"BaselineEntry MUST reject unknown field 'fingerprintt'; \
pre-fix, this was silently accepted and fingerprint was missing"
);
}
/// # Contract
///
/// `WaiverEntry` MUST reject unknown fields so that typos like
/// `rule_ld` instead of `rule_id` produce a clear error rather than
/// silently defaulting `rule_id` to `None`, which could make a waiver
/// match all rules (a policy bypass).
#[test]
fn waiver_entry_rejects_unknown_fields() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\n\
waivers:\n \
- rule_ld: RULE_A\n \
reason: approved exception\n"
);
let result: Result<WaiverFile, _> = serde_yaml::from_str(&yaml);
assert!(
result.is_err(),
"WaiverEntry MUST reject unknown field 'rule_ld'; \
pre-fix, this was silently accepted and rule_id defaulted to None"
);
}
/// # Contract
///
/// `BaselineFile` MUST reject unknown top-level fields so that typos
/// like `entires` instead of `entries` are caught at load time.
#[test]
fn baseline_file_rejects_unknown_top_level_fields() {
let yaml = format!("schema_version: {POLICY_SCHEMA_VERSION}\nentires: []\n");
let result: Result<BaselineFile, _> = serde_yaml::from_str(&yaml);
assert!(
result.is_err(),
"BaselineFile MUST reject unknown field 'entires'; \
pre-fix, this was silently accepted and entries defaulted to empty"
);
}
/// # Contract
///
/// `WaiverFile` MUST reject unknown top-level fields so that typos
/// like `wavers` instead of `waivers` are caught at load time.
#[test]
fn waiver_file_rejects_unknown_top_level_fields() {
let yaml = format!("schema_version: {POLICY_SCHEMA_VERSION}\nwavers: []\n");
let result: Result<WaiverFile, _> = serde_yaml::from_str(&yaml);
assert!(
result.is_err(),
"WaiverFile MUST reject unknown field 'wavers'; \
pre-fix, this was silently accepted and waivers defaulted to empty"
);
}
}