Skip to main content

rustledger_plugin/
dispatch.rs

1//! Plugin dispatch: classify a plugin reference into a runtime, then run it.
2//!
3//! This is the single entry point the loader uses to invoke a `plugin "..."`
4//! reference. It is deliberately split into two phases:
5//!
6//! - [`resolve_plugin`] — pure classification: native vs WASM vs Python,
7//!   path-security, feature-gating, and the #1432 bare-module-name rejection.
8//!   Returns a runnable [`ResolvedPlugin`] or a typed [`PluginResolveError`].
9//! - [`ResolvedPlugin::run`] — execute the chosen runtime, returning the plugin's
10//!   [`PluginOutput`] (ops + diagnostics) or a typed [`PluginRunError`].
11//!
12//! Errors are typed (not host `LedgerError`s) so the loader keeps ownership of
13//! its error-code convention: it maps these kinds to `E8001/E8002/E8004/E8005`
14//! and records diagnostics through one uniform path. The split lets the loader
15//! build wrappers and apply ops once per plugin instead of once per runtime.
16
17use std::path::Path;
18
19use crate::native::{NativePlugin, NativePluginRegistry};
20use crate::{DirectiveWrapper, PluginInput, PluginOptions, PluginOutput};
21
22/// Which pass's native plugins to resolve.
23///
24/// Native plugins are partitioned into synth (pre-booking) and regular
25/// (post-booking) registries; a `RegularPlugin` is never returned from the synth
26/// lookup and vice versa.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PluginPass {
29    /// Pre-booking synth plugins (`find_synth`).
30    Synth,
31    /// Post-booking regular plugins (`find_regular`).
32    Regular,
33}
34
35/// A plugin reference resolved to a concrete runtime, ready to [`run`].
36///
37/// [`run`]: ResolvedPlugin::run
38pub enum ResolvedPlugin<'a> {
39    /// A native plugin from the typed registry (already matched to the pass).
40    Native(&'a dyn NativePlugin),
41    /// A WASM plugin file — path resolved and path-security-checked.
42    #[cfg(feature = "wasm-runtime")]
43    Wasm(std::path::PathBuf),
44    /// A Python file-based plugin — path resolved and checked. Bare module-name
45    /// references are rejected during resolution (#1432), so this is a file.
46    #[cfg(feature = "python-plugins")]
47    Python {
48        /// The raw reference as written in `plugin "..."`.
49        raw: String,
50        /// The resolved absolute path.
51        resolved: std::path::PathBuf,
52    },
53}
54
55/// Why a plugin reference could not be resolved to a runnable runtime. The host
56/// maps each kind to its own error code and message.
57#[derive(Debug)]
58pub enum PluginResolveError {
59    /// The resolved path escapes the ledger base directory (path-security).
60    PathOutsideBase {
61        /// The offending reference.
62        name: String,
63    },
64    /// A `.wasm` reference, but the WASM runtime is not compiled in.
65    WasmFeatureDisabled {
66        /// The reference.
67        name: String,
68    },
69    /// A Python reference, but the Python runtime is not compiled in.
70    PythonFeatureDisabled {
71        /// The reference.
72        name: String,
73    },
74    /// A bare Python module name (`plugin "pkg.mod"`), unsupported by design.
75    /// `suggested_file` is the module's source path if system Python found it.
76    PythonModuleName {
77        /// The reference.
78        name: String,
79        /// Resolved source path, if discoverable.
80        suggested_file: Option<String>,
81    },
82    /// An unknown reference (not native, WASM, or Python-shaped).
83    NotFound {
84        /// The reference.
85        name: String,
86        /// Resolved source path if system Python could find it as a module.
87        suggested_file: Option<String>,
88    },
89}
90
91/// Why a resolved plugin failed at runtime (load or execution).
92#[derive(Debug)]
93pub enum PluginRunError {
94    /// A WASM plugin failed to load or execute. `message` is the inner reason.
95    WasmFailed {
96        /// The plugin file path.
97        path: std::path::PathBuf,
98        /// The underlying failure detail.
99        message: String,
100    },
101    /// A Python plugin failed (runtime unavailable or execution error).
102    PythonFailed {
103        /// The full failure message.
104        message: String,
105    },
106}
107
108/// Classify a plugin invocation into a runnable [`ResolvedPlugin`].
109///
110/// Native plugins resolve through the typed registry keyed on `pass`; everything
111/// else is classified by extension/shape. The Python file-vs-module test is the
112/// single-sourced [`crate::python::is_python_plugin_file_ref`] criterion.
113///
114/// # Errors
115///
116/// Returns a [`PluginResolveError`] when the reference can't run: a path-security
117/// violation, a bare module name (#1432), an unknown name, or a runtime whose
118/// feature is disabled.
119#[cfg_attr(
120    not(any(feature = "wasm-runtime", feature = "python-plugins")),
121    allow(unused_variables)
122)]
123pub fn resolve_plugin<'a>(
124    name: &str,
125    force_python: bool,
126    pass: PluginPass,
127    registry: &'a NativePluginRegistry,
128    base_dir: &Path,
129    path_security: bool,
130) -> Result<ResolvedPlugin<'a>, PluginResolveError> {
131    // Native plugins resolve through the typed registry keyed on the pass.
132    // Prefixed names resolve via the short last segment inside the registry.
133    let native: Option<&dyn NativePlugin> = if force_python {
134        None
135    } else {
136        match pass {
137            PluginPass::Synth => registry.find_synth(name).map(|p| p as &dyn NativePlugin),
138            PluginPass::Regular => registry.find_regular(name).map(|p| p as &dyn NativePlugin),
139        }
140    };
141    if let Some(plugin) = native {
142        return Ok(ResolvedPlugin::Native(plugin));
143    }
144
145    // Not native — classify by extension / shape.
146    let ext = Path::new(name)
147        .extension()
148        .and_then(|e| e.to_str())
149        .unwrap_or("")
150        .to_lowercase();
151
152    if ext == "wasm" {
153        #[cfg(feature = "wasm-runtime")]
154        {
155            return Ok(ResolvedPlugin::Wasm(resolve_path(
156                name,
157                base_dir,
158                path_security,
159            )?));
160        }
161        #[cfg(not(feature = "wasm-runtime"))]
162        return Err(PluginResolveError::WasmFeatureDisabled {
163            name: name.to_string(),
164        });
165    }
166
167    if force_python || ext == "py" || name.contains(std::path::MAIN_SEPARATOR) || name.contains('.')
168    {
169        // Python module or file-based plugin (or `python:`-prefixed force_python).
170        #[cfg(feature = "python-plugins")]
171        {
172            let resolved = resolve_path(name, base_dir, path_security)?;
173            // A bare module name (`plugin "pkg.mod"`) is unsupported by design —
174            // reject it up front with an actionable message rather than spinning
175            // up the runtime just to fail and relabel the error (#1432).
176            if is_python_module_name(&resolved, name) {
177                return Err(PluginResolveError::PythonModuleName {
178                    name: name.to_string(),
179                    suggested_file: crate::python::suggest_module_path(name),
180                });
181            }
182            return Ok(ResolvedPlugin::Python {
183                raw: name.to_string(),
184                resolved,
185            });
186        }
187        #[cfg(not(feature = "python-plugins"))]
188        return Err(PluginResolveError::PythonFeatureDisabled {
189            name: name.to_string(),
190        });
191    }
192
193    // Completely unknown plugin name. If system Python can resolve it as a
194    // module, surface the file path; otherwise it is genuinely not found.
195    #[cfg(feature = "python-plugins")]
196    {
197        Err(PluginResolveError::NotFound {
198            name: name.to_string(),
199            suggested_file: crate::python::suggest_module_path(name),
200        })
201    }
202    #[cfg(not(feature = "python-plugins"))]
203    Err(PluginResolveError::NotFound {
204        name: name.to_string(),
205        suggested_file: None,
206    })
207}
208
209impl ResolvedPlugin<'_> {
210    /// Execute the resolved plugin against `wrappers`, returning the plugin's
211    /// [`PluginOutput`] (ops + diagnostics).
212    ///
213    /// # Errors
214    ///
215    /// Returns a [`PluginRunError`] only for a runtime-level failure (a WASM
216    /// load/execution error or a Python execution error). Per-directive plugin
217    /// diagnostics travel in `PluginOutput::errors`, not as an `Err`.
218    #[cfg_attr(not(feature = "python-plugins"), allow(unused_variables))]
219    pub fn run(
220        &self,
221        wrappers: Vec<DirectiveWrapper>,
222        options: &PluginOptions,
223        config: &Option<String>,
224        base_dir: &Path,
225    ) -> Result<PluginOutput, PluginRunError> {
226        match self {
227            ResolvedPlugin::Native(plugin) => Ok(plugin.process(PluginInput {
228                directives: wrappers,
229                options: options.clone(),
230                config: config.clone(),
231            })),
232            #[cfg(feature = "wasm-runtime")]
233            ResolvedPlugin::Wasm(path) => {
234                let mut mgr = crate::PluginManager::new();
235                let idx = mgr.load(path).map_err(|e| PluginRunError::WasmFailed {
236                    path: path.clone(),
237                    message: format!("failed to load: {e}"),
238                })?;
239                mgr.execute(
240                    idx,
241                    &PluginInput {
242                        directives: wrappers,
243                        options: options.clone(),
244                        config: config.clone(),
245                    },
246                )
247                .map_err(|e| PluginRunError::WasmFailed {
248                    path: path.clone(),
249                    message: format!("execution failed: {e}"),
250                })
251            }
252            #[cfg(feature = "python-plugins")]
253            ResolvedPlugin::Python { raw, resolved } => {
254                let runtime = crate::python::PythonRuntime::new().map_err(|e| {
255                    PluginRunError::PythonFailed {
256                        message: format!("Python runtime unavailable: {e}"),
257                    }
258                })?;
259                let input = PluginInput {
260                    directives: wrappers,
261                    options: options.clone(),
262                    config: config.clone(),
263                };
264                // File-vs-module classifier matches the up-front #1432 rejection.
265                if is_python_plugin_file(resolved, raw) {
266                    runtime
267                        .execute_module(raw, &input, Some(base_dir))
268                        .map_err(|e| PluginRunError::PythonFailed {
269                            message: format!("Python plugin execution failed: {e}"),
270                        })
271                } else {
272                    runtime
273                        .execute_module(raw, &input, Some(base_dir))
274                        .map_err(|e| PluginRunError::PythonFailed {
275                            message: format!("Python plugin '{raw}' execution failed: {e}"),
276                        })
277                }
278            }
279        }
280    }
281}
282
283/// Resolve a plugin reference to a path under the ledger directory (absolute
284/// when `base_dir` is — a relative `name` is joined onto `base_dir` as-is),
285/// enforcing path-security fail-closed (see [`path_within_base`]).
286#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
287fn resolve_path(
288    name: &str,
289    base_dir: &Path,
290    path_security: bool,
291) -> Result<std::path::PathBuf, PluginResolveError> {
292    let p = Path::new(name);
293    let resolved = if p.is_absolute() {
294        p.to_path_buf()
295    } else {
296        base_dir.join(name)
297    };
298    if path_security && !path_within_base(&resolved, base_dir) {
299        return Err(PluginResolveError::PathOutsideBase {
300            name: name.to_string(),
301        });
302    }
303    Ok(resolved)
304}
305
306/// Whether `raw` is a bare Python *module name* (no `.py`, no separator, no such
307/// file) rather than a file reference. Module names are unsupported (#1432).
308#[cfg(feature = "python-plugins")]
309fn is_python_module_name(resolved: &Path, raw: &str) -> bool {
310    !is_python_plugin_file(resolved, raw)
311}
312
313/// Classify a Python reference as a FILE path: a file when it resolves to an
314/// existing path, or its name is file-like by the shared
315/// [`crate::python::is_python_plugin_file_ref`] criterion (`.py` or a separator).
316#[cfg(feature = "python-plugins")]
317fn is_python_plugin_file(resolved: &Path, raw: &str) -> bool {
318    resolved.exists() || crate::python::is_python_plugin_file_ref(raw)
319}
320
321/// Lexically resolve `.` / `..` in `p` WITHOUT touching the filesystem, so a
322/// `..` escape is caught even when the target does not exist on disk.
323#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
324fn lexically_normalize(p: &Path) -> std::path::PathBuf {
325    use std::path::Component;
326    let mut out = std::path::PathBuf::new();
327    for comp in p.components() {
328        match comp {
329            Component::ParentDir => {
330                if !out.pop() {
331                    // `..` above the root is clamped (mirrors canonicalize).
332                }
333            }
334            Component::CurDir => {}
335            other => out.push(other.as_os_str()),
336        }
337    }
338    out
339}
340
341/// True if `resolved` is inside `base_dir`.
342///
343/// Canonicalizes when the path exists (symlink-safe). ONLY a not-yet-existing
344/// path (`NotFound`) falls back to the lexical `..` check; every other case —
345/// the base failing to canonicalize, or a permission/I/O error on the plugin
346/// path — is unverifiable and fails CLOSED (returns `false`) rather than
347/// guessing via the symlink-blind lexical comparison.
348#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
349fn path_within_base(resolved: &Path, base_dir: &Path) -> bool {
350    match resolved.canonicalize() {
351        Ok(canon_plugin) => match base_dir.canonicalize() {
352            Ok(canon_base) => canon_plugin.starts_with(&canon_base),
353            // Plugin canonicalized but base didn't — cannot compare in one
354            // namespace, so fail closed.
355            Err(_) => false,
356        },
357        // Only a not-yet-existing path falls back to the lexical `..` check; a
358        // permission/I/O error is unverifiable, so reject rather than guess.
359        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
360            lexically_normalize(resolved).starts_with(lexically_normalize(base_dir))
361        }
362        Err(_) => false,
363    }
364}
365
366#[cfg(all(test, any(feature = "wasm-runtime", feature = "python-plugins")))]
367mod path_security_tests {
368    use super::{lexically_normalize, path_within_base};
369    use std::path::Path;
370
371    #[test]
372    fn lexically_normalize_resolves_dotdot_for_nonexistent_paths() {
373        assert_eq!(
374            lexically_normalize(Path::new("/ledger/../../etc/passwd")),
375            Path::new("/etc/passwd"),
376        );
377        assert_eq!(lexically_normalize(Path::new("/../../x")), Path::new("/x"));
378        assert_eq!(
379            lexically_normalize(Path::new("/ledger/./plugins/p.py")),
380            Path::new("/ledger/plugins/p.py"),
381        );
382    }
383
384    #[test]
385    fn path_within_base_rejects_traversal_even_when_path_absent() {
386        assert!(!path_within_base(
387            Path::new("/ledger/../../etc/evil.wasm"),
388            Path::new("/ledger"),
389        ));
390        assert!(path_within_base(
391            Path::new("/ledger/plugins/ok.wasm"),
392            Path::new("/ledger"),
393        ));
394        assert!(!path_within_base(
395            Path::new("/other/p.wasm"),
396            Path::new("/ledger"),
397        ));
398    }
399}
400
401#[cfg(all(test, feature = "python-plugins"))]
402mod module_name_tests {
403    use super::is_python_module_name;
404    use std::path::Path;
405
406    #[test]
407    fn bare_module_name_is_a_module() {
408        // No `.py`, no separator, and the resolved path does not exist.
409        let missing = Path::new("/nonexistent/beancount.plugins.foo");
410        assert!(is_python_module_name(missing, "beancount.plugins.foo"));
411    }
412
413    #[test]
414    fn py_file_is_not_a_module() {
415        let missing = Path::new("/nonexistent/myplugin.py");
416        assert!(!is_python_module_name(missing, "myplugin.py"));
417        // Case-insensitive extension (mirrors the runtime).
418        assert!(!is_python_module_name(
419            Path::new("/nonexistent/MyPlugin.PY"),
420            "MyPlugin.PY"
421        ));
422    }
423
424    #[test]
425    fn path_separated_ref_is_not_a_module() {
426        // Both `/` and the platform separator count as path markers, so a
427        // forward-slash ref is a file even on Windows.
428        assert!(!is_python_module_name(
429            Path::new("/nonexistent/plugins/foo"),
430            "plugins/foo"
431        ));
432    }
433
434    #[test]
435    fn existing_file_is_not_a_module() {
436        let dir = tempfile::tempdir().unwrap();
437        let file = dir.path().join("pkg.mod");
438        std::fs::write(&file, "").unwrap();
439        // A real file named like a module is still a file reference.
440        assert!(!is_python_module_name(&file, "pkg.mod"));
441    }
442}