use std::path::Path;
use std::sync::Arc;
use redb::{
Database, MultimapTableDefinition, ReadableTable, ReadableTableMetadata, TableDefinition,
};
use serde_json::Value;
use csaf_models::csaf_document::{CsafDocument, CsafMeta};
use csaf_models::provider_meta::ProviderMetadata;
use csaf_models::settings::Settings;
use crate::error::Result;
const CSAF_DOCUMENTS: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_documents");
const CSAF_META: TableDefinition<&str, &[u8]> = TableDefinition::new("csaf_meta");
const CSAF_DATE_INDEX: TableDefinition<&[u8], &str> = TableDefinition::new("csaf_date_index");
const CSAF_CATEGORY_INDEX: MultimapTableDefinition<&str, &str> =
MultimapTableDefinition::new("csaf_category_index");
const SETTINGS: TableDefinition<&str, &[u8]> = TableDefinition::new("settings");
const PROVIDER_METADATA: TableDefinition<&str, &[u8]> = TableDefinition::new("provider_metadata");
fn index_key(timestamp_millis: i64, tracking_id: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(8 + tracking_id.len());
key.extend_from_slice(×tamp_millis.to_be_bytes());
key.extend_from_slice(tracking_id.as_bytes());
key
}
#[derive(Clone)]
pub struct CsafStorage {
db: Arc<Database>,
}
impl CsafStorage {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let db = Database::create(path)?;
let txn = db.begin_write()?;
{
let _ = txn.open_table(CSAF_DOCUMENTS)?;
let _ = txn.open_table(CSAF_META)?;
let _ = txn.open_table(CSAF_DATE_INDEX)?;
let _ = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
let _ = txn.open_table(SETTINGS)?;
let _ = txn.open_table(PROVIDER_METADATA)?;
}
txn.commit()?;
Ok(Self { db: Arc::new(db) })
}
pub fn open_temp() -> Result<Self> {
let tmp = tempfile::NamedTempFile::new()?;
Self::open(tmp.path())
}
pub fn copy_file_with_snapshot(&self, src: &Path, dst: &Path) -> Result<()> {
if !src.exists() {
return Err(crate::error::CsafError::Storage(format!(
"redb source file missing: {}",
src.display()
)));
}
let read_txn = self.db.begin_read()?;
std::fs::copy(src, dst)?;
drop(read_txn);
match redb::Database::open(dst) {
Ok(_verified) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(dst);
Err(crate::error::CsafError::Storage(format!(
"redb dump verification failed: {e}"
)))
},
}
}
pub fn put_document(&self, doc: &CsafDocument) -> Result<()> {
let tracking_id = doc.tracking_id();
let json_bytes = serde_json::to_vec(doc)?;
let meta = CsafMeta::from_document(doc);
let meta_bytes = serde_json::to_vec(&meta)?;
let timestamp = chrono::Utc::now().timestamp_millis();
let idx_key = index_key(timestamp, tracking_id);
let txn = self.db.begin_write()?;
{
let mut docs_table = txn.open_table(CSAF_DOCUMENTS)?;
docs_table.insert(tracking_id, json_bytes.as_slice())?;
let mut meta_table = txn.open_table(CSAF_META)?;
meta_table.insert(tracking_id, meta_bytes.as_slice())?;
let mut date_index = txn.open_table(CSAF_DATE_INDEX)?;
date_index.insert(idx_key.as_slice(), tracking_id)?;
let mut cat_index = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
cat_index.insert(doc.category(), tracking_id)?;
}
txn.commit()?;
Ok(())
}
pub fn get_document(&self, tracking_id: &str) -> Result<Option<CsafDocument>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(CSAF_DOCUMENTS)?;
match table.get(tracking_id)? {
Some(value) => {
let doc: CsafDocument = serde_json::from_slice(value.value())?;
Ok(Some(doc))
},
None => Ok(None),
}
}
pub fn get_document_json(&self, tracking_id: &str) -> Result<Option<Value>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(CSAF_DOCUMENTS)?;
match table.get(tracking_id)? {
Some(value) => {
let json: Value = serde_json::from_slice(value.value())?;
Ok(Some(json))
},
None => Ok(None),
}
}
pub fn delete_document(&self, tracking_id: &str) -> Result<bool> {
let txn = self.db.begin_write()?;
let existed;
{
let mut docs_table = txn.open_table(CSAF_DOCUMENTS)?;
existed = docs_table.remove(tracking_id)?.is_some();
let mut meta_table = txn.open_table(CSAF_META)?;
meta_table.remove(tracking_id)?;
let mut cat_index = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
for cat in &[
"csaf_security_advisory",
"csaf_vex",
"csaf_informational_advisory",
] {
cat_index.remove(cat, tracking_id)?;
}
}
txn.commit()?;
Ok(existed)
}
pub fn list_meta(&self, limit: usize, offset: usize) -> Result<Vec<CsafMeta>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(CSAF_META)?;
let mut results = Vec::new();
let mut skipped = 0;
let iter = table.iter()?;
for entry in iter {
let (_key, value) = entry?;
if skipped < offset {
skipped += 1;
continue;
}
if results.len() >= limit {
break;
}
let meta: CsafMeta = serde_json::from_slice(value.value())?;
results.push(meta);
}
Ok(results)
}
pub fn count_documents(&self) -> Result<usize> {
let txn = self.db.begin_read()?;
let table = txn.open_table(CSAF_DOCUMENTS)?;
let count = usize::try_from(table.len()?).unwrap_or(usize::MAX);
Ok(count)
}
pub fn document_exists(&self, tracking_id: &str) -> Result<bool> {
let txn = self.db.begin_read()?;
let table = txn.open_table(CSAF_DOCUMENTS)?;
Ok(table.get(tracking_id)?.is_some())
}
pub fn list_by_category(&self, category: &str) -> Result<Vec<String>> {
let txn = self.db.begin_read()?;
let table = txn.open_multimap_table(CSAF_CATEGORY_INDEX)?;
let mut ids = Vec::new();
if let Ok(iter) = table.get(category) {
for entry in iter {
let value = entry?;
ids.push(value.value().to_owned());
}
}
Ok(ids)
}
pub fn get_settings(&self) -> Result<Settings> {
let txn = self.db.begin_read()?;
let table = txn.open_table(SETTINGS)?;
match table.get("settings")? {
Some(value) => {
let settings: Settings = serde_json::from_slice(value.value())?;
Ok(settings)
},
None => Ok(Settings::default()),
}
}
pub fn put_settings(&self, settings: &Settings) -> Result<()> {
let bytes = serde_json::to_vec(settings)?;
let txn = self.db.begin_write()?;
{
let mut table = txn.open_table(SETTINGS)?;
table.insert("settings", bytes.as_slice())?;
}
txn.commit()?;
Ok(())
}
pub fn get_provider_metadata(&self) -> Result<Option<ProviderMetadata>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(PROVIDER_METADATA)?;
match table.get("default")? {
Some(value) => {
let meta: ProviderMetadata = serde_json::from_slice(value.value())?;
Ok(Some(meta))
},
None => Ok(None),
}
}
pub fn put_provider_metadata(&self, meta: &ProviderMetadata) -> Result<()> {
let bytes = serde_json::to_vec(meta)?;
let txn = self.db.begin_write()?;
{
let mut table = txn.open_table(PROVIDER_METADATA)?;
table.insert("default", bytes.as_slice())?;
}
txn.commit()?;
Ok(())
}
pub fn check_storage_up(&self) -> Result<bool> {
let txn = self.db.begin_read()?;
let _ = txn.open_table(CSAF_DOCUMENTS)?;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_doc() -> CsafDocument {
let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
serde_json::from_str(json).expect("parse error")
}
#[test]
fn test_put_and_get_document() {
let storage = CsafStorage::open_temp().expect("open failed");
let doc = test_doc();
storage.put_document(&doc).expect("put failed");
let retrieved = storage
.get_document("ndaal-sa-2026-003")
.expect("get failed")
.expect("doc not found");
assert_eq!(retrieved.document.tracking.id, "ndaal-sa-2026-003");
assert_eq!(retrieved.document.category, "csaf_security_advisory");
}
#[test]
fn test_delete_document() {
let storage = CsafStorage::open_temp().expect("open failed");
let doc = test_doc();
storage.put_document(&doc).expect("put failed");
assert!(
storage
.document_exists("ndaal-sa-2026-003")
.expect("exists check failed")
);
let deleted = storage
.delete_document("ndaal-sa-2026-003")
.expect("delete failed");
assert!(deleted);
assert!(
!storage
.document_exists("ndaal-sa-2026-003")
.expect("exists check failed")
);
}
#[test]
fn test_delete_nonexistent() {
let storage = CsafStorage::open_temp().expect("open failed");
let deleted = storage
.delete_document("nonexistent")
.expect("delete failed");
assert!(!deleted);
}
#[test]
fn test_list_meta() {
let storage = CsafStorage::open_temp().expect("open failed");
let doc = test_doc();
storage.put_document(&doc).expect("put failed");
let meta_list = storage.list_meta(100, 0).expect("list failed");
assert_eq!(meta_list.len(), 1);
assert_eq!(meta_list[0].tracking_id, "ndaal-sa-2026-003");
}
#[test]
fn test_count_documents() {
let storage = CsafStorage::open_temp().expect("open failed");
assert_eq!(storage.count_documents().expect("count failed"), 0);
let doc = test_doc();
storage.put_document(&doc).expect("put failed");
assert_eq!(storage.count_documents().expect("count failed"), 1);
}
#[test]
fn test_list_by_category() {
let storage = CsafStorage::open_temp().expect("open failed");
let doc = test_doc();
storage.put_document(&doc).expect("put failed");
let ids = storage
.list_by_category("csaf_security_advisory")
.expect("list failed");
assert!(ids.contains(&"ndaal-sa-2026-003".to_owned()));
let empty = storage.list_by_category("csaf_vex").expect("list failed");
assert!(empty.is_empty());
}
#[test]
fn test_settings_roundtrip() {
let storage = CsafStorage::open_temp().expect("open failed");
let settings = storage.get_settings().expect("get failed");
assert_eq!(settings.csaf_mode, "2.1");
let mut custom = settings;
custom.csaf_mode = "2.0".to_owned();
custom.theme = "dark".to_owned();
storage.put_settings(&custom).expect("put failed");
let loaded = storage.get_settings().expect("get failed");
assert_eq!(loaded.csaf_mode, "2.0");
assert_eq!(loaded.theme, "dark");
}
#[test]
fn test_provider_metadata_roundtrip() {
let storage = CsafStorage::open_temp().expect("open failed");
assert!(
storage
.get_provider_metadata()
.expect("get failed")
.is_none()
);
let json = include_str!("../../../test/csaf/provider-metadata.json");
let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");
storage.put_provider_metadata(&meta).expect("put failed");
let loaded = storage
.get_provider_metadata()
.expect("get failed")
.expect("meta not found");
assert_eq!(loaded.role, "csaf_publisher");
}
#[test]
fn test_health_check() {
let storage = CsafStorage::open_temp().expect("open failed");
assert!(storage.check_storage_up().expect("health check failed"));
}
#[test]
fn test_store_all_test_files() {
let storage = CsafStorage::open_temp().expect("open failed");
let test_dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
let mut count = 0;
for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
let entry = entry.expect("dir entry error");
if !entry.file_type().expect("type error").is_dir() {
continue;
}
for file in std::fs::read_dir(entry.path()).expect("subdir error") {
let file = file.expect("file error");
let path = file.path();
if path.extension().is_some_and(|e| e == "json") {
let content = std::fs::read_to_string(&path).expect("read error");
let doc: CsafDocument = serde_json::from_str(&content).expect("parse error");
storage.put_document(&doc).expect("put failed");
count += 1;
}
}
}
assert!(count >= 15, "Expected at least 15 test files, got {count}");
assert_eq!(storage.count_documents().expect("count failed"), count);
}
}