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
38pub struct Runner {
40 engine: Engine,
41}
42
43impl Runner {
44 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 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}