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::{SidecarHashes, 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: 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 let dump_handle = crate::fs::DataDir::open_or_create(dump_dir)?;
95
96 let timestamp = filename_safe_timestamp();
97
98 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 storage.copy_file_with_snapshot(&redb_src, &redb_dst)?;
108 let redb_bytes_on_disk = dump_handle.file_len(&redb_rel)?;
109
110 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 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
143fn 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
156fn 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 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(); 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 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 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 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 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 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 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}