1use crate::{Plan, Step, doctor::System, quote};
8use helixforge_core::{Risk, printable};
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11
12#[derive(Clone, Debug, Default, Serialize)]
14pub struct Hardware {
15 pub firmware: Firmware,
16 pub disks: Vec<Disk>,
17 pub sensors: Vec<Sensor>,
18 pub batteries: Vec<Battery>,
19 pub pci: Vec<Device>,
20 pub usb: Vec<Device>,
21}
22
23#[derive(Clone, Debug, Default, Serialize)]
25pub struct Firmware {
26 pub available: bool,
28 pub devices: Vec<FirmwareDevice>,
29 pub updates: Vec<FirmwareUpdate>,
31 pub note: Option<String>,
33}
34
35#[derive(Clone, Debug, Default, Serialize)]
36pub struct FirmwareDevice {
37 pub id: String,
38 pub name: String,
39 pub vendor: Option<String>,
40 pub version: Option<String>,
41 pub updatable: bool,
42}
43
44#[derive(Clone, Debug, Default, Serialize)]
45pub struct FirmwareUpdate {
46 pub device_id: String,
47 pub device: String,
48 pub from: Option<String>,
49 pub to: String,
50 pub summary: Option<String>,
51 pub urgency: Option<String>,
52}
53
54#[derive(Clone, Debug, Default, Serialize)]
55pub struct Disk {
56 pub name: String,
58 pub model: Option<String>,
59 pub bytes: Option<u64>,
60 pub kind: String,
62 pub firmware: Option<String>,
63 pub celsius: Option<f64>,
64}
65
66#[derive(Clone, Debug, Default, Serialize)]
67pub struct Sensor {
68 pub chip: String,
69 pub label: String,
70 pub celsius: f64,
71 pub high: Option<f64>,
72 pub critical: Option<f64>,
73}
74
75impl Sensor {
76 pub fn hot(&self) -> bool {
78 self.high.is_some_and(|high| self.celsius >= high)
79 || self
80 .critical
81 .is_some_and(|critical| self.celsius >= critical - 5.0)
82 }
83}
84
85#[derive(Clone, Debug, Default, Serialize)]
86pub struct Battery {
87 pub name: String,
88 pub status: Option<String>,
89 pub capacity: Option<u32>,
91 pub health: Option<u32>,
93 pub cycles: Option<u32>,
94 pub model: Option<String>,
95 pub technology: Option<String>,
96}
97
98#[derive(Clone, Debug, Default, Serialize)]
99pub struct Device {
100 pub address: String,
102 pub class: Option<String>,
103 pub name: String,
104 pub driver: Option<String>,
106}
107
108pub fn gather(system: &dyn System) -> Hardware {
110 Hardware {
111 firmware: firmware(system),
112 disks: disks(system),
113 sensors: sensors(system),
114 batteries: batteries(system),
115 pci: pci(system),
116 usb: usb(system),
117 }
118}
119
120fn read(system: &dyn System, path: &Path) -> Option<String> {
121 system
122 .read(path)
123 .map(|text| printable(text.trim()))
124 .filter(|text| !text.is_empty())
125}
126
127fn number(system: &dyn System, path: &Path) -> Option<f64> {
128 read(system, path)?.parse().ok()
129}
130
131fn firmware(system: &dyn System) -> Firmware {
132 let mut firmware = Firmware {
133 available: system.on_path("fwupdmgr"),
134 ..Firmware::default()
135 };
136 if !firmware.available {
137 firmware.note = Some("fwupd is not installed, so firmware is not listed".to_string());
138 return firmware;
139 }
140 match system.run(&["fwupdmgr", "get-devices", "--json"]) {
141 Some((true, text)) => {
142 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
143 for device in parsed["Devices"].as_array().into_iter().flatten() {
144 let (Some(id), Some(name)) = (device["DeviceId"].as_str(), device["Name"].as_str())
145 else {
146 continue;
147 };
148 let flags: Vec<&str> = device["Flags"]
149 .as_array()
150 .into_iter()
151 .flatten()
152 .filter_map(|flag| flag.as_str())
153 .collect();
154 firmware.devices.push(FirmwareDevice {
155 id: printable(id),
156 name: printable(name),
157 vendor: device["Vendor"].as_str().map(printable),
158 version: device["Version"].as_str().map(printable),
159 updatable: flags.contains(&"updatable"),
160 });
161 }
162 }
163 _ => firmware.note = Some("fwupd's service did not answer".to_string()),
164 }
165 if let Some((_, text)) = system.run(&["fwupdmgr", "get-updates", "--json"]) {
166 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
167 for device in parsed["Devices"].as_array().into_iter().flatten() {
168 let Some(id) = device["DeviceId"].as_str().filter(|id| valid_device_id(id)) else {
169 continue;
170 };
171 let Some(release) = device["Releases"]
172 .as_array()
173 .and_then(|releases| releases.first())
174 else {
175 continue;
176 };
177 let Some(to) = release["Version"].as_str() else {
178 continue;
179 };
180 firmware.updates.push(FirmwareUpdate {
181 device_id: id.to_string(),
182 device: printable(device["Name"].as_str().unwrap_or(id)),
183 from: device["Version"].as_str().map(printable),
184 to: printable(to),
185 summary: release["Summary"].as_str().map(printable),
186 urgency: release["Urgency"].as_str().map(printable),
187 });
188 }
189 }
190 firmware
191}
192
193pub fn valid_device_id(id: &str) -> bool {
195 id.len() == 40 && id.bytes().all(|byte| byte.is_ascii_hexdigit())
196}
197
198pub fn valid_disk(name: &str) -> bool {
200 !name.is_empty()
201 && name.len() <= 32
202 && name
203 .bytes()
204 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
205}
206
207fn disks(system: &dyn System) -> Vec<Disk> {
208 let block = Path::new("/sys/block");
209 let mut found = Vec::new();
210 for name in system.list(block) {
211 if !valid_disk(&name)
213 || ["loop", "ram", "zram", "sr", "dm", "md", "fd"]
214 .iter()
215 .any(|prefix| name.starts_with(prefix))
216 {
217 continue;
218 }
219 let device = block.join(&name);
220 let nvme = name.starts_with("nvme");
221 let controller = name
222 .strip_prefix("nvme")
223 .and_then(|rest| rest.split('n').next())
224 .map(|number| PathBuf::from(format!("/sys/class/nvme/nvme{number}")));
225 let model = controller
226 .as_ref()
227 .and_then(|controller| read(system, &controller.join("model")))
228 .or_else(|| read(system, &device.join("device/model")));
229 let firmware = controller
230 .as_ref()
231 .and_then(|controller| read(system, &controller.join("firmware_rev")))
232 .or_else(|| read(system, &device.join("device/rev")));
233 let rotational = read(system, &device.join("queue/rotational")).as_deref() == Some("1");
234 let on_usb = system
236 .link(&device)
237 .is_some_and(|target| target.to_string_lossy().contains("/usb"));
238 let bytes = read(system, &device.join("size"))
239 .and_then(|sectors| sectors.parse::<u64>().ok())
240 .map(|sectors| sectors * 512);
241 if bytes == Some(0) {
242 continue;
243 }
244 let celsius = controller.as_ref().and_then(|controller| {
245 system
246 .list(controller)
247 .into_iter()
248 .find(|entry| entry.starts_with("hwmon"))
249 .and_then(|hwmon| number(system, &controller.join(hwmon).join("temp1_input")))
250 .map(|milli| milli / 1000.0)
251 });
252 found.push(Disk {
253 name,
254 model,
255 bytes,
256 kind: if nvme {
257 "NVMe".to_string()
258 } else if on_usb {
259 "USB".to_string()
260 } else if rotational {
261 "HDD".to_string()
262 } else {
263 "SSD".to_string()
264 },
265 firmware,
266 celsius,
267 });
268 }
269 found
270}
271
272fn sensors(system: &dyn System) -> Vec<Sensor> {
273 let root = Path::new("/sys/class/hwmon");
274 let mut found = Vec::new();
275 for hwmon in system.list(root) {
276 let directory = root.join(&hwmon);
277 let chip = read(system, &directory.join("name")).unwrap_or_else(|| hwmon.clone());
278 let mut inputs: Vec<String> = system
279 .list(&directory)
280 .into_iter()
281 .filter(|name| name.starts_with("temp") && name.ends_with("_input"))
282 .collect();
283 inputs.sort_by_key(|name| {
284 name.trim_start_matches("temp")
285 .trim_end_matches("_input")
286 .parse::<u32>()
287 .unwrap_or(0)
288 });
289 for input in inputs {
290 let stem = input.trim_end_matches("_input");
291 let Some(milli) = number(system, &directory.join(&input)).filter(|milli| *milli > 0.0)
293 else {
294 continue;
295 };
296 let degrees = |suffix: &str| {
297 number(system, &directory.join(format!("{stem}_{suffix}")))
298 .map(|milli| milli / 1000.0)
299 .filter(|value| *value > 0.0 && *value < 200.0)
300 };
301 found.push(Sensor {
302 chip: chip.clone(),
303 label: read(system, &directory.join(format!("{stem}_label")))
304 .unwrap_or_else(|| stem.to_string()),
305 celsius: milli / 1000.0,
306 high: degrees("max"),
307 critical: degrees("crit"),
308 });
309 }
310 }
311 found
312}
313
314fn batteries(system: &dyn System) -> Vec<Battery> {
315 let root = Path::new("/sys/class/power_supply");
316 let mut found = Vec::new();
317 for name in system.list(root) {
318 let directory = root.join(&name);
319 if read(system, &directory.join("type")).as_deref() != Some("Battery") {
320 continue;
321 }
322 let value = |file: &str| {
323 read(system, &directory.join(file)).and_then(|text| text.parse::<u64>().ok())
324 };
325 let health = match (value("energy_full"), value("energy_full_design")) {
326 (Some(full), Some(design)) if design > 0 => Some(full * 100 / design),
327 _ => match (value("charge_full"), value("charge_full_design")) {
328 (Some(full), Some(design)) if design > 0 => Some(full * 100 / design),
329 _ => None,
330 },
331 };
332 found.push(Battery {
333 name,
334 status: read(system, &directory.join("status")),
335 capacity: value("capacity").map(|value| value as u32),
336 health: health.map(|value| value as u32),
337 cycles: value("cycle_count")
338 .map(|value| value as u32)
339 .filter(|cycles| *cycles > 0),
340 model: read(system, &directory.join("model_name")),
341 technology: read(system, &directory.join("technology")),
342 });
343 }
344 found
345}
346
347fn pci(system: &dyn System) -> Vec<Device> {
348 let Some((true, text)) = system.run(&["lspci", "-nnk"]) else {
349 return Vec::new();
350 };
351 let mut found: Vec<Device> = Vec::new();
352 for line in text.lines() {
353 if let Some(rest) = line.strip_prefix('\t') {
354 if let Some(driver) = rest.strip_prefix("Kernel driver in use: ")
355 && let Some(last) = found.last_mut()
356 {
357 last.driver = Some(printable(driver.trim()));
358 }
359 continue;
360 }
361 let Some((address, rest)) = line.split_once(' ') else {
362 continue;
363 };
364 let (class, name) = match rest.split_once(": ") {
365 Some((class, name)) => (Some(printable(class)), printable(name)),
366 None => (None, printable(rest)),
367 };
368 found.push(Device {
369 address: printable(address),
370 class,
371 name,
372 driver: None,
373 });
374 }
375 found
376}
377
378fn usb(system: &dyn System) -> Vec<Device> {
379 let Some((true, text)) = system.run(&["lsusb"]) else {
380 return Vec::new();
381 };
382 text.lines()
383 .filter_map(|line| {
384 let (bus, rest) = line.split_once(": ID ")?;
385 let (id, name) = rest.split_once(' ').unwrap_or((rest, ""));
386 if name.contains("root hub") {
388 return None;
389 }
390 Some(Device {
391 address: printable(
392 bus.trim_start_matches("Bus ")
393 .replace(" Device ", ":")
394 .as_str(),
395 ),
396 class: Some(printable(id)),
397 name: printable(name.trim()),
398 driver: None,
399 })
400 })
401 .collect()
402}
403
404pub fn plan_firmware(update: &FirmwareUpdate) -> Option<Plan> {
407 if !valid_device_id(&update.device_id) {
408 return None;
409 }
410 let words = ["sudo", "fwupdmgr", "update", update.device_id.as_str()];
411 Some(Plan {
412 steps: vec![Step::Shell {
413 id: "firmware-update".to_string(),
414 name: format!(
415 "Update the firmware of {} to {}",
416 update.device, update.to
417 ),
418 program: words.iter().map(|word| quote(word)).collect::<Vec<_>>().join(" "),
419 display: words.join(" "),
420 requires: vec!["sudo".to_string(), "fwupdmgr".to_string()],
421 changes: Vec::new(),
422 }],
423 notes: vec![
424 "Keep the machine on mains power and do not switch it off until fwupd says it has finished; some updates finish at the next reboot.".to_string(),
425 ],
426 requirements: vec!["fwupdmgr".to_string(), "sudo".to_string()],
427 privileged: true,
428 risk: Risk::High,
429 depends: vec![Vec::new()],
430 ..Plan::default()
431 })
432}
433
434pub fn plan_smart(disk: &str) -> Option<Plan> {
437 if !valid_disk(disk) {
438 return None;
439 }
440 let device = format!("/dev/{disk}");
441 let words = [
442 "sudo",
443 "smartctl",
444 "--health",
445 "--attributes",
446 device.as_str(),
447 ];
448 Some(Plan {
449 steps: vec![Step::Shell {
450 id: "smart-check".to_string(),
451 name: format!("SMART health of {disk}"),
452 program: words
453 .iter()
454 .map(|word| quote(word))
455 .collect::<Vec<_>>()
456 .join(" "),
457 display: words.join(" "),
458 requires: vec!["sudo".to_string(), "smartctl".to_string()],
459 changes: Vec::new(),
460 }],
461 notes: vec!["It only reads the disk's own health record.".to_string()],
462 requirements: vec!["smartctl".to_string(), "sudo".to_string()],
463 privileged: true,
464 risk: Risk::Low,
465 depends: vec![Vec::new()],
466 ..Plan::default()
467 })
468}
469
470pub fn size(bytes: u64) -> String {
472 let gb = bytes as f64 / 1e9;
473 if gb >= 1000.0 {
474 format!("{:.1} TB", gb / 1000.0)
475 } else {
476 format!("{gb:.0} GB")
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use std::collections::HashMap;
484
485 #[derive(Default)]
486 struct Fake {
487 files: HashMap<PathBuf, String>,
488 commands: HashMap<String, String>,
489 tools: Vec<&'static str>,
490 }
491
492 impl Fake {
493 fn file(&mut self, path: &str, text: &str) {
494 self.files.insert(PathBuf::from(path), text.to_string());
495 }
496 }
497
498 impl System for Fake {
499 fn read(&self, path: &Path) -> Option<String> {
500 self.files.get(path).cloned()
501 }
502 fn exists(&self, path: &Path) -> bool {
503 self.files.keys().any(|known| known.starts_with(path))
504 }
505 fn list(&self, path: &Path) -> Vec<String> {
506 let mut names: Vec<String> = self
507 .files
508 .keys()
509 .filter_map(|known| known.strip_prefix(path).ok())
510 .filter_map(|rest| rest.components().next())
511 .map(|part| part.as_os_str().to_string_lossy().into_owned())
512 .collect();
513 names.sort();
514 names.dedup();
515 names
516 }
517 fn link(&self, _: &Path) -> Option<PathBuf> {
518 None
519 }
520 fn run(&self, command: &[&str]) -> Option<(bool, String)> {
521 self.commands
522 .get(&command.join(" "))
523 .map(|text| (true, text.clone()))
524 }
525 fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
526 Err("offline".into())
527 }
528 fn space(&self, _: &Path) -> Option<(u64, u64)> {
529 None
530 }
531 fn owner(&self, _: &Path) -> Option<u32> {
532 None
533 }
534 fn is_link(&self, _: &Path) -> bool {
535 false
536 }
537 fn canonical(&self, _: &Path) -> Option<PathBuf> {
538 None
539 }
540 fn user(&self) -> (u32, u32, PathBuf) {
541 (1000, 1000, PathBuf::from("/home/me"))
542 }
543 fn on_path(&self, tool: &str) -> bool {
544 self.tools.contains(&tool)
545 }
546 fn processes(&self) -> Vec<String> {
547 Vec::new()
548 }
549 }
550
551 const DEVICE: &str = "9ea4da9853eae668f5bd65b10d14e4363f619406";
552
553 fn machine() -> Fake {
554 let mut fake = Fake {
555 tools: vec!["fwupdmgr"],
556 ..Fake::default()
557 };
558 fake.file("/sys/block/nvme0n1/size", "1953525168\n");
559 fake.file("/sys/block/nvme0n1/queue/rotational", "0\n");
560 fake.file(
561 "/sys/class/nvme/nvme0/model",
562 "Samsung SSD 990 PRO 1TB \n",
563 );
564 fake.file("/sys/class/nvme/nvme0/firmware_rev", "8B2QJXD7\n");
565 fake.file("/sys/class/nvme/nvme0/hwmon3/temp1_input", "38850\n");
566 fake.file("/sys/block/loop0/size", "100\n");
567 fake.file("/sys/block/sda/size", "3907029168\n");
568 fake.file("/sys/block/sda/queue/rotational", "1\n");
569 fake.file("/sys/block/sda/device/model", "WDC WD20EZRZ\n");
570 fake.file("/sys/class/hwmon/hwmon7/name", "coretemp\n");
571 fake.file("/sys/class/hwmon/hwmon7/temp1_label", "Package id 0\n");
572 fake.file("/sys/class/hwmon/hwmon7/temp1_input", "97000\n");
573 fake.file("/sys/class/hwmon/hwmon7/temp1_max", "100000\n");
574 fake.file("/sys/class/hwmon/hwmon7/temp1_crit", "100000\n");
575 fake.file("/sys/class/hwmon/hwmon7/temp2_input", "45000\n");
576 fake.file("/sys/class/power_supply/AC/type", "Mains\n");
577 fake.file("/sys/class/power_supply/BAT0/type", "Battery\n");
578 fake.file("/sys/class/power_supply/BAT0/status", "Charging\n");
579 fake.file("/sys/class/power_supply/BAT0/capacity", "100\n");
580 fake.file("/sys/class/power_supply/BAT0/energy_full", "53950000\n");
581 fake.file(
582 "/sys/class/power_supply/BAT0/energy_full_design",
583 "68000000\n",
584 );
585 fake.file("/sys/class/power_supply/BAT0/cycle_count", "662\n");
586 fake.commands.insert(
587 "fwupdmgr get-devices --json".into(),
588 format!(r#"{{"Devices": [{{"DeviceId": "{DEVICE}", "Name": "Bolt Receiver", "Version": "MPR05.04", "Vendor": "Logitech", "Flags": ["updatable"]}}, {{"Name": "no id"}}]}}"#),
589 );
590 fake.commands.insert(
591 "fwupdmgr get-updates --json".into(),
592 format!(r#"{{"Devices": [{{"DeviceId": "{DEVICE}", "Name": "Bolt Receiver", "Version": "MPR05.04", "Releases": [{{"Version": "MPR05.06", "Summary": "Fixes pairing\u001b[2J", "Urgency": "medium"}}]}}, {{"DeviceId": "../../etc", "Releases": [{{"Version": "1"}}]}}]}}"#),
593 );
594 fake.commands.insert(
595 "lspci -nnk".into(),
596 "00:02.0 VGA compatible controller [0300]: Intel Corporation UHD Graphics [8086:9a60]\n\tSubsystem: Lenovo\n\tKernel driver in use: i915\n01:00.0 VGA compatible controller [0300]: NVIDIA Corporation TU117M [10de:1f99]\n".into(),
597 );
598 fake.commands.insert(
599 "lsusb".into(),
600 "Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub\nBus 002 Device 003: ID 046d:c548 Logitech, Inc. Logi Bolt Receiver\n".into(),
601 );
602 fake
603 }
604
605 #[test]
606 fn the_machine_is_read_without_root() {
607 let found = gather(&machine());
608 assert_eq!(found.firmware.devices.len(), 1);
609 assert!(found.firmware.devices[0].updatable);
610 assert_eq!(found.firmware.updates.len(), 1);
612 let update = &found.firmware.updates[0];
613 assert_eq!(update.to, "MPR05.06");
614 assert!(!update.summary.as_ref().unwrap().contains('\u{1b}'));
615 let names: Vec<&str> = found.disks.iter().map(|disk| disk.name.as_str()).collect();
616 assert_eq!(names, ["nvme0n1", "sda"]);
617 assert_eq!(found.disks[0].kind, "NVMe");
618 assert_eq!(
619 found.disks[0].model.as_deref(),
620 Some("Samsung SSD 990 PRO 1TB")
621 );
622 assert_eq!(found.disks[0].celsius, Some(38.85));
623 assert_eq!(size(found.disks[0].bytes.unwrap()), "1.0 TB");
624 assert_eq!(found.disks[1].kind, "HDD");
625 assert_eq!(found.sensors.len(), 2);
626 assert!(found.sensors[0].hot());
627 assert!(!found.sensors[1].hot());
628 assert_eq!(found.batteries.len(), 1);
629 assert_eq!(found.batteries[0].health, Some(79));
630 assert_eq!(found.batteries[0].cycles, Some(662));
631 assert_eq!(found.pci.len(), 2);
632 assert_eq!(found.pci[0].driver.as_deref(), Some("i915"));
633 assert_eq!(found.pci[1].driver, None);
634 assert_eq!(found.usb.len(), 1);
635 assert_eq!(found.usb[0].name, "Logitech, Inc. Logi Bolt Receiver");
636 }
637
638 #[test]
639 fn without_fwupd_firmware_says_why() {
640 let mut fake = machine();
641 fake.tools.clear();
642 let found = gather(&fake);
643 assert!(!found.firmware.available);
644 assert!(found.firmware.note.as_ref().unwrap().contains("fwupd"));
645 }
646
647 #[test]
648 fn firmware_and_smart_plans_name_only_checked_values() {
649 let found = gather(&machine());
650 let planned = plan_firmware(&found.firmware.updates[0]).unwrap();
651 assert_eq!(planned.risk, Risk::High);
652 assert!(planned.privileged);
653 assert!(planned.steps[0].display().ends_with(DEVICE));
654 let bad = FirmwareUpdate {
655 device_id: "x; reboot".into(),
656 ..found.firmware.updates[0].clone()
657 };
658 assert!(plan_firmware(&bad).is_none());
659 let smart = plan_smart("nvme0n1").unwrap();
660 assert_eq!(
661 smart.steps[0].display(),
662 "sudo smartctl --health --attributes /dev/nvme0n1"
663 );
664 assert!(plan_smart("../sda").is_none());
665 assert!(plan_smart("sda;x").is_none());
666 }
667}