Skip to main content

helixforge_engine/
doctor.rs

1//! HELIX Doctor: read-only health checks that say what is wrong, show the
2//! evidence, and offer a repair where a safe one exists. A repair is a plan
3//! of its own, confirmed and run like any other; nothing here changes the
4//! system.
5
6use crate::{Plan, Step, quote};
7use helixforge_core::{PackageManager, Risk, printable};
8use std::{
9    collections::HashMap,
10    path::{Path, PathBuf},
11    process::{Command, Stdio},
12    time::Duration,
13};
14
15/// How a check came out.
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
17pub enum Health {
18    Good,
19    /// Worth knowing; not broken.
20    Notice,
21    Problem,
22}
23
24impl Health {
25    pub fn label(self) -> &'static str {
26        match self {
27            Health::Good => "OK",
28            Health::Notice => "Notice",
29            Health::Problem => "Problem",
30        }
31    }
32}
33
34/// What one check found.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Diagnosis {
37    /// Stable, for `helixforge doctor --repair <id>`.
38    pub id: String,
39    pub title: String,
40    pub health: Health,
41    pub summary: String,
42    /// What was looked at, line by line.
43    pub evidence: Vec<String>,
44    /// What to do when there is no repair HelixForge can run.
45    pub advice: Option<String>,
46    /// A repair HelixForge can run, after its own confirmation.
47    pub repair: Option<Repair>,
48    /// A catalog entry that addresses it.
49    pub entry: Option<String>,
50}
51
52/// A repair: a few commands, each built from quoted words.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct Repair {
55    pub label: String,
56    pub commands: Vec<Vec<String>>,
57}
58
59impl Repair {
60    fn new(label: impl Into<String>, commands: Vec<Vec<&str>>) -> Self {
61        Repair {
62            label: label.into(),
63            commands: commands
64                .into_iter()
65                .map(|command| command.into_iter().map(String::from).collect())
66                .collect(),
67        }
68    }
69
70    /// The repair as a plan of one step.
71    pub fn plan(&self, id: &str) -> Plan {
72        let program = self
73            .commands
74            .iter()
75            .map(|command| {
76                command
77                    .iter()
78                    .map(|word| quote(word))
79                    .collect::<Vec<_>>()
80                    .join(" ")
81            })
82            .collect::<Vec<_>>()
83            .join(" &&\n");
84        // Shown in plans; the program keeps the real words, quoted.
85        let display = printable(
86            &self
87                .commands
88                .iter()
89                .map(|command| command.join(" "))
90                .collect::<Vec<_>>()
91                .join(" && "),
92        );
93        let mut requires: Vec<String> = self
94            .commands
95            .iter()
96            .filter_map(|command| command.first().cloned())
97            .collect();
98        requires.sort();
99        requires.dedup();
100        let privileged = requires.iter().any(|tool| tool == "sudo");
101        Plan {
102            steps: vec![Step::Shell {
103                id: format!("repair-{id}"),
104                name: format!("Repair: {}", self.label),
105                program,
106                display,
107                requires: requires.clone(),
108                changes: Vec::new(),
109            }],
110            requirements: requires,
111            privileged,
112            risk: if privileged { Risk::Medium } else { Risk::Low },
113            depends: vec![Vec::new()],
114            ..Plan::default()
115        }
116    }
117}
118
119/// What the checks read from the machine; tests give a made-up one.
120pub trait System {
121    fn read(&self, path: &Path) -> Option<String>;
122    fn exists(&self, path: &Path) -> bool;
123    fn list(&self, path: &Path) -> Vec<String>;
124    fn link(&self, path: &Path) -> Option<PathBuf>;
125    /// A command's success and standard output; None when it cannot run.
126    fn run(&self, command: &[&str]) -> Option<(bool, String)>;
127    /// The addresses a host name resolves to, within a few seconds.
128    fn resolve(&self, host: &str) -> Result<Vec<String>, String>;
129    /// Free and total bytes of the file system holding a path.
130    fn space(&self, path: &Path) -> Option<(u64, u64)>;
131    /// The owner's user id.
132    fn owner(&self, path: &Path) -> Option<u32>;
133    /// Whether the path itself is a symbolic link.
134    fn is_link(&self, path: &Path) -> bool;
135    /// The path with every link resolved.
136    fn canonical(&self, path: &Path) -> Option<PathBuf>;
137    fn user(&self) -> (u32, u32, PathBuf);
138    fn on_path(&self, tool: &str) -> bool;
139    /// Names of running processes.
140    fn processes(&self) -> Vec<String>;
141}
142
143/// This machine.
144pub struct Here;
145
146impl System for Here {
147    fn read(&self, path: &Path) -> Option<String> {
148        std::fs::read_to_string(path).ok()
149    }
150
151    fn exists(&self, path: &Path) -> bool {
152        path.exists()
153    }
154
155    fn list(&self, path: &Path) -> Vec<String> {
156        let mut names: Vec<String> = std::fs::read_dir(path)
157            .map(|listing| {
158                listing
159                    .flatten()
160                    .map(|item| item.file_name().to_string_lossy().into_owned())
161                    .collect()
162            })
163            .unwrap_or_default();
164        names.sort();
165        names
166    }
167
168    fn link(&self, path: &Path) -> Option<PathBuf> {
169        std::fs::read_link(path).ok()
170    }
171
172    fn run(&self, command: &[&str]) -> Option<(bool, String)> {
173        let output = Command::new(command[0])
174            .args(&command[1..])
175            .env("LC_ALL", "C")
176            .stdin(Stdio::null())
177            .stderr(Stdio::null())
178            .output()
179            .ok()?;
180        Some((
181            output.status.success(),
182            String::from_utf8_lossy(&output.stdout).into_owned(),
183        ))
184    }
185
186    fn resolve(&self, host: &str) -> Result<Vec<String>, String> {
187        use std::net::ToSocketAddrs;
188        let host = format!("{host}:443");
189        let (sender, receiver) = std::sync::mpsc::channel();
190        std::thread::spawn(move || {
191            let result = host
192                .to_socket_addrs()
193                .map(|addresses| addresses.map(|address| address.ip().to_string()).collect())
194                .map_err(|error| error.to_string());
195            let _ = sender.send(result);
196        });
197        receiver
198            .recv_timeout(Duration::from_secs(5))
199            .unwrap_or_else(|_| Err("no answer within 5 seconds".to_string()))
200    }
201
202    fn space(&self, path: &Path) -> Option<(u64, u64)> {
203        let stats = nix::sys::statvfs::statvfs(path).ok()?;
204        let unit = stats.fragment_size() as u64;
205        Some((
206            stats.blocks_available() as u64 * unit,
207            stats.blocks() as u64 * unit,
208        ))
209    }
210
211    fn owner(&self, path: &Path) -> Option<u32> {
212        use std::os::unix::fs::MetadataExt;
213        std::fs::symlink_metadata(path)
214            .ok()
215            .map(|metadata| metadata.uid())
216    }
217
218    fn is_link(&self, path: &Path) -> bool {
219        std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
220    }
221
222    fn canonical(&self, path: &Path) -> Option<PathBuf> {
223        std::fs::canonicalize(path).ok()
224    }
225
226    fn user(&self) -> (u32, u32, PathBuf) {
227        (
228            nix::unistd::getuid().as_raw(),
229            nix::unistd::getgid().as_raw(),
230            std::env::var_os("HOME")
231                .map(PathBuf::from)
232                .unwrap_or_default(),
233        )
234    }
235
236    fn on_path(&self, tool: &str) -> bool {
237        helixforge_platform::binary_exists(tool)
238    }
239
240    fn processes(&self) -> Vec<String> {
241        self.list(Path::new("/proc"))
242            .into_iter()
243            .filter(|name| name.chars().all(|character| character.is_ascii_digit()))
244            .filter_map(|pid| self.read(&Path::new("/proc").join(pid).join("comm")))
245            .map(|name| name.trim().to_string())
246            .collect()
247    }
248}
249
250/// A host every Linux machine with working DNS can resolve.
251const PROBE_HOST: &str = "github.com";
252
253/// Run every check.
254/// What is shown is made printable: journal lines, unit and file names and
255/// error text come from outside HelixForge.
256pub fn examine(system: &dyn System, manager: Option<PackageManager>) -> Vec<Diagnosis> {
257    let mut found = gather(system, manager);
258    for diagnosis in &mut found {
259        diagnosis.title = printable(&diagnosis.title);
260        diagnosis.summary = printable(&diagnosis.summary);
261        diagnosis.evidence = diagnosis
262            .evidence
263            .iter()
264            .map(|line| printable(line))
265            .collect();
266        diagnosis.advice = diagnosis.advice.as_deref().map(printable);
267        if let Some(repair) = &mut diagnosis.repair {
268            repair.label = printable(&repair.label);
269        }
270    }
271    found
272}
273
274fn gather(system: &dyn System, manager: Option<PackageManager>) -> Vec<Diagnosis> {
275    let mut found = Vec::new();
276    let online = network(system, &mut found);
277    dns(system, online, &mut found);
278    services(system, &mut found);
279    graphics(system, manager, &mut found);
280    packages(system, manager, &mut found);
281    permissions(system, &mut found);
282    boot(system, manager, &mut found);
283    space(system, manager, &mut found);
284    found
285}
286
287fn diagnosis(id: &str, title: &str, health: Health, summary: impl Into<String>) -> Diagnosis {
288    Diagnosis {
289        id: id.to_string(),
290        title: title.to_string(),
291        health,
292        summary: summary.into(),
293        evidence: Vec::new(),
294        advice: None,
295        repair: None,
296        entry: None,
297    }
298}
299
300fn network(system: &dyn System, into: &mut Vec<Diagnosis>) -> bool {
301    let routes = system
302        .read(Path::new("/proc/net/route"))
303        .unwrap_or_default();
304    let default: Vec<&str> = routes
305        .lines()
306        .skip(1)
307        .filter_map(|line| {
308            let mut columns = line.split_whitespace();
309            let interface = columns.next()?;
310            (columns.next()? == "00000000").then_some(interface)
311        })
312        .collect();
313    let mut found = if default.is_empty() {
314        let mut found = diagnosis(
315            "network",
316            "Network",
317            Health::Problem,
318            "Not connected: there is no default route",
319        );
320        found.advice = Some(
321            "Connect to a network from the desktop's network menu or with nmcli, then check again."
322                .into(),
323        );
324        found
325    } else {
326        diagnosis(
327            "network",
328            "Network",
329            Health::Good,
330            format!("Connected through {}", default.join(", ")),
331        )
332    };
333    found.evidence.push(format!(
334        "default routes: {}",
335        if default.is_empty() {
336            "none".into()
337        } else {
338            default.join(", ")
339        }
340    ));
341    into.push(found);
342    !default.is_empty()
343}
344
345fn dns(system: &dyn System, online: bool, into: &mut Vec<Diagnosis>) {
346    let servers: Vec<String> = system
347        .read(Path::new("/etc/resolv.conf"))
348        .unwrap_or_default()
349        .lines()
350        .filter_map(|line| line.trim().strip_prefix("nameserver"))
351        .map(|server| server.trim().to_string())
352        .collect();
353    if !online {
354        into.push(diagnosis(
355            "dns",
356            "Name resolution (DNS)",
357            Health::Notice,
358            "Not checked: no network",
359        ));
360        return;
361    }
362    let mut found = match system.resolve(PROBE_HOST) {
363        Ok(addresses) => {
364            let mut found = diagnosis(
365                "dns",
366                "Name resolution (DNS)",
367                Health::Good,
368                format!("{PROBE_HOST} resolves"),
369            );
370            found
371                .evidence
372                .push(format!("{PROBE_HOST}: {}", addresses.join(", ")));
373            found
374        }
375        Err(error) => {
376            let mut found = diagnosis(
377                "dns",
378                "Name resolution (DNS)",
379                Health::Problem,
380                format!("{PROBE_HOST} does not resolve, though the network is up"),
381            );
382            found.evidence.push(format!("{PROBE_HOST}: {error}"));
383            let active = |unit: &str| {
384                system
385                    .run(&["systemctl", "is-active", unit])
386                    .is_some_and(|(ok, _)| ok)
387            };
388            if active("systemd-resolved") {
389                found.repair = Some(Repair::new(
390                    "restart systemd-resolved",
391                    vec![vec!["sudo", "systemctl", "restart", "systemd-resolved"]],
392                ));
393            } else if active("NetworkManager") {
394                found.repair = Some(Repair::new(
395                    "restart NetworkManager",
396                    vec![vec!["sudo", "systemctl", "restart", "NetworkManager"]],
397                ));
398            } else {
399                found.advice = Some("Check the name servers in /etc/resolv.conf, or set them in your network settings.".into());
400            }
401            found
402        }
403    };
404    found.evidence.push(format!(
405        "name servers: {}",
406        if servers.is_empty() {
407            "none in /etc/resolv.conf".into()
408        } else {
409            servers.join(", ")
410        }
411    ));
412    into.push(found);
413}
414
415fn services(system: &dyn System, into: &mut Vec<Diagnosis>) {
416    let mut failed = 0;
417    for (user, scope) in [(false, "system"), (true, "user")] {
418        let mut command = vec!["systemctl"];
419        if user {
420            command.push("--user");
421        }
422        command.extend(["--failed", "--no-legend", "--plain"]);
423        let Some((_, listing)) = system.run(&command) else {
424            continue;
425        };
426        for (unit, load) in listing.lines().filter_map(|line| {
427            let mut columns = line.split_whitespace();
428            Some((columns.next()?, columns.next().unwrap_or("")))
429        }) {
430            if !unit.contains('.') || unit.contains(['/', '\'']) {
431                continue;
432            }
433            failed += 1;
434            let id = format!("{}service:{unit}", if user { "user-" } else { "" });
435            let systemctl: Vec<&str> = if user {
436                vec!["systemctl", "--user"]
437            } else {
438                vec!["sudo", "systemctl"]
439            };
440            // Its unit file is gone (the package was removed): nothing can
441            // restart it, and systemd only remembers that it failed.
442            if load == "not-found" {
443                let mut found = diagnosis(
444                    &id,
445                    &format!("Service {unit}"),
446                    Health::Notice,
447                    format!(
448                        "The {scope} service {unit} was removed, but its last failure is still remembered"
449                    ),
450                );
451                found.evidence.push(format!(
452                    "systemctl lists {unit} as failed with no unit file (not-found)"
453                ));
454                let mut forget = systemctl.clone();
455                forget.extend(["reset-failed", unit]);
456                found.repair = Some(Repair::new(
457                    format!("forget {unit}'s failure"),
458                    vec![forget],
459                ));
460                into.push(found);
461                continue;
462            }
463            let mut found = diagnosis(
464                &id,
465                &format!("Service {unit}"),
466                Health::Problem,
467                format!("The {scope} service {unit} failed"),
468            );
469            let mut journal = vec!["journalctl", "-n", "5", "--no-pager", "-q", "-o", "cat"];
470            journal.extend(if user {
471                ["--user-unit", unit]
472            } else {
473                ["-u", unit]
474            });
475            if let Some((_, lines)) = system.run(&journal) {
476                found
477                    .evidence
478                    .extend(lines.lines().map(|line| format!("journal: {line}")));
479            }
480            let mut restart = systemctl;
481            restart.extend(["restart", unit]);
482            found.repair = Some(Repair::new(format!("restart {unit}"), vec![restart]));
483            into.push(found);
484        }
485    }
486    if failed == 0 {
487        into.push(diagnosis(
488            "services",
489            "Services",
490            Health::Good,
491            "No failed services",
492        ));
493    }
494}
495
496fn graphics(system: &dyn System, manager: Option<PackageManager>, into: &mut Vec<Diagnosis>) {
497    let devices = Path::new("/sys/bus/pci/devices");
498    for slot in system.list(devices) {
499        let device = devices.join(&slot);
500        let class = system.read(&device.join("class")).unwrap_or_default();
501        if !class.trim().starts_with("0x03") {
502            continue;
503        }
504        let vendor = match system
505            .read(&device.join("vendor"))
506            .unwrap_or_default()
507            .trim()
508        {
509            "0x10de" => "NVIDIA",
510            "0x1002" => "AMD",
511            "0x8086" => "Intel",
512            _ => "Other",
513        };
514        let driver = system.link(&device.join("driver")).and_then(|link| {
515            link.file_name()
516                .map(|name| name.to_string_lossy().into_owned())
517        });
518        let id = format!("graphics:{slot}");
519        let title = format!("{vendor} graphics ({slot})");
520        let mut found = match driver.as_deref() {
521            None => {
522                let mut found = diagnosis(
523                    &id,
524                    &title,
525                    Health::Problem,
526                    "No driver is in use for this graphics card",
527                );
528                found.entry = Some(
529                    if vendor == "NVIDIA" && manager == Some(PackageManager::Arch) {
530                        "nvidia-drivers-hardware-acceleration".to_string()
531                    } else {
532                        "general-gpu-drivers".to_string()
533                    },
534                );
535                found
536            }
537            Some("nouveau") => {
538                let mut found = diagnosis(
539                    &id,
540                    &title,
541                    Health::Notice,
542                    "The open nouveau driver is in use; games and CUDA need NVIDIA's driver",
543                );
544                found.entry = Some(if manager == Some(PackageManager::Arch) {
545                    "nvidia-drivers-hardware-acceleration".to_string()
546                } else {
547                    "general-gpu-drivers".to_string()
548                });
549                found
550            }
551            Some(driver) => diagnosis(
552                &id,
553                &title,
554                Health::Good,
555                format!("The {driver} driver is in use"),
556            ),
557        };
558        found.evidence.push(format!(
559            "{slot}: vendor {vendor}, driver {}",
560            driver.as_deref().unwrap_or("none")
561        ));
562        into.push(found);
563    }
564}
565
566fn packages(system: &dyn System, manager: Option<PackageManager>, into: &mut Vec<Diagnosis>) {
567    match manager {
568        Some(PackageManager::Arch) => {
569            let lock = Path::new("/var/lib/pacman/db.lck");
570            if !system.exists(lock) {
571                into.push(diagnosis(
572                    "package-lock",
573                    "Package database",
574                    Health::Good,
575                    "pacman's database is not locked",
576                ));
577                return;
578            }
579            let running: Vec<String> = system
580                .processes()
581                .into_iter()
582                .filter(|name| {
583                    matches!(
584                        name.as_str(),
585                        "pacman" | "paru" | "yay" | "pamac" | "packagekitd"
586                    )
587                })
588                .collect();
589            if running.is_empty() {
590                let mut found = diagnosis(
591                    "package-lock",
592                    "Package database",
593                    Health::Problem,
594                    "pacman's database is locked, but no package manager is running",
595                );
596                found.evidence.push(format!(
597                    "{} exists; no pacman, paru, yay, pamac or packagekitd process",
598                    lock.display()
599                ));
600                found.repair = Some(Repair::new(
601                    "remove the stale pacman lock",
602                    vec![vec!["sudo", "rm", "-f", "/var/lib/pacman/db.lck"]],
603                ));
604                into.push(found);
605            } else {
606                let mut found = diagnosis(
607                    "package-lock",
608                    "Package database",
609                    Health::Notice,
610                    "pacman's database is locked while a package manager runs",
611                );
612                found
613                    .evidence
614                    .push(format!("running: {}", running.join(", ")));
615                found.advice = Some("Let it finish; the lock goes when it does.".into());
616                into.push(found);
617            }
618        }
619        Some(PackageManager::Debian) => match system.run(&["dpkg", "--audit"]) {
620            Some((_, report)) if !report.trim().is_empty() => {
621                let mut found = diagnosis(
622                    "dpkg-audit",
623                    "Package database",
624                    Health::Problem,
625                    "dpkg reports packages that are not fully installed",
626                );
627                found
628                    .evidence
629                    .extend(report.lines().take(8).map(String::from));
630                found.repair = Some(Repair::new(
631                    "finish configuring packages",
632                    vec![vec!["sudo", "dpkg", "--configure", "-a"]],
633                ));
634                into.push(found);
635            }
636            _ => into.push(diagnosis(
637                "dpkg-audit",
638                "Package database",
639                Health::Good,
640                "dpkg reports nothing unfinished",
641            )),
642        },
643        _ => {}
644    }
645}
646
647fn permissions(system: &dyn System, into: &mut Vec<Diagnosis>) {
648    let (uid, gid, home) = system.user();
649    if uid == 0 || home.as_os_str().is_empty() {
650        return;
651    }
652    let Some(real_home) = system.canonical(&home) else {
653        return;
654    };
655    // Where a path in the home really is, if no link anywhere on its way
656    // leads out of where it appears; None otherwise.
657    let inside = |path: &Path| -> Option<PathBuf> {
658        let expected = real_home.join(path.strip_prefix(&home).ok()?);
659        (system.canonical(path)? == expected).then_some(expected)
660    };
661    let mut wrong: Vec<PathBuf> = Vec::new();
662    // Links are never handed to chown; they are only mentioned.
663    let mut links: Vec<PathBuf> = Vec::new();
664    if system.owner(&real_home).is_some_and(|owner| owner != uid) {
665        wrong.push(real_home.clone());
666    }
667    for folder in [
668        home.clone(),
669        home.join(".config"),
670        home.join(".local/share"),
671        home.join(".local/bin"),
672        home.join(".cache"),
673    ] {
674        // A folder that is a link, or reached through one, is not looked in.
675        if folder != home && (system.is_link(&folder) || inside(&folder).is_none()) {
676            continue;
677        }
678        for name in system.list(&folder) {
679            let path = folder.join(&name);
680            if folder == home && !name.starts_with('.') {
681                continue;
682            }
683            if system.is_link(&path) {
684                if system.owner(&path) == Some(0) {
685                    links.push(path);
686                }
687                continue;
688            }
689            if system.owner(&path) != Some(0) {
690                continue;
691            }
692            match inside(&path) {
693                Some(real) if !wrong.contains(&real) => wrong.push(real),
694                Some(_) => {}
695                None => links.push(path),
696            }
697        }
698    }
699    let left_alone = |path: &PathBuf| {
700        format!(
701            "owned by root through a link, left alone: {}",
702            path.display()
703        )
704    };
705    if wrong.is_empty() {
706        let mut found = diagnosis(
707            "permissions",
708            "Home folder permissions",
709            Health::Good,
710            "Your home folder and settings are yours",
711        );
712        found.evidence.extend(links.iter().map(left_alone));
713        into.push(found);
714        return;
715    }
716    let mut found = diagnosis(
717        "permissions",
718        "Home folder permissions",
719        Health::Problem,
720        if wrong.len() == 1 {
721            "1 item in your home belongs to root".to_string()
722        } else {
723            format!("{} items in your home belong to root", wrong.len())
724        },
725    );
726    found.evidence.extend(
727        wrong
728            .iter()
729            .map(|path| format!("owned by root: {}", path.display())),
730    );
731    found.advice = Some(
732        "Programs running as you cannot change these. This usually comes from running a program with sudo."
733            .into(),
734    );
735    found.evidence.extend(links.iter().map(left_alone));
736    let owner = format!("{uid}:{gid}");
737    // Just before the change, every path must still be where it was found,
738    // with no link on its way; otherwise nothing changes.
739    let mut guard: Vec<String> = vec![
740        "sh".into(),
741        "-c".into(),
742        "for path; do [ \"$(realpath -e -- \"$path\")\" = \"$path\" ] || { echo \"HelixForge: $path moved or is reached through a link now; nothing was changed\" >&2; exit 1; }; done".into(),
743        "sh".into(),
744    ];
745    guard.extend(wrong.iter().map(|path| path.to_string_lossy().into_owned()));
746    // -h changes a link itself and -P never follows one while recursing, so
747    // nothing outside these paths changes, even if one becomes a link later.
748    let mut command: Vec<String> = vec![
749        "sudo".into(),
750        "chown".into(),
751        "-R".into(),
752        "-h".into(),
753        "-P".into(),
754        owner,
755        "--".into(),
756    ];
757    command.extend(wrong.iter().map(|path| path.to_string_lossy().into_owned()));
758    found.repair = Some(Repair {
759        label: "give them back to you".into(),
760        commands: vec![guard, command],
761    });
762    into.push(found);
763}
764
765fn boot(system: &dyn System, manager: Option<PackageManager>, into: &mut Vec<Diagnosis>) {
766    let modules = if system.exists(Path::new("/usr/lib/modules")) {
767        Path::new("/usr/lib/modules")
768    } else {
769        Path::new("/lib/modules")
770    };
771    let mut missing: Vec<String> = Vec::new();
772    let mut evidence = Vec::new();
773    for version in system.list(modules) {
774        // Arch names its images after the package base; the others after the version.
775        let (kernel, image) = match system.read(&modules.join(&version).join("pkgbase")) {
776            Some(base) => {
777                let base = base.trim().to_string();
778                (
779                    format!("/boot/vmlinuz-{base}"),
780                    format!("/boot/initramfs-{base}.img"),
781                )
782            }
783            None if manager == Some(PackageManager::Debian) => (
784                format!("/boot/vmlinuz-{version}"),
785                format!("/boot/initrd.img-{version}"),
786            ),
787            None => (
788                format!("/boot/vmlinuz-{version}"),
789                format!("/boot/initramfs-{version}.img"),
790            ),
791        };
792        if !system.exists(Path::new(&kernel)) {
793            // Modules left behind by a removed kernel, or a kernel kept elsewhere.
794            continue;
795        }
796        if system.exists(Path::new(&image)) {
797            evidence.push(format!("{kernel}: {image} present"));
798        } else {
799            evidence.push(format!("{kernel}: {image} MISSING"));
800            missing.push(image);
801        }
802    }
803    let regenerate: Option<Vec<&str>> = if system.on_path("mkinitcpio") {
804        Some(vec!["sudo", "mkinitcpio", "-P"])
805    } else if system.on_path("dracut-rebuild") {
806        Some(vec!["sudo", "dracut-rebuild"])
807    } else if system.on_path("update-initramfs") {
808        Some(vec!["sudo", "update-initramfs", "-u", "-k", "all"])
809    } else if system.on_path("dracut") {
810        Some(vec!["sudo", "dracut", "--regenerate-all", "--force"])
811    } else {
812        None
813    };
814    let mut found = if missing.is_empty() {
815        diagnosis(
816            "initramfs",
817            "Boot images",
818            Health::Good,
819            "Every installed kernel has its initramfs",
820        )
821    } else {
822        let mut found = diagnosis(
823            "initramfs",
824            "Boot images",
825            Health::Problem,
826            format!(
827                "{} kernel(s) have no initramfs and would not boot",
828                missing.len()
829            ),
830        );
831        match regenerate {
832            Some(command) => {
833                found.repair = Some(Repair::new("rebuild the initramfs images", vec![command]))
834            }
835            None => {
836                found.advice = Some("Rebuild them with your distribution's initramfs tool.".into())
837            }
838        }
839        found
840    };
841    found.evidence = evidence;
842    into.push(found);
843    // GRUB: a configuration without a generated menu.
844    if system.exists(Path::new("/etc/default/grub")) {
845        let menu = ["/boot/grub/grub.cfg", "/boot/grub2/grub.cfg"]
846            .into_iter()
847            .find(|path| system.exists(Path::new(path)));
848        let mut found = match menu {
849            Some(menu) => {
850                let mut found = diagnosis(
851                    "grub-menu",
852                    "GRUB menu",
853                    Health::Good,
854                    "GRUB's menu is generated",
855                );
856                found.evidence.push(format!("{menu} present"));
857                found
858            }
859            None => {
860                let mut found = diagnosis(
861                    "grub-menu",
862                    "GRUB menu",
863                    Health::Problem,
864                    "/etc/default/grub exists, but GRUB has no generated menu",
865                );
866                let output = if system.exists(Path::new("/boot/grub2")) {
867                    "/boot/grub2/grub.cfg"
868                } else {
869                    "/boot/grub/grub.cfg"
870                };
871                let tool = if system.on_path("grub2-mkconfig") {
872                    "grub2-mkconfig"
873                } else {
874                    "grub-mkconfig"
875                };
876                found.repair = Some(Repair::new(
877                    "generate GRUB's menu",
878                    vec![vec!["sudo", tool, "-o", output]],
879                ));
880                found
881            }
882        };
883        found.evidence.push("/etc/default/grub present".into());
884        into.push(found);
885    }
886    // The EFI system partition, small on many installs.
887    for esp in ["/boot/efi", "/efi"] {
888        let path = Path::new(esp);
889        if !system.exists(path) {
890            continue;
891        }
892        let Some((free, total)) = system.space(path) else {
893            continue;
894        };
895        if total == 0 {
896            continue;
897        }
898        let mib = |bytes: u64| bytes >> 20;
899        let mut found = if free < (16 << 20) || free * 10 < total {
900            let mut found = diagnosis(
901                "esp-space",
902                "EFI partition",
903                Health::Notice,
904                format!("{esp} has {} MiB free of {} MiB", mib(free), mib(total)),
905            );
906            found.advice = Some(
907                "Boot-loader updates write here; remove old boot entries or kernels if it fills."
908                    .into(),
909            );
910            found
911        } else {
912            diagnosis(
913                "esp-space",
914                "EFI partition",
915                Health::Good,
916                format!("{esp} has {} MiB free of {} MiB", mib(free), mib(total)),
917            )
918        };
919        found.evidence.push(format!(
920            "{esp}: {} MiB free, {} MiB total",
921            mib(free),
922            mib(total)
923        ));
924        into.push(found);
925        break;
926    }
927}
928
929fn space(system: &dyn System, manager: Option<PackageManager>, into: &mut Vec<Diagnosis>) {
930    let Some((free, total)) = system.space(Path::new("/")) else {
931        return;
932    };
933    if total == 0 {
934        return;
935    }
936    let gib = |bytes: u64| bytes as f64 / f64::from(1u32 << 30);
937    let summary = format!("{:.1} GiB free of {:.1} GiB on /", gib(free), gib(total));
938    let mut found = if free < (2 << 30) || free * 20 < total {
939        let mut found = diagnosis("disk-space", "Disk space", Health::Problem, summary);
940        found.repair = match manager {
941            Some(PackageManager::Arch) if system.on_path("paccache") => Some(Repair::new(
942                "keep two versions of each cached package and trim the journal",
943                vec![
944                    vec!["sudo", "paccache", "-rk2"],
945                    vec!["sudo", "journalctl", "--vacuum-size=200M"],
946                ],
947            )),
948            Some(PackageManager::Debian) => Some(Repair::new(
949                "clear apt's package cache",
950                vec![vec!["sudo", "apt-get", "clean"]],
951            )),
952            Some(PackageManager::Fedora) => Some(Repair::new(
953                "clear dnf's package cache",
954                vec![vec!["sudo", "dnf", "clean", "packages"]],
955            )),
956            _ => None,
957        };
958        found
959    } else if free * 10 < total {
960        diagnosis("disk-space", "Disk space", Health::Notice, summary)
961    } else {
962        diagnosis("disk-space", "Disk space", Health::Good, summary)
963    };
964    found.evidence.push(format!(
965        "/: {:.1} GiB free, {:.1} GiB total",
966        gib(free),
967        gib(total)
968    ));
969    into.push(found);
970}
971
972/// A made-up machine, for tests.
973#[derive(Default)]
974pub struct Fake {
975    pub files: HashMap<PathBuf, String>,
976    pub folders: HashMap<PathBuf, Vec<String>>,
977    pub links: HashMap<PathBuf, PathBuf>,
978    pub commands: HashMap<String, (bool, String)>,
979    pub dns: Option<Result<Vec<String>, String>>,
980    pub spaces: HashMap<PathBuf, (u64, u64)>,
981    pub owners: HashMap<PathBuf, u32>,
982    /// Where a path really is, when a link on the way leads elsewhere.
983    pub canonical: HashMap<PathBuf, PathBuf>,
984    pub tools: Vec<String>,
985    pub running: Vec<String>,
986    pub home: PathBuf,
987}
988
989impl System for Fake {
990    fn read(&self, path: &Path) -> Option<String> {
991        self.files.get(path).cloned()
992    }
993
994    fn exists(&self, path: &Path) -> bool {
995        self.files.contains_key(path) || self.folders.contains_key(path)
996    }
997
998    fn list(&self, path: &Path) -> Vec<String> {
999        self.folders.get(path).cloned().unwrap_or_default()
1000    }
1001
1002    fn link(&self, path: &Path) -> Option<PathBuf> {
1003        self.links.get(path).cloned()
1004    }
1005
1006    fn run(&self, command: &[&str]) -> Option<(bool, String)> {
1007        self.commands.get(&command.join(" ")).cloned()
1008    }
1009
1010    fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1011        self.dns
1012            .clone()
1013            .unwrap_or_else(|| Ok(vec!["192.0.2.1".into()]))
1014    }
1015
1016    fn space(&self, path: &Path) -> Option<(u64, u64)> {
1017        self.spaces.get(path).copied()
1018    }
1019
1020    fn owner(&self, path: &Path) -> Option<u32> {
1021        Some(self.owners.get(path).copied().unwrap_or(1000))
1022    }
1023
1024    fn is_link(&self, path: &Path) -> bool {
1025        self.links.contains_key(path)
1026    }
1027
1028    fn canonical(&self, path: &Path) -> Option<PathBuf> {
1029        Some(
1030            self.canonical
1031                .get(path)
1032                .cloned()
1033                .unwrap_or_else(|| path.to_path_buf()),
1034        )
1035    }
1036
1037    fn user(&self) -> (u32, u32, PathBuf) {
1038        (1000, 1000, self.home.clone())
1039    }
1040
1041    fn on_path(&self, tool: &str) -> bool {
1042        self.tools.iter().any(|known| known == tool)
1043    }
1044
1045    fn processes(&self) -> Vec<String> {
1046        self.running.clone()
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    const ROUTE: &str = "Iface\tDestination\tGateway\nwlan0\t00000000\t0101A8C0\n";
1055
1056    fn healthy() -> Fake {
1057        let mut fake = Fake {
1058            home: PathBuf::from("/home/me"),
1059            ..Fake::default()
1060        };
1061        fake.files.insert("/proc/net/route".into(), ROUTE.into());
1062        fake.files
1063            .insert("/etc/resolv.conf".into(), "nameserver 192.0.2.53\n".into());
1064        fake.commands.insert(
1065            "systemctl --failed --no-legend --plain".into(),
1066            (true, String::new()),
1067        );
1068        fake.commands.insert(
1069            "systemctl --user --failed --no-legend --plain".into(),
1070            (true, String::new()),
1071        );
1072        fake.folders
1073            .insert("/sys/bus/pci/devices".into(), vec!["0000:01:00.0".into()]);
1074        fake.files.insert(
1075            "/sys/bus/pci/devices/0000:01:00.0/class".into(),
1076            "0x030000\n".into(),
1077        );
1078        fake.files.insert(
1079            "/sys/bus/pci/devices/0000:01:00.0/vendor".into(),
1080            "0x10de\n".into(),
1081        );
1082        fake.links.insert(
1083            "/sys/bus/pci/devices/0000:01:00.0/driver".into(),
1084            "../../../bus/pci/drivers/nvidia".into(),
1085        );
1086        fake.folders
1087            .insert("/usr/lib/modules".into(), vec!["7.2.10-1-garuda".into()]);
1088        fake.files.insert(
1089            "/usr/lib/modules/7.2.10-1-garuda/pkgbase".into(),
1090            "linux-garuda\n".into(),
1091        );
1092        fake.files
1093            .insert("/boot/vmlinuz-linux-garuda".into(), String::new());
1094        fake.files
1095            .insert("/boot/initramfs-linux-garuda.img".into(), String::new());
1096        fake.files.insert("/etc/default/grub".into(), String::new());
1097        fake.files
1098            .insert("/boot/grub/grub.cfg".into(), String::new());
1099        fake.folders.insert("/boot/efi".into(), Vec::new());
1100        fake.spaces
1101            .insert("/boot/efi".into(), (290 << 20, 300 << 20));
1102        fake.spaces.insert("/".into(), (500 << 30, 900 << 30));
1103        fake.folders
1104            .insert("/home/me".into(), vec![".config".into(), "Music".into()]);
1105        fake.tools.push("dracut-rebuild".into());
1106        fake
1107    }
1108
1109    fn find<'a>(found: &'a [Diagnosis], id: &str) -> &'a Diagnosis {
1110        found
1111            .iter()
1112            .find(|diagnosis| diagnosis.id == id)
1113            .unwrap_or_else(|| panic!("no {id} in {found:?}"))
1114    }
1115
1116    #[test]
1117    fn a_healthy_machine_is_all_good() {
1118        let found = examine(&healthy(), Some(PackageManager::Arch));
1119        let problems: Vec<&Diagnosis> = found
1120            .iter()
1121            .filter(|diagnosis| diagnosis.health != Health::Good)
1122            .collect();
1123        assert!(problems.is_empty(), "{problems:?}");
1124        let ids: Vec<&str> = found
1125            .iter()
1126            .map(|diagnosis| diagnosis.id.as_str())
1127            .collect();
1128        assert_eq!(
1129            ids,
1130            [
1131                "network",
1132                "dns",
1133                "services",
1134                "graphics:0000:01:00.0",
1135                "package-lock",
1136                "permissions",
1137                "initramfs",
1138                "grub-menu",
1139                "esp-space",
1140                "disk-space"
1141            ]
1142        );
1143    }
1144
1145    #[test]
1146    fn network_and_dns_problems_say_what_to_do() {
1147        let mut offline = healthy();
1148        offline
1149            .files
1150            .insert("/proc/net/route".into(), "Iface\tDestination\n".into());
1151        let found = examine(&offline, Some(PackageManager::Arch));
1152        assert_eq!(find(&found, "network").health, Health::Problem);
1153        assert!(find(&found, "network").advice.is_some());
1154        assert_eq!(find(&found, "dns").summary, "Not checked: no network");
1155        let mut broken = healthy();
1156        broken.dns = Some(Err("failed to lookup address information".into()));
1157        broken.commands.insert(
1158            "systemctl is-active systemd-resolved".into(),
1159            (false, "inactive\n".into()),
1160        );
1161        broken.commands.insert(
1162            "systemctl is-active NetworkManager".into(),
1163            (true, "active\n".into()),
1164        );
1165        let found = examine(&broken, Some(PackageManager::Arch));
1166        let dns = find(&found, "dns");
1167        assert_eq!(dns.health, Health::Problem);
1168        assert!(dns.evidence.iter().any(|line| line.contains("192.0.2.53")));
1169        assert_eq!(
1170            dns.repair.as_ref().unwrap().plan("dns").steps[0].display(),
1171            "sudo systemctl restart NetworkManager"
1172        );
1173    }
1174
1175    #[test]
1176    fn failed_services_show_their_journal_and_restart_separately() {
1177        let mut fake = healthy();
1178        fake.commands.insert(
1179            "systemctl --failed --no-legend --plain".into(),
1180            (true, "cups.service loaded failed failed CUPS\n".into()),
1181        );
1182        fake.commands.insert(
1183            "systemctl --user --failed --no-legend --plain".into(),
1184            (
1185                true,
1186                "syncthing.service loaded failed failed Syncthing\n".into(),
1187            ),
1188        );
1189        fake.commands.insert(
1190            "journalctl -n 5 --no-pager -q -o cat -u cups.service".into(),
1191            (true, "cupsd: unable to bind\n".into()),
1192        );
1193        let found = examine(&fake, Some(PackageManager::Arch));
1194        let cups = find(&found, "service:cups.service");
1195        assert_eq!(cups.evidence, ["journal: cupsd: unable to bind"]);
1196        assert_eq!(
1197            cups.repair.as_ref().unwrap().plan("x").steps[0].display(),
1198            "sudo systemctl restart cups.service"
1199        );
1200        let user = find(&found, "user-service:syncthing.service");
1201        let plan = user.repair.as_ref().unwrap().plan("x");
1202        assert_eq!(
1203            plan.steps[0].display(),
1204            "systemctl --user restart syncthing.service"
1205        );
1206        assert!(!plan.privileged);
1207        assert!(found.iter().all(|diagnosis| diagnosis.id != "services"));
1208    }
1209
1210    #[test]
1211    fn a_removed_service_is_forgotten_not_restarted() {
1212        let mut fake = healthy();
1213        fake.commands.insert(
1214            "systemctl --failed --no-legend --plain".into(),
1215            (
1216                true,
1217                "autorandr.service not-found failed failed autorandr.service\n".into(),
1218            ),
1219        );
1220        fake.commands.insert(
1221            "systemctl --user --failed --no-legend --plain".into(),
1222            (
1223                true,
1224                "old.service not-found failed failed old.service\n".into(),
1225            ),
1226        );
1227        let found = examine(&fake, Some(PackageManager::Arch));
1228        let gone = find(&found, "service:autorandr.service");
1229        assert_eq!(gone.health, Health::Notice);
1230        assert!(gone.summary.contains("was removed"), "{}", gone.summary);
1231        let repair = gone.repair.as_ref().unwrap();
1232        assert_eq!(repair.label, "forget autorandr.service's failure");
1233        assert_eq!(
1234            repair.plan("x").steps[0].display(),
1235            "sudo systemctl reset-failed autorandr.service"
1236        );
1237        let user = find(&found, "user-service:old.service");
1238        assert_eq!(
1239            user.repair.as_ref().unwrap().plan("x").steps[0].display(),
1240            "systemctl --user reset-failed old.service"
1241        );
1242    }
1243
1244    #[test]
1245    fn text_from_outside_cannot_drive_the_terminal() {
1246        let mut fake = healthy();
1247        fake.commands.insert(
1248            "systemctl --failed --no-legend --plain".into(),
1249            (true, "x.service loaded failed failed X\n".into()),
1250        );
1251        fake.commands.insert(
1252            "journalctl -n 5 --no-pager -q -o cat -u x.service".into(),
1253            (true, "\u{1b}]52;c;bWFsaWNl\u{7}\u{1b}[2Jgone\n".into()),
1254        );
1255        fake.dns = Some(Err("bad\u{1b}[31m".into()));
1256        let found = examine(&fake, Some(PackageManager::Arch));
1257        for diagnosis in &found {
1258            for text in diagnosis
1259                .evidence
1260                .iter()
1261                .chain([&diagnosis.summary, &diagnosis.title])
1262            {
1263                assert!(!text.chars().any(char::is_control), "{text:?}");
1264            }
1265        }
1266        assert_eq!(
1267            find(&found, "service:x.service").evidence,
1268            ["journal: ?]52;c;bWFsaWNl??[2Jgone"]
1269        );
1270    }
1271
1272    #[test]
1273    fn graphics_without_a_driver_point_to_an_entry() {
1274        let mut fake = healthy();
1275        fake.links.clear();
1276        let found = examine(&fake, Some(PackageManager::Arch));
1277        let card = find(&found, "graphics:0000:01:00.0");
1278        assert_eq!(card.health, Health::Problem);
1279        assert_eq!(
1280            card.entry.as_deref(),
1281            Some("nvidia-drivers-hardware-acceleration")
1282        );
1283        fake.links.insert(
1284            "/sys/bus/pci/devices/0000:01:00.0/driver".into(),
1285            "../nouveau".into(),
1286        );
1287        let found = examine(&fake, Some(PackageManager::Fedora));
1288        assert_eq!(find(&found, "graphics:0000:01:00.0").health, Health::Notice);
1289        assert_eq!(
1290            find(&found, "graphics:0000:01:00.0").entry.as_deref(),
1291            Some("general-gpu-drivers")
1292        );
1293    }
1294
1295    #[test]
1296    fn a_stale_pacman_lock_is_removed_only_when_nothing_runs() {
1297        let mut fake = healthy();
1298        fake.files
1299            .insert("/var/lib/pacman/db.lck".into(), String::new());
1300        let found = examine(&fake, Some(PackageManager::Arch));
1301        let lock = find(&found, "package-lock");
1302        assert_eq!(lock.health, Health::Problem);
1303        assert_eq!(
1304            lock.repair.as_ref().unwrap().plan("x").steps[0].display(),
1305            "sudo rm -f /var/lib/pacman/db.lck"
1306        );
1307        fake.running = vec!["paru".into()];
1308        let found = examine(&fake, Some(PackageManager::Arch));
1309        let lock = find(&found, "package-lock");
1310        assert_eq!(lock.health, Health::Notice);
1311        assert!(lock.repair.is_none());
1312        let mut debian = healthy();
1313        debian.commands.insert(
1314            "dpkg --audit".into(),
1315            (
1316                true,
1317                "The following packages are only half configured:\n x\n".into(),
1318            ),
1319        );
1320        let found = examine(&debian, Some(PackageManager::Debian));
1321        assert_eq!(
1322            find(&found, "dpkg-audit")
1323                .repair
1324                .as_ref()
1325                .unwrap()
1326                .plan("x")
1327                .steps[0]
1328                .display(),
1329            "sudo dpkg --configure -a"
1330        );
1331    }
1332
1333    #[test]
1334    fn root_owned_settings_are_given_back() {
1335        let mut fake = healthy();
1336        fake.folders.insert(
1337            "/home/me/.local/share".into(),
1338            vec!["Shelly".into(), "fonts".into()],
1339        );
1340        fake.owners.insert("/home/me/.local/share/Shelly".into(), 0);
1341        let found = examine(&fake, Some(PackageManager::Arch));
1342        let permissions = find(&found, "permissions");
1343        assert_eq!(permissions.health, Health::Problem);
1344        assert_eq!(
1345            permissions.evidence,
1346            ["owned by root: /home/me/.local/share/Shelly"]
1347        );
1348        let commands = &permissions.repair.as_ref().unwrap().commands;
1349        assert_eq!(commands[0][..2], ["sh", "-c"], "the guard comes first");
1350        assert_eq!(commands[0][4..], ["/home/me/.local/share/Shelly"]);
1351        assert_eq!(
1352            commands[1].join(" "),
1353            "sudo chown -R -h -P 1000:1000 -- /home/me/.local/share/Shelly"
1354        );
1355        // A link owned by root is never handed to chown, only mentioned.
1356        fake.folders
1357            .insert("/home/me/.config".into(), vec!["evil".into()]);
1358        fake.owners.insert("/home/me/.config/evil".into(), 0);
1359        fake.links
1360            .insert("/home/me/.config/evil".into(), "/etc/shadow".into());
1361        let found = examine(&fake, Some(PackageManager::Arch));
1362        let permissions = find(&found, "permissions");
1363        let commands = &permissions.repair.as_ref().unwrap().commands;
1364        assert!(
1365            !commands.concat().iter().any(|word| word.contains("evil")),
1366            "{commands:?}"
1367        );
1368        assert!(
1369            permissions
1370                .evidence
1371                .iter()
1372                .any(|line| line
1373                    == "owned by root through a link, left alone: /home/me/.config/evil")
1374        );
1375        fake.folders.remove(Path::new("/home/me/.config"));
1376        // A path with a quote in it stays one quoted word in the program.
1377        fake.folders
1378            .insert("/home/me/.config".into(), vec!["it's".into()]);
1379        fake.owners.insert("/home/me/.config/it's".into(), 0);
1380        let found = examine(&fake, Some(PackageManager::Arch));
1381        let Step::Shell { program, .. } = &find(&found, "permissions")
1382            .repair
1383            .as_ref()
1384            .unwrap()
1385            .plan("x")
1386            .steps[0]
1387        else {
1388            panic!("expected a shell step");
1389        };
1390        assert!(program.contains("'/home/me/.config/it'\\''s'"), "{program}");
1391    }
1392
1393    #[test]
1394    fn folders_and_items_reached_through_links_are_left_alone() {
1395        // A folder that is a link is not looked in.
1396        let mut linked = healthy();
1397        linked
1398            .folders
1399            .insert("/home/me/.local/share".into(), vec!["dbus-1".into()]);
1400        linked
1401            .owners
1402            .insert("/home/me/.local/share/dbus-1".into(), 0);
1403        linked
1404            .links
1405            .insert("/home/me/.local/share".into(), "/usr/share".into());
1406        let check =
1407            |fake: &Fake| find(&examine(fake, Some(PackageManager::Arch)), "permissions").clone();
1408        assert_eq!(check(&linked).health, Health::Good);
1409        // Nor one reached through a link further up.
1410        linked.links.clear();
1411        linked
1412            .canonical
1413            .insert("/home/me/.local/share".into(), "/usr/share".into());
1414        assert_eq!(check(&linked).health, Health::Good);
1415        // An item that resolves elsewhere is only mentioned.
1416        linked.canonical.clear();
1417        linked.canonical.insert(
1418            "/home/me/.local/share/dbus-1".into(),
1419            "/usr/share/dbus-1".into(),
1420        );
1421        let permissions = check(&linked);
1422        assert_eq!(permissions.health, Health::Good);
1423        assert_eq!(
1424            permissions.evidence,
1425            ["owned by root through a link, left alone: /home/me/.local/share/dbus-1"]
1426        );
1427    }
1428
1429    #[test]
1430    fn the_guard_stops_the_repair_when_a_path_is_reached_through_a_link() {
1431        let root = tempfile::tempdir().unwrap();
1432        let base = root.path().canonicalize().unwrap();
1433        let real = base.join("real");
1434        std::fs::create_dir(&real).unwrap();
1435        let link = base.join("link");
1436        std::os::unix::fs::symlink(&real, &link).unwrap();
1437        // The repair's own guard, as permissions() builds it.
1438        let mut fake = healthy();
1439        fake.folders
1440            .insert("/home/me/.config".into(), vec!["x".into()]);
1441        fake.owners.insert("/home/me/.config/x".into(), 0);
1442        let found = examine(&fake, Some(PackageManager::Arch));
1443        let guard = find(&found, "permissions")
1444            .repair
1445            .as_ref()
1446            .unwrap()
1447            .commands[0][..4]
1448            .to_vec();
1449        let passes = |path: &Path| {
1450            let mut words = guard.clone();
1451            words.push(path.to_string_lossy().into_owned());
1452            let repair = Repair {
1453                label: "test".into(),
1454                commands: vec![words],
1455            };
1456            let Step::Shell { program, .. } = &repair.plan("x").steps[0] else {
1457                panic!("expected a shell step");
1458            };
1459            Command::new("sh")
1460                .arg("-c")
1461                .arg(program)
1462                .status()
1463                .unwrap()
1464                .success()
1465        };
1466        assert!(passes(&real));
1467        assert!(!passes(&link), "a link fails the guard");
1468        assert!(
1469            !passes(&link.join("..").join("real")),
1470            "so does a path through one"
1471        );
1472        assert!(!passes(&base.join("gone")), "and a path that is gone");
1473    }
1474    #[test]
1475    fn boot_problems_offer_the_matching_rebuild() {
1476        let mut fake = healthy();
1477        fake.files
1478            .remove(Path::new("/boot/initramfs-linux-garuda.img"));
1479        fake.files.remove(Path::new("/boot/grub/grub.cfg"));
1480        fake.spaces.insert("/boot/efi".into(), (8 << 20, 300 << 20));
1481        let found = examine(&fake, Some(PackageManager::Arch));
1482        let images = find(&found, "initramfs");
1483        assert_eq!(images.health, Health::Problem);
1484        assert_eq!(
1485            images.repair.as_ref().unwrap().plan("x").steps[0].display(),
1486            "sudo dracut-rebuild"
1487        );
1488        assert_eq!(
1489            find(&found, "grub-menu")
1490                .repair
1491                .as_ref()
1492                .unwrap()
1493                .plan("x")
1494                .steps[0]
1495                .display(),
1496            "sudo grub-mkconfig -o /boot/grub/grub.cfg"
1497        );
1498        assert_eq!(find(&found, "esp-space").health, Health::Notice);
1499        // Debian names its images after the kernel version.
1500        let mut debian = healthy();
1501        debian
1502            .folders
1503            .insert("/usr/lib/modules".into(), vec!["6.12.0-1-amd64".into()]);
1504        debian
1505            .files
1506            .insert("/boot/vmlinuz-6.12.0-1-amd64".into(), String::new());
1507        debian.tools = vec!["update-initramfs".into()];
1508        let found = examine(&debian, Some(PackageManager::Debian));
1509        assert_eq!(
1510            find(&found, "initramfs")
1511                .repair
1512                .as_ref()
1513                .unwrap()
1514                .plan("x")
1515                .steps[0]
1516                .display(),
1517            "sudo update-initramfs -u -k all"
1518        );
1519    }
1520
1521    #[test]
1522    fn a_full_disk_offers_cache_cleaning() {
1523        let mut fake = healthy();
1524        fake.spaces.insert("/".into(), (1 << 30, 100 << 30));
1525        fake.tools.push("paccache".into());
1526        let found = examine(&fake, Some(PackageManager::Arch));
1527        let disk = find(&found, "disk-space");
1528        assert_eq!(disk.health, Health::Problem);
1529        assert_eq!(
1530            disk.repair.as_ref().unwrap().plan("x").steps[0].display(),
1531            "sudo paccache -rk2 && sudo journalctl --vacuum-size=200M"
1532        );
1533        fake.spaces.insert("/".into(), (8 << 30, 100 << 30));
1534        assert_eq!(
1535            find(&examine(&fake, Some(PackageManager::Arch)), "disk-space").health,
1536            Health::Notice
1537        );
1538    }
1539}