Skip to main content

lumen_plugin/
host.rs

1//! The loader side: dlopen plugin cdylibs, verify their descriptors, bring
2//! them up, and bind what they registered onto an app. Compiled only under
3//! the `host` feature; the runtime is the consumer, never plugin authors.
4//!
5//! A module that fails to load is collected rather than fatal. One broken
6//! plugin should not take the app down with it, so [`PluginSet::load`] hands
7//! back everything that came up and a [`LoadFailure`] for everything that did
8//! not.
9
10use 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/// One module to load: a name, a library file that already exists, and the
26/// configuration the app declared for it.
27///
28/// Resolution happened before this: where the file came from (a path in the
29/// app, the plugin cache, a bundle) is the caller's business, and the loader
30/// only opens what it is given.
31#[derive(Debug, Clone)]
32pub struct ResolvedModule {
33    /// The name the app declared. The library must report the same one.
34    pub name: String,
35    /// The library file to open.
36    pub path: PathBuf,
37    /// The module's own configuration, passed through untouched.
38    pub config: toml::Table,
39}
40
41/// What every module in a set is told about the app.
42#[derive(Debug, Clone)]
43pub struct InitEnv {
44    /// The app directory.
45    pub app_dir: PathBuf,
46    /// The app's id.
47    pub app_id: String,
48    /// Whether the app runs without a window.
49    pub headless: bool,
50    /// Whether the app was started with hot reload on.
51    pub hot_reload: bool,
52}
53
54/// What the engine provides to the plugins it loads.
55///
56/// Every method is called from whichever thread the plugin used, at any time
57/// from the moment the module initializes until the process ends, so an
58/// implementation is a queue push rather than a piece of work.
59pub trait HostHooks: Send + Sync + 'static {
60    /// Take an event a module pushed. Returns false when the engine is no
61    /// longer accepting events, which is what a plugin's worker thread sees
62    /// while the app shuts down.
63    fn event(&self, module: &str, event: PluginEvent) -> bool;
64
65    /// Take a diagnostic line from a module.
66    fn log(&self, module: &str, level: LogLevel, message: &str);
67
68    /// Ask for another tick, so a queued event is drained without waiting
69    /// for input.
70    fn wake(&self);
71}
72
73/// Why one module did not load.
74#[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/// One module that did not load, named so a banner can say which and why.
111#[derive(Debug, thiserror::Error)]
112#[error("plugin '{module}' ({}): {reason}", path.display())]
113pub struct LoadFailure {
114    /// The name the app declared the module under.
115    pub module: String,
116    /// The library file the loader probed.
117    pub path: PathBuf,
118    /// What went wrong.
119    pub reason: FailureReason,
120}
121
122/// The engine state one module's callbacks land in. Leaked at load: a plugin
123/// may hold its [`Host`](crate::Host) on a worker thread that outlives every
124/// borrow the engine could hand out, and the library it lives in is never
125/// unloaded either.
126struct HostCtx {
127    module: String,
128    hooks: Arc<dyn HostHooks>,
129}
130
131/// One loaded module: the open library, the descriptor inside it, and what
132/// its init registered.
133struct Loaded {
134    name: String,
135    path: PathBuf,
136    fns: Vec<FnDecl>,
137    preludes: Vec<ScriptPrelude>,
138    /// The library, mapped for the rest of the process and never unloaded:
139    /// `desc` points into it, the host table handed to the module is leaked
140    /// on the same terms, and a thread the module started in its init can
141    /// still be running code there long after the set is gone.
142    _lib: ManuallyDrop<Library>,
143    desc: *const Desc,
144}
145
146// `desc` points at a static inside the library, which stays mapped for the
147// rest of the process; `Desc` itself is Sync.
148unsafe impl Send for Loaded {}
149unsafe impl Sync for Loaded {}
150
151impl Loaded {
152    fn desc(&self) -> &Desc {
153        unsafe { &*self.desc }
154    }
155
156    /// Run one of this module's functions: encode the call, drive the hook,
157    /// apply what came back.
158    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        // Both were required at load, so a module in a set has them.
169        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                // Emitted before the result is read, so a call that failed
183                // part way still applies what it managed, the same as an
184                // in-process script function.
185                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
202/// The loaded modules of one app, in declaration order.
203///
204/// Libraries are dlopen'd once and held for the process lifetime: a plugin
205/// hands out function bodies, threads, and a host handle, none of which
206/// survive the code they live in being unmapped.
207pub struct PluginSet {
208    modules: Vec<Arc<Loaded>>,
209}
210
211impl PluginSet {
212    /// Load every module, in order. Each one that fails is collected with
213    /// its name, the file that was probed, and the reason; the rest load.
214    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    /// Bind every loaded module's functions and language sources onto the
235    /// app, in declaration order.
236    ///
237    /// The bodies call back into the plugin, so this runs before the script
238    /// hosts load and the app keeps the set alive for as long as it runs.
239    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    /// Tell every module the app is going down, in declaration order. Best
258    /// effort: a module that exports no shutdown is skipped, and the
259    /// libraries stay mapped either way.
260    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    /// The names of the loaded modules, in declaration order.
269    pub fn names(&self) -> impl Iterator<Item = &str> {
270        self.modules.iter().map(|m| m.name.as_str())
271    }
272
273    /// How many modules loaded.
274    pub fn len(&self) -> usize {
275        self.modules.len()
276    }
277
278    /// Whether nothing loaded.
279    pub fn is_empty(&self) -> bool {
280        self.modules.is_empty()
281    }
282}
283
284/// Open one module, verify it, and bring it up.
285fn load_one(
286    module: &ResolvedModule,
287    env: &InitEnv,
288    hooks: &Arc<dyn HostHooks>,
289    already: &[Arc<Loaded>],
290) -> Result<Loaded, FailureReason> {
291    // Two entries pointing at one file would share the library's statics,
292    // and a runtime plugin holds one instance per process; the second entry
293    // would run against the first one's configuration.
294    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    // From here the mapping is the process's, whether or not the rest of
299    // this load works out: an init that started a thread and then reported
300    // failure left that thread running in here. See [`Loaded`].
301    let lib = ManuallyDrop::new(lib);
302    let desc = match unsafe { dlopen::entry_descriptor(&lib, abi::ENTRY) } {
303        Some(desc) => desc,
304        // A compiler plugin is a cdylib too, and pointing the runtime at one
305        // is a mistake worth naming instead of calling it "not a plugin".
306        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
334/// Whether two paths name one file. Canonicalized, so `./lib.so` and
335/// `lib.so` are the same entry; a path that cannot be canonicalized (it was
336/// opened, so this is rare) compares as itself.
337fn 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
342/// The handshake: refuse anything about the descriptor that would make the
343/// byte payloads or the pointers untrustworthy. The first two fields sit at
344/// frozen offsets and are read through the raw pointer before a `&Desc` for
345/// the whole struct exists, so a truncated or foreign descriptor is refused
346/// without ever forming a reference past its end.
347///
348/// # Safety (internal)
349/// `desc` is non-null and points at at least 8 readable bytes; the caller
350/// checked null, and any exporter of the entry symbol provides at least the
351/// frozen prefix.
352fn 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
392/// Bring one verified module up and take back its manifest.
393fn 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    // A `toml::Table` always re-serializes: keys are strings and the
402    // serializer orders values ahead of tables itself.
403    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
433/// One init call. The same buffer-and-status dance as
434/// [`dlopen::call_hook`], which init cannot use: it takes the host table
435/// where a hook takes a second byte slice.
436///
437/// # Safety
438/// `init` and `free` come from the same verified descriptor, so the buffer
439/// init allocates is the one `free` knows how to release.
440unsafe 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
468/// What a manifest may not say. Everything here is a plugin bug rather than
469/// a user's, so the message is written for whoever wrote the plugin.
470fn 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
496/// Build one module's host table. Leaked: see [`HostCtx`].
497fn 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
512/// Borrow the context one of the entries below was called with.
513///
514/// # Safety
515/// `ctx` is the pointer [`vtable`] leaked, which lives for the process.
516unsafe fn host_ctx<'a>(ctx: *mut c_void) -> Option<&'a HostCtx> {
517    (!ctx.is_null()).then(|| unsafe { &*ctx.cast::<HostCtx>() })
518}
519
520/// Take one event. Called from whichever thread the plugin used, so the
521/// panic is caught here: unwinding out of an `extern "C"` function aborts,
522/// and the thread it would abort is the plugin's.
523unsafe 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
539/// Take one diagnostic line. Same threading and panic contract as
540/// [`emit_event`].
541unsafe 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
556/// Ask for a tick. Same threading and panic contract as [`emit_event`].
557unsafe 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        // The same name in two namespaces is two functions, not a clash.
733        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    // -- hand-built descriptor harness ------------------------------------
739    //
740    // A `Loaded` over the test binary's own handle (`Library::this`) and a
741    // static descriptor, so a call can be driven against hook behaviors no
742    // well-formed plugin produces: absent payloads, garbage payloads,
743    // unknown status codes.
744
745    #[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    /// A host that ignores what it is handed. The index test below only
840    /// needs a module to exist.
841    #[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    /// The other half of the out-of-range arm: a real plugin refusing an
856    /// index it registered no function for. Nothing the loader does can
857    /// produce that call, so it takes a loaded module driven by hand.
858    #[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    /// Answers with a `CallOut` carrying one command and a failure, so the
887    /// "emitted before the result is read" contract is checked without the
888    /// fixture.
889    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}