Skip to main content

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}