Skip to main content

tilewright_source/
files.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Bounded access to a source tree. Every path is relative to the root,
5//! `/`-separated and validated; symbolic links are never followed. On Unix
6//! every component is opened through its parent's directory handle without
7//! following links (`beneath`); elsewhere components are checked by path
8//! before use (`portable`), on Windows for any reparse point.
9
10#[cfg(unix)]
11pub(crate) mod beneath;
12#[cfg(target_os = "linux")]
13pub(crate) mod mounts;
14#[cfg(any(not(unix), test))]
15pub(crate) mod portable;
16
17#[cfg(unix)]
18pub(crate) use beneath as platform;
19#[cfg(not(unix))]
20pub(crate) use portable as platform;
21
22use crate::{
23    Code, Diagnostic, LoadOptions, Overlay,
24    diagnostic::{Diagnostics, Positions},
25};
26use sha2::{Digest, Sha256};
27#[cfg(all(test, target_os = "linux"))]
28use std::path::Path;
29use std::{
30    cell::Cell,
31    collections::{BTreeMap, BTreeSet},
32    io::Read as _,
33    rc::Rc,
34};
35use tilewright_jsonc::{Document, ErrorCode};
36
37/// Why a path beneath the root could not be used.
38#[derive(Debug)]
39pub(crate) enum Refused {
40    /// A symbolic link, at this `/`-joined prefix of the path.
41    Link(String),
42    Missing,
43    /// The entry is not of the kind asked for.
44    Unexpected,
45    Io(std::io::Error),
46}
47
48/// What a path names, without following a link.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub(crate) enum Found {
51    File,
52    Directory,
53    Other,
54    Missing,
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub(crate) enum RawKind {
59    File,
60    Directory,
61    Link,
62    Other,
63}
64
65/// A directory entry; `name` is `None` when it is not UTF-8.
66#[derive(Debug)]
67pub(crate) struct RawEntry {
68    pub name: Option<String>,
69    pub kind: RawKind,
70}
71
72/// The components of a relative path.
73pub(crate) fn components(path: &str) -> Vec<&str> {
74    path.split('/')
75        .filter(|component| !component.is_empty())
76        .collect()
77}
78
79/// A parsed source file.
80pub(crate) struct Source {
81    pub path: String,
82    pub document: Document,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub(crate) enum EntryKind {
87    File,
88    Directory,
89    /// Neither a regular file nor a directory, such as a FIFO.
90    Other,
91}
92
93pub(crate) struct Entry {
94    pub name: String,
95    pub kind: EntryKind,
96}
97
98pub(crate) struct Tree<'o> {
99    root: platform::Root,
100    options: &'o LoadOptions,
101    overlay: &'o Overlay,
102    files: usize,
103    entries: usize,
104    bytes: u64,
105    digests: BTreeMap<String, (u64, [u8; 32])>,
106    diagnostics: Diagnostics,
107    exhausted: bool,
108}
109
110/// Whether `path` is a safe relative path ending in `.jsonc`: at most 1,024
111/// bytes and 32 `/`-separated components of at most 128 ASCII letters,
112/// digits, `_`, `.` and `-`, none starting with `.` or `-`.
113#[must_use]
114pub fn is_jsonc_path(path: &str) -> bool {
115    is_safe_relative(path) && path.as_bytes().ends_with(b".jsonc")
116}
117
118/// Whether `path` is a safe relative path: `/`-separated components of
119/// ASCII letters, digits, `_`, `.` and `-`, none starting with `.` or `-`.
120pub(crate) fn is_safe_relative(path: &str) -> bool {
121    let components: Vec<&str> = path.split('/').collect();
122    path.len() <= 1024
123        && components.len() <= 32
124        && components.iter().all(|component| {
125            let bytes = component.as_bytes();
126            !bytes.is_empty()
127                && bytes.len() <= 128
128                && (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_')
129                && bytes
130                    .iter()
131                    .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(byte))
132        })
133}
134
135/// The overlay of a load that edits nothing.
136#[cfg(all(test, target_os = "linux"))]
137static NO_OVERLAY: Overlay = BTreeMap::new();
138
139impl<'o> Tree<'o> {
140    #[cfg(all(test, target_os = "linux"))]
141    pub fn new(root: &Path, options: &'o LoadOptions) -> Self {
142        Self::in_root(
143            platform::Root::new(root),
144            options,
145            &NO_OVERLAY,
146            Rc::new(Cell::new(0)),
147        )
148    }
149
150    /// A tree over an opened root whose files are those on disk with
151    /// `overlay` applied: an entry's text replaces or creates the file at
152    /// its path, and an entry of `None` removes it. Directories that hold
153    /// an overlay file exist. `total` is the load's shared diagnostic
154    /// count; once it reaches the maximum, nothing more is read.
155    pub fn in_root(
156        root: platform::Root,
157        options: &'o LoadOptions,
158        overlay: &'o Overlay,
159        total: Rc<Cell<usize>>,
160    ) -> Self {
161        Self {
162            root,
163            options,
164            overlay,
165            files: 0,
166            entries: 0,
167            bytes: 0,
168            digests: BTreeMap::new(),
169            diagnostics: Diagnostics::sharing(options.maximum_diagnostics, total),
170            exhausted: false,
171        }
172    }
173
174    /// Reports the exhausted budget once.
175    fn exhaust(&mut self, path: &str) {
176        if !self.exhausted {
177            self.exhausted = true;
178            self.diagnostics.push(Diagnostic::file(
179                path,
180                Code::Budget,
181                format!(
182                    "the load exceeds {} files, {} directory entries or {} bytes",
183                    self.options.maximum_files,
184                    self.options.maximum_entries,
185                    self.options.maximum_total_bytes
186                ),
187            ));
188        }
189    }
190
191    /// Whether one more file of `length` bytes fits the load's budget.
192    fn within_budget(&self, length: u64) -> bool {
193        !self.exhausted
194            && self.files < self.options.maximum_files
195            && self.bytes.saturating_add(length) <= self.options.maximum_total_bytes
196    }
197
198    pub fn take_diagnostics(&mut self) -> Diagnostics {
199        let total = self.diagnostics.total();
200        std::mem::replace(
201            &mut self.diagnostics,
202            Diagnostics::sharing(self.options.maximum_diagnostics, total),
203        )
204    }
205
206    /// Whether the load's diagnostics are full, so reading on is useless.
207    fn stopped(&self) -> bool {
208        self.diagnostics.is_full()
209    }
210
211    /// Reports a symbolic link at `path`.
212    fn link(&mut self, path: &str) {
213        self.diagnostics.push(Diagnostic::file(
214            path,
215            Code::Symlink,
216            "symbolic links are not followed",
217        ));
218    }
219
220    /// Whether a regular file or directory exists at `path`. A symbolic
221    /// link anywhere in the path is reported and counts as neither.
222    pub fn kind(&mut self, path: &str) -> Option<EntryKind> {
223        match self.overlay.get(path) {
224            Some(Some(_)) => return Some(EntryKind::File),
225            Some(None) => return None,
226            None => {}
227        }
228        match self.root.kind(&components(path)) {
229            Ok(Found::File) => Some(EntryKind::File),
230            Ok(Found::Directory) => Some(EntryKind::Directory),
231            Ok(Found::Other) => Some(EntryKind::Other),
232            Ok(Found::Missing) | Err(Refused::Missing | Refused::Unexpected) => self
233                .holds_overlay_files(path)
234                .then_some(EntryKind::Directory),
235            Err(Refused::Link(link)) => {
236                self.link(&link);
237                None
238            }
239            Err(Refused::Io(error)) => {
240                self.diagnostics.push(Diagnostic::file(
241                    path,
242                    Code::Io,
243                    format!("cannot read file type: {error}"),
244                ));
245                None
246            }
247        }
248    }
249
250    /// Whether the overlay puts a file beneath the directory `path`.
251    fn holds_overlay_files(&self, path: &str) -> bool {
252        overlay_beneath(self.overlay, path).any(|(_, value)| value.is_some())
253    }
254
255    /// Whether the optional directory `path` is present. An entry of
256    /// another kind there is reported, not treated as absent.
257    pub fn container(&mut self, path: &str) -> bool {
258        match self.kind(path) {
259            Some(EntryKind::Directory) => true,
260            Some(EntryKind::File | EntryKind::Other) => {
261                self.diagnostics.push(Diagnostic::file(
262                    path,
263                    Code::UnexpectedFile,
264                    "must be a directory",
265                ));
266                false
267            }
268            None => false,
269        }
270    }
271
272    /// Lists a directory, sorted by name. Symbolic links, special files and
273    /// non-UTF-8 names are reported and skipped.
274    pub fn list(&mut self, path: &str) -> Vec<Entry> {
275        if self.stopped() {
276            self.diagnostics.skip();
277            return Vec::new();
278        }
279        // Overlay files replace, add or remove entries; a directory that
280        // holds an overlay file is listed. Names are looked up in sets, so
281        // merging costs a logarithm per entry.
282        let mut added: BTreeMap<&str, EntryKind> = BTreeMap::new();
283        let remaining = self.options.maximum_entries.saturating_sub(self.entries);
284        for (rest, value) in overlay_beneath(self.overlay, path) {
285            match (rest.split_once('/'), value) {
286                (None, Some(_)) => {
287                    added.insert(rest, EntryKind::File);
288                }
289                (Some((directory, _)), Some(_)) => {
290                    added.entry(directory).or_insert(EntryKind::Directory);
291                }
292                (_, None) => {}
293            }
294            // Each name added is counted once, here or on disk, so more
295            // names than the budget has left exhaust it whatever is there.
296            if added.len() > remaining {
297                self.exhaust(path);
298                return Vec::new();
299            }
300        }
301        // An entry the overlay replaces or removes is not looked at at
302        // all, so what is there on disk, a link, a special file or a
303        // directory, is not reported.
304        let mut entries = self.list_disk(path);
305        let present: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
306        for (name, kind) in added {
307            if !present.contains(name) {
308                self.entries += 1;
309                if self.entries > self.options.maximum_entries {
310                    self.exhaust(path);
311                    break;
312                }
313                entries.push(Entry {
314                    name: name.to_owned(),
315                    kind,
316                });
317            }
318        }
319        entries.sort_by(|left, right| left.name.cmp(&right.name));
320        entries
321    }
322
323    /// Lists a directory on disk. A directory that is missing on disk but
324    /// holds overlay files is empty there.
325    fn list_disk(&mut self, path: &str) -> Vec<Entry> {
326        let overlay = self.overlay;
327        let directory = match self.root.list(&components(path)) {
328            Ok(directory) => directory,
329            Err(Refused::Link(link)) => {
330                self.link(&link);
331                return Vec::new();
332            }
333            Err(Refused::Missing) if self.holds_overlay_files(path) => return Vec::new(),
334            Err(refused) => {
335                let reason = match refused {
336                    Refused::Io(error) => error.to_string(),
337                    Refused::Missing => "it is missing".to_owned(),
338                    Refused::Unexpected | Refused::Link(_) => "not a directory".to_owned(),
339                };
340                self.diagnostics.push(Diagnostic::file(
341                    path,
342                    Code::Io,
343                    format!("cannot list directory: {reason}"),
344                ));
345                return Vec::new();
346            }
347        };
348        let mut entries = Vec::new();
349        for entry in directory {
350            if self.exhausted {
351                break;
352            }
353            self.entries += 1;
354            if self.entries > self.options.maximum_entries {
355                self.exhaust(path);
356                break;
357            }
358            let entry = match entry {
359                Ok(entry) => entry,
360                Err(error) => {
361                    self.diagnostics.push(Diagnostic::file(
362                        path,
363                        Code::Io,
364                        format!("cannot list directory: {error}"),
365                    ));
366                    continue;
367                }
368            };
369            let Some(name) = entry.name else {
370                self.diagnostics.push(Diagnostic::file(
371                    path,
372                    Code::UnexpectedFile,
373                    "file name is not UTF-8",
374                ));
375                continue;
376            };
377            if overlay.contains_key(&join(path, &name)) {
378                // The overlay's entry takes its place, and is counted then.
379                self.entries -= 1;
380                continue;
381            }
382            let child = join(path, &name);
383            match entry.kind {
384                RawKind::Link => self.link(&child),
385                RawKind::Directory => entries.push(Entry {
386                    name,
387                    kind: EntryKind::Directory,
388                }),
389                RawKind::File => entries.push(Entry {
390                    name,
391                    kind: EntryKind::File,
392                }),
393                RawKind::Other => self.diagnostics.push(Diagnostic::file(
394                    &child,
395                    Code::UnexpectedFile,
396                    "not a regular file or directory",
397                )),
398            }
399        }
400        entries
401    }
402
403    /// Reads and parses a JSONC document. Every problem is reported.
404    pub fn document(&mut self, path: &str) -> Option<Source> {
405        if self.stopped() {
406            self.diagnostics.skip();
407            return None;
408        }
409        match self.overlay.get(path) {
410            Some(Some(text)) => return self.overlay_document(path, text),
411            Some(None) => {
412                self.diagnostics
413                    .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
414                return None;
415            }
416            None => {}
417        }
418        // The file is opened first and its opened handle checked, so what is
419        // checked is what is read.
420        let (file, length) = match self.root.file(&components(path)) {
421            Ok(opened) => opened,
422            Err(Refused::Link(link)) => {
423                self.link(&link);
424                return None;
425            }
426            Err(Refused::Unexpected) => {
427                self.diagnostics.push(Diagnostic::file(
428                    path,
429                    Code::UnexpectedFile,
430                    "expected a regular file",
431                ));
432                return None;
433            }
434            Err(Refused::Missing) => {
435                self.diagnostics
436                    .push(Diagnostic::file(path, Code::MissingFile, "file is missing"));
437                return None;
438            }
439            Err(Refused::Io(error)) => {
440                self.diagnostics.push(Diagnostic::file(
441                    path,
442                    Code::Io,
443                    format!("cannot read file: {error}"),
444                ));
445                return None;
446            }
447        };
448        let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
449        if length > limit {
450            self.diagnostics.push(Diagnostic::file(
451                path,
452                ErrorCode::InputTooLarge,
453                format!("file is larger than {limit} bytes"),
454            ));
455            return None;
456        }
457        if !self.within_budget(length) {
458            self.exhaust(path);
459            return None;
460        }
461        // Read at most one byte past the file limit or the remaining budget,
462        // so a file that grows after the size check cannot force an
463        // unbounded read; its actual length is checked again below.
464        let remaining = self.options.maximum_total_bytes.saturating_sub(self.bytes);
465        let mut bytes = Vec::new();
466        let bytes = match file
467            .take(limit.min(remaining).saturating_add(1))
468            .read_to_end(&mut bytes)
469        {
470            Ok(_) => bytes,
471            Err(error) => {
472                self.diagnostics.push(Diagnostic::file(
473                    path,
474                    Code::Io,
475                    format!("cannot read file: {error}"),
476                ));
477                return None;
478            }
479        };
480        let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
481        if length > limit {
482            self.diagnostics.push(Diagnostic::file(
483                path,
484                ErrorCode::InputTooLarge,
485                format!("file is larger than {limit} bytes"),
486            ));
487            return None;
488        }
489        if !self.within_budget(length) {
490            self.exhaust(path);
491            return None;
492        }
493        self.parse(path, bytes)
494    }
495
496    /// Reads an overlay file under the same limits and budget as a file on
497    /// disk.
498    fn overlay_document(&mut self, path: &str, text: &str) -> Option<Source> {
499        let limit = u64::try_from(self.options.limits.max_input_bytes).unwrap_or(u64::MAX);
500        let length = u64::try_from(text.len()).unwrap_or(u64::MAX);
501        if length > limit {
502            self.diagnostics.push(Diagnostic::file(
503                path,
504                ErrorCode::InputTooLarge,
505                format!("file is larger than {limit} bytes"),
506            ));
507            return None;
508        }
509        if !self.within_budget(length) {
510            self.exhaust(path);
511            return None;
512        }
513        self.parse(path, text.as_bytes().to_vec())
514    }
515
516    /// Counts, digests and parses the bytes of a file that fits the budget.
517    fn parse(&mut self, path: &str, bytes: Vec<u8>) -> Option<Source> {
518        let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
519        self.files += 1;
520        self.bytes = self.bytes.saturating_add(length);
521        self.digests
522            .insert(path.to_owned(), (length, Sha256::digest(&bytes).into()));
523        let text = match String::from_utf8(bytes) {
524            Ok(text) => text,
525            Err(error) => {
526                self.diagnostics.push(Diagnostic::file(
527                    path,
528                    Code::Encoding,
529                    format!("not UTF-8 at byte {}", error.utf8_error().valid_up_to()),
530                ));
531                return None;
532            }
533        };
534        match Document::parse(text.as_str(), &self.options.limits) {
535            Ok(document) => Some(Source {
536                path: path.to_owned(),
537                document,
538            }),
539            Err(error) => {
540                self.diagnostics.push(Diagnostic::at(
541                    path,
542                    &Positions::new(&text),
543                    error.offset,
544                    error.offset,
545                    error.code,
546                    "invalid JSONC",
547                ));
548                None
549            }
550        }
551    }
552
553    /// SHA-256 over every file read: for each path in byte order, the UTF-8
554    /// path, a zero byte, the length as little-endian `u64`, and the SHA-256
555    /// of the file's bytes.
556    pub fn digest(&self) -> [u8; 32] {
557        let mut hasher = Sha256::new();
558        for (path, (length, digest)) in &self.digests {
559            hasher.update(path.as_bytes());
560            hasher.update([0]);
561            hasher.update(length.to_le_bytes());
562            hasher.update(digest);
563        }
564        hasher.finalize().into()
565    }
566}
567
568/// The overlay entries beneath the directory `path`, with the rest of
569/// their paths.
570fn overlay_beneath<'a>(
571    overlay: &'a Overlay,
572    path: &str,
573) -> impl Iterator<Item = (&'a str, &'a Option<String>)> + 'a {
574    let prefix = if path.is_empty() {
575        String::new()
576    } else {
577        format!("{path}/")
578    };
579    overlay
580        .range(prefix.clone()..)
581        .map_while(move |(key, value)| key.strip_prefix(prefix.as_str()).map(|rest| (rest, value)))
582}
583
584pub(crate) fn join(directory: &str, name: &str) -> String {
585    if directory.is_empty() {
586        name.to_owned()
587    } else {
588        format!("{directory}/{name}")
589    }
590}
591
592/// Both access implementations refuse links themselves, at the component
593/// where they occur, rather than relying on an earlier check.
594#[cfg(all(test, unix))]
595mod tests {
596    use super::{Found, RawKind, Refused};
597    use std::{fs, os::unix::fs::symlink};
598
599    fn tree() -> tempfile::TempDir {
600        let root = tempfile::tempdir().unwrap();
601        fs::create_dir(root.path().join("dir")).unwrap();
602        fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
603        symlink(root.path().join("dir"), root.path().join("link")).unwrap();
604        symlink(
605            root.path().join("dir/file.jsonc"),
606            root.path().join("dir/file-link.jsonc"),
607        )
608        .unwrap();
609        root
610    }
611
612    /// The link prefix a refusal names.
613    fn link<T>(result: Result<T, Refused>) -> String {
614        match result {
615            Err(Refused::Link(prefix)) => prefix,
616            Err(other) => panic!("expected a link, got {other:?}"),
617            Ok(_) => panic!("expected a link, got success"),
618        }
619    }
620
621    macro_rules! access_tests {
622        ($name:ident, $module:ident) => {
623            #[test]
624            fn $name() {
625                let directory = tree();
626                let root = super::$module::Root::new(directory.path());
627                assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
628                assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
629                assert_eq!(
630                    link(root.file(&["dir", "file-link.jsonc"])),
631                    "dir/file-link.jsonc"
632                );
633                assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
634                assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
635                assert!(matches!(
636                    root.file(&["dir", "file.jsonc", "x"]),
637                    Err(Refused::Unexpected)
638                ));
639                assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
640                assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
641                assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
642                assert_eq!(link(root.kind(&["link"])), "link");
643                assert_eq!(link(root.list(&["link"])), "link");
644                let mut entries: Vec<(Option<String>, RawKind)> = root
645                    .list(&["dir"])
646                    .unwrap()
647                    .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
648                    .collect::<Result<_, _>>()
649                    .unwrap();
650                entries.sort_by(|left, right| left.0.cmp(&right.0));
651                assert_eq!(
652                    entries,
653                    [
654                        (Some("file-link.jsonc".to_owned()), RawKind::Link),
655                        (Some("file.jsonc".to_owned()), RawKind::File),
656                    ]
657                );
658            }
659        };
660    }
661
662    access_tests!(beneath_refuses_links, beneath);
663    access_tests!(portable_refuses_links, portable);
664
665    macro_rules! write_tests {
666        ($name:ident, $module:ident) => {
667            #[test]
668            fn $name() {
669                let directory = tree();
670                let root = super::$module::Root::new(directory.path());
671                // Directories are created as asked, never through a link.
672                assert!(matches!(
673                    root.directory(&["new", "deeper"], false),
674                    Err(Refused::Missing)
675                ));
676                let made = root.directory(&["new", "deeper"], true).unwrap();
677                assert!(directory.path().join("new/deeper").is_dir());
678                assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
679                assert!(!directory.path().join("dir/inside").exists());
680                assert!(matches!(
681                    root.directory(&["dir", "file.jsonc"], true),
682                    Err(Refused::Unexpected)
683                ));
684
685                // A write replaces a link or hard link instead of writing
686                // through it, and takes the access it is given.
687                let dir = root.directory(&["dir"], false).unwrap();
688                let outside = tempfile::tempdir().unwrap();
689                let target = outside.path().join("target");
690                fs::write(&target, "outside").unwrap();
691                symlink(&target, directory.path().join("dir/staged")).unwrap();
692                dir.write("staged", None, b"new").unwrap();
693                assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
694                fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
695                dir.write("hard", None, b"new").unwrap();
696                assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
697                {
698                    use std::os::unix::fs::PermissionsExt as _;
699                    let file = directory.path().join("dir/file.jsonc");
700                    fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
701                    let like = dir.access("file.jsonc").unwrap();
702                    assert!(dir.access("nope").unwrap().is_none());
703                    dir.write("moded", like.as_ref(), b"x").unwrap();
704                    let mode = fs::metadata(directory.path().join("dir/moded"))
705                        .unwrap()
706                        .permissions()
707                        .mode();
708                    assert_eq!(mode & 0o777, 0o640);
709                    // A directory's own access can be read and set.
710                    let mut own = dir.own_access().unwrap();
711                    own.mode = 0o750;
712                    dir.set_own_access(&own).unwrap();
713                    assert_eq!(dir.own_access().unwrap(), own);
714                }
715
716                dir.rename("staged", "file.jsonc").unwrap();
717                assert_eq!(
718                    fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
719                    "new"
720                );
721                assert!(dir.remove_file("hard").unwrap());
722                assert!(!dir.remove_file("hard").unwrap());
723                assert!(dir.remove_file("file-link.jsonc").unwrap());
724                assert!(target.exists());
725                dir.sync().unwrap();
726                made.sync().unwrap();
727
728                let top = root.directory(&["new"], false).unwrap();
729                fs::write(directory.path().join("new/deeper/x"), "").unwrap();
730                assert!(!top.remove_directory("deeper").unwrap());
731                fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
732                assert!(top.remove_directory("deeper").unwrap());
733                assert!(!top.remove_directory("deeper").unwrap());
734
735                assert!(root.lock().unwrap().is_some());
736                // Making a directory says whether this call made it.
737                assert!(root.make_directory(&["made"]).unwrap());
738                assert!(!root.make_directory(&["made"]).unwrap());
739                assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
740                assert!(matches!(
741                    root.make_directory(&["nope", "deeper"]),
742                    Err(Refused::Missing)
743                ));
744                // A clone is a handle on the same directory.
745                let clone = root.try_clone();
746                assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
747            }
748        };
749    }
750
751    write_tests!(beneath_writes_beneath_the_root, beneath);
752    write_tests!(portable_writes_beneath_the_root, portable);
753
754    #[cfg(target_os = "linux")]
755    #[test]
756    fn a_written_file_with_access_has_no_inherited_access_control_list() {
757        use std::os::unix::fs::PermissionsExt as _;
758        let directory = tree();
759        // A default list for new files: user::rwx, user:nobody:r--,
760        // group::r-x, mask::r-x, other::---.
761        let mut acl = 2_u32.to_le_bytes().to_vec();
762        for (tag, permissions, id) in [
763            (0x01_u16, 7_u16, u32::MAX),
764            (0x02, 4, 65534),
765            (0x04, 5, u32::MAX),
766            (0x10, 5, u32::MAX),
767            (0x20, 0, u32::MAX),
768        ] {
769            acl.extend(tag.to_le_bytes());
770            acl.extend(permissions.to_le_bytes());
771            acl.extend(id.to_le_bytes());
772        }
773        let dir_path = directory.path().join("dir");
774        if rustix::fs::setxattr(
775            &dir_path,
776            "system.posix_acl_default",
777            &acl,
778            rustix::fs::XattrFlags::empty(),
779        )
780        .is_err()
781        {
782            // No ACL support here.
783            return;
784        }
785        let root = super::beneath::Root::new(directory.path());
786        let dir = root.directory(&["dir"], false).unwrap();
787        // The directory's own list is refused.
788        assert!(dir.own_access().is_err());
789        let mut access = dir.access("file.jsonc").unwrap().unwrap();
790        access.mode = 0o640;
791        dir.write("new", Some(&access), b"x").unwrap();
792        let written = dir_path.join("new");
793        assert_eq!(
794            rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
795            Err(rustix::io::Errno::NODATA)
796        );
797        assert_eq!(
798            fs::metadata(&written).unwrap().permissions().mode() & 0o777,
799            0o640
800        );
801        // Without access, the file has what the directory gives it.
802        dir.write("default", None, b"x").unwrap();
803        assert!(
804            rustix::fs::getxattr(
805                dir_path.join("default"),
806                "system.posix_acl_access",
807                &mut [0_u8; 0][..]
808            )
809            .is_ok()
810        );
811    }
812
813    #[cfg(target_os = "linux")]
814    #[test]
815    fn labels_are_compared_and_never_given() {
816        use std::os::unix::fs::MetadataExt as _;
817        let label = |path: &std::path::Path, value: &[u8]| {
818            let metadata = fs::metadata(path).unwrap();
819            super::beneath::GIVEN_LABELS.with(|labels| {
820                labels
821                    .borrow_mut()
822                    .insert((metadata.dev(), metadata.ino()), value.to_vec())
823            });
824        };
825        let directory = tree();
826        let root = super::beneath::Root::new(directory.path());
827        let dir = root.directory(&["dir"], false).unwrap();
828        let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
829        label(
830            &directory.path().join("dir/file.jsonc"),
831            b"system_u:object_r:secret_t:s0\0",
832        );
833        let labelled = dir.access("file.jsonc").unwrap().unwrap();
834        assert_eq!(labelled.mode, unlabelled.mode);
835        assert_ne!(labelled.control, unlabelled.control);
836        assert_ne!(labelled.audience(false), unlabelled.audience(false));
837        assert_eq!(labelled.new_file().control, labelled.control);
838        assert_eq!(
839            labelled.new_file().user,
840            rustix::process::geteuid().as_raw()
841        );
842        // A new file the system labels otherwise is refused before anything
843        // is written, and none is left.
844        assert!(dir.write("new", Some(&labelled), b"secret").is_err());
845        assert!(!directory.path().join("dir/new").exists());
846        dir.write("new", Some(&unlabelled), b"x").unwrap();
847        // So is a directory.
848        label(
849            &directory.path().join("dir"),
850            b"system_u:object_r:secret_t:s0\0",
851        );
852        let own = dir.own_access().unwrap();
853        assert_ne!(own.control, [0; 32]);
854        let made = root.directory(&["made"], true).unwrap();
855        assert!(made.set_own_access(&own).is_err());
856        made.set_own_access(&made.own_access().unwrap()).unwrap();
857    }
858
859    #[cfg(target_os = "linux")]
860    #[test]
861    fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
862        let directory = tree();
863        let root = super::beneath::Root::new(directory.path());
864        let dir = root.directory(&["dir"], false).unwrap();
865        // A test's own directory is found by its mount among this process's
866        // mounts, and told as its type and magic number say: checked on a
867        // local file system, not on a container's overlay root.
868        let mounts = super::beneath::Mounts::read();
869        let told = dir.unchecked_file_system(&mounts).unwrap();
870        if fs::File::open("/proc/self/mountinfo").is_err() {
871            // Only without a readable /proc may it not be told; in a chroot
872            // whose root is not a mount point, where the tree's mount is not
873            // listed, this test fails, as publishing there needs consent.
874            assert!(told.unwrap().starts_with("one that could not be told: "));
875        } else {
876            let id = dir.mount_id().unwrap().unwrap();
877            let found = mounts.type_of(id).unwrap();
878            let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
879            assert_eq!(told, expected);
880            // On a local file system, as CI's ext4 or a tmpfs, it is checked.
881            if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
882                assert_eq!(told, None);
883            }
884        }
885        // Both the type of the mount and the magic number must be a
886        // checked file system's: a 9p share passes on its host's magic
887        // number, and an overlay's upper layer may be remote.
888        for (found, magic, name) in [
889            ("ext4", 0xEF53, None),
890            ("xfs", 0x5846_5342, None),
891            ("btrfs", 0x9123_683E, None),
892            ("tmpfs", 0x0102_1994, None),
893            ("zfs", 0x2FC1_2FC1, None),
894            ("9p", 0xEF53, Some("9p")),
895            ("overlay", 0x794C_7630, Some("overlay")),
896            ("cifs", 0xFF53_4D42, Some("cifs")),
897            ("vboxsf", 0x786F_4256, Some("vboxsf")),
898            ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
899            (
900                "ext4",
901                0x0102_1997,
902                Some("ext4, though statfs gives type 0x1021997"),
903            ),
904        ] {
905            super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
906            let told = dir.unchecked_file_system(&mounts).unwrap();
907            super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
908            assert_eq!(told.as_deref(), name, "{found} {magic:x}");
909        }
910    }
911
912    #[cfg(target_os = "linux")]
913    #[test]
914    fn a_file_system_that_cannot_be_told_is_not_checked() {
915        use super::mounts::Mounts;
916        let directory = tree();
917        let root = super::beneath::Root::new(directory.path());
918        let dir = root.directory(&["dir"], false).unwrap();
919        let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
920        let real = Mounts::read();
921        // Only without a readable /proc may the mount not be known; in a
922        // chroot whose root is not a mount point, where the tree's mount is
923        // not listed, this test fails, as publishing there needs consent.
924        if fs::File::open("/proc/self/mountinfo").is_err() {
925            assert!(
926                told(&real)
927                    .unwrap()
928                    .starts_with("one that could not be told: ")
929            );
930            return;
931        }
932        let id = dir.mount_id().unwrap().unwrap();
933        let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
934        // Mounts that could not be read, a table without the mount, and a
935        // line for it that is malformed or too long each leave its file
936        // system untold, and so not checked.
937        for (mounts, reason) in [
938            (
939                Mounts::failed("it is not there"),
940                "it is not there".to_owned(),
941            ),
942            (
943                Mounts::parsed(b""),
944                format!("its mount, {id}, is not in /proc/self/mountinfo"),
945            ),
946            (
947                Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
948                "its line in /proc/self/mountinfo is malformed".to_owned(),
949            ),
950            (
951                Mounts::parsed(
952                    format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
953                ),
954                "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
955            ),
956            (
957                Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
958                format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
959            ),
960        ] {
961            assert_eq!(
962                told(&mounts),
963                Some(format!("one that could not be told: {reason}"))
964            );
965        }
966        // Without /proc, mounts that could not be read are named as the
967        // cause, before a mount ID that fdinfo, in /proc too, cannot give;
968        // and where only fdinfo cannot be read, that is.
969        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
970        let without_proc = told(&Mounts::failed("it is not there"));
971        let without_fdinfo = told(&real);
972        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
973        assert_eq!(
974            without_proc.as_deref(),
975            Some("one that could not be told: it is not there")
976        );
977        let without_fdinfo = without_fdinfo.unwrap();
978        assert!(
979            without_fdinfo.starts_with(
980                "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
981            ),
982            "{without_fdinfo}"
983        );
984        // A hostile line for another mount changes nothing for this one.
985        let found = real.type_of(id).unwrap().escape_ascii().to_string();
986        let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
987        table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
988        table.extend([0xFF, b' ', b'x', b'\n']);
989        table.extend(line(id, &format!("{found} x rw")).as_bytes());
990        table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
991        assert_eq!(told(&Mounts::parsed(&table)), told(&real));
992    }
993
994    #[cfg(target_os = "linux")]
995    #[test]
996    fn mountinfo_is_read_a_line_at_a_time() {
997        use super::mounts::scan;
998        let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
99936 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
100037 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
100138 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1002x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
100339 22 8:1 / / rw -\n\
100440 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
100541 22 8:1 / / rw - xfs a rw";
1006        // A small buffer, so lines are read across its edges.
1007        let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1008        assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1009        assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1010        assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1011        assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1012        for id in [38, 39, 40] {
1013            assert!(mounts[&id].is_err(), "{id}");
1014        }
1015        // A line without a mount ID is left out.
1016        assert_eq!(mounts.len(), 7);
1017        // What follows the first 64 KiB of a long line is skipped to its
1018        // newline, even where it reads as a line of its own, right at the
1019        // cut or a buffer later, wherever the reader's buffer ends:
1020        // Mounts::read reads through an 8 KiB one (BufReader::new).
1021        for gap in [0, 8 << 10] {
1022            let mut long = b"22 1 8:1 / /".to_vec();
1023            long.resize((64 << 10) + gap, b'a');
1024            long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1025            for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1026                let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1027                let mounts = scan(reader).unwrap();
1028                let at = format!("gap {gap}, buffer {capacity}");
1029                assert!(
1030                    mounts[&22].as_ref().unwrap_err().contains("longer than"),
1031                    "{at}"
1032                );
1033                assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1034                assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1035            }
1036        }
1037    }
1038
1039    #[cfg(target_os = "linux")]
1040    #[test]
1041    fn fdinfo_gives_the_mount_statx_does() {
1042        use rustix::fs::{AtFlags, StatxFlags, statx};
1043        use std::os::fd::AsRawFd as _;
1044        if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1045            return;
1046        }
1047        let directory = tree();
1048        let file = fs::File::open(directory.path().join("dir")).unwrap();
1049        // Before Linux 4.11, or where seccomp blocks it, there is no statx,
1050        // and fdinfo is the only source.
1051        let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1052            Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1053            Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1054            Err(errno) => panic!("{errno}"),
1055        };
1056        let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1057        assert!(fdinfo.is_some());
1058        if from_statx.is_some() {
1059            assert_eq!(fdinfo, from_statx);
1060        }
1061        // A directory's mount ID comes from fdinfo where statx gives none,
1062        // as before Linux 5.8.
1063        let root = super::beneath::Root::new(directory.path());
1064        let dir = root.directory(&["dir"], false).unwrap();
1065        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1066        let fallback = dir.mount_id();
1067        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1068        let fallback = fallback.unwrap().unwrap();
1069        assert_eq!(Some(fallback), fdinfo);
1070        if let Some(id) = from_statx {
1071            assert_eq!(fallback, id);
1072        }
1073    }
1074
1075    #[test]
1076    fn the_lock_excludes_a_second_holder() {
1077        let directory = tree();
1078        let root = super::beneath::Root::new(directory.path());
1079        let other = super::beneath::Root::new(directory.path());
1080        let held = root.lock().unwrap();
1081        assert!(held.is_some());
1082        assert!(other.lock().unwrap().is_none());
1083        drop(held);
1084        assert!(other.lock().unwrap().is_some());
1085    }
1086}
1087
1088#[cfg(all(test, target_os = "linux"))]
1089mod selinux_tests {
1090    use super::beneath::selinux::{request, trimmed};
1091
1092    #[test]
1093    fn label_questions_are_one_line_with_the_name_encoded() {
1094        let query = request(
1095            b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1096            b"unconfined_u:object_r:user_home_t:s0\0",
1097            7,
1098            "0_0.jsonc",
1099        )
1100        .unwrap();
1101        assert_eq!(
1102            query,
1103            b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1104        );
1105        let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1106        assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1107        assert!(request(b"a b", b"c", 1, "x").is_err());
1108        assert!(request(b"", b"c", 1, "x").is_err());
1109        assert_eq!(trimmed(b"label\0\n"), b"label");
1110        assert_eq!(trimmed(b"\0"), b"");
1111    }
1112}
1113
1114/// On Windows the path-based implementation refuses every reparse point,
1115/// junctions included, at the component where it occurs, removes one
1116/// without touching what it names, and holds its lock in a file.
1117#[cfg(all(test, windows))]
1118mod windows_tests {
1119    use super::{Found, RawKind, Refused, portable::Root};
1120    use std::{
1121        fs,
1122        path::{Path, PathBuf},
1123        process::{Command, Stdio},
1124    };
1125
1126    /// Makes `link` a junction to the directory `target`.
1127    fn junction(link: &Path, target: &Path) {
1128        // `mklink` takes `/` for an option, so the paths use `\\`.
1129        let native = |path: &Path| path.components().collect::<PathBuf>();
1130        let status = Command::new("cmd")
1131            .args(["/C", "mklink", "/J"])
1132            .arg(native(link))
1133            .arg(native(target))
1134            .stdout(Stdio::null())
1135            .status()
1136            .unwrap();
1137        assert!(status.success());
1138    }
1139
1140    fn link<T>(result: Result<T, Refused>) -> String {
1141        match result {
1142            Err(Refused::Link(prefix)) => prefix,
1143            Err(other) => panic!("expected a link, got {other:?}"),
1144            Ok(_) => panic!("expected a link, got success"),
1145        }
1146    }
1147
1148    #[test]
1149    fn junctions_are_links() {
1150        let directory = tempfile::tempdir().unwrap();
1151        fs::create_dir(directory.path().join("dir")).unwrap();
1152        fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1153        junction(
1154            &directory.path().join("link"),
1155            &directory.path().join("dir"),
1156        );
1157        let root = Root::new(directory.path());
1158        assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1159        assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1160        assert_eq!(link(root.kind(&["link"])), "link");
1161        assert_eq!(link(root.directory(&["link"], false)), "link");
1162        assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1163        assert!(!directory.path().join("dir/inside").exists());
1164        let kinds: Vec<(Option<String>, RawKind)> = root
1165            .list(&[])
1166            .unwrap()
1167            .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1168            .collect::<Result<_, _>>()
1169            .unwrap();
1170        assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1171        assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1172
1173        // A write over a junction replaces it, not what it names.
1174        let base = root.directory(&[], false).unwrap();
1175        base.write("link", None, b"x").unwrap();
1176        assert!(directory.path().join("dir/file.jsonc").exists());
1177        assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1178    }
1179
1180    #[test]
1181    fn read_only_files_are_refused() {
1182        let directory = tempfile::tempdir().unwrap();
1183        let file = directory.path().join("file.jsonc");
1184        fs::write(&file, "{}").unwrap();
1185        let base = Root::new(directory.path()).directory(&[], false).unwrap();
1186        assert!(base.access("file.jsonc").unwrap().is_some());
1187        let mut permissions = fs::metadata(&file).unwrap().permissions();
1188        permissions.set_readonly(true);
1189        fs::set_permissions(&file, permissions).unwrap();
1190        assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1191    }
1192
1193    #[test]
1194    fn encrypted_files_and_directories_are_refused() {
1195        let directory = tempfile::tempdir().unwrap();
1196        let inside = directory.path().join("inside");
1197        fs::create_dir(&inside).unwrap();
1198        fs::write(inside.join("file.jsonc"), "{}").unwrap();
1199        // EFS is not on every volume; where it is not, there is nothing to
1200        // refuse.
1201        let encrypted = Command::new("cipher")
1202            .arg("/e")
1203            .arg("/s:".to_owned() + &inside.display().to_string())
1204            .stdout(Stdio::null())
1205            .status()
1206            .is_ok_and(|status| status.success());
1207        let root = Root::new(directory.path());
1208        let dir = root.directory(&["inside"], false).unwrap();
1209        // The hosted runners have it, so there the test must not pass
1210        // without it.
1211        assert!(
1212            encrypted || std::env::var_os("CI").is_none(),
1213            "cipher /e failed on CI"
1214        );
1215        if encrypted {
1216            assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1217            assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1218        } else {
1219            assert!(dir.access("file.jsonc").unwrap().is_some());
1220        }
1221    }
1222
1223    #[test]
1224    fn the_lock_is_a_file_held_alone() {
1225        let directory = tempfile::tempdir().unwrap();
1226        let lock_file = directory.path().join(crate::access::LOCK_NAME);
1227        let root = Root::new(directory.path());
1228        let other = Root::new(directory.path());
1229        let held = root.lock().unwrap();
1230        assert!(held.is_some());
1231        assert!(other.lock().unwrap().is_none());
1232        // A file renamed into the root may replace another meanwhile.
1233        let base = root.directory(&[], false).unwrap();
1234        base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1235            .unwrap();
1236        base.write("world.jsonc", None, b"old").unwrap();
1237        base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1238            .unwrap();
1239        // The lock file goes with the lock.
1240        drop(held);
1241        assert!(!lock_file.exists());
1242        assert!(other.lock().unwrap().is_some());
1243        // One that no edit holds is not taken over.
1244        fs::write(&lock_file, "someone's").unwrap();
1245        assert!(matches!(root.lock(), Err(Refused::Io(_))));
1246        assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1247    }
1248}