Skip to main content

rich_ext/plugin_loading/
mod.rs

1//! Runtime plugins: native libraries and WASM modules loaded from a path.
2//!
3//! Both loaders decode a plugin's self-description into one
4//! [`PluginAbi`](rich_plugin_api::abi::PluginAbi) and wrap it in a
5//! [`RuntimePlugin`], an ordinary [`Plugin`] that
6//! [`ExtensionRegistry::add_plugin`](crate::ExtensionRegistry::add_plugin)
7//! accepts like any other: the same name rules, the same conflict checks.
8//!
9//! A runtime plugin exchanges text only (see [`rich_plugin_api::abi`]), and
10//! everything it returns is sanitized here before a console sees it:
11//! terminal controls are made visible, and only a `fence-ansi` capability
12//! keeps SGR styling, through [`sanitize_ansi_for_decoder`].
13//!
14//! Loading is behind Cargo features, both off by default:
15//! `dylib-plugins` ([`load_native`]) and `wasm-plugins` ([`load_wasm`]).
16//! A native plugin runs arbitrary code in this process, so load one only from
17//! a path the user chose. See docs/design/plugin-loading.md and the threat
18//! model in docs/PLUGINS.md.
19//!
20//! [`sanitize_ansi_for_decoder`]: crate::sanitize::sanitize_ansi_for_decoder
21
22use std::fmt;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use rich::console::ConsoleOptions;
27use rich::segment::Segment;
28use rich::{Console, FenceRenderer, Highlighter, Renderable, Style, StyleType, Text};
29use rich_plugin_api::abi::{AbiError, CapabilityKind, PluginAbi};
30use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar, TextTransform};
31
32use crate::sanitize::{sanitize_ansi_for_decoder, sanitize_terminal_controls};
33
34#[cfg(feature = "dylib-plugins")]
35mod native;
36#[cfg(feature = "wasm-plugins")]
37mod wasm;
38
39#[cfg(feature = "dylib-plugins")]
40pub use native::load_native;
41#[cfg(feature = "wasm-plugins")]
42pub use wasm::{load_wasm, load_wasm_bytes};
43
44/// The most bytes one call may return; more is an error.
45pub const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
46/// The most spans one highlighter call may return; the rest are dropped.
47pub const MAX_SPANS: usize = 10_000;
48
49/// How a runtime plugin was loaded.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub enum RuntimeKind {
52    /// A native library through the C ABI (`dylib-plugins`).
53    Native,
54    /// A WASM module in the sandbox (`wasm-plugins`).
55    Wasm,
56}
57
58impl RuntimeKind {
59    /// `native` or `wasm`.
60    pub fn as_str(self) -> &'static str {
61        match self {
62            RuntimeKind::Native => "native",
63            RuntimeKind::Wasm => "wasm",
64        }
65    }
66
67    /// The kind a path's extension names: `.wasm`, or a platform library
68    /// (`.so`, `.dylib`, `.dll`).
69    pub fn for_path(path: &Path) -> Option<RuntimeKind> {
70        match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
71            "wasm" => Some(RuntimeKind::Wasm),
72            "so" | "dylib" | "dll" => Some(RuntimeKind::Native),
73            _ => None,
74        }
75    }
76
77    /// The Cargo feature that loads this kind.
78    pub fn feature(self) -> &'static str {
79        match self {
80            RuntimeKind::Native => "dylib-plugins",
81            RuntimeKind::Wasm => "wasm-plugins",
82        }
83    }
84
85    /// Whether this build can load this kind.
86    pub fn supported(self) -> bool {
87        match self {
88            RuntimeKind::Native => cfg!(feature = "dylib-plugins"),
89            RuntimeKind::Wasm => cfg!(feature = "wasm-plugins"),
90        }
91    }
92}
93
94impl fmt::Display for RuntimeKind {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(self.as_str())
97    }
98}
99
100/// Limits for a WASM plugin. Every call runs in a fresh instance.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub struct WasmLimits {
103    /// Fuel for one call (roughly, instructions), instantiation included.
104    pub fuel: u64,
105    /// The most linear memory an instance may have, in bytes.
106    pub memory_bytes: usize,
107    /// The most elements its table may hold. Each element takes host memory
108    /// outside the linear memory, so this bounds it too.
109    pub table_elements: usize,
110    /// The largest module file accepted, in bytes.
111    pub module_bytes: u64,
112}
113
114impl Default for WasmLimits {
115    fn default() -> Self {
116        WasmLimits {
117            fuel: 50_000_000,
118            memory_bytes: 64 * 1024 * 1024,
119            table_elements: 10_000,
120            module_bytes: 16 * 1024 * 1024,
121        }
122    }
123}
124
125/// Why a runtime plugin could not be loaded. Every variant names the path.
126#[derive(Clone, Debug, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum LoadError {
129    /// The file could not be read or opened.
130    Io { path: PathBuf, message: String },
131    /// Not a `.wasm`, `.so`, `.dylib` or `.dll` file.
132    UnknownKind { path: PathBuf },
133    /// This build cannot load that kind: it needs `feature`.
134    Unsupported {
135        path: PathBuf,
136        feature: &'static str,
137    },
138    /// The file is not a plugin: an export or symbol is missing or wrong.
139    NotAPlugin { path: PathBuf, message: String },
140    /// The plugin's description was refused (ABI version, names, kinds).
141    Abi { path: PathBuf, error: AbiError },
142    /// The plugin exceeded a limit (fuel, memory, size) while loading.
143    Limit { path: PathBuf, message: String },
144}
145
146impl LoadError {
147    /// The path that failed.
148    pub fn path(&self) -> &Path {
149        match self {
150            LoadError::Io { path, .. }
151            | LoadError::UnknownKind { path }
152            | LoadError::Unsupported { path, .. }
153            | LoadError::NotAPlugin { path, .. }
154            | LoadError::Abi { path, .. }
155            | LoadError::Limit { path, .. } => path,
156        }
157    }
158}
159
160impl fmt::Display for LoadError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        let path = self.path().display();
163        match self {
164            LoadError::Io { message, .. } => write!(f, "plugin {path}: {message}"),
165            LoadError::UnknownKind { .. } => write!(
166                f,
167                "plugin {path}: not a plugin file; expected .wasm, or a native library \
168                 (.so, .dylib, .dll)"
169            ),
170            LoadError::Unsupported { feature, .. } => write!(
171                f,
172                "plugin {path}: this build cannot load it; it needs the {feature} feature"
173            ),
174            LoadError::NotAPlugin { message, .. } => {
175                write!(f, "plugin {path}: not a rich plugin: {message}")
176            }
177            LoadError::Abi { error, .. } => write!(f, "plugin {path}: {error}"),
178            LoadError::Limit { message, .. } => write!(f, "plugin {path}: {message}"),
179        }
180    }
181}
182
183impl std::error::Error for LoadError {}
184
185/// Options for [`load`].
186#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
187pub struct LoadOptions {
188    pub wasm: WasmLimits,
189}
190
191/// Load the plugin at `path`, choosing the loader by its extension.
192///
193/// Refused, never panicking, when the kind is unknown or not compiled in, or
194/// when the loader refuses it.
195pub fn load(path: &Path, options: &LoadOptions) -> Result<RuntimePlugin, LoadError> {
196    let kind = RuntimeKind::for_path(path).ok_or_else(|| LoadError::UnknownKind {
197        path: path.to_path_buf(),
198    })?;
199    if !kind.supported() {
200        return Err(LoadError::Unsupported {
201            path: path.to_path_buf(),
202            feature: kind.feature(),
203        });
204    }
205    let _ = options;
206    match kind {
207        #[cfg(feature = "dylib-plugins")]
208        RuntimeKind::Native => load_native(path),
209        #[cfg(feature = "wasm-plugins")]
210        RuntimeKind::Wasm => load_wasm(path, &options.wasm),
211        #[allow(unreachable_patterns)]
212        _ => unreachable!("checked by RuntimeKind::supported"),
213    }
214}
215
216/// Runs a runtime plugin's capabilities. The native and WASM loaders each
217/// implement it; tests can too.
218pub trait AbiBackend: Send + Sync {
219    /// Run capability number `capability` (its index in
220    /// [`PluginAbi::capabilities`]) on `input` with `width` cells available
221    /// (0 when unknown). `Err` is the plugin's error message, or the host's.
222    fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String>;
223}
224
225/// A loaded runtime plugin: its description, where it came from, and the
226/// backend that runs it. Keeping any of its capabilities keeps the backend
227/// (and a native library) loaded.
228#[derive(Clone)]
229pub struct RuntimePlugin {
230    abi: PluginAbi,
231    kind: RuntimeKind,
232    path: PathBuf,
233    backend: Arc<dyn AbiBackend>,
234}
235
236impl fmt::Debug for RuntimePlugin {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        f.debug_struct("RuntimePlugin")
239            .field("abi", &self.abi)
240            .field("kind", &self.kind)
241            .field("path", &self.path)
242            .finish_non_exhaustive()
243    }
244}
245
246impl RuntimePlugin {
247    /// Wrap a backend. The description is [validated](PluginAbi::validate)
248    /// again, so a backend cannot bypass the ABI checks.
249    pub fn new(
250        abi: PluginAbi,
251        kind: RuntimeKind,
252        path: impl Into<PathBuf>,
253        backend: Arc<dyn AbiBackend>,
254    ) -> Result<Self, LoadError> {
255        let path = path.into();
256        abi.validate().map_err(|error| LoadError::Abi {
257            path: path.clone(),
258            error,
259        })?;
260        Ok(RuntimePlugin {
261            abi,
262            kind,
263            path,
264            backend,
265        })
266    }
267
268    /// The plugin's self-description.
269    pub fn abi(&self) -> &PluginAbi {
270        &self.abi
271    }
272
273    /// Native or WASM.
274    pub fn kind(&self) -> RuntimeKind {
275        self.kind
276    }
277
278    /// The file it was loaded from.
279    pub fn path(&self) -> &Path {
280        &self.path
281    }
282
283    /// Call a capability directly, with the output limit applied but not
284    /// sanitized (the registry adapters sanitize).
285    pub fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String> {
286        let output = self.backend.call(capability, input, width)?;
287        if output.len() > MAX_OUTPUT_BYTES {
288            return Err(format!(
289                "the plugin returned {} bytes; at most {MAX_OUTPUT_BYTES} are accepted",
290                output.len()
291            ));
292        }
293        Ok(output)
294    }
295
296    fn capability(&self, index: usize) -> Capability {
297        Capability {
298            plugin: Arc::new(self.clone()),
299            index,
300        }
301    }
302}
303
304impl Plugin for RuntimePlugin {
305    fn metadata(&self) -> PluginMetadata {
306        let metadata = PluginMetadata::new(&*self.abi.name, &*self.abi.name, &*self.abi.version);
307        if self.abi.description.is_empty() {
308            metadata
309        } else {
310            metadata.description(&*self.abi.description)
311        }
312    }
313
314    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
315        for (index, capability) in self.abi.capabilities.iter().enumerate() {
316            let adapter = self.capability(index);
317            match capability.kind {
318                CapabilityKind::Transform => {
319                    registrar.transform(&capability.name, Arc::new(adapter));
320                }
321                CapabilityKind::Highlighter => {
322                    registrar.highlighter(Box::new(move || Box::new(adapter.clone())));
323                }
324                CapabilityKind::FenceMarkup | CapabilityKind::FenceAnsi => {
325                    registrar.fence_renderer(&capability.name, Arc::new(adapter));
326                }
327                other => {
328                    return Err(PluginError::Other(format!(
329                        "unsupported capability kind {other}"
330                    )));
331                }
332            }
333        }
334        Ok(())
335    }
336}
337
338/// One capability of a [`RuntimePlugin`], adapted to the registry's traits.
339#[derive(Clone)]
340struct Capability {
341    plugin: Arc<RuntimePlugin>,
342    index: usize,
343}
344
345impl Capability {
346    fn kind(&self) -> CapabilityKind {
347        self.plugin.abi.capabilities[self.index].kind
348    }
349
350    fn call(&self, input: &str, width: u32) -> Result<String, String> {
351        self.plugin.call(self.index, input, width)
352    }
353
354    /// The output as a [`Text`], sanitized for this capability's kind; `None`
355    /// when markup does not parse.
356    fn text(&self, output: &str) -> Option<Text> {
357        let mut text = if self.kind().produces_ansi() {
358            Text::from_ansi(&sanitize_ansi_for_decoder(output), "")
359        } else if self.kind() == CapabilityKind::FenceMarkup {
360            Text::from_markup(&sanitize_terminal_controls(output)).ok()?
361        } else {
362            Text::new(sanitize_terminal_controls(output))
363        };
364        // `[link=…]` markup would print a hyperlink whose target the reader
365        // cannot see, so plugin output carries no links (or click meta).
366        for span in text.spans_mut() {
367            without_links(&mut span.style);
368        }
369        Some(text)
370    }
371}
372
373/// Drop any link or meta from `style`. A definition (`"link https://…"`) is
374/// resolved first; a theme key is the host's own and stays.
375fn without_links(style: &mut StyleType) {
376    let resolved = match style {
377        StyleType::Style(style) => Some(style.clear_meta_and_links()),
378        StyleType::Name(name) => Style::parse(name).ok().map(|s| s.clear_meta_and_links()),
379    };
380    if let Some(resolved) = resolved {
381        *style = StyleType::Style(resolved);
382    }
383}
384
385impl TextTransform for Capability {
386    fn transform(&self, text: Text) -> Result<Text, PluginError> {
387        let output = self.call(text.plain(), 0).map_err(|message| {
388            PluginError::Other(format!("plugin {:?}: {message}", self.plugin.abi.name))
389        })?;
390        Ok(Text::new(sanitize_terminal_controls(&output)))
391    }
392}
393
394impl Highlighter for Capability {
395    fn highlight(&self, text: &mut Text) {
396        // A highlighter cannot fail: a plugin error highlights nothing.
397        let Ok(output) = self.call(text.plain(), 0) else {
398            return;
399        };
400        let plain = text.plain().to_string();
401        for span in parse_spans(&output, &plain).take(MAX_SPANS) {
402            text.stylize(span.2, span.0, span.1);
403        }
404    }
405}
406
407/// The spans in a highlighter's output that apply to `plain`: in range, on
408/// character boundaries, with a style that parses.
409fn parse_spans<'a>(
410    output: &'a str,
411    plain: &'a str,
412) -> impl Iterator<Item = (usize, usize, Style)> + 'a {
413    output.lines().filter_map(move |line| {
414        let mut parts = line.trim().splitn(3, ' ');
415        let start: usize = parts.next()?.parse().ok()?;
416        let end: usize = parts.next()?.parse().ok()?;
417        // Without links: a highlighter only colours, it may not add a
418        // hyperlink the reader cannot see.
419        let style = Style::parse(parts.next()?.trim())
420            .ok()?
421            .clear_meta_and_links();
422        (start < end
423            && end <= plain.len()
424            && plain.is_char_boundary(start)
425            && plain.is_char_boundary(end))
426        .then_some((start, end, style))
427    })
428}
429
430impl FenceRenderer for Capability {
431    fn render_fence(
432        &self,
433        _language: &str,
434        code: &str,
435        console: &Console,
436        options: &ConsoleOptions,
437    ) -> Option<Vec<Segment>> {
438        // A failed call leaves the fence to render as code.
439        let width = u32::try_from(options.max_width).unwrap_or(u32::MAX);
440        let output = self.call(code, width).ok()?;
441        let text = self.text(&output)?;
442        Some(text.rich_render(console, options))
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::ExtensionRegistry;
450    use rich_plugin_api::abi::AbiCapability;
451
452    /// A backend in Rust: `upper` upper-cases, `digits` styles digits,
453    /// `box` returns markup, `paint` returns ANSI with an OSC title.
454    struct Fake;
455
456    impl AbiBackend for Fake {
457        fn call(&self, capability: usize, input: &str, _width: u32) -> Result<String, String> {
458            match capability {
459                0 => Ok(format!("{}\u{1b}]0;pwned\u{7}", input.to_uppercase())),
460                1 => Ok(input
461                    .char_indices()
462                    .filter(|(_, c)| c.is_ascii_digit())
463                    .map(|(i, _)| format!("{i} {} bold\n", i + 1))
464                    .chain(["0 999 bold\n".into(), "x y z\n".into()])
465                    .collect()),
466                2 => Ok(format!("[bold]{input}[/]\u{1b}[2J")),
467                3 => Ok(format!("\u{1b}[31m{input}\u{1b}[0m\u{1b}]0;t\u{7}\u{9b}2J")),
468                _ => Err("no such capability".into()),
469            }
470        }
471    }
472
473    fn plugin() -> RuntimePlugin {
474        let mut abi = PluginAbi::new("fake", "1.0.0");
475        for (kind, name) in [
476            (CapabilityKind::Transform, "upper"),
477            (CapabilityKind::Highlighter, "digits"),
478            (CapabilityKind::FenceMarkup, "box"),
479            (CapabilityKind::FenceAnsi, "paint"),
480        ] {
481            abi.capabilities.push(AbiCapability {
482                kind,
483                name: name.into(),
484            });
485        }
486        RuntimePlugin::new(abi, RuntimeKind::Wasm, "fake.wasm", Arc::new(Fake)).unwrap()
487    }
488
489    #[test]
490    fn capabilities_reach_the_registry_and_output_is_sanitized() {
491        let mut registry = ExtensionRegistry::new();
492        registry.add_plugin(&plugin()).unwrap();
493        let caps = &registry.plugins()[0].capabilities;
494        assert_eq!(caps.len(), 4);
495
496        // A transform's output keeps no control: the OSC is made visible.
497        let upper = registry.transform("upper").unwrap();
498        let out = upper.transform(Text::new("a1")).unwrap();
499        assert_eq!(out.plain(), "A1␛]0;pwned␇");
500
501        // Spans out of range or malformed are skipped.
502        let mut console = Console::builder()
503            .width(20)
504            .color_system(Some(rich::ColorSystem::Standard))
505            .force_terminal(true)
506            .build();
507        registry.install(&mut console);
508        let mut text = Text::new("a1b22");
509        Capability {
510            plugin: Arc::new(plugin()),
511            index: 1,
512        }
513        .highlight(&mut text);
514        assert_eq!(text.spans().len(), 3);
515
516        // Fences: markup and ANSI render, controls do not survive.
517        let fences = registry.fences().unwrap();
518        let options = console.options();
519        let render = |language: &str| {
520            let segments = fences
521                .render_fence(language, "hi", &console, &options)
522                .unwrap();
523            segments
524                .iter()
525                .map(|s| s.text.to_string())
526                .collect::<String>()
527        };
528        let boxed = render("box");
529        assert!(boxed.starts_with("hi␛[2J"), "{boxed:?}");
530        let painted = render("paint");
531        assert!(painted.starts_with("hi\\u{009B}2J"), "{painted:?}");
532        let segments = fences
533            .render_fence("paint", "hi", &console, &options)
534            .unwrap();
535        assert!(segments.iter().any(|s| s.text == "hi" && s.style.is_some()));
536    }
537
538    #[test]
539    fn a_failing_fence_falls_back_and_output_is_capped() {
540        struct Big;
541        impl AbiBackend for Big {
542            fn call(&self, _: usize, _: &str, _: u32) -> Result<String, String> {
543                Ok("x".repeat(MAX_OUTPUT_BYTES + 1))
544            }
545        }
546        let mut abi = PluginAbi::new("big", "1");
547        abi.capabilities.push(AbiCapability {
548            kind: CapabilityKind::FenceMarkup,
549            name: "big".into(),
550        });
551        let plugin = RuntimePlugin::new(abi, RuntimeKind::Native, "big.so", Arc::new(Big)).unwrap();
552        assert!(plugin.call(0, "", 0).unwrap_err().contains("at most"));
553        let console = Console::builder().width(20).build();
554        let fence = plugin.capability(0);
555        assert!(
556            fence
557                .render_fence("big", "x", &console, &console.options())
558                .is_none()
559        );
560    }
561
562    #[test]
563    fn a_backend_cannot_bypass_the_abi_checks() {
564        let abi = PluginAbi::new("Bad Name", "1");
565        let error =
566            RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)).unwrap_err();
567        assert!(error.to_string().contains("x.wasm"));
568        let mut abi = PluginAbi::new("ok", "1");
569        abi.abi_major += 1;
570        assert!(matches!(
571            RuntimePlugin::new(abi, RuntimeKind::Wasm, "x.wasm", Arc::new(Fake)),
572            Err(LoadError::Abi {
573                error: AbiError::Incompatible { .. },
574                ..
575            })
576        ));
577    }
578
579    #[test]
580    fn unknown_and_disabled_kinds_are_refused() {
581        let options = LoadOptions::default();
582        assert!(matches!(
583            load(Path::new("plugin.txt"), &options),
584            Err(LoadError::UnknownKind { .. })
585        ));
586        if !cfg!(feature = "wasm-plugins") {
587            let error = load(Path::new("p.wasm"), &options).unwrap_err();
588            assert!(error.to_string().contains("wasm-plugins"), "{error}");
589        }
590        if !cfg!(feature = "dylib-plugins") {
591            let error = load(Path::new("p.so"), &options).unwrap_err();
592            assert!(error.to_string().contains("dylib-plugins"), "{error}");
593        }
594    }
595}