Skip to main content

hyphae_storage/
backup.rs

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