//! OSCAL 1.1 catalog types + mapping into the controls corpus. //! //! Deserialises the OSCAL catalog served by breakpilot-compliance //! (`GET /api/compliance/v1/oscal/catalog`) and maps its controls into the //! framework-agnostic [`crate::traits::Control`] that the mapping engine consumes. //! Only the fields we use are modelled; unknown OSCAL fields are ignored so the //! producer can add detail without breaking us. //! //! Scope boundary: this is the *catalog* (domain content). Assessment objectives //! and scanner routing live in our assessment layer, not here — see //! [`crate::traits::ControlsProvider`]. use serde::Deserialize; use crate::models::onboarding::ComplianceFramework; use crate::traits::Control as CorpusControl; /// A parsed OSCAL catalog document (`{"catalog": {...}}`). #[derive(Debug, Clone, Deserialize)] pub struct OscalDocument { pub catalog: Catalog, } /// An OSCAL catalog: metadata + a tree of control groups. #[derive(Debug, Clone, Deserialize)] pub struct Catalog { pub uuid: String, pub metadata: Metadata, #[serde(default)] pub groups: Vec, #[serde(rename = "back-matter", default)] pub back_matter: Option, } /// Catalog metadata (title/version + provenance props). #[derive(Debug, Clone, Deserialize)] pub struct Metadata { pub title: String, pub version: String, #[serde(rename = "oscal-version")] pub oscal_version: String, #[serde(default)] pub props: Vec, } /// A name/value property, optionally namespaced. #[derive(Debug, Clone, Deserialize)] pub struct Prop { pub name: String, pub value: String, #[serde(default)] pub ns: Option, } /// A control group (may nest sub-groups and controls). #[derive(Debug, Clone, Deserialize)] pub struct Group { #[serde(default)] pub id: String, #[serde(default)] pub title: String, #[serde(default)] pub controls: Vec, #[serde(default)] pub groups: Vec, } /// An OSCAL control (may nest enhancement controls). #[derive(Debug, Clone, Deserialize)] pub struct Control { pub id: String, #[serde(default)] pub title: String, #[serde(default)] pub props: Vec, #[serde(default)] pub parts: Vec, #[serde(default)] pub links: Vec, #[serde(default)] pub controls: Vec, } /// A control part (e.g. the `statement`), may nest sub-parts. #[derive(Debug, Clone, Deserialize)] pub struct Part { #[serde(default)] pub name: String, #[serde(default)] pub prose: Option, #[serde(default)] pub parts: Vec, } /// A link, e.g. a `reference` to a back-matter resource. #[derive(Debug, Clone, Deserialize)] pub struct Link { pub href: String, #[serde(default)] pub rel: Option, } /// Back-matter holding referenced resources (e.g. the CRA measures). #[derive(Debug, Clone, Deserialize)] pub struct BackMatter { #[serde(default)] pub resources: Vec, } /// A back-matter resource referenced by control links. #[derive(Debug, Clone, Deserialize)] pub struct Resource { pub uuid: String, #[serde(default)] pub title: Option, #[serde(default)] pub description: Option, } impl Metadata { /// First prop value with the given name. pub fn prop(&self, name: &str) -> Option<&str> { self.props .iter() .find(|p| p.name == name) .map(|p| p.value.as_str()) } } impl Control { /// First prop value with the given name. pub fn prop(&self, name: &str) -> Option<&str> { self.props .iter() .find(|p| p.name == name) .map(|p| p.value.as_str()) } /// The control's `statement` prose, if present. pub fn statement(&self) -> Option<&str> { self.parts .iter() .find(|p| p.name == "statement") .and_then(|p| p.prose.as_deref()) } } impl OscalDocument { /// The framework this catalog declares (`metadata.props[name="framework"]`). pub fn framework(&self) -> Option { framework_from_str(self.catalog.metadata.prop("framework")?) } /// The catalog `content-hash` prop — consumers pin this to snapshot/detect drift. pub fn content_hash(&self) -> Option<&str> { self.catalog.metadata.prop("content-hash") } /// Flatten the catalog into the corpus controls the mapping engine consumes. pub fn to_controls(&self) -> Vec { let framework = self.framework().unwrap_or(ComplianceFramework::Cra); let source_label = self.catalog.metadata.title.as_str(); let mut out = Vec::new(); for group in &self.catalog.groups { collect_group(group, framework, source_label, &mut out); } out } } /// Map an OSCAL framework token (e.g. `"cra"`) to [`ComplianceFramework`] via its /// serde snake_case representation. fn framework_from_str(raw: &str) -> Option { serde_json::from_value(serde_json::Value::String(raw.to_string())).ok() } fn collect_group( group: &Group, framework: ComplianceFramework, source_label: &str, out: &mut Vec, ) { for control in &group.controls { collect_control(control, framework, source_label, out); } for sub in &group.groups { collect_group(sub, framework, source_label, out); } } fn collect_control( control: &Control, framework: ComplianceFramework, source_label: &str, out: &mut Vec, ) { let source = match control.prop("annex-anchor") { Some(anchor) => Some(format!("{source_label} · {anchor}")), None => Some(source_label.to_string()), }; out.push(CorpusControl { id: control.id.clone(), framework, title: control.title.clone(), text: control.statement().unwrap_or_default().to_string(), source, }); for enhancement in &control.controls { collect_control(enhancement, framework, source_label, out); } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; const CATALOG: &str = include_str!("../../tests/data/cra_catalog.json"); fn parse() -> OscalDocument { serde_json::from_str(CATALOG).unwrap() } #[test] fn parses_full_catalog() { let doc = parse(); assert_eq!(doc.catalog.metadata.oscal_version, "1.1.2"); assert!(!doc.catalog.groups.is_empty()); assert!(doc.catalog.back_matter.is_some()); } #[test] fn maps_all_controls_to_corpus() { let doc = parse(); let controls = doc.to_controls(); assert_eq!(controls.len(), 40); assert_eq!(doc.framework(), Some(ComplianceFramework::Cra)); let c8 = controls.iter().find(|c| c.id == "cra-ai-8").unwrap(); assert_eq!(c8.framework, ComplianceFramework::Cra); assert!(!c8.title.is_empty()); assert!(!c8.text.is_empty(), "statement prose should map into text"); assert!(c8.source.as_deref().unwrap_or_default().contains("Annex I")); } #[test] fn exposes_content_hash_for_snapshotting() { assert_eq!(parse().content_hash().map(str::len), Some(64)); } }