pub mod cache;
pub mod config;
pub mod pattern;
mod host;
mod linker;
use std::path::Path;
use wasmtime::{Engine, Store};
use wasmtime::component::Component;
use yosh_plugin_api::{
CAP_ALL, CAP_HOOK_ON_CD, CAP_HOOK_POST_EXEC, CAP_HOOK_PRE_EXEC, CAP_HOOK_PRE_PROMPT,
Capability, parse_capability,
};
use crate::env::ShellEnv;
use self::cache::{CacheKey, CacheRejection, sha256_hex, sidecar_path, validate_cwasm};
use self::config::{PluginConfig, expand_tilde};
use self::host::HostContext;
mod generated {
wasmtime::component::bindgen!({
path: "crates/yosh-plugin-api/wit",
world: "plugin-world",
});
}
use self::generated::{PluginWorld, PluginWorldPre};
use self::generated::yosh::plugin::types::{HookName, PluginInfo};
#[derive(Debug)]
pub enum PluginExec {
NotHandled,
Handled(i32),
Failed,
}
struct LoadedPlugin {
pub(super) name: String,
store: Store<HostContext>,
bindings: PluginWorld,
plugin_info: PluginInfo,
capabilities: u32,
invalidated: bool,
}
impl LoadedPlugin {
fn provides_command(&self, name: &str) -> bool {
self.plugin_info.commands.iter().any(|c| c == name)
}
fn implements_hook(&self, hook: HookName) -> bool {
self.plugin_info.implemented_hooks.contains(&hook)
}
}
pub struct PluginManager {
engine: Engine,
engine_fingerprint: String,
plugins: Vec<LoadedPlugin>,
}
impl PluginManager {
pub fn new() -> Self {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.async_support(false);
config.consume_fuel(false);
let _ = config.cache_config_load_default();
let engine_fingerprint =
"v1;component_model=true;async=false;fuel=false;cranelift".to_string();
let engine = Engine::new(&config).expect("wasmtime Engine::new");
PluginManager {
engine,
engine_fingerprint,
plugins: Vec::new(),
}
}
pub fn load_from_config(&mut self, config_path: &Path, env: &mut ShellEnv) {
let config = match PluginConfig::load(config_path) {
Ok(c) => c,
Err(_) => return,
};
for entry in &config.plugin {
if !entry.enabled {
continue;
}
let path = expand_tilde(&entry.path);
let config_caps = entry
.capabilities
.as_ref()
.map(|strs| config::capabilities_from_strs(strs));
if let Err(e) = self.load_one(
&path,
env,
config_caps,
entry.cwasm_path.as_deref(),
entry.cache_key.as_ref(),
entry.allowed_commands.as_deref().unwrap_or_default(),
) {
eprintln!("yosh: plugin: {}", e);
}
}
}
pub fn load_plugin(&mut self, path: &Path, env: &mut ShellEnv) -> Result<(), String> {
self.load_one(path, env, None, None, None, &[])
}
pub(super) fn load_one(
&mut self,
path: &Path,
env: &mut ShellEnv,
config_capabilities: Option<u32>,
cwasm_path: Option<&Path>,
expected_key: Option<&CacheKey>,
allowed_commands: &[String],
) -> Result<(), String> {
let wasm_bytes = std::fs::read(path)
.map_err(|e| format!("{}: {}", path.display(), e))?;
if let Some(key) = expected_key {
let actual = sha256_hex(&wasm_bytes);
if actual != key.wasm_sha256 {
return Err(format!(
"{}: wasm SHA-256 mismatch (lockfile {}, actual {}); \
refusing to load. Run 'yosh-plugin sync' to refresh.",
path.display(),
&key.wasm_sha256,
&actual,
));
}
}
let component = match (cwasm_path, expected_key) {
(Some(cwasm), Some(lockfile_key)) => {
let sidecar = sidecar_path(cwasm);
let runtime_key = CacheKey::for_runtime(
lockfile_key.wasm_sha256.clone(),
&self.engine_fingerprint,
);
match validate_cwasm(cwasm, &sidecar, path, &runtime_key) {
Ok(()) => {
let cwasm_bytes = std::fs::read(cwasm).map_err(|e| {
format!("{}: cwasm read failed: {}", cwasm.display(), e)
})?;
unsafe { Component::deserialize(&self.engine, &cwasm_bytes) }
.map_err(|e| {
format!("{}: cwasm deserialize failed: {}", cwasm.display(), e)
})?
}
Err(reason) => {
eprintln!(
"yosh: plugin '{}': cwasm cache stale ({}); \
precompiling in memory (run 'yosh-plugin sync' to refresh)",
path.display(),
reason.as_str(),
);
Component::new(&self.engine, &wasm_bytes).map_err(|e| {
format!("{}: component compile failed: {}", path.display(), e)
})?
}
}
}
_ => Component::new(&self.engine, &wasm_bytes)
.map_err(|e| format!("{}: component compile failed: {}", path.display(), e))?,
};
let parsed_allowed_commands: Vec<self::pattern::CommandPattern> = allowed_commands
.iter()
.map(|s| {
self::pattern::CommandPattern::parse(s).map_err(|e| {
format!("{}: invalid allowed_commands pattern '{}': {}", path.display(), s, e)
})
})
.collect::<Result<_, _>>()?;
let scratch_linker = linker::build_linker(&self.engine, CAP_ALL)
.map_err(|e| format!("{}: linker init failed: {}", path.display(), e))?;
let scratch_pre = PluginWorldPre::new(
scratch_linker
.instantiate_pre(&component)
.map_err(|e| format!("{}: instantiate_pre failed: {}", path.display(), e))?,
)
.map_err(|e| format!("{}: bindings pre-init failed: {}", path.display(), e))?;
let mut scratch_store = Store::new(
&self.engine,
HostContext::new_for_plugin("<probing>", CAP_ALL),
);
let scratch_world = scratch_pre
.instantiate(&mut scratch_store)
.map_err(|e| format!("{}: instantiate failed: {}", path.display(), e))?;
let plugin_info = scratch_world
.yosh_plugin_plugin()
.call_metadata(&mut scratch_store)
.map_err(|e| format!("{}: metadata trap: {}", path.display(), e))?;
let requested_capabilities = parse_required_capabilities(&plugin_info, &plugin_info.name);
let effective_capabilities = match config_capabilities {
None => requested_capabilities,
Some(allow) => {
let effective = requested_capabilities & allow;
let denied = requested_capabilities & !effective;
if denied != 0 {
log_denied_capabilities(&plugin_info.name, denied);
}
effective
}
};
let real_linker = linker::build_linker(&self.engine, effective_capabilities)
.map_err(|e| format!("{}: linker build failed: {}", path.display(), e))?;
let real_pre = PluginWorldPre::new(
real_linker
.instantiate_pre(&component)
.map_err(|e| format!("{}: real instantiate_pre: {}", path.display(), e))?,
)
.map_err(|e| format!("{}: real bindings pre-init: {}", path.display(), e))?;
let mut host_ctx = HostContext::new_for_plugin(
plugin_info.name.clone(),
effective_capabilities,
);
host_ctx.allowed_commands = parsed_allowed_commands;
let mut store = Store::new(&self.engine, host_ctx);
let bindings = real_pre
.instantiate(&mut store)
.map_err(|e| format!("{}: real instantiate: {}", path.display(), e))?;
let on_load_result = {
let mut guard = EnvGuard::bind(&mut store, env);
bindings.yosh_plugin_plugin().call_on_load(guard.store())
};
match on_load_result {
Ok(Ok(())) => {}
Ok(Err(msg)) => {
return Err(format!("{}: on_load returned error: {}", plugin_info.name, msg));
}
Err(e) => {
return Err(format!("{}: on_load trap: {}", plugin_info.name, e));
}
}
self.plugins.push(LoadedPlugin {
name: plugin_info.name.clone(),
store,
bindings,
plugin_info,
capabilities: effective_capabilities,
invalidated: false,
});
Ok(())
}
pub fn exec_command(
&mut self,
env: &mut ShellEnv,
name: &str,
args: &[String],
) -> PluginExec {
let Some(idx) = self.plugins.iter().position(|p| p.provides_command(name)) else {
return PluginExec::NotHandled;
};
let plugin = &mut self.plugins[idx];
match with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_plugin().call_exec(store, name, args)
}) {
Some(exit) => PluginExec::Handled(exit),
None => PluginExec::Failed,
}
}
pub fn call_pre_exec(&mut self, env: &mut ShellEnv, cmd: &str) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_PRE_EXEC == 0 {
continue;
}
if !plugin.implements_hook(HookName::PreExec) {
continue;
}
let _ = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_hooks().call_pre_exec(store, cmd)
});
}
}
pub fn call_post_exec(&mut self, env: &mut ShellEnv, cmd: &str, exit_code: i32) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_POST_EXEC == 0 {
continue;
}
if !plugin.implements_hook(HookName::PostExec) {
continue;
}
let _ = with_env(plugin, env, |bindings, store| {
bindings
.yosh_plugin_hooks()
.call_post_exec(store, cmd, exit_code)
});
}
}
pub fn call_on_cd(&mut self, env: &mut ShellEnv, old_dir: &str, new_dir: &str) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_ON_CD == 0 {
continue;
}
if !plugin.implements_hook(HookName::OnCd) {
continue;
}
let _ = with_env(plugin, env, |bindings, store| {
bindings
.yosh_plugin_hooks()
.call_on_cd(store, old_dir, new_dir)
});
}
}
pub fn call_pre_prompt(&mut self, env: &mut ShellEnv) {
for plugin in &mut self.plugins {
if plugin.capabilities & CAP_HOOK_PRE_PROMPT == 0 {
continue;
}
if !plugin.implements_hook(HookName::PrePrompt) {
continue;
}
let _ = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_hooks().call_pre_prompt(store)
});
}
}
pub fn unload_all(&mut self, env: &mut ShellEnv) {
let mut plugins = std::mem::take(&mut self.plugins);
for plugin in &mut plugins {
if plugin.invalidated {
continue;
}
let _ = with_env(plugin, env, |bindings, store| {
bindings.yosh_plugin_plugin().call_on_unload(store)
});
}
drop(plugins);
}
pub fn has_command(&self, name: &str) -> bool {
self.plugins.iter().any(|p| p.provides_command(name))
}
pub fn engine_fingerprint(&self) -> &str {
&self.engine_fingerprint
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
struct EnvGuard<'a> {
store: &'a mut Store<HostContext>,
}
impl<'a> EnvGuard<'a> {
fn bind(store: &'a mut Store<HostContext>, env: &mut ShellEnv) -> Self {
store.data_mut().env = env as *mut _;
EnvGuard { store }
}
fn store(&mut self) -> &mut Store<HostContext> {
self.store
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
self.store.data_mut().env = std::ptr::null_mut();
}
}
fn with_env<R>(
plugin: &mut LoadedPlugin,
env: &mut ShellEnv,
f: impl FnOnce(&PluginWorld, &mut Store<HostContext>) -> Result<R, wasmtime::Error>,
) -> Option<R> {
if plugin.invalidated {
eprintln!(
"yosh: plugin '{}': skipped (instance invalidated by earlier trap)",
plugin.name
);
return None;
}
let bindings = &plugin.bindings;
let result = {
let mut guard = EnvGuard::bind(&mut plugin.store, env);
f(bindings, guard.store())
};
match result {
Ok(r) => Some(r),
Err(e) => {
if let Some(trap) = e.downcast_ref::<wasmtime::Trap>() {
eprintln!(
"yosh: plugin '{}': trapped: {} — disabling for the rest of this session",
plugin.name, trap
);
plugin.invalidated = true;
} else {
eprintln!("yosh: plugin '{}': call failed: {}", plugin.name, e);
}
None
}
}
}
fn parse_required_capabilities(plugin_info: &PluginInfo, plugin_name: &str) -> u32 {
let mut bits: u32 = 0;
for s in &plugin_info.required_capabilities {
match parse_capability(s) {
Some(cap) => bits |= cap.to_bitflag(),
None => {
eprintln!(
"yosh: plugin '{}': unknown capability string '{}' (ignored)",
plugin_name, s
);
}
}
}
bits
}
fn log_denied_capabilities(plugin_name: &str, denied: u32) {
let caps = [
Capability::VariablesRead,
Capability::VariablesWrite,
Capability::Filesystem,
Capability::Io,
Capability::HookPreExec,
Capability::HookPostExec,
Capability::HookOnCd,
Capability::HookPrePrompt,
Capability::FilesRead,
Capability::FilesWrite,
];
for cap in caps {
if denied & cap.to_bitflag() != 0 {
eprintln!(
"yosh: plugin '{}': capability '{}' requested but not granted",
plugin_name,
cap.as_str()
);
}
}
}
#[cfg(any(test, feature = "test-helpers"))]
pub mod test_helpers {
use super::*;
pub fn load_plugin_with_caps(
manager: &mut PluginManager,
path: &Path,
env: &mut ShellEnv,
caps: u32,
allowed_commands: &[String],
) -> Result<(), String> {
manager.load_one(path, env, Some(caps), None, None, allowed_commands)
}
pub fn load_plugin_with_cache(
manager: &mut PluginManager,
path: &Path,
env: &mut ShellEnv,
caps: u32,
cwasm_path: &Path,
expected_key: &super::cache::CacheKey,
allowed_commands: &[String],
) -> Result<(), String> {
manager.load_one(path, env, Some(caps), Some(cwasm_path), Some(expected_key), allowed_commands)
}
pub fn env_pointer_is_null_in_store(manager: &PluginManager) -> Option<bool> {
let plugin = manager.plugins.last()?;
Some(plugin.store.data().env.is_null())
}
}