Skip to main content

uni_plugin/
fs_guard.rs

1//! Path normalization for filesystem capability matching.
2//!
3//! Capability allow-lists (`Capability::Filesystem { read, write }`) are globs
4//! matched by [`crate::capability`]'s path-opaque `wildcard_match`, where `*`
5//! and `**` both span `/`. That matcher is correct for opaque strings (URLs,
6//! key ids), but a filesystem path carries `.`/`..` semantics the matcher does
7//! not understand: a guest granted `read: ["/data/**"]` could pass
8//! `"/data/../../etc/passwd"`, which textually matches `/data/**` while the
9//! kernel resolves it to `/etc/passwd` — a sandbox escape.
10//!
11//! The fix is to make the *checked* path identical to the *acted-upon* path.
12//! [`normalize_capability_path`] is the deterministic, IO-free first layer: it
13//! requires an absolute path and lexically resolves `.`/`..`, rejecting any path
14//! that would escape above the filesystem root. Loaders match the **normalized**
15//! path against the allow-list, and additionally canonicalize (resolving
16//! symlinks) before the syscall for defense in depth — see the loader fs host
17//! fns (e.g. `uni-plugin-rhai`'s `host_fn_impls::fs`).
18
19// Rust guideline compliant
20
21use std::path::{Component, Path, PathBuf};
22
23/// Lexically normalize an absolute capability path for allow-list matching.
24///
25/// Resolves `.` and `..` components purely textually (no filesystem access, so
26/// it works for not-yet-created write targets) and collapses redundant
27/// separators. The result is the canonical lexical form a loader should match
28/// against a `Capability::Filesystem` allow-list and then act on.
29///
30/// Returns `None` when the path is unsafe to admit:
31/// - it is **relative** (capability paths must be absolute), or
32/// - a `..` component would escape **above the filesystem root**, or
33/// - it contains a platform prefix (e.g. a Windows drive prefix), which the
34///   capability model does not model.
35///
36/// A `..` that stays within the root is resolved, not rejected — e.g.
37/// `/data/../etc` normalizes to `/etc`; admitting it here is safe because the
38/// allow-list match then rejects `/etc` for a `/data/**` grant. Only true
39/// root escapes are refused outright.
40///
41/// # Examples
42/// ```
43/// use std::path::PathBuf;
44/// use uni_plugin::normalize_capability_path as norm;
45///
46/// assert_eq!(norm("/data/./sub/f"), Some(PathBuf::from("/data/sub/f")));
47/// assert_eq!(norm("/data/../etc"), Some(PathBuf::from("/etc")));
48/// assert_eq!(norm("/data/../../etc/passwd"), None); // escapes above root
49/// assert_eq!(norm("data/x"), None); // relative
50/// ```
51#[must_use]
52pub fn normalize_capability_path(path: &str) -> Option<PathBuf> {
53    let mut out = PathBuf::new();
54    let mut has_root = false;
55    let mut depth: usize = 0; // count of Normal components above root
56
57    for comp in Path::new(path).components() {
58        match comp {
59            Component::RootDir => {
60                has_root = true;
61                out.push(comp.as_os_str());
62            }
63            // Drop `.` and any redundant separators (components() already
64            // collapses the latter).
65            Component::CurDir => {}
66            Component::ParentDir => {
67                // Pop one Normal component. If there is none above the root, the
68                // path is trying to escape — refuse.
69                if depth == 0 {
70                    return None;
71                }
72                depth -= 1;
73                out.pop();
74            }
75            Component::Normal(c) => {
76                depth += 1;
77                out.push(c);
78            }
79            // Windows drive/UNC prefixes are not part of the capability model.
80            Component::Prefix(_) => return None,
81        }
82    }
83
84    // Capability paths must be absolute; a relative path has no anchored root to
85    // compare against an allow-list.
86    if !has_root {
87        return None;
88    }
89    Some(out)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn rejects_relative_paths() {
98        assert_eq!(normalize_capability_path("data/x"), None);
99        assert_eq!(normalize_capability_path("./data/x"), None);
100        assert_eq!(normalize_capability_path(""), None);
101        assert_eq!(normalize_capability_path("../etc/passwd"), None);
102    }
103
104    #[test]
105    fn rejects_escape_above_root() {
106        // The reported exploit: two `..` from depth-1 pops past the root.
107        assert_eq!(normalize_capability_path("/data/../../etc/passwd"), None);
108        assert_eq!(normalize_capability_path("/.."), None);
109        assert_eq!(normalize_capability_path("/data/../.."), None);
110    }
111
112    #[test]
113    fn resolves_dot_and_redundant_separators() {
114        assert_eq!(
115            normalize_capability_path("/data/./sub/f"),
116            Some(PathBuf::from("/data/sub/f"))
117        );
118        assert_eq!(
119            normalize_capability_path("/data//sub///f"),
120            Some(PathBuf::from("/data/sub/f"))
121        );
122        assert_eq!(
123            normalize_capability_path("/data/sub/f"),
124            Some(PathBuf::from("/data/sub/f"))
125        );
126    }
127
128    #[test]
129    fn resolves_in_root_parent_without_rejecting() {
130        // `..` that stays within the root resolves; the allow-list (not this
131        // function) is responsible for rejecting the resulting `/etc`.
132        assert_eq!(
133            normalize_capability_path("/data/../etc"),
134            Some(PathBuf::from("/etc"))
135        );
136        assert_eq!(
137            normalize_capability_path("/data/.."),
138            Some(PathBuf::from("/"))
139        );
140    }
141}