Skip to main content

waf_wasm/
runtime.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! The runtime: compile a Proxy-Wasm guest, drive its request-path lifecycle per request,
5//! and expose it as a [`WafModule`]. See ARCHITECTURE ยง9 and the B3 plan.
6//!
7//! - **Linker**: the implemented subset ([`crate::host::add_to_linker`]) plus a dynamic
8//!   stub for every other import the guest declares, so instantiation never fails on a
9//!   missing import while the coverage gap stays explicit ([`crate::report`]).
10//! - **Pool**: pre-instantiated `(Store, Instance)` pairs behind a `Mutex` + `Condvar`.
11//!   Checkout blocks up to `checkout_timeout`; exhaustion fails closed (the
12//!   `on_internal_error` binary). A `Store` is never shared across threads.
13//! - **DoS**: fuel reset per request, a memory cap; any trap fails closed (`Reject{500}`).
14
15use std::sync::{Arc, Condvar, Mutex};
16use std::time::{Duration, Instant};
17
18use tracing::warn;
19use waf_core::{Decision, Phase, RequestContext, WafModule};
20use wasmi::{
21    Config, Engine, Instance, Linker, Module, Store, StoreLimitsBuilder, Val, ValType,
22};
23
24use crate::abi::Status;
25use crate::host::{add_to_linker, HostState, LocalResponse};
26use crate::marshal::{header_value, RequestView};
27use crate::report::{self, ImportReport};
28
29const ROOT_CTX: i32 = 1;
30const HTTP_CTX: i32 = 2;
31
32/// Tunables for a plugin instance set.
33#[derive(Debug, Clone)]
34pub struct WasmOptions {
35    pub pool_size: usize,
36    pub fuel_per_request: u64,
37    pub max_memory_bytes: usize,
38    pub checkout_timeout: Duration,
39}
40
41impl Default for WasmOptions {
42    fn default() -> Self {
43        WasmOptions {
44            pool_size: 4,
45            fuel_per_request: 10_000_000,
46            max_memory_bytes: 16 * 1024 * 1024,
47            checkout_timeout: Duration::from_millis(100),
48        }
49    }
50}
51
52/// Errors building a plugin (load-time, before serving).
53#[derive(Debug)]
54pub enum WasmError {
55    Compile(String),
56    Instantiate(String),
57    MissingMemory,
58    MissingAllocator,
59    VmStart(String),
60}
61
62impl std::fmt::Display for WasmError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            WasmError::Compile(e) => write!(f, "wasm compile failed: {e}"),
66            WasmError::Instantiate(e) => write!(f, "wasm instantiate failed: {e}"),
67            WasmError::MissingMemory => write!(f, "guest exports no linear memory"),
68            WasmError::MissingAllocator => {
69                write!(f, "guest exports no allocator (proxy_on_memory_allocate/malloc)")
70            }
71            WasmError::VmStart(e) => write!(f, "guest vm/configure start failed: {e}"),
72        }
73    }
74}
75
76impl std::error::Error for WasmError {}
77
78struct Pooled {
79    store: Store<HostState>,
80    instance: Instance,
81}
82
83struct Runtime {
84    name: String,
85    fuel: u64,
86    checkout_timeout: Duration,
87    pool: Mutex<Vec<Pooled>>,
88    available: Condvar,
89}
90
91/// A Proxy-Wasm plugin exposed as a [`WafModule`]. Build with [`WasmModule::from_bytes`].
92pub struct WasmModule {
93    id: String,
94    runtime: Arc<Runtime>,
95}
96
97impl WasmModule {
98    /// Compile `wasm`, build a pool of `opts.pool_size` instances, run each guest's VM
99    /// start/configure with `config`, and return the module plus its import report.
100    pub fn from_bytes(
101        name: &str,
102        wasm: &[u8],
103        config: &[u8],
104        opts: &WasmOptions,
105    ) -> Result<(WasmModule, ImportReport), WasmError> {
106        let mut engine_cfg = Config::default();
107        engine_cfg.consume_fuel(true);
108        let engine = Engine::new(&engine_cfg);
109        let module =
110            Module::new(&engine, wasm).map_err(|e| WasmError::Compile(e.to_string()))?;
111
112        let report = report::classify(&module);
113
114        // One linker (store-independent) reused for every pool instance.
115        let mut linker: Linker<HostState> = Linker::new(&engine);
116        add_to_linker(&mut linker).map_err(|e| WasmError::Instantiate(e.to_string()))?;
117        register_stubs(&mut linker, &module)
118            .map_err(|e| WasmError::Instantiate(e.to_string()))?;
119
120        let mut pool = Vec::with_capacity(opts.pool_size.max(1));
121        for _ in 0..opts.pool_size.max(1) {
122            pool.push(build_instance(&engine, &linker, &module, config, opts)?);
123        }
124
125        let runtime = Arc::new(Runtime {
126            name: name.to_string(),
127            fuel: opts.fuel_per_request,
128            checkout_timeout: opts.checkout_timeout,
129            pool: Mutex::new(pool),
130            available: Condvar::new(),
131        });
132        Ok((WasmModule { id: format!("wasm:{name}"), runtime }, report))
133    }
134
135    fn checkout(&self) -> Option<Pooled> {
136        let rt = &self.runtime;
137        let mut guard = rt.pool.lock().unwrap();
138        let deadline = Instant::now() + rt.checkout_timeout;
139        loop {
140            if let Some(p) = guard.pop() {
141                return Some(p);
142            }
143            let now = Instant::now();
144            if now >= deadline {
145                return None;
146            }
147            let (g, _to) = rt.available.wait_timeout(guard, deadline - now).unwrap();
148            guard = g;
149        }
150    }
151
152    fn checkin(&self, p: Pooled) {
153        self.runtime.pool.lock().unwrap().push(p);
154        self.runtime.available.notify_one();
155    }
156}
157
158impl WafModule for WasmModule {
159    fn id(&self) -> &str {
160        &self.id
161    }
162
163    fn phase(&self) -> Phase {
164        Phase::Body
165    }
166
167    fn init(&mut self, _cfg: &waf_core::Config) {
168        // Compilation + VM start happen in `from_bytes`, before serving.
169    }
170
171    fn inspect(&self, ctx: &RequestContext) -> Decision {
172        let view = view_from(ctx);
173        let mut pooled = match self.checkout() {
174            Some(p) => p,
175            // Pool exhausted within the timeout: fail closed (on_internal_error binary).
176            None => {
177                warn!(plugin = %self.runtime.name, "wasm pool exhausted -> fail closed");
178                return reject_500(&self.runtime.name);
179            }
180        };
181        let decision =
182            run_request(&mut pooled.store, &pooled.instance, view, &ctx.request_id, self.runtime.fuel, &self.runtime.name);
183        self.checkin(pooled);
184        decision
185    }
186
187    /// CRS-like: the host cannot prove a WASM plugin inert, so it runs on every request.
188    fn structural(&self) -> bool {
189        true
190    }
191}
192
193/// Instantiate one pool member and run its VM start/configure.
194fn build_instance(
195    engine: &Engine,
196    linker: &Linker<HostState>,
197    module: &Module,
198    config: &[u8],
199    opts: &WasmOptions,
200) -> Result<Pooled, WasmError> {
201    let limiter = StoreLimitsBuilder::new().memory_size(opts.max_memory_bytes).build();
202    let mut store = Store::new(engine, HostState::new(limiter));
203    store.limiter(|s| &mut s.limiter);
204    store.data_mut().config = config.to_vec();
205    // VM start can run guest code -> bound it with one request's fuel budget.
206    store.set_fuel(opts.fuel_per_request).ok();
207
208    let instance = linker
209        .instantiate_and_start(&mut store, module)
210        .map_err(|e| WasmError::Instantiate(e.to_string()))?;
211
212    // Cache memory + allocator for the host functions.
213    let mem = instance.get_memory(&store, "memory").ok_or(WasmError::MissingMemory)?;
214    let alloc = crate::abi::ALLOC_EXPORTS
215        .iter()
216        .find_map(|n| instance.get_typed_func::<i32, i32>(&store, n).ok())
217        .ok_or(WasmError::MissingAllocator)?;
218    store.data_mut().mem = Some(mem);
219    store.data_mut().alloc = Some(alloc);
220
221    // Proxy-Wasm SDKs register their root-context factory in a WASI-reactor initializer
222    // (`_initialize`, or `_start` from the older `main!` macro). wasmi does NOT call it
223    // automatically (it only runs a wasm `start` section, which these modules lack), so the
224    // SDK's context map would be empty and `proxy_on_context_create` would panic. Call it
225    // once here, before the lifecycle.
226    for init in ["_initialize", "_start"] {
227        if instance.get_func(&store, init).is_some() {
228            call_opt(&mut store, &instance, init, &[])
229                .map_err(|e| WasmError::VmStart(format!("{init}: {e}")))?;
230            break;
231        }
232    }
233
234    // Root context + vm start + configure.
235    call_opt(&mut store, &instance, "proxy_on_context_create", &[ROOT_CTX, 0])
236        .map_err(|e| WasmError::VmStart(e.to_string()))?;
237    if let Some(r) = call_opt(&mut store, &instance, "proxy_on_vm_start", &[ROOT_CTX, 0])
238        .map_err(|e| WasmError::VmStart(e.to_string()))?
239    {
240        if r == 0 {
241            return Err(WasmError::VmStart("proxy_on_vm_start returned failure".into()));
242        }
243    }
244    if let Some(r) =
245        call_opt(&mut store, &instance, "proxy_on_configure", &[ROOT_CTX, config.len() as i32])
246            .map_err(|e| WasmError::VmStart(e.to_string()))?
247    {
248        if r == 0 {
249            return Err(WasmError::VmStart("proxy_on_configure returned failure".into()));
250        }
251    }
252
253    Ok(Pooled { store, instance })
254}
255
256/// Register a dynamic stub for every func import the runtime does not implement. The stub
257/// returns `Status::Unimplemented` for single-`i32` results (the honest answer the SDK maps
258/// to an error) and zeros otherwise.
259fn register_stubs(linker: &mut Linker<HostState>, module: &Module) -> Result<(), wasmi::Error> {
260    for import in module.imports() {
261        let Some(func_ty) = import.ty().func().cloned() else { continue };
262        let name = import.name();
263        if report::IMPLEMENTED.contains(&name) {
264            continue;
265        }
266        let name = name.to_string();
267        let res_types: Vec<ValType> = func_ty.results().to_vec();
268        let unimpl = res_types.len() == 1 && res_types[0] == ValType::I32;
269        // Whether *invoking* this stub changes detection semantics (paletto #4). A real
270        // SDK *declares* every host import, so the accurate "degraded" signal is a runtime
271        // call, not a declared import โ€” warn once, when actually hit.
272        let semantic = report::SEMANTIC.contains(&name.as_str());
273        let call_name = name.clone();
274        linker.func_new(
275            "env",
276            &name,
277            func_ty,
278            move |mut caller, _params: &[Val], results: &mut [Val]| {
279                if semantic && caller.data().semantic_stub_hit.is_none() {
280                    caller.data_mut().semantic_stub_hit = Some(call_name.clone());
281                    warn!(
282                        host_call = %call_name,
283                        "wasm plugin invoked a stubbed semantic host call -> Unimplemented (DEGRADED at runtime)"
284                    );
285                }
286                for (slot, ty) in results.iter_mut().zip(res_types.iter()) {
287                    *slot = zero_val(*ty);
288                }
289                if unimpl {
290                    if let Some(s) = results.get_mut(0) {
291                        *s = Val::I32(Status::Unimplemented.code());
292                    }
293                }
294                Ok(())
295            },
296        )?;
297    }
298    Ok(())
299}
300
301fn zero_val(t: ValType) -> Val {
302    match t {
303        ValType::I32 => Val::I32(0),
304        ValType::I64 => Val::I64(0),
305        ValType::F32 => Val::F32(0.0f32.into()),
306        ValType::F64 => Val::F64(0.0f64.into()),
307        _ => Val::I32(0),
308    }
309}
310
311/// Build the request view the host exposes to the guest (header names lowercased so
312/// property/value lookups are case-insensitive). The HTTP/2-style **pseudo-headers**
313/// (`:method`/`:path`/`:authority`/`:scheme`) are injected into the header map because
314/// real Proxy-Wasm filters routinely read them there (Envoy convention) โ€” in addition to
315/// the `get_property` path.
316fn view_from(ctx: &RequestContext) -> RequestView {
317    let real: Vec<(String, String)> = ctx
318        .headers
319        .iter()
320        .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
321        .collect();
322    let host = header_value(&real, "host").cloned().unwrap_or_default();
323    let path = match &ctx.query {
324        Some(q) => format!("{}?{}", ctx.path, q),
325        None => ctx.path.clone(),
326    };
327    let mut headers = vec![
328        (":method".to_string(), ctx.method.clone()),
329        (":path".to_string(), path.clone()),
330        (":authority".to_string(), host.clone()),
331        (":scheme".to_string(), "http".to_string()),
332    ];
333    headers.extend(real);
334    RequestView {
335        method: ctx.method.clone(),
336        path,
337        url_path: ctx.path.clone(),
338        protocol: ctx.http_version.clone(),
339        scheme: "http".to_string(),
340        host,
341        source_addr: ctx.client_ip.to_string(),
342        headers,
343        body: ctx.body.clone(),
344    }
345}
346
347/// Drive one request's callback sequence and map the captured disposition to a `Decision`.
348/// Any trap/error fails closed.
349fn run_request(
350    store: &mut Store<HostState>,
351    inst: &Instance,
352    view: RequestView,
353    request_id: &str,
354    fuel: u64,
355    name: &str,
356) -> Decision {
357    store.data_mut().reset_for(view, request_id.to_string());
358    if store.set_fuel(fuel).is_err() {
359        return reject_500(name);
360    }
361    match run_callbacks(store, inst) {
362        Ok(()) => map_decision(store.data().captured.clone(), name),
363        Err(e) => {
364            warn!(plugin = %name, error = %e, "wasm request failed (fail-closed)");
365            reject_500(name)
366        }
367    }
368}
369
370fn run_callbacks(store: &mut Store<HostState>, inst: &Instance) -> Result<(), wasmi::Error> {
371    let num_headers = store.data().req.headers.len() as i32;
372    let body_len = store.data().req.body.len() as i32;
373    call_opt(store, inst, "proxy_on_context_create", &[HTTP_CTX, ROOT_CTX])?;
374    let action = call_opt(store, inst, "proxy_on_request_headers", &[HTTP_CTX, num_headers, 1])?
375        .unwrap_or(0);
376    if action == 0 {
377        // Continue
378        call_opt(store, inst, "proxy_on_request_body", &[HTTP_CTX, body_len, 1])?;
379    }
380    call_opt(store, inst, "proxy_on_done", &[HTTP_CTX])?;
381    call_opt(store, inst, "proxy_on_delete", &[HTTP_CTX])?;
382    Ok(())
383}
384
385fn map_decision(captured: Option<LocalResponse>, name: &str) -> Decision {
386    match captured {
387        None => Decision::Allow,
388        Some(lr) => {
389            let rule_id = format!("wasm-{name}");
390            let reason = if lr.detail.is_empty() {
391                "wasm plugin local response".to_string()
392            } else {
393                lr.detail
394            };
395            if lr.status == 403 {
396                Decision::Block { rule_id, reason }
397            } else {
398                Decision::Reject { rule_id, reason, status: lr.status, retry_after: None }
399            }
400        }
401    }
402}
403
404fn reject_500(name: &str) -> Decision {
405    Decision::Reject {
406        rule_id: format!("wasm-{name}"),
407        reason: "wasm runtime error (fail-closed)".to_string(),
408        status: 500,
409        retry_after: None,
410    }
411}
412
413/// Call an exported function by name if it exists, tolerating ABI arity differences
414/// (0.2.0 vs 0.2.1) by padding/truncating `args` to the export's actual param count.
415/// Returns the first result as `i32` (or `Some(0)` for a void export, `None` if absent).
416fn call_opt(
417    store: &mut Store<HostState>,
418    inst: &Instance,
419    name: &str,
420    args: &[i32],
421) -> Result<Option<i32>, wasmi::Error> {
422    let Some(func) = inst.get_func(&*store, name) else { return Ok(None) };
423    let ty = func.ty(&*store);
424    let nparams = ty.params().len();
425    let params: Vec<Val> = (0..nparams).map(|i| Val::I32(args.get(i).copied().unwrap_or(0))).collect();
426    let mut results: Vec<Val> = ty.results().iter().map(|t| zero_val(*t)).collect();
427    func.call(&mut *store, &params, &mut results)?;
428    Ok(Some(results.first().and_then(|v| v.i32()).unwrap_or(0)))
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    // A guest that imports a SEMANTIC host call (`proxy_http_call`, 10 params) and invokes
436    // it โ€” exercising the runtime degradation signal (paletto #4).
437    const HTTP_CALL_WAT: &str = r#"
438    (module
439      (import "env" "proxy_http_call"
440        (func $http (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
441      (memory (export "memory") 1)
442      (func (export "proxy_on_memory_allocate") (param i32) (result i32) (i32.const 16))
443      (func (export "run") (result i32)
444        (call $http (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0)
445                    (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0))))
446    "#;
447
448    #[test]
449    fn semantic_stub_invocation_returns_unimplemented_and_is_flagged() {
450        let mut cfg = Config::default();
451        cfg.consume_fuel(true);
452        let engine = Engine::new(&cfg);
453        let module = Module::new(&engine, wat::parse_str(HTTP_CALL_WAT).unwrap().as_slice()).unwrap();
454
455        // The stub must be classified semantic (so a runtime call flags it).
456        let report = report::classify(&module);
457        assert_eq!(report.semantic_stubs(), vec!["proxy_http_call"]);
458
459        let mut linker = Linker::new(&engine);
460        add_to_linker(&mut linker).unwrap();
461        register_stubs(&mut linker, &module).unwrap();
462
463        let limiter = StoreLimitsBuilder::new().memory_size(64 * 1024).build();
464        let mut store = Store::new(&engine, HostState::new(limiter));
465        store.limiter(|s| &mut s.limiter);
466        store.set_fuel(1_000_000).unwrap();
467        let inst = linker.instantiate_and_start(&mut store, &module).unwrap();
468
469        let run = inst.get_typed_func::<(), i32>(&store, "run").unwrap();
470        let ret = run.call(&mut store, ()).unwrap();
471        assert_eq!(ret, Status::Unimplemented.code(), "semantic stub must answer Unimplemented");
472        assert_eq!(
473            store.data().semantic_stub_hit.as_deref(),
474            Some("proxy_http_call"),
475            "invoking a semantic stub must flag the instance as degraded at runtime"
476        );
477    }
478}