Skip to main content

git_policy_cli/subvolume/
fs_beneath.rs

1//! What:
2//!  Directory-relative file operations that never follow a symbolic link or cross a mount: open a directory, a file or a
3//!  link below a held directory handle one plain name at a time (`openat2` with `RESOLVE_BENEATH`, `RESOLVE_NO_SYMLINKS`,
4//!  `RESOLVE_NO_XDEV` and `RESOLVE_NO_MAGICLINKS`), create entries there, set times on them, rename them, publish an open
5//!  file under a new name without replacing anything, and remove a lock file whose identity is the one that was recorded.
6//! Why:
7//!  A path string is resolved again by every call that takes it, so a directory swapped for a link between two calls
8//!  redirects the second one. A name that was checked may also name another object when it is unlinked, and nothing in the
9//!  kernel makes the two one step, so no tree is deleted through this module: what the ladder made is kept and reported.
10//!  The clone rung and the snapshot's stub removal work on trees other processes can rename into; holding the handle of each
11//!  directory and naming one component at a time keeps every call on the object that was looked at.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const root = await openRoot('/canonical/path'); // throws when an ancestor is a link
16//! const dir = await openDirectoryAt(root, 'a'); // throws on a link, a mount, '.' or '..'
17//! await removeOwnedAt(dir, 'file', recordedIdentity); // keeps a replacement
18//! ```
19
20use std::ffi::{CString, OsStr, OsString};
21use std::fs::File;
22use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
23use std::os::unix::ffi::OsStrExt;
24use std::path::{Component, Path, PathBuf};
25
26/// `RESOLVE_NO_XDEV`: no mount point, bind mounts included, is crossed.
27const RESOLVE_NO_XDEV: u64 = 0x01;
28/// `RESOLVE_NO_MAGICLINKS`: no `/proc` style link is followed.
29const RESOLVE_NO_MAGICLINKS: u64 = 0x02;
30/// `RESOLVE_NO_SYMLINKS`: no symbolic link is followed, the last component included.
31const RESOLVE_NO_SYMLINKS: u64 = 0x04;
32/// `RESOLVE_BENEATH`: the lookup stays below the directory it starts from.
33const RESOLVE_BENEATH: u64 = 0x08;
34
35/// The arguments of `openat2`, laid out as the kernel's `struct open_how`.
36#[repr(C)]
37struct OpenHow {
38    /// The `open` flags.
39    flags: u64,
40    /// The mode of a created file.
41    mode: u64,
42    /// The `RESOLVE_*` bits.
43    resolve: u64,
44}
45
46/// The identity of a filesystem object: device, inode and, where the filesystem gives one, the birth time.
47///
48/// Why: a path or a name proves nothing about which object it names later; this does, up to the reuse of an inode number
49/// within one birth-time tick.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct ObjectId {
52    /// The device number.
53    pub device: u64,
54    /// The inode number.
55    pub inode: u64,
56    /// The birth time as seconds and nanoseconds, when the filesystem reports it.
57    pub birth: Option<(i64, u32)>,
58}
59
60impl ObjectId {
61    /// The identity as `device:inode:seconds:nanoseconds` (`-` for an unknown birth time).
62    pub fn encode(&self) -> String {
63        return match self.birth {
64            Some((seconds, nanos)) => format!("{}:{}:{seconds}:{nanos}", self.device, self.inode),
65            None => format!("{}:{}:-:-", self.device, self.inode),
66        };
67    }
68
69    /// The identity from its encoding.
70    pub fn decode(text: &str) -> Option<ObjectId> {
71        let mut parts = text.split(':');
72        let device: u64 = parts.next()?.parse().ok()?;
73        let inode: u64 = parts.next()?.parse().ok()?;
74        let seconds: &str = parts.next()?;
75        let nanos: &str = parts.next()?;
76        let birth: Option<(i64, u32)> = if seconds == "-" { None } else { Some((seconds.parse().ok()?, nanos.parse().ok()?)) };
77        return if parts.next().is_none() { Some(ObjectId { device, inode, birth }) } else { None };
78    }
79}
80
81/// The components of a relative path, each refused unless it is a plain name.
82///
83/// Why: `..`, `.`, a root or a prefix would leave the held directory or name it twice.
84pub fn plain_components(relative: &Path) -> std::io::Result<Vec<OsString>> {
85    let mut names: Vec<OsString> = Vec::new();
86    for part in relative.components() {
87        match part {
88            Component::Normal(name) => names.push(name.to_os_string()),
89            _ => return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a plain relative path", relative.display()))),
90        }
91    }
92    return Ok(names);
93}
94
95/// A NUL-terminated copy of one name, refused when it is empty, `.`, `..`, holds a slash or a NUL byte.
96fn name_of(name: &OsStr) -> std::io::Result<CString> {
97    let bytes: &[u8] = name.as_bytes();
98    if bytes.is_empty() || bytes == b"." || bytes == b".." || bytes.contains(&b'/') {
99        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "a name must be one plain component"));
100    }
101    return CString::new(bytes).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidInput, "a name holds a NUL byte"); });
102}
103
104/// Turn a C result that is `-1` on failure into the thread's error.
105fn checked(result: libc::c_int) -> std::io::Result<libc::c_int> {
106    return if result < 0 { Err(std::io::Error::last_os_error()) } else { Ok(result) };
107}
108
109/// `openat2` of one plain name below `dir`, resolving nothing but the name itself.
110fn open_beneath(dir: &File, name: &OsStr, flags: libc::c_int, mode: u32) -> std::io::Result<File> {
111    let c_name = name_of(name)?;
112    let how: OpenHow = OpenHow {
113        flags: (flags | libc::O_CLOEXEC) as u64,
114        mode: u64::from(mode),
115        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV | RESOLVE_NO_MAGICLINKS,
116    };
117    // SAFETY: `c_name` is a NUL-terminated string and `how` a live `open_how` of the size passed, both outliving the call;
118    // `dir` is an open descriptor.
119    let result: libc::c_long = unsafe { libc::syscall(libc::SYS_openat2, dir.as_raw_fd(), c_name.as_ptr(), &how as *const OpenHow, std::mem::size_of::<OpenHow>()) };
120    if result < 0 {
121        return Err(std::io::Error::last_os_error());
122    }
123    // SAFETY: `openat2` returned a new descriptor that nothing else owns.
124    return Ok(File::from(unsafe { OwnedFd::from_raw_fd(result as libc::c_int) }));
125}
126
127/// Open the directory at the canonical absolute path `path` as the root of a walk.
128///
129/// Why: the ancestors of a path are resolved by the kernel and may be links, so the open directory's own path, read back
130/// from the descriptor, must equal the path asked for; a path with a link among its ancestors is refused.
131pub fn open_root(path: &Path) -> std::io::Result<File> {
132    if !path.is_absolute() {
133        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not absolute", path.display())));
134    }
135    let name = CString::new(path.as_os_str().as_bytes()).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidInput, "a path holds a NUL byte"); })?;
136    // SAFETY: `name` is a NUL-terminated string that outlives the call.
137    let descriptor: libc::c_int = checked(unsafe { libc::open(name.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) })?;
138    // SAFETY: `open` returned a new descriptor that nothing else owns.
139    let held: File = File::from(unsafe { OwnedFd::from_raw_fd(descriptor) });
140    let real: PathBuf = std::fs::read_link(handle_path(&held))?;
141    if real.as_os_str() != path.as_os_str() {
142        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is reached through a link: it is {}", path.display(), real.display())));
143    }
144    return Ok(held);
145}
146
147/// Open the subdirectory `name` of `parent`, refusing a link, a mount point and `.` or `..`.
148pub fn open_directory_at(parent: &File, name: &OsStr) -> std::io::Result<File> {
149    return open_beneath(parent, name, libc::O_RDONLY | libc::O_DIRECTORY, 0);
150}
151
152/// Open the directory at `names` below `root`, one component at a time, refusing a link, a mount point or a directory on
153///  another device (a subvolume of a btrfs filesystem has its own).
154pub fn open_directory_beneath(root: &File, names: &[OsString]) -> std::io::Result<File> {
155    let device: u64 = device_of(root)?;
156    let mut current: File = root.try_clone()?;
157    for name in names {
158        let next: File = open_directory_at(&current, name.as_os_str())?;
159        if device_of(&next)? != device {
160            return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is on another device", name.to_string_lossy())));
161        }
162        current = next;
163    }
164    return Ok(current);
165}
166
167/// The device number of an open file.
168pub fn device_of(file: &File) -> std::io::Result<u64> {
169    use std::os::unix::fs::MetadataExt;
170    return Ok(file.metadata()?.dev());
171}
172
173/// Open the regular file `name` of `dir` for reading, refusing a link; a fifo or a device is refused later by its type.
174pub fn open_file_at(dir: &File, name: &OsStr) -> std::io::Result<File> {
175    return open_beneath(dir, name, libc::O_RDONLY | libc::O_NONBLOCK, 0);
176}
177
178/// Create the file `name` in `dir` with `mode`, failing when anything is there, a link included.
179pub fn create_file_at(dir: &File, name: &OsStr, mode: u32) -> std::io::Result<File> {
180    return open_beneath(dir, name, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, mode);
181}
182
183/// Create the directory `name` in `dir` with `mode`; `Ok(true)` when it was made, `Ok(false)` when a directory was
184///  already there (a link or a file in the way is an error).
185pub fn make_directory_at(dir: &File, name: &OsStr, mode: u32) -> std::io::Result<bool> {
186    let c_name = name_of(name)?;
187    // SAFETY: `c_name` outlives the call and `dir` is an open descriptor.
188    let result: libc::c_int = unsafe { libc::mkdirat(dir.as_raw_fd(), c_name.as_ptr(), mode as libc::mode_t) };
189    if result == 0 {
190        return Ok(true);
191    }
192    let error: std::io::Error = std::io::Error::last_os_error();
193    if error.kind() == std::io::ErrorKind::AlreadyExists {
194        drop(open_directory_at(dir, name)?);
195        return Ok(false);
196    }
197    return Err(error);
198}
199
200/// Create the link `name` in `dir` pointing at `target`.
201pub fn symlink_at(target: &Path, dir: &File, name: &OsStr) -> std::io::Result<()> {
202    let c_name = name_of(name)?;
203    let c_target = CString::new(target.as_os_str().as_bytes()).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidInput, "a link target holds a NUL byte"); })?;
204    // SAFETY: both strings outlive the call and `dir` is an open descriptor.
205    checked(unsafe { libc::symlinkat(c_target.as_ptr(), dir.as_raw_fd(), c_name.as_ptr()) })?;
206    return Ok(());
207}
208
209/// The target of the link `name` in `dir`.
210pub fn read_link_at(dir: &File, name: &OsStr) -> std::io::Result<PathBuf> {
211    use std::os::unix::ffi::OsStringExt;
212    let c_name = name_of(name)?;
213    let mut buffer: Vec<u8> = vec![0; 4096];
214    // SAFETY: `c_name` outlives the call, `buffer` has the length passed, and `dir` is an open descriptor.
215    let length: libc::ssize_t = unsafe { libc::readlinkat(dir.as_raw_fd(), c_name.as_ptr(), buffer.as_mut_ptr().cast::<libc::c_char>(), buffer.len()) };
216    if length < 0 {
217        return Err(std::io::Error::last_os_error());
218    }
219    buffer.truncate(usize::try_from(length).unwrap_or(0));
220    return Ok(PathBuf::from(OsString::from_vec(buffer)));
221}
222
223/// The path through which a held directory handle names that directory, for tools that take a path.
224pub fn handle_path(directory: &File) -> PathBuf {
225    return Path::new("/proc/self/fd").join(directory.as_raw_fd().to_string());
226}
227
228/// The metadata of the entry `name` in `dir`, without following a link.
229pub fn entry_metadata_at(dir: &File, name: &OsStr) -> std::io::Result<std::fs::Metadata> {
230    // The name is one plain component, so the lookup below the descriptor's magic link resolves nothing else.
231    name_of(name)?;
232    return std::fs::symlink_metadata(handle_path(dir).join(name));
233}
234
235/// Set the access and modification times of the entry `name` in `dir`, without following a link.
236pub fn set_times_at(dir: &File, name: &OsStr, times: [libc::timespec; 2]) -> std::io::Result<()> {
237    let c_name = name_of(name)?;
238    // SAFETY: `c_name` outlives the call, `times` holds the two timestamps the call reads, and `dir` is an open descriptor.
239    checked(unsafe { libc::utimensat(dir.as_raw_fd(), c_name.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) })?;
240    return Ok(());
241}
242
243/// Remove the entry `name` from `dir`: a directory when `directory` is set (it must be empty), else a file or a link.
244///
245/// Why: this removes whatever the name holds now; use `remove_owned_at` for an object that was made earlier.
246pub fn remove_at(dir: &File, name: &OsStr, directory: bool) -> std::io::Result<()> {
247    let c_name = name_of(name)?;
248    let flags: libc::c_int = if directory { libc::AT_REMOVEDIR } else { 0 };
249    // SAFETY: `c_name` outlives the call and `dir` is an open descriptor.
250    checked(unsafe { libc::unlinkat(dir.as_raw_fd(), c_name.as_ptr(), flags) })?;
251    return Ok(());
252}
253
254/// Rename `from` of `from_dir` to `to` of `to_dir`; with `replace` false an existing `to` is an error (`RENAME_NOREPLACE`).
255pub fn rename_at(from_dir: &File, from: &OsStr, to_dir: &File, to: &OsStr, replace: bool) -> std::io::Result<()> {
256    let c_from = name_of(from)?;
257    let c_to = name_of(to)?;
258    let flags: libc::c_uint = if replace { 0 } else { libc::RENAME_NOREPLACE };
259    // SAFETY: both names outlive the call and both directories are open descriptors.
260    checked(unsafe { libc::renameat2(from_dir.as_raw_fd(), c_from.as_ptr(), to_dir.as_raw_fd(), c_to.as_ptr(), flags) })?;
261    return Ok(());
262}
263
264/// Exchange the entries `first` and `second` of `dir` atomically (`RENAME_EXCHANGE`).
265pub fn exchange_at(dir: &File, first: &OsStr, second: &OsStr) -> std::io::Result<()> {
266    let c_first = name_of(first)?;
267    let c_second = name_of(second)?;
268    // SAFETY: both names outlive the call and `dir` is an open descriptor.
269    checked(unsafe { libc::renameat2(dir.as_raw_fd(), c_first.as_ptr(), dir.as_raw_fd(), c_second.as_ptr(), libc::RENAME_EXCHANGE) })?;
270    return Ok(());
271}
272
273/// Build an identity from the fields of `statx`.
274fn id_of_statx(found: &libc::statx) -> ObjectId {
275    let birth: Option<(i64, u32)> = if found.stx_mask & libc::STATX_BTIME != 0 { Some((found.stx_btime.tv_sec, found.stx_btime.tv_nsec)) } else { None };
276    return ObjectId { device: libc::makedev(found.stx_dev_major, found.stx_dev_minor), inode: found.stx_ino, birth };
277}
278
279/// The identity of the entry `name` in `dir`, without following a link (`name` empty means `dir` itself).
280fn identity_statx(dir: &File, name: Option<&OsStr>) -> std::io::Result<ObjectId> {
281    let (c_name, flags): (CString, libc::c_int) = match name {
282        Some(plain) => (name_of(plain)?, libc::AT_SYMLINK_NOFOLLOW),
283        None => (CString::default(), libc::AT_EMPTY_PATH),
284    };
285    // SAFETY: an all-zero `statx` is a valid value for the call to fill.
286    let mut found: libc::statx = unsafe { std::mem::zeroed() };
287    // SAFETY: `c_name` outlives the call, `found` is a live `statx`, and `dir` is an open descriptor.
288    checked(unsafe { libc::statx(dir.as_raw_fd(), c_name.as_ptr(), flags, libc::STATX_BASIC_STATS | libc::STATX_BTIME, &mut found) })?;
289    return Ok(id_of_statx(&found));
290}
291
292/// The identity of an open file or directory.
293pub fn identity_of(file: &File) -> std::io::Result<ObjectId> {
294    return identity_statx(file, None);
295}
296
297/// The identity of the entry `name` in `dir`, a link not followed.
298pub fn identity_at(dir: &File, name: &OsStr) -> std::io::Result<ObjectId> {
299    return identity_statx(dir, Some(name));
300}
301
302/// What a request to remove an entry that the caller made did.
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub enum Owned {
305    /// The entry was removed.
306    Removed,
307    /// Nothing was at the name.
308    Absent,
309    /// The name held another object and was left alone, for this reason.
310    Kept(String),
311}
312
313/// What:
314///  Remove the entry `name` of `dir` if, at the moment of the look, it is the object with identity `expected`.
315/// Why:
316///   This is the one removal by name the ladder keeps, for the lock file it created and still holds open: it is what Git
317///  itself does with its own lock. A name can be taken between the look and the unlink, and nothing in the kernel makes the
318///  two one step, so the function is used only for a lock that no other writer can have replaced without breaking the lock
319///  protocol, and never to remove a tree: a directory is refused. Everything else the ladder made stays and is reported.
320///
321/// In TS you'd write (pseudocode):
322/// ```ts
323/// await removeIfIdentical(dir, 'index.lock', expected);
324/// ```
325pub fn remove_if_identical(dir: &File, name: &OsStr, expected: ObjectId) -> std::io::Result<Owned> {
326    let found: ObjectId = match identity_at(dir, name) {
327        Ok(known) => known,
328        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Owned::Absent),
329        Err(error) => return Err(error),
330    };
331    if found != expected {
332        return Ok(Owned::Kept(format!("{} is not the object that was made ({} against {})", name.to_string_lossy(), found.encode(), expected.encode())));
333    }
334    if entry_metadata_at(dir, name)?.is_dir() {
335        return Ok(Owned::Kept(format!("{} is a directory and is not removed", name.to_string_lossy())));
336    }
337    return match remove_at(dir, name, false) {
338        Ok(()) => Ok(Owned::Removed),
339        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Owned::Absent),
340        Err(error) => Err(error),
341    };
342}
343
344/// What:
345///  Make the open file `source` another name `name` of `dir`, failing if the name exists.
346/// Why:
347///   The object published is the one the descriptor holds, not whatever a separately checked name refers to at the moment of a
348///  rename, and an existing entry is never replaced (`linkat` fails with `EEXIST`).
349///
350/// In TS you'd write (pseudocode):
351/// ```ts
352/// await linkOpenFileAt(file, dir, 'index');
353/// ```
354pub fn link_open_file_at(source: &File, dir: &File, name: &OsStr) -> std::io::Result<()> {
355    let c_name = name_of(name)?;
356    let through = CString::new(handle_path(source).as_os_str().as_bytes()).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidInput, "a path holds a NUL byte"); })?;
357    // SAFETY: both strings outlive the call, `dir` is an open descriptor, and `AT_SYMLINK_FOLLOW` makes the link to the file
358    // the descriptor's magic link names.
359    checked(unsafe { libc::linkat(libc::AT_FDCWD, through.as_ptr(), dir.as_raw_fd(), c_name.as_ptr(), libc::AT_SYMLINK_FOLLOW) })?;
360    return Ok(());
361}
362
363/// Give the owner read, write and search permission on the open directory, through its descriptor.
364///
365/// Why: a directory of mode 555 or 000 cannot be emptied until its owner can write and search it; the change lands on the
366/// directory that is held, whatever its path becomes. A directory the user does not own keeps its mode and the later
367/// removal reports the operating system's refusal.
368pub fn widen_directory(directory: &File) -> std::io::Result<()> {
369    use std::os::unix::fs::{MetadataExt, PermissionsExt};
370    let mode: u32 = directory.metadata()?.mode();
371    if mode & 0o700 == 0o700 {
372        return Ok(());
373    }
374    return directory.set_permissions(std::fs::Permissions::from_mode(mode | 0o700));
375}
376
377/// Open the subdirectory `name` of `parent` as `open_directory_at` does, giving the owner access to a directory whose mode
378///  forbids opening it.
379///
380/// Why: the retry names the very directory the first open found (a handle that only names it), so a swapped name cannot
381/// redirect the change of mode.
382pub fn open_directory_widening(parent: &File, name: &OsStr) -> std::io::Result<File> {
383    use std::os::unix::fs::{MetadataExt, PermissionsExt};
384    match open_directory_at(parent, name) {
385        Err(error) if error.raw_os_error() == Some(libc::EACCES) => {
386            let handle: File = open_beneath(parent, name, libc::O_PATH | libc::O_DIRECTORY, 0)?;
387            let mode: u32 = handle.metadata()?.mode();
388            if std::fs::set_permissions(handle_path(&handle), std::fs::Permissions::from_mode(mode | 0o700)).is_err() {
389                return Err(error);
390            }
391            return open_directory_at(parent, name);
392        }
393        other => return other,
394    }
395}
396
397/// Controls stay out of the release executable.
398#[cfg(test)]
399#[path = "fs_beneath_tests.rs"]
400mod tests;