Skip to main content

gravityfile_plugin/
sandbox.rs

1//! Security sandboxing for plugins.
2//!
3//! This module provides sandboxing capabilities to restrict what plugins
4//! can access and do.
5
6use std::collections::HashSet;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// Permission types that can be granted to plugins.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Permission {
15    /// Read files from the filesystem.
16    Read,
17
18    /// Write files to the filesystem.
19    Write,
20
21    /// Execute external commands.
22    Execute,
23
24    /// Access network resources.
25    Network,
26
27    /// Access environment variables.
28    Environment,
29
30    /// Access clipboard.
31    Clipboard,
32
33    /// Modify UI elements.
34    Ui,
35
36    /// Send notifications.
37    Notify,
38}
39
40/// Configuration for plugin sandboxing.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SandboxConfig {
43    /// Paths the plugin is allowed to read from.
44    pub allowed_read_paths: Vec<PathBuf>,
45
46    /// Paths the plugin is allowed to write to.
47    pub allowed_write_paths: Vec<PathBuf>,
48
49    /// Commands the plugin is allowed to execute.
50    pub allowed_commands: HashSet<String>,
51
52    /// Whether network access is allowed.
53    pub allow_network: bool,
54
55    /// Whether environment access is allowed.
56    pub allow_env: bool,
57
58    /// Maximum execution time in milliseconds.
59    pub timeout_ms: u64,
60
61    /// Maximum memory in bytes (0 = unlimited).
62    pub max_memory: usize,
63
64    /// Maximum file size that can be read.
65    pub max_read_size: usize,
66
67    /// Granted permissions.
68    pub permissions: HashSet<Permission>,
69}
70
71impl Default for SandboxConfig {
72    fn default() -> Self {
73        let mut permissions = HashSet::new();
74        permissions.insert(Permission::Read);
75        permissions.insert(Permission::Notify);
76
77        Self {
78            allowed_read_paths: vec![],
79            allowed_write_paths: vec![],
80            allowed_commands: HashSet::new(),
81            allow_network: false,
82            allow_env: false,
83            timeout_ms: 5000,
84            max_memory: 256 * 1024 * 1024,   // 256 MB
85            max_read_size: 10 * 1024 * 1024, // 10 MB
86            permissions,
87        }
88    }
89}
90
91impl SandboxConfig {
92    /// Create a minimal sandbox with no permissions.
93    pub fn minimal() -> Self {
94        Self {
95            allowed_read_paths: vec![],
96            allowed_write_paths: vec![],
97            allowed_commands: HashSet::new(),
98            allow_network: false,
99            allow_env: false,
100            timeout_ms: 1000,
101            max_memory: 64 * 1024 * 1024,
102            max_read_size: 1024 * 1024,
103            permissions: HashSet::new(),
104        }
105    }
106
107    /// Create a permissive sandbox for trusted plugins.
108    ///
109    /// WARNING: This preset grants broad filesystem read access and environment
110    /// variable access. It does NOT grant execute or wildcard command access.
111    /// Use only with fully audited, first-party plugins. For untrusted plugins
112    /// use [`SandboxConfig::default`] or [`SandboxConfig::minimal`] instead.
113    pub fn permissive() -> Self {
114        let mut permissions = HashSet::new();
115        permissions.insert(Permission::Read);
116        permissions.insert(Permission::Write);
117        permissions.insert(Permission::Environment);
118        permissions.insert(Permission::Ui);
119        permissions.insert(Permission::Notify);
120
121        Self {
122            allowed_read_paths: vec![PathBuf::from("/")],
123            allowed_write_paths: vec![],
124            allowed_commands: HashSet::new(),
125            allow_network: false,
126            allow_env: true,
127            timeout_ms: 30000,
128            max_memory: 1024 * 1024 * 1024,
129            max_read_size: 100 * 1024 * 1024,
130            permissions,
131        }
132    }
133
134    /// Add an allowed read path.
135    pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
136        self.allowed_read_paths.push(path.into());
137        self.permissions.insert(Permission::Read);
138        self
139    }
140
141    /// Add an allowed write path.
142    pub fn allow_write(mut self, path: impl Into<PathBuf>) -> Self {
143        self.allowed_write_paths.push(path.into());
144        self.permissions.insert(Permission::Write);
145        self
146    }
147
148    /// Allow a specific command.
149    pub fn allow_command(mut self, cmd: impl Into<String>) -> Self {
150        self.allowed_commands.insert(cmd.into());
151        self.permissions.insert(Permission::Execute);
152        self
153    }
154
155    /// Enable network access.
156    pub fn allow_network(mut self) -> Self {
157        self.allow_network = true;
158        self.permissions.insert(Permission::Network);
159        self
160    }
161
162    /// Set timeout.
163    pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
164        self.timeout_ms = timeout_ms;
165        self
166    }
167
168    /// Set memory limit.
169    pub fn with_memory_limit(mut self, bytes: usize) -> Self {
170        self.max_memory = bytes;
171        self
172    }
173
174    /// Grant a permission.
175    pub fn grant(mut self, permission: Permission) -> Self {
176        self.permissions.insert(permission);
177        self
178    }
179
180    /// Check if a permission is granted.
181    pub fn has_permission(&self, permission: Permission) -> bool {
182        self.permissions.contains(&permission)
183    }
184
185    /// Check if reading a path is allowed.
186    ///
187    /// Paths are normalized before comparison to prevent traversal attacks
188    /// via symlinks or `..` components.
189    pub fn can_read(&self, path: &std::path::Path) -> bool {
190        if !self.has_permission(Permission::Read) {
191            return false;
192        }
193        if self.allowed_read_paths.is_empty() {
194            return false;
195        }
196
197        let Some(normalized) = self.normalize_path(path) else {
198            return false;
199        };
200        self.allowed_read_paths
201            .iter()
202            .filter_map(|p| dunce::canonicalize(p).ok())
203            .any(|allowed| normalized.starts_with(&allowed))
204    }
205
206    /// Check if writing to a path is allowed.
207    ///
208    /// Paths are normalized before comparison to prevent traversal attacks
209    /// via symlinks or `..` components.
210    pub fn can_write(&self, path: &std::path::Path) -> bool {
211        if !self.has_permission(Permission::Write) {
212            return false;
213        }
214        if self.allowed_write_paths.is_empty() {
215            return false;
216        }
217
218        let Some(normalized) = self.normalize_path(path) else {
219            return false;
220        };
221        self.allowed_write_paths
222            .iter()
223            .filter_map(|p| dunce::canonicalize(p).ok())
224            .any(|allowed| normalized.starts_with(&allowed))
225    }
226
227    /// Normalize a path for sandbox comparison, handling non-existent components.
228    ///
229    /// Returns `None` if the path cannot be sufficiently resolved, which causes
230    /// the caller to deny access (safe default).
231    fn normalize_path(&self, path: &std::path::Path) -> Option<PathBuf> {
232        // Try canonicalizing directly first (works for existing paths)
233        if let Ok(c) = dunce::canonicalize(path) {
234            return Some(c);
235        }
236
237        // If it doesn't exist, try canonicalizing the parent and joining the
238        // final component. This handles writes to new files inside existing dirs.
239        if let Some(parent) = path.parent()
240            && let Ok(c) = dunce::canonicalize(parent)
241            && let Some(name) = path.file_name()
242        {
243            return Some(c.join(name));
244        }
245
246        // Cannot resolve — deny by returning None.
247        None
248    }
249
250    /// Check if executing a command is allowed.
251    pub fn can_execute(&self, command: &str) -> bool {
252        if !self.has_permission(Permission::Execute) {
253            return false;
254        }
255        self.allowed_commands.contains("*") || self.allowed_commands.contains(command)
256    }
257}