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