switchyard/fs/restore/
engine.rs

1use std::path::{Path, PathBuf};
2
3use super::{
4    idempotence, integrity, selector, steps,
5    types::{RestoreOptions, SnapshotSel},
6};
7use crate::fs::backup::sidecar::read_sidecar;
8use crate::types::safepath::SafePath;
9
10/// Restore a file from its backup. When no backup exists, return an error unless `force_best_effort` is true.
11///
12/// # Errors
13///
14/// Returns an IO error if the backup file cannot be restored.
15pub fn restore_file(
16    target: &SafePath,
17    dry_run: bool,
18    force_best_effort: bool,
19    backup_tag: &str,
20) -> std::io::Result<()> {
21    let opts = RestoreOptions {
22        dry_run,
23        force_best_effort,
24        backup_tag: backup_tag.to_string(),
25    };
26    restore_impl(target, SnapshotSel::Latest, &opts)
27}
28
29/// Restore from the previous (second newest) backup pair. Used when a fresh snapshot
30/// was just captured pre-restore and we want to restore to the state before snapshot.
31///
32/// # Errors
33///
34/// Returns an IO error if the backup file cannot be restored.
35pub fn restore_file_prev(
36    target: &SafePath,
37    dry_run: bool,
38    force_best_effort: bool,
39    backup_tag: &str,
40) -> std::io::Result<()> {
41    let opts = RestoreOptions {
42        dry_run,
43        force_best_effort,
44        backup_tag: backup_tag.to_string(),
45    };
46    restore_impl(target, SnapshotSel::Previous, &opts)
47}
48
49/// Engine entry that performs restore given a selector and options.
50///
51/// # Errors
52///
53/// Returns an IO error if the backup file cannot be restored.
54#[allow(
55    clippy::too_many_lines,
56    reason = "Will split into RestorePlanner plan/execute in PR6"
57)]
58pub fn restore_impl(
59    target: &SafePath,
60    sel: SnapshotSel,
61    opts: &RestoreOptions,
62) -> std::io::Result<()> {
63    let target_path = target.as_path();
64    // In DryRun, avoid any filesystem planning/probing and return success immediately.
65    // This guarantees no errors in dry-run even when backups are missing.
66    if opts.dry_run {
67        return Ok(());
68    }
69    match RestorePlanner::plan(&target_path, sel, opts) {
70        Ok((_backup_opt, _sidecar_opt, action)) => RestorePlanner::execute(&target_path, action),
71        Err(e)
72            if e.kind() == std::io::ErrorKind::NotFound && matches!(sel, SnapshotSel::Previous) =>
73        {
74            // Fallback: if no previous snapshot exists (e.g., first snapshot just captured),
75            // attempt restore from the latest snapshot instead.
76            match RestorePlanner::plan(&target_path, SnapshotSel::Latest, opts) {
77                Ok((_b2, _s2, action2)) => RestorePlanner::execute(&target_path, action2),
78                Err(e2) => Err(e2),
79            }
80        }
81        Err(e) => Err(e),
82    }
83}
84
85/// Planned action for restore execution.
86#[derive(Debug, Clone)]
87pub enum RestoreAction {
88    Noop,
89    FileRename {
90        backup: PathBuf,
91        mode: Option<u32>,
92    },
93    SymlinkTo {
94        dest: PathBuf,
95        cleanup_backup: Option<PathBuf>,
96    },
97    EnsureAbsent {
98        cleanup_backup: Option<PathBuf>,
99    },
100    LegacyRename {
101        backup: PathBuf,
102    },
103}
104
105/// Planner facade that selects the correct restore action and validates integrity/idempotence.
106struct RestorePlanner;
107
108impl RestorePlanner {
109    #[allow(
110        clippy::too_many_lines,
111        reason = "Will be split into RestorePlanner plan/execute in follow-up PR"
112    )]
113    fn plan(
114        target: &Path,
115        sel: SnapshotSel,
116        opts: &RestoreOptions,
117    ) -> std::io::Result<(
118        Option<PathBuf>,
119        Option<crate::fs::backup::sidecar::BackupSidecar>,
120        RestoreAction,
121    )> {
122        // Locate backup payload and sidecar based on selector
123        let pair = match sel {
124            SnapshotSel::Latest => selector::latest(target, &opts.backup_tag),
125            SnapshotSel::Previous => selector::previous(target, &opts.backup_tag),
126        };
127        let (backup_opt, sidecar_path): (Option<PathBuf>, PathBuf) = if let Some(p) = pair {
128            p
129        } else {
130            if !opts.force_best_effort {
131                return Err(std::io::Error::new(
132                    std::io::ErrorKind::NotFound,
133                    "backup missing",
134                ));
135            }
136            return Ok((None, None, RestoreAction::Noop));
137        };
138        // Read sidecar if present
139        let sc = read_sidecar(&sidecar_path).ok();
140        if let Some(ref side) = sc {
141            // Idempotence
142            if idempotence::is_idempotent(
143                target,
144                side.prior_kind.as_str(),
145                side.prior_dest.as_deref(),
146            ) {
147                return Ok((backup_opt, sc, RestoreAction::Noop));
148            }
149        }
150        if let Some(side) = sc.clone() {
151            let action = match side.prior_kind.as_str() {
152                "file" => {
153                    let backup: PathBuf = if let Some(p) = backup_opt.clone() {
154                        p
155                    } else {
156                        if opts.force_best_effort {
157                            return Ok((backup_opt, Some(side), RestoreAction::Noop));
158                        }
159                        return Err(std::io::Error::new(
160                            std::io::ErrorKind::NotFound,
161                            "backup payload missing",
162                        ));
163                    };
164                    if let Some(ref expected) = side.payload_hash {
165                        if !integrity::verify_payload_hash_ok(&backup, expected.as_str()) {
166                            if opts.force_best_effort {
167                                return Ok((backup_opt, Some(side), RestoreAction::Noop));
168                            }
169                            return Err(std::io::Error::new(
170                                std::io::ErrorKind::NotFound,
171                                "backup payload hash mismatch",
172                            ));
173                        }
174                    }
175                    let mode = side
176                        .mode
177                        .as_ref()
178                        .and_then(|ms| u32::from_str_radix(ms, 8).ok());
179                    RestoreAction::FileRename { backup, mode }
180                }
181                "symlink" => {
182                    if let Some(dest) = side.prior_dest.as_ref() {
183                        RestoreAction::SymlinkTo {
184                            dest: PathBuf::from(dest),
185                            cleanup_backup: backup_opt.clone(),
186                        }
187                    } else if let Some(backup) = backup_opt.clone() {
188                        RestoreAction::LegacyRename { backup }
189                    } else if opts.force_best_effort {
190                        RestoreAction::Noop
191                    } else {
192                        return Err(std::io::Error::new(
193                            std::io::ErrorKind::NotFound,
194                            "backup payload missing",
195                        ));
196                    }
197                }
198                "none" => RestoreAction::EnsureAbsent {
199                    cleanup_backup: backup_opt.clone(),
200                },
201                _ => {
202                    if let Some(backup) = backup_opt.clone() {
203                        RestoreAction::LegacyRename { backup }
204                    } else if opts.force_best_effort {
205                        RestoreAction::Noop
206                    } else {
207                        return Err(std::io::Error::new(
208                            std::io::ErrorKind::NotFound,
209                            "backup payload missing",
210                        ));
211                    }
212                }
213            };
214            return Ok((backup_opt, Some(side), action));
215        }
216        // No sidecar; legacy rename if backup exists
217        if let Some(backup) = backup_opt.clone() {
218            Ok((backup_opt, None, RestoreAction::LegacyRename { backup }))
219        } else if opts.force_best_effort {
220            Ok((None, None, RestoreAction::Noop))
221        } else {
222            Err(std::io::Error::new(
223                std::io::ErrorKind::NotFound,
224                "backup missing",
225            ))
226        }
227    }
228
229    fn execute(target: &Path, action: RestoreAction) -> std::io::Result<()> {
230        match action {
231            RestoreAction::Noop => Ok(()),
232            RestoreAction::FileRename { backup, mode } => {
233                steps::restore_file_bytes(target, &backup, mode)
234            }
235            RestoreAction::SymlinkTo {
236                dest,
237                cleanup_backup,
238            } => {
239                steps::restore_symlink_to(target, &dest)?;
240                if let Some(b) = cleanup_backup {
241                    let _ = std::fs::remove_file(b);
242                }
243                Ok(())
244            }
245            RestoreAction::EnsureAbsent { cleanup_backup } => {
246                steps::ensure_absent(target)?;
247                if let Some(b) = cleanup_backup {
248                    let _ = std::fs::remove_file(b);
249                }
250                Ok(())
251            }
252            RestoreAction::LegacyRename { backup } => steps::legacy_rename(target, &backup),
253        }
254    }
255}