use std::path::{Path, PathBuf};
use std::time::Duration;
use csaf_models::db::DbPool;
use csaf_models::settings::Settings;
use rusqlite::Connection;
use rusqlite::backup::Backup;
use crate::error::Result;
use crate::sidecar::write_sidecar_files_for;
use crate::storage::CsafStorage;
const SQLITE_BACKUP_PAGES_PER_STEP: std::os::raw::c_int = 1024;
#[derive(Debug, Clone)]
pub struct DumpResult {
pub timestamp: String,
pub redb_path: PathBuf,
pub redb_bytes: u64,
pub sqlite_path: PathBuf,
pub sqlite_bytes: u64,
pub sidecars: Vec<PathBuf>,
}
pub fn dump_database(
data_dir: &Path,
dump_dir: &Path,
storage: &CsafStorage,
pool: &DbPool,
settings: &Settings,
) -> Result<DumpResult> {
std::fs::create_dir_all(dump_dir)?;
let timestamp = filename_safe_timestamp();
let redb_src = data_dir.join("csaf.redb");
let redb_dst = dump_dir.join(format!("csaf.redb.{timestamp}"));
storage.copy_file_with_snapshot(&redb_src, &redb_dst)?;
let redb_bytes_on_disk = std::fs::metadata(&redb_dst)?.len();
let sqlite_dst = dump_dir.join(format!("csaf.sqlite.{timestamp}"));
backup_sqlite(pool, &sqlite_dst)?;
let sqlite_bytes_on_disk = std::fs::metadata(&sqlite_dst)?.len();
let mut sidecars: Vec<PathBuf> = Vec::new();
let redb_bytes = std::fs::read(&redb_dst)?;
let (redb_s256, redb_s3) = write_sidecar_files_for(
&redb_dst,
&redb_bytes,
settings.sidecar_sha256,
settings.sidecar_sha3_512,
)?;
if let Some(p) = redb_s256 {
sidecars.push(p);
}
if let Some(p) = redb_s3 {
sidecars.push(p);
}
drop(redb_bytes);
let sqlite_bytes = std::fs::read(&sqlite_dst)?;
let (sql_s256, sql_s3) = write_sidecar_files_for(
&sqlite_dst,
&sqlite_bytes,
settings.sidecar_sha256,
settings.sidecar_sha3_512,
)?;
if let Some(p) = sql_s256 {
sidecars.push(p);
}
if let Some(p) = sql_s3 {
sidecars.push(p);
}
drop(sqlite_bytes);
Ok(DumpResult {
timestamp,
redb_path: redb_dst,
redb_bytes: redb_bytes_on_disk,
sqlite_path: sqlite_dst,
sqlite_bytes: sqlite_bytes_on_disk,
sidecars,
})
}
fn backup_sqlite(pool: &DbPool, dst: &Path) -> Result<()> {
pool.with_conn(|src_conn| {
let mut dst_conn = Connection::open(dst)?;
let backup = Backup::new(src_conn, &mut dst_conn)?;
backup.run_to_completion(SQLITE_BACKUP_PAGES_PER_STEP, Duration::ZERO, None)?;
Ok(())
})?;
Ok(())
}
fn filename_safe_timestamp() -> String {
use chrono::Utc;
Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::CsafError;
use crate::storage::CsafStorage;
use csaf_models::settings::Settings;
use tempfile::tempdir;
fn seeded_data_dir() -> (tempfile::TempDir, CsafStorage, DbPool) {
let dir = tempdir().expect("tmpdir");
let redb_path = dir.path().join("csaf.redb");
let sqlite_path = dir.path().join("csaf.sqlite");
let storage = CsafStorage::open(&redb_path).expect("open redb");
let pool = DbPool::open(&sqlite_path).expect("open sqlite");
(dir, storage, pool)
}
#[test]
fn test_dump_database_happy_path_writes_all_files() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("dumps");
let settings = Settings::default(); let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
assert!(res.redb_path.exists(), "redb dump missing");
assert!(res.sqlite_path.exists(), "sqlite dump missing");
assert!(res.redb_bytes > 0);
assert!(res.sqlite_bytes > 0);
assert!(!res.timestamp.is_empty());
assert_eq!(res.sidecars.len(), 4);
for side in &res.sidecars {
assert!(side.exists(), "sidecar not on disk: {}", side.display());
let contents = std::fs::read_to_string(side).expect("read sidecar");
assert!(contents.contains(" "), "GNU format requires 2 spaces");
}
}
#[test]
fn test_dump_database_respects_sidecar_toggles() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("dumps");
let settings = Settings {
sidecar_sha256: true,
sidecar_sha3_512: false,
..Settings::default()
};
let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
assert_eq!(res.sidecars.len(), 2);
for side in &res.sidecars {
let name = side.file_name().unwrap().to_string_lossy();
assert!(name.ends_with(".sha256"), "unexpected sidecar: {name}");
}
}
#[test]
fn test_dump_database_no_sidecars_when_both_disabled() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("dumps");
let settings = Settings {
sidecar_sha256: false,
sidecar_sha3_512: false,
..Settings::default()
};
let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
assert!(res.sidecars.is_empty());
}
#[test]
fn test_dump_database_creates_dump_dir() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("nested/does/not/exist");
assert!(!dump_dir.exists());
let settings = Settings::default();
dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump ok");
assert!(dump_dir.exists());
}
#[test]
fn test_dump_database_missing_redb_source_returns_err() {
let dir = tempdir().expect("tmpdir");
let other = tempdir().expect("tmpdir2");
let storage = CsafStorage::open(&other.path().join("csaf.redb")).expect("open redb");
let sqlite_path = dir.path().join("csaf.sqlite");
let pool = DbPool::open(&sqlite_path).expect("open sqlite");
let dump_dir = dir.path().join("dumps");
let err = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
.expect_err("should error with missing source");
match err {
CsafError::Storage(msg) => {
assert!(msg.contains("redb source file missing"), "got: {msg}");
},
other => panic!("wrong error variant: {other:?}"),
}
}
#[test]
fn test_filename_safe_timestamp_format() {
let ts = filename_safe_timestamp();
assert_eq!(ts.len(), 16, "got: {ts}");
assert!(ts.ends_with('Z'));
assert!(!ts.contains(':'), "colons break Windows filenames");
assert!(!ts.contains('/'));
}
#[test]
fn test_dump_redb_file_is_openable() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("dumps");
let res =
dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default()).expect("dump ok");
let reopen = redb::Database::open(&res.redb_path).expect("open dumped redb");
let _ = reopen.begin_read().expect("begin_read on dump");
}
#[test]
fn test_dump_sqlite_file_is_openable() {
let (dir, storage, pool) = seeded_data_dir();
let dump_dir = dir.path().join("dumps");
let res =
dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default()).expect("dump ok");
let reopen = Connection::open(&res.sqlite_path).expect("open dumped sqlite");
let schema_count: i64 = reopen
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table'",
[],
|r| r.get(0),
)
.expect("query count");
assert!(schema_count > 0, "sqlite dump has no tables");
}
}