use crate::policy::baseline::{BaselineFile, WaiverFile};
use crate::policy::disposition::DispositionOverlay;
use crate::policy::types::PolicyFile;
use crate::ports::{FileSystemError, FileSystemProvider};
use std::path::Path;
use super::validators::{
validate_baseline, validate_disposition_overlay, validate_policy, validate_waivers,
};
#[derive(Debug, thiserror::Error)]
pub enum PolicyLoadError {
#[error("filesystem error: {0}")]
Io(#[from] FileSystemError),
#[error("file is not valid UTF-8: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("malformed file: {0}")]
Parse(String),
#[error("validation failed: {0}")]
Validation(String),
}
fn read_text_through_port<F: FileSystemProvider>(
fs: &F,
path: &Path,
) -> Result<String, PolicyLoadError> {
let bytes = fs.read_file_bytes(path)?;
String::from_utf8(bytes.as_bytes().to_vec()).map_err(|e| {
PolicyLoadError::Parse(format!(
"{}: file contains invalid UTF-8: {}",
path.display(),
e
))
})
}
fn select_parser_error(
content: &str,
json_err: serde_json::Error,
yaml_err: serde_yaml::Error,
) -> String {
let trimmed = content.trim_start();
let looks_like_json = trimmed.starts_with('{') || trimmed.starts_with('[');
if looks_like_json {
json_err.to_string()
} else {
yaml_err.to_string()
}
}
fn parse_json_or_yaml<T>(content: &str) -> Result<T, PolicyLoadError>
where
T: serde::de::DeserializeOwned,
{
if content.trim().is_empty() {
return Err(PolicyLoadError::Parse(
"policy file is empty (whitespace only); refusing to silently apply defaulted fields"
.to_string(),
));
}
match serde_json::from_str::<T>(content) {
Ok(value) => Ok(value),
Err(json_err) => match serde_yaml::from_str::<T>(content) {
Ok(value) => Ok(value),
Err(yaml_err) => Err(PolicyLoadError::Parse(select_parser_error(
content, json_err, yaml_err,
))),
},
}
}
fn load_validated<F, T>(
fs: &F,
path: &Path,
validate: fn(&T) -> Result<(), String>,
) -> Result<T, PolicyLoadError>
where
F: FileSystemProvider,
T: serde::de::DeserializeOwned,
{
let content = read_text_through_port(fs, path)?;
let value: T = parse_json_or_yaml(&content)?;
validate(&value).map_err(PolicyLoadError::Validation)?;
Ok(value)
}
pub fn load_baseline<F: FileSystemProvider>(
fs: &F,
path: &Path,
) -> Result<BaselineFile, PolicyLoadError> {
load_validated(fs, path, validate_baseline)
}
pub fn load_waivers<F: FileSystemProvider>(
fs: &F,
path: &Path,
) -> Result<WaiverFile, PolicyLoadError> {
load_validated(fs, path, validate_waivers)
}
pub fn load_policy<F: FileSystemProvider>(
fs: &F,
path: &Path,
) -> Result<PolicyFile, PolicyLoadError> {
load_validated(fs, path, validate_policy)
}
pub fn load_disposition_overlay<F: FileSystemProvider>(
fs: &F,
path: &Path,
) -> Result<DispositionOverlay, PolicyLoadError> {
load_validated(fs, path, validate_disposition_overlay)
}
#[cfg(test)]
mod load_waivers_tests {
use super::*;
use crate::adapters::StdFileSystemProvider;
use crate::policy::POLICY_SCHEMA_VERSION;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_yaml(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("create tempfile");
file.write_all(content.as_bytes()).expect("write tempfile");
file.flush().expect("flush tempfile");
file
}
fn fs() -> StdFileSystemProvider {
StdFileSystemProvider::new()
}
#[test]
fn load_waivers_rejects_invalid_schema_version() {
let yaml = "schema_version: bogus/v0\nwaivers: []\n";
let file = write_yaml(yaml);
let err = load_waivers(&fs(), file.path()).expect_err(
"waiver file with unknown schema_version MUST fail validation at load time",
);
assert!(
matches!(err, PolicyLoadError::Validation(_)),
"schema mismatch must surface as PolicyLoadError::Validation; got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("schema_version") || msg.contains("Unsupported"),
"error must explain schema mismatch; got: {msg}"
);
}
#[test]
fn load_waivers_rejects_waiver_without_selectors() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\nwaivers:\n - reason: 'no selectors at all'\n",
);
let file = write_yaml(&yaml);
let err = load_waivers(&fs(), file.path())
.expect_err("waiver entry with no rule_id/artifact_path/context MUST fail validation");
assert!(
matches!(err, PolicyLoadError::Validation(_)),
"missing-selector failure must surface as PolicyLoadError::Validation; got: {err:?}"
);
assert!(
err.to_string().contains("selector"),
"error must mention the missing selector requirement; got: {err}"
);
}
#[test]
fn load_waivers_accepts_well_formed_file() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\nwaivers:\n - rule_id: RULE_A\n reason: 'known false positive on this rule'\n",
);
let file = write_yaml(&yaml);
let loaded = load_waivers(&fs(), file.path()).expect("well-formed waiver file must load");
assert_eq!(loaded.waivers.len(), 1);
assert_eq!(loaded.waivers[0].rule_id.as_deref(), Some("RULE_A"));
}
#[test]
fn load_waivers_rejects_empty_or_whitespace_file() {
for blank in ["", " ", "\n\n\t\n \n"] {
let file = write_yaml(blank);
let err = load_waivers(&fs(), file.path()).expect_err(
"empty/whitespace policy file MUST fail at load time, not silently default",
);
assert!(
matches!(err, PolicyLoadError::Parse(_)),
"must surface as Parse error; got {err:?}"
);
assert!(
err.to_string().contains("empty"),
"error must mention emptiness; got {err}"
);
}
}
}
#[cfg(test)]
mod load_baseline_tests {
use super::*;
use crate::adapters::StdFileSystemProvider;
use crate::policy::POLICY_SCHEMA_VERSION;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_yaml(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("create tempfile");
file.write_all(content.as_bytes()).expect("write tempfile");
file.flush().expect("flush tempfile");
file
}
fn fs() -> StdFileSystemProvider {
StdFileSystemProvider::new()
}
#[test]
fn load_baseline_rejects_invalid_schema_version() {
let yaml = "schema_version: bogus/v0\nentries: []\n";
let file = write_yaml(yaml);
let err = load_baseline(&fs(), file.path()).expect_err(
"baseline file with unknown schema_version MUST fail validation at load time",
);
assert!(
matches!(err, PolicyLoadError::Validation(_)),
"schema mismatch must surface as PolicyLoadError::Validation; got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("schema_version") || msg.contains("Unsupported"),
"error must explain schema mismatch; got: {msg}"
);
}
#[test]
fn load_baseline_rejects_entry_with_empty_fingerprint() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: ''\n rule_id: RULE_A\n reason: 'whatever'\n",
);
let file = write_yaml(&yaml);
let err = load_baseline(&fs(), file.path())
.expect_err("baseline entry with empty fingerprint MUST fail validation");
assert!(
matches!(err, PolicyLoadError::Validation(_)),
"empty-fingerprint rejection must surface as PolicyLoadError::Validation; got: {err:?}"
);
assert!(
err.to_string().contains("fingerprint"),
"error must mention the empty fingerprint; got: {err}"
);
}
#[test]
fn load_baseline_rejects_entry_with_empty_reason() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: 'abc123'\n rule_id: RULE_A\n reason: ' '\n",
);
let file = write_yaml(&yaml);
let err = load_baseline(&fs(), file.path())
.expect_err("baseline entry with empty reason MUST fail validation");
assert!(
matches!(err, PolicyLoadError::Validation(_)),
"empty-reason rejection must surface as PolicyLoadError::Validation; got: {err:?}"
);
assert!(
err.to_string().contains("reason"),
"error must mention the empty reason; got: {err}"
);
}
#[test]
fn load_baseline_accepts_well_formed_file() {
let yaml = format!(
"schema_version: {POLICY_SCHEMA_VERSION}\nentries:\n - fingerprint: 'sha256:abc'\n rule_id: RULE_A\n reason: 'documented exception'\n",
);
let file = write_yaml(&yaml);
let loaded =
load_baseline(&fs(), file.path()).expect("well-formed baseline file must load");
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].rule_id, "RULE_A");
assert_eq!(loaded.entries[0].fingerprint, "sha256:abc");
}
}
#[cfg(test)]
mod parser_error_selection_tests {
use super::*;
#[test]
fn parse_error_for_json_shaped_content_surfaces_json_diagnostic() {
let bad_json = "{\"key\": \"value\" \"oops\"}";
let err: PolicyLoadError = parse_json_or_yaml::<serde_json::Value>(bad_json)
.expect_err("invalid JSON-shaped content must fail to parse");
let msg = match err {
PolicyLoadError::Parse(s) => s,
other => panic!("expected Parse error, got {other:?}"),
};
assert!(
msg.contains("expected `,` or `}`") || msg.contains("expected value"),
"JSON-shaped content must surface JSON diagnostic, not YAML; got: {msg}"
);
}
#[test]
fn parse_error_for_yaml_shaped_content_surfaces_yaml_diagnostic() {
let bad_yaml = "key: value\n bad: : indent\n";
let err: PolicyLoadError = parse_json_or_yaml::<serde_yaml::Value>(bad_yaml)
.expect_err("invalid YAML-shaped content must fail to parse");
assert!(
matches!(err, PolicyLoadError::Parse(_)),
"expected Parse error; got {err:?}"
);
}
#[test]
fn parse_error_for_indented_json_still_reports_json() {
let bad_json = " \n{\"oops\": \"missing-close\"\n";
let err: PolicyLoadError = parse_json_or_yaml::<serde_json::Value>(bad_json)
.expect_err("invalid leading-whitespace JSON must fail");
let msg = match err {
PolicyLoadError::Parse(s) => s,
other => panic!("expected Parse error, got {other:?}"),
};
assert!(
!msg.contains("mapping values are not allowed"),
"leading-whitespace JSON must NOT surface YAML's mapping error; got: {msg}"
);
}
}
#[cfg(test)]
mod load_disposition_tests {
use super::*;
use crate::adapters::StdFileSystemProvider;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn load_disposition_overlay_reads_json_through_port() {
let mut file = NamedTempFile::new().expect("tempfile");
file.write_all(
br#"{"records":[{"finding_fingerprint":"fp1","rule_id":"R1","analyst_disposition":"false_positive","recorded_at":"2026-01-01T00:00:00Z"}]}"#,
)
.expect("write");
file.flush().expect("flush");
let fs = StdFileSystemProvider::new();
let overlay = load_disposition_overlay(&fs, file.path()).expect("load");
assert_eq!(overlay.records.len(), 1);
assert_eq!(overlay.records[0].rule_id, "R1");
}
#[test]
fn load_disposition_overlay_rejects_unknown_fields() {
let mut file = NamedTempFile::new().expect("tempfile");
file.write_all(br#"{"records":[],"bogus":true}"#)
.expect("write");
file.flush().expect("flush");
let fs = StdFileSystemProvider::new();
assert!(load_disposition_overlay(&fs, file.path()).is_err());
}
}