Skip to main content

powdb_backup/
restore.rs

1use crate::manifest::{BackupManifest, SyncSnapshotMetadata};
2use powdb_storage::catalog::{Catalog, CATALOG_LSN_FILE};
3use std::io;
4use std::path::Path;
5
6/// Controls how restore writes sync identity metadata.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum RestoreSyncMode {
9    /// Restore the durable database files but do not recreate sync identity.
10    /// This is the default for ordinary backup restore so restored data dirs
11    /// remain safe for the plain PowDB engine lifecycle.
12    StripSyncIdentity,
13    /// Restore keeps the source database identity. This is the right mode for
14    /// disaster recovery of the same sync lineage through sync-aware
15    /// open/checkpoint paths.
16    PreserveSyncIdentity,
17    /// Restore mints a fresh database identity after verifying the source sync
18    /// snapshot metadata. Use this for clone/fork restores that must not share
19    /// replication lineage with the source database.
20    ForkWithNewSyncIdentity,
21}
22
23/// Refuse a non-empty destination: a stale wal.log left there would replay
24/// onto the restored data on `Catalog::open` and corrupt it. Restore requires
25/// a fresh or empty directory. A nonexistent or empty dest is fine.
26pub(crate) fn ensure_empty_dir(dest_data_dir: &Path) -> io::Result<()> {
27    if dest_data_dir.exists() && dest_data_dir.read_dir()?.next().is_some() {
28        return Err(io::Error::other(format!(
29            "restore destination {} is not empty; restore requires a fresh or empty directory",
30            dest_data_dir.display()
31        )));
32    }
33    std::fs::create_dir_all(dest_data_dir)?;
34    Ok(())
35}
36
37fn is_plain_manifest_name(name: &str) -> bool {
38    !name.is_empty()
39        && name != "."
40        && name != ".."
41        && !name.contains('/')
42        && !name.contains('\\')
43        && !name.contains(':')
44        && name
45            .chars()
46            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
47}
48
49pub(crate) fn validate_backup_file_name(name: &str) -> io::Result<()> {
50    let durable_name = name == "catalog.bin"
51        || name == CATALOG_LSN_FILE
52        || (name.ends_with(".heap") && name.len() > ".heap".len())
53        || (name.ends_with(".idx") && name.len() > ".idx".len())
54        || (name.ends_with(".eidx") && name.len() > ".eidx".len());
55    if !is_plain_manifest_name(name) || !durable_name {
56        return Err(io::Error::other(format!(
57            "invalid backup manifest file name: {name}"
58        )));
59    }
60    Ok(())
61}
62
63pub(crate) fn validate_delta_file_name(delta_file: &str, data_file: &str) -> io::Result<()> {
64    validate_backup_file_name(data_file)?;
65    if !data_file.ends_with(".heap") {
66        return Err(io::Error::other(format!(
67            "invalid backup manifest delta target file name: {data_file}"
68        )));
69    }
70    let expected = format!("{data_file}.delta");
71    if !is_plain_manifest_name(delta_file) || delta_file != expected {
72        return Err(io::Error::other(format!(
73            "invalid backup manifest delta file name: {delta_file}"
74        )));
75    }
76    Ok(())
77}
78
79/// Verify every file in a full backup's manifest against its blake3, then copy
80/// it into `dest`. Does NOT open the catalog or write sync identity metadata —
81/// callers decide when to validate. Assumes `dest` already exists.
82pub(crate) fn verify_and_copy_full(
83    manifest: &BackupManifest,
84    backup_dir: &Path,
85    dest_data_dir: &Path,
86) -> io::Result<()> {
87    for f in &manifest.files {
88        validate_backup_file_name(&f.name)?;
89        let bytes = std::fs::read(backup_dir.join(&f.name))?;
90        let hash = blake3::hash(&bytes).to_hex().to_string();
91        if hash != f.blake3_hex {
92            return Err(io::Error::other(format!(
93                "integrity check failed for {}: blake3 mismatch (backup is corrupt)",
94                f.name
95            )));
96        }
97        std::fs::write(dest_data_dir.join(&f.name), &bytes)?;
98    }
99    Ok(())
100}
101
102pub(crate) fn verify_restored_sync_catalog(
103    sync: &SyncSnapshotMetadata,
104    dest_data_dir: &Path,
105) -> io::Result<()> {
106    let catalog_bytes = std::fs::read(dest_data_dir.join("catalog.bin"))?;
107    let catalog_hash = blake3::hash(&catalog_bytes).to_hex().to_string();
108    if catalog_hash != sync.catalog_blake3_hex {
109        return Err(io::Error::new(
110            io::ErrorKind::InvalidData,
111            "restored catalog hash does not match sync snapshot metadata",
112        ));
113    }
114    Ok(())
115}
116
117pub(crate) fn apply_restore_sync_mode(
118    sync: Option<&SyncSnapshotMetadata>,
119    dest_data_dir: &Path,
120    sync_mode: RestoreSyncMode,
121) -> io::Result<()> {
122    match sync_mode {
123        RestoreSyncMode::StripSyncIdentity => {
124            if let Some(sync) = sync {
125                verify_restored_sync_catalog(sync, dest_data_dir)?;
126            }
127        }
128        RestoreSyncMode::PreserveSyncIdentity => {
129            let sync = sync.ok_or_else(|| {
130                io::Error::new(
131                    io::ErrorKind::InvalidInput,
132                    "preserve sync identity restore requires sync snapshot metadata",
133                )
134            })?;
135            verify_restored_sync_catalog(sync, dest_data_dir)?;
136            powdb_sync::write_identity_snapshot(dest_data_dir, &sync.identity)?;
137        }
138        RestoreSyncMode::ForkWithNewSyncIdentity => {
139            if let Some(sync) = sync {
140                verify_restored_sync_catalog(sync, dest_data_dir)?;
141            }
142            let _ = powdb_sync::open_or_create_identity(dest_data_dir)?;
143        }
144    }
145    Ok(())
146}
147
148/// Rebuild a data dir from a full backup. Verifies every file's blake3 against
149/// the manifest BEFORE writing it, then opens the result through
150/// `Catalog::open` (which sets `next_lsn = max_page_lsn + 1` — the v0.4.3
151/// LSN-reset fix) to validate that the restored database actually opens.
152pub fn restore(backup_dir: &Path, dest_data_dir: &Path) -> io::Result<()> {
153    restore_with_sync_mode(
154        backup_dir,
155        dest_data_dir,
156        RestoreSyncMode::StripSyncIdentity,
157    )
158}
159
160/// Rebuild a data dir from a full backup with explicit sync identity semantics.
161/// `restore` calls this with `StripSyncIdentity` so ordinary restored data dirs
162/// stay safe for the plain PowDB engine lifecycle.
163pub fn restore_with_sync_mode(
164    backup_dir: &Path,
165    dest_data_dir: &Path,
166    sync_mode: RestoreSyncMode,
167) -> io::Result<()> {
168    let manifest = BackupManifest::read(backup_dir)?;
169    ensure_empty_dir(dest_data_dir)?;
170    verify_and_copy_full(&manifest, backup_dir, dest_data_dir)?;
171    apply_restore_sync_mode(manifest.sync.as_ref(), dest_data_dir, sync_mode)?;
172    // Validate: opening must succeed and reset next_lsn correctly.
173    let cat = Catalog::open(dest_data_dir)?;
174    if cat.active_catalog_version() != manifest.catalog_version {
175        return Err(io::Error::new(
176            io::ErrorKind::InvalidData,
177            "restored catalog format does not match backup manifest",
178        ));
179    }
180    drop(cat);
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn manifest_file_names_reject_path_traversal() {
190        for bad in [
191            "../catalog.bin",
192            "/tmp/catalog.bin",
193            "nested/catalog.bin",
194            "nested\\catalog.bin",
195            "C:\\tmp\\catalog.bin",
196            "",
197            ".",
198            "..",
199            ".heap",
200            ".idx",
201            ".eidx",
202            "wal.log",
203        ] {
204            assert!(
205                validate_backup_file_name(bad).is_err(),
206                "{bad:?} must be rejected"
207            );
208        }
209    }
210
211    #[test]
212    fn manifest_file_names_accept_only_durable_root_files() {
213        for good in [
214            "catalog.bin",
215            CATALOG_LSN_FILE,
216            "User.heap",
217            "User_email.idx",
218            "User_7.eidx",
219        ] {
220            validate_backup_file_name(good).unwrap();
221        }
222    }
223
224    #[test]
225    fn delta_file_must_match_paged_file_name() {
226        validate_delta_file_name("User.heap.delta", "User.heap").unwrap();
227        assert!(validate_delta_file_name("User_email.idx.delta", "User_email.idx").is_err());
228        assert!(validate_delta_file_name("../User.heap.delta", "User.heap").is_err());
229        assert!(validate_delta_file_name("Other.heap.delta", "User.heap").is_err());
230        assert!(validate_delta_file_name("catalog.bin.delta", "catalog.bin").is_err());
231        assert!(validate_delta_file_name("catalog.lsn.delta", CATALOG_LSN_FILE).is_err());
232    }
233}