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    // Physical drive numbers are contiguous from 0 in the common case, but a removed
430    // disk can leave a gap; scan a fixed range and skip any drive that won't open.
431    // A failed CreateFileW on a nonexistent device returns immediately, so this is
432    // still orders of magnitude cheaper than spawning PowerShell.
433    const MAX_DRIVES: u32 = 32;
434    (0..MAX_DRIVES)
435        .filter_map(win_ffi::query_physical_drive)
436        .collect()
437}
438
439/// Classifies and formats a single physical disk into its display label, mirroring
440/// the columns the old `Get-PhysicalDisk` parser used (model, size, media type, bus).
441///
442/// `bus_type` is a `STORAGE_BUS_TYPE` value; `incurs_seek_penalty` is `None` when the
443/// seek-penalty IOCTL was unavailable (treated as SSD, matching the old "Unspecified"
444/// fallback).
445#[cfg(target_os = "windows")]
446fn format_disk_label(
447    model: &str,
448    size_bytes: Option<u64>,
449    bus_type: u32,
450    incurs_seek_penalty: Option<bool>,
451) -> String {
452    let kind = if bus_type == win_ffi::BUS_TYPE_NVME {
453        "NVMe SSD"
454    } else {
455        match incurs_seek_penalty {
456            Some(true) => "HDD",
457            // Non-rotational, or unknown (no NVMe bus, no seek-penalty info): treat as
458            // SSD — matches the prior "MediaType Unspecified → SSD" behavior.
459            Some(false) | None => "SSD",
460        }
461    };
462
463    let name = model.trim();
464    let size_str = size_bytes.map(format_size).unwrap_or_default();
465    let label = if name.is_empty() {
466        format!("{} [{}]", size_str, kind)
467    } else {
468        format!("{} {} [{}]", name, size_str, kind)
469    };
470    label.trim().to_string()
471}
472
473/// Builds the model/friendly-name string from a storage descriptor's vendor and
474/// product id fields.
475///
476/// The product id is the model string Windows surfaces as `Get-PhysicalDisk`'s
477/// `FriendlyName` (e.g. "Samsung SSD 980 Pro"). The vendor id is only prepended when
478/// it adds information — SATA drives report a generic "ATA" vendor that the friendly
479/// name never includes, and USB/NVMe often duplicate the vendor inside the product id.
480#[cfg(target_os = "windows")]
481fn combine_model(vendor: &str, product: &str) -> String {
482    let v = vendor.trim();
483    let p = product.trim();
484    if p.is_empty() {
485        return v.to_string();
486    }
487    if v.is_empty()
488        || v.eq_ignore_ascii_case("ATA")
489        || p.to_ascii_lowercase().contains(&v.to_ascii_lowercase())
490    {
491        p.to_string()
492    } else {
493        format!("{} {}", v, p)
494    }
495}
496
497/// Native Win32 storage IOCTL bindings and per-drive query helpers.
498///
499/// Uses hand-written `extern "system"` declarations to match the crate's existing
500/// Windows FFI style (see `win_reg.rs`) rather than pulling in a Win32 binding crate.
501#[cfg(target_os = "windows")]
502mod win_ffi {
503    use super::{combine_model, format_disk_label};
504    use std::ffi::{c_void, OsStr};
505    use std::mem::size_of;
506    use std::os::windows::ffi::OsStrExt;
507    use std::ptr;
508
509    #[allow(clippy::upper_case_acronyms)]
510    type HANDLE = *mut c_void;
511    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
512    const FILE_SHARE_READ: u32 = 0x0000_0001;
513    const FILE_SHARE_WRITE: u32 = 0x0000_0002;
514    const OPEN_EXISTING: u32 = 3;
515
516    // Both IOCTLs below are FILE_ANY_ACCESS, so a handle opened with zero desired
517    // access can issue them without administrator rights.
518    const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D_1400;
519    const IOCTL_DISK_GET_DRIVE_GEOMETRY_EX: u32 = 0x0007_00A0;
520
521    // STORAGE_PROPERTY_ID values.
522    const STORAGE_DEVICE_PROPERTY: u32 = 0;
523    const STORAGE_DEVICE_SEEK_PENALTY_PROPERTY: u32 = 7;
524    // STORAGE_QUERY_TYPE value.
525    const PROPERTY_STANDARD_QUERY: u32 = 0;
526
527    /// `STORAGE_BUS_TYPE::BusTypeNvme`.
528    pub const BUS_TYPE_NVME: u32 = 17;
529
530    #[repr(C)]
531    struct StoragePropertyQuery {
532        property_id: u32,
533        query_type: u32,
534        additional_parameters: [u8; 1],
535    }
536
537    #[repr(C)]
538    struct StorageDeviceDescriptor {
539        version: u32,
540        size: u32,
541        device_type: u8,
542        device_type_modifier: u8,
543        removable_media: u8,
544        command_queueing: u8,
545        vendor_id_offset: u32,
546        product_id_offset: u32,
547        product_revision_offset: u32,
548        serial_number_offset: u32,
549        bus_type: u32,
550        raw_properties_length: u32,
551        raw_device_properties: [u8; 1],
552    }
553
554    #[repr(C)]
555    struct DeviceSeekPenaltyDescriptor {
556        version: u32,
557        size: u32,
558        incurs_seek_penalty: u8,
559    }
560
561    #[repr(C)]
562    struct DiskGeometry {
563        cylinders: i64,
564        media_type: u32,
565        tracks_per_cylinder: u32,
566        sectors_per_track: u32,
567        bytes_per_sector: u32,
568    }
569
570    #[repr(C)]
571    struct DiskGeometryEx {
572        geometry: DiskGeometry,
573        disk_size: i64,
574        data: [u8; 1],
575    }
576
577    extern "system" {
578        fn CreateFileW(
579            lp_file_name: *const u16,
580            dw_desired_access: u32,
581            dw_share_mode: u32,
582            lp_security_attributes: *mut c_void,
583            dw_creation_disposition: u32,
584            dw_flags_and_attributes: u32,
585            h_template_file: HANDLE,
586        ) -> HANDLE;
587
588        fn DeviceIoControl(
589            h_device: HANDLE,
590            dw_io_control_code: u32,
591            lp_in_buffer: *const c_void,
592            n_in_buffer_size: u32,
593            lp_out_buffer: *mut c_void,
594            n_out_buffer_size: u32,
595            lp_bytes_returned: *mut u32,
596            lp_overlapped: *mut c_void,
597        ) -> i32;
598
599        fn CloseHandle(h_object: HANDLE) -> i32;
600    }
601
602    /// Opens `\\.\PhysicalDrive{index}` and returns its formatted label, or `None` if
603    /// the drive does not exist or its device descriptor cannot be read.
604    pub fn query_physical_drive(index: u32) -> Option<String> {
605        let path = format!(r"\\.\PhysicalDrive{index}");
606        let path_w: Vec<u16> = OsStr::new(&path).encode_wide().chain(Some(0)).collect();
607
608        // SAFETY: path_w is a valid null-terminated wide string; zero desired access is
609        // sufficient for the FILE_ANY_ACCESS query IOCTLs used below.
610        let handle = unsafe {
611            CreateFileW(
612                path_w.as_ptr(),
613                0,
614                FILE_SHARE_READ | FILE_SHARE_WRITE,
615                ptr::null_mut(),
616                OPEN_EXISTING,
617                0,
618                ptr::null_mut(),
619            )
620        };
621        if handle == INVALID_HANDLE_VALUE || handle.is_null() {
622            return None;
623        }
624
625        let descriptor = query_device_descriptor(handle);
626        let result = descriptor.map(|(bus_type, model)| {
627            let size = query_disk_size(handle);
628            let seek = query_seek_penalty(handle);
629            format_disk_label(&model, size, bus_type, seek)
630        });
631
632        // SAFETY: handle came from a successful CreateFileW and is closed exactly once.
633        unsafe {
634            CloseHandle(handle);
635        }
636        result
637    }
638
639    /// Reads a null-terminated ANSI string embedded in `buf` at `offset` bytes from the
640    /// start. An offset of 0 means the field is absent.
641    fn read_ansi_at(buf: &[u8], offset: usize) -> String {
642        if offset == 0 || offset >= buf.len() {
643            return String::new();
644        }
645        let bytes = &buf[offset..];
646        let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
647        String::from_utf8_lossy(&bytes[..end]).trim().to_string()
648    }
649
650    /// Queries `IOCTL_STORAGE_QUERY_PROPERTY` for the device descriptor, returning the
651    /// bus type and combined model string.
652    fn query_device_descriptor(handle: HANDLE) -> Option<(u32, String)> {
653        let query = StoragePropertyQuery {
654            property_id: STORAGE_DEVICE_PROPERTY,
655            query_type: PROPERTY_STANDARD_QUERY,
656            additional_parameters: [0; 1],
657        };
658        // The descriptor is followed inline by its vendor/product/serial strings; a
659        // fixed 1 KiB buffer comfortably holds the header plus those fields.
660        let mut buf = [0u8; 1024];
661        let mut returned: u32 = 0;
662        // SAFETY: query is a valid input buffer of the declared size; buf is writable
663        // and its length is passed as the output size.
664        let ok = unsafe {
665            DeviceIoControl(
666                handle,
667                IOCTL_STORAGE_QUERY_PROPERTY,
668                &query as *const _ as *const c_void,
669                size_of::<StoragePropertyQuery>() as u32,
670                buf.as_mut_ptr() as *mut c_void,
671                buf.len() as u32,
672                &mut returned,
673                ptr::null_mut(),
674            )
675        };
676        if ok == 0 || (returned as usize) < size_of::<StorageDeviceDescriptor>() {
677            return None;
678        }
679        // SAFETY: the IOCTL wrote at least a full StorageDeviceDescriptor into buf,
680        // which is correctly aligned (a [u8; 1024] array plus the descriptor's u32
681        // alignment is satisfied at offset 0).
682        let desc = unsafe { &*(buf.as_ptr() as *const StorageDeviceDescriptor) };
683        let bus_type = desc.bus_type;
684        let vendor = read_ansi_at(&buf, desc.vendor_id_offset as usize);
685        let product = read_ansi_at(&buf, desc.product_id_offset as usize);
686        Some((bus_type, combine_model(&vendor, &product)))
687    }
688
689    /// Queries `IOCTL_DISK_GET_DRIVE_GEOMETRY_EX` for the total disk size in bytes.
690    fn query_disk_size(handle: HANDLE) -> Option<u64> {
691        let mut geo = DiskGeometryEx {
692            geometry: DiskGeometry {
693                cylinders: 0,
694                media_type: 0,
695                tracks_per_cylinder: 0,
696                sectors_per_track: 0,
697                bytes_per_sector: 0,
698            },
699            disk_size: 0,
700            data: [0; 1],
701        };
702        let mut returned: u32 = 0;
703        // SAFETY: geo is a writable DiskGeometryEx passed with its own size.
704        let ok = unsafe {
705            DeviceIoControl(
706                handle,
707                IOCTL_DISK_GET_DRIVE_GEOMETRY_EX,
708                ptr::null(),
709                0,
710                &mut geo as *mut _ as *mut c_void,
711                size_of::<DiskGeometryEx>() as u32,
712                &mut returned,
713                ptr::null_mut(),
714            )
715        };
716        if ok == 0 || geo.disk_size <= 0 {
717            None
718        } else {
719            Some(geo.disk_size as u64)
720        }
721    }
722
723    /// Queries `IOCTL_STORAGE_QUERY_PROPERTY` seek-penalty info. `Some(true)` indicates
724    /// a rotational (HDD) device, `Some(false)` a solid-state device, `None` if the
725    /// property is unavailable.
726    fn query_seek_penalty(handle: HANDLE) -> Option<bool> {
727        let query = StoragePropertyQuery {
728            property_id: STORAGE_DEVICE_SEEK_PENALTY_PROPERTY,
729            query_type: PROPERTY_STANDARD_QUERY,
730            additional_parameters: [0; 1],
731        };
732        let mut desc = DeviceSeekPenaltyDescriptor {
733            version: 0,
734            size: 0,
735            incurs_seek_penalty: 0,
736        };
737        let mut returned: u32 = 0;
738        // SAFETY: query is a valid input buffer; desc is a writable output buffer.
739        let ok = unsafe {
740            DeviceIoControl(
741                handle,
742                IOCTL_STORAGE_QUERY_PROPERTY,
743                &query as *const _ as *const c_void,
744                size_of::<StoragePropertyQuery>() as u32,
745                &mut desc as *mut _ as *mut c_void,
746                size_of::<DeviceSeekPenaltyDescriptor>() as u32,
747                &mut returned,
748                ptr::null_mut(),
749            )
750        };
751        if ok == 0 || (returned as usize) < size_of::<DeviceSeekPenaltyDescriptor>() {
752            None
753        } else {
754            Some(desc.incurs_seek_penalty != 0)
755        }
756    }
757
758    #[cfg(test)]
759    mod layout {
760        use std::mem::{offset_of, size_of};
761
762        // The driver reads these `#[repr(C)]` buffers by fixed offset, so pin the layout —
763        // an accidental field reorder or padding change would silently corrupt reads.
764        #[test]
765        fn ffi_struct_layout() {
766            assert_eq!(size_of::<super::StoragePropertyQuery>(), 12);
767            assert_eq!(size_of::<super::StorageDeviceDescriptor>(), 40);
768            assert_eq!(
769                offset_of!(super::StorageDeviceDescriptor, vendor_id_offset),
770                12
771            );
772            assert_eq!(
773                offset_of!(super::StorageDeviceDescriptor, product_id_offset),
774                16
775            );
776            assert_eq!(offset_of!(super::StorageDeviceDescriptor, bus_type), 28);
777            assert_eq!(size_of::<super::DeviceSeekPenaltyDescriptor>(), 12);
778            assert_eq!(size_of::<super::DiskGeometryEx>(), 40);
779            assert_eq!(offset_of!(super::DiskGeometryEx, disk_size), 24);
780        }
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    #[cfg(any(target_os = "linux", target_os = "macos"))]
787    use super::format_size;
788    #[cfg(target_os = "linux")]
789    use super::{is_skip_fs, strip_embedded_size};
790
791    #[cfg(target_os = "linux")]
792    #[test]
793    fn test_is_skip_fs_pseudo() {
794        assert!(is_skip_fs("sysfs", false));
795        assert!(is_skip_fs("proc", false));
796        assert!(is_skip_fs("tmpfs", false));
797        assert!(is_skip_fs("fusectl", false)); // fusectl is always skipped
798        assert!(is_skip_fs("fusectl", true)); // even with include_fuse
799    }
800
801    #[cfg(target_os = "linux")]
802    #[test]
803    fn test_is_skip_fs_fuse_excluded_by_default() {
804        assert!(is_skip_fs("fuse.gvfsd-fuse", false));
805        assert!(is_skip_fs("fuse.sshfs", false));
806        assert!(is_skip_fs("fuse.cryfs", false));
807    }
808
809    #[cfg(target_os = "linux")]
810    #[test]
811    fn test_is_skip_fs_fuse_included_in_full() {
812        assert!(!is_skip_fs("fuse.gvfsd-fuse", true));
813        assert!(!is_skip_fs("fuse.sshfs", true));
814        assert!(!is_skip_fs("fuse.cryfs", true));
815    }
816
817    #[cfg(target_os = "linux")]
818    #[test]
819    fn test_is_skip_fs_real_fs() {
820        assert!(!is_skip_fs("ext4", false));
821        assert!(!is_skip_fs("btrfs", false));
822        assert!(!is_skip_fs("vfat", false));
823    }
824
825    #[cfg(target_os = "linux")]
826    #[test]
827    fn test_strip_embedded_size() {
828        assert_eq!(
829            strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
830            "BC901 NVMe SK hynix"
831        );
832        assert_eq!(
833            strip_embedded_size("Samsung SSD 970 EVO 500GB"),
834            "Samsung SSD 970 EVO"
835        );
836        assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
837        assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); // Crucial model — no unit suffix
838        assert_eq!(
839            strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
840            "SAMSUNG MZQL23T8HCLS"
841        ); // no unit
842        assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
843    }
844
845    #[cfg(any(target_os = "linux", target_os = "macos"))]
846    #[test]
847    fn test_format_size_gb() {
848        assert_eq!(format_size(512_110_190_592), "512 GB");
849    }
850
851    #[cfg(any(target_os = "linux", target_os = "macos"))]
852    #[test]
853    fn test_format_size_tb() {
854        assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
855    }
856
857    #[cfg(any(target_os = "linux", target_os = "macos"))]
858    #[test]
859    fn test_format_size_2tb() {
860        assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
861    }
862
863    #[cfg(target_os = "macos")]
864    #[test]
865    fn test_parse_diskutil_info_plist_apple_silicon() {
866        // Matches real output from an Apple M-series Mac (disk0).
867        // IORegistryEntryName comes before MediaName in the plist; MediaName must win.
868        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
869<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
870<plist version="1.0">
871<dict>
872	<key>BusProtocol</key>
873	<string>Apple Fabric</string>
874	<key>IORegistryEntryName</key>
875	<string>APPLE SSD AP1024Z Media</string>
876	<key>MediaName</key>
877	<string>APPLE SSD AP1024Z</string>
878	<key>SolidState</key>
879	<true/>
880	<key>TotalSize</key>
881	<integer>1000555581440</integer>
882	<key>VirtualOrPhysical</key>
883	<string>Unknown</string>
884</dict>
885</plist>"#;
886        let result = super::parse_diskutil_info_plist(plist);
887        assert_eq!(result, Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string()));
888    }
889
890    #[cfg(target_os = "macos")]
891    #[test]
892    fn test_parse_diskutil_info_plist_nvme() {
893        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
894<plist version="1.0">
895<dict>
896	<key>BusProtocol</key>
897	<string>PCIe</string>
898	<key>MediaName</key>
899	<string>Samsung SSD 990 Pro</string>
900	<key>SolidState</key>
901	<true/>
902	<key>TotalSize</key>
903	<integer>2000398934016</integer>
904	<key>VirtualOrPhysical</key>
905	<string>Physical</string>
906</dict>
907</plist>"#;
908        let result = super::parse_diskutil_info_plist(plist);
909        assert_eq!(
910            result,
911            Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
912        );
913    }
914
915    #[cfg(target_os = "macos")]
916    #[test]
917    fn test_parse_diskutil_info_plist_virtual_skipped() {
918        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
919<plist version="1.0">
920<dict>
921	<key>MediaName</key>
922	<string>APFS Container Disk</string>
923	<key>TotalSize</key>
924	<integer>500000000000</integer>
925	<key>VirtualOrPhysical</key>
926	<string>Virtual</string>
927</dict>
928</plist>"#;
929        let result = super::parse_diskutil_info_plist(plist);
930        assert_eq!(result, None);
931    }
932
933    #[cfg(target_os = "windows")]
934    use super::win_ffi::BUS_TYPE_NVME;
935    #[cfg(target_os = "windows")]
936    use super::{combine_model, format_disk_label};
937
938    #[cfg(target_os = "windows")]
939    #[test]
940    fn test_format_disk_label_nvme() {
941        // NVMe bus type wins regardless of seek-penalty info.
942        let label = format_disk_label(
943            "Samsung SSD 980 Pro",
944            Some(1_000_204_886_016),
945            BUS_TYPE_NVME,
946            Some(false),
947        );
948        assert_eq!(label, "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
949    }
950
951    #[cfg(target_os = "windows")]
952    #[test]
953    fn test_format_disk_label_hdd() {
954        // Non-NVMe bus (SATA = 11) with a seek penalty is an HDD.
955        let label = format_disk_label("WD Blue", Some(2_000_398_934_016), 11, Some(true));
956        assert_eq!(label, "WD Blue 2.0 TB [HDD]");
957    }
958
959    #[cfg(target_os = "windows")]
960    #[test]
961    fn test_format_disk_label_sata_ssd() {
962        // SATA SSD: no seek penalty.
963        let label = format_disk_label(
964            "Crucial CT500MX500SSD1",
965            Some(500_107_862_016),
966            11,
967            Some(false),
968        );
969        assert_eq!(label, "Crucial CT500MX500SSD1 500 GB [SSD]");
970    }
971
972    #[cfg(target_os = "windows")]
973    #[test]
974    fn test_format_disk_label_unknown_seek_penalty_defaults_to_ssd() {
975        // No NVMe bus and no seek-penalty info (e.g. eMMC/SD) → SSD fallback.
976        let label = format_disk_label("Some eMMC", Some(64_000_000_000), 13, None);
977        assert_eq!(label, "Some eMMC 64 GB [SSD]");
978    }
979
980    #[cfg(target_os = "windows")]
981    #[test]
982    fn test_format_disk_label_empty_model() {
983        let label = format_disk_label("", Some(500_107_862_016), 11, Some(false));
984        assert_eq!(label, "500 GB [SSD]");
985    }
986
987    #[cfg(target_os = "windows")]
988    #[test]
989    fn test_combine_model_generic_ata_vendor_suppressed() {
990        // SATA drives report a generic "ATA" vendor id that FriendlyName omits.
991        assert_eq!(
992            combine_model("ATA", "Samsung SSD 860 EVO"),
993            "Samsung SSD 860 EVO"
994        );
995    }
996
997    #[cfg(target_os = "windows")]
998    #[test]
999    fn test_combine_model_empty_vendor() {
1000        assert_eq!(
1001            combine_model("", "Samsung SSD 980 Pro"),
1002            "Samsung SSD 980 Pro"
1003        );
1004    }
1005
1006    #[cfg(target_os = "windows")]
1007    #[test]
1008    fn test_combine_model_vendor_already_in_product() {
1009        // Avoid "Samsung Samsung SSD 980 Pro".
1010        assert_eq!(
1011            combine_model("Samsung", "Samsung SSD 980 Pro"),
1012            "Samsung SSD 980 Pro"
1013        );
1014    }
1015
1016    #[cfg(target_os = "windows")]
1017    #[test]
1018    fn test_combine_model_distinct_vendor_prepended() {
1019        assert_eq!(combine_model("Kingston", "A400 SSD"), "Kingston A400 SSD");
1020    }
1021
1022    #[cfg(target_os = "windows")]
1023    #[test]
1024    fn test_combine_model_empty_product_falls_back_to_vendor() {
1025        assert_eq!(combine_model("SomeVendor", ""), "SomeVendor");
1026    }
1027}