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}