-
-
Notifications
You must be signed in to change notification settings - Fork 19
fix: false hdd detection on virtual devices #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KSXGitHub
merged 81 commits into
KSXGitHub:master
from
khai-slop-labs:claude/fix-hdd-detection-OQvIf
Mar 15, 2026
Merged
Changes from all commits
Commits
Show all changes
81 commits
Select commit
Hold shift + click to select a range
b9a3dc8
Fix false HDD detection for virtual block devices (VirtIO, Xen, etc.)
claude 61aded0
Fix formatting and clippy lint in HDD detection code
claude 9813170
Address Copilot review feedback on HDD detection
claude e993186
Document why virtual disk detection is Linux-only
claude 5a93978
Refactor: move HDD correction out of Api trait into callsite
claude 2ad8c58
docs: remove unneeded assertion message
KSXGitHub 1e8fe63
test: use `pretty_assertions`
KSXGitHub 9f1a836
perf: reduce allocations
KSXGitHub 8bbe578
refactor: better naming convention
KSXGitHub d0ccc83
refactor: remove unneeded import and qualification
KSXGitHub 299fdcf
refactor: use names that make more sense
KSXGitHub 827342f
refactor: rearrange
KSXGitHub d19d21a
docs: remove commented-out code
KSXGitHub 9429d33
refactor: rearrange
KSXGitHub 30899e9
docs: add some notes
KSXGitHub 06b26e4
refactor: one-liner
KSXGitHub 9993b85
refactor: use `pipe`
KSXGitHub 310a277
docs: explain the reason for allocation
KSXGitHub aa790f3
docs: add some notes
KSXGitHub 30e3bde
docs: add some notes
KSXGitHub c944704
refactor: stop qualifying
KSXGitHub 003edbc
refactor: replace lambda with function name
KSXGitHub f04425b
refactor: remove unnecessary `unwrap_or_default`
KSXGitHub c92eaab
docs: question the AI
KSXGitHub 7e57748
refactor: clearer code path
KSXGitHub 87b1128
Refactor: route all side-effect calls through Api trait
claude 703f513
refactor: merge imports
KSXGitHub 816cc45
fix: clippy
KSXGitHub 295ce7e
docs: add some notes
KSXGitHub 76244cd
Refactor: split Api into DiskApi + FsApi for proper mocking
claude bee507d
refactor: remove `#[inline]`
KSXGitHub 1819a76
Refactor: decouple DiskApi and FsApi type parameters
claude f355f80
Fix mount-point filter ordering and Xen driver name matching
claude 061b6a4
Add missing test for xen_blkfront (underscore) driver name
claude 93a6f5d
Document known limitation: LVM/device-mapper bypass
claude a4cdd37
docs: the decision
KSXGitHub 24bb337
docs: don't use heading
KSXGitHub 34003de
chore(git): merge from master
KSXGitHub f05d4c3
docs: remove section delimiters
KSXGitHub edcbdad
docs: correctly use inline code blocks
KSXGitHub 3dd1f4e
docs: correctly use inline code blocks
claude dd3d18e
docs: correctly use inline code blocks
claude 7e63e9c
test(hdd): add vmware/hyper-v tests and fix review issues
claude 97c3736
docs(hdd): clarify smoke test intent in doc comment
claude 63e9678
docs: fix import order convention to match `cargo fmt`
claude c1fd4eb
docs: sync import order convention across all AI instruction files
claude 2934ba7
docs: remove import order rules enforced by `cargo fmt`
claude bf15415
chore(git): merge from master
KSXGitHub 59e9ac5
test: split linux-only tests from shared tests
KSXGitHub 5d1275c
docs: grammar
KSXGitHub 2735dc0
docs(test): document host-dependent smoke tests
claude 24edab3
refactor(hdd): use descriptive generic parameter names
claude 7b12666
refactor: fix broken fmt
KSXGitHub 921e6cc
docs: prefer bold over heading
KSXGitHub 64820e7
refactor: re-add `inline`
KSXGitHub 55577d0
refactor(hdd): use rsplit_once for partition suffix stripping
claude daced3f
refactor: replace lambda with function names
KSXGitHub 8917d58
refactor(hdd): remove DiskApi associated type, use &self methods
claude c237336
test(hdd): clarify mapper test and add dm-device limitation test
claude c7c1ba5
perf: optimize
KSXGitHub 335d1b3
fix(hdd): address PR review feedback
claude e33b6d7
refactor(hdd): extract smoke tests into peer module, rename correct_h…
claude f2d2877
refactor(hdd): rename RealApi to RealFs
claude fccc193
docs(hdd): fix stale correct_hdd_detection reference in test.rs
claude cd58f96
refactor(hdd): extract identity_fs_api macro to reduce test boilerplate
claude c95ff37
refactor(hdd): flatten single-test modules into plain test functions
claude b320525
refactor(hdd): replace identity_fs_api + boilerplate with reclassify_…
claude 2c56de2
refactor(hdd): rename reclassify_test_case to identity_reclassify_tes…
claude fe00b4e
refactor(hdd): replace named unused bindings with plain `_`
claude d7d3d35
fix(hdd): improve dm-0 LVM test mock and clarify comment
claude 85a8df7
style: remove an unclean trailing comma
KSXGitHub 9836c95
fix(hdd): rename macro parameter mount_point to disk_name
claude 34c6538
refactor: replace lambda with function name
KSXGitHub b6042c5
fix(hdd): add `virtio-blk` driver variant and document recursion safety
claude 195d3a2
refactor(hdd): extract `VIRTUAL_DISK_KIND` constant for magic value
claude 690975c
refactor(hdd): rename `is_in_hdd` to `is_hdd` and fix macro capture v…
claude d5f6879
docs(hdd): fix stale `is_in_hdd` reference in doc comment
claude 48b844b
style(hdd): add missing space after `=` in macro invocations
claude 504996c
refactor: replace lambda with function name
KSXGitHub f8bf605
refactor(hdd): rename single-letter closure variables to descriptive …
claude 1add205
refactor(hdd): rename driver tuple bindings to drv_path/drv_name
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PathBuf>; | ||
| #[cfg(target_os = "linux")] | ||
| fn path_exists(path: &Path) -> bool; | ||
| #[cfg(target_os = "linux")] | ||
| fn read_link(path: &Path) -> io::Result<PathBuf>; | ||
| } | ||
|
|
||
| /// 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<PathBuf> { | ||
| 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<PathBuf> { | ||
| 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<Fs: FsApi>(kind: DiskKind, disk_name: &str) -> DiskKind { | ||
| if kind != DiskKind::HDD { | ||
| return kind; | ||
| } | ||
| if let Some(block_dev) = extract_block_device_name::<Fs>(disk_name) { | ||
| if is_virtual_block_device::<Fs>(&block_dev) { | ||
| return VIRTUAL_DISK_KIND; | ||
| } | ||
| } | ||
| DiskKind::HDD | ||
| } | ||
|
KSXGitHub marked this conversation as resolved.
|
||
|
|
||
| /// 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<Fs: FsApi>(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<Fs: FsApi>(device_path: &str) -> Option<Cow<'_, str>> { | ||
| 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::<Fs>) | ||
| .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; | ||
|
KSXGitHub marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // 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::<Fs>) | ||
| .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/<block_dev>` exists, `None` otherwise. | ||
| #[cfg(target_os = "linux")] | ||
| fn validate_block_device<Fs: FsApi>(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/<dev>/device/driver` and checks | ||
| /// if it matches known virtual block device drivers. | ||
| #[cfg(target_os = "linux")] | ||
| fn is_virtual_block_device<Fs: FsApi>(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" | ||
|
KSXGitHub marked this conversation as resolved.
|
||
| ) | ||
| ) | ||
|
KSXGitHub marked this conversation as resolved.
KSXGitHub marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// Check if any path is in any HDD. | ||
| pub fn any_path_is_in_hdd<Api: self::Api>(paths: &[PathBuf], disks: &[Api::Disk]) -> bool { | ||
| pub fn any_path_is_in_hdd<Disk: DiskApi, Fs: FsApi>(paths: &[PathBuf], disks: &[Disk]) -> bool { | ||
| paths | ||
| .iter() | ||
| .filter_map(|file| Api::canonicalize(file).ok()) | ||
| .any(|path| path_is_in_hdd::<Api>(&path, disks)) | ||
| .filter_map(|file| Fs::canonicalize(file).ok()) | ||
| .any(|path| path_is_in_hdd::<Disk, Fs>(&path, disks)) | ||
| } | ||
|
KSXGitHub marked this conversation as resolved.
KSXGitHub marked this conversation as resolved.
|
||
|
|
||
| /// Check if path is in any HDD. | ||
| fn path_is_in_hdd<Api: self::Api>(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<Disk: DiskApi, Fs: FsApi>(path: &Path, disks: &[Disk]) -> bool { | ||
| let mount_point = find_mount_point(path, disks.iter().map(Disk::get_mount_point)); | ||
|
KSXGitHub marked this conversation as resolved.
|
||
| 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::<Fs>) | ||
| } | ||
|
|
||
| /// Check if a disk is an HDD after applying platform-specific corrections. | ||
| fn is_hdd<Fs: FsApi>(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::<Fs>(kind, name) == DiskKind::HDD, | ||
| None => kind == DiskKind::HDD, // can't parse name, keep original classification | ||
| } | ||
|
KSXGitHub marked this conversation as resolved.
|
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod test; | ||
|
|
||
| #[cfg(target_os = "linux")] | ||
| #[cfg(test)] | ||
| mod test_linux; | ||
|
|
||
| #[cfg(target_os = "linux")] | ||
| #[cfg(test)] | ||
| mod test_linux_smoke; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.