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