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