tilewright-source 0.1.0

Loads and validates Tilewright world source trees into a typed, game-neutral model
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
// Copyright 2026 Seraphina Studios
// SPDX-License-Identifier: MIT

//! Confined access to the files of a source tree, for tools that edit it.
//!
//! This is the loader's own path confinement: paths are root-relative and
//! `/`-separated, with no empty, `.` or `..` component and no `\`, `:` or
//! NUL in any component, and nothing beneath the root is followed if it is
//! a symbolic link. On Unix every component is opened through its parent's
//! directory handle without following links, and writes, renames and
//! removals act on names inside a directory opened that way, so a link
//! swapped in at any moment is never followed. Elsewhere components are
//! checked by path before use, on Windows for any reparse point (symbolic
//! links, junctions and mount points among them), a best effort that a
//! local process changing the tree at the same time can race.

use crate::files::{Refused, components, platform};
use std::{
    fmt,
    io::Read as _,
    path::{Path, PathBuf},
};

/// Why a path beneath the root could not be used.
#[derive(Debug)]
pub enum AccessError {
    /// The path has an empty, `.` or `..` component, or a component with
    /// `\`, `:` or NUL; or a name has a `/`.
    Path(String),
    /// A symbolic link at this prefix of the path.
    Link(String),
    /// The entry is not a regular file or directory, as the access needs.
    Unexpected(String),
    /// The file is larger than the limit of the read.
    TooLarge(String),
    /// Another holder has the root's lock.
    Locked,
    Io(String, std::io::Error),
}

impl fmt::Display for AccessError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Path(path) => write!(formatter, "{path}: not a relative path beneath the root"),
            Self::Link(path) => write!(formatter, "{path}: symbolic links are not followed"),
            Self::Unexpected(path) => {
                write!(formatter, "{path}: not a regular file or directory")
            }
            Self::TooLarge(path) => write!(formatter, "{path}: file is too large"),
            Self::Locked => write!(formatter, "another edit holds the source tree's lock"),
            Self::Io(path, error) => write!(formatter, "{path}: {error}"),
        }
    }
}

impl std::error::Error for AccessError {}

impl AccessError {
    /// A removal of `path` that took effect but is not finished, as
    /// [`removal_pending`](Self::removal_pending) says, for tests of what
    /// is made of one; only Windows makes them otherwise.
    #[doc(hidden)]
    #[must_use]
    pub fn pending_removal(path: &str) -> Self {
        Self::Io(
            path.to_owned(),
            std::io::Error::new(std::io::ErrorKind::ResourceBusy, RemovalPending),
        )
    }

    /// Whether this is a removal that took effect but is not finished: on
    /// Windows a removed entry stays, on some file systems, until another
    /// program closes it, and goes then.
    #[must_use]
    pub fn removal_pending(&self) -> bool {
        type Inner = dyn std::error::Error + Send + Sync;
        matches!(self, Self::Io(_, error) if error.get_ref().is_some_and(Inner::is::<RemovalPending>))
    }
}

/// A removal that took effect but stays until another program closes the
/// entry; see [`AccessError::removal_pending`].
#[derive(Debug)]
pub(crate) struct RemovalPending;

impl fmt::Display for RemovalPending {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("it is removed, but stays until another program closes it")
    }
}

impl std::error::Error for RemovalPending {}

fn refused(path: &str, refused: Refused) -> AccessError {
    match refused {
        Refused::Link(prefix) => AccessError::Link(prefix),
        Refused::Missing | Refused::Unexpected => AccessError::Unexpected(path.to_owned()),
        Refused::Io(error) => AccessError::Io(path.to_owned(), error),
    }
}

/// The name of the file in the root that is its lock on Windows. The
/// command line writes no output there on any platform.
pub const LOCK_NAME: &str = ".tilewright.lock";

/// The checked components of a root-relative path; the empty path is the
/// root.
pub(crate) fn checked(path: &str) -> Result<Vec<&str>, AccessError> {
    if path.is_empty() {
        return Ok(Vec::new());
    }
    if path.len() > 4096 || path.split('/').any(|name| checked_name(name).is_err()) {
        return Err(AccessError::Path(path.to_owned()));
    }
    Ok(components(path))
}

/// Whether `name` can name an entry of a directory: not empty, `.` or `..`,
/// and without `/`, `\`, `:` or NUL, so that no platform reads it as more
/// than one component, a drive or a stream.
fn checked_name(name: &str) -> Result<&str, AccessError> {
    if name.is_empty()
        || name == "."
        || name == ".."
        || name.len() > 255
        || name.contains(['/', '\\', ':', '\0'])
    {
        Err(AccessError::Path(name.to_owned()))
    } else {
        Ok(name)
    }
}

/// Who may use a file or directory, as publishing keeps it: where there are
/// no modes, a read-only file has none of the write bits.
///
/// On Unix: the permission bits, the owner and the group, and on Linux the
/// mandatory access control labels (`SELinux`, Smack), which publishing
/// requires to be the same on every entry it compares. Access control that
/// publishing could not keep is refused when the access is read: on Linux
/// an access control list. On macOS and Windows access control lists are
/// neither read nor kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Access {
    /// The permission bits, with the set-user, set-group and sticky bits.
    pub mode: u32,
    /// The owning user.
    pub user: u32,
    /// The owning group.
    pub group: u32,
    /// A digest of the mandatory access control labels (Linux), all zero
    /// where there are none.
    pub control: [u8; 32],
}

/// Who may reach content through a file or directory: for a file who may
/// read it, for a directory who may search it. Publishing requires the
/// entries it compares to have one audience.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Audience {
    /// The classes that may reach, as read bits.
    reach: u32,
    /// The owner, where not everyone may reach.
    user: Option<u32>,
    /// The group, where only it may reach.
    group: Option<u32>,
    /// The labels.
    control: [u8; 32],
}

impl Access {
    /// Whether the group may read and others may not, so that the group
    /// says who may.
    #[must_use]
    pub const fn group_reads_alone(&self) -> bool {
        self.mode & 0o040 != 0 && self.mode & 0o004 == 0
    }

    /// Whether the classes that may reach content through an entry with
    /// this access, a directory if `directory`, nest: its owner may
    /// whenever its group may, and its group whenever others may. Where
    /// they do not, as for mode `0604`, which denies the group what it
    /// lets others read, who may reach depends on who the owner and group
    /// are, which [`audience`](Self::audience) does not say.
    #[must_use]
    pub const fn nested(&self, directory: bool) -> bool {
        let bits = if directory {
            self.mode & 0o111
        } else {
            (self.mode & 0o444) >> 2
        };
        let (owner, group, other) = (bits & 0o100 != 0, bits & 0o010 != 0, bits & 0o001 != 0);
        (owner || !group) && (group || !other)
    }

    /// Who may reach content through an entry with this access, a
    /// directory if `directory`.
    #[must_use]
    pub fn audience(&self, directory: bool) -> Audience {
        let reach = if directory {
            (self.mode & 0o111) << 2
        } else {
            self.mode & 0o444
        };
        Audience {
            reach,
            user: (reach != 0o444).then_some(self.user),
            group: (reach & 0o040 != 0 && reach & 0o004 == 0).then_some(self.group),
            control: self.control,
        }
    }

    /// The access a new file in a directory with this access is given: the
    /// directory's mode without the execute bits, its group and its labels,
    /// and as its owner the user that makes it, this process's effective
    /// user (0 where there are no users).
    #[must_use]
    pub fn new_file(&self) -> Self {
        #[cfg(unix)]
        let user = rustix::process::geteuid().as_raw();
        #[cfg(not(unix))]
        let user = 0;
        Self {
            mode: self.mode & 0o666,
            user,
            ..*self
        }
    }
}

/// A source tree root. The root itself may be a link; nothing beneath it is
/// followed.
pub struct SourceRoot {
    path: PathBuf,
    root: platform::Root,
}

impl SourceRoot {
    /// Opens the root. On Unix the directory is opened here, and every
    /// later access goes through that handle, so moving or replacing what
    /// the path names afterwards does not move the root; elsewhere the path
    /// is resolved to an absolute one here.
    #[must_use]
    pub fn new(root: &Path) -> Self {
        Self {
            path: root.to_path_buf(),
            root: platform::Root::new(root),
        }
    }

    /// A second handle on the same root directory.
    #[must_use]
    pub fn try_clone(&self) -> Self {
        Self {
            path: self.path.clone(),
            root: self.root.try_clone(),
        }
    }

    /// The path supplied when this root was opened, for display only.
    /// It may be relative, moved or replaced; using it to reopen the tree
    /// does not preserve this root's identity. Access files through this
    /// root instead.
    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub(crate) fn platform(&self) -> platform::Root {
        self.root.try_clone()
    }

    /// What is at `path`, not following it, if anything: as the file
    /// system finds it by that name, which on one that ignores case, or as
    /// on Windows drops trailing dots, can be an entry named otherwise.
    /// A path through a file names nothing; a link on the way is an error.
    pub fn exists(&self, path: &str) -> Result<bool, AccessError> {
        // The empty path is the root.
        if path.is_empty() {
            return self.directory(path, false).map(|root| root.is_some());
        }
        match self.root.kind(&checked(path)?) {
            Ok(crate::files::Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => {
                Ok(false)
            }
            Ok(_) => Ok(true),
            // The entry itself is a link.
            Err(Refused::Link(prefix)) if prefix == path => Ok(true),
            Err(other) => Err(refused(path, other)),
        }
    }

    /// Reads a regular file of at most `limit` bytes. `Ok(None)` means the
    /// file, or a directory on its way, is missing.
    pub fn read(&self, path: &str, limit: u64) -> Result<Option<Vec<u8>>, AccessError> {
        let parts = checked(path)?;
        if parts.is_empty() {
            return Err(AccessError::Unexpected(path.to_owned()));
        }
        let (file, length) = match self.root.file(&parts) {
            Ok(opened) => opened,
            Err(Refused::Missing) => return Ok(None),
            Err(other) => return Err(refused(path, other)),
        };
        if length > limit {
            return Err(AccessError::TooLarge(path.to_owned()));
        }
        // One byte past the limit shows a file that grew after the check.
        let mut bytes = Vec::new();
        file.take(limit.saturating_add(1))
            .read_to_end(&mut bytes)
            .map_err(|error| AccessError::Io(path.to_owned(), error))?;
        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
            return Err(AccessError::TooLarge(path.to_owned()));
        }
        Ok(Some(bytes))
    }

    /// Opens the directory `path` (the empty path is the root), creating it
    /// and its missing parents when `create`. `Ok(None)` means it is missing
    /// and was not created.
    pub fn directory(
        &self,
        path: &str,
        create: bool,
    ) -> Result<Option<SourceDirectory>, AccessError> {
        match self.root.directory(&checked(path)?, create) {
            Ok(directory) => Ok(Some(SourceDirectory {
                directory,
                path: path.to_owned(),
            })),
            Err(Refused::Missing) => Ok(None),
            Err(other) => Err(refused(path, other)),
        }
    }

    /// Makes the directory `path` in its existing parent. Returns whether
    /// this call made it: `false` when something by that name was already
    /// there, which the caller then did not create.
    pub fn make_directory(&self, path: &str) -> Result<bool, AccessError> {
        self.root
            .make_directory(&checked(path)?)
            .map_err(|error| refused(path, error))
    }

    /// The UTF-8 names of the entries of the directory `path`, links
    /// included, and how many entries it has, those whose names are not
    /// UTF-8 included; `None` when it is missing. A directory with more
    /// than `limit` entries is an error, [`AccessError::TooLarge`].
    pub fn names(
        &self,
        path: &str,
        limit: usize,
    ) -> Result<Option<(std::collections::BTreeSet<String>, usize)>, AccessError> {
        let listing = match self.root.list(&checked(path)?) {
            Ok(listing) => listing,
            Err(Refused::Missing) => return Ok(None),
            Err(other) => return Err(refused(path, other)),
        };
        let mut names = std::collections::BTreeSet::new();
        let mut count = 0;
        for entry in listing {
            if count >= limit {
                return Err(AccessError::TooLarge(path.to_owned()));
            }
            count += 1;
            let entry = entry.map_err(|error| AccessError::Io(path.to_owned(), error))?;
            names.extend(entry.name);
        }
        Ok(Some((names, count)))
    }

    /// Takes the root's exclusive lock, held until the returned value is
    /// dropped. It fails at once with [`AccessError::Locked`] if another
    /// holder, in this process or another, has it. On Unix it is an
    /// advisory `flock` of the root directory; on Windows the file
    /// [`LOCK_NAME`] in the root, created afresh, shared with no one and
    /// deleted when the lock is released or its process stops, so a file by
    /// that name that no one holds is an error. Elsewhere there is no lock
    /// and this always succeeds.
    pub fn lock(&self) -> Result<SourceLock, AccessError> {
        match self.root.lock() {
            Ok(Some(lock)) => Ok(SourceLock(lock)),
            Ok(None) => Err(AccessError::Locked),
            Err(other) => Err(refused(".", other)),
        }
    }
}

/// The mounts of this process, read once to tell the file systems of
/// several directories ([`SourceDirectory::unchecked_file_system`]): on
/// Linux from `/proc/self/mountinfo`, bounded; elsewhere nothing.
pub struct Mounts(platform::Mounts);

impl Mounts {
    /// Reads the mounts. Where they cannot be read, every directory's file
    /// system is one that cannot be told.
    #[must_use]
    pub fn read() -> Self {
        Self(platform::Mounts::read())
    }
}

/// The root's lock; see [`SourceRoot::lock`].
pub struct SourceLock(#[allow(dead_code)] platform::Lock);

/// A directory beneath the root. Every name is a single entry of it.
pub struct SourceDirectory {
    directory: platform::Directory,
    path: String,
}

impl SourceDirectory {
    fn entry(&self, name: &str) -> String {
        crate::files::join(&self.path, name)
    }

    /// Writes a new file `name` with `bytes` and syncs it. Whatever was at
    /// `name` is removed first, so a link or hard link there is never
    /// written through. With `access`, the file is its owner's alone until
    /// written, then takes that mode and group, and on Linux no access
    /// control list it would get from the directory; it must have the
    /// labels of `access`, which is checked before anything is written.
    /// Where there are no modes, it is read-only if `access` has no write
    /// bits. Without `access`, it has the platform's default.
    pub fn write(
        &self,
        name: &str,
        access: Option<&Access>,
        bytes: &[u8],
    ) -> Result<(), AccessError> {
        self.directory
            .write(checked_name(name)?, access, bytes)
            .map_err(|error| refused(&self.entry(name), error))
    }

    /// Who may use the regular file `name`; `None` when there is no regular
    /// file there. A file whose access publishing could not keep is
    /// refused: on Linux one with an access control list, and on Windows a
    /// read-only or encrypted one. macOS and
    /// Windows access control lists are neither read nor kept.
    pub fn access(&self, name: &str) -> Result<Option<Access>, AccessError> {
        self.directory
            .access(checked_name(name)?)
            .map_err(|error| refused(&self.entry(name), error))
    }

    /// Who may use this directory. On Linux, a directory with an access
    /// control list, its own or one it gives new files, is refused.
    pub fn own_access(&self) -> Result<Access, AccessError> {
        self.directory
            .own_access()
            .map_err(|error| refused(&self.path, error))
    }

    /// Gives this directory the mode and group of `access`, and checks that
    /// it has the labels of `access`.
    pub fn set_own_access(&self, access: &Access) -> Result<(), AccessError> {
        self.directory
            .set_own_access(access)
            .map_err(|error| refused(&self.path, error))
    }

    /// Checks, before either is made, that a file made here as `name` would
    /// get the access control beside the mode bits that one made as
    /// `staged` would: on Linux with `SELinux`, the label its policy gives a
    /// new file of that name, which may depend on the name and is not given
    /// again on renaming. Elsewhere there is nothing to check.
    pub fn check_final_name(&self, staged: &str, name: &str) -> Result<(), AccessError> {
        self.directory
            .check_final_name(checked_name(staged)?, checked_name(name)?)
            .map_err(|error| refused(&self.entry(name), error))
    }

    /// Renames `from` to `to`, replacing `to`, atomically.
    pub fn rename(&self, from: &str, to: &str) -> Result<(), AccessError> {
        self.directory
            .rename(checked_name(from)?, checked_name(to)?)
            .map_err(|error| refused(&self.entry(to), error))
    }

    /// Removes the file `name`; `false` when there was none.
    pub fn remove_file(&self, name: &str) -> Result<bool, AccessError> {
        self.directory
            .remove_file(checked_name(name)?)
            .map_err(|error| refused(&self.entry(name), error))
    }

    /// Removes the directory `name` if it is empty; `false` when it is
    /// missing or not empty.
    pub fn remove_directory(&self, name: &str) -> Result<bool, AccessError> {
        self.directory
            .remove_directory(checked_name(name)?)
            .map_err(|error| refused(&self.entry(name), error))
    }

    /// The file system this directory is on, in words, where publishing
    /// does not read the access control lists of what is in it: on Linux
    /// any but the local ones that keep them as the attributes publishing
    /// reads (ext2, ext3, ext4, XFS, Btrfs, tmpfs, F2FS, ZFS and bcachefs),
    /// so network, host-shared (such as WSL's Windows drives or a virtual
    /// machine's shared folders), FUSE, NTFS and stacked ones (overlayfs)
    /// among them. The directory's mount is found by its ID in `mounts`,
    /// and both the type the kernel names it with and the magic number
    /// `statfs` gives must be such a file system's; one that cannot be
    /// told, as where `/proc` cannot be read or the mount's line is
    /// malformed, is not checked
    /// either, and says why. `None` where it is checked, and on other
    /// platforms, which do not read access control lists at all.
    pub fn unchecked_file_system(&self, mounts: &Mounts) -> Result<Option<String>, AccessError> {
        self.directory
            .unchecked_file_system(&mounts.0)
            .map_err(|error| refused(&self.path, error))
    }

    /// Syncs the directory, making renames and removals in it durable.
    /// Returns `false` on platforms that cannot sync a directory, where
    /// they may not be durable yet.
    pub fn sync(&self) -> Result<bool, AccessError> {
        self.directory
            .sync()
            .map_err(|error| refused(&self.path, error))
    }
}

impl fmt::Debug for SourceRoot {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("SourceRoot")
    }
}