Skip to main content

rigtop_core/linux/
btrfs.rs

1//! btrfs filesystem health from `/sys/fs/btrfs/<fsid>`.
2//!
3//! Everything read here is world-readable, so RAID profile, allocation and
4//! per-device error counters are available without root. (`btrfs device
5//! stats` needs root and offers the same counters.)
6//!
7//! Layout used:
8//! - `label`
9//! - `devices/<name>`: one symlink per member block device
10//! - `devinfo/<devid>/missing`, `devinfo/<devid>/error_stats`
11//!
12//! sysfs has no devid-to-name mapping, so device names come from the
13//! `BTRFS_IOC_DEV_INFO` ioctl on a mount point, which needs no root either.
14//! - `allocation/<data|metadata|system>/<profile>/{total_bytes,used_bytes}`
15//!
16//! Scrub results are not in sysfs: btrfs-progs keeps them in
17//! `/var/lib/btrfs/scrub.status.<fsid>` (mode 0600). The root collector in
18//! `contrib/collect/` copies that file to a world-readable directory; see
19//! [`read_scrub`].
20
21use std::fs::{self, File};
22use std::os::fd::AsRawFd;
23use std::path::{Path, PathBuf};
24
25use super::mounts::MountEntry;
26use super::{SysRoots, read_trimmed, read_u64, sorted_entries};
27use crate::snapshot::{BtrfsAllocation, BtrfsDeviceStats, BtrfsFilesystem, BtrfsScrub};
28
29/// Where btrfs-progs itself writes scrub status files (readable by root only).
30pub const BTRFS_PROGS_STATUS_DIR: &str = "/var/lib/btrfs";
31
32/// Block group types in display order.
33const ALLOCATION_KINDS: &[&str] = &["data", "metadata", "system"];
34
35/// Reads every btrfs filesystem known to the kernel and attaches mount points
36/// from `mounts`.
37pub fn read_filesystems(roots: &SysRoots, mounts: &[MountEntry]) -> Vec<BtrfsFilesystem> {
38    let base = roots.sys.join("fs/btrfs");
39    sorted_entries(&base)
40        .into_iter()
41        // `features` is a global directory, not a filesystem.
42        .filter(|entry| entry != "features")
43        .map(|fsid| read_filesystem(&base.join(&fsid), fsid, mounts))
44        .collect()
45}
46
47fn read_filesystem(dir: &Path, fsid: String, mounts: &[MountEntry]) -> BtrfsFilesystem {
48    let devices = sorted_entries(&dir.join("devices"));
49    let mut device_stats: Vec<BtrfsDeviceStats> = sorted_entries(&dir.join("devinfo"))
50        .into_iter()
51        .filter_map(|devid| read_device_stats(&dir.join("devinfo").join(&devid), &devid))
52        .collect();
53    let allocations = ALLOCATION_KINDS
54        .iter()
55        .flat_map(|kind| read_allocations(&dir.join("allocation").join(kind), kind))
56        .collect();
57
58    // A btrfs mount's source can be any member device, so match on all of them.
59    let mut mount_points: Vec<String> = mounts
60        .iter()
61        .filter(|m| m.fs_type == "btrfs" && devices.contains(&device_basename(&m.device)))
62        .map(|m| m.mount_point.clone())
63        .collect();
64    mount_points.sort_by_key(|m| (m.len(), m.clone()));
65    mount_points.dedup();
66
67    if let Some(mount) = mount_points.first().and_then(|m| File::open(m).ok()) {
68        apply_dev_info(&mut device_stats, |devid| dev_info(&mount, devid));
69    }
70
71    BtrfsFilesystem {
72        label: read_trimmed(&dir.join("label")).unwrap_or_default(),
73        fsid,
74        mount_points,
75        devices,
76        device_stats,
77        allocations,
78        scrub: None,
79    }
80}
81
82/// Reads the last scrub result of filesystem `fsid` from the first of `dirs`
83/// containing a readable `scrub.status.<fsid>`.
84pub fn read_scrub(dirs: &[PathBuf], fsid: &str) -> Option<BtrfsScrub> {
85    let name = format!("scrub.status.{fsid}");
86    dirs.iter()
87        .find_map(|dir| fs::read_to_string(dir.join(&name)).ok())
88        .and_then(|text| parse_scrub_status(&text))
89}
90
91/// Parses a btrfs-progs scrub status file:
92///
93/// ```text
94/// scrub status:1
95/// <fsid>:<devid>|data_bytes_scrubbed:N|...|t_start:EPOCH|duration:S|canceled:0|finished:1
96/// ```
97///
98/// One line per device; the result sums the devices. Returns `None` when the
99/// file has no device line or no start time (scrub never ran).
100pub(crate) fn parse_scrub_status(text: &str) -> Option<BtrfsScrub> {
101    let mut scrub = BtrfsScrub::default();
102    let mut started: Option<u64> = None;
103    let (mut all_finished, mut any_canceled, mut devices) = (true, false, 0);
104    for line in text.lines().filter(|l| l.contains('|')) {
105        devices += 1;
106        let mut finished = false;
107        // Skip the "<fsid>:<devid>" prefix, then read key:value pairs.
108        for pair in line.split('|').skip(1) {
109            let Some((key, value)) = pair.split_once(':') else {
110                continue;
111            };
112            let Ok(value) = value.trim().parse::<u64>() else {
113                continue;
114            };
115            match key {
116                "data_bytes_scrubbed" | "tree_bytes_scrubbed" => scrub.bytes_scrubbed += value,
117                "read_errors" | "csum_errors" | "verify_errors" | "super_errors" => {
118                    scrub.errors += value
119                }
120                "corrected_errors" => scrub.corrected += value,
121                "uncorrectable_errors" => scrub.uncorrectable += value,
122                // Devices run in parallel: earliest start, longest duration.
123                "t_start" if value > 0 => started = Some(started.map_or(value, |s| s.min(value))),
124                "duration" => scrub.duration_secs = scrub.duration_secs.max(value),
125                "canceled" => any_canceled |= value != 0,
126                "finished" => finished = value != 0,
127                _ => {}
128            }
129        }
130        all_finished &= finished;
131    }
132    let started = started.filter(|_| devices > 0)?;
133    scrub.started_at_ms = started.saturating_mul(1000);
134    scrub.state = if any_canceled {
135        "canceled"
136    } else if all_finished {
137        "finished"
138    } else {
139        "unfinished"
140    }
141    .to_owned();
142    Some(scrub)
143}
144
145/// What `BTRFS_IOC_DEV_INFO` reports about one device.
146#[derive(Debug, Clone, PartialEq)]
147pub(crate) struct DevInfo {
148    /// Device path, e.g. `/dev/nvme0n1`.
149    pub path: String,
150    pub total_bytes: u64,
151    pub used_bytes: u64,
152}
153
154/// Fills names and sizes into `stats` from `lookup` (the ioctl, or a fake in
155/// tests). Devices it knows nothing about keep their `devid` label.
156pub(crate) fn apply_dev_info(
157    stats: &mut [BtrfsDeviceStats],
158    mut lookup: impl FnMut(u64) -> Option<DevInfo>,
159) {
160    for device in stats {
161        let Some(info) = lookup(device.devid) else {
162            continue;
163        };
164        // A missing device has no path.
165        if let Some(name) = Path::new(&info.path).file_name() {
166            device.name = name.to_string_lossy().into_owned();
167        }
168        device.size_bytes = Some(info.total_bytes);
169        device.used_bytes = Some(info.used_bytes);
170    }
171}
172
173/// `_IOWR(BTRFS_IOCTL_MAGIC = 0x94, 30, struct btrfs_ioctl_dev_info_args)`;
174/// the argument struct is 4096 bytes.
175const BTRFS_IOC_DEV_INFO: u32 = 0xD000_941E;
176/// Offsets in `struct btrfs_ioctl_dev_info_args` (linux/btrfs.h): devid,
177/// uuid[16], bytes_used, total_bytes, fsid[16], unused[377], path[1024].
178const DEV_INFO_SIZE: usize = 4096;
179const DEV_INFO_USED: usize = 24;
180const DEV_INFO_TOTAL: usize = 32;
181const DEV_INFO_PATH: usize = 3072;
182
183/// Asks the filesystem mounted at `mount` about device `devid`.
184fn dev_info(mount: &File, devid: u64) -> Option<DevInfo> {
185    let mut args = [0u8; DEV_INFO_SIZE];
186    args[..8].copy_from_slice(&devid.to_ne_bytes());
187    // SAFETY: the buffer has the size the request encodes, and the kernel
188    // only writes within it.
189    let result = unsafe {
190        libc::ioctl(
191            mount.as_raw_fd(),
192            BTRFS_IOC_DEV_INFO as _,
193            args.as_mut_ptr(),
194        )
195    };
196    if result != 0 {
197        return None;
198    }
199    let u64_at = |at: usize| u64::from_ne_bytes(args[at..at + 8].try_into().unwrap());
200    let path = &args[DEV_INFO_PATH..];
201    let end = path.iter().position(|&b| b == 0).unwrap_or(path.len());
202    Some(DevInfo {
203        path: String::from_utf8_lossy(&path[..end]).into_owned(),
204        total_bytes: u64_at(DEV_INFO_TOTAL),
205        used_bytes: u64_at(DEV_INFO_USED),
206    })
207}
208
209/// Reads `devinfo/<devid>`. Returns `None` for non-numeric entries.
210fn read_device_stats(dir: &Path, devid: &str) -> Option<BtrfsDeviceStats> {
211    let mut stats = BtrfsDeviceStats {
212        devid: devid.parse().ok()?,
213        missing: read_u64(&dir.join("missing")).is_some_and(|v| v != 0),
214        ..Default::default()
215    };
216    // error_stats: one "<name> <count>" pair per line.
217    let text = fs::read_to_string(dir.join("error_stats")).unwrap_or_default();
218    for line in text.lines() {
219        let Some((name, value)) = line.split_once(' ') else {
220            continue;
221        };
222        let Ok(value) = value.trim().parse::<u64>() else {
223            continue;
224        };
225        match name {
226            "write_errs" => stats.write_errs = value,
227            "read_errs" => stats.read_errs = value,
228            "flush_errs" => stats.flush_errs = value,
229            "corruption_errs" => stats.corruption_errs = value,
230            "generation_errs" => stats.generation_errs = value,
231            _ => {}
232        }
233    }
234    Some(stats)
235}
236
237/// Reads the profile subdirectories of one block group type. Usually there is
238/// exactly one; two appear while a balance converts between profiles.
239fn read_allocations(dir: &Path, kind: &str) -> Vec<BtrfsAllocation> {
240    sorted_entries(dir)
241        .into_iter()
242        .filter(|entry| dir.join(entry).is_dir())
243        .filter_map(|profile| {
244            let profile_dir = dir.join(&profile);
245            Some(BtrfsAllocation {
246                kind: kind.to_owned(),
247                total_bytes: read_u64(&profile_dir.join("total_bytes"))?,
248                used_bytes: read_u64(&profile_dir.join("used_bytes"))?,
249                profile,
250            })
251        })
252        .collect()
253}
254
255/// Maps a mount source such as `/dev/nvme1n1p1` or `/dev/mapper/root` to the
256/// kernel device name used under `devices/` (`nvme1n1p1`, `dm-0`).
257fn device_basename(device: &str) -> String {
258    let path = Path::new(device);
259    // Device-mapper names are symlinks to `../dm-N`; resolve them.
260    let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
261    resolved
262        .file_name()
263        .map(|n| n.to_string_lossy().into_owned())
264        .unwrap_or_default()
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::linux::testutil::write;
271
272    const FSID: &str = "acb63bf0-ab89-4069-bb64-d3352482c507";
273
274    fn fixture(root: &Path) -> SysRoots {
275        let base = format!("sys/fs/btrfs/{FSID}");
276        write(root, "sys/fs/btrfs/features/raid1c34", "0\n");
277        write(root, &format!("{base}/label"), "\n");
278        for dev in ["nvme0n1", "nvme1n1p1", "nvme2n1"] {
279            write(root, &format!("{base}/devices/{dev}/size"), "0\n");
280        }
281        for devid in 1..=3 {
282            let corruption = if devid == 2 { 3 } else { 0 };
283            write(
284                root,
285                &format!("{base}/devinfo/{devid}/error_stats"),
286                &format!(
287                    "write_errs 0\nread_errs 0\nflush_errs 0\ncorruption_errs {corruption}\ngeneration_errs 0\n"
288                ),
289            );
290            write(root, &format!("{base}/devinfo/{devid}/missing"), "0\n");
291        }
292        write(
293            root,
294            &format!("{base}/allocation/data/raid0/total_bytes"),
295            "1000\n",
296        );
297        write(
298            root,
299            &format!("{base}/allocation/data/raid0/used_bytes"),
300            "900\n",
301        );
302        // Files directly under the kind directory must not be taken as profiles.
303        write(
304            root,
305            &format!("{base}/allocation/data/total_bytes"),
306            "1000\n",
307        );
308        write(
309            root,
310            &format!("{base}/allocation/metadata/raid1c3/total_bytes"),
311            "30\n",
312        );
313        write(
314            root,
315            &format!("{base}/allocation/metadata/raid1c3/used_bytes"),
316            "10\n",
317        );
318        SysRoots {
319            sys: root.join("sys"),
320            proc: root.join("proc"),
321        }
322    }
323
324    fn mount(device: &str, mount_point: &str, fs_type: &str) -> MountEntry {
325        MountEntry {
326            device: device.to_owned(),
327            mount_point: mount_point.to_owned(),
328            fs_type: fs_type.to_owned(),
329        }
330    }
331
332    #[test]
333    fn parses_scrub_status_across_devices() {
334        let text = "scrub status:1\n\
335acb6:1|data_extents_scrubbed:10|data_bytes_scrubbed:1000|tree_bytes_scrubbed:24|read_errors:0|csum_errors:2|verify_errors:0|super_errors:0|uncorrectable_errors:0|corrected_errors:2|last_physical:0|t_start:1792700000|t_resumed:0|duration:3600|canceled:0|finished:1\n\
336acb6:2|data_bytes_scrubbed:2000|csum_errors:0|t_start:1792700005|duration:4000|canceled:0|finished:1\n";
337        let scrub = parse_scrub_status(text).unwrap();
338        assert_eq!(scrub.started_at_ms, 1_792_700_000_000);
339        assert_eq!(scrub.duration_secs, 4000);
340        assert_eq!(scrub.state, "finished");
341        assert_eq!(scrub.bytes_scrubbed, 3024);
342        assert_eq!(scrub.errors, 2);
343        assert_eq!(scrub.corrected, 2);
344    }
345
346    #[test]
347    fn scrub_state_and_missing_status() {
348        let running = "scrub status:1\nfs:1|t_start:100|finished:0|canceled:0\nfs:2|t_start:100|finished:1|canceled:0\n";
349        assert_eq!(parse_scrub_status(running).unwrap().state, "unfinished");
350        let canceled = "scrub status:1\nfs:1|t_start:100|finished:0|canceled:1\n";
351        assert_eq!(parse_scrub_status(canceled).unwrap().state, "canceled");
352        assert!(parse_scrub_status("scrub status:1\n").is_none());
353
354        // The first directory with the file wins; missing directories are skipped.
355        let tmp = tempfile::tempdir().unwrap();
356        let dir = tmp.path().join("copies");
357        fs::create_dir_all(&dir).unwrap();
358        fs::write(dir.join("scrub.status.abc"), canceled).unwrap();
359        let dirs = vec![tmp.path().join("absent"), dir];
360        assert_eq!(read_scrub(&dirs, "abc").unwrap().state, "canceled");
361        assert!(read_scrub(&dirs, "other").is_none());
362    }
363
364    #[test]
365    fn reads_devices_errors_and_profiles() {
366        let tmp = tempfile::tempdir().unwrap();
367        let roots = fixture(tmp.path());
368        let mounts = vec![
369            mount("/dev/nvme1n1p1", "/home", "btrfs"),
370            mount("/dev/nvme1n1p1", "/", "btrfs"),
371            mount("/dev/sda1", "/boot", "ext4"),
372        ];
373
374        let filesystems = read_filesystems(&roots, &mounts);
375        assert_eq!(filesystems.len(), 1);
376        let fs = &filesystems[0];
377        assert_eq!(fs.fsid, FSID);
378        assert_eq!(fs.devices, vec!["nvme0n1", "nvme1n1p1", "nvme2n1"]);
379        assert_eq!(fs.mount_points, vec!["/", "/home"]);
380
381        assert_eq!(fs.device_stats.len(), 3);
382        assert_eq!(fs.device_stats[1].devid, 2);
383        assert_eq!(fs.device_stats[1].corruption_errs, 3);
384        assert!(fs.has_problems());
385
386        let profiles: Vec<(&str, &str)> = fs
387            .allocations
388            .iter()
389            .map(|a| (a.kind.as_str(), a.profile.as_str()))
390            .collect();
391        assert_eq!(profiles, vec![("data", "raid0"), ("metadata", "raid1c3")]);
392        assert_eq!(fs.allocations[0].used_bytes, 900);
393    }
394
395    #[test]
396    fn device_names_come_from_dev_info() {
397        let mut stats: Vec<BtrfsDeviceStats> = (1..=3)
398            .map(|devid| BtrfsDeviceStats {
399                devid,
400                ..Default::default()
401            })
402            .collect();
403        apply_dev_info(&mut stats, |devid| match devid {
404            1 => Some(DevInfo {
405                path: "/dev/nvme2n1p1".to_owned(),
406                total_bytes: 2000,
407                used_bytes: 500,
408            }),
409            // A missing device: known to btrfs, without a path.
410            2 => Some(DevInfo {
411                path: String::new(),
412                total_bytes: 2000,
413                used_bytes: 0,
414            }),
415            _ => None,
416        });
417        assert_eq!(stats[0].label(), "nvme2n1p1");
418        assert_eq!(stats[0].size_bytes, Some(2000));
419        assert_eq!(stats[1].label(), "devid 2");
420        assert_eq!(stats[2].label(), "devid 3");
421        assert_eq!(stats[2].size_bytes, None);
422    }
423
424    /// The ioctl itself, where the root filesystem is btrfs (skipped
425    /// elsewhere, e.g. on CI).
426    #[test]
427    fn dev_info_on_a_real_btrfs_root() {
428        let mounts = fs::read_to_string("/proc/self/mounts").unwrap_or_default();
429        if !mounts
430            .lines()
431            .any(|l| l.split_whitespace().nth(1) == Some("/") && l.contains(" btrfs "))
432        {
433            return;
434        }
435        let root = File::open("/").unwrap();
436        let info = dev_info(&root, 1).expect("devid 1 of the root filesystem");
437        assert!(info.path.starts_with("/dev/"), "{info:?}");
438        assert!(
439            info.total_bytes > 0 && info.used_bytes <= info.total_bytes,
440            "{info:?}"
441        );
442    }
443}