Skip to main content

rws_wasm_plugin/wasm_plugin/
mod.rs

1//! WebAssembly middleware (18.10.0, #40, feature `wasm-plugins`): a
2//! [`WasmLayer`] runs a core WebAssembly module on each request and,
3//! optionally, each response. The module may let the request pass, change
4//! its headers or target, answer it itself, or change the response's status
5//! and headers.
6//!
7//! **Isolation.** Plugins get no imports at all — no WASI, no clock, no
8//! files, no network: a module that imports anything is refused at load.
9//! They see only what the host hands them. CPU per call is bounded by fuel,
10//! memory by a limit on the instance's linear memory. A trap, running out
11//! of fuel or memory, or output the host can't read answers `500` and
12//! counts in `rws_wasm_plugin_errors_total`; it never takes the server down.
13//! Each request gets a fresh instance, so nothing leaks from one request to
14//! the next.
15//!
16//! **ABI** (version 1). The module exports:
17//!
18//! | Export | Signature | |
19//! |---|---|---|
20//! | `memory` | memory | required |
21//! | `rws_alloc` | `(len: i32) -> i32` | required: a buffer of `len` bytes the host writes input into |
22//! | `rws_on_request` | `(ptr: i32, len: i32) -> i64` | required |
23//! | `rws_on_response` | `(ptr: i32, len: i32) -> i64` | optional |
24//! | `rws_init` | `(ptr: i32, len: i32)` | optional: called once per instance with the `config` string |
25//!
26//! A hook returns `0` to change nothing, or `(ptr << 32) | len` of its
27//! output in its memory. The request a hook gets:
28//!
29//! ```text
30//! GET /path?query HTTP/1.1
31//! 203.0.113.7                  ← the client address
32//! host: example.com            ← every header, "name: value"
33//! user-agent: curl/8.11.1
34//!                              ← blank line; the body isn't passed
35//! ```
36//!
37//! The response: its status code on the first line, then its headers and a
38//! blank line. Output is one directive per line:
39//!
40//! | Directive | In | Does |
41//! |---|---|---|
42//! | `set <name>: <value>` | both | replace the header |
43//! | `add <name>: <value>` | both | add a header |
44//! | `remove <name>` | both | remove the header |
45//! | `uri <target>` | request | change the request target (origin form, `/…`) |
46//! | `status <code>` | response | change the status |
47//! | `respond <code>` | request | answer now: header lines (`name: value`) follow, then a blank line and the body |
48//!
49//! Anything else is an error (fail closed). Header names and values with
50//! CR, LF or NUL are refused.
51
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::sync::{Arc, Mutex, OnceLock};
54
55use wasmtime::{Config, Engine, Instance, InstancePre, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, Trap, TypedFunc};
56
57use crate::application::Application;
58use crate::header::Header;
59use crate::middleware::Middleware;
60use crate::request::Request;
61use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
62use crate::server::ConnectionInfo;
63
64/// Fuel per call when not configured: roughly tens of milliseconds of CPU.
65pub const DEFAULT_FUEL: u64 = 50_000_000;
66/// Linear memory limit when not configured, in MiB.
67pub const DEFAULT_MEMORY_MB: u64 = 16;
68/// The most output a hook may return.
69pub const MAX_OUTPUT: usize = 1024 * 1024;
70
71fn engine() -> &'static Engine {
72    static ENGINE: OnceLock<Engine> = OnceLock::new();
73    ENGINE.get_or_init(|| {
74        let mut config = Config::new();
75        config.consume_fuel(true);
76        Engine::new(&config).expect("a wasmtime engine with fuel")
77    })
78}
79
80/// What went wrong in a plugin call (the metric's `kind` label).
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum PluginError {
83    /// Ran out of fuel.
84    Fuel,
85    /// Hit the memory limit.
86    Memory,
87    /// Any other trap (`unreachable`, out-of-bounds access, …).
88    Trap(String),
89    /// Output the host couldn't read.
90    Output(String),
91}
92
93impl PluginError {
94    fn kind(&self) -> &'static str {
95        match self {
96            PluginError::Fuel => "fuel",
97            PluginError::Memory => "memory",
98            PluginError::Trap(_) => "trap",
99            PluginError::Output(_) => "output",
100        }
101    }
102}
103
104impl std::fmt::Display for PluginError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            PluginError::Fuel => write!(f, "ran out of fuel"),
108            PluginError::Memory => write!(f, "hit its memory limit"),
109            PluginError::Trap(t) => write!(f, "trapped: {t}"),
110            PluginError::Output(o) => write!(f, "bad output: {o}"),
111        }
112    }
113}
114
115/// A request hook's decision.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RequestAction {
118    Pass,
119    /// Header edits and an optional new target.
120    Modify { edits: Vec<Edit>, uri: Option<String> },
121    Respond { status: u16, headers: Vec<(String, String)>, body: Vec<u8> },
122}
123
124/// A response hook's decision.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ResponseAction {
127    pub edits: Vec<Edit>,
128    pub status: Option<u16>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum Edit {
133    Set(String, String),
134    Add(String, String),
135    Remove(String),
136}
137
138fn valid_token(s: &str) -> bool {
139    !s.is_empty() && !s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
140}
141
142fn header_pair(rest: &str) -> Result<(String, String), PluginError> {
143    let (name, value) = rest.split_once(':').ok_or_else(|| PluginError::Output(format!("expected name: value, got {rest:?}")))?;
144    let (name, value) = (name.trim(), value.trim());
145    if !valid_token(name) || name.contains(' ') || value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
146        return Err(PluginError::Output(format!("bad header {name:?}")));
147    }
148    Ok((name.to_string(), value.to_string()))
149}
150
151fn status_code(s: &str) -> Result<u16, PluginError> {
152    match s.trim().parse::<u16>() {
153        Ok(code) if (100..=599).contains(&code) => Ok(code),
154        _ => Err(PluginError::Output(format!("bad status {s:?}"))),
155    }
156}
157
158/// A request hook's output, decoded. Public for the fuzz target.
159pub fn parse_request_output(output: &[u8]) -> Result<RequestAction, PluginError> {
160    if output.is_empty() {
161        return Ok(RequestAction::Pass);
162    }
163    let text_end = output.len();
164    let mut edits = Vec::new();
165    let mut uri = None;
166    let mut offset = 0;
167    while offset < text_end {
168        let end = output[offset..].iter().position(|&b| b == b'\n').map_or(text_end, |p| offset + p);
169        let line = std::str::from_utf8(&output[offset..end]).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
170        let line = line.strip_suffix('\r').unwrap_or(line);
171        offset = (end + 1).min(text_end);
172        if line.is_empty() {
173            continue;
174        }
175        let (directive, rest) = line.split_once(' ').unwrap_or((line, ""));
176        match directive {
177            "set" => {
178                let (n, v) = header_pair(rest)?;
179                edits.push(Edit::Set(n, v));
180            }
181            "add" => {
182                let (n, v) = header_pair(rest)?;
183                edits.push(Edit::Add(n, v));
184            }
185            "remove" if valid_token(rest.trim()) => edits.push(Edit::Remove(rest.trim().to_string())),
186            "uri" if rest.starts_with('/') && valid_token(rest) && !rest.contains(' ') => uri = Some(rest.to_string()),
187            "respond" => {
188                let status = status_code(rest)?;
189                let mut headers = Vec::new();
190                loop {
191                    if offset >= text_end {
192                        return Ok(RequestAction::Respond { status, headers, body: Vec::new() });
193                    }
194                    let end = output[offset..].iter().position(|&b| b == b'\n').map_or(text_end, |p| offset + p);
195                    let line = std::str::from_utf8(&output[offset..end]).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
196                    let line = line.strip_suffix('\r').unwrap_or(line);
197                    offset = (end + 1).min(text_end);
198                    if line.is_empty() {
199                        let body = if end < text_end { output[offset..].to_vec() } else { Vec::new() };
200                        return Ok(RequestAction::Respond { status, headers, body });
201                    }
202                    headers.push(header_pair(line)?);
203                }
204            }
205            _ => return Err(PluginError::Output(format!("unknown directive {line:?}"))),
206        }
207    }
208    Ok(if edits.is_empty() && uri.is_none() { RequestAction::Pass } else { RequestAction::Modify { edits, uri } })
209}
210
211/// A response hook's output, decoded. Public for the fuzz target.
212pub fn parse_response_output(output: &[u8]) -> Result<ResponseAction, PluginError> {
213    let text = std::str::from_utf8(output).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
214    let mut action = ResponseAction { edits: Vec::new(), status: None };
215    for line in text.lines() {
216        if line.is_empty() {
217            continue;
218        }
219        let (directive, rest) = line.split_once(' ').unwrap_or((line, ""));
220        match directive {
221            "set" => {
222                let (n, v) = header_pair(rest)?;
223                action.edits.push(Edit::Set(n, v));
224            }
225            "add" => {
226                let (n, v) = header_pair(rest)?;
227                action.edits.push(Edit::Add(n, v));
228            }
229            "remove" if valid_token(rest.trim()) => action.edits.push(Edit::Remove(rest.trim().to_string())),
230            "status" => action.status = Some(status_code(rest)?),
231            _ => return Err(PluginError::Output(format!("unknown directive {line:?}"))),
232        }
233    }
234    Ok(action)
235}
236
237fn apply(headers: &mut Vec<Header>, edits: &[Edit]) {
238    for edit in edits {
239        match edit {
240            Edit::Set(name, value) => {
241                headers.retain(|h| !h.name.eq_ignore_ascii_case(name));
242                headers.push(Header::new(name.clone(), value.clone()));
243            }
244            Edit::Add(name, value) => headers.push(Header::new(name.clone(), value.clone())),
245            Edit::Remove(name) => headers.retain(|h| !h.name.eq_ignore_ascii_case(name)),
246        }
247    }
248}
249
250fn request_input(request: &Request, connection: &ConnectionInfo) -> Vec<u8> {
251    let mut out = format!("{} {} {}\n{}\n", request.method, request.request_uri, request.http_version, crate::ip_filter::client_ip(request, &connection.client.ip)).into_bytes();
252    for h in &request.headers {
253        out.extend_from_slice(h.name.to_ascii_lowercase().as_bytes());
254        out.extend_from_slice(b": ");
255        out.extend_from_slice(h.value.as_bytes());
256        out.push(b'\n');
257    }
258    out.push(b'\n');
259    out
260}
261
262fn response_input(response: &Response) -> Vec<u8> {
263    let mut out = format!("{}\n", response.status_code).into_bytes();
264    let content_type = response.content_range_list.first().map(|p| p.content_type.clone()).filter(|c| !c.is_empty());
265    if let Some(ct) = content_type.filter(|_| !response.headers.iter().any(|h| h.name.eq_ignore_ascii_case("content-type"))) {
266        out.extend_from_slice(format!("content-type: {ct}\n").as_bytes());
267    }
268    for h in &response.headers {
269        out.extend_from_slice(h.name.to_ascii_lowercase().as_bytes());
270        out.extend_from_slice(b": ");
271        out.extend_from_slice(h.value.as_bytes());
272        out.push(b'\n');
273    }
274    out.push(b'\n');
275    out
276}
277
278struct State {
279    limits: StoreLimits,
280}
281
282/// One instance of the plugin, for one request.
283struct Call {
284    store: Store<State>,
285    instance: Instance,
286}
287
288impl Call {
289    fn error(&self, e: wasmtime::Error) -> PluginError {
290        match e.downcast_ref::<Trap>() {
291            Some(Trap::OutOfFuel) => PluginError::Fuel,
292            Some(Trap::AllocationTooLarge) => PluginError::Memory,
293            Some(trap) => PluginError::Trap(trap.to_string()),
294            None => {
295                // A grow past the limit (trap_on_grow_failure) is an error
296                // whose cause names it, not a Trap.
297                let chain = format!("{e:#}");
298                if chain.contains("grow") || (chain.contains("memory") && chain.contains("limit")) {
299                    PluginError::Memory
300                } else {
301                    PluginError::Trap(chain.lines().next().unwrap_or_default().to_string())
302                }
303            }
304        }
305    }
306
307    fn write_input(&mut self, input: &[u8]) -> Result<(i32, i32), PluginError> {
308        let alloc: TypedFunc<i32, i32> = self
309            .instance
310            .get_typed_func(&mut self.store, "rws_alloc")
311            .map_err(|e| PluginError::Output(format!("no rws_alloc: {e}")))?;
312        let len = i32::try_from(input.len()).map_err(|_| PluginError::Output("input too large".to_string()))?;
313        let ptr = alloc.call(&mut self.store, len).map_err(|e| self.error(e))?;
314        let memory = self.instance.get_memory(&mut self.store, "memory").ok_or_else(|| PluginError::Output("no memory export".to_string()))?;
315        memory
316            .write(&mut self.store, ptr as u32 as usize, input)
317            .map_err(|_| PluginError::Output("rws_alloc returned a buffer outside memory".to_string()))?;
318        Ok((ptr, len))
319    }
320
321    /// Call hook `name` with `input`; `None` when the module doesn't export it.
322    fn hook(&mut self, name: &str, input: &[u8]) -> Result<Option<Vec<u8>>, PluginError> {
323        let Ok(func) = self.instance.get_typed_func::<(i32, i32), i64>(&mut self.store, name) else {
324            return Ok(None);
325        };
326        let (ptr, len) = self.write_input(input)?;
327        let packed = func.call(&mut self.store, (ptr, len)).map_err(|e| self.error(e))?;
328        if packed == 0 {
329            return Ok(Some(Vec::new()));
330        }
331        let (out_ptr, out_len) = ((packed as u64 >> 32) as usize, (packed as u64 & 0xffff_ffff) as usize);
332        if out_len > MAX_OUTPUT {
333            return Err(PluginError::Output(format!("output of {out_len} bytes is over {MAX_OUTPUT}")));
334        }
335        let memory = self.instance.get_memory(&mut self.store, "memory").ok_or_else(|| PluginError::Output("no memory export".to_string()))?;
336        let data = memory.data(&self.store);
337        let end = out_ptr.checked_add(out_len).filter(|&e| e <= data.len()).ok_or_else(|| PluginError::Output("output outside memory".to_string()))?;
338        Ok(Some(data[out_ptr..end].to_vec()))
339    }
340}
341
342struct Plugin {
343    name: String,
344    pre: InstancePre<State>,
345    fuel: u64,
346    memory_bytes: usize,
347    config: Vec<u8>,
348    has_response_hook: bool,
349}
350
351/// WebAssembly middleware. See the [module docs](self).
352#[derive(Clone)]
353pub struct WasmLayer {
354    plugin: Arc<Plugin>,
355}
356
357impl std::fmt::Debug for WasmLayer {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("WasmLayer").field("name", &self.plugin.name).field("fuel", &self.plugin.fuel).finish_non_exhaustive()
360    }
361}
362
363impl WasmLayer {
364    /// Compile `wasm` (binary module bytes) as plugin `name`. Refused: a
365    /// module that imports anything, or lacks `memory`, `rws_alloc` or
366    /// `rws_on_request` of the right types.
367    pub fn new(name: impl Into<String>, wasm: &[u8]) -> Result<WasmLayer, String> {
368        let name = name.into();
369        let module = Module::new(engine(), wasm).map_err(|e| format!("plugin {name}: not a valid WebAssembly module: {e}"))?;
370        if let Some(import) = module.imports().next() {
371            return Err(format!(
372                "plugin {name}: imports {}::{}; plugins get no imports (no WASI)",
373                import.module(),
374                import.name()
375            ));
376        }
377        let exports: Vec<(String, wasmtime::ExternType)> = module.exports().map(|e| (e.name().to_string(), e.ty())).collect();
378        let func = |n: &str| exports.iter().find(|(name, _)| name == n).and_then(|(_, t)| t.func().cloned());
379        let shape = |n: &str, params: &[wasmtime::ValType], results: &[wasmtime::ValType]| {
380            func(n).is_some_and(|f| {
381                f.params().map(|p| p.to_string()).collect::<Vec<_>>() == params.iter().map(|p| p.to_string()).collect::<Vec<_>>()
382                    && f.results().map(|p| p.to_string()).collect::<Vec<_>>() == results.iter().map(|p| p.to_string()).collect::<Vec<_>>()
383            })
384        };
385        use wasmtime::ValType::{I32, I64};
386        if !exports.iter().any(|(n, t)| n == "memory" && t.memory().is_some()) {
387            return Err(format!("plugin {name}: exports no memory"));
388        }
389        if !shape("rws_alloc", &[I32], &[I32]) {
390            return Err(format!("plugin {name}: needs rws_alloc(len: i32) -> i32"));
391        }
392        if !shape("rws_on_request", &[I32, I32], &[I64]) {
393            return Err(format!("plugin {name}: needs rws_on_request(ptr: i32, len: i32) -> i64"));
394        }
395        if func("rws_on_response").is_some() && !shape("rws_on_response", &[I32, I32], &[I64]) {
396            return Err(format!("plugin {name}: rws_on_response must be (ptr: i32, len: i32) -> i64"));
397        }
398        if func("rws_init").is_some() && !shape("rws_init", &[I32, I32], &[]) {
399            return Err(format!("plugin {name}: rws_init must be (ptr: i32, len: i32)"));
400        }
401        let pre = Linker::new(engine()).instantiate_pre(&module).map_err(|e| format!("plugin {name}: {e}"))?;
402        Ok(WasmLayer {
403            plugin: Arc::new(Plugin {
404                has_response_hook: func("rws_on_response").is_some(),
405                name,
406                pre,
407                fuel: DEFAULT_FUEL,
408                memory_bytes: (DEFAULT_MEMORY_MB * 1024 * 1024) as usize,
409                config: Vec::new(),
410            }),
411        })
412    }
413
414    /// [`Self::new`] from a `.wasm` file; the plugin is named after it.
415    pub fn from_file(path: &str) -> Result<WasmLayer, String> {
416        let wasm = std::fs::read(path).map_err(|e| format!("plugin {path}: {e}"))?;
417        let name = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
418        WasmLayer::new(name, &wasm)
419    }
420
421    fn edit(self, f: impl FnOnce(&mut Plugin)) -> WasmLayer {
422        let mut plugin = Plugin {
423            name: self.plugin.name.clone(),
424            pre: self.plugin.pre.clone(),
425            fuel: self.plugin.fuel,
426            memory_bytes: self.plugin.memory_bytes,
427            config: self.plugin.config.clone(),
428            has_response_hook: self.plugin.has_response_hook,
429        };
430        f(&mut plugin);
431        WasmLayer { plugin: Arc::new(plugin) }
432    }
433
434    /// Fuel per hook call (default [`DEFAULT_FUEL`]).
435    pub fn fuel(self, fuel: u64) -> WasmLayer {
436        self.edit(|p| p.fuel = fuel.max(1))
437    }
438
439    /// Linear memory limit in MiB (default [`DEFAULT_MEMORY_MB`]).
440    pub fn memory_mb(self, mb: u64) -> WasmLayer {
441        self.edit(|p| p.memory_bytes = (mb.max(1) * 1024 * 1024) as usize)
442    }
443
444    /// The string `rws_init` gets.
445    pub fn config(self, config: impl Into<String>) -> WasmLayer {
446        let config = config.into().into_bytes();
447        self.edit(|p| p.config = config)
448    }
449
450    pub fn name(&self) -> &str {
451        &self.plugin.name
452    }
453
454    fn instantiate(&self) -> Result<Call, PluginError> {
455        let p = &self.plugin;
456        let limits = StoreLimitsBuilder::new().memory_size(p.memory_bytes).instances(1).tables(4).memories(1).trap_on_grow_failure(true).build();
457        let mut store = Store::new(engine(), State { limits });
458        store.limiter(|s| &mut s.limits);
459        store.set_fuel(p.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
460        let instance = match p.pre.instantiate(&mut store) {
461            Ok(instance) => instance,
462            Err(e) => {
463                let text = format!("{e:#}");
464                return Err(if text.contains("memory") { PluginError::Memory } else if text.contains("fuel") { PluginError::Fuel } else { PluginError::Trap(text) });
465            }
466        };
467        let mut call = Call { store, instance };
468        if !p.config.is_empty() {
469            if let Ok(init) = call.instance.get_typed_func::<(i32, i32), ()>(&mut call.store, "rws_init") {
470                let (ptr, len) = call.write_input(&p.config)?;
471                init.call(&mut call.store, (ptr, len)).map_err(|e| call.error(e))?;
472            }
473        }
474        Ok(call)
475    }
476
477    /// Run the request hook on its own (for tests and embedders).
478    pub fn on_request(&self, request: &Request, connection: &ConnectionInfo) -> Result<RequestAction, PluginError> {
479        let mut call = self.instantiate()?;
480        let output = call.hook("rws_on_request", &request_input(request, connection))?.unwrap_or_default();
481        parse_request_output(&output)
482    }
483
484    fn failed(&self, hook: &'static str, e: &PluginError) -> Response {
485        metrics::error(&self.plugin.name, e.kind());
486        eprintln!("wasm plugin {} ({hook}) {e}", self.plugin.name);
487        crate::error::problem(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, "a plugin failed")
488    }
489}
490
491impl Middleware for WasmLayer {
492    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
493        metrics::call(&self.plugin.name, "request");
494        let mut call = match self.instantiate() {
495            Ok(call) => call,
496            Err(e) => return Ok(self.failed("request", &e)),
497        };
498        let action = call
499            .hook("rws_on_request", &request_input(request, connection))
500            .and_then(|output| parse_request_output(&output.unwrap_or_default()));
501        let mut response = match action {
502            Err(e) => return Ok(self.failed("request", &e)),
503            Ok(RequestAction::Respond { status, headers, body }) => {
504                let reason = STATUS_CODE_REASON_PHRASE_FOR(status);
505                let content_type = headers
506                    .iter()
507                    .find(|(n, _)| n.eq_ignore_ascii_case("content-type"))
508                    .map(|(_, v)| v.clone())
509                    .unwrap_or_else(|| "text/plain".to_string());
510                let mut response = Response::get_response(reason, None, Some(vec![crate::range::Range::get_content_range(body, content_type)]));
511                response.status_code = status as i16;
512                for (name, value) in headers.into_iter().filter(|(n, _)| !n.eq_ignore_ascii_case("content-type") && !n.eq_ignore_ascii_case("content-length")) {
513                    response.headers.push(Header::new(name, value));
514                }
515                return Ok(response);
516            }
517            Ok(RequestAction::Pass) => next.execute(request, connection)?,
518            Ok(RequestAction::Modify { edits, uri }) => {
519                let mut changed = request.clone();
520                apply(&mut changed.headers, &edits);
521                if let Some(uri) = uri {
522                    changed.request_uri = uri;
523                }
524                next.execute(&changed, connection)?
525            }
526        };
527        if self.plugin.has_response_hook {
528            metrics::call(&self.plugin.name, "response");
529            let action = call
530                .hook("rws_on_response", &response_input(&response))
531                .and_then(|output| parse_response_output(&output.unwrap_or_default()));
532            match action {
533                Err(e) => return Ok(self.failed("response", &e)),
534                Ok(action) => {
535                    apply(&mut response.headers, &action.edits);
536                    if let Some(status) = action.status {
537                        response.status_code = status as i16;
538                        response.reason_phrase = STATUS_CODE_REASON_PHRASE_FOR(status).reason_phrase.to_string();
539                    }
540                }
541            }
542        }
543        Ok(response)
544    }
545
546    /// Headers only: a streamed request body passes through untouched.
547    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
548        true
549    }
550}
551
552/// The reason phrase table entry for `status` (a generic one for codes
553/// without a standard phrase).
554#[allow(non_snake_case)]
555fn STATUS_CODE_REASON_PHRASE_FOR(status: u16) -> &'static crate::response::StatusCodeReasonPhrase {
556    crate::response::Response::status_code_reason_phrase_list()
557        .into_iter()
558        .find(|s| *s.status_code as u16 == status)
559        .unwrap_or(STATUS_CODE_REASON_PHRASE.n200_ok)
560}
561
562/// `rws_wasm_plugin_*` series.
563#[doc(hidden)]
564pub mod metrics {
565    use super::*;
566    use std::collections::BTreeMap;
567
568    #[derive(Default)]
569    struct Stats {
570        calls: BTreeMap<(String, &'static str), u64>,
571        errors: BTreeMap<(String, &'static str), u64>,
572    }
573
574    static ANY: AtomicU64 = AtomicU64::new(0);
575
576    fn stats() -> &'static Mutex<Stats> {
577        static STATS: OnceLock<Mutex<Stats>> = OnceLock::new();
578        STATS.get_or_init(Mutex::default)
579    }
580
581    #[doc(hidden)]
582    pub fn call(plugin: &str, hook: &'static str) {
583        if ANY.fetch_add(1, Ordering::Relaxed) == 0 {
584            crate::metrics::register_section(crate::metrics::section::WASM_PLUGINS, prometheus_text);
585        }
586        *stats().lock().unwrap_or_else(|e| e.into_inner()).calls.entry((plugin.to_string(), hook)).or_default() += 1;
587    }
588
589    #[doc(hidden)]
590    pub fn error(plugin: &str, kind: &'static str) {
591        *stats().lock().unwrap_or_else(|e| e.into_inner()).errors.entry((plugin.to_string(), kind)).or_default() += 1;
592    }
593
594    fn label(value: &str) -> String {
595        value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
596    }
597
598    #[doc(hidden)]
599    pub fn prometheus_text() -> String {
600        if ANY.load(Ordering::Relaxed) == 0 {
601            return String::new();
602        }
603        let stats = stats().lock().unwrap_or_else(|e| e.into_inner());
604        let mut out = String::from("# HELP rws_wasm_plugin_calls_total WebAssembly plugin hook calls\n# TYPE rws_wasm_plugin_calls_total counter\n");
605        for ((plugin, hook), n) in &stats.calls {
606            out.push_str(&format!("rws_wasm_plugin_calls_total{{plugin=\"{}\",hook=\"{hook}\"}} {n}\n", label(plugin)));
607        }
608        if !stats.errors.is_empty() {
609            out.push_str("# HELP rws_wasm_plugin_errors_total WebAssembly plugin calls that failed (answered 500), by kind: fuel, memory, trap, output\n# TYPE rws_wasm_plugin_errors_total counter\n");
610            for ((plugin, kind), n) in &stats.errors {
611                out.push_str(&format!("rws_wasm_plugin_errors_total{{plugin=\"{}\",kind=\"{kind}\"}} {n}\n", label(plugin)));
612            }
613        }
614        out
615    }
616}
617
618#[cfg(test)]
619mod tests;