Skip to content
Merged
Show file tree
Hide file tree
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 Mar 11, 2026
61aded0
Fix formatting and clippy lint in HDD detection code
claude Mar 11, 2026
9813170
Address Copilot review feedback on HDD detection
claude Mar 11, 2026
e993186
Document why virtual disk detection is Linux-only
claude Mar 13, 2026
5a93978
Refactor: move HDD correction out of Api trait into callsite
claude Mar 13, 2026
2ad8c58
docs: remove unneeded assertion message
KSXGitHub Mar 15, 2026
1e8fe63
test: use `pretty_assertions`
KSXGitHub Mar 15, 2026
9f1a836
perf: reduce allocations
KSXGitHub Mar 15, 2026
8bbe578
refactor: better naming convention
KSXGitHub Mar 15, 2026
d0ccc83
refactor: remove unneeded import and qualification
KSXGitHub Mar 15, 2026
299fdcf
refactor: use names that make more sense
KSXGitHub Mar 15, 2026
827342f
refactor: rearrange
KSXGitHub Mar 15, 2026
d19d21a
docs: remove commented-out code
KSXGitHub Mar 15, 2026
9429d33
refactor: rearrange
KSXGitHub Mar 15, 2026
30899e9
docs: add some notes
KSXGitHub Mar 15, 2026
06b26e4
refactor: one-liner
KSXGitHub Mar 15, 2026
9993b85
refactor: use `pipe`
KSXGitHub Mar 15, 2026
310a277
docs: explain the reason for allocation
KSXGitHub Mar 15, 2026
aa790f3
docs: add some notes
KSXGitHub Mar 15, 2026
30e3bde
docs: add some notes
KSXGitHub Mar 15, 2026
c944704
refactor: stop qualifying
KSXGitHub Mar 15, 2026
003edbc
refactor: replace lambda with function name
KSXGitHub Mar 15, 2026
f04425b
refactor: remove unnecessary `unwrap_or_default`
KSXGitHub Mar 15, 2026
c92eaab
docs: question the AI
KSXGitHub Mar 15, 2026
7e57748
refactor: clearer code path
KSXGitHub Mar 15, 2026
87b1128
Refactor: route all side-effect calls through Api trait
claude Mar 15, 2026
703f513
refactor: merge imports
KSXGitHub Mar 15, 2026
816cc45
fix: clippy
KSXGitHub Mar 15, 2026
295ce7e
docs: add some notes
KSXGitHub Mar 15, 2026
76244cd
Refactor: split Api into DiskApi + FsApi for proper mocking
claude Mar 15, 2026
bee507d
refactor: remove `#[inline]`
KSXGitHub Mar 15, 2026
1819a76
Refactor: decouple DiskApi and FsApi type parameters
claude Mar 15, 2026
f355f80
Fix mount-point filter ordering and Xen driver name matching
claude Mar 15, 2026
061b6a4
Add missing test for xen_blkfront (underscore) driver name
claude Mar 15, 2026
93a6f5d
Document known limitation: LVM/device-mapper bypass
claude Mar 15, 2026
a4cdd37
docs: the decision
KSXGitHub Mar 15, 2026
24bb337
docs: don't use heading
KSXGitHub Mar 15, 2026
34003de
chore(git): merge from master
KSXGitHub Mar 15, 2026
f05d4c3
docs: remove section delimiters
KSXGitHub Mar 15, 2026
edcbdad
docs: correctly use inline code blocks
KSXGitHub Mar 15, 2026
3dd1f4e
docs: correctly use inline code blocks
claude Mar 15, 2026
dd3d18e
docs: correctly use inline code blocks
claude Mar 15, 2026
7e63e9c
test(hdd): add vmware/hyper-v tests and fix review issues
claude Mar 15, 2026
97c3736
docs(hdd): clarify smoke test intent in doc comment
claude Mar 15, 2026
63e9678
docs: fix import order convention to match `cargo fmt`
claude Mar 15, 2026
c1fd4eb
docs: sync import order convention across all AI instruction files
claude Mar 15, 2026
2934ba7
docs: remove import order rules enforced by `cargo fmt`
claude Mar 15, 2026
bf15415
chore(git): merge from master
KSXGitHub Mar 15, 2026
59e9ac5
test: split linux-only tests from shared tests
KSXGitHub Mar 15, 2026
5d1275c
docs: grammar
KSXGitHub Mar 15, 2026
2735dc0
docs(test): document host-dependent smoke tests
claude Mar 15, 2026
24edab3
refactor(hdd): use descriptive generic parameter names
claude Mar 15, 2026
7b12666
refactor: fix broken fmt
KSXGitHub Mar 15, 2026
921e6cc
docs: prefer bold over heading
KSXGitHub Mar 15, 2026
64820e7
refactor: re-add `inline`
KSXGitHub Mar 15, 2026
55577d0
refactor(hdd): use rsplit_once for partition suffix stripping
claude Mar 15, 2026
daced3f
refactor: replace lambda with function names
KSXGitHub Mar 15, 2026
8917d58
refactor(hdd): remove DiskApi associated type, use &self methods
claude Mar 15, 2026
c237336
test(hdd): clarify mapper test and add dm-device limitation test
claude Mar 15, 2026
c7c1ba5
perf: optimize
KSXGitHub Mar 15, 2026
335d1b3
fix(hdd): address PR review feedback
claude Mar 15, 2026
e33b6d7
refactor(hdd): extract smoke tests into peer module, rename correct_h…
claude Mar 15, 2026
f2d2877
refactor(hdd): rename RealApi to RealFs
claude Mar 15, 2026
fccc193
docs(hdd): fix stale correct_hdd_detection reference in test.rs
claude Mar 15, 2026
cd58f96
refactor(hdd): extract identity_fs_api macro to reduce test boilerplate
claude Mar 15, 2026
c95ff37
refactor(hdd): flatten single-test modules into plain test functions
claude Mar 15, 2026
b320525
refactor(hdd): replace identity_fs_api + boilerplate with reclassify_…
claude Mar 15, 2026
2c56de2
refactor(hdd): rename reclassify_test_case to identity_reclassify_tes…
claude Mar 15, 2026
fe00b4e
refactor(hdd): replace named unused bindings with plain `_`
claude Mar 15, 2026
d7d3d35
fix(hdd): improve dm-0 LVM test mock and clarify comment
claude Mar 15, 2026
85a8df7
style: remove an unclean trailing comma
KSXGitHub Mar 15, 2026
9836c95
fix(hdd): rename macro parameter mount_point to disk_name
claude Mar 15, 2026
34c6538
refactor: replace lambda with function name
KSXGitHub Mar 15, 2026
b6042c5
fix(hdd): add `virtio-blk` driver variant and document recursion safety
claude Mar 15, 2026
195d3a2
refactor(hdd): extract `VIRTUAL_DISK_KIND` constant for magic value
claude Mar 15, 2026
690975c
refactor(hdd): rename `is_in_hdd` to `is_hdd` and fix macro capture v…
claude Mar 15, 2026
d5f6879
docs(hdd): fix stale `is_in_hdd` reference in doc comment
claude Mar 15, 2026
48b844b
style(hdd): add missing space after `=` in macro invocations
claude Mar 15, 2026
504996c
refactor: replace lambda with function name
KSXGitHub Mar 15, 2026
f8bf605
refactor(hdd): rename single-letter closure variables to descriptive …
claude Mar 15, 2026
1add205
refactor(hdd): rename driver tuple bindings to drv_path/drv_name
claude Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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::<hdd::RealApi>(&self.args.files, &disks) {
if any_path_is_in_hdd::<Disk, hdd::RealFs>(&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)
Expand Down
269 changes: 249 additions & 20 deletions src/app/hdd.rs
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};
Comment thread
KSXGitHub marked this conversation as resolved.

/// 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
}
Comment thread
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;
Comment thread
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"
Comment thread
KSXGitHub marked this conversation as resolved.
)
)
Comment thread
KSXGitHub marked this conversation as resolved.
Comment thread
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))
}
Comment thread
KSXGitHub marked this conversation as resolved.
Comment thread
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));
Comment thread
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
}
Comment thread
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;
Loading
Loading