Skip to main content

helixforge_engine/
backups.rs

1//! Configuration backups in one place: the copies HelixForge's scripts leave
2//! beside a file before changing it (`<file>.commander-backup-<time>`,
3//! `<file>.helixforge-backup-<time>`), found in the user's configuration and
4//! in the system places those scripts change, and restored in a way that can
5//! itself be undone. Snapshots (Snapper, Timeshift) are taken before system
6//! changes where the machine supports them; HelixForge does not roll
7//! snapshots or packages back itself.
8
9use crate::{Plan, Step, quote};
10use helixforge_core::{Risk, printable};
11use std::{
12    fs,
13    path::{Path, PathBuf},
14};
15
16/// One backup copy and the file it was taken from.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct Backup {
19    pub backup: PathBuf,
20    pub original: PathBuf,
21    /// When it was taken, `YYYY-MM-DD HH:MM:SS`, from its name.
22    pub taken: Option<String>,
23    /// Restoring it needs root.
24    pub system: bool,
25    pub directory: bool,
26}
27
28impl Backup {
29    /// The time it was taken as one sortable number.
30    fn stamp(&self) -> String {
31        self.taken.clone().unwrap_or_default()
32    }
33}
34
35/// The original's name and the time stamp, for a backup's file name.
36pub fn parse_name(name: &str) -> Option<(&str, &str)> {
37    for marker in [".commander-backup-", ".helixforge-backup-"] {
38        if let Some((original, stamp)) = name.rsplit_once(marker)
39            && !original.is_empty()
40            && stamp.len() >= 14
41            && stamp
42                .chars()
43                .all(|character| character.is_ascii_digit() || character == '-')
44        {
45            return Some((original, stamp));
46        }
47    }
48    None
49}
50
51/// `20261015164340...` as `2026-10-15 16:43:40`.
52fn readable(stamp: &str) -> Option<String> {
53    let digits: String = stamp.chars().take_while(char::is_ascii_digit).collect();
54    if digits.len() < 14 {
55        return None;
56    }
57    Some(format!(
58        "{}-{}-{} {}:{}:{}",
59        &digits[0..4],
60        &digits[4..6],
61        &digits[6..8],
62        &digits[8..10],
63        &digits[10..12],
64        &digits[12..14]
65    ))
66}
67
68/// Where backups are looked for: the user's configuration (any depth, as
69/// the dotfiles installers leave them) and the system places HelixForge's
70/// scripts change (shallow).
71pub struct Roots {
72    /// Searched to any depth, skipping caches and repositories.
73    pub user: Vec<PathBuf>,
74    /// The home directory itself, one level.
75    pub home: Option<PathBuf>,
76    /// Searched a few levels deep; restoring these needs root.
77    pub system: Vec<(PathBuf, usize)>,
78}
79
80impl Roots {
81    /// This user's and this machine's places.
82    pub fn here() -> Self {
83        let home = std::env::var_os("HOME").map(PathBuf::from);
84        let xdg = |name: &str, fallback: &str| {
85            std::env::var_os(name)
86                .map(PathBuf::from)
87                .filter(|path| path.is_absolute())
88                .or_else(|| home.as_ref().map(|home| home.join(fallback)))
89        };
90        Roots {
91            user: [
92                xdg("XDG_CONFIG_HOME", ".config"),
93                xdg("XDG_DATA_HOME", ".local/share"),
94                home.as_ref().map(|home| home.join(".local/bin")),
95            ]
96            .into_iter()
97            .flatten()
98            .collect(),
99            home: home.clone(),
100            system: vec![
101                (PathBuf::from("/etc"), 3),
102                (PathBuf::from("/boot"), 3),
103                (PathBuf::from("/usr/share/sddm/themes"), 1),
104                (PathBuf::from("/usr/share/fonts"), 2),
105            ],
106        }
107    }
108}
109
110/// Directories not worth searching for configuration backups.
111const SKIP: [&str; 8] = [
112    ".git",
113    "node_modules",
114    "Trash",
115    "containers",
116    "Steam",
117    "flatpak",
118    "helixforge",
119    "sources",
120];
121
122/// Every backup under the roots, newest first.
123pub fn discover(roots: &Roots) -> Vec<Backup> {
124    let mut found = Vec::new();
125    if let Some(home) = &roots.home {
126        walk(home, 1, false, &mut found);
127    }
128    for root in &roots.user {
129        walk(root, usize::MAX, false, &mut found);
130    }
131    for (root, depth) in &roots.system {
132        walk(root, *depth, true, &mut found);
133    }
134    found.sort_by_key(|backup| std::cmp::Reverse(backup.stamp()));
135    found.dedup_by(|a, b| a.backup == b.backup);
136    found
137}
138
139fn walk(directory: &Path, depth: usize, system: bool, into: &mut Vec<Backup>) {
140    if depth == 0 {
141        return;
142    }
143    let Ok(listing) = fs::read_dir(directory) else {
144        return;
145    };
146    for item in listing.flatten() {
147        let path = item.path();
148        // Links are never followed, nor offered: they belong to another owner.
149        let Ok(kind) = item.file_type() else { continue };
150        if kind.is_symlink() {
151            continue;
152        }
153        let name = item.file_name().to_string_lossy().into_owned();
154        if let Some((original, stamp)) = parse_name(&name) {
155            into.push(Backup {
156                original: directory.join(original),
157                taken: readable(stamp),
158                system,
159                directory: kind.is_dir(),
160                backup: path,
161            });
162            continue;
163        }
164        if kind.is_dir() && !SKIP.contains(&name.as_str()) {
165            walk(&path, depth - 1, system, into);
166        }
167    }
168}
169
170/// The step that puts a backup back: the current file, if any, is moved
171/// aside as a backup of its own first, so the restore can be undone too.
172pub fn restore_step(backup: &Backup) -> Step {
173    let sudo = if backup.system { "sudo " } else { "" };
174    let original = quote(&backup.original.to_string_lossy());
175    let copy = quote(&backup.backup.to_string_lossy());
176    let program = format!(
177        "hf_stamp=$(date +%Y%m%d%H%M%S)\n\
178         if [ -e {original} ] || [ -L {original} ]; then\n  \
179         {sudo}mv -- {original} {original}.helixforge-backup-\"$hf_stamp\" || exit 1\n  \
180         printf 'HelixForge: the current one is kept as %s\\n' {original}.helixforge-backup-\"$hf_stamp\"\n\
181         fi\n\
182         {sudo}cp -a -- {copy} {original} || exit 1\n\
183         printf 'HelixForge: restored %s\\n' {original}"
184    );
185    Step::Shell {
186        id: "restore".to_string(),
187        name: printable(&format!("Restore {}", backup.original.display())),
188        program,
189        // Names on disk are shown made printable; the program quotes them as they are.
190        display: printable(&format!(
191            "{sudo}cp -a {} {}  (the current one is kept as a backup first)",
192            backup.backup.display(),
193            backup.original.display()
194        )),
195        requires: if backup.system {
196            vec!["sudo".to_string()]
197        } else {
198            Vec::new()
199        },
200    }
201}
202
203/// A plan that restores one backup.
204pub fn plan_restore(backup: &Backup) -> Plan {
205    let step = restore_step(backup);
206    Plan {
207        requirements: step_requirements(&step),
208        privileged: backup.system,
209        risk: if backup.system {
210            Risk::Medium
211        } else {
212            Risk::Low
213        },
214        steps: vec![step],
215        depends: vec![Vec::new()],
216        ..Plan::default()
217    }
218}
219
220fn step_requirements(step: &Step) -> Vec<String> {
221    match step {
222        Step::Shell { requires, .. } => requires.clone(),
223        _ => Vec::new(),
224    }
225}
226
227/// A snapshot tool the machine is set up for.
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum Snapshots {
230    /// Snapper with a configuration for the root file system.
231    Snapper,
232    Timeshift,
233}
234
235impl Snapshots {
236    /// The tool this machine has set up, if any.
237    pub fn here() -> Option<Self> {
238        Self::detect(
239            &|path| Path::new(path).exists(),
240            &helixforge_platform::binary_exists,
241        )
242    }
243
244    /// `here` with the questions answered, for tests.
245    pub fn detect(exists: &dyn Fn(&str) -> bool, on_path: &dyn Fn(&str) -> bool) -> Option<Self> {
246        if on_path("snapper") && exists("/etc/snapper/configs/root") {
247            Some(Snapshots::Snapper)
248        } else if on_path("timeshift") && exists("/etc/timeshift/timeshift.json") {
249            Some(Snapshots::Timeshift)
250        } else {
251            None
252        }
253    }
254
255    pub fn label(self) -> &'static str {
256        match self {
257            Snapshots::Snapper => "Snapper",
258            Snapshots::Timeshift => "Timeshift",
259        }
260    }
261
262    /// Where its snapshots are browsed and rolled back.
263    pub fn how_to_restore(self) -> &'static str {
264        match self {
265            Snapshots::Snapper => {
266                "Btrfs Assistant, or snapper list and snapper rollback, from a terminal or the boot menu's snapshots"
267            }
268            Snapshots::Timeshift => "Timeshift, or timeshift --restore from a terminal",
269        }
270    }
271
272    /// The step that takes a snapshot, described as `description`.
273    pub fn step(self, description: &str) -> Step {
274        let words: Vec<String> = match self {
275            Snapshots::Snapper => vec![
276                "sudo".into(),
277                "snapper".into(),
278                "-c".into(),
279                "root".into(),
280                "create".into(),
281                "--cleanup-algorithm".into(),
282                "number".into(),
283                "--description".into(),
284                description.into(),
285            ],
286            Snapshots::Timeshift => vec![
287                "sudo".into(),
288                "timeshift".into(),
289                "--create".into(),
290                "--comments".into(),
291                description.into(),
292            ],
293        };
294        let program = words
295            .iter()
296            .map(|word| quote(word))
297            .collect::<Vec<_>>()
298            .join(" ");
299        Step::Shell {
300            id: "snapshot".to_string(),
301            name: format!("Take a {} snapshot", self.label()),
302            program,
303            display: words.join(" "),
304            requires: vec![self.tool().to_string(), "sudo".to_string()],
305        }
306    }
307
308    fn tool(self) -> &'static str {
309        match self {
310            Snapshots::Snapper => "snapper",
311            Snapshots::Timeshift => "timeshift",
312        }
313    }
314}
315
316/// Whether a plan changes the system enough to snapshot first: risk high, or
317/// a step whose entry changes privileged files, the kernel or disks.
318pub fn worth_a_snapshot(plan: &Plan, flags: &dyn Fn(&str) -> Option<String>) -> bool {
319    plan.risk == Risk::High
320        || plan.steps.iter().any(|step| {
321            flags(step.id()).is_some_and(|flags| {
322                flags
323                    .split_whitespace()
324                    .any(|flag| matches!(flag, "PFM" | "K" | "D" | "SI"))
325            })
326        })
327}
328
329/// Put a snapshot step first, taken before the plan's other steps run.
330pub fn with_snapshot(plan: &mut Plan, tool: Snapshots) {
331    let names: Vec<&str> = plan.steps.iter().map(Step::name).collect();
332    let description = format!("HelixForge, before: {}", names.join(", "));
333    let step = tool.step(&description);
334    plan.requirements.extend(step_requirements(&step));
335    plan.requirements.sort();
336    plan.requirements.dedup();
337    plan.privileged = true;
338    plan.steps.insert(0, step);
339    // Every index moves up by one; nothing depends on the snapshot.
340    let mut depends = vec![Vec::new()];
341    depends.extend(
342        plan.depends
343            .iter()
344            .map(|list| list.iter().map(|index| index + 1).collect()),
345    );
346    plan.depends = depends;
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::shell_program;
353    use std::process::Command;
354
355    #[test]
356    fn backup_names_give_the_original_and_the_time() {
357        assert_eq!(
358            parse_name("grub.commander-backup-20261015164340-4242"),
359            Some(("grub", "20261015164340-4242"))
360        );
361        assert_eq!(
362            parse_name("pacman.conf.helixforge-backup-20261015164340"),
363            Some(("pacman.conf", "20261015164340"))
364        );
365        assert_eq!(
366            parse_name("example.commander-backup-20261015142214095844"),
367            Some(("example", "20261015142214095844"))
368        );
369        assert_eq!(parse_name("grub.backup"), None);
370        assert_eq!(parse_name(".commander-backup-20261015164340"), None);
371        assert_eq!(parse_name("x.commander-backup-2026"), None);
372        assert_eq!(
373            readable("20261015164340-1").as_deref(),
374            Some("2026-10-15 16:43:40")
375        );
376    }
377
378    #[test]
379    fn backups_are_found_newest_first_and_links_are_left_alone() {
380        let root = tempfile::tempdir().unwrap();
381        let home = root.path().join("home");
382        let config = home.join(".config");
383        let etc = root.path().join("etc");
384        let write = |path: &Path| {
385            fs::create_dir_all(path.parent().unwrap()).unwrap();
386            fs::write(path, "x").unwrap();
387        };
388        write(&config.join("ghostty/config.commander-backup-20261001000000-1"));
389        write(&config.join("deep/a/b/c/d/e.helixforge-backup-20261003000000"));
390        fs::create_dir_all(config.join("nvim.commander-backup-20261002000000-7/lua")).unwrap();
391        write(&home.join(".bashrc.commander-backup-20260901000000-1"));
392        write(&config.join("node_modules/x.commander-backup-20261005000000-1"));
393        write(&etc.join("default/grub.commander-backup-20261004000000-9"));
394        write(&etc.join("a/b/c/d/too-deep.commander-backup-20261006000000-1"));
395        std::os::unix::fs::symlink(
396            etc.join("default/grub.commander-backup-20261004000000-9"),
397            config.join("linked.commander-backup-20261007000000-1"),
398        )
399        .unwrap();
400        let roots = Roots {
401            user: vec![config.clone()],
402            home: Some(home.clone()),
403            system: vec![(etc.clone(), 3)],
404        };
405        let found = discover(&roots);
406        let shown: Vec<(String, bool, bool)> = found
407            .iter()
408            .map(|backup| {
409                (
410                    backup
411                        .original
412                        .strip_prefix(root.path())
413                        .unwrap()
414                        .display()
415                        .to_string(),
416                    backup.system,
417                    backup.directory,
418                )
419            })
420            .collect();
421        assert_eq!(
422            shown,
423            [
424                ("etc/default/grub".to_string(), true, false),
425                ("home/.config/deep/a/b/c/d/e".to_string(), false, false),
426                ("home/.config/nvim".to_string(), false, true),
427                ("home/.config/ghostty/config".to_string(), false, false),
428                ("home/.bashrc".to_string(), false, false),
429            ]
430        );
431        assert_eq!(found[0].taken.as_deref(), Some("2026-10-04 00:00:00"));
432    }
433
434    #[test]
435    fn a_restore_keeps_the_current_one_and_can_be_undone() {
436        let root = tempfile::tempdir().unwrap();
437        let original = root.path().join("config");
438        let backup = root.path().join("config.commander-backup-20261001000000-1");
439        fs::write(&original, "current").unwrap();
440        fs::write(&backup, "older").unwrap();
441        let found = Backup {
442            backup: backup.clone(),
443            original: original.clone(),
444            taken: readable("20261001000000"),
445            system: false,
446            directory: false,
447        };
448        let plan = plan_restore(&found);
449        assert_eq!(plan.risk, Risk::Low);
450        assert!(!plan.privileged);
451        assert!(plan.steps[0].display().starts_with("cp -a "));
452        let run = |plan: &Plan| {
453            let output = Command::new("sh")
454                .arg("-c")
455                .arg(shell_program(plan, Path::new("/lib"), Path::new("/hf")))
456                .output()
457                .unwrap();
458            assert!(
459                output.status.success(),
460                "{}",
461                String::from_utf8_lossy(&output.stderr)
462            );
463        };
464        run(&plan);
465        assert_eq!(fs::read_to_string(&original).unwrap(), "older");
466        assert_eq!(
467            fs::read_to_string(&backup).unwrap(),
468            "older",
469            "the backup stays"
470        );
471        let kept: Vec<PathBuf> = fs::read_dir(root.path())
472            .unwrap()
473            .flatten()
474            .map(|item| item.path())
475            .filter(|path| path.to_string_lossy().contains(".helixforge-backup-"))
476            .collect();
477        assert_eq!(kept.len(), 1);
478        assert_eq!(fs::read_to_string(&kept[0]).unwrap(), "current");
479        // A folder, with nothing in its place now.
480        let folder = root.path().join("nvim");
481        let saved = root.path().join("nvim.commander-backup-20261001000000-1");
482        fs::create_dir_all(saved.join("lua")).unwrap();
483        fs::write(saved.join("lua/init.lua"), "old").unwrap();
484        let plan = plan_restore(&Backup {
485            backup: saved,
486            original: folder.clone(),
487            taken: None,
488            system: false,
489            directory: true,
490        });
491        run(&plan);
492        assert_eq!(
493            fs::read_to_string(folder.join("lua/init.lua")).unwrap(),
494            "old"
495        );
496        // A system file needs sudo.
497        let system = plan_restore(&Backup {
498            backup: PathBuf::from("/etc/default/grub.commander-backup-20261001000000-1"),
499            original: PathBuf::from("/etc/default/grub"),
500            taken: None,
501            system: true,
502            directory: false,
503        });
504        assert!(system.privileged);
505        assert_eq!(system.requirements, ["sudo"]);
506        assert!(system.steps[0].display().starts_with("sudo cp -a "));
507    }
508
509    #[test]
510    fn a_strange_name_is_shown_safely_and_restored_as_it_is() {
511        let backup = Backup {
512            backup: PathBuf::from("/home/me/.config/a\u{1b}[2Jb.commander-backup-20261001000000-1"),
513            original: PathBuf::from("/home/me/.config/a\u{1b}[2Jb"),
514            taken: None,
515            system: false,
516            directory: false,
517        };
518        let step = restore_step(&backup);
519        assert!(!step.display().contains('\u{1b}'));
520        assert!(!step.name().contains('\u{1b}'));
521        let Step::Shell { program, .. } = &step else {
522            panic!("expected a shell step");
523        };
524        assert!(
525            program.contains("'/home/me/.config/a\u{1b}[2Jb'"),
526            "the real name, quoted"
527        );
528    }
529
530    #[test]
531    fn snapshots_go_first_when_the_system_changes() {
532        let snapper = |path: &str| path == "/etc/snapper/configs/root";
533        assert_eq!(
534            Snapshots::detect(&snapper, &|_| true),
535            Some(Snapshots::Snapper)
536        );
537        assert_eq!(Snapshots::detect(&|_| false, &|_| true), None);
538        assert_eq!(
539            Snapshots::detect(
540                &|path| path == "/etc/timeshift/timeshift.json",
541                &|tool| tool == "timeshift"
542            ),
543            Some(Snapshots::Timeshift)
544        );
545        let mut plan = Plan {
546            steps: vec![
547                Step::Command {
548                    id: "grub".into(),
549                    name: "GRUB Theme".into(),
550                    line: "true".into(),
551                },
552                Step::Command {
553                    id: "after".into(),
554                    name: "After".into(),
555                    line: "true".into(),
556                },
557            ],
558            depends: vec![vec![], vec![0]],
559            risk: Risk::Medium,
560            ..Plan::default()
561        };
562        let flags = |id: &str| (id == "grub").then(|| "I PFM MP".to_string());
563        assert!(worth_a_snapshot(&plan, &flags));
564        assert!(!worth_a_snapshot(&plan, &|_| Some("I".to_string())));
565        with_snapshot(&mut plan, Snapshots::Snapper);
566        assert_eq!(plan.steps[0].name(), "Take a Snapper snapshot");
567        assert_eq!(
568            plan.steps[0].display(),
569            "sudo snapper -c root create --cleanup-algorithm number --description HelixForge, before: GRUB Theme, After"
570        );
571        assert_eq!(plan.depends, [vec![], vec![], vec![1]]);
572        assert!(plan.privileged);
573        assert!(plan.requirements.contains(&"snapper".to_string()));
574    }
575}