skill_veil_core/policy/state/
loaders.rs1use crate::policy::baseline::{BaselineFile, WaiverFile};
15use crate::policy::disposition::DispositionOverlay;
16use crate::policy::types::PolicyFile;
17use crate::ports::{FileSystemError, FileSystemProvider};
18use std::path::Path;
19
20use super::validators::{
21 validate_baseline, validate_disposition_overlay, validate_policy, validate_waivers,
22};
23
24#[derive(Debug, thiserror::Error)]
32pub enum PolicyLoadError {
33 #[error("filesystem error: {0}")]
35 Io(#[from] FileSystemError),
36 #[error("file is not valid UTF-8: {0}")]
38 InvalidUtf8(#[from] std::string::FromUtf8Error),
39 #[error("malformed file: {0}")]
41 Parse(String),
42 #[error("validation failed: {0}")]
44 Validation(String),
45}
46
47fn read_text_through_port<F: FileSystemProvider>(
51 fs: &F,
52 path: &Path,
53) -> Result<String, PolicyLoadError> {
54 let bytes = fs.read_file_bytes(path)?;
55 String::from_utf8(bytes.as_bytes().to_vec()).map_err(|e| {
56 PolicyLoadError::Parse(format!(
57 "{}: file contains invalid UTF-8: {}",
58 path.display(),
59 e
60 ))
61 })
62}
63
64fn select_parser_error(
72 content: &str,
73 json_err: serde_json::Error,
74 yaml_err: serde_yaml::Error,
75) -> String {
76 let trimmed = content.trim_start();
77 let looks_like_json = trimmed.starts_with('{') || trimmed.starts_with('[');
78 if looks_like_json {
79 json_err.to_string()
80 } else {
81 yaml_err.to_string()
82 }
83}
84
85fn parse_json_or_yaml<T>(content: &str) -> Result<T, PolicyLoadError>
86where
87 T: serde::de::DeserializeOwned,
88{
89 if content.trim().is_empty() {
97 return Err(PolicyLoadError::Parse(
98 "policy file is empty (whitespace only); refusing to silently apply defaulted fields"
99 .to_string(),
100 ));
101 }
102 match serde_json::from_str::<T>(content) {
103 Ok(value) => Ok(value),
104 Err(json_err) => match serde_yaml::from_str::<T>(content) {
105 Ok(value) => Ok(value),
106 Err(yaml_err) => Err(PolicyLoadError::Parse(select_parser_error(
107 content, json_err, yaml_err,
108 ))),
109 },
110 }
111}
112
113fn load_validated<F, T>(
118 fs: &F,
119 path: &Path,
120 validate: fn(&T) -> Result<(), String>,
121) -> Result<T, PolicyLoadError>
122where
123 F: FileSystemProvider,
124 T: serde::de::DeserializeOwned,
125{
126 let content = read_text_through_port(fs, path)?;
127 let value: T = parse_json_or_yaml(&content)?;
128 validate(&value).map_err(PolicyLoadError::Validation)?;
129 Ok(value)
130}
131
132pub fn load_baseline<F: FileSystemProvider>(
144 fs: &F,
145 path: &Path,
146) -> Result<BaselineFile, PolicyLoadError> {
147 load_validated(fs, path, validate_baseline)
148}
149
150pub fn load_waivers<F: FileSystemProvider>(
162 fs: &F,
163 path: &Path,
164) -> Result<WaiverFile, PolicyLoadError> {
165 load_validated(fs, path, validate_waivers)
166}
167
168pub fn load_policy<F: FileSystemProvider>(
180 fs: &F,
181 path: &Path,
182) -> Result<PolicyFile, PolicyLoadError> {
183 load_validated(fs, path, validate_policy)
184}
185
186pub fn load_disposition_overlay<F: FileSystemProvider>(
196 fs: &F,
197 path: &Path,
198) -> Result<DispositionOverlay, PolicyLoadError> {
199 load_validated(fs, path, validate_disposition_overlay)
200}
201
202#[cfg(test)]
203mod load_waivers_tests {
204 use super::*;
205 use crate::adapters::StdFileSystemProvider;
206 use crate::policy::POLICY_SCHEMA_VERSION;
207 use std::io::Write;
208 use tempfile::NamedTempFile;
209
210 fn write_yaml(content: &str) -> NamedTempFile {
211 let mut file = NamedTempFile::new().expect("create tempfile");
212 file.write_all(content.as_bytes()).expect("write tempfile");
213 file.flush().expect("flush tempfile");
214 file
215 }
216
217 fn fs() -> StdFileSystemProvider {
218 StdFileSystemProvider::new()
219 }
220
221 #[test]
230 fn load_waivers_rejects_invalid_schema_version() {
231 let yaml = "schema_version: bogus/v0\nwaivers: []\n";
232 let file = write_yaml(yaml);
233
234 let err = load_waivers(&fs(), file.path()).expect_err(
235 "waiver file with unknown schema_version MUST fail validation at load time",
236 );
237 assert!(
238 matches!(err, PolicyLoadError::Validation(_)),
239 "schema mismatch must surface as PolicyLoadError::Validation; got: {err:?}"
240 );
241 let msg = err.to_string();
242 assert!(
243 msg.contains("schema_version") || msg.contains("Unsupported"),
244 "error must explain schema mismatch; got: {msg}"
245 );
246 }
247
248 #[test]
256 fn load_waivers_rejects_waiver_without_selectors() {
257 let yaml = format!(
258 "schema_version: {POLICY_SCHEMA_VERSION}\nwaivers:\n - reason: 'no selectors at all'\n",
259 );
260 let file = write_yaml(&yaml);
261
262 let err = load_waivers(&fs(), file.path())
263 .expect_err("waiver entry with no rule_id/artifact_path/context MUST fail validation");
264 assert!(
265 matches!(err, PolicyLoadError::Validation(_)),
266 "missing-selector failure must surface as PolicyLoadError::Validation; got: {err:?}"
267 );
268 assert!(
269 err.to_string().contains("selector"),
270 "error must mention the missing selector requirement; got: {err}"
271 );
272 }
273
274 #[test]
280 fn load_waivers_accepts_well_formed_file() {
281 let yaml = format!(
282 "schema_version: {POLICY_SCHEMA_VERSION}\nwaivers:\n - rule_id: RULE_A\n reason: 'known false positive on this rule'\n",
283 );
284 let file = write_yaml(&yaml);
285
286 let loaded = load_waivers(&fs(), file.path()).expect("well-formed waiver file must load");
287 assert_eq!(loaded.waivers.len(), 1);
288 assert_eq!(loaded.waivers[0].rule_id.as_deref(), Some("RULE_A"));
289 }
290
291 #[test]
302 fn load_waivers_rejects_empty_or_whitespace_file() {
303 for blank in ["", " ", "\n\n\t\n \n"] {
304 let file = write_yaml(blank);
305 let err = load_waivers(&fs(), file.path()).expect_err(
306 "empty/whitespace policy file MUST fail at load time, not silently default",
307 );
308 assert!(
309 matches!(err, PolicyLoadError::Parse(_)),
310 "must surface as Parse error; got {err:?}"
311 );
312 assert!(
313 err.to_string().contains("empty"),
314 "error must mention emptiness; got {err}"
315 );
316 }
317 }
318}
319
320#[cfg(test)]
321mod load_baseline_tests {
322 use super::*;
323 use crate::adapters::StdFileSystemProvider;
324 use crate::policy::POLICY_SCHEMA_VERSION;
325 use std::io::Write;
326 use tempfile::NamedTempFile;
327
328 fn write_yaml(content: &str) -> NamedTempFile {
329 let mut file = NamedTempFile::new().expect("create tempfile");
330 file.write_all(content.as_bytes()).expect("write tempfile");
331 file.flush().expect("flush tempfile");
332 file
333 }
334
335 fn fs() -> StdFileSystemProvider {
336 StdFileSystemProvider::new()
337 }
338
339 #[test]
348 fn load_baseline_rejects_invalid_schema_version() {
349 let yaml = "schema_version: bogus/v0\nentries: []\n";
350 let file = write_yaml(yaml);
351
352 let err = load_baseline(&fs(), file.path()).expect_err(
353 "baseline file with unknown schema_version MUST fail validation at load time",
354 );
355 assert!(
356 matches!(err, PolicyLoadError::Validation(_)),
357 "schema mismatch must surface as PolicyLoadError::Validation; got: {err:?}"
358 );
359 let msg = err.to_string();
360 assert!(
361 msg.contains("schema_version") || msg.contains("Unsupported"),
362 "error must explain schema mismatch; got: {msg}"
363 );
364 }
365
366 #[test]
372 fn load_baseline_rejects_entry_with_empty_fingerprint() {
373 let yaml = format!(
374 "schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: ''\n rule_id: RULE_A\n reason: 'whatever'\n",
375 );
376 let file = write_yaml(&yaml);
377
378 let err = load_baseline(&fs(), file.path())
379 .expect_err("baseline entry with empty fingerprint MUST fail validation");
380 assert!(
381 matches!(err, PolicyLoadError::Validation(_)),
382 "empty-fingerprint rejection must surface as PolicyLoadError::Validation; got: {err:?}"
383 );
384 assert!(
385 err.to_string().contains("fingerprint"),
386 "error must mention the empty fingerprint; got: {err}"
387 );
388 }
389
390 #[test]
396 fn load_baseline_rejects_entry_with_empty_reason() {
397 let yaml = format!(
398 "schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: 'abc123'\n rule_id: RULE_A\n reason: ' '\n",
399 );
400 let file = write_yaml(&yaml);
401
402 let err = load_baseline(&fs(), file.path())
403 .expect_err("baseline entry with empty reason MUST fail validation");
404 assert!(
405 matches!(err, PolicyLoadError::Validation(_)),
406 "empty-reason rejection must surface as PolicyLoadError::Validation; got: {err:?}"
407 );
408 assert!(
409 err.to_string().contains("reason"),
410 "error must mention the empty reason; got: {err}"
411 );
412 }
413
414 #[test]
419 fn load_baseline_accepts_well_formed_file() {
420 let yaml = format!(
421 "schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: 'sha256:abc'\n rule_id: RULE_A\n reason: 'documented exception'\n",
422 );
423 let file = write_yaml(&yaml);
424
425 let loaded =
426 load_baseline(&fs(), file.path()).expect("well-formed baseline file must load");
427 assert_eq!(loaded.entries.len(), 1);
428 assert_eq!(loaded.entries[0].rule_id, "RULE_A");
429 assert_eq!(loaded.entries[0].fingerprint, "sha256:abc");
430 }
431}
432
433#[cfg(test)]
434mod parser_error_selection_tests {
435 use super::*;
436
437 #[test]
445 fn parse_error_for_json_shaped_content_surfaces_json_diagnostic() {
446 let bad_json = "{\"key\": \"value\" \"oops\"}";
451 let err: PolicyLoadError = parse_json_or_yaml::<serde_json::Value>(bad_json)
452 .expect_err("invalid JSON-shaped content must fail to parse");
453 let msg = match err {
454 PolicyLoadError::Parse(s) => s,
455 other => panic!("expected Parse error, got {other:?}"),
456 };
457 assert!(
463 msg.contains("expected `,` or `}`") || msg.contains("expected value"),
464 "JSON-shaped content must surface JSON diagnostic, not YAML; got: {msg}"
465 );
466 }
467
468 #[test]
472 fn parse_error_for_yaml_shaped_content_surfaces_yaml_diagnostic() {
473 let bad_yaml = "key: value\n bad: : indent\n";
475 let err: PolicyLoadError = parse_json_or_yaml::<serde_yaml::Value>(bad_yaml)
476 .expect_err("invalid YAML-shaped content must fail to parse");
477 assert!(
478 matches!(err, PolicyLoadError::Parse(_)),
479 "expected Parse error; got {err:?}"
480 );
481 }
482
483 #[test]
488 fn parse_error_for_indented_json_still_reports_json() {
489 let bad_json = " \n{\"oops\": \"missing-close\"\n";
490 let err: PolicyLoadError = parse_json_or_yaml::<serde_json::Value>(bad_json)
491 .expect_err("invalid leading-whitespace JSON must fail");
492 let msg = match err {
493 PolicyLoadError::Parse(s) => s,
494 other => panic!("expected Parse error, got {other:?}"),
495 };
496 assert!(
497 !msg.contains("mapping values are not allowed"),
498 "leading-whitespace JSON must NOT surface YAML's mapping error; got: {msg}"
499 );
500 }
501}
502
503#[cfg(test)]
504mod load_disposition_tests {
505 use super::*;
506 use crate::adapters::StdFileSystemProvider;
507 use std::io::Write;
508 use tempfile::NamedTempFile;
509
510 #[test]
514 fn load_disposition_overlay_reads_json_through_port() {
515 let mut file = NamedTempFile::new().expect("tempfile");
516 file.write_all(
517 br#"{"records":[{"finding_fingerprint":"fp1","rule_id":"R1","analyst_disposition":"false_positive","recorded_at":"2026-01-01T00:00:00Z"}]}"#,
518 )
519 .expect("write");
520 file.flush().expect("flush");
521 let fs = StdFileSystemProvider::new();
522 let overlay = load_disposition_overlay(&fs, file.path()).expect("load");
523 assert_eq!(overlay.records.len(), 1);
524 assert_eq!(overlay.records[0].rule_id, "R1");
525 }
526
527 #[test]
531 fn load_disposition_overlay_rejects_unknown_fields() {
532 let mut file = NamedTempFile::new().expect("tempfile");
533 file.write_all(br#"{"records":[],"bogus":true}"#)
534 .expect("write");
535 file.flush().expect("flush");
536 let fs = StdFileSystemProvider::new();
537 assert!(load_disposition_overlay(&fs, file.path()).is_err());
538 }
539}