1use crate::model::{ConformityRoute, CraProductClass, CraSidecarMetadata, NormalizedSbom};
16use crate::pipeline::parse_sbom_with_context;
17use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
18use anyhow::{Context, Result};
19use std::path::PathBuf;
20
21const DOSSIER_FILES: [&str; 3] = [
23 "eu-declaration-of-conformity.md",
24 "technical-documentation.md",
25 "vulnerability-handling-policy.md",
26];
27
28#[allow(clippy::needless_pass_by_value)]
34pub fn run_cra_docs(
35 sbom_path: PathBuf,
36 output_dir: PathBuf,
37 cra_sidecar_path: Option<PathBuf>,
38 cra_product_class: Option<String>,
39) -> Result<()> {
40 run_cra_docs_with_force(
41 sbom_path,
42 output_dir,
43 cra_sidecar_path,
44 cra_product_class,
45 false,
46 )
47}
48
49#[allow(clippy::needless_pass_by_value)]
56pub fn run_cra_docs_with_force(
57 sbom_path: PathBuf,
58 output_dir: PathBuf,
59 cra_sidecar_path: Option<PathBuf>,
60 cra_product_class: Option<String>,
61 force: bool,
62) -> Result<()> {
63 if !force {
64 let existing: Vec<String> = DOSSIER_FILES
65 .iter()
66 .map(|name| output_dir.join(name))
67 .filter(|p| p.exists())
68 .map(|p| format!(" {}", p.display()))
69 .collect();
70 if !existing.is_empty() {
71 anyhow::bail!(
72 "refusing to overwrite existing dossier file(s) (they may contain hand-completed \
73 content):\n{}\nPass --force to overwrite.",
74 existing.join("\n")
75 );
76 }
77 }
78
79 let parsed = parse_sbom_with_context(&sbom_path, false)?;
80 let sbom = parsed.sbom();
81
82 let sidecar = super::load_cra_sidecar(cra_sidecar_path.as_deref(), &sbom_path)?;
85
86 let cli_class = super::parse_cra_product_class(cra_product_class.as_deref())?;
89 let sidecar_class = sidecar.as_ref().and_then(|s| s.product_class);
90 if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
91 && cli != side
92 {
93 tracing::warn!(
94 "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
95 cli.label(),
96 side.label()
97 );
98 }
99 let effective_class = sidecar_class
100 .or(cli_class)
101 .unwrap_or(CraProductClass::Default);
102
103 let mut checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
105 if let Some(sc) = sidecar.clone() {
106 checker = checker.with_sidecar(sc);
107 }
108 checker = checker.with_product_class(effective_class);
109 let compliance = checker.check(sbom);
110 let route = checker.effective_route();
111
112 std::fs::create_dir_all(&output_dir)
113 .with_context(|| format!("creating output directory {}", output_dir.display()))?;
114
115 write_doc(
116 &output_dir.join(DOSSIER_FILES[0]),
117 &render_doc(sbom, sidecar.as_ref(), effective_class, route),
118 )?;
119 write_doc(
120 &output_dir.join(DOSSIER_FILES[1]),
121 &render_tech_doc(sbom, sidecar.as_ref(), effective_class, route, &compliance),
122 )?;
123 write_doc(
124 &output_dir.join(DOSSIER_FILES[2]),
125 &render_vuln_policy(sbom, sidecar.as_ref()),
126 )?;
127
128 println!(
129 "CRA dossier written to {} ({} files)",
130 output_dir.display(),
131 3
132 );
133 Ok(())
134}
135
136fn write_doc(path: &std::path::Path, content: &str) -> Result<()> {
137 std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))
138}
139
140fn render_doc(
142 sbom: &NormalizedSbom,
143 sidecar: Option<&CraSidecarMetadata>,
144 class: CraProductClass,
145 route: ConformityRoute,
146) -> String {
147 let manufacturer = sidecar
148 .and_then(|s| s.manufacturer_name.as_deref())
149 .or_else(|| {
150 sbom.document
151 .creators
152 .iter()
153 .find(|c| matches!(c.creator_type, crate::model::CreatorType::Organization))
154 .map(|c| c.name.as_str())
155 })
156 .unwrap_or("_TBD: manufacturer name_");
157 let manufacturer_email = sidecar
158 .and_then(|s| s.manufacturer_email.as_deref())
159 .unwrap_or("_TBD: manufacturer email_");
160 let product_name = sidecar
161 .and_then(|s| s.product_name.as_deref())
162 .or(sbom.document.name.as_deref())
163 .unwrap_or("_TBD: product name_");
164 let product_version = sidecar
165 .and_then(|s| s.product_version.as_deref())
166 .unwrap_or("_TBD: product version_");
167 let ce_marking = sidecar
168 .and_then(|s| s.ce_marking_reference.as_deref())
169 .unwrap_or("_TBD: CE marking reference / DoC document ID_");
170
171 let other_legislation = if sidecar.is_some_and(|s| s.is_high_risk_ai) {
176 "- Regulation (EU) 2024/1689 (AI Act) — high-risk AI system: \
177 draw up Annex IV technical documentation. Run \
178 `sbom-tools validate <sbom> --standard ai-act --cra-sidecar <sidecar>` \
179 for an Annex IV documentation-readiness check (readiness only, not a \
180 legal-conformity guarantee).\n\
181 - _TBD: list any other applicable EU regulations (NIS2, GDPR, …)_"
182 } else {
183 "- _TBD: list any other applicable EU regulations (NIS2, GDPR, AI Act, …)_"
184 };
185
186 format!(
187 "# EU Declaration of Conformity (Cyber Resilience Act, Annex V)\n\n\
188 > **Generated by sbom-tools cra-docs.** Review and complete the `_TBD_` \
189 placeholders before relying on this document for conformity assessment.\n\n\
190 **1. Product**\n\
191 - Name: {product_name}\n\
192 - Version: {product_version}\n\
193 - CE marking / DoC reference: {ce_marking}\n\
194 - CRA product class: {class_name}\n\n\
195 **2. Manufacturer**\n\
196 - Name: {manufacturer}\n\
197 - Contact: {manufacturer_email}\n\
198 - Address: _TBD: registered office address_\n\n\
199 **3. Conformity-assessment route (CRA Annex VIII)**\n\
200 - Route: {route_name}\n\
201 - Notified body (if applicable): _TBD: notified body name + 4-digit number_\n\
202 - Certificate / attestation reference: _TBD_\n\n\
203 **4. Applicable CRA requirements**\n\
204 - Regulation (EU) 2024/2847 — Annex I (Essential cybersecurity requirements)\n\
205 - Annex I Part I — Cybersecurity requirements relating to the properties of products with digital elements\n\
206 - Annex I Part II — Vulnerability-handling requirements\n\
207 - Annex II — Information and instructions to the user\n\n\
208 **5. Harmonised standards / common specifications applied**\n\
209 - prEN 40000-1-3 (horizontal SBOM and vulnerability-handling requirements)\n\
210 - BSI TR-03183-2 (German national CRA-aligned SBOM technical guideline)\n\
211 - _TBD: any vertical EN 304-6xx product-class standards applied_\n\n\
212 **6. Other Union legislation in conjunction with which conformity is declared**\n\
213 {other_legislation}\n\n\
214 **7. Signed for and on behalf of the manufacturer**\n\
215 - Place: _TBD_\n\
216 - Date: _TBD_\n\
217 - Name and function: _TBD_\n\
218 - Signature: _TBD_\n",
219 product_name = product_name,
220 product_version = product_version,
221 ce_marking = ce_marking,
222 class_name = class.name(),
223 manufacturer = manufacturer,
224 manufacturer_email = manufacturer_email,
225 route_name = route.name(),
226 other_legislation = other_legislation,
227 )
228}
229
230fn render_tech_doc(
232 sbom: &NormalizedSbom,
233 sidecar: Option<&CraSidecarMetadata>,
234 class: CraProductClass,
235 route: ConformityRoute,
236 compliance: &ComplianceResult,
237) -> String {
238 let component_count = sbom.components.len();
239 let dependency_count = sbom.edges.len();
240 let format_label = format!("{:?} {}", sbom.document.format, sbom.document.spec_version);
241 let risk_assessment = sidecar
242 .and_then(|s| s.risk_assessment_url.as_deref())
243 .unwrap_or("_TBD: link to documented risk assessment (CRA Art. 13(2))_");
244 let methodology = sidecar
245 .and_then(|s| s.risk_assessment_methodology.as_deref())
246 .unwrap_or("_TBD: e.g., ISO/IEC 27005:2022_");
247 let psirt = sidecar
248 .and_then(|s| s.psirt_url.as_deref())
249 .unwrap_or("_TBD: PSIRT URL (CRA Art. 14)_");
250 let support_end = sidecar
251 .and_then(|s| s.support_end_date)
252 .map(|d| d.format("%Y-%m-%d").to_string())
253 .unwrap_or_else(|| "_TBD: support end date (CRA Art. 13(8))_".to_string());
254
255 let adjacent_regulation_md = render_adjacent_regulation_section(sidecar);
256 let controls_assertion_md = render_controls_assertion_section(sidecar);
257
258 let mut violations_md = String::new();
259 if compliance.violations.is_empty() {
260 violations_md.push_str("_No CRA compliance issues detected by sbom-tools._\n");
261 } else {
262 let errors = compliance.error_count;
263 let warnings = compliance.warning_count;
264 let infos = compliance.info_count;
265 violations_md.push_str(&format!(
266 "**Compliance check summary** ({errors} errors, {warnings} warnings, {infos} info):\n\n"
267 ));
268 for v in compliance.violations.iter().take(10) {
269 let sev = match v.severity {
270 crate::quality::ViolationSeverity::Error => "ERROR",
271 crate::quality::ViolationSeverity::Warning => "WARN",
272 crate::quality::ViolationSeverity::Info => "INFO",
273 };
274 violations_md.push_str(&format!(
275 "- **[{}] {}** — {}\n",
276 sev, v.requirement, v.message
277 ));
278 }
279 if compliance.violations.len() > 10 {
280 violations_md.push_str(&format!(
281 "- … and {} more findings (see SARIF / JSON output)\n",
282 compliance.violations.len() - 10
283 ));
284 }
285 }
286
287 format!(
288 "# Technical Documentation Summary (CRA Annex VII)\n\n\
289 > **Generated by sbom-tools cra-docs.** Use this document as a \
290 starting point; complete the `_TBD_` fields and attach evidence \
291 before submission.\n\n\
292 ## 1. Product description\n\
293 - SBOM format: {format_label}\n\
294 - Components in scope: {component_count}\n\
295 - Declared dependency edges: {dependency_count}\n\
296 - CRA product class: {class_name}\n\
297 - Conformity route: {route_name}\n\n\
298 ## 2. Risk assessment (CRA Art. 13(2))\n\
299 - Risk-assessment document: {risk_assessment}\n\
300 - Methodology: {methodology}\n\
301 - Risk-acceptance criteria (Art. 13(2) risk assessment): _TBD_\n\n\
302 ## 3. Vulnerability-handling process (Annex I Part II)\n\
303 - Process description: see `vulnerability-handling-policy.md` in this dossier\n\
304 - PSIRT: {psirt}\n\
305 - Support / security-update end date: {support_end}\n\n\
306 ## 4. Software Bill of Materials\n\
307 - Embedded SBOM: provided as a separate file in this submission\n\
308 - Generated by sbom-tools v{tool_version}\n\
309 - SBOM serial number: {serial}\n\n\
310 ## 5. Compliance check summary\n\n\
311 {violations_md}\n\
312 ## 6. Test and evaluation reports\n\
313 - Penetration test report: _TBD_\n\
314 - Code review / SAST report: _TBD_\n\
315 - DAST / fuzzing report: _TBD_\n\
316 - Third-party attestation (Module B+C / H / EUCC): _TBD_\n\n\
317 ## 7. Cybersecurity-relevant changes since previous version\n\
318 - _TBD: changelog of security-relevant changes_\n\
319 {controls_assertion_md}\
320 {adjacent_regulation_md}",
321 format_label = format_label,
322 component_count = component_count,
323 dependency_count = dependency_count,
324 class_name = class.name(),
325 route_name = route.name(),
326 risk_assessment = risk_assessment,
327 methodology = methodology,
328 psirt = psirt,
329 support_end = support_end,
330 tool_version = env!("CARGO_PKG_VERSION"),
331 serial = sbom
332 .document
333 .serial_number
334 .as_deref()
335 .unwrap_or("_TBD: assign a unique serial / namespace_"),
336 violations_md = violations_md,
337 controls_assertion_md = controls_assertion_md,
338 adjacent_regulation_md = adjacent_regulation_md,
339 )
340}
341
342fn render_controls_assertion_section(sidecar: Option<&CraSidecarMetadata>) -> String {
347 let Some(sc) = sidecar else {
348 return String::new();
349 };
350 if sc.annex_i_part_i_controls.is_empty() {
351 return String::new();
352 }
353 let mut s = String::from("\n## 8. Annex I Part I controls assertion (prEN 40000-1-2/1-4)\n\n");
354 s.push_str(
355 "Per-control assertions for CRA Annex I Part I, sourced from the \
356 sidecar `annex_i_part_i_controls` block. `Satisfied` rows must \
357 carry an evidence URL — un-evidenced claims are flagged by \
358 `sbom-tools validate --standard cra` as Warnings.\n\n",
359 );
360 s.push_str("| Control | Satisfied | Methodology | Evidence | Note |\n");
361 s.push_str("|---------|-----------|-------------|----------|------|\n");
362 for (id, claim) in &sc.annex_i_part_i_controls {
363 let satisfied = if claim.satisfied { "✅" } else { "❌" };
364 let methodology = claim.methodology.as_deref().unwrap_or("_TBD_");
365 let evidence = claim
366 .evidence_url
367 .as_deref()
368 .map(|u| format!("[link]({u})"))
369 .unwrap_or_else(|| "_TBD_".to_string());
370 let note = claim.note.as_deref().unwrap_or("");
371 s.push_str(&format!(
372 "| {id} | {satisfied} | {methodology} | {evidence} | {note} |\n"
373 ));
374 }
375 s.push('\n');
376 s
377}
378
379fn render_adjacent_regulation_section(sidecar: Option<&CraSidecarMetadata>) -> String {
383 let Some(sc) = sidecar else {
384 return String::new();
385 };
386 let any = sc.is_nis2_essential_entity
387 || sc.is_nis2_important_entity
388 || sc.processes_personal_data
389 || sc.is_high_risk_ai
390 || sc.red_repealed_until.is_some();
391 if !any {
392 return String::new();
393 }
394
395 let mut s = String::from("\n## 9. Adjacent regulation\n\n");
398 s.push_str(
399 "The CRA does not operate in isolation. The following adjacent EU \
400 legal acts apply to this product based on the sidecar declarations \
401 and must be coordinated with CRA conformity assessment.\n\n",
402 );
403
404 if sc.is_nis2_essential_entity || sc.is_nis2_important_entity {
405 let entity_kind = if sc.is_nis2_essential_entity {
406 "essential entity (NIS2 Annex I)"
407 } else {
408 "important entity (NIS2 Annex II)"
409 };
410 s.push_str(&format!(
411 "### NIS2 — Directive (EU) 2022/2555\n\n\
412 - Manufacturer is registered as an **{entity_kind}**.\n\
413 - **Art. 23 incident reporting** runs in parallel with CRA \
414 Art. 14: a 24-hour early warning to the national CSIRT *and* \
415 ENISA, followed by a 72-hour incident notification, and a \
416 1-month final report.\n\
417 - **Art. 21 risk-management measures** overlap with CRA \
418 Annex I Part I and the documented risk assessment in §2 \
419 above.\n\
420 - National competent authority registration (NIS2 Art. 27) \
421 is a precondition for the Art. 23 reporting channels listed \
422 in `vulnerability-handling-policy.md`.\n\n",
423 ));
424 }
425
426 if sc.processes_personal_data {
427 s.push_str(
428 "### GDPR — Regulation (EU) 2016/679\n\n\
429 - The product processes personal data, so **GDPR Art. 32 \
430 (security of processing)** applies alongside CRA Annex I \
431 Part I (1) cybersecurity properties.\n\
432 - Personal-data breaches must additionally be reported to \
433 the supervisory authority under **Art. 33** (within 72 h) \
434 and to data subjects under **Art. 34** when the breach is \
435 likely to result in a high risk.\n\
436 - The CRA technical-documentation set (this dossier) should \
437 cross-reference the Data Protection Impact Assessment \
438 (DPIA) when one has been performed under Art. 35.\n\n",
439 );
440 }
441
442 if sc.is_high_risk_ai {
443 s.push_str(
444 "### AI Act — Regulation (EU) 2024/1689\n\n\
445 - The product is a **high-risk AI system** within the meaning \
446 of the AI Act.\n\
447 - AI-Act conformity assessment runs **in addition to** CRA \
448 Annex VIII; the CE marking covers both regulations \
449 simultaneously and the EU Declaration of Conformity must \
450 list both legal bases.\n\
451 - The post-market monitoring plan (AI Act Art. 72) and \
452 serious-incident reporting (Art. 73) are coordinated with \
453 CRA Art. 14 reporting; use the same channels listed in \
454 `vulnerability-handling-policy.md`.\n\
455 - For an Annex IV technical-documentation **readiness** check \
456 over the AI-BOM (model description, training-data \
457 characteristics, validation metrics), run \
458 `sbom-tools validate <sbom> --standard ai-act --cra-sidecar <sidecar>`. \
459 This is a documentation-readiness aid, not a legal-conformity \
460 guarantee.\n\n",
461 );
462 }
463
464 if let Some(until) = sc.red_repealed_until {
465 s.push_str(&format!(
466 "### Radio Equipment Directive (RED) — Directive 2014/53/EU\n\n\
467 - The cybersecurity provisions in **RED Art. 3(3)(d/e/f)** \
468 apply to this product until **{}** (sidecar field \
469 `red_repealed_until`).\n\
470 - Once superseded by the CRA, RED references in the SBOM / \
471 technical documentation should be retired and replaced with \
472 the matching CRA Annex I requirement.\n\n",
473 until.format("%Y-%m-%d"),
474 ));
475 }
476
477 s
478}
479
480fn render_vuln_policy(sbom: &NormalizedSbom, sidecar: Option<&CraSidecarMetadata>) -> String {
482 let psirt = sidecar
483 .and_then(|s| s.psirt_url.as_deref())
484 .unwrap_or("_TBD: PSIRT URL_");
485 let security_contact = sidecar
486 .and_then(|s| s.security_contact.as_deref())
487 .unwrap_or("_TBD: security contact email_");
488 let cvd_policy = sidecar
489 .and_then(|s| s.coordinated_disclosure_policy_url.as_deref())
490 .unwrap_or("_TBD: coordinated vulnerability-disclosure policy URL_");
491 let early = sidecar
492 .and_then(|s| s.early_warning_contact.as_deref())
493 .unwrap_or("_TBD: 24-hour early-warning channel (CRA Art. 14(2)(a) / 14(4)(a))_");
494 let incident = sidecar
495 .and_then(|s| s.incident_report_contact.as_deref())
496 .unwrap_or("_TBD: 72-hour notification channel (CRA Art. 14(2)(b) / 14(4)(b))_");
497 let enisa = sidecar
498 .and_then(|s| s.enisa_reporting_platform_id.as_deref())
499 .unwrap_or("_TBD: ENISA single-reporting-platform manufacturer ID (Art. 14(7))_");
500
501 format!(
502 "# Vulnerability-Handling Policy (CRA Annex I Part II)\n\n\
503 > **Generated by sbom-tools cra-docs.** Replace `_TBD_` placeholders \
504 with your operational details before publishing this document.\n\n\
505 ## 1. Scope\n\
506 This policy applies to all products with digital elements identified in \
507 the accompanying SBOM ({components} components, primary identifier \
508 {primary_id}).\n\n\
509 ## 2. Reporting channels\n\
510 | Channel | Endpoint |\n\
511 |---|---|\n\
512 | PSIRT (public reporting portal) | {psirt} |\n\
513 | Security contact (encrypted email) | {security_contact} |\n\
514 | Coordinated disclosure policy | {cvd_policy} |\n\
515 | 24-hour early warning (CRA Art. 14(2)(a) / 14(4)(a)) | {early} |\n\
516 | 72-hour notification (CRA Art. 14(2)(b) / 14(4)(b)) | {incident} |\n\
517 | ENISA single reporting platform (Art. 14(7)) | {enisa} |\n\n\
518 ## 3. Process commitments\n\
519 - **Acknowledgement**: we will acknowledge receipt of any vulnerability \
520 report within _TBD_ business days.\n\
521 - **Assessment**: we will perform an initial impact assessment within \
522 _TBD_ business days.\n\
523 - **Disclosure**: we follow ISO/IEC 29147 / 30111 coordinated-disclosure \
524 practice (target embargo: _TBD_).\n\
525 - **Patch availability**: security patches are made available free of \
526 charge for the duration of the support period.\n\n\
527 ## 4. Active-exploitation handling\n\
528 - We monitor CISA KEV, ENISA EU-VDB, and OSV.dev for actively-exploited \
529 vulnerabilities affecting components in our SBOMs.\n\
530 - When an actively-exploited vulnerability is confirmed, the early-warning \
531 channel above is engaged within 24 hours and a CSAF v2.0 advisory is \
532 published as soon as a remediation or mitigation is available.\n\n\
533 ## 5. SBOM update commitment\n\
534 - The SBOM accompanying this product is regenerated and re-signed on \
535 every release that introduces, removes, or upgrades a tracked \
536 component (CRA Art. 13(7) / Annex I Part II (1)).\n\
537 - VEX statements (CSAF v2.0 / OpenVEX / CycloneDX VEX) are published \
538 alongside the SBOM whenever a vulnerability affecting a tracked \
539 component is acknowledged.\n",
540 components = sbom.components.len(),
541 primary_id = sbom
542 .primary_component_id
543 .as_ref()
544 .map(|c| c.value().to_string())
545 .unwrap_or_else(|| "_TBD: primary component ID_".to_string()),
546 psirt = psirt,
547 security_contact = security_contact,
548 cvd_policy = cvd_policy,
549 early = early,
550 incident = incident,
551 enisa = enisa,
552 )
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::model::Component;
559 use tempfile::tempdir;
560
561 #[test]
562 fn dossier_files_created_for_minimal_sbom() {
563 let dir = tempdir().unwrap();
564 let sbom_path = dir.path().join("app.cdx.json");
565 std::fs::write(
566 &sbom_path,
567 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
568 )
569 .unwrap();
570 let out = dir.path().join("dossier");
571
572 run_cra_docs(sbom_path, out.clone(), None, None).expect("cra-docs runs");
573
574 assert!(out.join("eu-declaration-of-conformity.md").exists());
575 assert!(out.join("technical-documentation.md").exists());
576 assert!(out.join("vulnerability-handling-policy.md").exists());
577 }
578
579 #[test]
580 fn rerun_refuses_to_clobber_existing_dossier_unless_forced() {
581 let dir = tempdir().unwrap();
582 let sbom_path = dir.path().join("app.cdx.json");
583 std::fs::write(
584 &sbom_path,
585 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
586 )
587 .unwrap();
588 let out = dir.path().join("dossier");
589
590 run_cra_docs(sbom_path.clone(), out.clone(), None, None).expect("first run succeeds");
591
592 let doc = out.join("eu-declaration-of-conformity.md");
594 std::fs::write(&doc, "HAND-EDITED").unwrap();
595
596 let err = run_cra_docs(sbom_path.clone(), out.clone(), None, None)
598 .expect_err("rerun without --force must refuse to overwrite");
599 let msg = err.to_string();
600 assert!(
601 msg.contains("eu-declaration-of-conformity.md") && msg.contains("--force"),
602 "error must list existing files and mention --force: {msg}"
603 );
604 assert_eq!(
605 std::fs::read_to_string(&doc).unwrap(),
606 "HAND-EDITED",
607 "hand-edited dossier must be untouched"
608 );
609
610 run_cra_docs_with_force(sbom_path, out.clone(), None, None, true)
612 .expect("forced rerun succeeds");
613 assert_ne!(std::fs::read_to_string(&doc).unwrap(), "HAND-EDITED");
614 }
615
616 #[test]
617 fn doc_template_is_filled_from_sidecar() {
618 let mut sbom = NormalizedSbom::default();
619 sbom.add_component(Component::new("c".to_string(), "c".to_string()));
620 let sidecar = CraSidecarMetadata {
621 manufacturer_name: Some("ExCorp".to_string()),
622 manufacturer_email: Some("legal@example.com".to_string()),
623 product_name: Some("ExProduct".to_string()),
624 product_version: Some("1.0".to_string()),
625 ce_marking_reference: Some("EU-DoC-2026-001".to_string()),
626 ..Default::default()
627 };
628 let doc = render_doc(
629 &sbom,
630 Some(&sidecar),
631 CraProductClass::ImportantClass1,
632 ConformityRoute::ModuleA,
633 );
634 assert!(doc.contains("ExCorp"));
635 assert!(doc.contains("legal@example.com"));
636 assert!(doc.contains("ExProduct"));
637 assert!(doc.contains("EU-DoC-2026-001"));
638 assert!(doc.contains("Important Class I"));
639 assert!(doc.contains("Module A"));
640 }
641
642 #[test]
643 fn vuln_policy_filled_from_sidecar() {
644 let sbom = NormalizedSbom::default();
645 let sidecar = CraSidecarMetadata {
646 psirt_url: Some("https://example.com/psirt".to_string()),
647 security_contact: Some("security@example.com".to_string()),
648 coordinated_disclosure_policy_url: Some("https://example.com/security/cvd".to_string()),
649 early_warning_contact: Some("ew@example.com".to_string()),
650 incident_report_contact: Some("incidents@example.com".to_string()),
651 enisa_reporting_platform_id: Some("EU-MFR-1".to_string()),
652 ..Default::default()
653 };
654 let policy = render_vuln_policy(&sbom, Some(&sidecar));
655 assert!(policy.contains("https://example.com/psirt"));
656 assert!(policy.contains("security@example.com"));
657 assert!(policy.contains("https://example.com/security/cvd"));
658 assert!(policy.contains("ew@example.com"));
659 assert!(policy.contains("incidents@example.com"));
660 assert!(policy.contains("EU-MFR-1"));
661 }
662
663 #[test]
664 fn tech_doc_includes_compliance_summary() {
665 let mut sbom = NormalizedSbom::default();
666 sbom.add_component(Component::new("c".to_string(), "c".to_string()));
667 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
668 let tech = render_tech_doc(
669 &sbom,
670 None,
671 CraProductClass::Default,
672 ConformityRoute::ModuleA,
673 &result,
674 );
675 assert!(tech.contains("Components in scope: 1"));
677 assert!(tech.contains("Compliance check summary"));
678 }
679}