From 05d590fd5d6406b07a4f30fe857de35dba2b7580 Mon Sep 17 00:00:00 2001 From: Michael Lux Date: Wed, 2 Apr 2025 15:47:54 +0200 Subject: [PATCH 1/3] Add new CSAF validation rules and optimize test structure Introduced additional validation rules (6.1.37 - 6.1.42) for CSAF 2.1, including checks for sharing group names, TLP consistency, and PURLs. Refactored test structure to use a centralized helper function for streamlined testing and reduced redundancy. --- Cargo.lock | 194 +++++++++++++++++- csaf-lib/Cargo.toml | 2 + .../csaf/csaf2_0/getter_implementations.rs | 99 ++++++++- .../csaf/csaf2_1/getter_implementations.rs | 69 ++++++- csaf-lib/src/csaf/csaf2_1/validation.rs | 17 +- csaf-lib/src/csaf/getter_traits.rs | 60 +++++- csaf-lib/src/csaf/mod.rs | 1 + csaf-lib/src/csaf/test_helper.rs | 65 ++++++ csaf-lib/src/csaf/validations/mod.rs | 5 + csaf-lib/src/csaf/validations/test_6_1_01.rs | 11 +- csaf-lib/src/csaf/validations/test_6_1_02.rs | 11 +- csaf-lib/src/csaf/validations/test_6_1_34.rs | 56 +++-- csaf-lib/src/csaf/validations/test_6_1_35.rs | 59 +++--- csaf-lib/src/csaf/validations/test_6_1_36.rs | 50 ++--- csaf-lib/src/csaf/validations/test_6_1_37.rs | 34 +-- csaf-lib/src/csaf/validations/test_6_1_38.rs | 61 ++++++ csaf-lib/src/csaf/validations/test_6_1_39.rs | 68 ++++++ csaf-lib/src/csaf/validations/test_6_1_40.rs | 79 +++++++ csaf-lib/src/csaf/validations/test_6_1_41.rs | 90 ++++++++ csaf-lib/src/csaf/validations/test_6_1_42.rs | 148 +++++++++++++ 20 files changed, 1030 insertions(+), 149 deletions(-) create mode 100644 csaf-lib/src/csaf/test_helper.rs create mode 100644 csaf-lib/src/csaf/validations/test_6_1_38.rs create mode 100644 csaf-lib/src/csaf/validations/test_6_1_39.rs create mode 100644 csaf-lib/src/csaf/validations/test_6_1_40.rs create mode 100644 csaf-lib/src/csaf/validations/test_6_1_41.rs create mode 100644 csaf-lib/src/csaf/validations/test_6_1_42.rs diff --git a/Cargo.lock b/Cargo.lock index 0b86662..282db25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.34" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff" +checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" dependencies = [ "clap_builder", "clap_derive", @@ -142,9 +142,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.34" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489" +checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" dependencies = [ "anstream", "anstyle", @@ -187,7 +187,9 @@ name = "csaf-lib" version = "0.1.0" dependencies = [ "chrono", + "glob", "prettyplease", + "purl", "regex", "regress", "schemars", @@ -225,6 +227,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "hashbrown" version = "0.15.2" @@ -242,11 +250,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" -version = "0.1.62" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -317,9 +331,59 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.2" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] [[package]] name = "prettyplease" @@ -340,6 +404,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "purl" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ebe4262ae91ddd28c8721111a0a6e9e58860e211fc92116c4bb85c98fd96ad" +dependencies = [ + "hex", + "percent-encoding", + "phf", + "smartstring", + "thiserror", + "unicase", +] + [[package]] name = "quote" version = "1.0.40" @@ -349,6 +427,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "regex" version = "1.11.1" @@ -494,6 +587,29 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -578,6 +694,12 @@ dependencies = [ "typify-impl", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -590,6 +712,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -650,11 +778,37 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ - "windows-targets", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -663,6 +817,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/csaf-lib/Cargo.toml b/csaf-lib/Cargo.toml index d7ebee1..ed61225 100644 --- a/csaf-lib/Cargo.toml +++ b/csaf-lib/Cargo.toml @@ -9,6 +9,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } regex = "1" +glob = "0.3.2" +purl = "0.1" [build-dependencies] schemars = "0.8" diff --git a/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs b/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs index 72e46e8..f8f5ba3 100644 --- a/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs +++ b/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs @@ -1,7 +1,8 @@ -use crate::csaf::csaf2_0::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, Flag, FullProductNameT, Involvement, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, Threat, Tracking, Vulnerability}; -use crate::csaf::csaf2_1::schema::{CategoryOfTheRemediation as Remediation21}; -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, ThreatTrait, TrackingTrait, VulnerabilityTrait}; +use crate::csaf::csaf2_0::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, DocumentStatus, Flag, FullProductNameT, HelperToIdentifyTheProduct, Involvement, LabelOfTlp, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, RulesForSharingDocument, Threat, Tracking, TrafficLightProtocolTlp, Vulnerability}; +use crate::csaf::csaf2_1::schema::{CategoryOfTheRemediation as Remediation21, DocumentStatus as Status21, LabelOfTlp as Tlp21}; +use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; use std::ops::Deref; +use crate::csaf::validation::ValidationError; impl RemediationTrait for Remediation { /// Normalizes the remediation categories from CSAF 2.0 to those of CSAF 2.1. @@ -168,10 +169,82 @@ impl CsafTrait for CommonSecurityAdvisoryFramework { impl DocumentTrait for DocumentLevelMetaData { type TrackingType = Tracking; + type DistributionType = RulesForSharingDocument; fn get_tracking(&self) -> &Self::TrackingType { &self.tracking } + + /// Return distribution as ref Option, it is optional anyways + fn get_distribution_20(&self) -> Option<&Self::DistributionType> { + self.distribution.as_ref() + } + + /// Return distribution or a Validation error to satisfy CSAF 2.1 semantics + fn get_distribution_21(&self) -> Result<&Self::DistributionType, ValidationError> { + match self.distribution.as_ref() { + None => Err(ValidationError { + message: "CSAF 2.1 requires the distribution property, but it is not set.".to_string(), + instance_path: "/document/distribution".to_string() + }), + Some(distribution) => Ok(distribution) + } + } +} + +impl DistributionTrait for RulesForSharingDocument { + type SharingGroupType = (); + type TlpType = TrafficLightProtocolTlp; + + fn get_sharing_group(&self) -> &Option { + &None + } + + /// Return TLP as ref Option, it is an option anyway + fn get_tlp_20(&self) -> Option<&Self::TlpType> { + self.tlp.as_ref() + } + + /// Return TLP or a ValidationError to satisfy CSAF 2.1 semantics + fn get_tlp_21(&self) -> Result<&Self::TlpType, ValidationError> { + match self.tlp.as_ref() { + None => Err(ValidationError { + message: "CSAF 2.1 requires the TLP property, but it is not set.".to_string(), + instance_path: "/document/distribution/sharing_group/tlp".to_string() + }), + Some(tlp) => Ok(tlp) + } + } +} + +impl SharingGroupTrait for () { + fn get_id(&self) -> &String { + panic!("Sharing groups are not implemented in CSAF 2.0"); + } + + fn get_name(&self) -> Option<&String> { + panic!("Sharing groups are not implemented in CSAF 2.0"); + } +} + +impl TlpTrait for TrafficLightProtocolTlp { + /// Normalizes the TLP (Traffic Light Protocol) labels from CSAF 2.0 to those of CSAF 2.1. + /// + /// # Explanation + /// In CSAF 2.1, the TLP labeling scheme was updated to align with the official TLP 2.0 standard, + /// which renamed "WHITE" to "CLEAR". This function ensures that TLP labels from CSAF 2.0 + /// are converted to their corresponding labels in CSAF 2.1. + /// + /// # Returns + /// A CSAF 2.1 `Tlp21` value that corresponds to the TLP label of the current object. + fn get_label(&self) -> Tlp21 { + match self.label { + LabelOfTlp::Amber => Tlp21::Amber, + LabelOfTlp::Green => Tlp21::Green, + LabelOfTlp::Red => Tlp21::Red, + LabelOfTlp::White => Tlp21::Clear, + } + } } impl TrackingTrait for Tracking { @@ -193,6 +266,14 @@ impl TrackingTrait for Tracking { fn get_revision_history(&self) -> &Vec { &self.revision_history } + + fn get_status(&self) -> Status21 { + match self.status { + DocumentStatus::Draft => Status21::Draft, + DocumentStatus::Final => Status21::Final, + DocumentStatus::Interim => Status21::Interim, + } + } } impl GeneratorTrait for DocumentGenerator { @@ -276,7 +357,19 @@ impl RelationshipTrait for Relationship { } impl FullProductNameTrait for FullProductNameT { + type ProductIdentificationHelperType = HelperToIdentifyTheProduct; + fn get_product_id(&self) -> &String { self.product_id.deref() } + + fn get_product_identification_helper(&self) -> &Option { + &self.product_identification_helper + } } + +impl ProductIdentificationHelperTrait for HelperToIdentifyTheProduct { + fn get_purls(&self) -> Option<&[String]> { + self.purl.as_ref().map(|purl| std::slice::from_ref(purl)) + } +} \ No newline at end of file diff --git a/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs b/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs index 8aa0fd0..9efdbce 100644 --- a/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs +++ b/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs @@ -1,10 +1,11 @@ -use crate::csaf::csaf2_1::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, Flag, FullProductNameT, Involvement, Metric, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, Threat, Tracking, Vulnerability}; -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, ThreatTrait, TrackingTrait, VulnerabilityTrait}; +use crate::csaf::csaf2_1::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, DocumentStatus, Flag, FullProductNameT, HelperToIdentifyTheProduct, Involvement, LabelOfTlp, Metric, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, RulesForSharingDocument, SharingGroup, Threat, Tracking, TrafficLightProtocolTlp, Vulnerability}; +use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; use std::ops::Deref; +use crate::csaf::validation::ValidationError; impl RemediationTrait for Remediation { fn get_category(&self) -> CategoryOfTheRemediation { - self.category.clone() + self.category } fn get_product_ids(&self) -> Option + '_> { @@ -143,10 +144,56 @@ impl CsafTrait for CommonSecurityAdvisoryFramework { impl DocumentTrait for DocumentLevelMetaData { type TrackingType = Tracking; + type DistributionType = RulesForSharingDocument; fn get_tracking(&self) -> &Self::TrackingType { &self.tracking } + + /// We normalize to Option here because property was optional in CSAF 2.0 + fn get_distribution_21(&self) -> Result<&Self::DistributionType, ValidationError> { + Ok(&self.distribution) + } + + /// Always return the value because it is mandatory + fn get_distribution_20(&self) -> Option<&Self::DistributionType> { + Some(&self.distribution) + } +} + +impl DistributionTrait for RulesForSharingDocument { + type SharingGroupType = SharingGroup; + type TlpType = TrafficLightProtocolTlp; + + fn get_sharing_group(&self) -> &Option { + &self.sharing_group + } + + /// We normalize to Option here because property was optional in CSAF 2.0 + fn get_tlp_20(&self) -> Option<&Self::TlpType> { + Some(&self.tlp) + } + + /// Always return the value because it is mandatory + fn get_tlp_21(&self) -> Result<&Self::TlpType, ValidationError> { + Ok(&self.tlp) + } +} + +impl SharingGroupTrait for SharingGroup { + fn get_id(&self) -> &String { + &self.id + } + + fn get_name(&self) -> Option<&String> { + self.name.as_ref().map(|x| x.deref()) + } +} + +impl TlpTrait for TrafficLightProtocolTlp { + fn get_label(&self) -> LabelOfTlp { + self.label + } } impl TrackingTrait for Tracking { @@ -168,6 +215,10 @@ impl TrackingTrait for Tracking { fn get_revision_history(&self) -> &Vec { &self.revision_history } + + fn get_status(&self) -> DocumentStatus { + self.status + } } impl GeneratorTrait for DocumentGenerator { @@ -251,7 +302,19 @@ impl RelationshipTrait for Relationship { } impl FullProductNameTrait for FullProductNameT { + type ProductIdentificationHelperType = HelperToIdentifyTheProduct; + fn get_product_id(&self) -> &String { self.product_id.deref() } + + fn get_product_identification_helper(&self) -> &Option { + &self.product_identification_helper + } +} + +impl ProductIdentificationHelperTrait for HelperToIdentifyTheProduct { + fn get_purls(&self) -> Option<&[String]> { + self.purls.as_ref().map(|v| v.as_slice()) + } } diff --git a/csaf-lib/src/csaf/csaf2_1/validation.rs b/csaf-lib/src/csaf/csaf2_1/validation.rs index b0c8092..f91999e 100644 --- a/csaf-lib/src/csaf/csaf2_1/validation.rs +++ b/csaf-lib/src/csaf/csaf2_1/validation.rs @@ -5,11 +5,20 @@ use crate::csaf::validations::test_6_1_02::test_6_1_02_multiple_definition_of_pr use crate::csaf::validations::test_6_1_34::test_6_1_34_branches_recursion_depth; use crate::csaf::validations::test_6_1_35::test_6_1_35_contradicting_remediations; use crate::csaf::validations::test_6_1_36::test_6_1_36_status_group_contradicting_remediation_categories; +use crate::csaf::validations::test_6_1_37::test_6_1_37_date_and_time; +use crate::csaf::validations::test_6_1_38::test_6_1_38_non_public_sharing_group_max_uuid; +use crate::csaf::validations::test_6_1_39::test_6_1_39_public_sharing_group_with_no_max_uuid; +use crate::csaf::validations::test_6_1_40::test_6_1_40_invalid_sharing_group_name; +use crate::csaf::validations::test_6_1_41::test_6_1_41_missing_sharing_group_name; +use crate::csaf::validations::test_6_1_42::test_6_1_42_purl_consistency; use std::collections::HashMap; impl Validatable for CommonSecurityAdvisoryFramework { fn presets(&self) -> HashMap> { - let basic_tests = Vec::from(["6.1.1", "6.1.2", "6.1.34", "6.1.35", "6.1.36"]); + let basic_tests = Vec::from([ + "6.1.1", "6.1.2", "6.1.34", "6.1.35", "6.1.36", "6.1.37", + "6.1.38", "6.1.39", "6.1.40", "6.1.41", "6.1.42" + ]); // More tests may be added in extend() here later let extended_tests: Vec<&str> = basic_tests.clone(); // extended_tests.extend(["foo"].iter()); @@ -30,6 +39,12 @@ impl Validatable for CommonSecurityAdvisoryFram ("6.1.34", test_6_1_34_branches_recursion_depth as CsafTest), ("6.1.35", test_6_1_35_contradicting_remediations as CsafTest), ("6.1.36", test_6_1_36_status_group_contradicting_remediation_categories as CsafTest), + ("6.1.37", test_6_1_37_date_and_time as CsafTest), + ("6.1.38", test_6_1_38_non_public_sharing_group_max_uuid as CsafTest), + ("6.1.39", test_6_1_39_public_sharing_group_with_no_max_uuid as CsafTest), + ("6.1.40", test_6_1_40_invalid_sharing_group_name as CsafTest), + ("6.1.41", test_6_1_41_missing_sharing_group_name as CsafTest), + ("6.1.42", test_6_1_42_purl_consistency as CsafTest), ]) } diff --git a/csaf-lib/src/csaf/getter_traits.rs b/csaf-lib/src/csaf/getter_traits.rs index 81ef1c7..7671f15 100644 --- a/csaf-lib/src/csaf/getter_traits.rs +++ b/csaf-lib/src/csaf/getter_traits.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeSet, HashSet}; -use crate::csaf::csaf2_1::schema::CategoryOfTheRemediation; +use crate::csaf::csaf2_1::schema::{CategoryOfTheRemediation, DocumentStatus, LabelOfTlp}; use crate::csaf::helpers::resolve_product_groups; +use crate::csaf::validation::ValidationError; /// Trait representing an abstract Common Security Advisory Framework (CSAF) document. /// @@ -32,8 +33,50 @@ pub trait DocumentTrait { /// Type representing document tracking information type TrackingType: TrackingTrait; + /// Type representing document distribution information + type DistributionType: DistributionTrait; + /// Returns the tracking information for this document fn get_tracking(&self) -> &Self::TrackingType; + + /// Returns the distribution information for this document with CSAF 2.1 semantics + fn get_distribution_21(&self) -> Result<&Self::DistributionType, ValidationError>; + + /// Returns the distribution information for this document with CSAF 2.0 semantics + fn get_distribution_20(&self) -> Option<&Self::DistributionType>; +} + +/// Trait representing distribution information for a document +pub trait DistributionTrait { + /// Type representing sharing group information + type SharingGroupType: SharingGroupTrait; + + /// Type representing TLP (Traffic Light Protocol) information + type TlpType: TlpTrait; + + /// Returns the sharing group for this distribution + fn get_sharing_group(&self) -> &Option; + + /// Returns the TLP information for this distribution with CSAF 2.0 semantics + fn get_tlp_20(&self) -> Option<&Self::TlpType>; + + /// Returns the TLP information for this distribution with CSAF 2.1 semantics + fn get_tlp_21(&self) -> Result<&Self::TlpType, ValidationError>; +} + +/// Trait representing sharing group information +pub trait SharingGroupTrait { + /// Returns the ID of the sharing group + fn get_id(&self) -> &String; + + /// Returns the optional name of the sharing group + fn get_name(&self) -> Option<&String>; +} + +/// Trait representing TLP (Traffic Light Protocol) information +pub trait TlpTrait { + /// Returns the TLP label + fn get_label(&self) -> LabelOfTlp; } pub trait TrackingTrait { @@ -54,6 +97,9 @@ pub trait TrackingTrait { /// Returns the revision history for this document fn get_revision_history(&self) -> &Vec; + + /// Returns the status of this document + fn get_status(&self) -> DocumentStatus; } /// Trait for accessing document generator information @@ -351,6 +397,18 @@ pub trait RelationshipTrait { /// Trait representing an abstract full product name in a CSAF document. pub trait FullProductNameTrait { + /// The associated type representing a product identification helper. + type ProductIdentificationHelperType: ProductIdentificationHelperTrait; + /// Returns the product ID from the full product name. fn get_product_id(&self) -> &String; + + /// Returns the product identification helper associated with the full product name. + fn get_product_identification_helper(&self) -> &Option; +} + +/// Trait representing an abstract product identification helper of a full product name. +pub trait ProductIdentificationHelperTrait { + /// Returns the PURLs identifying the associated product. + fn get_purls(&self) -> Option<&[String]>; } \ No newline at end of file diff --git a/csaf-lib/src/csaf/mod.rs b/csaf-lib/src/csaf/mod.rs index 962f04f..f284633 100644 --- a/csaf-lib/src/csaf/mod.rs +++ b/csaf-lib/src/csaf/mod.rs @@ -5,3 +5,4 @@ pub mod product_helpers; pub mod validation; pub mod getter_traits; pub mod validations; +pub mod test_helper; diff --git a/csaf-lib/src/csaf/test_helper.rs b/csaf-lib/src/csaf/test_helper.rs new file mode 100644 index 0000000..929271f --- /dev/null +++ b/csaf-lib/src/csaf/test_helper.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use crate::csaf::csaf2_1::loader::load_document; +use crate::csaf::csaf2_1::schema::CommonSecurityAdvisoryFramework; +use crate::csaf::validation::{Test, ValidationError}; + +/// Generic test helper that loads all test files matching a specific test number pattern +/// and runs positive and negative validations against a test function. +/// +/// # Arguments +/// * `test_number` - The test number to run (e.g., "36" for 6.1.36 tests) +/// * `test_function` - The test function to execute against each document +/// * `negative_cases` - A slice of tuples containing (file_suffix, expected_validation_error) +/// for negative test cases (starting with "0") +/// +/// This function assumes tests with filenames ending with numbers starting with "0" +/// are negative tests, and those starting with "1" are positive tests. +pub fn run_csaf21_tests( + test_number: &str, + test_function: Test, + expected_errors: HashMap<&str, &ValidationError>, +) { + use glob::glob; + + // Find all test files matching the pattern + let pattern = &format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-{}-*.json", test_number); + let file_prefix = &format!("oasis_csaf_tc-csaf_2_1-2024-6-1-{}-", test_number); + + // Load and test each file + for entry in glob(pattern).expect("Failed to parse glob pattern") { + if let Ok(path) = entry { + // Extract the file suffix (e.g., "01", "02", etc.) + let file_name = path.file_name().unwrap().to_string_lossy(); + let test_num = file_name + .strip_prefix(file_prefix) + .unwrap() + .strip_suffix(".json") + .unwrap(); + + // Load the document + let doc = load_document(path.to_string_lossy().as_ref()).unwrap(); + + // Check if this is expected to be a negative or positive test case + if test_num.starts_with('0') { + // Negative test case - should fail with specific error + let expected_error = expected_errors.get(test_num).expect( + &format!("Missing expected error definition for negative test case {}", test_num) + ); + assert_eq!( + Err((*expected_error).clone()), + test_function(&doc), + "Negative test case {} should have failed with the expected error", test_num + ); + } else if test_num.starts_with('1') { + // Positive test case - should succeed + assert_eq!( + Ok(()), + test_function(&doc), + "Positive test case {} should have succeeded", test_num + ); + } else { + panic!("Unexpected test case number format: {}", test_num); + } + } + } +} \ No newline at end of file diff --git a/csaf-lib/src/csaf/validations/mod.rs b/csaf-lib/src/csaf/validations/mod.rs index 7c85673..52fd5da 100644 --- a/csaf-lib/src/csaf/validations/mod.rs +++ b/csaf-lib/src/csaf/validations/mod.rs @@ -4,3 +4,8 @@ pub mod test_6_1_34; pub mod test_6_1_35; pub mod test_6_1_36; pub mod test_6_1_37; +pub mod test_6_1_38; +pub mod test_6_1_39; +pub mod test_6_1_40; +pub mod test_6_1_41; +pub mod test_6_1_42; diff --git a/csaf-lib/src/csaf/validations/test_6_1_01.rs b/csaf-lib/src/csaf/validations/test_6_1_01.rs index 07723f3..c5c27eb 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_01.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_01.rs @@ -24,8 +24,9 @@ pub fn test_6_1_01_missing_definition_of_product_id( #[cfg(test)] mod tests { + use std::collections::HashMap; use crate::csaf::csaf2_0::loader::load_document as load_20; - use crate::csaf::csaf2_1::loader::load_document as load_21; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_01::test_6_1_01_missing_definition_of_product_id; @@ -46,13 +47,11 @@ mod tests { #[test] fn test_6_1_01_csaf_2_1() { - let doc = load_21("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-01-01.json").unwrap(); - assert_eq!( - test_6_1_01_missing_definition_of_product_id(&doc), - Err(ValidationError { + run_csaf21_tests("01", test_6_1_01_missing_definition_of_product_id, HashMap::from([ + ("01", &ValidationError { message: EXPECTED_ERROR.to_string(), instance_path: EXPECTED_INSTANCE_PATH.to_string(), }) - ); + ])); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_02.rs b/csaf-lib/src/csaf/validations/test_6_1_02.rs index fca1a0d..2ae1f55 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_02.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_02.rs @@ -36,8 +36,9 @@ fn find_duplicates(vec: Vec<(String, String)>) -> Vec<(String, Vec)> { #[cfg(test)] mod tests { + use std::collections::HashMap; use crate::csaf::csaf2_0::loader::load_document as load_20; - use crate::csaf::csaf2_1::loader::load_document as load_21; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_02::test_6_1_02_multiple_definition_of_product_id; @@ -58,13 +59,11 @@ mod tests { #[test] fn test_test_6_1_02_csaf_2_1() { - let doc = load_21("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-02-01.json").unwrap(); - assert_eq!( - test_6_1_02_multiple_definition_of_product_id(&doc), - Err(ValidationError { + run_csaf21_tests("02", test_6_1_02_multiple_definition_of_product_id, HashMap::from([ + ("01", &ValidationError { message: EXPECTED_ERROR.to_string(), instance_path: EXPECTED_INSTANCE_PATH.to_string(), }) - ) + ])); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_34.rs b/csaf-lib/src/csaf/validations/test_6_1_34.rs index 33b7b0d..a99f362 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_34.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_34.rs @@ -44,42 +44,34 @@ fn find_excessive_branch_depth_rec(branch: &impl BranchTrait, remaining_depth: u #[cfg(test)] mod tests { - use crate::csaf::csaf2_1::loader::load_document; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_34::test_6_1_34_branches_recursion_depth; + use std::collections::HashMap; #[test] fn test_test_6_1_34() { - for x in ["11"].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-34-{}.json", x).as_str()).unwrap(); - assert_eq!( - Ok(()), - test_6_1_34_branches_recursion_depth(&doc) - ) - } - for (x, err) in [ - ("01", ValidationError { - message: "Branches recursion depth too big (> 30)".to_string(), - instance_path: "/product_tree/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0".to_string() - }), - ("02", ValidationError { - message: "Branches recursion depth too big (> 30)".to_string(), - instance_path: "/product_tree/branches/0/branches/0/branches/1/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ - /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0".to_string() - }), - ].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-34-{}.json", x).as_str()).unwrap(); - assert_eq!( - Err(err.to_owned()), - test_6_1_34_branches_recursion_depth(&doc) - ) - } + run_csaf21_tests( + "34", + test_6_1_34_branches_recursion_depth, + HashMap::from([ + ("01", &ValidationError { + message: "Branches recursion depth too big (> 30)".to_string(), + instance_path: "/product_tree/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0".to_string(), + }), + ("02", &ValidationError { + message: "Branches recursion depth too big (> 30)".to_string(), + instance_path: "/product_tree/branches/0/branches/0/branches/1/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0/branches/0\ + /branches/0/branches/0/branches/0/branches/0/branches/0/branches/0".to_string(), + }), + ]), + ); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_35.rs b/csaf-lib/src/csaf/validations/test_6_1_35.rs index 1f1851c..4e7e197 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_35.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_35.rs @@ -1,7 +1,7 @@ use crate::csaf::csaf2_1::schema::CategoryOfTheRemediation; use crate::csaf::getter_traits::{CsafTrait, RemediationTrait, VulnerabilityTrait}; -use std::collections::BTreeMap; use crate::csaf::validation::ValidationError; +use std::collections::BTreeMap; /// Totally exclusive categories that cannot be combined with any other category. static EX_STATES: &[CategoryOfTheRemediation] = &[ @@ -61,43 +61,34 @@ pub fn test_6_1_35_contradicting_remediations( #[cfg(test)] mod tests { - use crate::csaf::csaf2_1::loader::load_document; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_35::test_6_1_35_contradicting_remediations; + use std::collections::HashMap; #[test] fn test_test_6_1_35() { - for x in ["11", "12", "13", "14"].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-35-{}.json", x).as_str()).unwrap(); - assert_eq!( - Ok(()), - test_6_1_35_contradicting_remediations(&doc) - ) - } - for (x, err) in [ - ("01", ValidationError { - message: "Product CSAFPID-9080700 has contradicting remediations: no_fix_planned and vendor_fix".to_string(), - instance_path: "/vulnerabilities/0/remediations/1".to_string() - }), - ("02", ValidationError { - message: "Product CSAFPID-9080700 has contradicting remediations: none_available and mitigation".to_string(), - instance_path: "/vulnerabilities/0/remediations/1".to_string() - }), - ("03", ValidationError { - message: "Product CSAFPID-9080702 has contradicting remediations: workaround, fix_planned and optional_patch".to_string(), - instance_path: "/vulnerabilities/0/remediations/2".to_string(), - }), - ("04", ValidationError { - message: "Product CSAFPID-9080701 has contradicting remediations: mitigation, fix_planned and optional_patch".to_string(), - instance_path: "/vulnerabilities/0/remediations/2".to_string(), - }), - ].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-35-{}.json", x).as_str()).unwrap(); - - assert_eq!( - Err(err.clone()), - test_6_1_35_contradicting_remediations(&doc) - ) - } + run_csaf21_tests( + "35", + test_6_1_35_contradicting_remediations, + HashMap::from([ + ("01", &ValidationError { + message: "Product CSAFPID-9080700 has contradicting remediations: no_fix_planned and vendor_fix".to_string(), + instance_path: "/vulnerabilities/0/remediations/1".to_string(), + }), + ("02", &ValidationError { + message: "Product CSAFPID-9080700 has contradicting remediations: none_available and mitigation".to_string(), + instance_path: "/vulnerabilities/0/remediations/1".to_string(), + }), + ("03", &ValidationError { + message: "Product CSAFPID-9080702 has contradicting remediations: workaround, fix_planned and optional_patch".to_string(), + instance_path: "/vulnerabilities/0/remediations/2".to_string(), + }), + ("04", &ValidationError { + message: "Product CSAFPID-9080701 has contradicting remediations: mitigation, fix_planned and optional_patch".to_string(), + instance_path: "/vulnerabilities/0/remediations/2".to_string(), + }), + ]), + ); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_36.rs b/csaf-lib/src/csaf/validations/test_6_1_36.rs index b0c4844..00038d9 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_36.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_36.rs @@ -1,7 +1,7 @@ -use std::collections::HashSet; use crate::csaf::csaf2_1::schema::CategoryOfTheRemediation; use crate::csaf::getter_traits::{CsafTrait, ProductStatusTrait, RemediationTrait, VulnerabilityTrait}; use crate::csaf::validation::ValidationError; +use std::collections::HashSet; /// Remediation categories that conflict with the product status "not affected". const NOT_AFFECTED_CONFLICTS: &[CategoryOfTheRemediation] = &[ @@ -81,38 +81,30 @@ pub fn test_6_1_36_status_group_contradicting_remediation_categories( #[cfg(test)] mod tests { - use crate::csaf::csaf2_1::loader::load_document; + use std::collections::HashMap; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_36::test_6_1_36_status_group_contradicting_remediation_categories; #[test] fn test_test_6_1_36() { - for x in ["11", "12", "13"].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-36-{}.json", x).as_str()).unwrap(); - assert_eq!( - Ok(()), - test_6_1_36_status_group_contradicting_remediation_categories(&doc) - ) - } - for (x, err) in [ - ("01", ValidationError { - message: "Product CSAFPID-9080700 is listed as not affected but has conflicting remediation category vendor_fix".to_string(), - instance_path: "/vulnerabilities/0/remediations/0".to_string() - }), - ("02", ValidationError { - message: "Product CSAFPID-9080703 is listed as fixed but has conflicting remediation category none_available".to_string(), - instance_path: "/vulnerabilities/0/remediations/0".to_string() - }), - ("03", ValidationError { - message: "Product CSAFPID-9080700 is listed as affected but has conflicting remediation category optional_patch".to_string(), - instance_path: "/vulnerabilities/0/remediations/0".to_string(), - }), - ].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-36-{}.json", x).as_str()).unwrap(); - assert_eq!( - Err(err.clone()), - test_6_1_36_status_group_contradicting_remediation_categories(&doc) - ) - } + run_csaf21_tests( + "36", + test_6_1_36_status_group_contradicting_remediation_categories, + HashMap::from([ + ("01", &ValidationError { + message: "Product CSAFPID-9080700 is listed as not affected but has conflicting remediation category vendor_fix".to_string(), + instance_path: "/vulnerabilities/0/remediations/0".to_string() + }), + ("02", &ValidationError { + message: "Product CSAFPID-9080703 is listed as fixed but has conflicting remediation category none_available".to_string(), + instance_path: "/vulnerabilities/0/remediations/0".to_string() + }), + ("03", &ValidationError { + message: "Product CSAFPID-9080700 is listed as affected but has conflicting remediation category optional_patch".to_string(), + instance_path: "/vulnerabilities/0/remediations/0".to_string(), + }), + ]), + ); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_37.rs b/csaf-lib/src/csaf/validations/test_6_1_37.rs index 7aaf72e..4ee4db4 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_37.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_37.rs @@ -106,33 +106,21 @@ fn check_datetime(date_time: &String, instance_path: &str) -> Result<(), Validat #[cfg(test)] mod tests { - use crate::csaf::csaf2_1::loader::load_document; + use crate::csaf::test_helper::run_csaf21_tests; use crate::csaf::validation::ValidationError; use crate::csaf::validations::test_6_1_37::test_6_1_37_date_and_time; + use std::collections::HashMap; #[test] fn test_test_6_1_37() { - for x in ["11"].iter() { - let doc = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-37-{}.json", x).as_str()).unwrap(); - assert_eq!( - Ok(()), - test_6_1_37_date_and_time(&doc) - ) - } - for (x, err) in [ - ("01", ValidationError { - message: "Invalid date-time string 2024-01-24 10:00:00.000Z, expected RFC3339-compliant format with non-empty timezone".to_string(), - instance_path: "/document/tracking/initial_release_date".to_string() - }), - ].iter() { - let doc_result = load_document(format!("../csaf/csaf_2.1/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-37-{}.json", x).as_str()); - match doc_result { - Ok(doc) => { - let result = test_6_1_37_date_and_time(&doc); - assert_eq!(result, Err(err.clone())); - }, - Err(error) => panic!("Unexpected error: {:?}", error), - } - } + run_csaf21_tests( + "37", + test_6_1_37_date_and_time, HashMap::from([ + ("01", &ValidationError { + message: "Invalid date-time string 2024-01-24 10:00:00.000Z, expected RFC3339-compliant format with non-empty timezone".to_string(), + instance_path: "/document/tracking/initial_release_date".to_string(), + }), + ]) + ); } } diff --git a/csaf-lib/src/csaf/validations/test_6_1_38.rs b/csaf-lib/src/csaf/validations/test_6_1_38.rs new file mode 100644 index 0000000..e59dc26 --- /dev/null +++ b/csaf-lib/src/csaf/validations/test_6_1_38.rs @@ -0,0 +1,61 @@ +use crate::csaf::csaf2_1::schema::LabelOfTlp::Clear; +use crate::csaf::getter_traits::{CsafTrait, DistributionTrait, DocumentTrait, SharingGroupTrait, TlpTrait}; +use crate::csaf::validation::ValidationError; + +static MAX_UUID: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + +/// Validates that a CSAF document using the maximum UUID as the sharing group ID +/// has the TLP (Traffic Light Protocol) label set to `CLEAR`. +/// +/// According to CSAF 2.1 specifications, when a document uses such a +/// sharing group ID, it must be publicly accessible, represented by +/// having the TLP label as `CLEAR`. +/// +/// # Arguments +/// +/// * `doc` - A reference to an object implementing the `CsafTrait` interface. +/// +/// # Returns +/// +/// * `Ok(())` if the validation passes. +/// * `Err(ValidationError)` if the validation fails, with a message explaining the reason +/// and the JSON path to the invalid element. +pub fn test_6_1_38_non_public_sharing_group_max_uuid( + doc: &impl CsafTrait, +) -> Result<(), ValidationError> { + let distribution = doc.get_document().get_distribution_21()?; + + if let Some(sharing_group) = distribution.get_sharing_group() { + if sharing_group.get_id() == MAX_UUID && distribution.get_tlp_21()?.get_label() != Clear { + return Err(ValidationError { + message: "Document must be public (TLD CLEAR) when using max UUID as sharing group ID.".to_string(), + instance_path: "/document/distribution/sharing_group/tlp/label".to_string() + }) + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::csaf::test_helper::run_csaf21_tests; + use crate::csaf::validation::ValidationError; + use crate::csaf::validations::test_6_1_38::test_6_1_38_non_public_sharing_group_max_uuid; + use std::collections::HashMap; + + #[test] + fn test_test_6_1_38() { + let expected_error = ValidationError { + message: "Document must be public (TLD CLEAR) when using max UUID as sharing group ID.".to_string(), + instance_path: "/document/distribution/sharing_group/tlp/label".to_string(), + }; + + run_csaf21_tests("38", test_6_1_38_non_public_sharing_group_max_uuid, HashMap::from([ + ("01", &expected_error), + ("02", &expected_error), + ("03", &expected_error), + ("04", &expected_error), + ])); + } +} diff --git a/csaf-lib/src/csaf/validations/test_6_1_39.rs b/csaf-lib/src/csaf/validations/test_6_1_39.rs new file mode 100644 index 0000000..404927f --- /dev/null +++ b/csaf-lib/src/csaf/validations/test_6_1_39.rs @@ -0,0 +1,68 @@ +use crate::csaf::csaf2_1::schema::DocumentStatus; +use crate::csaf::getter_traits::{CsafTrait, DistributionTrait, DocumentTrait, SharingGroupTrait, TlpTrait, TrackingTrait}; +use crate::csaf::validation::ValidationError; +use crate::csaf::csaf2_1::schema::LabelOfTlp::Clear; + +static MAX_UUID: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; +static NIL_UUID: &str = "00000000-0000-0000-0000-000000000000"; + +/// Validates that when a document is marked with TLP CLEAR, any associated sharing group +/// must either have a `MAX_UUID` as its ID or a `NIL_UUID` accompanied by the document status being "Draft". +/// +/// This function checks the following (if TLP CLEAR): +/// - If the sharing group ID is `MAX_UUID`, the validation passes. +/// - If the sharing group ID is `NIL_UUID` and the document status is "Draft", the validation passes. +/// - Otherwise, the function returns a `ValidationError` with a relevant error message. +/// +/// # Arguments +/// +/// - `doc`: A document implementing the `CsafTrait` interface. +/// +/// # Returns +/// +/// - `Ok(())` if the validation passes. +/// - `Err(ValidationError)` if the requirements are not met. +pub fn test_6_1_39_public_sharing_group_with_no_max_uuid( + doc: &impl CsafTrait, +) -> Result<(), ValidationError> { + let distribution = doc.get_document().get_distribution_21()?; + + if distribution.get_tlp_21()?.get_label() == Clear { + if let Some(sharing_group) = distribution.get_sharing_group() { + let sharing_group_id = sharing_group.get_id(); + return if sharing_group_id == MAX_UUID { + Ok(()) + } else if sharing_group_id == NIL_UUID && doc.get_document().get_tracking().get_status() == DocumentStatus::Draft { + Ok(()) + } else { + Err(ValidationError { + message: "Document with TLP CLEAR and sharing group must use max UUID or nil UUID plus draft status.".to_string(), + instance_path: "/document/distribution/sharing_group/id".to_string(), + }) + }; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use crate::csaf::test_helper::run_csaf21_tests; + use crate::csaf::validation::ValidationError; + use crate::csaf::validations::test_6_1_39::test_6_1_39_public_sharing_group_with_no_max_uuid; + + #[test] + fn test_test_6_1_39() { + let expected_error = ValidationError { + message: "Document with TLP CLEAR and sharing group must use max UUID or nil UUID plus draft status.".to_string(), + instance_path: "/document/distribution/sharing_group/id".to_string(), + }; + + run_csaf21_tests("39", test_6_1_39_public_sharing_group_with_no_max_uuid, HashMap::from([ + ("01", &expected_error), + ("02", &expected_error), + ])); + } +} diff --git a/csaf-lib/src/csaf/validations/test_6_1_40.rs b/csaf-lib/src/csaf/validations/test_6_1_40.rs new file mode 100644 index 0000000..3a0b88b --- /dev/null +++ b/csaf-lib/src/csaf/validations/test_6_1_40.rs @@ -0,0 +1,79 @@ +use crate::csaf::getter_traits::{CsafTrait, DistributionTrait, DocumentTrait, SharingGroupTrait}; +use crate::csaf::validation::ValidationError; + +static NAME_PUBLIC: &str = "Public"; +static NAME_PRIVATE: &str = "No sharing allowed"; +static MAX_UUID: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; +static NIL_UUID: &str = "00000000-0000-0000-0000-000000000000"; + +/// Validates the sharing group name and ID combinations in a CSAF document. +/// +/// This function checks if the sharing group name and ID in the document's distribution +/// follow specific rules: +/// +/// - If the sharing group name is "Public", the ID must be the maximum UUID +/// ("ffffffff-ffff-ffff-ffff-ffffffffffff"). +/// - If the sharing group name is "No sharing allowed", the ID must be the nil UUID +/// ("00000000-0000-0000-0000-000000000000"). +/// +/// # Arguments +/// +/// * `doc` - A reference to an object implementing the `CsafTrait` interface. +/// +/// # Returns +/// +/// * `Ok(())` if the validation passes. +/// * `Err(ValidationError)` if the validation fails, with a message explaining the reason +/// and the JSON path to the invalid element. +pub fn test_6_1_40_invalid_sharing_group_name( + doc: &impl CsafTrait, +) -> Result<(), ValidationError> { + let distribution = doc.get_document().get_distribution_21()?; + + if let Some(sharing_group) = distribution.get_sharing_group() { + if let Some(sharing_group_name) = sharing_group.get_name() { + if sharing_group_name == NAME_PUBLIC { + if sharing_group.get_id() != MAX_UUID { + return Err(ValidationError { + message: format!("Sharing group name \"{}\" is prohibited without max UUID.", NAME_PUBLIC), + instance_path: "/document/distribution/sharing_group/name".to_string() + }) + } + } else if sharing_group_name == NAME_PRIVATE { + if sharing_group.get_id() != NIL_UUID { + return Err(ValidationError { + message: format!("Sharing group name \"{}\" is prohibited without nil UUID.", NAME_PRIVATE), + instance_path: "/document/distribution/sharing_group/name".to_string() + }) + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use crate::csaf::test_helper::run_csaf21_tests; + use crate::csaf::validation::ValidationError; + use crate::csaf::validations::test_6_1_40::{test_6_1_40_invalid_sharing_group_name, NAME_PRIVATE, NAME_PUBLIC}; + + #[test] + fn test_test_6_1_40() { + run_csaf21_tests( + "40", + test_6_1_40_invalid_sharing_group_name, HashMap::from([ + ("01", &ValidationError { + message: format!("Sharing group name \"{}\" is prohibited without max UUID.", NAME_PUBLIC), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ("02", &ValidationError { + message: format!("Sharing group name \"{}\" is prohibited without nil UUID.", NAME_PRIVATE), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ]) + ); + } +} diff --git a/csaf-lib/src/csaf/validations/test_6_1_41.rs b/csaf-lib/src/csaf/validations/test_6_1_41.rs new file mode 100644 index 0000000..efe6004 --- /dev/null +++ b/csaf-lib/src/csaf/validations/test_6_1_41.rs @@ -0,0 +1,90 @@ +use crate::csaf::getter_traits::{CsafTrait, DistributionTrait, DocumentTrait, SharingGroupTrait}; +use crate::csaf::validation::ValidationError; + +static NAME_PUBLIC: &str = "Public"; +static NAME_PRIVATE: &str = "No sharing allowed"; +static MAX_UUID: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; +static NIL_UUID: &str = "00000000-0000-0000-0000-000000000000"; + +/// Validates that a CSAF document with specific sharing group IDs has the correct corresponding name. +/// +/// This function ensures that: +/// - When using the maximum UUID ("ffffffff-ffff-ffff-ffff-ffffffffffff"), the sharing group name +/// must be "Public". +/// - When using the nil UUID ("00000000-0000-0000-0000-000000000000"), the sharing group name +/// must be "No sharing allowed". +/// +/// # Arguments +/// +/// * `doc` - A reference to an object implementing the `CsafTrait` interface. +/// +/// # Returns +/// +/// * `Ok(())` if the validation passes. +/// * `Err(ValidationError)` if the validation fails, with a message explaining the reason +/// and the JSON path to the invalid element. +pub fn test_6_1_41_missing_sharing_group_name( + doc: &impl CsafTrait, +) -> Result<(), ValidationError> { + let distribution = doc.get_document().get_distribution_21()?; + + if let Some(sharing_group) = distribution.get_sharing_group() { + // Check if max UUID is used + if sharing_group.get_id() == MAX_UUID { + // If max UUID is used, name must exist and be NAME_PUBLIC + match sharing_group.get_name() { + Some(name) if name == NAME_PUBLIC => {}, + _ => return Err(ValidationError { + message: format!("Max UUID requires sharing group name to be \"{}\".", NAME_PUBLIC), + instance_path: "/document/distribution/sharing_group/name".to_string() + }) + } + } + // Check if nil UUID is used + else if sharing_group.get_id() == NIL_UUID { + // If nil UUID is used, name must exist and be NAME_PRIVATE + match sharing_group.get_name() { + Some(name) if name == NAME_PRIVATE => {}, + _ => return Err(ValidationError { + message: format!("Nil UUID requires sharing group name to be \"{}\".", NAME_PRIVATE), + instance_path: "/document/distribution/sharing_group/name".to_string() + }) + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use crate::csaf::test_helper::run_csaf21_tests; + use crate::csaf::validation::ValidationError; + use crate::csaf::validations::test_6_1_41::{test_6_1_41_missing_sharing_group_name, NAME_PRIVATE, NAME_PUBLIC}; + + #[test] + fn test_test_6_1_41() { + run_csaf21_tests( + "41", + test_6_1_41_missing_sharing_group_name, HashMap::from([ + ("01", &ValidationError { + message: format!("Max UUID requires sharing group name to be \"{}\".", NAME_PUBLIC), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ("02", &ValidationError { + message: format!("Nil UUID requires sharing group name to be \"{}\".", NAME_PRIVATE), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ("03", &ValidationError { + message: format!("Max UUID requires sharing group name to be \"{}\".", NAME_PUBLIC), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ("04", &ValidationError { + message: format!("Nil UUID requires sharing group name to be \"{}\".", NAME_PRIVATE), + instance_path: "/document/distribution/sharing_group/name".to_string() + }), + ]) + ); + } +} diff --git a/csaf-lib/src/csaf/validations/test_6_1_42.rs b/csaf-lib/src/csaf/validations/test_6_1_42.rs new file mode 100644 index 0000000..cb524d9 --- /dev/null +++ b/csaf-lib/src/csaf/validations/test_6_1_42.rs @@ -0,0 +1,148 @@ +use crate::csaf::getter_traits::{BranchTrait, CsafTrait, FullProductNameTrait, ProductIdentificationHelperTrait, ProductTreeTrait, RelationshipTrait}; +use crate::csaf::validation::ValidationError; +use purl::GenericPurl; + +pub fn test_6_1_42_purl_consistency( + doc: &impl CsafTrait, +) -> Result<(), ValidationError> { + // Skip if product_tree is None + if let Some(product_tree) = doc.get_product_tree() { + // Check full_product_names + for (i, fpn) in product_tree.get_full_product_names().iter().enumerate() { + if let Some(helper) = fpn.get_product_identification_helper() { + if let Some(purls) = helper.get_purls() { + check_purls_consistency( + purls, + &format!("/product_tree/full_product_names/{}/product_identification_helper/purls", i) + )?; + } + } + } + + // Check branches recursively + if let Some(branches) = product_tree.get_branches() { + check_branches_recursive(branches, "/product_tree/branches")?; + } + + // Check relationships + for (index, rel) in product_tree.get_relationships().iter().enumerate() { + let fpn = rel.get_full_product_name(); + if let Some(helper) = fpn.get_product_identification_helper() { + if let Some(purls) = helper.get_purls() { + check_purls_consistency( + purls, + &format!("/product_tree/relationships/{}/full_product_name/product_identification_helper/purls", index) + )?; + } + } + } + } + + Ok(()) +} + +// Helper function to check purl consistency in branches recursively +fn check_branches_recursive( + branches: &[impl BranchTrait], + path_base: &str, +) -> Result<(), ValidationError> { + for (index, branch) in branches.iter().enumerate() { + let current_path = format!("{}/{}", path_base, index); + + // Check product in branch if exists + if let Some(product) = branch.get_product() { + if let Some(helper) = product.get_product_identification_helper() { + if let Some(purls) = helper.get_purls() { + check_purls_consistency( + purls, + &format!("{}/product/product_identification_helper/purls", current_path) + )?; + } + } + } + + // Check sub-branches recursively + if let Some(sub_branches) = branch.get_branches() { + check_branches_recursive( + sub_branches, + &format!("{}/branches", current_path) + )?; + } + } + + Ok(()) +} + +fn check_purls_consistency(purls: &[String], json_path: &str) -> Result<(), ValidationError> { + if purls.len() <= 1 { + return Ok(()); + } + + let mut base_parts: Option = None; + + for (i, purl_str) in purls.iter().enumerate() { + // Parse the PURL + let purl = match purl_str.parse::>() { + Ok(p) => p, + Err(_) => { + return Err(ValidationError { + message: format!("Invalid PURL format: {}", purl_str), + instance_path: format!("{}/{}", json_path, i), + }); + } + }; + + // Strip qualifiers + let current_parts = match purl.into_builder().without_qualifiers().build() { + Ok(purl) => purl.to_string(), + Err(_) => { + return Err(ValidationError { + message: format!("Error whilst stripping qualifiers from PURL: {}", purl_str), + instance_path: format!("{}/{}", json_path, i), + }); + }, + }; + + if let Some(ref base) = base_parts { + // Must always match + if current_parts != *base { + return Err(ValidationError { + message: String::from("PURLs within the same product_identification_helper must only differ in qualifiers"), + instance_path: format!("{}/{}", json_path, i), + }); + } + } else { + // First PURL becomes the base for comparison + base_parts = Some(current_parts); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use crate::csaf::test_helper::run_csaf21_tests; + use crate::csaf::validation::ValidationError; + use crate::csaf::validations::test_6_1_42::test_6_1_42_purl_consistency; + + static ERROR_MESSAGE: &str = "PURLs within the same product_identification_helper must only differ in qualifiers"; + + #[test] + fn test_test_6_1_42() { + run_csaf21_tests( + "42", + test_6_1_42_purl_consistency, HashMap::from([ + ("01", &ValidationError { + message: ERROR_MESSAGE.to_string(), + instance_path: "/product_tree/full_product_names/0/product_identification_helper/purls/1".to_string(), + }), + ("02", &ValidationError { + message: ERROR_MESSAGE.to_string(), + instance_path: "/product_tree/branches/0/branches/0/branches/0/product/product_identification_helper/purls/2".to_string(), + }), + ]) + ); + } +} From 227dcb12bc1526d0f726f324a8056b470c20a9c7 Mon Sep 17 00:00:00 2001 From: Michael Lux Date: Thu, 3 Apr 2025 08:53:56 +0200 Subject: [PATCH 2/3] Enhance datetime validation with chrono-based parsing checks Added a chrono-based plausibility check to ensure RFC3339 date-time strings are valid beyond regex matching. This improvement catches invalid cases like out-of-range dates, providing more accurate validation error messages. --- csaf | 2 +- csaf-lib/src/csaf/validations/test_6_1_37.rs | 25 +++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/csaf b/csaf index b4d5053..65f402d 160000 --- a/csaf +++ b/csaf @@ -1 +1 @@ -Subproject commit b4d505309ad069e5677142e2a3f1e17fba28be79 +Subproject commit 65f402d7d5298b1957ce61f1e175c755069214ae diff --git a/csaf-lib/src/csaf/validations/test_6_1_37.rs b/csaf-lib/src/csaf/validations/test_6_1_37.rs index 4ee4db4..840c806 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_37.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_37.rs @@ -95,7 +95,14 @@ pub fn test_6_1_37_date_and_time( fn check_datetime(date_time: &String, instance_path: &str) -> Result<(), ValidationError> { if get_rfc3339_regex().is_match(date_time) { - Ok(()) + // Add chrono-based plausibility check + match chrono::DateTime::parse_from_rfc3339(date_time) { + Ok(_) => Ok(()), // Successfully parsed as a valid RFC3339 datetime + Err(e) => Err(ValidationError { + message: format!("Date-time string {} matched RFC3339 regex but failed chrono parsing: {}", date_time, e), + instance_path: instance_path.to_string(), + }), + } } else { Err(ValidationError { message: format!("Invalid date-time string {}, expected RFC3339-compliant format with non-empty timezone", date_time), @@ -120,6 +127,22 @@ mod tests { message: "Invalid date-time string 2024-01-24 10:00:00.000Z, expected RFC3339-compliant format with non-empty timezone".to_string(), instance_path: "/document/tracking/initial_release_date".to_string(), }), + ("02", &ValidationError { + message: "Invalid date-time string 2024-01-24T10:00:00.000z, expected RFC3339-compliant format with non-empty timezone".to_string(), + instance_path: "/document/tracking/initial_release_date".to_string(), + }), + ("03", &ValidationError { + message: "Date-time string 2014-13-31T00:00:00+01:00 matched RFC3339 regex but failed chrono parsing: input is out of range".to_string(), + instance_path: "/vulnerabilities/0/discovery_date".to_string(), + }), + ("04", &ValidationError { + message: "Date-time string 2023-02-30T00:00:00+01:00 matched RFC3339 regex but failed chrono parsing: input is out of range".to_string(), + instance_path: "/vulnerabilities/0/discovery_date".to_string(), + }), + ("05", &ValidationError { + message: "Date-time string 1900-02-29T00:00:00+01:00 matched RFC3339 regex but failed chrono parsing: input is out of range".to_string(), + instance_path: "/vulnerabilities/0/discovery_date".to_string(), + }), ]) ); } From b8ce344304deff781890100262d07732bec2cf42 Mon Sep 17 00:00:00 2001 From: Michael Lux Date: Mon, 7 Apr 2025 15:59:40 +0200 Subject: [PATCH 3/3] Refactor product tree traversal and validation logic Introduced a unified product tree traversal API via `visit_all_products` and `visit_branches_rec`, simplifying branch recursion and validation. Replaced specific `gather_product_definitions` and custom traversal functions with reusable logic, ensuring consistency and reducing code duplication. Additionally, improved modularity by renaming traits and refining parameterized implementations for better clarity. --- .../csaf/csaf2_0/getter_implementations.rs | 31 ++-- .../csaf/csaf2_1/getter_implementations.rs | 31 ++-- csaf-lib/src/csaf/csaf2_1/validation.rs | 2 +- csaf-lib/src/csaf/getter_traits.rs | 159 ++++++++++++++++-- csaf-lib/src/csaf/helpers.rs | 26 ++- csaf-lib/src/csaf/mod.rs | 4 +- csaf-lib/src/csaf/product_helpers.rs | 54 +----- csaf-lib/src/csaf/validations/test_6_1_01.rs | 13 +- csaf-lib/src/csaf/validations/test_6_1_02.rs | 43 ++--- csaf-lib/src/csaf/validations/test_6_1_34.rs | 39 +---- csaf-lib/src/csaf/validations/test_6_1_42.rs | 153 +++++------------ 11 files changed, 289 insertions(+), 266 deletions(-) diff --git a/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs b/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs index f8f5ba3..4c585c8 100644 --- a/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs +++ b/csaf-lib/src/csaf/csaf2_0/getter_implementations.rs @@ -1,6 +1,6 @@ use crate::csaf::csaf2_0::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, DocumentStatus, Flag, FullProductNameT, HelperToIdentifyTheProduct, Involvement, LabelOfTlp, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, RulesForSharingDocument, Threat, Tracking, TrafficLightProtocolTlp, Vulnerability}; use crate::csaf::csaf2_1::schema::{CategoryOfTheRemediation as Remediation21, DocumentStatus as Status21, LabelOfTlp as Tlp21}; -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; +use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, ProductTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; use std::ops::Deref; use crate::csaf::validation::ValidationError; @@ -315,17 +315,18 @@ impl ProductTreeTrait for ProductTree { fn get_full_product_names(&self) -> &Vec { &self.full_product_names } -} -impl BranchTrait for Branch { - type BranchType = Branch; - type FullProductNameType = FullProductNameT; + fn visit_all_products(&self, callback: &mut impl FnMut(&Self::FullProductNameType, &str) -> Result<(), ValidationError>) -> Result<(), ValidationError> { + self.visit_all_products_generic(callback) + } +} - fn get_branches(&self) -> Option<&Vec> { +impl BranchTrait for Branch { + fn get_branches(&self) -> Option<&Vec> { self.branches.as_ref().map(|branches| branches.deref()) } - fn get_product(&self) -> &Option { + fn get_product(&self) -> &Option { &self.product } } @@ -340,9 +341,7 @@ impl ProductGroupTrait for ProductGroup { } } -impl RelationshipTrait for Relationship { - type FullProductNameType = FullProductNameT; - +impl RelationshipTrait for Relationship { fn get_product_reference(&self) -> &String { self.product_reference.deref() } @@ -351,12 +350,12 @@ impl RelationshipTrait for Relationship { self.relates_to_product_reference.deref() } - fn get_full_product_name(&self) -> &Self::FullProductNameType { + fn get_full_product_name(&self) -> &FullProductNameT { &self.full_product_name } } -impl FullProductNameTrait for FullProductNameT { +impl ProductTrait for FullProductNameT { type ProductIdentificationHelperType = HelperToIdentifyTheProduct; fn get_product_id(&self) -> &String { @@ -372,4 +371,12 @@ impl ProductIdentificationHelperTrait for HelperToIdentifyTheProduct { fn get_purls(&self) -> Option<&[String]> { self.purl.as_ref().map(|purl| std::slice::from_ref(purl)) } + + fn get_model_numbers(&self) -> Option + '_> { + self.model_numbers.as_ref().map(|v| v.iter().map(|x| x.deref())) + } + + fn get_serial_numbers(&self) -> Option + '_> { + self.serial_numbers.as_ref().map(|v| v.iter().map(|x| x.deref())) + } } \ No newline at end of file diff --git a/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs b/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs index 9efdbce..1569bd8 100644 --- a/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs +++ b/csaf-lib/src/csaf/csaf2_1/getter_implementations.rs @@ -1,5 +1,5 @@ use crate::csaf::csaf2_1::schema::{Branch, CategoryOfTheRemediation, CommonSecurityAdvisoryFramework, DocumentGenerator, DocumentLevelMetaData, DocumentStatus, Flag, FullProductNameT, HelperToIdentifyTheProduct, Involvement, LabelOfTlp, Metric, ProductGroup, ProductStatus, ProductTree, Relationship, Remediation, Revision, RulesForSharingDocument, SharingGroup, Threat, Tracking, TrafficLightProtocolTlp, Vulnerability}; -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, FullProductNameTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; +use crate::csaf::getter_traits::{BranchTrait, CsafTrait, DistributionTrait, DocumentTrait, FlagTrait, ProductTrait, GeneratorTrait, InvolvementTrait, MetricTrait, ProductGroupTrait, ProductIdentificationHelperTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, RevisionTrait, SharingGroupTrait, ThreatTrait, TlpTrait, TrackingTrait, VulnerabilityTrait}; use std::ops::Deref; use crate::csaf::validation::ValidationError; @@ -260,17 +260,18 @@ impl ProductTreeTrait for ProductTree { fn get_full_product_names(&self) -> &Vec { &self.full_product_names } -} -impl BranchTrait for Branch { - type BranchType = Branch; - type FullProductNameType = FullProductNameT; + fn visit_all_products(&self, callback: &mut impl FnMut(&Self::FullProductNameType, &str) -> Result<(), ValidationError>) -> Result<(), ValidationError> { + self.visit_all_products_generic(callback) + } +} - fn get_branches(&self) -> Option<&Vec> { +impl BranchTrait for Branch { + fn get_branches(&self) -> Option<&Vec> { self.branches.as_ref().map(|branches| branches.deref()) } - fn get_product(&self) -> &Option { + fn get_product(&self) -> &Option { &self.product } } @@ -285,9 +286,7 @@ impl ProductGroupTrait for ProductGroup { } } -impl RelationshipTrait for Relationship { - type FullProductNameType = FullProductNameT; - +impl RelationshipTrait for Relationship { fn get_product_reference(&self) -> &String { self.product_reference.deref() } @@ -296,12 +295,12 @@ impl RelationshipTrait for Relationship { self.relates_to_product_reference.deref() } - fn get_full_product_name(&self) -> &Self::FullProductNameType { + fn get_full_product_name(&self) -> &FullProductNameT { &self.full_product_name } } -impl FullProductNameTrait for FullProductNameT { +impl ProductTrait for FullProductNameT { type ProductIdentificationHelperType = HelperToIdentifyTheProduct; fn get_product_id(&self) -> &String { @@ -317,4 +316,12 @@ impl ProductIdentificationHelperTrait for HelperToIdentifyTheProduct { fn get_purls(&self) -> Option<&[String]> { self.purls.as_ref().map(|v| v.as_slice()) } + + fn get_model_numbers(&self) -> Option + '_> { + self.model_numbers.as_ref().map(|v| v.iter().map(|x| x.deref())) + } + + fn get_serial_numbers(&self) -> Option + '_> { + self.serial_numbers.as_ref().map(|v| v.iter().map(|x| x.deref())) + } } diff --git a/csaf-lib/src/csaf/csaf2_1/validation.rs b/csaf-lib/src/csaf/csaf2_1/validation.rs index f91999e..2a6c8ea 100644 --- a/csaf-lib/src/csaf/csaf2_1/validation.rs +++ b/csaf-lib/src/csaf/csaf2_1/validation.rs @@ -1,4 +1,4 @@ -use super::schema::CommonSecurityAdvisoryFramework; +use super::schema::{CommonSecurityAdvisoryFramework}; use crate::csaf::validation::{Test, Validatable, ValidationPreset}; use crate::csaf::validations::test_6_1_01::test_6_1_01_missing_definition_of_product_id; use crate::csaf::validations::test_6_1_02::test_6_1_02_multiple_definition_of_product_id; diff --git a/csaf-lib/src/csaf/getter_traits.rs b/csaf-lib/src/csaf/getter_traits.rs index 7671f15..368fab7 100644 --- a/csaf-lib/src/csaf/getter_traits.rs +++ b/csaf-lib/src/csaf/getter_traits.rs @@ -329,16 +329,16 @@ pub trait ThreatTrait { /// access to its product groups. pub trait ProductTreeTrait { /// The associated type representing the type of branch in the product tree. - type BranchType: BranchTrait; + type BranchType: BranchTrait; /// The associated type representing the type of product groups in the product tree. type ProductGroupType: ProductGroupTrait; /// The associated type representing the type of relationships in the product tree. - type RelationshipType: RelationshipTrait; + type RelationshipType: RelationshipTrait; /// The associated type representing the type of full product name. - type FullProductNameType: FullProductNameTrait; + type FullProductNameType: ProductTrait; /// Returns an optional reference to the list of branches in the product tree. fn get_branches(&self) -> Option<&Vec>; @@ -351,21 +351,143 @@ pub trait ProductTreeTrait { /// Retrieves a reference to the list of full product names in the product tree. fn get_full_product_names(&self) -> &Vec; -} -/// Trait representing an abstract branch in a product tree. -pub trait BranchTrait { - /// The associated type representing child branches. - type BranchType: BranchTrait; + /// Visits all product references in the product tree by invoking the provided callback for each + /// product. Returns immediately with the error Result provided by `callback`, if occurring. + /// + /// This method traverses all locations in the product tree where products can be referenced: + /// - Products within branches (recursively) + /// - Full product names at the top level + /// - Full product names within relationships + /// + /// # Parameters + /// * `callback` - A mutable function that takes a reference to a product and its path string, + /// and returns a `Result<(), ValidationError>`. The path string represents the JSON pointer + /// to the product's location in the document. + /// + /// # Returns + /// * `Ok(())` if all products were visited successfully + /// * `Err(ValidationError)` if the callback returned an error for any product + fn visit_all_products_generic( + &self, + callback: &mut impl FnMut(&Self::FullProductNameType, &str) -> Result<(), ValidationError> + ) -> Result<(), ValidationError> { + // Visit products in branches + if let Some(branches) = self.get_branches().as_ref() { + for (i, branch) in branches.iter().enumerate() { + branch.visit_branches_rec(&format!("/product_tree/branches/{}", i), &mut |branch: &Self::BranchType, path| { + if let Some(product_ref) = branch.get_product() { + callback(product_ref, &format!("{}/product", path))?; + } + Ok(()) + })?; + } + } + + // Visit full_product_names + for (i, fpn) in self.get_full_product_names().iter().enumerate() { + callback( + fpn, + &format!("/product_tree/full_product_names/{}", i), + )?; + } + + // Visit relationships + for (i, rel) in self.get_relationships().iter().enumerate() { + callback( + rel.get_full_product_name(), + &format!("/product_tree/relationships/{}/full_product_name", i), + )?; + } - /// The associated type representing a full product name. - type FullProductNameType: FullProductNameTrait; + Ok(()) + } + /// A trait wrapper for `visit_all_products_generic()` that allows implementations to provide + /// type-specific callbacks for product traversal. + /// + /// This method is intended to be implemented by trait objects to handle their specific + /// product name types while reusing the generic traversal logic defined in + /// `visit_all_products_generic()`. + /// + /// # Parameters + /// * `callback` - A mutable function that takes a reference to a product and its path string, + /// returning a `Result<(), ValidationError>`. The callback will be invoked with the concrete + /// type specified by the implementing trait. + /// + /// # Returns + /// * `Ok(())` if all products were visited successfully + /// * `Err(ValidationError)` if the callback returned an error for any product + /// + /// # Implementation Note + /// Trait implementers should typically implement this by delegating to + /// `visit_all_products_generic()` with the same callback. + fn visit_all_products( + &self, + callback: &mut impl FnMut(&Self::FullProductNameType, &str) -> Result<(), ValidationError> + ) -> Result<(), ValidationError>; +} + +/// Trait representing an abstract branch in a product tree. +pub trait BranchTrait : Sized { /// Returns an optional reference to the child branches of this branch. - fn get_branches(&self) -> Option<&Vec>; + fn get_branches(&self) -> Option<&Vec>; /// Retrieves the full product name associated with this branch, if available. - fn get_product(&self) -> &Option; + fn get_product(&self) -> &Option; + + /// Recursively visits all branches in the tree structure, + /// applying the provided callback function to each branch. + /// + /// This method traverses the entire branch hierarchy, starting from the current branch and + /// proceeding depth-first through all child branches. For each branch, it calls the + /// provided callback function with the branch object and its path representation. + /// + /// # Parameters + /// * `path` - A string representing the current path in the branch hierarchy + /// * `callback` - A mutable function that takes a reference to Self and the + /// current path string, and returns a Result + /// + /// # Returns + /// * `Ok(())` if the traversal completes successfully + /// * `Err(ValidationError)` if the callback returns an error for any branch + fn visit_branches_rec(&self, path: &str, callback: &mut impl FnMut(&Self, &str) -> Result<(), ValidationError>) -> Result<(), ValidationError> { + callback(self, &path)?; + if let Some(branches) = self.get_branches().as_ref() { + for (i, branch) in branches.iter().enumerate() { + branch.visit_branches_rec(&format!("{}/branches/{}", path, i), callback)?; + } + } + Ok(()) + } + + /// Searches for branches that exceed the maximum allowed depth in the branch hierarchy. + /// + /// This method recursively checks if the branch structure exceeds the specified depth limit. + /// It traverses the branch hierarchy depth-first, decrementing the remaining depth parameter + /// at each level. If branches are found beyond the allowed depth, it returns the path to the + /// first excessive branch. + /// + /// # Parameters + /// * `remaining_depth` - The maximum number of branch levels still allowed + /// + /// # Returns + /// * `Some(String)` containing the path to the first branch that exceeds the allowed depth + /// * `None` if no branches exceed the allowed depth + fn find_excessive_branch_depth(&self, remaining_depth: u32) -> Option { + if let Some(branches) = self.get_branches() { + // If we've reached depth limit and there are branches, we've found a violation + if remaining_depth == 1 { + return Some("/branches/0".to_string()); + } + for (i, branch) in branches.iter().enumerate() { + if let Some(sub_path) = branch.find_excessive_branch_depth(remaining_depth - 1) { + return Some(format!("/branches/{}{}", i, sub_path)); + } + } + } + None + } } /// Trait representing an abstract product group in a CSAF document. @@ -381,10 +503,7 @@ pub trait ProductGroupTrait { } /// Trait representing an abstract relationship in a product tree. -pub trait RelationshipTrait { - /// The associated type representing a full product name. - type FullProductNameType: FullProductNameTrait; - +pub trait RelationshipTrait { /// Retrieves the product reference identifier. fn get_product_reference(&self) -> &String; @@ -392,11 +511,11 @@ pub trait RelationshipTrait { fn get_relates_to_product_reference(&self) -> &String; /// Retrieves the full product name associated with the relationship. - fn get_full_product_name(&self) -> &Self::FullProductNameType; + fn get_full_product_name(&self) -> &FPN; } /// Trait representing an abstract full product name in a CSAF document. -pub trait FullProductNameTrait { +pub trait ProductTrait { /// The associated type representing a product identification helper. type ProductIdentificationHelperType: ProductIdentificationHelperTrait; @@ -411,4 +530,8 @@ pub trait FullProductNameTrait { pub trait ProductIdentificationHelperTrait { /// Returns the PURLs identifying the associated product. fn get_purls(&self) -> Option<&[String]>; + + fn get_model_numbers(&self) -> Option + '_>; + + fn get_serial_numbers(&self) -> Option + '_>; } \ No newline at end of file diff --git a/csaf-lib/src/csaf/helpers.rs b/csaf-lib/src/csaf/helpers.rs index 78c7bd5..cf0ec7f 100644 --- a/csaf-lib/src/csaf/helpers.rs +++ b/csaf-lib/src/csaf/helpers.rs @@ -16,4 +16,28 @@ where .flatten() .collect() }) -} \ No newline at end of file +} + +/// Counts the number of unescaped '*' characters in a given string. +/// An asterisk is considered "unescaped" if it is not preceded by a backslash ('\\'). +/// Consecutive backslashes alternate between escaping or not escaping characters. +/// +/// # Arguments +/// +/// * `s` - A string slice to be analyzed. +/// +/// # Returns +/// +/// Returns the number of unescaped '*' characters found in the string. +pub fn count_unescaped_stars(s: &str) -> u32 { + let mut escaped = false; + let mut count = 0u32; + for c in s.chars() { + match c { + '\\' => escaped = !escaped, + '*' if !escaped => count += 1, + _ => escaped = false, + } + } + count +} diff --git a/csaf-lib/src/csaf/mod.rs b/csaf-lib/src/csaf/mod.rs index f284633..bb22916 100644 --- a/csaf-lib/src/csaf/mod.rs +++ b/csaf-lib/src/csaf/mod.rs @@ -1,8 +1,8 @@ pub mod csaf2_0; pub mod csaf2_1; -mod helpers; +pub mod helpers; pub mod product_helpers; pub mod validation; pub mod getter_traits; pub mod validations; -pub mod test_helper; +pub mod test_helper; \ No newline at end of file diff --git a/csaf-lib/src/csaf/product_helpers.rs b/csaf-lib/src/csaf/product_helpers.rs index eed9401..5e598f8 100644 --- a/csaf-lib/src/csaf/product_helpers.rs +++ b/csaf-lib/src/csaf/product_helpers.rs @@ -1,4 +1,4 @@ -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, FullProductNameTrait, MetricTrait, ProductGroupTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, ThreatTrait, VulnerabilityTrait}; +use crate::csaf::getter_traits::{CsafTrait, MetricTrait, ProductGroupTrait, ProductStatusTrait, ProductTreeTrait, RelationshipTrait, RemediationTrait, ThreatTrait, VulnerabilityTrait}; pub fn gather_product_references(doc: &impl CsafTrait) -> Vec<(String, String)> { let mut ids = Vec::<(String, String)>::new(); @@ -100,55 +100,3 @@ pub fn gather_product_references(doc: &impl CsafTrait) -> Vec<(String, String)> ids } - -fn gather_product_definitions_from_branch( - branch: &impl BranchTrait, - ids: &mut Vec<(String, String)>, - path: &str -) { - // Gather from /product/product_id - if let Some(product) = branch.get_product() { - ids.push(( - product.get_product_id().to_owned(), - format!("{}/product/product_id", path) - )); - } - - // Go into the sub-branches - if let Some(branches) = branch.get_branches().as_ref() { - for (i, b) in branches.iter().enumerate() { - gather_product_definitions_from_branch(b, ids, &format!("{}/branches/{}", path, i)); - } - } -} - -pub fn gather_product_definitions(doc: &impl CsafTrait) -> Vec<(String, String)> { - let mut ids = Vec::<(String, String)>::new(); - - if let Some(tree) = doc.get_product_tree().as_ref() { - // /product_tree/branches[](/branches[])*/product/product_id - if let Some(branches) = tree.get_branches().as_ref() { - for (i, branch) in branches.iter().enumerate() { - gather_product_definitions_from_branch(branch, &mut ids, &format!("/product_tree/branches/{}", i)); - } - } - - // /product_tree/full_product_names[]/product_id - for (i, fpn) in tree.get_full_product_names().iter().enumerate() { - ids.push(( - fpn.get_product_id().to_owned(), - format!("/product_tree/full_product_names/{}/product_id", i) - )); - } - - // /product_tree/relationships[]/full_product_name/product_id - for (i, rel) in tree.get_relationships().iter().enumerate() { - ids.push(( - rel.get_full_product_name().get_product_id().to_owned(), - format!("/product_tree/relationships/{}/full_product_name/product_id", i) - )); - } - } - - ids -} diff --git a/csaf-lib/src/csaf/validations/test_6_1_01.rs b/csaf-lib/src/csaf/validations/test_6_1_01.rs index c5c27eb..3541fed 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_01.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_01.rs @@ -1,13 +1,18 @@ -use crate::csaf::getter_traits::CsafTrait; -use crate::csaf::product_helpers::{gather_product_definitions, gather_product_references}; +use crate::csaf::getter_traits::{CsafTrait, ProductTrait, ProductTreeTrait}; +use crate::csaf::product_helpers::gather_product_references; use std::collections::HashSet; use crate::csaf::validation::ValidationError; pub fn test_6_1_01_missing_definition_of_product_id( doc: &impl CsafTrait, ) -> Result<(), ValidationError> { - let definitions = gather_product_definitions(doc); - let definitions_set = HashSet::::from_iter(definitions.iter().map(|x| x.1.to_owned())); + let mut definitions_set = HashSet::::new(); + if let Some(tree) = doc.get_product_tree().as_ref() { + _ = tree.visit_all_products(&mut |fpn, _path| { + definitions_set.insert(fpn.get_product_id().to_owned()); + Ok(()) + }); + } let references = gather_product_references(doc); for (ref_id, ref_path) in references.iter() { diff --git a/csaf-lib/src/csaf/validations/test_6_1_02.rs b/csaf-lib/src/csaf/validations/test_6_1_02.rs index 2ae1f55..4f1b648 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_02.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_02.rs @@ -1,37 +1,28 @@ -use crate::csaf::getter_traits::CsafTrait; -use crate::csaf::product_helpers::gather_product_definitions; +use crate::csaf::getter_traits::{CsafTrait, ProductTrait, ProductTreeTrait}; use crate::csaf::validation::ValidationError; -use std::collections::HashMap; +use std::collections::HashSet; pub fn test_6_1_02_multiple_definition_of_product_id( doc: &impl CsafTrait, ) -> Result<(), ValidationError> { - let definitions: Vec<_> = gather_product_definitions(doc); - let duplicates = find_duplicates(definitions); - - if let Some(duplicate) = duplicates.first() { - Err(ValidationError { - message: format!("Duplicate definition for product ID {}", duplicate.0), - instance_path: duplicate.1[1].to_owned(), - }) - } else { - Ok(()) - } -} - -fn find_duplicates(vec: Vec<(String, String)>) -> Vec<(String, Vec)> { // Map to store each key with all of its paths - let mut conflicts = HashMap::new(); - - for (key, path) in vec { - // Add this path to the list for this key - conflicts.entry(key).or_insert_with(Vec::new).push(path); + let mut conflicts = HashSet::::new(); + + if let Some(tree) = doc.get_product_tree().as_ref() { + tree.visit_all_products(&mut |product, path| { + if conflicts.contains(product.get_product_id()) { + Err(ValidationError { + message: format!("Duplicate definition for product ID {}", product.get_product_id()), + instance_path: format!("{}/product_id", path), + }) + } else { + conflicts.insert(product.get_product_id().to_owned()); + Ok(()) + } + })?; } - // Filter to keep only entries with multiple paths (actual duplicates) - conflicts.into_iter() - .filter(|(_, paths)| paths.len() > 1) - .collect() + Ok(()) } #[cfg(test)] diff --git a/csaf-lib/src/csaf/validations/test_6_1_34.rs b/csaf-lib/src/csaf/validations/test_6_1_34.rs index a99f362..512b288 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_34.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_34.rs @@ -7,39 +7,18 @@ pub fn test_6_1_34_branches_recursion_depth( doc: &impl CsafTrait, ) -> Result<(), ValidationError> { if let Some(tree) = doc.get_product_tree().as_ref() { - if let Some(path) = find_excessive_branch_depth(tree.get_branches(), MAX_DEPTH) { - return Err(ValidationError { - message: format!("Branches recursion depth too big (> {})", MAX_DEPTH), - instance_path: format!("/product_tree{}", path) - }); - } - } - Ok(()) -} - -fn find_excessive_branch_depth(branches: Option<&Vec>, remaining_depth: u32) -> Option { - if let Some(branches) = branches { - for (i, branch) in branches.iter().enumerate() { - if let Some(subpath) = find_excessive_branch_depth_rec(branch, remaining_depth) { - return Some(format!("/branches/{}{}", i, subpath)); + if let Some(branches) = tree.get_branches() { + for (i, branch) in branches.iter().enumerate() { + if let Some(path) = branch.find_excessive_branch_depth(MAX_DEPTH) { + return Err(ValidationError { + message: format!("Branches recursion depth too big (> {})", MAX_DEPTH), + instance_path: format!("/product_tree/branches/{}{}", i, path) + }); + } } } } - None -} - -fn find_excessive_branch_depth_rec(branch: &impl BranchTrait, remaining_depth: u32) -> Option { - if let Some(branches) = branch.get_branches() { - // If we've reached depth limit and there are branches, we've found a violation - if remaining_depth == 1 { - return Some("/branches/0".to_string()); - } - - // Otherwise, check the branches with one less remaining depth - return find_excessive_branch_depth(Some(branches), remaining_depth - 1); - } - - None + Ok(()) } #[cfg(test)] diff --git a/csaf-lib/src/csaf/validations/test_6_1_42.rs b/csaf-lib/src/csaf/validations/test_6_1_42.rs index cb524d9..c931467 100644 --- a/csaf-lib/src/csaf/validations/test_6_1_42.rs +++ b/csaf-lib/src/csaf/validations/test_6_1_42.rs @@ -1,122 +1,61 @@ -use crate::csaf::getter_traits::{BranchTrait, CsafTrait, FullProductNameTrait, ProductIdentificationHelperTrait, ProductTreeTrait, RelationshipTrait}; +use crate::csaf::getter_traits::{CsafTrait, ProductIdentificationHelperTrait, ProductTrait, ProductTreeTrait}; use crate::csaf::validation::ValidationError; use purl::GenericPurl; pub fn test_6_1_42_purl_consistency( doc: &impl CsafTrait, ) -> Result<(), ValidationError> { - // Skip if product_tree is None if let Some(product_tree) = doc.get_product_tree() { - // Check full_product_names - for (i, fpn) in product_tree.get_full_product_names().iter().enumerate() { - if let Some(helper) = fpn.get_product_identification_helper() { - if let Some(purls) = helper.get_purls() { - check_purls_consistency( - purls, - &format!("/product_tree/full_product_names/{}/product_identification_helper/purls", i) - )?; - } - } - } - - // Check branches recursively - if let Some(branches) = product_tree.get_branches() { - check_branches_recursive(branches, "/product_tree/branches")?; - } - - // Check relationships - for (index, rel) in product_tree.get_relationships().iter().enumerate() { - let fpn = rel.get_full_product_name(); - if let Some(helper) = fpn.get_product_identification_helper() { - if let Some(purls) = helper.get_purls() { - check_purls_consistency( - purls, - &format!("/product_tree/relationships/{}/full_product_name/product_identification_helper/purls", index) - )?; - } - } - } - } - - Ok(()) -} - -// Helper function to check purl consistency in branches recursively -fn check_branches_recursive( - branches: &[impl BranchTrait], - path_base: &str, -) -> Result<(), ValidationError> { - for (index, branch) in branches.iter().enumerate() { - let current_path = format!("{}/{}", path_base, index); - - // Check product in branch if exists - if let Some(product) = branch.get_product() { + product_tree.visit_all_products(&mut |product, path| { if let Some(helper) = product.get_product_identification_helper() { if let Some(purls) = helper.get_purls() { - check_purls_consistency( - purls, - &format!("{}/product/product_identification_helper/purls", current_path) - )?; + if purls.len() <= 1 { + return Ok(()); + } + + let mut base_parts: Option = None; + + for (i, purl_str) in purls.iter().enumerate() { + // Parse the PURL + let purl = match purl_str.parse::>() { + Ok(p) => p, + Err(_) => { + return Err(ValidationError { + message: format!("Invalid PURL format: {}", purl_str), + instance_path: format!("{}/product_identification_helper/purls/{}", path, i), + }); + } + }; + + // Strip qualifiers + let current_parts = match purl.into_builder().without_qualifiers().build() { + Ok(purl) => purl.to_string(), + Err(_) => { + return Err(ValidationError { + message: format!("Error whilst stripping qualifiers from PURL: {}", purl_str), + instance_path: format!("{}/product_identification_helper/purls/{}", path, i), + }); + }, + }; + + if let Some(ref base) = base_parts { + // Must always match + if current_parts != *base { + return Err(ValidationError { + message: String::from("PURLs within the same product_identification_helper must only differ in qualifiers"), + instance_path: format!("{}/product_identification_helper/purls/{}", path, i), + }); + } + } else { + // First PURL becomes the base for comparison + base_parts = Some(current_parts); + } + } } } - } - - // Check sub-branches recursively - if let Some(sub_branches) = branch.get_branches() { - check_branches_recursive( - sub_branches, - &format!("{}/branches", current_path) - )?; - } - } - - Ok(()) -} - -fn check_purls_consistency(purls: &[String], json_path: &str) -> Result<(), ValidationError> { - if purls.len() <= 1 { - return Ok(()); - } - - let mut base_parts: Option = None; - - for (i, purl_str) in purls.iter().enumerate() { - // Parse the PURL - let purl = match purl_str.parse::>() { - Ok(p) => p, - Err(_) => { - return Err(ValidationError { - message: format!("Invalid PURL format: {}", purl_str), - instance_path: format!("{}/{}", json_path, i), - }); - } - }; - - // Strip qualifiers - let current_parts = match purl.into_builder().without_qualifiers().build() { - Ok(purl) => purl.to_string(), - Err(_) => { - return Err(ValidationError { - message: format!("Error whilst stripping qualifiers from PURL: {}", purl_str), - instance_path: format!("{}/{}", json_path, i), - }); - }, - }; - - if let Some(ref base) = base_parts { - // Must always match - if current_parts != *base { - return Err(ValidationError { - message: String::from("PURLs within the same product_identification_helper must only differ in qualifiers"), - instance_path: format!("{}/{}", json_path, i), - }); - } - } else { - // First PURL becomes the base for comparison - base_parts = Some(current_parts); - } + Ok(()) + })?; } - Ok(()) }