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 ///
64 /// # Examples
65 ///
66 /// ```
67 /// use martensite_plugin::CapabilitySet;
68 ///
69 /// let caps = CapabilitySet::empty();
70 /// assert!(caps.is_empty());
71 /// ```
72 pub fn empty() -> Self {
73 Self(HashSet::new())
74 }
75
76 /// Returns a builder for constructing a capability set fluently.
77 ///
78 /// # Examples
79 ///
80 /// ```
81 /// use martensite_plugin::{Capability, CapabilitySet};
82 ///
83 /// let caps = CapabilitySet::builder().grant(Capability::Network).build();
84 /// assert!(caps.contains(&Capability::Network));
85 /// ```
86 pub fn builder() -> PluginBuilder {
87 PluginBuilder::new()
88 }
89
90 /// Returns the number of distinct capabilities in the set.
91 ///
92 /// # Examples
93 ///
94 /// ```
95 /// use martensite_plugin::{Capability, CapabilitySet};
96 ///
97 /// let mut caps = CapabilitySet::empty();
98 /// assert_eq!(caps.len(), 0);
99 /// caps.grant(Capability::Network);
100 /// assert_eq!(caps.len(), 1);
101 /// ```
102 pub fn len(&self) -> usize {
103 self.0.len()
104 }
105
106 /// Returns `true` if no capabilities have been granted.
107 ///
108 /// # Examples
109 ///
110 /// ```
111 /// use martensite_plugin::CapabilitySet;
112 ///
113 /// let caps = CapabilitySet::empty();
114 /// assert!(caps.is_empty());
115 /// ```
116 pub fn is_empty(&self) -> bool {
117 self.0.is_empty()
118 }
119
120 /// Grants a capability, returning `true` if it was newly inserted.
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// use martensite_plugin::{Capability, CapabilitySet};
126 ///
127 /// let mut caps = CapabilitySet::empty();
128 /// assert!(caps.grant(Capability::Network));
129 /// assert!(!caps.grant(Capability::Network)); // already granted
130 /// ```
131 pub fn grant(&mut self, cap: Capability) -> bool {
132 self.0.insert(cap)
133 }
134
135 /// Revokes a capability, returning `true` if it was present.
136 ///
137 /// # Examples
138 ///
139 /// ```
140 /// use martensite_plugin::{Capability, CapabilitySet};
141 ///
142 /// let mut caps = CapabilitySet::empty();
143 /// caps.grant(Capability::Network);
144 /// assert!(caps.revoke(&Capability::Network));
145 /// assert!(!caps.revoke(&Capability::Network)); // already revoked
146 /// ```
147 pub fn revoke(&mut self, cap: &Capability) -> bool {
148 self.0.remove(cap)
149 }
150
151 /// Returns `true` if the capability is currently granted.
152 ///
153 /// # Examples
154 ///
155 /// ```
156 /// use martensite_plugin::{Capability, CapabilitySet};
157 ///
158 /// let mut caps = CapabilitySet::empty();
159 /// caps.grant(Capability::Network);
160 /// assert!(caps.contains(&Capability::Network));
161 /// ```
162 pub fn contains(&self, cap: &Capability) -> bool {
163 self.0.contains(cap)
164 }
165
166 /// Returns `true` if a `file_read` request for `requested_path` is
167 /// authorized by any granted [`Capability::FileRead`] entry.
168 ///
169 /// Authorization is performed by canonicalizing both the granted
170 /// roots and the requested path, then requiring the requested path
171 /// to be equal to, or descend into, at least one granted root. This
172 /// defeats path-traversal attacks (`/assets/../etc/passwd`) that
173 /// exact-match checks would otherwise miss when a directory is
174 /// granted and a child file is requested.
175 ///
176 /// When the requested file does not exist on disk (so
177 /// [`std::fs::canonicalize`] fails), the path is normalized
178 /// lexically via [`std::path::Path::components`] stripping of `.`
179 /// and resolving `..` against the granted root, and the prefix
180 /// check is applied to the normalized form. This keeps the check
181 /// total (no filesystem dependency) while still rejecting `..`
182 /// escapes.
183 ///
184 /// Granting a directory (e.g. `FileRead("/assets")`) authorizes
185 /// reads of any file beneath it (e.g. `/assets/textures/foo.png`).
186 /// Granting a file authorizes only that exact file.
187 ///
188 /// # Examples
189 ///
190 /// ```
191 /// use martensite_plugin::{Capability, CapabilitySet};
192 /// use std::path::Path;
193 ///
194 /// let mut caps = CapabilitySet::empty();
195 /// caps.grant(Capability::FileRead("/assets".into()));
196 /// assert!(caps.file_read_allowed(Path::new("/assets/foo.txt")));
197 /// assert!(!caps.file_read_allowed(Path::new("/etc/passwd")));
198 /// ```
199 pub fn file_read_allowed(&self, requested_path: &std::path::Path) -> bool {
200 self.file_path_allowed(requested_path, true)
201 }
202
203 /// Returns `true` if a `file_write` request for `requested_path` is
204 /// authorized by any granted [`Capability::FileWrite`] entry.
205 /// See [`Self::file_read_allowed`] for canonicalization semantics.
206 ///
207 /// # Examples
208 ///
209 /// ```
210 /// use martensite_plugin::{Capability, CapabilitySet};
211 /// use std::path::Path;
212 ///
213 /// let mut caps = CapabilitySet::empty();
214 /// caps.grant(Capability::FileWrite("/tmp/log".into()));
215 /// assert!(caps.file_write_allowed(Path::new("/tmp/log")));
216 /// assert!(!caps.file_write_allowed(Path::new("/etc/passwd")));
217 /// ```
218 pub fn file_write_allowed(&self, requested_path: &std::path::Path) -> bool {
219 self.file_path_allowed(requested_path, false)
220 }
221
222 fn file_path_allowed(&self, requested_path: &std::path::Path, read: bool) -> bool {
223 let requested_canon = std::fs::canonicalize(requested_path).ok();
224 for cap in self.0.iter() {
225 let granted = match cap {
226 Capability::FileRead(p) if read => p,
227 Capability::FileWrite(p) if !read => p,
228 _ => continue,
229 };
230 // Try filesystem canonicalization first (strongest guarantee).
231 if let (Some(req_c), Ok(grant_c)) = (&requested_canon, std::fs::canonicalize(granted)) {
232 if req_c == &grant_c || req_c.starts_with(&grant_c) {
233 return true;
234 }
235 continue;
236 }
237 // Fall back to lexical normalization for paths that do not
238 // exist yet (writes) or are inside a granted directory whose
239 // own canonicalization also failed.
240 if lexical_starts_with(requested_path, granted) {
241 return true;
242 }
243 }
244 false
245 }
246}
247
248/// Lexically normalize `path` (resolving `.` and `..` components without
249/// touching the filesystem) and return `true` if the normalized form is
250/// equal to, or a descendant of, `root` (also lexically normalized).
251///
252/// This is the filesystem-independent fallback used when
253/// [`std::fs::canonicalize`] cannot resolve a path (e.g. the file does
254/// not yet exist). It rejects `..` escapes from a granted root while
255/// permitting legitimate child paths.
256fn lexical_starts_with(path: &std::path::Path, root: &std::path::Path) -> bool {
257 let norm_path = lexical_normalize(path);
258 let norm_root = lexical_normalize(root);
259 norm_path == norm_root || norm_path.starts_with(&norm_root)
260}
261
262/// Lexically normalize a path by consuming `.` components and resolving
263/// `..` components against the accumulated prefix, without touching the
264/// filesystem. The result is a `PathBuf` containing only normal
265/// components.
266fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
267 use std::path::Component;
268 let mut out = std::path::PathBuf::new();
269 for comp in path.components() {
270 match comp {
271 Component::CurDir => {}
272 Component::ParentDir => {
273 if !out.pop() {
274 // `..` that escapes the root: keep it so a prefix
275 // check will fail rather than silently allow.
276 out.push("..");
277 }
278 }
279 Component::RootDir | Component::Prefix(_) => {
280 out.push(comp.as_os_str());
281 }
282 Component::Normal(s) => out.push(s),
283 }
284 }
285 out
286}
287
288/// Fluent builder for assembling a [`CapabilitySet`].
289///
290/// # Examples
291///
292/// ```
293/// use martensite_plugin::{Capability, CapabilitySet, PluginBuilder};
294/// use std::path::PathBuf;
295///
296/// let caps = PluginBuilder::new()
297/// .grant(Capability::Network)
298/// .grant(Capability::FileRead(PathBuf::from("/assets")))
299/// .revoke(Capability::Network)
300/// .build();
301///
302/// assert!(!caps.contains(&Capability::Network));
303/// assert!(caps.contains(&Capability::FileRead(PathBuf::from("/assets"))));
304/// ```
305#[derive(Clone, Debug, Default, PartialEq, Eq)]
306pub struct PluginBuilder {
307 caps: CapabilitySet,
308}
309
310impl PluginBuilder {
311 /// Creates a new builder with no capabilities granted.
312 ///
313 /// # Examples
314 ///
315 /// ```
316 /// use martensite_plugin::PluginBuilder;
317 ///
318 /// let builder = PluginBuilder::new();
319 /// let caps = builder.build();
320 /// assert!(caps.is_empty());
321 /// ```
322 pub fn new() -> Self {
323 Self {
324 caps: CapabilitySet::empty(),
325 }
326 }
327
328 /// Grants the given capability and returns the builder.
329 ///
330 /// # Examples
331 ///
332 /// ```
333 /// use martensite_plugin::{Capability, PluginBuilder};
334 ///
335 /// let caps = PluginBuilder::new().grant(Capability::Network).build();
336 /// assert!(caps.contains(&Capability::Network));
337 /// ```
338 pub fn grant(mut self, cap: Capability) -> Self {
339 self.caps.grant(cap);
340 self
341 }
342
343 /// Revokes the given capability and returns the builder.
344 ///
345 /// # Examples
346 ///
347 /// ```
348 /// use martensite_plugin::{Capability, PluginBuilder};
349 ///
350 /// let caps = PluginBuilder::new()
351 /// .grant(Capability::Network)
352 /// .revoke(Capability::Network)
353 /// .build();
354 /// assert!(!caps.contains(&Capability::Network));
355 /// ```
356 pub fn revoke(mut self, cap: Capability) -> Self {
357 self.caps.revoke(&cap);
358 self
359 }
360
361 /// Finalizes the builder into an immutable capability set.
362 ///
363 /// # Examples
364 ///
365 /// ```
366 /// use martensite_plugin::{Capability, PluginBuilder};
367 ///
368 /// let caps = PluginBuilder::new().grant(Capability::Network).build();
369 /// assert!(caps.contains(&Capability::Network));
370 /// ```
371 pub fn build(self) -> CapabilitySet {
372 self.caps
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn empty_set_contains_nothing() {
382 let caps = CapabilitySet::empty();
383 assert!(caps.is_empty());
384 assert_eq!(caps.len(), 0);
385 assert!(!caps.contains(&Capability::Network));
386 }
387
388 #[test]
389 fn grant_and_revoke_signal() {
390 let mut caps = CapabilitySet::empty();
391 let id = SignalId::next();
392 let cap = Capability::SignalRead(id);
393
394 assert!(caps.grant(cap.clone()));
395 assert!(caps.contains(&cap));
396 assert!(!caps.grant(cap.clone()));
397
398 assert!(caps.revoke(&cap));
399 assert!(!caps.contains(&cap));
400 assert!(!caps.revoke(&cap));
401 }
402
403 #[test]
404 fn builder_assembles_caps() {
405 let path = PathBuf::from("/assets");
406 let caps = PluginBuilder::new()
407 .grant(Capability::Network)
408 .grant(Capability::FileRead(path.clone()))
409 .grant(Capability::SignalWrite(SignalId::next()))
410 .revoke(Capability::Network)
411 .build();
412
413 assert_eq!(caps.len(), 2);
414 assert!(!caps.contains(&Capability::Network));
415 assert!(caps.contains(&Capability::FileRead(path)));
416 }
417
418 #[test]
419 fn file_capabilities_are_distinct_by_path() {
420 let a = Capability::FileRead(PathBuf::from("/a"));
421 let b = Capability::FileRead(PathBuf::from("/b"));
422 let mut caps = CapabilitySet::empty();
423 caps.grant(a.clone());
424 assert!(caps.contains(&a));
425 assert!(!caps.contains(&b));
426 }
427
428 #[test]
429 fn file_read_allowed_rejects_traversal_lexically() {
430 // Grant a directory and verify that a `..` escape is rejected
431 // even when the filesystem cannot canonicalize the path.
432 let mut caps = CapabilitySet::empty();
433 caps.grant(Capability::FileRead(PathBuf::from("/assets")));
434
435 // Legitimate child path is allowed (lexical fallback).
436 assert!(caps.file_read_allowed(std::path::Path::new("/assets/foo.txt")));
437 // Traversal escape is rejected.
438 assert!(!caps.file_read_allowed(std::path::Path::new("/assets/../etc/passwd")));
439 // Sibling directory is rejected.
440 assert!(!caps.file_read_allowed(std::path::Path::new("/etc/passwd")));
441 // Exact granted root is allowed.
442 assert!(caps.file_read_allowed(std::path::Path::new("/assets")));
443 }
444
445 #[test]
446 fn file_read_allowed_exact_file_grant() {
447 let mut caps = CapabilitySet::empty();
448 caps.grant(Capability::FileRead(PathBuf::from("/assets/secret.txt")));
449 assert!(caps.file_read_allowed(std::path::Path::new("/assets/secret.txt")));
450 // A sibling file under the same directory is not allowed.
451 assert!(!caps.file_read_allowed(std::path::Path::new("/assets/other.txt")));
452 }
453
454 #[test]
455 fn file_read_allowed_real_dir_traversal() {
456 // Use the tempdir crate pattern via std::env::temp_dir for a
457 // real filesystem traversal test.
458 let tmp = std::env::temp_dir().join("martensite_plugin_traversal_test");
459 std::fs::create_dir_all(&tmp).unwrap();
460 let sub = tmp.join("sub");
461 std::fs::create_dir_all(&sub).unwrap();
462 let secret = tmp.join("secret.txt");
463 std::fs::write(&secret, b"x").unwrap();
464 let child = sub.join("child.txt");
465 std::fs::write(&child, b"y").unwrap();
466
467 let mut caps = CapabilitySet::empty();
468 caps.grant(Capability::FileRead(sub.clone()));
469
470 // Child inside the granted dir is allowed.
471 assert!(caps.file_read_allowed(&child));
472 // Sibling outside the granted dir is rejected even with `..`.
473 let escape = sub.join("..").join("secret.txt");
474 assert!(!caps.file_read_allowed(&escape));
475
476 std::fs::remove_dir_all(&tmp).ok();
477 }
478
479 #[test]
480 fn lexical_normalize_strips_dot_and_resolves_dotdot() {
481 assert_eq!(
482 lexical_normalize(std::path::Path::new("/a/b/./c")),
483 std::path::PathBuf::from("/a/b/c")
484 );
485 assert_eq!(
486 lexical_normalize(std::path::Path::new("/a/b/../c")),
487 std::path::PathBuf::from("/a/c")
488 );
489 // `..` that escapes the root is preserved (cannot pop the root
490 // component), so a prefix check against the original root fails.
491 let escaped = lexical_normalize(std::path::Path::new("/a/../../etc"));
492 assert!(!escaped.starts_with(std::path::Path::new("/a")));
493 }
494}