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