1use std::{
4 collections::BTreeSet,
5 fs::{self, File, OpenOptions},
6 io::{self, Read, Write},
7 path::{Path, PathBuf},
8};
9
10use hyphae_core::DISK_FORMAT_VERSION;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13use uuid::Uuid;
14
15use crate::{
16 DataDirectory, DurableLog, SnapshotError, SnapshotInfo, StorageEngine, StorageError,
17 manifest::StorageManifest, verify_snapshot,
18};
19
20const BACKUP_MANIFEST: &str = "BACKUP.json";
21const BACKUP_SNAPSHOT: &str = "snapshot.hysnap";
22const BACKUP_KIND: &str = "hyphae-backup";
23const BACKUP_FORMAT_VERSION: u16 = 1;
24const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
25
26#[derive(Debug, Error)]
28pub enum BackupError {
29 #[error("backup or restore destination already exists: {0}")]
31 DestinationExists(PathBuf),
32
33 #[error("backup destination must be outside the live data directory: {0}")]
35 DestinationInsideDataDirectory(PathBuf),
36
37 #[error("restore destination must be outside the backup directory: {0}")]
39 RestoreInsideBackup(PathBuf),
40
41 #[error("invalid backup layout at {path}: {reason}")]
43 InvalidLayout {
44 path: PathBuf,
46 reason: &'static str,
48 },
49
50 #[error("invalid backup manifest at {path}: {reason}")]
52 InvalidManifest {
53 path: PathBuf,
55 reason: &'static str,
57 },
58
59 #[error("failed to decode backup manifest {path}: {source}")]
61 ManifestJson {
62 path: PathBuf,
64 #[source]
66 source: serde_json::Error,
67 },
68
69 #[error(transparent)]
71 Snapshot(#[from] SnapshotError),
72
73 #[error(transparent)]
75 Storage(#[from] StorageError),
76
77 #[error("failed to {action} {path}: {source}")]
79 Io {
80 action: &'static str,
82 path: PathBuf,
84 #[source]
86 source: io::Error,
87 },
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct BackupInfo {
93 pub path: PathBuf,
95 pub snapshot: SnapshotInfo,
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct RestoreInfo {
102 pub data_path: PathBuf,
104 pub snapshot: SnapshotInfo,
106}
107
108#[derive(Debug, Deserialize, Serialize)]
109#[serde(deny_unknown_fields)]
110struct BackupManifest {
111 kind: String,
112 backup_format_version: u16,
113 disk_format_version: u16,
114 snapshot_file: String,
115 checkpoint_sequence: u64,
116 checkpoint_digest: Option<String>,
117 entry_count: u64,
118 receipt_count: u64,
119 snapshot_digest: String,
120 snapshot_file_bytes: u64,
121}
122
123impl BackupManifest {
124 fn from_snapshot(snapshot: &SnapshotInfo) -> Self {
125 Self {
126 kind: BACKUP_KIND.to_owned(),
127 backup_format_version: BACKUP_FORMAT_VERSION,
128 disk_format_version: DISK_FORMAT_VERSION,
129 snapshot_file: BACKUP_SNAPSHOT.to_owned(),
130 checkpoint_sequence: snapshot.checkpoint_sequence,
131 checkpoint_digest: snapshot.checkpoint_digest.map(|digest| encode_hex(&digest)),
132 entry_count: snapshot.entry_count,
133 receipt_count: snapshot.receipt_count,
134 snapshot_digest: encode_hex(&snapshot.snapshot_digest),
135 snapshot_file_bytes: snapshot.file_bytes,
136 }
137 }
138
139 fn matches(&self, snapshot: &SnapshotInfo) -> bool {
140 self.kind == BACKUP_KIND
141 && self.backup_format_version == BACKUP_FORMAT_VERSION
142 && self.disk_format_version == DISK_FORMAT_VERSION
143 && self.snapshot_file == BACKUP_SNAPSHOT
144 && self.checkpoint_sequence == snapshot.checkpoint_sequence
145 && self.checkpoint_digest
146 == snapshot.checkpoint_digest.map(|digest| encode_hex(&digest))
147 && self.entry_count == snapshot.entry_count
148 && self.receipt_count == snapshot.receipt_count
149 && self.snapshot_digest == encode_hex(&snapshot.snapshot_digest)
150 && self.snapshot_file_bytes == snapshot.file_bytes
151 }
152}
153
154pub(crate) fn create_backup(
155 storage: &StorageEngine,
156 destination: &Path,
157) -> Result<BackupInfo, BackupError> {
158 let parent = prepare_destination_parent(destination)?;
159 let source_root = fs::canonicalize(storage.data_path()).map_err(|source| BackupError::Io {
160 action: "canonicalize live data directory",
161 path: storage.data_path().to_path_buf(),
162 source,
163 })?;
164 let destination_parent = fs::canonicalize(&parent).map_err(|source| BackupError::Io {
165 action: "canonicalize backup parent",
166 path: parent.clone(),
167 source,
168 })?;
169 if destination_parent.starts_with(&source_root) {
170 return Err(BackupError::DestinationInsideDataDirectory(
171 destination.to_path_buf(),
172 ));
173 }
174
175 let snapshot = storage.snapshot().map_err(|source| match source {
176 StorageError::Snapshot { source } => BackupError::Snapshot(*source),
177 other => BackupError::Storage(other),
178 })?;
179 let staging = staging_path(destination, "backup")?;
180 fs::create_dir(&staging).map_err(|source| BackupError::Io {
181 action: "create backup staging directory",
182 path: staging.clone(),
183 source,
184 })?;
185 let result = write_backup_staging(&staging, &snapshot).and_then(|()| {
186 let staged = verify_backup(&staging)?;
187 fs::rename(&staging, destination).map_err(|source| BackupError::Io {
188 action: "atomically promote verified backup",
189 path: destination.to_path_buf(),
190 source,
191 })?;
192 sync_directory(&parent)?;
193 Ok(BackupInfo {
194 path: destination.to_path_buf(),
195 snapshot: SnapshotInfo {
196 path: destination.join(BACKUP_SNAPSHOT),
197 ..staged.snapshot
198 },
199 })
200 });
201 if result.is_err() {
202 let _ignored = fs::remove_dir_all(&staging);
203 }
204 result
205}
206
207pub fn verify_backup(path: impl AsRef<Path>) -> Result<BackupInfo, BackupError> {
214 let path = path.as_ref();
215 validate_layout(path)?;
216 let manifest_path = path.join(BACKUP_MANIFEST);
217 let manifest = read_manifest(&manifest_path)?;
218 let snapshot_path = path.join(BACKUP_SNAPSHOT);
219 let snapshot = verify_snapshot(&snapshot_path)?;
220 if !manifest.matches(&snapshot) {
221 return Err(BackupError::InvalidManifest {
222 path: manifest_path,
223 reason: "manifest fields do not match the verified snapshot",
224 });
225 }
226 Ok(BackupInfo {
227 path: path.to_path_buf(),
228 snapshot,
229 })
230}
231
232pub fn restore_backup(
243 backup: impl AsRef<Path>,
244 destination: impl AsRef<Path>,
245) -> Result<RestoreInfo, BackupError> {
246 let backup = backup.as_ref();
247 let destination = destination.as_ref();
248 let verified = verify_backup(backup)?;
249 let parent = prepare_destination_parent(destination)?;
250 let backup_root = fs::canonicalize(backup).map_err(|source| BackupError::Io {
251 action: "canonicalize backup directory",
252 path: backup.to_path_buf(),
253 source,
254 })?;
255 let destination_parent = fs::canonicalize(&parent).map_err(|source| BackupError::Io {
256 action: "canonicalize restore parent",
257 path: parent.clone(),
258 source,
259 })?;
260 if destination_parent.starts_with(&backup_root) {
261 return Err(BackupError::RestoreInsideBackup(destination.to_path_buf()));
262 }
263
264 let staging = staging_path(destination, "restore")?;
265 fs::create_dir(&staging).map_err(|source| BackupError::Io {
266 action: "create restore staging directory",
267 path: staging.clone(),
268 source,
269 })?;
270 let result = restore_into_staging(&verified, &staging).and_then(|snapshot| {
271 fs::rename(&staging, destination).map_err(|source| BackupError::Io {
272 action: "atomically activate restored data directory",
273 path: destination.to_path_buf(),
274 source,
275 })?;
276 sync_directory(&parent)?;
277 Ok(RestoreInfo {
278 data_path: destination.to_path_buf(),
279 snapshot: SnapshotInfo {
280 path: destination
281 .join("snapshots")
282 .join(snapshot_filename(snapshot.checkpoint_sequence)),
283 ..snapshot
284 },
285 })
286 });
287 if result.is_err() {
288 let _ignored = fs::remove_dir_all(&staging);
289 }
290 result
291}
292
293fn write_backup_staging(staging: &Path, snapshot: &SnapshotInfo) -> Result<(), BackupError> {
294 let copied_path = staging.join(BACKUP_SNAPSHOT);
295 copy_new_file(&snapshot.path, &copied_path, "copy backup snapshot")?;
296 let copied = verify_snapshot(&copied_path)?;
297 if !same_snapshot_identity(snapshot, &copied) {
298 return Err(BackupError::InvalidManifest {
299 path: copied_path,
300 reason: "snapshot changed while backup was copied",
301 });
302 }
303 let mut encoded =
304 serde_json::to_vec_pretty(&BackupManifest::from_snapshot(&copied)).map_err(|source| {
305 BackupError::ManifestJson {
306 path: staging.join(BACKUP_MANIFEST),
307 source,
308 }
309 })?;
310 encoded.push(b'\n');
311 let manifest_path = staging.join(BACKUP_MANIFEST);
312 let mut file = OpenOptions::new()
313 .create_new(true)
314 .write(true)
315 .open(&manifest_path)
316 .map_err(|source| BackupError::Io {
317 action: "create backup manifest",
318 path: manifest_path.clone(),
319 source,
320 })?;
321 file.write_all(&encoded)
322 .and_then(|()| file.sync_all())
323 .map_err(|source| BackupError::Io {
324 action: "synchronize backup manifest",
325 path: manifest_path,
326 source,
327 })?;
328 sync_directory(staging)
329}
330
331fn restore_into_staging(backup: &BackupInfo, staging: &Path) -> Result<SnapshotInfo, BackupError> {
332 let mut directory = DataDirectory::open(staging).map_err(StorageError::from)?;
333 let checkpoint = backup.snapshot.checkpoint_sequence;
334 if checkpoint > 0 {
335 let snapshot_path = staging
336 .join("snapshots")
337 .join(snapshot_filename(checkpoint));
338 copy_new_file(
339 &backup.snapshot.path,
340 &snapshot_path,
341 "copy restored snapshot",
342 )?;
343 let restored = verify_snapshot(&snapshot_path)?;
344 if !same_snapshot_identity(&backup.snapshot, &restored) {
345 return Err(BackupError::InvalidManifest {
346 path: snapshot_path,
347 reason: "restored snapshot differs from verified backup",
348 });
349 }
350 let base_digest = restored
351 .checkpoint_digest
352 .ok_or(BackupError::InvalidManifest {
353 path: backup.path.join(BACKUP_MANIFEST),
354 reason: "nonempty backup lacks a checkpoint digest",
355 })?;
356 let manifest = StorageManifest {
357 generation: 2,
358 active_segment: 2,
359 base_sequence: checkpoint,
360 base_digest,
361 snapshot_digest: restored.snapshot_digest,
362 };
363 let (active_log, recovery) = DurableLog::open_file_at(
364 staging.join("log/00000000000000000002.hylog"),
365 checkpoint,
366 base_digest,
367 )
368 .map_err(StorageError::from)?;
369 if recovery.valid_bytes != 0 {
370 return Err(BackupError::InvalidLayout {
371 path: staging.to_path_buf(),
372 reason: "new restore log segment is not empty",
373 });
374 }
375 drop(active_log);
376 directory
377 .commit_manifest(manifest)
378 .map_err(StorageError::from)?;
379 }
380 drop(directory);
381
382 let opened = StorageEngine::open(staging)?;
383 let rebuilt = opened.storage.snapshot().map_err(|source| match source {
384 StorageError::Snapshot { source } => BackupError::Snapshot(*source),
385 other => BackupError::Storage(other),
386 })?;
387 if !same_snapshot_identity(&backup.snapshot, &rebuilt) {
388 return Err(BackupError::InvalidManifest {
389 path: backup.path.join(BACKUP_MANIFEST),
390 reason: "restored engine checkpoint differs from backup",
391 });
392 }
393 drop(opened);
394 sync_directory(staging)?;
395 Ok(rebuilt)
396}
397
398fn prepare_destination_parent(destination: &Path) -> Result<PathBuf, BackupError> {
399 if destination.exists() {
400 return Err(BackupError::DestinationExists(destination.to_path_buf()));
401 }
402 let parent = destination
403 .parent()
404 .filter(|path| !path.as_os_str().is_empty())
405 .unwrap_or_else(|| Path::new("."))
406 .to_path_buf();
407 if destination.file_name().is_none() {
408 return Err(BackupError::InvalidLayout {
409 path: destination.to_path_buf(),
410 reason: "destination has no final path component",
411 });
412 }
413 fs::create_dir_all(&parent).map_err(|source| BackupError::Io {
414 action: "create destination parent",
415 path: parent.clone(),
416 source,
417 })?;
418 Ok(parent)
419}
420
421fn staging_path(destination: &Path, operation: &str) -> Result<PathBuf, BackupError> {
422 let filename = destination
423 .file_name()
424 .and_then(|name| name.to_str())
425 .ok_or_else(|| BackupError::InvalidLayout {
426 path: destination.to_path_buf(),
427 reason: "destination filename is not valid Unicode",
428 })?;
429 Ok(destination.with_file_name(format!(
430 ".{filename}.hyphae-{operation}-{}.tmp",
431 Uuid::now_v7()
432 )))
433}
434
435fn validate_layout(path: &Path) -> Result<(), BackupError> {
436 let metadata = fs::symlink_metadata(path).map_err(|source| BackupError::Io {
437 action: "inspect backup directory",
438 path: path.to_path_buf(),
439 source,
440 })?;
441 if !metadata.is_dir() || metadata.file_type().is_symlink() {
442 return Err(BackupError::InvalidLayout {
443 path: path.to_path_buf(),
444 reason: "backup root must be a real directory",
445 });
446 }
447 let mut names = BTreeSet::new();
448 for entry in fs::read_dir(path).map_err(|source| BackupError::Io {
449 action: "list backup directory",
450 path: path.to_path_buf(),
451 source,
452 })? {
453 let entry = entry.map_err(|source| BackupError::Io {
454 action: "read backup directory entry",
455 path: path.to_path_buf(),
456 source,
457 })?;
458 if !entry
459 .file_type()
460 .map_err(|source| BackupError::Io {
461 action: "inspect backup file",
462 path: entry.path(),
463 source,
464 })?
465 .is_file()
466 {
467 return Err(BackupError::InvalidLayout {
468 path: entry.path(),
469 reason: "backup entries must be regular files",
470 });
471 }
472 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
473 return Err(BackupError::InvalidLayout {
474 path: entry.path(),
475 reason: "backup filename is not valid Unicode",
476 });
477 };
478 names.insert(name);
479 }
480 let expected = BTreeSet::from([BACKUP_MANIFEST.to_owned(), BACKUP_SNAPSHOT.to_owned()]);
481 if names != expected {
482 return Err(BackupError::InvalidLayout {
483 path: path.to_path_buf(),
484 reason: "backup must contain exactly BACKUP.json and snapshot.hysnap",
485 });
486 }
487 Ok(())
488}
489
490fn read_manifest(path: &Path) -> Result<BackupManifest, BackupError> {
491 let metadata = fs::metadata(path).map_err(|source| BackupError::Io {
492 action: "inspect backup manifest",
493 path: path.to_path_buf(),
494 source,
495 })?;
496 if metadata.len() > MAX_MANIFEST_BYTES {
497 return Err(BackupError::InvalidManifest {
498 path: path.to_path_buf(),
499 reason: "manifest exceeds 64 KiB",
500 });
501 }
502 let capacity = usize::try_from(metadata.len()).map_err(|_| BackupError::InvalidManifest {
503 path: path.to_path_buf(),
504 reason: "manifest length does not fit memory limits",
505 })?;
506 let mut encoded = Vec::with_capacity(capacity);
507 File::open(path)
508 .map(|file| file.take(MAX_MANIFEST_BYTES.saturating_add(1)))
509 .and_then(|mut bounded| bounded.read_to_end(&mut encoded))
510 .map_err(|source| BackupError::Io {
511 action: "read backup manifest",
512 path: path.to_path_buf(),
513 source,
514 })?;
515 if u64::try_from(encoded.len()).unwrap_or(u64::MAX) > MAX_MANIFEST_BYTES {
516 return Err(BackupError::InvalidManifest {
517 path: path.to_path_buf(),
518 reason: "manifest exceeds 64 KiB",
519 });
520 }
521 serde_json::from_slice(&encoded).map_err(|source| BackupError::ManifestJson {
522 path: path.to_path_buf(),
523 source,
524 })
525}
526
527fn copy_new_file(
528 source: &Path,
529 destination: &Path,
530 action: &'static str,
531) -> Result<(), BackupError> {
532 let metadata = fs::symlink_metadata(source).map_err(|source_error| BackupError::Io {
533 action: "inspect source file",
534 path: source.to_path_buf(),
535 source: source_error,
536 })?;
537 if !metadata.is_file() || metadata.file_type().is_symlink() {
538 return Err(BackupError::InvalidLayout {
539 path: source.to_path_buf(),
540 reason: "snapshot must be a regular file",
541 });
542 }
543 let mut input = File::open(source).map_err(|source_error| BackupError::Io {
544 action,
545 path: source.to_path_buf(),
546 source: source_error,
547 })?;
548 let mut output = OpenOptions::new()
549 .create_new(true)
550 .write(true)
551 .open(destination)
552 .map_err(|source_error| BackupError::Io {
553 action,
554 path: destination.to_path_buf(),
555 source: source_error,
556 })?;
557 io::copy(&mut input, &mut output)
558 .and_then(|_| output.sync_all())
559 .map_err(|source_error| BackupError::Io {
560 action,
561 path: destination.to_path_buf(),
562 source: source_error,
563 })?;
564 Ok(())
565}
566
567fn same_snapshot_identity(left: &SnapshotInfo, right: &SnapshotInfo) -> bool {
568 left.checkpoint_sequence == right.checkpoint_sequence
569 && left.checkpoint_digest == right.checkpoint_digest
570 && left.entry_count == right.entry_count
571 && left.receipt_count == right.receipt_count
572 && left.snapshot_digest == right.snapshot_digest
573 && left.file_bytes == right.file_bytes
574}
575
576fn snapshot_filename(sequence: u64) -> String {
577 format!("snapshot-{sequence:020}.hysnap")
578}
579
580fn encode_hex(bytes: &[u8]) -> String {
581 const HEX: &[u8; 16] = b"0123456789abcdef";
582 let mut encoded = String::with_capacity(bytes.len() * 2);
583 for byte in bytes {
584 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
585 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
586 }
587 encoded
588}
589
590#[cfg(unix)]
591fn sync_directory(path: &Path) -> Result<(), BackupError> {
592 File::open(path)
593 .and_then(|directory| directory.sync_all())
594 .map_err(|source| BackupError::Io {
595 action: "synchronize directory",
596 path: path.to_path_buf(),
597 source,
598 })
599}
600
601#[cfg(not(unix))]
602#[allow(
603 clippy::unnecessary_wraps,
604 reason = "keep the fallible directory-sync interface shared with Unix callers"
605)]
606fn sync_directory(_path: &Path) -> Result<(), BackupError> {
607 Ok(())
608}
609
610#[cfg(test)]
611mod tests {
612 use std::{
613 error::Error,
614 fs,
615 io::{Seek, SeekFrom, Write},
616 };
617
618 use uuid::Uuid;
619
620 use super::{BackupError, restore_backup, verify_backup};
621 use crate::{AppendOutcome, Mutation, StorageEngine, test_support::TestDirectory};
622
623 #[test]
624 fn backup_restore_preserves_values_receipts_and_sequence() -> Result<(), Box<dyn Error>> {
625 let temporary = TestDirectory::new("backup-round-trip")?;
626 let source = temporary.path().join("source");
627 let backup = temporary.path().join("backup");
628 let restored = temporary.path().join("restored");
629 let transaction_id = Uuid::now_v7();
630 let mutation = Mutation::put(b"alpha", b"value".to_vec());
631 let mut opened = StorageEngine::open(&source)?;
632 let committed = opened
633 .storage
634 .write(transaction_id, std::slice::from_ref(&mutation))?;
635 let AppendOutcome::Committed(receipt) = committed else {
636 return Err("initial write was not committed".into());
637 };
638 let created = opened.storage.backup(&backup)?;
639 assert_eq!(created, verify_backup(&backup)?);
640 drop(opened);
641
642 let activated = restore_backup(&backup, &restored)?;
643 assert_eq!(
644 activated.snapshot.snapshot_digest,
645 created.snapshot.snapshot_digest
646 );
647 let mut reopened = StorageEngine::open(&restored)?;
648 assert_eq!(reopened.storage.get(b"alpha")?, Some(b"value".to_vec()));
649 assert!(matches!(
650 reopened.storage.write(transaction_id, std::slice::from_ref(&mutation))?,
651 AppendOutcome::Existing(existing) if existing == receipt
652 ));
653 let next = reopened
654 .storage
655 .write(Uuid::now_v7(), &[Mutation::put(b"beta", b"next".to_vec())])?;
656 let next_receipt = match next {
657 AppendOutcome::Committed(next_receipt) | AppendOutcome::Existing(next_receipt) => {
658 next_receipt
659 }
660 };
661 assert!(next_receipt.commit_sequence > receipt.commit_sequence);
662 Ok(())
663 }
664
665 #[test]
666 fn corrupt_backup_never_activates_destination() -> Result<(), Box<dyn Error>> {
667 let temporary = TestDirectory::new("backup-corruption")?;
668 let source = temporary.path().join("source");
669 let backup = temporary.path().join("backup");
670 let destination = temporary.path().join("destination");
671 let mut opened = StorageEngine::open(&source)?;
672 opened.storage.write(
673 Uuid::now_v7(),
674 &[Mutation::put(b"alpha", b"value".to_vec())],
675 )?;
676 opened.storage.backup(&backup)?;
677 drop(opened);
678
679 let snapshot = backup.join("snapshot.hysnap");
680 let mut file = fs::OpenOptions::new().write(true).open(&snapshot)?;
681 file.seek(SeekFrom::Start(16))?;
682 file.write_all(&[0xff])?;
683 file.sync_all()?;
684 assert!(restore_backup(&backup, &destination).is_err());
685 assert!(!destination.exists());
686 Ok(())
687 }
688
689 #[test]
690 fn backup_refuses_existing_and_live_directory_destinations() -> Result<(), Box<dyn Error>> {
691 let temporary = TestDirectory::new("backup-destinations")?;
692 let source = temporary.path().join("source");
693 let existing = temporary.path().join("existing");
694 fs::create_dir(&existing)?;
695 let opened = StorageEngine::open(&source)?;
696 assert!(matches!(
697 opened.storage.backup(&existing),
698 Err(BackupError::DestinationExists(_))
699 ));
700 assert!(matches!(
701 opened.storage.backup(source.join("nested-backup")),
702 Err(BackupError::DestinationInsideDataDirectory(_))
703 ));
704 Ok(())
705 }
706
707 #[test]
708 fn backup_layout_manifest_and_restore_location_are_bounded() -> Result<(), Box<dyn Error>> {
709 let temporary = TestDirectory::new("backup-input-bounds")?;
710 let source = temporary.path().join("source");
711 let backup = temporary.path().join("backup");
712 let opened = StorageEngine::open(&source)?;
713 opened.storage.backup(&backup)?;
714 drop(opened);
715
716 let extra = backup.join("unexpected");
717 fs::write(&extra, b"unexpected")?;
718 assert!(matches!(
719 verify_backup(&backup),
720 Err(BackupError::InvalidLayout { .. })
721 ));
722 fs::remove_file(extra)?;
723
724 assert!(matches!(
725 restore_backup(&backup, backup.join("nested")),
726 Err(BackupError::RestoreInsideBackup(_))
727 ));
728
729 let manifest = backup.join("BACKUP.json");
730 fs::OpenOptions::new()
731 .write(true)
732 .open(&manifest)?
733 .set_len(64 * 1024 + 1)?;
734 assert!(matches!(
735 verify_backup(&backup),
736 Err(BackupError::InvalidManifest { .. })
737 ));
738 Ok(())
739 }
740
741 #[test]
742 fn empty_backup_restores_as_an_empty_writable_engine() -> Result<(), Box<dyn Error>> {
743 let temporary = TestDirectory::new("backup-empty")?;
744 let source = temporary.path().join("source");
745 let backup = temporary.path().join("backup");
746 let restored = temporary.path().join("restored");
747 let opened = StorageEngine::open(&source)?;
748 let created = opened.storage.backup(&backup)?;
749 assert_eq!(created.snapshot.checkpoint_sequence, 0);
750 drop(opened);
751
752 restore_backup(&backup, &restored)?;
753 let mut reopened = StorageEngine::open(&restored)?;
754 assert_eq!(reopened.storage.get(b"missing")?, None);
755 assert!(matches!(
756 reopened.storage.write(
757 Uuid::now_v7(),
758 &[Mutation::put(b"first", b"value".to_vec())]
759 )?,
760 AppendOutcome::Committed(_)
761 ));
762 Ok(())
763 }
764}