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/// A step a test runs inside a create.
793#[cfg(test)]
794pub(crate) type Hook = Box<dyn FnMut() -> std::io::Result<()>>;
795
796#[cfg(test)]
797thread_local! {
798    /// What a create on this thread does right after it made its file,
799    /// before writing it, for tests: fail, or change the directory as
800    /// another process could meanwhile.
801    pub(crate) static AFTER_CREATE: std::cell::RefCell<Option<Hook>> =
802        const { std::cell::RefCell::new(None) };
803}
804
805#[cfg(test)]
806thread_local! {
807    /// The error a portable create's open fails with on this thread, for
808    /// tests, instead of opening anything: Windows has errors other than
809    /// "already exists" for a name that is taken.
810    pub(crate) static OPEN_FAILS: std::cell::Cell<Option<std::io::ErrorKind>> =
811        const { std::cell::Cell::new(None) };
812}
813
814/// This thread's [`OPEN_FAILS`].
815#[cfg(test)]
816pub(crate) fn open_fails() -> Option<std::io::ErrorKind> {
817    OPEN_FAILS.with(std::cell::Cell::get)
818}
819
820/// Runs this thread's [`AFTER_CREATE`], if it has one.
821#[cfg(test)]
822pub(crate) fn after_create() -> std::io::Result<()> {
823    AFTER_CREATE.with(|hook| hook.borrow_mut().as_mut().map_or(Ok(()), |hook| hook()))
824}
825
826/// Both access implementations refuse links themselves, at the component
827/// where they occur, rather than relying on an earlier check.
828#[cfg(all(test, unix))]
829mod tests {
830    use super::{Found, RawKind, Refused};
831    use std::{fs, os::unix::fs::symlink};
832
833    #[test]
834    fn an_entry_the_overlay_removes_is_never_counted() {
835        use super::{LoadOptions, Overlay, Tree, platform};
836        use std::{cell::Cell, rc::Rc};
837        let root = tempfile::tempdir().unwrap();
838        for index in 0..10 {
839            fs::write(root.path().join(format!("{index}_0.jsonc")), "{}").unwrap();
840        }
841        // The overlay removes every entry the file system lists after the
842        // first, which is all the budget holds.
843        let mut listing = fs::read_dir(root.path())
844            .unwrap()
845            .map(|entry| entry.unwrap().file_name().into_string().unwrap());
846        let first = listing.next().unwrap();
847        let overlay: Overlay = listing.map(|name| (name, None)).collect();
848        let options = LoadOptions {
849            maximum_entries: 1,
850            ..LoadOptions::default()
851        };
852        let total = Rc::new(Cell::new(0));
853        let mut tree = Tree::in_root(platform::Root::new(root.path()), &options, &overlay, total);
854        let listed = tree.list("");
855        assert_eq!(listed.len(), 1);
856        assert_eq!(listed[0].name, first);
857        assert!(!tree.exhausted);
858    }
859
860    fn tree() -> tempfile::TempDir {
861        let root = tempfile::tempdir().unwrap();
862        fs::create_dir(root.path().join("dir")).unwrap();
863        fs::write(root.path().join("dir/file.jsonc"), "{}").unwrap();
864        symlink(root.path().join("dir"), root.path().join("link")).unwrap();
865        symlink(
866            root.path().join("dir/file.jsonc"),
867            root.path().join("dir/file-link.jsonc"),
868        )
869        .unwrap();
870        root
871    }
872
873    /// The link prefix a refusal names.
874    fn link<T>(result: Result<T, Refused>) -> String {
875        match result {
876            Err(Refused::Link(prefix)) => prefix,
877            Err(other) => panic!("expected a link, got {other:?}"),
878            Ok(_) => panic!("expected a link, got success"),
879        }
880    }
881
882    macro_rules! access_tests {
883        ($name:ident, $module:ident) => {
884            #[test]
885            fn $name() {
886                let directory = tree();
887                let root = super::$module::Root::new(directory.path());
888                assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
889                assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
890                assert_eq!(
891                    link(root.file(&["dir", "file-link.jsonc"])),
892                    "dir/file-link.jsonc"
893                );
894                assert!(matches!(root.file(&["dir"]), Err(Refused::Unexpected)));
895                assert!(matches!(root.file(&["dir", "nope"]), Err(Refused::Missing)));
896                assert!(matches!(
897                    root.file(&["dir", "file.jsonc", "x"]),
898                    Err(Refused::Unexpected)
899                ));
900                assert_eq!(root.kind(&["dir"]).unwrap(), Found::Directory);
901                assert_eq!(root.kind(&["dir", "file.jsonc"]).unwrap(), Found::File);
902                assert_eq!(root.kind(&["nope"]).unwrap(), Found::Missing);
903                assert_eq!(link(root.kind(&["link"])), "link");
904                assert_eq!(link(root.list(&["link"])), "link");
905                let mut entries: Vec<(Option<String>, RawKind)> = root
906                    .list(&["dir"])
907                    .unwrap()
908                    .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
909                    .collect::<Result<_, _>>()
910                    .unwrap();
911                entries.sort_by(|left, right| left.0.cmp(&right.0));
912                assert_eq!(
913                    entries,
914                    [
915                        (Some("file-link.jsonc".to_owned()), RawKind::Link),
916                        (Some("file.jsonc".to_owned()), RawKind::File),
917                    ]
918                );
919            }
920        };
921    }
922
923    access_tests!(beneath_refuses_links, beneath);
924    access_tests!(portable_refuses_links, portable);
925
926    macro_rules! write_tests {
927        ($name:ident, $module:ident) => {
928            #[test]
929            fn $name() {
930                let directory = tree();
931                let root = super::$module::Root::new(directory.path());
932                // Directories are created as asked, never through a link.
933                assert!(matches!(
934                    root.directory(&["new", "deeper"], false),
935                    Err(Refused::Missing)
936                ));
937                let made = root.directory(&["new", "deeper"], true).unwrap();
938                assert!(directory.path().join("new/deeper").is_dir());
939                assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
940                assert!(!directory.path().join("dir/inside").exists());
941                assert!(matches!(
942                    root.directory(&["dir", "file.jsonc"], true),
943                    Err(Refused::Unexpected)
944                ));
945
946                // A write replaces a link or hard link instead of writing
947                // through it, and takes the access it is given.
948                let dir = root.directory(&["dir"], false).unwrap();
949                let outside = tempfile::tempdir().unwrap();
950                let target = outside.path().join("target");
951                fs::write(&target, "outside").unwrap();
952                symlink(&target, directory.path().join("dir/staged")).unwrap();
953                dir.write("staged", None, b"new").unwrap();
954                assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
955                fs::hard_link(&target, directory.path().join("dir/hard")).unwrap();
956                dir.write("hard", None, b"new").unwrap();
957                assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
958                {
959                    use std::os::unix::fs::PermissionsExt as _;
960                    let file = directory.path().join("dir/file.jsonc");
961                    fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
962                    let like = dir.access("file.jsonc").unwrap();
963                    assert!(dir.access("nope").unwrap().is_none());
964                    dir.write("moded", like.as_ref(), b"x").unwrap();
965                    let mode = fs::metadata(directory.path().join("dir/moded"))
966                        .unwrap()
967                        .permissions()
968                        .mode();
969                    assert_eq!(mode & 0o777, 0o640);
970                    // A directory's own access can be read and set.
971                    let mut own = dir.own_access().unwrap();
972                    own.mode = 0o750;
973                    dir.set_own_access(&own).unwrap();
974                    assert_eq!(dir.own_access().unwrap(), own);
975                }
976
977                dir.rename("staged", "file.jsonc").unwrap();
978                assert_eq!(
979                    fs::read_to_string(directory.path().join("dir/file.jsonc")).unwrap(),
980                    "new"
981                );
982                assert!(dir.remove_file("hard").unwrap());
983                assert!(!dir.remove_file("hard").unwrap());
984                assert!(dir.remove_file("file-link.jsonc").unwrap());
985                assert!(target.exists());
986                dir.sync().unwrap();
987                made.sync().unwrap();
988
989                let top = root.directory(&["new"], false).unwrap();
990                fs::write(directory.path().join("new/deeper/x"), "").unwrap();
991                assert!(!top.remove_directory("deeper").unwrap());
992                fs::remove_file(directory.path().join("new/deeper/x")).unwrap();
993                assert!(top.remove_directory("deeper").unwrap());
994                assert!(!top.remove_directory("deeper").unwrap());
995
996                assert!(root.lock().unwrap().is_some());
997                // Making a directory says whether this call made it.
998                assert!(root.make_directory(&["made"]).unwrap());
999                assert!(!root.make_directory(&["made"]).unwrap());
1000                assert!(!root.make_directory(&["dir", "file.jsonc"]).unwrap());
1001                assert!(matches!(
1002                    root.make_directory(&["nope", "deeper"]),
1003                    Err(Refused::Missing)
1004                ));
1005                // A clone is a handle on the same directory.
1006                let clone = root.try_clone();
1007                assert_eq!(clone.kind(&["dir"]).unwrap(), Found::Directory);
1008            }
1009        };
1010    }
1011
1012    write_tests!(beneath_writes_beneath_the_root, beneath);
1013    write_tests!(portable_writes_beneath_the_root, portable);
1014
1015    /// Everything about an entry a create must leave as it was, not
1016    /// following it: its kind, identity, links, mode, size, times, and its
1017    /// bytes, link target or entries.
1018    fn snapshot(path: &std::path::Path) -> String {
1019        use std::os::unix::fs::MetadataExt as _;
1020        let metadata = fs::symlink_metadata(path).unwrap();
1021        let content = if metadata.file_type().is_symlink() {
1022            format!("-> {}", fs::read_link(path).unwrap().display())
1023        } else if metadata.is_dir() {
1024            let mut names: Vec<_> = fs::read_dir(path)
1025                .unwrap()
1026                .map(|entry| entry.unwrap().file_name())
1027                .collect();
1028            names.sort();
1029            format!("{names:?}")
1030        } else if metadata.is_file() {
1031            format!("{:?}", fs::read(path).unwrap())
1032        } else {
1033            String::new()
1034        };
1035        format!(
1036            "{:?} {} {} {} {:o} {} {} {} {} {content}",
1037            metadata.file_type(),
1038            metadata.dev(),
1039            metadata.ino(),
1040            metadata.nlink(),
1041            metadata.mode(),
1042            metadata.len(),
1043            metadata.mtime(),
1044            metadata.mtime_nsec(),
1045            metadata.ctime_nsec(),
1046        )
1047    }
1048
1049    /// Whether a create refused because something was at the name.
1050    fn already_there<T: std::fmt::Debug>(result: Result<T, Refused>) -> bool {
1051        type Inner = dyn std::error::Error + Send + Sync;
1052        match result {
1053            Err(Refused::Io(error)) => {
1054                error.kind() == std::io::ErrorKind::AlreadyExists
1055                    && error
1056                        .get_ref()
1057                        .is_some_and(Inner::is::<crate::access::AlreadyThere>)
1058            }
1059            other => panic!("expected something already there, got {other:?}"),
1060        }
1061    }
1062
1063    /// Whether a create's error says its file may be left.
1064    fn left(error: &std::io::Error) -> bool {
1065        type Inner = dyn std::error::Error + Send + Sync;
1066        error
1067            .get_ref()
1068            .is_some_and(Inner::is::<crate::access::LeftBehind>)
1069    }
1070
1071    /// Sets this thread's hook inside creates until it is dropped.
1072    struct Hooked;
1073
1074    impl Hooked {
1075        fn new(hook: impl FnMut() -> std::io::Result<()> + 'static) -> Self {
1076            super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
1077            Self
1078        }
1079    }
1080
1081    impl Drop for Hooked {
1082        fn drop(&mut self) {
1083            super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1084        }
1085    }
1086
1087    macro_rules! create_tests {
1088        ($name:ident, $module:ident) => {
1089            #[test]
1090            fn $name() {
1091                use std::os::unix::fs::PermissionsExt as _;
1092                let directory = tree();
1093                let root = super::$module::Root::new(directory.path());
1094                let dir = root.directory(&["dir"], false).unwrap();
1095                let at = |name: &str| directory.path().join("dir").join(name);
1096
1097                // A new file has exactly the bytes and the access asked for.
1098                dir.create("new", None, b"bytes").unwrap();
1099                assert_eq!(fs::read(at("new")).unwrap(), b"bytes");
1100                fs::set_permissions(at("file.jsonc"), fs::Permissions::from_mode(0o640)).unwrap();
1101                let like = dir.access("file.jsonc").unwrap().unwrap();
1102                dir.create("moded", Some(&like), b"").unwrap();
1103                assert_eq!(fs::read(at("moded")).unwrap(), b"");
1104                assert_eq!(
1105                    fs::metadata(at("moded")).unwrap().permissions().mode() & 0o7777,
1106                    0o640
1107                );
1108
1109                // Nothing already at the name is removed, replaced, followed
1110                // or written through: a file, a hard link to one outside,
1111                // directories empty or not, links to a file, a directory or
1112                // nothing (outside the tree), and a FIFO.
1113                let outside = tempfile::tempdir().unwrap();
1114                let target = outside.path().join("target");
1115                fs::write(&target, "outside").unwrap();
1116                fs::create_dir(outside.path().join("elsewhere")).unwrap();
1117                fs::write(at("file"), "keep").unwrap();
1118                fs::hard_link(&target, at("hard")).unwrap();
1119                fs::create_dir(at("empty")).unwrap();
1120                fs::create_dir(at("full")).unwrap();
1121                fs::write(at("full/inside"), "kept").unwrap();
1122                symlink(&target, at("to-file")).unwrap();
1123                symlink(outside.path().join("elsewhere"), at("to-directory")).unwrap();
1124                symlink(outside.path().join("nothing"), at("dangling")).unwrap();
1125                symlink("file", at("relative")).unwrap();
1126                let fifo = std::process::Command::new("mkfifo")
1127                    .arg(at("fifo"))
1128                    .status()
1129                    .unwrap();
1130                assert!(fifo.success());
1131                let names = [
1132                    "new",
1133                    "file",
1134                    "hard",
1135                    "empty",
1136                    "full",
1137                    "to-file",
1138                    "to-directory",
1139                    "dangling",
1140                    "relative",
1141                    "fifo",
1142                    "file-link.jsonc",
1143                ];
1144                let before: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1145                let outside_before = snapshot(outside.path());
1146                for name in names {
1147                    assert!(already_there(dir.create(name, None, b"x")), "{name}");
1148                    assert!(already_there(dir.create(name, Some(&like), b"x")), "{name}");
1149                }
1150                let after: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1151                assert_eq!(before, after);
1152                assert_eq!(snapshot(outside.path()), outside_before);
1153                assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
1154                assert!(!outside.path().join("nothing").exists());
1155
1156                // Through a link to a directory nothing is made either.
1157                assert_eq!(link(root.directory(&["link"], false)), "link");
1158            }
1159        };
1160    }
1161
1162    macro_rules! create_failure_tests {
1163        ($name:ident, $module:ident, $removes:expr) => {
1164            #[test]
1165            fn $name() {
1166                use std::os::unix::fs::PermissionsExt as _;
1167                let directory = tree();
1168                let root = super::$module::Root::new(directory.path());
1169                let dir = root.directory(&["dir"], false).unwrap();
1170                let at = |name: &str| directory.path().join("dir").join(name);
1171                fs::set_permissions(at("file.jsonc"), fs::Permissions::from_mode(0o640)).unwrap();
1172                let like = dir.access("file.jsonc").unwrap().unwrap();
1173
1174                // A create that fails after making its file removes the
1175                // file only if that is still what is at the name (Unix,
1176                // beneath), or never (portable), and says when it may be
1177                // left.
1178                {
1179                    let _hooked = Hooked::new(|| Err(std::io::ErrorKind::StorageFull.into()));
1180                    let failed = dir.create("failed", None, b"x");
1181                    let Err(Refused::Io(error)) = failed else {
1182                        panic!("expected a failure, got {failed:?}");
1183                    };
1184                    assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
1185                    let removes: bool = $removes;
1186                    assert_eq!(at("failed").exists(), !removes);
1187                    assert_eq!(error.to_string().contains("may be left"), !removes);
1188                    assert_eq!(left(&error), !removes);
1189                }
1190                {
1191                    // Another process renames the new file away and puts its
1192                    // own at the name, then the create fails; that file
1193                    // survives the cleanup.
1194                    let (moved, name) = (at("moved"), at("swapped"));
1195                    let _hooked = Hooked::new(move || {
1196                        fs::rename(&name, &moved)?;
1197                        fs::write(&name, "someone else's")?;
1198                        Err(std::io::ErrorKind::StorageFull.into())
1199                    });
1200                    let failed = dir.create("swapped", None, b"x");
1201                    let Err(Refused::Io(error)) = failed else {
1202                        panic!("expected a failure, got {failed:?}");
1203                    };
1204                    assert!(error.to_string().contains("may be left"), "{error}");
1205                    assert!(left(&error));
1206                    assert_eq!(fs::read_to_string(at("swapped")).unwrap(), "someone else's");
1207                    assert!(at("moved").exists());
1208                }
1209                {
1210                    // So does a link it puts there to the new file, now
1211                    // moved: the cleanup does not follow it.
1212                    let (moved, name) = (at("moved-ours"), at("linked"));
1213                    let _hooked = Hooked::new(move || {
1214                        fs::rename(&name, &moved)?;
1215                        symlink("moved-ours", &name)?;
1216                        Err(std::io::ErrorKind::StorageFull.into())
1217                    });
1218                    let failed = dir.create("linked", None, b"x");
1219                    let Err(Refused::Io(error)) = failed else {
1220                        panic!("expected a failure, got {failed:?}");
1221                    };
1222                    assert!(left(&error), "{error}");
1223                    let linked = fs::symlink_metadata(at("linked")).unwrap();
1224                    assert!(linked.file_type().is_symlink());
1225                    assert!(at("moved-ours").is_file());
1226                }
1227
1228                // The file is made with its final mode only when it has no
1229                // access of its own: with access, it is its owner's alone
1230                // until written, as the hook, before any byte, sees.
1231                fs::File::create(at("reference")).unwrap();
1232                let default = fs::metadata(at("reference")).unwrap().permissions().mode() & 0o7777;
1233                assert_ne!(default, 0o600, "a umask that hides the difference");
1234                for (name, access, expected) in [
1235                    ("mode-default", None, default),
1236                    ("mode-owner", Some(like), 0o600),
1237                ] {
1238                    let seen = std::rc::Rc::new(std::cell::Cell::new(None));
1239                    let (path, found) = (at(name), seen.clone());
1240                    let _hooked = Hooked::new(move || {
1241                        let metadata = fs::symlink_metadata(&path)?;
1242                        found.set(Some((
1243                            metadata.permissions().mode() & 0o7777,
1244                            metadata.len(),
1245                        )));
1246                        Ok(())
1247                    });
1248                    dir.create(name, access.as_ref(), b"x").unwrap();
1249                    assert_eq!(seen.get(), Some((expected, 0)), "{name}");
1250                }
1251            }
1252        };
1253    }
1254
1255    create_tests!(beneath_creates_only_new_files, beneath);
1256    create_tests!(portable_creates_only_new_files, portable);
1257    create_failure_tests!(beneath_removes_only_the_file_it_made, beneath, true);
1258    create_failure_tests!(portable_never_removes_the_file_it_made, portable, false);
1259
1260    /// Whether a result is a create's refusal of a taken name.
1261    fn taken(result: &Result<(), Refused>) -> bool {
1262        type Inner = dyn std::error::Error + Send + Sync;
1263        matches!(result, Err(Refused::Io(error))
1264            if error.get_ref().is_some_and(Inner::is::<crate::access::AlreadyThere>))
1265    }
1266
1267    /// A portable create whose open fails says the name is taken only when
1268    /// something is at it.
1269    #[test]
1270    fn a_portable_create_that_cannot_open_is_taken_only_if_something_is_there() {
1271        use std::os::unix::fs::PermissionsExt as _;
1272        let directory = tree();
1273        let root = super::portable::Root::new(directory.path());
1274        let dir = root.directory(&["dir"], false).unwrap();
1275        // An error other than "already exists", as Windows gives for some
1276        // taken names.
1277        super::OPEN_FAILS.with(|fails| fails.set(Some(std::io::ErrorKind::PermissionDenied)));
1278        let free = dir.create("free", None, b"x");
1279        let held = dir.create("file.jsonc", None, b"x");
1280        super::OPEN_FAILS.with(|fails| fails.set(None));
1281        assert!(
1282            matches!(&free, Err(Refused::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied),
1283            "{free:?}"
1284        );
1285        assert!(!taken(&free));
1286        assert!(taken(&held), "{held:?}");
1287        // Real failures with nothing at the name: a directory removed
1288        // since it was opened, and one that cannot be written.
1289        fs::create_dir(directory.path().join("gone")).unwrap();
1290        let gone = root.directory(&["gone"], false).unwrap();
1291        fs::remove_dir(directory.path().join("gone")).unwrap();
1292        let missing = gone.create("x", None, b"x");
1293        assert!(missing.is_err() && !taken(&missing), "{missing:?}");
1294        if rustix::process::geteuid().as_raw() != 0 {
1295            let locked = directory.path().join("locked");
1296            fs::create_dir(&locked).unwrap();
1297            fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap();
1298            let refused = root
1299                .directory(&["locked"], false)
1300                .unwrap()
1301                .create("x", None, b"x");
1302            fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
1303            assert!(refused.is_err() && !taken(&refused), "{refused:?}");
1304        }
1305    }
1306
1307    #[cfg(target_os = "linux")]
1308    #[test]
1309    fn a_written_file_with_access_has_no_inherited_access_control_list() {
1310        use std::os::unix::fs::PermissionsExt as _;
1311        let directory = tree();
1312        // A default list for new files: user::rwx, user:nobody:r--,
1313        // group::r-x, mask::r-x, other::---.
1314        let mut acl = 2_u32.to_le_bytes().to_vec();
1315        for (tag, permissions, id) in [
1316            (0x01_u16, 7_u16, u32::MAX),
1317            (0x02, 4, 65534),
1318            (0x04, 5, u32::MAX),
1319            (0x10, 5, u32::MAX),
1320            (0x20, 0, u32::MAX),
1321        ] {
1322            acl.extend(tag.to_le_bytes());
1323            acl.extend(permissions.to_le_bytes());
1324            acl.extend(id.to_le_bytes());
1325        }
1326        let dir_path = directory.path().join("dir");
1327        if rustix::fs::setxattr(
1328            &dir_path,
1329            "system.posix_acl_default",
1330            &acl,
1331            rustix::fs::XattrFlags::empty(),
1332        )
1333        .is_err()
1334        {
1335            // No ACL support here.
1336            return;
1337        }
1338        let root = super::beneath::Root::new(directory.path());
1339        let dir = root.directory(&["dir"], false).unwrap();
1340        // The directory's own list is refused.
1341        assert!(dir.own_access().is_err());
1342        let mut access = dir.access("file.jsonc").unwrap().unwrap();
1343        access.mode = 0o640;
1344        dir.write("new", Some(&access), b"x").unwrap();
1345        let written = dir_path.join("new");
1346        assert_eq!(
1347            rustix::fs::getxattr(&written, "system.posix_acl_access", &mut [0_u8; 0][..]),
1348            Err(rustix::io::Errno::NODATA)
1349        );
1350        assert_eq!(
1351            fs::metadata(&written).unwrap().permissions().mode() & 0o777,
1352            0o640
1353        );
1354        // So has a created one.
1355        dir.create("created", Some(&access), b"x").unwrap();
1356        assert_eq!(
1357            rustix::fs::getxattr(
1358                dir_path.join("created"),
1359                "system.posix_acl_access",
1360                &mut [0_u8; 0][..]
1361            ),
1362            Err(rustix::io::Errno::NODATA)
1363        );
1364        // Without access, the file has what the directory gives it.
1365        dir.write("default", None, b"x").unwrap();
1366        assert!(
1367            rustix::fs::getxattr(
1368                dir_path.join("default"),
1369                "system.posix_acl_access",
1370                &mut [0_u8; 0][..]
1371            )
1372            .is_ok()
1373        );
1374    }
1375
1376    #[cfg(target_os = "linux")]
1377    #[test]
1378    fn labels_are_compared_and_never_given() {
1379        use std::os::unix::fs::MetadataExt as _;
1380        let label = |path: &std::path::Path, value: &[u8]| {
1381            let metadata = fs::metadata(path).unwrap();
1382            super::beneath::GIVEN_LABELS.with(|labels| {
1383                labels
1384                    .borrow_mut()
1385                    .insert((metadata.dev(), metadata.ino()), value.to_vec())
1386            });
1387        };
1388        let directory = tree();
1389        let root = super::beneath::Root::new(directory.path());
1390        let dir = root.directory(&["dir"], false).unwrap();
1391        let unlabelled = dir.access("file.jsonc").unwrap().unwrap();
1392        label(
1393            &directory.path().join("dir/file.jsonc"),
1394            b"system_u:object_r:secret_t:s0\0",
1395        );
1396        let labelled = dir.access("file.jsonc").unwrap().unwrap();
1397        assert_eq!(labelled.mode, unlabelled.mode);
1398        assert_ne!(labelled.control, unlabelled.control);
1399        assert_ne!(labelled.audience(false), unlabelled.audience(false));
1400        assert_eq!(labelled.new_file().control, labelled.control);
1401        assert_eq!(
1402            labelled.new_file().user,
1403            rustix::process::geteuid().as_raw()
1404        );
1405        // A new file the system labels otherwise is refused before anything
1406        // is written, and none is left.
1407        assert!(dir.write("new", Some(&labelled), b"secret").is_err());
1408        assert!(!directory.path().join("dir/new").exists());
1409        dir.write("new", Some(&unlabelled), b"x").unwrap();
1410        // So is a created one, which is removed, being still the file made.
1411        let refused = dir.create("created", Some(&labelled), b"secret");
1412        assert!(
1413            matches!(&refused, Err(Refused::Io(error)) if !error.to_string().contains("may be left"))
1414        );
1415        assert!(!directory.path().join("dir/created").exists());
1416        dir.create("created", Some(&unlabelled), b"x").unwrap();
1417        // The labels are checked before a byte is written: a file moved
1418        // away as it is made, so that it is left, is still empty.
1419        let (made, moved) = (
1420            directory.path().join("dir/labelled"),
1421            directory.path().join("dir/moved-labelled"),
1422        );
1423        let moving = moved.clone();
1424        super::AFTER_CREATE.with(|slot| {
1425            *slot.borrow_mut() = Some(Box::new(move || fs::rename(&made, &moving)));
1426        });
1427        let refused = dir.create("labelled", Some(&labelled), b"secret");
1428        super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1429        assert!(
1430            matches!(&refused, Err(Refused::Io(error)) if left(error)),
1431            "{refused:?}"
1432        );
1433        assert_eq!(fs::read(&moved).unwrap(), b"");
1434        // So is a directory.
1435        label(
1436            &directory.path().join("dir"),
1437            b"system_u:object_r:secret_t:s0\0",
1438        );
1439        let own = dir.own_access().unwrap();
1440        assert_ne!(own.control, [0; 32]);
1441        let made = root.directory(&["made"], true).unwrap();
1442        assert!(made.set_own_access(&own).is_err());
1443        made.set_own_access(&made.own_access().unwrap()).unwrap();
1444    }
1445
1446    #[cfg(target_os = "linux")]
1447    #[test]
1448    fn only_local_file_systems_that_show_their_access_control_lists_are_checked() {
1449        let directory = tree();
1450        let root = super::beneath::Root::new(directory.path());
1451        let dir = root.directory(&["dir"], false).unwrap();
1452        // A test's own directory is found by its mount among this process's
1453        // mounts, and told as its type and magic number say: checked on a
1454        // local file system, not on a container's overlay root.
1455        let mounts = super::beneath::Mounts::read();
1456        let told = dir.unchecked_file_system(&mounts).unwrap();
1457        if fs::File::open("/proc/self/mountinfo").is_err() {
1458            // Only without a readable /proc may it not be told; in a chroot
1459            // whose root is not a mount point, where the tree's mount is not
1460            // listed, this test fails, as publishing there needs consent.
1461            assert!(told.unwrap().starts_with("one that could not be told: "));
1462        } else {
1463            let id = dir.mount_id().unwrap().unwrap();
1464            let found = mounts.type_of(id).unwrap();
1465            let expected = super::beneath::unchecked_file_system(found, dir.magic().unwrap());
1466            assert_eq!(told, expected);
1467            // On a local file system, as CI's ext4 or a tmpfs, it is checked.
1468            if matches!(found, b"ext4" | b"tmpfs" | b"xfs" | b"btrfs") {
1469                assert_eq!(told, None);
1470            }
1471        }
1472        // Both the type of the mount and the magic number must be a
1473        // checked file system's: a 9p share passes on its host's magic
1474        // number, and an overlay's upper layer may be remote.
1475        for (found, magic, name) in [
1476            ("ext4", 0xEF53, None),
1477            ("xfs", 0x5846_5342, None),
1478            ("btrfs", 0x9123_683E, None),
1479            ("tmpfs", 0x0102_1994, None),
1480            ("zfs", 0x2FC1_2FC1, None),
1481            ("9p", 0xEF53, Some("9p")),
1482            ("overlay", 0x794C_7630, Some("overlay")),
1483            ("cifs", 0xFF53_4D42, Some("cifs")),
1484            ("vboxsf", 0x786F_4256, Some("vboxsf")),
1485            ("fuse.a\tb", 0x6573_5546, Some("fuse.a\\tb")),
1486            (
1487                "ext4",
1488                0x0102_1997,
1489                Some("ext4, though statfs gives type 0x1021997"),
1490            ),
1491        ] {
1492            super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(Some((found, magic))));
1493            let told = dir.unchecked_file_system(&mounts).unwrap();
1494            super::beneath::GIVEN_FILE_SYSTEM.with(|given| given.set(None));
1495            assert_eq!(told.as_deref(), name, "{found} {magic:x}");
1496        }
1497    }
1498
1499    #[cfg(target_os = "linux")]
1500    #[test]
1501    fn a_file_system_that_cannot_be_told_is_not_checked() {
1502        use super::mounts::Mounts;
1503        let directory = tree();
1504        let root = super::beneath::Root::new(directory.path());
1505        let dir = root.directory(&["dir"], false).unwrap();
1506        let told = |mounts: &Mounts| dir.unchecked_file_system(mounts).unwrap();
1507        let real = Mounts::read();
1508        // Only without a readable /proc may the mount not be known; in a
1509        // chroot whose root is not a mount point, where the tree's mount is
1510        // not listed, this test fails, as publishing there needs consent.
1511        if fs::File::open("/proc/self/mountinfo").is_err() {
1512            assert!(
1513                told(&real)
1514                    .unwrap()
1515                    .starts_with("one that could not be told: ")
1516            );
1517            return;
1518        }
1519        let id = dir.mount_id().unwrap().unwrap();
1520        let line = |id: u64, rest: &str| format!("{id} 1 8:1 / /mnt rw - {rest}\n");
1521        // Mounts that could not be read, a table without the mount, and a
1522        // line for it that is malformed or too long each leave its file
1523        // system untold, and so not checked.
1524        for (mounts, reason) in [
1525            (
1526                Mounts::failed("it is not there"),
1527                "it is not there".to_owned(),
1528            ),
1529            (
1530                Mounts::parsed(b""),
1531                format!("its mount, {id}, is not in /proc/self/mountinfo"),
1532            ),
1533            (
1534                Mounts::parsed(line(id, "ext\\9 /dev/sda1 rw").as_bytes()),
1535                "its line in /proc/self/mountinfo is malformed".to_owned(),
1536            ),
1537            (
1538                Mounts::parsed(
1539                    format!("{id} 1 8:1 / /{} rw - ext4 x rw\n", "a".repeat(70_000)).as_bytes(),
1540                ),
1541                "its line in /proc/self/mountinfo is longer than 65536 bytes".to_owned(),
1542            ),
1543            (
1544                Mounts::parsed((line(id, "ext4 a rw") + &line(id, "ext4 b rw")).as_bytes()),
1545                format!("its mount, {id}, is listed twice in /proc/self/mountinfo"),
1546            ),
1547        ] {
1548            assert_eq!(
1549                told(&mounts),
1550                Some(format!("one that could not be told: {reason}"))
1551            );
1552        }
1553        // Without /proc, mounts that could not be read are named as the
1554        // cause, before a mount ID that fdinfo, in /proc too, cannot give;
1555        // and where only fdinfo cannot be read, that is.
1556        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/nonexistent/fdinfo")));
1557        let without_proc = told(&Mounts::failed("it is not there"));
1558        let without_fdinfo = told(&real);
1559        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1560        assert_eq!(
1561            without_proc.as_deref(),
1562            Some("one that could not be told: it is not there")
1563        );
1564        let without_fdinfo = without_fdinfo.unwrap();
1565        assert!(
1566            without_fdinfo.starts_with(
1567                "one that could not be told: statx gives no mount ID, and /proc/self/fdinfo could not be read ("
1568            ),
1569            "{without_fdinfo}"
1570        );
1571        // A hostile line for another mount changes nothing for this one.
1572        let found = real.type_of(id).unwrap().escape_ascii().to_string();
1573        let mut table = line(id + 1, "fuse.\u{7f} x rw").into_bytes();
1574        table.extend(format!("{} 1 8:1 / /mnt rw - fuse.", id + 2).as_bytes());
1575        table.extend([0xFF, b' ', b'x', b'\n']);
1576        table.extend(line(id, &format!("{found} x rw")).as_bytes());
1577        table.extend(line(id + 3, &"\\040".repeat(400)).as_bytes());
1578        assert_eq!(told(&Mounts::parsed(&table)), told(&real));
1579    }
1580
1581    #[cfg(target_os = "linux")]
1582    #[test]
1583    fn mountinfo_is_read_a_line_at_a_time() {
1584        use super::mounts::scan;
1585        let text = b"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n\
158636 22 0:32 / /mnt/with\\040space rw - 9p host rw,trans=virtio\n\
158737 22 0:33 / /mnt/\xff\xfe rw master:2 shared:3 - fuse.s\\134sh\xff host rw\n\
158838 22 8:1 / / rw ext4 /dev/sda1 rw\n\
1589x 1 8:1 / / rw - ext4 /dev/sda1 rw\n\
159039 22 8:1 / / rw -\n\
159140 22 8:1 / / rw - ext\\9 /dev/sda1 rw\n\
159241 22 8:1 / / rw - xfs a rw";
1593        // A small buffer, so lines are read across its edges.
1594        let mounts = scan(std::io::BufReader::with_capacity(7, &text[..])).unwrap();
1595        assert_eq!(mounts[&22].as_deref(), Ok(&b"ext4"[..]));
1596        assert_eq!(mounts[&36].as_deref(), Ok(&b"9p"[..]));
1597        assert_eq!(mounts[&37].as_deref(), Ok(&b"fuse.s\\sh\xff"[..]));
1598        assert_eq!(mounts[&41].as_deref(), Ok(&b"xfs"[..]));
1599        for id in [38, 39, 40] {
1600            assert!(mounts[&id].is_err(), "{id}");
1601        }
1602        // A line without a mount ID is left out.
1603        assert_eq!(mounts.len(), 7);
1604        // What follows the first 64 KiB of a long line is skipped to its
1605        // newline, even where it reads as a line of its own, right at the
1606        // cut or a buffer later, wherever the reader's buffer ends:
1607        // Mounts::read reads through an 8 KiB one (BufReader::new).
1608        for gap in [0, 8 << 10] {
1609            let mut long = b"22 1 8:1 / /".to_vec();
1610            long.resize((64 << 10) + gap, b'a');
1611            long.extend(b"99 1 8:1 / / rw - ext4 x rw\n23 1 8:1 / / rw - xfs a rw\n");
1612            for capacity in [4 << 10, 8 << 10, 64 << 10, 1 << 20] {
1613                let reader = std::io::BufReader::with_capacity(capacity, &long[..]);
1614                let mounts = scan(reader).unwrap();
1615                let at = format!("gap {gap}, buffer {capacity}");
1616                assert!(
1617                    mounts[&22].as_ref().unwrap_err().contains("longer than"),
1618                    "{at}"
1619                );
1620                assert_eq!(mounts[&23].as_deref(), Ok(&b"xfs"[..]), "{at}");
1621                assert_eq!(mounts.keys().copied().collect::<Vec<_>>(), [22, 23], "{at}");
1622            }
1623        }
1624    }
1625
1626    #[cfg(target_os = "linux")]
1627    #[test]
1628    fn fdinfo_gives_the_mount_statx_does() {
1629        use rustix::fs::{AtFlags, StatxFlags, statx};
1630        use std::os::fd::AsRawFd as _;
1631        if !std::path::Path::new("/proc/self/fdinfo").is_dir() {
1632            return;
1633        }
1634        let directory = tree();
1635        let file = fs::File::open(directory.path().join("dir")).unwrap();
1636        // Before Linux 4.11, or where seccomp blocks it, there is no statx,
1637        // and fdinfo is the only source.
1638        let from_statx = match statx(&file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) {
1639            Ok(found) if found.stx_mask & StatxFlags::MNT_ID.bits() != 0 => Some(found.stx_mnt_id),
1640            Ok(_) | Err(rustix::io::Errno::NOSYS) => None,
1641            Err(errno) => panic!("{errno}"),
1642        };
1643        let fdinfo = super::mounts::fdinfo_mount_id("/proc/self/fdinfo", file.as_raw_fd()).unwrap();
1644        assert!(fdinfo.is_some());
1645        if from_statx.is_some() {
1646            assert_eq!(fdinfo, from_statx);
1647        }
1648        // A directory's mount ID comes from fdinfo where statx gives none,
1649        // as before Linux 5.8.
1650        let root = super::beneath::Root::new(directory.path());
1651        let dir = root.directory(&["dir"], false).unwrap();
1652        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(Some("/proc/self/fdinfo")));
1653        let fallback = dir.mount_id();
1654        super::beneath::NO_STATX_MOUNT_ID.with(|no| no.set(None));
1655        let fallback = fallback.unwrap().unwrap();
1656        assert_eq!(Some(fallback), fdinfo);
1657        if let Some(id) = from_statx {
1658            assert_eq!(fallback, id);
1659        }
1660    }
1661
1662    #[test]
1663    fn the_lock_excludes_a_second_holder() {
1664        let directory = tree();
1665        let root = super::beneath::Root::new(directory.path());
1666        let other = super::beneath::Root::new(directory.path());
1667        let held = root.lock().unwrap();
1668        assert!(held.is_some());
1669        assert!(other.lock().unwrap().is_none());
1670        drop(held);
1671        assert!(other.lock().unwrap().is_some());
1672    }
1673}
1674
1675#[cfg(all(test, target_os = "linux"))]
1676mod selinux_tests {
1677    use super::beneath::selinux::{request, trimmed};
1678
1679    #[test]
1680    fn label_questions_are_one_line_with_the_name_encoded() {
1681        let query = request(
1682            b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023\0",
1683            b"unconfined_u:object_r:user_home_t:s0\0",
1684            7,
1685            "0_0.jsonc",
1686        )
1687        .unwrap();
1688        assert_eq!(
1689            query,
1690            b"unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 unconfined_u:object_r:user_home_t:s0 7 0_0.jsonc"
1691        );
1692        let odd = request(b"a", b"b", 1, "x y%+\u{e9}").unwrap();
1693        assert_eq!(odd, b"a b 1 x%20y%25%2b%c3%a9");
1694        assert!(request(b"a b", b"c", 1, "x").is_err());
1695        assert!(request(b"", b"c", 1, "x").is_err());
1696        assert_eq!(trimmed(b"label\0\n"), b"label");
1697        assert_eq!(trimmed(b"\0"), b"");
1698    }
1699}
1700
1701/// On Windows the path-based implementation refuses every reparse point,
1702/// junctions included, at the component where it occurs, removes one
1703/// without touching what it names, and holds its lock in a file.
1704#[cfg(all(test, windows))]
1705mod windows_tests {
1706    use super::{Found, RawKind, Refused, portable::Root};
1707    use std::{
1708        fs,
1709        path::{Path, PathBuf},
1710        process::{Command, Stdio},
1711    };
1712
1713    /// Makes `link` a junction to the directory `target`.
1714    fn junction(link: &Path, target: &Path) {
1715        // `mklink` takes `/` for an option, so the paths use `\\`.
1716        let native = |path: &Path| path.components().collect::<PathBuf>();
1717        let status = Command::new("cmd")
1718            .args(["/C", "mklink", "/J"])
1719            .arg(native(link))
1720            .arg(native(target))
1721            .stdout(Stdio::null())
1722            .status()
1723            .unwrap();
1724        assert!(status.success());
1725    }
1726
1727    fn link<T>(result: Result<T, Refused>) -> String {
1728        match result {
1729            Err(Refused::Link(prefix)) => prefix,
1730            Err(other) => panic!("expected a link, got {other:?}"),
1731            Ok(_) => panic!("expected a link, got success"),
1732        }
1733    }
1734
1735    #[test]
1736    fn junctions_are_links() {
1737        let directory = tempfile::tempdir().unwrap();
1738        fs::create_dir(directory.path().join("dir")).unwrap();
1739        fs::write(directory.path().join("dir/file.jsonc"), "{}").unwrap();
1740        junction(
1741            &directory.path().join("link"),
1742            &directory.path().join("dir"),
1743        );
1744        let root = Root::new(directory.path());
1745        assert_eq!(root.file(&["dir", "file.jsonc"]).unwrap().1, 2);
1746        assert_eq!(link(root.file(&["link", "file.jsonc"])), "link");
1747        assert_eq!(link(root.kind(&["link"])), "link");
1748        assert_eq!(link(root.directory(&["link"], false)), "link");
1749        assert_eq!(link(root.directory(&["link", "inside"], true)), "link");
1750        assert!(!directory.path().join("dir/inside").exists());
1751        let kinds: Vec<(Option<String>, RawKind)> = root
1752            .list(&[])
1753            .unwrap()
1754            .map(|entry| entry.map(|entry| (entry.name, entry.kind)))
1755            .collect::<Result<_, _>>()
1756            .unwrap();
1757        assert!(kinds.contains(&(Some("link".to_owned()), RawKind::Link)));
1758        assert!(kinds.contains(&(Some("dir".to_owned()), RawKind::Directory)));
1759
1760        // A write over a junction replaces it, not what it names.
1761        let base = root.directory(&[], false).unwrap();
1762        base.write("link", None, b"x").unwrap();
1763        assert!(directory.path().join("dir/file.jsonc").exists());
1764        assert_eq!(root.kind(&["link"]).unwrap(), Found::File);
1765    }
1766
1767    #[test]
1768    fn read_only_files_are_refused() {
1769        let directory = tempfile::tempdir().unwrap();
1770        let file = directory.path().join("file.jsonc");
1771        fs::write(&file, "{}").unwrap();
1772        let base = Root::new(directory.path()).directory(&[], false).unwrap();
1773        assert!(base.access("file.jsonc").unwrap().is_some());
1774        let mut permissions = fs::metadata(&file).unwrap().permissions();
1775        permissions.set_readonly(true);
1776        fs::set_permissions(&file, permissions).unwrap();
1777        assert!(matches!(base.access("file.jsonc"), Err(Refused::Io(_))));
1778    }
1779
1780    #[test]
1781    fn encrypted_files_and_directories_are_refused() {
1782        let directory = tempfile::tempdir().unwrap();
1783        let inside = directory.path().join("inside");
1784        fs::create_dir(&inside).unwrap();
1785        fs::write(inside.join("file.jsonc"), "{}").unwrap();
1786        // EFS is not on every volume; where it is not, there is nothing to
1787        // refuse.
1788        let encrypted = Command::new("cipher")
1789            .arg("/e")
1790            .arg("/s:".to_owned() + &inside.display().to_string())
1791            .stdout(Stdio::null())
1792            .status()
1793            .is_ok_and(|status| status.success());
1794        let root = Root::new(directory.path());
1795        let dir = root.directory(&["inside"], false).unwrap();
1796        // The hosted runners have it, so there the test must not pass
1797        // without it.
1798        assert!(
1799            encrypted || std::env::var_os("CI").is_none(),
1800            "cipher /e failed on CI"
1801        );
1802        if encrypted {
1803            assert!(matches!(dir.access("file.jsonc"), Err(Refused::Io(_))));
1804            assert!(matches!(dir.own_access(), Err(Refused::Io(_))));
1805        } else {
1806            assert!(dir.access("file.jsonc").unwrap().is_some());
1807        }
1808    }
1809
1810    #[test]
1811    fn the_lock_is_a_file_held_alone() {
1812        let directory = tempfile::tempdir().unwrap();
1813        let lock_file = directory.path().join(crate::access::LOCK_NAME);
1814        let root = Root::new(directory.path());
1815        let other = Root::new(directory.path());
1816        let held = root.lock().unwrap();
1817        assert!(held.is_some());
1818        assert!(other.lock().unwrap().is_none());
1819        // A file renamed into the root may replace another meanwhile.
1820        let base = root.directory(&[], false).unwrap();
1821        base.write(".world.jsonc.tilewright-tmp", None, b"{}")
1822            .unwrap();
1823        base.write("world.jsonc", None, b"old").unwrap();
1824        base.rename(".world.jsonc.tilewright-tmp", "world.jsonc")
1825            .unwrap();
1826        // The lock file goes with the lock.
1827        drop(held);
1828        assert!(!lock_file.exists());
1829        assert!(other.lock().unwrap().is_some());
1830        // One that no edit holds is not taken over.
1831        fs::write(&lock_file, "someone's").unwrap();
1832        assert!(matches!(root.lock(), Err(Refused::Io(_))));
1833        assert_eq!(fs::read_to_string(&lock_file).unwrap(), "someone's");
1834    }
1835
1836    /// Everything about an entry a create must leave as it was, not
1837    /// following it: attributes, size, times, and a file's bytes or a
1838    /// directory's entries (a reparse point's metadata alone).
1839    fn snapshot(path: &Path) -> String {
1840        use std::os::windows::fs::MetadataExt as _;
1841        let metadata = fs::symlink_metadata(path).unwrap();
1842        let reparse = metadata.file_attributes() & 0x400 != 0;
1843        let content = if reparse {
1844            String::new()
1845        } else if metadata.is_dir() {
1846            let mut names: Vec<_> = fs::read_dir(path)
1847                .unwrap()
1848                .map(|entry| entry.unwrap().file_name())
1849                .collect();
1850            names.sort();
1851            format!("{names:?}")
1852        } else {
1853            format!("{:?}", fs::read(path).unwrap())
1854        };
1855        format!(
1856            "{:x} {} {} {} {content}",
1857            metadata.file_attributes(),
1858            metadata.file_size(),
1859            metadata.creation_time(),
1860            metadata.last_write_time(),
1861        )
1862    }
1863
1864    fn already_there(result: Result<(), Refused>) -> bool {
1865        type Inner = dyn std::error::Error + Send + Sync;
1866        match result {
1867            Err(Refused::Io(error)) => {
1868                error.kind() == std::io::ErrorKind::AlreadyExists
1869                    && error
1870                        .get_ref()
1871                        .is_some_and(Inner::is::<crate::access::AlreadyThere>)
1872            }
1873            other => panic!("expected something already there, got {other:?}"),
1874        }
1875    }
1876
1877    fn hook(hook: impl FnMut() -> std::io::Result<()> + 'static) {
1878        super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
1879    }
1880
1881    fn unhook() {
1882        super::AFTER_CREATE.with(|slot| *slot.borrow_mut() = None);
1883    }
1884
1885    #[test]
1886    fn a_create_never_replaces_or_removes_an_entry() {
1887        let directory = tempfile::tempdir().unwrap();
1888        let outside = tempfile::tempdir().unwrap();
1889        let at = |name: &str| directory.path().join(name);
1890        let root = Root::new(directory.path());
1891        let base = root.directory(&[], false).unwrap();
1892
1893        // A new file has exactly the bytes asked for, and read-only access
1894        // makes it read-only.
1895        base.create("new", None, b"bytes").unwrap();
1896        assert_eq!(fs::read(at("new")).unwrap(), b"bytes");
1897        let read_only = crate::Access {
1898            mode: 0o444,
1899            user: 0,
1900            group: 0,
1901            control: [0; 32],
1902        };
1903        base.create("read-only", Some(&read_only), b"x").unwrap();
1904        assert!(
1905            fs::metadata(at("read-only"))
1906                .unwrap()
1907                .permissions()
1908                .readonly()
1909        );
1910
1911        // Nothing at the name is removed, replaced or followed: a file,
1912        // directories empty or not, a junction to a directory outside, a
1913        // junction to nothing, and a symbolic link to a file outside.
1914        fs::write(at("file"), "keep").unwrap();
1915        fs::create_dir(at("empty")).unwrap();
1916        fs::create_dir(at("full")).unwrap();
1917        fs::write(at("full/inside"), "kept").unwrap();
1918        fs::create_dir(outside.path().join("elsewhere")).unwrap();
1919        junction(&at("junction"), &outside.path().join("elsewhere"));
1920        fs::create_dir(outside.path().join("gone")).unwrap();
1921        junction(&at("dangling"), &outside.path().join("gone"));
1922        fs::remove_dir(outside.path().join("gone")).unwrap();
1923        let target = outside.path().join("target");
1924        fs::write(&target, "outside").unwrap();
1925        let mut names = vec![
1926            "new",
1927            "read-only",
1928            "file",
1929            "empty",
1930            "full",
1931            "junction",
1932            "dangling",
1933        ];
1934        // Symbolic links need a privilege the hosted runners have.
1935        let linked = std::os::windows::fs::symlink_file(&target, at("to-file")).is_ok();
1936        assert!(
1937            linked || std::env::var_os("CI").is_none(),
1938            "symlink_file failed on CI"
1939        );
1940        if linked {
1941            names.push("to-file");
1942        }
1943        let before: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1944        for name in &names {
1945            assert!(already_there(base.create(name, None, b"x")), "{name}");
1946            assert!(
1947                already_there(base.create(name, Some(&read_only), b"x")),
1948                "{name}"
1949            );
1950        }
1951        let after: Vec<String> = names.iter().map(|name| snapshot(&at(name))).collect();
1952        assert_eq!(before, after);
1953        assert_eq!(fs::read_to_string(&target).unwrap(), "outside");
1954        assert!(!outside.path().join("gone").exists());
1955        assert_eq!(
1956            fs::read_dir(outside.path().join("elsewhere"))
1957                .unwrap()
1958                .count(),
1959            0
1960        );
1961
1962        // A create that fails after making its file never removes it, and
1963        // says it may be left.
1964        hook(|| Err(std::io::ErrorKind::StorageFull.into()));
1965        let failed = base.create("failed", None, b"x");
1966        unhook();
1967        let Err(Refused::Io(error)) = failed else {
1968            panic!("expected a failure, got {failed:?}");
1969        };
1970        assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
1971        assert!(error.to_string().contains("may be left"), "{error}");
1972        assert!(at("failed").exists());
1973        // Nor what another process put at the name meanwhile.
1974        let (moved, name) = (at("moved"), at("swapped"));
1975        hook(move || {
1976            fs::rename(&name, &moved)?;
1977            fs::write(&name, "someone else's")?;
1978            Err(std::io::ErrorKind::StorageFull.into())
1979        });
1980        let failed = base.create("swapped", None, b"x");
1981        unhook();
1982        assert!(matches!(failed, Err(Refused::Io(_))), "{failed:?}");
1983        assert_eq!(fs::read_to_string(at("swapped")).unwrap(), "someone else's");
1984        assert!(at("moved").exists());
1985    }
1986
1987    /// An open that fails says the name is taken only when something is at
1988    /// it: with an error other than "already exists", and with the directory
1989    /// removed since it was opened.
1990    #[test]
1991    fn a_create_that_cannot_open_is_taken_only_if_something_is_there() {
1992        type Inner = dyn std::error::Error + Send + Sync;
1993        let directory = tempfile::tempdir().unwrap();
1994        let at = |name: &str| directory.path().join(name);
1995        let root = Root::new(directory.path());
1996        let base = root.directory(&[], false).unwrap();
1997        fs::write(at("file"), "keep").unwrap();
1998        super::OPEN_FAILS.with(|fails| fails.set(Some(std::io::ErrorKind::PermissionDenied)));
1999        let free = base.create("free", None, b"x");
2000        let held = base.create("file", None, b"x");
2001        super::OPEN_FAILS.with(|fails| fails.set(None));
2002        let taken = |result: &Result<(), Refused>| {
2003            matches!(result, Err(Refused::Io(error))
2004                if error.get_ref().is_some_and(Inner::is::<crate::access::AlreadyThere>))
2005        };
2006        assert!(
2007            matches!(&free, Err(Refused::Io(_))) && !taken(&free),
2008            "{free:?}"
2009        );
2010        assert!(taken(&held), "{held:?}");
2011        fs::create_dir(at("gone")).unwrap();
2012        let gone = root.directory(&["gone"], false).unwrap();
2013        fs::remove_dir(at("gone")).unwrap();
2014        let missing = gone.create("x", None, b"x");
2015        assert!(missing.is_err() && !taken(&missing), "{missing:?}");
2016    }
2017}