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}