Skip to main content

rust_web_server/
lib.rs

1//! # rust-web-server
2//!
3//! An HTTP/1.1, HTTP/2, and HTTP/3 (QUIC) server, reverse proxy, and
4//! application framework in one crate. HTTP/1.1 parsing, routing, middleware,
5//! JSON, WebSocket, and SSE are implemented in-crate; HTTP/2 and HTTP/3
6//! framing use the `h2` and `quinn`/`h3` crates, and TLS uses `rustls`.
7//!
8//! ## Use as a library
9//!
10//! Add to `Cargo.toml`:
11//!
12//! ```toml
13//! [dependencies]
14//! rust-web-server = "17"
15//! ```
16//!
17//! ## Quick start
18//!
19//! ```rust,no_run
20//! use rust_web_server::prelude::*;
21//!
22//! fn hello(_: &Request, _: &PathParams, _: &ConnectionInfo, _: &()) -> Response {
23//!     Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "Hello, world!")
24//! }
25//!
26//! fn main() {
27//!     let app = App::with_state(()).get("/hello", hello);
28//!     let (listener, pool) = Server::setup().unwrap();
29//!     Server::run(listener, pool, app);
30//! }
31//! ```
32//!
33//! See [DEVELOPER.md](https://github.com/bohdaq/rust-web-server/blob/main/DEVELOPER.md)
34//! for the full building blocks reference and use case examples.
35
36// Allows `::rust_web_server::…` paths to resolve from within this crate's own
37// tests, which is required by proc-macro derive output that uses that prefix.
38extern crate self as rust_web_server;
39
40pub mod app;
41#[cfg(feature = "auth")]
42pub mod auth;
43
44#[cfg(feature = "macros")]
45pub use rws_macros::{delete, get, patch, post, put, route, Config, FromRequest, JsonSchema, Validate};
46#[cfg(all(feature = "macros", any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
47pub use rws_macros::Model;
48#[cfg(feature = "http2")]
49pub mod async_state;
50#[cfg(feature = "http2")]
51pub(crate) mod async_bridge;
52pub mod session;
53// Socket-coupled — see spec/WASM_SHIM.md for what a wasm32-wasip2 build
54// excludes and why (no OS threads, no raw TCP/TLS in a WASI guest).
55#[cfg(not(target_arch = "wasm32"))]
56pub(crate) mod redis_protocol;
57pub mod sse;
58pub mod compression;
59pub mod cookie;
60pub mod error;
61pub mod extract;
62pub mod ip_filter;
63pub mod macros;
64pub mod blocklist;
65pub mod cache;
66pub mod early_hints;
67pub mod authz;
68pub mod config_reload;
69#[cfg(not(target_arch = "wasm32"))]
70pub mod config_watch;
71pub mod config_check;
72/// RSA public keys and RS256 / rsa-sha256 signature verification (no `rsa` crate).
73#[cfg(any(feature = "auth-asymmetric", feature = "sso"))]
74pub mod rsa_key;
75pub mod server_config;
76pub mod feature;
77pub mod maintenance;
78pub mod metrics;
79pub mod mcp;
80pub mod request_log;
81pub mod request_id;
82#[cfg(not(target_arch = "wasm32"))]
83pub mod otel;
84#[cfg(feature = "acme")]
85pub mod acme;
86pub mod middleware;
87pub mod rate_limit;
88pub mod router;
89pub mod schema;
90pub mod state;
91pub mod test_client;
92pub mod application;
93pub mod body;
94pub mod client_hint;
95pub mod controller;
96pub mod core;
97pub mod cors;
98pub mod entry_point;
99pub mod ext;
100pub mod header;
101pub mod http;
102pub mod json;
103pub mod language;
104#[cfg(not(target_arch = "wasm32"))]
105pub mod log;
106pub mod mime_type;
107pub mod null;
108pub mod range;
109pub mod request;
110pub mod response;
111pub mod server;
112pub mod symbol;
113#[cfg(not(target_arch = "wasm32"))]
114pub mod thread_pool;
115pub mod url;
116pub mod pagination;
117#[cfg(not(target_arch = "wasm32"))]
118pub mod proxy;
119pub mod rewrite;
120pub mod scheduler;
121#[cfg(not(target_arch = "wasm32"))]
122pub mod tcp_proxy;
123#[cfg(not(target_arch = "wasm32"))]
124pub mod udp_proxy;
125#[cfg(not(target_arch = "wasm32"))]
126pub mod ws_proxy;
127#[cfg(not(target_arch = "wasm32"))]
128pub mod canary;
129#[cfg(not(target_arch = "wasm32"))]
130pub mod circuit_breaker;
131// `BackendPool`/`DiscoverySource` (DNS/etcd/Consul/Docker) are native-only —
132// gated internally in service_discovery/mod.rs, not at this declaration —
133// because `json_lite` (a plain JSON parser, no sockets) must stay available
134// for `secrets`'s Vault/AWS-SM/Key-Vault backends on every target.
135pub mod service_discovery;
136/// DNS wire format, shared by the SRV service discovery and ACME's DNS-01.
137pub(crate) mod dns;
138pub mod config_binding;
139pub mod di;
140#[cfg(not(target_arch = "wasm32"))]
141pub mod proxy_config;
142#[cfg(not(target_arch = "wasm32"))]
143pub mod llm;
144#[cfg(not(target_arch = "wasm32"))]
145pub mod admin;
146#[cfg(feature = "wasm-plugins")]
147pub mod wasm_plugin;
148#[cfg(not(target_arch = "wasm32"))]
149pub mod ingress;
150#[cfg(feature = "tera")]
151pub mod template;
152pub mod validate;
153pub mod virtual_host;
154#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
155pub mod model;
156#[cfg(not(target_arch = "wasm32"))]
157pub mod websocket;
158// Has both a native (TcpStream/rustls) and a wasm32 (wasi:http outgoing-handler)
159// backend behind Client::send() — see the #[cfg(target_arch = "wasm32")]
160// `send_once` in this module. spec/WASM_SHIM.md Phase 2 item 7.
161pub mod http_client;
162#[cfg(feature = "crypto")]
163pub mod crypto;
164#[cfg(feature = "csrf")]
165pub mod csrf;
166#[cfg(feature = "sso")]
167pub mod sso;
168// SMTP is a raw-TCP protocol with no wasi:http equivalent (unlike
169// http_client, which the host can proxy via outgoing-handler) — genuinely
170// can't work in a wasi:http guest, not just ungated. spec/WASM_SHIM.md.
171#[cfg(all(feature = "mailer", not(target_arch = "wasm32")))]
172pub mod mailer;
173#[cfg(feature = "jobs")]
174pub mod jobs;
175// Also compiled under `secrets` — `secrets::aws_secrets_manager`/`azure_key_vault`
176// reuse `StorageError` and the SigV4/Managed-Identity credential helpers from here.
177#[cfg(any(feature = "storage-local", feature = "storage-s3", feature = "storage-azure", feature = "secrets"))]
178pub mod storage;
179#[cfg(feature = "openapi")]
180pub mod openapi;
181#[cfg(feature = "tower")]
182pub mod tower;
183#[cfg(feature = "gateway-api")]
184pub mod gateway_api;
185#[cfg(feature = "webhook")]
186pub mod webhook;
187#[cfg(feature = "secrets")]
188pub mod secrets;
189// Runs wrapped work on a background OS thread to bound wait time — needs
190// `std::thread::spawn`, unavailable on wasm32-wasip2. See spec/WASM_SHIM.md.
191#[cfg(not(target_arch = "wasm32"))]
192pub mod timeout;
193pub mod prelude;
194
195#[cfg(feature = "http2")]
196#[doc(hidden)]
197pub mod tls;
198
199#[cfg(feature = "http2")]
200#[doc(hidden)]
201pub mod h2_server;
202
203#[cfg(feature = "http3")]
204#[doc(hidden)]
205pub mod h3_handler;
206
207/// Shared infrastructure for tests that write process-wide environment variables.
208///
209/// Tests that call `override_environment_variables_from_config` or
210/// `CommandLineArgument::set_environment_variable` must hold this lock for
211/// their entire duration so they don't race with other tests reading the
212/// same variables.
213#[cfg(test)]
214mod stack_depth_tests;
215
216/// A rustls client-config builder with this crate's crypto provider
217/// (aws-lc-rs) named explicitly. `rustls::ClientConfig::builder()` uses the
218/// process-wide default provider and panics when none is installed and more
219/// than one is compiled in — which happens as soon as any dependency enables
220/// rustls's `ring` backend (e.g. sqlx). Every outbound TLS client in the
221/// crate starts here.
222#[cfg(all(any(feature = "http-client", feature = "http2"), not(target_arch = "wasm32")))]
223pub(crate) fn tls_client_builder() -> rustls::ConfigBuilder<rustls::ClientConfig, rustls::WantsVerifier> {
224    rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()))
225        .with_safe_default_protocol_versions()
226        .expect("aws-lc-rs supports rustls's default protocol versions")
227}
228
229#[cfg(test)]
230pub mod test_env {
231    use std::sync::{Mutex, OnceLock};
232    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
233    pub fn lock() -> std::sync::MutexGuard<'static, ()> {
234        LOCK.get_or_init(|| Mutex::new(()))
235            .lock()
236            .unwrap_or_else(|e| e.into_inner())
237    }
238
239    /// A per-process path under the OS temp dir that is deleted (file or
240    /// directory) when dropped — so a test cleans up even when an assertion
241    /// fails before its last line.
242    pub struct TempPath(std::path::PathBuf);
243
244    impl TempPath {
245        pub fn new(name: &str) -> Self {
246            TempPath(std::env::temp_dir().join(format!("rws_test_{}_{}", std::process::id(), name)))
247        }
248
249        /// The same in the working directory (the static-file root), for
250        /// tests that need a file the server would serve.
251        pub fn new_in_cwd(name: &str) -> Self {
252            TempPath(std::env::current_dir().expect("working directory").join(format!("rws_test_{}_{}", std::process::id(), name)))
253        }
254
255        pub fn as_str(&self) -> &str {
256            self.0.to_str().expect("temp dir path is UTF-8")
257        }
258    }
259
260    impl std::ops::Deref for TempPath {
261        type Target = std::path::Path;
262        fn deref(&self) -> &std::path::Path {
263            &self.0
264        }
265    }
266
267    impl Drop for TempPath {
268        fn drop(&mut self) {
269            // A symlink is removed itself, never followed (a test may link
270            // to a system directory).
271            let is_link = std::fs::symlink_metadata(&self.0).is_ok_and(|m| m.file_type().is_symlink());
272            if is_link {
273                let _ = std::fs::remove_file(&self.0);
274            } else if self.0.is_dir() {
275                let _ = std::fs::remove_dir_all(&self.0);
276            } else {
277                let _ = std::fs::remove_file(&self.0);
278            }
279        }
280    }
281}
282
283/// Entry points for `fuzz/` targets that reach crate-private parsers.
284/// Compiled only under `--cfg fuzzing`, which `cargo fuzz` sets — never part
285/// of the public API.
286#[cfg(fuzzing)]
287#[doc(hidden)]
288pub mod fuzz_api {
289    /// Proxy route-matching normalization of a request target (#50): any
290    /// input, and normalizing twice changes nothing.
291    pub fn proxy_normalize_target(data: &[u8]) {
292        let Ok(target) = std::str::from_utf8(data) else { return };
293        let once = crate::proxy_config::normalize_request_target(target).into_owned();
294        let twice = crate::proxy_config::normalize_request_target(&once).into_owned();
295        assert_eq!(once, twice, "normalization is not idempotent for {:?}", target);
296    }
297
298    /// Static-file request-path decoding (#55). Must never panic; the
299    /// decoded path never carries a `..` segment past the web-root check.
300    pub fn static_path_decode(data: &[u8]) {
301        let Ok(path) = std::str::from_utf8(data) else { return };
302        if let Ok(fs_path) = crate::app::controller::static_resource::static_fs_path(path) {
303            let relative = &fs_path[crate::app::controller::static_resource::working_directory().len()..];
304            assert!(!relative.split(['/', '\\']).any(|segment| segment == ".."), "{:?} -> {:?}", path, fs_path);
305        }
306    }
307
308    /// `Accept-Encoding` negotiation (#7): any header value; the choice is
309    /// always one of the offered codings, and a coding the value refuses
310    /// (its first `q=` parameter is 0) is never chosen.
311    pub fn accept_encoding(data: &[u8]) {
312        let Ok(value) = std::str::from_utf8(data) else { return };
313        let request = crate::request::Request {
314            method: "GET".to_string(),
315            request_uri: "/".to_string(),
316            http_version: "HTTP/1.1".to_string(),
317            headers: vec![crate::header::Header::new(crate::header::Header::_ACCEPT_ENCODING, value.to_string())],
318            body: Default::default(),
319        };
320        for preference in [crate::compression::DYNAMIC_PREFERENCE, crate::compression::STATIC_PREFERENCE] {
321            if let Some(chosen) = crate::compression::negotiate(&request, preference) {
322                assert!(preference.contains(&chosen));
323                // Refused: the coding's first `q=` parameter is exactly 0.
324                let refused = value.split(',').any(|c| {
325                    let mut parts = c.split(';');
326                    parts.next().unwrap_or("").trim().eq_ignore_ascii_case(chosen.token())
327                        && parts
328                            .map(str::trim)
329                            .find(|p| p.as_bytes().get(..2).is_some_and(|b| b.eq_ignore_ascii_case(b"q=")))
330                            .is_some_and(|q| q[2..].trim().parse::<f32>() == Ok(0.0))
331                });
332                let listed_once = value.split(',').filter(|c| c.split(';').next().unwrap_or("").trim().eq_ignore_ascii_case(chosen.token())).count() == 1;
333                assert!(!(refused && listed_once), "{value:?} refused {chosen:?}");
334            }
335        }
336    }
337
338    /// What a WebAssembly plugin returns, as the host reads it (#40).
339    /// Must never panic.
340    #[cfg(feature = "wasm-plugins")]
341    pub fn wasm_plugin_output(data: &[u8]) {
342        let _ = crate::wasm_plugin::parse_request_output(data);
343        let _ = crate::wasm_plugin::parse_response_output(data);
344    }
345
346    /// A disk-cache entry file as the cache reads it back (#39): the header
347    /// line and key, then the stored response. Must never panic.
348    pub fn cache_entry(data: &[u8]) {
349        if let Some((_, _, _, offset)) = crate::cache::disk::parse_entry_header(data) {
350            let _ = crate::response::Response::parse_upstream(&data[offset..], crate::proxy::REGENERATED_OR_HOP_BY_HOP);
351        }
352    }
353
354    /// Authorization text as the admin API and config file read it (#32):
355    /// a tuple, a subject, and a relation definition.
356    pub fn authz_parse(data: &[u8]) {
357        let text = String::from_utf8_lossy(data);
358        let _ = crate::authz::Tuple::parse(&text);
359        let _ = crate::authz::Subject::parse(&text);
360        if let Ok(terms) = crate::authz::rebac::parse_expression(&text) {
361            let _ = terms.len();
362            let _ = crate::authz::Schema::new().relation("t", "r", &text).and_then(|s| s.validate());
363        }
364    }
365
366    /// An `Available-Dictionary` header value, and dictionary-compressed
367    /// bodies through both decoders (#38).
368    pub fn available_dictionary(data: &[u8]) {
369        use crate::compression::dictionary::{decode, parse_available_dictionary, DictionaryEncoding};
370        let _ = parse_available_dictionary(&String::from_utf8_lossy(data));
371        for encoding in [DictionaryEncoding::Dcz, DictionaryEncoding::Dcb] {
372            let _ = decode(encoding, data, b"dictionary", 1 << 16);
373        }
374    }
375
376    /// Provider responses as the LLM proxy reads them (#41): usage from a
377    /// JSON body or an event stream fed in pieces, and chunked framing.
378    /// Must never panic.
379    pub fn llm_usage(data: &[u8]) {
380        for content_type in ["application/json", "text/event-stream"] {
381            let mut parser = crate::llm::UsageParser::new(content_type);
382            for piece in data.chunks(7) {
383                parser.feed(piece);
384            }
385            let _ = parser.finish();
386        }
387        let mut decoder = crate::llm::usage::ChunkDecoder::default();
388        let mut out = Vec::new();
389        let _ = decoder.decode(data, &mut out);
390    }
391
392    /// An upstream MCP server's reply as the gateway reads it (#62): a JSON
393    /// body or an event stream, then the response's tools. Must never panic.
394    pub fn mcp_gateway_reply(data: &[u8]) {
395        for content_type in ["application/json", "text/event-stream"] {
396            if let Ok(result) = crate::mcp::gateway::parse_reply(content_type, data, 1) {
397                let _ = crate::mcp::gateway::fuzzing::tools(&result);
398            }
399        }
400    }
401
402    /// A WebSocket frame from a client (#8): any bytes; a frame never holds
403    /// more payload than the limit allows. Must never panic.
404    #[cfg(not(target_arch = "wasm32"))]
405    pub fn websocket_frame(data: &[u8]) {
406        let mut cursor = std::io::Cursor::new(data);
407        while let Ok(frame) = crate::websocket::WebSocket::read_frame_limited(&mut cursor, 4096) {
408            let len = match &frame {
409                crate::websocket::Frame::Text(t) => t.len(),
410                crate::websocket::Frame::Binary(b) | crate::websocket::Frame::Ping(b) | crate::websocket::Frame::Pong(b) => b.len(),
411                crate::websocket::Frame::Continuation { data, .. } => data.len(),
412                crate::websocket::Frame::Close(_, reason) => reason.len() + 2,
413            };
414            assert!(len <= 4096 + 2);
415        }
416    }
417
418    /// A DNS response read for SRV records (#8). Must never panic.
419    #[cfg(not(target_arch = "wasm32"))]
420    pub fn dns_srv_response(data: &[u8]) {
421        let id = data.get(..2).map_or(0, |b| u16::from_be_bytes([b[0], b[1]]));
422        let _ = crate::service_discovery::dns_srv::parse_response(data, id);
423    }
424
425    /// A DNS message: the shared parser (SRV discovery, ACME DNS-01), TXT
426    /// values and, with `acme`, a DNS UPDATE reply. Must never panic.
427    /// A certificate's subjectAltNames, as a BackendTLSPolicy checks a
428    /// backend's (untrusted) certificate. Must never panic.
429    pub fn x509_subject_alt_names(data: &[u8]) {
430        let _ = crate::core::x509::subject_alt_names(data);
431    }
432
433    pub fn dns_message(data: &[u8]) {
434        if let Ok(message) = crate::dns::parse(data) {
435            for record in message.answers.iter().chain(&message.authority).chain(&message.additional) {
436                let _ = crate::dns::txt_value(&data[record.rdata.clone()]);
437            }
438        }
439        #[cfg(feature = "acme")]
440        let _ = crate::acme::dns01::check_update_reply(data, 0);
441    }
442
443    /// A Redis (RESP) reply (#8). Must never panic.
444    #[cfg(not(target_arch = "wasm32"))]
445    pub fn redis_reply(data: &[u8]) {
446        let _ = crate::redis_protocol::read_reply(&mut std::io::Cursor::new(data));
447    }
448
449    /// A `Cookie` request header (#8): every parsed cookie has a name.
450    pub fn cookie_header(data: &[u8]) {
451        let Ok(value) = std::str::from_utf8(data) else { return };
452        let jar = crate::cookie::CookieJar::parse(value);
453        assert!(jar.cookies.iter().all(|c| !c.name.is_empty()));
454    }
455
456    /// A W3C `baggage` header (#25): members survive a round trip through
457    /// the header. Must never panic.
458    #[cfg(not(target_arch = "wasm32"))]
459    pub fn otel_baggage(data: &[u8]) {
460        let Ok(value) = std::str::from_utf8(data) else { return };
461        let baggage = crate::otel::Baggage::parse(value);
462        assert!(baggage.len() <= crate::otel::baggage::MAX_MEMBERS);
463        let again = crate::otel::Baggage::parse(&baggage.to_header());
464        for (key, value) in baggage.iter() {
465            if let Some(round_tripped) = again.get(key) {
466                assert_eq!(value, round_tripped);
467            }
468        }
469    }
470
471    /// What ACME renewal parses from the CA (#1): certificate DER (validity,
472    /// serial, Authority Key Identifier), renewalInfo windows (RFC 3339) and
473    /// directory profile names. Must never panic.
474    #[cfg(feature = "acme")]
475    pub fn acme_renewal(data: &[u8]) {
476        let _ = crate::acme::fuzzing::cert_info_from_der(data);
477        if let Ok(text) = std::str::from_utf8(data) {
478            let _ = crate::acme::fuzzing::parse_suggested_window(text);
479            let _ = crate::acme::fuzzing::parse_rfc3339(text);
480            let _ = crate::acme::fuzzing::json_object_keys(text, "profiles");
481        }
482    }
483
484    /// The SAML XML parser (`sso::saml::xml`), fed IdP-supplied documents.
485    #[cfg(feature = "sso-saml")]
486    pub fn saml_xml_parse(xml: &str) {
487        let _ = crate::sso::saml::xml_parse_for_fuzzing(xml);
488    }
489
490    /// HTTP/1.1 request framing (`server::framing`) over a raw connection
491    /// buffer: head detection, body framing, and the chunked body decoder,
492    /// fed in two pieces split at the first byte to mimic separate reads.
493    pub fn http1_framing(data: &[u8]) {
494        use crate::server::framing::{self, BodyFraming, ChunkedBody, HeadScan};
495        let mut pending = data.to_vec();
496        framing::skip_leading_empty_lines(&mut pending);
497        let HeadScan::Complete(head_len) = framing::scan_head(&pending) else { return };
498        assert!(head_len <= pending.len() && head_len <= framing::MAX_HEAD_SIZE);
499        let Ok(request) = crate::request::Request::parse(&pending[..head_len]) else { return };
500        for header in &request.headers {
501            let _ = crate::http::date::parse(&header.value);
502        }
503        let _ = framing::validate_head(&pending[..head_len], &request);
504        let _ = framing::keep_alive(&request);
505        let body = &pending[head_len..];
506        if let Ok(BodyFraming::Chunked) = framing::body_framing(&request) {
507            let split = data.first().map_or(0, |&b| b as usize).min(body.len());
508            let mut decoder = ChunkedBody::new(1 << 20);
509            let mut buffer = body[..split].to_vec();
510            for next in [&body[split..], &[][..]] {
511                match decoder.feed(&buffer) {
512                    Ok((consumed, _)) => {
513                        assert!(consumed <= buffer.len());
514                        buffer.drain(..consumed);
515                    }
516                    Err(_) => return,
517                }
518                buffer.extend_from_slice(next);
519            }
520            assert!(decoder.body.len() <= 1 << 20);
521        }
522    }
523}
524
525
526/// Compiles the README's Rust example as a doctest, so it can't drift from the API.
527#[cfg(all(doctest, feature = "http3"))]
528#[doc = include_str!("../README.md")]
529pub struct ReadmeDoctests;