Skip to main content

retch_sysinfo/
disk.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Physical disk detection (model, size, type) and logical disk space reporting.
5
6/// Returns formatted disk space strings for real mounted filesystems.
7///
8/// Skips pseudo-filesystems unconditionally. Skips `fuse.*` mounts unless
9/// `include_fuse` is true — FUSE mounts can block indefinitely on `statvfs`
10/// (e.g. cryfs/EncFS vaults), so they are only enabled in `--full` mode.
11///
12/// On Linux, reads /proc/mounts and calls statvfs ourselves so we can filter
13/// before the blocking call. On macOS, lists mounts with `getfsstat` and sizes them with
14/// `statfs` (see [`macos_volume_entry`]). Elsewhere, delegates to sysinfo::Disks.
15pub fn detect_logical_disks(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
16    #[cfg(target_os = "linux")]
17    {
18        detect_logical_linux(include_fuse)
19    }
20
21    #[cfg(target_os = "macos")]
22    {
23        let _ = include_fuse;
24        crate::macos_ffi::get_mounts()
25            .iter()
26            .filter_map(macos_volume_entry)
27            .collect()
28    }
29
30    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
31    {
32        let _ = include_fuse;
33        detect_logical_sysinfo()
34    }
35}
36
37/// Selects and shapes one macOS mount as `(mount point, total, available, fs type)`.
38///
39/// Keeps exactly the volumes `sysinfo::Disks` kept before v0.20.15: browsable and local, with
40/// a non-zero size. That is `/` and `/System/Volumes/Data` on a stock Mac, plus any mounted
41/// external volume; APFS system volumes, `devfs` and `autofs` are not browsable.
42///
43/// **Available space is `statfs`'s `f_bavail`, not sysinfo's figure** (user decision,
44/// v0.20.15). sysinfo reported CoreFoundation's "available for important usage", which counts
45/// purgeable space (Finder's number): 336.6 vs 316.2 GiB on the Mac this was written on. That
46/// query cost ~9 ms per volume and was all of `--short`'s gap to fastfetch, which reports
47/// `f_bavail` too, as `df` does.
48#[cfg(target_os = "macos")]
49pub fn macos_volume_entry(m: &crate::macos_ffi::MacMount) -> Option<(String, u64, u64, String)> {
50    if !m.browsable || !m.local || m.total_bytes == 0 {
51        return None;
52    }
53    Some((
54        m.mount_point.clone(),
55        m.total_bytes,
56        m.avail_bytes,
57        m.fs_type.clone(),
58    ))
59}
60
61/// Filesystem types that are virtual/pseudo and should never appear in disk output.
62#[cfg(target_os = "linux")]
63fn is_skip_fs(fs_type: &str, include_fuse: bool) -> bool {
64    const SKIP: &[&str] = &[
65        "sysfs",
66        "proc",
67        "devtmpfs",
68        "tmpfs",
69        "devpts",
70        "cgroup",
71        "cgroup2",
72        "pstore",
73        "bpf",
74        "tracefs",
75        "debugfs",
76        "securityfs",
77        "hugetlbfs",
78        "mqueue",
79        "fusectl",
80        "rpc_pipefs",
81        "configfs",
82        "autofs",
83        "efivarfs",
84        "binfmt_misc",
85        "squashfs",
86        "overlay",
87        "ramfs",
88        "rootfs",
89        "nsfs",
90        "pipefs",
91        "sockfs",
92        "anon_inodefs",
93        "cpuset",
94    ];
95    // fuse.* covers gvfsd-fuse, cryfs, gocryptfs, encfs, etc.
96    SKIP.contains(&fs_type) || (fs_type.starts_with("fuse.") && !include_fuse)
97}
98
99#[cfg(target_os = "linux")]
100fn detect_logical_linux(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
101    use std::collections::HashSet;
102    use std::ffi::CString;
103
104    let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
105    let mut results = Vec::new();
106    let mut seen_devs: HashSet<String> = HashSet::new();
107
108    for line in mounts.lines() {
109        let parts: Vec<&str> = line.splitn(4, ' ').collect();
110        if parts.len() < 3 {
111            continue;
112        }
113        let device = parts[0];
114        let mount_point = parts[1];
115        let fs_type = parts[2];
116
117        if is_skip_fs(fs_type, include_fuse) {
118            continue;
119        }
120
121        // Deduplicate bind mounts / multiple mounts of the same device.
122        if device.starts_with('/') && !seen_devs.insert(device.to_string()) {
123            continue;
124        }
125
126        let Ok(mp_c) = CString::new(mount_point) else {
127            continue;
128        };
129
130        let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
131        if unsafe { libc::statvfs(mp_c.as_ptr(), &mut stat) } != 0 {
132            continue;
133        }
134
135        let total = (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64);
136        let avail = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
137
138        if total == 0 {
139            continue;
140        }
141
142        results.push((mount_point.to_string(), total, avail, fs_type.to_string()));
143    }
144
145    results
146}
147
148#[cfg(not(any(target_os = "linux", target_os = "macos")))]
149fn detect_logical_sysinfo() -> Vec<(String, u64, u64, String)> {
150    use sysinfo::Disks;
151    Disks::new_with_refreshed_list()
152        .iter()
153        .filter(|d| d.total_space() > 0)
154        .map(|d| {
155            (
156                d.mount_point().to_string_lossy().to_string(),
157                d.total_space(),
158                d.available_space(),
159                d.file_system().to_string_lossy().to_string(),
160            )
161        })
162        .collect()
163}
164
165pub fn detect_physical_disks() -> Vec<String> {
166    #[cfg(target_os = "linux")]
167    return detect_linux();
168
169    #[cfg(target_os = "macos")]
170    return detect_macos();
171
172    #[cfg(target_os = "windows")]
173    return detect_windows();
174
175    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
176    return Vec::new();
177}
178
179/// True for block-device names that are virtual rather than physical media.
180///
181/// Shared with [`crate::io::sample_disk_io`] so the `phys-disk` and `disk-io` fields
182/// cannot drift into disagreeing about what counts as a disk: a device listed by one and
183/// not the other reads as a bug in whichever field the user looked at second.
184#[cfg(target_os = "linux")]
185pub(crate) fn is_virtual_block_name(name: &str) -> bool {
186    name.starts_with("loop")
187        || name.starts_with("ram")
188        || name.starts_with("zram")
189        || name.starts_with("dm-")
190        || name.starts_with("md")
191}
192
193#[cfg(target_os = "linux")]
194fn detect_linux() -> Vec<String> {
195    use std::fs;
196
197    let Ok(entries) = fs::read_dir("/sys/class/block") else {
198        return Vec::new();
199    };
200
201    let mut disks = Vec::new();
202
203    for entry in entries.flatten() {
204        let name = entry.file_name();
205        let name = name.to_string_lossy();
206
207        // Skip partitions, virtual, and loop devices
208        if is_virtual_block_name(&name) {
209            continue;
210        }
211
212        let dev_path = entry.path();
213
214        // Skip partitions (they have a "partition" file)
215        if dev_path.join("partition").exists() {
216            continue;
217        }
218
219        // Skip devices with no queue (not a real block device)
220        if !dev_path.join("queue").exists() {
221            continue;
222        }
223
224        let model = fs::read_to_string(dev_path.join("device/model"))
225            .map(|s| strip_embedded_size(s.trim()).to_string())
226            .unwrap_or_default();
227
228        // Size in 512-byte sectors
229        let size_bytes = fs::read_to_string(dev_path.join("size"))
230            .ok()
231            .and_then(|s| s.trim().parse::<u64>().ok())
232            .map(|sectors| sectors * 512);
233
234        let rotational = fs::read_to_string(dev_path.join("queue/rotational"))
235            .map(|s| s.trim() == "1")
236            .unwrap_or(false);
237
238        let is_nvme = name.starts_with("nvme");
239
240        let kind = if is_nvme {
241            "NVMe SSD"
242        } else if rotational {
243            "HDD"
244        } else {
245            "SSD"
246        };
247
248        let size_str = size_bytes.map(format_size).unwrap_or_default();
249
250        let label = if model.is_empty() {
251            format!("{} [{}]", size_str, kind)
252        } else {
253            format!("{} {} [{}]", model.trim(), size_str, kind)
254        };
255
256        let label = label.trim().to_string();
257        if !label.is_empty() {
258            disks.push(label);
259        }
260    }
261
262    disks.sort();
263    disks
264}
265
266/// Physical disks on macOS, read natively from IOKit (see
267/// [`crate::macos_ffi::get_physical_disks`]).
268///
269/// Until v0.20.14 this spawned `diskutil list -plist` and then one `diskutil info -plist` per
270/// whole disk, serially — ~1 s on an M-series Mac with four whole disks, and the whole of the
271/// default mode's critical path. The output is unchanged.
272#[cfg(target_os = "macos")]
273fn detect_macos() -> Vec<String> {
274    crate::macos_ffi::get_physical_disks()
275        .iter()
276        .filter_map(format_macos_disk)
277        .collect()
278}
279
280/// Formats one IOKit whole disk into its display label, or `None` when it is virtual.
281///
282/// Kept identical to the label the old `diskutil info -plist` parser produced, field for
283/// field: `MediaName` is the device's `Product Name`, `BusProtocol` its
284/// `Physical Interconnect`, `SolidState` its `Medium Type`. **Disk images are the one
285/// virtual disk the IOKit walk returns** (synthesized APFS containers have no block-storage
286/// driver at all); diskutil marks them `Virtual` and IOKit says `Virtual Interface`.
287#[cfg(target_os = "macos")]
288pub fn format_macos_disk(disk: &crate::macos_ffi::MacDiskRaw) -> Option<String> {
289    if disk.interconnect == "Virtual Interface" {
290        return None;
291    }
292
293    let protocol = disk.interconnect.to_lowercase();
294    let kind = if protocol.contains("pcie") || protocol.contains("nvme") {
295        "NVMe SSD"
296    } else if disk.solid_state {
297        "SSD"
298    } else {
299        "HDD"
300    };
301
302    let size_str = disk.size_bytes.map(format_size).unwrap_or_default();
303
304    let label = if disk.model.trim().is_empty() {
305        format!("{} [{}]", size_str, kind)
306    } else {
307        format!("{} {} [{}]", disk.model.trim(), size_str, kind)
308    };
309
310    Some(label.trim().to_string())
311}
312
313/// Strips a trailing size token (e.g. "1024GB", "512GB", "2TB") from a model string.
314/// Many NVMe vendors embed the capacity in the model name; we compute it separately.
315#[cfg(target_os = "linux")]
316fn strip_embedded_size(model: &str) -> &str {
317    let bytes = model.as_bytes();
318    // Walk backwards over digits, then a unit suffix (GB/TB/MB), then optional space
319    let mut i = bytes.len();
320    // Strip trailing whitespace
321    while i > 0 && bytes[i - 1] == b' ' {
322        i -= 1;
323    }
324    // Must end with "GB" or "TB" or "MB"
325    if i >= 2 {
326        let suffix = &bytes[i - 2..i];
327        if matches!(suffix, b"GB" | b"TB" | b"MB") {
328            i -= 2;
329            // Strip the digits before the unit
330            let digits_end = i;
331            while i > 0 && bytes[i - 1].is_ascii_digit() {
332                i -= 1;
333            }
334            if i < digits_end {
335                // Strip one optional space between model name and size token
336                if i > 0 && bytes[i - 1] == b' ' {
337                    i -= 1;
338                }
339                return model[..i].trim_end();
340            }
341        }
342    }
343    model
344}
345
346#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
347fn format_size(bytes: u64) -> String {
348    const TB: u64 = 1_000_000_000_000;
349    const GB: u64 = 1_000_000_000;
350    if bytes >= TB {
351        format!("{:.1} TB", bytes as f64 / TB as f64)
352    } else {
353        format!("{:.0} GB", bytes as f64 / GB as f64)
354    }
355}
356
357/// Enumerates physical disks via native Win32 storage IOCTLs.
358///
359/// Replaces the previous `Get-PhysicalDisk` PowerShell spawn (~1.7 s of interpreter
360/// startup) with direct `DeviceIoControl` queries against `\\.\PhysicalDriveN`. Each
361/// drive is opened with **no** access rights (`dwDesiredAccess = 0`) and only
362/// `FILE_ANY_ACCESS` query IOCTLs are used, so no elevation is required.
363#[cfg(target_os = "windows")]
364fn detect_windows() -> Vec<String> {
365    (0..MAX_PHYSICAL_DRIVES)
366        .filter_map(win_ffi::query_physical_drive)
367        .collect()
368}
369
370/// How many `\\.\PhysicalDriveN` indices to probe.
371///
372/// Physical drive numbers are contiguous from 0 in the common case, but a removed disk
373/// can leave a gap, so a fixed range is scanned and anything that will not open is
374/// skipped. A failed `CreateFileW` on a nonexistent device returns immediately, so this
375/// is still orders of magnitude cheaper than spawning PowerShell.
376///
377/// Shared with [`crate::io::sample_disk_io`] rather than duplicated: if the two scanned
378/// different ranges, `phys-disk` and `disk-io` would disagree about which disks exist on
379/// a machine with more than one of them — the same drift that sharing
380/// [`is_virtual_block_name`] prevents on Linux.
381#[cfg(target_os = "windows")]
382pub(crate) const MAX_PHYSICAL_DRIVES: u32 = 32;
383
384/// Classifies and formats a single physical disk into its display label, mirroring
385/// the columns the old `Get-PhysicalDisk` parser used (model, size, media type, bus).
386///
387/// `bus_type` is a `STORAGE_BUS_TYPE` value; `incurs_seek_penalty` is `None` when the
388/// seek-penalty IOCTL was unavailable (treated as SSD, matching the old "Unspecified"
389/// fallback).
390#[cfg(target_os = "windows")]
391fn format_disk_label(
392    model: &str,
393    size_bytes: Option<u64>,
394    bus_type: u32,
395    incurs_seek_penalty: Option<bool>,
396) -> String {
397    let kind = if bus_type == win_ffi::BUS_TYPE_NVME {
398        "NVMe SSD"
399    } else {
400        match incurs_seek_penalty {
401            Some(true) => "HDD",
402            // Non-rotational, or unknown (no NVMe bus, no seek-penalty info): treat as
403            // SSD — matches the prior "MediaType Unspecified → SSD" behavior.
404            Some(false) | None => "SSD",
405        }
406    };
407
408    let name = model.trim();
409    let size_str = size_bytes.map(format_size).unwrap_or_default();
410    let label = if name.is_empty() {
411        format!("{} [{}]", size_str, kind)
412    } else {
413        format!("{} {} [{}]", name, size_str, kind)
414    };
415    label.trim().to_string()
416}
417
418/// Builds the model/friendly-name string from a storage descriptor's vendor and
419/// product id fields.
420///
421/// The product id is the model string Windows surfaces as `Get-PhysicalDisk`'s
422/// `FriendlyName` (e.g. "Samsung SSD 980 Pro"). The vendor id is only prepended when
423/// it adds information — SATA drives report a generic "ATA" vendor that the friendly
424/// name never includes, and USB/NVMe often duplicate the vendor inside the product id.
425#[cfg(target_os = "windows")]
426fn combine_model(vendor: &str, product: &str) -> String {
427    let v = vendor.trim();
428    let p = product.trim();
429    if p.is_empty() {
430        return v.to_string();
431    }
432    if v.is_empty()
433        || v.eq_ignore_ascii_case("ATA")
434        || p.to_ascii_lowercase().contains(&v.to_ascii_lowercase())
435    {
436        p.to_string()
437    } else {
438        format!("{} {}", v, p)
439    }
440}
441
442/// Native Win32 storage IOCTL bindings and per-drive query helpers.
443///
444/// Uses hand-written `extern "system"` declarations to match the crate's existing
445/// Windows FFI style (see `win_reg.rs`) rather than pulling in a Win32 binding crate.
446#[cfg(target_os = "windows")]
447mod win_ffi {
448    use super::{combine_model, format_disk_label};
449    use std::ffi::{c_void, OsStr};
450    use std::mem::size_of;
451    use std::os::windows::ffi::OsStrExt;
452    use std::ptr;
453
454    #[allow(clippy::upper_case_acronyms)]
455    type HANDLE = *mut c_void;
456    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
457    const FILE_SHARE_READ: u32 = 0x0000_0001;
458    const FILE_SHARE_WRITE: u32 = 0x0000_0002;
459    const OPEN_EXISTING: u32 = 3;
460
461    // Both IOCTLs below are FILE_ANY_ACCESS, so a handle opened with zero desired
462    // access can issue them without administrator rights.
463    const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D_1400;
464    const IOCTL_DISK_GET_DRIVE_GEOMETRY_EX: u32 = 0x0007_00A0;
465
466    // STORAGE_PROPERTY_ID values.
467    const STORAGE_DEVICE_PROPERTY: u32 = 0;
468    const STORAGE_DEVICE_SEEK_PENALTY_PROPERTY: u32 = 7;
469    // STORAGE_QUERY_TYPE value.
470    const PROPERTY_STANDARD_QUERY: u32 = 0;
471
472    /// `STORAGE_BUS_TYPE::BusTypeNvme`.
473    pub const BUS_TYPE_NVME: u32 = 17;
474
475    #[repr(C)]
476    struct StoragePropertyQuery {
477        property_id: u32,
478        query_type: u32,
479        additional_parameters: [u8; 1],
480    }
481
482    #[repr(C)]
483    struct StorageDeviceDescriptor {
484        version: u32,
485        size: u32,
486        device_type: u8,
487        device_type_modifier: u8,
488        removable_media: u8,
489        command_queueing: u8,
490        vendor_id_offset: u32,
491        product_id_offset: u32,
492        product_revision_offset: u32,
493        serial_number_offset: u32,
494        bus_type: u32,
495        raw_properties_length: u32,
496        raw_device_properties: [u8; 1],
497    }
498
499    #[repr(C)]
500    struct DeviceSeekPenaltyDescriptor {
501        version: u32,
502        size: u32,
503        incurs_seek_penalty: u8,
504    }
505
506    #[repr(C)]
507    struct DiskGeometry {
508        cylinders: i64,
509        media_type: u32,
510        tracks_per_cylinder: u32,
511        sectors_per_track: u32,
512        bytes_per_sector: u32,
513    }
514
515    #[repr(C)]
516    struct DiskGeometryEx {
517        geometry: DiskGeometry,
518        disk_size: i64,
519        data: [u8; 1],
520    }
521
522    extern "system" {
523        fn CreateFileW(
524            lp_file_name: *const u16,
525            dw_desired_access: u32,
526            dw_share_mode: u32,
527            lp_security_attributes: *mut c_void,
528            dw_creation_disposition: u32,
529            dw_flags_and_attributes: u32,
530            h_template_file: HANDLE,
531        ) -> HANDLE;
532
533        fn DeviceIoControl(
534            h_device: HANDLE,
535            dw_io_control_code: u32,
536            lp_in_buffer: *const c_void,
537            n_in_buffer_size: u32,
538            lp_out_buffer: *mut c_void,
539            n_out_buffer_size: u32,
540            lp_bytes_returned: *mut u32,
541            lp_overlapped: *mut c_void,
542        ) -> i32;
543
544        fn CloseHandle(h_object: HANDLE) -> i32;
545    }
546
547    /// Opens `\\.\PhysicalDrive{index}` and returns its formatted label, or `None` if
548    /// the drive does not exist or its device descriptor cannot be read.
549    pub fn query_physical_drive(index: u32) -> Option<String> {
550        let path = format!(r"\\.\PhysicalDrive{index}");
551        let path_w: Vec<u16> = OsStr::new(&path).encode_wide().chain(Some(0)).collect();
552
553        // SAFETY: path_w is a valid null-terminated wide string; zero desired access is
554        // sufficient for the FILE_ANY_ACCESS query IOCTLs used below.
555        let handle = unsafe {
556            CreateFileW(
557                path_w.as_ptr(),
558                0,
559                FILE_SHARE_READ | FILE_SHARE_WRITE,
560                ptr::null_mut(),
561                OPEN_EXISTING,
562                0,
563                ptr::null_mut(),
564            )
565        };
566        if handle == INVALID_HANDLE_VALUE || handle.is_null() {
567            return None;
568        }
569
570        let descriptor = query_device_descriptor(handle);
571        let result = descriptor.map(|(bus_type, model)| {
572            let size = query_disk_size(handle);
573            let seek = query_seek_penalty(handle);
574            format_disk_label(&model, size, bus_type, seek)
575        });
576
577        // SAFETY: handle came from a successful CreateFileW and is closed exactly once.
578        unsafe {
579            CloseHandle(handle);
580        }
581        result
582    }
583
584    /// Reads a null-terminated ANSI string embedded in `buf` at `offset` bytes from the
585    /// start. An offset of 0 means the field is absent.
586    fn read_ansi_at(buf: &[u8], offset: usize) -> String {
587        if offset == 0 || offset >= buf.len() {
588            return String::new();
589        }
590        let bytes = &buf[offset..];
591        let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
592        String::from_utf8_lossy(&bytes[..end]).trim().to_string()
593    }
594
595    /// Queries `IOCTL_STORAGE_QUERY_PROPERTY` for the device descriptor, returning the
596    /// bus type and combined model string.
597    fn query_device_descriptor(handle: HANDLE) -> Option<(u32, String)> {
598        let query = StoragePropertyQuery {
599            property_id: STORAGE_DEVICE_PROPERTY,
600            query_type: PROPERTY_STANDARD_QUERY,
601            additional_parameters: [0; 1],
602        };
603        // The descriptor is followed inline by its vendor/product/serial strings; a
604        // fixed 1 KiB buffer comfortably holds the header plus those fields.
605        let mut buf = [0u8; 1024];
606        let mut returned: u32 = 0;
607        // SAFETY: query is a valid input buffer of the declared size; buf is writable
608        // and its length is passed as the output size.
609        let ok = unsafe {
610            DeviceIoControl(
611                handle,
612                IOCTL_STORAGE_QUERY_PROPERTY,
613                &query as *const _ as *const c_void,
614                size_of::<StoragePropertyQuery>() as u32,
615                buf.as_mut_ptr() as *mut c_void,
616                buf.len() as u32,
617                &mut returned,
618                ptr::null_mut(),
619            )
620        };
621        if ok == 0 || (returned as usize) < size_of::<StorageDeviceDescriptor>() {
622            return None;
623        }
624        // SAFETY: the IOCTL wrote at least a full StorageDeviceDescriptor into buf,
625        // which is correctly aligned (a [u8; 1024] array plus the descriptor's u32
626        // alignment is satisfied at offset 0).
627        let desc = unsafe { &*(buf.as_ptr() as *const StorageDeviceDescriptor) };
628        let bus_type = desc.bus_type;
629        let vendor = read_ansi_at(&buf, desc.vendor_id_offset as usize);
630        let product = read_ansi_at(&buf, desc.product_id_offset as usize);
631        Some((bus_type, combine_model(&vendor, &product)))
632    }
633
634    /// Queries `IOCTL_DISK_GET_DRIVE_GEOMETRY_EX` for the total disk size in bytes.
635    fn query_disk_size(handle: HANDLE) -> Option<u64> {
636        let mut geo = DiskGeometryEx {
637            geometry: DiskGeometry {
638                cylinders: 0,
639                media_type: 0,
640                tracks_per_cylinder: 0,
641                sectors_per_track: 0,
642                bytes_per_sector: 0,
643            },
644            disk_size: 0,
645            data: [0; 1],
646        };
647        let mut returned: u32 = 0;
648        // SAFETY: geo is a writable DiskGeometryEx passed with its own size.
649        let ok = unsafe {
650            DeviceIoControl(
651                handle,
652                IOCTL_DISK_GET_DRIVE_GEOMETRY_EX,
653                ptr::null(),
654                0,
655                &mut geo as *mut _ as *mut c_void,
656                size_of::<DiskGeometryEx>() as u32,
657                &mut returned,
658                ptr::null_mut(),
659            )
660        };
661        if ok == 0 || geo.disk_size <= 0 {
662            None
663        } else {
664            Some(geo.disk_size as u64)
665        }
666    }
667
668    /// Queries `IOCTL_STORAGE_QUERY_PROPERTY` seek-penalty info. `Some(true)` indicates
669    /// a rotational (HDD) device, `Some(false)` a solid-state device, `None` if the
670    /// property is unavailable.
671    fn query_seek_penalty(handle: HANDLE) -> Option<bool> {
672        let query = StoragePropertyQuery {
673            property_id: STORAGE_DEVICE_SEEK_PENALTY_PROPERTY,
674            query_type: PROPERTY_STANDARD_QUERY,
675            additional_parameters: [0; 1],
676        };
677        let mut desc = DeviceSeekPenaltyDescriptor {
678            version: 0,
679            size: 0,
680            incurs_seek_penalty: 0,
681        };
682        let mut returned: u32 = 0;
683        // SAFETY: query is a valid input buffer; desc is a writable output buffer.
684        let ok = unsafe {
685            DeviceIoControl(
686                handle,
687                IOCTL_STORAGE_QUERY_PROPERTY,
688                &query as *const _ as *const c_void,
689                size_of::<StoragePropertyQuery>() as u32,
690                &mut desc as *mut _ as *mut c_void,
691                size_of::<DeviceSeekPenaltyDescriptor>() as u32,
692                &mut returned,
693                ptr::null_mut(),
694            )
695        };
696        if ok == 0 || (returned as usize) < size_of::<DeviceSeekPenaltyDescriptor>() {
697            None
698        } else {
699            Some(desc.incurs_seek_penalty != 0)
700        }
701    }
702
703    #[cfg(test)]
704    mod layout {
705        use std::mem::{offset_of, size_of};
706
707        // The driver reads these `#[repr(C)]` buffers by fixed offset, so pin the layout —
708        // an accidental field reorder or padding change would silently corrupt reads.
709        #[test]
710        fn ffi_struct_layout() {
711            assert_eq!(size_of::<super::StoragePropertyQuery>(), 12);
712            assert_eq!(size_of::<super::StorageDeviceDescriptor>(), 40);
713            assert_eq!(
714                offset_of!(super::StorageDeviceDescriptor, vendor_id_offset),
715                12
716            );
717            assert_eq!(
718                offset_of!(super::StorageDeviceDescriptor, product_id_offset),
719                16
720            );
721            assert_eq!(offset_of!(super::StorageDeviceDescriptor, bus_type), 28);
722            assert_eq!(size_of::<super::DeviceSeekPenaltyDescriptor>(), 12);
723            assert_eq!(size_of::<super::DiskGeometryEx>(), 40);
724            assert_eq!(offset_of!(super::DiskGeometryEx, disk_size), 24);
725        }
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    #[cfg(any(target_os = "linux", target_os = "macos"))]
732    use super::format_size;
733    #[cfg(target_os = "linux")]
734    use super::{is_skip_fs, strip_embedded_size};
735
736    #[cfg(target_os = "linux")]
737    #[test]
738    fn test_is_skip_fs_pseudo() {
739        assert!(is_skip_fs("sysfs", false));
740        assert!(is_skip_fs("proc", false));
741        assert!(is_skip_fs("tmpfs", false));
742        assert!(is_skip_fs("fusectl", false)); // fusectl is always skipped
743        assert!(is_skip_fs("fusectl", true)); // even with include_fuse
744    }
745
746    #[cfg(target_os = "linux")]
747    #[test]
748    fn test_is_skip_fs_fuse_excluded_by_default() {
749        assert!(is_skip_fs("fuse.gvfsd-fuse", false));
750        assert!(is_skip_fs("fuse.sshfs", false));
751        assert!(is_skip_fs("fuse.cryfs", false));
752    }
753
754    #[cfg(target_os = "linux")]
755    #[test]
756    fn test_is_skip_fs_fuse_included_in_full() {
757        assert!(!is_skip_fs("fuse.gvfsd-fuse", true));
758        assert!(!is_skip_fs("fuse.sshfs", true));
759        assert!(!is_skip_fs("fuse.cryfs", true));
760    }
761
762    #[cfg(target_os = "linux")]
763    #[test]
764    fn test_is_skip_fs_real_fs() {
765        assert!(!is_skip_fs("ext4", false));
766        assert!(!is_skip_fs("btrfs", false));
767        assert!(!is_skip_fs("vfat", false));
768    }
769
770    #[cfg(target_os = "linux")]
771    #[test]
772    fn test_strip_embedded_size() {
773        assert_eq!(
774            strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
775            "BC901 NVMe SK hynix"
776        );
777        assert_eq!(
778            strip_embedded_size("Samsung SSD 970 EVO 500GB"),
779            "Samsung SSD 970 EVO"
780        );
781        assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
782        assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); // Crucial model — no unit suffix
783        assert_eq!(
784            strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
785            "SAMSUNG MZQL23T8HCLS"
786        ); // no unit
787        assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
788    }
789
790    #[cfg(any(target_os = "linux", target_os = "macos"))]
791    #[test]
792    fn test_format_size_gb() {
793        assert_eq!(format_size(512_110_190_592), "512 GB");
794    }
795
796    #[cfg(any(target_os = "linux", target_os = "macos"))]
797    #[test]
798    fn test_format_size_tb() {
799        assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
800    }
801
802    #[cfg(any(target_os = "linux", target_os = "macos"))]
803    #[test]
804    fn test_format_size_2tb() {
805        assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
806    }
807
808    /// A `MacDiskRaw` fixture; every value below was read from a real Mac's IORegistry.
809    #[cfg(target_os = "macos")]
810    fn mac_disk(
811        model: &str,
812        size: Option<u64>,
813        ssd: bool,
814        bus: &str,
815    ) -> crate::macos_ffi::MacDiskRaw {
816        crate::macos_ffi::MacDiskRaw {
817            bsd_name: "disk0".to_string(),
818            model: model.to_string(),
819            size_bytes: size,
820            solid_state: ssd,
821            interconnect: bus.to_string(),
822        }
823    }
824
825    #[cfg(target_os = "macos")]
826    #[test]
827    fn test_format_macos_disk_apple_silicon() {
828        // chani (M-series), disk0: IOEmbeddedNVMeBlockDevice, `Physical Interconnect` =
829        // "Apple Fabric", `Medium Type` = "Solid State". Same label the diskutil parser gave.
830        let d = mac_disk(
831            "APPLE SSD AP1024Z",
832            Some(1_000_555_581_440),
833            true,
834            "Apple Fabric",
835        );
836        assert_eq!(
837            super::format_macos_disk(&d),
838            Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string())
839        );
840    }
841
842    #[cfg(target_os = "macos")]
843    #[test]
844    fn test_format_macos_disk_nvme() {
845        let d = mac_disk("Samsung SSD 990 Pro", Some(2_000_398_934_016), true, "PCIe");
846        assert_eq!(
847            super::format_macos_disk(&d),
848            Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
849        );
850    }
851
852    #[cfg(target_os = "macos")]
853    #[test]
854    fn test_format_macos_disk_image_skipped() {
855        // A mounted .dmg as IOKit reports it (IODiskImageBlockStorageDeviceOutKernel):
856        // diskutil calls it `VirtualOrPhysical = Virtual`, so it must not be listed.
857        let d = mac_disk("Disk Image", Some(10_485_760), false, "Virtual Interface");
858        assert_eq!(super::format_macos_disk(&d), None);
859    }
860
861    #[cfg(target_os = "macos")]
862    #[test]
863    fn test_format_macos_disk_no_medium_type_is_hdd() {
864        // USB enclosures often publish no `Medium Type`; diskutil then says SolidState=false.
865        let d = mac_disk("", Some(500_107_862_016), false, "USB");
866        assert_eq!(
867            super::format_macos_disk(&d),
868            Some("500 GB [HDD]".to_string())
869        );
870    }
871
872    /// A `MacMount` fixture. Flags are as CoreFoundation reported them on an M-series Mac.
873    #[cfg(target_os = "macos")]
874    fn mac_mount(
875        mp: &str,
876        fs: &str,
877        total: u64,
878        browsable: bool,
879        local: bool,
880    ) -> crate::macos_ffi::MacMount {
881        crate::macos_ffi::MacMount {
882            mount_point: mp.to_string(),
883            fs_type: fs.to_string(),
884            total_bytes: total,
885            avail_bytes: total / 3,
886            browsable,
887            local,
888        }
889    }
890
891    #[cfg(target_os = "macos")]
892    #[test]
893    fn test_macos_volume_entry_keeps_what_sysinfo_kept() {
894        // The 13 mounts of chani (macOS 27), with CoreFoundation's flags. sysinfo kept
895        // exactly `/` and `/System/Volumes/Data`; note Data is browsable to CoreFoundation
896        // although its statfs flags carry MNT_DONTBROWSE, which is why the flag is read
897        // from CoreFoundation and not from f_flags.
898        let mounts = [
899            mac_mount("/", "apfs", 994_662_584_320, true, true),
900            mac_mount("/dev", "devfs", 220_160, false, true),
901            mac_mount("/System/Volumes/VM", "apfs", 994_662_584_320, false, true),
902            mac_mount(
903                "/System/Volumes/Preboot",
904                "apfs",
905                994_662_584_320,
906                false,
907                true,
908            ),
909            mac_mount(
910                "/System/Volumes/Update",
911                "apfs",
912                994_662_584_320,
913                false,
914                true,
915            ),
916            mac_mount("/System/Volumes/xarts", "apfs", 524_288_000, false, true),
917            mac_mount(
918                "/System/Volumes/iSCPreboot",
919                "apfs",
920                524_288_000,
921                false,
922                true,
923            ),
924            mac_mount("/System/Volumes/Hardware", "apfs", 524_288_000, false, true),
925            mac_mount("/System/Volumes/Data", "apfs", 994_662_584_320, true, true),
926            mac_mount("/System/Volumes/Data/home", "autofs", 0, false, false),
927            mac_mount("/Volumes/Recovery", "apfs", 994_662_584_320, false, true),
928            mac_mount(
929                "/System/Volumes/Update/SFR/mnt1",
930                "apfs",
931                5_368_709_120,
932                false,
933                true,
934            ),
935            mac_mount(
936                "/System/Volumes/Update/mnt1",
937                "apfs",
938                994_662_584_320,
939                false,
940                true,
941            ),
942        ];
943        let kept: Vec<String> = mounts
944            .iter()
945            .filter_map(super::macos_volume_entry)
946            .map(|(mp, ..)| mp)
947            .collect();
948        assert_eq!(kept, vec!["/", "/System/Volumes/Data"]);
949    }
950
951    #[cfg(target_os = "macos")]
952    #[test]
953    fn test_macos_volume_entry_drops_remote_and_empty() {
954        // A browsable SMB share is not local (sysinfo skipped it, as other platforms do).
955        let smb = mac_mount("/Volumes/share", "smbfs", 4_000_000_000_000, true, false);
956        assert_eq!(super::macos_volume_entry(&smb), None);
957        // A browsable local volume whose statfs failed (size 0) is not reported as 0 GB.
958        let empty = mac_mount("/Volumes/X", "apfs", 0, true, true);
959        assert_eq!(super::macos_volume_entry(&empty), None);
960        // An external disk is kept with its statfs figures and fs type.
961        let ext = mac_mount("/Volumes/USB", "exfat", 128_000_000_000, true, true);
962        assert_eq!(
963            super::macos_volume_entry(&ext),
964            Some((
965                "/Volumes/USB".to_string(),
966                128_000_000_000,
967                128_000_000_000 / 3,
968                "exfat".to_string()
969            ))
970        );
971    }
972
973    #[cfg(target_os = "macos")]
974    #[test]
975    fn test_bsd_disk_sort_key_is_numeric() {
976        use crate::macos_ffi::bsd_disk_sort_key;
977        let mut names = vec!["disk10", "disk2", "disk0", "disk1"];
978        names.sort_by_key(|n| bsd_disk_sort_key(n));
979        assert_eq!(names, vec!["disk0", "disk1", "disk2", "disk10"]);
980    }
981
982    #[cfg(target_os = "windows")]
983    use super::win_ffi::BUS_TYPE_NVME;
984    #[cfg(target_os = "windows")]
985    use super::{combine_model, format_disk_label};
986
987    #[cfg(target_os = "windows")]
988    #[test]
989    fn test_format_disk_label_nvme() {
990        // NVMe bus type wins regardless of seek-penalty info.
991        let label = format_disk_label(
992            "Samsung SSD 980 Pro",
993            Some(1_000_204_886_016),
994            BUS_TYPE_NVME,
995            Some(false),
996        );
997        assert_eq!(label, "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
998    }
999
1000    #[cfg(target_os = "windows")]
1001    #[test]
1002    fn test_format_disk_label_hdd() {
1003        // Non-NVMe bus (SATA = 11) with a seek penalty is an HDD.
1004        let label = format_disk_label("WD Blue", Some(2_000_398_934_016), 11, Some(true));
1005        assert_eq!(label, "WD Blue 2.0 TB [HDD]");
1006    }
1007
1008    #[cfg(target_os = "windows")]
1009    #[test]
1010    fn test_format_disk_label_sata_ssd() {
1011        // SATA SSD: no seek penalty.
1012        let label = format_disk_label(
1013            "Crucial CT500MX500SSD1",
1014            Some(500_107_862_016),
1015            11,
1016            Some(false),
1017        );
1018        assert_eq!(label, "Crucial CT500MX500SSD1 500 GB [SSD]");
1019    }
1020
1021    #[cfg(target_os = "windows")]
1022    #[test]
1023    fn test_format_disk_label_unknown_seek_penalty_defaults_to_ssd() {
1024        // No NVMe bus and no seek-penalty info (e.g. eMMC/SD) → SSD fallback.
1025        let label = format_disk_label("Some eMMC", Some(64_000_000_000), 13, None);
1026        assert_eq!(label, "Some eMMC 64 GB [SSD]");
1027    }
1028
1029    #[cfg(target_os = "windows")]
1030    #[test]
1031    fn test_format_disk_label_empty_model() {
1032        let label = format_disk_label("", Some(500_107_862_016), 11, Some(false));
1033        assert_eq!(label, "500 GB [SSD]");
1034    }
1035
1036    #[cfg(target_os = "windows")]
1037    #[test]
1038    fn test_combine_model_generic_ata_vendor_suppressed() {
1039        // SATA drives report a generic "ATA" vendor id that FriendlyName omits.
1040        assert_eq!(
1041            combine_model("ATA", "Samsung SSD 860 EVO"),
1042            "Samsung SSD 860 EVO"
1043        );
1044    }
1045
1046    #[cfg(target_os = "windows")]
1047    #[test]
1048    fn test_combine_model_empty_vendor() {
1049        assert_eq!(
1050            combine_model("", "Samsung SSD 980 Pro"),
1051            "Samsung SSD 980 Pro"
1052        );
1053    }
1054
1055    #[cfg(target_os = "windows")]
1056    #[test]
1057    fn test_combine_model_vendor_already_in_product() {
1058        // Avoid "Samsung Samsung SSD 980 Pro".
1059        assert_eq!(
1060            combine_model("Samsung", "Samsung SSD 980 Pro"),
1061            "Samsung SSD 980 Pro"
1062        );
1063    }
1064
1065    #[cfg(target_os = "windows")]
1066    #[test]
1067    fn test_combine_model_distinct_vendor_prepended() {
1068        assert_eq!(combine_model("Kingston", "A400 SSD"), "Kingston A400 SSD");
1069    }
1070
1071    #[cfg(target_os = "windows")]
1072    #[test]
1073    fn test_combine_model_empty_product_falls_back_to_vendor() {
1074        assert_eq!(combine_model("SomeVendor", ""), "SomeVendor");
1075    }
1076}