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    /// Whether to generate SHA-256 sidecar files.
33    #[serde(default = "default_true")]
34    pub sidecar_sha256: bool,
35
36    /// Whether to generate SHA3-512 sidecar files.
37    #[serde(default = "default_true")]
38    pub sidecar_sha3_512: bool,
39
40    /// Naming convention prefix for CSAF files.
41    #[serde(default = "default_naming_convention")]
42    pub naming_convention: String,
43
44    // -----------------------------------------------------------------------
45    // Publisher defaults used when creating new CSAF documents.
46    // -----------------------------------------------------------------------
47    /// Default publisher name injected into new CSAF documents.
48    #[serde(default = "default_publisher_name")]
49    pub publisher_name: String,
50
51    /// Default publisher namespace URI.
52    #[serde(default = "default_publisher_namespace")]
53    pub publisher_namespace: String,
54
55    /// Default publisher category (`vendor`, `discoverer`, `coordinator`,
56    /// `user`, `translator`, `other`).
57    #[serde(default = "default_publisher_category")]
58    pub publisher_category: String,
59
60    /// Default publisher contact details (email or URL).
61    #[serde(default = "default_publisher_contact")]
62    pub publisher_contact_details: String,
63
64    // -----------------------------------------------------------------------
65    // Classification defaults -- TLP 2.0, German Verschlusssache, NATO.
66    //
67    // These drive the colored TLP dropdown and the Verschlusssache/NATO
68    // selects on the CSAF create/edit form.
69    // -----------------------------------------------------------------------
70    /// Default TLP 2.0 label (one of [`TLP_LABELS`]).
71    #[serde(default = "default_tlp")]
72    pub tlp_default: String,
73
74    /// Whether the Verschlusssache (German national classification) field
75    /// is exposed on the CSAF form.
76    #[serde(default = "default_true")]
77    pub verschlusssache_enabled: bool,
78
79    /// Default Verschlusssache label (one of [`VERSCHLUSSSACHE_LABELS`]).
80    #[serde(default = "default_verschlusssache")]
81    pub verschlusssache_default: String,
82
83    /// Whether the NATO classification field is exposed on the CSAF form.
84    #[serde(default = "default_true")]
85    pub nato_enabled: bool,
86
87    /// Default NATO classification label (one of [`NATO_LABELS`]).
88    #[serde(default = "default_nato")]
89    pub nato_default: String,
90
91    /// Where to write Verschlusssache / NATO values in the exported CSAF
92    /// document. One of [`CLASSIFICATION_STORAGE_MODES`]:
93    /// `"distribution_text"`, `"notes"`, or `"both"`.
94    #[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
123/// Allowed values for the publisher category dropdown.
124pub const PUBLISHER_CATEGORIES: &[&str] = &[
125    "vendor",
126    "discoverer",
127    "coordinator",
128    "user",
129    "translator",
130    "other",
131];
132
133/// Check whether a candidate string is an allowed publisher category.
134#[must_use]
135pub fn is_valid_publisher_category(value: &str) -> bool {
136    PUBLISHER_CATEGORIES.contains(&value)
137}
138
139/// Allowed TLP 2.0 labels (`https://www.first.org/tlp/`).
140pub const TLP_LABELS: &[&str] = &["CLEAR", "GREEN", "AMBER", "AMBER+STRICT", "RED"];
141
142/// Allowed Verschlusssache (German national classification) labels
143/// according to <https://de.wikipedia.org/wiki/Verschlusssache#Einstufung>.
144pub 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
151/// Allowed NATO classification labels according to
152/// <https://de.wikipedia.org/wiki/Geheimhaltungsgrad#NATO>.
153pub const NATO_LABELS: &[&str] = &[
154    "NR (NATO RESTRICTED)",
155    "NC (NATO CONFIDENTIAL)",
156    "NS (NATO SECRET)",
157    "CTS (COSMIC TOP SECRET)",
158];
159
160/// Where to write Verschlusssache / NATO values in the exported CSAF JSON.
161///
162/// - `"distribution_text"`: append to `document.distribution.text` as
163///   `"Verschlusssache: <value> | NATO: <value>"`.
164/// - `"notes"`: add one `document.notes[]` entry per field with the
165///   well-known titles `"Verschlusssache"` and `"NATO Classification"`.
166/// - `"both"`: write to both locations so any CSAF consumer can find it.
167pub const CLASSIFICATION_STORAGE_MODES: &[&str] = &["distribution_text", "notes", "both"];
168
169/// Check whether a candidate string is an allowed TLP 2.0 label.
170#[must_use]
171pub fn is_valid_tlp(value: &str) -> bool {
172    TLP_LABELS.contains(&value)
173}
174
175/// Check whether a candidate string is an allowed Verschlusssache label.
176#[must_use]
177pub fn is_valid_verschlusssache(value: &str) -> bool {
178    VERSCHLUSSSACHE_LABELS.contains(&value)
179}
180
181/// Check whether a candidate string is an allowed NATO classification
182/// label.
183#[must_use]
184pub fn is_valid_nato(value: &str) -> bool {
185    NATO_LABELS.contains(&value)
186}
187
188/// Check whether a candidate string is an allowed classification storage
189/// mode.
190#[must_use]
191pub fn is_valid_classification_storage_mode(value: &str) -> bool {
192    CLASSIFICATION_STORAGE_MODES.contains(&value)
193}
194
195/// Maximum accepted length (bytes) of a user-supplied storage path.
196pub const STORAGE_PATH_MAX_LEN: usize = 4096;
197
198/// Check whether a candidate string is an acceptable
199/// filesystem-path setting (`import_directory`, `export_directory`,
200/// `dump_directory`).
201///
202/// Rejects:
203/// - empty / whitespace-only strings,
204/// - strings longer than [`STORAGE_PATH_MAX_LEN`] bytes,
205/// - strings containing a NUL byte (`\0`),
206/// - strings containing a `..` path component (path-traversal guard),
207/// - strings that start with `~` (no shell-style home expansion).
208///
209/// The check is purely syntactic — it does not touch the filesystem.
210#[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    // Reject any `..` segment, anywhere. Uses Path to avoid missing
223    // encodings like `a/..` vs `..\\b` on different platforms.
224    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// Dense assertion blocks are acceptable in tests — clippy's
294// cognitive-complexity threshold is tuned for production code paths.
295#[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        // Defaults applied for missing fields, including the publisher defaults.
343        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        // Every (TLP, VS, NATO, mode) quadruple must round-trip through
443        // JSON without drift.
444        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        // JSON written by a previous version without the publisher fields
468        // must still deserialise successfully via the `default` helpers.
469        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        // Dump directory must also receive its serde default.
482        assert_eq!(settings.dump_directory, "./data_dump");
483        // Classification fields must also get their defaults from the
484        // serde(default) helpers.
485        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}