use std::path::Path;
use crate::native::{NativePlugin, NativePluginRegistry};
use crate::{DirectiveWrapper, PluginInput, PluginOptions, PluginOutput};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginPass {
Synth,
Regular,
}
pub enum ResolvedPlugin<'a> {
Native(&'a dyn NativePlugin),
#[cfg(feature = "wasm-runtime")]
Wasm(std::path::PathBuf),
#[cfg(feature = "python-plugins")]
Python {
raw: String,
resolved: std::path::PathBuf,
},
}
#[derive(Debug)]
pub enum PluginResolveError {
PathOutsideBase {
name: String,
},
WasmFeatureDisabled {
name: String,
},
PythonFeatureDisabled {
name: String,
},
PythonModuleName {
name: String,
suggested_file: Option<String>,
},
NotFound {
name: String,
suggested_file: Option<String>,
},
}
#[derive(Debug)]
pub enum PluginRunError {
WasmFailed {
path: std::path::PathBuf,
message: String,
},
PythonFailed {
message: String,
},
}
#[cfg_attr(
not(any(feature = "wasm-runtime", feature = "python-plugins")),
allow(unused_variables)
)]
pub fn resolve_plugin<'a>(
name: &str,
force_python: bool,
pass: PluginPass,
registry: &'a NativePluginRegistry,
base_dir: &Path,
path_security: bool,
) -> Result<ResolvedPlugin<'a>, PluginResolveError> {
let native: Option<&dyn NativePlugin> = if force_python {
None
} else {
match pass {
PluginPass::Synth => registry.find_synth(name).map(|p| p as &dyn NativePlugin),
PluginPass::Regular => registry.find_regular(name).map(|p| p as &dyn NativePlugin),
}
};
if let Some(plugin) = native {
return Ok(ResolvedPlugin::Native(plugin));
}
let ext = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "wasm" {
#[cfg(feature = "wasm-runtime")]
{
return Ok(ResolvedPlugin::Wasm(resolve_path(
name,
base_dir,
path_security,
)?));
}
#[cfg(not(feature = "wasm-runtime"))]
return Err(PluginResolveError::WasmFeatureDisabled {
name: name.to_string(),
});
}
if force_python || ext == "py" || name.contains(std::path::MAIN_SEPARATOR) || name.contains('.')
{
#[cfg(feature = "python-plugins")]
{
let resolved = resolve_path(name, base_dir, path_security)?;
if is_python_module_name(&resolved, name) {
return Err(PluginResolveError::PythonModuleName {
name: name.to_string(),
suggested_file: crate::python::suggest_module_path(name),
});
}
return Ok(ResolvedPlugin::Python {
raw: name.to_string(),
resolved,
});
}
#[cfg(not(feature = "python-plugins"))]
return Err(PluginResolveError::PythonFeatureDisabled {
name: name.to_string(),
});
}
#[cfg(feature = "python-plugins")]
{
Err(PluginResolveError::NotFound {
name: name.to_string(),
suggested_file: crate::python::suggest_module_path(name),
})
}
#[cfg(not(feature = "python-plugins"))]
Err(PluginResolveError::NotFound {
name: name.to_string(),
suggested_file: None,
})
}
impl ResolvedPlugin<'_> {
#[cfg_attr(not(feature = "python-plugins"), allow(unused_variables))]
pub fn run(
&self,
wrappers: Vec<DirectiveWrapper>,
options: &PluginOptions,
config: &Option<String>,
base_dir: &Path,
) -> Result<PluginOutput, PluginRunError> {
match self {
ResolvedPlugin::Native(plugin) => Ok(plugin.process(PluginInput {
directives: wrappers,
options: options.clone(),
config: config.clone(),
})),
#[cfg(feature = "wasm-runtime")]
ResolvedPlugin::Wasm(path) => {
let mut mgr = crate::PluginManager::new();
let idx = mgr.load(path).map_err(|e| PluginRunError::WasmFailed {
path: path.clone(),
message: format!("failed to load: {e}"),
})?;
mgr.execute(
idx,
&PluginInput {
directives: wrappers,
options: options.clone(),
config: config.clone(),
},
)
.map_err(|e| PluginRunError::WasmFailed {
path: path.clone(),
message: format!("execution failed: {e}"),
})
}
#[cfg(feature = "python-plugins")]
ResolvedPlugin::Python { raw, resolved } => {
let runtime = crate::python::PythonRuntime::new().map_err(|e| {
PluginRunError::PythonFailed {
message: format!("Python runtime unavailable: {e}"),
}
})?;
let input = PluginInput {
directives: wrappers,
options: options.clone(),
config: config.clone(),
};
if is_python_plugin_file(resolved, raw) {
runtime
.execute_module(raw, &input, Some(base_dir))
.map_err(|e| PluginRunError::PythonFailed {
message: format!("Python plugin execution failed: {e}"),
})
} else {
runtime
.execute_module(raw, &input, Some(base_dir))
.map_err(|e| PluginRunError::PythonFailed {
message: format!("Python plugin '{raw}' execution failed: {e}"),
})
}
}
}
}
}
#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
fn resolve_path(
name: &str,
base_dir: &Path,
path_security: bool,
) -> Result<std::path::PathBuf, PluginResolveError> {
let p = Path::new(name);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(name)
};
if path_security && !path_within_base(&resolved, base_dir) {
return Err(PluginResolveError::PathOutsideBase {
name: name.to_string(),
});
}
Ok(resolved)
}
#[cfg(feature = "python-plugins")]
fn is_python_module_name(resolved: &Path, raw: &str) -> bool {
!is_python_plugin_file(resolved, raw)
}
#[cfg(feature = "python-plugins")]
fn is_python_plugin_file(resolved: &Path, raw: &str) -> bool {
resolved.exists() || crate::python::is_python_plugin_file_ref(raw)
}
#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
fn lexically_normalize(p: &Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => {
if !out.pop() {
}
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(any(feature = "wasm-runtime", feature = "python-plugins"))]
fn path_within_base(resolved: &Path, base_dir: &Path) -> bool {
match resolved.canonicalize() {
Ok(canon_plugin) => match base_dir.canonicalize() {
Ok(canon_base) => canon_plugin.starts_with(&canon_base),
Err(_) => false,
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
lexically_normalize(resolved).starts_with(lexically_normalize(base_dir))
}
Err(_) => false,
}
}
#[cfg(all(test, any(feature = "wasm-runtime", feature = "python-plugins")))]
mod path_security_tests {
use super::{lexically_normalize, path_within_base};
use std::path::Path;
#[test]
fn lexically_normalize_resolves_dotdot_for_nonexistent_paths() {
assert_eq!(
lexically_normalize(Path::new("/ledger/../../etc/passwd")),
Path::new("/etc/passwd"),
);
assert_eq!(lexically_normalize(Path::new("/../../x")), Path::new("/x"));
assert_eq!(
lexically_normalize(Path::new("/ledger/./plugins/p.py")),
Path::new("/ledger/plugins/p.py"),
);
}
#[test]
fn path_within_base_rejects_traversal_even_when_path_absent() {
assert!(!path_within_base(
Path::new("/ledger/../../etc/evil.wasm"),
Path::new("/ledger"),
));
assert!(path_within_base(
Path::new("/ledger/plugins/ok.wasm"),
Path::new("/ledger"),
));
assert!(!path_within_base(
Path::new("/other/p.wasm"),
Path::new("/ledger"),
));
}
}
#[cfg(all(test, feature = "python-plugins"))]
mod module_name_tests {
use super::is_python_module_name;
use std::path::Path;
#[test]
fn bare_module_name_is_a_module() {
let missing = Path::new("/nonexistent/beancount.plugins.foo");
assert!(is_python_module_name(missing, "beancount.plugins.foo"));
}
#[test]
fn py_file_is_not_a_module() {
let missing = Path::new("/nonexistent/myplugin.py");
assert!(!is_python_module_name(missing, "myplugin.py"));
assert!(!is_python_module_name(
Path::new("/nonexistent/MyPlugin.PY"),
"MyPlugin.PY"
));
}
#[test]
fn path_separated_ref_is_not_a_module() {
assert!(!is_python_module_name(
Path::new("/nonexistent/plugins/foo"),
"plugins/foo"
));
}
#[test]
fn existing_file_is_not_a_module() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("pkg.mod");
std::fs::write(&file, "").unwrap();
assert!(!is_python_module_name(&file, "pkg.mod"));
}
}