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