1use 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
39const SQLITE_BACKUP_PAGES_PER_STEP: std::os::raw::c_int = 1024;
43
44#[derive(Debug, Clone)]
46pub struct DumpResult {
47 pub timestamp: String,
50 pub redb_path: PathBuf,
52 pub redb_bytes: u64,
54 pub sqlite_path: PathBuf,
56 pub sqlite_bytes: u64,
58 pub sidecars: Vec<PathBuf>,
61}
62
63pub 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 let redb_src = data_dir.join("csaf.redb");
95 let redb_dst = dump_dir.join(format!("csaf.redb.{timestamp}"));
96 storage.copy_file_with_snapshot(&redb_src, &redb_dst)?;
101 let redb_bytes_on_disk = std::fs::metadata(&redb_dst)?.len();
102
103 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 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
151fn 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
164fn 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 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(); 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 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 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 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 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 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}