//! Pull + snapshot [`ControlsProvider`] backed by breakpilot-compliance's OSCAL //! catalog export. //! //! Fetches `GET {base}/api/compliance/v1/oscal/catalog?framework=`, snapshots //! the exact bytes to disk (so scans are deterministic and keep working offline / //! on-prem), and maps the catalog into the corpus controls the mapping engine //! consumes. The producer owns the catalog; we own the assessment — this is the //! ingest half of the loop. use std::path::PathBuf; use secrecy::{ExposeSecret, SecretString}; use compliance_core::error::CoreError; use compliance_core::models::onboarding::ComplianceFramework; use compliance_core::models::oscal::OscalDocument; use compliance_core::traits::{Control, ControlQuery, ControlsProvider}; /// A [`ControlsProvider`] that pulls the OSCAL catalog from breakpilot-compliance /// and snapshots it locally for deterministic / offline reuse. pub struct OscalControlsProvider { http: reqwest::Client, base_url: String, token: Option, snapshot_dir: PathBuf, } impl OscalControlsProvider { /// Create a provider. `base_url` is the breakpilot-compliance root (e.g. /// `http://backend-compliance:8002`); `snapshot_dir` is where catalog /// snapshots are written so a later scan can reuse them without the network. pub fn new( http: reqwest::Client, base_url: impl Into, token: Option, snapshot_dir: impl Into, ) -> Self { Self { http, base_url: base_url.into(), token, snapshot_dir: snapshot_dir.into(), } } fn catalog_url(&self, framework: &str) -> String { format!( "{}/api/compliance/v1/oscal/catalog?framework={framework}", self.base_url.trim_end_matches('/') ) } fn snapshot_path(&self, framework: &str) -> PathBuf { self.snapshot_dir .join(format!("oscal-catalog-{framework}.json")) } /// Fetch the raw catalog bytes for a framework token over HTTP. async fn fetch_raw(&self, framework: &str) -> Result, CoreError> { let mut req = self.http.get(self.catalog_url(framework)); if let Some(token) = &self.token { req = req.bearer_auth(token.expose_secret()); } let resp = req .send() .await .map_err(|e| CoreError::Http(e.to_string()))?; if !resp.status().is_success() { return Err(CoreError::Http(format!( "catalog fetch for {framework} returned HTTP {}", resp.status() ))); } resp.bytes() .await .map(|b| b.to_vec()) .map_err(|e| CoreError::Http(e.to_string())) } /// Write a catalog snapshot atomically (temp file + rename). async fn write_snapshot(&self, framework: &str, raw: &[u8]) -> Result<(), CoreError> { tokio::fs::create_dir_all(&self.snapshot_dir).await?; let path = self.snapshot_path(framework); let tmp = path.with_extension("json.tmp"); tokio::fs::write(&tmp, raw).await?; tokio::fs::rename(&tmp, &path).await?; Ok(()) } /// Read a previously written snapshot, if one exists. async fn read_snapshot(&self, framework: &str) -> Result, CoreError> { match tokio::fs::read(self.snapshot_path(framework)).await { Ok(raw) => Ok(Some(serde_json::from_slice(&raw)?)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Load the catalog for a framework token: fetch fresh + snapshot the exact /// bytes; on network failure, fall back to the last snapshot so scans run. async fn load_token(&self, framework: &str) -> Result { match self.fetch_raw(framework).await { Ok(raw) => { let doc: OscalDocument = serde_json::from_slice(&raw)?; if let Err(e) = self.write_snapshot(framework, &raw).await { tracing::warn!(framework, error = %e, "failed to write OSCAL snapshot"); } Ok(doc) } Err(fetch_err) => match self.read_snapshot(framework).await? { Some(doc) => { tracing::warn!( framework, error = %fetch_err, "OSCAL catalog fetch failed; falling back to snapshot" ); Ok(doc) } None => Err(fetch_err), }, } } /// Load the OSCAL catalog for a compliance framework. pub async fn load(&self, framework: ComplianceFramework) -> Result { self.load_token(&framework.to_string()).await } /// Load the code-checkable master-controls catalog /// (`?framework=master-controls`). pub async fn load_master_controls(&self) -> Result { self.load_token("master-controls").await } } /// Order controls whose title/text mention the query context first (stable), then /// truncate to the requested limit. Naive relevance — refined when the assessment /// layer lands. fn rank_and_truncate(mut controls: Vec, context: &str, limit: usize) -> Vec { if !context.is_empty() { let needle = context.to_lowercase(); controls.sort_by_key(|c| { let hit = c.title.to_lowercase().contains(&needle) || c.text.to_lowercase().contains(&needle); u8::from(!hit) }); } controls.truncate(limit); controls } impl ControlsProvider for OscalControlsProvider { fn name(&self) -> &str { "breakpilot-oscal" } async fn controls(&self, query: &ControlQuery<'_>) -> Result, CoreError> { let mut out: Vec = Vec::new(); for &framework in query.frameworks { match self.load(framework).await { Ok(doc) => out.extend(doc.to_controls()), Err(e) => { tracing::warn!(%framework, error = %e, "skipping framework: catalog unavailable") } } } Ok(rank_and_truncate(out, query.context, query.limit)) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; const MINI_CATALOG: &str = r#"{"catalog":{"uuid":"u","metadata":{"title":"T", "version":"1.0.0","oscal-version":"1.1.2","props":[{"name":"framework","value":"cra"}]}, "groups":[{"id":"g","title":"G","controls":[{"id":"cra-ai-1","title":"MFA", "props":[],"parts":[{"name":"statement","prose":"require mfa"}]}]}]}}"#; fn provider(dir: &std::path::Path) -> OscalControlsProvider { OscalControlsProvider::new(reqwest::Client::new(), "http://unused/", None, dir) } #[test] fn builds_catalog_url_and_snapshot_path() { let p = provider(std::path::Path::new("/snap")); assert_eq!( p.catalog_url("cra"), "http://unused/api/compliance/v1/oscal/catalog?framework=cra" ); assert_eq!( p.snapshot_path("cra"), std::path::Path::new("/snap/oscal-catalog-cra.json") ); } #[test] fn ranks_context_hits_first_then_truncates() { let mk = |id: &str, title: &str| Control { id: id.into(), framework: ComplianceFramework::Cra, title: title.into(), text: String::new(), source: None, }; let controls = vec![ mk("a", "logging policy"), mk("b", "multi-factor auth"), mk("c", "backup"), ]; let ranked = rank_and_truncate(controls, "auth", 2); assert_eq!(ranked.len(), 2); assert_eq!(ranked[0].id, "b"); // the "auth" hit floats to the top } #[tokio::test] async fn snapshot_round_trip_and_offline_fallback() { let dir = std::env::temp_dir().join(format!("oscal-test-{}", uuid::Uuid::new_v4())); let p = provider(&dir); assert!(p.read_snapshot("cra").await.unwrap().is_none()); p.write_snapshot("cra", MINI_CATALOG.as_bytes()) .await .unwrap(); let doc = p.read_snapshot("cra").await.unwrap().unwrap(); assert_eq!(doc.to_controls().len(), 1); assert_eq!(doc.framework(), Some(ComplianceFramework::Cra)); let _ = std::fs::remove_dir_all(&dir); } }