1use 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
17pub enum Health {
18 Good,
19 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#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Diagnosis {
37 pub id: String,
39 pub title: String,
40 pub health: Health,
41 pub summary: String,
42 pub evidence: Vec<String>,
44 pub advice: Option<String>,
46 pub repair: Option<Repair>,
48 pub entry: Option<String>,
50}
51
52#[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 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 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
118pub 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 fn run(&self, command: &[&str]) -> Option<(bool, String)>;
126 fn resolve(&self, host: &str) -> Result<Vec<String>, String>;
128 fn space(&self, path: &Path) -> Option<(u64, u64)>;
130 fn owner(&self, path: &Path) -> Option<u32>;
132 fn is_link(&self, path: &Path) -> bool;
134 fn canonical(&self, path: &Path) -> Option<PathBuf>;
136 fn user(&self) -> (u32, u32, PathBuf);
137 fn on_path(&self, tool: &str) -> bool;
138 fn processes(&self) -> Vec<String>;
140}
141
142pub 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
249const PROBE_HOST: &str = "github.com";
251
252pub 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 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 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 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 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 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 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 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 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 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 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#[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 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 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 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 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 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 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 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 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}