martensite_plugin/security.rs
1//! Capability-based security sandbox for Martensite plugins.
2//!
3//! Plugins start with no access to host resources. Each permission must be
4//! explicitly granted through a [`Capability`] before the plugin runtime will
5//! allow a host call to proceed. Unauthorized calls trap the guest cleanly.
6
7use std::collections::HashSet;
8use std::path::PathBuf;
9
10use martensite_reactive::SignalId;
11
12/// A single host resource permission that can be granted to a plugin.
13///
14/// Capabilities are compared by value, so two grants for the same signal or the
15/// same filesystem path are equivalent.
16///
17/// # Examples
18///
19/// ```
20/// use martensite_plugin::Capability;
21/// use std::path::PathBuf;
22///
23/// let read_asset = Capability::FileRead(PathBuf::from("/assets"));
24/// let write_log = Capability::FileWrite(PathBuf::from("/tmp/plugin.log"));
25/// let network = Capability::Network;
26///
27/// assert_ne!(read_asset, network);
28/// ```
29#[derive(Clone, Debug, PartialEq, Eq, Hash)]
30pub enum Capability {
31 /// Permission to read the current value of the given reactive signal.
32 SignalRead(SignalId),
33 /// Permission to write a new value into the given reactive signal.
34 SignalWrite(SignalId),
35 /// Permission to read from the given filesystem path.
36 FileRead(PathBuf),
37 /// Permission to write to the given filesystem path.
38 FileWrite(PathBuf),
39 /// Permission to open network sockets.
40 Network,
41}
42
43/// A set of capabilities held by a plugin instance.
44///
45/// Membership tests are `O(1)` on average.
46///
47/// # Examples
48///
49/// ```
50/// use martensite_plugin::{Capability, CapabilitySet};
51///
52/// let mut caps = CapabilitySet::empty();
53/// caps.grant(Capability::Network);
54/// assert!(caps.contains(&Capability::Network));
55/// caps.revoke(&Capability::Network);
56/// assert!(!caps.contains(&Capability::Network));
57/// ```
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct CapabilitySet(HashSet<Capability>);
60
61impl CapabilitySet {
62 /// Creates an empty capability set.
63 pub fn empty() -> Self {
64 Self(HashSet::new())
65 }
66
67 /// Returns a builder for constructing a capability set fluently.
68 pub fn builder() -> PluginBuilder {
69 PluginBuilder::new()
70 }
71
72 /// Returns the number of distinct capabilities in the set.
73 pub fn len(&self) -> usize {
74 self.0.len()
75 }
76
77 /// Returns `true` if no capabilities have been granted.
78 pub fn is_empty(&self) -> bool {
79 self.0.is_empty()
80 }
81
82 /// Grants a capability, returning `true` if it was newly inserted.
83 pub fn grant(&mut self, cap: Capability) -> bool {
84 self.0.insert(cap)
85 }
86
87 /// Revokes a capability, returning `true` if it was present.
88 pub fn revoke(&mut self, cap: &Capability) -> bool {
89 self.0.remove(cap)
90 }
91
92 /// Returns `true` if the capability is currently granted.
93 pub fn contains(&self, cap: &Capability) -> bool {
94 self.0.contains(cap)
95 }
96
97 /// Returns `true` if a `file_read` request for `requested_path` is
98 /// authorized by any granted [`Capability::FileRead`] entry.
99 ///
100 /// Authorization is performed by canonicalizing both the granted
101 /// roots and the requested path, then requiring the requested path
102 /// to be equal to, or descend into, at least one granted root. This
103 /// defeats path-traversal attacks (`/assets/../etc/passwd`) that
104 /// exact-match checks would otherwise miss when a directory is
105 /// granted and a child file is requested.
106 ///
107 /// When the requested file does not exist on disk (so
108 /// [`std::fs::canonicalize`] fails), the path is normalized
109 /// lexically via [`std::path::Path::components`] stripping of `.`
110 /// and resolving `..` against the granted root, and the prefix
111 /// check is applied to the normalized form. This keeps the check
112 /// total (no filesystem dependency) while still rejecting `..`
113 /// escapes.
114 ///
115 /// Granting a directory (e.g. `FileRead("/assets")`) authorizes
116 /// reads of any file beneath it (e.g. `/assets/textures/foo.png`).
117 /// Granting a file authorizes only that exact file.
118 pub fn file_read_allowed(&self, requested_path: &std::path::Path) -> bool {
119 self.file_path_allowed(requested_path, true)
120 }
121
122 /// Returns `true` if a `file_write` request for `requested_path` is
123 /// authorized by any granted [`Capability::FileWrite`] entry.
124 /// See [`Self::file_read_allowed`] for canonicalization semantics.
125 pub fn file_write_allowed(&self, requested_path: &std::path::Path) -> bool {
126 self.file_path_allowed(requested_path, false)
127 }
128
129 fn file_path_allowed(&self, requested_path: &std::path::Path, read: bool) -> bool {
130 let requested_canon = std::fs::canonicalize(requested_path).ok();
131 for cap in self.0.iter() {
132 let granted = match cap {
133 Capability::FileRead(p) if read => p,
134 Capability::FileWrite(p) if !read => p,
135 _ => continue,
136 };
137 // Try filesystem canonicalization first (strongest guarantee).
138 if let (Some(req_c), Ok(grant_c)) = (&requested_canon, std::fs::canonicalize(granted)) {
139 if req_c == &grant_c || req_c.starts_with(&grant_c) {
140 return true;
141 }
142 continue;
143 }
144 // Fall back to lexical normalization for paths that do not
145 // exist yet (writes) or are inside a granted directory whose
146 // own canonicalization also failed.
147 if lexical_starts_with(requested_path, granted) {
148 return true;
149 }
150 }
151 false
152 }
153}
154
155/// Lexically normalize `path` (resolving `.` and `..` components without
156/// touching the filesystem) and return `true` if the normalized form is
157/// equal to, or a descendant of, `root` (also lexically normalized).
158///
159/// This is the filesystem-independent fallback used when
160/// [`std::fs::canonicalize`] cannot resolve a path (e.g. the file does
161/// not yet exist). It rejects `..` escapes from a granted root while
162/// permitting legitimate child paths.
163fn lexical_starts_with(path: &std::path::Path, root: &std::path::Path) -> bool {
164 let norm_path = lexical_normalize(path);
165 let norm_root = lexical_normalize(root);
166 norm_path == norm_root || norm_path.starts_with(&norm_root)
167}
168
169/// Lexically normalize a path by consuming `.` components and resolving
170/// `..` components against the accumulated prefix, without touching the
171/// filesystem. The result is a `PathBuf` containing only normal
172/// components.
173fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
174 use std::path::Component;
175 let mut out = std::path::PathBuf::new();
176 for comp in path.components() {
177 match comp {
178 Component::CurDir => {}
179 Component::ParentDir => {
180 if !out.pop() {
181 // `..` that escapes the root: keep it so a prefix
182 // check will fail rather than silently allow.
183 out.push("..");
184 }
185 }
186 Component::RootDir | Component::Prefix(_) => {
187 out.push(comp.as_os_str());
188 }
189 Component::Normal(s) => out.push(s),
190 }
191 }
192 out
193}
194
195/// Fluent builder for assembling a [`CapabilitySet`].
196///
197/// # Examples
198///
199/// ```
200/// use martensite_plugin::{Capability, CapabilitySet, PluginBuilder};
201/// use std::path::PathBuf;
202///
203/// let caps = PluginBuilder::new()
204/// .grant(Capability::Network)
205/// .grant(Capability::FileRead(PathBuf::from("/assets")))
206/// .revoke(Capability::Network)
207/// .build();
208///
209/// assert!(!caps.contains(&Capability::Network));
210/// assert!(caps.contains(&Capability::FileRead(PathBuf::from("/assets"))));
211/// ```
212#[derive(Clone, Debug, Default, PartialEq, Eq)]
213pub struct PluginBuilder {
214 caps: CapabilitySet,
215}
216
217impl PluginBuilder {
218 /// Creates a new builder with no capabilities granted.
219 pub fn new() -> Self {
220 Self {
221 caps: CapabilitySet::empty(),
222 }
223 }
224
225 /// Grants the given capability and returns the builder.
226 pub fn grant(mut self, cap: Capability) -> Self {
227 self.caps.grant(cap);
228 self
229 }
230
231 /// Revokes the given capability and returns the builder.
232 pub fn revoke(mut self, cap: Capability) -> Self {
233 self.caps.revoke(&cap);
234 self
235 }
236
237 /// Finalizes the builder into an immutable capability set.
238 pub fn build(self) -> CapabilitySet {
239 self.caps
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn empty_set_contains_nothing() {
249 let caps = CapabilitySet::empty();
250 assert!(caps.is_empty());
251 assert_eq!(caps.len(), 0);
252 assert!(!caps.contains(&Capability::Network));
253 }
254
255 #[test]
256 fn grant_and_revoke_signal() {
257 let mut caps = CapabilitySet::empty();
258 let id = SignalId::next();
259 let cap = Capability::SignalRead(id);
260
261 assert!(caps.grant(cap.clone()));
262 assert!(caps.contains(&cap));
263 assert!(!caps.grant(cap.clone()));
264
265 assert!(caps.revoke(&cap));
266 assert!(!caps.contains(&cap));
267 assert!(!caps.revoke(&cap));
268 }
269
270 #[test]
271 fn builder_assembles_caps() {
272 let path = PathBuf::from("/assets");
273 let caps = PluginBuilder::new()
274 .grant(Capability::Network)
275 .grant(Capability::FileRead(path.clone()))
276 .grant(Capability::SignalWrite(SignalId::next()))
277 .revoke(Capability::Network)
278 .build();
279
280 assert_eq!(caps.len(), 2);
281 assert!(!caps.contains(&Capability::Network));
282 assert!(caps.contains(&Capability::FileRead(path)));
283 }
284
285 #[test]
286 fn file_capabilities_are_distinct_by_path() {
287 let a = Capability::FileRead(PathBuf::from("/a"));
288 let b = Capability::FileRead(PathBuf::from("/b"));
289 let mut caps = CapabilitySet::empty();
290 caps.grant(a.clone());
291 assert!(caps.contains(&a));
292 assert!(!caps.contains(&b));
293 }
294
295 #[test]
296 fn file_read_allowed_rejects_traversal_lexically() {
297 // Grant a directory and verify that a `..` escape is rejected
298 // even when the filesystem cannot canonicalize the path.
299 let mut caps = CapabilitySet::empty();
300 caps.grant(Capability::FileRead(PathBuf::from("/assets")));
301
302 // Legitimate child path is allowed (lexical fallback).
303 assert!(caps.file_read_allowed(std::path::Path::new("/assets/foo.txt")));
304 // Traversal escape is rejected.
305 assert!(!caps.file_read_allowed(std::path::Path::new("/assets/../etc/passwd")));
306 // Sibling directory is rejected.
307 assert!(!caps.file_read_allowed(std::path::Path::new("/etc/passwd")));
308 // Exact granted root is allowed.
309 assert!(caps.file_read_allowed(std::path::Path::new("/assets")));
310 }
311
312 #[test]
313 fn file_read_allowed_exact_file_grant() {
314 let mut caps = CapabilitySet::empty();
315 caps.grant(Capability::FileRead(PathBuf::from("/assets/secret.txt")));
316 assert!(caps.file_read_allowed(std::path::Path::new("/assets/secret.txt")));
317 // A sibling file under the same directory is not allowed.
318 assert!(!caps.file_read_allowed(std::path::Path::new("/assets/other.txt")));
319 }
320
321 #[test]
322 fn file_read_allowed_real_dir_traversal() {
323 // Use the tempdir crate pattern via std::env::temp_dir for a
324 // real filesystem traversal test.
325 let tmp = std::env::temp_dir().join("martensite_plugin_traversal_test");
326 std::fs::create_dir_all(&tmp).unwrap();
327 let sub = tmp.join("sub");
328 std::fs::create_dir_all(&sub).unwrap();
329 let secret = tmp.join("secret.txt");
330 std::fs::write(&secret, b"x").unwrap();
331 let child = sub.join("child.txt");
332 std::fs::write(&child, b"y").unwrap();
333
334 let mut caps = CapabilitySet::empty();
335 caps.grant(Capability::FileRead(sub.clone()));
336
337 // Child inside the granted dir is allowed.
338 assert!(caps.file_read_allowed(&child));
339 // Sibling outside the granted dir is rejected even with `..`.
340 let escape = sub.join("..").join("secret.txt");
341 assert!(!caps.file_read_allowed(&escape));
342
343 std::fs::remove_dir_all(&tmp).ok();
344 }
345
346 #[test]
347 fn lexical_normalize_strips_dot_and_resolves_dotdot() {
348 assert_eq!(
349 lexical_normalize(std::path::Path::new("/a/b/./c")),
350 std::path::PathBuf::from("/a/b/c")
351 );
352 assert_eq!(
353 lexical_normalize(std::path::Path::new("/a/b/../c")),
354 std::path::PathBuf::from("/a/c")
355 );
356 // `..` that escapes the root is preserved (cannot pop the root
357 // component), so a prefix check against the original root fails.
358 let escaped = lexical_normalize(std::path::Path::new("/a/../../etc"));
359 assert!(!escaped.starts_with(std::path::Path::new("/a")));
360 }
361}