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 and/or SHA3-512 hash sidecar files.
9//!
10//! The pipeline has two write paths:
11//!
12//! - **sqlite**: uses the `rusqlite::backup::Backup` API so the copy
13//!   is consistent regardless of WAL / SHM state.
14//! - **redb**: redb is copy-on-write; holding an active read
15//!   transaction while copying the file is enough to pin a consistent
16//!   snapshot because writers allocate new pages instead of mutating
17//!   existing ones.
18//!
19//! Both dumps are verified after write (a fresh open + integrity
20//! check) before the function returns `Ok`.
21//!
22//! Hash sidecars are produced via
23//! [`crate::sidecar::write_sidecar_files_for`], which preserves the
24//! full file extension (`csaf.redb.sha256`, not
25//! `csaf.sha256`).
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_sha3_512`
73///   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_s3) = write_sidecar_files_for(
113        &redb_dst,
114        &redb_bytes,
115        settings.sidecar_sha256,
116        settings.sidecar_sha3_512,
117    )?;
118    if let Some(p) = redb_s256 {
119        sidecars.push(p);
120    }
121    if let Some(p) = redb_s3 {
122        sidecars.push(p);
123    }
124    drop(redb_bytes);
125
126    let sqlite_bytes = std::fs::read(&sqlite_dst)?;
127    let (sql_s256, sql_s3) = write_sidecar_files_for(
128        &sqlite_dst,
129        &sqlite_bytes,
130        settings.sidecar_sha256,
131        settings.sidecar_sha3_512,
132    )?;
133    if let Some(p) = sql_s256 {
134        sidecars.push(p);
135    }
136    if let Some(p) = sql_s3 {
137        sidecars.push(p);
138    }
139    drop(sqlite_bytes);
140
141    Ok(DumpResult {
142        timestamp,
143        redb_path: redb_dst,
144        redb_bytes: redb_bytes_on_disk,
145        sqlite_path: sqlite_dst,
146        sqlite_bytes: sqlite_bytes_on_disk,
147        sidecars,
148    })
149}
150
151/// Copy the live sqlite database into `dst` using the rusqlite online
152/// backup API. This produces a consistent snapshot even while writes
153/// are in flight.
154fn backup_sqlite(pool: &DbPool, dst: &Path) -> Result<()> {
155    pool.with_conn(|src_conn| {
156        let mut dst_conn = Connection::open(dst)?;
157        let backup = Backup::new(src_conn, &mut dst_conn)?;
158        backup.run_to_completion(SQLITE_BACKUP_PAGES_PER_STEP, Duration::ZERO, None)?;
159        Ok(())
160    })?;
161    Ok(())
162}
163
164/// ISO 8601 UTC timestamp with no characters that are invalid in
165/// filenames on common platforms (no colons, no slashes).
166fn filename_safe_timestamp() -> String {
167    use chrono::Utc;
168    Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::error::CsafError;
175    use crate::storage::CsafStorage;
176    use csaf_models::settings::Settings;
177    use tempfile::tempdir;
178
179    fn seeded_data_dir() -> (tempfile::TempDir, CsafStorage, DbPool) {
180        let dir = tempdir().expect("tmpdir");
181        // Create both live DB files using the real open paths.
182        let redb_path = dir.path().join("csaf.redb");
183        let sqlite_path = dir.path().join("csaf.sqlite");
184        let storage = CsafStorage::open(&redb_path).expect("open redb");
185        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
186        (dir, storage, pool)
187    }
188
189    #[test]
190    fn test_dump_database_happy_path_writes_all_files() {
191        let (dir, storage, pool) = seeded_data_dir();
192        let dump_dir = dir.path().join("dumps");
193
194        let settings = Settings::default(); // both sidecars ON
195        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
196
197        assert!(res.redb_path.exists(), "redb dump missing");
198        assert!(res.sqlite_path.exists(), "sqlite dump missing");
199        assert!(res.redb_bytes > 0);
200        assert!(res.sqlite_bytes > 0);
201        assert!(!res.timestamp.is_empty());
202
203        // Sidecar count: 2 per file (sha256 + sha3-512) * 2 files = 4.
204        assert_eq!(res.sidecars.len(), 4);
205        for side in &res.sidecars {
206            assert!(side.exists(), "sidecar not on disk: {}", side.display());
207            let contents = std::fs::read_to_string(side).expect("read sidecar");
208            assert!(contents.contains("  "), "GNU format requires 2 spaces");
209        }
210    }
211
212    #[test]
213    fn test_dump_database_respects_sidecar_toggles() {
214        let (dir, storage, pool) = seeded_data_dir();
215        let dump_dir = dir.path().join("dumps");
216
217        let settings = Settings {
218            sidecar_sha256: true,
219            sidecar_sha3_512: false,
220            ..Settings::default()
221        };
222        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
223
224        // 2 files × 1 sidecar each = 2.
225        assert_eq!(res.sidecars.len(), 2);
226        for side in &res.sidecars {
227            let name = side.file_name().unwrap().to_string_lossy();
228            assert!(name.ends_with(".sha256"), "unexpected sidecar: {name}");
229        }
230    }
231
232    #[test]
233    fn test_dump_database_no_sidecars_when_both_disabled() {
234        let (dir, storage, pool) = seeded_data_dir();
235        let dump_dir = dir.path().join("dumps");
236
237        let settings = Settings {
238            sidecar_sha256: false,
239            sidecar_sha3_512: false,
240            ..Settings::default()
241        };
242        let res = dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump_database ok");
243        assert!(res.sidecars.is_empty());
244    }
245
246    #[test]
247    fn test_dump_database_creates_dump_dir() {
248        let (dir, storage, pool) = seeded_data_dir();
249        let dump_dir = dir.path().join("nested/does/not/exist");
250        assert!(!dump_dir.exists());
251
252        let settings = Settings::default();
253        dump_database(dir.path(), &dump_dir, &storage, &pool, &settings).expect("dump ok");
254        assert!(dump_dir.exists());
255    }
256
257    #[test]
258    fn test_dump_database_missing_redb_source_returns_err() {
259        // Seed only sqlite; open a redb in a DIFFERENT directory so the
260        // `data_dir` passed to `dump_database` has no `csaf.redb`.
261        let dir = tempdir().expect("tmpdir");
262        let other = tempdir().expect("tmpdir2");
263        let storage = CsafStorage::open(&other.path().join("csaf.redb")).expect("open redb");
264        let sqlite_path = dir.path().join("csaf.sqlite");
265        let pool = DbPool::open(&sqlite_path).expect("open sqlite");
266
267        let dump_dir = dir.path().join("dumps");
268        let err = dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default())
269            .expect_err("should error with missing source");
270        match err {
271            CsafError::Storage(msg) => {
272                assert!(msg.contains("redb source file missing"), "got: {msg}");
273            },
274            other => panic!("wrong error variant: {other:?}"),
275        }
276    }
277
278    #[test]
279    fn test_filename_safe_timestamp_format() {
280        let ts = filename_safe_timestamp();
281        // Must be 16 chars: 8 digits date + T + 6 digits time + Z.
282        assert_eq!(ts.len(), 16, "got: {ts}");
283        assert!(ts.ends_with('Z'));
284        assert!(!ts.contains(':'), "colons break Windows filenames");
285        assert!(!ts.contains('/'));
286    }
287
288    #[test]
289    fn test_dump_redb_file_is_openable() {
290        let (dir, storage, pool) = seeded_data_dir();
291        let dump_dir = dir.path().join("dumps");
292        let res =
293            dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default()).expect("dump ok");
294
295        // The dumped redb file must open cleanly.
296        let reopen = redb::Database::open(&res.redb_path).expect("open dumped redb");
297        let _ = reopen.begin_read().expect("begin_read on dump");
298    }
299
300    #[test]
301    fn test_dump_sqlite_file_is_openable() {
302        let (dir, storage, pool) = seeded_data_dir();
303        let dump_dir = dir.path().join("dumps");
304        let res =
305            dump_database(dir.path(), &dump_dir, &storage, &pool, &Settings::default()).expect("dump ok");
306
307        let reopen = Connection::open(&res.sqlite_path).expect("open dumped sqlite");
308        let schema_count: i64 = reopen
309            .query_row(
310                "SELECT count(*) FROM sqlite_master WHERE type='table'",
311                [],
312                |r| r.get(0),
313            )
314            .expect("query count");
315        assert!(schema_count > 0, "sqlite dump has no tables");
316    }
317}