use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[allow(clippy::struct_excessive_bools)]
pub struct Settings {
#[serde(default = "default_csaf_mode")]
pub csaf_mode: String,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_import_directory")]
pub import_directory: String,
#[serde(default = "default_export_directory")]
pub export_directory: String,
#[serde(default = "default_dump_directory")]
pub dump_directory: String,
#[serde(default = "default_true")]
pub sidecar_sha256: bool,
#[serde(default = "default_true")]
pub sidecar_sha3_512: bool,
#[serde(default = "default_naming_convention")]
pub naming_convention: String,
#[serde(default = "default_publisher_name")]
pub publisher_name: String,
#[serde(default = "default_publisher_namespace")]
pub publisher_namespace: String,
#[serde(default = "default_publisher_category")]
pub publisher_category: String,
#[serde(default = "default_publisher_contact")]
pub publisher_contact_details: String,
#[serde(default = "default_tlp")]
pub tlp_default: String,
#[serde(default = "default_true")]
pub verschlusssache_enabled: bool,
#[serde(default = "default_verschlusssache")]
pub verschlusssache_default: String,
#[serde(default = "default_true")]
pub nato_enabled: bool,
#[serde(default = "default_nato")]
pub nato_default: String,
#[serde(default = "default_classification_storage_mode")]
pub classification_storage_mode: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
csaf_mode: default_csaf_mode(),
theme: default_theme(),
import_directory: default_import_directory(),
export_directory: default_export_directory(),
dump_directory: default_dump_directory(),
sidecar_sha256: true,
sidecar_sha3_512: true,
naming_convention: default_naming_convention(),
publisher_name: default_publisher_name(),
publisher_namespace: default_publisher_namespace(),
publisher_category: default_publisher_category(),
publisher_contact_details: default_publisher_contact(),
tlp_default: default_tlp(),
verschlusssache_enabled: true,
verschlusssache_default: default_verschlusssache(),
nato_enabled: true,
nato_default: default_nato(),
classification_storage_mode: default_classification_storage_mode(),
}
}
}
pub const PUBLISHER_CATEGORIES: &[&str] = &[
"vendor",
"discoverer",
"coordinator",
"user",
"translator",
"other",
];
#[must_use]
pub fn is_valid_publisher_category(value: &str) -> bool {
PUBLISHER_CATEGORIES.contains(&value)
}
pub const TLP_LABELS: &[&str] = &["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"];
pub const VERSCHLUSSSACHE_LABELS: &[&str] = &[
"VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)",
"VS-Vertr. (VS-VERTRAULICH)",
"Geh. (GEHEIM)",
"Str. Geh. (STRENG GEHEIM)",
];
pub const NATO_LABELS: &[&str] = &[
"NR (NATO RESTRICTED)",
"NC (NATO CONFIDENTIAL)",
"NS (NATO SECRET)",
"CTS (COSMIC TOP SECRET)",
];
pub const CLASSIFICATION_STORAGE_MODES: &[&str] = &["distribution_text", "notes", "both"];
#[must_use]
pub fn is_valid_tlp(value: &str) -> bool {
TLP_LABELS.contains(&value)
}
#[must_use]
pub fn is_valid_verschlusssache(value: &str) -> bool {
VERSCHLUSSSACHE_LABELS.contains(&value)
}
#[must_use]
pub fn is_valid_nato(value: &str) -> bool {
NATO_LABELS.contains(&value)
}
#[must_use]
pub fn is_valid_classification_storage_mode(value: &str) -> bool {
CLASSIFICATION_STORAGE_MODES.contains(&value)
}
pub const STORAGE_PATH_MAX_LEN: usize = 4096;
#[must_use]
pub fn is_valid_storage_path(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.len() > STORAGE_PATH_MAX_LEN {
return false;
}
if trimmed.contains('\0') {
return false;
}
if trimmed.starts_with('~') {
return false;
}
for component in std::path::Path::new(trimmed).components() {
if matches!(component, std::path::Component::ParentDir) {
return false;
}
}
true
}
fn default_csaf_mode() -> String {
"2.1".to_owned()
}
fn default_theme() -> String {
"light".to_owned()
}
fn default_import_directory() -> String {
"./data_import".to_owned()
}
fn default_export_directory() -> String {
"./data_export".to_owned()
}
fn default_dump_directory() -> String {
"./data_dump".to_owned()
}
fn default_naming_convention() -> String {
"ndaal-sa-".to_owned()
}
fn default_publisher_name() -> String {
"ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG".to_owned()
}
fn default_publisher_namespace() -> String {
"https://ndaal.eu/csaf".to_owned()
}
fn default_publisher_category() -> String {
"vendor".to_owned()
}
fn default_publisher_contact() -> String {
"security@ndaal.eu".to_owned()
}
fn default_tlp() -> String {
"AMBER".to_owned()
}
fn default_verschlusssache() -> String {
"VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)".to_owned()
}
fn default_nato() -> String {
"NR (NATO RESTRICTED)".to_owned()
}
fn default_classification_storage_mode() -> String {
"both".to_owned()
}
const fn default_true() -> bool {
true
}
#[cfg(test)]
#[allow(clippy::cognitive_complexity)]
mod tests {
use super::*;
#[test]
fn test_default_settings() {
let settings = Settings::default();
assert_eq!(settings.csaf_mode, "2.1");
assert_eq!(settings.theme, "light");
assert!(settings.sidecar_sha256);
assert!(settings.sidecar_sha3_512);
assert_eq!(settings.naming_convention, "ndaal-sa-");
assert_eq!(settings.import_directory, "./data_import");
assert_eq!(settings.export_directory, "./data_export");
assert_eq!(settings.dump_directory, "./data_dump");
assert_eq!(
settings.publisher_name,
"ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG"
);
assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
assert_eq!(settings.publisher_category, "vendor");
assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
assert_eq!(settings.tlp_default, "AMBER");
assert!(settings.verschlusssache_enabled);
assert_eq!(
settings.verschlusssache_default,
"VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
);
assert!(settings.nato_enabled);
assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
assert_eq!(settings.classification_storage_mode, "both");
}
#[test]
fn test_settings_roundtrip() {
let settings = Settings::default();
let json = serde_json::to_string(&settings).expect("serialize failed");
let parsed: Settings = serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(settings, parsed);
}
#[test]
fn test_partial_deserialization() {
let json = r#"{"csaf_mode": "2.0", "theme": "dark"}"#;
let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(settings.csaf_mode, "2.0");
assert_eq!(settings.theme, "dark");
assert_eq!(settings.dump_directory, "./data_dump");
assert!(settings.sidecar_sha256);
assert_eq!(settings.naming_convention, "ndaal-sa-");
assert_eq!(settings.publisher_category, "vendor");
assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
}
#[test]
fn test_publisher_category_whitelist() {
for cat in PUBLISHER_CATEGORIES {
assert!(is_valid_publisher_category(cat), "{cat} should be valid");
}
assert!(!is_valid_publisher_category("VENDOR"));
assert!(!is_valid_publisher_category("bogus"));
assert!(!is_valid_publisher_category(""));
}
#[test]
fn test_tlp_whitelist() {
for label in TLP_LABELS {
assert!(is_valid_tlp(label), "{label} should be a valid TLP label");
}
assert!(!is_valid_tlp(""));
assert!(!is_valid_tlp("clear"));
assert!(!is_valid_tlp("AMBER "));
assert!(!is_valid_tlp("WHITE"));
}
#[test]
fn test_verschlusssache_whitelist() {
for label in VERSCHLUSSSACHE_LABELS {
assert!(is_valid_verschlusssache(label));
}
assert!(!is_valid_verschlusssache(""));
assert!(!is_valid_verschlusssache("VS-NfD"));
assert!(!is_valid_verschlusssache("VS-NUR FÜR DEN DIENSTGEBRAUCH"));
}
#[test]
fn test_nato_whitelist() {
for label in NATO_LABELS {
assert!(is_valid_nato(label));
}
assert!(!is_valid_nato(""));
assert!(!is_valid_nato("NR"));
assert!(!is_valid_nato("nr (nato restricted)"));
}
#[test]
fn test_is_valid_storage_path_accepts() {
for good in [
"./data_dump",
"./data_import",
"/var/lib/csaf",
"data/dump",
"C:/dev/csaf/dump",
] {
assert!(is_valid_storage_path(good), "{good} should be accepted");
}
}
#[test]
fn test_is_valid_storage_path_rejects() {
for bad in [
"",
" ",
"\t",
"~/dumps",
"~\\dumps",
"../etc/passwd",
"./../a",
"foo/../bar",
"a\0b",
] {
assert!(!is_valid_storage_path(bad), "{bad:?} should be rejected");
}
}
#[test]
fn test_is_valid_storage_path_max_length() {
let huge = "a".repeat(STORAGE_PATH_MAX_LEN + 1);
assert!(!is_valid_storage_path(&huge));
let ok = "a".repeat(STORAGE_PATH_MAX_LEN);
assert!(is_valid_storage_path(&ok));
}
#[test]
fn test_classification_storage_mode_whitelist() {
for mode in CLASSIFICATION_STORAGE_MODES {
assert!(is_valid_classification_storage_mode(mode));
}
assert!(!is_valid_classification_storage_mode(""));
assert!(!is_valid_classification_storage_mode("BOTH"));
assert!(!is_valid_classification_storage_mode("distribution"));
assert!(!is_valid_classification_storage_mode("note"));
}
#[test]
fn test_cartesian_combinations_roundtrip() {
for tlp in TLP_LABELS {
for vs in VERSCHLUSSSACHE_LABELS {
for nato in NATO_LABELS {
for mode in CLASSIFICATION_STORAGE_MODES {
let settings = Settings {
tlp_default: (*tlp).to_owned(),
verschlusssache_default: (*vs).to_owned(),
nato_default: (*nato).to_owned(),
classification_storage_mode: (*mode).to_owned(),
..Settings::default()
};
let json = serde_json::to_string(&settings).expect("serialize failed");
let parsed: Settings =
serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(settings, parsed);
}
}
}
}
}
#[test]
fn test_old_settings_json_still_loads() {
let json = r#"{
"csaf_mode": "2.1",
"theme": "dark",
"import_directory": "./data_import",
"export_directory": "./data_export",
"sidecar_sha256": true,
"sidecar_sha3_512": true,
"naming_convention": "ndaal-sa-"
}"#;
let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(settings.publisher_category, "vendor");
assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
assert_eq!(settings.dump_directory, "./data_dump");
assert_eq!(settings.tlp_default, "AMBER");
assert!(settings.verschlusssache_enabled);
assert_eq!(
settings.verschlusssache_default,
"VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
);
assert!(settings.nato_enabled);
assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
assert_eq!(settings.classification_storage_mode, "both");
}
}