sysinfo/unix/linux/
disk.rs

1// Take a look at the license at the top of the repository in the LICENSE file.
2
3use crate::sys::utils::{get_all_utf8_data, to_cpath};
4use crate::{Disk, DiskKind, DiskRefreshKind, DiskUsage};
5
6use libc::statvfs;
7use std::collections::HashMap;
8use std::ffi::{OsStr, OsString};
9use std::fs;
10use std::mem::MaybeUninit;
11use std::os::unix::ffi::OsStrExt;
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14
15/// Copied from [`psutil`]:
16///
17/// "man iostat" states that sectors are equivalent with blocks and have
18/// a size of 512 bytes. Despite this value can be queried at runtime
19/// via /sys/block/{DISK}/queue/hw_sector_size and results may vary
20/// between 1k, 2k, or 4k... 512 appears to be a magic constant used
21/// throughout Linux source code:
22/// * <https://stackoverflow.com/a/38136179/376587>
23/// * <https://lists.gt.net/linux/kernel/2241060>
24/// * <https://github.com/giampaolo/psutil/issues/1305>
25/// * <https://github.com/torvalds/linux/blob/4f671fe2f9523a1ea206f63fe60a7c7b3a56d5c7/include/linux/bio.h#L99>
26/// * <https://lkml.org/lkml/2015/8/17/234>
27///
28/// [`psutil`]: <https://github.com/giampaolo/psutil/blob/master/psutil/_pslinux.py#L103>
29const SECTOR_SIZE: u64 = 512;
30
31macro_rules! cast {
32    ($x:expr) => {
33        u64::from($x)
34    };
35}
36
37pub(crate) struct DiskInner {
38    type_: DiskKind,
39    device_name: OsString,
40    actual_device_name: Option<String>,
41    pub(crate) file_system: OsString,
42    mount_point: PathBuf,
43    total_space: u64,
44    available_space: u64,
45    is_removable: bool,
46    is_read_only: bool,
47    old_written_bytes: u64,
48    old_read_bytes: u64,
49    written_bytes: u64,
50    read_bytes: u64,
51    updated: bool,
52}
53
54#[cfg(test)]
55impl Default for DiskInner {
56    fn default() -> Self {
57        Self {
58            type_: DiskKind::Unknown(0),
59            device_name: OsString::new(),
60            actual_device_name: None,
61            file_system: OsString::new(),
62            mount_point: PathBuf::new(),
63            total_space: 0,
64            available_space: 0,
65            is_removable: false,
66            is_read_only: false,
67            old_written_bytes: 0,
68            old_read_bytes: 0,
69            written_bytes: 0,
70            read_bytes: 0,
71            updated: false,
72        }
73    }
74}
75
76impl DiskInner {
77    pub(crate) fn kind(&self) -> DiskKind {
78        self.type_
79    }
80
81    pub(crate) fn name(&self) -> &OsStr {
82        &self.device_name
83    }
84
85    pub(crate) fn file_system(&self) -> &OsStr {
86        &self.file_system
87    }
88
89    pub(crate) fn mount_point(&self) -> &Path {
90        &self.mount_point
91    }
92
93    pub(crate) fn total_space(&self) -> u64 {
94        self.total_space
95    }
96
97    pub(crate) fn available_space(&self) -> u64 {
98        self.available_space
99    }
100
101    pub(crate) fn is_removable(&self) -> bool {
102        self.is_removable
103    }
104
105    pub(crate) fn is_read_only(&self) -> bool {
106        self.is_read_only
107    }
108
109    pub(crate) fn refresh_specifics(&mut self, refresh_kind: DiskRefreshKind) -> bool {
110        self.efficient_refresh(refresh_kind, &disk_stats(&refresh_kind), false)
111    }
112
113    fn efficient_refresh(
114        &mut self,
115        refresh_kind: DiskRefreshKind,
116        procfs_disk_stats: &HashMap<String, DiskStat>,
117        first: bool,
118    ) -> bool {
119        if refresh_kind.io_usage() {
120            if self.actual_device_name.is_none() {
121                self.actual_device_name = Some(get_actual_device_name(&self.device_name));
122            }
123            if let Some(stat) = self
124                .actual_device_name
125                .as_ref()
126                .and_then(|actual_device_name| procfs_disk_stats.get(actual_device_name))
127            {
128                self.old_read_bytes = self.read_bytes;
129                self.old_written_bytes = self.written_bytes;
130                self.read_bytes = stat.sectors_read * SECTOR_SIZE;
131                self.written_bytes = stat.sectors_written * SECTOR_SIZE;
132            } else {
133                sysinfo_debug!("Failed to update disk i/o stats");
134            }
135        }
136
137        if refresh_kind.kind() && self.type_ == DiskKind::Unknown(-1) {
138            self.type_ = find_type_for_device_name(&self.device_name);
139        }
140
141        if refresh_kind.storage()
142            && let Some((total_space, available_space, is_read_only)) =
143                unsafe { load_statvfs_values(&self.mount_point) }
144        {
145            self.total_space = total_space;
146            self.available_space = available_space;
147            if first {
148                self.is_read_only = is_read_only;
149            }
150        }
151
152        true
153    }
154
155    pub(crate) fn usage(&self) -> DiskUsage {
156        DiskUsage {
157            read_bytes: self.read_bytes.saturating_sub(self.old_read_bytes),
158            total_read_bytes: self.read_bytes,
159            written_bytes: self.written_bytes.saturating_sub(self.old_written_bytes),
160            total_written_bytes: self.written_bytes,
161        }
162    }
163}
164
165impl crate::DisksInner {
166    pub(crate) fn new() -> Self {
167        Self {
168            disks: Vec::with_capacity(2),
169        }
170    }
171
172    pub(crate) fn refresh_specifics(
173        &mut self,
174        remove_not_listed_disks: bool,
175        refresh_kind: DiskRefreshKind,
176    ) {
177        get_all_list(
178            &mut self.disks,
179            &get_all_utf8_data("/proc/mounts", 16_385).unwrap_or_default(),
180            refresh_kind,
181        );
182
183        if remove_not_listed_disks {
184            self.disks.retain_mut(|disk| {
185                if !disk.inner.updated {
186                    return false;
187                }
188                disk.inner.updated = false;
189                true
190            });
191        } else {
192            for c in self.disks.iter_mut() {
193                c.inner.updated = false;
194            }
195        }
196    }
197
198    pub(crate) fn list(&self) -> &[Disk] {
199        &self.disks
200    }
201
202    pub(crate) fn list_mut(&mut self) -> &mut [Disk] {
203        &mut self.disks
204    }
205}
206
207/// Resolves the actual device name for a specified `device` from `/proc/mounts`
208///
209/// This function is inspired by the [`bottom`] crate implementation and essentially does the following:
210///     1. Canonicalizes the specified device path to its absolute form
211///     2. Strips the "/dev" prefix from the canonicalized path
212///
213/// [`bottom`]: <https://github.com/ClementTsang/bottom/blob/main/src/data_collection/disks/unix/linux/partition.rs#L44>
214fn get_actual_device_name(device: &OsStr) -> String {
215    let device_path = PathBuf::from(device);
216
217    std::fs::canonicalize(&device_path)
218        .ok()
219        .and_then(|path| path.strip_prefix("/dev").ok().map(Path::to_path_buf))
220        .unwrap_or(device_path)
221        .to_str()
222        .map(str::to_owned)
223        .unwrap_or_default()
224}
225
226unsafe fn load_statvfs_values(mount_point: &Path) -> Option<(u64, u64, bool)> {
227    let mount_point_cpath = to_cpath(mount_point);
228    let mut stat: MaybeUninit<statvfs> = MaybeUninit::uninit();
229    if unsafe {
230        retry_eintr!(statvfs(
231            mount_point_cpath.as_ptr() as *const _,
232            stat.as_mut_ptr()
233        ))
234    } == 0
235    {
236        let stat = unsafe { stat.assume_init() };
237
238        let bsize = cast!(stat.f_bsize);
239        let blocks = cast!(stat.f_blocks);
240        let bavail = cast!(stat.f_bavail);
241        let total = bsize.saturating_mul(blocks);
242        if total == 0 {
243            return None;
244        }
245        let available = bsize.saturating_mul(bavail);
246        let is_read_only = (stat.f_flag & libc::ST_RDONLY) != 0;
247
248        Some((total, available, is_read_only))
249    } else {
250        None
251    }
252}
253
254fn new_disk(
255    device_name: &OsStr,
256    mount_point: &Path,
257    file_system: &OsStr,
258    removable_entries: &[PathBuf],
259    procfs_disk_stats: &HashMap<String, DiskStat>,
260    refresh_kind: DiskRefreshKind,
261) -> Disk {
262    let is_removable = removable_entries
263        .iter()
264        .any(|e| e.as_os_str() == device_name);
265
266    let mut disk = Disk {
267        inner: DiskInner {
268            type_: DiskKind::Unknown(-1),
269            device_name: device_name.to_owned(),
270            actual_device_name: None,
271            file_system: file_system.to_owned(),
272            mount_point: mount_point.to_owned(),
273            total_space: 0,
274            available_space: 0,
275            is_removable,
276            is_read_only: false,
277            old_read_bytes: 0,
278            old_written_bytes: 0,
279            read_bytes: 0,
280            written_bytes: 0,
281            updated: true,
282        },
283    };
284    disk.inner
285        .efficient_refresh(refresh_kind, procfs_disk_stats, true);
286    disk
287}
288
289#[allow(clippy::manual_range_contains)]
290fn find_type_for_device_name(device_name: &OsStr) -> DiskKind {
291    // The format of devices are as follows:
292    //  - device_name is symbolic link in the case of /dev/mapper/
293    //     and /dev/root, and the target is corresponding device under
294    //     /sys/block/
295    //  - In the case of /dev/sd, the format is /dev/sd[a-z][1-9],
296    //     corresponding to /sys/block/sd[a-z]
297    //  - In the case of /dev/nvme, the format is /dev/nvme[0-9]n[0-9]p[0-9],
298    //     corresponding to /sys/block/nvme[0-9]n[0-9]
299    //  - In the case of /dev/mmcblk, the format is /dev/mmcblk[0-9]p[0-9],
300    //     corresponding to /sys/block/mmcblk[0-9]
301    let device_name_path = device_name.to_str().unwrap_or_default();
302    let real_path = fs::canonicalize(device_name).unwrap_or_else(|_| PathBuf::from(device_name));
303    let mut real_path = real_path.to_str().unwrap_or_default();
304    if device_name_path.starts_with("/dev/mapper/") {
305        // Recursively solve, for example /dev/dm-0
306        if real_path != device_name_path {
307            return find_type_for_device_name(OsStr::new(&real_path));
308        }
309    } else if device_name_path.starts_with("/dev/sd") || device_name_path.starts_with("/dev/vd") {
310        // Turn "sda1" into "sda" or "vda1" into "vda"
311        real_path = real_path.trim_start_matches("/dev/");
312        real_path = real_path.trim_end_matches(|c| c >= '0' && c <= '9');
313    } else if device_name_path.starts_with("/dev/nvme") {
314        // Turn "nvme0n1p1" into "nvme0n1"
315        real_path = match real_path.find('p') {
316            Some(idx) => &real_path["/dev/".len()..idx],
317            None => &real_path["/dev/".len()..],
318        };
319    } else if device_name_path.starts_with("/dev/root") {
320        // Recursively solve, for example /dev/mmcblk0p1
321        if real_path != device_name_path {
322            return find_type_for_device_name(OsStr::new(&real_path));
323        }
324    } else if device_name_path.starts_with("/dev/mmcblk") {
325        // Turn "mmcblk0p1" into "mmcblk0"
326        real_path = match real_path.find('p') {
327            Some(idx) => &real_path["/dev/".len()..idx],
328            None => &real_path["/dev/".len()..],
329        };
330    } else {
331        // Default case: remove /dev/ and expects the name presents under /sys/block/
332        // For example, /dev/dm-0 to dm-0
333        real_path = real_path.trim_start_matches("/dev/");
334    }
335
336    let trimmed: &OsStr = OsStrExt::from_bytes(real_path.as_bytes());
337
338    let path = Path::new("/sys/block/")
339        .to_owned()
340        .join(trimmed)
341        .join("queue/rotational");
342    // Normally, this file only contains '0' or '1' but just in case, we get 8 bytes...
343    match get_all_utf8_data(path, 8)
344        .unwrap_or_default()
345        .trim()
346        .parse()
347        .ok()
348    {
349        // The disk is marked as rotational so it's a HDD.
350        Some(1) => DiskKind::HDD,
351        // The disk is marked as non-rotational so it's very likely a SSD.
352        Some(0) => DiskKind::SSD,
353        // Normally it shouldn't happen but welcome to the wonderful world of IT! :D
354        Some(x) => DiskKind::Unknown(x),
355        // The information isn't available...
356        None => DiskKind::Unknown(-1),
357    }
358}
359
360fn get_all_list(container: &mut Vec<Disk>, content: &str, refresh_kind: DiskRefreshKind) {
361    // The goal of this array is to list all removable devices (the ones whose name starts with
362    // "usb-").
363    let removable_entries = match fs::read_dir("/dev/disk/by-id/") {
364        Ok(r) => r
365            .filter_map(|res| Some(res.ok()?.path()))
366            .filter_map(|e| {
367                if e.file_name()
368                    .and_then(|x| Some(x.to_str()?.starts_with("usb-")))
369                    .unwrap_or_default()
370                {
371                    e.canonicalize().ok()
372                } else {
373                    None
374                }
375            })
376            .collect::<Vec<PathBuf>>(),
377        _ => Vec::new(),
378    };
379
380    let procfs_disk_stats = disk_stats(&refresh_kind);
381
382    for (fs_spec, fs_file, fs_vfstype) in content
383        .lines()
384        .map(|line| {
385            let line = line.trim_start();
386            // mounts format
387            // http://man7.org/linux/man-pages/man5/fstab.5.html
388            // fs_spec<tab>fs_file<tab>fs_vfstype<tab>other fields
389            let mut fields = line.split_whitespace();
390            let fs_spec = fields.next().unwrap_or("");
391            let fs_file = fields
392                .next()
393                .unwrap_or("")
394                .replace("\\134", "\\")
395                .replace("\\040", " ")
396                .replace("\\011", "\t")
397                .replace("\\012", "\n");
398            let fs_vfstype = fields.next().unwrap_or("");
399            (fs_spec, fs_file, fs_vfstype)
400        })
401        .filter(|(fs_spec, fs_file, fs_vfstype)| {
402            // Check if fs_vfstype is one of our 'ignored' file systems.
403            let filtered = match *fs_vfstype {
404                "rootfs" | // https://www.kernel.org/doc/Documentation/filesystems/ramfs-rootfs-initramfs.txt
405                "sysfs" | // pseudo file system for kernel objects
406                "proc" |  // another pseudo file system
407                "devtmpfs" |
408                "cgroup" |
409                "cgroup2" |
410                "pstore" | // https://www.kernel.org/doc/Documentation/ABI/testing/pstore
411                "squashfs" | // squashfs is a compressed read-only file system (for snaps)
412                "rpc_pipefs" | // The pipefs pseudo file system service
413                "iso9660" | // optical media
414                "devpts" | // https://www.kernel.org/doc/Documentation/filesystems/devpts.txt
415                "hugetlbfs" | // https://www.kernel.org/doc/Documentation/vm/hugetlbfs_reserv.txt
416                "mqueue" // https://man7.org/linux/man-pages/man7/mq_overview.7.html
417                => true,
418                "tmpfs" => !cfg!(feature = "linux-tmpfs"),
419                // calling statvfs on a mounted CIFS or NFS or through autofs may hang, when they are mounted with option: hard
420                "cifs" | "nfs" | "nfs4" | "autofs" => !cfg!(feature = "linux-netdevs"),
421                _ => false,
422            };
423
424            !(filtered ||
425               fs_file.starts_with("/sys") || // check if fs_file is an 'ignored' mount point
426               fs_file.starts_with("/proc") ||
427               (fs_file.starts_with("/run") && !fs_file.starts_with("/run/media")) ||
428               fs_spec.starts_with("sunrpc"))
429        })
430    {
431        let mount_point = Path::new(&fs_file);
432        if let Some(disk) = container.iter_mut().find(|d| {
433            d.inner.mount_point == mount_point
434                && d.inner.device_name == fs_spec
435                && d.inner.file_system == fs_vfstype
436        }) {
437            disk.inner
438                .efficient_refresh(refresh_kind, &procfs_disk_stats, false);
439            disk.inner.updated = true;
440            continue;
441        }
442        container.push(new_disk(
443            fs_spec.as_ref(),
444            mount_point,
445            fs_vfstype.as_ref(),
446            &removable_entries,
447            &procfs_disk_stats,
448            refresh_kind,
449        ));
450    }
451}
452
453/// Disk IO stat information from `/proc/diskstats` file.
454///
455/// To fully understand these fields, please see the
456/// [iostats.txt](https://www.kernel.org/doc/Documentation/iostats.txt) kernel documentation.
457///
458/// This type only contains the value `sysinfo` is interested into.
459///
460/// The fields of this file are:
461/// 1. major number
462/// 2. minor number
463/// 3. device name
464/// 4. reads completed successfully
465/// 5. reads merged
466/// 6. sectors read
467/// 7. time spent reading (ms)
468/// 8. writes completed
469/// 9. writes merged
470/// 10. sectors written
471/// 11. time spent writing (ms)
472/// 12. I/Os currently in progress
473/// 13. time spent doing I/Os (ms)
474/// 14. weighted time spent doing I/Os (ms)
475///
476/// Doc reference: https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
477///
478/// Doc reference: https://www.kernel.org/doc/Documentation/iostats.txt
479#[derive(Debug, PartialEq)]
480struct DiskStat {
481    sectors_read: u64,
482    sectors_written: u64,
483}
484
485impl DiskStat {
486    /// Returns the name and the values we're interested into.
487    fn new_from_line(line: &str) -> Option<(String, Self)> {
488        let mut iter = line.split_whitespace();
489        // 3rd field
490        let name = iter.nth(2).map(ToString::to_string)?;
491        // 6th field
492        let sectors_read = iter.nth(2).and_then(|v| u64::from_str(v).ok()).unwrap_or(0);
493        // 10th field
494        let sectors_written = iter.nth(3).and_then(|v| u64::from_str(v).ok()).unwrap_or(0);
495        Some((
496            name,
497            Self {
498                sectors_read,
499                sectors_written,
500            },
501        ))
502    }
503}
504
505fn disk_stats(refresh_kind: &DiskRefreshKind) -> HashMap<String, DiskStat> {
506    if refresh_kind.io_usage() {
507        let path = "/proc/diskstats";
508        match fs::read_to_string(path) {
509            Ok(content) => disk_stats_inner(&content),
510            Err(_error) => {
511                sysinfo_debug!("failed to read {path:?}: {_error:?}");
512                HashMap::new()
513            }
514        }
515    } else {
516        Default::default()
517    }
518}
519
520// We split this function out to make it possible to test it.
521fn disk_stats_inner(content: &str) -> HashMap<String, DiskStat> {
522    let mut data = HashMap::new();
523
524    for line in content.lines() {
525        let line = line.trim();
526        if line.is_empty() {
527            continue;
528        }
529        if let Some((name, stats)) = DiskStat::new_from_line(line) {
530            data.insert(name, stats);
531        }
532    }
533    data
534}
535
536#[cfg(test)]
537mod test {
538    use super::{DiskStat, disk_stats_inner};
539    use std::collections::HashMap;
540
541    #[test]
542    fn test_disk_stat_parsing() {
543        // Content of a (very nicely formatted) `/proc/diskstats` file.
544        let file_content = "\
545 259       0 nvme0n1   571695 101559 38943220 165643 9824246  1076193 462375378 4140037  0  1038904 4740493  254020 0  1436922320 68519 306875 366293
546 259       1 nvme0n1p1 240    2360   15468    48     2        0       2         0        0  21      50       8      0  2373552    2     0      0
547 259       2 nvme0n1p2 243    10     11626    26     63       39      616       125      0  84      163      44     0  1075280    11    0      0
548 259       3 nvme0n1p3 571069 99189  38910302 165547 9824180  1076154 462374760 4139911  0  1084855 4373964  253968 0  1433473488 68505 0      0
549 253       0 dm-0      670206 0      38909056 259490 10900330 0       462374760 12906518 0  1177098 13195902 253968 0  1433473488 29894 0      0
550 252       0 zram0     2382   0      20984    11     260261   0       2082088   2063     0  1964    2074     0      0  0          0     0      0
551 1         2 bla       4      5      6        7      8        9       10        11       12 13      14       15     16 17         18    19     20
552";
553
554        let data = disk_stats_inner(file_content);
555        let expected_data: HashMap<String, DiskStat> = HashMap::from([
556            (
557                "nvme0n1".to_string(),
558                DiskStat {
559                    sectors_read: 38943220,
560                    sectors_written: 462375378,
561                },
562            ),
563            (
564                "nvme0n1p1".to_string(),
565                DiskStat {
566                    sectors_read: 15468,
567                    sectors_written: 2,
568                },
569            ),
570            (
571                "nvme0n1p2".to_string(),
572                DiskStat {
573                    sectors_read: 11626,
574                    sectors_written: 616,
575                },
576            ),
577            (
578                "nvme0n1p3".to_string(),
579                DiskStat {
580                    sectors_read: 38910302,
581                    sectors_written: 462374760,
582                },
583            ),
584            (
585                "dm-0".to_string(),
586                DiskStat {
587                    sectors_read: 38909056,
588                    sectors_written: 462374760,
589                },
590            ),
591            (
592                "zram0".to_string(),
593                DiskStat {
594                    sectors_read: 20984,
595                    sectors_written: 2082088,
596                },
597            ),
598            // This one ensures that we read the correct fields.
599            (
600                "bla".to_string(),
601                DiskStat {
602                    sectors_read: 6,
603                    sectors_written: 10,
604                },
605            ),
606        ]);
607
608        assert_eq!(data, expected_data);
609    }
610
611    #[test]
612    fn disk_entry_with_less_information() {
613        let file_content = "\
614 systemd-1      /efi autofs rw,relatime,fd=181,pgrp=1,timeout=120,minproto=5,maxproto=5,direct,pipe_ino=8311 0 0
615 /dev/nvme0n1p1 /efi vfat   rw,nosuid,nodev,noexec,relatime,nosymfollow,fmask=0077,dmask=0077                0 0
616";
617
618        let data = disk_stats_inner(file_content);
619        let expected_data: HashMap<String, DiskStat> = HashMap::from([
620            (
621                "autofs".to_string(),
622                DiskStat {
623                    sectors_read: 0,
624                    sectors_written: 0,
625                },
626            ),
627            (
628                "vfat".to_string(),
629                DiskStat {
630                    sectors_read: 0,
631                    sectors_written: 0,
632                },
633            ),
634        ]);
635
636        assert_eq!(data, expected_data);
637    }
638}