eov_plugin_api/
manifest.rs1use crate::{IconDescriptor, PluginError, PluginResult};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[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 pub icon_svg: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PluginManifest {
20 pub id: String,
22 pub name: String,
24 pub version: String,
26 #[serde(default)]
29 pub entry_ui: Option<String>,
30 #[serde(default)]
33 pub entry_component: Option<String>,
34 pub icon: Option<IconDescriptor>,
36 #[serde(default)]
38 pub toolbar_buttons: Vec<ManifestToolbarButton>,
39}
40
41pub const MANIFEST_FILENAME: &str = "plugin.toml";
43
44impl PluginManifest {
45 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 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 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 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 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 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 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 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}