Skip to main content

retch_sysinfo/
io.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Disk and network I/O throughput sampling.
5//!
6//! Both fields report a **rate**, which a one-shot process cannot read directly: the
7//! kernel exposes cumulative counters, so a rate needs two samples and a known interval.
8//! fastfetch solves this with a dedicated ~1 s sleep (measured: `fastfetch -s NetIO`
9//! takes 1.00 s against 0.00 s for a counter-only module). retch cannot afford that —
10//! `--long` targets ~500 ms end to end, and being slower than fastfetch is treated as a
11//! blocking regression (NOTES.md §3, "Performance Regression Vigilance").
12//!
13//! So this module follows the v0.3.49 `cpu-usage` pattern instead: [`fetch`] samples the
14//! counters *before* the concurrent probe scope and diffs them *after*, making the
15//! existing collection window the sampling interval. A floor is applied only when the
16//! window came out too short to measure anything (an isolated `--fields disk-io`), which
17//! is the sole case where these fields add any wall-clock at all.
18//!
19//! The consequence, stated plainly because it is the honest reading of the number: the
20//! window varies by mode — roughly 0.4 s in `--long`, seconds in `--full` — so the value
21//! is the *average* rate over the run, not an instantaneous one. That is the right
22//! trade for a fetcher; a stable window would cost a sleep on every invocation.
23//!
24//! [`fetch`]: crate::fetch
25
26/// Cumulative byte counters for one device at a point in time.
27///
28/// `read`/`write` are disk semantics; for network interfaces they carry RX/TX
29/// respectively, since the rate arithmetic is identical and only the labels differ.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct IoCounters {
32    /// Kernel device name (`nvme0n1`, `wlp0s20f3`).
33    pub device: String,
34    /// Bytes read (disk) or received (network) since boot.
35    pub read: u64,
36    /// Bytes written (disk) or transmitted (network) since boot.
37    pub write: u64,
38}
39
40/// A device's throughput over the sampling window, in bytes per second.
41#[derive(Debug, Clone, PartialEq)]
42pub struct IoRate {
43    /// Kernel device name.
44    pub device: String,
45    /// Read/RX rate in bytes per second.
46    pub read: f64,
47    /// Write/TX rate in bytes per second.
48    pub write: f64,
49}
50
51/// Bytes per sector in `/proc/diskstats`.
52///
53/// **This is a fixed kernel convention, not the device's sector size.** diskstats reports
54/// in 512-byte bio sectors regardless of what `/sys/block/<dev>/queue/hw_sector_size`
55/// says, so keying this off the hardware value inflates every figure 8× on a 4 KiB-sector
56/// drive. `disk.rs` already relies on the same convention for `/sys/block/<dev>/size`.
57///
58/// Confirmed here by writing a known 64 MiB of incompressible data and reading the delta:
59/// 146808 sectors, i.e. 71 MiB at 512 B/sector (the excess is btrfs metadata and CoW)
60/// against an impossible 573 MiB at 4096. **Verification limit, recorded rather than
61/// papered over:** the host used for that check has `hw_sector_size` 512 itself, so the
62/// result confirms the value without discriminating the two rules. A device with a 4 KiB
63/// logical sector would separate them.
64const DISKSTATS_SECTOR_BYTES: u64 = 512;
65
66/// Field index of "sectors read" in a `/proc/diskstats` line (0-based, after splitting on
67/// whitespace): major, minor, name, reads completed, reads merged, **sectors read**.
68const DISKSTATS_SECTORS_READ: usize = 5;
69
70/// Field index of "sectors written": … ms reading, writes completed, writes merged,
71/// **sectors written**.
72const DISKSTATS_SECTORS_WRITTEN: usize = 9;
73
74/// Parses `/proc/diskstats` into per-device byte counters.
75///
76/// `keep` decides which device names survive; it is injected rather than hardcoded so the
77/// tests can assert against a verbatim fixture without depending on the block devices of
78/// whatever machine runs them — the #155/v0.6.2 lesson, where a "parse this fixture" test
79/// silently consulted live hardware and failed on one developer's box.
80///
81/// Lines with too few fields or unparsable counters are skipped rather than defaulted to
82/// zero: a zero would render as a confident `0 B/s` for a device that was never read.
83pub fn parse_diskstats<F>(content: &str, keep: F) -> Vec<IoCounters>
84where
85    F: Fn(&str) -> bool,
86{
87    let mut out = Vec::new();
88    for line in content.lines() {
89        let fields: Vec<&str> = line.split_whitespace().collect();
90        if fields.len() <= DISKSTATS_SECTORS_WRITTEN {
91            continue;
92        }
93        let name = fields[2];
94        if !keep(name) {
95            continue;
96        }
97        let (Ok(read_sectors), Ok(written_sectors)) = (
98            fields[DISKSTATS_SECTORS_READ].parse::<u64>(),
99            fields[DISKSTATS_SECTORS_WRITTEN].parse::<u64>(),
100        ) else {
101            continue;
102        };
103        out.push(IoCounters {
104            device: name.to_string(),
105            read: read_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
106            write: written_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
107        });
108    }
109    out
110}
111
112/// Computes per-device rates between two samples taken `elapsed_secs` apart.
113///
114/// A device present in only one sample is **dropped**, not reported: an interface that
115/// appeared mid-run (a VPN link coming up) has no baseline, and treating its lifetime
116/// counter as a delta would render a spectacular fictional rate.
117///
118/// Counter decreases are clamped to zero via `saturating_sub`. Counters do reset in
119/// practice — an interface going down and up, a module reload — and a wrapped subtraction
120/// would produce an exabyte-scale rate from a perfectly ordinary event.
121pub fn compute_rates(
122    before: &[IoCounters],
123    after: &[IoCounters],
124    elapsed_secs: f64,
125) -> Vec<IoRate> {
126    // Written as an explicit finite check rather than `<= 0.0` because a NaN window must
127    // also yield nothing: `NaN <= 0.0` is false, so the terse form would divide by it.
128    if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
129        return Vec::new();
130    }
131    after
132        .iter()
133        .filter_map(|now| {
134            let prev = before.iter().find(|p| p.device == now.device)?;
135            Some(IoRate {
136                device: now.device.clone(),
137                read: now.read.saturating_sub(prev.read) as f64 / elapsed_secs,
138                write: now.write.saturating_sub(prev.write) as f64 / elapsed_secs,
139            })
140        })
141        .collect()
142}
143
144/// Formats a byte-per-second rate for display (`"1.2 MB/s"`).
145///
146/// Delegates to [`crate::network::format_bytes`] so the unit vocabulary matches the `Net`
147/// field's existing `RX:`/`TX:` totals rather than introducing a second scheme alongside
148/// it. Non-finite and negative inputs render as `0 B/s`; they cannot arise from
149/// [`compute_rates`], but the formatter is public and should not print `NaN B/s`.
150pub fn format_rate(bytes_per_sec: f64) -> String {
151    let clamped = if bytes_per_sec.is_finite() && bytes_per_sec > 0.0 {
152        bytes_per_sec.round() as u64
153    } else {
154        0
155    };
156    format!("{}/s", crate::network::format_bytes(clamped))
157}
158
159/// Renders one device's rates as a display line, e.g.
160/// `"nvme0n1 R: 1.2 MB/s W: 0 B/s"`.
161pub fn format_io_line(rate: &IoRate, read_label: &str, write_label: &str) -> String {
162    format!(
163        "{} {}: {} {}: {}",
164        rate.device,
165        read_label,
166        format_rate(rate.read),
167        write_label,
168        format_rate(rate.write)
169    )
170}
171
172/// Chooses which interfaces the `net-io` field reports.
173///
174/// The default-route interface when it is known — matching both fastfetch and the way the
175/// `Net` field already singles that interface out. Otherwise (offline, or the active
176/// interface could not be resolved) every interface that actually moved bytes during the
177/// window, so an unusual routing setup still reports something rather than nothing.
178///
179/// Returning an empty list when the active interface is known but idle is deliberate: a
180/// `0 B/s` line for the interface you are using is a real, informative reading.
181pub fn select_net_rates(rates: Vec<IoRate>, active: Option<&str>) -> Vec<IoRate> {
182    if let Some(active) = active {
183        let selected: Vec<IoRate> = rates
184            .iter()
185            .filter(|r| r.device == active)
186            .cloned()
187            .collect();
188        if !selected.is_empty() {
189            return selected;
190        }
191    }
192    rates
193        .into_iter()
194        .filter(|r| r.read > 0.0 || r.write > 0.0)
195        .collect()
196}
197
198/// Samples cumulative disk byte counters for physical whole disks.
199///
200/// Linux reads `/proc/diskstats`; Windows queries `IOCTL_DISK_PERFORMANCE` per
201/// `\\.\PhysicalDriveN`; macOS reads the `Statistics` dictionary off each IOKit
202/// `IOBlockStorageDriver`. Elsewhere this returns an empty vector, so the field is simply
203/// absent rather than wrong (same shape as `brightness`, `keyboard`, `tpm`).
204///
205/// Partitions are excluded on all three because their traffic is already counted against
206/// the parent device — reporting both would double every disk's apparent throughput. On
207/// Windows that falls out of addressing whole drives directly, and on macOS out of the
208/// counters living on the driver above the whole-disk media; on Linux it takes an
209/// explicit filter.
210pub fn sample_disk_io() -> Vec<IoCounters> {
211    #[cfg(target_os = "linux")]
212    {
213        let Ok(content) = std::fs::read_to_string("/proc/diskstats") else {
214            return Vec::new();
215        };
216        parse_diskstats(&content, is_physical_disk)
217    }
218
219    #[cfg(target_os = "windows")]
220    {
221        win_ffi::sample_physical_drives()
222    }
223
224    #[cfg(target_os = "macos")]
225    {
226        crate::macos_ffi::get_block_storage_io()
227            .into_iter()
228            .map(|(device, read, write)| IoCounters {
229                device,
230                read,
231                write,
232            })
233            .collect()
234    }
235
236    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
237    {
238        Vec::new()
239    }
240}
241
242/// True when `name` is a physical whole disk rather than a partition or virtual device.
243///
244/// Shares [`crate::disk::is_virtual_block_name`] with the `phys-disk` field so the two
245/// cannot drift into disagreeing about what counts as a disk, then applies the same two
246/// sysfs tests `disk::detect_linux` uses: partitions carry a `partition` file, and a real
247/// block device has a `queue` directory.
248#[cfg(target_os = "linux")]
249fn is_physical_disk(name: &str) -> bool {
250    if crate::disk::is_virtual_block_name(name) {
251        return false;
252    }
253    let dev = std::path::Path::new("/sys/class/block").join(name);
254    !dev.join("partition").exists() && dev.join("queue").exists()
255}
256
257/// Samples cumulative network byte counters per interface.
258///
259/// Linux reads `/sys/class/net/<iface>/statistics/{rx,tx}_bytes` directly rather than
260/// going through sysinfo, so the two samples are guaranteed to come from the same source
261/// and the same units as each other. Windows reads `InOctets`/`OutOctets` from
262/// `GetIfTable2`, which is the same source `Get-NetAdapterStatistics` reports and needs no
263/// subprocess. macOS reads `if_data64` from `sysctl(NET_RT_IFLIST2)` — see [`mac_ffi`] for
264/// why the obvious `getifaddrs` route is wrong. Empty on other platforms.
265///
266/// Loopback is excluded on all three — its traffic is the machine talking to itself and
267/// says nothing about network throughput.
268///
269/// **The interface names must stay in the same vocabulary as `active_interface`**, or
270/// [`select_net_rates`] silently stops matching and falls through to its "everything that
271/// moved" branch. On Windows both are the adapter's friendly name (`Wi-Fi`): sysinfo
272/// reports it, and it is `MIB_IF_ROW2.Alias`. On macOS both are the BSD interface name
273/// (`en9`): sysinfo reports it, and `if_indextoname` returns it.
274pub fn sample_net_io() -> Vec<IoCounters> {
275    #[cfg(target_os = "linux")]
276    {
277        let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
278            return Vec::new();
279        };
280        let mut out = Vec::new();
281        for entry in entries.flatten() {
282            let name = entry.file_name().to_string_lossy().to_string();
283            if name == "lo" || name.starts_with("lo:") {
284                continue;
285            }
286            let stats = entry.path().join("statistics");
287            let read = read_counter(&stats.join("rx_bytes"));
288            let write = read_counter(&stats.join("tx_bytes"));
289            if let (Some(read), Some(write)) = (read, write) {
290                out.push(IoCounters {
291                    device: name,
292                    read,
293                    write,
294                });
295            }
296        }
297        out.sort_by(|a, b| a.device.cmp(&b.device));
298        out
299    }
300
301    #[cfg(target_os = "windows")]
302    {
303        win_ffi::sample_interfaces()
304    }
305
306    #[cfg(target_os = "macos")]
307    {
308        mac_ffi::sample_interfaces()
309    }
310
311    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
312    {
313        Vec::new()
314    }
315}
316
317/// Reads a single unsigned counter from a sysfs file.
318#[cfg(target_os = "linux")]
319fn read_counter(path: &std::path::Path) -> Option<u64> {
320    std::fs::read_to_string(path)
321        .ok()?
322        .trim()
323        .parse::<u64>()
324        .ok()
325}
326
327/// Names a physical drive after its `\\.\PhysicalDriveN` index.
328///
329/// The Linux arm reports kernel device names (`nvme0n1`), so Windows reports the closest
330/// equivalent rather than the model string `phys-disk` shows — the two fields answer
331/// different questions, and a drive index is what identifies the device here.
332#[cfg(any(target_os = "windows", test))]
333fn physical_drive_name(index: u32) -> String {
334    format!("PhysicalDrive{index}")
335}
336
337/// Native Win32 bindings for the two counter sources.
338///
339/// Hand-written `extern "system"` declarations, matching the crate's Windows FFI house
340/// style (`win_reg.rs`, `disk.rs`) rather than pulling in a binding crate. The
341/// `CreateFileW`/`DeviceIoControl`/`CloseHandle` declarations duplicate `disk.rs`'s: they
342/// are declarations of the same OS entry points, carrying no logic that could drift, and
343/// sharing them would mean passing raw `HANDLE`s across module boundaries. The scan range
344/// they are used over *is* shared — see [`crate::disk::MAX_PHYSICAL_DRIVES`].
345#[cfg(target_os = "windows")]
346mod win_ffi {
347    use super::{physical_drive_name, IoCounters};
348    use std::ffi::{c_void, OsStr};
349    use std::mem::size_of;
350    use std::os::windows::ffi::OsStrExt;
351    use std::ptr;
352
353    #[allow(clippy::upper_case_acronyms)]
354    type HANDLE = *mut c_void;
355    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
356    const FILE_SHARE_READ: u32 = 0x0000_0001;
357    const FILE_SHARE_WRITE: u32 = 0x0000_0002;
358    const OPEN_EXISTING: u32 = 3;
359
360    /// `IOCTL_DISK_PERFORMANCE`, `CTL_CODE(IOCTL_DISK_BASE, 0x0008, METHOD_BUFFERED,
361    /// FILE_ANY_ACCESS)`.
362    ///
363    /// The access bits are zero, so — like the two IOCTLs `disk.rs` uses — it can be
364    /// issued on a handle opened with no access rights and needs no elevation. Confirmed
365    /// on Windows 11 from an unelevated shell before this code was written.
366    const IOCTL_DISK_PERFORMANCE: u32 = 0x0007_0020;
367
368    /// `DISK_PERFORMANCE`. Only the two byte counters are read; the rest of the struct is
369    /// declared so the layout — and therefore those two offsets — is right.
370    #[repr(C)]
371    #[derive(Default)]
372    struct DiskPerformance {
373        bytes_read: i64,
374        bytes_written: i64,
375        read_time: i64,
376        write_time: i64,
377        idle_time: i64,
378        read_count: u32,
379        write_count: u32,
380        queue_depth: u32,
381        split_count: u32,
382        query_time: i64,
383        storage_device_number: u32,
384        storage_manager_name: [u16; 8],
385    }
386
387    extern "system" {
388        fn CreateFileW(
389            lp_file_name: *const u16,
390            dw_desired_access: u32,
391            dw_share_mode: u32,
392            lp_security_attributes: *mut c_void,
393            dw_creation_disposition: u32,
394            dw_flags_and_attributes: u32,
395            h_template_file: HANDLE,
396        ) -> HANDLE;
397
398        fn DeviceIoControl(
399            h_device: HANDLE,
400            dw_io_control_code: u32,
401            lp_in_buffer: *const c_void,
402            n_in_buffer_size: u32,
403            lp_out_buffer: *mut c_void,
404            n_out_buffer_size: u32,
405            lp_bytes_returned: *mut u32,
406            lp_overlapped: *mut c_void,
407        ) -> i32;
408
409        fn CloseHandle(h_object: HANDLE) -> i32;
410    }
411
412    /// Reads cumulative byte counters for every physical drive that answers.
413    ///
414    /// A drive that will not open, or whose IOCTL fails, is **skipped rather than
415    /// reported as zero**: `DISK_PERFORMANCE` counters can be turned off, and a confident
416    /// `0 B/s` for a disk that was never measured is worse than no line at all — the
417    /// `Users: 0` call (v0.6.1).
418    pub fn sample_physical_drives() -> Vec<IoCounters> {
419        (0..crate::disk::MAX_PHYSICAL_DRIVES)
420            .filter_map(query_drive_counters)
421            .collect()
422    }
423
424    /// Opens `\\.\PhysicalDrive{index}` with no access rights and queries its counters.
425    fn query_drive_counters(index: u32) -> Option<IoCounters> {
426        let path = format!(r"\\.\PhysicalDrive{index}");
427        let path_w: Vec<u16> = OsStr::new(&path).encode_wide().chain(Some(0)).collect();
428
429        // SAFETY: path_w is a valid null-terminated wide string. Zero desired access is
430        // sufficient for IOCTL_DISK_PERFORMANCE, which is FILE_ANY_ACCESS.
431        let handle = unsafe {
432            CreateFileW(
433                path_w.as_ptr(),
434                0,
435                FILE_SHARE_READ | FILE_SHARE_WRITE,
436                ptr::null_mut(),
437                OPEN_EXISTING,
438                0,
439                ptr::null_mut(),
440            )
441        };
442        if handle == INVALID_HANDLE_VALUE || handle.is_null() {
443            return None;
444        }
445
446        let mut perf = DiskPerformance::default();
447        let mut returned: u32 = 0;
448        // SAFETY: perf is a writable DiskPerformance passed with its own size; the IOCTL
449        // takes no input buffer.
450        let ok = unsafe {
451            DeviceIoControl(
452                handle,
453                IOCTL_DISK_PERFORMANCE,
454                ptr::null(),
455                0,
456                &mut perf as *mut _ as *mut c_void,
457                size_of::<DiskPerformance>() as u32,
458                &mut returned,
459                ptr::null_mut(),
460            )
461        };
462        // SAFETY: handle came from a successful CreateFileW and is closed exactly once.
463        unsafe {
464            CloseHandle(handle);
465        }
466
467        if ok == 0 || (returned as usize) < size_of::<DiskPerformance>() {
468            return None;
469        }
470        // Negative counters are not reachable from a working driver, but the field is a
471        // signed LARGE_INTEGER; clamp rather than wrap into an enormous u64.
472        Some(IoCounters {
473            device: physical_drive_name(index),
474            read: perf.bytes_read.max(0) as u64,
475            write: perf.bytes_written.max(0) as u64,
476        })
477    }
478
479    /// Reads cumulative per-interface byte counters via the shared `GetIfTable2`
480    /// enumeration, which has already excluded NDIS filter instances and loopback.
481    pub fn sample_interfaces() -> Vec<IoCounters> {
482        crate::win_iftable::interfaces()
483            .into_iter()
484            .map(|row| IoCounters {
485                device: row.name,
486                read: row.in_octets,
487                write: row.out_octets,
488            })
489            .collect()
490    }
491
492    #[cfg(test)]
493    mod layout {
494        use std::mem::{offset_of, size_of};
495
496        // These structs are read by fixed offset — MIB_IF_ROW2's counters sit past 1.2 KB
497        // of preceding fields, so a reorder or a padding change would silently read some
498        // other field's bytes as a byte count. The expected values were confirmed against
499        // live data before being pinned here: reading in/out_octets at these offsets
500        // reproduced `Get-NetAdapterStatistics`' per-adapter totals.
501        #[test]
502        fn ffi_struct_layout() {
503            assert_eq!(size_of::<super::DiskPerformance>(), 88);
504            assert_eq!(offset_of!(super::DiskPerformance, bytes_read), 0);
505            assert_eq!(offset_of!(super::DiskPerformance, bytes_written), 8);
506        }
507    }
508}
509
510/// macOS per-interface byte counters via `sysctl(NET_RT_IFLIST2)`.
511///
512/// **This deliberately does NOT use `getifaddrs`, and the reason is the whole point of
513/// this module.** `getifaddrs` hands back `struct if_data`, whose `ifi_ibytes`/`ifi_obytes`
514/// are **32-bit** and therefore wrap every 4 GiB. That is not a theoretical concern: the
515/// machine this was written on read `en0 ibytes = 3_317_575_680`, i.e. 77% of the way to
516/// the ceiling on a single boot, so the wrap would have landed inside an ordinary session
517/// and produced a plausible-looking wrong rate rather than an obvious failure.
518///
519/// `NET_RT_IFLIST2` returns `if_msghdr2` records carrying `if_data64`, whose counters are
520/// genuinely 64-bit. It is the source `netstat -ib` itself reads; verified against it
521/// under a sustained download (this 60.89 MB/s vs netstat 59.54 MB/s over overlapping
522/// windows, both 0 B/s idle).
523///
524/// This is the exact shape of the `GetIfTable` vs `GetIfTable2` decision recorded in the
525/// v0.11.0 entry — the older call is easier to reach for and silently wraps.
526#[cfg(target_os = "macos")]
527mod mac_ffi {
528    use super::IoCounters;
529    use std::ffi::{c_void, CStr};
530
531    /// `NET_RT_IFLIST2` — the routing-table op that returns 64-bit interface counters.
532    pub(super) const NET_RT_IFLIST2: i32 = 6;
533    /// `RTM_IFINFO2` — the message type carrying an `if_msghdr2`.
534    pub(super) const RTM_IFINFO2: u8 = 0x12;
535
536    /// Reads the interface list into cumulative counters.
537    ///
538    /// Loopback is excluded, matching the Linux and Windows arms: its traffic is the
539    /// machine talking to itself and says nothing about network throughput. Every other
540    /// interface is reported, exactly as on Linux — [`super::select_net_rates`] narrows to
541    /// the default-route interface when one is known, so the extras only ever surface in
542    /// its fallback branch.
543    ///
544    /// Names come from `if_indextoname` rather than by parsing the trailing `sockaddr_dl`.
545    /// That keeps the vocabulary identical to `active_interface`'s (`en9` here, confirmed
546    /// against the live `Net` field) without this module having to know the sockaddr
547    /// layout at all.
548    pub(super) fn sample_interfaces() -> Vec<IoCounters> {
549        let mut out = Vec::new();
550        let mut mib: [i32; 6] = [libc::CTL_NET, libc::PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0];
551
552        // SAFETY: the two-call sizing pattern. The first call writes only `len`; the
553        // second fills a buffer of exactly that size. `mib` is a valid 6-element array.
554        unsafe {
555            let mut len: libc::size_t = 0;
556            if libc::sysctl(
557                mib.as_mut_ptr(),
558                6,
559                std::ptr::null_mut(),
560                &mut len,
561                std::ptr::null_mut(),
562                0,
563            ) != 0
564                || len == 0
565            {
566                return out;
567            }
568            let mut buf = vec![0u8; len];
569            if libc::sysctl(
570                mib.as_mut_ptr(),
571                6,
572                buf.as_mut_ptr() as *mut c_void,
573                &mut len,
574                std::ptr::null_mut(),
575                0,
576            ) != 0
577            {
578                return out;
579            }
580            // The second call can report a shorter length than the first reserved.
581            buf.truncate(len);
582
583            let hdr_size = std::mem::size_of::<libc::if_msghdr2>();
584            let mut off = 0usize;
585            while off + hdr_size <= buf.len() {
586                let hdr = &*(buf.as_ptr().add(off) as *const libc::if_msghdr2);
587                let msglen = hdr.ifm_msglen as usize;
588                // A zero msglen would not advance the cursor: bail rather than spin.
589                if msglen == 0 {
590                    break;
591                }
592                // NOTE: read these fields by COPY, never by reference. `if_data64` is
593                // 4-byte aligned but its counters are `u64`, so `&hdr.ifm_data.ifi_ibytes`
594                // is a misaligned reference — undefined behaviour even if never
595                // dereferenced, and rejected by rustc as E0793. Copying into the struct
596                // literal below is what keeps this sound.
597                if hdr.ifm_type == RTM_IFINFO2 {
598                    let mut namebuf = [0i8; libc::IF_NAMESIZE];
599                    let np = libc::if_indextoname(hdr.ifm_index as u32, namebuf.as_mut_ptr());
600                    if !np.is_null() {
601                        let name = CStr::from_ptr(namebuf.as_ptr())
602                            .to_string_lossy()
603                            .to_string();
604                        if !is_loopback(&name) {
605                            out.push(IoCounters {
606                                device: name,
607                                read: hdr.ifm_data.ifi_ibytes,
608                                write: hdr.ifm_data.ifi_obytes,
609                            });
610                        }
611                    }
612                }
613                off += msglen;
614            }
615        }
616        out.sort_by(|a, b| a.device.cmp(&b.device));
617        out
618    }
619
620    /// True for the loopback interface. macOS names it `lo0`; the prefix test also covers
621    /// any additional `loN` the kernel may present.
622    pub(super) fn is_loopback(name: &str) -> bool {
623        name.starts_with("lo")
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    /// Verbatim `/proc/diskstats` excerpt from a Fedora 44 host (corrino): one NVMe whole
632    /// disk, three of its partitions, and a zram device.
633    const DISKSTATS: &str = "\
634 259       0 nvme0n1 881658 6545 23259904 398678 110634 278 3327562 320368 0 53695 721779 6070 0 2002840 1798 2314 934
635 259       1 nvme0n1p1 338 1067 10262 173 2 0 2 0 0 24 173 0 0 0 0 0 0
636 259       2 nvme0n1p2 289 12 7954 45 22 17 288 6 0 42 51 0 0 0 0 0 0
637 259       3 nvme0n1p3 880938 5466 23238992 398446 110607 261 3327272 320361 0 65541 720606 6070 0 2002840 1798 0 0
638 251       0 zram0 46534 0 381008 231 252558 0 2769832 3263 0 5270 3494 0 0 0 0 0 0
639";
640
641    #[test]
642    fn test_parse_diskstats_reads_the_sector_columns() {
643        let parsed = parse_diskstats(DISKSTATS, |n| n == "nvme0n1");
644        assert_eq!(parsed.len(), 1);
645        // Columns 6 and 10 of the line, in 512-byte sectors.
646        assert_eq!(parsed[0].read, 23_259_904 * 512);
647        assert_eq!(parsed[0].write, 3_327_562 * 512);
648    }
649
650    #[test]
651    fn test_parse_diskstats_honors_the_injected_filter() {
652        // The filter is what keeps partitions out; without it their traffic is counted a
653        // second time against the same physical device.
654        let all = parse_diskstats(DISKSTATS, |_| true);
655        assert_eq!(all.len(), 5);
656        let whole = parse_diskstats(DISKSTATS, |n| !n.starts_with("zram") && !n.contains('p'));
657        assert_eq!(
658            whole.iter().map(|c| c.device.as_str()).collect::<Vec<_>>(),
659            vec!["nvme0n1"]
660        );
661    }
662
663    #[test]
664    fn test_parse_diskstats_skips_malformed_lines() {
665        let content = "259 0 nvme0n1 1 2\n259 0 sda 1 2 x 4 5 6 notanumber 8 9 10\n";
666        assert!(parse_diskstats(content, |_| true).is_empty());
667    }
668
669    #[test]
670    fn test_compute_rates_divides_the_delta_by_the_window() {
671        let before = vec![IoCounters {
672            device: "nvme0n1".into(),
673            read: 1_000,
674            write: 2_000,
675        }];
676        let after = vec![IoCounters {
677            device: "nvme0n1".into(),
678            read: 3_000,
679            write: 2_000,
680        }];
681        let rates = compute_rates(&before, &after, 0.5);
682        assert_eq!(rates.len(), 1);
683        assert_eq!(rates[0].read, 4_000.0);
684        assert_eq!(rates[0].write, 0.0);
685    }
686
687    #[test]
688    fn test_compute_rates_drops_devices_missing_from_either_sample() {
689        let before = vec![IoCounters {
690            device: "eth0".into(),
691            read: 10,
692            write: 10,
693        }];
694        let after = vec![
695            IoCounters {
696                device: "eth0".into(),
697                read: 20,
698                write: 10,
699            },
700            // Appeared mid-run: its lifetime counter is not a delta.
701            IoCounters {
702                device: "wt0".into(),
703                read: 9_999_999,
704                write: 9_999_999,
705            },
706        ];
707        let rates = compute_rates(&before, &after, 1.0);
708        assert_eq!(rates.len(), 1);
709        assert_eq!(rates[0].device, "eth0");
710    }
711
712    #[test]
713    fn test_compute_rates_clamps_a_counter_reset_to_zero() {
714        // An interface going down and up resets its counters; a wrapping subtraction here
715        // renders an exabyte-per-second reading from an ordinary event.
716        //
717        // The clamp reports 0, NOT the post-reset counter. A decrease says the baseline is
718        // void, not how many bytes flowed after it — and 1024 bytes into a window is a
719        // guess that is wrong whenever the decrease had some other cause. Under-reporting
720        // beats asserting something false, the same call as `Users: 0` (v0.6.1) and the
721        // ambiguous input devices (v0.7.0).
722        let before = vec![
723            IoCounters {
724                device: "wlan0".into(),
725                read: 5_000_000,
726                write: 5_000_000,
727            },
728            IoCounters {
729                device: "eth0".into(),
730                read: 1_000,
731                write: 1_000,
732            },
733        ];
734        let after = vec![
735            IoCounters {
736                device: "wlan0".into(),
737                read: 1_024,
738                write: 0,
739            },
740            IoCounters {
741                device: "eth0".into(),
742                read: 3_000,
743                write: 1_000,
744            },
745        ];
746        let rates = compute_rates(&before, &after, 1.0);
747        assert_eq!(rates[0].device, "wlan0");
748        assert_eq!(rates[0].read, 0.0);
749        assert_eq!(rates[0].write, 0.0);
750        // The unaffected device in the same pair must still report, so this test cannot
751        // pass by every rate happening to be zero.
752        assert_eq!(rates[1].device, "eth0");
753        assert_eq!(rates[1].read, 2_000.0);
754    }
755
756    #[test]
757    fn test_compute_rates_refuses_a_zero_or_negative_window() {
758        let sample = vec![IoCounters {
759            device: "nvme0n1".into(),
760            read: 1,
761            write: 1,
762        }];
763        assert!(compute_rates(&sample, &sample, 0.0).is_empty());
764        assert!(compute_rates(&sample, &sample, -1.0).is_empty());
765        assert!(compute_rates(&sample, &sample, f64::NAN).is_empty());
766    }
767
768    #[test]
769    fn test_format_rate_matches_the_net_field_units() {
770        assert_eq!(format_rate(0.0), "0 B/s");
771        assert_eq!(format_rate(512.0), "512 B/s");
772        assert_eq!(format_rate(1024.0), "1.0 KB/s");
773        assert_eq!(format_rate(1024.0 * 1024.0 * 1.5), "1.5 MB/s");
774        // Not reachable from compute_rates, but the formatter is public.
775        assert_eq!(format_rate(f64::NAN), "0 B/s");
776        assert_eq!(format_rate(-1.0), "0 B/s");
777    }
778
779    #[test]
780    fn test_format_io_line() {
781        let rate = IoRate {
782            device: "nvme0n1".into(),
783            read: 0.0,
784            write: 1024.0 * 308.0,
785        };
786        assert_eq!(
787            format_io_line(&rate, "R", "W"),
788            "nvme0n1 R: 0 B/s W: 308.0 KB/s"
789        );
790    }
791
792    #[test]
793    fn test_select_net_rates_prefers_the_active_interface() {
794        let rates = vec![
795            IoRate {
796                device: "wlp0s20f3".into(),
797                read: 100.0,
798                write: 50.0,
799            },
800            IoRate {
801                device: "wt0".into(),
802                read: 10.0,
803                write: 10.0,
804            },
805        ];
806        let selected = select_net_rates(rates, Some("wlp0s20f3"));
807        assert_eq!(selected.len(), 1);
808        assert_eq!(selected[0].device, "wlp0s20f3");
809    }
810
811    #[test]
812    fn test_select_net_rates_keeps_an_idle_active_interface() {
813        // 0 B/s on the interface you are actually using is a reading, not a miss.
814        let rates = vec![IoRate {
815            device: "eth0".into(),
816            read: 0.0,
817            write: 0.0,
818        }];
819        let selected = select_net_rates(rates, Some("eth0"));
820        assert_eq!(selected.len(), 1);
821        assert_eq!(selected[0].device, "eth0");
822    }
823
824    #[test]
825    fn test_physical_drive_name_matches_the_device_path() {
826        assert_eq!(physical_drive_name(0), "PhysicalDrive0");
827        assert_eq!(physical_drive_name(31), "PhysicalDrive31");
828    }
829
830    #[test]
831    fn test_select_net_rates_falls_back_to_busy_interfaces() {
832        let rates = vec![
833            IoRate {
834                device: "eth0".into(),
835                read: 0.0,
836                write: 0.0,
837            },
838            IoRate {
839                device: "wt0".into(),
840                read: 1.0,
841                write: 0.0,
842            },
843        ];
844        // Unknown active interface: report what moved, not everything.
845        let selected = select_net_rates(rates.clone(), None);
846        assert_eq!(selected.len(), 1);
847        assert_eq!(selected[0].device, "wt0");
848        // An active interface that is not in the list at all falls back the same way.
849        let selected = select_net_rates(rates, Some("ppp0"));
850        assert_eq!(selected.len(), 1);
851        assert_eq!(selected[0].device, "wt0");
852    }
853
854    /// The macOS counters must be 64-bit, and this is the test that says so.
855    ///
856    /// `getifaddrs`' `if_data` carries **32-bit** byte counters that wrap every 4 GiB;
857    /// `NET_RT_IFLIST2`'s `if_data64` does not. The wrap is not hypothetical — the machine
858    /// this was developed on sat at 3.09 GiB on `en0`, so the obvious source would have
859    /// wrapped mid-session and reported a plausible wrong rate instead of failing.
860    ///
861    /// This asserts the property by *type*: the annotated bindings below stop compiling if
862    /// a future `libc` ever narrowed these fields, rather than silently reintroducing the
863    /// wrap. A value assertion could not catch that; only the type can.
864    ///
865    /// **The bindings must be copies, not references.** `if_data64` is 4-byte aligned
866    /// while `ifi_ibytes` is a `u64`, so `&data.ifi_ibytes` is a misaligned reference —
867    /// which is undefined behaviour *even if never dereferenced*, and rustc rejects it
868    /// outright (E0793). The first version of this test used `size_of_val(&field)` and
869    /// failed to compile for exactly that reason. The production sampler is safe for the
870    /// same reason this test now is: it copies the field into a struct literal rather than
871    /// borrowing it.
872    #[cfg(target_os = "macos")]
873    #[test]
874    fn test_macos_interface_counters_are_64_bit() {
875        let data: libc::if_data64 = unsafe { std::mem::zeroed() };
876        let ibytes: u64 = data.ifi_ibytes;
877        let obytes: u64 = data.ifi_obytes;
878        assert_eq!(ibytes, 0);
879        assert_eq!(obytes, 0);
880
881        // The narrow source, pinned so the distinction this module exists for stays
882        // visible: `if_data`'s counters are u32 and wrap every 4 GiB.
883        let narrow: libc::if_data = unsafe { std::mem::zeroed() };
884        let narrow_ibytes: u32 = narrow.ifi_ibytes;
885        assert_eq!(narrow_ibytes, 0);
886    }
887
888    /// Pins the two sysctl constants. A wrong `NET_RT_IFLIST2` returns the *32-bit*
889    /// `NET_RT_IFLIST` (5) layout, and a wrong `RTM_IFINFO2` matches no record at all —
890    /// both of which present as "this Mac has no interfaces", not as an error.
891    #[cfg(target_os = "macos")]
892    #[test]
893    fn test_macos_sysctl_constants() {
894        assert_eq!(mac_ffi::NET_RT_IFLIST2, 6);
895        assert_eq!(mac_ffi::RTM_IFINFO2, 0x12);
896    }
897
898    #[cfg(target_os = "macos")]
899    #[test]
900    fn test_macos_loopback_is_excluded() {
901        assert!(mac_ffi::is_loopback("lo0"));
902        assert!(mac_ffi::is_loopback("lo"));
903        assert!(!mac_ffi::is_loopback("en0"));
904        assert!(!mac_ffi::is_loopback("utun100"));
905        // `llw0` (low-latency WLAN) starts with `l` but is a real interface.
906        assert!(!mac_ffi::is_loopback("llw0"));
907    }
908}