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