Skip to main content

rama_utils/fs/
mod.rs

1//! Filesystem helpers shared by Rama crates.
2//!
3//! Async helpers are the default. Use the `_sync` variants from blocking code.
4//!
5//! These helpers are implemented with `std`/`tokio` path APIs. They reject
6//! lexical traversal and static symlink escapes, but they cannot make path-based
7//! checks race-free if an attacker can concurrently mutate the checked directory.
8//! Use them with roots that are not writable by untrusted actors.
9
10mod sanitize;
11#[doc(inline)]
12pub use sanitize::{
13    UnsafePathError, is_reserved_device_name, sanitize_path, sanitize_relative_path,
14};
15
16use std::{
17    fs, io,
18    path::{Path, PathBuf},
19};
20
21#[cfg(loom)]
22use std::fs::File;
23#[cfg(not(loom))]
24use tokio::fs::File;
25
26/// How symbolic links are treated when opening a file confined to a root
27/// directory via [`OpenOptions::jail`] or [`OpenOptionsSync::jail`].
28///
29/// Symlink handling only applies when a jail root is set; without one there is
30/// no boundary to confine to.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[non_exhaustive]
33pub enum SymlinkPolicy {
34    /// Symlinks may be followed, but the fully resolved path must stay within
35    /// the jail root. A symlink that resolves outside the root is rejected with
36    /// [`UnsafePathError::EscapesRoot`]. This is the default.
37    #[default]
38    RestrictToRoot,
39    /// Symlinks are followed even when they resolve outside the jail root. The
40    /// lexical confinement (no `..`, no absolute paths) still applies, but the
41    /// resolved target is not checked against the root. Opt in only when the
42    /// linked targets are trusted.
43    Allow,
44}
45
46/// Permissions to apply when [`OpenOptions`] or [`OpenOptionsSync`] creates a
47/// new file.
48///
49/// These permissions only affect newly-created files. Existing files keep their
50/// current permissions, matching the behavior of platform open options.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52#[non_exhaustive]
53pub enum CreatedFilePermissions {
54    /// Use platform defaults.
55    #[default]
56    Default,
57    /// Create private files intended for secrets or sensitive diagnostics.
58    ///
59    /// On Unix this creates files with mode `0o600` before the process umask is
60    /// applied. On other platforms the file inherits the platform default ACLs.
61    OwnerReadWrite,
62}
63
64/// Open `path` read-only with path-traversal protection.
65///
66/// Equivalent to `OpenOptions::new().read(true).open(path)`. Rejects `..`
67/// traversal, reserved device names and smuggled path prefixes (see
68/// [`sanitize_path`]). Absolute paths are permitted; use [`safe_open_in`] to
69/// confine the path to a trusted root directory instead.
70///
71/// Path rejection surfaces as [`io::ErrorKind::InvalidInput`].
72pub async fn safe_open(path: impl AsRef<Path>) -> io::Result<File> {
73    OpenOptions::new().read(true).open(path).await
74}
75
76/// Blocking variant of [`safe_open`].
77pub fn safe_open_sync(path: impl AsRef<Path>) -> io::Result<fs::File> {
78    OpenOptionsSync::new().read(true).open(path)
79}
80
81/// Open a file read-only, confined to within the trusted directory `root`.
82///
83/// `path` is treated as relative to `root`; absolute paths, `..` traversal,
84/// reserved device names, smuggled prefixes, and symbolic links that resolve
85/// outside `root` are all rejected. `root` itself must exist.
86///
87/// Equivalent to `OpenOptions::new().read(true).jail(root).open(path)`.
88pub async fn safe_open_in(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<File> {
89    OpenOptions::new()
90        .read(true)
91        .jail(root.as_ref())
92        .open(path)
93        .await
94}
95
96/// Open a file read-only, confined to `root`, given an **absolute** path.
97///
98/// [`safe_open_in`] treats its path as relative to `root` and rejects
99/// absolute ones; this accepts an absolute path (as a `file:` uri yields)
100/// and rejects it when it does not live under `root`.
101pub async fn safe_open_under(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<File> {
102    let (root, path) = (root.as_ref(), path.as_ref());
103    let relative = path.strip_prefix(root).map_err(|_e| {
104        io::Error::new(
105            io::ErrorKind::InvalidInput,
106            "path is outside the confined root",
107        )
108    })?;
109    safe_open_in(root, relative).await
110}
111
112/// Blocking variant of [`safe_open_under`].
113pub fn safe_open_under_sync(
114    root: impl AsRef<Path>,
115    path: impl AsRef<Path>,
116) -> io::Result<fs::File> {
117    let (root, path) = (root.as_ref(), path.as_ref());
118    let relative = path.strip_prefix(root).map_err(|_e| {
119        io::Error::new(
120            io::ErrorKind::InvalidInput,
121            "path is outside the confined root",
122        )
123    })?;
124    safe_open_in_sync(root, relative)
125}
126
127/// Blocking variant of [`safe_open_in`].
128pub fn safe_open_in_sync(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<fs::File> {
129    OpenOptionsSync::new()
130        .read(true)
131        .jail(root.as_ref())
132        .open(path)
133}
134
135/// Resolve `path` below `root` after applying root-confined path validation.
136///
137/// `path` is treated as relative to `root`; absolute paths, `..` traversal,
138/// reserved device names, smuggled prefixes, and symbolic links that resolve
139/// outside `root` are rejected. `root` itself must exist.
140///
141/// The returned [`PathBuf`] is not a capability: if the directory tree is
142/// modified after this function returns, callers must validate again or perform
143/// the filesystem operation through a safe helper.
144pub async fn safe_path_in(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<PathBuf> {
145    let root = root.as_ref();
146    let path = root.join(sanitize_relative_path(path)?);
147    let canonical_root = canonicalize_root(root).await?;
148    ensure_within_canonical_root(&canonical_root, &path).await?;
149    Ok(path)
150}
151
152/// Blocking variant of [`safe_path_in`].
153pub fn safe_path_in_sync(root: impl AsRef<Path>, path: impl AsRef<Path>) -> io::Result<PathBuf> {
154    let root = root.as_ref();
155    let path = root.join(sanitize_relative_path(path)?);
156    let canonical_root = canonicalize_root_sync(root)?;
157    ensure_within_canonical_root_sync(&canonical_root, &path)?;
158    Ok(path)
159}
160
161/// Canonicalize an existing path confined to within the trusted directory `root`.
162///
163/// `path` is treated as relative to `root`; absolute paths, `..` traversal,
164/// reserved device names, smuggled prefixes, and symbolic links that resolve
165/// outside `root` are all rejected. `root` and `path` must exist.
166pub async fn safe_canonicalize_in(
167    root: impl AsRef<Path>,
168    path: impl AsRef<Path>,
169) -> io::Result<PathBuf> {
170    let root = root.as_ref();
171    let path = root.join(sanitize_relative_path(path)?);
172    let canonical_root = canonicalize_root(root).await?;
173    let canonical_path = canonicalize_existing_path(&path).await?;
174    if !canonical_path.starts_with(&canonical_root) {
175        return Err(UnsafePathError::EscapesRoot.into());
176    }
177    Ok(canonical_path)
178}
179
180/// Blocking variant of [`safe_canonicalize_in`].
181pub fn safe_canonicalize_in_sync(
182    root: impl AsRef<Path>,
183    path: impl AsRef<Path>,
184) -> io::Result<PathBuf> {
185    let root = root.as_ref();
186    let path = root.join(sanitize_relative_path(path)?);
187    let canonical_root = canonicalize_root_sync(root)?;
188    let canonical_path = canonicalize_existing_path_sync(&path)?;
189    if !canonical_path.starts_with(&canonical_root) {
190        return Err(UnsafePathError::EscapesRoot.into());
191    }
192    Ok(canonical_path)
193}
194
195/// Create a directory and all missing parents below `root`.
196///
197/// `path` is treated as relative to `root`; absolute paths, `..` traversal,
198/// reserved device names, smuggled prefixes, and symbolic links that resolve
199/// outside `root` are rejected. `root` itself must exist.
200pub async fn safe_create_dir_all_in(
201    root: impl AsRef<Path>,
202    path: impl AsRef<Path>,
203) -> io::Result<()> {
204    let root = root.as_ref();
205    let path = root.join(sanitize_relative_path(path)?);
206    let canonical_root = canonicalize_root(root).await?;
207    ensure_within_canonical_root(&canonical_root, &path).await?;
208    #[cfg(loom)]
209    fs::create_dir_all(&path)?;
210    #[cfg(not(loom))]
211    tokio::fs::create_dir_all(&path).await?;
212    ensure_within_canonical_root(&canonical_root, &path).await
213}
214
215/// Blocking variant of [`safe_create_dir_all_in`].
216pub fn safe_create_dir_all_in_sync(
217    root: impl AsRef<Path>,
218    path: impl AsRef<Path>,
219) -> io::Result<()> {
220    let root = root.as_ref();
221    let path = root.join(sanitize_relative_path(path)?);
222    let canonical_root = canonicalize_root_sync(root)?;
223    ensure_within_canonical_root_sync(&canonical_root, &path)?;
224    fs::create_dir_all(&path)?;
225    ensure_within_canonical_root_sync(&canonical_root, &path)
226}
227
228/// Write `contents` to a file below `root`, creating missing parent
229/// directories.
230///
231/// `path` is treated as relative to `root`; absolute paths, `..` traversal,
232/// reserved device names, smuggled prefixes, and symbolic links that resolve
233/// outside `root` are rejected. `root` itself must exist.
234pub async fn safe_write_in(
235    root: impl AsRef<Path>,
236    path: impl AsRef<Path>,
237    contents: impl AsRef<[u8]>,
238) -> io::Result<()> {
239    let root = root.as_ref();
240    let path = root.join(sanitize_relative_path(path)?);
241    let contents = contents.as_ref().to_owned();
242    let canonical_root = canonicalize_root(root).await?;
243
244    if let Some(parent) = path.parent() {
245        ensure_within_canonical_root(&canonical_root, parent).await?;
246        #[cfg(loom)]
247        fs::create_dir_all(parent)?;
248        #[cfg(not(loom))]
249        tokio::fs::create_dir_all(parent).await?;
250        ensure_within_canonical_root(&canonical_root, parent).await?;
251    }
252
253    ensure_within_canonical_root(&canonical_root, &path).await?;
254    #[cfg(loom)]
255    fs::write(&path, contents)?;
256    #[cfg(not(loom))]
257    tokio::fs::write(&path, contents).await?;
258    ensure_within_canonical_root(&canonical_root, &path).await
259}
260
261/// Blocking variant of [`safe_write_in`].
262pub fn safe_write_in_sync(
263    root: impl AsRef<Path>,
264    path: impl AsRef<Path>,
265    contents: impl AsRef<[u8]>,
266) -> io::Result<()> {
267    let root = root.as_ref();
268    let path = root.join(sanitize_relative_path(path)?);
269    let canonical_root = canonicalize_root_sync(root)?;
270
271    if let Some(parent) = path.parent() {
272        ensure_within_canonical_root_sync(&canonical_root, parent)?;
273        fs::create_dir_all(parent)?;
274        ensure_within_canonical_root_sync(&canonical_root, parent)?;
275    }
276
277    ensure_within_canonical_root_sync(&canonical_root, &path)?;
278    fs::write(&path, contents)?;
279    ensure_within_canonical_root_sync(&canonical_root, &path)
280}
281
282/// Options to open a file with async path-traversal protection.
283///
284/// Mirrors the access-mode setters of [`tokio::fs::OpenOptions`]
285/// (read/write/append/truncate/create/create_new) and adds
286/// [`jail`](Self::jail) to confine every opened path to a trusted root
287/// directory.
288#[derive(Debug, Clone)]
289pub struct OpenOptions {
290    inner: OpenOptionsInner,
291}
292
293impl OpenOptions {
294    /// Create a new set of options with every flag disabled, matching
295    /// [`tokio::fs::OpenOptions::new`]. Enable at least one access mode (e.g.
296    /// [`read`](Self::read)) before calling [`open`](Self::open).
297    #[must_use]
298    pub fn new() -> Self {
299        Self {
300            inner: OpenOptionsInner::new(),
301        }
302    }
303
304    /// Set the option for read access.
305    pub fn read(&mut self, read: bool) -> &mut Self {
306        self.inner.read = read;
307        self
308    }
309
310    /// Set the option for write access.
311    pub fn write(&mut self, write: bool) -> &mut Self {
312        self.inner.write = write;
313        self
314    }
315
316    /// Set the option for append mode.
317    pub fn append(&mut self, append: bool) -> &mut Self {
318        self.inner.append = append;
319        self
320    }
321
322    /// Set the option for truncating a previous file.
323    pub fn truncate(&mut self, truncate: bool) -> &mut Self {
324        self.inner.truncate = truncate;
325        self
326    }
327
328    /// Set the option to create a new file, or open it if it already exists.
329    pub fn create(&mut self, create: bool) -> &mut Self {
330        self.inner.create = create;
331        self
332    }
333
334    /// Set the option to create a new file, failing if it already exists.
335    pub fn create_new(&mut self, create_new: bool) -> &mut Self {
336        self.inner.create_new = create_new;
337        self
338    }
339
340    /// Confine every opened path to within `root`.
341    ///
342    /// The path passed to [`open`](Self::open) is then interpreted as relative
343    /// to `root`; absolute paths are rejected, and the resolved path must remain
344    /// within `root` or the open fails with [`UnsafePathError::EscapesRoot`].
345    /// `root` must exist when opening.
346    pub fn jail(&mut self, root: impl Into<PathBuf>) -> &mut Self {
347        self.inner.jail = Some(root.into());
348        self
349    }
350
351    /// Set how symbolic links are treated within the [`jail`](Self::jail) root.
352    ///
353    /// Defaults to [`SymlinkPolicy::RestrictToRoot`]. Has no effect unless a
354    /// jail root is configured.
355    pub fn symlinks(&mut self, policy: SymlinkPolicy) -> &mut Self {
356        self.inner.symlinks = policy;
357        self
358    }
359
360    /// Set permissions used when this open operation creates a new file.
361    pub fn created_file_permissions(&mut self, permissions: CreatedFilePermissions) -> &mut Self {
362        self.inner.created_file_permissions = permissions;
363        self
364    }
365
366    /// Open the file at `path` with the configured options, after validating it
367    /// against path-traversal attacks.
368    pub async fn open(&self, path: impl AsRef<Path>) -> io::Result<File> {
369        let path = self.inner.resolve(path.as_ref()).await?;
370        #[cfg(loom)]
371        let file = self.inner.std_options().open(&path)?;
372        #[cfg(not(loom))]
373        let file = self.inner.tokio_options().open(&path).await?;
374        if let Some(root) = &self.inner.jail
375            && self.inner.symlinks == SymlinkPolicy::RestrictToRoot
376        {
377            ensure_within_root(root, &path).await?;
378        }
379        Ok(file)
380    }
381}
382
383impl Default for OpenOptions {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389/// Options to open a file with blocking path-traversal protection.
390///
391/// This is the sync counterpart to [`OpenOptions`].
392#[derive(Debug, Clone)]
393pub struct OpenOptionsSync {
394    inner: OpenOptionsInner,
395}
396
397impl OpenOptionsSync {
398    /// Create a new set of options with every flag disabled, matching
399    /// [`std::fs::OpenOptions::new`]. Enable at least one access mode (e.g.
400    /// [`read`](Self::read)) before calling [`open`](Self::open).
401    #[must_use]
402    pub fn new() -> Self {
403        Self {
404            inner: OpenOptionsInner::new(),
405        }
406    }
407
408    /// Set the option for read access.
409    pub fn read(&mut self, read: bool) -> &mut Self {
410        self.inner.read = read;
411        self
412    }
413
414    /// Set the option for write access.
415    pub fn write(&mut self, write: bool) -> &mut Self {
416        self.inner.write = write;
417        self
418    }
419
420    /// Set the option for append mode.
421    pub fn append(&mut self, append: bool) -> &mut Self {
422        self.inner.append = append;
423        self
424    }
425
426    /// Set the option for truncating a previous file.
427    pub fn truncate(&mut self, truncate: bool) -> &mut Self {
428        self.inner.truncate = truncate;
429        self
430    }
431
432    /// Set the option to create a new file, or open it if it already exists.
433    pub fn create(&mut self, create: bool) -> &mut Self {
434        self.inner.create = create;
435        self
436    }
437
438    /// Set the option to create a new file, failing if it already exists.
439    pub fn create_new(&mut self, create_new: bool) -> &mut Self {
440        self.inner.create_new = create_new;
441        self
442    }
443
444    /// Confine every opened path to within `root`.
445    pub fn jail(&mut self, root: impl Into<PathBuf>) -> &mut Self {
446        self.inner.jail = Some(root.into());
447        self
448    }
449
450    /// Set how symbolic links are treated within the [`jail`](Self::jail) root.
451    ///
452    /// Defaults to [`SymlinkPolicy::RestrictToRoot`]. Has no effect unless a
453    /// jail root is configured.
454    pub fn symlinks(&mut self, policy: SymlinkPolicy) -> &mut Self {
455        self.inner.symlinks = policy;
456        self
457    }
458
459    /// Set permissions used when this open operation creates a new file.
460    pub fn created_file_permissions(&mut self, permissions: CreatedFilePermissions) -> &mut Self {
461        self.inner.created_file_permissions = permissions;
462        self
463    }
464
465    /// Open the file at `path` with the configured options, after validating it
466    /// against path-traversal attacks.
467    pub fn open(&self, path: impl AsRef<Path>) -> io::Result<fs::File> {
468        let path = self.inner.resolve_sync(path.as_ref())?;
469        let file = self.inner.std_options().open(&path)?;
470        if let Some(root) = &self.inner.jail
471            && self.inner.symlinks == SymlinkPolicy::RestrictToRoot
472        {
473            ensure_within_root_sync(root, &path)?;
474        }
475        Ok(file)
476    }
477}
478
479impl Default for OpenOptionsSync {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485#[derive(Debug, Clone)]
486struct OpenOptionsInner {
487    read: bool,
488    write: bool,
489    append: bool,
490    truncate: bool,
491    create: bool,
492    create_new: bool,
493    jail: Option<PathBuf>,
494    symlinks: SymlinkPolicy,
495    created_file_permissions: CreatedFilePermissions,
496}
497
498impl OpenOptionsInner {
499    fn new() -> Self {
500        Self {
501            read: false,
502            write: false,
503            append: false,
504            truncate: false,
505            create: false,
506            create_new: false,
507            jail: None,
508            symlinks: SymlinkPolicy::RestrictToRoot,
509            created_file_permissions: CreatedFilePermissions::Default,
510        }
511    }
512
513    #[cfg(not(loom))]
514    fn tokio_options(&self) -> tokio::fs::OpenOptions {
515        let mut opts = tokio::fs::OpenOptions::new();
516        opts.read(self.read)
517            .write(self.write)
518            .append(self.append)
519            .truncate(self.truncate)
520            .create(self.create)
521            .create_new(self.create_new);
522        #[cfg(unix)]
523        if self.created_file_permissions == CreatedFilePermissions::OwnerReadWrite {
524            opts.mode(0o600);
525        }
526        opts
527    }
528
529    fn std_options(&self) -> fs::OpenOptions {
530        let mut opts = fs::OpenOptions::new();
531        opts.read(self.read)
532            .write(self.write)
533            .append(self.append)
534            .truncate(self.truncate)
535            .create(self.create)
536            .create_new(self.create_new);
537        #[cfg(unix)]
538        if self.created_file_permissions == CreatedFilePermissions::OwnerReadWrite {
539            use std::os::unix::fs::OpenOptionsExt as _;
540            opts.mode(0o600);
541        }
542        opts
543    }
544
545    async fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
546        match &self.jail {
547            None => Ok(sanitize_path(path)?),
548            Some(root) => {
549                let full = root.join(sanitize_relative_path(path)?);
550                if self.symlinks == SymlinkPolicy::RestrictToRoot {
551                    ensure_within_root(root, &full).await?;
552                }
553                Ok(full)
554            }
555        }
556    }
557
558    fn resolve_sync(&self, path: &Path) -> io::Result<PathBuf> {
559        match &self.jail {
560            None => Ok(sanitize_path(path)?),
561            Some(root) => {
562                let full = root.join(sanitize_relative_path(path)?);
563                if self.symlinks == SymlinkPolicy::RestrictToRoot {
564                    ensure_within_root_sync(root, &full)?;
565                }
566                Ok(full)
567            }
568        }
569    }
570}
571
572/// Verify, by canonicalizing, that `target` resolves to a location within
573/// `root`. This defends against symbolic links that point outside the jail.
574///
575/// Only the portion of `target` that already exists is canonicalized; the
576/// lexical sanitization already guarantees the not-yet-existing tail contains
577/// no `..` components.
578async fn ensure_within_root(root: &Path, target: &Path) -> io::Result<()> {
579    let canonical_root = canonicalize_root(root).await?;
580    ensure_within_canonical_root(&canonical_root, target).await
581}
582
583#[cfg(loom)]
584async fn canonicalize_root(root: &Path) -> io::Result<PathBuf> {
585    canonicalize_root_sync(root)
586}
587
588#[cfg(not(loom))]
589async fn canonicalize_root(root: &Path) -> io::Result<PathBuf> {
590    tokio::fs::canonicalize(root).await
591}
592
593#[cfg(loom)]
594async fn ensure_within_canonical_root(canonical_root: &Path, target: &Path) -> io::Result<()> {
595    ensure_within_canonical_root_sync(canonical_root, target)
596}
597
598#[cfg(not(loom))]
599async fn ensure_within_canonical_root(canonical_root: &Path, target: &Path) -> io::Result<()> {
600    if let Some(existing) = nearest_existing_ancestor(target).await {
601        let canonical_target = canonicalize_existing_path(&existing).await?;
602        if !canonical_target.starts_with(canonical_root) {
603            return Err(UnsafePathError::EscapesRoot.into());
604        }
605    }
606    Ok(())
607}
608
609fn ensure_within_root_sync(root: &Path, target: &Path) -> io::Result<()> {
610    let canonical_root = canonicalize_root_sync(root)?;
611    ensure_within_canonical_root_sync(&canonical_root, target)
612}
613
614fn canonicalize_root_sync(root: &Path) -> io::Result<PathBuf> {
615    fs::canonicalize(root)
616}
617
618fn ensure_within_canonical_root_sync(canonical_root: &Path, target: &Path) -> io::Result<()> {
619    if let Some(existing) = nearest_existing_ancestor_sync(target) {
620        let canonical_target = canonicalize_existing_path_sync(&existing)?;
621        if !canonical_target.starts_with(canonical_root) {
622            return Err(UnsafePathError::EscapesRoot.into());
623        }
624    }
625    Ok(())
626}
627
628#[cfg(not(loom))]
629async fn canonicalize_existing_path(path: &Path) -> io::Result<PathBuf> {
630    match tokio::fs::canonicalize(path).await {
631        Ok(path) => Ok(path),
632        Err(err) if err.kind() == io::ErrorKind::NotFound => {
633            if tokio::fs::symlink_metadata(path).await.is_ok() {
634                return Err(UnsafePathError::EscapesRoot.into());
635            }
636            Err(err)
637        }
638        Err(err) => Err(err),
639    }
640}
641
642#[cfg(loom)]
643async fn canonicalize_existing_path(path: &Path) -> io::Result<PathBuf> {
644    canonicalize_existing_path_sync(path)
645}
646
647fn canonicalize_existing_path_sync(path: &Path) -> io::Result<PathBuf> {
648    match fs::canonicalize(path) {
649        Ok(path) => Ok(path),
650        Err(err) if err.kind() == io::ErrorKind::NotFound => {
651            if fs::symlink_metadata(path).is_ok() {
652                return Err(UnsafePathError::EscapesRoot.into());
653            }
654            Err(err)
655        }
656        Err(err) => Err(err),
657    }
658}
659
660/// Walk up from `path` until an existing path is found, returning it.
661#[cfg(not(loom))]
662async fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
663    let mut current = Some(path);
664    while let Some(candidate) = current {
665        if tokio::fs::symlink_metadata(candidate).await.is_ok() {
666            return Some(candidate.to_path_buf());
667        }
668        current = candidate.parent();
669    }
670    None
671}
672
673fn nearest_existing_ancestor_sync(path: &Path) -> Option<PathBuf> {
674    let mut current = Some(path);
675    while let Some(candidate) = current {
676        if fs::symlink_metadata(candidate).is_ok() {
677            return Some(candidate.to_path_buf());
678        }
679        current = candidate.parent();
680    }
681    None
682}
683
684#[cfg(all(test, not(loom)))]
685mod tests {
686    use super::*;
687    use tokio::io::AsyncReadExt;
688
689    async fn read_to_string(mut file: File) -> io::Result<String> {
690        let mut buf = String::new();
691        file.read_to_string(&mut buf).await?;
692        Ok(buf)
693    }
694
695    fn err_kind<T>(result: io::Result<T>) -> Option<io::ErrorKind> {
696        result.err().map(|err| err.kind())
697    }
698
699    #[tokio::test]
700    async fn safe_open_reads_a_regular_file() {
701        let dir = tempfile::tempdir().unwrap();
702        let path = dir.path().join("hello.txt");
703        tokio::fs::write(&path, b"hello world").await.unwrap();
704
705        let file = safe_open(&path).await.unwrap();
706        assert_eq!(read_to_string(file).await.unwrap(), "hello world");
707    }
708
709    #[test]
710    fn safe_open_sync_reads_a_regular_file() {
711        let dir = tempfile::tempdir().unwrap();
712        let path = dir.path().join("hello.txt");
713        fs::write(&path, b"hello world").unwrap();
714
715        let text = {
716            let mut s = String::new();
717            use std::io::Read as _;
718            safe_open_sync(&path)
719                .unwrap()
720                .read_to_string(&mut s)
721                .unwrap();
722            s
723        };
724        assert_eq!(text, "hello world");
725    }
726
727    #[tokio::test]
728    async fn safe_open_rejects_parent_dir_traversal() {
729        let dir = tempfile::tempdir().unwrap();
730        let path = dir.path().join("sub/../secret.txt");
731        assert_eq!(
732            err_kind(safe_open(&path).await),
733            Some(io::ErrorKind::InvalidInput),
734        );
735    }
736
737    #[tokio::test]
738    async fn safe_open_missing_file_is_not_found() {
739        let dir = tempfile::tempdir().unwrap();
740        let path = dir.path().join("does-not-exist.txt");
741        assert_eq!(
742            err_kind(safe_open(&path).await),
743            Some(io::ErrorKind::NotFound),
744        );
745    }
746
747    #[tokio::test]
748    async fn safe_open_in_serves_files_within_root() {
749        let root = tempfile::tempdir().unwrap();
750        tokio::fs::create_dir(root.path().join("assets"))
751            .await
752            .unwrap();
753        tokio::fs::write(root.path().join("assets/app.js"), b"console.log(1)")
754            .await
755            .unwrap();
756
757        let file = safe_open_in(root.path(), "assets/app.js").await.unwrap();
758        assert_eq!(read_to_string(file).await.unwrap(), "console.log(1)");
759
760        let file = safe_open_in(root.path(), "/assets/app.js").await;
761        assert_eq!(err_kind(file), Some(io::ErrorKind::InvalidInput));
762    }
763
764    #[tokio::test]
765    async fn safe_open_in_rejects_traversal_out_of_root() {
766        let parent = tempfile::tempdir().unwrap();
767        tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
768            .await
769            .unwrap();
770        let root = parent.path().join("public");
771        tokio::fs::create_dir(&root).await.unwrap();
772        tokio::fs::write(root.join("index.html"), b"<h1>hi</h1>")
773            .await
774            .unwrap();
775
776        safe_open_in(&root, "index.html").await.unwrap();
777
778        for payload in ["../secret.txt", "../../etc/passwd", "..\\secret.txt"] {
779            let result = safe_open_in(&root, payload).await;
780            assert!(
781                result.is_err(),
782                "expected `{payload}` to be rejected, got Ok",
783            );
784        }
785    }
786
787    #[cfg(unix)]
788    #[tokio::test]
789    async fn safe_open_in_rejects_symlink_escaping_root() {
790        let parent = tempfile::tempdir().unwrap();
791        tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
792            .await
793            .unwrap();
794        let root = parent.path().join("public");
795        tokio::fs::create_dir(&root).await.unwrap();
796
797        std::os::unix::fs::symlink(parent.path().join("secret.txt"), root.join("escape")).unwrap();
798
799        let result = safe_open_in(&root, "escape").await;
800        assert_eq!(err_kind(result), Some(io::ErrorKind::InvalidInput));
801    }
802
803    #[cfg(unix)]
804    #[tokio::test]
805    async fn safe_open_in_allows_symlink_within_root() {
806        let root = tempfile::tempdir().unwrap();
807        tokio::fs::write(root.path().join("real.txt"), b"data")
808            .await
809            .unwrap();
810        std::os::unix::fs::symlink(root.path().join("real.txt"), root.path().join("link")).unwrap();
811
812        let file = safe_open_in(root.path(), "link").await.unwrap();
813        assert_eq!(read_to_string(file).await.unwrap(), "data");
814    }
815
816    #[cfg(unix)]
817    #[tokio::test]
818    async fn jail_create_rejects_dangling_symlink_escape() {
819        let parent = tempfile::tempdir().unwrap();
820        let root = parent.path().join("public");
821        let outside = parent.path().join("outside");
822        tokio::fs::create_dir(&root).await.unwrap();
823        tokio::fs::create_dir(&outside).await.unwrap();
824
825        let outside_target = outside.join("created.txt");
826        std::os::unix::fs::symlink(&outside_target, root.join("upload.txt")).unwrap();
827
828        let result = OpenOptions::new()
829            .write(true)
830            .create(true)
831            .jail(&root)
832            .open("upload.txt")
833            .await;
834
835        assert_eq!(err_kind(result), Some(io::ErrorKind::InvalidInput));
836        assert!(!outside_target.exists());
837    }
838
839    #[cfg(unix)]
840    #[test]
841    fn jail_create_sync_rejects_dangling_symlink_escape() {
842        let parent = tempfile::tempdir().unwrap();
843        let root = parent.path().join("public");
844        let outside = parent.path().join("outside");
845        fs::create_dir(&root).unwrap();
846        fs::create_dir(&outside).unwrap();
847
848        let outside_target = outside.join("created.txt");
849        std::os::unix::fs::symlink(&outside_target, root.join("upload.txt")).unwrap();
850
851        let result = OpenOptionsSync::new()
852            .write(true)
853            .create(true)
854            .jail(&root)
855            .open("upload.txt");
856
857        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
858        assert!(!outside_target.exists());
859    }
860
861    #[cfg(unix)]
862    #[tokio::test]
863    async fn jail_allow_symlinks_follows_escaping_link_but_keeps_lexical_guard() {
864        let parent = tempfile::tempdir().unwrap();
865        tokio::fs::write(parent.path().join("secret.txt"), b"top secret")
866            .await
867            .unwrap();
868        let root = parent.path().join("public");
869        tokio::fs::create_dir(&root).await.unwrap();
870        std::os::unix::fs::symlink(parent.path().join("secret.txt"), root.join("escape")).unwrap();
871
872        assert_eq!(
873            err_kind(safe_open_in(&root, "escape").await),
874            Some(io::ErrorKind::InvalidInput),
875        );
876
877        let file = OpenOptions::new()
878            .read(true)
879            .jail(&root)
880            .symlinks(SymlinkPolicy::Allow)
881            .open("escape")
882            .await
883            .unwrap();
884        assert_eq!(read_to_string(file).await.unwrap(), "top secret");
885
886        let traversal = OpenOptions::new()
887            .read(true)
888            .jail(&root)
889            .symlinks(SymlinkPolicy::Allow)
890            .open("../secret.txt")
891            .await;
892        assert_eq!(err_kind(traversal), Some(io::ErrorKind::InvalidInput));
893    }
894
895    #[tokio::test]
896    async fn open_options_can_create_within_jail() {
897        let root = tempfile::tempdir().unwrap();
898        OpenOptions::new()
899            .write(true)
900            .create(true)
901            .jail(root.path())
902            .open("nested/created.txt")
903            .await
904            .expect_err("parent dir does not exist yet");
905
906        tokio::fs::create_dir(root.path().join("nested"))
907            .await
908            .unwrap();
909        let _file = OpenOptions::new()
910            .write(true)
911            .create(true)
912            .jail(root.path())
913            .open("nested/created.txt")
914            .await
915            .unwrap();
916        assert!(root.path().join("nested/created.txt").exists());
917    }
918
919    #[cfg(unix)]
920    #[tokio::test]
921    async fn open_options_private_created_file_has_no_group_or_other_bits() {
922        use std::os::unix::fs::PermissionsExt as _;
923
924        let root = tempfile::tempdir().unwrap();
925        let path = root.path().join("secret.txt");
926        let _file = OpenOptions::new()
927            .write(true)
928            .create_new(true)
929            .created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
930            .open(&path)
931            .await
932            .unwrap();
933
934        let mode = tokio::fs::metadata(&path)
935            .await
936            .unwrap()
937            .permissions()
938            .mode();
939        assert_eq!(mode & 0o077, 0);
940    }
941
942    #[cfg(unix)]
943    #[test]
944    fn open_options_sync_private_created_file_has_no_group_or_other_bits() {
945        use std::os::unix::fs::PermissionsExt as _;
946
947        let root = tempfile::tempdir().unwrap();
948        let path = root.path().join("secret.txt");
949        let _file = OpenOptionsSync::new()
950            .write(true)
951            .create_new(true)
952            .created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
953            .open(&path)
954            .unwrap();
955
956        let mode = fs::metadata(&path).unwrap().permissions().mode();
957        assert_eq!(mode & 0o077, 0);
958    }
959
960    #[tokio::test]
961    async fn safe_write_in_creates_parent_dirs() {
962        let root = tempfile::tempdir().unwrap();
963        safe_write_in(root.path(), "nested/file.txt", b"hello")
964            .await
965            .unwrap();
966        assert_eq!(
967            tokio::fs::read_to_string(root.path().join("nested/file.txt"))
968                .await
969                .unwrap(),
970            "hello",
971        );
972    }
973
974    #[test]
975    fn safe_write_in_sync_creates_parent_dirs() {
976        let root = tempfile::tempdir().unwrap();
977        safe_write_in_sync(root.path(), "nested/file.txt", b"hello").unwrap();
978        assert_eq!(
979            fs::read_to_string(root.path().join("nested/file.txt")).unwrap(),
980            "hello",
981        );
982    }
983
984    #[test]
985    fn safe_write_in_sync_rejects_traversal() {
986        let root = tempfile::tempdir().unwrap();
987        let err = safe_write_in_sync(root.path(), "../escape.txt", b"nope").unwrap_err();
988        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
989        assert!(!root.path().parent().unwrap().join("escape.txt").exists());
990    }
991
992    #[tokio::test]
993    async fn safe_create_dir_all_in_rejects_absolute_paths() {
994        let root = tempfile::tempdir().unwrap();
995        let err = safe_create_dir_all_in(root.path(), "/tmp/escape")
996            .await
997            .unwrap_err();
998        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
999    }
1000
1001    #[test]
1002    fn safe_create_dir_all_in_sync_rejects_absolute_paths() {
1003        let root = tempfile::tempdir().unwrap();
1004        let err = safe_create_dir_all_in_sync(root.path(), "/tmp/escape").unwrap_err();
1005        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1006    }
1007
1008    #[test]
1009    fn safe_path_in_sync_resolves_plain_relative_path() {
1010        let root = tempfile::tempdir().unwrap();
1011        assert_eq!(
1012            safe_path_in_sync(root.path(), "nested/file.txt").unwrap(),
1013            root.path().join("nested/file.txt"),
1014        );
1015    }
1016
1017    #[tokio::test]
1018    async fn safe_canonicalize_in_resolves_existing_path() {
1019        let root = tempfile::tempdir().unwrap();
1020        tokio::fs::create_dir(root.path().join("nested"))
1021            .await
1022            .unwrap();
1023        assert_eq!(
1024            safe_canonicalize_in(root.path(), "nested").await.unwrap(),
1025            root.path().canonicalize().unwrap().join("nested"),
1026        );
1027    }
1028
1029    #[test]
1030    fn safe_canonicalize_in_sync_resolves_existing_path() {
1031        let root = tempfile::tempdir().unwrap();
1032        fs::create_dir(root.path().join("nested")).unwrap();
1033        assert_eq!(
1034            safe_canonicalize_in_sync(root.path(), "nested").unwrap(),
1035            root.path().canonicalize().unwrap().join("nested"),
1036        );
1037    }
1038
1039    #[cfg(unix)]
1040    #[test]
1041    fn safe_write_in_sync_rejects_symlink_escape() {
1042        let parent = tempfile::tempdir().unwrap();
1043        let root = parent.path().join("root");
1044        let outside = parent.path().join("outside");
1045        fs::create_dir(&root).unwrap();
1046        fs::create_dir(&outside).unwrap();
1047        std::os::unix::fs::symlink(outside.join("created.txt"), root.join("link")).unwrap();
1048
1049        let err = safe_write_in_sync(&root, "link", b"nope").unwrap_err();
1050        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1051        assert!(!outside.join("created.txt").exists());
1052    }
1053
1054    #[cfg(unix)]
1055    #[test]
1056    fn safe_create_dir_all_in_sync_rejects_symlink_escape() {
1057        let parent = tempfile::tempdir().unwrap();
1058        let root = parent.path().join("root");
1059        let outside = parent.path().join("outside");
1060        fs::create_dir(&root).unwrap();
1061        fs::create_dir(&outside).unwrap();
1062        std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1063
1064        let err = safe_create_dir_all_in_sync(&root, "link/nested").unwrap_err();
1065        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1066        assert!(!outside.join("nested").exists());
1067    }
1068
1069    #[cfg(unix)]
1070    #[test]
1071    fn safe_canonicalize_in_sync_rejects_symlink_escape() {
1072        let parent = tempfile::tempdir().unwrap();
1073        let root = parent.path().join("root");
1074        let outside = parent.path().join("outside");
1075        fs::create_dir(&root).unwrap();
1076        fs::create_dir(&outside).unwrap();
1077        std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1078
1079        let err = safe_canonicalize_in_sync(&root, "link").unwrap_err();
1080        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1081    }
1082
1083    #[cfg(unix)]
1084    #[tokio::test]
1085    async fn safe_canonicalize_in_rejects_symlink_escape() {
1086        let parent = tempfile::tempdir().unwrap();
1087        let root = parent.path().join("root");
1088        let outside = parent.path().join("outside");
1089        tokio::fs::create_dir(&root).await.unwrap();
1090        tokio::fs::create_dir(&outside).await.unwrap();
1091        std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1092
1093        let err = safe_canonicalize_in(&root, "link").await.unwrap_err();
1094        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1095    }
1096}