Skip to main content

csaf_models/
csaf_document.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Full CSAF 2.0 and 2.1 document serde types.
5//!
6//! These types are derived from the OASIS CSAF 2.1 JSON schema and the 15 test
7//! advisory files in `test/csaf/`. They support both serialization and
8//! deserialization with strict field validation.
9
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// Top-level CSAF document
14// ---------------------------------------------------------------------------
15
16/// A complete CSAF document (versions 2.0 and 2.1).
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct CsafDocument {
19    /// JSON schema URL.
20    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
21    pub schema: Option<String>,
22
23    /// Core document metadata.
24    pub document: Document,
25
26    /// Product tree describing affected products.
27    pub product_tree: ProductTree,
28
29    /// List of vulnerabilities described by this advisory.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub vulnerabilities: Vec<Vulnerability>,
32}
33
34// ---------------------------------------------------------------------------
35// Document metadata
36// ---------------------------------------------------------------------------
37
38/// Core document metadata section.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct Document {
41    /// Document category (e.g. `csaf_security_advisory`, `csaf_vex`,
42    /// `csaf_informational_advisory`).
43    pub category: String,
44
45    /// CSAF version (`"2.0"` or `"2.1"`).
46    pub csaf_version: String,
47
48    /// Distribution restrictions.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub distribution: Option<Distribution>,
51
52    /// Document language (BCP 47 tag, e.g. `"en"`).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub lang: Option<String>,
55
56    /// Informational notes about the document.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub notes: Vec<Note>,
59
60    /// Publisher information.
61    pub publisher: Publisher,
62
63    /// External references.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub references: Vec<Reference>,
66
67    /// Document title.
68    pub title: String,
69
70    /// Document lifecycle tracking.
71    pub tracking: Tracking,
72}
73
74/// Distribution restrictions (TLP labelling and free-form restrictions).
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct Distribution {
77    /// TLP information.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub tlp: Option<Tlp>,
80
81    /// Free-form textual distribution restrictions beyond TLP. Used to
82    /// carry Verschlusssache / NATO classification strings when the user
83    /// selects `distribution_text` or `both` as the storage mode.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub text: Option<String>,
86}
87
88/// Traffic Light Protocol label.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct Tlp {
91    /// TLP label (`CLEAR`, `GREEN`, `AMBER`, `AMBER+STRICT`, `RED`).
92    pub label: String,
93
94    /// Optional TLP specification URL.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub url: Option<String>,
97}
98
99/// A textual note within the document or vulnerability.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct Note {
102    /// Note category (`summary`, `description`, `general`, `legal_disclaimer`,
103    /// `other`, `faq`, `details`).
104    pub category: String,
105
106    /// Note text content.
107    pub text: String,
108
109    /// Optional note title.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub title: Option<String>,
112
113    /// Optional target audience.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub audience: Option<String>,
116}
117
118/// Publisher identity and role.
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120pub struct Publisher {
121    /// Publisher category (`vendor`, `discoverer`, `coordinator`, `user`,
122    /// `other`, `translator`).
123    pub category: String,
124
125    /// Contact information.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub contact_details: Option<String>,
128
129    /// Issuing authority description.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub issuing_authority: Option<String>,
132
133    /// Publisher name.
134    pub name: String,
135
136    /// Publisher namespace URI.
137    pub namespace: String,
138}
139
140/// A reference to an external resource.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct Reference {
143    /// Reference category (`self`, `external`).
144    pub category: String,
145
146    /// Description of the reference.
147    pub summary: String,
148
149    /// URL of the referenced resource.
150    pub url: String,
151}
152
153// ---------------------------------------------------------------------------
154// Tracking
155// ---------------------------------------------------------------------------
156
157/// Document lifecycle tracking information.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct Tracking {
160    /// Date of the current release.
161    pub current_release_date: String,
162
163    /// Generator tool information.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub generator: Option<Generator>,
166
167    /// Document tracking identifier (e.g. `ndaal-sa-2026-001`).
168    pub id: String,
169
170    /// Date of the initial release.
171    pub initial_release_date: String,
172
173    /// Revision history entries.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub revision_history: Vec<Revision>,
176
177    /// Document status (`draft`, `interim`, `final`).
178    pub status: String,
179
180    /// Document version (semver-like, e.g. `"1.0.0"`).
181    pub version: String,
182
183    /// Aliases for this tracking ID.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub aliases: Vec<String>,
186}
187
188/// Generator engine metadata.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190pub struct Generator {
191    /// Generator engine details.
192    pub engine: Engine,
193
194    /// Generation date.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub date: Option<String>,
197}
198
199/// Generator engine identification.
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct Engine {
202    /// Engine name.
203    pub name: String,
204
205    /// Engine version.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub version: Option<String>,
208}
209
210/// A single revision history entry.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct Revision {
213    /// Revision date.
214    pub date: String,
215
216    /// Revision number (semver-like).
217    pub number: String,
218
219    /// Summary of changes in this revision.
220    pub summary: String,
221}
222
223// ---------------------------------------------------------------------------
224// Product tree
225// ---------------------------------------------------------------------------
226
227/// Product hierarchy tree.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct ProductTree {
230    /// Branch hierarchy of vendors, products, and versions.
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub branches: Vec<Branch>,
233
234    /// Full product names defined outside the branch hierarchy.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub full_product_names: Vec<FullProductName>,
237
238    /// Product groupings for vulnerability status.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub product_groups: Vec<ProductGroup>,
241
242    /// Relationships between products.
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub relationships: Vec<Relationship>,
245}
246
247/// A branch in the product tree hierarchy.
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249pub struct Branch {
250    /// Branch category (`vendor`, `product_name`, `product_version`,
251    /// `product_version_range`, `product_family`, `architecture`, `language`,
252    /// `legacy`, `patch_level`, `service_pack`, `specification`, `host_name`).
253    pub category: String,
254
255    /// Branch display name.
256    pub name: String,
257
258    /// Child branches.
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub branches: Vec<Self>,
261
262    /// Product definition at this branch level.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub product: Option<FullProductName>,
265}
266
267/// A full product name definition.
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269pub struct FullProductName {
270    /// Human-readable product name.
271    pub name: String,
272
273    /// Unique product identifier within this document.
274    pub product_id: String,
275
276    /// CPE identifier.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub cpe: Option<String>,
279
280    /// PURL identifier.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub purl: Option<String>,
283}
284
285/// A grouping of products.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287pub struct ProductGroup {
288    /// Group identifier.
289    pub group_id: String,
290
291    /// Product IDs in this group.
292    pub product_ids: Vec<String>,
293
294    /// Optional group summary.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub summary: Option<String>,
297}
298
299/// A relationship between products.
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301pub struct Relationship {
302    /// Relationship category.
303    pub category: String,
304
305    /// Full product name for the relationship.
306    pub full_product_name: FullProductName,
307
308    /// Product reference.
309    pub product_reference: String,
310
311    /// Relates-to product reference.
312    pub relates_to_product_reference: String,
313}
314
315// ---------------------------------------------------------------------------
316// Vulnerabilities
317// ---------------------------------------------------------------------------
318
319/// A vulnerability entry within a CSAF document.
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321pub struct Vulnerability {
322    /// CVE identifier (e.g. `CVE-2024-1234`).
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub cve: Option<String>,
325
326    /// CWE weakness classification.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub cwe: Option<Cwe>,
329
330    /// Date the vulnerability was discovered.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub discovery_date: Option<String>,
333
334    /// Additional identifiers.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub ids: Vec<VulnerabilityId>,
337
338    /// Informational notes about the vulnerability.
339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
340    pub notes: Vec<Note>,
341
342    /// Product status classifications.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub product_status: Option<ProductStatus>,
345
346    /// Remediation steps.
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub remediations: Vec<Remediation>,
349
350    /// CVSS scoring metrics.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub metrics: Vec<Metric>,
353
354    /// Threat information.
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub threats: Vec<Threat>,
357
358    /// Vulnerability title.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub title: Option<String>,
361
362    /// Release date.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub release_date: Option<String>,
365
366    /// Vulnerability references.
367    #[serde(default, skip_serializing_if = "Vec::is_empty")]
368    pub references: Vec<Reference>,
369
370    /// Involvements.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub involvements: Vec<Involvement>,
373
374    /// Flags.
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub flags: Vec<Flag>,
377}
378
379/// CWE (Common Weakness Enumeration) reference.
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
381pub struct Cwe {
382    /// CWE identifier (e.g. `CWE-79`).
383    pub id: String,
384
385    /// CWE name.
386    pub name: String,
387}
388
389/// Additional vulnerability identifier.
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391pub struct VulnerabilityId {
392    /// Identifier system name (e.g. `RustSec`).
393    pub system_name: String,
394
395    /// Identifier value.
396    pub text: String,
397}
398
399/// Product status classifications for a vulnerability.
400#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
401pub struct ProductStatus {
402    /// Products confirmed to be affected.
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub known_affected: Vec<String>,
405
406    /// Products confirmed to be not affected.
407    #[serde(default, skip_serializing_if = "Vec::is_empty")]
408    pub known_not_affected: Vec<String>,
409
410    /// Products with the vulnerability fixed.
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub fixed: Vec<String>,
413
414    /// Products under investigation.
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub under_investigation: Vec<String>,
417
418    /// First affected product versions.
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub first_affected: Vec<String>,
421
422    /// First fixed product versions.
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub first_fixed: Vec<String>,
425
426    /// Last affected product versions.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub last_affected: Vec<String>,
429
430    /// Recommended product versions.
431    #[serde(default, skip_serializing_if = "Vec::is_empty")]
432    pub recommended: Vec<String>,
433}
434
435/// Remediation action for a vulnerability.
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
437pub struct Remediation {
438    /// Remediation category (`vendor_fix`, `mitigation`, `workaround`,
439    /// `no_fix_planned`, `none_available`).
440    pub category: String,
441
442    /// Remediation description.
443    pub details: String,
444
445    /// Affected product IDs.
446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
447    pub product_ids: Vec<String>,
448
449    /// Affected product group IDs.
450    #[serde(default, skip_serializing_if = "Vec::is_empty")]
451    pub group_ids: Vec<String>,
452
453    /// Remediation URL.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub url: Option<String>,
456
457    /// Remediation date.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub date: Option<String>,
460
461    /// Restart required.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub restart_required: Option<RestartRequired>,
464
465    /// Entitlements.
466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
467    pub entitlements: Vec<String>,
468}
469
470/// Restart requirement specification.
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
472pub struct RestartRequired {
473    /// Restart category.
474    pub category: String,
475
476    /// Additional details.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub details: Option<String>,
479}
480
481/// CVSS scoring metric container.
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
483pub struct Metric {
484    /// Metric content containing CVSS scores.
485    pub content: MetricContent,
486
487    /// Product IDs this metric applies to.
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub products: Vec<String>,
490
491    /// Source of the metric.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub source: Option<String>,
494}
495
496/// Metric content containing one or both CVSS versions.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
498pub struct MetricContent {
499    /// CVSS v3.1 scoring.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub cvss_v3: Option<CvssV3>,
502
503    /// CVSS v4.0 scoring.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub cvss_v4: Option<CvssV4>,
506}
507
508/// CVSS v3.1 scoring data.
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
510pub struct CvssV3 {
511    /// CVSS version (always `"3.1"`).
512    pub version: String,
513
514    /// CVSS vector string.
515    #[serde(rename = "vectorString")]
516    pub vector_string: String,
517
518    /// Base score (0.0 to 10.0).
519    #[serde(rename = "baseScore")]
520    pub base_score: f64,
521
522    /// Base severity (`NONE`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`).
523    #[serde(rename = "baseSeverity")]
524    pub base_severity: String,
525
526    /// Attack vector.
527    #[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
528    pub attack_vector: Option<String>,
529
530    /// Attack complexity.
531    #[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
532    pub attack_complexity: Option<String>,
533
534    /// Privileges required.
535    #[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
536    pub privileges_required: Option<String>,
537
538    /// User interaction.
539    #[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
540    pub user_interaction: Option<String>,
541
542    /// Scope.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub scope: Option<String>,
545
546    /// Confidentiality impact.
547    #[serde(
548        rename = "confidentialityImpact",
549        skip_serializing_if = "Option::is_none"
550    )]
551    pub confidentiality_impact: Option<String>,
552
553    /// Integrity impact.
554    #[serde(rename = "integrityImpact", skip_serializing_if = "Option::is_none")]
555    pub integrity_impact: Option<String>,
556
557    /// Availability impact.
558    #[serde(rename = "availabilityImpact", skip_serializing_if = "Option::is_none")]
559    pub availability_impact: Option<String>,
560}
561
562/// CVSS v4.0 scoring data.
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
564pub struct CvssV4 {
565    /// CVSS version (always `"4.0"`).
566    pub version: String,
567
568    /// CVSS vector string.
569    #[serde(rename = "vectorString")]
570    pub vector_string: String,
571
572    /// Base score (0.0 to 10.0).
573    #[serde(rename = "baseScore")]
574    pub base_score: f64,
575
576    /// Base severity (`NONE`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`).
577    #[serde(rename = "baseSeverity")]
578    pub base_severity: String,
579
580    /// Attack vector.
581    #[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
582    pub attack_vector: Option<String>,
583
584    /// Attack complexity.
585    #[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
586    pub attack_complexity: Option<String>,
587
588    /// Attack requirements.
589    #[serde(rename = "attackRequirements", skip_serializing_if = "Option::is_none")]
590    pub attack_requirements: Option<String>,
591
592    /// Privileges required.
593    #[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
594    pub privileges_required: Option<String>,
595
596    /// User interaction.
597    #[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
598    pub user_interaction: Option<String>,
599
600    /// Confidentiality impact (vulnerable component) — CVSS v4 `VC`.
601    #[serde(
602        rename = "vulnConfidentialityImpact",
603        skip_serializing_if = "Option::is_none"
604    )]
605    pub confidentiality_impact: Option<String>,
606
607    /// Integrity impact (vulnerable component) — CVSS v4 `VI`.
608    #[serde(
609        rename = "vulnIntegrityImpact",
610        skip_serializing_if = "Option::is_none"
611    )]
612    pub integrity_impact: Option<String>,
613
614    /// Availability impact (vulnerable component) — CVSS v4 `VA`.
615    #[serde(
616        rename = "vulnAvailabilityImpact",
617        skip_serializing_if = "Option::is_none"
618    )]
619    pub availability_impact: Option<String>,
620
621    /// Confidentiality impact (subsequent system).
622    #[serde(
623        rename = "subConfidentialityImpact",
624        skip_serializing_if = "Option::is_none"
625    )]
626    pub sub_confidentiality_impact: Option<String>,
627
628    /// Integrity impact (subsequent system).
629    #[serde(rename = "subIntegrityImpact", skip_serializing_if = "Option::is_none")]
630    pub sub_integrity_impact: Option<String>,
631
632    /// Availability impact (subsequent system).
633    #[serde(
634        rename = "subAvailabilityImpact",
635        skip_serializing_if = "Option::is_none"
636    )]
637    pub sub_availability_impact: Option<String>,
638}
639
640/// Threat information.
641#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
642pub struct Threat {
643    /// Threat category (`exploit_status`, `impact`, `target_set`).
644    pub category: String,
645
646    /// Threat description.
647    pub details: String,
648
649    /// Affected product IDs.
650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
651    pub product_ids: Vec<String>,
652
653    /// Affected product group IDs.
654    #[serde(default, skip_serializing_if = "Vec::is_empty")]
655    pub group_ids: Vec<String>,
656
657    /// Date of threat assessment.
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub date: Option<String>,
660}
661
662/// Involvement information.
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
664pub struct Involvement {
665    /// Party involved.
666    pub party: String,
667
668    /// Status of involvement.
669    pub status: String,
670
671    /// Summary.
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub summary: Option<String>,
674}
675
676/// Flag on a vulnerability.
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
678pub struct Flag {
679    /// Label.
680    pub label: String,
681
682    /// Date.
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub date: Option<String>,
685
686    /// Product IDs.
687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
688    pub product_ids: Vec<String>,
689
690    /// Group IDs.
691    #[serde(default, skip_serializing_if = "Vec::is_empty")]
692    pub group_ids: Vec<String>,
693}
694
695// ---------------------------------------------------------------------------
696// Helper: extract all product IDs from the product tree
697// ---------------------------------------------------------------------------
698
699impl CsafDocument {
700    /// Extract all product IDs defined in the product tree.
701    #[must_use]
702    pub fn all_product_ids(&self) -> Vec<String> {
703        let mut ids = Vec::new();
704        collect_product_ids_from_branches(&self.product_tree.branches, &mut ids);
705        for fpn in &self.product_tree.full_product_names {
706            ids.push(fpn.product_id.clone());
707        }
708        ids
709    }
710
711    /// Get the tracking ID.
712    #[must_use]
713    pub fn tracking_id(&self) -> &str {
714        &self.document.tracking.id
715    }
716
717    /// Get the CSAF version.
718    #[must_use]
719    pub fn csaf_version(&self) -> &str {
720        &self.document.csaf_version
721    }
722
723    /// Get the document category.
724    #[must_use]
725    pub fn category(&self) -> &str {
726        &self.document.category
727    }
728}
729
730/// Recursively collect product IDs from branch hierarchy.
731fn collect_product_ids_from_branches(branches: &[Branch], ids: &mut Vec<String>) {
732    for branch in branches {
733        if let Some(product) = &branch.product {
734            ids.push(product.product_id.clone());
735        }
736        collect_product_ids_from_branches(&branch.branches, ids);
737    }
738}
739
740// ---------------------------------------------------------------------------
741// Summary metadata (for listing/search, stored separately from full doc)
742// ---------------------------------------------------------------------------
743
744/// Lightweight metadata extracted from a CSAF document for listing and search.
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
746pub struct CsafMeta {
747    /// Tracking identifier.
748    pub tracking_id: String,
749
750    /// Document title.
751    pub title: String,
752
753    /// Document category.
754    pub category: String,
755
756    /// CSAF version.
757    pub csaf_version: String,
758
759    /// Document status.
760    pub status: String,
761
762    /// Current release date (ISO 8601).
763    pub current_release_date: String,
764
765    /// Initial release date (ISO 8601).
766    pub initial_release_date: String,
767
768    /// Document version.
769    pub version: String,
770
771    /// Publisher name.
772    pub publisher_name: String,
773
774    /// TLP label.
775    pub tlp_label: Option<String>,
776
777    /// Number of vulnerabilities.
778    pub vulnerability_count: usize,
779
780    /// Highest CVSS v3.1 base score across all vulnerabilities.
781    pub max_cvss_v3_score: Option<f64>,
782
783    /// Highest CVSS v4.0 base score across all vulnerabilities.
784    pub max_cvss_v4_score: Option<f64>,
785}
786
787impl CsafMeta {
788    /// Extract summary metadata from a full CSAF document.
789    #[must_use]
790    pub fn from_document(doc: &CsafDocument) -> Self {
791        let tlp_label = doc
792            .document
793            .distribution
794            .as_ref()
795            .and_then(|d| d.tlp.as_ref())
796            .map(|t| t.label.clone());
797
798        let mut max_v3: Option<f64> = None;
799        let mut max_v4: Option<f64> = None;
800
801        for vuln in &doc.vulnerabilities {
802            for metric in &vuln.metrics {
803                if let Some(v3) = &metric.content.cvss_v3 {
804                    let current = max_v3.unwrap_or(0.0);
805                    if v3.base_score > current {
806                        max_v3 = Some(v3.base_score);
807                    }
808                }
809                if let Some(v4) = &metric.content.cvss_v4 {
810                    let current = max_v4.unwrap_or(0.0);
811                    if v4.base_score > current {
812                        max_v4 = Some(v4.base_score);
813                    }
814                }
815            }
816        }
817
818        Self {
819            tracking_id: doc.document.tracking.id.clone(),
820            title: doc.document.title.clone(),
821            category: doc.document.category.clone(),
822            csaf_version: doc.document.csaf_version.clone(),
823            status: doc.document.tracking.status.clone(),
824            current_release_date: doc.document.tracking.current_release_date.clone(),
825            initial_release_date: doc.document.tracking.initial_release_date.clone(),
826            version: doc.document.tracking.version.clone(),
827            publisher_name: doc.document.publisher.name.clone(),
828            tlp_label,
829            vulnerability_count: doc.vulnerabilities.len(),
830            max_cvss_v3_score: max_v3,
831            max_cvss_v4_score: max_v4,
832        }
833    }
834}
835
836#[cfg(test)]
837// Dense assertion blocks in tests are allowed to keep one round-trip
838// test per fixture readable; clippy's cognitive-complexity threshold
839// is tuned for production code paths.
840#[allow(clippy::cognitive_complexity)]
841mod tests {
842    use super::*;
843
844    #[test]
845    fn test_deserialize_csaf_security_advisory() {
846        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
847        let doc: CsafDocument =
848            serde_json::from_str(json).expect("Failed to deserialize CSAF document");
849
850        assert_eq!(doc.document.category, "csaf_security_advisory");
851        assert_eq!(doc.document.csaf_version, "2.1");
852        assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-003");
853        assert_eq!(doc.document.tracking.status, "final");
854        assert_eq!(
855            doc.document.publisher.name,
856            "ndaal Gesellschaft f\u{fc}r Sicherheit in der Informationstechnik mbH & Co KG"
857        );
858        assert_eq!(doc.vulnerabilities.len(), 1);
859
860        let vuln = &doc.vulnerabilities[0];
861        assert_eq!(vuln.cve.as_deref(), Some("CVE-0000-0001"));
862        assert_eq!(vuln.metrics.len(), 1);
863
864        let metric = &vuln.metrics[0];
865        let v3 = metric.content.cvss_v3.as_ref().expect("CVSS v3 missing");
866        assert!((v3.base_score - 9.8).abs() < f64::EPSILON);
867        assert_eq!(v3.base_severity, "CRITICAL");
868
869        let v4 = metric.content.cvss_v4.as_ref().expect("CVSS v4 missing");
870        assert!((v4.base_score - 9.3).abs() < f64::EPSILON);
871    }
872
873    #[test]
874    fn test_deserialize_csaf_vex() {
875        let json = include_str!("../../../test/csaf/2026/015/ndaal-sa-2026-015.json");
876        let doc: CsafDocument =
877            serde_json::from_str(json).expect("Failed to deserialize VEX document");
878
879        assert_eq!(doc.document.category, "csaf_vex");
880        assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-015");
881    }
882
883    #[test]
884    fn test_deserialize_all_test_files() {
885        let test_dir =
886            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
887
888        for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
889            let entry = entry.expect("dir entry error");
890            if !entry.file_type().expect("file type error").is_dir() {
891                continue;
892            }
893            for file in std::fs::read_dir(entry.path()).expect("subdir read error") {
894                let file = file.expect("file entry error");
895                let path = file.path();
896                if path.extension().is_some_and(|e| e == "json") {
897                    let content = std::fs::read_to_string(&path)
898                        .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
899                    let result: Result<CsafDocument, _> = serde_json::from_str(&content);
900                    assert!(
901                        result.is_ok(),
902                        "Failed to parse {}: {:?}",
903                        path.display(),
904                        result.err()
905                    );
906                }
907            }
908        }
909    }
910
911    #[test]
912    fn test_csaf_meta_extraction() {
913        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
914        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
915        let meta = CsafMeta::from_document(&doc);
916
917        assert_eq!(meta.tracking_id, "ndaal-sa-2026-003");
918        assert_eq!(meta.category, "csaf_security_advisory");
919        assert_eq!(meta.vulnerability_count, 1);
920        assert!(
921            meta.max_cvss_v3_score
922                .is_some_and(|s| (s - 9.8).abs() < f64::EPSILON)
923        );
924        assert!(
925            meta.max_cvss_v4_score
926                .is_some_and(|s| (s - 9.3).abs() < f64::EPSILON)
927        );
928        assert_eq!(meta.tlp_label.as_deref(), Some("CLEAR"));
929    }
930
931    #[test]
932    fn test_all_product_ids() {
933        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
934        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
935        let ids = doc.all_product_ids();
936
937        assert!(ids.contains(&"CSAFPID-001".to_owned()));
938        assert!(ids.contains(&"CSAFPID-002".to_owned()));
939    }
940
941    #[test]
942    fn test_roundtrip_serialization() {
943        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
944        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
945        let serialized = serde_json::to_string_pretty(&doc).expect("serialize error");
946        let doc2: CsafDocument = serde_json::from_str(&serialized).expect("re-parse error");
947        assert_eq!(doc, doc2);
948    }
949}