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        changes: vec![crate::ShellChange {
201            path: backup.original.clone(),
202            from: (!backup.directory).then(|| backup.backup.clone()),
203            text: "put back from the backup; the one there now is kept as a backup first"
204                .to_string(),
205        }],
206    }
207}
208
209/// A plan that restores one backup.
210pub fn plan_restore(backup: &Backup) -> Plan {
211    let step = restore_step(backup);
212    Plan {
213        requirements: step_requirements(&step),
214        privileged: backup.system,
215        risk: if backup.system {
216            Risk::Medium
217        } else {
218            Risk::Low
219        },
220        steps: vec![step],
221        depends: vec![Vec::new()],
222        ..Plan::default()
223    }
224}
225
226fn step_requirements(step: &Step) -> Vec<String> {
227    match step {
228        Step::Shell { requires, .. } => requires.clone(),
229        _ => Vec::new(),
230    }
231}
232
233/// A snapshot tool the machine is set up for.
234#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235pub enum Snapshots {
236    /// Snapper with a configuration for the root file system.
237    Snapper,
238    Timeshift,
239}
240
241impl Snapshots {
242    /// The tool this machine has set up, if any.
243    pub fn here() -> Option<Self> {
244        Self::detect(
245            &|path| Path::new(path).exists(),
246            &helixforge_platform::binary_exists,
247        )
248    }
249
250    /// `here` with the questions answered, for tests.
251    pub fn detect(exists: &dyn Fn(&str) -> bool, on_path: &dyn Fn(&str) -> bool) -> Option<Self> {
252        if on_path("snapper") && exists("/etc/snapper/configs/root") {
253            Some(Snapshots::Snapper)
254        } else if on_path("timeshift") && exists("/etc/timeshift/timeshift.json") {
255            Some(Snapshots::Timeshift)
256        } else {
257            None
258        }
259    }
260
261    pub fn label(self) -> &'static str {
262        match self {
263            Snapshots::Snapper => "Snapper",
264            Snapshots::Timeshift => "Timeshift",
265        }
266    }
267
268    /// Where its snapshots are browsed and rolled back.
269    pub fn how_to_restore(self) -> &'static str {
270        match self {
271            Snapshots::Snapper => {
272                "Btrfs Assistant, or snapper list and snapper rollback, from a terminal or the boot menu's snapshots"
273            }
274            Snapshots::Timeshift => "Timeshift, or timeshift --restore from a terminal",
275        }
276    }
277
278    /// The step that takes a snapshot, described as `description`.
279    pub fn step(self, description: &str) -> Step {
280        let words: Vec<String> = match self {
281            Snapshots::Snapper => vec![
282                "sudo".into(),
283                "snapper".into(),
284                "-c".into(),
285                "root".into(),
286                "create".into(),
287                "--cleanup-algorithm".into(),
288                "number".into(),
289                "--description".into(),
290                description.into(),
291            ],
292            Snapshots::Timeshift => vec![
293                "sudo".into(),
294                "timeshift".into(),
295                "--create".into(),
296                "--comments".into(),
297                description.into(),
298            ],
299        };
300        let program = words
301            .iter()
302            .map(|word| quote(word))
303            .collect::<Vec<_>>()
304            .join(" ");
305        Step::Shell {
306            id: "snapshot".to_string(),
307            name: format!("Take a {} snapshot", self.label()),
308            program,
309            display: words.join(" "),
310            requires: vec![self.tool().to_string(), "sudo".to_string()],
311            changes: Vec::new(),
312        }
313    }
314
315    fn tool(self) -> &'static str {
316        match self {
317            Snapshots::Snapper => "snapper",
318            Snapshots::Timeshift => "timeshift",
319        }
320    }
321}
322
323/// Whether a plan changes the system enough to snapshot first: risk high, or
324/// a step whose entry changes privileged files, the kernel or disks.
325pub fn worth_a_snapshot(plan: &Plan, flags: &dyn Fn(&str) -> Option<String>) -> bool {
326    plan.risk == Risk::High
327        || plan.steps.iter().any(|step| {
328            flags(step.id()).is_some_and(|flags| {
329                flags
330                    .split_whitespace()
331                    .any(|flag| matches!(flag, "PFM" | "K" | "D" | "SI"))
332            })
333        })
334}
335
336/// Put a snapshot step first, taken before the plan's other steps run.
337pub fn with_snapshot(plan: &mut Plan, tool: Snapshots) {
338    let names: Vec<&str> = plan.steps.iter().map(Step::name).collect();
339    let description = format!("HelixForge, before: {}", names.join(", "));
340    let step = tool.step(&description);
341    plan.requirements.extend(step_requirements(&step));
342    plan.requirements.sort();
343    plan.requirements.dedup();
344    plan.privileged = true;
345    plan.steps.insert(0, step);
346    // Every index moves up by one; nothing depends on the snapshot.
347    let mut depends = vec![Vec::new()];
348    depends.extend(
349        plan.depends
350            .iter()
351            .map(|list| list.iter().map(|index| index + 1).collect()),
352    );
353    plan.depends = depends;
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::shell_program;
360    use std::process::Command;
361
362    #[test]
363    fn backup_names_give_the_original_and_the_time() {
364        assert_eq!(
365            parse_name("grub.commander-backup-20261015164340-4242"),
366            Some(("grub", "20261015164340-4242"))
367        );
368        assert_eq!(
369            parse_name("pacman.conf.helixforge-backup-20261015164340"),
370            Some(("pacman.conf", "20261015164340"))
371        );
372        assert_eq!(
373            parse_name("example.commander-backup-20261015142214095844"),
374            Some(("example", "20261015142214095844"))
375        );
376        assert_eq!(parse_name("grub.backup"), None);
377        assert_eq!(parse_name(".commander-backup-20261015164340"), None);
378        assert_eq!(parse_name("x.commander-backup-2026"), None);
379        assert_eq!(
380            readable("20261015164340-1").as_deref(),
381            Some("2026-10-15 16:43:40")
382        );
383    }
384
385    #[test]
386    fn backups_are_found_newest_first_and_links_are_left_alone() {
387        let root = tempfile::tempdir().unwrap();
388        let home = root.path().join("home");
389        let config = home.join(".config");
390        let etc = root.path().join("etc");
391        let write = |path: &Path| {
392            fs::create_dir_all(path.parent().unwrap()).unwrap();
393            fs::write(path, "x").unwrap();
394        };
395        write(&config.join("ghostty/config.commander-backup-20261001000000-1"));
396        write(&config.join("deep/a/b/c/d/e.helixforge-backup-20261003000000"));
397        fs::create_dir_all(config.join("nvim.commander-backup-20261002000000-7/lua")).unwrap();
398        write(&home.join(".bashrc.commander-backup-20260901000000-1"));
399        write(&config.join("node_modules/x.commander-backup-20261005000000-1"));
400        write(&etc.join("default/grub.commander-backup-20261004000000-9"));
401        write(&etc.join("a/b/c/d/too-deep.commander-backup-20261006000000-1"));
402        std::os::unix::fs::symlink(
403            etc.join("default/grub.commander-backup-20261004000000-9"),
404            config.join("linked.commander-backup-20261007000000-1"),
405        )
406        .unwrap();
407        let roots = Roots {
408            user: vec![config.clone()],
409            home: Some(home.clone()),
410            system: vec![(etc.clone(), 3)],
411        };
412        let found = discover(&roots);
413        let shown: Vec<(String, bool, bool)> = found
414            .iter()
415            .map(|backup| {
416                (
417                    backup
418                        .original
419                        .strip_prefix(root.path())
420                        .unwrap()
421                        .display()
422                        .to_string(),
423                    backup.system,
424                    backup.directory,
425                )
426            })
427            .collect();
428        assert_eq!(
429            shown,
430            [
431                ("etc/default/grub".to_string(), true, false),
432                ("home/.config/deep/a/b/c/d/e".to_string(), false, false),
433                ("home/.config/nvim".to_string(), false, true),
434                ("home/.config/ghostty/config".to_string(), false, false),
435                ("home/.bashrc".to_string(), false, false),
436            ]
437        );
438        assert_eq!(found[0].taken.as_deref(), Some("2026-10-04 00:00:00"));
439    }
440
441    #[test]
442    fn a_restore_keeps_the_current_one_and_can_be_undone() {
443        let root = tempfile::tempdir().unwrap();
444        let original = root.path().join("config");
445        let backup = root.path().join("config.commander-backup-20261001000000-1");
446        fs::write(&original, "current").unwrap();
447        fs::write(&backup, "older").unwrap();
448        let found = Backup {
449            backup: backup.clone(),
450            original: original.clone(),
451            taken: readable("20261001000000"),
452            system: false,
453            directory: false,
454        };
455        let plan = plan_restore(&found);
456        assert_eq!(plan.risk, Risk::Low);
457        assert!(!plan.privileged);
458        assert!(plan.steps[0].display().starts_with("cp -a "));
459        let run = |plan: &Plan| {
460            let output = Command::new("sh")
461                .arg("-c")
462                .arg(shell_program(plan, Path::new("/lib"), Path::new("/hf")))
463                .output()
464                .unwrap();
465            assert!(
466                output.status.success(),
467                "{}",
468                String::from_utf8_lossy(&output.stderr)
469            );
470        };
471        run(&plan);
472        assert_eq!(fs::read_to_string(&original).unwrap(), "older");
473        assert_eq!(
474            fs::read_to_string(&backup).unwrap(),
475            "older",
476            "the backup stays"
477        );
478        let kept: Vec<PathBuf> = fs::read_dir(root.path())
479            .unwrap()
480            .flatten()
481            .map(|item| item.path())
482            .filter(|path| path.to_string_lossy().contains(".helixforge-backup-"))
483            .collect();
484        assert_eq!(kept.len(), 1);
485        assert_eq!(fs::read_to_string(&kept[0]).unwrap(), "current");
486        // A folder, with nothing in its place now.
487        let folder = root.path().join("nvim");
488        let saved = root.path().join("nvim.commander-backup-20261001000000-1");
489        fs::create_dir_all(saved.join("lua")).unwrap();
490        fs::write(saved.join("lua/init.lua"), "old").unwrap();
491        let plan = plan_restore(&Backup {
492            backup: saved,
493            original: folder.clone(),
494            taken: None,
495            system: false,
496            directory: true,
497        });
498        run(&plan);
499        assert_eq!(
500            fs::read_to_string(folder.join("lua/init.lua")).unwrap(),
501            "old"
502        );
503        // A system file needs sudo.
504        let system = plan_restore(&Backup {
505            backup: PathBuf::from("/etc/default/grub.commander-backup-20261001000000-1"),
506            original: PathBuf::from("/etc/default/grub"),
507            taken: None,
508            system: true,
509            directory: false,
510        });
511        assert!(system.privileged);
512        assert_eq!(system.requirements, ["sudo"]);
513        assert!(system.steps[0].display().starts_with("sudo cp -a "));
514    }
515
516    #[test]
517    fn a_strange_name_is_shown_safely_and_restored_as_it_is() {
518        let backup = Backup {
519            backup: PathBuf::from("/home/me/.config/a\u{1b}[2Jb.commander-backup-20261001000000-1"),
520            original: PathBuf::from("/home/me/.config/a\u{1b}[2Jb"),
521            taken: None,
522            system: false,
523            directory: false,
524        };
525        let step = restore_step(&backup);
526        assert!(!step.display().contains('\u{1b}'));
527        assert!(!step.name().contains('\u{1b}'));
528        let Step::Shell { program, .. } = &step else {
529            panic!("expected a shell step");
530        };
531        assert!(
532            program.contains("'/home/me/.config/a\u{1b}[2Jb'"),
533            "the real name, quoted"
534        );
535    }
536
537    #[test]
538    fn snapshots_go_first_when_the_system_changes() {
539        let snapper = |path: &str| path == "/etc/snapper/configs/root";
540        assert_eq!(
541            Snapshots::detect(&snapper, &|_| true),
542            Some(Snapshots::Snapper)
543        );
544        assert_eq!(Snapshots::detect(&|_| false, &|_| true), None);
545        assert_eq!(
546            Snapshots::detect(
547                &|path| path == "/etc/timeshift/timeshift.json",
548                &|tool| tool == "timeshift"
549            ),
550            Some(Snapshots::Timeshift)
551        );
552        let mut plan = Plan {
553            steps: vec![
554                Step::Command {
555                    id: "grub".into(),
556                    name: "GRUB Theme".into(),
557                    line: "true".into(),
558                },
559                Step::Command {
560                    id: "after".into(),
561                    name: "After".into(),
562                    line: "true".into(),
563                },
564            ],
565            depends: vec![vec![], vec![0]],
566            risk: Risk::Medium,
567            ..Plan::default()
568        };
569        let flags = |id: &str| (id == "grub").then(|| "I PFM MP".to_string());
570        assert!(worth_a_snapshot(&plan, &flags));
571        assert!(!worth_a_snapshot(&plan, &|_| Some("I".to_string())));
572        with_snapshot(&mut plan, Snapshots::Snapper);
573        assert_eq!(plan.steps[0].name(), "Take a Snapper snapshot");
574        assert_eq!(
575            plan.steps[0].display(),
576            "sudo snapper -c root create --cleanup-algorithm number --description HelixForge, before: GRUB Theme, After"
577        );
578        assert_eq!(plan.depends, [vec![], vec![], vec![1]]);
579        assert!(plan.privileged);
580        assert!(plan.requirements.contains(&"snapper".to_string()));
581    }
582}