Skip to main content

entracte_lib/
plugin_store.rs

1//! Load/save the installed-plugin registry (`plugins.json`), mirroring
2//! `pause_store` / `screen_time_store`: a capped read and an atomic,
3//! `0o600` write via `secure_io`. A missing or unparseable file yields an
4//! empty registry rather than failing startup.
5
6use 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
14/// Defensive cap on the registry file. Generous: each record is small
15/// provenance + a list of the strings the plugin added.
16const MAX_REGISTRY_BYTES: u64 = 4 * 1024 * 1024;
17
18/// Directory holding installed detector/export module binaries, beside
19/// `plugins.json`.
20fn 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
27/// On-disk path for plugin `id`'s wasm module. `id` is reverse-DNS
28/// (`[a-z0-9.-]`, validated at install) so `{id}.wasm` is always a single
29/// filename component — no path separators, no traversal.
30pub fn module_path(registry_path: &Path, id: &str) -> PathBuf {
31    modules_dir(registry_path).join(format!("{id}.wasm"))
32}
33
34/// Atomically persist a plugin's wasm module with owner-only permissions.
35pub fn save_module(registry_path: &Path, id: &str, bytes: &[u8]) -> io::Result<()> {
36    write_user_only(&module_path(registry_path, id), bytes)
37}
38
39/// Cap on a module read back from disk — matches the install-time decode cap.
40const MAX_MODULE_BYTES: u64 = 16 * 1024 * 1024;
41
42/// Read a plugin's wasm module from disk (size-capped). Errors if missing,
43/// oversized, or unreadable — callers treat that as "no detector to run".
44pub 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
56/// Remove a plugin's module file. Missing is fine (idempotent uninstall).
57pub 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
66/// Sidecar filename for one of plugin `plugin_id`'s image assets. Both
67/// `plugin_id` (reverse-DNS) and `asset_id` (`[a-z0-9._-]`) are validated at
68/// install, so the result is a single safe filename component — no separators,
69/// no traversal. Recorded in the registry so uninstall can remove exactly it.
70pub fn asset_file_name(plugin_id: &str, asset_id: &str, ext: &str) -> String {
71    format!("{plugin_id}.{asset_id}.{ext}")
72}
73
74/// Absolute path for an asset sidecar named `file_name`, beside the modules.
75pub fn asset_path(registry_path: &Path, file_name: &str) -> PathBuf {
76    modules_dir(registry_path).join(file_name)
77}
78
79/// Atomically persist an image asset with owner-only permissions.
80pub 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
84/// Remove an asset sidecar. Missing is fine (idempotent uninstall).
85pub 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
94/// Load the registry, defaulting to empty on a missing or malformed file.
95pub 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
109/// Atomically persist the registry with owner-only permissions.
110pub 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        // Idempotent: deleting again is a no-op, not an error.
163        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        // A bare path has no parent dir; modules still resolve to a relative
187        // `plugin-modules/` rather than panicking.
188        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        // A directory where the module file would be: `remove_file` fails with
195        // a non-NotFound error, which is logged rather than panicking.
196        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"); // must not panic
200        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); // missing now: idempotent, no panic
212    }
213
214    #[test]
215    fn delete_asset_logs_and_continues_when_the_path_is_not_removable() {
216        // A directory where the sidecar would be: `remove_file` fails with a
217        // non-NotFound error, which is logged rather than panicking.
218        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); // must not panic
222        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, &reg).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        // A path that exists but isn't a readable file (a directory) hits the
243        // generic read-error arm, which still degrades to an empty registry.
244        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}