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}