Skip to main content

entracte_lib/plugins/
registry.rs

1//! The installed-plugin registry: provenance for each installed plugin plus
2//! the merge-and-track record of exactly what content it added, so an
3//! uninstall can remove precisely those entries. Pure data + pure methods;
4//! the on-disk persistence is in `crate::plugin_store`.
5//!
6//! We deliberately store a lightweight provenance record rather than the
7//! whole manifest: a content plugin's pack already lives in `settings.json`
8//! after merge, so re-storing it here would duplicate it. What we keep is
9//! what an uninstall and the Settings UI need — identity, author/version,
10//! the signing key (for future key-pinning), and the [`AddedContent`].
11
12use serde::{Deserialize, Serialize};
13
14use super::manifest::{DetectConfig, ExportConfig, Manifest, PluginKind};
15use crate::scheduler::content_pack::AddedContent;
16
17/// One installed plugin: provenance + what it added to settings.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct InstalledPlugin {
20    pub id: String,
21    pub name: String,
22    #[serde(default)]
23    pub author: String,
24    pub version: String,
25    pub kind: PluginKind,
26    /// The base64 ed25519 public key the manifest was signed with. Recorded
27    /// so a future update flow can detect a key change (TOFU pinning).
28    pub public_key: String,
29    /// The concrete content this plugin added, for a clean uninstall.
30    /// Empty for non-content plugins.
31    #[serde(default)]
32    pub added: AddedContent,
33    /// Granted capability strings (a detector's imports), so the eval worker
34    /// can rebuild the sandbox with exactly the consented host functions.
35    /// Empty for content plugins.
36    #[serde(default)]
37    pub capabilities: Vec<String>,
38    /// Detector configuration (the process pattern), threaded into the
39    /// sandbox context at eval time. `None` for non-detectors.
40    #[serde(default)]
41    pub detect: Option<DetectConfig>,
42    /// Export configuration (sink / format / destination / events) for an
43    /// export adapter, read by the delivery path. `None` for non-exports.
44    #[serde(default)]
45    pub export: Option<ExportConfig>,
46}
47
48impl InstalledPlugin {
49    /// Build a registry record from a validated manifest and the content its
50    /// install actually merged.
51    pub fn from_manifest(manifest: &Manifest, added: AddedContent) -> Self {
52        Self {
53            id: manifest.id.clone(),
54            name: manifest.name.clone(),
55            author: manifest.author.clone(),
56            version: manifest.version.clone(),
57            kind: manifest.kind,
58            public_key: manifest.signature.public_key.clone(),
59            added,
60            capabilities: Vec::new(),
61            detect: None,
62            export: None,
63        }
64    }
65
66    /// Build a registry record for an installed detector: its granted
67    /// capabilities and detect config travel with it so the eval worker can
68    /// reconstruct the sandbox. The module bytes live on disk, keyed by id.
69    pub fn from_detector(manifest: &Manifest) -> Self {
70        Self {
71            id: manifest.id.clone(),
72            name: manifest.name.clone(),
73            author: manifest.author.clone(),
74            version: manifest.version.clone(),
75            kind: manifest.kind,
76            public_key: manifest.signature.public_key.clone(),
77            added: AddedContent::default(),
78            capabilities: manifest.imports.clone(),
79            detect: manifest.detect.clone(),
80            export: None,
81        }
82    }
83
84    /// Build a registry record for an installed export adapter: its export
85    /// config travels with it so the delivery path can render + deliver stats.
86    pub fn from_export(manifest: &Manifest) -> Self {
87        Self {
88            id: manifest.id.clone(),
89            name: manifest.name.clone(),
90            author: manifest.author.clone(),
91            version: manifest.version.clone(),
92            kind: manifest.kind,
93            public_key: manifest.signature.public_key.clone(),
94            added: AddedContent::default(),
95            capabilities: Vec::new(),
96            detect: None,
97            export: manifest.export.clone(),
98        }
99    }
100}
101
102/// The set of installed plugins. Keyed by `id` (unique).
103#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct PluginRegistry {
105    #[serde(default)]
106    pub plugins: Vec<InstalledPlugin>,
107}
108
109impl PluginRegistry {
110    /// Whether a plugin with this id is already installed.
111    pub fn contains(&self, id: &str) -> bool {
112        self.plugins.iter().any(|p| p.id == id)
113    }
114
115    /// Add a record. Caller must have checked [`Self::contains`] first; this
116    /// does not dedupe.
117    pub fn insert(&mut self, plugin: InstalledPlugin) {
118        self.plugins.push(plugin);
119    }
120
121    /// Remove and return the record for `id`, if present.
122    pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
123        let idx = self.plugins.iter().position(|p| p.id == id)?;
124        Some(self.plugins.remove(idx))
125    }
126
127    /// A renderer-facing summary of each installed plugin, in install order.
128    pub fn summaries(&self) -> Vec<PluginSummary> {
129        self.plugins.iter().map(PluginSummary::from).collect()
130    }
131
132    /// The export configs of every installed export adapter subscribed to
133    /// `event`, for the delivery path. Install order.
134    pub fn export_configs_for(&self, event: crate::hooks::HookEvent) -> Vec<ExportConfig> {
135        self.plugins
136            .iter()
137            .filter(|p| p.kind == PluginKind::Export)
138            .filter_map(|p| p.export.clone())
139            .filter(|cfg| cfg.on.contains(&event))
140            .collect()
141    }
142
143    /// What the off-tick eval task needs for each installed detector: id (to
144    /// load its module), parsed granted capabilities (to rebuild the sandbox),
145    /// and the process pattern. Capability strings that fail to parse are
146    /// dropped — they were validated at install, so this is belt-and-braces.
147    pub fn detector_snapshots(&self) -> Vec<super::eval::DetectorSnapshot> {
148        self.plugins
149            .iter()
150            .filter(|p| p.kind == PluginKind::Detector)
151            .map(|p| super::eval::DetectorSnapshot {
152                id: p.id.clone(),
153                capabilities: p
154                    .capabilities
155                    .iter()
156                    .filter_map(|c| super::manifest::Capability::parse(c).ok())
157                    .collect(),
158                process_pattern: p.detect.as_ref().and_then(|d| d.process_name.clone()),
159            })
160            .collect()
161    }
162}
163
164/// What the Settings UI shows per installed plugin. Counts come from the
165/// merge-and-track record so the user sees the effect of each plugin.
166#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
167pub struct PluginSummary {
168    pub id: String,
169    pub name: String,
170    pub author: String,
171    pub version: String,
172    pub kind: PluginKind,
173    pub hints_added: usize,
174    pub routines_added: usize,
175}
176
177impl From<&InstalledPlugin> for PluginSummary {
178    fn from(p: &InstalledPlugin) -> Self {
179        let a = &p.added;
180        let hints_added = a.micro_physical.len()
181            + a.micro_psychological.len()
182            + a.long_solo.len()
183            + a.long_social.len()
184            + a.sleep.len();
185        Self {
186            id: p.id.clone(),
187            name: p.name.clone(),
188            author: p.author.clone(),
189            version: p.version.clone(),
190            kind: p.kind,
191            hints_added,
192            routines_added: a.routine_ids.len(),
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn record(id: &str) -> InstalledPlugin {
202        InstalledPlugin {
203            id: id.to_string(),
204            name: "Pack".to_string(),
205            author: "Me".to_string(),
206            version: "1.0.0".to_string(),
207            kind: PluginKind::Content,
208            public_key: "AA==".to_string(),
209            added: AddedContent {
210                micro_physical: vec!["a".to_string(), "b".to_string()],
211                routine_ids: vec!["r1".to_string()],
212                ..AddedContent::default()
213            },
214            capabilities: Vec::new(),
215            detect: None,
216            export: None,
217        }
218    }
219
220    fn detector_record(id: &str) -> InstalledPlugin {
221        InstalledPlugin {
222            id: id.to_string(),
223            name: "Detector".to_string(),
224            author: "Me".to_string(),
225            version: "1.0.0".to_string(),
226            kind: PluginKind::Detector,
227            public_key: "AA==".to_string(),
228            added: AddedContent::default(),
229            // One valid capability + one junk string that must be dropped.
230            capabilities: vec!["detect:processes".to_string(), "garbage".to_string()],
231            detect: Some(DetectConfig {
232                process_name: Some("zoom".to_string()),
233            }),
234            export: None,
235        }
236    }
237
238    #[test]
239    fn detector_snapshots_parses_caps_and_excludes_content() {
240        use super::super::manifest::Capability;
241        let mut reg = PluginRegistry::default();
242        reg.insert(record("com.x.content")); // content → excluded
243        reg.insert(detector_record("com.x.det"));
244
245        let snaps = reg.detector_snapshots();
246        assert_eq!(snaps.len(), 1);
247        assert_eq!(snaps[0].id, "com.x.det");
248        // The junk capability string was dropped; the valid one parsed.
249        assert_eq!(snaps[0].capabilities, vec![Capability::DetectProcesses]);
250        assert_eq!(snaps[0].process_pattern, Some("zoom".to_string()));
251    }
252
253    #[test]
254    fn insert_contains_remove() {
255        let mut reg = PluginRegistry::default();
256        assert!(!reg.contains("com.x.pack"));
257        reg.insert(record("com.x.pack"));
258        assert!(reg.contains("com.x.pack"));
259
260        let removed = reg.remove("com.x.pack").unwrap();
261        assert_eq!(removed.id, "com.x.pack");
262        assert!(!reg.contains("com.x.pack"));
263        assert!(reg.remove("com.x.pack").is_none());
264    }
265
266    #[test]
267    fn summaries_count_hints_and_routines() {
268        let mut reg = PluginRegistry::default();
269        reg.insert(record("com.x.pack"));
270        let s = reg.summaries();
271        assert_eq!(s.len(), 1);
272        assert_eq!(s[0].hints_added, 2);
273        assert_eq!(s[0].routines_added, 1);
274        assert_eq!(s[0].kind, PluginKind::Content);
275    }
276
277    #[test]
278    fn registry_serde_round_trips() {
279        let mut reg = PluginRegistry::default();
280        reg.insert(record("com.x.pack"));
281        let json = serde_json::to_string(&reg).unwrap();
282        let back: PluginRegistry = serde_json::from_str(&json).unwrap();
283        assert_eq!(back, reg);
284    }
285}