Skip to main content

tilewright_source/
access.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Confined access to the files of a source tree, for tools that edit it.
5//!
6//! This is the loader's own path confinement: paths are root-relative and
7//! `/`-separated, with no empty, `.` or `..` component and no `\`, `:` or
8//! NUL in any component, and nothing beneath the root is followed if it is
9//! a symbolic link. On Unix every component is opened through its parent's
10//! directory handle without following links, and writes, renames and
11//! removals act on names inside a directory opened that way, so a link
12//! swapped in at any moment is never followed. Elsewhere components are
13//! checked by path before use, on Windows for any reparse point (symbolic
14//! links, junctions and mount points among them), a best effort that a
15//! local process changing the tree at the same time can race.
16
17use crate::files::{Refused, components, platform};
18use std::{
19    fmt,
20    io::Read as _,
21    path::{Path, PathBuf},
22};
23
24/// Why a path beneath the root could not be used.
25#[derive(Debug)]
26pub enum AccessError {
27    /// The path has an empty, `.` or `..` component, or a component with
28    /// `\`, `:` or NUL; or a name has a `/`.
29    Path(String),
30    /// A symbolic link at this prefix of the path.
31    Link(String),
32    /// The entry is not a regular file or directory, as the access needs.
33    Unexpected(String),
34    /// The file is larger than the limit of the read.
35    TooLarge(String),
36    /// Another holder has the root's lock.
37    Locked,
38    Io(String, std::io::Error),
39}
40
41impl fmt::Display for AccessError {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Path(path) => write!(formatter, "{path}: not a relative path beneath the root"),
45            Self::Link(path) => write!(formatter, "{path}: symbolic links are not followed"),
46            Self::Unexpected(path) => {
47                write!(formatter, "{path}: not a regular file or directory")
48            }
49            Self::TooLarge(path) => write!(formatter, "{path}: file is too large"),
50            Self::Locked => write!(formatter, "another edit holds the source tree's lock"),
51            Self::Io(path, error) => write!(formatter, "{path}: {error}"),
52        }
53    }
54}
55
56impl std::error::Error for AccessError {}
57
58impl AccessError {
59    /// A removal of `path` that took effect but is not finished, as
60    /// [`removal_pending`](Self::removal_pending) says, for tests of what
61    /// is made of one; only Windows makes them otherwise.
62    #[doc(hidden)]
63    #[must_use]
64    pub fn pending_removal(path: &str) -> Self {
65        Self::Io(
66            path.to_owned(),
67            std::io::Error::new(std::io::ErrorKind::ResourceBusy, RemovalPending),
68        )
69    }
70
71    /// Whether this is a removal that took effect but is not finished: on
72    /// Windows a removed entry stays, on some file systems, until another
73    /// program closes it, and goes then.
74    #[must_use]
75    pub fn removal_pending(&self) -> bool {
76        type Inner = dyn std::error::Error + Send + Sync;
77        matches!(self, Self::Io(_, error) if error.get_ref().is_some_and(Inner::is::<RemovalPending>))
78    }
79}
80
81/// A removal that took effect but stays until another program closes the
82/// entry; see [`AccessError::removal_pending`].
83#[derive(Debug)]
84pub(crate) struct RemovalPending;
85
86impl fmt::Display for RemovalPending {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        formatter.write_str("it is removed, but stays until another program closes it")
89    }
90}
91
92impl std::error::Error for RemovalPending {}
93
94fn refused(path: &str, refused: Refused) -> AccessError {
95    match refused {
96        Refused::Link(prefix) => AccessError::Link(prefix),
97        Refused::Missing | Refused::Unexpected => AccessError::Unexpected(path.to_owned()),
98        Refused::Io(error) => AccessError::Io(path.to_owned(), error),
99    }
100}
101
102/// The name of the file in the root that is its lock on Windows. The
103/// command line writes no output there on any platform.
104pub const LOCK_NAME: &str = ".tilewright.lock";
105
106/// The checked components of a root-relative path; the empty path is the
107/// root.
108pub(crate) fn checked(path: &str) -> Result<Vec<&str>, AccessError> {
109    if path.is_empty() {
110        return Ok(Vec::new());
111    }
112    if path.len() > 4096 || path.split('/').any(|name| checked_name(name).is_err()) {
113        return Err(AccessError::Path(path.to_owned()));
114    }
115    Ok(components(path))
116}
117
118/// Whether `name` can name an entry of a directory: not empty, `.` or `..`,
119/// and without `/`, `\`, `:` or NUL, so that no platform reads it as more
120/// than one component, a drive or a stream.
121fn checked_name(name: &str) -> Result<&str, AccessError> {
122    if name.is_empty()
123        || name == "."
124        || name == ".."
125        || name.len() > 255
126        || name.contains(['/', '\\', ':', '\0'])
127    {
128        Err(AccessError::Path(name.to_owned()))
129    } else {
130        Ok(name)
131    }
132}
133
134/// Who may use a file or directory, as publishing keeps it: where there are
135/// no modes, a read-only file has none of the write bits.
136///
137/// On Unix: the permission bits, the owner and the group, and on Linux the
138/// mandatory access control labels (`SELinux`, Smack), which publishing
139/// requires to be the same on every entry it compares. Access control that
140/// publishing could not keep is refused when the access is read: on Linux
141/// an access control list. On macOS and Windows access control lists are
142/// neither read nor kept.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub struct Access {
145    /// The permission bits, with the set-user, set-group and sticky bits.
146    pub mode: u32,
147    /// The owning user.
148    pub user: u32,
149    /// The owning group.
150    pub group: u32,
151    /// A digest of the mandatory access control labels (Linux), all zero
152    /// where there are none.
153    pub control: [u8; 32],
154}
155
156/// Who may reach content through a file or directory: for a file who may
157/// read it, for a directory who may search it. Publishing requires the
158/// entries it compares to have one audience.
159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
160pub struct Audience {
161    /// The classes that may reach, as read bits.
162    reach: u32,
163    /// The owner, where not everyone may reach.
164    user: Option<u32>,
165    /// The group, where only it may reach.
166    group: Option<u32>,
167    /// The labels.
168    control: [u8; 32],
169}
170
171impl Access {
172    /// Whether the group may read and others may not, so that the group
173    /// says who may.
174    #[must_use]
175    pub const fn group_reads_alone(&self) -> bool {
176        self.mode & 0o040 != 0 && self.mode & 0o004 == 0
177    }
178
179    /// Whether the classes that may reach content through an entry with
180    /// this access, a directory if `directory`, nest: its owner may
181    /// whenever its group may, and its group whenever others may. Where
182    /// they do not, as for mode `0604`, which denies the group what it
183    /// lets others read, who may reach depends on who the owner and group
184    /// are, which [`audience`](Self::audience) does not say.
185    #[must_use]
186    pub const fn nested(&self, directory: bool) -> bool {
187        let bits = if directory {
188            self.mode & 0o111
189        } else {
190            (self.mode & 0o444) >> 2
191        };
192        let (owner, group, other) = (bits & 0o100 != 0, bits & 0o010 != 0, bits & 0o001 != 0);
193        (owner || !group) && (group || !other)
194    }
195
196    /// Who may reach content through an entry with this access, a
197    /// directory if `directory`.
198    #[must_use]
199    pub fn audience(&self, directory: bool) -> Audience {
200        let reach = if directory {
201            (self.mode & 0o111) << 2
202        } else {
203            self.mode & 0o444
204        };
205        Audience {
206            reach,
207            user: (reach != 0o444).then_some(self.user),
208            group: (reach & 0o040 != 0 && reach & 0o004 == 0).then_some(self.group),
209            control: self.control,
210        }
211    }
212
213    /// The access a new file in a directory with this access is given: the
214    /// directory's mode without the execute bits, its group and its labels,
215    /// and as its owner the user that makes it, this process's effective
216    /// user (0 where there are no users).
217    #[must_use]
218    pub fn new_file(&self) -> Self {
219        #[cfg(unix)]
220        let user = rustix::process::geteuid().as_raw();
221        #[cfg(not(unix))]
222        let user = 0;
223        Self {
224            mode: self.mode & 0o666,
225            user,
226            ..*self
227        }
228    }
229}
230
231/// A source tree root. The root itself may be a link; nothing beneath it is
232/// followed.
233pub struct SourceRoot {
234    path: PathBuf,
235    root: platform::Root,
236}
237
238impl SourceRoot {
239    /// Opens the root. On Unix the directory is opened here, and every
240    /// later access goes through that handle, so moving or replacing what
241    /// the path names afterwards does not move the root; elsewhere the path
242    /// is resolved to an absolute one here.
243    #[must_use]
244    pub fn new(root: &Path) -> Self {
245        Self {
246            path: root.to_path_buf(),
247            root: platform::Root::new(root),
248        }
249    }
250
251    /// A second handle on the same root directory.
252    #[must_use]
253    pub fn try_clone(&self) -> Self {
254        Self {
255            path: self.path.clone(),
256            root: self.root.try_clone(),
257        }
258    }
259
260    /// The path supplied when this root was opened, for display only.
261    /// It may be relative, moved or replaced; using it to reopen the tree
262    /// does not preserve this root's identity. Access files through this
263    /// root instead.
264    #[must_use]
265    pub fn path(&self) -> &Path {
266        &self.path
267    }
268
269    pub(crate) fn platform(&self) -> platform::Root {
270        self.root.try_clone()
271    }
272
273    /// What is at `path`, not following it, if anything: as the file
274    /// system finds it by that name, which on one that ignores case, or as
275    /// on Windows drops trailing dots, can be an entry named otherwise.
276    /// A path through a file names nothing; a link on the way is an error.
277    pub fn exists(&self, path: &str) -> Result<bool, AccessError> {
278        // The empty path is the root.
279        if path.is_empty() {
280            return self.directory(path, false).map(|root| root.is_some());
281        }
282        match self.root.kind(&checked(path)?) {
283            Ok(crate::files::Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => {
284                Ok(false)
285            }
286            Ok(_) => Ok(true),
287            // The entry itself is a link.
288            Err(Refused::Link(prefix)) if prefix == path => Ok(true),
289            Err(other) => Err(refused(path, other)),
290        }
291    }
292
293    /// Reads a regular file of at most `limit` bytes. `Ok(None)` means the
294    /// file, or a directory on its way, is missing.
295    pub fn read(&self, path: &str, limit: u64) -> Result<Option<Vec<u8>>, AccessError> {
296        let parts = checked(path)?;
297        if parts.is_empty() {
298            return Err(AccessError::Unexpected(path.to_owned()));
299        }
300        let (file, length) = match self.root.file(&parts) {
301            Ok(opened) => opened,
302            Err(Refused::Missing) => return Ok(None),
303            Err(other) => return Err(refused(path, other)),
304        };
305        if length > limit {
306            return Err(AccessError::TooLarge(path.to_owned()));
307        }
308        // One byte past the limit shows a file that grew after the check.
309        let mut bytes = Vec::new();
310        file.take(limit.saturating_add(1))
311            .read_to_end(&mut bytes)
312            .map_err(|error| AccessError::Io(path.to_owned(), error))?;
313        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
314            return Err(AccessError::TooLarge(path.to_owned()));
315        }
316        Ok(Some(bytes))
317    }
318
319    /// Opens the directory `path` (the empty path is the root), creating it
320    /// and its missing parents when `create`. `Ok(None)` means it is missing
321    /// and was not created.
322    pub fn directory(
323        &self,
324        path: &str,
325        create: bool,
326    ) -> Result<Option<SourceDirectory>, AccessError> {
327        match self.root.directory(&checked(path)?, create) {
328            Ok(directory) => Ok(Some(SourceDirectory {
329                directory,
330                path: path.to_owned(),
331            })),
332            Err(Refused::Missing) => Ok(None),
333            Err(other) => Err(refused(path, other)),
334        }
335    }
336
337    /// Makes the directory `path` in its existing parent. Returns whether
338    /// this call made it: `false` when something by that name was already
339    /// there, which the caller then did not create.
340    pub fn make_directory(&self, path: &str) -> Result<bool, AccessError> {
341        self.root
342            .make_directory(&checked(path)?)
343            .map_err(|error| refused(path, error))
344    }
345
346    /// The UTF-8 names of the entries of the directory `path`, links
347    /// included, and how many entries it has, those whose names are not
348    /// UTF-8 included; `None` when it is missing. A directory with more
349    /// than `limit` entries is an error, [`AccessError::TooLarge`].
350    pub fn names(
351        &self,
352        path: &str,
353        limit: usize,
354    ) -> Result<Option<(std::collections::BTreeSet<String>, usize)>, AccessError> {
355        let listing = match self.root.list(&checked(path)?) {
356            Ok(listing) => listing,
357            Err(Refused::Missing) => return Ok(None),
358            Err(other) => return Err(refused(path, other)),
359        };
360        let mut names = std::collections::BTreeSet::new();
361        let mut count = 0;
362        for entry in listing {
363            if count >= limit {
364                return Err(AccessError::TooLarge(path.to_owned()));
365            }
366            count += 1;
367            let entry = entry.map_err(|error| AccessError::Io(path.to_owned(), error))?;
368            names.extend(entry.name);
369        }
370        Ok(Some((names, count)))
371    }
372
373    /// Takes the root's exclusive lock, held until the returned value is
374    /// dropped. It fails at once with [`AccessError::Locked`] if another
375    /// holder, in this process or another, has it. On Unix it is an
376    /// advisory `flock` of the root directory; on Windows the file
377    /// [`LOCK_NAME`] in the root, created afresh, shared with no one and
378    /// deleted when the lock is released or its process stops, so a file by
379    /// that name that no one holds is an error. Elsewhere there is no lock
380    /// and this always succeeds.
381    pub fn lock(&self) -> Result<SourceLock, AccessError> {
382        match self.root.lock() {
383            Ok(Some(lock)) => Ok(SourceLock(lock)),
384            Ok(None) => Err(AccessError::Locked),
385            Err(other) => Err(refused(".", other)),
386        }
387    }
388}
389
390/// The mounts of this process, read once to tell the file systems of
391/// several directories ([`SourceDirectory::unchecked_file_system`]): on
392/// Linux from `/proc/self/mountinfo`, bounded; elsewhere nothing.
393pub struct Mounts(platform::Mounts);
394
395impl Mounts {
396    /// Reads the mounts. Where they cannot be read, every directory's file
397    /// system is one that cannot be told.
398    #[must_use]
399    pub fn read() -> Self {
400        Self(platform::Mounts::read())
401    }
402}
403
404/// The root's lock; see [`SourceRoot::lock`].
405pub struct SourceLock(#[allow(dead_code)] platform::Lock);
406
407/// A directory beneath the root. Every name is a single entry of it.
408pub struct SourceDirectory {
409    directory: platform::Directory,
410    path: String,
411}
412
413impl SourceDirectory {
414    fn entry(&self, name: &str) -> String {
415        crate::files::join(&self.path, name)
416    }
417
418    /// Writes a new file `name` with `bytes` and syncs it. Whatever was at
419    /// `name` is removed first, so a link or hard link there is never
420    /// written through. With `access`, the file is its owner's alone until
421    /// written, then takes that mode and group, and on Linux no access
422    /// control list it would get from the directory; it must have the
423    /// labels of `access`, which is checked before anything is written.
424    /// Where there are no modes, it is read-only if `access` has no write
425    /// bits. Without `access`, it has the platform's default.
426    pub fn write(
427        &self,
428        name: &str,
429        access: Option<&Access>,
430        bytes: &[u8],
431    ) -> Result<(), AccessError> {
432        self.directory
433            .write(checked_name(name)?, access, bytes)
434            .map_err(|error| refused(&self.entry(name), error))
435    }
436
437    /// Who may use the regular file `name`; `None` when there is no regular
438    /// file there. A file whose access publishing could not keep is
439    /// refused: on Linux one with an access control list, and on Windows a
440    /// read-only or encrypted one. macOS and
441    /// Windows access control lists are neither read nor kept.
442    pub fn access(&self, name: &str) -> Result<Option<Access>, AccessError> {
443        self.directory
444            .access(checked_name(name)?)
445            .map_err(|error| refused(&self.entry(name), error))
446    }
447
448    /// Who may use this directory. On Linux, a directory with an access
449    /// control list, its own or one it gives new files, is refused.
450    pub fn own_access(&self) -> Result<Access, AccessError> {
451        self.directory
452            .own_access()
453            .map_err(|error| refused(&self.path, error))
454    }
455
456    /// Gives this directory the mode and group of `access`, and checks that
457    /// it has the labels of `access`.
458    pub fn set_own_access(&self, access: &Access) -> Result<(), AccessError> {
459        self.directory
460            .set_own_access(access)
461            .map_err(|error| refused(&self.path, error))
462    }
463
464    /// Checks, before either is made, that a file made here as `name` would
465    /// get the access control beside the mode bits that one made as
466    /// `staged` would: on Linux with `SELinux`, the label its policy gives a
467    /// new file of that name, which may depend on the name and is not given
468    /// again on renaming. Elsewhere there is nothing to check.
469    pub fn check_final_name(&self, staged: &str, name: &str) -> Result<(), AccessError> {
470        self.directory
471            .check_final_name(checked_name(staged)?, checked_name(name)?)
472            .map_err(|error| refused(&self.entry(name), error))
473    }
474
475    /// Renames `from` to `to`, replacing `to`, atomically.
476    pub fn rename(&self, from: &str, to: &str) -> Result<(), AccessError> {
477        self.directory
478            .rename(checked_name(from)?, checked_name(to)?)
479            .map_err(|error| refused(&self.entry(to), error))
480    }
481
482    /// Removes the file `name`; `false` when there was none.
483    pub fn remove_file(&self, name: &str) -> Result<bool, AccessError> {
484        self.directory
485            .remove_file(checked_name(name)?)
486            .map_err(|error| refused(&self.entry(name), error))
487    }
488
489    /// Removes the directory `name` if it is empty; `false` when it is
490    /// missing or not empty.
491    pub fn remove_directory(&self, name: &str) -> Result<bool, AccessError> {
492        self.directory
493            .remove_directory(checked_name(name)?)
494            .map_err(|error| refused(&self.entry(name), error))
495    }
496
497    /// The file system this directory is on, in words, where publishing
498    /// does not read the access control lists of what is in it: on Linux
499    /// any but the local ones that keep them as the attributes publishing
500    /// reads (ext2, ext3, ext4, XFS, Btrfs, tmpfs, F2FS, ZFS and bcachefs),
501    /// so network, host-shared (such as WSL's Windows drives or a virtual
502    /// machine's shared folders), FUSE, NTFS and stacked ones (overlayfs)
503    /// among them. The directory's mount is found by its ID in `mounts`,
504    /// and both the type the kernel names it with and the magic number
505    /// `statfs` gives must be such a file system's; one that cannot be
506    /// told, as where `/proc` cannot be read or the mount's line is
507    /// malformed, is not checked
508    /// either, and says why. `None` where it is checked, and on other
509    /// platforms, which do not read access control lists at all.
510    pub fn unchecked_file_system(&self, mounts: &Mounts) -> Result<Option<String>, AccessError> {
511        self.directory
512            .unchecked_file_system(&mounts.0)
513            .map_err(|error| refused(&self.path, error))
514    }
515
516    /// Syncs the directory, making renames and removals in it durable.
517    /// Returns `false` on platforms that cannot sync a directory, where
518    /// they may not be durable yet.
519    pub fn sync(&self) -> Result<bool, AccessError> {
520        self.directory
521            .sync()
522            .map_err(|error| refused(&self.path, error))
523    }
524}
525
526impl fmt::Debug for SourceRoot {
527    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
528        formatter.write_str("SourceRoot")
529    }
530}