1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[allow(clippy::struct_excessive_bools)]
11pub struct Settings {
12 #[serde(default = "default_csaf_mode")]
14 pub csaf_mode: String,
15
16 #[serde(default = "default_theme")]
18 pub theme: String,
19
20 #[serde(default = "default_import_directory")]
22 pub import_directory: String,
23
24 #[serde(default = "default_export_directory")]
26 pub export_directory: String,
27
28 #[serde(default = "default_dump_directory")]
30 pub dump_directory: String,
31
32 #[serde(default = "default_true")]
34 pub sidecar_sha256: bool,
35
36 #[serde(default = "default_true")]
38 pub sidecar_sha3_512: bool,
39
40 #[serde(default = "default_naming_convention")]
42 pub naming_convention: String,
43
44 #[serde(default = "default_publisher_name")]
49 pub publisher_name: String,
50
51 #[serde(default = "default_publisher_namespace")]
53 pub publisher_namespace: String,
54
55 #[serde(default = "default_publisher_category")]
58 pub publisher_category: String,
59
60 #[serde(default = "default_publisher_contact")]
62 pub publisher_contact_details: String,
63
64 #[serde(default = "default_tlp")]
72 pub tlp_default: String,
73
74 #[serde(default = "default_true")]
77 pub verschlusssache_enabled: bool,
78
79 #[serde(default = "default_verschlusssache")]
81 pub verschlusssache_default: String,
82
83 #[serde(default = "default_true")]
85 pub nato_enabled: bool,
86
87 #[serde(default = "default_nato")]
89 pub nato_default: String,
90
91 #[serde(default = "default_classification_storage_mode")]
95 pub classification_storage_mode: String,
96}
97
98impl Default for Settings {
99 fn default() -> Self {
100 Self {
101 csaf_mode: default_csaf_mode(),
102 theme: default_theme(),
103 import_directory: default_import_directory(),
104 export_directory: default_export_directory(),
105 dump_directory: default_dump_directory(),
106 sidecar_sha256: true,
107 sidecar_sha3_512: true,
108 naming_convention: default_naming_convention(),
109 publisher_name: default_publisher_name(),
110 publisher_namespace: default_publisher_namespace(),
111 publisher_category: default_publisher_category(),
112 publisher_contact_details: default_publisher_contact(),
113 tlp_default: default_tlp(),
114 verschlusssache_enabled: true,
115 verschlusssache_default: default_verschlusssache(),
116 nato_enabled: true,
117 nato_default: default_nato(),
118 classification_storage_mode: default_classification_storage_mode(),
119 }
120 }
121}
122
123pub const PUBLISHER_CATEGORIES: &[&str] = &[
125 "vendor",
126 "discoverer",
127 "coordinator",
128 "user",
129 "translator",
130 "other",
131];
132
133#[must_use]
135pub fn is_valid_publisher_category(value: &str) -> bool {
136 PUBLISHER_CATEGORIES.contains(&value)
137}
138
139pub const TLP_LABELS: &[&str] = &["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"];
141
142pub const VERSCHLUSSSACHE_LABELS: &[&str] = &[
145 "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)",
146 "VS-Vertr. (VS-VERTRAULICH)",
147 "Geh. (GEHEIM)",
148 "Str. Geh. (STRENG GEHEIM)",
149];
150
151pub const NATO_LABELS: &[&str] = &[
154 "NR (NATO RESTRICTED)",
155 "NC (NATO CONFIDENTIAL)",
156 "NS (NATO SECRET)",
157 "CTS (COSMIC TOP SECRET)",
158];
159
160pub const CLASSIFICATION_STORAGE_MODES: &[&str] = &["distribution_text", "notes", "both"];
168
169#[must_use]
171pub fn is_valid_tlp(value: &str) -> bool {
172 TLP_LABELS.contains(&value)
173}
174
175#[must_use]
177pub fn is_valid_verschlusssache(value: &str) -> bool {
178 VERSCHLUSSSACHE_LABELS.contains(&value)
179}
180
181#[must_use]
184pub fn is_valid_nato(value: &str) -> bool {
185 NATO_LABELS.contains(&value)
186}
187
188#[must_use]
191pub fn is_valid_classification_storage_mode(value: &str) -> bool {
192 CLASSIFICATION_STORAGE_MODES.contains(&value)
193}
194
195pub const STORAGE_PATH_MAX_LEN: usize = 4096;
197
198#[must_use]
211pub fn is_valid_storage_path(value: &str) -> bool {
212 let trimmed = value.trim();
213 if trimmed.is_empty() || trimmed.len() > STORAGE_PATH_MAX_LEN {
214 return false;
215 }
216 if trimmed.contains('\0') {
217 return false;
218 }
219 if trimmed.starts_with('~') {
220 return false;
221 }
222 for component in std::path::Path::new(trimmed).components() {
225 if matches!(component, std::path::Component::ParentDir) {
226 return false;
227 }
228 }
229 true
230}
231
232fn default_csaf_mode() -> String {
233 "2.1".to_owned()
234}
235
236fn default_theme() -> String {
237 "light".to_owned()
238}
239
240fn default_import_directory() -> String {
241 "./data_import".to_owned()
242}
243
244fn default_export_directory() -> String {
245 "./data_export".to_owned()
246}
247
248fn default_dump_directory() -> String {
249 "./data_dump".to_owned()
250}
251
252fn default_naming_convention() -> String {
253 "ndaal-sa-".to_owned()
254}
255
256fn default_publisher_name() -> String {
257 "ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG".to_owned()
258}
259
260fn default_publisher_namespace() -> String {
261 "https://ndaal.eu/csaf".to_owned()
262}
263
264fn default_publisher_category() -> String {
265 "vendor".to_owned()
266}
267
268fn default_publisher_contact() -> String {
269 "security@ndaal.eu".to_owned()
270}
271
272fn default_tlp() -> String {
273 "AMBER".to_owned()
274}
275
276fn default_verschlusssache() -> String {
277 "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)".to_owned()
278}
279
280fn default_nato() -> String {
281 "NR (NATO RESTRICTED)".to_owned()
282}
283
284fn default_classification_storage_mode() -> String {
285 "both".to_owned()
286}
287
288const fn default_true() -> bool {
289 true
290}
291
292#[cfg(test)]
293#[allow(clippy::cognitive_complexity)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_default_settings() {
301 let settings = Settings::default();
302 assert_eq!(settings.csaf_mode, "2.1");
303 assert_eq!(settings.theme, "light");
304 assert!(settings.sidecar_sha256);
305 assert!(settings.sidecar_sha3_512);
306 assert_eq!(settings.naming_convention, "ndaal-sa-");
307 assert_eq!(settings.import_directory, "./data_import");
308 assert_eq!(settings.export_directory, "./data_export");
309 assert_eq!(settings.dump_directory, "./data_dump");
310 assert_eq!(
311 settings.publisher_name,
312 "ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG"
313 );
314 assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
315 assert_eq!(settings.publisher_category, "vendor");
316 assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
317 assert_eq!(settings.tlp_default, "AMBER");
318 assert!(settings.verschlusssache_enabled);
319 assert_eq!(
320 settings.verschlusssache_default,
321 "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
322 );
323 assert!(settings.nato_enabled);
324 assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
325 assert_eq!(settings.classification_storage_mode, "both");
326 }
327
328 #[test]
329 fn test_settings_roundtrip() {
330 let settings = Settings::default();
331 let json = serde_json::to_string(&settings).expect("serialize failed");
332 let parsed: Settings = serde_json::from_str(&json).expect("deserialize failed");
333 assert_eq!(settings, parsed);
334 }
335
336 #[test]
337 fn test_partial_deserialization() {
338 let json = r#"{"csaf_mode": "2.0", "theme": "dark"}"#;
339 let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
340 assert_eq!(settings.csaf_mode, "2.0");
341 assert_eq!(settings.theme, "dark");
342 assert_eq!(settings.dump_directory, "./data_dump");
344 assert!(settings.sidecar_sha256);
345 assert_eq!(settings.naming_convention, "ndaal-sa-");
346 assert_eq!(settings.publisher_category, "vendor");
347 assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
348 }
349
350 #[test]
351 fn test_publisher_category_whitelist() {
352 for cat in PUBLISHER_CATEGORIES {
353 assert!(is_valid_publisher_category(cat), "{cat} should be valid");
354 }
355 assert!(!is_valid_publisher_category("VENDOR"));
356 assert!(!is_valid_publisher_category("bogus"));
357 assert!(!is_valid_publisher_category(""));
358 }
359
360 #[test]
361 fn test_tlp_whitelist() {
362 for label in TLP_LABELS {
363 assert!(is_valid_tlp(label), "{label} should be a valid TLP label");
364 }
365 assert!(!is_valid_tlp(""));
366 assert!(!is_valid_tlp("clear"));
367 assert!(!is_valid_tlp("AMBER "));
368 assert!(!is_valid_tlp("WHITE"));
369 }
370
371 #[test]
372 fn test_verschlusssache_whitelist() {
373 for label in VERSCHLUSSSACHE_LABELS {
374 assert!(is_valid_verschlusssache(label));
375 }
376 assert!(!is_valid_verschlusssache(""));
377 assert!(!is_valid_verschlusssache("VS-NfD"));
378 assert!(!is_valid_verschlusssache("VS-NUR FÜR DEN DIENSTGEBRAUCH"));
379 }
380
381 #[test]
382 fn test_nato_whitelist() {
383 for label in NATO_LABELS {
384 assert!(is_valid_nato(label));
385 }
386 assert!(!is_valid_nato(""));
387 assert!(!is_valid_nato("NR"));
388 assert!(!is_valid_nato("nr (nato restricted)"));
389 }
390
391 #[test]
392 fn test_is_valid_storage_path_accepts() {
393 for good in [
394 "./data_dump",
395 "./data_import",
396 "/var/lib/csaf",
397 "data/dump",
398 "C:/dev/csaf/dump",
399 ] {
400 assert!(is_valid_storage_path(good), "{good} should be accepted");
401 }
402 }
403
404 #[test]
405 fn test_is_valid_storage_path_rejects() {
406 for bad in [
407 "",
408 " ",
409 "\t",
410 "~/dumps",
411 "~\\dumps",
412 "../etc/passwd",
413 "./../a",
414 "foo/../bar",
415 "a\0b",
416 ] {
417 assert!(!is_valid_storage_path(bad), "{bad:?} should be rejected");
418 }
419 }
420
421 #[test]
422 fn test_is_valid_storage_path_max_length() {
423 let huge = "a".repeat(STORAGE_PATH_MAX_LEN + 1);
424 assert!(!is_valid_storage_path(&huge));
425 let ok = "a".repeat(STORAGE_PATH_MAX_LEN);
426 assert!(is_valid_storage_path(&ok));
427 }
428
429 #[test]
430 fn test_classification_storage_mode_whitelist() {
431 for mode in CLASSIFICATION_STORAGE_MODES {
432 assert!(is_valid_classification_storage_mode(mode));
433 }
434 assert!(!is_valid_classification_storage_mode(""));
435 assert!(!is_valid_classification_storage_mode("BOTH"));
436 assert!(!is_valid_classification_storage_mode("distribution"));
437 assert!(!is_valid_classification_storage_mode("note"));
438 }
439
440 #[test]
441 fn test_cartesian_combinations_roundtrip() {
442 for tlp in TLP_LABELS {
445 for vs in VERSCHLUSSSACHE_LABELS {
446 for nato in NATO_LABELS {
447 for mode in CLASSIFICATION_STORAGE_MODES {
448 let settings = Settings {
449 tlp_default: (*tlp).to_owned(),
450 verschlusssache_default: (*vs).to_owned(),
451 nato_default: (*nato).to_owned(),
452 classification_storage_mode: (*mode).to_owned(),
453 ..Settings::default()
454 };
455 let json = serde_json::to_string(&settings).expect("serialize failed");
456 let parsed: Settings =
457 serde_json::from_str(&json).expect("deserialize failed");
458 assert_eq!(settings, parsed);
459 }
460 }
461 }
462 }
463 }
464
465 #[test]
466 fn test_old_settings_json_still_loads() {
467 let json = r#"{
470 "csaf_mode": "2.1",
471 "theme": "dark",
472 "import_directory": "./data_import",
473 "export_directory": "./data_export",
474 "sidecar_sha256": true,
475 "sidecar_sha3_512": true,
476 "naming_convention": "ndaal-sa-"
477 }"#;
478 let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
479 assert_eq!(settings.publisher_category, "vendor");
480 assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
481 assert_eq!(settings.dump_directory, "./data_dump");
483 assert_eq!(settings.tlp_default, "AMBER");
486 assert!(settings.verschlusssache_enabled);
487 assert_eq!(
488 settings.verschlusssache_default,
489 "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
490 );
491 assert!(settings.nato_enabled);
492 assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
493 assert_eq!(settings.classification_storage_mode, "both");
494 }
495}