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}