1use std::io;
7use std::path::{Path, PathBuf};
8
9use log::error;
10
11use crate::plugins::PluginRegistry;
12use crate::secure_io::{read_capped, write_user_only};
13
14const MAX_REGISTRY_BYTES: u64 = 4 * 1024 * 1024;
17
18fn modules_dir(registry_path: &Path) -> PathBuf {
21 match registry_path.parent() {
22 Some(dir) => dir.join("plugin-modules"),
23 None => PathBuf::from("plugin-modules"),
24 }
25}
26
27pub fn module_path(registry_path: &Path, id: &str) -> PathBuf {
31 modules_dir(registry_path).join(format!("{id}.wasm"))
32}
33
34pub fn save_module(registry_path: &Path, id: &str, bytes: &[u8]) -> io::Result<()> {
36 write_user_only(&module_path(registry_path, id), bytes)
37}
38
39const MAX_MODULE_BYTES: u64 = 16 * 1024 * 1024;
41
42pub fn load_module(registry_path: &Path, id: &str) -> io::Result<Vec<u8>> {
45 let path = module_path(registry_path, id);
46 let meta = std::fs::metadata(&path)?;
47 if meta.len() > MAX_MODULE_BYTES {
48 return Err(io::Error::new(
49 io::ErrorKind::InvalidData,
50 "plugin module exceeds the size cap",
51 ));
52 }
53 std::fs::read(&path)
54}
55
56pub fn delete_module(registry_path: &Path, id: &str) {
58 let path = module_path(registry_path, id);
59 if let Err(e) = std::fs::remove_file(&path) {
60 if e.kind() != io::ErrorKind::NotFound {
61 error!("plugin_store: failed to remove {}: {e}", path.display());
62 }
63 }
64}
65
66pub fn asset_file_name(plugin_id: &str, asset_id: &str, ext: &str) -> String {
71 format!("{plugin_id}.{asset_id}.{ext}")
72}
73
74pub fn asset_path(registry_path: &Path, file_name: &str) -> PathBuf {
76 modules_dir(registry_path).join(file_name)
77}
78
79pub fn save_asset(registry_path: &Path, file_name: &str, bytes: &[u8]) -> io::Result<()> {
81 write_user_only(&asset_path(registry_path, file_name), bytes)
82}
83
84pub fn delete_asset(registry_path: &Path, file_name: &str) {
86 let path = asset_path(registry_path, file_name);
87 if let Err(e) = std::fs::remove_file(&path) {
88 if e.kind() != io::ErrorKind::NotFound {
89 error!("plugin_store: failed to remove {}: {e}", path.display());
90 }
91 }
92}
93
94pub fn load(path: &Path) -> PluginRegistry {
96 match read_capped(path, MAX_REGISTRY_BYTES) {
97 Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
98 error!("plugin_store: failed to parse {}: {e}", path.display());
99 PluginRegistry::default()
100 }),
101 Err(e) if e.kind() == io::ErrorKind::NotFound => PluginRegistry::default(),
102 Err(e) => {
103 error!("plugin_store: failed to read {}: {e}", path.display());
104 PluginRegistry::default()
105 }
106 }
107}
108
109pub fn save(path: &Path, registry: &PluginRegistry) -> io::Result<()> {
111 let body = serde_json::to_string_pretty(registry).map_err(io::Error::other)?;
112 write_user_only(path, body.as_bytes())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::plugins::registry::InstalledPlugin;
119 use crate::plugins::PluginKind;
120
121 fn temp_path() -> (tempfile::TempDir, std::path::PathBuf) {
122 let dir = tempfile::tempdir().unwrap();
123 let path = dir.path().join("plugins.json");
124 (dir, path)
125 }
126
127 fn sample() -> PluginRegistry {
128 let mut reg = PluginRegistry::default();
129 reg.insert(InstalledPlugin {
130 id: "com.x.pack".to_string(),
131 name: "Pack".to_string(),
132 author: "Me".to_string(),
133 version: "1.0.0".to_string(),
134 kind: PluginKind::Content,
135 public_key: "AA==".to_string(),
136 added: Default::default(),
137 capabilities: Vec::new(),
138 detect: None,
139 export: None,
140 });
141 reg
142 }
143
144 #[test]
145 fn missing_file_loads_empty() {
146 let (_d, path) = temp_path();
147 assert_eq!(load(&path), PluginRegistry::default());
148 }
149
150 #[test]
151 fn module_save_load_delete_round_trip() {
152 let (_d, path) = temp_path();
153 let mp = module_path(&path, "com.x.detector");
154 assert_eq!(mp.file_name().unwrap(), "com.x.detector.wasm");
155 assert!(mp.starts_with(path.parent().unwrap().join("plugin-modules")));
156
157 save_module(&path, "com.x.detector", b"\0asm bytes").unwrap();
158 assert_eq!(std::fs::read(&mp).unwrap(), b"\0asm bytes");
159
160 delete_module(&path, "com.x.detector");
161 assert!(!mp.exists());
162 delete_module(&path, "com.x.detector");
164 }
165
166 #[test]
167 fn load_module_reads_saved_and_errors_on_missing_or_oversized() {
168 let (_d, path) = temp_path();
169 assert!(load_module(&path, "com.x.det").is_err(), "missing → err");
170
171 save_module(&path, "com.x.det", b"\0asm bytes").unwrap();
172 assert_eq!(load_module(&path, "com.x.det").unwrap(), b"\0asm bytes");
173
174 save_module(
175 &path,
176 "com.x.big",
177 &vec![0u8; (MAX_MODULE_BYTES + 1) as usize],
178 )
179 .unwrap();
180 let err = load_module(&path, "com.x.big").unwrap_err();
181 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
182 }
183
184 #[test]
185 fn module_path_falls_back_when_registry_has_no_parent() {
186 let p = module_path(Path::new(""), "com.x.detector");
189 assert_eq!(p, Path::new("plugin-modules").join("com.x.detector.wasm"));
190 }
191
192 #[test]
193 fn delete_module_logs_and_continues_when_the_path_is_not_removable() {
194 let (_d, path) = temp_path();
197 let mp = module_path(&path, "com.x.detector");
198 std::fs::create_dir_all(&mp).unwrap();
199 delete_module(&path, "com.x.detector"); assert!(mp.exists(), "the directory is left in place");
201 }
202
203 #[test]
204 fn save_load_and_delete_an_asset_round_trips() {
205 let (_d, path) = temp_path();
206 let name = asset_file_name("com.x.yoga", "twist", "png");
207 save_asset(&path, &name, b"\x89PNG fake").unwrap();
208 assert!(asset_path(&path, &name).exists());
209 delete_asset(&path, &name);
210 assert!(!asset_path(&path, &name).exists());
211 delete_asset(&path, &name); }
213
214 #[test]
215 fn delete_asset_logs_and_continues_when_the_path_is_not_removable() {
216 let (_d, path) = temp_path();
219 let name = asset_file_name("com.x.yoga", "twist", "png");
220 std::fs::create_dir_all(asset_path(&path, &name)).unwrap();
221 delete_asset(&path, &name); assert!(asset_path(&path, &name).exists(), "the directory remains");
223 }
224
225 #[test]
226 fn save_then_load_round_trips() {
227 let (_d, path) = temp_path();
228 let reg = sample();
229 save(&path, ®).unwrap();
230 assert_eq!(load(&path), reg);
231 }
232
233 #[test]
234 fn malformed_file_loads_empty() {
235 let (_d, path) = temp_path();
236 std::fs::write(&path, b"{ not json").unwrap();
237 assert_eq!(load(&path), PluginRegistry::default());
238 }
239
240 #[test]
241 fn unreadable_path_loads_empty() {
242 let dir = tempfile::tempdir().unwrap();
245 let as_dir = dir.path().join("subdir");
246 std::fs::create_dir(&as_dir).unwrap();
247 assert_eq!(load(&as_dir), PluginRegistry::default());
248 }
249}