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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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)]
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).
601    #[serde(
602        rename = "confidentialityImpact",
603        skip_serializing_if = "Option::is_none"
604    )]
605    pub confidentiality_impact: Option<String>,
606
607    /// Integrity impact (vulnerable component).
608    #[serde(rename = "integrityImpact", skip_serializing_if = "Option::is_none")]
609    pub integrity_impact: Option<String>,
610
611    /// Availability impact (vulnerable component).
612    #[serde(rename = "availabilityImpact", skip_serializing_if = "Option::is_none")]
613    pub availability_impact: Option<String>,
614
615    /// Confidentiality impact (subsequent system).
616    #[serde(
617        rename = "subConfidentialityImpact",
618        skip_serializing_if = "Option::is_none"
619    )]
620    pub sub_confidentiality_impact: Option<String>,
621
622    /// Integrity impact (subsequent system).
623    #[serde(rename = "subIntegrityImpact", skip_serializing_if = "Option::is_none")]
624    pub sub_integrity_impact: Option<String>,
625
626    /// Availability impact (subsequent system).
627    #[serde(
628        rename = "subAvailabilityImpact",
629        skip_serializing_if = "Option::is_none"
630    )]
631    pub sub_availability_impact: Option<String>,
632}
633
634/// Threat information.
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636pub struct Threat {
637    /// Threat category (`exploit_status`, `impact`, `target_set`).
638    pub category: String,
639
640    /// Threat description.
641    pub details: String,
642
643    /// Affected product IDs.
644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
645    pub product_ids: Vec<String>,
646
647    /// Affected product group IDs.
648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
649    pub group_ids: Vec<String>,
650
651    /// Date of threat assessment.
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub date: Option<String>,
654}
655
656/// Involvement information.
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
658pub struct Involvement {
659    /// Party involved.
660    pub party: String,
661
662    /// Status of involvement.
663    pub status: String,
664
665    /// Summary.
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub summary: Option<String>,
668}
669
670/// Flag on a vulnerability.
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct Flag {
673    /// Label.
674    pub label: String,
675
676    /// Date.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub date: Option<String>,
679
680    /// Product IDs.
681    #[serde(default, skip_serializing_if = "Vec::is_empty")]
682    pub product_ids: Vec<String>,
683
684    /// Group IDs.
685    #[serde(default, skip_serializing_if = "Vec::is_empty")]
686    pub group_ids: Vec<String>,
687}
688
689// ---------------------------------------------------------------------------
690// Helper: extract all product IDs from the product tree
691// ---------------------------------------------------------------------------
692
693impl CsafDocument {
694    /// Extract all product IDs defined in the product tree.
695    #[must_use]
696    pub fn all_product_ids(&self) -> Vec<String> {
697        let mut ids = Vec::new();
698        collect_product_ids_from_branches(&self.product_tree.branches, &mut ids);
699        for fpn in &self.product_tree.full_product_names {
700            ids.push(fpn.product_id.clone());
701        }
702        ids
703    }
704
705    /// Get the tracking ID.
706    #[must_use]
707    pub fn tracking_id(&self) -> &str {
708        &self.document.tracking.id
709    }
710
711    /// Get the CSAF version.
712    #[must_use]
713    pub fn csaf_version(&self) -> &str {
714        &self.document.csaf_version
715    }
716
717    /// Get the document category.
718    #[must_use]
719    pub fn category(&self) -> &str {
720        &self.document.category
721    }
722}
723
724/// Recursively collect product IDs from branch hierarchy.
725fn collect_product_ids_from_branches(branches: &[Branch], ids: &mut Vec<String>) {
726    for branch in branches {
727        if let Some(product) = &branch.product {
728            ids.push(product.product_id.clone());
729        }
730        collect_product_ids_from_branches(&branch.branches, ids);
731    }
732}
733
734// ---------------------------------------------------------------------------
735// Summary metadata (for listing/search, stored separately from full doc)
736// ---------------------------------------------------------------------------
737
738/// Lightweight metadata extracted from a CSAF document for listing and search.
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct CsafMeta {
741    /// Tracking identifier.
742    pub tracking_id: String,
743
744    /// Document title.
745    pub title: String,
746
747    /// Document category.
748    pub category: String,
749
750    /// CSAF version.
751    pub csaf_version: String,
752
753    /// Document status.
754    pub status: String,
755
756    /// Current release date (ISO 8601).
757    pub current_release_date: String,
758
759    /// Initial release date (ISO 8601).
760    pub initial_release_date: String,
761
762    /// Document version.
763    pub version: String,
764
765    /// Publisher name.
766    pub publisher_name: String,
767
768    /// TLP label.
769    pub tlp_label: Option<String>,
770
771    /// Number of vulnerabilities.
772    pub vulnerability_count: usize,
773
774    /// Highest CVSS v3.1 base score across all vulnerabilities.
775    pub max_cvss_v3_score: Option<f64>,
776
777    /// Highest CVSS v4.0 base score across all vulnerabilities.
778    pub max_cvss_v4_score: Option<f64>,
779}
780
781impl CsafMeta {
782    /// Extract summary metadata from a full CSAF document.
783    #[must_use]
784    pub fn from_document(doc: &CsafDocument) -> Self {
785        let tlp_label = doc
786            .document
787            .distribution
788            .as_ref()
789            .and_then(|d| d.tlp.as_ref())
790            .map(|t| t.label.clone());
791
792        let mut max_v3: Option<f64> = None;
793        let mut max_v4: Option<f64> = None;
794
795        for vuln in &doc.vulnerabilities {
796            for metric in &vuln.metrics {
797                if let Some(v3) = &metric.content.cvss_v3 {
798                    let current = max_v3.unwrap_or(0.0);
799                    if v3.base_score > current {
800                        max_v3 = Some(v3.base_score);
801                    }
802                }
803                if let Some(v4) = &metric.content.cvss_v4 {
804                    let current = max_v4.unwrap_or(0.0);
805                    if v4.base_score > current {
806                        max_v4 = Some(v4.base_score);
807                    }
808                }
809            }
810        }
811
812        Self {
813            tracking_id: doc.document.tracking.id.clone(),
814            title: doc.document.title.clone(),
815            category: doc.document.category.clone(),
816            csaf_version: doc.document.csaf_version.clone(),
817            status: doc.document.tracking.status.clone(),
818            current_release_date: doc.document.tracking.current_release_date.clone(),
819            initial_release_date: doc.document.tracking.initial_release_date.clone(),
820            version: doc.document.tracking.version.clone(),
821            publisher_name: doc.document.publisher.name.clone(),
822            tlp_label,
823            vulnerability_count: doc.vulnerabilities.len(),
824            max_cvss_v3_score: max_v3,
825            max_cvss_v4_score: max_v4,
826        }
827    }
828}
829
830#[cfg(test)]
831// Dense assertion blocks in tests are allowed to keep one round-trip
832// test per fixture readable; clippy's cognitive-complexity threshold
833// is tuned for production code paths.
834#[allow(clippy::cognitive_complexity)]
835mod tests {
836    use super::*;
837
838    #[test]
839    fn test_deserialize_csaf_security_advisory() {
840        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
841        let doc: CsafDocument =
842            serde_json::from_str(json).expect("Failed to deserialize CSAF document");
843
844        assert_eq!(doc.document.category, "csaf_security_advisory");
845        assert_eq!(doc.document.csaf_version, "2.1");
846        assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-003");
847        assert_eq!(doc.document.tracking.status, "final");
848        assert_eq!(
849            doc.document.publisher.name,
850            "ndaal Gesellschaft f\u{fc}r Sicherheit in der Informationstechnik mbH & Co KG"
851        );
852        assert_eq!(doc.vulnerabilities.len(), 1);
853
854        let vuln = &doc.vulnerabilities[0];
855        assert_eq!(vuln.cve.as_deref(), Some("CVE-0000-0001"));
856        assert_eq!(vuln.metrics.len(), 1);
857
858        let metric = &vuln.metrics[0];
859        let v3 = metric.content.cvss_v3.as_ref().expect("CVSS v3 missing");
860        assert!((v3.base_score - 9.8).abs() < f64::EPSILON);
861        assert_eq!(v3.base_severity, "CRITICAL");
862
863        let v4 = metric.content.cvss_v4.as_ref().expect("CVSS v4 missing");
864        assert!((v4.base_score - 9.3).abs() < f64::EPSILON);
865    }
866
867    #[test]
868    fn test_deserialize_csaf_vex() {
869        let json = include_str!("../../../test/csaf/2026/015/ndaal-sa-2026-015.json");
870        let doc: CsafDocument =
871            serde_json::from_str(json).expect("Failed to deserialize VEX document");
872
873        assert_eq!(doc.document.category, "csaf_vex");
874        assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-015");
875    }
876
877    #[test]
878    fn test_deserialize_all_test_files() {
879        let test_dir =
880            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
881
882        for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
883            let entry = entry.expect("dir entry error");
884            if !entry.file_type().expect("file type error").is_dir() {
885                continue;
886            }
887            for file in std::fs::read_dir(entry.path()).expect("subdir read error") {
888                let file = file.expect("file entry error");
889                let path = file.path();
890                if path.extension().is_some_and(|e| e == "json") {
891                    let content = std::fs::read_to_string(&path)
892                        .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
893                    let result: Result<CsafDocument, _> = serde_json::from_str(&content);
894                    assert!(
895                        result.is_ok(),
896                        "Failed to parse {}: {:?}",
897                        path.display(),
898                        result.err()
899                    );
900                }
901            }
902        }
903    }
904
905    #[test]
906    fn test_csaf_meta_extraction() {
907        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
908        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
909        let meta = CsafMeta::from_document(&doc);
910
911        assert_eq!(meta.tracking_id, "ndaal-sa-2026-003");
912        assert_eq!(meta.category, "csaf_security_advisory");
913        assert_eq!(meta.vulnerability_count, 1);
914        assert!(
915            meta.max_cvss_v3_score
916                .is_some_and(|s| (s - 9.8).abs() < f64::EPSILON)
917        );
918        assert!(
919            meta.max_cvss_v4_score
920                .is_some_and(|s| (s - 9.3).abs() < f64::EPSILON)
921        );
922        assert_eq!(meta.tlp_label.as_deref(), Some("CLEAR"));
923    }
924
925    #[test]
926    fn test_all_product_ids() {
927        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
928        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
929        let ids = doc.all_product_ids();
930
931        assert!(ids.contains(&"CSAFPID-001".to_owned()));
932        assert!(ids.contains(&"CSAFPID-002".to_owned()));
933    }
934
935    #[test]
936    fn test_roundtrip_serialization() {
937        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
938        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
939        let serialized = serde_json::to_string_pretty(&doc).expect("serialize error");
940        let doc2: CsafDocument = serde_json::from_str(&serialized).expect("re-parse error");
941        assert_eq!(doc, doc2);
942    }
943}