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