|
1 | 1 | use super::mount_point::find_mount_point; |
2 | 2 | use std::{ |
| 3 | + ffi::OsStr, |
3 | 4 | fs::canonicalize, |
4 | 5 | io, |
5 | 6 | path::{Path, PathBuf}, |
6 | 7 | }; |
7 | 8 | use sysinfo::{Disk, DiskKind}; |
8 | 9 |
|
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 { |
14 | 30 | 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>; |
15 | 35 | } |
16 | 36 |
|
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 | + } |
21 | 45 |
|
22 | 46 | #[inline] |
23 | | - fn get_disk_kind(disk: &Self::Disk) -> DiskKind { |
24 | | - disk.kind() |
| 47 | + fn get_disk_name(&self) -> &OsStr { |
| 48 | + self.name() |
25 | 49 | } |
26 | 50 |
|
27 | 51 | #[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() |
30 | 54 | } |
| 55 | +} |
31 | 56 |
|
| 57 | +impl FsApi for RealFs { |
32 | 58 | #[inline] |
33 | 59 | fn canonicalize(path: &Path) -> io::Result<PathBuf> { |
34 | 60 | canonicalize(path) |
35 | 61 | } |
| 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 | + ) |
36 | 243 | } |
37 | 244 |
|
38 | 245 | /// 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 { |
40 | 247 | paths |
41 | 248 | .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)) |
44 | 251 | } |
45 | 252 |
|
46 | 253 | /// 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 { |
49 | 260 | return false; |
50 | 261 | }; |
51 | 262 | disks |
52 | 263 | .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 | + } |
55 | 276 | } |
56 | 277 |
|
57 | 278 | #[cfg(test)] |
58 | 279 | 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