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