use serde_json::{json, Value};
use super::emit::{prop, scope_label, severity_label, subject_kind_label, uuid_v8};
use crate::Lint;
const CATALOG_LAST_MODIFIED: &str = "1970-01-01T00:00:00Z";
use super::OSCAL_VERSION;
const NS_CATALOG: &str = "pkix-lint.oscal.catalog";
const NS_CONTROL: &str = "pkix-lint.oscal.catalog.control";
const NS_PARAM: &str = "pkix-lint.oscal.catalog.param";
#[must_use]
pub fn catalog_from_lints(
lints: &[Box<dyn Lint>],
catalog_id: &str,
catalog_version: &str,
) -> Value {
let catalog_seed = catalog_seed(catalog_id, catalog_version);
let catalog_uuid = uuid_v8(NS_CATALOG, &catalog_seed);
let mut controls: Vec<Value> = Vec::with_capacity(lints.len());
for lint in lints {
controls.push(control_for_lint(lint.as_ref(), catalog_id, catalog_version));
}
let mut metadata_props = Vec::with_capacity(2);
if !catalog_id.is_empty() {
metadata_props.push(prop("pkix-lint.catalog-id", catalog_id));
}
if !catalog_version.is_empty() {
metadata_props.push(prop("pkix-lint.catalog-version", catalog_version));
}
let metadata = json!({
"title": "pkix-lint Lint Catalog",
"last-modified": CATALOG_LAST_MODIFIED,
"version": catalog_version,
"oscal-version": OSCAL_VERSION,
"props": metadata_props,
});
json!({
"catalog": {
"uuid": catalog_uuid,
"metadata": metadata,
"controls": controls,
}
})
}
fn control_for_lint(lint: &dyn Lint, catalog_id: &str, catalog_version: &str) -> Value {
let control_seed = control_seed(catalog_id, catalog_version, lint.id());
let control_uuid = uuid_v8(NS_CONTROL, &control_seed);
let mut props = Vec::with_capacity(6);
props.push(prop("pkix-lint.citation", lint.citation()));
props.push(prop("pkix-lint.severity", severity_label(lint.severity())));
props.push(prop("pkix-lint.scope", scope_label(lint.scope())));
props.push(prop(
"pkix-lint.applies-to",
subject_kind_label(lint.applies_to()),
));
if let Some(section_id) = lint.spec_section_id() {
props.push(prop("pkix-lint.section-id", section_id));
}
props.push(prop("pkix-lint.lint-id", lint.id()));
props.push(prop("pkix-lint.control-uuid", &control_uuid));
let mut links: Vec<Value> = Vec::new();
if let Some(url) = lint.spec_url() {
links.push(json!({
"href": url,
"rel": "reference",
}));
}
let mut parts: Vec<Value> = Vec::new();
if let Some(description) = lint.description() {
parts.push(json!({
"id": format!("{}_smt", lint.id()),
"name": "statement",
"prose": description,
}));
}
let params: Vec<Value> = lint
.parameters()
.iter()
.map(|p| param_value(p, catalog_id, catalog_version, lint.id()))
.collect();
let mut control = serde_json::Map::new();
control.insert("id".to_string(), json!(lint.id()));
control.insert("class".to_string(), json!("pkix-lint"));
control.insert("title".to_string(), json!(lint.title()));
if !params.is_empty() {
control.insert("params".to_string(), Value::Array(params));
}
control.insert("props".to_string(), Value::Array(props));
if !links.is_empty() {
control.insert("links".to_string(), Value::Array(links));
}
if !parts.is_empty() {
control.insert("parts".to_string(), Value::Array(parts));
}
Value::Object(control)
}
fn param_value(
p: &crate::LintParameter,
catalog_id: &str,
catalog_version: &str,
lint_id: &str,
) -> Value {
let mut seed = Vec::with_capacity(64);
seed.extend_from_slice(catalog_id.as_bytes());
seed.push(0);
seed.extend_from_slice(catalog_version.as_bytes());
seed.push(0);
seed.extend_from_slice(lint_id.as_bytes());
seed.push(0);
seed.extend_from_slice(p.id.as_bytes());
let param_uuid = uuid_v8(NS_PARAM, &seed);
let mut props = Vec::with_capacity(2);
props.push(prop("pkix-lint.param-uuid", ¶m_uuid));
props.push(prop("pkix-lint.param-default", p.default_value.as_ref()));
json!({
"id": format!("{}.{}", lint_id, p.id),
"label": p.label.as_ref(),
"values": [p.default_value.as_ref()],
"props": props,
})
}
fn catalog_seed(catalog_id: &str, catalog_version: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(catalog_id.len() + catalog_version.len() + 1);
buf.extend_from_slice(catalog_id.as_bytes());
buf.push(0);
buf.extend_from_slice(catalog_version.as_bytes());
buf
}
fn control_seed(catalog_id: &str, catalog_version: &str, lint_id: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(catalog_id.len() + catalog_version.len() + lint_id.len() + 2);
buf.extend_from_slice(catalog_id.as_bytes());
buf.push(0);
buf.extend_from_slice(catalog_version.as_bytes());
buf.push(0);
buf.extend_from_slice(lint_id.as_bytes());
buf
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rfc5280::Rfc5280MaxSerialLengthLint;
use crate::{Lint, LintResult, Scope, Severity, SubjectKind};
use x509_cert::Certificate;
#[derive(Clone)]
struct PolicyShapedLint {
id: &'static str,
section_id: &'static str,
}
impl Lint for PolicyShapedLint {
fn id(&self) -> &'static str {
self.id
}
fn citation(&self) -> &'static str {
"Test Policy §1.2.3"
}
fn severity(&self) -> Severity {
Severity::Error
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Leaf
}
fn title(&self) -> &str {
"Policy-shaped fixture lint (no RFC URL)"
}
fn spec_section_id(&self) -> Option<&str> {
Some(self.section_id)
}
fn check_cert(
&self,
_cert: &Certificate,
_kind: SubjectKind,
_now_unix: u64,
) -> LintResult {
LintResult::Pass
}
}
const POLICY_SHAPED_DEFAULT: PolicyShapedLint = PolicyShapedLint {
id: "test.policy.shaped",
section_id: "test-policy-1.2.3",
};
fn sample_lints() -> Vec<Box<dyn Lint>> {
vec![
Box::new(Rfc5280MaxSerialLengthLint::default()),
Box::new(POLICY_SHAPED_DEFAULT),
]
}
fn multi_lint_fixture() -> Vec<Box<dyn Lint>> {
vec![
Box::new(Rfc5280MaxSerialLengthLint::default()),
Box::new(PolicyShapedLint {
id: "test.policy.one",
section_id: "test-policy-1",
}),
Box::new(PolicyShapedLint {
id: "test.policy.two",
section_id: "test-policy-2",
}),
Box::new(PolicyShapedLint {
id: "test.policy.three",
section_id: "test-policy-3",
}),
Box::new(PolicyShapedLint {
id: "test.policy.four",
section_id: "test-policy-4",
}),
Box::new(PolicyShapedLint {
id: "test.policy.five",
section_id: "test-policy-5",
}),
]
}
#[test]
fn catalog_has_required_top_level_fields() {
let catalog = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let cat = catalog.get("catalog").expect("catalog wrapper");
assert!(cat.get("uuid").is_some(), "catalog.uuid required");
assert!(cat.get("metadata").is_some(), "catalog.metadata required");
assert!(cat.get("controls").is_some(), "catalog.controls required");
let metadata = cat.get("metadata").unwrap();
for required in ["title", "last-modified", "version", "oscal-version"] {
assert!(
metadata.get(required).is_some(),
"catalog.metadata.{required} required"
);
}
assert_eq!(metadata["oscal-version"], "1.1.2");
assert_eq!(metadata["last-modified"], CATALOG_LAST_MODIFIED);
assert_eq!(metadata["version"], "0.1.0");
}
#[test]
fn rfc5280_lint_maps_to_control() {
let catalog = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let controls = catalog["catalog"]["controls"].as_array().unwrap();
assert_eq!(controls.len(), 2);
let rfc_control = &controls[0]; assert_eq!(
rfc_control["id"],
"rfc5280.cert.serial_number.max_octets"
);
assert_eq!(rfc_control["class"], "pkix-lint");
assert_eq!(
rfc_control["title"],
"Certificate serialNumber must not exceed 20 octets"
);
let props = rfc_control["props"].as_array().expect("props array");
let names: Vec<&str> = props.iter().map(|p| p["name"].as_str().unwrap()).collect();
for expected in [
"pkix-lint.citation",
"pkix-lint.severity",
"pkix-lint.scope",
"pkix-lint.applies-to",
"pkix-lint.section-id",
"pkix-lint.lint-id",
"pkix-lint.control-uuid",
] {
assert!(
names.contains(&expected),
"missing prop {expected}; got: {names:?}"
);
}
let links = rfc_control["links"].as_array().expect("links array");
assert_eq!(links.len(), 1);
assert_eq!(links[0]["rel"], "reference");
assert_eq!(
links[0]["href"],
"https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.2"
);
let params = rfc_control["params"].as_array().expect("params array");
assert_eq!(params.len(), 1);
assert_eq!(
params[0]["id"],
"rfc5280.cert.serial_number.max_octets.max-octets"
);
assert_eq!(params[0]["values"][0], "20");
}
#[test]
fn policy_lint_without_spec_url_omits_links_array() {
let catalog = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let controls = catalog["catalog"]["controls"].as_array().unwrap();
let policy = &controls[1];
assert_eq!(policy["id"], "test.policy.shaped");
assert!(
policy.get("links").is_none(),
"Lint without spec_url must not emit links array; got: {policy}",
);
let props = policy["props"].as_array().unwrap();
let section_id_prop = props
.iter()
.find(|p| p["name"] == "pkix-lint.section-id")
.expect("section-id prop");
assert_eq!(section_id_prop["value"], "test-policy-1.2.3");
}
#[test]
fn output_is_byte_deterministic() {
let c1 = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let c2 = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let s1 = serde_json::to_string(&c1).unwrap();
let s2 = serde_json::to_string(&c2).unwrap();
assert_eq!(s1, s2, "catalog output must be byte-deterministic");
}
#[test]
fn catalog_uuid_is_uuid_v8_of_seed() {
let catalog = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let observed = catalog["catalog"]["uuid"].as_str().unwrap();
let mut expected_seed = Vec::new();
expected_seed.extend_from_slice(b"rs.pkix.test");
expected_seed.push(0);
expected_seed.extend_from_slice(b"0.1.0");
let expected = uuid_v8(NS_CATALOG, &expected_seed);
assert_eq!(observed, expected);
}
#[test]
fn changing_catalog_version_changes_all_uuids() {
let v1 = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let v2 = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.2.0");
assert_ne!(
v1["catalog"]["uuid"], v2["catalog"]["uuid"],
"catalog UUID must change with version"
);
let c1 = &v1["catalog"]["controls"][0];
let c2 = &v2["catalog"]["controls"][0];
let uuid_prop = |c: &Value| -> String {
c["props"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "pkix-lint.control-uuid")
.unwrap()["value"]
.as_str()
.unwrap()
.to_string()
};
assert_ne!(
uuid_prop(c1),
uuid_prop(c2),
"control UUID must change with catalog version"
);
}
#[test]
fn empty_lint_list_yields_empty_controls_array() {
let catalog = catalog_from_lints(&[], "rs.pkix.empty", "0.0.0");
let controls = catalog["catalog"]["controls"].as_array().unwrap();
assert!(controls.is_empty());
assert!(catalog["catalog"]["uuid"].as_str().is_some());
}
#[test]
fn parameter_id_is_namespaced_by_lint_id() {
let catalog = catalog_from_lints(&sample_lints(), "rs.pkix.test", "0.1.0");
let params = catalog["catalog"]["controls"][0]["params"]
.as_array()
.unwrap();
let pid = params[0]["id"].as_str().unwrap();
assert!(pid.starts_with("rfc5280.cert.serial_number.max_octets."));
assert!(pid.ends_with(".max-octets"));
}
use super::super::parse::{lint_ids_from_catalog, ParseError};
use crate::LintRunner;
fn load_cert(name: &str) -> x509_cert::Certificate {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../pkix-path/tests/fixtures/policy-checks/")
.join(name);
let der = std::fs::read(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
<x509_cert::Certificate as der::Decode>::from_der(&der)
.unwrap_or_else(|e| panic!("decode {name}: {e}"))
}
#[test]
fn lint_ids_from_catalog_extracts_in_order() {
let lints = multi_lint_fixture();
let expected_ids: Vec<String> = lints.iter().map(|l| l.id().to_string()).collect();
let catalog = catalog_from_lints(&lints, "rs.pkix.test", "2.0.0");
let serialized = serde_json::to_string(&catalog).expect("serialise");
let parsed: Value = serde_json::from_str(&serialized).expect("deserialise");
let ids = lint_ids_from_catalog(&parsed).expect("extract ids");
assert_eq!(ids, expected_ids);
}
#[test]
fn round_trip_preserves_findings_on_fixture() {
let cert = load_cert("leaf-rsa2048-sha1.der");
let runner_direct = LintRunner::new(multi_lint_fixture());
let catalog = catalog_from_lints(&multi_lint_fixture(), "rs.pkix.test", "2.0.0");
let serialised = serde_json::to_string(&catalog).expect("serialise");
let parsed: Value = serde_json::from_str(&serialised).expect("parse");
let ids = lint_ids_from_catalog(&parsed).expect("extract ids");
let runner_round_trip = LintRunner::new(multi_lint_fixture())
.filter_to_ids(&ids)
.expect("filter to ids");
assert_eq!(runner_round_trip.lints().len(), runner_direct.lints().len());
let now_unix = 1_700_000_000u64;
let direct = runner_direct.run_cert(&cert, crate::SubjectKind::Leaf, 0, now_unix);
let round = runner_round_trip.run_cert(&cert, crate::SubjectKind::Leaf, 0, now_unix);
let mut direct_sorted: Vec<_> = direct.iter().collect();
let mut round_sorted: Vec<_> = round.iter().collect();
direct_sorted.sort_by(|a, b| a.lint_id.cmp(&b.lint_id));
round_sorted.sort_by(|a, b| a.lint_id.cmp(&b.lint_id));
assert_eq!(direct_sorted.len(), round_sorted.len());
for (d, r) in direct_sorted.iter().zip(round_sorted.iter()) {
assert_eq!(d.lint_id, r.lint_id);
assert_eq!(d.result, r.result);
}
}
#[test]
fn filter_to_ids_errors_on_unknown_id() {
let runner = LintRunner::new(multi_lint_fixture());
let ids = vec!["test.policy.one".to_string(), "not.a.real.lint".to_string()];
match runner.filter_to_ids(&ids) {
Err(ParseError::UnknownLintId { id }) => {
assert_eq!(id, "not.a.real.lint");
}
other => panic!("expected UnknownLintId error; got: {other:?}"),
}
}
#[test]
fn filter_to_ids_preserves_id_order() {
let direct = multi_lint_fixture();
let mut reversed_ids: Vec<String> = direct.iter().map(|l| l.id().to_string()).collect();
reversed_ids.reverse();
let runner = LintRunner::new(multi_lint_fixture())
.filter_to_ids(&reversed_ids)
.expect("filter ok");
let observed: Vec<&str> = runner.lints().iter().map(|l| l.id()).collect();
let expected: Vec<&str> = reversed_ids.iter().map(String::as_str).collect();
assert_eq!(observed, expected);
}
#[test]
fn filter_to_ids_subset_drops_other_lints() {
let runner = LintRunner::new(multi_lint_fixture());
let ids = vec!["test.policy.one".to_string()];
let filtered = runner.filter_to_ids(&ids).expect("filter ok");
assert_eq!(filtered.lints().len(), 1);
assert_eq!(filtered.lints()[0].id(), "test.policy.one");
}
#[test]
fn filter_to_ids_preserves_bundle_version() {
let runner = LintRunner::with_bundle_version(multi_lint_fixture(), "v9.9.9");
let ids = vec!["test.policy.one".to_string()];
let filtered = runner.filter_to_ids(&ids).expect("filter ok");
assert_eq!(filtered.bundle_version(), "v9.9.9");
}
#[test]
fn lint_ids_from_catalog_rejects_non_object_root() {
let v: Value = serde_json::from_str("[]").unwrap();
match lint_ids_from_catalog(&v) {
Err(ParseError::CatalogNotObject) => {}
other => panic!("expected CatalogNotObject; got: {other:?}"),
}
}
#[test]
fn lint_ids_from_catalog_rejects_missing_wrapper() {
let v: Value = serde_json::json!({"not-catalog": {}});
match lint_ids_from_catalog(&v) {
Err(ParseError::CatalogMissingWrapper) => {}
other => panic!("expected CatalogMissingWrapper; got: {other:?}"),
}
}
#[test]
fn lint_ids_from_catalog_rejects_non_array_controls() {
let v: Value = serde_json::json!({
"catalog": {
"metadata": {"oscal-version": "1.1.2"},
"controls": "not-an-array",
}
});
match lint_ids_from_catalog(&v) {
Err(ParseError::ControlsNotArray) => {}
other => panic!("expected ControlsNotArray; got: {other:?}"),
}
}
#[test]
fn lint_ids_from_catalog_rejects_control_missing_id() {
let v: Value = serde_json::json!({
"catalog": {
"metadata": {"oscal-version": "1.1.2"},
"controls": [{"title": "no id here"}],
}
});
match lint_ids_from_catalog(&v) {
Err(ParseError::ControlMissingId { index: 0 }) => {}
other => panic!("expected ControlMissingId; got: {other:?}"),
}
}
#[test]
fn lint_ids_from_catalog_rejects_missing_oscal_version() {
let v: Value = serde_json::json!({"catalog": {"controls": []}});
match lint_ids_from_catalog(&v) {
Err(ParseError::MissingOscalVersion) => {}
other => panic!("expected MissingOscalVersion; got: {other:?}"),
}
}
#[test]
fn lint_ids_from_catalog_rejects_unsupported_oscal_version() {
for found in ["1.0.4", "1.2.0"] {
let v: Value = serde_json::json!({
"catalog": {
"metadata": {"oscal-version": found},
"controls": [],
}
});
match lint_ids_from_catalog(&v) {
Err(ParseError::UnsupportedOscalVersion { found: got }) => {
assert_eq!(got, found);
}
other => panic!(
"expected UnsupportedOscalVersion for version {found}; got: {other:?}"
),
}
}
}
#[test]
fn lint_ids_from_catalog_accepts_supported_oscal_version() {
let v: Value = serde_json::json!({
"catalog": {
"metadata": {"oscal-version": "1.1.2"},
"controls": [],
}
});
let ids = lint_ids_from_catalog(&v).expect("parse 1.1.2 catalog");
assert!(ids.is_empty());
}
}