Skip to main content

agent_runtime/restore_backups/
plan.rs

1//! Restore plan: walks one backup-run directory and matches every
2//! backed-up file to a `PlanAction::Symlink` in a regenerated install
3//! plan. The match is `(entry_id, dest.file_name())` — install's
4//! `move_to_backup` records both into `<run>/<entry_id>/<basename>`, so
5//! restoration is fully derivable from the link-map shape without a
6//! per-run manifest.
7
8use crate::install::plan::{InstallPlan, PlanAction};
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12/// Selector for `--from <timestamp>|latest`. Parsed by the CLI; the
13/// resolver under `restore_backups::run` picks an actual directory.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum BackupRunSelector {
16    /// `--from latest` — pick the highest-numbered unix-seconds dir.
17    Latest,
18    /// `--from <unix-seconds>` — pick that exact dir, or error.
19    Exact(u64),
20}
21
22impl std::str::FromStr for BackupRunSelector {
23    type Err = String;
24    fn from_str(s: &str) -> Result<Self, Self::Err> {
25        if s.eq_ignore_ascii_case("latest") {
26            return Ok(Self::Latest);
27        }
28        s.parse::<u64>().map(Self::Exact).map_err(|err| {
29            format!("--from must be `latest` or a unix-seconds timestamp (got `{s}`): {err}")
30        })
31    }
32}
33
34/// One step the restore executor will run. Each action carries the
35/// resolved source-of-truth backup path and the destination it should
36/// land at.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RestoreAction {
39    /// Move `source_backup` back to `dest`. Both paths are absolute.
40    ///
41    /// `expected_install_source` is the absolute path the post-install
42    /// symlink at `dest` should be pointing at — recorded from the
43    /// regenerated `InstallPlan` so the executor can refuse to clobber
44    /// a symlink an operator has manually retargeted away from the
45    /// install layout (the same protection `uninstall` enforces).
46    RestoreFile {
47        entry_id: String,
48        source_backup: PathBuf,
49        dest: PathBuf,
50        expected_install_source: PathBuf,
51    },
52    /// The backup file matched no `PlanAction::Symlink` in the
53    /// regenerated install plan — usually because the link-map entry was
54    /// removed or its destination changed between install and restore.
55    SkippedNoMatch {
56        entry_id: String,
57        source_backup: PathBuf,
58    },
59    /// More than one `PlanAction::Symlink` matched (`entry_id`,
60    /// `file_name`). This is the recursive-tree collision case advisory
61    /// in the module doc — install dropped the relative subpath, so
62    /// restore cannot disambiguate without operator input.
63    SkippedAmbiguous {
64        entry_id: String,
65        source_backup: PathBuf,
66        candidates: Vec<PathBuf>,
67    },
68}
69
70/// Resolved restore plan. `actions` preserves the order the run-directory
71/// walk produced (sorted, so test goldens stay stable).
72#[derive(Debug, Clone)]
73pub struct RestorePlan {
74    pub product: String,
75    pub home: PathBuf,
76    pub backup_run: PathBuf,
77    pub actions: Vec<RestoreAction>,
78}
79
80/// Errors that can occur while walking the backup-run directory. None
81/// fire when the run dir is empty — that case is an empty `actions`
82/// vector, not an error.
83#[derive(Debug, Error)]
84pub enum RestorePlanError {
85    #[error("io error reading backup run {path}: {source}")]
86    Io {
87        path: PathBuf,
88        #[source]
89        source: std::io::Error,
90    },
91}
92
93impl RestorePlan {
94    /// Build a restore plan by walking every regular file under
95    /// `backup_run/<entry_id>/` (skipping top-level `tag-*` markers) and
96    /// matching it against `install_plan.actions`.
97    ///
98    /// `surface_filter` (when `Some`) restricts the plan to backup files
99    /// whose `entry_id` matches the filter. Unmatched entries are
100    /// silently skipped at plan-build time so the executor does not
101    /// generate noise for filtered-out backups.
102    pub fn from_backup_run(
103        backup_run: &Path,
104        install_plan: &InstallPlan,
105        surface_filter: Option<&str>,
106    ) -> Result<Self, RestorePlanError> {
107        let mut actions = Vec::new();
108        let entries = match std::fs::read_dir(backup_run) {
109            Ok(r) => r,
110            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
111                return Ok(Self {
112                    product: install_plan.product.clone(),
113                    home: install_plan.home.clone(),
114                    backup_run: backup_run.to_path_buf(),
115                    actions,
116                });
117            }
118            Err(source) => {
119                return Err(RestorePlanError::Io {
120                    path: backup_run.to_path_buf(),
121                    source,
122                });
123            }
124        };
125
126        let mut entry_dirs: Vec<(String, PathBuf)> = Vec::new();
127        for entry in entries {
128            let entry = entry.map_err(|source| RestorePlanError::Io {
129                path: backup_run.to_path_buf(),
130                source,
131            })?;
132            let file_type = entry.file_type().map_err(|source| RestorePlanError::Io {
133                path: entry.path(),
134                source,
135            })?;
136            if !file_type.is_dir() {
137                // `tag-*` markers + any future top-level files: ignore.
138                continue;
139            }
140            let name = match entry.file_name().into_string() {
141                Ok(s) => s,
142                Err(_) => continue,
143            };
144            entry_dirs.push((name, entry.path()));
145        }
146        entry_dirs.sort_by(|a, b| a.0.cmp(&b.0));
147
148        for (entry_id, entry_dir) in entry_dirs {
149            if let Some(filter) = surface_filter
150                && entry_id != filter
151            {
152                continue;
153            }
154            walk_backup_dir(&entry_id, &entry_dir, install_plan, &mut actions)?;
155        }
156
157        Ok(Self {
158            product: install_plan.product.clone(),
159            home: install_plan.home.clone(),
160            backup_run: backup_run.to_path_buf(),
161            actions,
162        })
163    }
164}
165
166fn walk_backup_dir(
167    entry_id: &str,
168    dir: &Path,
169    install_plan: &InstallPlan,
170    out: &mut Vec<RestoreAction>,
171) -> Result<(), RestorePlanError> {
172    let mut files: Vec<PathBuf> = Vec::new();
173    collect_files(dir, &mut files)?;
174    files.sort();
175    for backup_file in files {
176        let file_name = match backup_file.file_name() {
177            Some(n) => n.to_os_string(),
178            None => continue,
179        };
180        let candidates: Vec<(PathBuf, PathBuf)> = install_plan
181            .actions
182            .iter()
183            .filter_map(|a| match a {
184                PlanAction::Symlink {
185                    entry_id: id,
186                    source,
187                    dest,
188                    ..
189                } if id == entry_id && dest.file_name() == Some(file_name.as_ref()) => {
190                    Some((dest.clone(), source.clone()))
191                }
192                _ => None,
193            })
194            .collect();
195        match candidates.len() {
196            1 => {
197                let (dest, expected_install_source) =
198                    candidates.into_iter().next().expect("len==1");
199                out.push(RestoreAction::RestoreFile {
200                    entry_id: entry_id.to_string(),
201                    source_backup: backup_file,
202                    dest,
203                    expected_install_source,
204                });
205            }
206            0 => out.push(RestoreAction::SkippedNoMatch {
207                entry_id: entry_id.to_string(),
208                source_backup: backup_file,
209            }),
210            _ => out.push(RestoreAction::SkippedAmbiguous {
211                entry_id: entry_id.to_string(),
212                source_backup: backup_file,
213                candidates: candidates.into_iter().map(|(d, _)| d).collect(),
214            }),
215        }
216    }
217    Ok(())
218}
219
220fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), RestorePlanError> {
221    let read = std::fs::read_dir(dir).map_err(|source| RestorePlanError::Io {
222        path: dir.to_path_buf(),
223        source,
224    })?;
225    for entry in read {
226        let entry = entry.map_err(|source| RestorePlanError::Io {
227            path: dir.to_path_buf(),
228            source,
229        })?;
230        let file_type = entry.file_type().map_err(|source| RestorePlanError::Io {
231            path: entry.path(),
232            source,
233        })?;
234        if file_type.is_dir() {
235            collect_files(&entry.path(), out)?;
236        } else if file_type.is_file() {
237            out.push(entry.path());
238        }
239    }
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn selector_parses_latest_case_insensitively() {
249        assert_eq!(
250            "latest".parse::<BackupRunSelector>().unwrap(),
251            BackupRunSelector::Latest
252        );
253        assert_eq!(
254            "LATEST".parse::<BackupRunSelector>().unwrap(),
255            BackupRunSelector::Latest
256        );
257    }
258
259    #[test]
260    fn selector_parses_unix_seconds() {
261        assert_eq!(
262            "1700000000".parse::<BackupRunSelector>().unwrap(),
263            BackupRunSelector::Exact(1_700_000_000)
264        );
265    }
266
267    #[test]
268    fn selector_rejects_garbage() {
269        assert!("yesterday".parse::<BackupRunSelector>().is_err());
270        assert!("-5".parse::<BackupRunSelector>().is_err());
271    }
272}