use std::collections::HashSet;
use std::path::PathBuf;
use martensite_reactive::SignalId;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Capability {
SignalRead(SignalId),
SignalWrite(SignalId),
FileRead(PathBuf),
FileWrite(PathBuf),
Network,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CapabilitySet(HashSet<Capability>);
impl CapabilitySet {
pub fn empty() -> Self {
Self(HashSet::new())
}
pub fn builder() -> PluginBuilder {
PluginBuilder::new()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn grant(&mut self, cap: Capability) -> bool {
self.0.insert(cap)
}
pub fn revoke(&mut self, cap: &Capability) -> bool {
self.0.remove(cap)
}
pub fn contains(&self, cap: &Capability) -> bool {
self.0.contains(cap)
}
pub fn file_read_allowed(&self, requested_path: &std::path::Path) -> bool {
self.file_path_allowed(requested_path, true)
}
pub fn file_write_allowed(&self, requested_path: &std::path::Path) -> bool {
self.file_path_allowed(requested_path, false)
}
fn file_path_allowed(&self, requested_path: &std::path::Path, read: bool) -> bool {
let requested_canon = std::fs::canonicalize(requested_path).ok();
for cap in self.0.iter() {
let granted = match cap {
Capability::FileRead(p) if read => p,
Capability::FileWrite(p) if !read => p,
_ => continue,
};
if let (Some(req_c), Ok(grant_c)) = (&requested_canon, std::fs::canonicalize(granted)) {
if req_c == &grant_c || req_c.starts_with(&grant_c) {
return true;
}
continue;
}
if lexical_starts_with(requested_path, granted) {
return true;
}
}
false
}
}
fn lexical_starts_with(path: &std::path::Path, root: &std::path::Path) -> bool {
let norm_path = lexical_normalize(path);
let norm_root = lexical_normalize(root);
norm_path == norm_root || norm_path.starts_with(&norm_root)
}
fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
Component::RootDir | Component::Prefix(_) => {
out.push(comp.as_os_str());
}
Component::Normal(s) => out.push(s),
}
}
out
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginBuilder {
caps: CapabilitySet,
}
impl PluginBuilder {
pub fn new() -> Self {
Self {
caps: CapabilitySet::empty(),
}
}
pub fn grant(mut self, cap: Capability) -> Self {
self.caps.grant(cap);
self
}
pub fn revoke(mut self, cap: Capability) -> Self {
self.caps.revoke(&cap);
self
}
pub fn build(self) -> CapabilitySet {
self.caps
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_set_contains_nothing() {
let caps = CapabilitySet::empty();
assert!(caps.is_empty());
assert_eq!(caps.len(), 0);
assert!(!caps.contains(&Capability::Network));
}
#[test]
fn grant_and_revoke_signal() {
let mut caps = CapabilitySet::empty();
let id = SignalId::next();
let cap = Capability::SignalRead(id);
assert!(caps.grant(cap.clone()));
assert!(caps.contains(&cap));
assert!(!caps.grant(cap.clone()));
assert!(caps.revoke(&cap));
assert!(!caps.contains(&cap));
assert!(!caps.revoke(&cap));
}
#[test]
fn builder_assembles_caps() {
let path = PathBuf::from("/assets");
let caps = PluginBuilder::new()
.grant(Capability::Network)
.grant(Capability::FileRead(path.clone()))
.grant(Capability::SignalWrite(SignalId::next()))
.revoke(Capability::Network)
.build();
assert_eq!(caps.len(), 2);
assert!(!caps.contains(&Capability::Network));
assert!(caps.contains(&Capability::FileRead(path)));
}
#[test]
fn file_capabilities_are_distinct_by_path() {
let a = Capability::FileRead(PathBuf::from("/a"));
let b = Capability::FileRead(PathBuf::from("/b"));
let mut caps = CapabilitySet::empty();
caps.grant(a.clone());
assert!(caps.contains(&a));
assert!(!caps.contains(&b));
}
#[test]
fn file_read_allowed_rejects_traversal_lexically() {
let mut caps = CapabilitySet::empty();
caps.grant(Capability::FileRead(PathBuf::from("/assets")));
assert!(caps.file_read_allowed(std::path::Path::new("/assets/foo.txt")));
assert!(!caps.file_read_allowed(std::path::Path::new("/assets/../etc/passwd")));
assert!(!caps.file_read_allowed(std::path::Path::new("/etc/passwd")));
assert!(caps.file_read_allowed(std::path::Path::new("/assets")));
}
#[test]
fn file_read_allowed_exact_file_grant() {
let mut caps = CapabilitySet::empty();
caps.grant(Capability::FileRead(PathBuf::from("/assets/secret.txt")));
assert!(caps.file_read_allowed(std::path::Path::new("/assets/secret.txt")));
assert!(!caps.file_read_allowed(std::path::Path::new("/assets/other.txt")));
}
#[test]
fn file_read_allowed_real_dir_traversal() {
let tmp = std::env::temp_dir().join("martensite_plugin_traversal_test");
std::fs::create_dir_all(&tmp).unwrap();
let sub = tmp.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let secret = tmp.join("secret.txt");
std::fs::write(&secret, b"x").unwrap();
let child = sub.join("child.txt");
std::fs::write(&child, b"y").unwrap();
let mut caps = CapabilitySet::empty();
caps.grant(Capability::FileRead(sub.clone()));
assert!(caps.file_read_allowed(&child));
let escape = sub.join("..").join("secret.txt");
assert!(!caps.file_read_allowed(&escape));
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn lexical_normalize_strips_dot_and_resolves_dotdot() {
assert_eq!(
lexical_normalize(std::path::Path::new("/a/b/./c")),
std::path::PathBuf::from("/a/b/c")
);
assert_eq!(
lexical_normalize(std::path::Path::new("/a/b/../c")),
std::path::PathBuf::from("/a/c")
);
let escaped = lexical_normalize(std::path::Path::new("/a/../../etc"));
assert!(!escaped.starts_with(std::path::Path::new("/a")));
}
}