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