Skip to main content

retch_sysinfo/
btrfs.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Btrfs filesystem detection — label, subvolume, and space allocation for
5//! mounted btrfs volumes.
6//!
7//! Linux only: btrfs is not a supported filesystem on macOS or Windows.
8
9/// Detects mounted btrfs filesystems and reports label + subvolume + allocation
10/// for each mount point.
11///
12/// One entry is reported per mount point (not per underlying device), since a
13/// single btrfs filesystem is commonly mounted at multiple points via distinct
14/// subvolumes (e.g. `/` and `/home` on the same device). Snapshot counts are
15/// best-effort: `btrfs subvolume list` requires root, so the count is omitted
16/// (rather than shown as zero) when it can't be read.
17///
18/// Shells out to `btrfs filesystem show`/`usage`, so this is gated behind
19/// `--long` and above like other subprocess-based fields (bios, wifi, ...).
20pub fn detect_btrfs() -> Vec<String> {
21    #[cfg(target_os = "linux")]
22    {
23        detect_linux()
24    }
25
26    #[cfg(not(target_os = "linux"))]
27    {
28        Vec::new()
29    }
30}
31
32#[cfg(target_os = "linux")]
33fn detect_linux() -> Vec<String> {
34    let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
35    let mut results = Vec::new();
36
37    for line in mounts.lines() {
38        let Some(entry) = parse_mount_line(line) else {
39            continue;
40        };
41        if entry.fs_type != "btrfs" {
42            continue;
43        }
44
45        if let Some(formatted) = format_btrfs_volume(entry.mount_point, entry.subvol) {
46            results.push(formatted);
47        }
48    }
49
50    results
51}
52
53#[cfg(target_os = "linux")]
54struct MountEntry<'a> {
55    mount_point: &'a str,
56    fs_type: &'a str,
57    subvol: Option<&'a str>,
58}
59
60/// Parses one `/proc/mounts` line into device/mountpoint/fstype/subvol-option.
61#[cfg(target_os = "linux")]
62fn parse_mount_line(line: &str) -> Option<MountEntry<'_>> {
63    let parts: Vec<&str> = line.splitn(4, ' ').collect();
64    if parts.len() < 4 {
65        return None;
66    }
67    let mount_point = parts[1];
68    let fs_type = parts[2];
69    // parts[3] is "options dump pass"; options is the first whitespace-separated token.
70    let options = parts[3].split(' ').next().unwrap_or("");
71    let subvol = options
72        .split(',')
73        .find_map(|opt| opt.strip_prefix("subvol="));
74
75    Some(MountEntry {
76        mount_point,
77        fs_type,
78        subvol,
79    })
80}
81
82/// Runs `btrfs filesystem show`/`usage --raw` for a mountpoint and formats the result.
83#[cfg(target_os = "linux")]
84fn format_btrfs_volume(mount_point: &str, subvol: Option<&str>) -> Option<String> {
85    let label = std::process::Command::new("btrfs")
86        .args(["filesystem", "show", mount_point])
87        .output()
88        .ok()
89        .filter(|o| o.status.success())
90        .and_then(|o| parse_btrfs_label(&String::from_utf8_lossy(&o.stdout)));
91
92    let usage = std::process::Command::new("btrfs")
93        .args(["filesystem", "usage", "--raw", mount_point])
94        .output()
95        .ok()
96        .filter(|o| o.status.success())
97        .and_then(|o| parse_btrfs_usage(&String::from_utf8_lossy(&o.stdout)))?;
98
99    let (used, size) = usage;
100    let used_gb = used as f64 / 1_073_741_824.0;
101    let size_gb = size as f64 / 1_073_741_824.0;
102
103    let snapshots = count_snapshots(mount_point);
104
105    let name = match (label, subvol) {
106        (Some(label), Some(subvol)) => format!("{} ({}, subvol={})", label, mount_point, subvol),
107        (Some(label), None) => format!("{} ({})", label, mount_point),
108        (None, Some(subvol)) => format!("{} (subvol={})", mount_point, subvol),
109        (None, None) => mount_point.to_string(),
110    };
111
112    Some(match snapshots {
113        Some(n) => format!(
114            "{}: {:.1} / {:.1} GB used, {} snapshot{}",
115            name,
116            used_gb,
117            size_gb,
118            n,
119            if n == 1 { "" } else { "s" }
120        ),
121        None => format!("{}: {:.1} / {:.1} GB used", name, used_gb, size_gb),
122    })
123}
124
125/// Counts snapshot subvolumes under a mountpoint via `btrfs subvolume list -s`.
126///
127/// Requires root (`CAP_SYS_ADMIN`); returns `None` (not `Some(0)`) if the
128/// query fails, so callers can distinguish "no snapshots" from "couldn't check".
129#[cfg(target_os = "linux")]
130fn count_snapshots(mount_point: &str) -> Option<usize> {
131    let output = std::process::Command::new("btrfs")
132        .args(["subvolume", "list", "-s", mount_point])
133        .output()
134        .ok()?;
135    if !output.status.success() {
136        return None;
137    }
138    let text = String::from_utf8_lossy(&output.stdout);
139    Some(text.lines().filter(|l| !l.trim().is_empty()).count())
140}
141
142/// Parses the `Label: 'name'` line from `btrfs filesystem show` output.
143#[cfg(target_os = "linux")]
144fn parse_btrfs_label(text: &str) -> Option<String> {
145    let line = text.lines().next()?;
146    let start = line.find('\'')? + 1;
147    let rest = &line[start..];
148    let end = rest.find('\'')?;
149    let label = &rest[..end];
150    if label.is_empty() {
151        None
152    } else {
153        Some(label.to_string())
154    }
155}
156
157/// Parses `Device size` and `Used` (in bytes) from `btrfs filesystem usage --raw` output.
158#[cfg(target_os = "linux")]
159fn parse_btrfs_usage(text: &str) -> Option<(u64, u64)> {
160    let mut size = None;
161    let mut used = None;
162
163    for line in text.lines() {
164        let trimmed = line.trim();
165        if let Some(rest) = trimmed.strip_prefix("Device size:") {
166            size = rest.trim().parse::<u64>().ok();
167        } else if let Some(rest) = trimmed.strip_prefix("Used:") {
168            used = rest.trim().parse::<u64>().ok();
169        }
170    }
171
172    Some((used?, size?))
173}
174
175#[cfg(test)]
176mod tests {
177    #[cfg(target_os = "linux")]
178    use super::{parse_btrfs_label, parse_btrfs_usage, parse_mount_line};
179
180    #[cfg(target_os = "linux")]
181    #[test]
182    fn test_parse_mount_line_btrfs_with_subvol() {
183        let line = "/dev/nvme0n1p3 /home btrfs rw,seclabel,relatime,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=257,subvol=/home 0 0";
184        let entry = parse_mount_line(line).unwrap();
185        assert_eq!(entry.mount_point, "/home");
186        assert_eq!(entry.fs_type, "btrfs");
187        assert_eq!(entry.subvol, Some("/home"));
188    }
189
190    #[cfg(target_os = "linux")]
191    #[test]
192    fn test_parse_mount_line_two_subvols_same_device_both_kept() {
193        // Regression: previously deduped by device, silently dropping /home
194        // when / and /home share the same underlying btrfs filesystem.
195        let root = "/dev/nvme0n1p3 / btrfs rw,subvolid=256,subvol=/root 0 0";
196        let home = "/dev/nvme0n1p3 /home btrfs rw,subvolid=257,subvol=/home 0 0";
197        let root_entry = parse_mount_line(root).unwrap();
198        let home_entry = parse_mount_line(home).unwrap();
199        assert_eq!(root_entry.mount_point, "/");
200        assert_eq!(root_entry.subvol, Some("/root"));
201        assert_eq!(home_entry.mount_point, "/home");
202        assert_eq!(home_entry.subvol, Some("/home"));
203    }
204
205    #[cfg(target_os = "linux")]
206    #[test]
207    fn test_parse_mount_line_non_btrfs() {
208        let line = "/dev/nvme0n1p1 /boot ext4 rw,seclabel,relatime 0 0";
209        let entry = parse_mount_line(line).unwrap();
210        assert_eq!(entry.fs_type, "ext4");
211    }
212
213    #[cfg(target_os = "linux")]
214    #[test]
215    fn test_parse_mount_line_no_subvol_option() {
216        let line = "/dev/sdb1 /mnt/data btrfs rw,relatime,space_cache=v2 0 0";
217        let entry = parse_mount_line(line).unwrap();
218        assert_eq!(entry.subvol, None);
219    }
220
221    #[cfg(target_os = "linux")]
222    #[test]
223    fn test_parse_btrfs_label() {
224        let text = "Label: 'root'  uuid: 1234abcd-5678-90ef-1234-567890abcdef\n\tTotal devices 1 FS bytes used 5.02GiB\n";
225        assert_eq!(parse_btrfs_label(text), Some("root".to_string()));
226    }
227
228    #[cfg(target_os = "linux")]
229    #[test]
230    fn test_parse_btrfs_label_none() {
231        let text = "Label: none  uuid: 1234abcd-5678-90ef-1234-567890abcdef\n";
232        assert_eq!(parse_btrfs_label(text), None);
233    }
234
235    #[cfg(target_os = "linux")]
236    #[test]
237    fn test_parse_btrfs_usage() {
238        let text = "Overall:\n    Device size:                  21474836480\n    Device allocated:              5390387200\n    Device unallocated:           16084449280\n    Device missing:                          0\n    Used:                          3576184832\n    Free (estimated):             17650311168      (min: 17650311168)\n";
239        assert_eq!(
240            parse_btrfs_usage(text),
241            Some((3_576_184_832, 21_474_836_480))
242        );
243    }
244
245    #[cfg(target_os = "linux")]
246    #[test]
247    fn test_parse_btrfs_usage_missing_fields() {
248        let text = "Overall:\n    Device size:                  21474836480\n";
249        assert_eq!(parse_btrfs_usage(text), None);
250    }
251}