1use crate::{MongrelError, Result};
11use mongreldb_types::ids::DatabaseId;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashSet;
15use std::io::Read;
16use std::path::{Component, Path, PathBuf};
17
18pub const BACKUP_FORMAT_VERSION: u16 = 1;
19pub const BACKUP_MANIFEST_PATH: &str = "_meta/backup.json";
20const MAX_BACKUP_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
21const REPLICATION_ID_PATH: &str = "_meta/replication_id";
23const REPLICATION_ID_LEN: usize = 32;
24const KEYS_PATH: &str = "_meta/keys";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct BackupFile {
29 pub path: PathBuf,
30 pub bytes: u64,
31 pub sha256: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct BackupEncryptionMetadata {
39 pub kdf: String,
41 pub cipher: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct BackupManifest {
47 pub format_version: u16,
48 pub epoch: u64,
49 pub created_unix_nanos: u64,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub database_id: Option<DatabaseId>,
60 #[serde(default)]
65 pub catalog_version: u64,
66 #[serde(default)]
72 pub snapshot_unix_micros: u64,
73 #[serde(default)]
79 pub open_generation: u64,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub encryption: Option<BackupEncryptionMetadata>,
83 pub files: Vec<BackupFile>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct BackupReport {
88 pub destination: PathBuf,
89 pub epoch: u64,
90 pub boundary_unix_nanos: u64,
92 pub files: usize,
93 pub bytes: u64,
94}
95
96#[derive(Debug, Default, Clone, PartialEq, Eq)]
100pub struct RestoreReport {
101 pub files_checked: usize,
103 pub files_ok: usize,
105 pub bytes_checked: u64,
107 pub catalog_loaded: bool,
110 pub manifest_consistent: bool,
113 pub issues: Vec<String>,
115}
116
117pub fn validate_restore(root: impl AsRef<Path>) -> Result<RestoreReport> {
123 let root = crate::durable_file::DurableRoot::open(root)?;
124 validate_restore_durable(&root)
125}
126
127pub(crate) fn validate_restore_durable(
128 root: &crate::durable_file::DurableRoot,
129) -> Result<RestoreReport> {
130 let (manifest, _) = verify_backup_durable_with_manifest_sha256(root)?;
131 let mut report = RestoreReport {
132 files_checked: manifest.files.len(),
133 files_ok: manifest.files.len(),
134 bytes_checked: manifest.total_bytes(),
135 manifest_consistent: true,
136 ..RestoreReport::default()
137 };
138 match crate::catalog::read_durable(root, None)? {
139 Some(_) => report.catalog_loaded = true,
140 None => report
141 .issues
142 .push("catalog did not decode without a passphrase (encrypted)".into()),
143 }
144 Ok(report)
145}
146
147impl BackupManifest {
148 pub(crate) fn create_controlled_durable(
149 root: &crate::durable_file::DurableRoot,
150 epoch: u64,
151 paths: &[PathBuf],
152 control: &crate::ExecutionControl,
153 ) -> Result<Self> {
154 let mut paths = paths.to_vec();
155 paths.sort();
156 paths.dedup();
157 let mut files = Vec::with_capacity(paths.len());
158 for (index, path) in paths.into_iter().enumerate() {
159 if index % 256 == 0 {
160 control.checkpoint()?;
161 }
162 validate_relative_path(&path)?;
163 let mut source = root.open_regular(&path)?;
164 let bytes = source.metadata()?.len();
165 files.push(BackupFile {
166 path,
167 bytes,
168 sha256: sha256_open_file_inner(&mut source, Some(control))?,
169 });
170 }
171 let now = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .unwrap_or_default();
174 Ok(Self {
175 format_version: BACKUP_FORMAT_VERSION,
176 epoch,
177 created_unix_nanos: now.as_nanos() as u64,
178 database_id: backup_database_id(root)?,
179 catalog_version: backup_catalog_version(root)?,
180 snapshot_unix_micros: now.as_micros() as u64,
181 open_generation: crate::catalog::read_generation(root)?.unwrap_or(0),
182 encryption: backup_encryption_metadata(root)?,
183 files,
184 })
185 }
186
187 pub(crate) fn write_to_durable(&self, root: &crate::durable_file::DurableRoot) -> Result<()> {
188 root.create_directory_all("_meta")?;
189 let bytes = serde_json::to_vec_pretty(self)
190 .map_err(|error| MongrelError::Other(format!("backup manifest encode: {error}")))?;
191 root.write_new(BACKUP_MANIFEST_PATH, &bytes)?;
192 Ok(())
193 }
194
195 pub fn total_bytes(&self) -> u64 {
196 self.files.iter().map(|file| file.bytes).sum()
197 }
198}
199
200fn backup_database_id(root: &crate::durable_file::DurableRoot) -> Result<Option<DatabaseId>> {
206 let file = match root.open_regular(REPLICATION_ID_PATH) {
207 Ok(file) => file,
208 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
209 Err(error) => return Err(error.into()),
210 };
211 let mut bytes = Vec::with_capacity(REPLICATION_ID_LEN);
212 file.take(REPLICATION_ID_LEN as u64 + 1)
213 .read_to_end(&mut bytes)?;
214 if bytes.len() != REPLICATION_ID_LEN || bytes.iter().all(|byte| *byte == 0) {
215 return Err(MongrelError::Other(format!(
216 "invalid database replication identity length: got {}, expected {REPLICATION_ID_LEN} nonzero bytes",
217 bytes.len()
218 )));
219 }
220 let mut id = [0u8; 16];
221 id.copy_from_slice(&bytes[..16]);
222 Ok(Some(DatabaseId::from_bytes(id)))
223}
224
225fn backup_catalog_version(root: &crate::durable_file::DurableRoot) -> Result<u64> {
229 Ok(crate::catalog::read_durable(root, None)?
230 .map(|catalog| catalog.catalog_version())
231 .unwrap_or(0))
232}
233
234fn backup_encryption_metadata(
235 root: &crate::durable_file::DurableRoot,
236) -> Result<Option<BackupEncryptionMetadata>> {
237 match root.open_regular(KEYS_PATH) {
238 Ok(_) => Ok(Some(BackupEncryptionMetadata {
239 kdf: "argon2id-hkdf-sha256".into(),
240 cipher: "aes-256-gcm".into(),
241 })),
242 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
243 Err(error) => Err(error.into()),
244 }
245}
246
247pub fn verify_backup(root: impl AsRef<Path>) -> Result<BackupManifest> {
249 let root = crate::durable_file::DurableRoot::open(root)?;
250 verify_backup_durable(&root)
251}
252
253pub(crate) fn verify_backup_durable(
254 root: &crate::durable_file::DurableRoot,
255) -> Result<BackupManifest> {
256 verify_backup_durable_with_manifest_sha256(root).map(|(manifest, _)| manifest)
257}
258
259pub(crate) fn verify_backup_durable_with_manifest_sha256(
260 root: &crate::durable_file::DurableRoot,
261) -> Result<(BackupManifest, String)> {
262 let manifest_bytes = read_backup_manifest_durable(root)?;
263 let manifest_sha256 = Sha256::digest(&manifest_bytes)
264 .iter()
265 .map(|byte| format!("{byte:02x}"))
266 .collect();
267 let manifest: BackupManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| {
268 MongrelError::InvalidArgument(format!("invalid backup manifest: {error}"))
269 })?;
270 if manifest.format_version != BACKUP_FORMAT_VERSION {
271 return Err(MongrelError::InvalidArgument(format!(
272 "unsupported backup format version {}",
273 manifest.format_version
274 )));
275 }
276 if root.open_regular(crate::catalog::CATALOG_FILENAME).is_err() {
277 return Err(MongrelError::InvalidArgument(
278 "backup has no catalog".into(),
279 ));
280 }
281 let mut expected = HashSet::with_capacity(manifest.files.len() + 1);
282 expected.insert(PathBuf::from(BACKUP_MANIFEST_PATH));
283 for file in &manifest.files {
284 validate_relative_path(&file.path)?;
285 if !expected.insert(file.path.clone()) {
286 return Err(MongrelError::InvalidArgument(format!(
287 "backup manifest lists {} more than once",
288 file.path.display()
289 )));
290 }
291 let mut source = root.open_regular(&file.path).map_err(|error| {
292 MongrelError::Other(format!("backup file {}: {error}", file.path.display()))
293 })?;
294 let metadata = source.metadata()?;
295 if metadata.len() != file.bytes {
296 return Err(MongrelError::Other(format!(
297 "backup file {} size mismatch: expected {}, got {}",
298 file.path.display(),
299 file.bytes,
300 metadata.len()
301 )));
302 }
303 let actual = sha256_open_file_inner(&mut source, None)?;
304 if actual != file.sha256 {
305 return Err(MongrelError::Other(format!(
306 "backup file {} checksum mismatch",
307 file.path.display()
308 )));
309 }
310 }
311 let mut actual = HashSet::new();
312 root.walk_regular_files(
313 |_, _| Ok(true),
314 |_| Ok(()),
315 |relative, _| {
316 actual.insert(relative.to_path_buf());
317 Ok(())
318 },
319 )?;
320 if actual != expected {
321 let mut missing = expected.difference(&actual).cloned().collect::<Vec<_>>();
322 let mut extra = actual.difference(&expected).cloned().collect::<Vec<_>>();
323 missing.sort();
324 extra.sort();
325 return Err(MongrelError::InvalidArgument(format!(
326 "backup file set differs from manifest (missing: {missing:?}; extra: {extra:?})"
327 )));
328 }
329 Ok((manifest, manifest_sha256))
330}
331
332fn read_backup_manifest_durable(root: &crate::durable_file::DurableRoot) -> Result<Vec<u8>> {
333 let file = root.open_regular(BACKUP_MANIFEST_PATH)?;
334 let bytes = file.metadata()?.len();
335 if bytes > MAX_BACKUP_MANIFEST_BYTES {
336 return Err(MongrelError::ResourceLimitExceeded {
337 resource: "backup manifest bytes",
338 requested: usize::try_from(bytes).unwrap_or(usize::MAX),
339 limit: MAX_BACKUP_MANIFEST_BYTES as usize,
340 });
341 }
342 let mut manifest_bytes = Vec::with_capacity(bytes as usize);
343 file.take(MAX_BACKUP_MANIFEST_BYTES + 1)
344 .read_to_end(&mut manifest_bytes)?;
345 Ok(manifest_bytes)
346}
347
348pub(crate) fn copy_file_synced(source: &Path, destination: &Path) -> Result<u64> {
349 let mut source = crate::durable_file::open_regular_nofollow(source)?;
350 copy_open_file_synced(&mut source, destination)
351}
352
353pub(crate) fn copy_open_file_synced(source: &mut std::fs::File, destination: &Path) -> Result<u64> {
354 let parent = destination
355 .parent()
356 .ok_or_else(|| MongrelError::InvalidArgument("invalid backup file path".into()))?;
357 std::fs::create_dir_all(parent)?;
358 let mut destination = std::fs::File::create(destination)?;
359 let bytes = std::io::copy(source, &mut destination)?;
360 destination.sync_all()?;
361 Ok(bytes)
362}
363
364pub(crate) fn sha256_open_file_inner(
365 file: &mut std::fs::File,
366 control: Option<&crate::ExecutionControl>,
367) -> Result<String> {
368 let mut hasher = Sha256::new();
369 let mut buffer = [0u8; 64 * 1024];
370 loop {
371 if let Some(control) = control {
372 control.checkpoint()?;
373 }
374 let read = file.read(&mut buffer)?;
375 if read == 0 {
376 break;
377 }
378 hasher.update(&buffer[..read]);
379 }
380 Ok(hasher
381 .finalize()
382 .iter()
383 .map(|byte| format!("{byte:02x}"))
384 .collect())
385}
386
387fn validate_relative_path(path: &Path) -> Result<()> {
388 if path.as_os_str().is_empty()
389 || path.is_absolute()
390 || path.components().any(|component| {
391 matches!(
392 component,
393 Component::ParentDir | Component::RootDir | Component::Prefix(_)
394 )
395 })
396 {
397 return Err(MongrelError::InvalidArgument(format!(
398 "invalid backup path {}",
399 path.display()
400 )));
401 }
402 Ok(())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn pre_1g_manifest_json_decodes_with_unknown_audit_fields() {
411 let json = r#"{"format_version":1,"epoch":7,"created_unix_nanos":42,"files":[]}"#;
412 let manifest: BackupManifest = serde_json::from_str(json).unwrap();
413 assert_eq!(manifest.format_version, 1);
414 assert_eq!(manifest.epoch, 7);
415 assert_eq!(manifest.database_id, None);
416 assert_eq!(manifest.catalog_version, 0);
417 assert_eq!(manifest.snapshot_unix_micros, 0);
418 assert_eq!(manifest.open_generation, 0);
419 assert_eq!(manifest.encryption, None);
420 }
421
422 #[test]
423 fn manifest_serde_round_trip_preserves_audit_fields() {
424 let manifest = BackupManifest {
425 format_version: BACKUP_FORMAT_VERSION,
426 epoch: 9,
427 created_unix_nanos: 1_000,
428 database_id: Some(DatabaseId::from_bytes([7; 16])),
429 catalog_version: 3,
430 snapshot_unix_micros: 1,
431 open_generation: 2,
432 encryption: Some(BackupEncryptionMetadata {
433 kdf: "argon2id-hkdf-sha256".into(),
434 cipher: "aes-256-gcm".into(),
435 }),
436 files: vec![BackupFile {
437 path: PathBuf::from("CATALOG"),
438 bytes: 10,
439 sha256: "ab".repeat(32),
440 }],
441 };
442 let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
443 let decoded: BackupManifest = serde_json::from_slice(&bytes).unwrap();
444 assert_eq!(decoded, manifest);
445 }
446
447 #[test]
448 fn database_id_follows_the_replication_identity_marker() {
449 let directory = tempfile::tempdir().unwrap();
450 let root = crate::durable_file::DurableRoot::open(directory.path()).unwrap();
451 assert_eq!(backup_database_id(&root).unwrap(), None);
452
453 root.create_directory_all("_meta").unwrap();
454 let identity = [7u8; REPLICATION_ID_LEN];
455 root.write_new(REPLICATION_ID_PATH, &identity).unwrap();
456 let id = backup_database_id(&root).unwrap().unwrap();
457 assert_eq!(id.as_bytes(), &[7u8; 16]);
458 assert_eq!(backup_database_id(&root).unwrap(), Some(id));
460 }
461
462 #[test]
463 fn database_id_fails_closed_on_a_malformed_identity_marker() {
464 let directory = tempfile::tempdir().unwrap();
465 let root = crate::durable_file::DurableRoot::open(directory.path()).unwrap();
466 root.create_directory_all("_meta").unwrap();
467 root.write_new(REPLICATION_ID_PATH, &[1, 2, 3]).unwrap();
468 assert!(backup_database_id(&root).is_err());
469
470 let zeroed = tempfile::tempdir().unwrap();
471 let zeroed = crate::durable_file::DurableRoot::open(zeroed.path()).unwrap();
472 zeroed.create_directory_all("_meta").unwrap();
473 zeroed
474 .write_new(REPLICATION_ID_PATH, &[0u8; REPLICATION_ID_LEN])
475 .unwrap();
476 assert!(backup_database_id(&zeroed).is_err());
477 }
478}