Skip to main content

skill_veil_core/policy/
baseline.rs

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