Skip to main content

gravityfile_plugin/
config.rs

1//! Plugin configuration and metadata.
2
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::types::PluginKind;
9
10/// Global plugin system configuration.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PluginConfig {
13    /// Directory where plugins are stored.
14    pub plugin_dir: PathBuf,
15
16    /// Directory for plugin configuration files.
17    pub config_dir: PathBuf,
18
19    /// Whether to enable the plugin system.
20    pub enabled: bool,
21
22    /// Default timeout for plugin operations in milliseconds.
23    pub default_timeout_ms: u64,
24
25    /// Maximum memory per plugin in MB (0 = unlimited).
26    pub max_memory_mb: usize,
27
28    /// Whether to allow plugins to access the network.
29    pub allow_network: bool,
30
31    /// List of disabled plugin names.
32    pub disabled_plugins: HashSet<String>,
33
34    /// Directories the plugin filesystem API may read from (e.g. the scan root).
35    /// Empty means the filesystem API denies all reads (the safe default).
36    pub fs_read_roots: Vec<PathBuf>,
37}
38
39impl Default for PluginConfig {
40    fn default() -> Self {
41        let dirs = gravityfile_core::paths::Dirs::discover()
42            .unwrap_or_else(|| gravityfile_core::paths::Dirs::under(std::path::Path::new(".")));
43
44        Self {
45            plugin_dir: dirs.plugin_dir(),
46            config_dir: dirs.config,
47            enabled: true,
48            default_timeout_ms: 5000,
49            max_memory_mb: 256,
50            allow_network: false,
51            disabled_plugins: HashSet::new(),
52            fs_read_roots: Vec::new(),
53        }
54    }
55}
56
57impl PluginConfig {
58    /// Create a new config with a custom plugin directory.
59    pub fn with_plugin_dir(mut self, dir: PathBuf) -> Self {
60        self.plugin_dir = dir;
61        self
62    }
63
64    /// Set the default timeout.
65    pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
66        self.default_timeout_ms = timeout_ms;
67        self
68    }
69
70    /// Disable a specific plugin.
71    pub fn disable_plugin(mut self, name: impl Into<String>) -> Self {
72        self.disabled_plugins.insert(name.into());
73        self
74    }
75
76    /// Check if a plugin is disabled.
77    pub fn is_disabled(&self, name: &str) -> bool {
78        self.disabled_plugins.contains(name)
79    }
80
81    /// Set the directories the plugin filesystem API may read from.
82    pub fn with_fs_read_roots(mut self, roots: Vec<PathBuf>) -> Self {
83        self.fs_read_roots = roots;
84        self
85    }
86}
87
88/// Metadata about a plugin from its plugin.toml file.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct PluginMetadata {
91    /// Plugin name (unique identifier).
92    pub name: String,
93
94    /// Plugin version.
95    #[serde(default = "default_version")]
96    pub version: String,
97
98    /// Human-readable description.
99    #[serde(default)]
100    pub description: String,
101
102    /// Plugin author.
103    #[serde(default)]
104    pub author: String,
105
106    /// Plugin homepage/repository URL.
107    #[serde(default)]
108    pub url: String,
109
110    /// Runtime to use (`lua`).
111    #[serde(default = "default_runtime")]
112    pub runtime: String,
113
114    /// Plugin kind/category.
115    #[serde(default)]
116    pub kind: PluginKind,
117
118    /// Entry point file (relative to plugin directory).
119    #[serde(default = "default_entry")]
120    pub entry: String,
121
122    /// Minimum gravityfile version required.
123    #[serde(default)]
124    pub min_version: Option<String>,
125
126    /// Hooks this plugin wants to receive.
127    #[serde(default)]
128    pub hooks: PluginHooks,
129
130    /// Permissions requested by this plugin.
131    #[serde(default)]
132    pub permissions: PluginPermissions,
133
134    /// Plugin dependencies (other plugin names).
135    #[serde(default)]
136    pub dependencies: Vec<String>,
137}
138
139fn default_version() -> String {
140    "0.1.0".to_string()
141}
142
143fn default_runtime() -> String {
144    "lua".to_string()
145}
146
147fn default_entry() -> String {
148    "main.lua".to_string()
149}
150
151impl Default for PluginMetadata {
152    fn default() -> Self {
153        Self {
154            name: String::new(),
155            version: default_version(),
156            description: String::new(),
157            author: String::new(),
158            url: String::new(),
159            runtime: default_runtime(),
160            kind: PluginKind::Hook,
161            entry: default_entry(),
162            min_version: None,
163            hooks: PluginHooks::default(),
164            permissions: PluginPermissions::default(),
165            dependencies: vec![],
166        }
167    }
168}
169
170/// Hooks that a plugin wants to receive.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct PluginHooks {
173    /// Navigation events.
174    #[serde(default)]
175    pub on_navigate: bool,
176    #[serde(default)]
177    pub on_drill_down: bool,
178    #[serde(default)]
179    pub on_back: bool,
180
181    /// Scan events.
182    #[serde(default)]
183    pub on_scan_start: bool,
184    #[serde(default)]
185    pub on_scan_progress: bool,
186    #[serde(default)]
187    pub on_scan_complete: bool,
188
189    /// File operation events.
190    #[serde(default)]
191    pub on_delete_start: bool,
192    #[serde(default)]
193    pub on_delete_complete: bool,
194    #[serde(default)]
195    pub on_copy_start: bool,
196    #[serde(default)]
197    pub on_copy_complete: bool,
198    #[serde(default)]
199    pub on_move_start: bool,
200    #[serde(default)]
201    pub on_move_complete: bool,
202
203    /// UI events.
204    #[serde(default)]
205    pub on_render: bool,
206    #[serde(default)]
207    pub on_action: bool,
208    #[serde(default)]
209    pub on_mode_change: bool,
210
211    /// Lifecycle events.
212    #[serde(default)]
213    pub on_startup: bool,
214    #[serde(default)]
215    pub on_shutdown: bool,
216}
217
218/// Permissions requested by a plugin.
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct PluginPermissions {
221    /// Filesystem access level.
222    #[serde(default)]
223    pub filesystem: FilesystemPermission,
224
225    /// Network access.
226    #[serde(default)]
227    pub network: bool,
228
229    /// Allowed external commands.
230    #[serde(default)]
231    pub commands: Vec<String>,
232
233    /// Environment variable access.
234    #[serde(default)]
235    pub env: bool,
236}
237
238/// Filesystem permission levels.
239#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum FilesystemPermission {
242    /// No filesystem access.
243    #[default]
244    None,
245
246    /// Read-only access to scan root.
247    Read,
248
249    /// Read/write access to scan root.
250    Write,
251
252    /// Full filesystem access (dangerous).
253    Full,
254}
255
256impl PluginPermissions {
257    /// Check if plugin can read files.
258    pub fn can_read(&self) -> bool {
259        !matches!(self.filesystem, FilesystemPermission::None)
260    }
261
262    /// Check if plugin can write files.
263    pub fn can_write(&self) -> bool {
264        matches!(
265            self.filesystem,
266            FilesystemPermission::Write | FilesystemPermission::Full
267        )
268    }
269
270    /// Check if plugin can run a command.
271    pub fn can_run_command(&self, cmd: &str) -> bool {
272        self.commands.iter().any(|c| c == cmd || c == "*")
273    }
274
275    /// Build a [`SandboxConfig`](crate::sandbox::SandboxConfig) enforcing these
276    /// declared permissions, scoped to `roots` (e.g. the scan root). This is the
277    /// bridge that turns the per-plugin permission declaration into an actually
278    /// enforced sandbox for isolated execution.
279    pub fn to_sandbox(
280        &self,
281        roots: &[PathBuf],
282        timeout_ms: u64,
283        max_memory: usize,
284    ) -> crate::sandbox::SandboxConfig {
285        use crate::sandbox::{Permission, SandboxConfig};
286
287        let mut sandbox = SandboxConfig::minimal();
288        sandbox.timeout_ms = timeout_ms;
289        sandbox.max_memory = max_memory;
290
291        match self.filesystem {
292            FilesystemPermission::None => {}
293            FilesystemPermission::Read => {
294                sandbox.permissions.insert(Permission::Read);
295                sandbox.allowed_read_paths = roots.to_vec();
296            }
297            FilesystemPermission::Write => {
298                sandbox.permissions.insert(Permission::Read);
299                sandbox.permissions.insert(Permission::Write);
300                sandbox.allowed_read_paths = roots.to_vec();
301                sandbox.allowed_write_paths = roots.to_vec();
302            }
303            FilesystemPermission::Full => {
304                sandbox.permissions.insert(Permission::Read);
305                sandbox.permissions.insert(Permission::Write);
306                sandbox.allowed_read_paths = vec![PathBuf::from("/")];
307                sandbox.allowed_write_paths = vec![PathBuf::from("/")];
308            }
309        }
310
311        if self.network {
312            sandbox.allow_network = true;
313            sandbox.permissions.insert(Permission::Network);
314        }
315        if self.env {
316            sandbox.allow_env = true;
317            sandbox.permissions.insert(Permission::Environment);
318        }
319        if !self.commands.is_empty() {
320            sandbox.permissions.insert(Permission::Execute);
321            for cmd in &self.commands {
322                sandbox.allowed_commands.insert(cmd.clone());
323            }
324        }
325
326        sandbox
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::sandbox::Permission;
334
335    #[test]
336    fn permissions_none_grants_no_filesystem() {
337        let perms = PluginPermissions::default();
338        let sb = perms.to_sandbox(&[PathBuf::from("/tmp")], 1000, 0);
339        assert!(!sb.has_permission(Permission::Read));
340        assert!(!sb.has_permission(Permission::Write));
341        assert!(sb.allowed_read_paths.is_empty());
342    }
343
344    #[test]
345    fn permissions_read_scopes_to_roots() {
346        let perms = PluginPermissions {
347            filesystem: FilesystemPermission::Read,
348            ..Default::default()
349        };
350        let sb = perms.to_sandbox(&[PathBuf::from("/tmp")], 1000, 0);
351        assert!(sb.has_permission(Permission::Read));
352        assert!(!sb.has_permission(Permission::Write));
353        assert_eq!(sb.allowed_read_paths, vec![PathBuf::from("/tmp")]);
354        assert!(sb.allowed_write_paths.is_empty());
355    }
356
357    #[test]
358    fn permissions_write_grants_read_and_write_scoped() {
359        let perms = PluginPermissions {
360            filesystem: FilesystemPermission::Write,
361            env: true,
362            commands: vec!["git".to_string()],
363            ..Default::default()
364        };
365        let sb = perms.to_sandbox(&[PathBuf::from("/data")], 1000, 0);
366        assert!(sb.has_permission(Permission::Read));
367        assert!(sb.has_permission(Permission::Write));
368        assert!(sb.has_permission(Permission::Environment));
369        assert!(sb.can_execute("git"));
370        assert!(!sb.can_execute("rm"));
371    }
372}