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