Skip to main content

hyphae_storage/
backup.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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/// Failure while creating, verifying, or restoring a portable backup.
27#[derive(Debug, Error)]
28pub enum BackupError {
29    /// The requested destination already exists and is never replaced.
30    #[error("backup or restore destination already exists: {0}")]
31    DestinationExists(PathBuf),
32
33    /// A backup destination inside the live directory would couple lifecycles.
34    #[error("backup destination must be outside the live data directory: {0}")]
35    DestinationInsideDataDirectory(PathBuf),
36
37    /// A restore destination inside its source backup is unsafe.
38    #[error("restore destination must be outside the backup directory: {0}")]
39    RestoreInsideBackup(PathBuf),
40
41    /// The backup directory does not contain exactly the canonical two files.
42    #[error("invalid backup layout at {path}: {reason}")]
43    InvalidLayout {
44        /// Backup path being validated.
45        path: PathBuf,
46        /// Stable validation reason.
47        reason: &'static str,
48    },
49
50    /// The bounded JSON manifest is malformed or disagrees with its snapshot.
51    #[error("invalid backup manifest at {path}: {reason}")]
52    InvalidManifest {
53        /// Manifest path being validated.
54        path: PathBuf,
55        /// Stable validation reason.
56        reason: &'static str,
57    },
58
59    /// Backup JSON could not be decoded.
60    #[error("failed to decode backup manifest {path}: {source}")]
61    ManifestJson {
62        /// Manifest path being decoded.
63        path: PathBuf,
64        /// JSON decoding failure.
65        #[source]
66        source: serde_json::Error,
67    },
68
69    /// Snapshot creation or validation failed.
70    #[error(transparent)]
71    Snapshot(#[from] SnapshotError),
72
73    /// Opening or validating restored storage failed before activation.
74    #[error(transparent)]
75    Storage(#[from] StorageError),
76
77    /// A filesystem operation failed.
78    #[error("failed to {action} {path}: {source}")]
79    Io {
80        /// Operation being performed.
81        action: &'static str,
82        /// Path involved in the operation.
83        path: PathBuf,
84        /// Operating-system failure.
85        #[source]
86        source: io::Error,
87    },
88}
89
90/// Verified metadata for one portable backup directory.
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct BackupInfo {
93    /// Canonical backup directory.
94    pub path: PathBuf,
95    /// Verified logical snapshot stored by the backup.
96    pub snapshot: SnapshotInfo,
97}
98
99/// Evidence that a backup was fully verified before destination activation.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct RestoreInfo {
102    /// Newly activated data directory.
103    pub data_path: PathBuf,
104    /// Logical snapshot verified after index reconstruction and reopen.
105    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
207/// Verifies a backup layout, bounded manifest, and complete snapshot.
208///
209/// # Errors
210///
211/// Returns an error for unexpected files, symlinks, malformed metadata,
212/// snapshot corruption, or any manifest/snapshot mismatch.
213pub 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
232/// Restores a verified backup to a new data directory.
233///
234/// The destination name becomes visible only after the snapshot is installed,
235/// the materialized index is rebuilt, and the complete storage engine reopens
236/// at the expected checkpoint.
237///
238/// # Errors
239///
240/// Returns an error when verification fails, the destination exists, or any
241/// staging, index-rebuild, reopen, or atomic-promotion operation fails.
242pub 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}