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