1use 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
13const MAX_MODULE_BYTES: usize = 16 * 1024 * 1024;
16
17pub 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
48pub 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#[allow(dead_code)]
80#[derive(Debug)]
81pub struct PreparedDetector {
82 pub manifest: Manifest,
83 pub module: Vec<u8>,
84}
85
86#[allow(dead_code)] pub 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 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 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 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, ®)
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, ®)
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 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 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 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 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 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, ®)
541 .unwrap_err()
542 .contains("already installed"));
543 }
544}