diff --git a/src/app.rs b/src/app.rs index 4ad14aa5..4569b257 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,7 +18,7 @@ use hdd::any_path_is_in_hdd; use pipe_trait::Pipe; use std::{io::stdin, time::Duration}; use sub::JsonOutputParam; -use sysinfo::Disks; +use sysinfo::{Disk, Disks}; #[cfg(unix)] use crate::get_size::{GetBlockCount, GetBlockSize}; @@ -136,7 +136,7 @@ impl App { let threads = match self.args.threads { Threads::Auto => { let disks = Disks::new_with_refreshed_list(); - if any_path_is_in_hdd::(&self.args.files, &disks) { + if any_path_is_in_hdd::(&self.args.files, &disks) { eprintln!("warning: HDD detected, the thread limit will be set to 1"); eprintln!("hint: You can pass --threads=max disable this behavior"); Some(1) diff --git a/src/app/hdd.rs b/src/app/hdd.rs index 4d29cf49..c144e5ed 100644 --- a/src/app/hdd.rs +++ b/src/app/hdd.rs @@ -1,58 +1,287 @@ use super::mount_point::find_mount_point; use std::{ + ffi::OsStr, fs::canonicalize, io, path::{Path, PathBuf}, }; use sysinfo::{Disk, DiskKind}; -/// Mockable APIs to interact with the system. -pub trait Api { - type Disk; - fn get_disk_kind(disk: &Self::Disk) -> DiskKind; - fn get_mount_point(disk: &Self::Disk) -> &Path; +#[cfg(target_os = "linux")] +use pipe_trait::Pipe; +#[cfg(target_os = "linux")] +use std::borrow::Cow; + +/// Mockable interface to [`sysinfo::Disk`] methods. +/// +/// Each method delegates to a corresponding [`sysinfo::Disk`] method, +/// enabling dependency injection for testing. +pub trait DiskApi { + fn get_disk_kind(&self) -> DiskKind; + fn get_disk_name(&self) -> &OsStr; + fn get_mount_point(&self) -> &Path; +} + +/// Mockable interface to filesystem operations. +/// +/// Abstracts system calls like [`canonicalize`], [`Path::exists`], and +/// [`std::fs::read_link`] so tests can substitute an in-memory fake. +pub trait FsApi { fn canonicalize(path: &Path) -> io::Result; + #[cfg(target_os = "linux")] + fn path_exists(path: &Path) -> bool; + #[cfg(target_os = "linux")] + fn read_link(path: &Path) -> io::Result; } -/// Implementation of [`Api`] that interacts with the real system. -pub struct RealApi; -impl Api for RealApi { - type Disk = Disk; +/// Implementation of [`FsApi`] that interacts with the real system. +pub struct RealFs; + +impl DiskApi for Disk { + #[inline] + fn get_disk_kind(&self) -> DiskKind { + self.kind() + } #[inline] - fn get_disk_kind(disk: &Self::Disk) -> DiskKind { - disk.kind() + fn get_disk_name(&self) -> &OsStr { + self.name() } #[inline] - fn get_mount_point(disk: &Self::Disk) -> &Path { - disk.mount_point() + fn get_mount_point(&self) -> &Path { + self.mount_point() } +} +impl FsApi for RealFs { #[inline] fn canonicalize(path: &Path) -> io::Result { canonicalize(path) } + + #[cfg(target_os = "linux")] + #[inline] + fn path_exists(path: &Path) -> bool { + path.exists() + } + + #[cfg(target_os = "linux")] + #[inline] + fn read_link(path: &Path) -> io::Result { + std::fs::read_link(path) + } +} + +/// Sentinel value used to reclassify virtual block devices that were +/// falsely reported as `DiskKind::HDD` by `sysinfo`. +#[cfg(target_os = "linux")] +const VIRTUAL_DISK_KIND: DiskKind = DiskKind::Unknown(-1); + +/// On Linux, the `rotational` sysfs flag defaults to `1` for virtual block devices +/// (e.g. VirtIO, Xen) because the kernel cannot determine the backing storage type. +/// This causes `sysinfo` to falsely report them as HDDs. +/// +/// This function checks the block device's driver via sysfs and reclassifies +/// known virtual drivers as `Unknown` instead of `HDD`. +#[cfg(target_os = "linux")] +fn reclassify_virtual_hdd(kind: DiskKind, disk_name: &str) -> DiskKind { + if kind != DiskKind::HDD { + return kind; + } + if let Some(block_dev) = extract_block_device_name::(disk_name) { + if is_virtual_block_device::(&block_dev) { + return VIRTUAL_DISK_KIND; + } + } + DiskKind::HDD +} + +/// On non-Linux platforms (macOS, FreeBSD), `sysinfo` currently reports +/// `DiskKind::Unknown` because there is no reliable OS API for determining +/// rotational vs solid-state. This means the `kind == DiskKind::HDD` check +/// in [`is_hdd`] never matches, so this function is effectively a no-op. +/// +/// If `sysinfo` ever gains accurate disk-kind detection on these platforms, +/// this function should be revisited — virtual disks on macOS (e.g. virtio +/// in QEMU) or FreeBSD (e.g. virtio-blk) could face the same misclassification. +#[cfg(not(target_os = "linux"))] +fn reclassify_virtual_hdd(kind: DiskKind, _: &str) -> DiskKind { + kind +} + +/// Resolve a device path through symlinks and then parse the block device name. +/// +/// Handles `/dev/mapper/xxx` symlinks and `/dev/root` by following them via +/// `canonicalize`, then delegates to [`parse_block_device_name`] for parsing +/// and [`validate_block_device`] to verify the device exists in sysfs. +/// +/// **Known limitation:** LVM / device-mapper +/// +/// On real LVM setups, `/dev/mapper/vg0-lv0` canonicalizes to `/dev/dm-0` +/// (a device-mapper device), not to the underlying physical device like +/// `/dev/vda1`. The `dm-0` device has no `/sys/block/dm-0/device/driver` +/// symlink, so [`is_virtual_block_device`] cannot determine its driver and +/// returns `false`. This means virtual-disk correction silently does nothing +/// for LVM volumes, even when the backing device is VirtIO. +/// +/// Fixing this would require walking `/sys/block/dm-*/slaves/` to discover +/// the real backing device(s). That introduces three problems: +/// +/// 1. [`FsApi`] would need a `read_dir` method, expanding the trait and +/// every mock implementation. +/// 2. The slave chain can be recursive (`dm` on `dm`, e.g. LUKS on LVM), +/// requiring unbounded traversal. +/// 3. A `dm` device can have multiple slaves (stripes, mirrors). A policy +/// decision is needed: is the device virtual only when *all* slaves are +/// virtual, or when *any* is? Neither answer is obviously correct. +/// +/// Given the complexity and the relative importance of the auto HDD detection feature, +/// we have chosen to ignore it. +#[cfg(target_os = "linux")] +fn extract_block_device_name(device_path: &str) -> Option> { + if !device_path.starts_with("/dev/mapper/") && !device_path.starts_with("/dev/root") { + let block_dev = parse_block_device_name(device_path)?; + return block_dev + .pipe(validate_block_device::) + .map(Cow::Borrowed); + } + + let canon_device_path = Fs::canonicalize(Path::new(device_path)).ok()?; + let canon_device_path = canon_device_path.to_str()?; + if canon_device_path == device_path { + return None; + } + + // Safe to recurse: `canonicalize` resolves all symlinks, so the + // canonical path will not start with `/dev/mapper/` or `/dev/root`. + canon_device_path + .pipe(extract_block_device_name::) + .map(Cow::into_owned) // must copy-allocate because `canon_device_path` is locally owned + .map(Cow::Owned) +} + +/// Parse the base block device name from a device path (pure string parsing). +/// +/// This function performs no I/O; it only strips the `/dev/` prefix and +/// partition suffixes to recover the base block device name. +/// +/// **Examples:** +/// - `/dev/vda1` → `Some("vda")` +/// - `/dev/sda1` → `Some("sda")` +/// - `/dev/xvda1` → `Some("xvda")` +/// - `/dev/nvme0n1p1` → `Some("nvme0n1")` +/// - `/dev/mmcblk0p1` → `Some("mmcblk0")` +/// - `vda1` (no `/dev/` prefix) → `None` +#[cfg(target_os = "linux")] +fn parse_block_device_name(device_path: &str) -> Option<&str> { + let name = device_path.strip_prefix("/dev/")?; + + let block_dev = if name.starts_with("sd") || name.starts_with("vd") || name.starts_with("xvd") { + // Strip trailing partition digits: "sda1" → "sda", "vda1" → "vda" + name.trim_end_matches(|c: char| c.is_ascii_digit()) + } else if name.starts_with("nvme") || name.starts_with("mmcblk") { + // Strip partition suffix: "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0" + match name.rsplit_once('p') { + Some((base, suffix)) + if !base.is_empty() + && !suffix.is_empty() + && suffix.bytes().all(|b| b.is_ascii_digit()) => + { + base + } + _ => name, + } + } else { + name + }; + + Some(block_dev) +} + +/// Verify that a block device exists in sysfs. +/// +/// Returns `Some(block_dev)` if `/sys/block/` exists, `None` otherwise. +#[cfg(target_os = "linux")] +fn validate_block_device(block_dev: &str) -> Option<&str> { + "/sys/block" + .pipe(Path::new) + .join(block_dev) + .pipe_as_ref(Fs::path_exists) + .then_some(block_dev) +} + +/// Check if a block device is backed by a virtual driver. +/// +/// Reads the driver symlink at `/sys/block//device/driver` and checks +/// if it matches known virtual block device drivers. +#[cfg(target_os = "linux")] +fn is_virtual_block_device(block_dev: &str) -> bool { + let driver_path = "/sys/block" + .pipe(Path::new) + .join(block_dev) + .join("device/driver"); + + let Ok(target) = Fs::read_link(&driver_path) else { + return false; + }; + + let driver_name = target.file_name().and_then(OsStr::to_str); + + matches!( + driver_name, + Some( + "virtio_blk" + | "virtio-blk" + | "xen_blkfront" + | "xen-blkfront" + | "vbd" + | "vmw_pvscsi" + | "hv_storvsc" + ) + ) } /// Check if any path is in any HDD. -pub fn any_path_is_in_hdd(paths: &[PathBuf], disks: &[Api::Disk]) -> bool { +pub fn any_path_is_in_hdd(paths: &[PathBuf], disks: &[Disk]) -> bool { paths .iter() - .filter_map(|file| Api::canonicalize(file).ok()) - .any(|path| path_is_in_hdd::(&path, disks)) + .filter_map(|file| Fs::canonicalize(file).ok()) + .any(|path| path_is_in_hdd::(&path, disks)) } /// Check if path is in any HDD. -fn path_is_in_hdd(path: &Path, disks: &[Api::Disk]) -> bool { - let Some(mount_point) = find_mount_point(path, disks.iter().map(Api::get_mount_point)) else { +/// +/// Applies [`reclassify_virtual_hdd`] to each disk's reported kind to work +/// around virtual block devices being falsely reported as HDDs on Linux. +fn path_is_in_hdd(path: &Path, disks: &[Disk]) -> bool { + let mount_point = find_mount_point(path, disks.iter().map(Disk::get_mount_point)); + let Some(mount_point) = mount_point else { return false; }; disks .iter() - .filter(|disk| Api::get_disk_kind(disk) == DiskKind::HDD) - .any(|disk| Api::get_mount_point(disk) == mount_point) + .filter(|disk| disk.get_mount_point() == mount_point) + .any(is_hdd::) +} + +/// Check if a disk is an HDD after applying platform-specific corrections. +fn is_hdd(disk: &impl DiskApi) -> bool { + let kind = disk.get_disk_kind(); + let name = disk.get_disk_name().to_str(); + match name { + Some(name) => reclassify_virtual_hdd::(kind, name) == DiskKind::HDD, + None => kind == DiskKind::HDD, // can't parse name, keep original classification + } } #[cfg(test)] mod test; + +#[cfg(target_os = "linux")] +#[cfg(test)] +mod test_linux; + +#[cfg(target_os = "linux")] +#[cfg(test)] +mod test_linux_smoke; diff --git a/src/app/hdd/test.rs b/src/app/hdd/test.rs index 88d70a4d..e70f15b1 100644 --- a/src/app/hdd/test.rs +++ b/src/app/hdd/test.rs @@ -1,47 +1,76 @@ -use super::{any_path_is_in_hdd, path_is_in_hdd, Api}; +use super::{any_path_is_in_hdd, path_is_in_hdd, DiskApi, FsApi}; use pipe_trait::Pipe; use pretty_assertions::assert_eq; -use std::path::{Path, PathBuf}; +use std::{ + ffi::OsStr, + io, + path::{Path, PathBuf}, +}; use sysinfo::DiskKind; -/// Fake disk for [`Api`]. +/// Fake disk for [`DiskApi`]. struct Disk { kind: DiskKind, + name: &'static str, mount_point: &'static str, } impl Disk { - fn new(kind: DiskKind, mount_point: &'static str) -> Self { - Self { kind, mount_point } + fn new(kind: DiskKind, name: &'static str, mount_point: &'static str) -> Self { + Self { + kind, + name, + mount_point, + } } } -/// Mocked implementation of [`Api`] for testing purposes. -struct MockedApi; -impl Api for MockedApi { - type Disk = Disk; +impl DiskApi for Disk { + fn get_disk_kind(&self) -> DiskKind { + self.kind + } - fn get_disk_kind(disk: &Self::Disk) -> DiskKind { - disk.kind + fn get_disk_name(&self) -> &OsStr { + OsStr::new(self.name) } - fn get_mount_point(disk: &Self::Disk) -> &Path { - Path::new(disk.mount_point) + fn get_mount_point(&self) -> &Path { + Path::new(self.mount_point) } +} - fn canonicalize(path: &Path) -> std::io::Result { +/// Mocked [`FsApi`] with no sysfs entries. +/// +/// `canonicalize` returns the path unchanged (all paths are canonical). +/// `path_exists` returns `false` and `read_link` returns `NotFound`, +/// so [`reclassify_virtual_hdd`](super::reclassify_virtual_hdd) is +/// effectively a no-op: disk kinds pass through unchanged. +struct EmptyFs; + +impl FsApi for EmptyFs { + fn canonicalize(path: &Path) -> io::Result { path.to_path_buf().pipe(Ok) } + + #[cfg(target_os = "linux")] + fn path_exists(_: &Path) -> bool { + false + } + + #[cfg(target_os = "linux")] + fn read_link(_: &Path) -> io::Result { + Err(io::Error::new(io::ErrorKind::NotFound, "mocked")) + } } #[test] fn test_any_path_in_hdd() { let disks = &[ - Disk::new(DiskKind::SSD, "/"), - Disk::new(DiskKind::HDD, "/home"), - Disk::new(DiskKind::HDD, "/mnt/hdd-data"), - Disk::new(DiskKind::SSD, "/mnt/ssd-data"), - Disk::new(DiskKind::HDD, "/mnt/hdd-data/repo"), + Disk::new(DiskKind::SSD, "/dev/sda", "/"), + Disk::new(DiskKind::HDD, "/dev/sdb", "/home"), + Disk::new(DiskKind::HDD, "/dev/sdc", "/mnt/hdd-data"), + Disk::new(DiskKind::SSD, "/dev/sdd", "/mnt/ssd-data"), + Disk::new(DiskKind::HDD, "/dev/sde", "/mnt/hdd-data/repo"), ]; let cases: &[(&[&str], bool)] = &[ @@ -69,18 +98,18 @@ fn test_any_path_in_hdd() { for (paths, in_hdd) in cases { let paths: Vec<_> = paths.iter().map(PathBuf::from).collect(); println!("CASE: {paths:?} → {in_hdd:?}"); - assert_eq!(any_path_is_in_hdd::(&paths, disks), *in_hdd); + assert_eq!(any_path_is_in_hdd::(&paths, disks), *in_hdd); } } #[test] fn test_path_in_hdd() { let disks = &[ - Disk::new(DiskKind::SSD, "/"), - Disk::new(DiskKind::HDD, "/home"), - Disk::new(DiskKind::HDD, "/mnt/hdd-data"), - Disk::new(DiskKind::SSD, "/mnt/ssd-data"), - Disk::new(DiskKind::HDD, "/mnt/hdd-data/repo"), + Disk::new(DiskKind::SSD, "/dev/sda", "/"), + Disk::new(DiskKind::HDD, "/dev/sdb", "/home"), + Disk::new(DiskKind::HDD, "/dev/sdc", "/mnt/hdd-data"), + Disk::new(DiskKind::SSD, "/dev/sdd", "/mnt/ssd-data"), + Disk::new(DiskKind::HDD, "/dev/sde", "/mnt/hdd-data/repo"), ]; for (path, in_hdd) in [ @@ -91,6 +120,9 @@ fn test_path_in_hdd() { ("/mnt/ssd-data/test/test", false), ] { println!("CASE: {path} → {in_hdd:?}"); - assert_eq!(path_is_in_hdd::(Path::new(path), disks), in_hdd); + assert_eq!( + path_is_in_hdd::(Path::new(path), disks), + in_hdd, + ); } } diff --git a/src/app/hdd/test_linux.rs b/src/app/hdd/test_linux.rs new file mode 100644 index 00000000..0956ee85 --- /dev/null +++ b/src/app/hdd/test_linux.rs @@ -0,0 +1,258 @@ +use super::{parse_block_device_name, reclassify_virtual_hdd, FsApi, VIRTUAL_DISK_KIND}; +use pipe_trait::Pipe; +use pretty_assertions::assert_eq; +use std::{ + io, + path::{Path, PathBuf}, +}; +use sysinfo::DiskKind; + +/// Test pure parsing of block device names — no sysfs dependency. +#[test] +fn test_parse_block_device_name() { + let cases: &[(&str, Option<&str>)] = &[ + // sd devices + ("/dev/sda", Some("sda")), + ("/dev/sda1", Some("sda")), + ("/dev/sdb3", Some("sdb")), + // virtio devices + ("/dev/vda", Some("vda")), + ("/dev/vda1", Some("vda")), + ("/dev/vdb2", Some("vdb")), + // xen devices + ("/dev/xvda", Some("xvda")), + ("/dev/xvda1", Some("xvda")), + // nvme devices + ("/dev/nvme0n1", Some("nvme0n1")), + ("/dev/nvme0n1p1", Some("nvme0n1")), + // mmcblk devices + ("/dev/mmcblk0", Some("mmcblk0")), + ("/dev/mmcblk0p1", Some("mmcblk0")), + // no /dev/ prefix → None + ("vda1", None), + // unknown device type still returns the name + ("/dev/loop0", Some("loop0")), + ]; + + for (input, expected) in cases { + let actual = parse_block_device_name(input); + println!("CASE: {input} → {actual:?} (expected {expected:?})"); + assert_eq!(actual, *expected); + } +} + +/// Generate a test that builds a mock `FsApi` with identity `canonicalize`, +/// then asserts that `reclassify_virtual_hdd` maps `DiskKind::HDD` to the +/// expected `DiskKind`. +/// +/// The sysfs paths (`/sys/block/{block}` and +/// `/sys/block/{block}/device/driver`) are derived from `block_device`, +/// so callers only supply the four varying pieces: block device name, kernel +/// driver name, disk name, and expected `DiskKind`. +macro_rules! identity_reclassify_test_case { + ( + $(#[$attr:meta])* + $name:ident where + block_device = $block:literal, + driver = $driver:literal, + disk_name = $disk_name:literal, + expected = $expected:expr, + ) => { + $(#[$attr])* + #[test] + fn $name() { + static DEVICES: &[&str] = &[concat!("/sys/block/", $block)]; + static DRIVERS: &[(&str, &str)] = + &[(concat!("/sys/block/", $block, "/device/driver"), $driver)]; + + struct Fs; + impl FsApi for Fs { + fn canonicalize(path: &Path) -> io::Result { + path.to_path_buf().pipe(Ok) + } + fn path_exists(path: &Path) -> bool { + DEVICES.iter().any(|dev| path == Path::new(*dev)) + } + fn read_link(path: &Path) -> io::Result { + DRIVERS + .iter() + .find(|(drv_path, _)| path == Path::new(*drv_path)) + .map(|(_, drv_name)| PathBuf::from(format!("/drivers/{drv_name}"))) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "mocked")) + } + } + + assert_eq!( + reclassify_virtual_hdd::(DiskKind::HDD, $disk_name), + $expected, + ); + } + }; +} + +identity_reclassify_test_case! { + /// VirtIO disk reported as HDD should be reclassified as [`VIRTUAL_DISK_KIND`]. + test_virtio_disk_is_reclassified where + block_device = "vda", + driver = "virtio_blk", + disk_name = "/dev/vda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// VirtIO disk whose sysfs driver is `virtio-blk` (the hyphenated + /// variant) should also be reclassified as [`VIRTUAL_DISK_KIND`]. + test_virtio_blk_hyphen_disk_is_reclassified where + block_device = "vda", + driver = "virtio-blk", + disk_name = "/dev/vda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// Xen disk whose sysfs driver is `vbd` (the xenbus-registered name) + /// should be reclassified as [`VIRTUAL_DISK_KIND`]. + test_xen_vbd_disk_is_reclassified where + block_device = "xvda", + driver = "vbd", + disk_name = "/dev/xvda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// Xen disk whose sysfs driver is `xen_blkfront` (the underscored kernel + /// module name) should be reclassified as [`VIRTUAL_DISK_KIND`]. + test_xen_blkfront_underscore_disk_is_reclassified where + block_device = "xvda", + driver = "xen_blkfront", + disk_name = "/dev/xvda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// Xen disk whose sysfs driver is `xen-blkfront` (the hyphenated module + /// name, which may appear on some kernel versions) should also be + /// reclassified as [`VIRTUAL_DISK_KIND`]. + test_xen_blkfront_hyphen_disk_is_reclassified where + block_device = "xvda", + driver = "xen-blkfront", + disk_name = "/dev/xvda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// VMware PVSCSI disk reported as `HDD` should be reclassified as [`VIRTUAL_DISK_KIND`]. + test_vmware_pvscsi_disk_is_reclassified where + block_device = "sda", + driver = "vmw_pvscsi", + disk_name = "/dev/sda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// Hyper-V storage controller disk reported as `HDD` should be reclassified as [`VIRTUAL_DISK_KIND`]. + test_hyperv_storvsc_disk_is_reclassified where + block_device = "sda", + driver = "hv_storvsc", + disk_name = "/dev/sda1", + expected = VIRTUAL_DISK_KIND, +} + +identity_reclassify_test_case! { + /// Physical SCSI disk reported as `HDD` should stay `HDD`. + test_physical_disk_stays_hdd where + block_device = "sda", + driver = "sd", + disk_name = "/dev/sda1", + expected = DiskKind::HDD, +} + +/// Synthetic scenario: `/dev/mapper/vg0-lv0` canonicalizes directly to a +/// VirtIO partition (`/dev/vda1`), exercising the symlink-resolution → +/// recursive-call → reclassify path. +/// +/// **Note:** On real LVM setups, `/dev/mapper/vg0-lv0` canonicalizes to +/// `/dev/dm-0`, not a partition device. See +/// `test_mapper_dm_device_is_not_corrected` for that case. +#[test] +fn test_mapper_symlink_resolves_to_virtual_partition() { + struct Fs; + impl FsApi for Fs { + fn canonicalize(path: &Path) -> io::Result { + [("/dev/mapper/vg0-lv0", "/dev/vda1")] + .iter() + .find(|(src, _)| path == Path::new(*src)) + .map(|(_, target)| PathBuf::from(*target)) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "mocked")) + } + fn path_exists(path: &Path) -> bool { + ["/sys/block/vda"].iter().any(|dev| path == Path::new(*dev)) + } + fn read_link(path: &Path) -> io::Result { + [("/sys/block/vda/device/driver", "virtio_blk")] + .iter() + .find(|(drv_path, _)| path == Path::new(*drv_path)) + .map(|(_, drv_name)| PathBuf::from(format!("/drivers/{drv_name}"))) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "mocked")) + } + } + + assert_eq!( + reclassify_virtual_hdd::(DiskKind::HDD, "/dev/mapper/vg0-lv0"), + VIRTUAL_DISK_KIND, + ); +} + +/// Known limitation: on real LVM setups, `/dev/mapper/vg0-lv0` canonicalizes +/// to `/dev/dm-0`. The `dm-0` device has no `/sys/block/dm-0/device/driver` +/// symlink, so virtual-disk correction silently does nothing. +/// +/// See the doc comment on [`extract_block_device_name`] for details. +#[test] +fn test_mapper_dm_device_is_not_corrected() { + struct Fs; + impl FsApi for Fs { + fn canonicalize(path: &Path) -> io::Result { + [("/dev/mapper/vg0-lv0", "/dev/dm-0")] + .iter() + .find(|(src, _)| path == Path::new(*src)) + .map(|(_, target)| PathBuf::from(*target)) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "mocked")) + } + fn path_exists(path: &Path) -> bool { + path == Path::new("/sys/block/dm-0") + } + fn read_link(_: &Path) -> io::Result { + Err(io::Error::new(io::ErrorKind::NotFound, "mocked")) + } + } + + // dm-0 is recognized but has no /sys/block/dm-0/device/driver + // symlink, so driver detection fails — HDD classification is preserved. + assert_eq!( + reclassify_virtual_hdd::(DiskKind::HDD, "/dev/mapper/vg0-lv0"), + DiskKind::HDD, + ); +} + +/// SSD disk should pass through unchanged — correction is not applied. +#[test] +fn test_ssd_is_not_corrected() { + struct Fs; + impl FsApi for Fs { + fn canonicalize(_: &Path) -> io::Result { + panic!("canonicalize should not be called for non-HDD disks"); + } + fn path_exists(_: &Path) -> bool { + panic!("path_exists should not be called for non-HDD disks"); + } + fn read_link(_: &Path) -> io::Result { + panic!("read_link should not be called for non-HDD disks"); + } + } + + assert_eq!( + reclassify_virtual_hdd::(DiskKind::SSD, "/dev/sda1"), + DiskKind::SSD, + ); +} diff --git a/src/app/hdd/test_linux_smoke.rs b/src/app/hdd/test_linux_smoke.rs new file mode 100644 index 00000000..45678382 --- /dev/null +++ b/src/app/hdd/test_linux_smoke.rs @@ -0,0 +1,36 @@ +use super::{extract_block_device_name, is_virtual_block_device, RealFs}; + +/// On hosts with a `/sys/block/vda` device, exercises the detection +/// pipeline without panicking. Silently skips if `vda` does not exist. +#[test] +fn real_sysfs_vda_does_not_panic() { + if std::path::Path::new("/sys/block/vda").exists() { + let _ = is_virtual_block_device::("vda"); + } +} + +/// A non-existent device name must return `false` without panicking. +#[test] +fn nonexistent_device_is_not_virtual() { + assert!( + !is_virtual_block_device::("nonexistent_device_xyz"), + "non-existent device should not be detected as virtual" + ); +} + +/// Runs the full detection pipeline on every mounted disk. +/// +/// Does **not** assert any specific virtual/non-virtual classification +/// because the result depends on the host hardware. Only verifies that +/// the pipeline completes without panicking. +#[test] +fn full_pipeline_does_not_panic() { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + for disk in disks.list() { + let name = disk.name().to_str().unwrap_or_default(); + if let Some(block_dev) = extract_block_device_name::(name) { + let _ = is_virtual_block_device::(&block_dev); + } + } +}