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