Skip to main content

plugin_loader/
lib.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use wasmtime::{Engine, Store};
5use wasmtime::component::{Component, Linker, ResourceTable};
6use wasmtime_wasi::{DirPerms, FilePerms, I32Exit, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
7use wasmtime_wasi::p2;
8use wasmtime_wasi::p2::bindings::sync::Command;
9
10mod pipe_plugin_bindings {
11    wasmtime::component::bindgen!({
12        path: "wit/cli",
13        world: "pipe-plugin",
14    });
15}
16
17#[derive(Default)]
18struct PipeState;
19
20pub struct LoadedPipe {
21    store: Store<PipeState>,
22    instance: pipe_plugin_bindings::PipePlugin,
23    meta: pipe_plugin_bindings::wacli::cli::types::PipeMeta,
24}
25
26mod pipe_runtime_bindings {
27    wasmtime::component::bindgen!({
28        path: "wit/cli",
29        world: "pipe-runtime-host",
30        with: {
31            "wacli:cli/pipe-runtime.pipe": crate::LoadedPipe,
32        },
33    });
34}
35
36use pipe_runtime_bindings::wacli::cli::{pipe_runtime, types as pipe_types};
37
38/// Runs a composed CLI component with dynamic pipe loading.
39pub struct Runner {
40    engine: Engine,
41}
42
43impl Runner {
44    /// Create a runner with component model enabled.
45    pub fn new() -> Result<Self> {
46        let mut config = wasmtime::Config::new();
47        config.wasm_component_model(true);
48        let engine = Engine::new(&config).context("failed to create wasmtime engine")?;
49        Ok(Self { engine })
50    }
51
52    /// Run a composed CLI component (.component.wasm).
53    pub fn run_component(&self, component_path: impl AsRef<Path>, args: &[String]) -> Result<u32> {
54        let component_path = component_path.as_ref();
55        let component = Component::from_file(&self.engine, component_path)
56            .with_context(|| format!("failed to load component: {}", component_path.display()))?;
57
58        let mut linker = Linker::new(&self.engine);
59        p2::add_to_linker_sync(&mut linker).context("failed to add WASI to linker")?;
60        pipe_runtime_bindings::PipeRuntimeHost::add_to_linker::<
61            HostState,
62            wasmtime::component::HasSelf<HostState>,
63        >(
64            &mut linker,
65            |state: &mut HostState| state,
66        )
67        .context("failed to add pipe-runtime to linker")?;
68
69        let program_name = component_path
70            .file_name()
71            .and_then(|name| name.to_str())
72            .unwrap_or("wacli")
73            .to_string();
74        let mut wasi_args = Vec::with_capacity(args.len() + 1);
75        wasi_args.push(program_name);
76        wasi_args.extend_from_slice(args);
77
78        let mut builder = WasiCtxBuilder::new();
79        builder.inherit_stdio().inherit_env().args(&wasi_args);
80        builder
81            .preopened_dir(".", ".", DirPerms::all(), FilePerms::all())
82            .context("failed to preopen current directory")?;
83        let ctx = builder.build();
84
85        let current_command = detect_command(args);
86        let plugins_dir = PathBuf::from("plugins");
87
88        let mut store = Store::new(
89            &self.engine,
90            HostState {
91                ctx,
92                table: ResourceTable::new(),
93                engine: self.engine.clone(),
94                plugins_dir,
95                current_command,
96            },
97        );
98
99        let command = Command::instantiate(&mut store, &component, &linker)
100            .context("failed to instantiate component")?;
101        match command.wasi_cli_run().call_run(&mut store) {
102            Ok(Ok(())) => Ok(0),
103            Ok(Err(())) => Ok(1),
104            Err(err) => {
105                if let Some(exit) = err.downcast_ref::<I32Exit>() {
106                    Ok(exit.0 as u32)
107                } else {
108                    Err(err).context("failed to invoke wasi:cli/run")
109                }
110            }
111        }
112    }
113}
114
115struct HostState {
116    ctx: WasiCtx,
117    table: ResourceTable,
118    engine: Engine,
119    plugins_dir: PathBuf,
120    current_command: Option<String>,
121}
122
123impl WasiView for HostState {
124    fn ctx(&mut self) -> WasiCtxView<'_> {
125        WasiCtxView {
126            ctx: &mut self.ctx,
127            table: &mut self.table,
128        }
129    }
130}
131
132impl pipe_types::Host for HostState {}
133
134impl pipe_runtime::Host for HostState {
135    fn list_pipes(&mut self) -> Vec<pipe_runtime::PipeInfo> {
136        let base = self.pipes_root();
137        if !base.exists() {
138            return Vec::new();
139        }
140        let mut pipes = Vec::new();
141        if collect_pipe_infos(&base, &base, &mut pipes).is_err() {
142            return Vec::new();
143        }
144        pipes.sort_by(|a, b| a.name.cmp(&b.name));
145        pipes
146    }
147
148    fn load_pipe(
149        &mut self,
150        name: String,
151    ) -> Result<wasmtime::component::Resource<LoadedPipe>, String> {
152        let normalized = self.resolve_pipe_name(&name)?;
153        let path = self.resolve_pipe_path(&normalized)?;
154        let pipe = self.instantiate_pipe(&path)?;
155        self.table
156            .push(pipe)
157            .map_err(|e| format!("failed to register pipe: {e}"))
158    }
159}
160
161impl pipe_runtime::HostPipe for HostState {
162    fn meta(&mut self, pipe: wasmtime::component::Resource<LoadedPipe>) -> pipe_runtime::PipeMeta {
163        match self.table.get(&pipe) {
164            Ok(pipe) => convert_pipe_meta(&pipe.meta),
165            Err(err) => pipe_runtime::PipeMeta {
166                name: "invalid".to_string(),
167                summary: format!("pipe handle is invalid: {err}"),
168                input_types: Vec::new(),
169                output_type: String::new(),
170                version: "0.0.0".to_string(),
171            },
172        }
173    }
174
175    fn process(
176        &mut self,
177        pipe: wasmtime::component::Resource<LoadedPipe>,
178        input: Vec<u8>,
179        options: Vec<String>,
180    ) -> Result<Vec<u8>, pipe_runtime::PipeError> {
181        let pipe = self.table.get_mut(&pipe).map_err(|e| {
182            pipe_runtime::PipeError::TransformError(format!("pipe handle is invalid: {e}"))
183        })?;
184        match pipe
185            .instance
186            .wacli_cli_pipe()
187            .call_process(&mut pipe.store, &input, &options)
188        {
189            Ok(Ok(bytes)) => Ok(bytes),
190            Ok(Err(err)) => Err(convert_pipe_error(err)),
191            Err(err) => Err(pipe_runtime::PipeError::TransformError(format!(
192                "pipe execution failed: {err}"
193            ))),
194        }
195    }
196
197    fn drop(
198        &mut self,
199        pipe: wasmtime::component::Resource<LoadedPipe>,
200    ) -> wasmtime::Result<()> {
201        self.table
202            .delete(pipe)
203            .map(|_| ())
204            .map_err(|e| anyhow::anyhow!(e))
205    }
206}
207
208impl HostState {
209    fn pipes_root(&self) -> PathBuf {
210        match &self.current_command {
211            Some(cmd) => self.plugins_dir.join(cmd),
212            None => self.plugins_dir.clone(),
213        }
214    }
215
216    fn resolve_pipe_name(&self, name: &str) -> Result<String, String> {
217        let trimmed = name.trim();
218        if trimmed.is_empty() {
219            return Err("pipe name is empty".to_string());
220        }
221        if trimmed.contains('\\') {
222            return Err("pipe name must use '/' separators".to_string());
223        }
224        let mut normalized = trimmed.trim_start_matches('/').to_string();
225        if let Some(stripped) = normalized.strip_suffix(".component.wasm") {
226            normalized = stripped.to_string();
227        }
228        if let Some(cmd) = &self.current_command {
229            let prefix = format!("{cmd}/");
230            if !normalized.starts_with(&prefix) {
231                normalized = format!("{prefix}{normalized}");
232            }
233        }
234        if !is_valid_pipe_name(&normalized) {
235            return Err(format!("invalid pipe name '{normalized}'"));
236        }
237        Ok(normalized)
238    }
239
240    fn resolve_pipe_path(&self, name: &str) -> Result<PathBuf, String> {
241        let base = self.plugins_dir.clone();
242        if !base.exists() {
243            return Err(format!("pipe directory not found: {}", base.display()));
244        }
245        let mut path = base.join(name);
246        path.set_extension("component.wasm");
247        if !path.exists() {
248            return Err(format!("pipe not found: {}", path.display()));
249        }
250        if !path.is_file() {
251            return Err(format!("pipe is not a file: {}", path.display()));
252        }
253        Ok(path)
254    }
255
256    fn instantiate_pipe(&self, path: &Path) -> Result<LoadedPipe, String> {
257        let bytes = fs::read(path)
258            .map_err(|e| format!("failed to read pipe {}: {e}", path.display()))?;
259        let component = Component::from_binary(&self.engine, &bytes)
260            .map_err(|e| format!("failed to parse pipe {}: {e}", path.display()))?;
261        let linker = Linker::new(&self.engine);
262        let mut store = Store::new(&self.engine, PipeState::default());
263        let instance = pipe_plugin_bindings::PipePlugin::instantiate(&mut store, &component, &linker)
264            .map_err(|e| format!("failed to instantiate pipe {}: {e}", path.display()))?;
265        let meta = instance
266            .wacli_cli_pipe()
267            .call_meta(&mut store)
268            .map_err(|e| format!("failed to read pipe metadata {}: {e}", path.display()))?;
269        Ok(LoadedPipe {
270            store,
271            instance,
272            meta,
273        })
274    }
275}
276
277fn detect_command(args: &[String]) -> Option<String> {
278    args.iter().find(|arg| !arg.starts_with('-')).cloned()
279}
280
281fn collect_pipe_infos(
282    base: &Path,
283    dir: &Path,
284    out: &mut Vec<pipe_runtime::PipeInfo>,
285) -> std::io::Result<()> {
286    for entry in fs::read_dir(dir)? {
287        let entry = entry?;
288        let path = entry.path();
289        if path.is_dir() {
290            collect_pipe_infos(base, &path, out)?;
291            continue;
292        }
293        if !path.is_file() {
294            continue;
295        }
296        let file_name = match path.file_name() {
297            Some(name) => name.to_string_lossy(),
298            None => continue,
299        };
300        if !file_name.ends_with(".component.wasm") {
301            continue;
302        }
303        let rel = match path.strip_prefix(base) {
304            Ok(rel) => rel,
305            Err(_) => continue,
306        };
307        let mut rel_str = rel.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
308        if let Some(stripped) = rel_str.strip_suffix(".component.wasm") {
309            rel_str = stripped.to_string();
310        } else {
311            continue;
312        }
313        if !is_valid_pipe_name(&rel_str) {
314            continue;
315        }
316        out.push(pipe_runtime::PipeInfo {
317            name: rel_str,
318            summary: String::new(),
319            path: path.display().to_string(),
320        });
321    }
322    Ok(())
323}
324
325fn convert_pipe_meta(meta: &pipe_plugin_bindings::wacli::cli::types::PipeMeta) -> pipe_runtime::PipeMeta {
326    pipe_runtime::PipeMeta {
327        name: meta.name.clone(),
328        summary: meta.summary.clone(),
329        input_types: meta.input_types.clone(),
330        output_type: meta.output_type.clone(),
331        version: meta.version.clone(),
332    }
333}
334
335fn convert_pipe_error(err: pipe_plugin_bindings::wacli::cli::types::PipeError) -> pipe_runtime::PipeError {
336    match err {
337        pipe_plugin_bindings::wacli::cli::types::PipeError::ParseError(msg) => {
338            pipe_runtime::PipeError::ParseError(msg)
339        }
340        pipe_plugin_bindings::wacli::cli::types::PipeError::TransformError(msg) => {
341            pipe_runtime::PipeError::TransformError(msg)
342        }
343        pipe_plugin_bindings::wacli::cli::types::PipeError::InvalidOption(msg) => {
344            pipe_runtime::PipeError::InvalidOption(msg)
345        }
346    }
347}
348
349fn is_valid_pipe_name(name: &str) -> bool {
350    if name.is_empty() {
351        return false;
352    }
353    for segment in name.split('/') {
354        if !is_valid_command_name(segment) {
355            return false;
356        }
357    }
358    true
359}
360
361fn is_valid_command_name(name: &str) -> bool {
362    if name.is_empty() {
363        return false;
364    }
365    let mut chars = name.chars();
366    match chars.next() {
367        Some(c) if c.is_ascii_lowercase() => {}
368        _ => return false,
369    }
370    for c in chars {
371        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
372            return false;
373        }
374    }
375    !name.ends_with('-')
376}