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    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}