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}