Skip to main content

eov_plugin_api/
manifest.rs

1//! Plugin manifest parsing and validation.
2
3use crate::{IconDescriptor, PluginError, PluginResult};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// A toolbar button declared in the manifest.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct ManifestToolbarButton {
10    pub button_id: String,
11    pub tooltip: String,
12    pub action_id: String,
13    /// Inline SVG icon data. If omitted, the plugin's top-level icon is used.
14    pub icon_svg: Option<String>,
15}
16
17/// The parsed contents of a `plugin.toml` manifest.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PluginManifest {
20    /// Unique, stable identifier for the plugin (e.g. `"example_plugin"`).
21    pub id: String,
22    /// Human-readable name shown in the UI.
23    pub name: String,
24    /// Semantic version string.
25    pub version: String,
26    /// Relative path to the `.slint` UI file (from the plugin root).
27    /// Optional — plugins that are pure viewport filters may omit this.
28    #[serde(default)]
29    pub entry_ui: Option<String>,
30    /// Name of the exported Slint component inside `entry_ui`.
31    /// Optional — plugins that are pure viewport filters may omit this.
32    #[serde(default)]
33    pub entry_component: Option<String>,
34    /// Optional icon for the toolbar button.
35    pub icon: Option<IconDescriptor>,
36    /// Toolbar buttons declared in the manifest.
37    #[serde(default)]
38    pub toolbar_buttons: Vec<ManifestToolbarButton>,
39}
40
41/// Name of the manifest file inside each plugin directory.
42pub const MANIFEST_FILENAME: &str = "plugin.toml";
43
44impl PluginManifest {
45    /// Parse a manifest from a TOML string, validating required fields.
46    pub fn from_toml(toml_str: &str, plugin_id_hint: &str) -> PluginResult<Self> {
47        let manifest: Self = toml::from_str(toml_str).map_err(|e| PluginError::Manifest {
48            plugin_id: plugin_id_hint.to_string(),
49            message: format!("TOML parse error: {e}"),
50        })?;
51        manifest.validate()?;
52        Ok(manifest)
53    }
54
55    /// Load a manifest from a file path.
56    pub fn from_file(path: &Path) -> PluginResult<Self> {
57        let dir_name = path
58            .parent()
59            .and_then(|p| p.file_name())
60            .and_then(|n| n.to_str())
61            .unwrap_or("<unknown>");
62        let contents = std::fs::read_to_string(path).map_err(|e| PluginError::Manifest {
63            plugin_id: dir_name.to_string(),
64            message: format!("failed to read {}: {e}", path.display()),
65        })?;
66        Self::from_toml(&contents, dir_name)
67    }
68
69    /// Validate semantic constraints beyond TOML structure.
70    fn validate(&self) -> PluginResult<()> {
71        if self.id.is_empty() {
72            return Err(PluginError::Manifest {
73                plugin_id: self.id.clone(),
74                message: "'id' must not be empty".into(),
75            });
76        }
77        if self.name.is_empty() {
78            return Err(PluginError::Manifest {
79                plugin_id: self.id.clone(),
80                message: "'name' must not be empty".into(),
81            });
82        }
83        if self.version.is_empty() {
84            return Err(PluginError::Manifest {
85                plugin_id: self.id.clone(),
86                message: "'version' must not be empty".into(),
87            });
88        }
89        if let Some(ref entry_ui) = self.entry_ui {
90            if entry_ui.is_empty() {
91                return Err(PluginError::Manifest {
92                    plugin_id: self.id.clone(),
93                    message: "'entry_ui' must not be empty when specified".into(),
94                });
95            }
96            // entry_ui must be a relative path
97            if Path::new(entry_ui).is_absolute() {
98                return Err(PluginError::Manifest {
99                    plugin_id: self.id.clone(),
100                    message: format!("'entry_ui' must be a relative path, got '{entry_ui}'"),
101                });
102            }
103            // Reject path traversal
104            if entry_ui.contains("..") {
105                return Err(PluginError::Manifest {
106                    plugin_id: self.id.clone(),
107                    message: format!("'entry_ui' must not contain '..', got '{entry_ui}'"),
108                });
109            }
110        }
111        if let Some(ref entry_component) = self.entry_component
112            && entry_component.is_empty()
113        {
114            return Err(PluginError::Manifest {
115                plugin_id: self.id.clone(),
116                message: "'entry_component' must not be empty when specified".into(),
117            });
118        }
119        // Validate icon file path if present
120        if let Some(IconDescriptor::File { path }) = &self.icon {
121            if path.is_absolute() {
122                return Err(PluginError::Manifest {
123                    plugin_id: self.id.clone(),
124                    message: format!("icon file path must be relative, got '{}'", path.display()),
125                });
126            }
127            if path.to_string_lossy().contains("..") {
128                return Err(PluginError::Manifest {
129                    plugin_id: self.id.clone(),
130                    message: format!(
131                        "icon file path must not contain '..', got '{}'",
132                        path.display()
133                    ),
134                });
135            }
136        }
137        Ok(())
138    }
139
140    /// Resolve the `entry_ui` to an absolute path given the plugin root.
141    /// Returns `None` if `entry_ui` is not set.
142    pub fn resolve_entry_ui(&self, plugin_root: &Path) -> Option<PathBuf> {
143        self.entry_ui.as_ref().map(|ui| plugin_root.join(ui))
144    }
145
146    /// Validate that referenced files actually exist on disk.
147    pub fn validate_files(&self, plugin_root: &Path) -> PluginResult<()> {
148        if let Some(ui_path) = self.resolve_entry_ui(plugin_root)
149            && !ui_path.exists()
150        {
151            return Err(PluginError::MissingFile {
152                plugin_id: self.id.clone(),
153                path: ui_path,
154            });
155        }
156        if let Some(IconDescriptor::File { path }) = &self.icon {
157            let icon_path = plugin_root.join(path);
158            if !icon_path.exists() {
159                return Err(PluginError::MissingFile {
160                    plugin_id: self.id.clone(),
161                    path: icon_path,
162                });
163            }
164        }
165        Ok(())
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    const VALID_TOML: &str = r#"
174id = "test_plugin"
175name = "Test Plugin"
176version = "1.0.0"
177entry_ui = "ui/panel.slint"
178entry_component = "Panel"
179
180[icon]
181kind = "svg"
182data = "<svg/>"
183"#;
184
185    #[test]
186    fn parse_valid_manifest() {
187        let m = PluginManifest::from_toml(VALID_TOML, "test").unwrap();
188        assert_eq!(m.id, "test_plugin");
189        assert_eq!(m.name, "Test Plugin");
190        assert_eq!(m.version, "1.0.0");
191        assert_eq!(m.entry_ui.as_deref(), Some("ui/panel.slint"));
192        assert_eq!(m.entry_component.as_deref(), Some("Panel"));
193        assert_eq!(
194            m.icon,
195            Some(IconDescriptor::Svg {
196                data: "<svg/>".into()
197            })
198        );
199    }
200
201    #[test]
202    fn reject_missing_id() {
203        let toml = r#"
204name = "Test"
205version = "1.0.0"
206entry_ui = "ui/p.slint"
207entry_component = "P"
208"#;
209        let err = PluginManifest::from_toml(toml, "hint").unwrap_err();
210        assert!(err.to_string().contains("TOML parse error"));
211    }
212
213    #[test]
214    fn reject_empty_id() {
215        let toml = r#"
216id = ""
217name = "Test"
218version = "1.0.0"
219entry_ui = "ui/p.slint"
220entry_component = "P"
221"#;
222        let err = PluginManifest::from_toml(toml, "hint").unwrap_err();
223        assert!(err.to_string().contains("'id' must not be empty"));
224    }
225
226    #[test]
227    fn reject_absolute_entry_ui() {
228        let toml = r#"
229id = "abs"
230name = "Test"
231version = "1.0.0"
232entry_ui = "/etc/evil.slint"
233entry_component = "Evil"
234"#;
235        let err = PluginManifest::from_toml(toml, "hint").unwrap_err();
236        assert!(err.to_string().contains("relative path"));
237    }
238
239    #[test]
240    fn reject_path_traversal_in_entry_ui() {
241        let toml = r#"
242id = "trav"
243name = "Test"
244version = "1.0.0"
245entry_ui = "../escape/evil.slint"
246entry_component = "Evil"
247"#;
248        let err = PluginManifest::from_toml(toml, "hint").unwrap_err();
249        assert!(err.to_string().contains(".."));
250    }
251
252    #[test]
253    fn resolve_entry_ui_relative_to_root() {
254        let m = PluginManifest::from_toml(VALID_TOML, "test").unwrap();
255        let resolved = m.resolve_entry_ui(Path::new("/plugins/test_plugin"));
256        assert_eq!(
257            resolved,
258            Some(PathBuf::from("/plugins/test_plugin/ui/panel.slint"))
259        );
260    }
261
262    #[test]
263    fn validate_files_missing_ui() {
264        let m = PluginManifest::from_toml(VALID_TOML, "test").unwrap();
265        let err = m
266            .validate_files(Path::new("/nonexistent/plugin/root"))
267            .unwrap_err();
268        match err {
269            PluginError::MissingFile { plugin_id, .. } => {
270                assert_eq!(plugin_id, "test_plugin");
271            }
272            other => panic!("expected MissingFile, got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn parse_manifest_without_icon() {
278        let toml = r#"
279id = "no_icon"
280name = "No Icon Plugin"
281version = "0.1.0"
282entry_ui = "ui/panel.slint"
283entry_component = "Panel"
284"#;
285        let m = PluginManifest::from_toml(toml, "no_icon").unwrap();
286        assert!(m.icon.is_none());
287    }
288
289    #[test]
290    fn reject_absolute_icon_path() {
291        let toml = r#"
292id = "bad_icon"
293name = "Test"
294version = "1.0.0"
295entry_ui = "ui/p.slint"
296entry_component = "P"
297
298[icon]
299kind = "file"
300path = "/etc/icon.png"
301"#;
302        let err = PluginManifest::from_toml(toml, "hint").unwrap_err();
303        assert!(err.to_string().contains("icon file path must be relative"));
304    }
305}