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    ///
91    /// # Errors
92    ///
93    /// Returns `Err` if the sysfs file cannot be read or parsed.
94    pub fn bg_reclaim_threshold(&self) -> io::Result<u64> {
95        self.read_u64("bg_reclaim_threshold")
96    }
97
98    /// Checksum algorithm in use, e.g. `"crc32c (crc32c-lib)"`.
99    /// `/sys/fs/btrfs/<uuid>/checksum`
100    ///
101    /// # Errors
102    ///
103    /// Returns `Err` if the sysfs file cannot be read.
104    pub fn checksum(&self) -> io::Result<String> {
105        self.read_file("checksum")
106    }
107
108    /// Minimum clone/reflink alignment in bytes.
109    /// `/sys/fs/btrfs/<uuid>/clone_alignment`
110    ///
111    /// # Errors
112    ///
113    /// Returns `Err` if the sysfs file cannot be read or parsed.
114    pub fn clone_alignment(&self) -> io::Result<u64> {
115        self.read_u64("clone_alignment")
116    }
117
118    /// Commit statistics since mount (or last reset).
119    /// `/sys/fs/btrfs/<uuid>/commit_stats`
120    ///
121    /// # Errors
122    ///
123    /// Returns `Err` if the sysfs file cannot be read or parsed.
124    pub fn commit_stats(&self) -> io::Result<CommitStats> {
125        let contents = self.read_file("commit_stats")?;
126        let mut commits = None;
127        let mut cur_commit_ms = None;
128        let mut last_commit_ms = None;
129        let mut max_commit_ms = None;
130        let mut total_commit_ms = None;
131
132        for line in contents.lines() {
133            let mut parts = line.splitn(2, ' ');
134            let key = parts.next().unwrap_or("").trim();
135            let val: u64 =
136                parts.next().unwrap_or("").trim().parse().map_err(|e| {
137                    io::Error::new(io::ErrorKind::InvalidData, e)
138                })?;
139            match key {
140                "commits" => commits = Some(val),
141                "cur_commit_ms" => cur_commit_ms = Some(val),
142                "last_commit_ms" => last_commit_ms = Some(val),
143                "max_commit_ms" => max_commit_ms = Some(val),
144                "total_commit_ms" => total_commit_ms = Some(val),
145                _ => {}
146            }
147        }
148
149        let missing = |name| {
150            io::Error::new(
151                io::ErrorKind::InvalidData,
152                format!("commit_stats: missing field '{name}'"),
153            )
154        };
155
156        Ok(CommitStats {
157            commits: commits.ok_or_else(|| missing("commits"))?,
158            cur_commit_ms: cur_commit_ms
159                .ok_or_else(|| missing("cur_commit_ms"))?,
160            last_commit_ms: last_commit_ms
161                .ok_or_else(|| missing("last_commit_ms"))?,
162            max_commit_ms: max_commit_ms
163                .ok_or_else(|| missing("max_commit_ms"))?,
164            total_commit_ms: total_commit_ms
165                .ok_or_else(|| missing("total_commit_ms"))?,
166        })
167    }
168
169    /// Reset the `max_commit_ms` counter by writing `0` to the `commit_stats`
170    /// file. Requires root.
171    /// `/sys/fs/btrfs/<uuid>/commit_stats`
172    ///
173    /// # Errors
174    ///
175    /// Returns `Err` if the write fails.
176    pub fn reset_commit_stats(&self) -> io::Result<()> {
177        fs::write(self.base.join("commit_stats"), b"0")
178    }
179
180    /// Name of the exclusive operation currently running, e.g. `"none"`,
181    /// `"balance"`, `"device add"`.
182    /// `/sys/fs/btrfs/<uuid>/exclusive_operation`
183    ///
184    /// # Errors
185    ///
186    /// Returns `Err` if the sysfs file cannot be read.
187    pub fn exclusive_operation(&self) -> io::Result<String> {
188        self.read_file("exclusive_operation")
189    }
190
191    /// Wait until no exclusive operation is running on the filesystem.
192    ///
193    /// Polls the `exclusive_operation` sysfs file at one-second intervals.
194    /// Returns immediately if no exclusive operation is in progress, or after
195    /// the running operation completes. Returns the name of the operation
196    /// that was waited on, or `"none"` if nothing was running.
197    ///
198    /// # Errors
199    ///
200    /// Returns `Err` if reading the sysfs file fails.
201    pub fn wait_for_exclusive_operation(&self) -> io::Result<String> {
202        let mut op = self.exclusive_operation()?;
203        if op == "none" {
204            return Ok(op);
205        }
206        let waited_for = op.clone();
207        while op != "none" {
208            std::thread::sleep(std::time::Duration::from_secs(1));
209            op = self.exclusive_operation()?;
210        }
211        Ok(waited_for)
212    }
213
214    /// Names of the filesystem features that are enabled. Each feature
215    /// corresponds to a file in the `features/` subdirectory.
216    /// `/sys/fs/btrfs/<uuid>/features/`
217    ///
218    /// # Errors
219    ///
220    /// Returns `Err` if reading the features directory fails.
221    pub fn features(&self) -> io::Result<Vec<String>> {
222        let mut features = Vec::new();
223        for entry in fs::read_dir(self.base.join("features"))? {
224            let entry = entry?;
225            if let Some(name) = entry.file_name().to_str() {
226                features.push(name.to_owned());
227            }
228        }
229        features.sort();
230        Ok(features)
231    }
232
233    /// Current filesystem generation number.
234    /// `/sys/fs/btrfs/<uuid>/generation`
235    ///
236    /// # Errors
237    ///
238    /// Returns `Err` if the sysfs file cannot be read or parsed.
239    pub fn generation(&self) -> io::Result<u64> {
240        self.read_u64("generation")
241    }
242
243    /// Filesystem label. Empty string if no label is set.
244    /// `/sys/fs/btrfs/<uuid>/label`
245    ///
246    /// # Errors
247    ///
248    /// Returns `Err` if the sysfs file cannot be read.
249    pub fn label(&self) -> io::Result<String> {
250        self.read_file("label")
251    }
252
253    /// Metadata UUID. May differ from the filesystem UUID if the metadata UUID
254    /// feature is in use.
255    /// `/sys/fs/btrfs/<uuid>/metadata_uuid`
256    ///
257    /// # Errors
258    ///
259    /// Returns `Err` if the sysfs file cannot be read or parsed.
260    pub fn metadata_uuid(&self) -> io::Result<Uuid> {
261        let s = self.read_file("metadata_uuid")?;
262        Uuid::parse_str(&s)
263            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
264    }
265
266    /// B-tree node size in bytes.
267    /// `/sys/fs/btrfs/<uuid>/nodesize`
268    ///
269    /// # Errors
270    ///
271    /// Returns `Err` if the sysfs file cannot be read or parsed.
272    pub fn nodesize(&self) -> io::Result<u64> {
273        self.read_u64("nodesize")
274    }
275
276    /// Whether the quota override is active.
277    /// `/sys/fs/btrfs/<uuid>/quota_override`
278    ///
279    /// # Errors
280    ///
281    /// Returns `Err` if the sysfs file cannot be read or parsed.
282    pub fn quota_override(&self) -> io::Result<bool> {
283        self.read_bool("quota_override")
284    }
285
286    /// Read policy for RAID profiles, e.g. `"[pid]"` or `"[roundrobin]"`.
287    /// `/sys/fs/btrfs/<uuid>/read_policy`
288    ///
289    /// # Errors
290    ///
291    /// Returns `Err` if the sysfs file cannot be read.
292    pub fn read_policy(&self) -> io::Result<String> {
293        self.read_file("read_policy")
294    }
295
296    /// Sector size in bytes.
297    /// `/sys/fs/btrfs/<uuid>/sectorsize`
298    ///
299    /// # Errors
300    ///
301    /// Returns `Err` if the sysfs file cannot be read or parsed.
302    pub fn sectorsize(&self) -> io::Result<u64> {
303        self.read_u64("sectorsize")
304    }
305
306    /// Whether a temporary fsid is in use (seeding device feature).
307    /// `/sys/fs/btrfs/<uuid>/temp_fsid`
308    ///
309    /// # Errors
310    ///
311    /// Returns `Err` if the sysfs file cannot be read or parsed.
312    pub fn temp_fsid(&self) -> io::Result<bool> {
313        self.read_bool("temp_fsid")
314    }
315
316    /// Read the per-device scrub throughput limit for the given device, in
317    /// bytes per second. A value of `0` means no limit is set (unlimited).
318    /// `/sys/fs/btrfs/<uuid>/devinfo/<devid>/scrub_speed_max`
319    ///
320    /// # Errors
321    ///
322    /// Returns `Err` if the sysfs file cannot be read or parsed.
323    pub fn scrub_speed_max_get(&self, devid: u64) -> io::Result<u64> {
324        let path = format!("devinfo/{devid}/scrub_speed_max");
325        match self.read_u64(&path) {
326            Ok(v) => Ok(v),
327            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(0),
328            Err(e) => Err(e),
329        }
330    }
331
332    /// Set the per-device scrub throughput limit for the given device, in
333    /// bytes per second. Pass `0` to remove the limit (unlimited).
334    /// Requires root.
335    /// `/sys/fs/btrfs/<uuid>/devinfo/<devid>/scrub_speed_max`
336    ///
337    /// # Errors
338    ///
339    /// Returns `Err` if the write fails.
340    pub fn scrub_speed_max_set(
341        &self,
342        devid: u64,
343        limit: u64,
344    ) -> io::Result<()> {
345        let path = self.base.join(format!("devinfo/{devid}/scrub_speed_max"));
346        fs::write(path, format!("{limit}\n"))
347    }
348
349    /// Maximum send stream protocol version supported by the kernel.
350    ///
351    /// Returns `1` if the sysfs file does not exist (older kernels without
352    /// versioned send stream support).
353    /// `/sys/fs/btrfs/features/send_stream_version`
354    #[must_use]
355    pub fn send_stream_version(&self) -> u32 {
356        // This is a global feature file, not per-filesystem.
357        let path =
358            std::path::Path::new("/sys/fs/btrfs/features/send_stream_version");
359        match fs::read_to_string(path) {
360            Ok(s) => s.trim().parse::<u32>().unwrap_or(1),
361            Err(_) => 1,
362        }
363    }
364
365    /// Quota status for this filesystem, read from
366    /// `/sys/fs/btrfs/<uuid>/qgroups/`.
367    ///
368    /// Returns `Ok(QuotaStatus { enabled: false, .. })` when quota is not
369    /// enabled (the `qgroups/` directory does not exist). Returns an
370    /// [`io::Error`] with kind `NotFound` if the sysfs entry for this UUID
371    /// does not exist (i.e. the filesystem is not currently mounted).
372    ///
373    /// # Errors
374    ///
375    /// Returns `Err` if the sysfs files cannot be read or parsed.
376    pub fn quota_status(&self) -> io::Result<QuotaStatus> {
377        let qgroups = self.base.join("qgroups");
378
379        if !qgroups.exists() {
380            return Ok(QuotaStatus {
381                enabled: false,
382                mode: None,
383                inconsistent: None,
384                override_limits: None,
385                drop_subtree_threshold: None,
386                total_count: None,
387                level0_count: None,
388            });
389        }
390
391        let mode = {
392            let s = fs::read_to_string(qgroups.join("mode"))?;
393            s.trim_end().to_owned()
394        };
395        let inconsistent = fs::read_to_string(qgroups.join("inconsistent"))?
396            .trim()
397            .parse::<u64>()
398            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
399            != 0;
400        let override_limits = self.read_bool("quota_override")?;
401        let drop_subtree_threshold =
402            fs::read_to_string(qgroups.join("drop_subtree_threshold"))?
403                .trim()
404                .parse::<u64>()
405                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
406
407        let mut total_count: u64 = 0;
408        let mut level0_count: u64 = 0;
409        for entry in fs::read_dir(&qgroups)? {
410            let entry = entry?;
411            let raw_name = entry.file_name();
412            let name = raw_name.to_string_lossy();
413            if let Some((level, _id)) =
414                parse_qgroup_entry_name(OsStr::new(name.as_ref()))
415            {
416                total_count += 1;
417                if level == 0 {
418                    level0_count += 1;
419                }
420            }
421        }
422
423        Ok(QuotaStatus {
424            enabled: true,
425            mode: Some(mode),
426            inconsistent: Some(inconsistent),
427            override_limits: Some(override_limits),
428            drop_subtree_threshold: Some(drop_subtree_threshold),
429            total_count: Some(total_count),
430            level0_count: Some(level0_count),
431        })
432    }
433}
434
435#[cfg(test)]
436impl SysfsBtrfs {
437    fn with_base(base: PathBuf) -> Self {
438        Self { base }
439    }
440}
441
442/// Parse a qgroups sysfs directory entry name of the form `<level>_<id>`.
443///
444/// Returns `Some((level, id))` for valid entries, `None` for anything else
445/// (e.g. `mode`, `inconsistent`, and other non-qgroup files in the directory).
446fn parse_qgroup_entry_name(name: &OsStr) -> Option<(u64, u64)> {
447    let s = name.to_str()?;
448    let (level_str, id_str) = s.split_once('_')?;
449    let level: u64 = level_str.parse().ok()?;
450    let id: u64 = id_str.parse().ok()?;
451    Some((level, id))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use std::fs;
458    use tempfile::TempDir;
459
460    fn setup() -> (TempDir, SysfsBtrfs) {
461        let dir = TempDir::new().unwrap();
462        let sysfs = SysfsBtrfs::with_base(dir.path().to_path_buf());
463        (dir, sysfs)
464    }
465
466    #[test]
467    fn read_u64_values() {
468        let (dir, sysfs) = setup();
469        fs::write(dir.path().join("nodesize"), "16384\n").unwrap();
470        fs::write(dir.path().join("sectorsize"), "4096\n").unwrap();
471        fs::write(dir.path().join("clone_alignment"), "4096\n").unwrap();
472        fs::write(dir.path().join("generation"), "42\n").unwrap();
473        fs::write(dir.path().join("bg_reclaim_threshold"), "75\n").unwrap();
474
475        assert_eq!(sysfs.nodesize().unwrap(), 16384);
476        assert_eq!(sysfs.sectorsize().unwrap(), 4096);
477        assert_eq!(sysfs.clone_alignment().unwrap(), 4096);
478        assert_eq!(sysfs.generation().unwrap(), 42);
479        assert_eq!(sysfs.bg_reclaim_threshold().unwrap(), 75);
480    }
481
482    #[test]
483    fn read_u64_invalid() {
484        let (dir, sysfs) = setup();
485        fs::write(dir.path().join("nodesize"), "not_a_number\n").unwrap();
486        assert!(sysfs.nodesize().is_err());
487    }
488
489    #[test]
490    fn read_u64_missing_file() {
491        let (_dir, sysfs) = setup();
492        let err = sysfs.nodesize().unwrap_err();
493        assert_eq!(err.kind(), io::ErrorKind::NotFound);
494    }
495
496    #[test]
497    fn read_string_values() {
498        let (dir, sysfs) = setup();
499        fs::write(dir.path().join("label"), "my-filesystem\n").unwrap();
500        fs::write(dir.path().join("checksum"), "crc32c (crc32c-lib)\n")
501            .unwrap();
502        fs::write(dir.path().join("read_policy"), "[pid]\n").unwrap();
503        fs::write(dir.path().join("exclusive_operation"), "none\n").unwrap();
504
505        assert_eq!(sysfs.label().unwrap(), "my-filesystem");
506        assert_eq!(sysfs.checksum().unwrap(), "crc32c (crc32c-lib)");
507        assert_eq!(sysfs.read_policy().unwrap(), "[pid]");
508        assert_eq!(sysfs.exclusive_operation().unwrap(), "none");
509    }
510
511    #[test]
512    fn read_empty_label() {
513        let (dir, sysfs) = setup();
514        fs::write(dir.path().join("label"), "\n").unwrap();
515        assert_eq!(sysfs.label().unwrap(), "");
516    }
517
518    #[test]
519    fn read_bool_values() {
520        let (dir, sysfs) = setup();
521        fs::write(dir.path().join("quota_override"), "0\n").unwrap();
522        assert!(!sysfs.quota_override().unwrap());
523
524        fs::write(dir.path().join("quota_override"), "1\n").unwrap();
525        assert!(sysfs.quota_override().unwrap());
526
527        fs::write(dir.path().join("temp_fsid"), "0\n").unwrap();
528        assert!(!sysfs.temp_fsid().unwrap());
529
530        fs::write(dir.path().join("temp_fsid"), "1\n").unwrap();
531        assert!(sysfs.temp_fsid().unwrap());
532    }
533
534    #[test]
535    fn metadata_uuid() {
536        let (dir, sysfs) = setup();
537        fs::write(
538            dir.path().join("metadata_uuid"),
539            "deadbeef-dead-beef-dead-beefdeadbeef\n",
540        )
541        .unwrap();
542        let uuid = sysfs.metadata_uuid().unwrap();
543        assert_eq!(uuid.to_string(), "deadbeef-dead-beef-dead-beefdeadbeef");
544    }
545
546    #[test]
547    fn metadata_uuid_invalid() {
548        let (dir, sysfs) = setup();
549        fs::write(dir.path().join("metadata_uuid"), "not-a-uuid\n").unwrap();
550        assert!(sysfs.metadata_uuid().is_err());
551    }
552
553    #[test]
554    fn commit_stats_valid() {
555        let (dir, sysfs) = setup();
556        fs::write(
557            dir.path().join("commit_stats"),
558            "commits 100\n\
559             cur_commit_ms 5\n\
560             last_commit_ms 12\n\
561             max_commit_ms 50\n\
562             total_commit_ms 2000\n",
563        )
564        .unwrap();
565
566        let stats = sysfs.commit_stats().unwrap();
567        assert_eq!(
568            stats,
569            CommitStats {
570                commits: 100,
571                cur_commit_ms: 5,
572                last_commit_ms: 12,
573                max_commit_ms: 50,
574                total_commit_ms: 2000,
575            }
576        );
577    }
578
579    #[test]
580    fn commit_stats_missing_field() {
581        let (dir, sysfs) = setup();
582        fs::write(
583            dir.path().join("commit_stats"),
584            "commits 100\ncur_commit_ms 5\n",
585        )
586        .unwrap();
587        let err = sysfs.commit_stats().unwrap_err();
588        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
589    }
590
591    #[test]
592    fn commit_stats_extra_fields_ignored() {
593        let (dir, sysfs) = setup();
594        fs::write(
595            dir.path().join("commit_stats"),
596            "commits 1\n\
597             cur_commit_ms 2\n\
598             last_commit_ms 3\n\
599             max_commit_ms 4\n\
600             total_commit_ms 5\n\
601             unknown_field 99\n",
602        )
603        .unwrap();
604        let stats = sysfs.commit_stats().unwrap();
605        assert_eq!(stats.commits, 1);
606    }
607
608    #[test]
609    fn features_directory() {
610        let (dir, sysfs) = setup();
611        let feat_dir = dir.path().join("features");
612        fs::create_dir(&feat_dir).unwrap();
613        fs::write(feat_dir.join("skinny_metadata"), "").unwrap();
614        fs::write(feat_dir.join("extended_iref"), "").unwrap();
615        fs::write(feat_dir.join("no_holes"), "").unwrap();
616
617        let features = sysfs.features().unwrap();
618        // Should be sorted alphabetically.
619        assert_eq!(
620            features,
621            vec!["extended_iref", "no_holes", "skinny_metadata"]
622        );
623    }
624
625    #[test]
626    fn features_empty() {
627        let (dir, sysfs) = setup();
628        fs::create_dir(dir.path().join("features")).unwrap();
629        assert!(sysfs.features().unwrap().is_empty());
630    }
631
632    #[test]
633    fn scrub_speed_max_get() {
634        let (dir, sysfs) = setup();
635        let devinfo = dir.path().join("devinfo/1");
636        fs::create_dir_all(&devinfo).unwrap();
637        fs::write(devinfo.join("scrub_speed_max"), "104857600\n").unwrap();
638
639        assert_eq!(sysfs.scrub_speed_max_get(1).unwrap(), 104_857_600);
640    }
641
642    #[test]
643    fn scrub_speed_max_get_missing_returns_zero() {
644        let (_dir, sysfs) = setup();
645        // No devinfo directory exists — should return 0, not error.
646        assert_eq!(sysfs.scrub_speed_max_get(99).unwrap(), 0);
647    }
648
649    #[test]
650    fn scrub_speed_max_set() {
651        let (dir, sysfs) = setup();
652        let devinfo = dir.path().join("devinfo/1");
653        fs::create_dir_all(&devinfo).unwrap();
654
655        sysfs.scrub_speed_max_set(1, 500_000_000).unwrap();
656        let contents =
657            fs::read_to_string(devinfo.join("scrub_speed_max")).unwrap();
658        assert_eq!(contents, "500000000\n");
659    }
660
661    #[test]
662    fn reset_commit_stats() {
663        let (dir, sysfs) = setup();
664        fs::write(dir.path().join("commit_stats"), "old data").unwrap();
665
666        sysfs.reset_commit_stats().unwrap();
667        let contents =
668            fs::read_to_string(dir.path().join("commit_stats")).unwrap();
669        assert_eq!(contents, "0");
670    }
671
672    #[test]
673    fn quota_status_disabled() {
674        let (_dir, sysfs) = setup();
675        // No qgroups directory → disabled.
676        let status = sysfs.quota_status().unwrap();
677        assert!(!status.enabled);
678        assert!(status.mode.is_none());
679    }
680
681    #[test]
682    fn quota_status_enabled() {
683        let (dir, sysfs) = setup();
684        let qg = dir.path().join("qgroups");
685        fs::create_dir(&qg).unwrap();
686        fs::write(qg.join("mode"), "qgroup\n").unwrap();
687        fs::write(qg.join("inconsistent"), "0\n").unwrap();
688        fs::write(qg.join("drop_subtree_threshold"), "8\n").unwrap();
689        fs::write(dir.path().join("quota_override"), "0\n").unwrap();
690        // Level-0 qgroup entries.
691        fs::write(qg.join("0_5"), "").unwrap();
692        fs::write(qg.join("0_256"), "").unwrap();
693        // Level-1 qgroup.
694        fs::write(qg.join("1_50"), "").unwrap();
695
696        let status = sysfs.quota_status().unwrap();
697        assert!(status.enabled);
698        assert_eq!(status.mode.as_deref(), Some("qgroup"));
699        assert_eq!(status.inconsistent, Some(false));
700        assert_eq!(status.override_limits, Some(false));
701        assert_eq!(status.drop_subtree_threshold, Some(8));
702        assert_eq!(status.total_count, Some(3));
703        assert_eq!(status.level0_count, Some(2));
704    }
705
706    #[test]
707    fn parse_qgroup_entry_name_valid() {
708        assert_eq!(
709            parse_qgroup_entry_name(OsStr::new("0_256")),
710            Some((0, 256))
711        );
712        assert_eq!(parse_qgroup_entry_name(OsStr::new("1_50")), Some((1, 50)));
713    }
714
715    #[test]
716    fn parse_qgroup_entry_name_invalid() {
717        assert_eq!(parse_qgroup_entry_name(OsStr::new("mode")), None);
718        assert_eq!(parse_qgroup_entry_name(OsStr::new("inconsistent")), None);
719        assert_eq!(parse_qgroup_entry_name(OsStr::new("abc_def")), None);
720        assert_eq!(parse_qgroup_entry_name(OsStr::new("")), None);
721    }
722}
723
724/// Quota status for a mounted btrfs filesystem, read from sysfs under
725/// `/sys/fs/btrfs/<uuid>/qgroups/`.
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct QuotaStatus {
728    /// Whether quota accounting is currently enabled.
729    pub enabled: bool,
730    /// Accounting mode: `"qgroup"` (full backref accounting) or `"squota"`
731    /// (simplified lifetime accounting). `None` when quotas are disabled.
732    pub mode: Option<String>,
733    /// Whether the quota tree is inconsistent; a rescan is needed to restore
734    /// accurate numbers. `None` when quotas are disabled.
735    pub inconsistent: Option<bool>,
736    /// Whether the quota override flag is active (limits are bypassed for
737    /// the current mount). `None` when quotas are disabled.
738    pub override_limits: Option<bool>,
739    /// Drop-subtree threshold: qgroup hierarchy levels below this value skip
740    /// detailed tracking during heavy write workloads. `None` when disabled.
741    pub drop_subtree_threshold: Option<u64>,
742    /// Total number of qgroups tracked by the kernel. `None` when disabled.
743    pub total_count: Option<u64>,
744    /// Number of level-0 qgroups (one per subvolume). `None` when disabled.
745    pub level0_count: Option<u64>,
746}