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
98/// Fluent builder for assembling a [`CapabilitySet`].
99///
100/// # Examples
101///
102/// ```
103/// use martensite_plugin::{Capability, CapabilitySet, PluginBuilder};
104/// use std::path::PathBuf;
105///
106/// let caps = PluginBuilder::new()
107///     .grant(Capability::Network)
108///     .grant(Capability::FileRead(PathBuf::from("/assets")))
109///     .revoke(Capability::Network)
110///     .build();
111///
112/// assert!(!caps.contains(&Capability::Network));
113/// assert!(caps.contains(&Capability::FileRead(PathBuf::from("/assets"))));
114/// ```
115#[derive(Clone, Debug, Default, PartialEq, Eq)]
116pub struct PluginBuilder {
117    caps: CapabilitySet,
118}
119
120impl PluginBuilder {
121    /// Creates a new builder with no capabilities granted.
122    pub fn new() -> Self {
123        Self {
124            caps: CapabilitySet::empty(),
125        }
126    }
127
128    /// Grants the given capability and returns the builder.
129    pub fn grant(mut self, cap: Capability) -> Self {
130        self.caps.grant(cap);
131        self
132    }
133
134    /// Revokes the given capability and returns the builder.
135    pub fn revoke(mut self, cap: Capability) -> Self {
136        self.caps.revoke(&cap);
137        self
138    }
139
140    /// Finalizes the builder into an immutable capability set.
141    pub fn build(self) -> CapabilitySet {
142        self.caps
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn empty_set_contains_nothing() {
152        let caps = CapabilitySet::empty();
153        assert!(caps.is_empty());
154        assert_eq!(caps.len(), 0);
155        assert!(!caps.contains(&Capability::Network));
156    }
157
158    #[test]
159    fn grant_and_revoke_signal() {
160        let mut caps = CapabilitySet::empty();
161        let id = SignalId::next();
162        let cap = Capability::SignalRead(id);
163
164        assert!(caps.grant(cap.clone()));
165        assert!(caps.contains(&cap));
166        assert!(!caps.grant(cap.clone()));
167
168        assert!(caps.revoke(&cap));
169        assert!(!caps.contains(&cap));
170        assert!(!caps.revoke(&cap));
171    }
172
173    #[test]
174    fn builder_assembles_caps() {
175        let path = PathBuf::from("/assets");
176        let caps = PluginBuilder::new()
177            .grant(Capability::Network)
178            .grant(Capability::FileRead(path.clone()))
179            .grant(Capability::SignalWrite(SignalId::next()))
180            .revoke(Capability::Network)
181            .build();
182
183        assert_eq!(caps.len(), 2);
184        assert!(!caps.contains(&Capability::Network));
185        assert!(caps.contains(&Capability::FileRead(path)));
186    }
187
188    #[test]
189    fn file_capabilities_are_distinct_by_path() {
190        let a = Capability::FileRead(PathBuf::from("/a"));
191        let b = Capability::FileRead(PathBuf::from("/b"));
192        let mut caps = CapabilitySet::empty();
193        caps.grant(a.clone());
194        assert!(caps.contains(&a));
195        assert!(!caps.contains(&b));
196    }
197}