1use serde::{Deserialize, Serialize};
18use std::sync::OnceLock;
19
20use super::policy::coverage::{CREDENTIAL_FIXTURES, DOMAIN_FIXTURES};
21use super::policy::ResolvedPolicy;
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)]
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, Serialize)]
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, Serialize)]
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, Serialize)]
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!("partial by design — unredacted classes: {}", missing.join(", ")),
317 )
318 } else {
319 (
320 RowStatus::NotEnforced,
321 format!("unredacted identifier classes: {}", missing.join(", ")),
322 )
323 }
324 }
325 "AIA-15.5-secrets" | "SOC2-C1.1" => {
326 let missing = missing_fixtures(CREDENTIAL_FIXTURES);
327 if missing.is_empty() {
328 (
329 RowStatus::Enforced,
330 format!(
331 "{}/{} credential fixture classes redacted",
332 CREDENTIAL_FIXTURES.len(),
333 CREDENTIAL_FIXTURES.len()
334 ),
335 )
336 } else {
337 (
338 RowStatus::NotEnforced,
339 format!("unredacted credential classes: {}", missing.join(", ")),
340 )
341 }
342 }
343 "AIA-15.5-access" | "ISO-A.9.4" | "SOC2-CC6.1" => {
344 if tool_surface_scoped {
345 (
346 RowStatus::Enforced,
347 format!(
348 "tool surface scoped (allow: {}, deny: {}) on top of the default-deny capability gate",
349 p.allow_tools.as_ref().map_or(0, Vec::len),
350 p.deny_tools.len()
351 ),
352 )
353 } else {
354 (
355 RowStatus::NotEnforced,
356 "pack neither allows nor denies tools — only the engine capability gate applies"
357 .to_string(),
358 )
359 }
360 }
361 "AIA-14.4e" => match p.max_context_tokens {
362 Some(cap) => (
363 RowStatus::Enforced,
364 format!("max_context_tokens = {cap} bounds every assembly"),
365 ),
366 None => (
367 RowStatus::NotEnforced,
368 "no hard context cap declared".to_string(),
369 ),
370 },
371 "SOC2-CC6.6" => {
372 if egress_denied {
373 (
374 RowStatus::Enforced,
375 format!("egress tools denied: {}", p.deny_tools.join(", ")),
376 )
377 } else {
378 (
379 RowStatus::NotEnforced,
380 "no egress tool denied by pack".to_string(),
381 )
382 }
383 }
384 "ISO-A.9.2" => {
385 let mut declared = Vec::new();
386 if p.max_context_tokens.is_some() {
387 declared.push("budget cap");
388 }
389 if p.audit_retention_days.is_some() {
390 declared.push("retention");
391 }
392 if tool_surface_scoped {
393 declared.push("tool scope");
394 }
395 if !p.redaction.is_empty() {
396 declared.push("redaction");
397 }
398 if declared.len() >= 3 {
399 (
400 RowStatus::Enforced,
401 format!("enforced process declared: {}", declared.join(", ")),
402 )
403 } else {
404 (
405 RowStatus::NotEnforced,
406 format!(
407 "pack declares too little process ({}) for an A.9.2 claim",
408 if declared.is_empty() {
409 "nothing".to_string()
410 } else {
411 declared.join(", ")
412 }
413 ),
414 )
415 }
416 }
417 other => (
418 RowStatus::NotVerified,
419 format!("no live check wired for pack-rule control {other} — fix the mapping or add a check"),
420 ),
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::core::policy::{builtin, resolve};
428
429 #[test]
430 fn all_mappings_parse_and_are_pinned() {
431 for m in frameworks() {
432 assert!(
433 !m.version_pin.is_empty(),
434 "{} missing version pin",
435 m.framework
436 );
437 assert!(!m.pinned_on.is_empty(), "{} missing pin date", m.framework);
438 assert!(m.review_cycle_months > 0);
439 assert!(
440 !m.disclaimer.is_empty(),
441 "{} missing disclaimer",
442 m.framework
443 );
444 assert!(!m.controls.is_empty());
445 }
446 assert_eq!(names(), vec!["eu-ai-act", "iso42001", "soc2"]);
447 }
448
449 #[test]
450 fn full_claims_carry_tests_and_partial_or_none_carry_gaps() {
451 for m in frameworks() {
452 for c in &m.controls {
453 match c.coverage {
454 Coverage::Full => {
455 assert!(
456 c.test.is_some(),
457 "{}/{} claims full coverage without a CI test (AC 2)",
458 m.framework,
459 c.id
460 );
461 assert!(c.mechanism != Mechanism::None);
462 }
463 Coverage::None => {
464 assert!(
465 c.gap.is_some(),
466 "{}/{} claims no coverage without documenting the gap",
467 m.framework,
468 c.id
469 );
470 assert_eq!(c.mechanism, Mechanism::None);
471 }
472 Coverage::Partial => {
473 assert!(
474 c.gap.is_some(),
475 "{}/{} partial coverage must document the residual gap",
476 m.framework,
477 c.id
478 );
479 }
480 }
481 }
482 }
483 }
484
485 #[test]
486 fn reference_packs_exist_and_enforce_every_pack_rule() {
487 for m in frameworks() {
488 let pack = builtin::get(&m.reference_pack).unwrap_or_else(|| {
489 panic!(
490 "{}: reference pack '{}' missing",
491 m.framework, m.reference_pack
492 )
493 });
494 let resolved = resolve(&pack).expect("reference pack resolves");
495 let rep = report(m, Some(&resolved));
496 for row in &rep.rows {
497 assert_ne!(
498 row.status,
499 RowStatus::NotEnforced,
500 "{}/{}: reference pack '{}' fails its own claim: {}",
501 m.framework,
502 row.id,
503 m.reference_pack,
504 row.detail
505 );
506 assert_ne!(
507 row.status,
508 RowStatus::NotVerified,
509 "{}/{}: full pack-rule claim without a wired live check",
510 m.framework,
511 row.id
512 );
513 }
514 }
515 }
516
517 #[test]
518 fn weak_pack_downgrades_pack_rule_claims() {
519 let m = get("eu-ai-act").unwrap();
522 let pack = builtin::get("open-source").unwrap();
523 let resolved = resolve(&pack).expect("resolves");
524 let rep = report(m, Some(&resolved));
525 let not_enforced = rep
526 .rows
527 .iter()
528 .filter(|r| r.status == RowStatus::NotEnforced)
529 .count();
530 assert!(
531 not_enforced >= 1,
532 "a weak pack must produce NotEnforced rows, got none"
533 );
534 }
535
536 #[test]
537 fn report_without_pack_marks_pack_rules_not_verified() {
538 let m = get("soc2").unwrap();
539 let rep = report(m, None);
540 assert!(rep
541 .rows
542 .iter()
543 .filter(|r| r.mechanism == Mechanism::PackRule)
544 .all(|r| r.status == RowStatus::NotVerified));
545 assert!(rep
547 .rows
548 .iter()
549 .any(|r| r.status == RowStatus::EngineGuarantee));
550 }
551}