Skip to main content

git_policy_cli/
fs_btrfs_ioctl.rs

1//! What:
2//!  The three btrfs ioctls the wrapper needs, called in process: create an empty subvolume, snapshot a subvolume,
3//!  and destroy a subvolume.
4//! Why:
5//!  `libc` has `FICLONE` and the filesystem magic numbers but no `BTRFS_IOC_*` constants, and the wrapper never starts
6//!  the `btrfs` program (the repository's rule: embed instead of spawning). The request numbers and the 4096-byte
7//!  argument structures are taken from the kernel header `include/uapi/linux/btrfs.h` (the host copy is
8//!  `/usr/include/linux/btrfs.h`): `BTRFS_IOCTL_MAGIC` is 0x94; `BTRFS_IOC_SUBVOL_CREATE` is `_IOW(0x94, 14,
9//!  struct btrfs_ioctl_vol_args)`; `BTRFS_IOC_SNAP_DESTROY` is `_IOW(0x94, 15, struct btrfs_ioctl_vol_args)`;
10//!  `BTRFS_IOC_SNAP_CREATE_V2` is `_IOW(0x94, 23, struct btrfs_ioctl_vol_args_v2)`. Both structures are 4096 bytes:
11//!  `vol_args` is an `s64` fd and 4088 name bytes; `vol_args_v2` is an `s64` fd, `u64` transid, `u64` flags, a
12//!  32-byte union and 4040 name bytes. `strace` of `btrfs subvolume snapshot` and `btrfs subvolume delete` shows the
13//!  same shapes (`doc/planning/cli-git-btrfs-worktree.md`, "Facts"). Each call is proven by one real call in a test on
14//!  a btrfs fixture, and the encoding of the request numbers is proven against `libc::FICLONE`.
15//!  Permissions (kernel `fs/btrfs/ioctl.c`): creating a subvolume is unrestricted; a snapshot needs ownership of the
16//!  source; destroying one needs the `user_subvol_rm_allowed` mount option for an unprivileged user.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! await btrfsIoctl(parentFd, 'SNAP_CREATE_V2', { fd: sourceFd, flags: 0, name });
21//! ```
22
23use super::fs_primitive_error::PrimitiveError;
24use std::ffi::OsStr;
25use std::fs::File;
26use std::os::unix::ffi::OsStrExt;
27use std::os::unix::io::AsRawFd;
28use std::path::Path;
29
30/// `BTRFS_IOCTL_MAGIC`.
31const BTRFS_IOCTL_MAGIC: u32 = 0x94;
32
33/// Bits the request number gives to the argument size, after the type and number fields (`_IOC_SIZESHIFT`).
34const IOC_SIZE_SHIFT: u32 = 16;
35
36/// Bits to the type field (`_IOC_TYPESHIFT`).
37const IOC_TYPE_SHIFT: u32 = 8;
38
39/// What:
40///  How an architecture encodes the direction of an ioctl request: the value of `_IOC_WRITE` and where the field sits.
41/// Why:
42///   Linux has three encodings, not one. `include/uapi/asm-generic/ioctl.h` (x86, arm, arm64, riscv, s390, loongarch)
43///  gives the direction 2 bits at shift 30 with `_IOC_WRITE` 1; `arch/powerpc`, `arch/mips`, `arch/sparc` and
44///  `arch/alpha` define `_IOC_SIZEBITS` 13, `_IOC_DIRBITS` 3 and `_IOC_WRITE` 4, which puts the field at shift 29;
45///  `arch/parisc` keeps 14 size bits and defines `_IOC_WRITE` 2. A request number built with the wrong one reaches the
46///  kernel as a different request and fails.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type IocLayout = { write: number; directionShift: number };
51/// ```
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct IocLayout {
54    /// `_IOC_WRITE`: the direction value for an argument user space writes.
55    pub write: u32,
56    /// `_IOC_DIRSHIFT`: where the direction field starts.
57    pub direction_shift: u32,
58}
59
60/// The asm-generic encoding.
61pub const GENERIC_IOC_LAYOUT: IocLayout = IocLayout { write: 1, direction_shift: 30 };
62
63/// The encoding of powerpc, mips, sparc and alpha: a 3-bit direction field with `_IOC_WRITE` 4.
64pub const WIDE_DIRECTION_IOC_LAYOUT: IocLayout = IocLayout { write: 4, direction_shift: 29 };
65
66/// The encoding of parisc: `_IOC_WRITE` 2 with the generic field width.
67pub const PARISC_IOC_LAYOUT: IocLayout = IocLayout { write: 2, direction_shift: 30 };
68
69/// The encoding of the architecture this code is built for.
70#[cfg(any(target_arch = "powerpc", target_arch = "powerpc64", target_arch = "mips", target_arch = "mips32r6", target_arch = "mips64", target_arch = "mips64r6", target_arch = "sparc", target_arch = "sparc64"))]
71pub const HOST_IOC_LAYOUT: IocLayout = WIDE_DIRECTION_IOC_LAYOUT;
72
73/// The encoding of the architecture this code is built for.
74#[cfg(not(any(target_arch = "powerpc", target_arch = "powerpc64", target_arch = "mips", target_arch = "mips32r6", target_arch = "mips64", target_arch = "mips64r6", target_arch = "sparc", target_arch = "sparc64")))]
75pub const HOST_IOC_LAYOUT: IocLayout = GENERIC_IOC_LAYOUT;
76
77/// The size of `struct btrfs_ioctl_vol_args` and `struct btrfs_ioctl_vol_args_v2`, both 4096 bytes.
78pub const VOLUME_ARGUMENT_BYTES: usize = 4096;
79
80/// The longest name `BTRFS_IOC_SNAP_CREATE_V2` takes: `BTRFS_SUBVOL_NAME_MAX` (4039) plus its NUL, in the header.
81const V2_NAME_CAPACITY: usize = 4040;
82
83/// The longest name the version 1 argument takes: `BTRFS_PATH_NAME_MAX + 1`, in the header.
84const V1_NAME_CAPACITY: usize = 4088;
85
86/// The longest file name any Linux directory entry has (`NAME_MAX`); btrfs accepts nothing longer for a subvolume.
87const NAME_MAX: usize = 255;
88
89/// What:
90///  The request number `_IOW(magic, number, size)` the C macro computes under a given architecture's encoding.
91///  `u32` is an unsigned 32-bit integer (siblings `u64` and `i32`); the macro's result fits 32 bits.
92/// Why:
93///   The numbers are computed from the header's definition instead of typed in as magic constants, so a test can check
94///  the encoding against `libc::FICLONE`, which `libc` already defines as `_IOW(0x94, 9, int)` for each architecture.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// const iow = (layout: IocLayout, nr: number, size: number) => (layout.write << layout.directionShift) | (size << 16) | (0x94 << 8) | nr;
99/// ```
100pub const fn ioc_write_with(layout: IocLayout, number: u32, size: usize) -> u32 {
101    return (layout.write << layout.direction_shift) | ((size as u32) << IOC_SIZE_SHIFT) | (BTRFS_IOCTL_MAGIC << IOC_TYPE_SHIFT) | number;
102}
103
104/// What:
105///  The request number `_IOW(magic, number, size)` for the architecture this code is built for.
106///  `libc::Ioctl` is the width the platform's `ioctl` takes.
107/// Why:
108///   See `ioc_write_with`.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// const iow = (nr: number, size: number) => iowFor(hostLayout, nr, size);
113/// ```
114pub const fn ioc_write(number: u32, size: usize) -> libc::Ioctl {
115    return ioc_write_with(HOST_IOC_LAYOUT, number, size) as libc::Ioctl;
116}
117
118/// `BTRFS_IOC_SUBVOL_CREATE`: `_IOW(0x94, 14, struct btrfs_ioctl_vol_args)`.
119pub const BTRFS_IOC_SUBVOL_CREATE: libc::Ioctl = ioc_write(14, VOLUME_ARGUMENT_BYTES);
120
121/// `BTRFS_IOC_SNAP_DESTROY`: `_IOW(0x94, 15, struct btrfs_ioctl_vol_args)`.
122pub const BTRFS_IOC_SNAP_DESTROY: libc::Ioctl = ioc_write(15, VOLUME_ARGUMENT_BYTES);
123
124/// `BTRFS_IOC_SNAP_CREATE_V2`: `_IOW(0x94, 23, struct btrfs_ioctl_vol_args_v2)`.
125pub const BTRFS_IOC_SNAP_CREATE_V2: libc::Ioctl = ioc_write(23, VOLUME_ARGUMENT_BYTES);
126
127/// What:
128///  `struct btrfs_ioctl_vol_args`: a source descriptor and a name.
129///  `#[repr(C)]` keeps the C field order and padding, which the kernel reads.
130/// Why:
131///   The subvolume create and destroy ioctls take only a name; the kernel ignores `fd` for them.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// type VolArgs = { fd: bigint; name: Uint8Array /* 4088 bytes */ };
136/// ```
137#[repr(C)]
138pub struct VolArgs {
139    /// `__s64 fd`.
140    pub fd: i64,
141    /// `char name[BTRFS_PATH_NAME_MAX + 1]`, NUL terminated.
142    pub name: [u8; V1_NAME_CAPACITY],
143}
144
145/// What:
146///  `struct btrfs_ioctl_vol_args_v2`: a source descriptor, flags and a name.
147/// Why:
148///   The snapshot ioctl takes the source subvolume as `fd` and the new name in the parent directory.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// type VolArgsV2 = { fd: bigint; transid: bigint; flags: bigint; union: Uint8Array /* 32 bytes */; name: Uint8Array /* 4040 bytes */ };
153/// ```
154#[repr(C)]
155pub struct VolArgsV2 {
156    /// `__s64 fd`: the source subvolume's descriptor for a snapshot.
157    pub fd: i64,
158    /// `__u64 transid`: output, the transaction that made the snapshot.
159    pub transid: u64,
160    /// `__u64 flags`: 0 for a writable snapshot (`BTRFS_SUBVOL_RDONLY` makes a read-only one).
161    pub flags: u64,
162    /// The 32-byte union (`size` and `qgroup_inherit`, or `unused[4]`), zero for no qgroup inheritance.
163    pub union_bytes: [u8; 32],
164    /// `char name[BTRFS_SUBVOL_NAME_MAX + 1]`, NUL terminated.
165    pub name: [u8; V2_NAME_CAPACITY],
166}
167
168/// What:
169///  Check one name for a subvolume entry and copy it, NUL terminated, into a fixed buffer.
170///  `const N: usize` is the buffer length (a TS tuple length is not a type parameter, so this is a generic over a
171///  number).
172/// Why:
173///   The kernel takes one path component: a slash, an empty name, `.` or `..` would be a different request than the
174///  caller meant, so they fail with `EINVAL` here before any call.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// function fillName(buffer: Uint8Array, name: Uint8Array): void { if (name.length === 0 || name.includes(0x2f) || name.length > 255) throw ...; buffer.set(name); }
179/// ```
180fn fill_name<const N: usize>(buffer: &mut [u8; N], name: &OsStr, operation: &'static str, parent: &Path) -> Result<(), PrimitiveError> {
181    let bytes: &[u8] = name.as_bytes();
182    let refused: bool = bytes.is_empty()
183        || bytes.len() > NAME_MAX
184        || bytes.len() >= N
185        || bytes.contains(&b'/')
186        || bytes.contains(&0)
187        || bytes == b"."
188        || bytes == b"..";
189    if refused {
190        return Err(PrimitiveError { operation, path: parent.join(name), errno: libc::EINVAL });
191    }
192    buffer[..bytes.len()].copy_from_slice(bytes);
193    return Ok(());
194}
195
196/// What:
197///  Open a directory for an ioctl on it.
198/// Why:
199///   The ioctls act on the directory descriptor; the kernel checks write and search permission on it itself.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// const fd = await open(directory, 'r');
204/// ```
205fn open_directory(directory: &Path, operation: &'static str) -> Result<File, PrimitiveError> {
206    return File::open(directory)
207        .map_err(|error| { return PrimitiveError { operation, path: directory.to_path_buf(), errno: error.raw_os_error().unwrap_or(0) }; });
208}
209
210/// What:
211///  Create an empty subvolume named `name` inside `parent` (`BTRFS_IOC_SUBVOL_CREATE`).
212/// Why:
213///   Creation is not restricted to privileged users. An empty subvolume is an empty directory to Git, so
214///  `git worktree add` accepts it as a destination.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// await btrfsIoctl(await open(parent), 'SUBVOL_CREATE', { name });
219/// ```
220pub fn subvolume_create(parent: &Path, name: &OsStr) -> Result<(), PrimitiveError> {
221    let operation: &'static str = "BTRFS_IOC_SUBVOL_CREATE";
222    let directory: File = open_directory(parent, operation)?;
223    // SAFETY: `VolArgs` is plain integers and bytes, so the all-zero value is a valid value.
224    let mut arguments: VolArgs = unsafe { std::mem::zeroed() };
225    fill_name(&mut arguments.name, name, operation, parent)?;
226    // SAFETY: `directory` is open for the call; `arguments` is a 4096-byte structure as the request number states.
227    let result: libc::c_int = unsafe { libc::ioctl(directory.as_raw_fd(), BTRFS_IOC_SUBVOL_CREATE, &arguments) };
228    if result != 0 {
229        return Err(PrimitiveError::last_os_error(operation, parent.join(name).as_path()));
230    }
231    return Ok(());
232}
233
234/// What:
235///  Snapshot the subvolume at `source` as a new subvolume `name` inside `parent` (`BTRFS_IOC_SNAP_CREATE_V2`, flags 0,
236///  so the snapshot is writable).
237/// Why:
238///   The kernel requires the source to be a subvolume root on the same filesystem and owned by the caller
239///  (`EINVAL`, `EXDEV` and `EPERM` otherwise), and shares every data block with the source.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// await btrfsIoctl(await open(parent), 'SNAP_CREATE_V2', { fd: await open(source), flags: 0, name });
244/// ```
245pub fn snapshot_create(source: &Path, parent: &Path, name: &OsStr) -> Result<(), PrimitiveError> {
246    let operation: &'static str = "BTRFS_IOC_SNAP_CREATE_V2";
247    let directory: File = open_directory(parent, operation)?;
248    let origin: File = open_directory(source, operation)?;
249    // SAFETY: `VolArgsV2` is plain integers and bytes, so the all-zero value is a valid value.
250    let mut arguments: VolArgsV2 = unsafe { std::mem::zeroed() };
251    arguments.fd = i64::from(origin.as_raw_fd());
252    fill_name(&mut arguments.name, name, operation, parent)?;
253    // SAFETY: both descriptors are open for the call; `arguments` is a 4096-byte structure as the request number states.
254    let result: libc::c_int = unsafe { libc::ioctl(directory.as_raw_fd(), BTRFS_IOC_SNAP_CREATE_V2, &arguments) };
255    if result != 0 {
256        return Err(PrimitiveError::last_os_error(operation, parent.join(name).as_path()));
257    }
258    return Ok(());
259}
260
261/// What:
262///  Destroy the subvolume `name` inside `parent` (`BTRFS_IOC_SNAP_DESTROY`).
263/// Why:
264///   For an unprivileged user the kernel needs the `user_subvol_rm_allowed` mount option (`EPERM` otherwise), write
265///  and search permission on the subvolume root, and no nested subvolume inside (`ENOTEMPTY`). Without the option an
266///  empty subvolume is removed by `rmdir`, which `btrfs_rmdir` allows for an empty subvolume root.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// await btrfsIoctl(await open(parent), 'SNAP_DESTROY', { name });
271/// ```
272pub fn snapshot_destroy(parent: &Path, name: &OsStr) -> Result<(), PrimitiveError> {
273    let operation: &'static str = "BTRFS_IOC_SNAP_DESTROY";
274    let directory: File = open_directory(parent, operation)?;
275    // SAFETY: `VolArgs` is plain integers and bytes, so the all-zero value is a valid value.
276    let mut arguments: VolArgs = unsafe { std::mem::zeroed() };
277    fill_name(&mut arguments.name, name, operation, parent)?;
278    // SAFETY: `directory` is open for the call; `arguments` is a 4096-byte structure as the request number states.
279    let result: libc::c_int = unsafe { libc::ioctl(directory.as_raw_fd(), BTRFS_IOC_SNAP_DESTROY, &arguments) };
280    if result != 0 {
281        return Err(PrimitiveError::last_os_error(operation, parent.join(name).as_path()));
282    }
283    return Ok(());
284}
285
286/// What:
287///  Split a path into its parent directory and last component.
288/// Why:
289///   The ioctls take the parent and a one-component name; callers hold whole paths. A path with no parent or no last
290///  component (`/`, `..`) fails with `EINVAL`.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// const [parent, name] = [dirname(path), basename(path)];
295/// ```
296pub fn split_parent(path: &Path, operation: &'static str) -> Result<(std::path::PathBuf, std::ffi::OsString), PrimitiveError> {
297    let refused = || { return PrimitiveError { operation, path: path.to_path_buf(), errno: libc::EINVAL }; };
298    let name: &OsStr = path.file_name().ok_or_else(refused)?;
299    let parent: &Path = path.parent().ok_or_else(refused)?;
300    let parent_path: std::path::PathBuf = if parent.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { parent.to_path_buf() };
301    return Ok((parent_path, name.to_os_string()));
302}
303
304/// What:
305///  Create an empty subvolume at a whole path.
306/// Why:
307///   The convenience over `subvolume_create` for callers that hold the destination path.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// await subvolumeCreate(dirname(path), basename(path));
312/// ```
313pub fn subvolume_create_at(path: &Path) -> Result<(), PrimitiveError> {
314    let (parent, name) = split_parent(path, "BTRFS_IOC_SUBVOL_CREATE")?;
315    return subvolume_create(parent.as_path(), name.as_os_str());
316}
317
318/// What:
319///  Destroy the subvolume at a whole path.
320/// Why:
321///   The convenience over `snapshot_destroy` for callers that hold the path.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// await snapshotDestroy(dirname(path), basename(path));
326/// ```
327pub fn subvolume_destroy_at(path: &Path) -> Result<(), PrimitiveError> {
328    let (parent, name) = split_parent(path, "BTRFS_IOC_SNAP_DESTROY")?;
329    return snapshot_destroy(parent.as_path(), name.as_os_str());
330}
331
332/// What:
333///  Snapshot the subvolume at `source` to the whole path `destination`.
334/// Why:
335///   The convenience over `snapshot_create` for callers that hold the destination path.
336///
337/// In TS you'd write (pseudocode):
338/// ```ts
339/// await snapshotCreate(source, dirname(destination), basename(destination));
340/// ```
341pub fn snapshot_create_at(source: &Path, destination: &Path) -> Result<(), PrimitiveError> {
342    let (parent, name) = split_parent(destination, "BTRFS_IOC_SNAP_CREATE_V2")?;
343    return snapshot_create(source, parent.as_path(), name.as_os_str());
344}
345
346/// Btrfs ioctl controls stay out of the release executable.
347#[cfg(test)]
348#[path = "fs_btrfs_ioctl_tests.rs"]
349mod tests;