Skip to main content

entracte_lib/plugins/
install.rs

1//! Pure install-time validation. No I/O, no settings mutation — the command
2//! layer (`scheduler::commands::plugins`) handles the file read, the consent
3//! dialog, the content merge under lock, and persistence. Keeping the gate
4//! pure makes every rejection path unit-testable.
5
6use base64::prelude::{Engine, BASE64_STANDARD};
7
8use super::manifest::{parse_manifest, validate_manifest, Capability, Manifest, PluginKind};
9use super::registry::PluginRegistry;
10use super::runtime::{build_sandboxed_plugin, SandboxContext};
11use super::signature::{sha256, verify_signature};
12
13/// Cap on a decoded wasm module. Generous for a real detector/export module
14/// while bounding a hostile base64 blob.
15const MAX_MODULE_BYTES: usize = 16 * 1024 * 1024;
16
17/// Validate an incoming content-plugin manifest end to end and return it
18/// ready to merge. Runs, in order: JSON parse, schema validation, the
19/// content-only gate, signature verification, and the not-already-installed
20/// check. Detector/export plugins validate but are rejected here — they need
21/// the wasm runtime that a later slice adds.
22///
23/// Signature note: a content plugin ships no wasm module, so the signature
24/// is verified over the manifest alone (no module hash).
25pub fn prepare_content_install(
26    manifest_json: &str,
27    registry: &PluginRegistry,
28) -> Result<Manifest, String> {
29    let manifest = parse_manifest(manifest_json)?;
30    validate_manifest(&manifest)?;
31
32    if manifest.kind != PluginKind::Content {
33        return Err("this installer handles content plugins only".to_string());
34    }
35
36    verify_signature(&manifest, None)?;
37
38    if registry.contains(&manifest.id) {
39        return Err(format!(
40            "plugin '{}' is already installed; remove it first",
41            manifest.id
42        ));
43    }
44
45    Ok(manifest)
46}
47
48/// Validate an incoming export-plugin manifest end to end and return it ready
49/// to register. Like content, an export adapter is declarative (no wasm), so
50/// the signature is verified over the manifest alone. Runs: parse → schema
51/// validation (which checks the export config) → export-only gate → signature
52/// → not-already-installed.
53pub fn prepare_export_install(
54    manifest_json: &str,
55    registry: &PluginRegistry,
56) -> Result<Manifest, String> {
57    let manifest = parse_manifest(manifest_json)?;
58    validate_manifest(&manifest)?;
59
60    if manifest.kind != PluginKind::Export {
61        return Err("this installer handles export plugins only".to_string());
62    }
63
64    verify_signature(&manifest, None)?;
65
66    if registry.contains(&manifest.id) {
67        return Err(format!(
68            "plugin '{}' is already installed; remove it first",
69            manifest.id
70        ));
71    }
72
73    Ok(manifest)
74}
75
76/// A detector validated and ready to install: the manifest plus its decoded,
77/// signature-verified, sandbox-linkable wasm module.
78// Fields read by the install command in the next slice.
79#[allow(dead_code)]
80#[derive(Debug)]
81pub struct PreparedDetector {
82    pub manifest: Manifest,
83    pub module: Vec<u8>,
84}
85
86/// Validate an incoming detector-plugin manifest end to end and return it with
87/// its decoded module, ready to persist. Runs, in order: parse, schema
88/// validation, the detector-only gate, base64-decode of the embedded module
89/// (size-capped), signature verification (binding the manifest **and** the
90/// module hash), the not-already-installed check, and — critically — an
91/// **install-time link check**: the module is instantiated in the sandbox with
92/// exactly the granted capabilities, which fails if it imports a host function
93/// whose capability wasn't granted. That's the bidirectional half of the
94/// import↔grant model and proves the module actually loads before we keep it.
95#[allow(dead_code)] // consumed by the install command in the next slice.
96pub fn prepare_detector_install(
97    manifest_json: &str,
98    registry: &PluginRegistry,
99) -> Result<PreparedDetector, String> {
100    let manifest = parse_manifest(manifest_json)?;
101    validate_manifest(&manifest)?;
102
103    if manifest.kind != PluginKind::Detector {
104        return Err("this installer handles detector plugins only".to_string());
105    }
106
107    let encoded = manifest
108        .module_base64
109        .as_deref()
110        .ok_or_else(|| "detector plugin is missing its module".to_string())?;
111    let module = BASE64_STANDARD
112        .decode(encoded.as_bytes())
113        .map_err(|_| "plugin module is not valid base64".to_string())?;
114    if module.is_empty() {
115        return Err("plugin module is empty".to_string());
116    }
117    if module.len() > MAX_MODULE_BYTES {
118        return Err(format!(
119            "plugin module exceeds {} MiB",
120            MAX_MODULE_BYTES / (1024 * 1024)
121        ));
122    }
123
124    verify_signature(&manifest, Some(sha256(&module)))?;
125
126    if registry.contains(&manifest.id) {
127        return Err(format!(
128            "plugin '{}' is already installed; remove it first",
129            manifest.id
130        ));
131    }
132
133    // Install-time link check: the module must instantiate against *only* the
134    // granted host functions. An ungranted import fails the wasmtime link.
135    let capabilities = manifest
136        .imports
137        .iter()
138        .map(|i| Capability::parse(i))
139        .collect::<Result<Vec<_>, _>>()?;
140    let ctx = SandboxContext {
141        process_pattern: manifest
142            .detect
143            .as_ref()
144            .and_then(|d| d.process_name.clone()),
145        ..Default::default()
146    };
147    build_sandboxed_plugin(&module, &capabilities, &ctx)
148        .map_err(|e| format!("plugin module failed the sandbox link check: {e}"))?;
149
150    Ok(PreparedDetector { manifest, module })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::plugins::registry::InstalledPlugin;
157    use crate::plugins::signature::{sha256, signing_payload};
158    use crate::scheduler::content_pack::{ContentPack, PackHints, CONTENT_PACK_VERSION};
159    use base64::prelude::{Engine, BASE64_STANDARD};
160    use ed25519_dalek::{Signer, SigningKey};
161
162    fn pack() -> ContentPack {
163        ContentPack {
164            version: CONTENT_PACK_VERSION,
165            name: "Pack".to_string(),
166            hints: PackHints {
167                micro_physical: vec!["Stretch".to_string()],
168                ..PackHints::default()
169            },
170            routines: vec![],
171        }
172    }
173
174    /// Build a signed content-plugin manifest JSON with a deterministic key.
175    fn signed_content_manifest_json(id: &str) -> String {
176        let mut m = Manifest {
177            manifest_version: crate::plugins::manifest::MANIFEST_VERSION,
178            id: id.to_string(),
179            name: "Idea pack".to_string(),
180            version: "1.0.0".to_string(),
181            author: "Jane".to_string(),
182            description: String::new(),
183            kind: PluginKind::Content,
184            module: None,
185            module_base64: None,
186            abi_version: None,
187            imports: vec![],
188            detect: None,
189            export: None,
190            content: Some(pack()),
191            assets: Vec::new(),
192            signature: super::super::manifest::Signature {
193                alg: "ed25519".to_string(),
194                public_key: String::new(),
195                sig: String::new(),
196            },
197        };
198        let key = SigningKey::from_bytes(&[5u8; 32]);
199        m.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
200        let sig = key.sign(&signing_payload(&m, None));
201        m.signature.sig = BASE64_STANDARD.encode(sig.to_bytes());
202        serde_json::to_string(&m).unwrap()
203    }
204
205    #[test]
206    fn accepts_a_signed_content_plugin() {
207        let json = signed_content_manifest_json("com.example.pack");
208        let m = prepare_content_install(&json, &PluginRegistry::default()).unwrap();
209        assert_eq!(m.id, "com.example.pack");
210        assert!(m.content.is_some());
211    }
212
213    fn signed_export_manifest_json(id: &str) -> String {
214        use crate::plugins::{ExportConfig, ExportFormat, ExportSink};
215        let mut m = Manifest {
216            manifest_version: crate::plugins::manifest::MANIFEST_VERSION,
217            id: id.to_string(),
218            name: "JSON export".to_string(),
219            version: "1.0.0".to_string(),
220            author: "Jane".to_string(),
221            description: String::new(),
222            kind: PluginKind::Export,
223            module: None,
224            module_base64: None,
225            abi_version: None,
226            imports: vec![],
227            detect: None,
228            export: Some(ExportConfig {
229                sink: ExportSink::Http,
230                format: ExportFormat::Json,
231                destination: "https://example.test/ingest".to_string(),
232                on: vec![crate::hooks::HookEvent::BreakEnd],
233            }),
234            content: None,
235            assets: Vec::new(),
236            signature: super::super::manifest::Signature {
237                alg: "ed25519".to_string(),
238                public_key: String::new(),
239                sig: String::new(),
240            },
241        };
242        let key = SigningKey::from_bytes(&[12u8; 32]);
243        m.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
244        m.signature.sig = BASE64_STANDARD.encode(key.sign(&signing_payload(&m, None)).to_bytes());
245        serde_json::to_string(&m).unwrap()
246    }
247
248    #[test]
249    fn accepts_a_signed_export_plugin() {
250        let json = signed_export_manifest_json("com.example.exp");
251        let m = prepare_export_install(&json, &PluginRegistry::default()).unwrap();
252        assert_eq!(m.id, "com.example.exp");
253        assert!(m.export.is_some());
254    }
255
256    #[test]
257    fn export_install_rejects_a_content_manifest() {
258        // Routed to the wrong installer: a content manifest reaches the
259        // export path and is refused before anything else.
260        let json = signed_content_manifest_json("com.example.pack");
261        assert!(prepare_export_install(&json, &PluginRegistry::default())
262            .unwrap_err()
263            .contains("handles export plugins only"));
264    }
265
266    #[test]
267    fn export_install_rejects_an_already_installed_id() {
268        let json = signed_export_manifest_json("com.example.exp");
269        let mut reg = PluginRegistry::default();
270        reg.insert(InstalledPlugin::from_export(
271            &parse_manifest(&json).unwrap(),
272        ));
273        assert!(prepare_export_install(&json, &reg)
274            .unwrap_err()
275            .contains("already installed"));
276    }
277
278    #[test]
279    fn rejects_a_bad_signature() {
280        let mut json_manifest: Manifest =
281            serde_json::from_str(&signed_content_manifest_json("com.example.pack")).unwrap();
282        json_manifest.name = "Tampered".to_string();
283        let json = serde_json::to_string(&json_manifest).unwrap();
284        assert!(prepare_content_install(&json, &PluginRegistry::default())
285            .unwrap_err()
286            .contains("does not match"));
287    }
288
289    #[test]
290    fn rejects_an_already_installed_id() {
291        let json = signed_content_manifest_json("com.example.pack");
292        let mut reg = PluginRegistry::default();
293        reg.insert(InstalledPlugin {
294            id: "com.example.pack".to_string(),
295            name: "Pack".to_string(),
296            author: String::new(),
297            version: "1.0.0".to_string(),
298            kind: PluginKind::Content,
299            public_key: "AA==".to_string(),
300            added: Default::default(),
301            capabilities: Vec::new(),
302            detect: None,
303            export: None,
304        });
305        assert!(prepare_content_install(&json, &reg)
306            .unwrap_err()
307            .contains("already installed"));
308    }
309
310    #[test]
311    fn content_installer_rejects_a_detector() {
312        let json = signed_detector_json(
313            "com.example.detector",
314            "detect:processes",
315            "host_process_running",
316        );
317        assert!(prepare_content_install(&json, &PluginRegistry::default())
318            .unwrap_err()
319            .contains("content plugins only"));
320    }
321
322    #[test]
323    fn rejects_malformed_json() {
324        assert!(prepare_content_install("{ not json", &PluginRegistry::default()).is_err());
325    }
326
327    /// A minimal detector module that imports the named `() -> i64` host
328    /// function and returns its result as the detect verdict.
329    fn detector_module(host_fn: &str) -> Vec<u8> {
330        wat::parse_str(format!(
331            r#"(module
332                 (import "extism:host/user" "{host_fn}" (func $f (result i64)))
333                 (memory (export "memory") 1)
334                 (func (export "detect") (result i32) (i32.wrap_i64 (call $f))))"#
335        ))
336        .unwrap()
337    }
338
339    /// Build a signed detector-plugin JSON whose module imports `host_fn` and
340    /// whose manifest declares `import`. A `detect:processes` import also gets
341    /// a process_name (required by validation).
342    fn signed_detector_json(id: &str, import: &str, host_fn: &str) -> String {
343        use crate::plugins::manifest::DetectConfig;
344        let module = detector_module(host_fn);
345        let mut m = Manifest {
346            manifest_version: crate::plugins::manifest::MANIFEST_VERSION,
347            id: id.to_string(),
348            name: "Focus detector".to_string(),
349            version: "1.0.0".to_string(),
350            author: "Jane".to_string(),
351            description: String::new(),
352            kind: PluginKind::Detector,
353            module: Some("module.wasm".to_string()),
354            module_base64: Some(BASE64_STANDARD.encode(&module)),
355            abi_version: Some(crate::plugins::manifest::SUPPORTED_ABI_VERSION),
356            imports: vec![import.to_string()],
357            detect: (import == "detect:processes").then(|| DetectConfig {
358                process_name: Some("zoom".to_string()),
359            }),
360            export: None,
361            content: None,
362            assets: Vec::new(),
363            signature: super::super::manifest::Signature {
364                alg: "ed25519".to_string(),
365                public_key: String::new(),
366                sig: String::new(),
367            },
368        };
369        let key = SigningKey::from_bytes(&[8u8; 32]);
370        m.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
371        m.signature.sig = BASE64_STANDARD.encode(
372            key.sign(&signing_payload(&m, Some(sha256(&module))))
373                .to_bytes(),
374        );
375        serde_json::to_string(&m).unwrap()
376    }
377
378    #[test]
379    fn accepts_a_signed_detector_whose_module_links() {
380        let json = signed_detector_json(
381            "com.example.focus",
382            "detect:processes",
383            "host_process_running",
384        );
385        let prepared = prepare_detector_install(&json, &PluginRegistry::default()).unwrap();
386        assert_eq!(prepared.manifest.id, "com.example.focus");
387        assert!(!prepared.module.is_empty());
388    }
389
390    #[test]
391    fn rejects_a_detector_module_that_imports_an_ungranted_host_function() {
392        // Manifest grants detect:processes, but the module imports the
393        // foreground-window host function — the link check must reject it.
394        use crate::plugins::manifest::DetectConfig;
395        let module = detector_module("host_foreground_window");
396        let mut m = Manifest {
397            manifest_version: crate::plugins::manifest::MANIFEST_VERSION,
398            id: "com.example.sneaky".to_string(),
399            name: "Sneaky".to_string(),
400            version: "1.0.0".to_string(),
401            author: String::new(),
402            description: String::new(),
403            kind: PluginKind::Detector,
404            module: Some("module.wasm".to_string()),
405            module_base64: Some(BASE64_STANDARD.encode(&module)),
406            abi_version: Some(crate::plugins::manifest::SUPPORTED_ABI_VERSION),
407            imports: vec!["detect:processes".to_string()],
408            detect: Some(DetectConfig {
409                process_name: Some("zoom".to_string()),
410            }),
411            export: None,
412            content: None,
413            assets: Vec::new(),
414            signature: super::super::manifest::Signature {
415                alg: "ed25519".to_string(),
416                public_key: String::new(),
417                sig: String::new(),
418            },
419        };
420        let key = SigningKey::from_bytes(&[9u8; 32]);
421        m.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
422        m.signature.sig = BASE64_STANDARD.encode(
423            key.sign(&signing_payload(&m, Some(sha256(&module))))
424                .to_bytes(),
425        );
426        let json = serde_json::to_string(&m).unwrap();
427        assert!(prepare_detector_install(&json, &PluginRegistry::default())
428            .unwrap_err()
429            .contains("sandbox link check"));
430    }
431
432    #[test]
433    fn detector_install_rejects_a_bad_signature() {
434        let mut m: Manifest = serde_json::from_str(&signed_detector_json(
435            "com.example.focus",
436            "detect:processes",
437            "host_process_running",
438        ))
439        .unwrap();
440        m.name = "Tampered".to_string();
441        let json = serde_json::to_string(&m).unwrap();
442        assert!(prepare_detector_install(&json, &PluginRegistry::default())
443            .unwrap_err()
444            .contains("does not match"));
445    }
446
447    #[test]
448    fn detector_install_rejects_a_missing_module() {
449        let mut m: Manifest = serde_json::from_str(&signed_detector_json(
450            "com.example.focus",
451            "detect:processes",
452            "host_process_running",
453        ))
454        .unwrap();
455        m.module_base64 = None;
456        // Re-sign so it's the module-absence, not the signature, that's caught.
457        let key = SigningKey::from_bytes(&[8u8; 32]);
458        m.signature.sig = BASE64_STANDARD.encode(key.sign(&signing_payload(&m, None)).to_bytes());
459        let json = serde_json::to_string(&m).unwrap();
460        assert!(prepare_detector_install(&json, &PluginRegistry::default())
461            .unwrap_err()
462            .contains("missing its module"));
463    }
464
465    #[test]
466    fn detector_install_rejects_an_oversized_module() {
467        // The size cap trips before signature/link checks, so the blob need
468        // not be valid wasm — just larger than the cap once decoded.
469        let mut m: Manifest = serde_json::from_str(&signed_detector_json(
470            "com.example.focus",
471            "detect:processes",
472            "host_process_running",
473        ))
474        .unwrap();
475        m.module_base64 = Some(BASE64_STANDARD.encode(vec![0u8; MAX_MODULE_BYTES + 1]));
476        let json = serde_json::to_string(&m).unwrap();
477        assert!(prepare_detector_install(&json, &PluginRegistry::default())
478            .unwrap_err()
479            .contains("exceeds"));
480    }
481
482    #[test]
483    fn detector_install_rejects_an_empty_module() {
484        let mut m: Manifest = serde_json::from_str(&signed_detector_json(
485            "com.example.focus",
486            "detect:processes",
487            "host_process_running",
488        ))
489        .unwrap();
490        m.module_base64 = Some(String::new());
491        let json = serde_json::to_string(&m).unwrap();
492        assert!(prepare_detector_install(&json, &PluginRegistry::default())
493            .unwrap_err()
494            .contains("empty"));
495    }
496
497    #[test]
498    fn detector_install_rejects_invalid_base64_module() {
499        let mut m: Manifest = serde_json::from_str(&signed_detector_json(
500            "com.example.focus",
501            "detect:processes",
502            "host_process_running",
503        ))
504        .unwrap();
505        m.module_base64 = Some("not valid base64!!!".to_string());
506        let json = serde_json::to_string(&m).unwrap();
507        assert!(prepare_detector_install(&json, &PluginRegistry::default())
508            .unwrap_err()
509            .contains("not valid base64"));
510    }
511
512    #[test]
513    fn detector_installer_rejects_a_content_plugin() {
514        let json = signed_content_manifest_json("com.example.pack");
515        assert!(prepare_detector_install(&json, &PluginRegistry::default())
516            .unwrap_err()
517            .contains("detector plugins only"));
518    }
519
520    #[test]
521    fn detector_install_rejects_an_already_installed_id() {
522        let json = signed_detector_json(
523            "com.example.focus",
524            "detect:processes",
525            "host_process_running",
526        );
527        let mut reg = PluginRegistry::default();
528        reg.insert(InstalledPlugin {
529            id: "com.example.focus".to_string(),
530            name: "Focus".to_string(),
531            author: String::new(),
532            version: "1.0.0".to_string(),
533            kind: PluginKind::Detector,
534            public_key: "AA==".to_string(),
535            added: Default::default(),
536            capabilities: Vec::new(),
537            detect: None,
538            export: None,
539        });
540        assert!(prepare_detector_install(&json, &reg)
541            .unwrap_err()
542            .contains("already installed"));
543    }
544}