Skip to main content

csaf_core/
storage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Embedded storage abstraction layer using redb.
5//!
6//! Provides the [`CsafStorage`] struct that wraps an embedded redb database
7//! for storing CSAF documents, metadata, settings, and provider metadata.
8
9use std::path::Path;
10use std::sync::Arc;
11
12use redb::{
13    Database, MultimapTableDefinition, ReadableTable, ReadableTableMetadata, TableDefinition,
14};
15use serde_json::Value;
16
17use csaf_models::csaf_document::{CsafDocument, CsafMeta};
18use csaf_models::provider_meta::ProviderMetadata;
19use csaf_models::settings::Settings;
20
21use crate::error::Result;
22
23// ---------------------------------------------------------------------------
24// Table definitions
25// ---------------------------------------------------------------------------
26
27/// CSAF document store: tracking_id -> JSON bytes.
28const CSAF_DOCUMENTS: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_documents");
29
30/// Document metadata for listing/search: tracking_id -> serialized CsafMeta.
31const CSAF_META: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_meta");
32
33/// Sorted index: big-endian timestamp bytes + tracking_id -> tracking_id.
34const CSAF_DATE_INDEX: TableDefinition<&[u8], &str> = TableDefinition::new("csaf_date_index");
35
36/// Category index: category -> tracking_ids (multimap).
37const CSAF_CATEGORY_INDEX: MultimapTableDefinition<&str, &str> =
38    MultimapTableDefinition::new("csaf_category_index");
39
40/// Application settings: key -> JSON bytes.
41const SETTINGS: TableDefinition<&str, &[u8]> = TableDefinition::new("settings");
42
43/// Provider metadata: "default" -> JSON bytes.
44const PROVIDER_METADATA: TableDefinition<&str, &[u8]> = TableDefinition::new("provider_metadata");
45
46// ---------------------------------------------------------------------------
47// Helper: composite index key
48// ---------------------------------------------------------------------------
49
50/// Build a composite key for sorted index: 8-byte big-endian timestamp + tracking_id.
51fn index_key(timestamp_millis: i64, tracking_id: &str) -> Vec<u8> {
52    let mut key = Vec::with_capacity(8 + tracking_id.len());
53    key.extend_from_slice(&timestamp_millis.to_be_bytes());
54    key.extend_from_slice(tracking_id.as_bytes());
55    key
56}
57
58// ---------------------------------------------------------------------------
59// CsafStorage
60// ---------------------------------------------------------------------------
61
62/// Embedded CSAF document storage backed by redb.
63#[derive(Clone)]
64pub struct CsafStorage {
65    db: Arc<Database>,
66}
67
68impl CsafStorage {
69    /// Open or create the redb database at the given path.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the database cannot be opened.
74    pub fn open(path: &Path) -> Result<Self> {
75        if let Some(parent) = path.parent() {
76            std::fs::create_dir_all(parent).ok();
77        }
78        let db = Database::create(path)?;
79
80        // Create tables on first use.
81        let txn = db.begin_write()?;
82        {
83            let _ = txn.open_table(CSAF_DOCUMENTS)?;
84            let _ = txn.open_table(CSAF_META)?;
85            let _ = txn.open_table(CSAF_DATE_INDEX)?;
86            let _ = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
87            let _ = txn.open_table(SETTINGS)?;
88            let _ = txn.open_table(PROVIDER_METADATA)?;
89        }
90        txn.commit()?;
91
92        Ok(Self { db: Arc::new(db) })
93    }
94
95    /// Open a temporary in-memory-like database (for tests).
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the temporary database cannot be created.
100    pub fn open_temp() -> Result<Self> {
101        let tmp = tempfile::NamedTempFile::new()?;
102        Self::open(tmp.path())
103    }
104
105    /// Copy the on-disk redb file at `src` to `dst` while holding a read
106    /// transaction on the live handle to pin an MVCC snapshot. This
107    /// avoids the "Database already open" lock collision that a
108    /// second `redb::Database::open` would trigger when the server is
109    /// running against the same file.
110    ///
111    /// The destination is then re-opened to verify the copy is a valid
112    /// redb file; the file is deleted and an error returned if the
113    /// verification fails.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the read txn can't be acquired, the file
118    /// can't be copied, or the copy fails integrity verification.
119    pub fn copy_file_with_snapshot(&self, src: &Path, dst: &Path) -> Result<()> {
120        if !src.exists() {
121            return Err(crate::error::CsafError::Storage(format!(
122                "redb source file missing: {}",
123                src.display()
124            )));
125        }
126        // Pin the snapshot on the LIVE handle (no second open).
127        let read_txn = self.db.begin_read()?;
128        std::fs::copy(src, dst)?;
129        drop(read_txn);
130
131        // Verify — reopening the freshly-written copy must succeed.
132        match redb::Database::open(dst) {
133            Ok(_verified) => Ok(()),
134            Err(e) => {
135                let _ = std::fs::remove_file(dst);
136                Err(crate::error::CsafError::Storage(format!(
137                    "redb dump verification failed: {e}"
138                )))
139            },
140        }
141    }
142
143    // -----------------------------------------------------------------------
144    // CSAF document CRUD
145    // -----------------------------------------------------------------------
146
147    /// Store a CSAF document (create or overwrite).
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if serialization or storage fails.
152    pub fn put_document(&self, doc: &CsafDocument) -> Result<()> {
153        let tracking_id = doc.tracking_id();
154        let json_bytes = serde_json::to_vec(doc)?;
155        let meta = CsafMeta::from_document(doc);
156        let meta_bytes = serde_json::to_vec(&meta)?;
157
158        let timestamp = chrono::Utc::now().timestamp_millis();
159        let idx_key = index_key(timestamp, tracking_id);
160
161        let txn = self.db.begin_write()?;
162        {
163            let mut docs_table = txn.open_table(CSAF_DOCUMENTS)?;
164            docs_table.insert(tracking_id, json_bytes.as_slice())?;
165
166            let mut meta_table = txn.open_table(CSAF_META)?;
167            meta_table.insert(tracking_id, meta_bytes.as_slice())?;
168
169            let mut date_index = txn.open_table(CSAF_DATE_INDEX)?;
170            date_index.insert(idx_key.as_slice(), tracking_id)?;
171
172            let mut cat_index = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
173            cat_index.insert(doc.category(), tracking_id)?;
174        }
175        txn.commit()?;
176
177        Ok(())
178    }
179
180    /// Retrieve a CSAF document by tracking ID.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the read fails.
185    pub fn get_document(&self, tracking_id: &str) -> Result<Option<CsafDocument>> {
186        let txn = self.db.begin_read()?;
187        let table = txn.open_table(CSAF_DOCUMENTS)?;
188
189        match table.get(tracking_id)? {
190            Some(value) => {
191                let doc: CsafDocument = serde_json::from_slice(value.value())?;
192                Ok(Some(doc))
193            },
194            None => Ok(None),
195        }
196    }
197
198    /// Retrieve a CSAF document as raw JSON value.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the read fails.
203    pub fn get_document_json(&self, tracking_id: &str) -> Result<Option<Value>> {
204        let txn = self.db.begin_read()?;
205        let table = txn.open_table(CSAF_DOCUMENTS)?;
206
207        match table.get(tracking_id)? {
208            Some(value) => {
209                let json: Value = serde_json::from_slice(value.value())?;
210                Ok(Some(json))
211            },
212            None => Ok(None),
213        }
214    }
215
216    /// Delete a CSAF document by tracking ID.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the delete fails.
221    pub fn delete_document(&self, tracking_id: &str) -> Result<bool> {
222        let txn = self.db.begin_write()?;
223        let existed;
224        {
225            let mut docs_table = txn.open_table(CSAF_DOCUMENTS)?;
226            existed = docs_table.remove(tracking_id)?.is_some();
227
228            let mut meta_table = txn.open_table(CSAF_META)?;
229            meta_table.remove(tracking_id)?;
230
231            // Remove from category index (all categories).
232            let mut cat_index = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
233            for cat in &[
234                "csaf_security_advisory",
235                "csaf_vex",
236                "csaf_informational_advisory",
237            ] {
238                cat_index.remove(cat, tracking_id)?;
239            }
240        }
241        txn.commit()?;
242
243        Ok(existed)
244    }
245
246    /// List all CSAF document metadata, ordered by tracking ID.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the read fails.
251    pub fn list_meta(&self, limit: usize, offset: usize) -> Result<Vec<CsafMeta>> {
252        let txn = self.db.begin_read()?;
253        let table = txn.open_table(CSAF_META)?;
254
255        let mut results = Vec::new();
256        let mut skipped = 0;
257
258        let iter = table.iter()?;
259        for entry in iter {
260            let (_key, value) = entry?;
261            if skipped < offset {
262                skipped += 1;
263                continue;
264            }
265            if results.len() >= limit {
266                break;
267            }
268            let meta: CsafMeta = serde_json::from_slice(value.value())?;
269            results.push(meta);
270        }
271
272        Ok(results)
273    }
274
275    /// Count total number of stored documents.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the read fails.
280    pub fn count_documents(&self) -> Result<usize> {
281        let txn = self.db.begin_read()?;
282        let table = txn.open_table(CSAF_DOCUMENTS)?;
283        // `table.len()` returns `u64`; on 32-bit targets it may be
284        // wider than `usize`. Saturate to the platform `usize::MAX`
285        // instead of silently truncating.
286        let count = usize::try_from(table.len()?).unwrap_or(usize::MAX);
287        Ok(count)
288    }
289
290    /// Check if a document exists.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the read fails.
295    pub fn document_exists(&self, tracking_id: &str) -> Result<bool> {
296        let txn = self.db.begin_read()?;
297        let table = txn.open_table(CSAF_DOCUMENTS)?;
298        Ok(table.get(tracking_id)?.is_some())
299    }
300
301    /// List tracking IDs for a given category.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the read fails.
306    pub fn list_by_category(&self, category: &str) -> Result<Vec<String>> {
307        let txn = self.db.begin_read()?;
308        let table = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
309
310        let mut ids = Vec::new();
311        if let Ok(iter) = table.get(category) {
312            for entry in iter {
313                let value = entry?;
314                ids.push(value.value().to_owned());
315            }
316        }
317
318        Ok(ids)
319    }
320
321    // -----------------------------------------------------------------------
322    // Settings
323    // -----------------------------------------------------------------------
324
325    /// Load application settings from storage.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the read fails.
330    pub fn get_settings(&self) -> Result<Settings> {
331        let txn = self.db.begin_read()?;
332        let table = txn.open_table(SETTINGS)?;
333
334        match table.get("settings")? {
335            Some(value) => {
336                let settings: Settings = serde_json::from_slice(value.value())?;
337                Ok(settings)
338            },
339            None => Ok(Settings::default()),
340        }
341    }
342
343    /// Save application settings to storage.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if serialization or storage fails.
348    pub fn put_settings(&self, settings: &Settings) -> Result<()> {
349        let bytes = serde_json::to_vec(settings)?;
350        let txn = self.db.begin_write()?;
351        {
352            let mut table = txn.open_table(SETTINGS)?;
353            table.insert("settings", bytes.as_slice())?;
354        }
355        txn.commit()?;
356        Ok(())
357    }
358
359    // -----------------------------------------------------------------------
360    // Provider metadata
361    // -----------------------------------------------------------------------
362
363    /// Load provider metadata from storage.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the read fails.
368    pub fn get_provider_metadata(&self) -> Result<Option<ProviderMetadata>> {
369        let txn = self.db.begin_read()?;
370        let table = txn.open_table(PROVIDER_METADATA)?;
371
372        match table.get("default")? {
373            Some(value) => {
374                let meta: ProviderMetadata = serde_json::from_slice(value.value())?;
375                Ok(Some(meta))
376            },
377            None => Ok(None),
378        }
379    }
380
381    /// Save provider metadata to storage.
382    ///
383    /// # Errors
384    ///
385    /// Returns an error if serialization or storage fails.
386    pub fn put_provider_metadata(&self, meta: &ProviderMetadata) -> Result<()> {
387        let bytes = serde_json::to_vec(meta)?;
388        let txn = self.db.begin_write()?;
389        {
390            let mut table = txn.open_table(PROVIDER_METADATA)?;
391            table.insert("default", bytes.as_slice())?;
392        }
393        txn.commit()?;
394        Ok(())
395    }
396
397    // -----------------------------------------------------------------------
398    // Health check
399    // -----------------------------------------------------------------------
400
401    /// Check if storage is operational.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error if the check fails.
406    pub fn check_storage_up(&self) -> Result<bool> {
407        let txn = self.db.begin_read()?;
408        let _ = txn.open_table(CSAF_DOCUMENTS)?;
409        Ok(true)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn test_doc() -> CsafDocument {
418        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
419        serde_json::from_str(json).expect("parse error")
420    }
421
422    #[test]
423    fn test_put_and_get_document() {
424        let storage = CsafStorage::open_temp().expect("open failed");
425        let doc = test_doc();
426
427        storage.put_document(&doc).expect("put failed");
428
429        let retrieved = storage
430            .get_document("ndaal-sa-2026-003")
431            .expect("get failed")
432            .expect("doc not found");
433
434        assert_eq!(retrieved.document.tracking.id, "ndaal-sa-2026-003");
435        assert_eq!(retrieved.document.category, "csaf_security_advisory");
436    }
437
438    #[test]
439    fn test_delete_document() {
440        let storage = CsafStorage::open_temp().expect("open failed");
441        let doc = test_doc();
442
443        storage.put_document(&doc).expect("put failed");
444        assert!(
445            storage
446                .document_exists("ndaal-sa-2026-003")
447                .expect("exists check failed")
448        );
449
450        let deleted = storage
451            .delete_document("ndaal-sa-2026-003")
452            .expect("delete failed");
453        assert!(deleted);
454
455        assert!(
456            !storage
457                .document_exists("ndaal-sa-2026-003")
458                .expect("exists check failed")
459        );
460    }
461
462    #[test]
463    fn test_delete_nonexistent() {
464        let storage = CsafStorage::open_temp().expect("open failed");
465        let deleted = storage
466            .delete_document("nonexistent")
467            .expect("delete failed");
468        assert!(!deleted);
469    }
470
471    #[test]
472    fn test_list_meta() {
473        let storage = CsafStorage::open_temp().expect("open failed");
474        let doc = test_doc();
475        storage.put_document(&doc).expect("put failed");
476
477        let meta_list = storage.list_meta(100, 0).expect("list failed");
478        assert_eq!(meta_list.len(), 1);
479        assert_eq!(meta_list[0].tracking_id, "ndaal-sa-2026-003");
480    }
481
482    #[test]
483    fn test_count_documents() {
484        let storage = CsafStorage::open_temp().expect("open failed");
485        assert_eq!(storage.count_documents().expect("count failed"), 0);
486
487        let doc = test_doc();
488        storage.put_document(&doc).expect("put failed");
489        assert_eq!(storage.count_documents().expect("count failed"), 1);
490    }
491
492    #[test]
493    fn test_list_by_category() {
494        let storage = CsafStorage::open_temp().expect("open failed");
495        let doc = test_doc();
496        storage.put_document(&doc).expect("put failed");
497
498        let ids = storage
499            .list_by_category("csaf_security_advisory")
500            .expect("list failed");
501        assert!(ids.contains(&"ndaal-sa-2026-003".to_owned()));
502
503        let empty = storage.list_by_category("csaf_vex").expect("list failed");
504        assert!(empty.is_empty());
505    }
506
507    #[test]
508    fn test_settings_roundtrip() {
509        let storage = CsafStorage::open_temp().expect("open failed");
510
511        let settings = storage.get_settings().expect("get failed");
512        assert_eq!(settings.csaf_mode, "2.1"); // default
513
514        let mut custom = settings;
515        custom.csaf_mode = "2.0".to_owned();
516        custom.theme = "dark".to_owned();
517        storage.put_settings(&custom).expect("put failed");
518
519        let loaded = storage.get_settings().expect("get failed");
520        assert_eq!(loaded.csaf_mode, "2.0");
521        assert_eq!(loaded.theme, "dark");
522    }
523
524    #[test]
525    fn test_provider_metadata_roundtrip() {
526        let storage = CsafStorage::open_temp().expect("open failed");
527
528        assert!(
529            storage
530                .get_provider_metadata()
531                .expect("get failed")
532                .is_none()
533        );
534
535        let json = include_str!("../../../test/csaf/provider-metadata.json");
536        let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");
537        storage.put_provider_metadata(&meta).expect("put failed");
538
539        let loaded = storage
540            .get_provider_metadata()
541            .expect("get failed")
542            .expect("meta not found");
543        assert_eq!(loaded.role, "csaf_publisher");
544    }
545
546    #[test]
547    fn test_health_check() {
548        let storage = CsafStorage::open_temp().expect("open failed");
549        assert!(storage.check_storage_up().expect("health check failed"));
550    }
551
552    #[test]
553    fn test_store_all_test_files() {
554        let storage = CsafStorage::open_temp().expect("open failed");
555        let test_dir =
556            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
557
558        let mut count = 0;
559        for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
560            let entry = entry.expect("dir entry error");
561            if !entry.file_type().expect("type error").is_dir() {
562                continue;
563            }
564            for file in std::fs::read_dir(entry.path()).expect("subdir error") {
565                let file = file.expect("file error");
566                let path = file.path();
567                if path.extension().is_some_and(|e| e == "json") {
568                    let content = std::fs::read_to_string(&path).expect("read error");
569                    let doc: CsafDocument = serde_json::from_str(&content).expect("parse error");
570                    storage.put_document(&doc).expect("put failed");
571                    count += 1;
572                }
573            }
574        }
575
576        assert!(count >= 15, "Expected at least 15 test files, got {count}");
577        assert_eq!(storage.count_documents().expect("count failed"), count);
578    }
579}