Skip to main content

hayson_engine/database/
backup.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use serde_json::{json, Value};
7
8use crate::atomic::write_atomic;
9use crate::catalog::{parse_catalog, read_catalog_text, Catalog, ReadCatalogError};
10use crate::crypto::{CipherOptions, Sealer};
11use crate::errors::{HaysonCipherError, HaysonSchemaError};
12use crate::lock::DirLock;
13use crate::storage::{partition_names_in, LOG_SUFFIX};
14
15use super::database::{Database, DatabaseError, HeldLock, LockMode};
16use super::layout::{Layout, LayoutOptions};
17use super::verify::{verify, VerifyOptions};
18
19/// The manifest a backup carries next to the files it copied.
20pub const BACKUP_MANIFEST: &str = "hayson-backup.json";
21const BACKUP_FORMAT: u64 = 1;
22const RESTORE_OLD: &str = ".restore-old";
23const RESTORE_NEW: &str = ".restore-new";
24
25#[derive(Debug, Clone)]
26pub struct BackupOptions {
27    /// How the writer lock is taken for the length of the copy. [`LockMode::Skip`] copies a database
28    /// a writer may be appending to, which can capture a half-written last line (the engine cuts
29    /// such a tail off on the next writable open) — but nothing worse.
30    pub lock: LockMode,
31    /// Also copy the `<table>.idx` snapshots. Off by default: they are caches the engine rebuilds
32    /// from the logs, so leaving them out makes a backup smaller and no less complete.
33    pub indexes: bool,
34    /// Tables whose data (log files, partitions, images, snapshot) is left out; their declarations
35    /// stay in the metadata document, so the copy opens with them empty. A name the database does
36    /// not declare is refused.
37    pub exclude_tables: Vec<String>,
38    /// The keys of an encrypted database: the files are copied as they are (still sealed), but the
39    /// metadata document has to be opened to know which files the database owns.
40    pub encryption: Option<CipherOptions>,
41}
42
43impl Default for BackupOptions {
44    fn default() -> Self {
45        BackupOptions {
46            lock: LockMode::default(),
47            indexes: false,
48            exclude_tables: Vec::new(),
49            encryption: None,
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BackupResult {
56    pub dest: PathBuf,
57    pub files: usize,
58    pub bytes: u64,
59}
60
61#[derive(Debug, Clone, Default)]
62pub struct RestoreOptions {
63    /// How the writer lock on the directory being restored into is taken. As in [`BackupOptions`].
64    pub lock: LockMode,
65    /// Refuses a backup holding an image file larger than this, before anything is replaced.
66    pub max_image_bytes: Option<u64>,
67    /// The keys of the encrypted database being restored into and restored from.
68    pub encryption: Option<CipherOptions>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RestoreResult {
73    pub dir: PathBuf,
74    pub files: usize,
75    pub bytes: u64,
76    /// Files of the database that was there before and were replaced.
77    pub replaced: usize,
78}
79
80/// The sealer for the keys of an encrypted database, if any were given.
81pub(super) fn sealer_of(encryption: Option<&CipherOptions>) -> Result<Option<std::sync::Arc<Sealer>>, DatabaseError> {
82    Ok(encryption.map(Sealer::new).transpose()?.map(std::sync::Arc::new))
83}
84
85pub(super) fn refuse(message: String) -> DatabaseError {
86    DatabaseError::Schema(HaysonSchemaError(message))
87}
88
89pub(super) fn read_catalog_of(layout: &Layout, sealer: Option<&Sealer>) -> Result<Catalog, DatabaseError> {
90    let path = layout.metadata_path();
91    let raw = match read_catalog_text(path, sealer) {
92        Ok(Some(raw)) => raw,
93        Ok(None) => {
94            return Err(refuse(format!(
95                "\"{}\" does not exist — there is no database to back up",
96                path.display()
97            )))
98        }
99        Err(ReadCatalogError::Io(error)) => return Err(DatabaseError::Io(error)),
100        Err(
101            ReadCatalogError::Cipher(error)
102            | ReadCatalogError::Encrypted(error)
103            | ReadCatalogError::NotEncrypted(error),
104        ) => return Err(DatabaseError::Cipher(Box::new(error))),
105    };
106    let value: Value = serde_json::from_str(&raw)
107        .map_err(|error| DatabaseError::Catalog(format!("\"{}\" is not valid JSON: {error}", path.display())))?;
108    Ok(parse_catalog(&value)?)
109}
110
111fn relative_safely(root: &Path, path: &Path) -> Result<PathBuf, DatabaseError> {
112    let relative = path
113        .strip_prefix(root)
114        .map_err(|_| refuse(format!("\"{}\" is outside the database directory", path.display())))?;
115    check_relative(relative)?;
116    Ok(relative.to_path_buf())
117}
118
119/// A path a backup or a restore may join onto a root: relative, no `..`, no root, no prefix.
120pub(super) fn check_relative(path: &Path) -> Result<(), DatabaseError> {
121    let sane = path.components().all(|part| matches!(part, Component::Normal(_)));
122    if sane && path.components().next().is_some() {
123        Ok(())
124    } else {
125        Err(refuse(format!(
126            "\"{}\" is not a path inside the database directory",
127            path.display()
128        )))
129    }
130}
131
132fn files_in(dir: &Path) -> Result<Vec<PathBuf>, DatabaseError> {
133    let mut found = Vec::new();
134    let Ok(entries) = fs::read_dir(dir) else {
135        return Ok(found);
136    };
137    for entry in entries {
138        let entry = entry?;
139        let kind = entry.file_type()?;
140        if kind.is_symlink() {
141            return Err(refuse(format!(
142                "\"{}\" is a symbolic link — refusing to follow it",
143                entry.path().display()
144            )));
145        }
146        if kind.is_file() {
147            found.push(entry.path());
148        }
149    }
150    found.sort();
151    Ok(found)
152}
153
154/// What a file the database owns is, for the callers that treat the kinds differently.
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
156pub(super) enum Owned {
157    Metadata,
158    /// A table's log or one of its partitions.
159    Log(String),
160    /// A file in a table's image directory.
161    Image(String),
162    Index(String),
163}
164
165/// Every file the database at `root` accounts for, as paths relative to `root`: its metadata
166/// document, the logs of its tables (one file, or one per partition), the files under each table's
167/// image directory and, when asked, the index snapshots. Anything else in the directory — another
168/// database in a subfolder, say — is not the database's and is left out.
169pub(super) fn owned_files(
170    root: &Path,
171    layout: &Layout,
172    catalog: &Catalog,
173    indexes: bool,
174    exclude: &[String],
175) -> Result<Vec<PathBuf>, DatabaseError> {
176    Ok(owned_entries(root, layout, catalog, indexes, exclude)?
177        .into_iter()
178        .map(|(path, _)| path)
179        .collect())
180}
181
182/// [`owned_files`] with what each file is.
183pub(super) fn owned_entries(
184    root: &Path,
185    layout: &Layout,
186    catalog: &Catalog,
187    indexes: bool,
188    exclude: &[String],
189) -> Result<Vec<(PathBuf, Owned)>, DatabaseError> {
190    if let Some(unknown) = exclude.iter().find(|name| !catalog.tables.contains_key(*name)) {
191        return Err(refuse(format!(
192            "cannot leave out table \"{unknown}\" — the database declares no such table"
193        )));
194    }
195    let mut owned: BTreeMap<PathBuf, Owned> = BTreeMap::new();
196    let mut add = |path: &Path, kind: Owned| -> Result<(), DatabaseError> {
197        owned.insert(relative_safely(root, path)?, kind);
198        Ok(())
199    };
200    add(layout.metadata_path(), Owned::Metadata)?;
201    for (name, entry) in &catalog.tables {
202        if exclude.contains(name) {
203            continue;
204        }
205        let flat = layout.log_path(name);
206        match fs::symlink_metadata(&flat) {
207            Ok(meta) if meta.file_type().is_symlink() => {
208                return Err(refuse(format!(
209                    "\"{}\" is a symbolic link — refusing to follow it",
210                    flat.display()
211                )))
212            }
213            Ok(meta) if meta.is_file() => add(&flat, Owned::Log(name.clone()))?,
214            _ => {}
215        }
216        let partitions = layout.tables().join(name);
217        for part in partition_names_in(&partitions) {
218            add(
219                &partitions.join(format!("{part}{LOG_SUFFIX}")),
220                Owned::Log(name.clone()),
221            )?;
222        }
223        if indexes && layout.index_path(name).is_file() {
224            add(&layout.index_path(name), Owned::Index(name.clone()))?;
225        }
226        let image_dir = entry
227            .options
228            .as_ref()
229            .and_then(|options| options.image_dir.clone())
230            .or_else(|| catalog.defaults.as_ref().and_then(|d| d.image_dir.clone()))
231            .map(|dir| root.join(dir))
232            .unwrap_or_else(|| layout.tables().join(format!("{name}.images")));
233        for file in files_in(&image_dir)? {
234            add(&file, Owned::Image(name.clone()))?;
235        }
236    }
237    Ok(owned.into_iter().collect())
238}
239
240/// The flat log files of the tables `exclude` leaves out, as paths relative to `root`. They are
241/// packed or copied empty: the catalog still declares those tables, and an unpacked database whose
242/// declared table has no log at all does not open read-only.
243pub(super) fn emptied_logs(root: &Path, layout: &Layout, catalog: &Catalog, exclude: &[String]) -> Vec<PathBuf> {
244    exclude
245        .iter()
246        .filter(|name| {
247            catalog
248                .tables
249                .get(*name)
250                .is_some_and(|entry| entry.partition_by.is_none())
251        })
252        .filter_map(|name| relative_safely(root, &layout.log_path(name)).ok())
253        .collect()
254}
255
256pub(super) fn hold(root: &Path, layout: &Layout, lock: &LockMode) -> Result<HeldLock, DatabaseError> {
257    let LockMode::Enabled(options) = lock else {
258        return Ok(HeldLock(None));
259    };
260    if root.is_dir() {
261        fs::create_dir_all(layout.runtime())?;
262    }
263    Ok(HeldLock(Some(DirLock::acquire_as(
264        layout.lock_path(),
265        options.clone(),
266        root.to_path_buf(),
267    )?)))
268}
269
270fn copy_synced(from: &Path, to: &Path) -> io::Result<u64> {
271    if let Some(parent) = to.parent() {
272        fs::create_dir_all(parent)?;
273    }
274    let bytes = fs::copy(from, to)?;
275    fs::File::open(to)?.sync_all()?;
276    Ok(bytes)
277}
278
279/// Copies a database directory into `dest`: the files the database accounts for (see
280/// [`owned_files`]) plus a `hayson-backup.json` manifest listing them with their sizes.
281///
282/// `dest` must not exist or must be an empty directory. The copy is built next to it and renamed
283/// into place, so a backup that fails halfway leaves no half-filled `dest`. The directory is held
284/// under the writer lock for the length of the copy unless `options.lock` is [`LockMode::Skip`];
285/// a database with an unfinished unit of work is refused, because its log and journal disagree
286/// until a writable open finishes it.
287///
288/// History kept in a database of its own is not part of this database's files, so it is not copied.
289pub fn backup_database(dir: &Path, dest: &Path, options: &BackupOptions) -> Result<BackupResult, DatabaseError> {
290    let sealer = sealer_of(options.encryption.as_ref())?;
291    let layout = Database::resolve_layout_sealed(dir, &LayoutOptions::default(), sealer.as_deref())?;
292    let catalog = read_catalog_of(&layout, sealer.as_deref())?;
293    if dest.exists()
294        && fs::read_dir(dest)
295            .map(|mut entries| entries.next().is_some())
296            .unwrap_or(true)
297    {
298        return Err(refuse(format!(
299            "\"{}\" already exists and is not an empty directory",
300            dest.display()
301        )));
302    }
303    let partial = sibling(dest, ".partial");
304    if partial.exists() {
305        fs::remove_dir_all(&partial)?;
306    }
307
308    let held = hold(dir, &layout, &options.lock)?;
309    let outcome = copy_database(dir, &layout, &catalog, &partial, options);
310    drop(held);
311    match outcome {
312        Ok(result) => {
313            if dest.exists() {
314                fs::remove_dir(dest)?;
315            }
316            fs::rename(&partial, dest)?;
317            Ok(BackupResult {
318                dest: dest.to_path_buf(),
319                ..result
320            })
321        }
322        Err(error) => {
323            let _ = fs::remove_dir_all(&partial);
324            Err(error)
325        }
326    }
327}
328
329pub(super) fn sibling(path: &Path, suffix: &str) -> PathBuf {
330    let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
331    name.push(suffix);
332    path.with_file_name(name)
333}
334
335fn copy_database(
336    root: &Path,
337    layout: &Layout,
338    catalog: &Catalog,
339    partial: &Path,
340    options: &BackupOptions,
341) -> Result<BackupResult, DatabaseError> {
342    if layout.journal_path().exists() {
343        return Err(refuse(format!(
344            "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then back it up",
345            layout.journal_path().display()
346        )));
347    }
348    let mut files = owned_files(root, layout, catalog, options.indexes, &options.exclude_tables)?;
349    let emptied = emptied_logs(root, layout, catalog, &options.exclude_tables);
350    files.extend(emptied.iter().cloned());
351    files.sort();
352    fs::create_dir_all(partial)?;
353    let mut listed = Vec::new();
354    let mut total = 0u64;
355    for relative in &files {
356        let bytes = if emptied.contains(relative) {
357            let target = partial.join(relative);
358            if let Some(parent) = target.parent() {
359                fs::create_dir_all(parent)?;
360            }
361            fs::File::create(&target)?.sync_all()?;
362            0
363        } else {
364            copy_synced(&root.join(relative), &partial.join(relative))?
365        };
366        total += bytes;
367        listed.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "bytes": bytes }));
368    }
369    let manifest = json!({
370        "format": BACKUP_FORMAT,
371        "createdAt": chrono::Utc::now().timestamp_millis(),
372        "engine": env!("CARGO_PKG_VERSION"),
373        "metadataFile": layout.metadata_file(),
374        "files": listed,
375    });
376    let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
377    text.push('\n');
378    write_atomic(&partial.join(BACKUP_MANIFEST), text.as_bytes(), None)?;
379    Ok(BackupResult {
380        dest: partial.to_path_buf(),
381        files: files.len(),
382        bytes: total,
383    })
384}
385
386#[derive(Clone)]
387pub(super) struct ManifestEntry {
388    pub(super) relative: PathBuf,
389    pub(super) bytes: u64,
390}
391
392fn read_manifest(backup: &Path) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
393    let path = backup.join(BACKUP_MANIFEST);
394    let raw = fs::read_to_string(&path).map_err(|error| {
395        refuse(format!(
396            "\"{}\" is not a readable backup manifest: {error}",
397            path.display()
398        ))
399    })?;
400    parse_manifest(&raw, &path.display().to_string())
401}
402
403pub(super) fn parse_manifest(raw: &str, label: &str) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
404    let manifest: Value =
405        serde_json::from_str(raw).map_err(|error| refuse(format!("\"{label}\" is not valid JSON: {error}")))?;
406    if manifest.get("format").and_then(Value::as_u64) != Some(BACKUP_FORMAT) {
407        return Err(refuse(format!("\"{label}\" is not a backup format this engine reads")));
408    }
409    let metadata_file = manifest
410        .get("metadataFile")
411        .and_then(Value::as_str)
412        .ok_or_else(|| refuse("backup manifest names no \"metadataFile\"".to_string()))?
413        .to_string();
414    let listed = manifest
415        .get("files")
416        .and_then(Value::as_array)
417        .ok_or_else(|| refuse("backup manifest lists no \"files\"".to_string()))?;
418    let mut entries = Vec::new();
419    for item in listed {
420        let relative = PathBuf::from(
421            item.get("path")
422                .and_then(Value::as_str)
423                .ok_or_else(|| refuse("a backup manifest entry has no \"path\"".to_string()))?,
424        );
425        check_relative(&relative)?;
426        let bytes = item
427            .get("bytes")
428            .and_then(Value::as_u64)
429            .ok_or_else(|| refuse(format!("manifest entry \"{}\" has no \"bytes\"", relative.display())))?;
430        entries.push(ManifestEntry { relative, bytes });
431    }
432    Ok((metadata_file, entries))
433}
434
435/// Restores a backup made by [`backup_database`] into `dir`, replacing the database that is there.
436///
437/// Recoverable: nothing is deleted until the replacement is in place. The backup is copied into
438/// `<dir>/.restore-new` and checked (sizes against the manifest, then [`verify`] with no error);
439/// the files of the database being replaced are moved into `<dir>/.restore-old`; the new files are
440/// moved in; and only then is `.restore-old` removed. If a move fails, the files already moved are
441/// put back, and when even that fails both errors are reported. A `.restore-old` left by an
442/// interrupted restore is refused, because it may hold the only good copy.
443///
444/// Only the files the database accounts for are replaced, so another database living in a
445/// subfolder of `dir` is untouched.
446pub fn restore_database(backup: &Path, dir: &Path, options: &RestoreOptions) -> Result<RestoreResult, DatabaseError> {
447    let (metadata_file, entries) = read_manifest(backup)?;
448    for entry in &entries {
449        let path = backup.join(&entry.relative);
450        let meta = fs::symlink_metadata(&path).map_err(|error| {
451            refuse(format!(
452                "backup file \"{}\" is missing: {error}",
453                entry.relative.display()
454            ))
455        })?;
456        if !meta.is_file() || meta.len() != entry.bytes {
457            return Err(refuse(format!(
458                "backup file \"{}\" does not match its manifest entry ({} bytes expected)",
459                entry.relative.display(),
460                entry.bytes
461            )));
462        }
463    }
464    let part = RestorePart {
465        sub: PathBuf::new(),
466        encryption: options.encryption.clone(),
467        current_encryption: None,
468        metadata_file,
469        entries: entries.clone(),
470    };
471    restore_staged(dir, vec![part], &options.lock, options.max_image_bytes, |staged, _| {
472        let mut total = 0u64;
473        for entry in &entries {
474            total += copy_synced(&backup.join(&entry.relative), &staged.join(&entry.relative))?;
475        }
476        Ok(total)
477    })
478}
479
480/// One database of a restore: where it lives under the directory being restored into (`sub`, empty
481/// for the directory itself), its metadata file name, and its files as paths relative to that
482/// directory (already prefixed with `sub`).
483pub(super) struct RestorePart {
484    pub(super) sub: PathBuf,
485    /// The keys of this database, when it is encrypted.
486    pub(super) encryption: Option<CipherOptions>,
487    /// The keys of the database that is on disk now and is being replaced, when they differ from
488    /// `encryption` (converting to or from encryption, rotating the key); `None` means the same.
489    pub(super) current_encryption: Option<Option<CipherOptions>>,
490    pub(super) metadata_file: String,
491    pub(super) entries: Vec<ManifestEntry>,
492}
493
494/// The recoverable half of a restore, shared with unpacking: `stage` fills the given staging
495/// directory with the files of every part (returning their byte total) while the writer lock of each
496/// database is held. All parts are staged and verified before any of them is swapped in, and the
497/// swap moves every part's files through one `.restore-old`, so a failure puts all of them back.
498pub(super) fn restore_staged(
499    dir: &Path,
500    mut parts: Vec<RestorePart>,
501    lock: &LockMode,
502    max_image_bytes: Option<u64>,
503    stage: impl FnOnce(&Path, &mut [RestorePart]) -> Result<u64, DatabaseError>,
504) -> Result<RestoreResult, DatabaseError> {
505    let old = dir.join(RESTORE_OLD);
506    if old.exists() {
507        return Err(refuse(format!(
508            "\"{}\" exists — an earlier restore was interrupted and it may hold the only good copy of the database; move its files back (or delete it) before restoring again",
509            old.display()
510        )));
511    }
512    let mut created: Vec<PathBuf> = Vec::new();
513    let mut made_roots: Vec<bool> = Vec::new();
514    let mut layouts = Vec::new();
515    let mut held = Vec::new();
516    for part in &parts {
517        let root = dir.join(&part.sub);
518        // NOTE: the topmost directory this call is about to make, so a refusal can take it away again.
519        let first_new = root.ancestors().take_while(|path| !path.exists()).last();
520        made_roots.push(first_new.is_some());
521        if let Some(first_new) = first_new {
522            created.push(first_new.to_path_buf());
523        }
524        fs::create_dir_all(&root)?;
525        let sealer = sealer_of(part.current_encryption.as_ref().unwrap_or(&part.encryption).as_ref())?;
526        let layout = Database::resolve_layout_sealed(
527            &root,
528            &LayoutOptions {
529                metadata_file: Some(part.metadata_file.clone()),
530                ..LayoutOptions::default()
531            },
532            sealer.as_deref(),
533        )?;
534        held.push(hold(&root, &layout, lock)?);
535        layouts.push(layout);
536    }
537    let staged = dir.join(RESTORE_NEW);
538    let outcome = install(dir, &layouts, &mut parts, &staged, &old, max_image_bytes, stage);
539    let _ = fs::remove_dir_all(&staged);
540    drop(held);
541    if outcome.is_err() {
542        // NOTE: directories this call made and nothing was installed into must not outlive the refusal.
543        for (layout, made) in layouts.iter().zip(&made_roots) {
544            if *made {
545                let _ = fs::remove_dir_all(layout.runtime());
546            }
547        }
548        for path in created.iter().rev() {
549            let _ = fs::remove_dir(path);
550            let mut parent = path.parent();
551            while let Some(up) = parent {
552                if up == dir || fs::remove_dir(up).is_err() {
553                    break;
554                }
555                parent = up.parent();
556            }
557        }
558        if dir.exists() && created.iter().any(|path| path == dir) {
559            let _ = fs::remove_dir(dir);
560        }
561    }
562    outcome
563}
564
565fn install(
566    root: &Path,
567    layouts: &[Layout],
568    parts: &mut [RestorePart],
569    staged: &Path,
570    old: &Path,
571    max_image_bytes: Option<u64>,
572    stage: impl FnOnce(&Path, &mut [RestorePart]) -> Result<u64, DatabaseError>,
573) -> Result<RestoreResult, DatabaseError> {
574    if staged.exists() {
575        fs::remove_dir_all(staged)?;
576    }
577    let total = stage(staged, parts)?;
578    for part in parts.iter() {
579        let report = verify(
580            &staged.join(&part.sub),
581            &VerifyOptions {
582                relations: false,
583                max_image_bytes,
584                encryption: part.encryption.clone(),
585                ..VerifyOptions::default()
586            },
587        );
588        let which = if part.sub.as_os_str().is_empty() {
589            String::new()
590        } else {
591            format!(" (database \"{}\")", part.sub.display())
592        };
593        if let Some(problem) = report.problems.iter().find(|p| {
594            matches!(
595                p.code,
596                "catalog-encrypted" | "catalog-key-rejected" | "catalog-not-encrypted"
597            )
598        }) {
599            return Err(DatabaseError::Cipher(Box::new(HaysonCipherError::new(
600                "",
601                crate::crypto::STORAGE_FIELD,
602                format!("{which}: {}", problem.message),
603            ))));
604        }
605        if let Some(problem) = report.problems.iter().find(|p| p.code == "image-oversize") {
606            return Err(refuse(format!(
607                "the backup holds an image over the size limit{which} ({}) — nothing was changed",
608                problem.message
609            )));
610        }
611        if let Some(problem) = report
612            .problems
613            .iter()
614            .find(|p| p.severity == super::verify::Severity::Error)
615        {
616            return Err(refuse(format!(
617                "the backup does not verify{which} ({}: {}) — nothing was changed",
618                problem.code, problem.message
619            )));
620        }
621    }
622
623    // The files of the databases now there: what each catalog accounts for (when it can be read),
624    // plus whatever the backup is about to overwrite.
625    let entries: Vec<&ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter()).collect();
626    let mut replaced: BTreeSet<PathBuf> = entries.iter().map(|e| e.relative.clone()).collect();
627    for (part, layout) in parts.iter().zip(layouts.iter()) {
628        let part_root = root.join(&part.sub);
629        let current = part.current_encryption.as_ref().unwrap_or(&part.encryption);
630        let sealer = sealer_of(current.as_ref()).ok().flatten();
631        if let Ok(catalog) = read_catalog_of(layout, sealer.as_deref()) {
632            if let Ok(current) = owned_files(&part_root, layout, &catalog, true, &[]) {
633                replaced.extend(current.into_iter().map(|relative| part.sub.join(relative)));
634            }
635        }
636    }
637    replaced.retain(|relative| root.join(relative).is_file());
638
639    fs::create_dir_all(old)?;
640    let mut moved_out: Vec<PathBuf> = Vec::new();
641    let mut moved_in: Vec<PathBuf> = Vec::new();
642    let swap = (|| -> io::Result<()> {
643        for relative in &replaced {
644            move_file(&root.join(relative), &old.join(relative))?;
645            moved_out.push(relative.clone());
646        }
647        for entry in &entries {
648            move_file(&staged.join(&entry.relative), &root.join(&entry.relative))?;
649            moved_in.push(entry.relative.clone());
650        }
651        Ok(())
652    })();
653    match swap {
654        Ok(()) => {
655            fs::remove_dir_all(old)?;
656            Ok(RestoreResult {
657                dir: root.to_path_buf(),
658                files: entries.len(),
659                bytes: total,
660                replaced: replaced.len(),
661            })
662        }
663        Err(error) => {
664            let rollback = (|| -> io::Result<()> {
665                for relative in &moved_in {
666                    fs::remove_file(root.join(relative))?;
667                }
668                for relative in &moved_out {
669                    move_file(&old.join(relative), &root.join(relative))?;
670                }
671                Ok(())
672            })();
673            match rollback {
674                Ok(()) => {
675                    let _ = fs::remove_dir_all(old);
676                    Err(DatabaseError::Io(error))
677                }
678                Err(rollback_error) => Err(refuse(format!(
679                    "restore failed ({error}) and putting the previous files back failed too ({rollback_error}) — the previous database is in \"{}\"",
680                    old.display()
681                ))),
682            }
683        }
684    }
685}
686
687fn move_file(from: &Path, to: &Path) -> io::Result<()> {
688    if let Some(parent) = to.parent() {
689        fs::create_dir_all(parent)?;
690    }
691    fs::rename(from, to)
692}