Skip to main content

sbom_tools/cli/
cra_docs.rs

1//! `cra-docs` command handler.
2//!
3//! Generates a CRA conformity dossier (Annex V DoC + Annex VII technical documentation)
4//! prefilled from the SBOM and an optional CRA sidecar. Output is a
5//! directory containing three Markdown files that a notified body or
6//! auditor can use as a starting point:
7//!
8//! - `eu-declaration-of-conformity.md` — Annex V Declaration of Conformity
9//! - `technical-documentation.md`      — Annex VII technical-documentation summary
10//! - `vulnerability-handling-policy.md` — Annex I Part II policy stub
11//!
12//! Fields the SBOM/sidecar can supply are filled in; everything else is
13//! left as `_TBD_` so the operator can complete the document by hand.
14
15use 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
21/// The dossier files `cra-docs` writes into the output directory.
22const DOSSIER_FILES: [&str; 3] = [
23    "eu-declaration-of-conformity.md",
24    "technical-documentation.md",
25    "vulnerability-handling-policy.md",
26];
27
28/// Run the `cra-docs` command. Generates 3 Markdown files in `output_dir`.
29///
30/// Refuses to overwrite existing dossier files (they are meant to be
31/// hand-completed after generation); see [`run_cra_docs_with_force`] for the
32/// `--force` variant.
33#[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/// [`run_cra_docs`] with an explicit overwrite decision.
50///
51/// The generated dossier is a *starting point* the operator completes by
52/// hand (`_TBD_` placeholders), so a re-run must not silently clobber those
53/// edits: when `force` is false and any dossier file already exists, this
54/// errors listing the existing files; `--force` overwrites them.
55#[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    // Sidecar resolution (shared with validate/quality/view): an explicit
83    // path that fails to load is a hard error; auto-discovery is best-effort.
84    let sidecar = super::load_cra_sidecar(cra_sidecar_path.as_deref(), &sbom_path)?;
85
86    // Effective product class: sidecar wins, else CLI flag, else Default.
87    // An explicitly passed unrecognized class is a hard error (strict parse).
88    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    // Build a compliance result so the dossier can summarise readiness.
104    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
140/// Render the EU Declaration of Conformity (Annex V) template.
141fn 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    // §6 "other Union legislation". When the sidecar flags the product as a
172    // high-risk AI system, name the AI Act explicitly and point the operator at
173    // the Annex IV technical-documentation readiness check; otherwise keep the
174    // hand-completable placeholder.
175    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
230/// Render the Annex VII technical-documentation summary.
231fn 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
342/// Render the prEN 40000-1-2/1-4 controls-assertion block (CRA-P5.5).
343/// Empty sidecars or sidecars without `annex_i_part_i_controls` skip the
344/// section entirely so the dossier stays clean for products that don't
345/// claim per-control assertions.
346fn 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
379/// Render the "Adjacent regulation" section (CRA-P4.4). Only fires for
380/// the regulatory overlap flags actually set on the sidecar, so an
381/// SBOM-only dossier (no sidecar) skips the section entirely.
382fn 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    // Numbered after the controls-assertion block so the dossier reads
396    // 7 → 8 → 9 when both are present (controls = §8, adjacent = §9).
397    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
480/// Render the Annex I Part II vulnerability-handling policy stub.
481fn 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        // Simulate the operator completing the dossier by hand.
593        let doc = out.join("eu-declaration-of-conformity.md");
594        std::fs::write(&doc, "HAND-EDITED").unwrap();
595
596        // Second run without --force must refuse, naming the existing files.
597        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        // --force overwrites.
611        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        // should mention component count + compliance summary header
676        assert!(tech.contains("Components in scope: 1"));
677        assert!(tech.contains("Compliance check summary"));
678    }
679}