Skip to main content

helixforge_engine/
hardware.rs

1//! The hardware and firmware center: read-only facts about the machine's
2//! firmware (through fwupd), disks, temperature sensors, batteries and PCI
3//! and USB devices, from `/sys` and tools that answer without root. A SMART
4//! check, which needs root, and a firmware update are plans of their own,
5//! each run only after it is confirmed.
6
7use crate::{Plan, Step, doctor::System, quote};
8use helixforge_core::{Risk, printable};
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11
12/// Everything the center shows.
13#[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/// What fwupd knows.
24#[derive(Clone, Debug, Default, Serialize)]
25pub struct Firmware {
26    /// Whether fwupd's command is installed.
27    pub available: bool,
28    pub devices: Vec<FirmwareDevice>,
29    /// Updates from the metadata fwupd already has, apart from the devices.
30    pub updates: Vec<FirmwareUpdate>,
31    /// Why something could not be read.
32    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    /// The block device's name: nvme0n1, sda.
57    pub name: String,
58    pub model: Option<String>,
59    pub bytes: Option<u64>,
60    /// NVMe, SSD or HDD.
61    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    /// At or past its high mark, or within five degrees of critical.
77    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    /// Charge now, percent.
90    pub capacity: Option<u32>,
91    /// What it holds when full against what it held new, percent.
92    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    /// The PCI slot or USB bus and device.
101    pub address: String,
102    pub class: Option<String>,
103    pub name: String,
104    /// The PCI driver in use.
105    pub driver: Option<String>,
106}
107
108/// Read the machine; fwupd answers from the metadata it already has.
109pub 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
193/// fwupd's device ids are 40 hexadecimal digits.
194pub fn valid_device_id(id: &str) -> bool {
195    id.len() == 40 && id.bytes().all(|byte| byte.is_ascii_hexdigit())
196}
197
198/// A block device name a SMART check may name.
199pub 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        // Partitions are not listed here; loop, RAM and optical devices are skipped.
212        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        // USB bridges often call any disk rotational; say only that it is on USB.
235        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            // Sensors with nothing attached read zero or less.
292            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            // Root hubs are the controllers themselves.
387            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
404/// A plan that updates one device's firmware through fwupd. High risk: a
405/// failed firmware update can leave the device unusable.
406pub 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
434/// A plan that reads a disk's SMART health and attributes. It only reads,
435/// but needs root to reach the disk.
436pub 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
470/// Bytes as GB or TB, as disks are sold.
471pub 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        // Only well-formed device ids, and only plain text, come through.
611        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}