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_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
159fn 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
172fn 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 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(); 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 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 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 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 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 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 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}