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
98pub struct Runner {
100 engine: Engine,
101}
102
103impl Runner {
104 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 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 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}