1use serde::{Deserialize, Serialize};
18use std::sync::OnceLock;
19
20use super::policy::ResolvedPolicy;
21use super::policy::coverage::{CREDENTIAL_FIXTURES, DOMAIN_FIXTURES};
22
23const MAPPING_SOURCES: &[(&str, &str)] = &[
25 (
26 "eu-ai-act",
27 include_str!("../../data/compliance/mappings/eu-ai-act.toml"),
28 ),
29 (
30 "iso42001",
31 include_str!("../../data/compliance/mappings/iso42001.toml"),
32 ),
33 (
34 "soc2",
35 include_str!("../../data/compliance/mappings/soc2.toml"),
36 ),
37];
38
39#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(deny_unknown_fields)]
43pub struct FrameworkMapping {
44 pub framework: String,
45 pub title: String,
46 pub version_pin: String,
48 pub pinned_on: String,
50 pub review_cycle_months: u32,
51 pub disclaimer: String,
52 pub reference_pack: String,
54 pub controls: Vec<ControlMapping>,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct ControlMapping {
60 pub id: String,
62 pub clause: String,
64 pub requirement: String,
66 pub mechanism: Mechanism,
67 pub leanctx: String,
69 pub evidence: String,
71 pub coverage: Coverage,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub gap: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub test: Option<String>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Mechanism {
83 PackRule,
85 AuditEvent,
87 EvidenceExport,
89 Slo,
91 None,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
96#[serde(rename_all = "snake_case")]
97pub enum Coverage {
98 Full,
99 Partial,
100 None,
101}
102
103fn registry() -> &'static Vec<FrameworkMapping> {
106 static REGISTRY: OnceLock<Vec<FrameworkMapping>> = OnceLock::new();
107 REGISTRY.get_or_init(|| {
108 MAPPING_SOURCES
109 .iter()
110 .map(|(id, toml_text)| {
111 let m: FrameworkMapping = toml::from_str(toml_text)
114 .unwrap_or_else(|e| panic!("compliance mapping '{id}' is invalid: {e}"));
115 assert_eq!(&m.framework, id, "mapping id/file mismatch for '{id}'");
116 m
117 })
118 .collect()
119 })
120}
121
122pub fn frameworks() -> &'static [FrameworkMapping] {
124 registry()
125}
126
127pub fn names() -> Vec<&'static str> {
129 registry().iter().map(|m| m.framework.as_str()).collect()
130}
131
132pub fn get(framework: &str) -> Option<&'static FrameworkMapping> {
134 registry().iter().find(|m| m.framework == framework)
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum RowStatus {
143 Enforced,
145 EngineGuarantee,
147 NotEnforced,
149 NotVerified,
151 Gap,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct ReportRow {
157 pub id: String,
158 pub clause: String,
159 pub requirement: String,
160 pub coverage: Coverage,
161 pub mechanism: Mechanism,
162 pub status: RowStatus,
163 pub detail: String,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub test: Option<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct ReportSummary {
170 pub controls_total: usize,
171 pub full_claimed: usize,
172 pub enforced: usize,
173 pub engine_guarantee: usize,
174 pub not_enforced: usize,
175 pub not_verified: usize,
176 pub gaps: usize,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct FrameworkReport {
181 pub framework: String,
182 pub title: String,
183 pub version_pin: String,
184 pub pinned_on: String,
185 pub disclaimer: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub pack: Option<String>,
188 pub rows: Vec<ReportRow>,
189 pub summary: ReportSummary,
190}
191
192pub fn report(mapping: &FrameworkMapping, policy: Option<&ResolvedPolicy>) -> FrameworkReport {
195 let rows: Vec<ReportRow> = mapping
196 .controls
197 .iter()
198 .map(|c| {
199 let (status, detail) = row_status(c, policy);
200 ReportRow {
201 id: c.id.clone(),
202 clause: c.clause.clone(),
203 requirement: c.requirement.clone(),
204 coverage: c.coverage,
205 mechanism: c.mechanism,
206 status,
207 detail,
208 test: c.test.clone(),
209 }
210 })
211 .collect();
212
213 let count = |s: RowStatus| rows.iter().filter(|r| r.status == s).count();
214 let summary = ReportSummary {
215 controls_total: rows.len(),
216 full_claimed: rows.iter().filter(|r| r.coverage == Coverage::Full).count(),
217 enforced: count(RowStatus::Enforced),
218 engine_guarantee: count(RowStatus::EngineGuarantee),
219 not_enforced: count(RowStatus::NotEnforced),
220 not_verified: count(RowStatus::NotVerified),
221 gaps: count(RowStatus::Gap),
222 };
223
224 FrameworkReport {
225 framework: mapping.framework.clone(),
226 title: mapping.title.clone(),
227 version_pin: mapping.version_pin.clone(),
228 pinned_on: mapping.pinned_on.clone(),
229 disclaimer: mapping.disclaimer.clone(),
230 pack: policy.map(|p| format!("{} v{}", p.name, p.version)),
231 rows,
232 summary,
233 }
234}
235
236fn row_status(control: &ControlMapping, policy: Option<&ResolvedPolicy>) -> (RowStatus, String) {
237 match control.mechanism {
238 Mechanism::None => (
239 RowStatus::Gap,
240 control
241 .gap
242 .clone()
243 .unwrap_or_else(|| "documented gap".to_string()),
244 ),
245 Mechanism::AuditEvent | Mechanism::EvidenceExport | Mechanism::Slo => (
246 RowStatus::EngineGuarantee,
247 match &control.test {
248 Some(t) => format!("{} — CI: {t}", control.leanctx),
249 None => control.leanctx.clone(),
250 },
251 ),
252 Mechanism::PackRule => match policy {
253 None => (
254 RowStatus::NotVerified,
255 "pack rule — pass a pack to verify enforcement".to_string(),
256 ),
257 Some(p) => verify_pack_rule(control, p),
258 },
259 }
260}
261
262fn verify_pack_rule(control: &ControlMapping, p: &ResolvedPolicy) -> (RowStatus, String) {
267 let patterns: Vec<regex::Regex> = p
268 .redaction
269 .values()
270 .filter_map(|raw| regex::Regex::new(raw).ok())
271 .collect();
272 let matches = |fixture: &str| patterns.iter().any(|re| re.is_match(fixture));
273 let missing_fixtures = |set: &'static [(&'static str, &'static str)]| -> Vec<&'static str> {
274 set.iter()
275 .filter(|(_, fx)| !matches(fx))
276 .map(|(class, _)| *class)
277 .collect()
278 };
279 let tool_surface_scoped = p.allow_tools.is_some() || !p.deny_tools.is_empty();
280 let egress_denied = p.deny_tools.iter().any(|t| {
281 ["url", "web", "fetch", "http"]
282 .iter()
283 .any(|hint| t.contains(hint))
284 });
285
286 match control.id.as_str() {
287 "AIA-26.6" => match p.audit_retention_days {
288 Some(days) if days >= 180 => (
289 RowStatus::Enforced,
290 format!("audit_retention_days = {days} (≥ 180 d / six months)"),
291 ),
292 Some(days) => (
293 RowStatus::NotEnforced,
294 format!("audit_retention_days = {days} < 180 — below Art. 26(6) minimum"),
295 ),
296 None => (
297 RowStatus::NotEnforced,
298 "pack declares no audit retention".to_string(),
299 ),
300 },
301 "AIA-10.5" | "ISO-A.7.4" => {
302 let missing = missing_fixtures(DOMAIN_FIXTURES);
303 if missing.is_empty() {
304 (
305 RowStatus::Enforced,
306 format!(
307 "{}/{} regulated-identifier fixture classes redacted",
308 DOMAIN_FIXTURES.len(),
309 DOMAIN_FIXTURES.len()
310 ),
311 )
312 } else if control.coverage == Coverage::Partial && missing.len() < DOMAIN_FIXTURES.len()
313 {
314 (
315 RowStatus::Enforced,
316 format!(
317 "partial by design — unredacted classes: {}",
318 missing.join(", ")
319 ),
320 )
321 } else {
322 (
323 RowStatus::NotEnforced,
324 format!("unredacted identifier classes: {}", missing.join(", ")),
325 )
326 }
327 }
328 "AIA-15.5-secrets" | "SOC2-C1.1" => {
329 let missing = missing_fixtures(CREDENTIAL_FIXTURES);
330 if missing.is_empty() {
331 (
332 RowStatus::Enforced,
333 format!(
334 "{}/{} credential fixture classes redacted",
335 CREDENTIAL_FIXTURES.len(),
336 CREDENTIAL_FIXTURES.len()
337 ),
338 )
339 } else {
340 (
341 RowStatus::NotEnforced,
342 format!("unredacted credential classes: {}", missing.join(", ")),
343 )
344 }
345 }
346 "AIA-15.5-access" | "ISO-A.9.4" | "SOC2-CC6.1" => {
347 if tool_surface_scoped {
348 (
349 RowStatus::Enforced,
350 format!(
351 "tool surface scoped (allow: {}, deny: {}) on top of the default-deny capability gate",
352 p.allow_tools.as_ref().map_or(0, Vec::len),
353 p.deny_tools.len()
354 ),
355 )
356 } else {
357 (
358 RowStatus::NotEnforced,
359 "pack neither allows nor denies tools — only the engine capability gate applies"
360 .to_string(),
361 )
362 }
363 }
364 "AIA-14.4e" => match p.max_context_tokens {
365 Some(cap) => (
366 RowStatus::Enforced,
367 format!("max_context_tokens = {cap} bounds every assembly"),
368 ),
369 None => (
370 RowStatus::NotEnforced,
371 "no hard context cap declared".to_string(),
372 ),
373 },
374 "SOC2-CC6.6" => {
375 if egress_denied {
376 (
377 RowStatus::Enforced,
378 format!("egress tools denied: {}", p.deny_tools.join(", ")),
379 )
380 } else {
381 (
382 RowStatus::NotEnforced,
383 "no egress tool denied by pack".to_string(),
384 )
385 }
386 }
387 "ISO-A.9.2" => {
388 let mut declared = Vec::new();
389 if p.max_context_tokens.is_some() {
390 declared.push("budget cap");
391 }
392 if p.audit_retention_days.is_some() {
393 declared.push("retention");
394 }
395 if tool_surface_scoped {
396 declared.push("tool scope");
397 }
398 if !p.redaction.is_empty() {
399 declared.push("redaction");
400 }
401 if declared.len() >= 3 {
402 (
403 RowStatus::Enforced,
404 format!("enforced process declared: {}", declared.join(", ")),
405 )
406 } else {
407 (
408 RowStatus::NotEnforced,
409 format!(
410 "pack declares too little process ({}) for an A.9.2 claim",
411 if declared.is_empty() {
412 "nothing".to_string()
413 } else {
414 declared.join(", ")
415 }
416 ),
417 )
418 }
419 }
420 other => (
421 RowStatus::NotVerified,
422 format!(
423 "no live check wired for pack-rule control {other} — fix the mapping or add a check"
424 ),
425 ),
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::core::policy::{builtin, resolve};
433
434 #[test]
435 fn all_mappings_parse_and_are_pinned() {
436 for m in frameworks() {
437 assert!(
438 !m.version_pin.is_empty(),
439 "{} missing version pin",
440 m.framework
441 );
442 assert!(!m.pinned_on.is_empty(), "{} missing pin date", m.framework);
443 assert!(m.review_cycle_months > 0);
444 assert!(
445 !m.disclaimer.is_empty(),
446 "{} missing disclaimer",
447 m.framework
448 );
449 assert!(!m.controls.is_empty());
450 }
451 assert_eq!(names(), vec!["eu-ai-act", "iso42001", "soc2"]);
452 }
453
454 #[test]
455 fn full_claims_carry_tests_and_partial_or_none_carry_gaps() {
456 for m in frameworks() {
457 for c in &m.controls {
458 match c.coverage {
459 Coverage::Full => {
460 assert!(
461 c.test.is_some(),
462 "{}/{} claims full coverage without a CI test (AC 2)",
463 m.framework,
464 c.id
465 );
466 assert!(c.mechanism != Mechanism::None);
467 }
468 Coverage::None => {
469 assert!(
470 c.gap.is_some(),
471 "{}/{} claims no coverage without documenting the gap",
472 m.framework,
473 c.id
474 );
475 assert_eq!(c.mechanism, Mechanism::None);
476 }
477 Coverage::Partial => {
478 assert!(
479 c.gap.is_some(),
480 "{}/{} partial coverage must document the residual gap",
481 m.framework,
482 c.id
483 );
484 }
485 }
486 }
487 }
488 }
489
490 #[test]
491 fn reference_packs_exist_and_enforce_every_pack_rule() {
492 for m in frameworks() {
493 let pack = builtin::get(&m.reference_pack).unwrap_or_else(|| {
494 panic!(
495 "{}: reference pack '{}' missing",
496 m.framework, m.reference_pack
497 )
498 });
499 let resolved = resolve(&pack).expect("reference pack resolves");
500 let rep = report(m, Some(&resolved));
501 for row in &rep.rows {
502 assert_ne!(
503 row.status,
504 RowStatus::NotEnforced,
505 "{}/{}: reference pack '{}' fails its own claim: {}",
506 m.framework,
507 row.id,
508 m.reference_pack,
509 row.detail
510 );
511 assert_ne!(
512 row.status,
513 RowStatus::NotVerified,
514 "{}/{}: full pack-rule claim without a wired live check",
515 m.framework,
516 row.id
517 );
518 }
519 }
520 }
521
522 #[test]
523 fn weak_pack_downgrades_pack_rule_claims() {
524 let m = get("eu-ai-act").unwrap();
527 let pack = builtin::get("open-source").unwrap();
528 let resolved = resolve(&pack).expect("resolves");
529 let rep = report(m, Some(&resolved));
530 let not_enforced = rep
531 .rows
532 .iter()
533 .filter(|r| r.status == RowStatus::NotEnforced)
534 .count();
535 assert!(
536 not_enforced >= 1,
537 "a weak pack must produce NotEnforced rows, got none"
538 );
539 }
540
541 #[test]
542 fn report_without_pack_marks_pack_rules_not_verified() {
543 let m = get("soc2").unwrap();
544 let rep = report(m, None);
545 assert!(
546 rep.rows
547 .iter()
548 .filter(|r| r.mechanism == Mechanism::PackRule)
549 .all(|r| r.status == RowStatus::NotVerified)
550 );
551 assert!(
553 rep.rows
554 .iter()
555 .any(|r| r.status == RowStatus::EngineGuarantee)
556 );
557 }
558}