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::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: &Path,
84    dump_dir: &Path,
85    storage: &CsafStorage,
86    pool: &DbPool,
87    settings: &Settings,
88) -> Result<DumpResult> {
89    std::fs::create_dir_all(dump_dir)?;
90
91    let timestamp = filename_safe_timestamp();
92
93    // --- redb -----------------------------------------------------------
94    let redb_src = data_dir.join("csaf.redb");
95    let redb_dst = dump_dir.join(format!("csaf.redb.{timestamp}"));
96    // Reuse the live redb handle to pin a read transaction. Opening a
97    // second `redb::Database::open` against the same file would collide
98    // with the running server ("Database already open. Cannot acquire
99    // lock.").
100    storage.copy_file_with_snapshot(&redb_src, &redb_dst)?;
101    let redb_bytes_on_disk = std::fs::metadata(&redb_dst)?.len();
102
103    // --- sqlite ---------------------------------------------------------
104    let sqlite_dst = dump_dir.join(format!("csaf.sqlite.{timestamp}"));
105    backup_sqlite(pool, &sqlite_dst)?;
106    let sqlite_bytes_on_disk = std::fs::metadata(&sqlite_dst)?.len();
107
108    // --- sidecars -------------------------------------------------------
109    let mut sidecars: Vec<PathBuf> = Vec::new();
110
111    let redb_bytes = std::fs::read(&redb_dst)?;
112    let (redb_s256, redb_s512, redb_s3) = write_sidecar_files_for(
113        &redb_dst,
114        &redb_bytes,
115        settings.sidecar_sha256,
116        settings.sidecar_sha512,
117        settings.sidecar_sha3_512,
118    )?;
119    if let Some(p) = redb_s256 {
120        sidecars.push(p);
121    }
122    if let Some(p) = redb_s512 {
123        sidecars.push(p);
124    }
125    if let Some(p) = redb_s3 {
126        sidecars.push(p);
127    }
128    drop(redb_bytes);
129
130    let sqlite_bytes = std::fs::read(&sqlite_dst)?;
131    let (sql_s256, sql_s512, sql_s3) = write_sidecar_files_for(
132        &sqlite_dst,
133        &sqlite_bytes,
134        settings.sidecar_sha256,
135        settings.sidecar_sha512,
136        settings.sidecar_sha3_512,
137    )?;
138    if let Some(p) = sql_s256 {
139        sidecars.push(p);
140    }
141    if let Some(p) = sql_s512 {
142        sidecars.push(p);
143    }
144    if let Some(p) = sql_s3 {
145        sidecars.push(p);
146    }
147    drop(sqlite_bytes);
148
149    Ok(DumpResult {
150        timestamp,
151        redb_path: redb_dst,
152        redb_bytes: redb_bytes_on_disk,
153        sqlite_path: sqlite_dst,
154        sqlite_bytes: sqlite_bytes_on_disk,
155        sidecars,
156    })
157}
158
159/// Copy the live sqlite database into `dst` using the rusqlite online
160/// backup API. This produces a consistent snapshot even while writes
161/// are in flight.
162fn backup_sqlite(pool: &DbPool, dst: &Path) -> Result<()> {
163    pool.with_conn(|src_conn| {
164        let mut dst_conn = Connection::open(dst)?;
165        let backup = Backup::new(src_conn, &mut dst_conn)?;
166        backup.run_to_completion(SQLITE_BACKUP_PAGES_PER_STEP, Duration::ZERO, None)?;
167        Ok(())
168    })?;
169    Ok(())
170}
171
172/// ISO 8601 UTC timestamp with no characters that are invalid in
173/// filenames on common platforms (no colons, no slashes).
174fn filename_safe_timestamp() -> String {
175    use chrono::Utc;
176    Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::error::CsafError;
183    use crate::storage::CsafStorage;
184    use csaf_models::settings::Settings;
185    use tempfile::tempdir;
186
187    fn seeded_data_dir() -> (tempfile::TempDir, CsafStorage, DbPool) {
188        let dir = tempdir().expect("tmpdir");
189        // Create both live DB files using the real open paths.
190        let redb_path = dir.path().join("csaf.redb");
191        let sqlite_path = dir.path().join("csaf.sqlite");
192        let storage = CsafStorage::open(&redb_path).expect("open redb");
193        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
194        (dir, storage, pool)
195    }
196
197    #[test]
198    fn test_dump_database_happy_path_writes_all_files() {
199        let (dir, storage, pool) = seeded_data_dir();
200        let dump_dir = dir.path().join("dumps");
201
202        let settings = Settings::default(); // all three sidecars ON
203        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
204            .expect("dump_database ok");
205
206        assert!(res.redb_path.exists(), "redb dump missing");
207        assert!(res.sqlite_path.exists(), "sqlite dump missing");
208        assert!(res.redb_bytes > 0);
209        assert!(res.sqlite_bytes > 0);
210        assert!(!res.timestamp.is_empty());
211
212        // Sidecar count: 3 per file (sha-256 + sha-512 + sha3-512) * 2 files = 6.
213        assert_eq!(res.sidecars.len(), 6);
214        for side in &res.sidecars {
215            assert!(side.exists(), "sidecar not on disk: {}", side.display());
216            let contents = std::fs::read_to_string(side).expect("read sidecar");
217            assert!(contents.contains("  "), "GNU format requires 2 spaces");
218            let name = side.file_name().unwrap().to_string_lossy();
219            assert!(
220                name.ends_with(".sha-256")
221                    || name.ends_with(".sha-512")
222                    || name.ends_with(".sha3-512"),
223                "unexpected sidecar extension: {name}"
224            );
225            // Regression guard for the 0.3.0 rename.
226            assert!(!name.ends_with(".sha256"), "legacy form leaked: {name}");
227            assert!(!name.ends_with(".sha512"), "legacy form leaked: {name}");
228        }
229    }
230
231    #[test]
232    fn test_dump_database_respects_sidecar_toggles() {
233        let (dir, storage, pool) = seeded_data_dir();
234        let dump_dir = dir.path().join("dumps");
235
236        let settings = Settings {
237            sidecar_sha256: true,
238            sidecar_sha512: false,
239            sidecar_sha3_512: false,
240            ..Settings::default()
241        };
242        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
243            .expect("dump_database ok");
244
245        // 2 files × 1 sidecar each = 2.
246        assert_eq!(res.sidecars.len(), 2);
247        for side in &res.sidecars {
248            let name = side.file_name().unwrap().to_string_lossy();
249            assert!(name.ends_with(".sha-256"), "unexpected sidecar: {name}");
250        }
251    }
252
253    #[test]
254    fn test_dump_database_no_sidecars_when_all_disabled() {
255        let (dir, storage, pool) = seeded_data_dir();
256        let dump_dir = dir.path().join("dumps");
257
258        let settings = Settings {
259            sidecar_sha256: false,
260            sidecar_sha512: false,
261            sidecar_sha3_512: false,
262            ..Settings::default()
263        };
264        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings)
265            .expect("dump_database ok");
266        assert!(res.sidecars.is_empty());
267    }
268
269    #[test]
270    fn test_dump_database_creates_dump_dir() {
271        let (dir, storage, pool) = seeded_data_dir();
272        let dump_dir = dir.path().join("nested/does/not/exist");
273        assert!(!dump_dir.exists());
274
275        let settings = Settings::default();
276        dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump ok");
277        assert!(dump_dir.exists());
278    }
279
280    #[test]
281    fn test_dump_database_missing_redb_source_returns_err() {
282        // Seed only sqlite; open a redb in a DIFFERENT directory so the
283        // `data_dir` passed to `dump_database` has no `csaf.redb`.
284        let dir = tempdir().expect("tmpdir");
285        let other = tempdir().expect("tmpdir2");
286        let storage = CsafStorage::open(&other.path().join("csaf.redb")).expect("open redb");
287        let sqlite_path = dir.path().join("csaf.sqlite");
288        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
289
290        let dump_dir = dir.path().join("dumps");
291        let err = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
292            .expect_err("should error with missing source");
293        match err {
294            CsafError::Storage(msg) => {
295                assert!(msg.contains("redb source file missing"), "got: {msg}");
296            },
297            other => panic!("wrong error variant: {other:?}"),
298        }
299    }
300
301    #[test]
302    fn test_filename_safe_timestamp_format() {
303        let ts = filename_safe_timestamp();
304        // Must be 16 chars: 8 digits date + T + 6 digits time + Z.
305        assert_eq!(ts.len(), 16, "got: {ts}");
306        assert!(ts.ends_with('Z'));
307        assert!(!ts.contains(':'), "colons break Windows filenames");
308        assert!(!ts.contains('/'));
309    }
310
311    #[test]
312    fn test_dump_redb_file_is_openable() {
313        let (dir, storage, pool) = seeded_data_dir();
314        let dump_dir = dir.path().join("dumps");
315        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
316            .expect("dump ok");
317
318        // The dumped redb file must open cleanly.
319        let reopen = redb::Database::open(&res.redb_path).expect("open dumped redb");
320        let _ = reopen.begin_read().expect("begin_read on dump");
321    }
322
323    #[test]
324    fn test_dump_sqlite_file_is_openable() {
325        let (dir, storage, pool) = seeded_data_dir();
326        let dump_dir = dir.path().join("dumps");
327        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
328            .expect("dump ok");
329
330        let reopen = Connection::open(&res.sqlite_path).expect("open dumped sqlite");
331        let schema_count: i64 = reopen
332            .query_row(
333                "SELECT count(*) FROM sqlite_master WHERE type='table'",
334                [],
335                |r| r.get(0),
336            )
337            .expect("query count");
338        assert!(schema_count > 0, "sqlite dump has no tables");
339    }
340}