Skip to content

Commit 0f0f243

Browse files
KSXGitHubclaudeCopilot
authored
fix: false hdd detection on virtual devices (#352)
Fixes virtual block devices (VirtIO, Xen, Hyper-V, VMware) on Linux being misclassified as HDDs. The kernel's `rotational` sysfs flag defaults to `1` for virtual devices, causing `sysinfo` to report them as HDDs. This PR checks the block device's driver via `/sys/block/<dev>/device/driver` and reclassifies known virtual drivers as `DiskKind::Unknown(-1)`. https://claude.ai/code/session_01MHKaZm6DcA9kjBPnW3KN5K --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 555982a commit 0f0f243

5 files changed

Lines changed: 603 additions & 48 deletions

File tree

src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use hdd::any_path_is_in_hdd;
1818
use pipe_trait::Pipe;
1919
use std::{io::stdin, time::Duration};
2020
use sub::JsonOutputParam;
21-
use sysinfo::Disks;
21+
use sysinfo::{Disk, Disks};
2222

2323
#[cfg(unix)]
2424
use crate::get_size::{GetBlockCount, GetBlockSize};
@@ -136,7 +136,7 @@ impl App {
136136
let threads = match self.args.threads {
137137
Threads::Auto => {
138138
let disks = Disks::new_with_refreshed_list();
139-
if any_path_is_in_hdd::<hdd::RealApi>(&self.args.files, &disks) {
139+
if any_path_is_in_hdd::<Disk, hdd::RealFs>(&self.args.files, &disks) {
140140
eprintln!("warning: HDD detected, the thread limit will be set to 1");
141141
eprintln!("hint: You can pass --threads=max disable this behavior");
142142
Some(1)

src/app/hdd.rs

Lines changed: 249 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,287 @@
11
use super::mount_point::find_mount_point;
22
use std::{
3+
ffi::OsStr,
34
fs::canonicalize,
45
io,
56
path::{Path, PathBuf},
67
};
78
use sysinfo::{Disk, DiskKind};
89

9-
/// Mockable APIs to interact with the system.
10-
pub trait Api {
11-
type Disk;
12-
fn get_disk_kind(disk: &Self::Disk) -> DiskKind;
13-
fn get_mount_point(disk: &Self::Disk) -> &Path;
10+
#[cfg(target_os = "linux")]
11+
use pipe_trait::Pipe;
12+
#[cfg(target_os = "linux")]
13+
use std::borrow::Cow;
14+
15+
/// Mockable interface to [`sysinfo::Disk`] methods.
16+
///
17+
/// Each method delegates to a corresponding [`sysinfo::Disk`] method,
18+
/// enabling dependency injection for testing.
19+
pub trait DiskApi {
20+
fn get_disk_kind(&self) -> DiskKind;
21+
fn get_disk_name(&self) -> &OsStr;
22+
fn get_mount_point(&self) -> &Path;
23+
}
24+
25+
/// Mockable interface to filesystem operations.
26+
///
27+
/// Abstracts system calls like [`canonicalize`], [`Path::exists`], and
28+
/// [`std::fs::read_link`] so tests can substitute an in-memory fake.
29+
pub trait FsApi {
1430
fn canonicalize(path: &Path) -> io::Result<PathBuf>;
31+
#[cfg(target_os = "linux")]
32+
fn path_exists(path: &Path) -> bool;
33+
#[cfg(target_os = "linux")]
34+
fn read_link(path: &Path) -> io::Result<PathBuf>;
1535
}
1636

17-
/// Implementation of [`Api`] that interacts with the real system.
18-
pub struct RealApi;
19-
impl Api for RealApi {
20-
type Disk = Disk;
37+
/// Implementation of [`FsApi`] that interacts with the real system.
38+
pub struct RealFs;
39+
40+
impl DiskApi for Disk {
41+
#[inline]
42+
fn get_disk_kind(&self) -> DiskKind {
43+
self.kind()
44+
}
2145

2246
#[inline]
23-
fn get_disk_kind(disk: &Self::Disk) -> DiskKind {
24-
disk.kind()
47+
fn get_disk_name(&self) -> &OsStr {
48+
self.name()
2549
}
2650

2751
#[inline]
28-
fn get_mount_point(disk: &Self::Disk) -> &Path {
29-
disk.mount_point()
52+
fn get_mount_point(&self) -> &Path {
53+
self.mount_point()
3054
}
55+
}
3156

57+
impl FsApi for RealFs {
3258
#[inline]
3359
fn canonicalize(path: &Path) -> io::Result<PathBuf> {
3460
canonicalize(path)
3561
}
62+
63+
#[cfg(target_os = "linux")]
64+
#[inline]
65+
fn path_exists(path: &Path) -> bool {
66+
path.exists()
67+
}
68+
69+
#[cfg(target_os = "linux")]
70+
#[inline]
71+
fn read_link(path: &Path) -> io::Result<PathBuf> {
72+
std::fs::read_link(path)
73+
}
74+
}
75+
76+
/// Sentinel value used to reclassify virtual block devices that were
77+
/// falsely reported as `DiskKind::HDD` by `sysinfo`.
78+
#[cfg(target_os = "linux")]
79+
const VIRTUAL_DISK_KIND: DiskKind = DiskKind::Unknown(-1);
80+
81+
/// On Linux, the `rotational` sysfs flag defaults to `1` for virtual block devices
82+
/// (e.g. VirtIO, Xen) because the kernel cannot determine the backing storage type.
83+
/// This causes `sysinfo` to falsely report them as HDDs.
84+
///
85+
/// This function checks the block device's driver via sysfs and reclassifies
86+
/// known virtual drivers as `Unknown` instead of `HDD`.
87+
#[cfg(target_os = "linux")]
88+
fn reclassify_virtual_hdd<Fs: FsApi>(kind: DiskKind, disk_name: &str) -> DiskKind {
89+
if kind != DiskKind::HDD {
90+
return kind;
91+
}
92+
if let Some(block_dev) = extract_block_device_name::<Fs>(disk_name) {
93+
if is_virtual_block_device::<Fs>(&block_dev) {
94+
return VIRTUAL_DISK_KIND;
95+
}
96+
}
97+
DiskKind::HDD
98+
}
99+
100+
/// On non-Linux platforms (macOS, FreeBSD), `sysinfo` currently reports
101+
/// `DiskKind::Unknown` because there is no reliable OS API for determining
102+
/// rotational vs solid-state. This means the `kind == DiskKind::HDD` check
103+
/// in [`is_hdd`] never matches, so this function is effectively a no-op.
104+
///
105+
/// If `sysinfo` ever gains accurate disk-kind detection on these platforms,
106+
/// this function should be revisited — virtual disks on macOS (e.g. virtio
107+
/// in QEMU) or FreeBSD (e.g. virtio-blk) could face the same misclassification.
108+
#[cfg(not(target_os = "linux"))]
109+
fn reclassify_virtual_hdd<Fs: FsApi>(kind: DiskKind, _: &str) -> DiskKind {
110+
kind
111+
}
112+
113+
/// Resolve a device path through symlinks and then parse the block device name.
114+
///
115+
/// Handles `/dev/mapper/xxx` symlinks and `/dev/root` by following them via
116+
/// `canonicalize`, then delegates to [`parse_block_device_name`] for parsing
117+
/// and [`validate_block_device`] to verify the device exists in sysfs.
118+
///
119+
/// **Known limitation:** LVM / device-mapper
120+
///
121+
/// On real LVM setups, `/dev/mapper/vg0-lv0` canonicalizes to `/dev/dm-0`
122+
/// (a device-mapper device), not to the underlying physical device like
123+
/// `/dev/vda1`. The `dm-0` device has no `/sys/block/dm-0/device/driver`
124+
/// symlink, so [`is_virtual_block_device`] cannot determine its driver and
125+
/// returns `false`. This means virtual-disk correction silently does nothing
126+
/// for LVM volumes, even when the backing device is VirtIO.
127+
///
128+
/// Fixing this would require walking `/sys/block/dm-*/slaves/` to discover
129+
/// the real backing device(s). That introduces three problems:
130+
///
131+
/// 1. [`FsApi`] would need a `read_dir` method, expanding the trait and
132+
/// every mock implementation.
133+
/// 2. The slave chain can be recursive (`dm` on `dm`, e.g. LUKS on LVM),
134+
/// requiring unbounded traversal.
135+
/// 3. A `dm` device can have multiple slaves (stripes, mirrors). A policy
136+
/// decision is needed: is the device virtual only when *all* slaves are
137+
/// virtual, or when *any* is? Neither answer is obviously correct.
138+
///
139+
/// Given the complexity and the relative importance of the auto HDD detection feature,
140+
/// we have chosen to ignore it.
141+
#[cfg(target_os = "linux")]
142+
fn extract_block_device_name<Fs: FsApi>(device_path: &str) -> Option<Cow<'_, str>> {
143+
if !device_path.starts_with("/dev/mapper/") && !device_path.starts_with("/dev/root") {
144+
let block_dev = parse_block_device_name(device_path)?;
145+
return block_dev
146+
.pipe(validate_block_device::<Fs>)
147+
.map(Cow::Borrowed);
148+
}
149+
150+
let canon_device_path = Fs::canonicalize(Path::new(device_path)).ok()?;
151+
let canon_device_path = canon_device_path.to_str()?;
152+
if canon_device_path == device_path {
153+
return None;
154+
}
155+
156+
// Safe to recurse: `canonicalize` resolves all symlinks, so the
157+
// canonical path will not start with `/dev/mapper/` or `/dev/root`.
158+
canon_device_path
159+
.pipe(extract_block_device_name::<Fs>)
160+
.map(Cow::into_owned) // must copy-allocate because `canon_device_path` is locally owned
161+
.map(Cow::Owned)
162+
}
163+
164+
/// Parse the base block device name from a device path (pure string parsing).
165+
///
166+
/// This function performs no I/O; it only strips the `/dev/` prefix and
167+
/// partition suffixes to recover the base block device name.
168+
///
169+
/// **Examples:**
170+
/// - `/dev/vda1` → `Some("vda")`
171+
/// - `/dev/sda1` → `Some("sda")`
172+
/// - `/dev/xvda1` → `Some("xvda")`
173+
/// - `/dev/nvme0n1p1` → `Some("nvme0n1")`
174+
/// - `/dev/mmcblk0p1` → `Some("mmcblk0")`
175+
/// - `vda1` (no `/dev/` prefix) → `None`
176+
#[cfg(target_os = "linux")]
177+
fn parse_block_device_name(device_path: &str) -> Option<&str> {
178+
let name = device_path.strip_prefix("/dev/")?;
179+
180+
let block_dev = if name.starts_with("sd") || name.starts_with("vd") || name.starts_with("xvd") {
181+
// Strip trailing partition digits: "sda1" → "sda", "vda1" → "vda"
182+
name.trim_end_matches(|c: char| c.is_ascii_digit())
183+
} else if name.starts_with("nvme") || name.starts_with("mmcblk") {
184+
// Strip partition suffix: "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0"
185+
match name.rsplit_once('p') {
186+
Some((base, suffix))
187+
if !base.is_empty()
188+
&& !suffix.is_empty()
189+
&& suffix.bytes().all(|b| b.is_ascii_digit()) =>
190+
{
191+
base
192+
}
193+
_ => name,
194+
}
195+
} else {
196+
name
197+
};
198+
199+
Some(block_dev)
200+
}
201+
202+
/// Verify that a block device exists in sysfs.
203+
///
204+
/// Returns `Some(block_dev)` if `/sys/block/<block_dev>` exists, `None` otherwise.
205+
#[cfg(target_os = "linux")]
206+
fn validate_block_device<Fs: FsApi>(block_dev: &str) -> Option<&str> {
207+
"/sys/block"
208+
.pipe(Path::new)
209+
.join(block_dev)
210+
.pipe_as_ref(Fs::path_exists)
211+
.then_some(block_dev)
212+
}
213+
214+
/// Check if a block device is backed by a virtual driver.
215+
///
216+
/// Reads the driver symlink at `/sys/block/<dev>/device/driver` and checks
217+
/// if it matches known virtual block device drivers.
218+
#[cfg(target_os = "linux")]
219+
fn is_virtual_block_device<Fs: FsApi>(block_dev: &str) -> bool {
220+
let driver_path = "/sys/block"
221+
.pipe(Path::new)
222+
.join(block_dev)
223+
.join("device/driver");
224+
225+
let Ok(target) = Fs::read_link(&driver_path) else {
226+
return false;
227+
};
228+
229+
let driver_name = target.file_name().and_then(OsStr::to_str);
230+
231+
matches!(
232+
driver_name,
233+
Some(
234+
"virtio_blk"
235+
| "virtio-blk"
236+
| "xen_blkfront"
237+
| "xen-blkfront"
238+
| "vbd"
239+
| "vmw_pvscsi"
240+
| "hv_storvsc"
241+
)
242+
)
36243
}
37244

38245
/// Check if any path is in any HDD.
39-
pub fn any_path_is_in_hdd<Api: self::Api>(paths: &[PathBuf], disks: &[Api::Disk]) -> bool {
246+
pub fn any_path_is_in_hdd<Disk: DiskApi, Fs: FsApi>(paths: &[PathBuf], disks: &[Disk]) -> bool {
40247
paths
41248
.iter()
42-
.filter_map(|file| Api::canonicalize(file).ok())
43-
.any(|path| path_is_in_hdd::<Api>(&path, disks))
249+
.filter_map(|file| Fs::canonicalize(file).ok())
250+
.any(|path| path_is_in_hdd::<Disk, Fs>(&path, disks))
44251
}
45252

46253
/// Check if path is in any HDD.
47-
fn path_is_in_hdd<Api: self::Api>(path: &Path, disks: &[Api::Disk]) -> bool {
48-
let Some(mount_point) = find_mount_point(path, disks.iter().map(Api::get_mount_point)) else {
254+
///
255+
/// Applies [`reclassify_virtual_hdd`] to each disk's reported kind to work
256+
/// around virtual block devices being falsely reported as HDDs on Linux.
257+
fn path_is_in_hdd<Disk: DiskApi, Fs: FsApi>(path: &Path, disks: &[Disk]) -> bool {
258+
let mount_point = find_mount_point(path, disks.iter().map(Disk::get_mount_point));
259+
let Some(mount_point) = mount_point else {
49260
return false;
50261
};
51262
disks
52263
.iter()
53-
.filter(|disk| Api::get_disk_kind(disk) == DiskKind::HDD)
54-
.any(|disk| Api::get_mount_point(disk) == mount_point)
264+
.filter(|disk| disk.get_mount_point() == mount_point)
265+
.any(is_hdd::<Fs>)
266+
}
267+
268+
/// Check if a disk is an HDD after applying platform-specific corrections.
269+
fn is_hdd<Fs: FsApi>(disk: &impl DiskApi) -> bool {
270+
let kind = disk.get_disk_kind();
271+
let name = disk.get_disk_name().to_str();
272+
match name {
273+
Some(name) => reclassify_virtual_hdd::<Fs>(kind, name) == DiskKind::HDD,
274+
None => kind == DiskKind::HDD, // can't parse name, keep original classification
275+
}
55276
}
56277

57278
#[cfg(test)]
58279
mod test;
280+
281+
#[cfg(target_os = "linux")]
282+
#[cfg(test)]
283+
mod test_linux;
284+
285+
#[cfg(target_os = "linux")]
286+
#[cfg(test)]
287+
mod test_linux_smoke;

0 commit comments

Comments
 (0)