Skip to main content

csaf_core/
dump.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Full-database dump (snapshot) pipeline.
5//!
6//! Produces consistent, timestamped copies of the embedded redb and
7//! sqlite databases in the configured dump directory, together with
8//! SHA-256, SHA-512, and/or SHA3-512 hash sidecar files (extensions
9//! `.sha-256`, `.sha-512`, `.sha3-512` per `CLAUDE.md`).
10//!
11//! The pipeline has two write paths:
12//!
13//! - **sqlite**: uses the `rusqlite::backup::Backup` API so the copy
14//!   is consistent regardless of WAL / SHM state.
15//! - **redb**: redb is copy-on-write; holding an active read
16//!   transaction while copying the file is enough to pin a consistent
17//!   snapshot because writers allocate new pages instead of mutating
18//!   existing ones.
19//!
20//! Both dumps are verified after write (a fresh open + integrity
21//! check) before the function returns `Ok`.
22//!
23//! Hash sidecars are produced via
24//! [`crate::sidecar::write_sidecar_files_for`], which preserves the
25//! full file extension (`csaf.redb.sha-256`, not `csaf.sha-256`).
26
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use csaf_models::db::DbPool;
31use csaf_models::settings::Settings;
32use rusqlite::Connection;
33use rusqlite::backup::Backup;
34
35use crate::error::Result;
36use crate::sidecar::{SidecarHashes, write_sidecar_files_for};
37use crate::storage::CsafStorage;
38
39/// How many pages the sqlite backup copies per step. A larger number
40/// means faster copies but a longer write-lock window; 1024 is the
41/// value recommended by the `rusqlite` docs for online backups.
42const SQLITE_BACKUP_PAGES_PER_STEP: std::os::raw::c_int = 1024;
43
44/// Result of a successful database dump.
45#[derive(Debug, Clone)]
46pub struct DumpResult {
47    /// ISO 8601 UTC timestamp (no colons, e.g. `20260417T120000Z`)
48    /// that was baked into every dumped file name.
49    pub timestamp: String,
50    /// Absolute path to the dumped redb file.
51    pub redb_path: PathBuf,
52    /// Size (bytes) of the dumped redb file.
53    pub redb_bytes: u64,
54    /// Absolute path to the dumped sqlite file.
55    pub sqlite_path: PathBuf,
56    /// Size (bytes) of the dumped sqlite file.
57    pub sqlite_bytes: u64,
58    /// Sidecar paths that were written (may be empty if both sidecar
59    /// settings are disabled).
60    pub sidecars: Vec<PathBuf>,
61}
62
63/// Take a consistent snapshot of the live redb + sqlite databases
64/// into `dump_dir` and emit hash sidecars based on the settings.
65///
66/// # Arguments
67///
68/// - `data_dir` — where the live `csaf.redb` / `csaf.sqlite` files
69///   live (the same `data_dir` configured in [`csaf-core::config::AppConfig`]).
70/// - `dump_dir` — target directory. Created if missing.
71/// - `pool` — live sqlite pool (used for the backup-API snapshot).
72/// - `settings` — read for the `sidecar_sha256` / `sidecar_sha512` /
73///   `sidecar_sha3_512` toggles. No other fields are consulted.
74///
75/// # Errors
76///
77/// - `CsafError::Io` if the target directory can't be created, or
78///   the on-disk redb file can't be copied.
79/// - `CsafError::Storage` if redb refuses to open the source or the
80///   freshly-written copy.
81/// - `CsafError::Database` on any sqlite backup error.
82pub fn dump_database(
83    data_dir: impl AsRef<Path>,
84    dump_dir: impl AsRef<Path>,
85    storage: &CsafStorage,
86    pool: &DbPool,
87    settings: &Settings,
88) -> Result<DumpResult> {
89    let data_dir = data_dir.as_ref();
90    let dump_dir = dump_dir.as_ref();
91
92    // Capability handle for the dump directory: creates it if missing and
93    // confines every sidecar write, read-back, and size probe to it.
94    let dump_handle = crate::fs::DataDir::open_or_create(dump_dir)?;
95
96    let timestamp = filename_safe_timestamp();
97
98    // --- redb -----------------------------------------------------------
99    let redb_src = data_dir.join("csaf.redb");
100    let redb_rel = format!("csaf.redb.{timestamp}");
101    let redb_dst = dump_dir.join(&redb_rel);
102    // The copy itself goes through redb's own file handling (it needs a
103    // real OS path; a cap-std fd cannot back `redb::Database`). Reuse the
104    // live redb handle to pin a read transaction — opening a second
105    // `redb::Database::open` against the same file would collide with the
106    // running server ("Database already open. Cannot acquire lock.").
107    storage.copy_file_with_snapshot(&redb_src, &redb_dst)?;
108    let redb_bytes_on_disk = dump_handle.file_len(&redb_rel)?;
109
110    // --- sqlite ---------------------------------------------------------
111    // rusqlite's backup API likewise needs a real OS path for the target.
112    let sqlite_rel = format!("csaf.sqlite.{timestamp}");
113    let sqlite_dst = dump_dir.join(&sqlite_rel);
114    backup_sqlite(pool, &sqlite_dst)?;
115    let sqlite_bytes_on_disk = dump_handle.file_len(&sqlite_rel)?;
116
117    // --- sidecars (confined to the dump directory) ----------------------
118    let hashes = SidecarHashes::from_settings(settings);
119    let mut sidecars: Vec<PathBuf> = Vec::new();
120
121    let redb_bytes = dump_handle.read(&redb_rel)?;
122    for rel in write_sidecar_files_for(&dump_handle, &redb_rel, &redb_bytes, hashes)? {
123        sidecars.push(dump_handle.resolve(&rel));
124    }
125    drop(redb_bytes);
126
127    let sqlite_bytes = dump_handle.read(&sqlite_rel)?;
128    for rel in write_sidecar_files_for(&dump_handle, &sqlite_rel, &sqlite_bytes, hashes)? {
129        sidecars.push(dump_handle.resolve(&rel));
130    }
131    drop(sqlite_bytes);
132
133    Ok(DumpResult {
134        timestamp,
135        redb_path: redb_dst,
136        redb_bytes: redb_bytes_on_disk,
137        sqlite_path: sqlite_dst,
138        sqlite_bytes: sqlite_bytes_on_disk,
139        sidecars,
140    })
141}
142
143/// Copy the live sqlite database into `dst` using the rusqlite online
144/// backup API. This produces a consistent snapshot even while writes
145/// are in flight.
146fn backup_sqlite(pool: &DbPool, dst: &Path) -> Result<()> {
147    pool.with_conn(|src_conn| {
148        let mut dst_conn = Connection::open(dst)?;
149        let backup = Backup::new(src_conn, &mut dst_conn)?;
150        backup.run_to_completion(SQLITE_BACKUP_PAGES_PER_STEP, Duration::ZERO, None)?;
151        Ok(())
152    })?;
153    Ok(())
154}
155
156/// ISO 8601 UTC timestamp with no characters that are invalid in
157/// filenames on common platforms (no colons, no slashes).
158fn filename_safe_timestamp() -> String {
159    use chrono::Utc;
160    Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::error::CsafError;
167    use crate::storage::CsafStorage;
168    use csaf_models::settings::Settings;
169    use tempfile::tempdir;
170
171    fn seeded_data_dir() -> (tempfile::TempDir, CsafStorage, DbPool) {
172        let dir = tempdir().expect("tmpdir");
173        // Create both live DB files using the real open paths.
174        let redb_path = dir.path().join("csaf.redb");
175        let sqlite_path = dir.path().join("csaf.sqlite");
176        let storage = CsafStorage::open(&redb_path).expect("open redb");
177        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
178        (dir, storage, pool)
179    }
180
181    #[test]
182    fn test_dump_database_happy_path_writes_all_files() {
183        let (dir, storage, pool) = seeded_data_dir();
184        let dump_dir = dir.path().join("dumps");
185
186        let settings = Settings::default(); // all three sidecars ON
187        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
188            .expect("dump_database ok");
189
190        assert!(res.redb_path.exists(), "redb dump missing");
191        assert!(res.sqlite_path.exists(), "sqlite dump missing");
192        assert!(res.redb_bytes > 0);
193        assert!(res.sqlite_bytes > 0);
194        assert!(!res.timestamp.is_empty());
195
196        // Sidecar count: 3 per file (sha-256 + sha-512 + sha3-512) * 2 files = 6.
197        assert_eq!(res.sidecars.len(), 6);
198        for side in &res.sidecars {
199            assert!(side.exists(), "sidecar not on disk: {}", side.display());
200            let contents = std::fs::read_to_string(side).expect("read sidecar");
201            assert!(contents.contains("  "), "GNU format requires 2 spaces");
202            let name = side.file_name().unwrap().to_string_lossy();
203            assert!(
204                name.ends_with(".sha-256")
205                    || name.ends_with(".sha-512")
206                    || name.ends_with(".sha3-512"),
207                "unexpected sidecar extension: {name}"
208            );
209            // Regression guard for the 0.3.0 rename.
210            assert!(!name.ends_with(".sha256"), "legacy form leaked: {name}");
211            assert!(!name.ends_with(".sha512"), "legacy form leaked: {name}");
212        }
213    }
214
215    #[test]
216    fn test_dump_database_respects_sidecar_toggles() {
217        let (dir, storage, pool) = seeded_data_dir();
218        let dump_dir = dir.path().join("dumps");
219
220        let settings = Settings {
221            sidecar_sha256: true,
222            sidecar_sha512: false,
223            sidecar_sha3_512: false,
224            ..Settings::default()
225        };
226        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
227            .expect("dump_database ok");
228
229        // 2 files × 1 sidecar each = 2.
230        assert_eq!(res.sidecars.len(), 2);
231        for side in &res.sidecars {
232            let name = side.file_name().unwrap().to_string_lossy();
233            assert!(name.ends_with(".sha-256"), "unexpected sidecar: {name}");
234        }
235    }
236
237    #[test]
238    fn test_dump_database_no_sidecars_when_all_disabled() {
239        let (dir, storage, pool) = seeded_data_dir();
240        let dump_dir = dir.path().join("dumps");
241
242        let settings = Settings {
243            sidecar_sha256: false,
244            sidecar_sha512: false,
245            sidecar_sha3_512: false,
246            ..Settings::default()
247        };
248        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
249            .expect("dump_database ok");
250        assert!(res.sidecars.is_empty());
251    }
252
253    #[test]
254    fn test_dump_database_creates_dump_dir() {
255        let (dir, storage, pool) = seeded_data_dir();
256        let dump_dir = dir.path().join("nested/does/not/exist");
257        assert!(!dump_dir.exists());
258
259        let settings = Settings::default();
260        dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump ok");
261        assert!(dump_dir.exists());
262    }
263
264    #[test]
265    fn test_dump_database_missing_redb_source_returns_err() {
266        // Seed only sqlite; open a redb in a DIFFERENT directory so the
267        // `data_dir` passed to `dump_database` has no `csaf.redb`.
268        let dir = tempdir().expect("tmpdir");
269        let other = tempdir().expect("tmpdir2");
270        let storage = CsafStorage::open(&other.path().join("csaf.redb")).expect("open redb");
271        let sqlite_path = dir.path().join("csaf.sqlite");
272        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
273
274        let dump_dir = dir.path().join("dumps");
275        let err = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
276            .expect_err("should error with missing source");
277        match err {
278            CsafError::Storage(msg) => {
279                assert!(msg.contains("redb source file missing"), "got: {msg}");
280            },
281            other => panic!("wrong error variant: {other:?}"),
282        }
283    }
284
285    #[test]
286    fn test_filename_safe_timestamp_format() {
287        let ts = filename_safe_timestamp();
288        // Must be 16 chars: 8 digits date + T + 6 digits time + Z.
289        assert_eq!(ts.len(), 16, "got: {ts}");
290        assert!(ts.ends_with('Z'));
291        assert!(!ts.contains(':'), "colons break Windows filenames");
292        assert!(!ts.contains('/'));
293    }
294
295    #[test]
296    fn test_dump_redb_file_is_openable() {
297        let (dir, storage, pool) = seeded_data_dir();
298        let dump_dir = dir.path().join("dumps");
299        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
300            .expect("dump ok");
301
302        // The dumped redb file must open cleanly.
303        let reopen = redb::Database::open(&res.redb_path).expect("open dumped redb");
304        let _ = reopen.begin_read().expect("begin_read on dump");
305    }
306
307    #[test]
308    fn test_dump_sqlite_file_is_openable() {
309        let (dir, storage, pool) = seeded_data_dir();
310        let dump_dir = dir.path().join("dumps");
311        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
312            .expect("dump ok");
313
314        let reopen = Connection::open(&res.sqlite_path).expect("open dumped sqlite");
315        let schema_count: i64 = reopen
316            .query_row(
317                "SELECT count(*) FROM sqlite_master WHERE type='table'",
318                [],
319                |r| r.get(0),
320            )
321            .expect("query count");
322        assert!(schema_count > 0, "sqlite dump has no tables");
323    }
324}