Skip to main content

http_wasm_guest/
lib.rs

1//! HTTP WebAssembly guest library for building `http-wasm` plugins.
2//!
3//! This crate exposes a guest-facing API for inspecting and mutating HTTP
4//! requests and responses within a host runtime. Implement [`Guest`] and
5//! call [`register`] to wire up your plugin entry points.
6
7use crate::host::{Request, Response};
8#[cfg(not(test))]
9use crate::memory::SyncCell;
10
11/// Host interface for requests, responses, logging, and feature management.
12pub mod host;
13mod memory;
14
15struct Handler {
16    guest: Box<dyn Guest>,
17}
18
19/// Trait implemented by guest plugins to handle HTTP requests and responses.
20///
21/// Implement this trait to observe and modify inbound requests and outbound
22/// responses. The runtime constructs [`Request`] and [`Response`] handles
23/// for each request cycle and forwards them to your implementation.
24pub trait Guest {
25    /// Handle an incoming request before upstream processing.
26    ///
27    /// Return `(true, ctx)` to continue the request to the upstream, passing
28    /// an optional `ctx` value that will be provided to response handling.
29    /// Returning `(false, _)` stops processing and short-circuits the request.
30    fn handle_request(&self, _request: &Request, _response: &Response) -> (bool, i32) {
31        (true, 0)
32    }
33
34    /// Handle an outgoing response after upstream processing completes.
35    ///
36    /// Use this hook to inspect or mutate headers and body before the response
37    /// is sent back to the client.
38    fn handle_response(&self, _req_ctx: i32, _request: &Request, _response: &Response, _is_error: bool) {}
39}
40
41#[cfg(not(test))]
42static GUEST: SyncCell<Option<Handler>> = SyncCell::new(None);
43
44#[cfg(test)]
45thread_local! {
46    static GUEST: std::cell::UnsafeCell<Option<Handler>> = const { std::cell::UnsafeCell::new(None) };
47}
48
49#[cfg(not(test))]
50fn with_guest<R>(f: impl FnOnce(&mut Option<Handler>) -> R) -> R {
51    // SAFETY: WASM guest is single-threaded.
52    let g = unsafe { &mut *GUEST.get() };
53    f(g)
54}
55
56#[cfg(test)]
57fn with_guest<R>(f: impl FnOnce(&mut Option<Handler>) -> R) -> R {
58    GUEST.with(|cell| {
59        // SAFETY: thread-local; no cross-thread aliasing.
60        let g = unsafe { &mut *cell.get() };
61        f(g)
62    })
63}
64
65/// Register a guest plugin implementation with the runtime.
66///
67/// Call this once from your guest module initialization to install your
68/// [`Guest`] implementation. Subsequent calls are ignored.
69pub fn register<T: Guest + 'static>(guest: T) {
70    with_guest(|g| {
71        if g.is_none() {
72            *g = Some(Handler { guest: Box::new(guest) });
73        }
74    });
75}
76
77#[unsafe(export_name = "handle_request")]
78fn http_request() -> i64 {
79    with_guest(|g| {
80        let (next, ctx_next) = match g {
81            Some(handler) => handler.guest.handle_request(&Request::new(), &Response::new()),
82            None => (true, 0),
83        };
84        if next { (ctx_next as i64) << 32 | 1 } else { 0 }
85    })
86}
87
88#[unsafe(export_name = "handle_response")]
89fn http_response(req_ctx: i32, is_error: i32) {
90    with_guest(|g| {
91        if let Some(handler) = g {
92            handler.guest.handle_response(req_ctx, &Request::new(), &Response::new(), is_error == 1);
93        }
94    });
95}
96
97#[cfg(feature = "log")]
98mod host_logger;
99#[cfg(feature = "log")]
100pub use host_logger::HostLogger;
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::host::admin;
106    use crate::host::feature;
107    use std::sync::Arc;
108    use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, AtomicU32, Ordering};
109
110    // =========================================================================
111    // Guest Trait Tests
112    // =========================================================================
113
114    struct TestPlugin {
115        request_handled: Arc<AtomicBool>,
116        response_handled: Arc<AtomicBool>,
117        continue_request: bool,
118        ctx_value: i32,
119    }
120
121    impl Guest for TestPlugin {
122        fn handle_request(&self, _request: &Request, _response: &Response) -> (bool, i32) {
123            self.request_handled.store(true, Ordering::SeqCst);
124            (self.continue_request, self.ctx_value)
125        }
126
127        fn handle_response(&self, _req_ctx: i32, _request: &Request, _response: &Response, _is_error: bool) {
128            self.response_handled.store(true, Ordering::SeqCst);
129        }
130    }
131
132    #[test]
133    fn guest_default_implementation() {
134        struct DefaultGuest;
135        impl Guest for DefaultGuest {}
136
137        let guest = DefaultGuest;
138        let request = Request::new();
139        let response = Response::new();
140
141        let (cont, ctx) = guest.handle_request(&request, &response);
142        assert!(cont);
143        assert_eq!(ctx, 0);
144    }
145
146    #[test]
147    fn guest_custom_implementation() {
148        let request_handled = Arc::new(AtomicBool::new(false));
149        let response_handled = Arc::new(AtomicBool::new(false));
150
151        let plugin = TestPlugin {
152            request_handled: request_handled.clone(),
153            response_handled: response_handled.clone(),
154            continue_request: true,
155            ctx_value: 42,
156        };
157
158        let request = Request::new();
159        let response = Response::new();
160
161        let (cont, ctx) = plugin.handle_request(&request, &response);
162        assert!(cont);
163        assert_eq!(ctx, 42);
164        assert!(request_handled.load(Ordering::SeqCst));
165
166        plugin.handle_response(ctx, &request, &response, false);
167        assert!(response_handled.load(Ordering::SeqCst));
168    }
169
170    #[test]
171    fn guest_stop_request() {
172        struct StopPlugin;
173        impl Guest for StopPlugin {
174            fn handle_request(&self, _request: &Request, _response: &Response) -> (bool, i32) {
175                (false, 0)
176            }
177        }
178
179        let plugin = StopPlugin;
180        let request = Request::new();
181        let response = Response::new();
182
183        let (cont, _) = plugin.handle_request(&request, &response);
184        assert!(!cont);
185    }
186
187    #[test]
188    fn guest_default_handle_response() {
189        // Test the default handle_response implementation
190        struct DefaultResponsePlugin;
191        impl Guest for DefaultResponsePlugin {}
192
193        let plugin = DefaultResponsePlugin;
194        let request = Request::new();
195        let response = Response::new();
196
197        // Call the default handle_response - should do nothing and not panic
198        plugin.handle_response(42, &request, &response, false);
199    }
200
201    #[test]
202    fn guest_context_passing() {
203        let ctx_received = Arc::new(AtomicI32::new(0));
204        let ctx_clone = ctx_received.clone();
205
206        struct ContextPlugin {
207            ctx_received: Arc<AtomicI32>,
208        }
209
210        impl Guest for ContextPlugin {
211            fn handle_request(&self, _request: &Request, _response: &Response) -> (bool, i32) {
212                (true, 12345)
213            }
214
215            fn handle_response(&self, req_ctx: i32, _request: &Request, _response: &Response, _is_error: bool) {
216                self.ctx_received.store(req_ctx, Ordering::SeqCst);
217            }
218        }
219
220        let plugin = ContextPlugin { ctx_received: ctx_clone };
221        let request = Request::new();
222        let response = Response::new();
223
224        let (_, ctx) = plugin.handle_request(&request, &response);
225        plugin.handle_response(ctx, &Request::new(), &Response::new(), false);
226
227        assert_eq!(ctx_received.load(Ordering::SeqCst), 12345);
228    }
229
230    /// Simulates a plugin that blocks certain requests
231    struct BlockingPlugin {
232        blocked_paths: Vec<&'static str>,
233    }
234
235    impl Guest for BlockingPlugin {
236        fn handle_request(&self, request: &Request, response: &Response) -> (bool, i32) {
237            let uri = request.uri();
238            let uri_str = uri.to_str().unwrap_or("");
239
240            for blocked in &self.blocked_paths {
241                if uri_str.contains(blocked) {
242                    response.set_status(403);
243                    response.body.write(b"Forbidden");
244                    return (false, 0);
245                }
246            }
247            (true, 0)
248        }
249    }
250
251    #[test]
252    fn e2e_blocking_plugin_allows() {
253        let plugin = BlockingPlugin { blocked_paths: vec!["/admin", "/secret"] };
254        let request = Request::new();
255        let response = Response::new();
256
257        // Mock returns "https://test" which doesn't contain blocked paths
258        let (cont, _) = plugin.handle_request(&request, &response);
259        assert!(cont);
260    }
261
262    #[test]
263    fn e2e_blocking_plugin_blocks() {
264        // Use "test" as blocked path since mock URI is "https://test"
265        let plugin = BlockingPlugin { blocked_paths: vec!["test"] };
266        let request = Request::new();
267        let response = Response::new();
268
269        // Mock returns "https://test" which contains "test"
270        let (cont, _) = plugin.handle_request(&request, &response);
271        assert!(!cont);
272    }
273
274    /// Simulates a plugin that uses configuration
275    struct ConfigurablePlugin;
276
277    impl Guest for ConfigurablePlugin {
278        fn handle_request(&self, request: &Request, _response: &Response) -> (bool, i32) {
279            let config = admin::config();
280            if config.to_str().unwrap_or("").contains("config") {
281                request.header.add(b"X-Config-Loaded", b"true");
282            }
283            (true, 0)
284        }
285    }
286
287    #[test]
288    fn e2e_configurable_plugin() {
289        let plugin = ConfigurablePlugin;
290        let request = Request::new();
291        let response = Response::new();
292
293        let (cont, _) = plugin.handle_request(&request, &response);
294        assert!(cont);
295    }
296
297    /// Simulates a plugin that enables features
298    struct FeatureEnablingPlugin;
299
300    impl Guest for FeatureEnablingPlugin {
301        fn handle_request(&self, _request: &Request, _response: &Response) -> (bool, i32) {
302            admin::enable(feature::BufferRequest | feature::BufferResponse);
303            (true, 0)
304        }
305    }
306
307    #[test]
308    fn e2e_feature_enabling_plugin() {
309        let plugin = FeatureEnablingPlugin;
310        let request = Request::new();
311        let response = Response::new();
312
313        let (cont, _) = plugin.handle_request(&request, &response);
314        assert!(cont);
315    }
316
317    /// Simulates a complete request/response cycle
318    struct FullCyclePlugin {
319        pub request_count: AtomicU32,
320    }
321
322    impl Guest for FullCyclePlugin {
323        fn handle_request(&self, request: &Request, _response: &Response) -> (bool, i32) {
324            let count = self.request_count.fetch_add(1, Ordering::SeqCst);
325
326            // Log request details
327            let _method = request.method();
328            let _uri = request.uri();
329            let _source = request.source_addr();
330
331            // Add tracking header
332            request.header.add(b"X-Request-Id", &format!("{}", count).into_bytes());
333            request.header.set(b"X-Foo", &format!("{}", count).into_bytes());
334            request.header.remove(b"X-Foo");
335            (true, count as i32)
336        }
337
338        fn handle_response(&self, req_ctx: i32, _request: &Request, response: &Response, is_error: bool) {
339            if !is_error {
340                response.header.set(b"X-Processed-By", b"FullCyclePlugin");
341                response.header.add(b"X-Request-Context", &format!("{}", req_ctx).into_bytes());
342            }
343        }
344    }
345
346    #[test]
347    fn e2e_full_cycle_plugin() {
348        let plugin = FullCyclePlugin { request_count: AtomicU32::new(0) };
349        let request = Request::new();
350        let response = Response::new();
351
352        // First request
353        let (cont1, ctx1) = plugin.handle_request(&request, &response);
354        assert!(cont1);
355        assert_eq!(ctx1, 0);
356        plugin.handle_response(ctx1, &Request::new(), &Response::new(), false);
357
358        // Second request
359        let req2 = Request::new();
360        let res2 = Response::new();
361        let (cont2, ctx2) = plugin.handle_request(&req2, &res2);
362        assert!(cont2);
363        assert_eq!(ctx2, 1);
364        plugin.handle_response(ctx2, &Request::new(), &Response::new(), false);
365    }
366
367    // =========================================================================
368    // Registration Tests
369    // =========================================================================
370
371    struct SimplePlugin;
372
373    impl Guest for SimplePlugin {}
374
375    #[test]
376    fn register_plugin() {
377        // Note: register uses OnceLock, so this will only work once per test run
378        // Additional calls are ignored
379        let plugin = SimplePlugin;
380        register(plugin);
381    }
382
383    // =========================================================================
384    // Entry Point Tests (http_request and http_response)
385    // =========================================================================
386
387    #[test]
388    fn http_request_returns_continue_with_context() {
389        // When a guest is registered and returns (true, ctx), the result should be
390        // (ctx << 32) | 1
391        let result = http_request();
392        // The lower bit should be 1 (continue)
393        assert_eq!(result & 1, 1);
394    }
395
396    #[test]
397    fn http_response_does_not_panic() {
398        // Should not panic when called with various parameters
399        http_response(0, 0);
400        http_response(42, 0);
401        http_response(0, 1);
402        http_response(123, 1);
403    }
404
405    #[test]
406    fn http_response_passes_correct_parameters() {
407        // Test that http_response correctly converts is_error parameter
408        // and passes ctx through to the guest
409        let ctx_received = Arc::new(AtomicI32::new(-1));
410        let is_error_received = Arc::new(AtomicU8::new(255)); // Use u8 to store bool
411
412        struct ResponseTrackingPlugin {
413            ctx_received: Arc<AtomicI32>,
414            is_error_received: Arc<AtomicU8>,
415        }
416
417        impl Guest for ResponseTrackingPlugin {
418            fn handle_response(&self, req_ctx: i32, _request: &Request, _response: &Response, is_error: bool) {
419                self.ctx_received.store(req_ctx, Ordering::SeqCst);
420                self.is_error_received.store(if is_error { 1 } else { 0 }, Ordering::SeqCst);
421            }
422        }
423
424        let plugin = ResponseTrackingPlugin { ctx_received: ctx_received.clone(), is_error_received: is_error_received.clone() };
425
426        let request = Request::new();
427        let response = Response::new();
428
429        // Test with is_error = false (0)
430        plugin.handle_response(42, &request, &response, false);
431        assert_eq!(ctx_received.load(Ordering::SeqCst), 42);
432        assert_eq!(is_error_received.load(Ordering::SeqCst), 0);
433
434        // Test with is_error = true (1)
435        plugin.handle_response(123, &Request::new(), &Response::new(), true);
436        assert_eq!(ctx_received.load(Ordering::SeqCst), 123);
437        assert_eq!(is_error_received.load(Ordering::SeqCst), 1);
438    }
439
440    #[test]
441    fn http_request_without_guest_returns_default() {
442        // When no guest is registered (or default behavior), should return continue
443        // This tests the None branch - though in practice GUEST is set by other tests
444        let result = http_request();
445        // Should still have the continue bit set
446        assert_eq!(result & 1, 1);
447    }
448}