Skip to main content

btrfs_uapi/
quota.rs

1//! # Quota and qgroup management: enabling quotas and tracking disk usage
2//!
3//! Quota accounting tracks disk usage per subvolume via qgroups (quota groups).
4//! It must be explicitly enabled before any qgroup limits or usage data are
5//! available.  Once enabled, usage numbers are maintained incrementally by the
6//! kernel; a rescan rebuilds them from scratch if they become inconsistent.
7//!
8//! Every subvolume automatically gets a level-0 qgroup whose ID matches the
9//! subvolume ID.  Higher-level qgroups can be created and linked into a
10//! parent-child hierarchy so that space usage rolls up through the tree.
11//!
12//! Quota status (whether quotas are on, which mode, inconsistency flag) is
13//! read from sysfs via [`crate::sysfs::SysfsBtrfs::quota_status`].
14//!
15//! Most operations require `CAP_SYS_ADMIN`.
16
17use crate::{
18    raw::{
19        BTRFS_FIRST_FREE_OBJECTID, BTRFS_LAST_FREE_OBJECTID,
20        BTRFS_QGROUP_INFO_KEY, BTRFS_QGROUP_LIMIT_EXCL_CMPR,
21        BTRFS_QGROUP_LIMIT_KEY, BTRFS_QGROUP_LIMIT_MAX_EXCL,
22        BTRFS_QGROUP_LIMIT_MAX_RFER, BTRFS_QGROUP_LIMIT_RFER_CMPR,
23        BTRFS_QGROUP_RELATION_KEY, BTRFS_QGROUP_STATUS_FLAG_INCONSISTENT,
24        BTRFS_QGROUP_STATUS_FLAG_ON, BTRFS_QGROUP_STATUS_FLAG_RESCAN,
25        BTRFS_QGROUP_STATUS_FLAG_SIMPLE_MODE, BTRFS_QGROUP_STATUS_KEY,
26        BTRFS_QUOTA_CTL_DISABLE, BTRFS_QUOTA_CTL_ENABLE,
27        BTRFS_QUOTA_CTL_ENABLE_SIMPLE_QUOTA, BTRFS_QUOTA_TREE_OBJECTID,
28        BTRFS_ROOT_ITEM_KEY, BTRFS_ROOT_TREE_OBJECTID, btrfs_ioc_qgroup_assign,
29        btrfs_ioc_qgroup_create, btrfs_ioc_qgroup_limit, btrfs_ioc_quota_ctl,
30        btrfs_ioc_quota_rescan, btrfs_ioc_quota_rescan_status,
31        btrfs_ioc_quota_rescan_wait, btrfs_ioctl_qgroup_assign_args,
32        btrfs_ioctl_qgroup_create_args, btrfs_ioctl_qgroup_limit_args,
33        btrfs_ioctl_quota_ctl_args, btrfs_ioctl_quota_rescan_args,
34        btrfs_qgroup_limit,
35    },
36    tree_search::{SearchKey, tree_search},
37};
38use bitflags::bitflags;
39use nix::errno::Errno;
40use std::{
41    collections::{HashMap, HashSet},
42    mem,
43    os::{fd::AsRawFd, unix::io::BorrowedFd},
44};
45
46/// Enable quota accounting on the filesystem referred to by `fd`.
47///
48/// When `simple` is `true`, uses `BTRFS_QUOTA_CTL_ENABLE_SIMPLE_QUOTA`, which
49/// accounts for extent ownership by lifetime rather than backref walks. This is
50/// faster but less precise than full qgroup accounting.
51///
52/// # Errors
53///
54/// Returns `Err` if the ioctl fails.
55pub fn quota_enable(fd: BorrowedFd, simple: bool) -> nix::Result<()> {
56    let cmd = if simple {
57        u64::from(BTRFS_QUOTA_CTL_ENABLE_SIMPLE_QUOTA)
58    } else {
59        u64::from(BTRFS_QUOTA_CTL_ENABLE)
60    };
61    let mut args: btrfs_ioctl_quota_ctl_args = unsafe { mem::zeroed() };
62    args.cmd = cmd;
63    unsafe { btrfs_ioc_quota_ctl(fd.as_raw_fd(), &raw mut args) }?;
64    Ok(())
65}
66
67/// Disable quota accounting on the filesystem referred to by `fd`.
68///
69/// # Errors
70///
71/// Returns `Err` if the ioctl fails.
72pub fn quota_disable(fd: BorrowedFd) -> nix::Result<()> {
73    let mut args: btrfs_ioctl_quota_ctl_args = unsafe { mem::zeroed() };
74    args.cmd = u64::from(BTRFS_QUOTA_CTL_DISABLE);
75    unsafe { btrfs_ioc_quota_ctl(fd.as_raw_fd(), &raw mut args) }?;
76    Ok(())
77}
78
79/// Start a quota rescan on the filesystem referred to by `fd`.
80///
81/// Returns immediately after kicking off the background scan. Use
82/// [`quota_rescan_wait`] to block until it finishes. If a rescan is already
83/// in progress the kernel returns `EINPROGRESS`; callers that are about to
84/// wait anyway can treat that as a non-error.
85///
86/// # Errors
87///
88/// Returns `Err` if the ioctl fails. `EINPROGRESS` if a rescan is
89/// already running.
90pub fn quota_rescan(fd: BorrowedFd) -> nix::Result<()> {
91    let args: btrfs_ioctl_quota_rescan_args = unsafe { mem::zeroed() };
92    unsafe { btrfs_ioc_quota_rescan(fd.as_raw_fd(), &raw const args) }?;
93    Ok(())
94}
95
96/// Block until the quota rescan currently running on the filesystem referred
97/// to by `fd` completes. Returns immediately if no rescan is in progress.
98///
99/// # Errors
100///
101/// Returns `Err` if the ioctl fails.
102pub fn quota_rescan_wait(fd: BorrowedFd) -> nix::Result<()> {
103    unsafe { btrfs_ioc_quota_rescan_wait(fd.as_raw_fd()) }?;
104    Ok(())
105}
106
107/// Status of an in-progress (or absent) quota rescan.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct QuotaRescanStatus {
110    /// Whether a rescan is currently running.
111    pub running: bool,
112    /// Object ID of the most recently scanned tree item. Only meaningful
113    /// when `running` is `true`.
114    pub progress: u64,
115}
116
117/// Query the status of the quota rescan on the filesystem referred to by `fd`.
118///
119/// # Errors
120///
121/// Returns `Err` if the ioctl fails.
122pub fn quota_rescan_status(fd: BorrowedFd) -> nix::Result<QuotaRescanStatus> {
123    let mut args: btrfs_ioctl_quota_rescan_args = unsafe { mem::zeroed() };
124    unsafe { btrfs_ioc_quota_rescan_status(fd.as_raw_fd(), &raw mut args) }?;
125    Ok(QuotaRescanStatus {
126        running: args.flags != 0,
127        progress: args.progress,
128    })
129}
130
131/// Extract the hierarchy level from a packed qgroup ID.
132///
133/// `qgroupid = (level << 48) | subvolid`.  Level 0 qgroups correspond
134/// directly to subvolumes.
135#[inline]
136#[must_use]
137pub fn qgroupid_level(qgroupid: u64) -> u16 {
138    (qgroupid >> 48) as u16
139}
140
141/// Extract the subvolume ID component from a packed qgroup ID.
142///
143/// Only meaningful for level-0 qgroups.
144#[inline]
145#[must_use]
146pub fn qgroupid_subvolid(qgroupid: u64) -> u64 {
147    qgroupid & 0x0000_FFFF_FFFF_FFFF
148}
149
150bitflags! {
151    /// Status flags for the quota tree as a whole (`BTRFS_QGROUP_STATUS_KEY`).
152    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
153    pub struct QgroupStatusFlags: u64 {
154        /// Quota accounting is enabled.
155        const ON           = BTRFS_QGROUP_STATUS_FLAG_ON as u64;
156        /// A rescan is currently in progress.
157        const RESCAN       = BTRFS_QGROUP_STATUS_FLAG_RESCAN as u64;
158        /// Accounting is inconsistent and a rescan is needed.
159        const INCONSISTENT = BTRFS_QGROUP_STATUS_FLAG_INCONSISTENT as u64;
160        /// Simple quota mode (squota) is active.
161        const SIMPLE_MODE  = BTRFS_QGROUP_STATUS_FLAG_SIMPLE_MODE as u64;
162    }
163}
164
165bitflags! {
166    /// Which limit fields are actively enforced on a qgroup.
167    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
168    pub struct QgroupLimitFlags: u64 {
169        /// `max_rfer` (maximum referenced bytes) is enforced.
170        const MAX_RFER  = BTRFS_QGROUP_LIMIT_MAX_RFER as u64;
171        /// `max_excl` (maximum exclusive bytes) is enforced.
172        const MAX_EXCL  = BTRFS_QGROUP_LIMIT_MAX_EXCL as u64;
173        /// Referenced bytes are compressed before comparison.
174        const RFER_CMPR = BTRFS_QGROUP_LIMIT_RFER_CMPR as u64;
175        /// Exclusive bytes are compressed before comparison.
176        const EXCL_CMPR = BTRFS_QGROUP_LIMIT_EXCL_CMPR as u64;
177    }
178}
179
180/// Usage and limit information for a single qgroup.
181#[derive(Debug, Clone)]
182pub struct QgroupInfo {
183    /// Packed qgroup ID: `(level << 48) | subvolid`.
184    pub qgroupid: u64,
185    /// Total referenced bytes (includes shared data).
186    pub rfer: u64,
187    /// Referenced bytes after compression.
188    pub rfer_cmpr: u64,
189    /// Exclusively-owned bytes (not shared with any other subvolume).
190    pub excl: u64,
191    /// Exclusively-owned bytes after compression.
192    pub excl_cmpr: u64,
193    /// Limit flags — which of the limit fields below are enforced.
194    pub limit_flags: QgroupLimitFlags,
195    /// Maximum referenced bytes.  `u64::MAX` when no limit is set.
196    pub max_rfer: u64,
197    /// Maximum exclusive bytes.  `u64::MAX` when no limit is set.
198    pub max_excl: u64,
199    /// IDs of qgroups that are parents of this one in the hierarchy.
200    pub parents: Vec<u64>,
201    /// IDs of qgroups that are children of this one in the hierarchy.
202    pub children: Vec<u64>,
203    /// Level-0 only: `true` when the corresponding subvolume no longer
204    /// exists (this is a "stale" qgroup left behind after deletion).
205    pub stale: bool,
206}
207
208/// Result of [`qgroup_list`]: overall quota status and per-qgroup details.
209#[derive(Debug, Clone)]
210pub struct QgroupList {
211    /// Flags from the `BTRFS_QGROUP_STATUS_KEY` item.
212    pub status_flags: QgroupStatusFlags,
213    /// All qgroups found in the quota tree, sorted by `qgroupid`.
214    pub qgroups: Vec<QgroupInfo>,
215}
216
217#[derive(Default)]
218struct QgroupEntryBuilder {
219    // From INFO item
220    has_info: bool,
221    rfer: u64,
222    rfer_cmpr: u64,
223    excl: u64,
224    excl_cmpr: u64,
225    // From LIMIT item
226    has_limit: bool,
227    limit_flags: u64,
228    max_rfer: u64,
229    max_excl: u64,
230    // From RELATION items
231    parents: Vec<u64>,
232    children: Vec<u64>,
233}
234
235impl QgroupEntryBuilder {
236    fn build(self, qgroupid: u64, stale: bool) -> QgroupInfo {
237        QgroupInfo {
238            qgroupid,
239            rfer: self.rfer,
240            rfer_cmpr: self.rfer_cmpr,
241            excl: self.excl,
242            excl_cmpr: self.excl_cmpr,
243            limit_flags: QgroupLimitFlags::from_bits_truncate(self.limit_flags),
244            max_rfer: if self.limit_flags
245                & u64::from(BTRFS_QGROUP_LIMIT_MAX_RFER)
246                != 0
247            {
248                self.max_rfer
249            } else {
250                u64::MAX
251            },
252            max_excl: if self.limit_flags
253                & u64::from(BTRFS_QGROUP_LIMIT_MAX_EXCL)
254                != 0
255            {
256                self.max_excl
257            } else {
258                u64::MAX
259            },
260            parents: self.parents,
261            children: self.children,
262            stale,
263        }
264    }
265}
266
267fn parse_status_flags(data: &[u8]) -> Option<u64> {
268    btrfs_disk::items::QgroupStatus::parse(data).map(|qs| qs.flags)
269}
270
271fn parse_info(builder: &mut QgroupEntryBuilder, data: &[u8]) {
272    let Some(qi) = btrfs_disk::items::QgroupInfo::parse(data) else {
273        return;
274    };
275    builder.has_info = true;
276    builder.rfer = qi.referenced;
277    builder.rfer_cmpr = qi.referenced_compressed;
278    builder.excl = qi.exclusive;
279    builder.excl_cmpr = qi.exclusive_compressed;
280}
281
282fn parse_limit(builder: &mut QgroupEntryBuilder, data: &[u8]) {
283    let Some(ql) = btrfs_disk::items::QgroupLimit::parse(data) else {
284        return;
285    };
286    builder.has_limit = true;
287    builder.limit_flags = ql.flags;
288    builder.max_rfer = ql.max_referenced;
289    builder.max_excl = ql.max_exclusive;
290}
291
292/// Create a new qgroup with the given `qgroupid` on the filesystem referred
293/// to by `fd`.
294///
295/// `qgroupid` is the packed form: `(level << 48) | subvolid`.
296///
297/// # Errors
298///
299/// Returns `Err` if the ioctl fails.
300pub fn qgroup_create(fd: BorrowedFd, qgroupid: u64) -> nix::Result<()> {
301    let mut args: btrfs_ioctl_qgroup_create_args = unsafe { mem::zeroed() };
302    args.create = 1;
303    args.qgroupid = qgroupid;
304    // SAFETY: args is fully initialised above and lives for the duration of
305    // the ioctl call.
306    unsafe { btrfs_ioc_qgroup_create(fd.as_raw_fd(), &raw const args) }?;
307    Ok(())
308}
309
310/// Destroy the qgroup with the given `qgroupid` on the filesystem referred
311/// to by `fd`.
312///
313/// # Errors
314///
315/// Returns `Err` if the ioctl fails.
316pub fn qgroup_destroy(fd: BorrowedFd, qgroupid: u64) -> nix::Result<()> {
317    let mut args: btrfs_ioctl_qgroup_create_args = unsafe { mem::zeroed() };
318    args.create = 0;
319    args.qgroupid = qgroupid;
320    // SAFETY: args is fully initialised above and lives for the duration of
321    // the ioctl call.
322    unsafe { btrfs_ioc_qgroup_create(fd.as_raw_fd(), &raw const args) }?;
323    Ok(())
324}
325
326/// Assign qgroup `src` as a member of qgroup `dst` (i.e. `src` becomes a
327/// child of `dst`).
328///
329/// Returns `true` if the kernel indicates that a quota rescan is now needed
330/// (the ioctl returned a positive value).
331///
332/// Errors: ENOENT if either qgroup does not exist.  EEXIST if the
333/// relationship already exists.
334///
335/// # Errors
336///
337/// Returns `Err` if the ioctl fails.
338pub fn qgroup_assign(fd: BorrowedFd, src: u64, dst: u64) -> nix::Result<bool> {
339    let mut args: btrfs_ioctl_qgroup_assign_args = unsafe { mem::zeroed() };
340    args.assign = 1;
341    args.src = src;
342    args.dst = dst;
343    // SAFETY: args is fully initialised above and lives for the duration of
344    // the ioctl call.
345    let ret =
346        unsafe { btrfs_ioc_qgroup_assign(fd.as_raw_fd(), &raw const args) }?;
347    Ok(ret > 0)
348}
349
350/// Remove the child-parent relationship between qgroups `src` and `dst`.
351///
352/// Returns `true` if the kernel indicates that a quota rescan is now needed.
353///
354/// Errors: ENOENT if either qgroup does not exist or the relationship
355/// is not present.
356///
357/// # Errors
358///
359/// Returns `Err` if the ioctl fails.
360pub fn qgroup_remove(fd: BorrowedFd, src: u64, dst: u64) -> nix::Result<bool> {
361    let mut args: btrfs_ioctl_qgroup_assign_args = unsafe { mem::zeroed() };
362    args.assign = 0;
363    args.src = src;
364    args.dst = dst;
365    // SAFETY: args is fully initialised above and lives for the duration of
366    // the ioctl call.
367    let ret =
368        unsafe { btrfs_ioc_qgroup_assign(fd.as_raw_fd(), &raw const args) }?;
369    Ok(ret > 0)
370}
371
372/// Set usage limits on a qgroup.
373///
374/// Pass `QgroupLimitFlags::MAX_RFER` in `flags` to enforce `max_rfer`, and/or
375/// `QgroupLimitFlags::MAX_EXCL` to enforce `max_excl`.  Clear a limit by
376/// omitting the corresponding flag.
377///
378/// # Errors
379///
380/// Returns `Err` if the ioctl fails.
381pub fn qgroup_limit(
382    fd: BorrowedFd,
383    qgroupid: u64,
384    flags: QgroupLimitFlags,
385    max_rfer: u64,
386    max_excl: u64,
387) -> nix::Result<()> {
388    let lim = btrfs_qgroup_limit {
389        flags: flags.bits(),
390        max_referenced: max_rfer,
391        max_exclusive: max_excl,
392        rsv_referenced: 0,
393        rsv_exclusive: 0,
394    };
395    let mut args: btrfs_ioctl_qgroup_limit_args = unsafe { mem::zeroed() };
396    args.qgroupid = qgroupid;
397    args.lim = lim;
398    // SAFETY: args is fully initialised above and lives for the duration of
399    // the ioctl call.  The ioctl number is #43 (_IOR direction in the kernel
400    // header), which reads args from userspace.
401    unsafe { btrfs_ioc_qgroup_limit(fd.as_raw_fd(), &raw mut args) }?;
402    Ok(())
403}
404
405/// List all qgroups and overall quota status for the filesystem referred to
406/// by `fd`.
407///
408/// Returns `Ok(QgroupList { status_flags: empty, qgroups: [] })` when quota
409/// accounting is not enabled (`ENOENT` from the kernel).
410///
411/// # Errors
412///
413/// Returns `Err` if the tree search fails (other than `ENOENT`).
414pub fn qgroup_list(fd: BorrowedFd) -> nix::Result<QgroupList> {
415    // Build a map of qgroupid → builder as we walk the quota tree.
416    let mut builders: HashMap<u64, QgroupEntryBuilder> = HashMap::new();
417    let mut status_flags = QgroupStatusFlags::empty();
418
419    // Scan the quota tree for STATUS / INFO / LIMIT / RELATION items in one pass.
420    let quota_key = SearchKey {
421        tree_id: u64::from(BTRFS_QUOTA_TREE_OBJECTID),
422        min_objectid: 0,
423        max_objectid: u64::MAX,
424        min_type: BTRFS_QGROUP_STATUS_KEY,
425        max_type: BTRFS_QGROUP_RELATION_KEY,
426        min_offset: 0,
427        max_offset: u64::MAX,
428        min_transid: 0,
429        max_transid: u64::MAX,
430    };
431
432    let scan_result = tree_search(fd, quota_key, |hdr, data| {
433        match hdr.item_type {
434            t if t == BTRFS_QGROUP_STATUS_KEY => {
435                if let Some(raw) = parse_status_flags(data) {
436                    status_flags = QgroupStatusFlags::from_bits_truncate(raw);
437                }
438            }
439            t if t == BTRFS_QGROUP_INFO_KEY => {
440                // offset = qgroupid
441                let entry = builders.entry(hdr.offset).or_default();
442                parse_info(entry, data);
443            }
444            t if t == BTRFS_QGROUP_LIMIT_KEY => {
445                // offset = qgroupid
446                let entry = builders.entry(hdr.offset).or_default();
447                parse_limit(entry, data);
448            }
449            t if t == BTRFS_QGROUP_RELATION_KEY => {
450                // The kernel stores two entries per relation:
451                //   (child, RELATION_KEY, parent)
452                //   (parent, RELATION_KEY, child)
453                // Only process the canonical form where objectid > offset,
454                // i.e. parent > child.
455                if hdr.objectid > hdr.offset {
456                    let parent = hdr.objectid;
457                    let child = hdr.offset;
458                    builders.entry(child).or_default().parents.push(parent);
459                    builders.entry(parent).or_default().children.push(child);
460                }
461            }
462            _ => {}
463        }
464        Ok(())
465    });
466
467    match scan_result {
468        Err(Errno::ENOENT) => {
469            // Quota tree does not exist — quotas are disabled.
470            return Ok(QgroupList {
471                status_flags: QgroupStatusFlags::empty(),
472                qgroups: Vec::new(),
473            });
474        }
475        Err(e) => return Err(e),
476        Ok(()) => {}
477    }
478
479    // Collect existing subvolume IDs so we can mark stale level-0 qgroups.
480    let existing_subvol_ids = collect_subvol_ids(fd)?;
481
482    // Convert builders to QgroupInfo, computing stale flag for level-0 groups.
483    let mut qgroups: Vec<QgroupInfo> = builders
484        .into_iter()
485        .map(|(qgroupid, builder)| {
486            let stale = if qgroupid_level(qgroupid) == 0 {
487                !existing_subvol_ids.contains(&qgroupid_subvolid(qgroupid))
488            } else {
489                false
490            };
491            builder.build(qgroupid, stale)
492        })
493        .collect();
494
495    qgroups.sort_by_key(|q| q.qgroupid);
496
497    Ok(QgroupList {
498        status_flags,
499        qgroups,
500    })
501}
502
503/// Collect the set of all existing subvolume IDs by scanning
504/// `ROOT_ITEM_KEY` entries in the root tree.
505fn collect_subvol_ids(fd: BorrowedFd) -> nix::Result<HashSet<u64>> {
506    let mut ids: HashSet<u64> = HashSet::new();
507
508    // BTRFS_LAST_FREE_OBJECTID binds as i32 = -256; cast to u64 gives
509    // 0xFFFFFFFF_FFFFFF00 as expected.
510    #[allow(clippy::cast_sign_loss)]
511    let key = SearchKey::for_objectid_range(
512        u64::from(BTRFS_ROOT_TREE_OBJECTID),
513        BTRFS_ROOT_ITEM_KEY,
514        u64::from(BTRFS_FIRST_FREE_OBJECTID),
515        BTRFS_LAST_FREE_OBJECTID as u64,
516    );
517
518    tree_search(fd, key, |hdr, _data| {
519        ids.insert(hdr.objectid);
520        Ok(())
521    })?;
522
523    Ok(ids)
524}
525
526/// Destroy all "stale" level-0 qgroups — those whose corresponding subvolume
527/// no longer exists.
528///
529/// In simple-quota mode (`SIMPLE_MODE` flag set), stale qgroups with non-zero
530/// `rfer` or `excl` are retained because they hold accounting information for
531/// dropped subvolumes.
532///
533/// Returns the number of qgroups successfully destroyed.
534///
535/// # Errors
536///
537/// Returns `Err` if listing qgroups fails.
538pub fn qgroup_clear_stale(fd: BorrowedFd) -> nix::Result<usize> {
539    let list = qgroup_list(fd)?;
540    let simple_mode =
541        list.status_flags.contains(QgroupStatusFlags::SIMPLE_MODE);
542
543    let mut count = 0usize;
544
545    for qg in &list.qgroups {
546        // Only process level-0 stale qgroups.
547        if qgroupid_level(qg.qgroupid) != 0 || !qg.stale {
548            continue;
549        }
550
551        // In simple-quota mode, keep stale qgroups that still have usage data.
552        if simple_mode && (qg.rfer != 0 || qg.excl != 0) {
553            continue;
554        }
555
556        if qgroup_destroy(fd, qg.qgroupid).is_ok() {
557            count += 1;
558        }
559    }
560
561    Ok(count)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn qgroupid_level_zero() {
570        assert_eq!(qgroupid_level(5), 0);
571        assert_eq!(qgroupid_level(256), 0);
572    }
573
574    #[test]
575    fn qgroupid_level_nonzero() {
576        let id = (1u64 << 48) | 100;
577        assert_eq!(qgroupid_level(id), 1);
578
579        let id = (3u64 << 48) | 42;
580        assert_eq!(qgroupid_level(id), 3);
581    }
582
583    #[test]
584    fn qgroupid_subvolid_extracts_lower_48_bits() {
585        assert_eq!(qgroupid_subvolid(256), 256);
586        assert_eq!(qgroupid_subvolid((1u64 << 48) | 100), 100);
587        assert_eq!(qgroupid_subvolid((2u64 << 48) | 0), 0);
588    }
589
590    #[test]
591    fn qgroupid_roundtrip() {
592        let level: u64 = 2;
593        let subvolid: u64 = 999;
594        let packed = (level << 48) | subvolid;
595        assert_eq!(qgroupid_level(packed), level as u16);
596        assert_eq!(qgroupid_subvolid(packed), subvolid);
597    }
598}