Skip to main content

zsh/extensions/
plugin_host.rs

1//! Native (Rust) plugin host — extension; no zsh C counterpart.
2//!
3//! zsh's `Src/module.c` `dlopen`s C `.so` modules that call `addbuiltin`
4//! against the shell's own symbols. zshrs generalises that into a
5//! **stable, versioned C ABI** (`znative` crate) so third parties
6//! ship a compiled `cdylib` and load it at runtime with
7//! `zmodload -R <path>` — no zshrs recompile, no zsh script glue. This
8//! is the first compiled Unix shell hosting native compiled-language
9//! plugins.
10//!
11//! ## Where plugin commands resolve
12//!
13//! fusevm compiles command names it does not recognise as builtins into
14//! *external* execution. A freshly-loaded plugin command is therefore
15//! unknown at compile time and arrives at
16//! [`crate::vm_helper`]'s `execute_external_bg`, which consults
17//! [`dispatch`] BEFORE spawning a process — the same slot zsh uses for
18//! `zmodload -ab` autoloaded builtins (`resolvebuiltin`). Plugins thus
19//! resolve after real builtins and functions, before PATH lookup.
20//!
21//! ## ABI safety
22//!
23//! Everything crossing the boundary is `#[repr(C)]`. The host verifies
24//! the plugin's `abi_version` matches [`znative::ABI_VERSION`]
25//! before trusting any pointer it returns; a mismatch is refused (a
26//! wrong struct layout would be undefined behaviour). The loaded
27//! [`libloading::Library`] is kept alive for the process lifetime — its
28//! `Drop` is a `dlclose`, which would invalidate the still-registered
29//! function pointers, so unload explicitly purges the registry first.
30
31#![allow(unused_imports)]
32
33use std::collections::HashMap;
34use std::ffi::{CStr, CString};
35use std::os::raw::{c_char, c_int};
36use std::sync::{Mutex, OnceLock};
37
38use znative::{BuiltinFn, HostApi, InitFn, PluginInfo, ABI_VERSION, INIT_SYMBOL};
39
40/// One loaded plugin. Dropping `_lib` runs `dlclose`, so this is only
41/// ever removed by [`unload`] AFTER its builtins are purged from
42/// [`registry`].
43struct LoadedPlugin {
44    name: String,
45    version: String,
46    path: String,
47    /// Kept alive for the process lifetime; drop = `dlclose`.
48    _lib: libloading::Library,
49}
50
51/// A registered command → its handler and owning plugin.
52#[derive(Clone, Copy)]
53struct BuiltinEntry {
54    func: BuiltinFn,
55    /// Index into the intern table is overkill; store nothing but the
56    /// function — ownership is tracked by name-prefix scan at unload.
57    _pad: (),
58}
59
60fn plugins() -> &'static Mutex<Vec<LoadedPlugin>> {
61    static P: OnceLock<Mutex<Vec<LoadedPlugin>>> = OnceLock::new();
62    P.get_or_init(|| Mutex::new(Vec::new()))
63}
64
65/// command-name → handler. Consulted by `execute_external_bg`.
66fn registry() -> &'static Mutex<HashMap<String, BuiltinEntry>> {
67    static R: OnceLock<Mutex<HashMap<String, BuiltinEntry>>> = OnceLock::new();
68    R.get_or_init(|| Mutex::new(HashMap::new()))
69}
70
71/// Staging area for builtins registered during a single `init` call.
72/// `init` runs before it returns the plugin name, so registrations are
73/// buffered here and tagged with the owning plugin afterwards. Access is
74/// serialised by [`load_lock`].
75fn staging() -> &'static Mutex<Vec<(String, BuiltinFn)>> {
76    static S: OnceLock<Mutex<Vec<(String, BuiltinFn)>>> = OnceLock::new();
77    S.get_or_init(|| Mutex::new(Vec::new()))
78}
79
80/// Serialises `load`/`unload` so the [`staging`] buffer is single-writer.
81fn load_lock() -> &'static Mutex<()> {
82    static L: OnceLock<Mutex<()>> = OnceLock::new();
83    L.get_or_init(|| Mutex::new(()))
84}
85
86/// Which plugin currently owns each registered command name — parallel
87/// to [`registry`], used only for `unload` bookkeeping.
88fn ownership() -> &'static Mutex<HashMap<String, String>> {
89    static O: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
90    O.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93/// Completion wirings a plugin requested via `register_completion` that
94/// have not yet been installed into compsys. Each entry is
95/// `(cmd, generator_builtin, owning_plugin)`. Flushed by
96/// [`flush_pending_completions`] at a safe point in the completion
97/// pipeline (NOT during plugin init — evaling compsys glue deep inside
98/// the `zmodload` call stack hangs the VM; `do_completion` is a designed
99/// re-entry point where compsys itself evals).
100fn pending_completions() -> &'static Mutex<Vec<(String, String, String)>> {
101    static PC: OnceLock<Mutex<Vec<(String, String, String)>>> = OnceLock::new();
102    PC.get_or_init(|| Mutex::new(Vec::new()))
103}
104
105/// Completions already wired into compsys, so `unload` can drop their
106/// glue functions and `flush` never double-installs. Maps cmd → owner.
107fn installed_completions() -> &'static Mutex<HashMap<String, String>> {
108    static IC: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
109    IC.get_or_init(|| Mutex::new(HashMap::new()))
110}
111
112// ============================================================
113// Host API callbacks — the `extern "C"` functions plugins call back
114// through. One shared, leaked `HostApi` table for the whole process.
115// ============================================================
116
117extern "C" fn host_register_builtin(
118    _host: *const HostApi,
119    name: *const c_char,
120    handler: BuiltinFn,
121) -> c_int {
122    if name.is_null() {
123        return 1;
124    }
125    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
126    staging().lock().unwrap().push((name, handler));
127    0
128}
129
130extern "C" fn host_print(_host: *const HostApi, text: *const c_char) {
131    if text.is_null() {
132        return;
133    }
134    let s = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
135    use std::io::Write as _;
136    let mut out = std::io::stdout();
137    let _ = out.write_all(s.as_bytes());
138    let _ = out.flush();
139}
140
141extern "C" fn host_eval(_host: *const HostApi, code: *const c_char) -> c_int {
142    if code.is_null() {
143        return 1;
144    }
145    let code = unsafe { CStr::from_ptr(code) }.to_string_lossy().into_owned();
146    // A plugin builtin runs inside VM context (dispatch happens in
147    // execute_external_bg), so an executor is in scope. Re-entrant
148    // with_executor is safe: the borrow is released before `f` runs.
149    crate::fusevm_bridge::try_with_executor(|exec| exec.execute_script(&code))
150        .map(|r| r.unwrap_or(1))
151        .unwrap_or(1)
152}
153
154extern "C" fn host_getvar(_host: *const HostApi, name: *const c_char) -> *mut c_char {
155    if name.is_null() {
156        return std::ptr::null_mut();
157    }
158    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
159    match crate::ported::params::getsparam(&name) {
160        Some(v) => match CString::new(v) {
161            Ok(c) => c.into_raw(),
162            Err(_) => std::ptr::null_mut(),
163        },
164        None => std::ptr::null_mut(),
165    }
166}
167
168extern "C" fn host_setvar(
169    _host: *const HostApi,
170    name: *const c_char,
171    value: *const c_char,
172) -> c_int {
173    if name.is_null() || value.is_null() {
174        return 1;
175    }
176    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
177    let value = unsafe { CStr::from_ptr(value) }.to_string_lossy().into_owned();
178    crate::ported::params::setsparam(&name, &value);
179    0
180}
181
182extern "C" fn host_free_cstring(_host: *const HostApi, s: *mut c_char) {
183    if !s.is_null() {
184        // Reclaim ownership of a string we handed out via `into_raw`.
185        unsafe { drop(CString::from_raw(s)) };
186    }
187}
188
189extern "C" fn host_register_completion(
190    _host: *const HostApi,
191    cmd: *const c_char,
192    generator: *const c_char,
193) -> c_int {
194    if cmd.is_null() || generator.is_null() {
195        return 1;
196    }
197    let cmd = unsafe { CStr::from_ptr(cmd) }.to_string_lossy().into_owned();
198    let generator = unsafe { CStr::from_ptr(generator) }
199        .to_string_lossy()
200        .into_owned();
201    // Owner is tagged after init returns (name unknown yet); stage empty.
202    pending_completions()
203        .lock()
204        .unwrap()
205        .push((cmd, generator, String::new()));
206    0
207}
208
209extern "C" fn host_getfunction(_host: *const HostApi, name: *const c_char) -> *mut c_char {
210    if name.is_null() {
211        return std::ptr::null_mut();
212    }
213    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
214    // Route through the exact `${functions[name]}` read: getpmfunction
215    // deparses the body into `u_str` and flags PM_UNSET when undefined.
216    match crate::ported::modules::parameter::getpmfunction(std::ptr::null_mut(), &name) {
217        Some(pm) if (pm.node.flags & crate::ported::zsh_h::PM_UNSET as i32) == 0 => {
218            match pm.u_str.and_then(|s| CString::new(s).ok()) {
219                Some(c) => c.into_raw(),
220                None => std::ptr::null_mut(),
221            }
222        }
223        _ => std::ptr::null_mut(),
224    }
225}
226
227extern "C" fn host_addfunction(
228    _host: *const HostApi,
229    name: *const c_char,
230    body: *const c_char,
231) -> c_int {
232    if name.is_null() || body.is_null() {
233        return 1;
234    }
235    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
236    let body = unsafe { CStr::from_ptr(body) }.to_string_lossy().into_owned();
237    if name.is_empty() {
238        return 1;
239    }
240    // Same install as `functions[name]=body`: parse `body` and store the
241    // shfunc in shfunctab (dis = 0 = enabled).
242    crate::ported::modules::parameter::setfunction(&name, body, 0);
243    // setfunction installs unconditionally; report success.
244    0
245}
246
247/// The single process-wide host table. Leaked so its address is
248/// `'static` — plugins may retain the `*const HostApi` and call through
249/// it from any builtin at any time.
250fn host_api() -> *const HostApi {
251    static API: OnceLock<usize> = OnceLock::new();
252    let addr = API.get_or_init(|| {
253        let boxed = Box::new(HostApi {
254            abi_version: ABI_VERSION,
255            ctx: std::ptr::null_mut(),
256            register_builtin: host_register_builtin,
257            print: host_print,
258            eval: host_eval,
259            getvar: host_getvar,
260            setvar: host_setvar,
261            free_cstring: host_free_cstring,
262            register_completion: host_register_completion,
263            getfunction: host_getfunction,
264            addfunction: host_addfunction,
265        });
266        Box::into_raw(boxed) as usize
267    });
268    *addr as *const HostApi
269}
270
271// ============================================================
272// Public API — driven by `zmodload -R`.
273// ============================================================
274
275/// Load a plugin `cdylib` from `path`. Returns the plugin's name on
276/// success. Idempotent-ish: loading a plugin whose name is already
277/// present is refused (unload first).
278pub fn load(path: &str) -> Result<String, String> {
279    let _guard = load_lock().lock().unwrap();
280
281    // `dlopen`. libloading resolves relative paths against the loader's
282    // search rules; expand `~` for convenience since shells hand raw
283    // tokens here.
284    let expanded = expand_tilde(path);
285    let lib = unsafe { libloading::Library::new(&expanded) }
286        .map_err(|e| format!("cannot load `{}`: {}", path, e))?;
287
288    // Resolve the mandatory init symbol.
289    let init: libloading::Symbol<InitFn> = unsafe {
290        lib.get(INIT_SYMBOL)
291            .map_err(|_| format!("`{}`: not a zshrs plugin (no {})", path,
292                String::from_utf8_lossy(&INIT_SYMBOL[..INIT_SYMBOL.len() - 1])))?
293    };
294
295    // Clear staging, call init, collect what it registered. Snapshot the
296    // pending-completion length so we can tag the entries THIS init adds
297    // with the owning plugin (the name isn't known until init returns).
298    staging().lock().unwrap().clear();
299    let pc_start = pending_completions().lock().unwrap().len();
300    let info_ptr: *const PluginInfo = init(host_api());
301    if info_ptr.is_null() {
302        staging().lock().unwrap().clear();
303        pending_completions().lock().unwrap().truncate(pc_start);
304        return Err(format!("`{}`: plugin init failed (ABI mismatch or error)", path));
305    }
306    let info = unsafe { &*info_ptr };
307    if info.abi_version != ABI_VERSION {
308        staging().lock().unwrap().clear();
309        pending_completions().lock().unwrap().truncate(pc_start);
310        return Err(format!(
311            "`{}`: ABI version {} != host {}",
312            path, info.abi_version, ABI_VERSION
313        ));
314    }
315    let name = cstr_or(info.name, "unknown");
316    let version = cstr_or(info.version, "?");
317
318    // Refuse a duplicate name — the second load's builtins would shadow
319    // the first with no clean unload story.
320    if plugins().lock().unwrap().iter().any(|p| p.name == name) {
321        staging().lock().unwrap().clear();
322        pending_completions().lock().unwrap().truncate(pc_start);
323        return Err(format!("plugin `{}` already loaded", name));
324    }
325
326    // Commit staged builtins into the live registry, tagged with owner.
327    let staged: Vec<(String, BuiltinFn)> = std::mem::take(&mut *staging().lock().unwrap());
328    {
329        let mut reg = registry().lock().unwrap();
330        let mut own = ownership().lock().unwrap();
331        for (cmd, func) in staged {
332            reg.insert(cmd.clone(), BuiltinEntry { func, _pad: () });
333            own.insert(cmd, name.clone());
334        }
335    }
336
337    // Tag the completion wirings this init staged with the owning plugin.
338    {
339        let mut pc = pending_completions().lock().unwrap();
340        for entry in pc.iter_mut().skip(pc_start) {
341            entry.2 = name.clone();
342        }
343    }
344
345    plugins().lock().unwrap().push(LoadedPlugin {
346        name: name.clone(),
347        version: version.clone(),
348        path: expanded,
349        _lib: lib,
350    });
351
352    tracing::info!(plugin = %name, version = %version, path, "loaded native plugin");
353    Ok(name)
354}
355
356/// Install any completion wirings that plugins requested but that have not
357/// yet been bound into compsys. Called at the top of the completion
358/// pipeline (`do_completion`) — a safe point where compsys itself evals,
359/// unlike plugin-init (deep in the `zmodload` call stack, where evaling
360/// hangs the VM). Idempotent: each pending entry is installed once, then
361/// moved to `installed_completions`.
362///
363/// For each `(cmd, generator)` it defines a compsys completion function
364/// `_zshrs_plug_<cmd>` that runs the generator with `$CURRENT $words` and
365/// `compadd`s its newline-separated output, then binds it with `compdef`.
366pub fn flush_pending_completions() {
367    let pending: Vec<(String, String, String)> = {
368        let mut pc = pending_completions().lock().unwrap();
369        if pc.is_empty() {
370            return;
371        }
372        std::mem::take(&mut *pc)
373    };
374    for (cmd, generator, owner) in pending {
375        // `${(@f)...}` splits the generator's stdout on newlines into the
376        // match array; guard on compdef so a pre-compinit flush no-ops.
377        let glue = format!(
378            "_zshrs_plug_{cmd}() {{ \
379                local -a _zp_m; \
380                _zp_m=(\"${{(@f)$({gen} $CURRENT $words)}}\"); \
381                compadd -- $_zp_m; \
382             }}; \
383             (( ${{+functions[compdef]}} )) && compdef _zshrs_plug_{cmd} {cmd} 2>/dev/null; :",
384            cmd = cmd,
385            gen = generator,
386        );
387        // Use the exec.rs free fn (NOT try_with_executor): during
388        // `do_completion` the thread-local CURRENT_EXECUTOR is unset —
389        // completion runs on SESSION_EXECUTOR. try_with_executor would
390        // no-op here and the compdef glue would never eval. This free fn
391        // falls back to SESSION_EXECUTOR, so the glue lands on the same
392        // executor that then runs the completion.
393        let _ = crate::ported::exec::execute_script(&glue);
394        installed_completions()
395            .lock()
396            .unwrap()
397            .insert(cmd, owner);
398    }
399}
400
401/// Unload a plugin by name: purge its command registrations FIRST (so no
402/// live function pointer survives), then drop the `Library` (`dlclose`).
403pub fn unload(name: &str) -> Result<(), String> {
404    let _guard = load_lock().lock().unwrap();
405
406    let present = plugins().lock().unwrap().iter().any(|p| p.name == name);
407    if !present {
408        return Err(format!("plugin `{}` not loaded", name));
409    }
410
411    // Purge registry entries owned by this plugin.
412    {
413        let mut own = ownership().lock().unwrap();
414        let mut reg = registry().lock().unwrap();
415        let owned: Vec<String> = own
416            .iter()
417            .filter(|(_, o)| o.as_str() == name)
418            .map(|(c, _)| c.clone())
419            .collect();
420        for cmd in owned {
421            reg.remove(&cmd);
422            own.remove(&cmd);
423        }
424    }
425
426    // Drop this plugin's completion bookkeeping. We do NOT eval here to
427    // tear down the compsys glue function (evaling deep in the `zmodload`
428    // call stack hangs the VM); the orphaned `_zshrs_plug_<cmd>` function
429    // simply calls a now-removed generator builtin → empty command-subst
430    // → no matches. Removing the `installed_completions` record lets a
431    // later reload re-install cleanly.
432    pending_completions()
433        .lock()
434        .unwrap()
435        .retain(|(_, _, o)| o != name);
436    installed_completions()
437        .lock()
438        .unwrap()
439        .retain(|_, o| o != name);
440
441    // Now it is safe to dlclose.
442    let mut ps = plugins().lock().unwrap();
443    if let Some(pos) = ps.iter().position(|p| p.name == name) {
444        let p = ps.remove(pos);
445        tracing::info!(plugin = %name, "unloaded native plugin");
446        drop(p); // explicit: dlclose here, after registry purge.
447    }
448    Ok(())
449}
450
451/// Command-resolution hook. Called from `execute_external_bg` for bare
452/// command names. Returns `Some(exit_status)` if a plugin owns `cmd`,
453/// else `None` (let PATH lookup proceed).
454pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
455    let entry = { registry().lock().unwrap().get(cmd).copied() }?;
456
457    // Build argv = [cmd, args...] as NUL-terminated C strings. Interior
458    // NULs can't occur in a shell word, but be defensive.
459    let mut owned: Vec<CString> = Vec::with_capacity(args.len() + 1);
460    owned.push(CString::new(cmd).ok()?);
461    for a in args {
462        owned.push(CString::new(a.as_str()).unwrap_or_else(|_| {
463            CString::new(a.replace('\0', "")).unwrap_or_default()
464        }));
465    }
466    let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
467
468    let rc = (entry.func)(host_api(), ptrs.len(), ptrs.as_ptr());
469    // `owned`/`ptrs` outlive the call. Done.
470    Some(rc as i32)
471}
472
473/// `(name, version, path)` for each loaded plugin, for `zmodload -R`
474/// listing. Sorted by name for stable output.
475pub fn list() -> Vec<(String, String, String)> {
476    let mut v: Vec<(String, String, String)> = plugins()
477        .lock()
478        .unwrap()
479        .iter()
480        .map(|p| (p.name.clone(), p.version.clone(), p.path.clone()))
481        .collect();
482    v.sort_by(|a, b| a.0.cmp(&b.0));
483    v
484}
485
486/// True if `name` is a live plugin command. Used where callers want to
487/// know whether a name resolves to a plugin without invoking it.
488pub fn is_plugin_command(name: &str) -> bool {
489    registry().lock().unwrap().contains_key(name)
490}
491
492/// `zmodload -R` native-plugin management, dispatched from
493/// `crate::ported::module::bin_zmodload` (kept out of `src/ported/`
494/// because it is a zshrs extension, not a C port).
495///
496///   `zmodload -R  <path>...`  load each cdylib
497///   `zmodload -R`             list loaded plugins (`name  version  path`)
498///   `zmodload -uR <name>...`  unload each plugin by name
499pub fn zmodload_rust_cmd(
500    nam: &str,
501    args: &[String],
502    ops: &crate::ported::zsh_h::options,
503) -> i32 {
504    use crate::ported::utils::zwarnnam;
505    use crate::ported::zsh_h::OPT_ISSET;
506
507    // `-u` → unload.
508    if OPT_ISSET(ops, b'u') {
509        if args.is_empty() {
510            zwarnnam(nam, "what do you want to unload?");
511            return 1;
512        }
513        let mut ret = 0;
514        for name in args {
515            if let Err(e) = unload(name) {
516                zwarnnam(nam, &e);
517                ret = 1;
518            }
519        }
520        return ret;
521    }
522    // No args → list.
523    if args.is_empty() {
524        for (name, version, path) in list() {
525            println!("{}  {}  {}", name, version, path);
526        }
527        return 0;
528    }
529    // Load each path.
530    let mut ret = 0;
531    for path in args {
532        if let Err(e) = load(path) {
533            zwarnnam(nam, &e);
534            ret = 1;
535        }
536    }
537    ret
538}
539
540fn cstr_or(p: *const c_char, dflt: &str) -> String {
541    if p.is_null() {
542        dflt.to_string()
543    } else {
544        unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
545    }
546}
547
548fn expand_tilde(path: &str) -> String {
549    if let Some(rest) = path.strip_prefix("~/") {
550        if let Some(home) = dirs::home_dir() {
551            return home.join(rest).to_string_lossy().into_owned();
552        }
553    }
554    path.to_string()
555}