Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 149 additions & 5 deletions csaf-rs/src/csaf_traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,46 +107,190 @@ pub trait DocumentReferenceTrait {
fn get_url(&self) -> &String;
}

/// Shared Enum representing document categories
/// Contains well-known categories of CSAF version 2.0 and 2.1 as enum variants
/// All other category strings (which are by definition csaf_base)
/// are represented as DocumentCategory::CsafBaseOther(String)
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DocumentCategory {
CsafBase,
CsafInformationalAdvisory,
CsafSecurityIncidentResponse,
CsafSecurityAdvisory,
CsafVex,
Other(String),
// These categories are only mentioned in CSAF 2.1, but as this is just a string wrapper used
// for syntactic sugar, we don't need to make this distinction here
CsafWithdrawn,
CsafSuperseded,
CsafDeprecatedSecurityAdvisory,
CsafBaseOther(String),
}

impl DocumentCategory {
const CSAF_20_KNOWN_PROFILES: [DocumentCategory; 5] = [
DocumentCategory::CsafBase,
DocumentCategory::CsafSecurityIncidentResponse,
DocumentCategory::CsafInformationalAdvisory,
DocumentCategory::CsafSecurityAdvisory,
DocumentCategory::CsafVex,
];

const CSAF_21_KNOWN_PROFILES: [DocumentCategory; 8] = [
DocumentCategory::CsafBase,
DocumentCategory::CsafSecurityIncidentResponse,
DocumentCategory::CsafInformationalAdvisory,
DocumentCategory::CsafSecurityAdvisory,
DocumentCategory::CsafVex,
DocumentCategory::CsafDeprecatedSecurityAdvisory,
DocumentCategory::CsafWithdrawn,
DocumentCategory::CsafSuperseded,
];

/// Helper function to remove whitespace, underscores and hyphens from a string
fn remove_whitespace_underscore_hyphen(s: String) -> String {
s.chars()
.filter(|c| !c.is_whitespace() && *c != '_' && *c != '-')
.collect()
}

/// Checks if the category string starts with "csaf_"
/// Removes whitespace, underscores and hyphens before checking
///
/// Examples:
/// `csaf_base´ -> true
/// `csaf_basE` -> true
/// ` csaf_base` -> true
/// `_csaf_base` -> true
/// `saf_base` -> false
/// `_saf_base` -> false
fn string_starts_with_csaf_underscore(s: String) -> bool {
let prefix_before_csaf_underscore = s.split("csaf_").next();
if let Some(prefix) = prefix_before_csaf_underscore {
// the category contains "csaf_"
let cleaned_prefix = Self::remove_whitespace_underscore_hyphen(prefix.to_string());
// return true if everything before "csaf_" is whitespace, underscore or hyphen
cleaned_prefix.is_empty()
} else {
// the category does not contain "csaf_"
false
}
}

/// Checks if the category is DocumentCategory::CsafBaseOther
pub fn is_base_other(&self) -> bool {
matches!(self, DocumentCategory::CsafBaseOther(_))
}

/// Checks if the category is DocumentCategory::CsafBase or DocumentCategory::CsafBaseOther
pub fn is_base(&self) -> bool {
matches!(self, DocumentCategory::CsafBase | DocumentCategory::CsafBaseOther(_))
}

/// Checks if the category string starts with "csaf_"
/// Removes whitespace, underscores and hyphens before checking
///
/// Examples:
/// `csaf_base´ -> true
/// `csaf_basE` -> true
/// ` csaf_base` -> true
/// `_csaf_base` -> true
/// `-csaf_base` -> true
/// `saf_base` -> false
/// `_saf_base` -> false
/// `Csaf_base` -> false
pub fn starts_with_csaf_underscore(&self) -> bool {
// check if this is DocumentCategory::Other
// if it is not, the string does start with "csaf_" by convention
if !self.is_base_other() {
return true;
}

Self::string_starts_with_csaf_underscore(self.to_string())
}

/// Checks if the document category is a known profile for the given CSAF version
pub fn is_known_profile(&self, version: &CsafVersion) -> bool {
match version {
CsafVersion::X20 => Self::CSAF_20_KNOWN_PROFILES.contains(self),
CsafVersion::X21 => Self::CSAF_21_KNOWN_PROFILES.contains(self),
}
}

/// Returns a concatenated string of known profiles for the given CSAF version
pub fn known_profile_concat(version: &CsafVersion) -> String {
let profiles: &[DocumentCategory] = match version {
CsafVersion::X20 => &Self::CSAF_20_KNOWN_PROFILES,
CsafVersion::X21 => &Self::CSAF_21_KNOWN_PROFILES,
};
profiles
.iter()
.map(|profile| profile.to_string())
.collect::<Vec<String>>()
.join(", ")
}

/// Returns a vector of tuples containing normalized known profile strings and their original enum values
pub fn known_profiles_normalized(version: &CsafVersion) -> Vec<(String, DocumentCategory)> {
let profiles: &[DocumentCategory] = match version {
CsafVersion::X20 => &Self::CSAF_20_KNOWN_PROFILES,
CsafVersion::X21 => &Self::CSAF_21_KNOWN_PROFILES,
};
profiles
.iter()
.map(|profile| (profile.normalize(), profile.clone()))
.collect()
}

/// Normalizes the document category string by removing leading "csaf" and any whitespace, hyphen or underscore
///
/// Examples:
/// `csaf_base´ -> `base`
/// `csaf-basE` -> `base`
/// ` csaf_base` -> `base`
/// `_csaf_base` -> `base`
/// `-csaf_base` -> `base`
/// `saf_base` -> `safbase`
/// `_saf_base` -> `safbase`
/// `Csaf_base` -> `csafbase`
/// `Some_Other-Category` -> `someothercategory`
pub fn normalize(&self) -> String {
// lowercase
let mut category_lowercase = self.to_string().to_lowercase();
// check if it starts with "csaf_" and prefix before is whitespace, underscore or hyphen
if Self::string_starts_with_csaf_underscore(category_lowercase.to_owned()) {
// remove leading prefix and "csaf_"
// we can safely unwrap here, as we already checked that it contains "csaf_"
category_lowercase = category_lowercase.split_once("csaf_").unwrap().1.to_string();
}
// remove whitespace, underscores and hyphens in the rest of the string
Self::remove_whitespace_underscore_hyphen(category_lowercase.to_string())
}

pub fn from_string(category: &str) -> Self {
match category {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do a to_lower here? I would expect it to match if the casing is different.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that that is explicitly not the case. Test 6.1.26 and the profile tests (6.1.27.x) that make heavy use of this mention an explicit list of document category strings to be checked against. Example from 6.1.26 from CSAF 2.0:

For CSAF 2.0, the test must be skipped for the following values in /document/category:

  csaf_base
  csaf_security_incident_response
  csaf_informational_advisory
  csaf_security_advisory
  csaf_vex

In the context of 6.1.26, csaf_vEx should hit the "leading csaf_ substring without being known document category" error and Csaf_vex and Csaf_Vex should hit the "document category too similar to 'csaf_vex'" error.

Copy link
Contributor Author

@peinjoh peinjoh Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tschmidtb51 will discussing this with @tziemek, we came up on two cases that we are unsure how to handle:

First, if the category string is csafvex, there isn't a reserved prefix csaf_, so we would lowercase it for the case-insensitive comparison, remove all whitespace, hyphen and underscore, of which there are none, so the value for the comparison is csafvex.

This would be compared to csaf_vex, which when applied the same steps, gives the value for the comparison vex.

The comparison would be csafvex vs vex, which would not match, and so csafvex would be a valid value.

We could circumvent this by removing all remove all whitespace, hyphen and underscores first, then checking for the csaf (without the appended _) prefix, then lowercasing.

But for this, we would need to look a csaf prefix instead of a csaf_ prefix.

Second, we are unsure if how casing should be handled when the case-insensitive characters are all correct, i.e. Csaf_vex, Csaf_Vex, csaf_Vex. In chapter 4.1, the following is given:

The value of /document/category SHALL NOT be equal to any value that is intended to only be used by another profile nor to the (case insensitive) name of any other profile from the standard.

Does the "(case insensitive)" apply to the "any value that is intended to only be used by another profile" here too?

"csaf_base" => DocumentCategory::CsafBase,
"csaf_informational_advisory" => DocumentCategory::CsafInformationalAdvisory,
"csaf_security_incident_response" => DocumentCategory::CsafSecurityIncidentResponse,
"csaf_security_advisory" => DocumentCategory::CsafSecurityAdvisory,
"csaf_vex" => DocumentCategory::CsafVex,
"csaf_deprecated_security_advisory" => DocumentCategory::CsafDeprecatedSecurityAdvisory,
"csaf_withdrawn" => DocumentCategory::CsafWithdrawn,
"csaf_superseded" => DocumentCategory::CsafSuperseded,
_ => DocumentCategory::Other("_".to_string()),
default => DocumentCategory::CsafBaseOther(default.to_string()),
}
}
}

impl Display for DocumentCategory {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
DocumentCategory::CsafBase => write!(f, "csaf_base"),
DocumentCategory::CsafInformationalAdvisory => write!(f, "csaf_informational_advisory"),
DocumentCategory::CsafSecurityIncidentResponse => write!(f, "csaf_security_incident_response"),
DocumentCategory::CsafSecurityAdvisory => write!(f, "csaf_security_advisory"),
DocumentCategory::CsafVex => write!(f, "csaf_vex"),
DocumentCategory::CsafDeprecatedSecurityAdvisory => write!(f, "csaf_deprecated_security_advisory"),
DocumentCategory::CsafWithdrawn => write!(f, "csaf_withdrawn"),
DocumentCategory::CsafSuperseded => write!(f, "csaf_superseded"),
DocumentCategory::Other(other) => write!(f, "{}", other),
DocumentCategory::CsafBaseOther(other) => write!(f, "{}", other),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion csaf-rs/src/validations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub mod test_6_1_22;
pub mod test_6_1_23;
pub mod test_6_1_24;
pub mod test_6_1_25;
// pub mod test_6_1_26;
pub mod test_6_1_26;
pub mod test_6_1_27_1;
pub mod test_6_1_27_2;
pub mod test_6_1_27_3;
Expand Down
107 changes: 107 additions & 0 deletions csaf-rs/src/validations/test_6_1_26.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::csaf_traits::{CsafTrait, CsafVersion, DocumentCategory, DocumentTrait};
use crate::validation::ValidationError;

/// 6.1.26 Prohibited Document Category Name
pub fn test_6_1_26_prohibited_document_category(doc: &impl CsafTrait) -> Result<(), Vec<ValidationError>> {
let version = doc.get_document().get_csaf_version();
let doc_category = doc.get_document().get_category();

// skip test for known profiles and categories
if doc_category.is_known_profile(version) {
return Ok(());
}

// throw error, as only known profiles are allowed to start with "csaf_"
if doc_category.starts_with_csaf_underscore() {
return Err(vec![test_6_1_27_6_err_generator_starts_with_csaf(
&doc_category,
version,
)]);
}

// throw error if document category is too similar to known categories
// this is done by comparing normalized versions of the categories
for normalized_known_category in DocumentCategory::known_profiles_normalized(version) {
if doc_category.normalize() == normalized_known_category.0 {
return Err(vec![test_6_1_27_6_err_generator_too_similar(
&doc_category,
&normalized_known_category.1,
)]);
}
}

Ok(())
}

fn test_6_1_27_6_err_generator_starts_with_csaf(
doc_category: &DocumentCategory,
version: &CsafVersion,
) -> ValidationError {
ValidationError {
message: format!(
"Document category '{}' is prohibited. Only the following values are allowed to starting with 'csaf_' are allowed: {}",
doc_category,
DocumentCategory::known_profile_concat(version)
),
instance_path: "/document/category".to_string(),
}
}

fn test_6_1_27_6_err_generator_too_similar(
doc_category: &DocumentCategory,
known_category: &DocumentCategory,
) -> ValidationError {
ValidationError {
message: format!(
"Document category '{}' is prohibited. It is too similar to the known category: {}",
doc_category, known_category
),
instance_path: "/document/category".to_string(),
}
}

#[cfg(test)]
mod tests {
use crate::csaf_traits::DocumentCategory;
use crate::test_helper::{run_csaf20_tests, run_csaf21_tests};
use crate::validations::test_6_1_26::{
test_6_1_26_prohibited_document_category, test_6_1_27_6_err_generator_too_similar,
};
use std::collections::HashMap;

#[test]
fn test_test_6_1_26() {
let errors = HashMap::from([
(
"01",
vec![test_6_1_27_6_err_generator_too_similar(
&DocumentCategory::from_string("Security_Incident_Response"),
&DocumentCategory::CsafSecurityIncidentResponse,
)],
),
(
"02",
vec![test_6_1_27_6_err_generator_too_similar(
&DocumentCategory::from_string("Deprecated Security Advisory"),
&DocumentCategory::CsafDeprecatedSecurityAdvisory,
)],
),
(
"03",
vec![test_6_1_27_6_err_generator_too_similar(
&DocumentCategory::from_string("withdrawn"),
&DocumentCategory::CsafWithdrawn,
)],
),
(
"04",
vec![test_6_1_27_6_err_generator_too_similar(
&DocumentCategory::from_string("superseded"),
&DocumentCategory::CsafSuperseded,
)],
),
]);
run_csaf20_tests("26", test_6_1_26_prohibited_document_category, errors.clone());
run_csaf21_tests("26", test_6_1_26_prohibited_document_category, errors);
}
}