1use 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
23const CSAF_DOCUMENTS: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_documents");
29
30const CSAF_META: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_meta");
32
33const CSAF_DATE_INDEX: TableDefinition<&[u8], &str> = TableDefinition::new("csaf_date_index");
35
36const CSAF_CATEGORY_INDEX: MultimapTableDefinition<&str, &str> =
38 MultimapTableDefinition::new("csaf_category_index");
39
40const SETTINGS: TableDefinition<&str, &[u8]> = TableDefinition::new("settings");
42
43const PROVIDER_METADATA: TableDefinition<&str, &[u8]> = TableDefinition::new("provider_metadata");
45
46fn 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(×tamp_millis.to_be_bytes());
54 key.extend_from_slice(tracking_id.as_bytes());
55 key
56}
57
58#[derive(Clone)]
64pub struct CsafStorage {
65 db: Arc<Database>,
66}
67
68impl CsafStorage {
69 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 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 pub fn open_temp() -> Result<Self> {
101 let tmp = tempfile::NamedTempFile::new()?;
102 Self::open(tmp.path())
103 }
104
105 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 let read_txn = self.db.begin_read()?;
128 std::fs::copy(src, dst)?;
129 drop(read_txn);
130
131 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 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 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 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 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 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 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 pub fn count_documents(&self) -> Result<usize> {
281 let txn = self.db.begin_read()?;
282 let table = txn.open_table(CSAF_DOCUMENTS)?;
283 let count = usize::try_from(table.len()?).unwrap_or(usize::MAX);
287 Ok(count)
288 }
289
290 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 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 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 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 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 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 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"); 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}