Skip to main content

csaf_models/
settings.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Application settings model.
5
6use serde::{Deserialize, Serialize};
7
8/// Application settings stored in redb.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[allow(clippy::struct_excessive_bools)]
11pub struct Settings {
12    /// CSAF mode: `"2.0"` or `"2.1"`.
13    #[serde(default = "default_csaf_mode")]
14    pub csaf_mode: String,
15
16    /// UI theme: `"light"` or `"dark"`.
17    #[serde(default = "default_theme")]
18    pub theme: String,
19
20    /// Directory to scan for CSAF imports.
21    #[serde(default = "default_import_directory")]
22    pub import_directory: String,
23
24    /// Directory to export CSAF files to.
25    #[serde(default = "default_export_directory")]
26    pub export_directory: String,
27
28    /// Directory to write full database dumps + hash sidecars to.
29    #[serde(default = "default_dump_directory")]
30    pub dump_directory: String,
31
32    /// Directory to write rolling application log files + sidecars to.
33    #[serde(default = "default_log_directory")]
34    pub log_directory: String,
35
36    /// Whether to generate SHA-256 sidecar files (extension `.sha-256`).
37    #[serde(default = "default_true")]
38    pub sidecar_sha256: bool,
39
40    /// Whether to generate SHA-512 (SHA-2 family) sidecar files
41    /// (extension `.sha-512`).
42    #[serde(default = "default_true")]
43    pub sidecar_sha512: bool,
44
45    /// Whether to generate SHA3-512 sidecar files (extension `.sha3-512`).
46    #[serde(default = "default_true")]
47    pub sidecar_sha3_512: bool,
48
49    /// Whether to generate BLAKE3-512 sidecar files (extension
50    /// `.blake3-512`). Enabled by default.
51    #[serde(default = "default_true")]
52    pub sidecar_blake3_512: bool,
53
54    /// Whether to generate SHAKE256-512 sidecar files (extension
55    /// `.shake256-512`). Enabled by default.
56    #[serde(default = "default_true")]
57    pub sidecar_shake256_512: bool,
58
59    /// Naming convention prefix for CSAF files.
60    #[serde(default = "default_naming_convention")]
61    pub naming_convention: String,
62
63    // -----------------------------------------------------------------------
64    // Publisher defaults used when creating new CSAF documents.
65    // -----------------------------------------------------------------------
66    /// Default publisher name injected into new CSAF documents.
67    #[serde(default = "default_publisher_name")]
68    pub publisher_name: String,
69
70    /// Default publisher namespace URI.
71    #[serde(default = "default_publisher_namespace")]
72    pub publisher_namespace: String,
73
74    /// Default publisher category (`vendor`, `discoverer`, `coordinator`,
75    /// `user`, `translator`, `other`).
76    #[serde(default = "default_publisher_category")]
77    pub publisher_category: String,
78
79    /// Default publisher contact details (email or URL).
80    #[serde(default = "default_publisher_contact")]
81    pub publisher_contact_details: String,
82
83    // -----------------------------------------------------------------------
84    // Classification defaults -- TLP 2.0, German Verschlusssache, NATO.
85    //
86    // These drive the colored TLP dropdown and the Verschlusssache/NATO
87    // selects on the CSAF create/edit form.
88    // -----------------------------------------------------------------------
89    /// Default TLP 2.0 label (one of [`TLP_LABELS`]).
90    #[serde(default = "default_tlp")]
91    pub tlp_default: String,
92
93    /// Whether the Verschlusssache (German national classification) field
94    /// is exposed on the CSAF form.
95    #[serde(default = "default_true")]
96    pub verschlusssache_enabled: bool,
97
98    /// Default Verschlusssache label (one of [`VERSCHLUSSSACHE_LABELS`]).
99    #[serde(default = "default_verschlusssache")]
100    pub verschlusssache_default: String,
101
102    /// Whether the NATO classification field is exposed on the CSAF form.
103    #[serde(default = "default_true")]
104    pub nato_enabled: bool,
105
106    /// Default NATO classification label (one of [`NATO_LABELS`]).
107    #[serde(default = "default_nato")]
108    pub nato_default: String,
109
110    /// Where to write Verschlusssache / NATO values in the exported CSAF
111    /// document. One of [`CLASSIFICATION_STORAGE_MODES`]:
112    /// `"distribution_text"`, `"notes"`, or `"both"`.
113    #[serde(default = "default_classification_storage_mode")]
114    pub classification_storage_mode: String,
115}
116
117impl Default for Settings {
118    fn default() -> Self {
119        Self {
120            csaf_mode: default_csaf_mode(),
121            theme: default_theme(),
122            import_directory: default_import_directory(),
123            export_directory: default_export_directory(),
124            dump_directory: default_dump_directory(),
125            log_directory: default_log_directory(),
126            sidecar_sha256: true,
127            sidecar_sha512: true,
128            sidecar_sha3_512: true,
129            sidecar_blake3_512: true,
130            sidecar_shake256_512: true,
131            naming_convention: default_naming_convention(),
132            publisher_name: default_publisher_name(),
133            publisher_namespace: default_publisher_namespace(),
134            publisher_category: default_publisher_category(),
135            publisher_contact_details: default_publisher_contact(),
136            tlp_default: default_tlp(),
137            verschlusssache_enabled: true,
138            verschlusssache_default: default_verschlusssache(),
139            nato_enabled: true,
140            nato_default: default_nato(),
141            classification_storage_mode: default_classification_storage_mode(),
142        }
143    }
144}
145
146/// Allowed values for the publisher category dropdown.
147pub const PUBLISHER_CATEGORIES: &[&str] = &[
148    "vendor",
149    "discoverer",
150    "coordinator",
151    "user",
152    "translator",
153    "other",
154];
155
156/// Check whether a candidate string is an allowed publisher category.
157#[must_use]
158pub fn is_valid_publisher_category(value: &str) -> bool {
159    PUBLISHER_CATEGORIES.contains(&value)
160}
161
162/// Allowed TLP 2.0 labels (`https://www.first.org/tlp/`).
163pub const TLP_LABELS: &[&str] = &["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"];
164
165/// Allowed Verschlusssache (German national classification) labels
166/// according to <https://de.wikipedia.org/wiki/Verschlusssache#Einstufung>.
167pub const VERSCHLUSSSACHE_LABELS: &[&str] = &[
168    "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)",
169    "VS-Vertr. (VS-VERTRAULICH)",
170    "Geh. (GEHEIM)",
171    "Str. Geh. (STRENG GEHEIM)",
172];
173
174/// Allowed NATO classification labels according to
175/// <https://de.wikipedia.org/wiki/Geheimhaltungsgrad#NATO>.
176pub const NATO_LABELS: &[&str] = &[
177    "NR (NATO RESTRICTED)",
178    "NC (NATO CONFIDENTIAL)",
179    "NS (NATO SECRET)",
180    "CTS (COSMIC TOP SECRET)",
181];
182
183/// Where to write Verschlusssache / NATO values in the exported CSAF JSON.
184///
185/// - `"distribution_text"`: append to `document.distribution.text` as
186///   `"Verschlusssache: <value> | NATO: <value>"`.
187/// - `"notes"`: add one `document.notes[]` entry per field with the
188///   well-known titles `"Verschlusssache"` and `"NATO Classification"`.
189/// - `"both"`: write to both locations so any CSAF consumer can find it.
190pub const CLASSIFICATION_STORAGE_MODES: &[&str] = &["distribution_text", "notes", "both"];
191
192/// Check whether a candidate string is an allowed TLP 2.0 label.
193#[must_use]
194pub fn is_valid_tlp(value: &str) -> bool {
195    TLP_LABELS.contains(&value)
196}
197
198/// Check whether a candidate string is an allowed Verschlusssache label.
199#[must_use]
200pub fn is_valid_verschlusssache(value: &str) -> bool {
201    VERSCHLUSSSACHE_LABELS.contains(&value)
202}
203
204/// Check whether a candidate string is an allowed NATO classification
205/// label.
206#[must_use]
207pub fn is_valid_nato(value: &str) -> bool {
208    NATO_LABELS.contains(&value)
209}
210
211/// Check whether a candidate string is an allowed classification storage
212/// mode.
213#[must_use]
214pub fn is_valid_classification_storage_mode(value: &str) -> bool {
215    CLASSIFICATION_STORAGE_MODES.contains(&value)
216}
217
218/// Maximum accepted length (bytes) of a user-supplied storage path.
219pub const STORAGE_PATH_MAX_LEN: usize = 4096;
220
221/// Check whether a candidate string is an acceptable
222/// filesystem-path setting (`import_directory`, `export_directory`,
223/// `dump_directory`).
224///
225/// Rejects:
226/// - empty / whitespace-only strings,
227/// - strings longer than [`STORAGE_PATH_MAX_LEN`] bytes,
228/// - strings containing a NUL byte (`\0`),
229/// - strings containing a `..` path component (path-traversal guard),
230/// - strings that start with `~` (no shell-style home expansion).
231///
232/// The check is purely syntactic — it does not touch the filesystem.
233#[must_use]
234pub fn is_valid_storage_path(value: &str) -> bool {
235    let trimmed = value.trim();
236    if trimmed.is_empty() || trimmed.len() > STORAGE_PATH_MAX_LEN {
237        return false;
238    }
239    if trimmed.contains('\0') {
240        return false;
241    }
242    if trimmed.starts_with('~') {
243        return false;
244    }
245    // Reject any `..` segment, anywhere. Uses Path to avoid missing
246    // encodings like `a/..` vs `..\\b` on different platforms.
247    for component in std::path::Path::new(trimmed).components() {
248        if matches!(component, std::path::Component::ParentDir) {
249            return false;
250        }
251    }
252    true
253}
254
255fn default_csaf_mode() -> String {
256    "2.1".to_owned()
257}
258
259fn default_theme() -> String {
260    "light".to_owned()
261}
262
263fn default_import_directory() -> String {
264    "./data_import".to_owned()
265}
266
267fn default_export_directory() -> String {
268    "./data_export".to_owned()
269}
270
271fn default_dump_directory() -> String {
272    "./data_dump".to_owned()
273}
274
275fn default_log_directory() -> String {
276    "./data_log".to_owned()
277}
278
279fn default_naming_convention() -> String {
280    "ndaal-sa-".to_owned()
281}
282
283fn default_publisher_name() -> String {
284    "ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG".to_owned()
285}
286
287fn default_publisher_namespace() -> String {
288    "https://ndaal.eu/csaf".to_owned()
289}
290
291fn default_publisher_category() -> String {
292    "vendor".to_owned()
293}
294
295fn default_publisher_contact() -> String {
296    "security@ndaal.eu".to_owned()
297}
298
299fn default_tlp() -> String {
300    "AMBER".to_owned()
301}
302
303fn default_verschlusssache() -> String {
304    "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)".to_owned()
305}
306
307fn default_nato() -> String {
308    "NR (NATO RESTRICTED)".to_owned()
309}
310
311fn default_classification_storage_mode() -> String {
312    "both".to_owned()
313}
314
315const fn default_true() -> bool {
316    true
317}
318
319#[cfg(test)]
320// Dense assertion blocks are acceptable in tests — clippy's
321// cognitive-complexity threshold is tuned for production code paths.
322#[allow(clippy::cognitive_complexity)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_default_settings() {
328        let settings = Settings::default();
329        assert_eq!(settings.csaf_mode, "2.1");
330        assert_eq!(settings.theme, "light");
331        assert!(settings.sidecar_sha256);
332        assert!(settings.sidecar_sha512);
333        assert!(settings.sidecar_sha3_512);
334        assert!(settings.sidecar_blake3_512);
335        assert!(settings.sidecar_shake256_512);
336        assert_eq!(settings.naming_convention, "ndaal-sa-");
337        assert_eq!(settings.import_directory, "./data_import");
338        assert_eq!(settings.export_directory, "./data_export");
339        assert_eq!(settings.dump_directory, "./data_dump");
340        assert_eq!(settings.log_directory, "./data_log");
341        assert_eq!(
342            settings.publisher_name,
343            "ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG"
344        );
345        assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
346        assert_eq!(settings.publisher_category, "vendor");
347        assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
348        assert_eq!(settings.tlp_default, "AMBER");
349        assert!(settings.verschlusssache_enabled);
350        assert_eq!(
351            settings.verschlusssache_default,
352            "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
353        );
354        assert!(settings.nato_enabled);
355        assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
356        assert_eq!(settings.classification_storage_mode, "both");
357    }
358
359    #[test]
360    fn test_settings_roundtrip() {
361        let settings = Settings::default();
362        let json = serde_json::to_string(&settings).expect("serialize failed");
363        let parsed: Settings = serde_json::from_str(&json).expect("deserialize failed");
364        assert_eq!(settings, parsed);
365    }
366
367    #[test]
368    fn test_partial_deserialization() {
369        let json = r#"{"csaf_mode": "2.0", "theme": "dark"}"#;
370        let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
371        assert_eq!(settings.csaf_mode, "2.0");
372        assert_eq!(settings.theme, "dark");
373        // Defaults applied for missing fields, including the publisher defaults.
374        assert_eq!(settings.dump_directory, "./data_dump");
375        assert_eq!(settings.log_directory, "./data_log");
376        assert!(settings.sidecar_sha256);
377        assert!(settings.sidecar_sha512);
378        assert_eq!(settings.naming_convention, "ndaal-sa-");
379        assert_eq!(settings.publisher_category, "vendor");
380        assert_eq!(settings.publisher_namespace, "https://ndaal.eu/csaf");
381    }
382
383    #[test]
384    fn test_publisher_category_whitelist() {
385        for cat in PUBLISHER_CATEGORIES {
386            assert!(is_valid_publisher_category(cat), "{cat} should be valid");
387        }
388        assert!(!is_valid_publisher_category("VENDOR"));
389        assert!(!is_valid_publisher_category("bogus"));
390        assert!(!is_valid_publisher_category(""));
391    }
392
393    #[test]
394    fn test_tlp_whitelist() {
395        for label in TLP_LABELS {
396            assert!(is_valid_tlp(label), "{label} should be a valid TLP label");
397        }
398        assert!(!is_valid_tlp(""));
399        assert!(!is_valid_tlp("clear"));
400        assert!(!is_valid_tlp("AMBER "));
401        assert!(!is_valid_tlp("WHITE"));
402    }
403
404    #[test]
405    fn test_verschlusssache_whitelist() {
406        for label in VERSCHLUSSSACHE_LABELS {
407            assert!(is_valid_verschlusssache(label));
408        }
409        assert!(!is_valid_verschlusssache(""));
410        assert!(!is_valid_verschlusssache("VS-NfD"));
411        assert!(!is_valid_verschlusssache("VS-NUR FÜR DEN DIENSTGEBRAUCH"));
412    }
413
414    #[test]
415    fn test_nato_whitelist() {
416        for label in NATO_LABELS {
417            assert!(is_valid_nato(label));
418        }
419        assert!(!is_valid_nato(""));
420        assert!(!is_valid_nato("NR"));
421        assert!(!is_valid_nato("nr (nato restricted)"));
422    }
423
424    #[test]
425    fn test_is_valid_storage_path_accepts() {
426        for good in [
427            "./data_dump",
428            "./data_import",
429            "/var/lib/csaf",
430            "data/dump",
431            "C:/dev/csaf/dump",
432        ] {
433            assert!(is_valid_storage_path(good), "{good} should be accepted");
434        }
435    }
436
437    #[test]
438    fn test_is_valid_storage_path_rejects() {
439        for bad in [
440            "",
441            "   ",
442            "\t",
443            "~/dumps",
444            "~\\dumps",
445            "../etc/passwd",
446            "./../a",
447            "foo/../bar",
448            "a\0b",
449        ] {
450            assert!(!is_valid_storage_path(bad), "{bad:?} should be rejected");
451        }
452    }
453
454    #[test]
455    fn test_is_valid_storage_path_max_length() {
456        let huge = "a".repeat(STORAGE_PATH_MAX_LEN + 1);
457        assert!(!is_valid_storage_path(&huge));
458        let ok = "a".repeat(STORAGE_PATH_MAX_LEN);
459        assert!(is_valid_storage_path(&ok));
460    }
461
462    #[test]
463    fn test_classification_storage_mode_whitelist() {
464        for mode in CLASSIFICATION_STORAGE_MODES {
465            assert!(is_valid_classification_storage_mode(mode));
466        }
467        assert!(!is_valid_classification_storage_mode(""));
468        assert!(!is_valid_classification_storage_mode("BOTH"));
469        assert!(!is_valid_classification_storage_mode("distribution"));
470        assert!(!is_valid_classification_storage_mode("note"));
471    }
472
473    #[test]
474    fn test_cartesian_combinations_roundtrip() {
475        // Every (TLP, VS, NATO, mode) quadruple must round-trip through
476        // JSON without drift.
477        for tlp in TLP_LABELS {
478            for vs in VERSCHLUSSSACHE_LABELS {
479                for nato in NATO_LABELS {
480                    for mode in CLASSIFICATION_STORAGE_MODES {
481                        let settings = Settings {
482                            tlp_default: (*tlp).to_owned(),
483                            verschlusssache_default: (*vs).to_owned(),
484                            nato_default: (*nato).to_owned(),
485                            classification_storage_mode: (*mode).to_owned(),
486                            ..Settings::default()
487                        };
488                        let json = serde_json::to_string(&settings).expect("serialize failed");
489                        let parsed: Settings =
490                            serde_json::from_str(&json).expect("deserialize failed");
491                        assert_eq!(settings, parsed);
492                    }
493                }
494            }
495        }
496    }
497
498    #[test]
499    fn test_old_settings_json_still_loads() {
500        // JSON written by a previous version without the publisher fields
501        // must still deserialise successfully via the `default` helpers.
502        let json = r#"{
503            "csaf_mode": "2.1",
504            "theme": "dark",
505            "import_directory": "./data_import",
506            "export_directory": "./data_export",
507            "sidecar_sha256": true,
508            "sidecar_sha3_512": true,
509            "naming_convention": "ndaal-sa-"
510        }"#;
511        let settings: Settings = serde_json::from_str(json).expect("deserialize failed");
512        assert_eq!(settings.publisher_category, "vendor");
513        assert_eq!(settings.publisher_contact_details, "security@ndaal.eu");
514        // Dump directory must also receive its serde default.
515        assert_eq!(settings.dump_directory, "./data_dump");
516        // Log directory + sha512 toggle (0.3.0 additions) also default.
517        assert_eq!(settings.log_directory, "./data_log");
518        assert!(settings.sidecar_sha512);
519        // Classification fields must also get their defaults from the
520        // serde(default) helpers.
521        assert_eq!(settings.tlp_default, "AMBER");
522        assert!(settings.verschlusssache_enabled);
523        assert_eq!(
524            settings.verschlusssache_default,
525            "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"
526        );
527        assert!(settings.nato_enabled);
528        assert_eq!(settings.nato_default, "NR (NATO RESTRICTED)");
529        assert_eq!(settings.classification_storage_mode, "both");
530    }
531}