Skip to main content

rightkit_fs/
contain.rs

1//! Path containment, one module: is a path inside a root, resolve a relative path under a root, and
2//! open a file under a root without following links.
3//!
4//! - [`is_within`]: is an absolute path the root or below it? (rightkit#452)
5//! - [`contain`] / [`contain_lenient`]: resolve a caller-supplied relative path under a root and refuse
6//!   any escape. (rightkit#373)
7//! - [`Roots`] and `open_confined` (feature `fs`): a set of approved folders, a resolver that follows
8//!   links but refuses to leave them, and a no-follow open with a handle-path recheck. (rightkit#400,
9//!   with the hard-link and sharing options of rightkit#613)
10//!
11//! All of them resolve through one function, so they agree on the edge cases: symlinks are followed with
12//! [`canonicalize_plain`] (a Windows verbatim `\\?\C:` root and a plain `C:` root are equal, and
13//! `\\?\UNC\` is folded to `\\`); a path that does not exist yet is resolved through its deepest existing
14//! ancestor with the missing tail appended; an existing entry that cannot be resolved (a dangling or
15//! looping link) is an error and never counts as absent, so a dangling link cannot be used to create a
16//! file outside the root; a `..` in the missing part is refused. Containment compares whole components,
17//! never a string prefix (`/data/app2` is not within `/data/app`), compares non-UTF-8 names byte for
18//! byte (never through a lossy conversion that could make two different names equal), and ignores case
19//! on Windows and macOS only when the filesystem agrees: a path that differs from the root by case
20//! alone is inside only if the root and the same-length prefix of the path are the same directory
21//! (device and file index), so a case-sensitive APFS volume or a per-directory case flag cannot grant
22//! access to a sibling that differs by case. Without feature `fs` a case-only difference is refused. Unicode normalization differences (a macOS name
23//! stored in NFD and requested in NFC) compare unequal, which errs toward refusing.
24//!
25//! # What is refused before the disk is touched
26//!
27//! A relative path where an absolute one is needed, an absolute or rooted path where a relative one is
28//! needed, any `..` component, a NUL byte, and (on Windows) a drive-relative or UNC prefix in a relative
29//! path, a reserved device name (`CON`, `NUL`, `COM1`, ...: opening `root\NUL` opens the device, not a
30//! file below the root), an alternate-data-stream name (`a:stream`), a name with a Windows-illegal
31//! character, and a trailing dot or space (Windows drops it, so the name would address another entry).
32//!
33//! # What is and is not defended
34//!
35//! [`is_within`], [`contain`], [`contain_lenient`] and [`Roots::resolve_confined`] are a check at one
36//! moment: another process can swap a directory for a symlink between the check and a later open.
37//! `open_confined` and `Roots::open_confined` close that race. On Unix they open the root, then each
38//! component with `openat(O_NOFOLLOW)`, so no link can be followed at any step; a [`Roots`] set also
39//! compares the opened root's device and inode with the ones recorded when the root was added, so a root
40//! swapped after approval is refused. On Windows each directory is checked with `symlink_metadata`, the
41//! leaf is opened with `FILE_FLAG_OPEN_REPARSE_POINT` (the link itself, never its target). Every
42//! directory from the root down is first opened as a held handle without `FILE_SHARE_DELETE`, so it
43//! cannot be renamed or swapped for a junction until the leaf is open, and each handle's final path
44//! must be below the root before anything is created. The leaf is opened without truncation, its
45//! handle path and link count are checked, and only then is it truncated; on Unix the same order
46//! applies (`O_TRUNC` is never passed), so a refused open never destroys data. A hard link to a file outside the root is indistinguishable
47//! from a file inside it unless `ConfinedOptions::require_single_link` is set. Use `crate::StateDir`
48//! where the directory itself must be pinned for writes.
49
50use crate::plain::{canonicalize_plain, plain_path};
51use std::ffi::{OsStr, OsString};
52use std::fmt;
53use std::io;
54use std::path::{Component, Path, PathBuf};
55
56/// Why a path was refused or could not be resolved.
57#[derive(Debug)]
58#[non_exhaustive]
59pub enum ContainError {
60    /// `rel` is empty, absolute, has a root or drive prefix, has a `..` component or a NUL byte, or (on
61    /// Windows) a name Windows cannot use as a plain component; or a path ends in `..` or has a `..` in
62    /// its not-yet-existing part.
63    InvalidRelative,
64    /// An absolute path was required (a [`Roots`] method, or a root given to [`Roots::add`]).
65    NotAbsolute,
66    /// The resolved path is not inside the resolved root (a symlink or junction leads out, the path
67    /// is outside every approved root, or an approved root was replaced after it was approved).
68    Escapes,
69    /// A no-follow open met a symbolic link or reparse point (anywhere, even one that points inside).
70    Symlink,
71    /// A confined open found something that is not a regular file (a directory, a FIFO, a device).
72    NotAFile,
73    /// A confined open with `ConfinedOptions::require_single_link` found a file with more than one
74    /// hard link.
75    HardLinked,
76    /// The root, the path, or an entry on the way could not be resolved. This includes a symlink that
77    /// dangles or loops: its target is unknown, so it is never trusted.
78    Io(io::Error),
79}
80
81impl fmt::Display for ContainError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::InvalidRelative => f.write_str("the path is not a plain relative path"),
85            Self::NotAbsolute => f.write_str("the path is not absolute"),
86            Self::Escapes => f.write_str("the path resolves outside the root"),
87            Self::Symlink => f.write_str("the path passes through a symbolic link"),
88            Self::NotAFile => f.write_str("the path is not a regular file"),
89            Self::HardLinked => f.write_str("the file has more than one hard link"),
90            Self::Io(e) => write!(f, "the path cannot be resolved: {e}"),
91        }
92    }
93}
94
95impl std::error::Error for ContainError {
96    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
97        match self {
98            Self::Io(e) => Some(e),
99            _ => None,
100        }
101    }
102}
103
104impl From<io::Error> for ContainError {
105    fn from(e: io::Error) -> Self {
106        Self::Io(e)
107    }
108}
109
110/// An `io::Error` of the kind a `std::io` caller expects: `InvalidInput` for a malformed path or a
111/// link, `PermissionDenied` for an escape or a hard link; an [`ContainError::Io`] keeps its own error.
112impl From<ContainError> for io::Error {
113    fn from(e: ContainError) -> Self {
114        if let ContainError::Io(inner) = e {
115            return inner;
116        }
117        let kind = match &e {
118            ContainError::InvalidRelative
119            | ContainError::NotAbsolute
120            | ContainError::Symlink
121            | ContainError::NotAFile => io::ErrorKind::InvalidInput,
122            ContainError::Escapes | ContainError::HardLinked => io::ErrorKind::PermissionDenied,
123            ContainError::Io(_) => io::ErrorKind::Other,
124        };
125        io::Error::new(kind, e)
126    }
127}
128
129// ---------------------------------------------------------------------------------------------
130// Resolution and comparison (shared by everything below)
131// ---------------------------------------------------------------------------------------------
132
133/// True when a name that does not exist yet could not be created as a plain component here: on
134/// Windows a reserved device name, a stream name, an illegal character, a trailing dot or space, or a
135/// name that is not valid Unicode. Always false elsewhere.
136fn unusable_new_name(name: &OsStr) -> bool {
137    cfg!(windows)
138        && name
139            .to_str()
140            .is_none_or(crate::names::is_unsafe_windows_component)
141}
142
143/// `InvalidRelative` when a `..` follows a component that does not exist. Decided from the
144/// components of the original path, before the OS collapses `..` lexically (Windows does, so
145/// `missing\..` would otherwise resolve to the parent and look contained).
146fn refuse_dot_dot_in_missing_part(path: &Path) -> Result<(), ContainError> {
147    let mut cur = PathBuf::new();
148    let mut exists = true;
149    for component in path.components() {
150        match component {
151            Component::ParentDir if !exists => return Err(ContainError::InvalidRelative),
152            Component::ParentDir => cur.push(".."),
153            other => {
154                cur.push(other.as_os_str());
155                if exists && matches!(other, Component::Normal(_)) {
156                    exists = std::fs::symlink_metadata(&cur).is_ok();
157                }
158            }
159        }
160    }
161    Ok(())
162}
163
164/// The real path of the deepest existing ancestor with the not-yet-existing tail appended.
165///
166/// `InvalidRelative` when the missing tail holds (or the path ends in) `..`, or (Windows) a name that
167/// cannot be a plain component; `Io` when an existing entry cannot be resolved (a dangling or looping
168/// link, or a file used as a directory).
169fn resolve_lenient(path: &Path) -> Result<PathBuf, ContainError> {
170    refuse_dot_dot_in_missing_part(path)?;
171    let mut tail: Vec<OsString> = Vec::new();
172    let mut head = path.to_path_buf();
173    loop {
174        let e = match canonicalize_plain(&head) {
175            Ok(mut real) => {
176                // Names below `real` are only a tail if `real` can hold them. Windows reports a
177                // file used as a directory as "not found", so the failed resolve above looked like
178                // an absent tail; check the existing prefix is a directory.
179                if !tail.is_empty() && !real.is_dir() {
180                    return Err(ContainError::Io(io::Error::new(
181                        io::ErrorKind::InvalidInput,
182                        "a path component is not a directory",
183                    )));
184                }
185                real.extend(tail.iter().rev());
186                return Ok(plain_path(real));
187            }
188            Err(e) => e,
189        };
190        // NotFound from canonicalize also describes a dangling link; only a name that is really
191        // absent may be treated as the not-yet-created tail.
192        match std::fs::symlink_metadata(&head) {
193            Ok(_) => return Err(ContainError::Io(e)),
194            Err(m) if m.kind() == io::ErrorKind::NotFound => {}
195            Err(m) => return Err(ContainError::Io(m)),
196        }
197        // `file_name` is `None` for a trailing `..` and for a root, so neither is skipped over.
198        match head.file_name() {
199            Some(name) if unusable_new_name(name) => return Err(ContainError::InvalidRelative),
200            Some(name) => tail.push(name.to_os_string()),
201            None if head.components().next_back() == Some(Component::ParentDir) => {
202                return Err(ContainError::InvalidRelative)
203            }
204            None => return Err(ContainError::Io(e)),
205        }
206        if !head.pop() {
207            return Err(ContainError::Io(e));
208        }
209    }
210}
211
212/// True when `path` resolves to `root` or below, with every existing ancestor of both resolved
213/// through links (the tail that does not exist yet is appended). Unlike [`is_within`] a path that
214/// cannot be resolved is an error, never "not within", so a caller refusing a descendant fails closed.
215#[cfg(feature = "fs")]
216pub(crate) fn resolved_within(root: &Path, path: &Path) -> Result<bool, ContainError> {
217    let root = resolve_lenient(root)?;
218    let path = resolve_lenient(path)?;
219    Ok(is_below(&root, &path))
220}
221
222/// `\\?\UNC\server\share\x` and `\\server\share\x` name the same place; fold the first to the second.
223fn fold_unc(text: &str) -> String {
224    match text.strip_prefix(r"\\?\UNC\") {
225        Some(rest) => format!(r"\\{rest}"),
226        None => text.to_string(),
227    }
228}
229
230#[cfg(any(windows, target_vendor = "apple"))]
231const CASE_INSENSITIVE: bool = true;
232#[cfg(not(any(windows, target_vendor = "apple")))]
233const CASE_INSENSITIVE: bool = false;
234
235/// Components as comparable names. A name that is valid Unicode is lowercased when `fold_case` is set;
236/// a name that is not is kept exactly, so two different byte strings never compare equal through a
237/// lossy conversion.
238fn comparable(path: &Path, fold_case: bool) -> Vec<OsString> {
239    let folded: PathBuf = match path.to_str() {
240        Some(text) => PathBuf::from(fold_unc(text)),
241        None => path.to_path_buf(),
242    };
243    folded
244        .components()
245        .map(|c| {
246            let name = c.as_os_str();
247            match name.to_str() {
248                Some(text) if fold_case => OsString::from(text.to_lowercase()),
249                _ => name.to_os_string(),
250            }
251        })
252        .collect()
253}
254
255/// Whole-component prefix test on resolved paths, with the case rule given.
256fn is_below_with(root: &Path, path: &Path, fold_case: bool) -> bool {
257    let (root, path) = (comparable(root, fold_case), comparable(path, fold_case));
258    path.len() >= root.len() && path[..root.len()] == root[..]
259}
260
261#[cfg(feature = "fs")]
262/// `path` with a verbatim UNC prefix folded to the plain UNC form, as the comparison sees it.
263fn normalized(path: &Path) -> PathBuf {
264    match path.to_str() {
265        Some(text) => PathBuf::from(fold_unc(text)),
266        None => path.to_path_buf(),
267    }
268}
269
270/// Whole-component prefix test on resolved paths, decided per filesystem.
271///
272/// An exact (case-sensitive) component match is always "below". Where the platform's default volumes
273/// are case-insensitive (Windows, Apple) a match that holds only after case folding is a candidate,
274/// never an answer: it counts only when the root and the same-length prefix of `path` are the very
275/// same directory (device and file index), which a case-sensitive volume or a per-directory case flag
276/// refuses (`/safe/Root` and `/safe/root` are two directories there). Without feature `fs` there is no
277/// identity check, so a case-only difference is refused.
278fn is_below(root: &Path, path: &Path) -> bool {
279    if is_below_with(root, path, false) {
280        return true;
281    }
282    if !CASE_INSENSITIVE || !is_below_with(root, path, true) {
283        return false;
284    }
285    same_directory_prefix(root, path)
286}
287
288#[cfg(feature = "fs")]
289fn same_directory_prefix(root: &Path, path: &Path) -> bool {
290    let count = normalized(root).components().count();
291    let prefix: PathBuf = normalized(path).components().take(count).collect();
292    match (
293        crate::file_key::FileKey::of_path(root),
294        crate::file_key::FileKey::of_path(&prefix),
295    ) {
296        (Ok(a), Ok(b)) => a == b,
297        _ => false,
298    }
299}
300
301#[cfg(not(feature = "fs"))]
302fn same_directory_prefix(_root: &Path, _path: &Path) -> bool {
303    false
304}
305
306fn inside(root: &Path, resolved: PathBuf) -> Result<PathBuf, ContainError> {
307    if is_below(root, &resolved) {
308        Ok(resolved)
309    } else {
310        Err(ContainError::Escapes)
311    }
312}
313
314/// `rel` must be a non-empty relative path of plain names; returns those names.
315fn plain_components(rel: &Path) -> Result<Vec<&OsStr>, ContainError> {
316    if rel.as_os_str().as_encoded_bytes().contains(&0) {
317        return Err(ContainError::InvalidRelative);
318    }
319    let mut names = Vec::new();
320    for component in rel.components() {
321        match component {
322            Component::Normal(name) => {
323                if unusable_new_name(name) {
324                    return Err(ContainError::InvalidRelative);
325                }
326                names.push(name);
327            }
328            Component::CurDir => {}
329            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
330                return Err(ContainError::InvalidRelative)
331            }
332        }
333    }
334    if names.is_empty() {
335        return Err(ContainError::InvalidRelative);
336    }
337    Ok(names)
338}
339
340// ---------------------------------------------------------------------------------------------
341// is_within, contain, contain_lenient
342// ---------------------------------------------------------------------------------------------
343
344/// True when `path` is `root` or lies below it. Both must be absolute, otherwise the answer is
345/// `false`.
346///
347/// Refusals, all `false`: a relative `root` or `path`; a `..` component in the part of either path
348/// that does not exist (it cannot be resolved without guessing); an entry that exists but cannot be
349/// resolved (a dangling or looping symlink), so a dangling link cannot smuggle a path outside; a NUL
350/// byte; on Windows a not-yet-existing name that is a reserved device name or otherwise unusable.
351///
352/// ```
353/// let dir = std::env::temp_dir();
354/// assert!(rightkit_fs::is_within(&dir, &dir.join("backups").join("a.db")));
355/// assert!(!rightkit_fs::is_within(&dir.join("backups"), &dir.join("backups2")));
356/// ```
357pub fn is_within(root: &Path, path: &Path) -> bool {
358    if !root.is_absolute() || !path.is_absolute() {
359        return false;
360    }
361    match (resolve_lenient(root), resolve_lenient(path)) {
362        (Ok(root), Ok(path)) => is_below(&root, &path),
363        _ => false,
364    }
365}
366
367/// Resolve `root/rel` through symlinks and return its canonical path, which must exist and lie inside
368/// `root`. `root` must exist. `rel` must be non-empty plain names (`.` is ignored; `..`, a root, a
369/// drive or UNC prefix and a NUL byte are refused). Errors: [`ContainError::InvalidRelative`],
370/// [`ContainError::Escapes`], [`ContainError::Io`] (including a missing path).
371pub fn contain(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
372    let names = plain_components(rel.as_ref())?;
373    let root = canonicalize_plain(root).map_err(ContainError::Io)?;
374    let mut joined = root.clone();
375    joined.extend(names);
376    let full = canonicalize_plain(&joined).map_err(ContainError::Io)?;
377    inside(&root, full)
378}
379
380/// As [`contain`], but the tail of the path may not exist yet. The deepest existing ancestor of
381/// `root/rel` is resolved through symlinks and must lie inside `root`; the names below it (which do not
382/// exist, so cannot be links) are appended unchanged. An existing entry that cannot be resolved, such as
383/// a dangling symlink, is an error, never treated as absent.
384pub fn contain_lenient(root: &Path, rel: impl AsRef<Path>) -> Result<PathBuf, ContainError> {
385    let names = plain_components(rel.as_ref())?;
386    let root = canonicalize_plain(root).map_err(ContainError::Io)?;
387    let mut head = root.clone();
388    head.extend(names);
389    inside(&root, resolve_lenient(&head)?)
390}
391
392// ---------------------------------------------------------------------------------------------
393// Roots: a set of approved folders (rightkit#400)
394// ---------------------------------------------------------------------------------------------
395
396/// A set of approved folders. Every path given to its methods must be absolute and must end up inside
397/// one of them, or it is refused. Roots are resolved when added, so a root given through a symlink or
398/// with a different case compares correctly.
399///
400/// [`Roots::resolve_confined`] follows links and refuses to leave the roots. With feature `fs`,
401/// `Roots::open_confined` additionally never follows a link at open time.
402#[derive(Debug, Clone, Default)]
403pub struct Roots {
404    roots: Vec<PathBuf>,
405    /// Device and inode of each root when it was added, parallel to `roots` (Unix opens compare it).
406    #[cfg(feature = "fs")]
407    keys: Vec<Option<crate::file_key::FileKey>>,
408}
409
410impl Roots {
411    /// No approved folder yet; every path is refused until one is added.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// A set built from `roots`. Each must be an absolute path to an existing directory.
417    pub fn with_roots<I, P>(roots: I) -> Result<Self, ContainError>
418    where
419        I: IntoIterator<Item = P>,
420        P: AsRef<Path>,
421    {
422        let mut set = Self::new();
423        for root in roots {
424            set.add(root)?;
425        }
426        Ok(set)
427    }
428
429    /// Approve one more folder. It must be absolute and an existing directory; it is stored resolved.
430    pub fn add(&mut self, root: impl AsRef<Path>) -> Result<(), ContainError> {
431        let root = root.as_ref();
432        if !root.is_absolute() {
433            return Err(ContainError::NotAbsolute);
434        }
435        let real = canonicalize_plain(root)?;
436        if !std::fs::metadata(&real)?.is_dir() {
437            return Err(ContainError::Io(io::Error::new(
438                io::ErrorKind::InvalidInput,
439                "an approved root must be a directory",
440            )));
441        }
442        if !self.roots.contains(&real) {
443            #[cfg(feature = "fs")]
444            self.keys
445                .push(crate::file_key::FileKey::of_path(&real).ok());
446            self.roots.push(real);
447        }
448        Ok(())
449    }
450
451    /// The approved folders, resolved.
452    pub fn roots(&self) -> &[PathBuf] {
453        &self.roots
454    }
455
456    /// True when no folder is approved.
457    pub fn is_empty(&self) -> bool {
458        self.roots.is_empty()
459    }
460
461    /// [`is_within`] against any approved folder.
462    pub fn contains(&self, path: &Path) -> bool {
463        path.is_absolute() && self.resolve_confined_lenient(path).is_ok()
464    }
465
466    /// The resolved path of `path`, which must exist, following links, and lie inside an approved
467    /// folder. Errors: [`ContainError::NotAbsolute`], [`ContainError::Escapes`], [`ContainError::Io`].
468    /// A check at one moment; use `Roots::open_confined` (feature `fs`) to read or write.
469    pub fn resolve_confined(&self, path: &Path) -> Result<PathBuf, ContainError> {
470        if !path.is_absolute() {
471            return Err(ContainError::NotAbsolute);
472        }
473        self.within_any(canonicalize_plain(path)?)
474    }
475
476    /// As [`Roots::resolve_confined`], but the tail of `path` may not exist yet (a file about to be
477    /// created). The deepest existing ancestor is resolved and must be inside an approved folder.
478    pub fn resolve_confined_lenient(&self, path: &Path) -> Result<PathBuf, ContainError> {
479        if !path.is_absolute() {
480            return Err(ContainError::NotAbsolute);
481        }
482        self.within_any(resolve_lenient(path)?)
483    }
484
485    fn within_any(&self, resolved: PathBuf) -> Result<PathBuf, ContainError> {
486        match self.roots.iter().find(|root| is_below(root, &resolved)) {
487            Some(_) => Ok(resolved),
488            None => Err(ContainError::Escapes),
489        }
490    }
491
492    /// The index of the approved root holding `path` and the plain names from that root to it. Links
493    /// in the parent directories are followed (and must resolve inside the root); the final name is
494    /// never resolved, so a link there is met by the open and refused.
495    #[cfg(feature = "fs")]
496    fn locate(&self, path: &Path) -> Result<(usize, Vec<OsString>), ContainError> {
497        if !path.is_absolute() {
498            return Err(ContainError::NotAbsolute);
499        }
500        if path.as_os_str().as_encoded_bytes().contains(&0) {
501            return Err(ContainError::InvalidRelative);
502        }
503        let name = match path.components().next_back() {
504            Some(Component::Normal(name)) => name.to_os_string(),
505            _ => return Err(ContainError::InvalidRelative),
506        };
507        if unusable_new_name(&name) {
508            return Err(ContainError::InvalidRelative);
509        }
510        let parent = path.parent().ok_or(ContainError::InvalidRelative)?;
511        let parent = resolve_lenient(parent)?;
512        let index = self
513            .roots
514            .iter()
515            .position(|root| is_below(root, &parent))
516            .ok_or(ContainError::Escapes)?;
517        let skip = self.roots[index].components().count();
518        let mut names: Vec<OsString> = parent
519            .components()
520            .skip(skip)
521            .map(|c| c.as_os_str().to_os_string())
522            .collect();
523        names.push(name);
524        Ok((index, names))
525    }
526
527    /// Open `path` inside an approved folder without following a link at open time. See
528    /// [`open_confined`] for the guarantee. Errors: [`ContainError::NotAbsolute`],
529    /// [`ContainError::InvalidRelative`] (the path ends in `..`, holds a NUL byte, or has a name
530    /// Windows cannot use), [`ContainError::Escapes`] (also: the approved root was replaced after it
531    /// was approved, Unix), [`ContainError::Symlink`], [`ContainError::NotAFile`],
532    /// [`ContainError::HardLinked`], [`ContainError::Io`].
533    #[cfg(feature = "fs")]
534    pub fn open_confined(
535        &self,
536        path: &Path,
537        options: &ConfinedOptions,
538    ) -> Result<std::fs::File, ContainError> {
539        let (index, names) = self.locate(path)?;
540        open_strict(&self.roots[index], &names, options, self.keys[index])
541    }
542}
543
544// ---------------------------------------------------------------------------------------------
545// No-follow open (feature `fs`)
546// ---------------------------------------------------------------------------------------------
547
548/// How [`open_confined`] opens the file: the std `OpenOptions` flags that matter, plus the checks of
549/// [`crate::handle::ReadOptions`], as a plain value. With no access mode set, the open is refused as
550/// `InvalidInput`. A new file is created owner-only (`0600`) on Unix, with default access on Windows.
551#[cfg(feature = "fs")]
552#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
553pub struct ConfinedOptions {
554    read: bool,
555    write: bool,
556    append: bool,
557    create: bool,
558    create_new: bool,
559    truncate: bool,
560    require_single_link: bool,
561    share_read_only: bool,
562}
563
564#[cfg(feature = "fs")]
565impl ConfinedOptions {
566    /// Read-only.
567    pub fn read_only() -> Self {
568        Self::default().read(true)
569    }
570    /// Open for reading.
571    pub fn read(mut self, yes: bool) -> Self {
572        self.read = yes;
573        self
574    }
575    /// Open for writing.
576    pub fn write(mut self, yes: bool) -> Self {
577        self.write = yes;
578        self
579    }
580    /// Open for appending.
581    pub fn append(mut self, yes: bool) -> Self {
582        self.append = yes;
583        self
584    }
585    /// Create the file when missing (needs `write` or `append`).
586    pub fn create(mut self, yes: bool) -> Self {
587        self.create = yes;
588        self
589    }
590    /// Create the file and fail when it exists (needs `write` or `append`).
591    pub fn create_new(mut self, yes: bool) -> Self {
592        self.create_new = yes;
593        self
594    }
595    /// Truncate to empty on open (needs `write`).
596    pub fn truncate(mut self, yes: bool) -> Self {
597        self.truncate = yes;
598        self
599    }
600    /// Refuse a file with more than one hard link ([`ContainError::HardLinked`]), checked on the
601    /// opened handle. For a read of a file the folder's owner did not make, where a hard link could
602    /// name a file outside the folder.
603    pub fn require_single_link(mut self, yes: bool) -> Self {
604        self.require_single_link = yes;
605        self
606    }
607    /// Windows: open with `FILE_SHARE_READ` only (a read-only open; see
608    /// [`crate::handle::ReadOptions::share_read_only`]). Ignored on Unix.
609    pub fn share_read_only(mut self, yes: bool) -> Self {
610        self.share_read_only = yes;
611        self
612    }
613
614    fn check(&self) -> Result<(), ContainError> {
615        let bad = |why: &'static str| {
616            Err(ContainError::Io(io::Error::new(
617                io::ErrorKind::InvalidInput,
618                why,
619            )))
620        };
621        if !(self.read || self.write || self.append) {
622            return bad("no access mode set");
623        }
624        if (self.create || self.create_new) && !(self.write || self.append) {
625            return bad("create needs write or append");
626        }
627        if self.truncate && !self.write {
628            return bad("truncate needs write");
629        }
630        if self.share_read_only && (self.write || self.append) {
631            return bad("share_read_only is for a read-only open");
632        }
633        Ok(())
634    }
635}
636
637/// Open `root/rel` without following a link, and return the file. `root` must exist; `rel` must be
638/// non-empty plain names (as for [`contain`]). A symbolic link or reparse point at any step, including
639/// one that points inside the root, is [`ContainError::Symlink`]. Something that is not a regular file
640/// is [`ContainError::NotAFile`] (a FIFO is never waited on). Other errors: `InvalidRelative`,
641/// `Escapes` (Windows handle-path recheck), `HardLinked`, `Io` (including a missing parent, and a
642/// missing file unless `create` is set).
643///
644/// Unlike [`contain_lenient`] followed by an open, nothing can be swapped for a link between the check
645/// and the open (see the module docs for the per-platform mechanism).
646#[cfg(feature = "fs")]
647pub fn open_confined(
648    root: &Path,
649    rel: impl AsRef<Path>,
650    options: &ConfinedOptions,
651) -> Result<std::fs::File, ContainError> {
652    let names: Vec<OsString> = plain_components(rel.as_ref())?
653        .into_iter()
654        .map(OsStr::to_os_string)
655        .collect();
656    let root = canonicalize_plain(root)?;
657    // No recorded identity for a root given per call; the Unix open still refuses a root that is a
658    // link and the walk below it never follows one.
659    open_strict(&root, &names, options, None)
660}
661
662#[cfg(all(feature = "fs", unix))]
663fn open_strict(
664    root: &Path,
665    names: &[OsString],
666    options: &ConfinedOptions,
667    expected_root: Option<crate::file_key::FileKey>,
668) -> Result<std::fs::File, ContainError> {
669    use std::ffi::CString;
670    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
671    use std::os::unix::ffi::OsStrExt;
672    use std::os::unix::fs::MetadataExt;
673
674    options.check()?;
675    let cstr = |bytes: &[u8]| {
676        CString::new(bytes).map_err(|_| {
677            ContainError::Io(io::Error::new(
678                io::ErrorKind::InvalidInput,
679                "path contains a NUL byte",
680            ))
681        })
682    };
683    let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
684
685    let root_c = cstr(root.as_os_str().as_bytes())?;
686    // O_NOFOLLOW: the resolved root has no link in it, so a link here means it was swapped since.
687    // SAFETY: `root_c` is a valid NUL-terminated string.
688    let fd = unsafe {
689        libc::open(
690            root_c.as_ptr(),
691            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
692        )
693    };
694    if fd < 0 {
695        let e = io::Error::last_os_error();
696        return Err(if e.raw_os_error() == Some(libc::ELOOP) {
697            ContainError::Symlink
698        } else {
699            ContainError::Io(e)
700        });
701    }
702    // SAFETY: `fd` is a fresh descriptor this function owns.
703    let mut dir = unsafe { OwnedFd::from_raw_fd(fd) };
704    if let Some(expected) = expected_root {
705        // SAFETY: zeroed `stat` is a valid out-parameter and `dir` is an open descriptor.
706        let mut st: libc::stat = unsafe { std::mem::zeroed() };
707        if unsafe { libc::fstat(dir.as_raw_fd(), &mut st) } != 0 {
708            return Err(ContainError::Io(io::Error::last_os_error()));
709        }
710        if st.st_dev as u64 != expected.volume || st.st_ino as u64 != expected.index {
711            return Err(ContainError::Escapes);
712        }
713    }
714
715    // Why a component failed: a link is reported as `Symlink`, anything else as the OS error.
716    let refuse = |dir: &OwnedFd, name: &std::ffi::CStr, err: io::Error| -> ContainError {
717        // SAFETY: zeroed `stat` is a valid out-parameter; `dir` and `name` are valid.
718        let mut st: libc::stat = unsafe { std::mem::zeroed() };
719        let rc = unsafe {
720            libc::fstatat(
721                dir.as_raw_fd(),
722                name.as_ptr(),
723                &mut st,
724                libc::AT_SYMLINK_NOFOLLOW,
725            )
726        };
727        if rc == 0 && (st.st_mode & libc::S_IFMT) == libc::S_IFLNK {
728            ContainError::Symlink
729        } else {
730            ContainError::Io(err)
731        }
732    };
733
734    for name in parents {
735        let c = cstr(name.as_bytes())?;
736        // SAFETY: `dir` is a live directory descriptor and `c` a valid string.
737        let fd = unsafe {
738            libc::openat(
739                dir.as_raw_fd(),
740                c.as_ptr(),
741                libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
742            )
743        };
744        if fd < 0 {
745            return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
746        }
747        // SAFETY: fresh descriptor owned here; the previous `dir` is dropped (closed) on assignment.
748        dir = unsafe { OwnedFd::from_raw_fd(fd) };
749    }
750
751    let c = cstr(last.as_bytes())?;
752    let mut flags = match (options.read, options.write || options.append) {
753        (true, false) => libc::O_RDONLY,
754        (false, true) => libc::O_WRONLY,
755        _ => libc::O_RDWR,
756    };
757    // O_NONBLOCK: a FIFO or device planted at the name must not hang the open. It is cleared again
758    // below once the handle is known to be a regular file.
759    flags |= libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK;
760    if options.append {
761        flags |= libc::O_APPEND;
762    }
763    if options.create_new {
764        flags |= libc::O_CREAT | libc::O_EXCL;
765    } else if options.create {
766        flags |= libc::O_CREAT;
767    }
768    // O_TRUNC is deliberately absent: truncation is destructive, so it happens only after the
769    // handle is proven to be a regular file with an acceptable link count (see below).
770    // SAFETY: `dir` is a live directory descriptor, `c` a valid string; the mode is read only with O_CREAT.
771    let fd = unsafe { libc::openat(dir.as_raw_fd(), c.as_ptr(), flags, 0o600 as libc::c_uint) };
772    if fd < 0 {
773        return Err(refuse(&dir, c.as_c_str(), io::Error::last_os_error()));
774    }
775    // SAFETY: fresh descriptor owned here.
776    let file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
777    let meta = file.metadata()?;
778    if !meta.is_file() {
779        return Err(ContainError::NotAFile);
780    }
781    crate::handle::clear_nonblock(&file)?;
782    if options.require_single_link && meta.nlink() != 1 {
783        return Err(ContainError::HardLinked);
784    }
785    if options.truncate {
786        file.set_len(0)?;
787    }
788    Ok(file)
789}
790
791#[cfg(all(feature = "fs", windows))]
792fn open_strict(
793    root: &Path,
794    names: &[OsString],
795    options: &ConfinedOptions,
796    _expected_root: Option<crate::file_key::FileKey>,
797) -> Result<std::fs::File, ContainError> {
798    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
799
800    /// Open the reparse point itself instead of following it.
801    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
802    /// Lets `CreateFileW` open a directory.
803    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
804    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
805    const FILE_SHARE_READ: u32 = 0x1;
806    /// A pinned directory: readers and writers may share it, but nobody may rename or delete it (no
807    /// `FILE_SHARE_DELETE`), so it cannot be replaced by a junction while the handle is held.
808    const FILE_SHARE_PIN: u32 = 0x3;
809    const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x7;
810
811    options.check()?;
812    let (last, parents) = names.split_last().ok_or(ContainError::InvalidRelative)?;
813
814    // Every directory from the root down is opened (never created) as a reparse-point-aware handle
815    // and held until the leaf is open. A held directory cannot be renamed, deleted or swapped for a
816    // junction, so the leaf path below cannot be redirected, and nothing is created or truncated
817    // until every ancestor is proven to be a real directory below the root.
818    let pin = |path: &Path| -> Result<std::fs::File, ContainError> {
819        let dir = std::fs::OpenOptions::new()
820            .read(true)
821            .share_mode(FILE_SHARE_PIN)
822            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
823            .open(path)?;
824        let meta = dir.metadata()?;
825        if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
826            return Err(ContainError::Symlink);
827        }
828        if !meta.is_dir() {
829            return Err(ContainError::Io(io::Error::new(
830                io::ErrorKind::NotADirectory,
831                "a path component is not a directory",
832            )));
833        }
834        if !is_below(root, &crate::handle::final_path(&dir)?) {
835            return Err(ContainError::Escapes);
836        }
837        Ok(dir)
838    };
839    let mut pins = vec![pin(root)?];
840    let mut cur = root.to_path_buf();
841    for name in parents {
842        cur.push(name);
843        pins.push(pin(&cur)?);
844    }
845    cur.push(last);
846    // No truncation here: it is applied only after the handle is validated.
847    let file = std::fs::OpenOptions::new()
848        .read(options.read)
849        .write(options.write)
850        .append(options.append)
851        .create(options.create)
852        .create_new(options.create_new)
853        .share_mode(if options.share_read_only {
854            FILE_SHARE_READ
855        } else {
856            FILE_SHARE_READ_WRITE_DELETE
857        })
858        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
859        .open(&cur)?;
860    let meta = file.metadata()?;
861    if meta.file_type().is_symlink() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
862        return Err(ContainError::Symlink);
863    }
864    if !meta.is_file() {
865        return Err(ContainError::NotAFile);
866    }
867    // The handle-path recheck: wherever the open really landed must still be below the root.
868    let real = crate::handle::final_path(&file)?;
869    if !is_below(root, &real) {
870        return Err(ContainError::Escapes);
871    }
872    if options.require_single_link && crate::handle::link_count(&file)? != 1 {
873        return Err(ContainError::HardLinked);
874    }
875    if options.truncate {
876        file.set_len(0)?;
877    }
878    drop(pins);
879    Ok(file)
880}
881
882#[cfg(all(feature = "fs", not(any(unix, windows))))]
883fn open_strict(
884    _root: &Path,
885    _names: &[OsString],
886    _options: &ConfinedOptions,
887    _expected_root: Option<crate::file_key::FileKey>,
888) -> Result<std::fs::File, ContainError> {
889    Err(ContainError::Io(io::Error::new(
890        io::ErrorKind::Unsupported,
891        "a confined open is not available on this platform",
892    )))
893}
894
895#[cfg(test)]
896mod contain_tests {
897    use super::*;
898    use std::fs;
899
900    fn tree() -> tempfile::TempDir {
901        let dir = tempfile::tempdir().unwrap();
902        fs::create_dir_all(dir.path().join("root/sub")).unwrap();
903        fs::write(dir.path().join("root/sub/a.txt"), b"a").unwrap();
904        fs::create_dir_all(dir.path().join("outside")).unwrap();
905        fs::write(dir.path().join("outside/secret.txt"), b"s").unwrap();
906        dir
907    }
908
909    #[test]
910    fn existing_file_resolves_inside() {
911        let t = tree();
912        let root = t.path().join("root");
913        let got = contain(&root, "sub/a.txt").unwrap();
914        assert_eq!(got, canonicalize_plain(root.join("sub/a.txt")).unwrap());
915        assert_eq!(contain(&root, "./sub/./a.txt").unwrap(), got);
916    }
917
918    #[test]
919    fn invalid_relative_forms_are_refused() {
920        let t = tree();
921        let root = t.path().join("root");
922        for rel in [
923            "",
924            ".",
925            "../outside/secret.txt",
926            "sub/../../outside",
927            "sub/..",
928            "/etc/passwd",
929            "a\0b",
930            "sub/a.txt\0",
931        ] {
932            assert!(
933                matches!(contain(&root, rel), Err(ContainError::InvalidRelative)),
934                "{rel:?}"
935            );
936            assert!(
937                matches!(
938                    contain_lenient(&root, rel),
939                    Err(ContainError::InvalidRelative)
940                ),
941                "{rel:?}"
942            );
943        }
944    }
945
946    #[test]
947    fn an_absolute_rel_path_pointing_inside_the_root_is_still_refused() {
948        let t = tree();
949        let root = t.path().join("root");
950        let inside = root.join("sub/a.txt");
951        assert!(matches!(
952            contain(&root, &inside),
953            Err(ContainError::InvalidRelative)
954        ));
955    }
956
957    #[test]
958    fn missing_leaf_is_io_for_strict_and_ok_for_lenient() {
959        let t = tree();
960        let root = t.path().join("root");
961        assert!(matches!(
962            contain(&root, "sub/new.txt"),
963            Err(ContainError::Io(_))
964        ));
965        let got = contain_lenient(&root, "sub/new/deeper/new.txt").unwrap();
966        let base = canonicalize_plain(root.join("sub")).unwrap();
967        assert_eq!(got, base.join("new").join("deeper").join("new.txt"));
968        assert_eq!(
969            contain_lenient(&root, "sub/a.txt").unwrap(),
970            contain(&root, "sub/a.txt").unwrap()
971        );
972    }
973
974    #[test]
975    fn missing_root_is_io() {
976        let t = tree();
977        assert!(matches!(
978            contain(&t.path().join("nope"), "a"),
979            Err(ContainError::Io(_))
980        ));
981        assert!(matches!(
982            contain_lenient(&t.path().join("nope"), "a"),
983            Err(ContainError::Io(_))
984        ));
985    }
986
987    #[test]
988    fn file_in_the_middle_is_an_error() {
989        let t = tree();
990        let root = t.path().join("root");
991        assert!(contain_lenient(&root, "sub/a.txt/x").is_err());
992    }
993
994    #[test]
995    fn windows_name_rules_apply_only_where_windows_applies_them() {
996        let t = tree();
997        let root = t.path().join("root");
998        for rel in [
999            "CON",
1000            "sub/nul.txt",
1001            "sub/COM1",
1002            "a:stream",
1003            "trail.",
1004            "sp ",
1005            "a*b",
1006            "q?",
1007        ] {
1008            let strict = contain_lenient(&root, rel);
1009            if cfg!(windows) {
1010                assert!(
1011                    matches!(strict, Err(ContainError::InvalidRelative)),
1012                    "{rel:?}: {strict:?}"
1013                );
1014            } else if !rel.contains('/') && !rel.contains('\\') {
1015                // Legal file names elsewhere: resolved below the root.
1016                assert!(strict.is_ok(), "{rel:?}: {strict:?}");
1017            }
1018        }
1019        // The pure predicate is the same everywhere.
1020        for rel in ["CON", "nul.txt", "COM1", "a:stream", "trail.", "sp ", "a*b"] {
1021            assert!(crate::names::is_unsafe_windows_component(rel), "{rel:?}");
1022        }
1023    }
1024
1025    #[test]
1026    fn a_not_yet_existing_name_that_windows_cannot_use_is_refused_by_is_within() {
1027        let t = tree();
1028        let root = t.path().join("root");
1029        let device = root.join("sub").join("NUL");
1030        assert_eq!(is_within(&root, &device), !cfg!(windows));
1031        let stream = root.join("sub").join("a.txt:stream");
1032        assert_eq!(is_within(&root, &stream), !cfg!(windows));
1033    }
1034
1035    #[cfg(unix)]
1036    mod unix {
1037        use super::*;
1038        use std::os::unix::fs::symlink;
1039
1040        #[test]
1041        fn symlink_out_is_an_escape() {
1042            let t = tree();
1043            let root = t.path().join("root");
1044            symlink(t.path().join("outside"), root.join("link")).unwrap();
1045            assert!(matches!(
1046                contain(&root, "link/secret.txt"),
1047                Err(ContainError::Escapes)
1048            ));
1049            assert!(matches!(contain(&root, "link"), Err(ContainError::Escapes)));
1050            // A new file below an escaping link is the classic write-through attack.
1051            assert!(matches!(
1052                contain_lenient(&root, "link/new.txt"),
1053                Err(ContainError::Escapes)
1054            ));
1055        }
1056
1057        #[test]
1058        fn symlink_within_root_is_followed() {
1059            let t = tree();
1060            let root = t.path().join("root");
1061            symlink(root.join("sub"), root.join("alias")).unwrap();
1062            let got = contain(&root, "alias/a.txt").unwrap();
1063            assert_eq!(got, canonicalize_plain(root.join("sub/a.txt")).unwrap());
1064        }
1065
1066        #[test]
1067        fn dangling_symlink_is_refused_even_when_lenient() {
1068            let t = tree();
1069            let root = t.path().join("root");
1070            // Points outside to a file that does not exist yet; creating it would write outside.
1071            symlink(t.path().join("outside/not-yet.txt"), root.join("dangling")).unwrap();
1072            assert!(matches!(
1073                contain_lenient(&root, "dangling"),
1074                Err(ContainError::Io(_))
1075            ));
1076            assert!(matches!(
1077                contain_lenient(&root, "dangling/x"),
1078                Err(ContainError::Io(_))
1079            ));
1080            assert!(contain(&root, "dangling").is_err());
1081        }
1082
1083        #[test]
1084        fn symlink_loop_is_refused() {
1085            let t = tree();
1086            let root = t.path().join("root");
1087            symlink(root.join("b"), root.join("a")).unwrap();
1088            symlink(root.join("a"), root.join("b")).unwrap();
1089            assert!(contain_lenient(&root, "a/x").is_err());
1090            assert!(contain(&root, "a").is_err());
1091        }
1092
1093        #[test]
1094        fn symlinked_root_is_resolved() {
1095            let t = tree();
1096            let alias = t.path().join("rootlink");
1097            symlink(t.path().join("root"), &alias).unwrap();
1098            let got = contain(&alias, "sub/a.txt").unwrap();
1099            assert_eq!(
1100                got,
1101                canonicalize_plain(t.path().join("root/sub/a.txt")).unwrap()
1102            );
1103        }
1104
1105        #[test]
1106        fn sibling_sharing_a_name_prefix_is_not_inside() {
1107            // `root` vs `root-evil`: whole components are compared, not bytes.
1108            let t = tree();
1109            let root = t.path().join("root");
1110            fs::create_dir(t.path().join("root-evil")).unwrap();
1111            symlink(t.path().join("root-evil"), root.join("l")).unwrap();
1112            assert!(matches!(
1113                contain_lenient(&root, "l/x"),
1114                Err(ContainError::Escapes)
1115            ));
1116        }
1117
1118        #[test]
1119        fn names_that_differ_only_in_invalid_utf8_are_never_equal() {
1120            use std::os::unix::ffi::OsStringExt;
1121            // Both bytes become U+FFFD under a lossy conversion; they must stay different.
1122            let a = PathBuf::from(OsString::from_vec(b"/data/\xff".to_vec()));
1123            let b = PathBuf::from(OsString::from_vec(b"/data/\xfe".to_vec()));
1124            assert!(!is_below_with(&a, &b, false));
1125            assert!(!is_below_with(&a, &b, true));
1126            assert!(is_below_with(&a, &a.join("x"), true));
1127        }
1128    }
1129
1130    #[test]
1131    fn case_folding_is_applied_only_when_asked() {
1132        let root = Path::new("/Data/App");
1133        let path = Path::new("/data/app/x");
1134        assert!(is_below_with(root, path, true));
1135        assert!(!is_below_with(root, path, false));
1136        assert!(!is_below_with(
1137            Path::new("/data/app"),
1138            Path::new("/data/app2"),
1139            true
1140        ));
1141    }
1142
1143    #[test]
1144    fn verbatim_unc_folds_to_the_plain_unc_form() {
1145        assert_eq!(fold_unc(r"\\?\UNC\srv\share\x"), r"\\srv\share\x");
1146        assert_eq!(fold_unc(r"\\srv\share\x"), r"\\srv\share\x");
1147        assert_eq!(fold_unc(r"\\?\C:\x"), r"\\?\C:\x");
1148    }
1149
1150    #[test]
1151    fn contain_error_converts_to_io_error_kinds() {
1152        let kind = |e: ContainError| io::Error::from(e).kind();
1153        assert_eq!(
1154            kind(ContainError::InvalidRelative),
1155            io::ErrorKind::InvalidInput
1156        );
1157        assert_eq!(kind(ContainError::Escapes), io::ErrorKind::PermissionDenied);
1158        assert_eq!(
1159            kind(ContainError::HardLinked),
1160            io::ErrorKind::PermissionDenied
1161        );
1162        assert_eq!(
1163            kind(ContainError::Io(io::Error::from(io::ErrorKind::NotFound))),
1164            io::ErrorKind::NotFound
1165        );
1166    }
1167}
1168
1169#[cfg(test)]
1170mod within_tests {
1171    use super::*;
1172    use std::fs;
1173
1174    fn root() -> (tempfile::TempDir, PathBuf) {
1175        let tmp = tempfile::tempdir().unwrap();
1176        let root = tmp.path().join("store");
1177        fs::create_dir_all(root.join("sub")).unwrap();
1178        (tmp, root)
1179    }
1180
1181    #[test]
1182    fn same_path_and_children_are_within() {
1183        let (_t, root) = root();
1184        assert!(is_within(&root, &root));
1185        assert!(is_within(&root, &root.join("sub")));
1186        assert!(is_within(&root, &root.join("sub").join("file.db")));
1187    }
1188
1189    #[test]
1190    fn a_missing_tail_is_resolved_through_the_existing_ancestor() {
1191        let (_t, root) = root();
1192        assert!(is_within(
1193            &root,
1194            &root.join("not").join("yet").join("made.db")
1195        ));
1196        assert!(!is_within(&root.join("sub"), &root.join("not").join("yet")));
1197    }
1198
1199    #[test]
1200    fn a_sibling_sharing_a_name_prefix_is_not_within() {
1201        let (t, root) = root();
1202        let sibling = t.path().join("store2");
1203        fs::create_dir_all(&sibling).unwrap();
1204        assert!(!is_within(&root, &sibling));
1205        assert!(!is_within(&root, &sibling.join("x")));
1206        assert!(!is_within(&root, t.path()));
1207    }
1208
1209    #[test]
1210    fn a_dot_dot_in_the_missing_part_is_refused() {
1211        let (t, root) = root();
1212        let escape = root.join("missing").join("..").join("..").join("elsewhere");
1213        assert!(!is_within(&root, &escape));
1214        assert!(!is_within(&root, &root.join("missing").join("..")));
1215        let _ = t;
1216    }
1217
1218    #[test]
1219    fn a_dot_dot_through_existing_directories_is_resolved() {
1220        let (t, root) = root();
1221        assert!(is_within(&root, &root.join("sub").join("..").join("sub")));
1222        assert!(!is_within(&root, &root.join("sub").join("..").join("..")));
1223        let _ = t;
1224    }
1225
1226    #[test]
1227    fn relative_paths_and_nul_bytes_are_refused() {
1228        let (_t, root) = root();
1229        assert!(!is_within(Path::new("store"), &root));
1230        assert!(!is_within(&root, Path::new("store/sub")));
1231        assert!(!is_within(Path::new(""), Path::new("")));
1232        assert!(!is_within(&root, &root.join("a\0b")));
1233    }
1234
1235    #[cfg(any(windows, target_vendor = "apple"))]
1236    #[test]
1237    fn case_differences_are_ignored_on_windows_and_macos() {
1238        let (_t, root) = root();
1239        let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
1240        assert!(is_within(&upper, &root.join("sub")));
1241        assert!(is_within(&root, &upper.join("Sub").join("New.db")));
1242    }
1243
1244    #[cfg(not(any(windows, target_vendor = "apple")))]
1245    #[test]
1246    fn case_matters_elsewhere() {
1247        let (t, root) = root();
1248        let other = t.path().join("STORE");
1249        assert!(!is_within(&root, &other.join("x")));
1250        assert!(!is_within(&other, &root));
1251    }
1252
1253    #[cfg(unix)]
1254    #[test]
1255    fn a_symlink_out_of_the_root_is_not_within_and_into_it_is() {
1256        let (t, root) = root();
1257        let outside = t.path().join("outside");
1258        fs::create_dir_all(&outside).unwrap();
1259        std::os::unix::fs::symlink(&outside, root.join("out")).unwrap();
1260        assert!(!is_within(&root, &root.join("out").join("f.db")));
1261        let link_in = t.path().join("alias");
1262        std::os::unix::fs::symlink(&root, &link_in).unwrap();
1263        assert!(is_within(&root, &link_in.join("sub").join("f.db")));
1264        assert!(is_within(&link_in, &root.join("sub")));
1265    }
1266
1267    #[cfg(unix)]
1268    #[test]
1269    fn a_dangling_symlink_is_refused_not_treated_as_missing() {
1270        let (t, root) = root();
1271        let outside = t.path().join("nowhere");
1272        std::os::unix::fs::symlink(&outside, root.join("dangling")).unwrap();
1273        assert!(!is_within(&root, &root.join("dangling").join("f.db")));
1274        assert!(!is_within(&root, &root.join("dangling")));
1275    }
1276
1277    #[cfg(windows)]
1278    #[test]
1279    fn verbatim_and_plain_drive_paths_compare_equal() {
1280        let (_t, root) = root();
1281        let verbatim = fs::canonicalize(&root).unwrap();
1282        assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
1283        let plain = plain_path(verbatim.clone());
1284        assert!(is_within(&verbatim, &plain.join("sub")));
1285        assert!(is_within(&plain, &verbatim.join("sub").join("new.db")));
1286    }
1287}
1288
1289#[cfg(test)]
1290mod roots_tests {
1291    use super::*;
1292    use std::fs;
1293
1294    fn two_roots() -> (tempfile::TempDir, PathBuf, PathBuf, Roots) {
1295        let t = tempfile::tempdir().unwrap();
1296        let a = t.path().join("a");
1297        let b = t.path().join("b");
1298        fs::create_dir_all(a.join("sub")).unwrap();
1299        fs::create_dir_all(&b).unwrap();
1300        fs::write(a.join("sub/f.txt"), b"f").unwrap();
1301        fs::write(t.path().join("outside.txt"), b"o").unwrap();
1302        let roots = Roots::with_roots([&a, &b]).unwrap();
1303        (t, a, b, roots)
1304    }
1305
1306    #[test]
1307    fn empty_set_refuses_everything() {
1308        let t = tempfile::tempdir().unwrap();
1309        let roots = Roots::new();
1310        assert!(roots.is_empty());
1311        assert!(!roots.contains(t.path()));
1312        assert!(matches!(
1313            roots.resolve_confined(t.path()),
1314            Err(ContainError::Escapes)
1315        ));
1316    }
1317
1318    #[test]
1319    fn add_requires_an_absolute_existing_directory() {
1320        let t = tempfile::tempdir().unwrap();
1321        let mut roots = Roots::new();
1322        assert!(matches!(
1323            roots.add("relative"),
1324            Err(ContainError::NotAbsolute)
1325        ));
1326        assert!(matches!(
1327            roots.add(t.path().join("missing")),
1328            Err(ContainError::Io(_))
1329        ));
1330        fs::write(t.path().join("file"), b"x").unwrap();
1331        assert!(matches!(
1332            roots.add(t.path().join("file")),
1333            Err(ContainError::Io(_))
1334        ));
1335        roots.add(t.path()).unwrap();
1336        roots.add(t.path()).unwrap();
1337        assert_eq!(roots.roots().len(), 1);
1338    }
1339
1340    #[test]
1341    fn paths_under_any_root_resolve_and_others_escape() {
1342        let (t, a, b, roots) = two_roots();
1343        assert!(roots.contains(&a.join("sub/f.txt")));
1344        assert!(roots.contains(&b.join("new/tail.txt")));
1345        assert!(!roots.contains(&t.path().join("outside.txt")));
1346        assert!(!roots.contains(t.path()));
1347        assert_eq!(
1348            roots.resolve_confined(&a.join("sub/f.txt")).unwrap(),
1349            canonicalize_plain(a.join("sub/f.txt")).unwrap()
1350        );
1351        assert!(matches!(
1352            roots.resolve_confined(&t.path().join("outside.txt")),
1353            Err(ContainError::Escapes)
1354        ));
1355        // Strict needs the path to exist; lenient does not.
1356        assert!(matches!(
1357            roots.resolve_confined(&a.join("nope")),
1358            Err(ContainError::Io(_))
1359        ));
1360        assert!(roots
1361            .resolve_confined_lenient(&a.join("nope/deeper.txt"))
1362            .is_ok());
1363    }
1364
1365    #[test]
1366    fn relative_and_dot_dot_paths_are_refused() {
1367        let (_t, a, _b, roots) = two_roots();
1368        assert!(matches!(
1369            roots.resolve_confined(Path::new("sub/f.txt")),
1370            Err(ContainError::NotAbsolute)
1371        ));
1372        let sneaky = a.join("missing").join("..").join("..").join("outside.txt");
1373        assert!(roots.resolve_confined_lenient(&sneaky).is_err());
1374        assert!(!roots.contains(&sneaky));
1375    }
1376
1377    #[test]
1378    fn a_name_prefix_sibling_of_a_root_is_outside() {
1379        let (t, _a, _b, roots) = two_roots();
1380        fs::create_dir_all(t.path().join("a2")).unwrap();
1381        assert!(!roots.contains(&t.path().join("a2").join("x")));
1382    }
1383
1384    #[cfg(unix)]
1385    #[test]
1386    fn a_link_out_escapes_and_a_link_within_is_followed() {
1387        let (t, a, _b, roots) = two_roots();
1388        std::os::unix::fs::symlink(t.path().join("outside.txt"), a.join("out")).unwrap();
1389        std::os::unix::fs::symlink(a.join("sub"), a.join("in")).unwrap();
1390        assert!(matches!(
1391            roots.resolve_confined(&a.join("out")),
1392            Err(ContainError::Escapes)
1393        ));
1394        assert!(roots.resolve_confined(&a.join("in/f.txt")).is_ok());
1395        std::os::unix::fs::symlink(t.path().join("gone"), a.join("dangling")).unwrap();
1396        assert!(roots.resolve_confined_lenient(&a.join("dangling")).is_err());
1397    }
1398}
1399
1400#[cfg(all(test, feature = "fs"))]
1401mod confined_tests {
1402    use super::*;
1403    use std::fs;
1404    use std::io::{Read, Write};
1405
1406    fn tree() -> (tempfile::TempDir, PathBuf) {
1407        let t = tempfile::tempdir().unwrap();
1408        let root = t.path().join("root");
1409        fs::create_dir_all(root.join("sub/deeper")).unwrap();
1410        fs::write(root.join("sub/a.txt"), b"alpha").unwrap();
1411        fs::create_dir_all(t.path().join("outside")).unwrap();
1412        fs::write(t.path().join("outside/secret.txt"), b"secret").unwrap();
1413        (t, root)
1414    }
1415
1416    fn read_all(mut f: fs::File) -> String {
1417        let mut s = String::new();
1418        f.read_to_string(&mut s).unwrap();
1419        s
1420    }
1421
1422    #[test]
1423    fn reads_a_file_under_the_root() {
1424        let (_t, root) = tree();
1425        let f = open_confined(&root, "sub/a.txt", &ConfinedOptions::read_only()).unwrap();
1426        assert_eq!(read_all(f), "alpha");
1427        let f = open_confined(&root, "./sub/./a.txt", &ConfinedOptions::read_only()).unwrap();
1428        assert_eq!(read_all(f), "alpha");
1429    }
1430
1431    #[test]
1432    fn invalid_relative_paths_are_refused_before_touching_the_disk() {
1433        let (_t, root) = tree();
1434        for rel in [
1435            "",
1436            ".",
1437            "../outside/secret.txt",
1438            "sub/../../outside/secret.txt",
1439            "/etc/passwd",
1440            "sub/a.txt\0",
1441        ] {
1442            let err = open_confined(&root, rel, &ConfinedOptions::read_only()).unwrap_err();
1443            assert!(
1444                matches!(err, ContainError::InvalidRelative),
1445                "{rel:?}: {err}"
1446            );
1447        }
1448    }
1449
1450    #[test]
1451    fn an_option_set_without_access_is_refused() {
1452        let (_t, root) = tree();
1453        let none = ConfinedOptions::default();
1454        assert!(matches!(
1455            open_confined(&root, "sub/a.txt", &none),
1456            Err(ContainError::Io(_))
1457        ));
1458        let create_read = ConfinedOptions::read_only().create(true);
1459        assert!(matches!(
1460            open_confined(&root, "sub/a.txt", &create_read),
1461            Err(ContainError::Io(_))
1462        ));
1463        let truncate_append = ConfinedOptions::default().append(true).truncate(true);
1464        assert!(matches!(
1465            open_confined(&root, "sub/a.txt", &truncate_append),
1466            Err(ContainError::Io(_))
1467        ));
1468        let share_write = ConfinedOptions::default().write(true).share_read_only(true);
1469        assert!(matches!(
1470            open_confined(&root, "sub/a.txt", &share_write),
1471            Err(ContainError::Io(_))
1472        ));
1473    }
1474
1475    #[test]
1476    fn creates_new_files_and_refuses_to_overwrite_with_create_new() {
1477        let (_t, root) = tree();
1478        let make = ConfinedOptions::default().write(true).create_new(true);
1479        let mut f = open_confined(&root, "sub/deeper/new.txt", &make).unwrap();
1480        f.write_all(b"fresh").unwrap();
1481        drop(f);
1482        assert_eq!(fs::read(root.join("sub/deeper/new.txt")).unwrap(), b"fresh");
1483        let again = open_confined(&root, "sub/deeper/new.txt", &make).unwrap_err();
1484        assert!(
1485            matches!(again, ContainError::Io(ref e) if e.kind() == io::ErrorKind::AlreadyExists)
1486        );
1487    }
1488
1489    #[test]
1490    fn a_missing_parent_or_file_is_io_not_a_silent_create() {
1491        let (_t, root) = tree();
1492        let read = ConfinedOptions::read_only();
1493        assert!(matches!(
1494            open_confined(&root, "nope/a.txt", &read),
1495            Err(ContainError::Io(_))
1496        ));
1497        assert!(matches!(
1498            open_confined(&root, "sub/missing.txt", &read),
1499            Err(ContainError::Io(_))
1500        ));
1501        assert!(!root.join("sub/missing.txt").exists());
1502    }
1503
1504    #[test]
1505    fn a_directory_is_not_a_file() {
1506        let (_t, root) = tree();
1507        let err = open_confined(&root, "sub", &ConfinedOptions::read_only()).unwrap_err();
1508        assert!(
1509            matches!(err, ContainError::NotAFile | ContainError::Io(_)),
1510            "{err}"
1511        );
1512    }
1513
1514    #[test]
1515    fn a_hard_linked_file_is_refused_only_when_a_single_link_is_required() {
1516        let (t, root) = tree();
1517        // A hard link inside the root to a file outside it: the path looks inside, the bytes are not.
1518        fs::hard_link(t.path().join("outside/secret.txt"), root.join("sub/alias")).unwrap();
1519        let plain = ConfinedOptions::read_only();
1520        assert_eq!(
1521            read_all(open_confined(&root, "sub/alias", &plain).unwrap()),
1522            "secret"
1523        );
1524        let strict = ConfinedOptions::read_only().require_single_link(true);
1525        let err = open_confined(&root, "sub/alias", &strict).unwrap_err();
1526        assert!(matches!(err, ContainError::HardLinked), "{err}");
1527        assert_eq!(
1528            read_all(open_confined(&root, "sub/a.txt", &strict).unwrap()),
1529            "alpha"
1530        );
1531    }
1532
1533    #[test]
1534    fn a_refused_hard_link_is_never_truncated() {
1535        let (t, root) = tree();
1536        let outside = t.path().join("outside/secret.txt");
1537        fs::hard_link(&outside, root.join("sub/alias")).unwrap();
1538        let strict = ConfinedOptions::default()
1539            .write(true)
1540            .truncate(true)
1541            .require_single_link(true);
1542        let err = open_confined(&root, "sub/alias", &strict).unwrap_err();
1543        assert!(matches!(err, ContainError::HardLinked), "{err}");
1544        assert_eq!(fs::read(&outside).unwrap(), b"secret");
1545    }
1546
1547    #[test]
1548    fn a_refused_directory_or_non_file_is_never_truncated() {
1549        let (_t, root) = tree();
1550        let trunc = ConfinedOptions::default().write(true).truncate(true);
1551        assert!(open_confined(&root, "sub", &trunc).is_err());
1552        assert_eq!(fs::read(root.join("sub/a.txt")).unwrap(), b"alpha");
1553    }
1554
1555    #[test]
1556    fn truncation_applies_after_validation_when_the_open_is_accepted() {
1557        let (_t, root) = tree();
1558        let trunc = ConfinedOptions::default().write(true).truncate(true);
1559        let f = open_confined(&root, "sub/a.txt", &trunc).unwrap();
1560        drop(f);
1561        assert_eq!(fs::read(root.join("sub/a.txt")).unwrap(), b"");
1562    }
1563
1564    #[test]
1565    fn a_case_only_difference_is_inside_only_when_the_filesystem_agrees() {
1566        let t = tempfile::tempdir().unwrap();
1567        let base = canonicalize_plain(t.path()).unwrap();
1568        fs::create_dir(base.join("root")).unwrap();
1569        let sensitive = fs::create_dir(base.join("Root")).is_ok();
1570        let a = base.join("root");
1571        let b = base.join("Root");
1572        if sensitive {
1573            // Two distinct directories: neither is inside the other.
1574            assert!(!is_below(&a, &b.join("x")));
1575            assert!(!is_below(&b, &a.join("x")));
1576            assert!(!is_within(&a, &b.join("x")));
1577            let roots = Roots::with_roots([&a]).unwrap();
1578            assert!(!roots.contains(&b.join("x")));
1579            assert!(matches!(
1580                roots.open_confined(&b.join("x"), &ConfinedOptions::read_only()),
1581                Err(ContainError::Escapes)
1582            ));
1583        } else if CASE_INSENSITIVE {
1584            assert!(is_below(&a, &b.join("x")));
1585        }
1586        // A folded match to a directory that does not exist is never granted.
1587        assert!(!is_below(&a, &base.join("ROOT2").join("x")));
1588    }
1589
1590    #[test]
1591    fn roots_open_picks_the_matching_root_and_refuses_the_rest() {
1592        let (t, root) = tree();
1593        let other = t.path().join("other");
1594        fs::create_dir_all(&other).unwrap();
1595        fs::write(other.join("o.txt"), b"other").unwrap();
1596        let roots = Roots::with_roots([&root, &other]).unwrap();
1597        let read = ConfinedOptions::read_only();
1598        assert_eq!(
1599            read_all(roots.open_confined(&root.join("sub/a.txt"), &read).unwrap()),
1600            "alpha"
1601        );
1602        assert_eq!(
1603            read_all(roots.open_confined(&other.join("o.txt"), &read).unwrap()),
1604            "other"
1605        );
1606        let err = roots
1607            .open_confined(&t.path().join("outside/secret.txt"), &read)
1608            .unwrap_err();
1609        assert!(matches!(err, ContainError::Escapes), "{err}");
1610        let err = roots
1611            .open_confined(Path::new("sub/a.txt"), &read)
1612            .unwrap_err();
1613        assert!(matches!(err, ContainError::NotAbsolute), "{err}");
1614        let err = roots
1615            .open_confined(&root.join("sub").join(".."), &read)
1616            .unwrap_err();
1617        assert!(matches!(err, ContainError::InvalidRelative), "{err}");
1618        let err = roots
1619            .open_confined(&root.join("sub").join("a\0b"), &read)
1620            .unwrap_err();
1621        assert!(matches!(err, ContainError::InvalidRelative), "{err}");
1622    }
1623
1624    #[test]
1625    fn roots_open_can_create_in_an_existing_directory_of_a_root() {
1626        let (_t, root) = tree();
1627        let roots = Roots::with_roots([&root]).unwrap();
1628        let make = ConfinedOptions::default().write(true).create_new(true);
1629        let mut f = roots
1630            .open_confined(&root.join("sub/made.txt"), &make)
1631            .unwrap();
1632        f.write_all(b"made").unwrap();
1633        drop(f);
1634        assert_eq!(fs::read(root.join("sub/made.txt")).unwrap(), b"made");
1635    }
1636
1637    #[cfg(unix)]
1638    mod unix {
1639        use super::*;
1640        use std::os::unix::fs::{symlink, PermissionsExt};
1641
1642        #[test]
1643        fn a_leaf_symlink_is_refused_even_when_it_points_inside() {
1644            let (_t, root) = tree();
1645            symlink(root.join("sub/a.txt"), root.join("inside-link")).unwrap();
1646            let err =
1647                open_confined(&root, "inside-link", &ConfinedOptions::read_only()).unwrap_err();
1648            assert!(matches!(err, ContainError::Symlink), "{err}");
1649        }
1650
1651        #[test]
1652        fn a_leaf_symlink_out_of_the_root_is_refused_and_never_followed() {
1653            let (t, root) = tree();
1654            symlink(t.path().join("outside/secret.txt"), root.join("out-link")).unwrap();
1655            let err = open_confined(&root, "out-link", &ConfinedOptions::read_only()).unwrap_err();
1656            assert!(matches!(err, ContainError::Symlink), "{err}");
1657        }
1658
1659        #[test]
1660        fn a_directory_symlink_on_the_way_is_refused() {
1661            let (t, root) = tree();
1662            symlink(t.path().join("outside"), root.join("dir-link")).unwrap();
1663            let err = open_confined(&root, "dir-link/secret.txt", &ConfinedOptions::read_only())
1664                .unwrap_err();
1665            assert!(matches!(err, ContainError::Symlink), "{err}");
1666            symlink(root.join("sub"), root.join("sub-link")).unwrap();
1667            let err =
1668                open_confined(&root, "sub-link/a.txt", &ConfinedOptions::read_only()).unwrap_err();
1669            assert!(matches!(err, ContainError::Symlink), "{err}");
1670        }
1671
1672        #[test]
1673        fn a_dangling_leaf_link_is_never_created_through() {
1674            let (t, root) = tree();
1675            let target = t.path().join("outside/new.txt");
1676            symlink(&target, root.join("dangling")).unwrap();
1677            let make = ConfinedOptions::default().write(true).create(true);
1678            let err = open_confined(&root, "dangling", &make).unwrap_err();
1679            assert!(matches!(err, ContainError::Symlink), "{err}");
1680            assert!(!target.exists(), "a file was created outside the root");
1681        }
1682
1683        #[test]
1684        fn the_root_itself_may_be_reached_through_a_link() {
1685            let (t, root) = tree();
1686            let alias = t.path().join("alias");
1687            symlink(&root, &alias).unwrap();
1688            let f = open_confined(&alias, "sub/a.txt", &ConfinedOptions::read_only()).unwrap();
1689            assert_eq!(read_all(f), "alpha");
1690        }
1691
1692        #[test]
1693        fn a_created_file_is_owner_only() {
1694            let (_t, root) = tree();
1695            let make = ConfinedOptions::default().write(true).create_new(true);
1696            open_confined(&root, "private.txt", &make).unwrap();
1697            let mode = fs::metadata(root.join("private.txt"))
1698                .unwrap()
1699                .permissions()
1700                .mode();
1701            assert_eq!(mode & 0o077, 0, "mode {mode:o}");
1702        }
1703
1704        #[test]
1705        fn a_fifo_is_refused_and_never_waited_on() {
1706            use std::ffi::CString;
1707            use std::os::unix::ffi::OsStrExt;
1708            let (_t, root) = tree();
1709            let path = root.join("pipe");
1710            let c = CString::new(path.as_os_str().as_bytes()).unwrap();
1711            // SAFETY: valid NUL-terminated path.
1712            assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
1713            let err = open_confined(&root, "pipe", &ConfinedOptions::read_only()).unwrap_err();
1714            assert!(matches!(err, ContainError::NotAFile), "{err}");
1715            let write = ConfinedOptions::default().write(true);
1716            // No reader exists: a blocking open would hang, the non-blocking one fails or is refused.
1717            assert!(open_confined(&root, "pipe", &write).is_err());
1718        }
1719
1720        #[test]
1721        fn roots_open_follows_links_in_parents_that_stay_inside_but_not_the_leaf() {
1722            let (t, root) = tree();
1723            let roots = Roots::with_roots([&root]).unwrap();
1724            symlink(root.join("sub"), root.join("sub-link")).unwrap();
1725            // A parent link that resolves inside the root is resolved before the open.
1726            let f = roots
1727                .open_confined(&root.join("sub-link/a.txt"), &ConfinedOptions::read_only())
1728                .unwrap();
1729            assert_eq!(read_all(f), "alpha");
1730            // A parent link that leaves the root is an escape.
1731            symlink(t.path().join("outside"), root.join("out-dir")).unwrap();
1732            let err = roots
1733                .open_confined(
1734                    &root.join("out-dir/secret.txt"),
1735                    &ConfinedOptions::read_only(),
1736                )
1737                .unwrap_err();
1738            assert!(matches!(err, ContainError::Escapes), "{err}");
1739            // A leaf link is never followed.
1740            symlink(root.join("sub/a.txt"), root.join("leaf")).unwrap();
1741            let err = roots
1742                .open_confined(&root.join("leaf"), &ConfinedOptions::read_only())
1743                .unwrap_err();
1744            assert!(matches!(err, ContainError::Symlink), "{err}");
1745        }
1746
1747        #[test]
1748        fn a_root_replaced_after_approval_is_refused() {
1749            let (t, root) = tree();
1750            let roots = Roots::with_roots([&root]).unwrap();
1751            let read = ConfinedOptions::read_only();
1752            assert!(roots.open_confined(&root.join("sub/a.txt"), &read).is_ok());
1753            // Swap the approved directory for a different one at the same path.
1754            let moved = t.path().join("moved");
1755            fs::rename(&root, &moved).unwrap();
1756            fs::create_dir_all(root.join("sub")).unwrap();
1757            fs::write(root.join("sub/a.txt"), b"impostor").unwrap();
1758            let err = roots
1759                .open_confined(&root.join("sub/a.txt"), &read)
1760                .unwrap_err();
1761            assert!(matches!(err, ContainError::Escapes), "{err}");
1762        }
1763    }
1764
1765    #[cfg(windows)]
1766    mod windows {
1767        use super::*;
1768
1769        #[test]
1770        fn case_differences_in_the_requested_path_open_the_same_file() {
1771            let (_t, root) = tree();
1772            let roots = Roots::with_roots([&root]).unwrap();
1773            let upper = PathBuf::from(
1774                root.join("SUB")
1775                    .join("A.TXT")
1776                    .to_string_lossy()
1777                    .to_uppercase(),
1778            );
1779            let f = roots
1780                .open_confined(&upper, &ConfinedOptions::read_only())
1781                .unwrap();
1782            assert_eq!(read_all(f), "alpha");
1783        }
1784
1785        #[test]
1786        fn a_reserved_device_name_is_refused_before_any_open() {
1787            let (_t, root) = tree();
1788            for rel in ["NUL", "sub/CON", "COM1.txt", "sub/a.txt:stream"] {
1789                let err = open_confined(&root, rel, &ConfinedOptions::read_only()).unwrap_err();
1790                assert!(matches!(err, ContainError::InvalidRelative), "{rel}: {err}");
1791            }
1792        }
1793    }
1794}