Skip to main content

resource_tracker/collector/
disk.rs

1use crate::metrics::{DiskMetrics, DiskMountMetrics, DiskType};
2use std::collections::HashMap;
3use std::ffi::CString;
4use std::time::Instant;
5
6type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
7
8const SECTOR_BYTES: u64 = 512;
9
10// ---------------------------------------------------------------------------
11// sysfs helpers
12// ---------------------------------------------------------------------------
13
14fn sysfs_read(path: &str) -> Option<String> {
15    std::fs::read_to_string(path)
16        .ok()
17        .map(|s| s.trim().to_string())
18        .filter(|s| !s.is_empty())
19}
20
21fn block_attr(device: &str, attr: &str) -> Option<String> {
22    sysfs_read(&format!("/sys/block/{}/{}", device, attr))
23}
24
25// ---------------------------------------------------------------------------
26// Hardware identity - read once at startup
27// ---------------------------------------------------------------------------
28
29#[derive(Clone)]
30struct DeviceInfo {
31    model: Option<String>,
32    vendor: Option<String>,
33    serial: Option<String>,
34    device_type: Option<DiskType>,
35    capacity_bytes: Option<u64>,
36    /// Physical sector size in bytes used for I/O accounting.
37    /// Read from `/sys/block/<dev>/queue/hw_sector_size`; falls back to 512.
38    sector_size: u32,
39}
40
41fn read_device_info(device: &str) -> DeviceInfo {
42    let model = block_attr(device, "device/model");
43    let vendor = block_attr(device, "device/vendor");
44    let serial = block_attr(device, "device/serial").or_else(|| block_attr(device, "device/wwid"));
45
46    let device_type = if device.starts_with("nvme") {
47        Some(DiskType::Nvme)
48    } else {
49        match block_attr(device, "queue/rotational").as_deref() {
50            Some("0") => Some(DiskType::Ssd),
51            Some("1") => Some(DiskType::Hdd),
52            _ => None,
53        }
54    };
55
56    // /sys/block/<dev>/size reports 512-byte logical sectors regardless of
57    // physical sector size, so capacity always uses SECTOR_BYTES (512).
58    let capacity_bytes = block_attr(device, "size")
59        .and_then(|s| s.parse::<u64>().ok())
60        .map(|sectors| sectors * SECTOR_BYTES);
61
62    // Physical sector size for I/O byte accounting.  On 4K-native NVMe drives
63    // this is 4096; on most SATA/HDD it is 512.  The kernel value is
64    // authoritative; fall back to 512 if absent or unparseable.
65    let sector_size = block_attr(device, "queue/hw_sector_size")
66        .and_then(|s| s.parse::<u32>().ok())
67        .filter(|&v| v >= 512)
68        .unwrap_or(u32::try_from(SECTOR_BYTES).unwrap_or(512));
69
70    DeviceInfo {
71        model,
72        vendor,
73        serial,
74        device_type,
75        capacity_bytes,
76        sector_size,
77    }
78}
79
80/// Discover all whole-disk block devices from /sys/block/ and cache their
81/// static identity. Called once in DiskCollector::new().
82fn discover_devices() -> HashMap<String, DeviceInfo> {
83    let Ok(entries) = std::fs::read_dir("/sys/block") else {
84        return HashMap::new();
85    };
86    entries
87        .flatten()
88        .filter_map(|e| {
89            let name = e.file_name().to_string_lossy().to_string();
90            if name.starts_with("loop") || name.starts_with("ram") {
91                return None;
92            }
93            let info = read_device_info(&name);
94            Some((name, info))
95        })
96        .collect()
97}
98
99// ---------------------------------------------------------------------------
100// Filesystem space - statvfs, polled each interval
101// ---------------------------------------------------------------------------
102
103/// Build the list of source-path prefixes to look for in `/proc/mounts` for
104/// a given block device.
105///
106/// Most devices map 1-to-1: `/dev/sda` → `["/dev/sda"]` (matches `sda1`, `sda2` …).
107///
108/// Two special cases add extra prefixes:
109///
110/// - **Device-mapper** (`dm-*`): `/proc/mounts` uses `/dev/mapper/<name>`, not
111///   `/dev/dm-N`. The canonical name is read from `/sys/block/<dev>/dm/name`.
112///
113/// - **`/dev/root`**: some distros (Ubuntu/AWS, cloud-init images) expose the
114///   root partition under this alias in `/proc/mounts` instead of the real
115///   device path. Resolution is attempted first via `read_link` (covers
116///   distros that make it a symlink) and then via a `major:minor` comparison
117///   between `/proc/self/mountinfo` and `/sys/block/<dev>/<part>/dev` (covers
118///   distros where it is a plain device node). See `dev_root_is_on_device`.
119fn device_source_prefixes(device_name: &str) -> Vec<String> {
120    let primary = format!("/dev/{}", device_name);
121    let mut prefixes = vec![primary.clone()];
122
123    if device_name.starts_with("dm-") {
124        if let Some(name) = sysfs_read(&format!("/sys/block/{}/dm/name", device_name)) {
125            prefixes.push(format!("/dev/mapper/{}", name));
126        }
127    }
128
129    if dev_root_is_on_device(device_name, &primary) {
130        prefixes.push("/dev/root".to_string());
131    }
132
133    prefixes
134}
135
136/// Return `true` if `/dev/root` (a root-partition alias used by Ubuntu/AWS and
137/// similar cloud images) belongs to `device_name`.
138///
139/// Two strategies, tried in order:
140///
141/// 1. **Symlink** (`read_link`): common on Debian and some Ubuntu builds.
142///    The symlink target (e.g. `nvme0n1p1`) is resolved and matched against
143///    the device prefix.
144///
145/// 2. **`/proc/self/mountinfo` + sysfs**: when `/dev/root` is a device node
146///    (not a symlink), we read the `major:minor` string from mountinfo field 3
147///    for the `/` mount whose source is `/dev/root`, then scan
148///    `/sys/block/<device>/<partition>/dev` for a matching string.
149///    Both sources use the same `"major:minor"` text format, so no encoding or
150///    `makedev(3)` arithmetic is needed.
151fn dev_root_is_on_device(device_name: &str, primary: &str) -> bool {
152    // Strategy 1: symlink
153    if let Ok(target) = std::fs::read_link("/dev/root") {
154        let t = target.to_string_lossy();
155        let resolved = if t.starts_with('/') {
156            t.to_string()
157        } else {
158            format!("/dev/{}", t)
159        };
160        return resolved.starts_with(primary);
161    }
162
163    // Strategy 2: mountinfo major:minor comparison
164    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default();
165    let Some(root_devnum) = mountinfo.lines().find_map(|line| {
166        // mountinfo fields (space-separated):
167        //   mount_id parent_id major:minor root mount_point options [optionals] - fstype source super_opts
168        let mut f = line.splitn(6, ' ');
169        f.next()?; // mount_id
170        f.next()?; // parent_id
171        let devnum = f.next()?.to_string(); // major:minor  ← what we need
172        f.next()?; // root within fs
173        let mpt = f.next()?; // mount_point
174        if mpt != "/" {
175            return None;
176        }
177        // Source is the second word after the " - " separator.
178        let sep = line.find(" - ")?;
179        let source = line[sep + 3..].split_whitespace().nth(1)?;
180        if source == "/dev/root" {
181            Some(devnum)
182        } else {
183            None
184        }
185    }) else {
186        return false;
187    };
188
189    // Does any partition of device_name carry this major:minor?
190    let sys_base = format!("/sys/block/{}", device_name);
191    let Ok(entries) = std::fs::read_dir(&sys_base) else {
192        return false;
193    };
194    entries.flatten().any(|e| {
195        let pname = e.file_name().to_string_lossy().to_string();
196        pname.starts_with(device_name)
197            && sysfs_read(&format!("{}/{}/dev", sys_base, pname))
198                .map_or(false, |dev| dev == root_devnum)
199    })
200}
201
202fn statvfs_space(path: &str) -> Option<(u64, u64, u64)> {
203    let cpath = CString::new(path).ok()?;
204    unsafe {
205        let mut buf: libc::statvfs = std::mem::zeroed();
206        if libc::statvfs(cpath.as_ptr(), &mut buf) != 0 {
207            return None;
208        }
209        // f_frsize is the fundamental block size; fall back to f_bsize if zero.
210        let bs = if buf.f_frsize > 0 {
211            buf.f_frsize as u64
212        } else {
213            buf.f_bsize as u64
214        };
215        let total = buf.f_blocks * bs;
216        let avail = buf.f_bavail * bs;
217        let used = total.saturating_sub(buf.f_bfree * bs);
218        Some((total, used, avail))
219    }
220}
221
222/// Read /proc/mounts and return filesystem space for all mount points whose
223/// source device path starts with `/dev/<device_name>` (covers partitions too).
224///
225/// Three filters/guards mirror the Python implementation and prevent inflation:
226///
227/// 1. Mount points under `/proc`, `/sys`, `/dev`, `/run` are skipped — these
228///    are virtual hierarchies and frequent bind-mount targets.
229/// 2. Each unique source path (e.g. `/dev/sda1`) is counted only once. The
230///    same source can appear at multiple mount points via bind mounts or btrfs
231///    subvolumes; without deduplication, `statvfs` returns the same pool size
232///    for each entry, multiplying the reported total by the subvolume count.
233/// 3. Pseudo-filesystems with `f_blocks == 0` are skipped after `statvfs`.
234fn mounts_for_device(device_name: &str) -> Vec<DiskMountMetrics> {
235    let content = match std::fs::read_to_string("/proc/mounts") {
236        Ok(c) => c,
237        Err(_) => return vec![],
238    };
239    let prefixes = device_source_prefixes(device_name);
240    let mut seen_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
241    let mut result = Vec::new();
242
243    for line in content.lines() {
244        if !prefixes.iter().any(|p| line.starts_with(p.as_str())) {
245            continue;
246        }
247        let mut parts = line.split_whitespace();
248        let (Some(source), Some(mount_point), Some(filesystem)) =
249            (parts.next(), parts.next(), parts.next())
250        else {
251            continue;
252        };
253
254        // Skip virtual filesystem mount-point hierarchies.
255        if mount_point.starts_with("/proc")
256            || mount_point.starts_with("/sys")
257            || mount_point.starts_with("/dev")
258            || mount_point.starts_with("/run")
259        {
260            continue;
261        }
262
263        // Deduplicate by source: btrfs subvolumes and bind mounts share the
264        // same source device and report the same pool total each — count once.
265        if !seen_sources.insert(source.to_string()) {
266            continue;
267        }
268
269        let Some((total, used, avail)) = statvfs_space(mount_point) else {
270            continue;
271        };
272
273        // Skip pseudo-filesystems that report no blocks.
274        if total == 0 {
275            continue;
276        }
277
278        let used_pct = used as f64 / total as f64 * 100.0;
279        result.push(DiskMountMetrics {
280            mount_point: mount_point.to_string(),
281            filesystem: filesystem.to_string(),
282            total_bytes: total,
283            used_bytes: used,
284            available_bytes: avail,
285            used_pct,
286        });
287    }
288    result
289}
290
291// ---------------------------------------------------------------------------
292// ZFS pool space
293// ---------------------------------------------------------------------------
294
295/// Collect space stats for every imported ZFS pool via `zpool list -Hp`.
296///
297/// ZFS `statvfs` is unsuitable for pool-level capacity: the kernel sets
298/// `f_blocks = (pool_avail + dataset.referenced_bytes) >> SPA_MINBLOCKSHIFT`,
299/// i.e. only the REFER column (space at this dataset level, not recursive),
300/// not the pool total.  For a pool with many child datasets this gives a
301/// heavily under-counted figure.  `zpool list` is the only authoritative
302/// source for pool capacity, used, and free.
303///
304/// Mount-point information is looked up from `/proc/mounts` as a best-effort
305/// annotation; the dataset with the fewest path components per pool is preferred.
306fn collect_zfs_spaces(timeout: std::time::Duration) -> Vec<(String, DiskMountMetrics)> {
307    // Fast guard: the ZFS kernel module creates /proc/spl/kstat/zfs/ when
308    // loaded.  A single stat(2) call avoids fork+exec on every collection
309    // cycle on the vast majority of systems that don't run ZFS.
310    if !std::path::Path::new("/proc/spl/kstat/zfs").exists() {
311        return vec![];
312    }
313
314    // Run zpool in a background thread with a timeout: on a degraded or
315    // slow pool the command can block for tens of seconds.
316    let (tx, rx) = std::sync::mpsc::channel();
317    std::thread::spawn(move || {
318        let _ = tx.send(
319            std::process::Command::new("zpool")
320                .args(["list", "-Hp", "-o", "name,size,allocated,free"])
321                .output(),
322        );
323    });
324    let out = match rx.recv_timeout(timeout) {
325        Ok(Ok(o)) if o.status.success() => o,
326        _ => return vec![],
327    };
328    let stdout = match std::str::from_utf8(&out.stdout) {
329        Ok(s) => s,
330        Err(_) => return vec![],
331    };
332
333    let mount_map = zfs_pool_mount_map();
334    let mut result = Vec::new();
335
336    for line in stdout.lines() {
337        let mut parts = line.split('\t');
338        let (Some(name), Some(size), Some(allocated), Some(free)) =
339            (parts.next(), parts.next(), parts.next(), parts.next())
340        else {
341            continue;
342        };
343        let total: u64 = match size.parse() {
344            Ok(v) => v,
345            Err(_) => continue,
346        };
347        if total == 0 {
348            continue;
349        }
350        let used: u64 = allocated.parse().unwrap_or(0);
351        let avail: u64 = free.parse().unwrap_or(0);
352        let mount_point = mount_map.get(name).cloned().unwrap_or_default();
353        result.push((
354            name.to_string(),
355            DiskMountMetrics {
356                mount_point,
357                filesystem: "zfs".to_string(),
358                total_bytes: total,
359                used_bytes: used,
360                available_bytes: avail,
361                used_pct: used as f64 / total as f64 * 100.0,
362            },
363        ));
364    }
365    result
366}
367
368/// Build a pool-name → shallowest-mount-point map from `/proc/mounts`.
369/// The root dataset (source with no `/`) is preferred; the dataset with the
370/// fewest path components (shallowest) is used when multiple datasets match.
371fn zfs_pool_mount_map() -> HashMap<String, String> {
372    let content = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
373    let mut map: HashMap<String, (usize, String)> = HashMap::new();
374    for line in content.lines() {
375        let mut parts = line.split_whitespace();
376        let (Some(source), Some(mount_point), Some(fs_type)) =
377            (parts.next(), parts.next(), parts.next())
378        else {
379            continue;
380        };
381        if fs_type != "zfs" {
382            continue;
383        }
384        let pool_name = source.split('/').next().unwrap_or(source).to_string();
385        let depth = source.split('/').count();
386        let entry = map.entry(pool_name).or_insert((usize::MAX, String::new()));
387        if depth < entry.0 {
388            *entry = (depth, mount_point.to_string());
389        }
390    }
391    map.into_iter().map(|(k, (_, v))| (k, v)).collect()
392}
393
394// ---------------------------------------------------------------------------
395// Delta snapshot + Collector
396// ---------------------------------------------------------------------------
397
398struct Snapshot {
399    instant: Instant,
400    sectors_read: HashMap<String, u64>,
401    sectors_written: HashMap<String, u64>,
402}
403
404pub struct DiskCollector {
405    /// Static hardware identity, cached once in new().
406    device_cache: HashMap<String, DeviceInfo>,
407    prev: Option<Snapshot>,
408    /// Maximum time to wait for `zpool list` before skipping ZFS stats.
409    /// Set to half the sampling interval so a slow/hung pool never blocks
410    /// more than one collection cycle.
411    zfs_timeout: std::time::Duration,
412}
413
414impl DiskCollector {
415    pub fn new(interval: std::time::Duration) -> Self {
416        Self {
417            device_cache: discover_devices(),
418            prev: None,
419            zfs_timeout: interval / 2,
420        }
421    }
422
423    pub fn collect(&mut self) -> Result<Vec<DiskMetrics>> {
424        let diskstats = procfs::diskstats()?;
425        let now = Instant::now();
426
427        // Include every device that is a direct /sys/block entry (whole disks,
428        // not partitions), excluding loop and ram devices.  Loop devices back
429        // squashfs snap mounts whose space is already counted as part of the
430        // underlying real disk, so including them double-counts that storage.
431        let block_set: std::collections::HashSet<String> = std::fs::read_dir("/sys/block")
432            .map(|dir| {
433                dir.flatten()
434                    .filter_map(|e| {
435                        let name = e.file_name().to_string_lossy().to_string();
436                        if name.starts_with("loop") || name.starts_with("ram") {
437                            None
438                        } else {
439                            Some(name)
440                        }
441                    })
442                    .collect()
443            })
444            .unwrap_or_default();
445
446        let devs: Vec<_> = diskstats
447            .iter()
448            .filter(|d| block_set.contains(&d.name))
449            .collect();
450
451        let sectors_read: HashMap<String, u64> = devs
452            .iter()
453            .map(|d| (d.name.clone(), d.sectors_read))
454            .collect();
455        let sectors_written: HashMap<String, u64> = devs
456            .iter()
457            .map(|d| (d.name.clone(), d.sectors_written))
458            .collect();
459
460        let mut metrics: Vec<DiskMetrics> = devs
461            .iter()
462            .map(|d| {
463                let info = self.device_cache.get(&d.name);
464
465                let sector_size: u32 = info
466                    .map_or(u32::try_from(SECTOR_BYTES).unwrap_or(512), |i| {
467                        i.sector_size
468                    });
469                let sector_size_f64 = f64::from(sector_size);
470                let sector_size_u64 = u64::from(sector_size);
471
472                let (read_bps, write_bps) = match &self.prev {
473                    None => (0.0, 0.0),
474                    Some(prev) => {
475                        let secs = (now - prev.instant).as_secs_f64().max(0.001);
476                        let sr = sectors_read[&d.name];
477                        let sw = sectors_written[&d.name];
478                        let psr = prev.sectors_read.get(&d.name).copied().unwrap_or(sr);
479                        let psw = prev.sectors_written.get(&d.name).copied().unwrap_or(sw);
480                        // u64 -> f64 is lossy for very large values but no From impl exists in std.
481                        (
482                            sr.saturating_sub(psr) as f64 * sector_size_f64 / secs,
483                            sw.saturating_sub(psw) as f64 * sector_size_f64 / secs,
484                        )
485                    }
486                };
487
488                DiskMetrics {
489                    device: d.name.clone(),
490                    model: info.and_then(|i| i.model.clone()),
491                    vendor: info.and_then(|i| i.vendor.clone()),
492                    serial: info.and_then(|i| i.serial.clone()),
493                    device_type: info.and_then(|i| i.device_type.clone()),
494                    capacity_bytes: info.and_then(|i| i.capacity_bytes),
495                    mounts: mounts_for_device(&d.name),
496                    read_bytes_per_sec: read_bps,
497                    write_bytes_per_sec: write_bps,
498                    read_bytes_total: sectors_read[&d.name] * sector_size_u64,
499                    write_bytes_total: sectors_written[&d.name] * sector_size_u64,
500                }
501            })
502            .collect();
503
504        // Append one synthetic entry per ZFS pool.  ZFS datasets don't appear
505        // in /sys/block or /proc/diskstats, so they need a separate path.
506        // The device name follows Python's convention: "zfs:<pool_name>".
507        for (pool_name, mount) in collect_zfs_spaces(self.zfs_timeout) {
508            let total = mount.total_bytes;
509            metrics.push(DiskMetrics {
510                device: format!("zfs:{}", pool_name),
511                model: None,
512                vendor: None,
513                serial: None,
514                device_type: None,
515                capacity_bytes: Some(total),
516                mounts: vec![mount],
517                // I/O is left at zero: the underlying physical devices that
518                // make up the pool appear in /proc/diskstats and are already
519                // tracked as separate DiskMetrics entries.  This synthetic
520                // entry exists solely for pool-level space accounting.
521                read_bytes_per_sec: 0.0,
522                write_bytes_per_sec: 0.0,
523                read_bytes_total: 0,
524                write_bytes_total: 0,
525            });
526        }
527
528        metrics.sort_by(|a, b| a.device.cmp(&b.device));
529        self.prev = Some(Snapshot {
530            instant: now,
531            sectors_read,
532            sectors_written,
533        });
534        Ok(metrics)
535    }
536}
537
538// ---------------------------------------------------------------------------
539// Unit tests
540// ---------------------------------------------------------------------------
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    // T-DSK-SECTOR: a 4K-native device (sector_size = 4096) produces byte
547    // counts 8x larger than the hard-coded 512 would give for the same
548    // sector delta.
549    #[test]
550    fn test_sector_size_4k_gives_8x_bytes() {
551        let sector_delta: u64 = 1000;
552        let sector_size_512: u32 = 512;
553        let sector_size_4096: u32 = 4096;
554
555        let bytes_512 = sector_delta * u64::from(sector_size_512);
556        let bytes_4096 = sector_delta * u64::from(sector_size_4096);
557
558        assert_eq!(
559            bytes_4096,
560            bytes_512 * 8,
561            "4K sector should produce 8x the bytes of 512-byte sector"
562        );
563    }
564
565    // Verify read_device_info falls back to 512 when hw_sector_size is absent
566    // (non-existent device path).
567    #[test]
568    fn test_sector_size_fallback_is_512() {
569        let info = read_device_info("__nonexistent_device__");
570        assert_eq!(info.sector_size, 512);
571    }
572
573    // T-DSK-01: first collect() returns Ok; all I/O rates are 0.0 (no prior snapshot).
574    #[test]
575    fn test_disk_first_collect_rates_zero() {
576        let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
577        let metrics = collector.collect().expect("first collect() should succeed");
578        metrics.iter().for_each(|d| {
579            assert_eq!(
580                d.read_bytes_per_sec, 0.0,
581                "read_bytes_per_sec must be 0.0 on first collect for {}",
582                d.device
583            );
584            assert_eq!(
585                d.write_bytes_per_sec, 0.0,
586                "write_bytes_per_sec must be 0.0 on first collect for {}",
587                d.device
588            );
589        });
590    }
591
592    // T-DSK-02: second collect() returns Ok; all I/O rates are >= 0.0.
593    #[test]
594    fn test_disk_second_collect_rates_nonneg() {
595        let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
596        let _ = collector.collect().expect("first collect() failed");
597        let metrics = collector.collect().expect("second collect() failed");
598        metrics.iter().for_each(|d| {
599            assert!(
600                d.read_bytes_per_sec >= 0.0,
601                "read_bytes_per_sec must be >= 0.0 for {}",
602                d.device
603            );
604            assert!(
605                d.write_bytes_per_sec >= 0.0,
606                "write_bytes_per_sec must be >= 0.0 for {}",
607                d.device
608            );
609        });
610    }
611
612    // T-DSK-03: results are sorted alphabetically by device name.
613    #[test]
614    fn test_disk_results_sorted_by_device() {
615        let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
616        let metrics = collector.collect().expect("collect() failed");
617        let names: Vec<&str> = metrics.iter().map(|d| d.device.as_str()).collect();
618        let mut sorted = names.clone();
619        sorted.sort();
620        assert_eq!(names, sorted, "disk metrics must be sorted by device name");
621    }
622
623    // T-DSK-04: cumulative totals are non-decreasing between two calls.
624    #[test]
625    fn test_disk_totals_nondecreasing() {
626        let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
627        let first = collector.collect().expect("first collect() failed");
628        let second = collector.collect().expect("second collect() failed");
629        let first_map: std::collections::HashMap<&str, (u64, u64)> = first
630            .iter()
631            .map(|d| (d.device.as_str(), (d.read_bytes_total, d.write_bytes_total)))
632            .collect();
633        second.iter().for_each(|d| {
634            if let Some(&(prev_r, prev_w)) = first_map.get(d.device.as_str()) {
635                assert!(
636                    d.read_bytes_total >= prev_r,
637                    "read_bytes_total decreased for {}: {} < {}",
638                    d.device,
639                    d.read_bytes_total,
640                    prev_r
641                );
642                assert!(
643                    d.write_bytes_total >= prev_w,
644                    "write_bytes_total decreased for {}: {} < {}",
645                    d.device,
646                    d.write_bytes_total,
647                    prev_w
648                );
649            }
650        });
651    }
652
653    // T-DSK-05: read_device_info for a non-existent device returns all None fields
654    // except sector_size (which falls back to 512).
655    #[test]
656    fn test_read_device_info_nonexistent_all_none() {
657        let info = read_device_info("__nonexistent__");
658        assert!(
659            info.model.is_none(),
660            "model must be None for missing device"
661        );
662        assert!(
663            info.vendor.is_none(),
664            "vendor must be None for missing device"
665        );
666        assert!(
667            info.serial.is_none(),
668            "serial must be None for missing device"
669        );
670        assert!(
671            info.device_type.is_none(),
672            "device_type must be None for missing device"
673        );
674        assert!(
675            info.capacity_bytes.is_none(),
676            "capacity_bytes must be None for missing device"
677        );
678    }
679}