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, 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/// 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    #[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
220/// Verifies a backup layout, bounded manifest, and complete snapshot.
221///
222/// # Errors
223///
224/// Returns an error for unexpected files, symlinks, malformed metadata,
225/// snapshot corruption, or any manifest/snapshot mismatch.
226pub 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
245/// Restores a verified backup to a new data directory.
246///
247/// The destination name becomes visible only after the snapshot is installed,
248/// the materialized index is rebuilt, and the complete storage engine reopens
249/// at the expected checkpoint.
250///
251/// # Errors
252///
253/// Returns an error when verification fails, the destination exists, or any
254/// staging, index-rebuild, reopen, or atomic-promotion operation fails.
255pub 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}