use crate::deviation::{
Deviation, DeviationAction, DeviationAddError, DeviationScope, DeviationStore,
};
use crate::Severity;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
NotArray,
RiskNotObject {
index: usize,
},
InvalidStatus {
index: usize,
found: String,
},
MissingDescription {
index: usize,
},
SubjectsNotArray {
index: usize,
},
MissingSubject {
index: usize,
},
SubjectNotObject {
index: usize,
},
SubjectMissingType {
index: usize,
},
UnknownSubjectType {
index: usize,
found: String,
},
MissingProp {
index: usize,
name: &'static str,
},
MissingSubjectProp {
index: usize,
name: &'static str,
},
EmptyProp {
index: usize,
name: &'static str,
},
UnknownAction {
index: usize,
found: String,
},
MalformedHex {
index: usize,
prop: &'static str,
},
MalformedDer {
index: usize,
prop: &'static str,
},
InvalidU64 {
index: usize,
prop: &'static str,
found: String,
},
InvalidI32 {
index: usize,
prop: &'static str,
found: String,
},
AddFailed {
index: usize,
source: DeviationAddError,
},
MissingOscalVersion,
UnsupportedOscalVersion {
found: String,
},
CatalogNotObject,
CatalogMissingWrapper,
ControlsNotArray,
ControlNotObject {
index: usize,
},
ControlMissingId {
index: usize,
},
ControlIdNotString {
index: usize,
},
ControlIdEmpty {
index: usize,
},
UnknownLintId {
id: String,
},
ProfileNotObject,
ProfileMissingWrapper,
ProfileImportsNotArray,
ProfileImportNotObject {
index: usize,
},
ProfileImportMissingHref {
index: usize,
},
ProfileImportHrefNotString {
index: usize,
},
ProfileImportHrefEmpty {
index: usize,
},
ProfileImportUnresolved {
index: usize,
href: String,
},
ProfileImportSourceUnknown {
index: usize,
href: String,
},
ProfileImportCycle {
href: String,
},
ProfileIncludeControlsNotArray {
index: usize,
},
ProfileExcludeControlsNotArray {
index: usize,
},
ProfileWithIdsEntryNotObject {
index: usize,
entry_index: usize,
},
ProfileWithIdsNotArray {
index: usize,
entry_index: usize,
},
ProfileWithIdNotString {
index: usize,
entry_index: usize,
},
ProfileSetParametersNotArray,
ProfileSetParameterNotObject {
entry_index: usize,
},
ProfileSetParameterMissingId {
entry_index: usize,
},
ProfileSetParameterIdEmpty {
entry_index: usize,
},
ProfileSetParameterValuesNotArray {
entry_index: usize,
},
ProfileSetParameterValuesEmpty {
entry_index: usize,
},
ProfileSetParameterValueNotString {
entry_index: usize,
},
UnknownParameterOverride {
param_id: String,
},
InvalidParameterOverride {
param_id: String,
source: crate::ParameterError,
},
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotArray => write!(f, "top-level OSCAL value is not a JSON array"),
Self::RiskNotObject { index } => {
write!(f, "Risk at index {index} is not a JSON object")
}
Self::InvalidStatus { index, found } => write!(
f,
"Risk at index {index} has status '{found}', expected 'deviation-approved'"
),
Self::MissingDescription { index } => {
write!(
f,
"Risk at index {index} has no description (justification)"
)
}
Self::SubjectsNotArray { index } => {
write!(f, "Risk at index {index} has no subjects array")
}
Self::MissingSubject { index } => {
write!(f, "Risk at index {index} has an empty subjects array")
}
Self::SubjectNotObject { index } => {
write!(
f,
"Risk at index {index} has a subject entry that is not a JSON object"
)
}
Self::SubjectMissingType { index } => {
write!(
f,
"Risk at index {index} has a subject with no 'type' discriminator"
)
}
Self::UnknownSubjectType { index, found } => write!(
f,
"Risk at index {index} has unknown subject type '{found}'"
),
Self::MissingProp { index, name } => {
write!(f, "Risk at index {index} is missing required prop '{name}'")
}
Self::MissingSubjectProp { index, name } => write!(
f,
"Risk at index {index} subject is missing required prop '{name}'"
),
Self::EmptyProp { index, name } => {
write!(
f,
"Risk at index {index} has empty value for required prop '{name}'"
)
}
Self::UnknownAction { index, found } => write!(
f,
"Risk at index {index} has unrecognized action value '{found}'"
),
Self::MalformedHex { index, prop } => {
write!(f, "Risk at index {index} prop '{prop}' is not valid hex")
}
Self::MalformedDer { index, prop } => write!(
f,
"Risk at index {index} prop '{prop}' is empty or not a valid DER-encoded Name"
),
Self::InvalidU64 { index, prop, found } => write!(
f,
"Risk at index {index} prop '{prop}' is not a decimal u64: '{found}'"
),
Self::InvalidI32 { index, prop, found } => write!(
f,
"Risk at index {index} prop '{prop}' is not a decimal i32: '{found}'"
),
Self::AddFailed { index, source } => write!(
f,
"Risk at index {index} could not be added to the store: {source}"
),
Self::MissingOscalVersion => write!(
f,
"OSCAL document is missing metadata.oscal-version"
),
Self::UnsupportedOscalVersion { found } => write!(
f,
"unsupported OSCAL version '{found}'; pkix-lint accepts {:?}",
crate::oscal::SUPPORTED_OSCAL_VERSIONS
),
Self::CatalogNotObject => {
write!(f, "top-level OSCAL Catalog value is not a JSON object")
}
Self::CatalogMissingWrapper => {
write!(f, "OSCAL Catalog value is missing the 'catalog' wrapper")
}
Self::ControlsNotArray => {
write!(f, "catalog.controls is missing or not a JSON array")
}
Self::ControlNotObject { index } => {
write!(f, "Control at index {index} is not a JSON object")
}
Self::ControlMissingId { index } => {
write!(f, "Control at index {index} is missing required 'id' field")
}
Self::ControlIdNotString { index } => {
write!(f, "Control at index {index} 'id' is not a JSON string")
}
Self::ControlIdEmpty { index } => {
write!(f, "Control at index {index} 'id' is an empty string")
}
Self::UnknownLintId { id } => write!(
f,
"Catalog Control id '{id}' has no matching registered Lint"
),
Self::ProfileNotObject => {
write!(f, "top-level OSCAL Profile value is not a JSON object")
}
Self::ProfileMissingWrapper => {
write!(f, "OSCAL Profile value is missing the 'profile' wrapper")
}
Self::ProfileImportsNotArray => {
write!(f, "profile.imports is missing or not a JSON array")
}
Self::ProfileImportNotObject { index } => {
write!(f, "profile.imports[{index}] is not a JSON object")
}
Self::ProfileImportMissingHref { index } => write!(
f,
"profile.imports[{index}] is missing required 'href' field"
),
Self::ProfileImportHrefNotString { index } => {
write!(f, "profile.imports[{index}] 'href' is not a JSON string")
}
Self::ProfileImportHrefEmpty { index } => {
write!(f, "profile.imports[{index}] 'href' is an empty string")
}
Self::ProfileImportUnresolved { index, href } => write!(
f,
"profile.imports[{index}] href '{href}' has no entry in sources"
),
Self::ProfileImportSourceUnknown { index, href } => write!(
f,
"profile.imports[{index}] href '{href}' source is neither a Catalog nor a Profile"
),
Self::ProfileImportCycle { href } => {
write!(f, "profile import cycle detected at href '{href}'")
}
Self::ProfileIncludeControlsNotArray { index } => write!(
f,
"profile.imports[{index}].include-controls is not a JSON array"
),
Self::ProfileExcludeControlsNotArray { index } => write!(
f,
"profile.imports[{index}].exclude-controls is not a JSON array"
),
Self::ProfileWithIdsEntryNotObject { index, entry_index } => write!(
f,
"profile.imports[{index}] include/exclude-controls[{entry_index}] is not a JSON object"
),
Self::ProfileWithIdsNotArray { index, entry_index } => write!(
f,
"profile.imports[{index}] include/exclude-controls[{entry_index}].with-ids is not a JSON array"
),
Self::ProfileWithIdNotString { index, entry_index } => write!(
f,
"profile.imports[{index}] include/exclude-controls[{entry_index}].with-ids contains a non-string id"
),
Self::ProfileSetParametersNotArray => {
write!(f, "profile.modify.set-parameters is not a JSON array")
}
Self::ProfileSetParameterNotObject { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] is not a JSON object"
),
Self::ProfileSetParameterMissingId { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] is missing required 'param-id'"
),
Self::ProfileSetParameterIdEmpty { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] 'param-id' is an empty string"
),
Self::ProfileSetParameterValuesNotArray { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] 'values' is missing or not a JSON array"
),
Self::ProfileSetParameterValuesEmpty { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] 'values' is empty"
),
Self::ProfileSetParameterValueNotString { entry_index } => write!(
f,
"profile.modify.set-parameters[{entry_index}] 'values[0]' is not a JSON string"
),
Self::UnknownParameterOverride { param_id } => write!(
f,
"no registered Lint owns composite parameter id '{param_id}'"
),
Self::InvalidParameterOverride { param_id, source } => write!(
f,
"Lint rejected parameter override for '{param_id}': {source}"
),
}
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::AddFailed { source, .. } => Some(source),
Self::InvalidParameterOverride { source, .. } => Some(source),
_ => None,
}
}
}
pub fn deviation_store_from_risks(value: &Value) -> Result<DeviationStore, ParseError> {
let arr = value.as_array().ok_or(ParseError::NotArray)?;
let mut store = DeviationStore::new();
for (idx, risk) in arr.iter().enumerate() {
let deviation = parse_risk(idx, risk)?;
store
.add(deviation)
.map_err(|source| ParseError::AddFailed { index: idx, source })?;
}
Ok(store)
}
fn parse_risk(idx: usize, risk: &Value) -> Result<Deviation, ParseError> {
let obj = risk
.as_object()
.ok_or(ParseError::RiskNotObject { index: idx })?;
let status = obj.get("status").and_then(Value::as_str).unwrap_or("");
if status != "deviation-approved" {
return Err(ParseError::InvalidStatus {
index: idx,
found: status.to_string(),
});
}
let justification = obj
.get("description")
.and_then(Value::as_str)
.ok_or(ParseError::MissingDescription { index: idx })?
.to_string();
let props_slice: Option<&[Value]> = obj
.get("props")
.and_then(Value::as_array)
.map(Vec::as_slice);
let get_prop = |name: &'static str| -> Option<&str> { find_prop_value(props_slice, name) };
let id = required_nonempty_prop(
idx,
get_prop("pkix-lint.deviation-id"),
"pkix-lint.deviation-id",
)?
.to_string();
let target_lint = required_nonempty_prop(
idx,
get_prop("pkix-lint.target-lint"),
"pkix-lint.target-lint",
)?
.to_string();
let action_str = required_nonempty_prop(idx, get_prop("pkix-lint.action"), "pkix-lint.action")?;
let action = parse_action(idx, action_str)?;
let authorized_by = required_nonempty_prop(
idx,
get_prop("pkix-lint.authorized-by"),
"pkix-lint.authorized-by",
)?
.to_string();
let effective_start = parse_optional_u64(
idx,
get_prop("pkix-lint.effective-start"),
"pkix-lint.effective-start",
)?;
let effective_end = parse_optional_u64(
idx,
get_prop("pkix-lint.effective-end"),
"pkix-lint.effective-end",
)?;
let priority = parse_optional_i32(
idx,
get_prop("pkix-lint.deviation-priority"),
"pkix-lint.deviation-priority",
)?
.unwrap_or(0);
let subjects = obj
.get("subjects")
.and_then(Value::as_array)
.ok_or(ParseError::SubjectsNotArray { index: idx })?;
let first_subject = subjects
.first()
.ok_or(ParseError::MissingSubject { index: idx })?;
let scope = parse_subject(idx, first_subject)?;
let evidence_uri = parse_evidence_uri(obj.get("links"));
Ok(Deviation {
id,
target_lint,
scope,
effective_start,
effective_end,
action,
justification,
authorized_by,
evidence_uri,
priority,
})
}
fn required_nonempty_prop<'a>(
idx: usize,
value: Option<&'a str>,
name: &'static str,
) -> Result<&'a str, ParseError> {
let s = value.ok_or(ParseError::MissingProp { index: idx, name })?;
if s.is_empty() {
return Err(ParseError::EmptyProp { index: idx, name });
}
Ok(s)
}
fn parse_action(idx: usize, s: &str) -> Result<DeviationAction, ParseError> {
if s == "suppress" {
return Ok(DeviationAction::Suppress);
}
if let Some(rest) = s.strip_prefix("downgrade:") {
let sev = match rest {
"info" => Severity::Info,
"notice" => Severity::Notice,
"warn" => Severity::Warn,
"error" => Severity::Error,
"fatal" => Severity::Fatal,
_ => {
return Err(ParseError::UnknownAction {
index: idx,
found: s.to_string(),
});
}
};
return Ok(DeviationAction::DowngradeSeverityTo(sev));
}
Err(ParseError::UnknownAction {
index: idx,
found: s.to_string(),
})
}
fn parse_optional_u64(
idx: usize,
value: Option<&str>,
prop_name: &'static str,
) -> Result<Option<u64>, ParseError> {
match value {
None => Ok(None),
Some(v) => v
.parse::<u64>()
.map(Some)
.map_err(|_| ParseError::InvalidU64 {
index: idx,
prop: prop_name,
found: v.to_string(),
}),
}
}
fn parse_optional_i32(
idx: usize,
value: Option<&str>,
prop_name: &'static str,
) -> Result<Option<i32>, ParseError> {
match value {
None => Ok(None),
Some(v) => v
.parse::<i32>()
.map(Some)
.map_err(|_| ParseError::InvalidI32 {
index: idx,
prop: prop_name,
found: v.to_string(),
}),
}
}
fn parse_subject(idx: usize, subj: &Value) -> Result<DeviationScope, ParseError> {
let obj = subj
.as_object()
.ok_or(ParseError::SubjectNotObject { index: idx })?;
let ty = obj
.get("type")
.and_then(Value::as_str)
.ok_or(ParseError::SubjectMissingType { index: idx })?;
let props_slice: Option<&[Value]> = obj
.get("props")
.and_then(Value::as_array)
.map(Vec::as_slice);
let get_prop = |name: &'static str| -> Option<&str> { find_prop_value(props_slice, name) };
let required = |name: &'static str| -> Result<&str, ParseError> {
get_prop(name).ok_or(ParseError::MissingSubjectProp { index: idx, name })
};
use crate::deviation::{
PROP_ISSUER_DN_DER, PROP_ISSUER_DN_SUBSTRING, PROP_SERIAL_END, PROP_SERIAL_START,
SCOPE_KIND_ANY, SCOPE_KIND_ISSUER_DN_CONTAINS, SCOPE_KIND_ISSUER_DN_EXACT,
SCOPE_KIND_SERIAL_RANGE,
};
match ty {
SCOPE_KIND_ANY => Ok(DeviationScope::any()),
SCOPE_KIND_ISSUER_DN_CONTAINS => {
let substring = required(PROP_ISSUER_DN_SUBSTRING)?.to_string();
Ok(DeviationScope::issuer_dn_contains(substring))
}
SCOPE_KIND_ISSUER_DN_EXACT => {
let der_hex = required(PROP_ISSUER_DN_DER)?;
let der = hex_decode(der_hex).ok_or(ParseError::MalformedHex {
index: idx,
prop: PROP_ISSUER_DN_DER,
})?;
decode_name(idx, &der, PROP_ISSUER_DN_DER)?;
Ok(DeviationScope::issuer_dn_exact(der))
}
SCOPE_KIND_SERIAL_RANGE => {
let der_hex = required(PROP_ISSUER_DN_DER)?;
let der = hex_decode(der_hex).ok_or(ParseError::MalformedHex {
index: idx,
prop: PROP_ISSUER_DN_DER,
})?;
decode_name(idx, &der, PROP_ISSUER_DN_DER)?;
let start_hex = required(PROP_SERIAL_START)?;
let start = hex_decode(start_hex).ok_or(ParseError::MalformedHex {
index: idx,
prop: PROP_SERIAL_START,
})?;
let end_hex = required(PROP_SERIAL_END)?;
let end = hex_decode(end_hex).ok_or(ParseError::MalformedHex {
index: idx,
prop: PROP_SERIAL_END,
})?;
Ok(DeviationScope::serial_range(der, start, end))
}
other => Err(ParseError::UnknownSubjectType {
index: idx,
found: other.to_string(),
}),
}
}
fn decode_name(
idx: usize,
der: &[u8],
prop: &'static str,
) -> Result<x509_cert::name::Name, ParseError> {
use der::Decode as _;
if der.is_empty() {
return Err(ParseError::MalformedDer { index: idx, prop });
}
x509_cert::name::Name::from_der(der).map_err(|_| ParseError::MalformedDer { index: idx, prop })
}
fn parse_evidence_uri(links: Option<&Value>) -> Option<String> {
let arr = links?.as_array()?;
for link in arr {
let obj = match link.as_object() {
Some(o) => o,
None => continue,
};
let rel = obj.get("rel").and_then(Value::as_str);
if rel == Some("reference") {
if let Some(href) = obj.get("href").and_then(Value::as_str) {
return Some(href.to_string());
}
}
}
None
}
fn find_prop_value<'a>(props: Option<&'a [Value]>, name: &str) -> Option<&'a str> {
let arr = props?;
arr.iter()
.filter_map(Value::as_object)
.find(|obj| obj.get("name").and_then(Value::as_str) == Some(name))
.and_then(|obj| obj.get("value").and_then(Value::as_str))
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let hi = nibble(bytes[i])?;
let lo = nibble(bytes[i + 1])?;
out.push((hi << 4) | lo);
i += 2;
}
Some(out)
}
fn nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub(crate) fn check_oscal_version(
container: &serde_json::Map<String, Value>,
) -> Result<(), ParseError> {
let version = container
.get("metadata")
.and_then(|m| m.get("oscal-version"))
.and_then(Value::as_str)
.ok_or(ParseError::MissingOscalVersion)?;
if !crate::oscal::SUPPORTED_OSCAL_VERSIONS.contains(&version) {
return Err(ParseError::UnsupportedOscalVersion {
found: version.to_string(),
});
}
Ok(())
}
pub fn lint_ids_from_catalog(value: &Value) -> Result<Vec<String>, ParseError> {
let obj = value.as_object().ok_or(ParseError::CatalogNotObject)?;
let catalog = obj
.get("catalog")
.and_then(|c| c.as_object())
.ok_or(ParseError::CatalogMissingWrapper)?;
check_oscal_version(catalog)?;
let controls = catalog
.get("controls")
.and_then(|c| c.as_array())
.ok_or(ParseError::ControlsNotArray)?;
let mut ids: Vec<String> = Vec::with_capacity(controls.len());
for (index, control) in controls.iter().enumerate() {
let control_obj = control
.as_object()
.ok_or(ParseError::ControlNotObject { index })?;
let id_value = control_obj
.get("id")
.ok_or(ParseError::ControlMissingId { index })?;
let id_str = id_value
.as_str()
.ok_or(ParseError::ControlIdNotString { index })?;
if id_str.is_empty() {
return Err(ParseError::ControlIdEmpty { index });
}
ids.push(id_str.to_owned());
}
Ok(ids)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deviation::{Deviation, DeviationAction, DeviationScope, DeviationStore};
use crate::Severity;
use serde_json::{json, Value};
fn sample_deviation_contains() -> Deviation {
Deviation {
id: "policy-2026-fpki-keyusage-q1".to_string(),
target_lint: "fpki.common.6.1.5".to_string(),
scope: DeviationScope::issuer_dn_contains("agency x issuing ca"),
effective_start: Some(1_704_067_200),
effective_end: Some(1_767_225_600),
action: DeviationAction::DowngradeSeverityTo(Severity::Warn),
justification: "FPKIPA waiver memo 2025-11-03; see exception register entry 47"
.to_string(),
authorized_by: "agency-x-ciso@agency.gov".to_string(),
evidence_uri: Some("https://pkipolicy.agency.gov/waivers/2025-11-03".to_string()),
priority: 0,
}
}
#[test]
fn round_trip_issuer_dn_contains_full_fields() {
let mut store = DeviationStore::new();
store.add(sample_deviation_contains()).expect("add");
let risks = super::super::emit::risks_from_store(&store);
let value = Value::Array(risks);
let parsed = deviation_store_from_risks(&value).expect("parse");
assert_eq!(parsed, store);
}
#[test]
fn round_trip_suppress_and_any_scope() {
let mut store = DeviationStore::new();
store
.add(Deviation {
id: "policy-internal-ca-suppress".to_string(),
target_lint: "rfc5280.keyusage.required".to_string(),
scope: DeviationScope::any(),
effective_start: None,
effective_end: None,
action: DeviationAction::Suppress,
justification: "Internal lab CA, never published to relying parties".to_string(),
authorized_by: "lab-lead@example.com".to_string(),
evidence_uri: None,
priority: 0,
})
.expect("add");
let risks = super::super::emit::risks_from_store(&store);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store);
}
fn load_pkits_good_ca() -> Option<x509_cert::Certificate> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../pkix-path/tests/pkits/certs/GoodCACert.crt");
let bytes = std::fs::read(&path).ok()?;
use der::Decode as _;
x509_cert::Certificate::from_der(&bytes).ok()
}
#[test]
fn round_trip_issuer_dn_exact() {
use der::Encode as _;
let Some(cert) = load_pkits_good_ca() else {
eprintln!("PKITS GoodCACert.crt not available — skipping");
return;
};
let subject_der = cert
.tbs_certificate
.subject
.to_der()
.expect("Name::to_der is infallible for a parsed Name");
let mut store = DeviationStore::new();
store
.add(Deviation {
id: "policy-good-ca-exact".to_string(),
target_lint: "rfc5280.bc.ca-true-required".to_string(),
scope: DeviationScope::issuer_dn_exact(subject_der),
effective_start: None,
effective_end: Some(2_000_000_000),
action: DeviationAction::DowngradeSeverityTo(Severity::Info),
justification: "Known intermediate; waiver tracked in eng-pki #42".to_string(),
authorized_by: "pki-lead@example.com".to_string(),
evidence_uri: None,
priority: 0,
})
.expect("add");
let risks = super::super::emit::risks_from_store(&store);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store);
}
#[test]
fn round_trip_serial_range() {
use der::Encode as _;
let Some(cert) = load_pkits_good_ca() else {
eprintln!("PKITS GoodCACert.crt not available — skipping");
return;
};
let subject_der = cert
.tbs_certificate
.subject
.to_der()
.expect("Name::to_der is infallible for a parsed Name");
let mut store = DeviationStore::new();
store
.add(Deviation {
id: "policy-batch-2026-q1".to_string(),
target_lint: "rfc5280.serial.unique".to_string(),
scope: DeviationScope::serial_range(
subject_der,
vec![0x01, 0x00],
vec![0x01, 0xff],
),
effective_start: Some(1_704_067_200),
effective_end: Some(1_711_929_600),
action: DeviationAction::DowngradeSeverityTo(Severity::Warn),
justification: "Issuance batch from Q1 2024 known-collision regenerated"
.to_string(),
authorized_by: "ca-ops@example.com".to_string(),
evidence_uri: Some(
"https://pki.example.com/incidents/2024-q1-serial-coll".to_string(),
),
priority: 0,
})
.expect("add");
let risks = super::super::emit::risks_from_store(&store);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store);
}
#[test]
fn round_trip_optional_fields_all_none() {
let mut store = DeviationStore::new();
store
.add(Deviation {
id: "policy-no-optionals".to_string(),
target_lint: "rfc5280.aki.required".to_string(),
scope: DeviationScope::any(),
effective_start: None,
effective_end: None,
action: DeviationAction::Suppress,
justification: "Bootstrap root that predates AKI requirement".to_string(),
authorized_by: "ops@example.com".to_string(),
evidence_uri: None,
priority: 0,
})
.expect("add");
let risks = super::super::emit::risks_from_store(&store);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store);
let d = &parsed.all()[0];
assert!(d.effective_start.is_none());
assert!(d.effective_end.is_none());
assert!(d.evidence_uri.is_none());
}
#[test]
fn round_trip_multi_deviation_store() {
let mut store = DeviationStore::new();
store.add(sample_deviation_contains()).expect("add 1");
store
.add(Deviation {
id: "policy-second-suppress".to_string(),
action: DeviationAction::Suppress,
scope: DeviationScope::any(),
..sample_deviation_contains()
})
.expect("add 2");
let risks = super::super::emit::risks_from_store(&store);
assert_eq!(risks.len(), 2);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store);
}
#[test]
fn round_trip_preserves_priority() {
let mut store = DeviationStore::new();
store
.add(
Deviation {
id: "policy-priority-high".to_string(),
target_lint: "rfc5280.bc.ca-true-required".to_string(),
scope: DeviationScope::any(),
effective_start: None,
effective_end: None,
action: DeviationAction::Suppress,
justification: "High-priority suppression".to_string(),
authorized_by: "lead@example.com".to_string(),
evidence_uri: None,
priority: 1000,
},
)
.expect("add high");
store
.add(
Deviation {
id: "policy-priority-low".to_string(),
target_lint: "rfc5280.aki.required".to_string(),
scope: DeviationScope::any(),
effective_start: None,
effective_end: None,
action: DeviationAction::Suppress,
justification: "Low-priority suppression".to_string(),
authorized_by: "lead@example.com".to_string(),
evidence_uri: None,
priority: -500,
},
)
.expect("add low");
store
.add(
Deviation {
id: "policy-priority-zero".to_string(),
target_lint: "rfc5280.ski.required".to_string(),
scope: DeviationScope::any(),
effective_start: None,
effective_end: None,
action: DeviationAction::Suppress,
justification: "Default-priority suppression".to_string(),
authorized_by: "lead@example.com".to_string(),
evidence_uri: None,
priority: 0,
},
)
.expect("add zero");
let risks = super::super::emit::risks_from_store(&store);
let prop_count: usize = risks
.iter()
.map(|r| {
r.get("props")
.and_then(Value::as_array)
.map(|props| {
props
.iter()
.filter(|p| {
p.get("name").and_then(Value::as_str)
== Some("pkix-lint.deviation-priority")
})
.count()
})
.unwrap_or(0)
})
.sum();
assert_eq!(
prop_count, 2,
"expected exactly 2 priority props (one each for +1000 and -500); zero is implicit"
);
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(parsed, store, "priority must round-trip exactly");
}
#[test]
fn parse_treats_missing_priority_prop_as_zero() {
let risks = json!([{
"uuid": "00000000-0000-8000-8000-000000000000",
"title": "Legacy deviation",
"description": "Pre-priority justification",
"statement": "Pre-priority justification",
"status": "deviation-approved",
"props": [
{"name": "pkix-lint.deviation-id", "value": "legacy-id"},
{"name": "pkix-lint.target-lint", "value": "rfc5280.aki.required"},
{"name": "pkix-lint.action", "value": "suppress"},
{"name": "pkix-lint.authorized-by", "value": "legacy@example.com"},
],
"subjects": [
{
"type": "pkix-lint.scope.any",
"subject-uuid": "00000000-0000-8000-8000-000000000001",
"title": "All certificates",
}
],
}]);
let parsed = deviation_store_from_risks(&risks).expect("parse legacy shape");
let d = &parsed.all()[0];
assert_eq!(
d.priority, 0,
"missing priority prop must decode to the default value 0"
);
}
#[test]
fn rejects_non_numeric_priority_prop() {
let risks = json!([{
"uuid": "00000000-0000-8000-8000-000000000000",
"title": "Malformed-priority deviation",
"description": "Justification text",
"statement": "Justification text",
"status": "deviation-approved",
"props": [
{"name": "pkix-lint.deviation-id", "value": "bad-priority"},
{"name": "pkix-lint.target-lint", "value": "rfc5280.aki.required"},
{"name": "pkix-lint.action", "value": "suppress"},
{"name": "pkix-lint.authorized-by", "value": "ops@example.com"},
{"name": "pkix-lint.deviation-priority", "value": "not-an-i32"},
],
"subjects": [
{
"type": "pkix-lint.scope.any",
"subject-uuid": "00000000-0000-8000-8000-000000000001",
"title": "All certificates",
}
],
}]);
let err = deviation_store_from_risks(&risks).expect_err("must reject");
match err {
ParseError::InvalidI32 { index, prop, found } => {
assert_eq!(index, 0);
assert_eq!(prop, "pkix-lint.deviation-priority");
assert_eq!(found, "not-an-i32");
}
other => panic!("expected InvalidI32, got: {other:?}"),
}
}
#[test]
fn rejects_top_level_non_array() {
let v = json!({"not": "an array"});
let err = deviation_store_from_risks(&v).expect_err("should fail");
assert!(matches!(err, ParseError::NotArray));
}
#[test]
fn rejects_risk_not_object() {
let v = json!(["not-an-object"]);
let err = deviation_store_from_risks(&v).expect_err("should fail");
assert!(matches!(err, ParseError::RiskNotObject { index: 0 }));
}
#[test]
fn rejects_wrong_status() {
let v = json!([{
"uuid": "00000000-0000-8000-8000-000000000000",
"status": "open",
"description": "x",
"props": [],
"subjects": [{"type": "pkix-lint.scope.any"}]
}]);
let err = deviation_store_from_risks(&v).expect_err("should fail");
match err {
ParseError::InvalidStatus { index: 0, found } => assert_eq!(found, "open"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_missing_description() {
let v = json!([{
"uuid": "00000000-0000-8000-8000-000000000000",
"status": "deviation-approved",
"props": [],
"subjects": [{"type": "pkix-lint.scope.any"}]
}]);
let err = deviation_store_from_risks(&v).expect_err("should fail");
assert!(matches!(err, ParseError::MissingDescription { index: 0 }));
}
fn minimal_valid_risk() -> Value {
json!({
"uuid": "00000000-0000-8000-8000-000000000000",
"status": "deviation-approved",
"description": "j",
"props": [
{"name": "pkix-lint.deviation-id", "value": "d1", "ns": "https://pkix.rs/oscal/pkix-lint"},
{"name": "pkix-lint.target-lint", "value": "t1", "ns": "https://pkix.rs/oscal/pkix-lint"},
{"name": "pkix-lint.action", "value": "suppress", "ns": "https://pkix.rs/oscal/pkix-lint"},
{"name": "pkix-lint.authorized-by","value": "a1", "ns": "https://pkix.rs/oscal/pkix-lint"}
],
"subjects": [{"type": "pkix-lint.scope.any"}]
})
}
fn drop_prop(risk: &mut Value, name: &str) {
let props = risk["props"].as_array_mut().expect("props array");
props.retain(|p| p["name"].as_str() != Some(name));
}
fn set_prop(risk: &mut Value, name: &str, value: &str) {
let props = risk["props"].as_array_mut().expect("props array");
if let Some(existing) = props.iter_mut().find(|p| p["name"].as_str() == Some(name)) {
existing["value"] = Value::String(value.to_string());
} else {
props.push(json!({
"name": name,
"value": value,
"ns": "https://pkix.rs/oscal/pkix-lint",
}));
}
}
#[test]
fn rejects_missing_required_prop_deviation_id() {
let mut risk = minimal_valid_risk();
drop_prop(&mut risk, "pkix-lint.deviation-id");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingProp {
index: 0,
name: "pkix-lint.deviation-id"
}
));
}
#[test]
fn rejects_missing_required_prop_target_lint() {
let mut risk = minimal_valid_risk();
drop_prop(&mut risk, "pkix-lint.target-lint");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingProp {
index: 0,
name: "pkix-lint.target-lint"
}
));
}
#[test]
fn rejects_missing_required_prop_action() {
let mut risk = minimal_valid_risk();
drop_prop(&mut risk, "pkix-lint.action");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingProp {
index: 0,
name: "pkix-lint.action"
}
));
}
#[test]
fn rejects_missing_required_prop_authorized_by() {
let mut risk = minimal_valid_risk();
drop_prop(&mut risk, "pkix-lint.authorized-by");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingProp {
index: 0,
name: "pkix-lint.authorized-by"
}
));
}
#[test]
fn rejects_empty_required_prop() {
let mut risk = minimal_valid_risk();
set_prop(&mut risk, "pkix-lint.deviation-id", "");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::EmptyProp {
index: 0,
name: "pkix-lint.deviation-id"
}
));
}
#[test]
fn rejects_unknown_action_bare() {
let mut risk = minimal_valid_risk();
set_prop(&mut risk, "pkix-lint.action", "ignore");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
match err {
ParseError::UnknownAction { index: 0, found } => assert_eq!(found, "ignore"),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_unknown_action_unknown_severity() {
let mut risk = minimal_valid_risk();
set_prop(&mut risk, "pkix-lint.action", "downgrade:critical");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
match err {
ParseError::UnknownAction { index: 0, found } => {
assert_eq!(found, "downgrade:critical");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_invalid_u64_effective_start() {
let mut risk = minimal_valid_risk();
set_prop(&mut risk, "pkix-lint.effective-start", "not-a-number");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
match err {
ParseError::InvalidU64 {
index: 0,
prop,
found,
} => {
assert_eq!(prop, "pkix-lint.effective-start");
assert_eq!(found, "not-a-number");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_missing_subjects_array() {
let mut risk = minimal_valid_risk();
risk.as_object_mut().unwrap().remove("subjects");
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(err, ParseError::SubjectsNotArray { index: 0 }));
}
#[test]
fn rejects_empty_subjects_array() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(err, ParseError::MissingSubject { index: 0 }));
}
#[test]
fn rejects_subject_missing_type() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{"title": "no type here"}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(err, ParseError::SubjectMissingType { index: 0 }));
}
#[test]
fn rejects_unknown_subject_type() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{"type": "pkix-lint.scope.future-variant"}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
match err {
ParseError::UnknownSubjectType { index: 0, found } => {
assert_eq!(found, "pkix-lint.scope.future-variant");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_missing_subject_prop_substring() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{
"type": "pkix-lint.scope.issuer-dn-contains",
"props": []
}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingSubjectProp {
index: 0,
name: "pkix-lint.issuer-dn-substring"
}
));
}
#[test]
fn rejects_malformed_hex_in_dn_der() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{
"type": "pkix-lint.scope.issuer-dn-exact",
"props": [
{"name": "pkix-lint.issuer-dn-der", "value": "not-hex", "ns": "https://pkix.rs/oscal/pkix-lint"}
]
}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MalformedHex {
index: 0,
prop: "pkix-lint.issuer-dn-der"
}
));
}
#[test]
fn rejects_empty_der_for_issuer_dn_exact() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{
"type": "pkix-lint.scope.issuer-dn-exact",
"props": [
{"name": "pkix-lint.issuer-dn-der", "value": "", "ns": "https://pkix.rs/oscal/pkix-lint"}
]
}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MalformedDer {
index: 0,
prop: "pkix-lint.issuer-dn-der"
}
));
}
#[test]
fn rejects_garbage_der_for_issuer_dn_exact() {
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{
"type": "pkix-lint.scope.issuer-dn-exact",
"props": [
{"name": "pkix-lint.issuer-dn-der", "value": "deadbeef", "ns": "https://pkix.rs/oscal/pkix-lint"}
]
}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MalformedDer {
index: 0,
prop: "pkix-lint.issuer-dn-der"
}
));
}
#[test]
fn rejects_serial_range_missing_serial_start() {
let Some(cert) = load_pkits_good_ca() else {
eprintln!("PKITS GoodCACert.crt not available — skipping");
return;
};
use der::Encode as _;
let der = cert.tbs_certificate.subject.to_der().expect("encode");
let der_hex: String = der.iter().map(|b| format!("{b:02x}")).collect();
let mut risk = minimal_valid_risk();
risk["subjects"] = json!([{
"type": "pkix-lint.scope.serial-range",
"props": [
{"name": "pkix-lint.issuer-dn-der", "value": der_hex, "ns": "https://pkix.rs/oscal/pkix-lint"},
{"name": "pkix-lint.serial-end", "value": "01ff", "ns": "https://pkix.rs/oscal/pkix-lint"}
]
}]);
let err = deviation_store_from_risks(&Value::Array(vec![risk])).expect_err("should fail");
assert!(matches!(
err,
ParseError::MissingSubjectProp {
index: 0,
name: "pkix-lint.serial-start"
}
));
}
#[test]
fn rejects_duplicate_id_in_input() {
let v = Value::Array(vec![minimal_valid_risk(), minimal_valid_risk()]);
let err = deviation_store_from_risks(&v).expect_err("should fail");
match err {
ParseError::AddFailed { index: 1, source } => {
assert!(matches!(source, DeviationAddError::DuplicateId(_)))
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn empty_input_yields_empty_store() {
let v = Value::Array(vec![]);
let parsed = deviation_store_from_risks(&v).expect("parse");
assert_eq!(parsed, DeviationStore::new());
}
#[test]
fn parser_accepts_uppercase_hex() {
assert_eq!(hex_decode("DEAD"), Some(vec![0xde, 0xad]));
assert_eq!(hex_decode("dead"), Some(vec![0xde, 0xad]));
assert_eq!(hex_decode("DeAd"), Some(vec![0xde, 0xad]));
assert_eq!(hex_decode("DEA"), None); assert_eq!(hex_decode("XYZW"), None); }
#[test]
fn parse_action_covers_all_severity_variants() {
assert!(matches!(
parse_action(0, "suppress"),
Ok(DeviationAction::Suppress)
));
assert!(matches!(
parse_action(0, "downgrade:info"),
Ok(DeviationAction::DowngradeSeverityTo(Severity::Info))
));
assert!(matches!(
parse_action(0, "downgrade:notice"),
Ok(DeviationAction::DowngradeSeverityTo(Severity::Notice))
));
assert!(matches!(
parse_action(0, "downgrade:warn"),
Ok(DeviationAction::DowngradeSeverityTo(Severity::Warn))
));
assert!(matches!(
parse_action(0, "downgrade:error"),
Ok(DeviationAction::DowngradeSeverityTo(Severity::Error))
));
assert!(matches!(
parse_action(0, "downgrade:fatal"),
Ok(DeviationAction::DowngradeSeverityTo(Severity::Fatal))
));
}
#[test]
fn second_link_with_rel_reference_is_ignored_when_first_matches() {
let mut store = DeviationStore::new();
store
.add(Deviation {
evidence_uri: Some("https://first.example.com/".to_string()),
..sample_deviation_contains()
})
.expect("add");
let mut risks = super::super::emit::risks_from_store(&store);
risks[0]["links"]
.as_array_mut()
.expect("links array")
.push(json!({
"href": "https://second.example.com/",
"rel": "reference",
"text": "Deviation authorization document",
}));
let parsed = deviation_store_from_risks(&Value::Array(risks)).expect("parse");
assert_eq!(
parsed.all()[0].evidence_uri.as_deref(),
Some("https://first.example.com/")
);
}
}