1use std::ffi::c_void;
11use std::mem::ManuallyDrop;
12use std::panic::{AssertUnwindSafe, catch_unwind};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use libloading::Library;
17use lumen_core::app::App;
18use lumen_plugin_abi::dlopen::{self, CallError, HookOut, PrefixError};
19use lumen_script::{ScriptFn, ScriptFnAppExt, ScriptFnCx, ScriptNs, ScriptPrelude, ScriptValue};
20
21use crate::abi::{self, Buf, Desc, HostVtable, LogLevel};
22use crate::wire::{Call, CallOut, FnDecl, InitCx, Manifest, PluginEvent};
23use crate::{SCRIPT_WIRE_VERSION, codec};
24
25#[derive(Debug, Clone)]
32pub struct ResolvedModule {
33 pub name: String,
35 pub path: PathBuf,
37 pub config: toml::Table,
39}
40
41#[derive(Debug, Clone)]
43pub struct InitEnv {
44 pub app_dir: PathBuf,
46 pub app_id: String,
48 pub headless: bool,
50 pub hot_reload: bool,
52}
53
54pub trait HostHooks: Send + Sync + 'static {
60 fn event(&self, module: &str, event: PluginEvent) -> bool;
64
65 fn log(&self, module: &str, level: LogLevel, message: &str);
67
68 fn wake(&self);
71}
72
73#[derive(Debug, thiserror::Error)]
75pub enum FailureReason {
76 #[error("failed to open: {0}")]
77 Open(String),
78 #[error("exports no lumen_plugin_v1 entry; is it a Lumen runtime plugin?")]
79 MissingEntry,
80 #[error(
81 "exports lumenc_plugin_v1, not lumen_plugin_v1; this is a compiler plugin - declare it under [[plugins]]"
82 )]
83 CompilerPlugin,
84 #[error("the entry returned a null descriptor")]
85 NullDescriptor,
86 #[error(
87 "built for plugin ABI {got}, this runtime speaks {want}; rebuild the plugin against the matching Lumen tag"
88 )]
89 AbiMismatch { want: u32, got: u32 },
90 #[error(
91 "built against script wire version {got}, this runtime speaks {want}; rebuild the plugin against the matching Lumen tag"
92 )]
93 ScriptWireMismatch { want: u16, got: u16 },
94 #[error("bad descriptor: {0}")]
95 BadDescriptor(String),
96 #[error(
97 "the library reports itself as '{reported}'; fix the name it is declared under, or the path"
98 )]
99 NameMismatch { reported: String },
100 #[error("already loaded as module '{0}'; declare one library once")]
101 AlreadyLoaded(String),
102 #[error("init failed: {0}")]
103 Init(String),
104 #[error("init panicked: {0}")]
105 InitPanicked(String),
106 #[error("bad manifest: {0}")]
107 BadManifest(String),
108}
109
110#[derive(Debug, thiserror::Error)]
112#[error("plugin '{module}' ({}): {reason}", path.display())]
113pub struct LoadFailure {
114 pub module: String,
116 pub path: PathBuf,
118 pub reason: FailureReason,
120}
121
122struct HostCtx {
127 module: String,
128 hooks: Arc<dyn HostHooks>,
129}
130
131struct Loaded {
134 name: String,
135 path: PathBuf,
136 fns: Vec<FnDecl>,
137 preludes: Vec<ScriptPrelude>,
138 _lib: ManuallyDrop<Library>,
143 desc: *const Desc,
144}
145
146unsafe impl Send for Loaded {}
149unsafe impl Sync for Loaded {}
150
151impl Loaded {
152 fn desc(&self) -> &Desc {
153 unsafe { &*self.desc }
154 }
155
156 fn call(&self, index: u32, cx: &mut ScriptFnCx<'_>) -> Result<ScriptValue, String> {
159 let label = format!(
160 "{}/{}",
161 self.name,
162 self.fns
163 .get(index as usize)
164 .map(|d| d.name.as_str())
165 .unwrap_or("?")
166 );
167 let desc = self.desc();
168 let (hook, free) = (
170 desc.call.expect("verified at load"),
171 desc.free.expect("verified at load"),
172 );
173 let call = Call {
174 index,
175 args: cx.args().to_vec(),
176 };
177 let bytes = codec::encode(&call).map_err(|e| format!("{label}: {e}"))?;
178 match unsafe { dlopen::call_hook(hook, free, &bytes, &[]) } {
179 Ok(HookOut::Bytes(out)) => {
180 let out: CallOut = codec::decode(&out)
181 .map_err(|e| format!("{label}: returned undecodable data: {e}"))?;
182 for command in out.commands {
186 cx.emit(command);
187 }
188 out.ret
189 }
190 Ok(HookOut::Unchanged) => Err(format!(
191 "{label}: answered UNCHANGED, which is not a call outcome"
192 )),
193 Err(CallError::Failed(message)) => Err(format!("{label}: {message}")),
194 Err(CallError::Panicked(message)) => Err(format!("{label}: panicked: {message}")),
195 Err(CallError::UnknownStatus(status)) => {
196 Err(format!("{label}: returned unknown status {status}"))
197 }
198 }
199 }
200}
201
202pub struct PluginSet {
208 modules: Vec<Arc<Loaded>>,
209}
210
211impl PluginSet {
212 pub fn load(
215 modules: &[ResolvedModule],
216 env: &InitEnv,
217 hooks: Arc<dyn HostHooks>,
218 ) -> (Self, Vec<LoadFailure>) {
219 let mut loaded: Vec<Arc<Loaded>> = Vec::with_capacity(modules.len());
220 let mut failures = Vec::new();
221 for module in modules {
222 match load_one(module, env, &hooks, &loaded) {
223 Ok(one) => loaded.push(Arc::new(one)),
224 Err(reason) => failures.push(LoadFailure {
225 module: module.name.clone(),
226 path: module.path.clone(),
227 reason,
228 }),
229 }
230 }
231 (PluginSet { modules: loaded }, failures)
232 }
233
234 pub fn install(&self, app: &mut App) {
240 for module in &self.modules {
241 for (index, decl) in module.fns.iter().enumerate() {
242 let target = Arc::clone(module);
243 let index = index as u32;
244 app.add_script_fn(ScriptFn {
245 name: decl.name.clone(),
246 ns: decl.ns.clone(),
247 sig: decl.sig.clone(),
248 body: Arc::new(move |cx: &mut ScriptFnCx<'_>| target.call(index, cx)),
249 });
250 }
251 for prelude in &module.preludes {
252 app.add_script_prelude(&prelude.lang, &prelude.ns, &prelude.source);
253 }
254 }
255 }
256
257 pub fn shutdown(&self) {
261 for module in &self.modules {
262 if let Some(shutdown) = module.desc().shutdown {
263 unsafe { shutdown() };
264 }
265 }
266 }
267
268 pub fn names(&self) -> impl Iterator<Item = &str> {
270 self.modules.iter().map(|m| m.name.as_str())
271 }
272
273 pub fn len(&self) -> usize {
275 self.modules.len()
276 }
277
278 pub fn is_empty(&self) -> bool {
280 self.modules.is_empty()
281 }
282}
283
284fn load_one(
286 module: &ResolvedModule,
287 env: &InitEnv,
288 hooks: &Arc<dyn HostHooks>,
289 already: &[Arc<Loaded>],
290) -> Result<Loaded, FailureReason> {
291 if let Some(prior) = already.iter().find(|l| same_file(&l.path, &module.path)) {
295 return Err(FailureReason::AlreadyLoaded(prior.name.clone()));
296 }
297 let lib = dlopen::open_library(&module.path).map_err(FailureReason::Open)?;
298 let lib = ManuallyDrop::new(lib);
302 let desc = match unsafe { dlopen::entry_descriptor(&lib, abi::ENTRY) } {
303 Some(desc) => desc,
304 None if unsafe { dlopen::entry_descriptor(&lib, b"lumenc_plugin_v1\0") }.is_some() => {
307 return Err(FailureReason::CompilerPlugin);
308 }
309 None => return Err(FailureReason::MissingEntry),
310 };
311 if desc.is_null() {
312 return Err(FailureReason::NullDescriptor);
313 }
314 let desc = desc.cast::<Desc>();
315 verify(&module.name, desc)?;
316 let manifest = init(&module.name, desc, module, env, hooks)?;
317 validate(&manifest).map_err(FailureReason::BadManifest)?;
318 if !manifest.fns.is_empty() && unsafe { &*desc }.call.is_none() {
319 return Err(FailureReason::BadDescriptor(format!(
320 "registered {} function(s) but exports no call entry",
321 manifest.fns.len()
322 )));
323 }
324 Ok(Loaded {
325 name: module.name.clone(),
326 path: module.path.clone(),
327 fns: manifest.fns,
328 preludes: manifest.preludes,
329 _lib: lib,
330 desc,
331 })
332}
333
334fn same_file(a: &Path, b: &Path) -> bool {
338 let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
339 real(a) == real(b)
340}
341
342fn verify(declared: &str, desc: *const Desc) -> Result<(), FailureReason> {
353 unsafe { dlopen::verify_prefix(desc.cast(), abi::ABI_VERSION, std::mem::size_of::<Desc>()) }
354 .map_err(|e| match e {
355 PrefixError::AbiMismatch { want, got } => FailureReason::AbiMismatch { want, got },
356 short => FailureReason::BadDescriptor(short.to_string()),
357 })?;
358 let desc: &Desc = unsafe { &*desc };
359 if desc.flags & abi::FLAG_PANIC_ABORT != 0 {
360 return Err(FailureReason::BadDescriptor(
361 "built with panic = \"abort\"; the panic-to-error contract needs unwinding, rebuild \
362 with the default panic = \"unwind\""
363 .to_string(),
364 ));
365 }
366 if desc.script_wire_version != SCRIPT_WIRE_VERSION {
367 return Err(FailureReason::ScriptWireMismatch {
368 want: SCRIPT_WIRE_VERSION,
369 got: desc.script_wire_version,
370 });
371 }
372 let c_str = |ptr: *const std::os::raw::c_char, what: &str| {
373 unsafe { dlopen::c_string(ptr, what) }.map_err(FailureReason::BadDescriptor)
374 };
375 let reported = c_str(desc.name, "name")?;
376 c_str(desc.version, "version")?;
377 if reported.is_empty() {
378 return Err(FailureReason::BadDescriptor("empty name".to_string()));
379 }
380 if reported != declared {
381 return Err(FailureReason::NameMismatch { reported });
382 }
383 if desc.init.is_none() {
384 return Err(FailureReason::BadDescriptor("no init function".to_string()));
385 }
386 if desc.free.is_none() {
387 return Err(FailureReason::BadDescriptor("no free function".to_string()));
388 }
389 Ok(())
390}
391
392fn init(
394 name: &str,
395 desc: *const Desc,
396 module: &ResolvedModule,
397 env: &InitEnv,
398 hooks: &Arc<dyn HostHooks>,
399) -> Result<Manifest, FailureReason> {
400 let desc: &Desc = unsafe { &*desc };
401 let config_toml = toml::to_string(&module.config).expect("a toml::Table always re-serializes");
404 let cx = InitCx::new(
405 env.app_dir.clone(),
406 env.app_id.clone(),
407 env.headless,
408 env.hot_reload,
409 env!("CARGO_PKG_VERSION").to_string(),
410 config_toml,
411 );
412 let bytes = codec::encode(&cx).expect("InitCx always encodes");
413 let table = vtable(name, Arc::clone(hooks));
414 let (init, free) = (
415 desc.init.expect("verified at load"),
416 desc.free.expect("verified at load"),
417 );
418 match unsafe { call_init(init, free, &bytes, table) } {
419 Ok(HookOut::Bytes(out)) => {
420 codec::decode(&out).map_err(|e| FailureReason::BadManifest(format!("undecodable: {e}")))
421 }
422 Ok(HookOut::Unchanged) => Err(FailureReason::Init(
423 "answered UNCHANGED, which is not an init outcome".to_string(),
424 )),
425 Err(CallError::Failed(message)) => Err(FailureReason::Init(message)),
426 Err(CallError::Panicked(message)) => Err(FailureReason::InitPanicked(message)),
427 Err(CallError::UnknownStatus(status)) => Err(FailureReason::Init(format!(
428 "returned unknown status {status}"
429 ))),
430 }
431}
432
433unsafe fn call_init(
441 init: abi::InitFn,
442 free: abi::FreeFn,
443 ctx: &[u8],
444 host: *const HostVtable,
445) -> Result<HookOut, CallError> {
446 let mut out = Buf::empty();
447 let status = unsafe { init(ctx.as_ptr(), ctx.len(), host, &mut out) };
448 let bytes = if out.ptr.is_null() {
449 Vec::new()
450 } else {
451 let copied = unsafe { std::slice::from_raw_parts(out.ptr, out.len) }.to_vec();
452 unsafe { free(out.ptr, out.len, out.cap) };
453 copied
454 };
455 match status {
456 abi::OK => Ok(HookOut::Bytes(bytes)),
457 abi::UNCHANGED => Ok(HookOut::Unchanged),
458 abi::ERR => Err(CallError::Failed(
459 String::from_utf8_lossy(&bytes).into_owned(),
460 )),
461 abi::PANICKED => Err(CallError::Panicked(
462 String::from_utf8_lossy(&bytes).into_owned(),
463 )),
464 other => Err(CallError::UnknownStatus(other)),
465 }
466}
467
468fn validate(manifest: &Manifest) -> Result<(), String> {
471 if !manifest.capabilities.is_empty() {
472 return Err(format!(
473 "declares capabilities ({}); they are reserved and must be empty",
474 manifest.capabilities.join(", ")
475 ));
476 }
477 let mut seen: Vec<(&ScriptNs, &str)> = Vec::with_capacity(manifest.fns.len());
478 for decl in &manifest.fns {
479 if decl.name.trim().is_empty() {
480 return Err("a function has an empty name".to_string());
481 }
482 if decl.ns == ScriptNs::Builtin {
483 return Err(format!(
484 "function '{}' claims the builtin namespace, which is the runtime's own",
485 decl.name
486 ));
487 }
488 if seen.contains(&(&decl.ns, decl.name.as_str())) {
489 return Err(format!("declares '{}' twice", decl.name));
490 }
491 seen.push((&decl.ns, decl.name.as_str()));
492 }
493 Ok(())
494}
495
496fn vtable(module: &str, hooks: Arc<dyn HostHooks>) -> &'static HostVtable {
498 let ctx: &'static HostCtx = Box::leak(Box::new(HostCtx {
499 module: module.to_string(),
500 hooks,
501 }));
502 Box::leak(Box::new(HostVtable {
503 struct_size: std::mem::size_of::<HostVtable>() as u32,
504 _pad: 0,
505 ctx: std::ptr::from_ref(ctx).cast_mut().cast::<c_void>(),
506 emit_event: Some(emit_event),
507 log: Some(log),
508 wake: Some(wake),
509 }))
510}
511
512unsafe fn host_ctx<'a>(ctx: *mut c_void) -> Option<&'a HostCtx> {
517 (!ctx.is_null()).then(|| unsafe { &*ctx.cast::<HostCtx>() })
518}
519
520unsafe extern "C" fn emit_event(ctx: *mut c_void, ptr: *const u8, len: usize) -> i32 {
524 let Some(host) = (unsafe { host_ctx(ctx) }) else {
525 return abi::ERR;
526 };
527 if ptr.is_null() && len > 0 {
528 return abi::ERR;
529 }
530 let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
531 let taken = catch_unwind(AssertUnwindSafe(|| match codec::decode(bytes) {
532 Ok(event) => host.hooks.event(&host.module, event),
533 Err(_) => false,
534 }))
535 .unwrap_or(false);
536 if taken { abi::OK } else { abi::ERR }
537}
538
539unsafe extern "C" fn log(ctx: *mut c_void, level: i32, ptr: *const u8, len: usize) {
542 let Some(host) = (unsafe { host_ctx(ctx) }) else {
543 return;
544 };
545 if ptr.is_null() && len > 0 {
546 return;
547 }
548 let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
549 let message = String::from_utf8_lossy(bytes);
550 let _ = catch_unwind(AssertUnwindSafe(|| {
551 host.hooks
552 .log(&host.module, LogLevel::from(level), &message)
553 }));
554}
555
556unsafe extern "C" fn wake(ctx: *mut c_void) {
558 let Some(host) = (unsafe { host_ctx(ctx) }) else {
559 return;
560 };
561 let _ = catch_unwind(AssertUnwindSafe(|| host.hooks.wake()));
562}
563
564#[cfg(test)]
565mod tests {
566 use lumen_plugin_abi::raw::{fill, free_buf};
567 use lumen_script::{ScriptCommand, ScriptSig};
568
569 use super::*;
570 use crate::abi::ABI_VERSION;
571
572 fn good_desc() -> Desc {
573 Desc {
574 abi_version: ABI_VERSION,
575 struct_size: std::mem::size_of::<Desc>() as u32,
576 script_wire_version: SCRIPT_WIRE_VERSION,
577 flags: 0,
578 reserved: 0,
579 name: c"demo".as_ptr(),
580 version: c"0.1.0".as_ptr(),
581 init: Some(init_stub),
582 call: None,
583 shutdown: None,
584 free: Some(free_buf),
585 }
586 }
587
588 unsafe extern "C" fn init_stub(
589 _ctx: *const u8,
590 _ctx_len: usize,
591 _host: *const HostVtable,
592 _out: *mut Buf,
593 ) -> i32 {
594 abi::OK
595 }
596
597 #[test]
598 fn a_good_descriptor_verifies() {
599 verify("demo", &good_desc() as *const Desc).unwrap();
600 }
601
602 #[test]
603 fn a_foreign_abi_version_is_refused() {
604 let mut d = good_desc();
605 d.abi_version = ABI_VERSION + 1;
606 let err = verify("demo", &d as *const Desc).unwrap_err();
607 assert!(matches!(err, FailureReason::AbiMismatch { got, .. } if got == ABI_VERSION + 1));
608 assert!(err.to_string().contains("matching Lumen tag"), "{err}");
609 }
610
611 #[test]
612 fn a_truncated_descriptor_is_refused() {
613 let mut d = good_desc();
614 d.struct_size = 8;
615 let err = verify("demo", &d as *const Desc).unwrap_err().to_string();
616 assert!(err.contains("descriptor is 8 bytes"), "{err}");
617 }
618
619 #[test]
620 fn a_wire_version_mismatch_names_both_numbers() {
621 let mut d = good_desc();
622 d.script_wire_version = SCRIPT_WIRE_VERSION + 7;
623 let err = verify("demo", &d as *const Desc).unwrap_err().to_string();
624 assert!(err.contains("script wire version"), "{err}");
625 assert!(
626 err.contains(&(SCRIPT_WIRE_VERSION + 7).to_string()),
627 "{err}"
628 );
629 assert!(err.contains(&SCRIPT_WIRE_VERSION.to_string()), "{err}");
630 }
631
632 #[test]
633 fn a_panic_abort_plugin_is_refused() {
634 let mut d = good_desc();
635 d.flags = abi::FLAG_PANIC_ABORT;
636 let err = verify("demo", &d as *const Desc).unwrap_err().to_string();
637 assert!(err.contains("panic = \"abort\""), "{err}");
638 }
639
640 #[test]
641 fn a_missing_entry_function_is_refused() {
642 for (mutate, want) in [
643 (
644 (|d: &mut Desc| d.init = None) as fn(&mut Desc),
645 "no init function",
646 ),
647 (|d: &mut Desc| d.free = None, "no free function"),
648 ] {
649 let mut d = good_desc();
650 mutate(&mut d);
651 let err = verify("demo", &d as *const Desc).unwrap_err().to_string();
652 assert!(err.contains(want), "{err}");
653 }
654 }
655
656 #[test]
657 fn a_name_that_is_missing_unreadable_or_someone_elses_is_refused() {
658 let mut d = good_desc();
659 d.name = std::ptr::null();
660 assert!(
661 verify("demo", &d as *const Desc)
662 .unwrap_err()
663 .to_string()
664 .contains("null name")
665 );
666
667 let mut d = good_desc();
668 d.name = c"\xff\xfe".as_ptr();
669 assert!(
670 verify("demo", &d as *const Desc)
671 .unwrap_err()
672 .to_string()
673 .contains("not UTF-8")
674 );
675
676 let mut d = good_desc();
677 d.name = c"".as_ptr();
678 assert!(
679 verify("", &d as *const Desc)
680 .unwrap_err()
681 .to_string()
682 .contains("empty name")
683 );
684
685 let err = verify("other", &good_desc() as *const Desc).unwrap_err();
686 assert!(matches!(&err, FailureReason::NameMismatch { reported } if reported == "demo"));
687 }
688
689 fn decl(name: &str) -> FnDecl {
690 FnDecl {
691 name: name.to_string(),
692 ns: ScriptNs::Extension,
693 sig: ScriptSig::default(),
694 }
695 }
696
697 #[test]
698 fn a_manifest_declaring_nothing_wrong_validates() {
699 validate(&Manifest {
700 fns: vec![decl("a"), decl("b")],
701 preludes: vec![ScriptPrelude {
702 lang: "candela".to_string(),
703 ns: "demo".to_string(),
704 source: String::new(),
705 }],
706 capabilities: Vec::new(),
707 })
708 .unwrap();
709 }
710
711 #[test]
712 fn a_manifest_is_refused_for_what_it_may_not_claim() {
713 let with = |fns: Vec<FnDecl>, capabilities: Vec<String>| Manifest {
714 fns,
715 preludes: Vec::new(),
716 capabilities,
717 };
718 let err = validate(&with(Vec::new(), vec!["fs".to_string()])).unwrap_err();
719 assert!(err.contains("reserved"), "{err}");
720
721 let err = validate(&with(vec![decl(" ")], Vec::new())).unwrap_err();
722 assert!(err.contains("empty name"), "{err}");
723
724 let mut builtin = decl("print");
725 builtin.ns = ScriptNs::Builtin;
726 let err = validate(&with(vec![builtin], Vec::new())).unwrap_err();
727 assert!(err.contains("builtin namespace"), "{err}");
728
729 let err = validate(&with(vec![decl("twice"), decl("twice")], Vec::new())).unwrap_err();
730 assert!(err.contains("declares 'twice' twice"), "{err}");
731
732 let mut named = decl("read");
734 named.ns = ScriptNs::Named("gpio".to_string());
735 validate(&with(vec![decl("read"), named], Vec::new())).unwrap();
736 }
737
738 #[cfg(unix)]
746 fn loaded_with(desc: &'static Desc) -> Loaded {
747 Loaded {
748 name: "harness".to_string(),
749 path: PathBuf::from("/harness"),
750 fns: vec![decl("f")],
751 preludes: Vec::new(),
752 _lib: ManuallyDrop::new(Library::from(libloading::os::unix::Library::this())),
753 desc,
754 }
755 }
756
757 #[cfg(unix)]
758 fn call_harness(desc: &'static Desc) -> Result<ScriptValue, String> {
759 let loaded = loaded_with(desc);
760 let args = [ScriptValue::I64(1)];
761 let mut out = Vec::new();
762 let mut cx = ScriptFnCx::new(&args, &mut out);
763 loaded.call(0, &mut cx)
764 }
765
766 unsafe extern "C" fn leak_free(_ptr: *mut u8, _len: usize, _cap: usize) {}
767
768 unsafe extern "C" fn unchanged_hook(
769 _input: *const u8,
770 _input_len: usize,
771 _ctx: *const u8,
772 _ctx_len: usize,
773 _out: *mut Buf,
774 ) -> i32 {
775 abi::UNCHANGED
776 }
777
778 unsafe extern "C" fn weird_status_hook(
779 _input: *const u8,
780 _input_len: usize,
781 _ctx: *const u8,
782 _ctx_len: usize,
783 _out: *mut Buf,
784 ) -> i32 {
785 99
786 }
787
788 unsafe extern "C" fn garbage_hook(
789 _input: *const u8,
790 _input_len: usize,
791 _ctx: *const u8,
792 _ctx_len: usize,
793 out: *mut Buf,
794 ) -> i32 {
795 let bytes = std::mem::ManuallyDrop::new(vec![0xffu8, 0xfe, 0x00, 0x9d]);
796 unsafe {
797 (*out).ptr = bytes.as_ptr() as *mut u8;
798 (*out).len = bytes.len();
799 (*out).cap = bytes.capacity();
800 }
801 abi::OK
802 }
803
804 #[cfg(unix)]
805 fn harness_desc(hook: abi::HookFn) -> Desc {
806 Desc {
807 call: Some(hook),
808 free: Some(leak_free),
809 ..good_desc()
810 }
811 }
812
813 #[cfg(unix)]
814 #[test]
815 fn hook_answers_no_call_produces_are_refused_by_name() {
816 for (hook, want) in [
817 (unchanged_hook as abi::HookFn, "answered UNCHANGED"),
818 (weird_status_hook, "unknown status 99"),
819 (garbage_hook, "undecodable data"),
820 ] {
821 let desc: &'static Desc = Box::leak(Box::new(harness_desc(hook)));
822 let err = call_harness(desc).unwrap_err();
823 assert!(err.contains(want), "{err}");
824 assert!(err.starts_with("harness/f: "), "{err}");
825 }
826 }
827
828 #[cfg(unix)]
829 #[test]
830 fn an_out_of_range_index_names_the_module_without_a_function() {
831 let desc: &'static Desc = Box::leak(Box::new(harness_desc(unchanged_hook)));
832 let loaded = loaded_with(desc);
833 let mut out = Vec::new();
834 let mut cx = ScriptFnCx::new(&[], &mut out);
835 let err = loaded.call(9, &mut cx).unwrap_err();
836 assert!(err.starts_with("harness/?: "), "{err}");
837 }
838
839 #[cfg(feature = "testing")]
842 struct Silent;
843
844 #[cfg(feature = "testing")]
845 impl HostHooks for Silent {
846 fn event(&self, _module: &str, _event: PluginEvent) -> bool {
847 true
848 }
849
850 fn log(&self, _module: &str, _level: LogLevel, _message: &str) {}
851
852 fn wake(&self) {}
853 }
854
855 #[cfg(feature = "testing")]
859 #[test]
860 fn a_plugin_refuses_an_index_it_registered_nothing_for() {
861 let app_dir =
862 std::env::temp_dir().join(format!("lumen-plugin-host-index-{}", std::process::id()));
863 let _ = std::fs::remove_dir_all(&app_dir);
864 std::fs::create_dir_all(&app_dir).unwrap();
865 let (set, failures) = PluginSet::load(
866 &[ResolvedModule {
867 name: "lumen-plugin-fixture".to_string(),
868 path: crate::testing::fixture_copy("host-index"),
869 config: toml::Table::new(),
870 }],
871 &InitEnv {
872 app_dir,
873 app_id: "host-index".to_string(),
874 headless: true,
875 hot_reload: false,
876 },
877 Arc::new(Silent),
878 );
879 assert!(failures.is_empty(), "{failures:?}");
880 let mut out = Vec::new();
881 let mut cx = ScriptFnCx::new(&[], &mut out);
882 let err = set.modules[0].call(99, &mut cx).unwrap_err();
883 assert!(err.contains("no function at index 99"), "{err}");
884 }
885
886 unsafe extern "C" fn commands_then_fail_hook(
890 _input: *const u8,
891 _input_len: usize,
892 _ctx: *const u8,
893 _ctx_len: usize,
894 out: *mut Buf,
895 ) -> i32 {
896 let bytes = codec::encode(&CallOut {
897 ret: Err("no".to_string()),
898 commands: vec![ScriptCommand::Print("before".into())],
899 })
900 .unwrap();
901 fill(unsafe { &mut *out }, bytes);
902 abi::OK
903 }
904
905 #[cfg(unix)]
906 #[test]
907 fn what_a_failing_call_emitted_is_still_applied() {
908 static DESC: std::sync::OnceLock<Desc> = std::sync::OnceLock::new();
909 let desc = DESC.get_or_init(|| Desc {
910 call: Some(commands_then_fail_hook),
911 free: Some(free_buf),
912 ..good_desc()
913 });
914 let loaded = loaded_with(desc);
915 let mut out = Vec::new();
916 let mut cx = ScriptFnCx::new(&[], &mut out);
917 assert_eq!(loaded.call(0, &mut cx), Err("no".to_string()));
918 assert_eq!(out.len(), 1);
919 }
920}