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