const RESERVED_REGISTERED: &[&str] = &["example", "invalid"];
const RESERVED_UNREGISTERED: &[&str] = &["x_example", "x_invalid"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedNamespace {
pub base: BaseNamespace,
pub extensions: Vec<Extension>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseNamespace {
Registered { name: String, fragment: Option<String> },
Unregistered { reverse_domain: String, fragment: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Extension {
Language(String),
Domain { reverse_domain: String, fragment: Option<String> },
Translation { reverse_domain: String, fragment: Option<String>, language: String },
}
impl ParsedNamespace {
pub fn parse_allow_test(namespace: &str) -> Result<Self, String> {
Self::parse_internal(namespace, true)
}
pub fn parse(namespace: &str) -> Result<Self, String> {
Self::parse_internal(namespace, false)
}
pub fn parse_internal(namespace: &str, allow_test: bool) -> Result<Self, String> {
if namespace.len() < 3 || namespace.len() > 1000 {
return Err(format!("Namespace length must be between 3 and 1000 characters, got {}", namespace.len()));
}
let parts: Vec<&str> = namespace.split('/').collect();
if parts.is_empty() {
return Err("Namespace cannot be empty".to_string());
}
let base = Self::parse_base(parts[0], allow_test)?;
let extensions = if parts.len() > 1 {
Self::parse_extensions(&parts[1..])?
} else {
Vec::new()
};
Ok(ParsedNamespace { base, extensions })
}
fn parse_base(base: &str, allow_test: bool) -> Result<BaseNamespace, String> {
if base.starts_with("x_") {
Self::parse_unregistered_base(base, allow_test)
} else {
Self::parse_registered_base(base, allow_test)
}
}
fn parse_registered_base(base: &str, allow_test: bool) -> Result<BaseNamespace, String> {
if RESERVED_REGISTERED.contains(&base) {
return Err(format!("Reserved namespace '{}' must not be used", base));
}
if !allow_test && base == "test" {
return Err("Reserved namespace 'test' must not be used".to_string());
}
if let Some(hash_pos) = base.find('#') {
let name = base[..hash_pos].to_string();
let fragment = base[hash_pos + 1..].to_string();
if fragment.is_empty() {
return Err("Fragment cannot be empty after '#'".to_string());
}
Ok(BaseNamespace::Registered { name, fragment: Some(fragment) })
} else {
Ok(BaseNamespace::Registered { name: base.to_string(), fragment: None })
}
}
fn parse_unregistered_base(base: &str, allow_test: bool) -> Result<BaseNamespace, String> {
let hash_pos = base.find('#')
.ok_or_else(|| "Unregistered namespace must contain a fragment (format: x_domain#fragment)".to_string())?;
let reverse_domain = base[2..hash_pos].to_string();
let fragment = base[hash_pos + 1..].to_string();
if reverse_domain.is_empty() {
return Err("Reverse domain name cannot be empty in unregistered namespace".to_string());
}
if fragment.is_empty() {
return Err("Fragment is required in unregistered namespace".to_string());
}
if !allow_test && reverse_domain == "test" {
return Err("Reserved namespace 'x_test' must not be used".to_string());
}
let full_unregistered = format!("x_{}", reverse_domain);
for reserved in RESERVED_UNREGISTERED {
if full_unregistered == *reserved {
return Err(format!("Reserved unregistered namespace '{}' must not be used", reserved));
}
}
Ok(BaseNamespace::Unregistered { reverse_domain, fragment })
}
fn parse_extensions(parts: &[&str]) -> Result<Vec<Extension>, String> {
let mut extensions = Vec::new();
for (idx, part) in parts.iter().enumerate() {
if idx == 0 {
if part.is_empty() {
continue;
} else {
extensions.push(Extension::Language(part.to_string()));
}
} else {
extensions.push(Self::parse_extension_segment(part)?);
}
}
Ok(extensions)
}
fn parse_extension_segment(segment: &str) -> Result<Extension, String> {
if segment.contains('$') {
Self::parse_translation(segment)
} else if segment.starts_with('.') {
Self::parse_domain_extension(segment)
} else {
Ok(Extension::Language(segment.to_string()))
}
}
fn parse_domain_extension(segment: &str) -> Result<Extension, String> {
if !segment.starts_with('.') {
return Err("Domain extension must start with '.'".to_string());
}
let content = &segment[1..];
if let Some(hash_pos) = content.find('#') {
let reverse_domain = content[..hash_pos].to_string();
let fragment = content[hash_pos + 1..].to_string();
if reverse_domain.is_empty() {
return Err("Reverse domain cannot be empty in extension".to_string());
}
if fragment.is_empty() {
return Err("Fragment cannot be empty after '#' in extension".to_string());
}
Ok(Extension::Domain {
reverse_domain,
fragment: Some(fragment)
})
} else {
if content.is_empty() {
return Err("Domain extension cannot be empty".to_string());
}
Ok(Extension::Domain {
reverse_domain: content.to_string(),
fragment: None
})
}
}
fn parse_translation(segment: &str) -> Result<Extension, String> {
if !segment.starts_with('.') {
return Err("Translation segment must start with '.'".to_string());
}
let content = &segment[1..];
let dollar_pos = content.find('$')
.ok_or_else(|| "Translation segment must contain '$' before language tag".to_string())?;
let domain_part = &content[..dollar_pos];
let language = content[dollar_pos + 1..].to_string();
if language.is_empty() {
return Err("Language tag cannot be empty in translation".to_string());
}
if let Some(hash_pos) = domain_part.find('#') {
let reverse_domain = domain_part[..hash_pos].to_string();
let fragment = domain_part[hash_pos + 1..].to_string();
if reverse_domain.is_empty() {
return Err("Reverse domain cannot be empty in translation".to_string());
}
if fragment.is_empty() {
return Err("Fragment cannot be empty after '#' in translation".to_string());
}
Ok(Extension::Translation {
reverse_domain,
fragment: Some(fragment),
language
})
} else {
if domain_part.is_empty() {
return Err("Domain part cannot be empty in translation".to_string());
}
Ok(Extension::Translation {
reverse_domain: domain_part.to_string(),
fragment: None,
language
})
}
}
pub fn base_name(&self) -> &str {
match &self.base {
BaseNamespace::Registered { name, .. } => name,
BaseNamespace::Unregistered { reverse_domain, .. } => reverse_domain,
}
}
pub fn is_unregistered(&self) -> bool {
matches!(self.base, BaseNamespace::Unregistered { .. })
}
pub fn is_registered(&self) -> bool {
matches!(self.base, BaseNamespace::Registered { .. })
}
}
pub fn validate_namespace(namespace: &str) -> Result<ParsedNamespace, String> {
ParsedNamespace::parse(namespace)
}
pub fn validate_namespace_allow_test(namespace: &str) -> Result<ParsedNamespace, String> {
ParsedNamespace::parse_allow_test(namespace)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_registered_namespace() {
let result = validate_namespace("ssvc");
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.is_registered());
assert_eq!(parsed.base_name(), "ssvc");
}
#[test]
fn test_registered_namespace_with_fragment() {
let result = validate_namespace("nist#800-30");
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.is_registered());
match parsed.base {
BaseNamespace::Registered { name, fragment } => {
assert_eq!(name, "nist");
assert_eq!(fragment, Some("800-30".to_string()));
}
_ => panic!("Expected registered namespace"),
}
}
#[test]
fn test_unregistered_namespace() {
let result = validate_namespace("x_example.test#test");
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.is_unregistered());
}
#[test]
fn test_unregistered_namespace_missing_fragment() {
let result = validate_namespace("x_example.test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("fragment"));
}
#[test]
fn test_reserved_registered_namespace() {
assert!(validate_namespace("example").is_err());
assert!(validate_namespace("test").is_err());
assert!(validate_namespace("invalid").is_err());
}
#[test]
fn test_reserved_unregistered_namespace() {
assert!(validate_namespace("x_example#test").is_err());
assert!(validate_namespace("x_test#foo").is_err());
assert!(validate_namespace("x_invalid#bar").is_err());
}
#[test]
fn test_namespace_with_language_extension() {
let result = validate_namespace("ssvc/de-DE");
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.extensions.len(), 1);
match &parsed.extensions[0] {
Extension::Language(lang) => assert_eq!(lang, "de-DE"),
_ => panic!("Expected language extension"),
}
}
#[test]
fn test_namespace_with_empty_first_extension() {
let result = validate_namespace("ssvc//.example.org#ref");
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.extensions.len(), 1);
match &parsed.extensions[0] {
Extension::Domain { reverse_domain, fragment } => {
assert_eq!(reverse_domain, "example.org");
assert_eq!(fragment, &Some("ref".to_string()));
}
_ => panic!("Expected domain extension"),
}
}
#[test]
fn test_complex_namespace_with_extensions() {
let result = validate_namespace("ssvc/de-DE/.example.organization#ref-arch-1");
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.extensions.len(), 2);
}
#[test]
fn test_translation_extension() {
let result = validate_namespace("ssvc//.example.isao#constituency/.example.isao$pl-PL");
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.extensions.len(), 2);
match &parsed.extensions[1] {
Extension::Translation { reverse_domain, fragment, language } => {
assert_eq!(reverse_domain, "example.isao");
assert_eq!(fragment, &None);
assert_eq!(language, "pl-PL");
}
_ => panic!("Expected translation extension"),
}
}
#[test]
fn test_namespace_too_short() {
let result = validate_namespace("ab");
assert!(result.is_err());
}
#[test]
fn test_namespace_too_long() {
let long_ns = "a".repeat(1001);
let result = validate_namespace(&long_ns);
assert!(result.is_err());
}
#[test]
fn test_example_from_docs() {
assert!(validate_namespace("ssvc").is_ok());
assert!(validate_namespace("cisa").is_ok());
assert!(validate_namespace("x_example.test#test//.example.test#private-extension").is_ok());
assert!(validate_namespace("ssvc/de-DE/.example.organization#reference-arch-1").is_ok());
}
#[test]
fn test_cvss_namespace_example() {
let result = validate_namespace("cvss");
assert!(result.is_ok());
}
#[test]
fn test_nist_with_fragment() {
let result = validate_namespace("nist#800-30");
assert!(result.is_ok());
let parsed = result.unwrap();
match parsed.base {
BaseNamespace::Registered { name, fragment } => {
assert_eq!(name, "nist");
assert_eq!(fragment.unwrap(), "800-30");
}
_ => panic!("Expected registered namespace"),
}
}
#[test]
fn test_multiple_extensions() {
let result = validate_namespace("ssvc/de-DE/.example.org#ref1/.example.org#ref2");
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.extensions.len(), 3);
}
#[test]
fn test_empty_fragment_error() {
let result = validate_namespace("ssvc#");
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[test]
fn test_translation_without_domain() {
let result = validate_namespace("ssvc//.$de-DE");
assert!(result.is_err());
}
#[test]
fn test_domain_extension_without_dot() {
let result = validate_namespace("ssvc//example.org");
assert!(result.is_ok());
}
#[test]
fn test_unregistered_multiple_dots_in_domain() {
let result = validate_namespace("x_com.example.subdomain#test");
assert!(result.is_ok());
let parsed = result.unwrap();
match parsed.base {
BaseNamespace::Unregistered { reverse_domain, fragment } => {
assert_eq!(reverse_domain, "com.example.subdomain");
assert_eq!(fragment, "test");
}
_ => panic!("Expected unregistered namespace"),
}
}
}