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}