Skip to main content

btrfs_uapi/
sysfs.rs

1//! # Sysfs interface: reading filesystem and device state from `/sys/fs/btrfs/`
2//!
3//! The kernel exposes per-filesystem information under
4//! `/sys/fs/btrfs/<uuid>/`, where `<uuid>` is the filesystem UUID as returned
5//! by [`filesystem_info`][`crate::filesystem::filesystem_info`]. This includes commit statistics,
6//! feature flags, quota state, and per-device scrub limits.
7//!
8//! The primary entry point is [`SysfsBtrfs`], which is constructed from a
9//! filesystem UUID and provides typed accessors for each sysfs file:
10//!
11//! ```no_run
12//! # use btrfs_uapi::{filesystem::filesystem_info, sysfs::SysfsBtrfs};
13//! # use std::{fs::File, os::unix::io::AsFd};
14//! # let file = File::open("/mnt/btrfs").unwrap();
15//! # let fd = file.as_fd();
16//! let info = filesystem_info(fd).unwrap();
17//! let sysfs = SysfsBtrfs::new(&info.uuid);
18//! println!("label: {}", sysfs.label().unwrap());
19//! println!("quota status: {:?}", sysfs.quota_status().unwrap());
20//! ```
21//!
22//! All accessors return [`std::io::Result`] and will return an error with kind
23//! [`std::io::ErrorKind::NotFound`] if the filesystem is not currently mounted.
24
25use std::{ffi::OsStr, fs, io, path::PathBuf};
26use uuid::Uuid;
27
28/// Returns the sysfs directory path for the btrfs filesystem with the given
29/// UUID: `/sys/fs/btrfs/<uuid>`.
30#[must_use]
31pub fn sysfs_btrfs_path(uuid: &Uuid) -> PathBuf {
32    PathBuf::from(format!("/sys/fs/btrfs/{}", uuid.as_hyphenated()))
33}
34
35/// Returns the path to a named file within the sysfs directory for the
36/// filesystem with the given UUID: `/sys/fs/btrfs/<uuid>/<name>`.
37#[must_use]
38pub fn sysfs_btrfs_path_file(uuid: &Uuid, name: &str) -> PathBuf {
39    sysfs_btrfs_path(uuid).join(name)
40}
41
42/// Commit statistics for a mounted btrfs filesystem, read from
43/// `/sys/fs/btrfs/<uuid>/commit_stats`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CommitStats {
46    /// Total number of commits since mount.
47    pub commits: u64,
48    /// Duration of the current (in-progress) commit in milliseconds.
49    pub cur_commit_ms: u64,
50    /// Duration of the last completed commit in milliseconds.
51    pub last_commit_ms: u64,
52    /// Maximum commit duration since mount (or last reset) in milliseconds.
53    pub max_commit_ms: u64,
54    /// Total time spent in commits since mount in milliseconds.
55    pub total_commit_ms: u64,
56}
57
58/// Provides typed access to the sysfs files exposed for a single mounted btrfs
59/// filesystem under `/sys/fs/btrfs/<uuid>/`.
60pub struct SysfsBtrfs {
61    base: PathBuf,
62}
63
64impl SysfsBtrfs {
65    /// Create a new `SysfsBtrfs` for the filesystem with the given UUID.
66    #[must_use]
67    pub fn new(uuid: &Uuid) -> Self {
68        Self {
69            base: sysfs_btrfs_path(uuid),
70        }
71    }
72
73    fn read_file(&self, name: &str) -> io::Result<String> {
74        let s = fs::read_to_string(self.base.join(name))?;
75        Ok(s.trim_end().to_owned())
76    }
77
78    fn read_u64(&self, name: &str) -> io::Result<u64> {
79        let s = self.read_file(name)?;
80        s.parse()
81            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
82    }
83
84    fn read_bool(&self, name: &str) -> io::Result<bool> {
85        Ok(self.read_u64(name)? != 0)
86    }
87
88    /// Background reclaim threshold as a percentage (0–100).
89    /// `/sys/fs/btrfs/<uuid>/bg_reclaim_threshold`
90    pub fn bg_reclaim_threshold(&self) -> io::Result<u64> {
91        self.read_u64("bg_reclaim_threshold")
92    }
93
94    /// Checksum algorithm in use, e.g. `"crc32c (crc32c-lib)"`.
95    /// `/sys/fs/btrfs/<uuid>/checksum`
96    pub fn checksum(&self) -> io::Result<String> {
97        self.read_file("checksum")
98    }
99
100    /// Minimum clone/reflink alignment in bytes.
101    /// `/sys/fs/btrfs/<uuid>/clone_alignment`
102    pub fn clone_alignment(&self) -> io::Result<u64> {
103        self.read_u64("clone_alignment")
104    }
105
106    /// Commit statistics since mount (or last reset).
107    /// `/sys/fs/btrfs/<uuid>/commit_stats`
108    pub fn commit_stats(&self) -> io::Result<CommitStats> {
109        let contents = self.read_file("commit_stats")?;
110        let mut commits = None;
111        let mut cur_commit_ms = None;
112        let mut last_commit_ms = None;
113        let mut max_commit_ms = None;
114        let mut total_commit_ms = None;
115
116        for line in contents.lines() {
117            let mut parts = line.splitn(2, ' ');
118            let key = parts.next().unwrap_or("").trim();
119            let val: u64 =
120                parts.next().unwrap_or("").trim().parse().map_err(|e| {
121                    io::Error::new(io::ErrorKind::InvalidData, e)
122                })?;
123            match key {
124                "commits" => commits = Some(val),
125                "cur_commit_ms" => cur_commit_ms = Some(val),
126                "last_commit_ms" => last_commit_ms = Some(val),
127                "max_commit_ms" => max_commit_ms = Some(val),
128                "total_commit_ms" => total_commit_ms = Some(val),
129                _ => {}
130            }
131        }
132
133        let missing = |name| {
134            io::Error::new(
135                io::ErrorKind::InvalidData,
136                format!("commit_stats: missing field '{name}'"),
137            )
138        };
139
140        Ok(CommitStats {
141            commits: commits.ok_or_else(|| missing("commits"))?,
142            cur_commit_ms: cur_commit_ms
143                .ok_or_else(|| missing("cur_commit_ms"))?,
144            last_commit_ms: last_commit_ms
145                .ok_or_else(|| missing("last_commit_ms"))?,
146            max_commit_ms: max_commit_ms
147                .ok_or_else(|| missing("max_commit_ms"))?,
148            total_commit_ms: total_commit_ms
149                .ok_or_else(|| missing("total_commit_ms"))?,
150        })
151    }
152
153    /// Reset the `max_commit_ms` counter by writing `0` to the `commit_stats`
154    /// file. Requires root.
155    /// `/sys/fs/btrfs/<uuid>/commit_stats`
156    pub fn reset_commit_stats(&self) -> io::Result<()> {
157        fs::write(self.base.join("commit_stats"), b"0")
158    }
159
160    /// Name of the exclusive operation currently running, e.g. `"none"`,
161    /// `"balance"`, `"device add"`.
162    /// `/sys/fs/btrfs/<uuid>/exclusive_operation`
163    pub fn exclusive_operation(&self) -> io::Result<String> {
164        self.read_file("exclusive_operation")
165    }
166
167    /// Wait until no exclusive operation is running on the filesystem.
168    ///
169    /// Polls the `exclusive_operation` sysfs file at one-second intervals.
170    /// Returns immediately if no exclusive operation is in progress, or after
171    /// the running operation completes. Returns the name of the operation
172    /// that was waited on, or `"none"` if nothing was running.
173    pub fn wait_for_exclusive_operation(&self) -> io::Result<String> {
174        let mut op = self.exclusive_operation()?;
175        if op == "none" {
176            return Ok(op);
177        }
178        let waited_for = op.clone();
179        while op != "none" {
180            std::thread::sleep(std::time::Duration::from_secs(1));
181            op = self.exclusive_operation()?;
182        }
183        Ok(waited_for)
184    }
185
186    /// Names of the filesystem features that are enabled. Each feature
187    /// corresponds to a file in the `features/` subdirectory.
188    /// `/sys/fs/btrfs/<uuid>/features/`
189    pub fn features(&self) -> io::Result<Vec<String>> {
190        let mut features = Vec::new();
191        for entry in fs::read_dir(self.base.join("features"))? {
192            let entry = entry?;
193            if let Some(name) = entry.file_name().to_str() {
194                features.push(name.to_owned());
195            }
196        }
197        features.sort();
198        Ok(features)
199    }
200
201    /// Current filesystem generation number.
202    /// `/sys/fs/btrfs/<uuid>/generation`
203    pub fn generation(&self) -> io::Result<u64> {
204        self.read_u64("generation")
205    }
206
207    /// Filesystem label. Empty string if no label is set.
208    /// `/sys/fs/btrfs/<uuid>/label`
209    pub fn label(&self) -> io::Result<String> {
210        self.read_file("label")
211    }
212
213    /// Metadata UUID. May differ from the filesystem UUID if the metadata UUID
214    /// feature is in use.
215    /// `/sys/fs/btrfs/<uuid>/metadata_uuid`
216    pub fn metadata_uuid(&self) -> io::Result<Uuid> {
217        let s = self.read_file("metadata_uuid")?;
218        Uuid::parse_str(&s)
219            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
220    }
221
222    /// B-tree node size in bytes.
223    /// `/sys/fs/btrfs/<uuid>/nodesize`
224    pub fn nodesize(&self) -> io::Result<u64> {
225        self.read_u64("nodesize")
226    }
227
228    /// Whether the quota override is active.
229    /// `/sys/fs/btrfs/<uuid>/quota_override`
230    pub fn quota_override(&self) -> io::Result<bool> {
231        self.read_bool("quota_override")
232    }
233
234    /// Read policy for RAID profiles, e.g. `"[pid]"` or `"[roundrobin]"`.
235    /// `/sys/fs/btrfs/<uuid>/read_policy`
236    pub fn read_policy(&self) -> io::Result<String> {
237        self.read_file("read_policy")
238    }
239
240    /// Sector size in bytes.
241    /// `/sys/fs/btrfs/<uuid>/sectorsize`
242    pub fn sectorsize(&self) -> io::Result<u64> {
243        self.read_u64("sectorsize")
244    }
245
246    /// Whether a temporary fsid is in use (seeding device feature).
247    /// `/sys/fs/btrfs/<uuid>/temp_fsid`
248    pub fn temp_fsid(&self) -> io::Result<bool> {
249        self.read_bool("temp_fsid")
250    }
251
252    /// Read the per-device scrub throughput limit for the given device, in
253    /// bytes per second. A value of `0` means no limit is set (unlimited).
254    /// `/sys/fs/btrfs/<uuid>/devinfo/<devid>/scrub_speed_max`
255    pub fn scrub_speed_max_get(&self, devid: u64) -> io::Result<u64> {
256        let path = format!("devinfo/{devid}/scrub_speed_max");
257        match self.read_u64(&path) {
258            Ok(v) => Ok(v),
259            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(0),
260            Err(e) => Err(e),
261        }
262    }
263
264    /// Set the per-device scrub throughput limit for the given device, in
265    /// bytes per second. Pass `0` to remove the limit (unlimited).
266    /// Requires root.
267    /// `/sys/fs/btrfs/<uuid>/devinfo/<devid>/scrub_speed_max`
268    pub fn scrub_speed_max_set(
269        &self,
270        devid: u64,
271        limit: u64,
272    ) -> io::Result<()> {
273        let path = self.base.join(format!("devinfo/{devid}/scrub_speed_max"));
274        fs::write(path, format!("{limit}\n"))
275    }
276
277    /// Maximum send stream protocol version supported by the kernel.
278    ///
279    /// Returns `1` if the sysfs file does not exist (older kernels without
280    /// versioned send stream support).
281    /// `/sys/fs/btrfs/features/send_stream_version`
282    #[must_use]
283    pub fn send_stream_version(&self) -> u32 {
284        // This is a global feature file, not per-filesystem.
285        let path =
286            std::path::Path::new("/sys/fs/btrfs/features/send_stream_version");
287        match fs::read_to_string(path) {
288            Ok(s) => s.trim().parse::<u32>().unwrap_or(1),
289            Err(_) => 1,
290        }
291    }
292
293    /// Quota status for this filesystem, read from
294    /// `/sys/fs/btrfs/<uuid>/qgroups/`.
295    ///
296    /// Returns `Ok(QuotaStatus { enabled: false, .. })` when quota is not
297    /// enabled (the `qgroups/` directory does not exist). Returns an
298    /// [`io::Error`] with kind `NotFound` if the sysfs entry for this UUID
299    /// does not exist (i.e. the filesystem is not currently mounted).
300    pub fn quota_status(&self) -> io::Result<QuotaStatus> {
301        let qgroups = self.base.join("qgroups");
302
303        if !qgroups.exists() {
304            return Ok(QuotaStatus {
305                enabled: false,
306                mode: None,
307                inconsistent: None,
308                override_limits: None,
309                drop_subtree_threshold: None,
310                total_count: None,
311                level0_count: None,
312            });
313        }
314
315        let mode = {
316            let s = fs::read_to_string(qgroups.join("mode"))?;
317            s.trim_end().to_owned()
318        };
319        let inconsistent = fs::read_to_string(qgroups.join("inconsistent"))?
320            .trim()
321            .parse::<u64>()
322            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
323            != 0;
324        let override_limits = self.read_bool("quota_override")?;
325        let drop_subtree_threshold =
326            fs::read_to_string(qgroups.join("drop_subtree_threshold"))?
327                .trim()
328                .parse::<u64>()
329                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
330
331        let mut total_count: u64 = 0;
332        let mut level0_count: u64 = 0;
333        for entry in fs::read_dir(&qgroups)? {
334            let entry = entry?;
335            let raw_name = entry.file_name();
336            let name = raw_name.to_string_lossy();
337            if let Some((level, _id)) =
338                parse_qgroup_entry_name(OsStr::new(name.as_ref()))
339            {
340                total_count += 1;
341                if level == 0 {
342                    level0_count += 1;
343                }
344            }
345        }
346
347        Ok(QuotaStatus {
348            enabled: true,
349            mode: Some(mode),
350            inconsistent: Some(inconsistent),
351            override_limits: Some(override_limits),
352            drop_subtree_threshold: Some(drop_subtree_threshold),
353            total_count: Some(total_count),
354            level0_count: Some(level0_count),
355        })
356    }
357}
358
359/// Parse a qgroups sysfs directory entry name of the form `<level>_<id>`.
360///
361/// Returns `Some((level, id))` for valid entries, `None` for anything else
362/// (e.g. `mode`, `inconsistent`, and other non-qgroup files in the directory).
363fn parse_qgroup_entry_name(name: &OsStr) -> Option<(u64, u64)> {
364    let s = name.to_str()?;
365    let (level_str, id_str) = s.split_once('_')?;
366    let level: u64 = level_str.parse().ok()?;
367    let id: u64 = id_str.parse().ok()?;
368    Some((level, id))
369}
370
371/// Quota status for a mounted btrfs filesystem, read from sysfs under
372/// `/sys/fs/btrfs/<uuid>/qgroups/`.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct QuotaStatus {
375    /// Whether quota accounting is currently enabled.
376    pub enabled: bool,
377    /// Accounting mode: `"qgroup"` (full backref accounting) or `"squota"`
378    /// (simplified lifetime accounting). `None` when quotas are disabled.
379    pub mode: Option<String>,
380    /// Whether the quota tree is inconsistent; a rescan is needed to restore
381    /// accurate numbers. `None` when quotas are disabled.
382    pub inconsistent: Option<bool>,
383    /// Whether the quota override flag is active (limits are bypassed for
384    /// the current mount). `None` when quotas are disabled.
385    pub override_limits: Option<bool>,
386    /// Drop-subtree threshold: qgroup hierarchy levels below this value skip
387    /// detailed tracking during heavy write workloads. `None` when disabled.
388    pub drop_subtree_threshold: Option<u64>,
389    /// Total number of qgroups tracked by the kernel. `None` when disabled.
390    pub total_count: Option<u64>,
391    /// Number of level-0 qgroups (one per subvolume). `None` when disabled.
392    pub level0_count: Option<u64>,
393}