rust-web-server 17.113.0

Single-binary web server and reverse proxy: automatic HTTPS (Let's Encrypt), HTTP/1.1, HTTP/2 and HTTP/3, load balancing, auth and rate limiting, configured in one TOML file. Also a Rust library: routing, middleware, MCP server, ORM, background jobs. No hyper, no Tower; HTTP/2 and HTTP/3 framing via h2 and quinn.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Build (http3 is the default — includes HTTP/3, HTTP/2, and TLS)
cargo build

# Build HTTP/2 + TLS only (no QUIC/HTTP/3)
cargo build --no-default-features --features http2

# Build HTTP/1.1-only (no TLS, lightest binary)
cargo build --no-default-features --features http1

# Run (falls back to plain HTTP/1.1 if no cert is configured)
cargo run

# Run with HTTPS + HTTP/2 + HTTP/3 active
cargo run -- --tls-cert-file=cert.pem --tls-key-file=key.pem

# Run all tests
cargo test

# Run a single test (replace the test path with the one you want)
cargo test --package rust-web-server --bin rws client_hint::tests::client_hints_header -- --exact

# End-to-end checks against real implementations (CI runs both on every push):
#   ACME against Pebble (Let's Encrypt's test CA): issuance, HTTPS h1/h2, proxy, redirects, renewal
ci/e2e/acme_pebble.sh target/debug/rws <pebble-binary> <pebble-source-dir>
#   HTTP/2 conformance with h2spec — must be 0 failed, 0 skipped
ci/e2e/h2spec.sh target/debug/rws <h2spec-binary>

# Fuzz every untrusted-input parser (nightly + cargo-fuzz) — see fuzz/README.md
fuzz/run.sh 60 4
```

Build `rws` with `--features "acme auth"` first for the ACME script. Pebble: `gh release download v2.10.1 --repo letsencrypt/pebble` (binary) plus `gh api repos/letsencrypt/pebble/tarball/v2.10.1` (its `test/` config and certs). h2spec: `gh release download v2.6.0 --repo summerwind/h2spec`. These caught bugs the unit tests didn't (ACME Host header/User-Agent, proxy 502s on non-canonical reason phrases, duplicate headers breaking HTTP/2) — run them after touching `acme`, `proxy`, `server`, `h2_handler`, or response parsing.

**Parsers of untrusted input** (requests, upstream responses, bodies, JSON, MCP, SAML XML, config files) must not recurse per line, part, or nesting level without a bound: a stack overflow aborts the whole process and can't be caught. Use loops; where recursion is natural (XML nesting), cap the depth (`sso::saml::xml::MAX_DEPTH`). `src/stack_depth_tests.rs` runs each such parser on a 256 KB stack — add a case there for a new parser, and a fuzz target in `fuzz/`. Never `unwrap()` a parse of a header or body value. The sync `ThreadPool` wraps each job in `catch_unwind`, so a handler panic costs one connection, not a worker; tokio tasks already isolate panics. Request targets are normalized in `Request::parse` (origin-form kept, absolute-form reduced to its path, `*` kept, anything else rejected) and code that needs a URL from `request_uri` uses `URL::parse_request_target`, never `"http://localhost" + uri`.

**Container image** (`Dockerfile.release`): distroless static, nonroot, no shell or coreutils — startup code must not shell out (the banner used to run `whoami` and `unwrap()` it, crashing every 17.111–17.112 image) and must not assume a writable working directory (`/srv` is root-owned and usually mounted read-only; ACME state goes to the `/data` volume via `RWS_CONFIG_ACME_*_PATH` env defaults). The release workflow runs the image and checks `/healthz`, the served page and `--version` before pushing to GHCR and (with `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN`) Docker Hub `bohdaq/rws`; its overview comes from `.github/DOCKERHUB.md`, synced by `dockerhub-overview.yml` on every change to that file.

Tests that write files use `crate::test_env::TempPath::new(name)` — it deletes the file/dir on drop, even when an assertion fails — never a bare path under `temp_dir()`.

MSRV is 1.88 (the highest `rust-version` among current dependencies across all features — tokio needs 1.85, `time`/`icu` via sqlx/tera need 1.88). CI checks it with the `msrv` job in `.github/workflows/ci.yml`; raise `rust-version` there and in `Cargo.toml` together.

## Required for every change

Every code change — new feature, bug fix, refactor — must include all three:

### 1. Tests

- Add tests in a `tests.rs` sibling file (e.g. `src/csrf/tests.rs`) or inline `#[cfg(test)]` block.
- Use `TestClient::new(app)` for integration-style tests; pure unit tests for helpers and pure functions.
- Cover the happy path and at least one failure/edge case per public function or method.
- Run `cargo test` before committing. All tests must pass.

#### Shared-state lock — mandatory for any test that touches `RWS_CONFIG_*` env vars

`cargo test` runs all tests in parallel within one process. `RWS_CONFIG_*` environment variables are process-wide mutable state. Any test that **writes** them — directly or transitively — races with every other test that reads them, causing intermittent failures that are hard to reproduce.

**Rule:** hold `let _g = crate::test_env::lock();` as the very first line of every test that does any of the following (directly or via a called function):

| Trigger | Why it writes env vars |
|---|---|
| `std::env::set_var("RWS_CONFIG_*", …)` | direct write |
| `std::env::remove_var("RWS_CONFIG_*")` | direct remove |
| `override_environment_variables_from_config(…)` | reads a `.toml` file and writes all keys it finds, including `thread_count` |
| `bootstrap()` | calls `override_environment_variables_from_config(None)` which reads the project-root `rws.config.toml` |
| `config_reload::reload()` | calls `override_environment_variables_from_config(None)` |
| `CommandLineArgument::_parse(…)` / `set_environment_variable(…)` | calls `env::set_var` for each parsed arg |
| `metrics::SERVER_READY.store(…)` | mutates a global `AtomicBool` read by `/readyz` tests |

The lock is a `static OnceLock<Mutex<()>>` defined in `src/lib.rs` under `#[cfg(test)]`. It must be held for the **entire** test body — assign it to a named binding (`_g`, not `_`) so it isn't dropped immediately:

```rust
#[test]
fn my_test() {
    let _g = crate::test_env::lock();   // held until end of function
    override_environment_variables_from_config(Some("/src/test/rws.config.toml"));
    // ... rest of test
}
```

**The transitive trap.** The lock is required even when a test doesn't call `set_var` directly. `bootstrap()` looks innocent but it calls `override_environment_variables_from_config(None)`, which finds the project-root `rws.config.toml` (which sets `thread_count = 200`) and writes it into the process environment. A test that calls `bootstrap()` without the lock will silently corrupt `RWS_CONFIG_THREAD_COUNT` for any concurrently running test that just set it to something else.

When adding a new test, ask: *does any function I call eventually call `env::set_var`?* If yes, take the lock.

#### Preferred alternative: `App::with_config` for CORS/CSP tests

Tests that only verify CORS, CSP, or other header behavior (not filesystem-dependent paths) should prefer the lock-free pattern:

```rust
#[test]
fn my_cors_test() {
    // No env writes, no lock needed — runs safely in parallel.
    use crate::server_config::ServerConfig;
    let config = ServerConfig {
        cors_allow_all: false,
        cors_allow_origins: "https://example.com".to_string(),
        ..ServerConfig::default()
    };
    let app = App::with_config(config);
    let client = crate::test_client::TestClient::new(app);
    // ... assertions
}
```

`App::with_config(config)` pins the app to a fixed `ServerConfig` — no env reads happen during request processing, so no lock is needed even under parallelism. The `test_env::lock()` pattern is still required for tests that call `bootstrap()`, `override_environment_variables_from_config()`, or any function that writes `RWS_CONFIG_*` vars.

### 2. DEVELOPER.md

- **Building blocks table** — add a row for every new public type, function, or middleware: `| Name | Module | What it does |`
- **Use cases** — add a numbered use-case section with a minimal, runnable code example showing the feature in context. Follow the existing `## Use Case #N: Title` heading pattern.

### 3. README.md + llms.txt

- Add a bullet or table row to the relevant "What's in the box" / "Optional features" section of `README.md`.
- Update `llms.txt` — add the new type/function to the relevant section (API surface, middleware table, module index, security checklist, etc.). `llms.txt` is the primary LLM discovery document; keep it current.

### 4. docs/ (Astro site)

- Update or create the relevant page under `docs/src/content/docs/`. Pages are organized by section: `building-apps/`, `features/`, `proxy/`, `database/`, `deployment/`, `reference/`, etc.
- For a new feature, add a new `.md` or `.mdx` file in the appropriate section and register it in the Astro sidebar if needed (`docs/astro.config.mjs`).
- For an existing feature, update the page that covers it — add a new `##` section, update examples, revise the description.
- Keep examples in docs consistent with examples in `DEVELOPER.md`.

Skipping any of these four is not acceptable. Docs and tests ship with the code, not after.

## Architecture

The default build (`http3` feature) uses a tokio async runtime and serves HTTP/3 over QUIC, HTTP/2, and HTTP/1.1 over TLS. The `http1`-only build is a fully synchronous, thread-pool-based server with no async runtime. HTTP/1.1 parsing, JSON, CORS, MIME types, range requests, WebSocket, SSE, and routing are implemented from scratch (no hyper/Tower/Axum); HTTP/2 framing uses the `h2` crate, HTTP/3 uses `h3`/`quinn`, and TLS uses `rustls`.

### Request lifecycle

**Async builds (`http2`/`http3`, the default):** `main.rs` → `Server::setup()` binds the listener → `Server::run_tls()`. With a certificate it accepts TLS (10 s handshake deadline, `http1_async::TLS_HANDSHAKE_TIMEOUT`) and routes by ALPN to `h2_handler` or `server::http1_async::serve`; without one it calls `Server::run_plain()`, an async accept loop (backs off on accept errors instead of exiting) that also uses `http1_async::serve`. `serve` is the one HTTP/1.1 handler for plain TCP and TLS: keep-alive loop, every read bounded by `http1_async::READ_TIMEOUT` (30 s) via one `Sleep` per connection that `read_more` pushes back before each read (extending a tokio timer is a lock-free CAS; `tokio::time::timeout` per read took the timer driver's shared lock), the synchronous `Application` run via `http1_async::execute_request` (HTTP/2 and HTTP/3 use the same helper): inline on the async task when `Application::runs_inline(request, connection)` is true (panics caught with `catch_unwind`), otherwise `execute_blocking` on tokio's blocking pool. `runs_inline` defaults to `false`; `App` says yes for GET/HEAD, `WithMiddleware` only if every layer's `Middleware::runs_inline` and the inner app agree (the built-in in-memory layers opt in), `AppWithState` only when no user route matches (`Router::matches`), `ConfigDrivenApp` only when no route matches and there's no global `[middleware]`, `McpServer` never for `/mcp`. Never return `true` for anything that can wait (DB, upstream, Redis) — it would stall the worker. The blocking-pool handoff (a shared lock, two cross-thread wake-ups) was the biggest per-request cost for small static files. `stream_file` GETs with a `Content-Length` over plain TCP go out via `sendfile(2)` (`server::sendfile`, Linux/macOS; `SendfileSocket` exposes the `TcpStream` only for plain TCP) after `Server::stream_file_head`; everything else (`stream_pipe`, TLS, HEAD) is written by the sync chunked writers on the blocking pool through a bounded channel. HTTP/2 and HTTP/3 stream those bodies via `server::body_stream::spawn_producer` (64 KB chunks; proxied chunked framing decoded by `ChunkedDecoder`; HTTP/2 sends within the peer's flow-control window). Connections are tasks, so idle clients can't exhaust anything but file descriptors — `ci/e2e/slow_clients.sh` holds 1000 idle sockets and requires service to continue.

**`http1`-only build:** `Server::setup()` also creates the `ThreadPool` → `Server::run()` accepts connections → dispatches each to the pool (30 s socket read timeout) → `Server::process()` implements an HTTP/1.1 keep-alive loop: reads bytes, calls `Request::parse()`, calls `app.execute()`, applies gzip compression (`compression::apply_gzip()` — text bodies ≥ `MIN_COMPRESS_SIZE` 512 B only; never a response that already has `Content-Encoding`; the original is kept when gzip isn't smaller), records metrics, sets `Connection` header, then writes the response. For large files `response.stream_file` triggers `Server::write_chunked_file()` instead.

Request framing for both HTTP/1.1 loops (`Server::process` and the async `http1_async::serve`) lives in `src/server/framing.rs`: each connection keeps a `pending` byte buffer across requests; `scan_head` finds the end of the head (it may span many reads, capped at `MAX_HEAD_SIZE` = 64 KiB → `Server::header_fields_too_large_response()` 431); the head alone is parsed by `Request::parse`; `body_framing` picks `Content-Length` / chunked / none (CL+TE, conflicting CLs, non-chunked TE → 400); the body is drained from `pending` by length, or decoded incrementally by `ChunkedBody::feed`; whatever remains is the next pipelined request. A body cut short by the client ends the session without dispatching.

`Server::process()` and `http1_async::serve()` both support `Expect: 100-continue` (RFC 9110 §10.1.1): after parsing headers and passing the `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` check, a request with `Expect: 100-continue` gets `Server::continue_response()` written immediately, before the body-continuation read loop runs — otherwise the read loop would block waiting for a body the client is itself waiting to be told to send. An `Expect` value other than `100-continue` gets `Server::expectation_failed_response()` (417) without reading the body. HTTP/2 (`h2_handler`) and HTTP/3 (`h3_handler`) read bodies as separate async `DATA` frames rather than one blocking read, so they don't have this deadlock risk and don't implement it.

`Server::run_redirect()` optionally listens on a second port and issues `301` redirects to HTTPS.

### Routing / controller dispatch

`App` (in `src/app/mod.rs`) implements the `Application` trait (`src/application/mod.rs`). `Application::execute` returns `Result<Response, String>`. `App::execute` walks a hardcoded list of `if Controller::is_matching(...)` checks and calls the first matching controller's `process()`. Controllers are checked in declaration order.

The `Controller` trait (`src/controller/mod.rs`) has two methods:
- `is_matching(request, connection) -> bool`
- `process(request, response, connection) -> Response`

Three ways to add routes:
1. **Controller pattern** — create a module under `src/app/controller/`, implement `Controller`, register it in `App::execute`.
2. **Router** — build a `Router` inside `Application::execute` and call `router.handle(request, connection)`. Prefer this for new code when you need named path parameters or don't want boilerplate controllers.
3. **State-aware app** — `App::with_state(S)` returns `AppWithState<S>` which has `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` builder methods accepting `fn(state, request, path_params, connection) -> Response`. `App::with_async_state(S)` gives the same API with `async fn` handlers (requires `http2` feature). Wrap with `.wrap(layer)` to add middleware.

### Application variants

- `App` — zero-config, wraps all built-in controllers. Entry point: `App::new()`.
- `AppWithState<S>` (`src/state/mod.rs`) — state-aware dynamic router; `state: Arc<S>` shared across handlers. Entry point: `App::with_state(S)`.
- `AsyncAppWithState<S>` (`src/async_state/mod.rs`) — same as `AppWithState` but handlers are `async fn`; requires `http2` feature. Entry point: `App::with_async_state(S)`.
- `WithMiddleware<A>` (`src/middleware/mod.rs`) — wraps any `Application` with a middleware stack. Entry point: `app.wrap(layer)`.
- `McpServer` (`src/mcp/mod.rs`) — implements `Application`; serves the MCP Streamable HTTP protocol at `POST /mcp`. Entry point: `app.mcp(name, version)` or `McpServer::new(name, version)`.

### Middleware

`src/middleware/mod.rs` defines the `Middleware` trait:
```rust
trait Middleware: Send + Sync {
    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String>;
}
```
`WithMiddleware<A>::wrap(layer)` pushes layers onto a `Vec<Box<dyn Middleware>>`; layers are applied in push order (first-pushed is outermost). Any `Application` can be wrapped via `.wrap(layer)`.

Built-in middleware: `RateLimitLayer`, `MetricsLayer`, `CacheLayer`, `OtelLayer`, `RewriteLayer`, `ReverseProxy`, `H2ReverseProxy`, `GrpcProxy`, `BasicAuthLayer`, `JwtLayer`, `ForwardAuthLayer`, `IpFilter`, `RequestIdLayer`, `TimeoutLayer`.

### Configuration

Configuration is layered (lowest → highest priority):
1. Defaults hardcoded in `src/entry_point/mod.rs` (`Config::*_DEFAULT_VALUE`)
2. System environment variables
3. `rws.config.toml` in the working directory
4. Command-line args (`rws.command_line`)

All config is read at startup into process environment variables (`RWS_CONFIG_*`) and then accessed globally via `env::var(...)`. There is no config struct passed around at runtime.

Key config constants (all in `src/entry_point/mod.rs`):
- `RWS_CONFIG_IP`, `RWS_CONFIG_PORT`, `RWS_CONFIG_THREAD_COUNT`
- `RWS_CONFIG_TLS_CERT_FILE`, `RWS_CONFIG_TLS_KEY_FILE`
- `RWS_CONFIG_TLS_CLIENT_CA_FILE` — mTLS CA cert; empty disables client cert verification
- `RWS_CONFIG_HTTP_REDIRECT_PORT` — if set, `Server::run_redirect()` sends `301` to HTTPS on this port
- `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`, `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`
- `RWS_CONFIG_LOG_FORMAT` — `"json"` (the binary's default), `"combined"`, or `"off"`/`"none"` (no per-request access-log line)
- `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` — per-read chunk size (default 10000); bodies larger than this span multiple reads automatically
- `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` — max accepted request body size (default `0`, unlimited); `413 Payload Too Large` before buffering when exceeded, checked in `Server::process`, `http1_async::serve`, `h2_handler`, and `h3_handler`

`rws.config.toml` → env mapping (`entry_point::config_file::read_config_file`): a key's setting is `RWS_CONFIG_` + table + key, upper-cased (`[rate_limit] max_requests` → `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`). If that matches a `CommandLineArgument` it goes through the CLI parser; otherwise it must be listed in `config_file::SETTINGS_WITHOUT_FLAG` (a test scans `src/` for every `"RWS_CONFIG_*"` literal and fails if one isn't reachable — add new settings there). `[[virtual_host]]` blocks become `RWS_CONFIG_VIRTUAL_HOST_<n>_{DOMAIN,CERT_FILE,KEY_FILE}`. Unknown keys print a startup warning; proxy tables (`route`, `upstream`, `tcp_proxy`, `udp_proxy`, `ws_proxy`) are skipped. Values are real TOML-ish strings (quotes stripped, inner spaces kept; arrays → comma lists). `RWS_CONFIG_FILE` selects the file for settings and proxy routes alike. Long-only CLI arguments use an empty `short_form` (never matched by `-=`). The `rws` binary enforces the global rate limit only when `rate_limit::is_configured()`. Proxy routes (`[[route]]`/`[[upstream]]`) go through the separate `proxy_config::parser`, where action keys may sit directly under `[route.action]` or in `[route.action.<type>]` (`merge_action_keys` folds the former into the latter; nested wins).

ACME (`acme` feature): `main.rs::start_acme()` → `AcmeConfig::from_env()` (empty env values = unset; cert/key default to the TLS paths, else `cert.pem`/`key.pem`) → `install_as_tls_certificate()` (fills empty `RWS_CONFIG_TLS_*` so `run_tls` serves it) → `provision_until_ready()` (backoff retry) → `run_renewal_loop()`. HTTP-01 key authorizations live in a process-wide store (`acme::challenges()`); a temporary listener serves them at startup, and once `Server::run_redirect` has bound the challenge port (`note_redirect_listener`) it serves them instead, so renewal never re-binds port 80. Challenge and redirect listeners use `server::bind_all_families` (IPv4 + IPv6). The ACME HTTP client sends `User-Agent` and a `Host` header with non-default ports, and trusts `RWS_CONFIG_ACME_CA_FILE` roots for private CAs. Verified end to end against Pebble.

Static-file hot path: `StaticResourceController::serve_regular_file` answers a GET/HEAD of a regular file without `Range` through `static_resource::open_file_cache` (open handles per path, 8 `RwLock` shards, 128 files max, revalidated by one path `stat` — device+inode — every `RWS_CONFIG_OPEN_FILE_CACHE_SECS`, default 1, `0` = off, Unix only; a hit is one `fstat` for current size/mtime and a `pread`; names without a known MIME type open with `O_NOFOLLOW` so symlinks go to the general path; `is_matching` skips its `stat` for a cached file) (small files read from that handle; over `STREAM_THRESHOLD` (256 KiB) → `stream_file` before reading). It declines directories, OPTIONS, ranges and symlinks whose name has no known MIME type (the general path takes the type from the link target) — `process_static_resources` stats and opens several times. Per-request config comes from `ServerConfig::current()` (an `Arc` snapshot behind a generation counter), never `from_env()`: `std::env::var` takes the process-wide environment lock, and ten of them per request showed up as lock contention. Anything that writes `RWS_CONFIG_*` must call `server_config::invalidate()` (config file loader, CLI parser, `set_default_values`, `bootstrap`, `config_reload::reload`, `secrets` already do; tests that `set_var` directly must too). Use `static_resource::static_fs_path`/`working_directory()` (the working dir, cached once) instead of `FileExt::get_static_filepath`, and `static_resource::file_modified_utc` (a `stat`) instead of `FileExt::file_modified_utc` (an `open`); `getcwd` and repeated opens dominated the profile. `is_matching` implementations must be silent and check cheap things first — an `eprintln!` there serialized every request on the stderr lock. Connection writers use `Response::generate_response_parts` (head and body separately; single-range bodies are moved, not copied) with `TCP_NODELAY` set on accepted sockets. `bench/run.sh` (and the manual `Benchmark` workflow) compares against Caddy and nginx (no compression, so all do equal work).

Byte ranges: `Range::parse_range_in_content_range` returns an **inclusive** `end` (RFC 9110 §14.1.2: open-ended/suffix ranges end at the last byte, a last-pos past EOF is clamped, oversized suffixes select the whole file). `response::content_range_header_value(status, cr)` is the single source of the `Content-Range` header — emitted only on `206` — used by `generate_response`, `generate`, `h2_handler`, `h3_handler`, and `TestClient`. Note `Range::get_content_range(body, ..)` (full bodies) still stores `end = len`; it never reaches a header because full bodies are `200`.

`Response::_parse_http_version_status_code_reason_phrase_string` (used by `Response::parse`, which parses every proxied upstream response) accepts any 3-digit status ≥ 100 and any or no reason phrase (RFC 9112 §4) — it used to require the canonical phrase for a known code, so a backend's "404 File not found" became a 502. HTTP/2 advertises `SETTINGS_MAX_CONCURRENT_STREAMS = h2_handler::MAX_CONCURRENT_STREAMS` (250) and `SETTINGS_MAX_HEADER_LIST_SIZE = h2_handler::MAX_HEADER_LIST_SIZE` (64 KiB); HTTP/3 uses the same value for `max_field_section_size`. `URL::parse` uses rws's own panic-free RFC 3986 parser (`url::parse_url`), not url-build-parse's (which unwraps on input like a non-numeric port); url-build-parse still provides `UrlComponents` and `build_url`.

Config-driven proxy routes to plain-HTTP upstreams with no route middleware and no global `[middleware]` skip the blocking pool entirely: `CompiledRoute.async_proxy` (set in `builder::compile_routes` when `DynamicProxy::supports_async()` and `route.middleware.is_empty()`) → `ConfigDrivenApp::execute_async` → `DynamicProxy::execute_async` (shares `prepare()` — backend choice, least-connections guard, prefix rewriting — with the sync `execute`) → `proxy::async_upstream::forward` (its own `Pool<tokio::net::TcpStream>`, same `build_request`/stale-retry/`should_stream_response` rules; a streamed response converts the socket with `into_std()` and hands it to the blocking writer). `Application::execute_async` (`http2`, default `None`) is awaited by `http1_async::execute_request` before `runs_inline`/`execute_blocking`, panics caught by `CatchUnwind`; `WithMiddleware` offers it only when every layer's `Middleware::is_passthrough` is true (the binary's `GlobalRateLimit` is when no rate limit is configured); `McpServer` forwards it for non-MCP paths. HEAD/1xx/204/304 responses end at the head in both forwarders (`proxy::response_has_no_body`). Upstream TLS is decided per route from the declared `[[upstream]]`, else from an `https://` upstream address. Otherwise upstream HTTP/1.1 goes through `proxy::forward_upstream(plain_pool, …, tls, …)` (ReverseProxy, `DynamicProxy`, canary; ingress calls `forward_pooled` directly): plain backends use a `ConnPool` (`= Pool<TcpStream>`; `proxy::shared_pool()`, 64 idle per `host:port`; ReverseProxy has its own), `https://` backends a process-wide `Pool<TlsStream>` (`rustls::StreamOwned`), both driven by the generic `forward_via<S: UpstreamIo>` / `exchange` (same streaming, chunked decoding and stale-connection handling). A pooled connection that fails before any response byte is retried once on a fresh connection only for idempotent methods. `connect_tcp` tries every resolved address (`localhost` → `::1` then `127.0.0.1`). The TLS client config is built once (`tls_client_config()`). Every outbound rustls client is built with `crate::tls_client_builder()` (explicit aws-lc-rs provider): `ClientConfig::builder()` panics when no default provider is installed and two are compiled in, and `quinn` is configured with `rustls-aws-lc-rs` (not its default `rustls-ring`) so the default build has only one.

Proxied buffered responses go through `proxy::normalize_upstream_response`: upstream `Content-Type` moves onto the body's `ContentRange`, upstream `Content-Length`/`Transfer-Encoding`/hop-by-hop headers are dropped (rws regenerates them — a duplicate `content-length` is an HTTP/2 protocol error), and the status line is re-emitted as HTTP/1.1. Streaming (`stream_pipe`) responses are not normalized.

Hot reload: `SIGHUP` (or `POST /admin/config/reload`) calls `config_reload::reload()` which re-reads CORS rules, rate limits, log format, and request allocation size, then runs hooks registered with `config_reload::on_reload`. On TLS builds, SIGHUP also rebuilds the `TlsAcceptor` from updated certs for all virtual hosts. `proxy_config::build_from_file()` registers a hook that calls `ConfigDrivenApp::reload_from_file()`: `ConfigDrivenApp` is an `Arc<RwLock<Arc<Generation>>>` shared by all clones (each `Generation` = compiled routes + fallback + global-middleware entry + `ProxyConfig` + source text + a `health_stop` flag); `reload_from_str` validates (`check_unknown_keys` + `validate`), keeps the old generation on errors, otherwise compiles routes (`builder::compile_routes`, new health checkers) and swaps, setting the old `health_stop`. Reloads are serialized (`reload_lock`) because one SIGHUP reaches every accept loop; an unchanged source is a no-op. L4/WS listeners (`builder::spawn_listeners`) are startup-only. `ci/e2e/config_reload.sh` exercises `--check` exit codes and SIGHUP reload against the binary.

### Key types

- `Request` (`src/request/mod.rs`) — method, request_uri, http_version, headers, body (raw bytes)
- `Response` (`src/response/mod.rs`) — status code, headers, body as `Vec<ContentRange>`, and `stream_file: Option<String>` for chunked file streaming
- `Header` (`src/header/mod.rs`) — name/value pair; constants for all standard header names
- `ConnectionInfo` (`src/server/mod.rs`) — client/server IP+port, request allocation size, and `sni_hostname: Option<String>` (SNI hostname from TLS handshake, `None` for plain HTTP), passed into every controller

### HTTP version constants

`src/http/mod.rs` defines `VERSION` (HTTP/0.9 through HTTP/3.0) and `HTTP::version_list()` which is used by `Request::parse_method_and_request_uri_and_http_version_string` to validate the version token on every incoming request.

### Dynamic router

`src/router/mod.rs` provides `Router` — a fluent, path-based router with named parameters and wildcards. Register handlers with `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, then call `router.handle(request, connection)` from inside `Application::execute`. Pattern syntax: `:name` for a named segment, `*name` for a trailing wildcard. `PathParams::get(name)` retrieves extracted values.

Call `.with_host("example.com")` before registering routes to restrict a `Router` to requests whose SNI hostname (TLS) or `Host` header (plain HTTP) matches that value — the foundation for virtual-host routing.

### Typed extractors

`src/extract/mod.rs` defines the `FromRequest` trait and built-in extractors:
- `Body` — raw bytes (never fails)
- `BodyText` — UTF-8 body (400 on invalid UTF-8)
- `Query` — parsed query string as `HashMap<String, String>`
- `RequestHeaders` — all request headers with case-insensitive `get(name)`

### Typed errors

`src/error/mod.rs` defines:
- `IntoResponse` trait — implement on your error enum to map it to a `Response`; `Response` itself is the identity implementation
- `AppError` enum — covers `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `UnprocessableEntity`, `TooManyRequests`, `Internal`; all implement `IntoResponse`

### Rate limiting

`src/rate_limit/mod.rs` provides `RateLimiter` — a thread-safe sliding-window limiter keyed by a string (typically the client IP). Call `limiter.check(key)` → `true` if within budget, `false` to return 429. `rate_limit::global()` returns a process-wide singleton configured via `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` (default 1000) and `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` (default 60).

### Metrics

`src/metrics/mod.rs` — global counters incremented directly in `Server::process()` and `dispatch_connection()`:
- `record_request()` — total requests
- `record_error()` — total errors
- `connection_open()` / `connection_close()` — current connection gauge
- `SERVER_READY: AtomicBool` — cleared on shutdown; checked by `/readyz`

`MetricsLayer` middleware records per-route `rws_route_requests_total{method,path,status}` counters and `rws_route_duration_seconds{method,path}` histograms. The built-in `GET /metrics` endpoint exposes all metrics in Prometheus text format.

### MCP server

`src/mcp/mod.rs` — `McpServer` implements `Application` and serves the MCP Streamable HTTP protocol (`POST /mcp`, JSON-RPC 2.0). Register tools, resources, and prompts via builder methods: `.tool(name, description, schema, handler)`, `.resource(uri_template, name, description, handler)`, `.prompt(name, description, handler)`. `.require_bearer(token)` gates all requests behind a static Bearer token. `.wrap(app)` falls through non-MCP requests to another `Application`. The binary ships 8 built-in rws-specific tools (`server_config`, `feature_flags`, `server_metrics`, `rate_limit_config`, `check_rate_limit`, `cors_config`, `list_static_files`, `reload_config`).

`initialize` negotiates `params.protocolVersion` down to the lower of the client's request and the server's own version (`YYYY-MM-DD` strings compare correctly lexically) rather than always claiming its own, and mints a session id (`Mcp-Session-Id` response header) that records that call's `clientInfo` in `sessions: Arc<Mutex<HashMap<String, StoredClientInfo>>>`. `.tool_with_context(name, description, schema, |ctx: McpContext, args: &str| ...)` registers a tool whose handler additionally receives that session's `McpContext` (`client_name`, `client_version`, `session_id`, `auth_claims` — `None` unless `.require_oauth()` verified this request's bearer JWT, see below); `execute()` reads the echoed `Mcp-Session-Id` header to look it up, while `handle_request(body)` (used directly in most tests) yields an empty `McpContext` unless the test calls `handle_request_with_context(body, ctx)` explicitly. Session records are never evicted.

`.tool_annotated(name, description, schema, annotations, handler)` (MCP 2025-03-26) attaches a `ToolAnnotations` value (`read_only_hint`, `destructive_hint`, `idempotent_hint`, `open_world_hint`, all `Option<bool>`) to a tool — hints, not enforced — serialized as an `annotations` object with camelCase keys (`readOnlyHint`, etc.) in `tools/list` only for tools that have them; its handler is the plain `Fn(&str) -> ...` shape like `.tool()`, not the context-aware shape, so there is no single builder combining annotations with `McpContext`.

`McpContent::image(data, mime_type)` and `McpContent::embedded(uri, text, mime_type)` cover the MCP spec's `image` and `resource` content variants alongside the original `text`/`json`; `to_content_json()` branches on `McpContent::kind` (`"text"` / `"image"` / `"resource"`) to pick the right JSON shape. `image` expects an already-base64-encoded string — no base64 crate is a dependency of this project. `resources/read`'s response format is untouched (hand-built, doesn't go through `to_content_json()`), so a resource handler still can't return image content that way.

`handle_request_with_context` detects a top-level JSON array (`body.trim_start().starts_with('[')`) and hands off to `handle_batch`, supporting JSON-RPC 2.0 batch requests — several calls dispatched from one `POST /mcp` body, joined into one `[...]` response array. `json_rpc::split_array_elements` does the array splitting (depth/string-tracking, like `bracket_extract`); the per-method dispatch table and JSON-RPC response rendering are factored into private `dispatch()`/`format_result()` helpers shared by both the single-request and batch code paths. Notifications in a batch contribute no response entry; an all-notification batch returns `202` with no body; an empty array (`[]`) returns one `Invalid Request` error rather than `[]`; a successful `initialize` inside a batch still mints a session and sets `Mcp-Session-Id` (only the first `initialize` in a batch counts, since one response carries one session id).

`.page_size(n)` (clamped to a minimum of `1`) enables cursor-based pagination for `tools/list`/`resources/list`/`prompts/list`; unset (the default), every item is returned in one response with no `nextCursor`. A shared `fn paginate(&self, items: &[String], body: &str) -> Result<(&[String], Option<String>), (i32, String)>` reads `params.cursor`, decodes it via private `encode_cursor`/`decode_cursor` (base64 of the decimal offset string, using self-contained `base64_encode`/`base64_decode` free functions — no base64 crate dependency, mirroring `websocket::base64_encode`'s same from-scratch approach), slices the items, and returns the page plus an optional `nextCursor`. An invalid/tampered cursor returns `INVALID_PARAMS` (`-32602`); an offset past the end returns an empty page with no `nextCursor`.

`GET /mcp` opens an SSE stream for server → client push, built on the existing `Response::stream_pipe: Option<Box<dyn Read + Send>>` mechanism (already used for reverse-proxy passthrough streaming) rather than new response-writing machinery — `Server::pipe_stream` drives it unmodified. `sse_clients: Arc<Mutex<Vec<SyncSender<Vec<u8>>>>>` holds one bounded (`mpsc::sync_channel(32)`) sender per connected client; `start_sse_stream()` registers a new one and returns a `Response` whose `stream_pipe` is `SseChannelReader` (a `Read` adapter blocking on the receiver, emitting a `: keep-alive` comment every `SSE_KEEPALIVE_INTERVAL` (15s) when idle — this doubles as disconnect detection, since the next real write after a dead peer fails). `.notify(method, params_json)` (public) renders one JSON-RPC notification as an SSE `data:` frame and pushes it to every client via `try_send` (never blocks); a client whose buffer is full is dropped from `sse_clients` exactly like a disconnected one. Only wired up for the plain HTTP/1.1 path — `h2_handler`/`h3_handler` don't drive `stream_pipe` for any response yet. `TestClient` doesn't drive `stream_pipe` either (it inspects the `Response` directly), so SSE tests call `start_sse_stream()`/`notify()` directly and read from the returned reader in-process.

`LogLevel` (8 RFC 5424 severities, `Debug` through `Emergency`, `PartialOrd`/`Ord` derived from declaration order) plus `min_log_level: Arc<Mutex<LogLevel>>` and `.logging_enabled()` (adds `"logging":{}` to `initialize`'s capabilities — advertising only, doesn't gate anything) implement `logging/setLevel` (`dispatch`'s `"logging/setLevel" => self.do_set_log_level(body)` stores the requested level) and `.log(level, logger, data_json)`, which builds a `notifications/message` params object and calls `self.notify(...)` — but only if `level >= *min_log_level.lock().unwrap()` — so it inherits `.notify()`'s backpressure/disconnect handling for free. Default `min_log_level` is `LogLevel::Debug` (nothing filtered) until a client calls `logging/setLevel`.

`tools`/`resources`/`prompts` are stored as `Arc<RwLock<Vec<T>>>` (not a plain `Vec`) so every clone of `McpServer` shares the same live list — this backs dynamic registration: `.register_tool(name, description, schema, handler)` / `.remove_tool(name) -> bool` and the `register_resource`/`remove_resource`, `register_prompt`/`remove_prompt` equivalents take `&self`, unlike the consuming `.tool()`/`.resource()`/`.prompt()` builders, so they can be called from any thread at any time (no separate `McpHandle` type — a cloned `McpServer` already shares the storage). `do_tools_call`/`do_resources_read`/`do_prompts_get` clone the matched handler's `Arc` out from under a short read-lock guard before invoking it, so a slow handler never blocks a concurrent registration. Each successful registration/removal pushes `notifications/{tools,resources,prompts}/list_changed` (no `params`) via `.notify()`; a no-op removal (name not found) pushes nothing. `initialize` advertises `listChanged:true` for all three unconditionally; `resources.subscribe` stays `false` (that's TODO-14, not implemented).

`McpContext` gained `pub progress_token: Option<String>` (the raw JSON form of `params._meta.progressToken` from a `tools/call` request — stored raw, not decoded, since the spec allows `string | number` and this way both round-trip correctly) and a private `sse_clients: Option<Arc<Mutex<Vec<SyncSender<Vec<u8>>>>>>`, set unconditionally in `context_for()` for every request. `do_tools_call` extracts `_meta.progressToken` via two nested `json_rpc::extract_raw` calls (no dotted-path support in the hand-rolled JSON helpers) and overrides `ctx.progress_token` before calling the handler. `McpContext::report_progress(progress, total, message)` pushes `notifications/progress` — reads `self.progress_token` internally (not passed as a parameter, unlike the TODO's own sketch) and silently no-ops if it's `None` or `sse_clients` is `None` (a hand-built context, e.g. via `handle_request_with_context`). `McpServer::notify` and `report_progress` share `render_notification()`/`broadcast_sse_to()` free functions (extracted from the old `notify`/`broadcast_sse` methods) so the JSON-RPC rendering and try_send-and-prune broadcast logic exists in exactly one place.

`.completion(ref_type, ref_name, handler)` (a consuming builder like `.tool()`/`.resource()`/`.prompt()`) registers an argument-autocompletion provider in `completions: Arc<RwLock<Vec<CompletionDef>>>`; `dispatch`'s `"completion/complete" => self.do_completion(body)` looks up the entry matching `ref.name` and `ref.type` with its `"ref/"` prefix stripped (so `ref_type: "tool"` matches wire value `"ref/tool"` — an extension beyond the real spec's `ref/prompt`/`ref/resource`, since this server treats tools as completable too), calls the handler with `argument.name`/`argument.value`, and renders `{"completion":{"values":[...],"hasMore":...,"total":...}}`. No matching registration returns empty `values`, not an error. Results beyond `MAX_COMPLETION_VALUES` (100) are truncated with `hasMore:true` and the untruncated `total`. `initialize` advertises `"completions":{}` automatically once `!self.completions.read().unwrap().is_empty()` — no separate opt-in flag. No dynamic (`&self`) equivalent — completions are builder-only.

Request cancellation (`notifications/cancelled`) is **cooperative**, via `cancellations: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>` keyed by a `tools/call`'s raw `id` token — not the async-only `tokio_util::CancellationToken` design originally sketched, since async tool handlers don't exist yet (TODO-17). Private `dispatch_with_cancellation` (wrapping `dispatch` in both `handle_request_with_context` and `handle_batch`) registers a flag before a `tools/call`, attaches it to `McpContext` via a new private `cancellation: Option<Arc<AtomicBool>>` field, and always removes the entry afterward regardless of whether it was checked — no leak risk, unlike `sessions`/`sse_clients`. `notifications/cancelled` is special-cased ahead of the generic notification-swallowing branch in both dispatch paths; it reads `params.requestId` (raw token, `string | number`) and flips the matching flag, silently no-op on an unknown/finished id. `McpContext::is_cancelled(&self) -> bool` is the handler-facing getter, always safe to call, `false` by default.

`resources/subscribe`/`resources/unsubscribe` (`.notify_resource_updated(uri)` is the resource-owner-facing push method) are the first genuinely *targeted* SSE notification in this module — every other one (`.notify()`, `.log()`, `list_changed`) broadcasts to all connected clients. `sse_clients` changed from a flat `Vec<SseSender>` to `Vec<SseClient>` (`struct SseClient { session_id: Option<String>, sender: SseSender }`); `start_sse_stream` now reads `Mcp-Session-Id` off the `GET /mcp` request to tag each connection. `subscriptions: Arc<Mutex<HashMap<String, Vec<String>>>>` maps a resource URI to subscribed session ids; `send_sse_to_sessions` (a session-filtering sibling of `broadcast_sse_to`) does the targeted send. `do_resource_subscribe`/`do_resource_unsubscribe` require `ctx.session_id` (`INVALID_PARAMS` without one — a subscription with no way to correlate to an SSE connection could never fire); unsubscribing the last subscriber for a URI prunes that URI's `subscriptions` entry entirely. `initialize`'s `resources.subscribe` is now unconditionally `true`. No proactive cleanup when an SSE connection disconnects without unsubscribing first — same tradeoff as `sessions`.

`sampling/createMessage` (server-side sampling) reverses the normal direction: `ctx.sample(request: SamplingRequest, timeout: Duration) -> Result<SamplingResponse, String>` (`.tool_with_context()` only) sends a JSON-RPC *request* to the client via `send_sse_to_sessions` and **blocks the calling thread** (not `async fn` — tool handlers themselves are still synchronous even where `.async_tool()` exists, see below) on an `mpsc::Receiver` until the client's `POST /mcp` reply arrives or `timeout` elapses. `SamplingRequest.messages: Vec<PromptMessage>` reuses `PromptMessage` rather than a duplicate `SamplingMessage` type (identical wire shape). The client's reply is a JSON-RPC *response* — no `method` — so `handle_request_with_context`/`handle_batch` now check a method-less body with a recognized `id` against `pending_replies: Arc<Mutex<HashMap<String, mpsc::Sender<Result<String,String>>>>>` (`try_deliver_sampling_response`) before falling back to the pre-existing "Missing method" error. `StoredClientInfo.supports_sampling` (from `params.capabilities.sampling` at `initialize` — a *client*-declared capability, not server-advertised) gates `sample()` failing fast without sending anything; it also fails fast with no session id or no live server, and times out if the client never responds (including simply having no `GET /mcp` connection open).

`ctx.sample`'s request/response mechanics are factored into a private `McpContext::send_and_wait(method, params_json, timeout)` shared with `ctx.list_roots(timeout) -> Result<Vec<McpRoot>, String>` (`roots/list` + `notifications/roots/list_changed`) — both are thin wrappers differing only in method/params sent and `result` parsing (`parse_sampling_response` vs `parse_roots_response`). Unlike sampling, `list_roots` **caches per session** in a new `StoredClientInfo.roots: Option<Vec<McpRoot>>` (`None` = never fetched or invalidated) — only the first call in a session round-trips, later calls return the cache. `notifications/roots/list_changed` (special-cased in both dispatch paths ahead of the generic notification branch, same position as `notifications/cancelled`) clears the cache via `invalidate_roots_cache(&ctx.session_id)`, keyed purely by session id since this notification carries no params. `StoredClientInfo.supports_roots` mirrors `supports_sampling`'s gate.

`.async_tool(name, description, schema, handler)` (`http2` feature) registers a tool whose handler is `Fn(&str) -> impl Future<Output = Result<McpContent, String>>`, stored separately in `async_tools: Arc<RwLock<Vec<AsyncToolDef>>>` (not unified into `ToolDef` via a handler enum — far less invasive than touching every existing sync-tool code path). `tools/call` bridges into the future via `crate::async_bridge::block_on_isolated` — the same mechanism `H2ReverseProxy`/`AsyncAppWithState::execute` use — not `tokio::task::block_in_place`, which panics outside a `multi_thread` runtime. `do_tools_list` merges `tools`/`async_tools` via a shared `render_tool_list_entry` helper; `do_tools_call` checks `tools` first, then `async_tools` under `#[cfg(feature = "http2")]`. `.register_async_tool(...)` is the dynamic equivalent (TODO-9 style); `.remove_tool(name)` checks both collections. No async equivalent of `.tool_with_context()`/`.tool_annotated()` yet.

`.require_oauth(provider: sso::OidcProvider, audience)` (MCP 2025-03-26 OAuth 2.0 authorization, `sso` feature) is an alternative to `.require_bearer(token)`: instead of one static shared secret, it verifies a client's `Authorization: Bearer <jwt>` against a live JWKS endpoint by reusing `sso::jwks::JwksCache` directly — the "leverage point" this feature's own backlog entry named, not reimplemented. Stored as `oauth: Option<Arc<OAuthConfig>>` (`OAuthConfig { jwks, provider, audience }`), gated `#[cfg(feature = "sso")]` end to end since `mcp` itself carries no Cargo feature of its own. `execute()` checks `self.oauth` before `self.auth_token` — if both are configured, OAuth wins and the static token is never checked, an explicit tie-break for an unsupported-in-practice combination. On success, the verified `sso::OidcClaims` (JSON-serialized) populates `McpContext.auth_claims`; on any failure (missing/malformed/invalid-signature/wrong-audience/wrong-issuer/expired token) the request gets the same `401` + `WWW-Authenticate: Bearer` `unauthorized()` response `.require_bearer()` already used. `GET /.well-known/oauth-authorization-server` is served automatically whenever OAuth is configured (checked ahead of the `/mcp` path match, since it's a sibling top-level path) with a minimal, not-full-RFC-8414 document (`issuer`/`authorization_endpoint`/`token_endpoint`/`jwks_uri` sourced straight from `provider`) — falls through to the fallback app when OAuth isn't configured. `require_oauth` takes an `OidcProvider` (a preset or the result of `OidcProvider::discover(issuer)`) rather than a bare JWKS URL string, correcting an ambiguity in the backlog entry's own sketch (which named its parameter `jwks_url` but gave a `.well-known/openid-configuration` discovery URL as the example value — two different things).

### Test client

`src/test_client/mod.rs` provides `TestClient<A>` — dispatches requests directly through an `Application` without opening a TCP socket. Use it in unit and integration tests:

```rust
let client = TestClient::new(App::new());
let res = client.get("/healthz").send();
assert_eq!(200, res.status());
```

### Graceful shutdown

HTTP/1.1 (`http1` feature, `ctrlc` dep): a `SIGINT`/`SIGTERM` handler sets an `AtomicBool`; the accept loop exits and the `ThreadPool` drains in-flight requests before the process exits.

HTTP/2 + HTTP/3 (async features): `tokio::signal` handles `SIGINT` and `SIGTERM`; each `run_tls`/`run_quic` loop `select!`s on the signal and breaks cleanly. `SERVER_READY` is cleared before breaking.

### No async (`http1` feature only)

When compiled with `--no-default-features --features http1`, the server uses `std::net::TcpListener` and a hand-rolled `ThreadPool` (`src/thread_pool/mod.rs`). Each connection is handled synchronously on a worker thread. No tokio, no async.

### HTTP/2 feature (`--features http2`)

When built with the `http2` feature, the binary uses a `tokio` runtime and serves TLS via `rustls` (aws-lc-rs crypto backend). ALPN negotiation selects HTTP/2 (`h2` crate) or HTTP/1.1 per connection automatically. New modules:

- `src/tls/mod.rs` — `SniCertResolver` implements `rustls::server::ResolvesServerCert`; `create_tls_acceptor_from_vhosts(vhosts, default_cert, default_key)` builds a multi-domain `TlsAcceptor` that picks the right cert per SNI hostname at handshake time. `create_tls_acceptor(cert, key)` is a backward-compat wrapper for single-cert deployments. When `RWS_CONFIG_TLS_CLIENT_CA_FILE` is set, `load_client_verifier()` builds a `WebPkiClientVerifier` that enforces mTLS on both HTTPS and QUIC listeners.
- `src/virtual_host/mod.rs` — `VirtualHostConfig { domain, cert_file, key_file }` carries per-domain cert configuration.
- `src/h2_handler/mod.rs` — translates `h2::RecvStream` requests into `crate::request::Request`, calls `app.execute()`, translates `Response` back into H2 frames. The `Application` and `Controller` traits are untouched.
- `src/server/mod.rs::Server::run_tls()` — async accept loop; extracts SNI hostname after the TLS handshake (`tls_stream.get_ref().1.server_name()`), routes by ALPN to `h2_handler::handle_connection` or `http1_async::serve`, populates `ConnectionInfo::sni_hostname`.
- `src/server/mod.rs::Server::run_redirect()` — optional second listener; sends `301 Moved Permanently` to HTTPS for every request; activated by `RWS_CONFIG_HTTP_REDIRECT_PORT`.
- HTTP/1.1 TLS responses include `Alt-Svc: h3=":PORT"` (or `h2` when http3 feature is absent) to advertise protocol availability.
- Forbidden HTTP/2 headers (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `proxy-connection`, `te`) are stripped from responses before sending.
- SIGHUP: reloads hot config via `config_reload::reload()` AND rebuilds `TlsAcceptor` with fresh certs for all virtual hosts.

### HTTP/3 feature (`--features http3`, default)

When built with the `http3` feature (which implies `http2`), a second listener starts over UDP using QUIC (`quinn` crate). HTTP/3 uses `h3` + `h3-quinn`. New additions:

- `src/tls/mod.rs::create_quinn_server_config_from_vhosts()` — builds a multi-domain `quinn::ServerConfig` via the same `SniCertResolver`; `create_quinn_server_config()` is the single-cert wrapper.
- `src/h3_handler/mod.rs` — accepts QUIC connections, extracts SNI from `conn.handshake_data()?.downcast::<HandshakeData>()`, resolves H3 streams via `RequestResolver::resolve_request()`, calls `app.execute()`, sends H3 responses.
- `src/server/mod.rs::Server::run_quic()` — binds a UDP endpoint on the same port as TCP; skipped silently if no cert is configured.
- `main()` runs `Server::run_tls`, `Server::run_quic`, and `Server::run_redirect` concurrently via `tokio::join!`.

### Proxy modules

- `src/proxy/mod.rs` — `ReverseProxy` (HTTP/1.1 reverse proxy middleware); `H2ReverseProxy` (HTTP/2 upstream proxy, `http2` feature, bridges sync middleware into the tokio runtime via `crate::async_bridge::block_on_isolated` — a scoped OS thread with its own single-threaded runtime, which works under any tokio runtime flavor including `current_thread`); `GrpcProxy` (wraps `H2ReverseProxy`, filters on `Content-Type: application/grpc*`). `Backend::parse()` strips `h2://` and `http://` prefixes.
- `src/async_bridge/mod.rs` (`http2` feature) — `block_on_isolated(f)`: runs an async closure to completion regardless of whether the calling thread is already inside a tokio runtime. Used by both `H2ReverseProxy` and `AsyncAppWithState::execute` to bridge their sync trait methods (`Middleware`/`Application`) into async code without `tokio::task::block_in_place`'s `multi_thread`-only requirement.
- `src/tcp_proxy/mod.rs` — `TcpProxy` standalone L4 TCP proxy; `bind(addr)` blocks and accepts connections; `relay(client)` spawns two threads doing `std::io::copy` for bidirectional byte relay; round-robin backend selection via `AtomicUsize`.
- `src/udp_proxy/mod.rs` — `UdpProxy` standalone UDP datagram proxy; per-datagram thread model; ephemeral socket per datagram connects to the backend; `set_read_timeout()` controls reply wait; round-robin backend selection.
- `src/ws_proxy/mod.rs` — `WsProxy` standalone WebSocket proxy; reads the HTTP upgrade request from the client, connects to the backend, exchanges upgrade handshake, then relays raw bytes bidirectionally in a two-thread loop.

### Dependency injection

`src/di/mod.rs` — `Container` is a type-keyed service store backed by `HashMap<TypeId, Box<dyn Any + Send + Sync>>`. Services are stored as `Arc<T>` or `Arc<dyn Trait>` (both are `Sized`, `'static`, `Any + Send + Sync` and can be stored in the map). Registration: `register::<T>(value)` wraps in `Arc<T>`; `provide::<T: ?Sized>(Arc<T>)` stores trait objects directly. Both keyed by `TypeId::of::<T>()`. Named services use a second map keyed by `(TypeId, String)`. Resolution: `get::<T>()` (works for both concrete and `dyn Trait`) and `get_named::<T>(name)`. `into_arc()` seals the container for sharing. No external dependencies.

### Config-driven proxy server

`src/proxy_config/` — turns `rws.config.toml` into a live proxy stack at startup; activated when the config file contains `[[route]]` or `[[upstream]]` sections.

- `mod.rs` — all config types (`ProxyConfig`, `UpstreamConfig`, `RouteConfig`, `ActionConfig`, `MiddlewareConfig`, `AuthConfig`, etc.); `ProxyConfig::is_proxy_mode()` scans the file; `ProxyConfig::load()` and `ProxyConfig::from_str()` parse it. `ConfigDrivenApp { routes: Arc<Vec<CompiledRoute>>, fallback: App }` implements `Application + Clone` (first-match router). `RouteMatcher` evaluates host, path prefix/exact, method, content-type prefix. `DynamicProxy` picks a live backend from `Arc<RwLock<Vec<String>>>` with an atomic round-robin counter. `RedirectAdapter` and `RespondAdapter` handle redirect/fixed-response actions. `PerRouteRateLimit` and `BearerAuthMiddleware` implement `Middleware`. `route.middleware.auth` accepts `AuthConfig::Bearer { token_env }`, `AuthConfig::Jwt { secret_env }` (wraps `auth::JwtLayer`, requires the `auth` feature), or `AuthConfig::Basic { htpasswd_file }` (wraps `auth::BasicAuthLayer::from_htpasswd_file`, requires the `auth` feature) — auth fails closed: when the secret/token env var is unset or empty, the htpasswd file is unreadable, or the `auth` feature isn't compiled in, `builder.rs::apply_middleware()` wraps the route in `AuthMisconfigured` (every request → `503`, error logged at startup) instead of skipping auth. `BearerAuthMiddleware` compares tokens with `constant_time_eq`.
- `validate.rs` — `ProxyConfig::validate(&mut ConfigReport)` (semantic checks: undeclared upstreams, unknown action types, unset auth secrets, bad backends/listen addresses, IPv4/IPv6 filter entries) and `check_unknown_keys(toml, report)` (a per-section schema of the keys the parser reads); used by `rws --check` (`src/config_check/`) and printed at startup by `build_from_file()`. `ConfigDrivenApp` wraps its `RouteDispatch` (routes + fallback `App`) in the top-level `[middleware]` table via `builder::apply_middleware`, rebuilt by `with_config`.
- `parser.rs` — hand-rolled TOML parser producing `SectionMap` (`HashMap<String, Vec<(String, String)>>`). Tracks `[[array]]` tables with counters (`upstream[0]`, `route[0].match`), expands an inline table `key = { a = 1 }` into the sub-section `<section>.key` (same as `[<section>.key]`) and arrays of strings.
- `health.rs` — `start_health_checker()` spawns a daemon thread per upstream; sends `GET {path} HTTP/1.1` with connect+read timeouts, tracks consecutive pass/fail, writes updated live-backend list via `RwLock`.
- `builder.rs` — `build_from_file()` / `build(config)`: creates upstream live-backend pools, starts health-checker threads, compiles routes (applies middleware stack via `apply_middleware()`), spawns L4/WS proxy threads. `ArcApp` adapter wraps `Arc<dyn Application>` so `WithMiddleware::new()` can take ownership.
- `main()` (all three feature variants) checks `ProxyConfig::is_proxy_mode()` before calling `build_app()`; if true, calls `build_from_file()` and passes the resulting `ConfigDrivenApp` to `Server::run` / `run_tls` / `run_quic`.

### Rewrite middleware

- `src/rewrite/mod.rs` — `RewriteLayer` implements `Middleware`; clones `Request` and applies `RequestRule` variants (header set/remove, URI set/strip-prefix/add-prefix) before dispatch, then applies `ResponseRule` variants (header set/remove, status override, body byte find-and-replace) on the way back. Private `replace_bytes()` does linear non-overlapping scan.

### Authentication middleware (`auth` feature)

`src/auth/mod.rs` — gated behind the `auth` Cargo feature (adds `hmac` + `sha2`, RustCrypto). All base64/JWT logic is hand-rolled — no third-party JWT or base64 crate.

- `BasicAuthLayer<F>` — validates `Authorization: Basic` against a `Fn(&str, &str) -> bool` closure; `BasicAuthLayer::from_htpasswd_file(path)` loads an htpasswd-style file once at construction (supports plain-text and rws's own `{SHA256}` scheme — not Apache's real `{SHA}`/`$apr1$`/bcrypt hashes). Issues `401` with a `WWW-Authenticate` challenge when the header is missing/malformed, `401` without a challenge when validation fails.
- `JwtLayer` — verifies `Authorization: Bearer <jwt>` signed HS256; rejects expired (`exp`) tokens. `build_jwt(claims_json, secret)` and `verify_jwt(token, secret) -> Option<Claims>` are public helpers for issuing/inspecting tokens outside the middleware (e.g. a login handler). `extract_bearer_token(request)` pulls the raw token string.
- `src/auth/forward.rs` — `ForwardAuthLayer` (Traefik/nginx-style forward-auth): forwards every request header as a `GET` to an external auth URL via `http_client::Client`. 2xx → request proceeds, with any `.copy_header(name)`-listed response header replacing the same-named request header (e.g. an auth service resolving a cookie to `X-User-Id`). Any other status → the auth service's response (status/headers/body) is passed through to the client verbatim, preserving `WWW-Authenticate`/`Location`/custom bodies. Unreachable auth service → `502 Bad Gateway` (fails closed). `.timeout_ms()` defaults to 5000ms.

### Request timeouts

`src/timeout/mod.rs` — per-route timeouts layered on top of the single global 30s connection read timeout. Rust cannot forcibly preempt a running synchronous handler, so the sync-side helpers (`with_timeout`, `with_timeout_state` for `Router`/`AppWithState` handlers, and `TimeoutLayer` for wrapping a whole `Application`) run the wrapped work on a background thread and bound how long they *wait*: past the deadline the caller gets `504 Gateway Timeout` immediately, but the spawned thread keeps running to completion with its result discarded. Only `with_timeout_async` (requires `http2`, for `AsyncAppWithState`) achieves genuine cancellation — dropping the `Future` via `tokio::time::timeout` actually stops it at its next `.await`.

### Request ID / correlation ID middleware

`src/request_id/mod.rs` — `RequestIdLayer` ensures every request/response pair carries a stable ID in a header (default `X-Request-Id`, override via `.header(name)`). If the incoming request already has the header (set by an upstream gateway), that value is echoed back unchanged so one ID follows a request across hops; otherwise `generate_request_id()` mints a UUID-v4-*shaped* (not spec-compliant, not cryptographically random) ID from a monotonic counter + timestamp splitmix64 finalizer. Works without OpenTelemetry configured, unlike `otel`'s span-based tracing.

### OpenAPI generation (`openapi` feature)

`src/openapi/mod.rs` — generates a minimal OpenAPI 3.0.3 JSON document directly from `Router::route_entries()` (paths, methods, path parameters only — no request/response body schemas, since Rust has no runtime type reflection). `build_spec(&OpenApiConfig, &[RouteInfo])` merges routes sharing a path into one `paths` entry and converts `:name`/`*name` segments to `{name}`. `swagger_ui_html(spec_url)` returns a self-contained Swagger UI page loading `swagger-ui-dist` from a CDN. Wired in via `AppWithState`/`AsyncAppWithState::openapi(OpenApiConfig::new(title, version))`, which registers `GET /openapi.json` and `GET /docs`.

### Model layer

`src/model/` — JPA/Hibernate-style ORM. Enabled by feature flags `model-sqlite`, `model-postgres`, or `model-mysql` (one per compilation unit). No third-party ORM dependencies.

- `mod.rs` — `Value` enum (backend-independent SQL value: `Null`, `Bool`, `Int`, `Float`, `Text`, `Bytes`); `ModelRow` (named column bag with typed `get::<T>(col)`); `Model` trait (generated by `#[derive(Model)]`); `FromColumn` / `ToColumn` impls for `i16`, `i32`, `i64`, `u32`, `u64`, `f32`, `f64`, `bool`, `String`, `Option<T>`, `&str`.
- `connection.rs` — `DbConfig` (host, port, user, password, database, pool_size; `from_env()` reads `RWS_DB_*` vars); `DbConnection` with cfg-gated backend field (`rusqlite::Connection` / `postgres::Client` / `mysql::Conn`). Methods: `execute`, `query_rows`, `begin`, `commit`, `rollback`, `transaction(closure)`, `query::<T>`, `query_raw`, `migrate`, `migration_status`.
- `pool.rs` — `DbPool` (pre-creates `pool_size` connections; thread-safe `Mutex<Vec<DbConnection>>`); `PooledConnection<'_>` (checked out connection; `Deref`/`DerefMut` to `DbConnection`; returned to pool on `Drop`).
- `repository.rs` — `Repository<T, ID>` trait; `ModelRepository<'a, T, ID>` impl for `Repository<T, i64>`. `save` chooses INSERT (pk==0 or auto-increment) vs UPDATE. SQLite uses `last_insert_rowid()`; PostgreSQL uses `RETURNING id`; MySQL uses `last_insert_id()`.
- `query.rs` — `QueryBuilder<'a, T>` with free-function SQL builders to avoid borrow conflicts. `where_eq` injects `__placeholder__` tokens replaced with `?` (SQLite/MySQL) or `$N` (PostgreSQL). `delete`, `update`, `fetch_all`, `fetch_one`, `count`.
- `migration.rs` — reads `*.sql` from a directory in lexicographic order; creates `_schema_migrations(version TEXT PRIMARY KEY, applied_at TEXT)` if absent; wraps each unapplied file in a `BEGIN`/`COMMIT` transaction.
- `relation.rs` — `HasMany<T>`, `HasOne<O>`, `BelongsTo<O>` — explicit-load helpers with a `.load(&mut conn)` method. No lazy loading, no hidden N+1 queries.
- `tests.rs` — `#[cfg(all(test, feature = "model-sqlite"))]` integration tests using SQLite `:memory:`. 14 tests covering all 8 phases.

`rws-macros/src/lib.rs` — `#[proc_macro_derive(Model, attributes(table, column, primary_key))]` generates `impl Model for Struct` plus `Struct::repository(&mut conn)` and `Struct::query(&mut conn)` helpers. Attributes: `#[table(name = "…")]` (struct-level); `#[primary_key]` / `#[primary_key(auto_increment)]`; `#[column(name = "…")]`; `#[ignore]` (uses `Default::default()` in `from_row`, excluded from `to_values`).

### HTTP client

`src/http_client/mod.rs` — synchronous outbound HTTP/1.1 client. Always compiled in; no feature flag needed for plain HTTP. HTTPS requires `any(feature = "http-client", feature = "http2")`.

- `Client` — builder-pattern client; `Client::new()` sets `timeout_ms: 30_000` and `max_redirects: 10`. Convenience methods `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()`, `.request(method, url)` each return a `RequestBuilder`.
- `RequestBuilder` — `.header(k,v)`, `.body(bytes)`, `.body_text(s)` (sets `Content-Type: text/plain`), `.body_json(s)` (sets `Content-Type: application/json`), `.form(&[(&str,&str)])` (percent-encodes and joins pairs, sets `Content-Type: application/x-www-form-urlencoded` — the body shape OAuth 2.0 token endpoints require; used by `sso::client::OidcClient::exchange_code`), `.timeout_ms(ms)`, `.send() -> Result<Response, HttpClientError>`. Follows redirects automatically, downgrading to GET on 301/302/303, preserving method on 307/308.
- `Response` — `.status()`, `.is_success()` (200–299), `.is_redirect()` (301/302/303/307/308), `.header(name)` (case-insensitive lookup), `.bytes()`, `.text()`, `.json::<T>()` (requires `serde` feature).
- `HttpClientError(String)` — implements `std::error::Error` and `Display`.
- `ParsedUrl` (private) — splits `scheme://host[:port]/path[?query]` without external URL crate.
- `tls_connect()` (private, `#[cfg(any(feature = "http-client", feature = "http2"))]`) — wraps a `TcpStream` in `rustls::StreamOwned<ClientConnection, TcpStream>` using `webpki_roots` CA store.
- `AsyncClient` / `AsyncRequestBuilder` (gated on `#[cfg(feature = "http2")]`) — same API including `.form()`, with `async fn send()`, backed by `tokio::net::TcpStream` and `tokio_rustls::TlsConnector`. Timeouts via `tokio::time::timeout`.
- Body reading strategy: chunked (`Transfer-Encoding: chunked`) → decode via `decode_chunked()`; content-length → read exactly N bytes; otherwise read until EOF.

### SSO (OAuth 2.0 / OIDC client, `sso` feature; Authorization Server, `sso-server` feature; SAML 2.0 SP, `sso-saml` feature)

`src/sso/` — OAuth 2.0 / OIDC client support (`rws` as the relying party, e.g. "Login with Google"). Implies `http-client` for TLS calls to the IdP. All JSON parsing and JWT verification is hand-rolled, matching the crate's no-third-party-HTTP-parsing philosophy elsewhere — the only new deps are `rsa`/`p256` (asymmetric signature verification), `sha2` (hashing), and `rand_core` (PKCE verifier / RSA keygen randomness).

- `jwks::JwksCache` — thread-safe cache of public keys from a JWKS endpoint (`Mutex<Vec<JwkEntry>>`, private). `.fetch()` downloads and replaces the cache via `crate::http_client::Client`; `.verify_jwt(token, &VerifyOptions) -> Result<OidcClaims, SsoError>` lazy-loads keys on first call (empty cache), verifies the RS256/ES256 signature via `try_verify` (matches `kid` from the JWT header against cached keys, falling back to trying every cached key if the JWT has no `kid`), then validates `exp`/`iat`/`iss`/`aud`. **Key rotation is reactive, not scheduled** — there's no background refresh timer (the original design sketch's `.refresh_interval_secs()` doesn't exist); instead, a failed `try_verify` triggers exactly one `fetch()` retry before giving up, so a token signed with a key rotated in after the last successful verification still verifies correctly on the very next call. ES256 signatures are the raw 64-byte `r||s` form (not DER) per the JWS spec, reconstructed via `p256::EncodedPoint::from_affine_coordinates`; RS256 uses `rsa::pkcs1v15::VerifyingKey<Sha256>`.
- `jwks::OidcClaims` — the standard OIDC claims (`sub`, `iss`, `aud: Vec<String>`, `exp`, `iat`, `nonce`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `locale`). No `groups`/`extra` fields — this module models only spec-standard claims, not IdP-specific extensions.
- `jwks::VerifyOptions` — `audience`, `issuer`, `leeway_secs` (clock-skew tolerance applied to both `exp` and `iat` checks).
- `discovery::OidcProvider` — endpoint URLs (`authorization_endpoint`, `token_endpoint`, `jwks_uri`, `userinfo_endpoint`, `end_session_endpoint`). `::discover(issuer)` fetches `{issuer}/.well-known/openid-configuration`; hardcoded presets `::google()`, `::microsoft(tenant_id)`, `::github()` (OAuth-only, empty `jwks_uri` — no id_token), `::okta(domain)`, `::auth0(domain)`, `::keycloak(base_url, realm)`.
- `config::OidcConfig` — bundles `provider`, `client_id`, `client_secret`, `redirect_uri`, `scopes`, `post_login_redirect`. Presets mirror `OidcProvider`'s; `::from_env()` reads `RWS_OIDC_*` vars (see `docs/src/content/docs/features/sso.md`).
- `client::OidcClient` — `.authorization_url(pkce, state, nonce)` builds the redirect URL (adds PKCE `code_challenge` only when `provider.jwks_uri` is non-empty, since GitHub's token endpoint rejects PKCE params); `.exchange_code(code, pkce_verifier)` POSTs to `token_endpoint` via `Client::form()` and returns `TokenResponse`; `.fetch_user_info(access_token)` covers GitHub-style providers with no `id_token`.
- `pkce::PkceVerifier` / `PkceChallenge` — `code_verifier` (random 43-char base64url) and `code_challenge = BASE64URL(SHA256(verifier))` (S256 only, per RFC 7636).
- `oidc_auth::OidcAuth` — `Middleware` impl: no session → redirect to `/auth/login`; intercepts `/auth/login` (redirect to IdP), `/auth/callback` (validate `state`, exchange code, verify `id_token` via `JwksCache`, verify `nonce`, store claims in session), `/auth/logout`. `.exclude(path)` bypasses auth for a path prefix. Claims are injected into the `X-Rws-Oidc-Claims` request header (JSON) for downstream handlers; `OidcAuth::claims(request)`/`::sub()`/`::email()` are the read-side helpers.

`server::AuthServer` / `client_store::ClientStore` (`sso-server` feature, implies `sso` + `auth`) — the reverse role: `rws` as its own OAuth 2.0 Authorization Server rather than a client of one. A `Middleware` intercepting `POST /oauth/token` (`client_credentials`, `authorization_code`+PKCE, `refresh_token` grants), `GET /oauth/authorize`, `GET /.well-known/openid-configuration`, `GET /.well-known/jwks.json`. Two deliberate deviations from the original design sketch, both documented at length in `src/sso/server.rs`'s module docs: (1) tokens are signed HS256 via the existing `auth::build_jwt`/`auth::verify_jwt` (not an RSA/EC key loaded from PEM — this crate has no private-key PEM/DER parser), so `/.well-known/jwks.json` always returns `{"keys":[]}` (no public key exists for a symmetric algorithm; resource servers must share `signing_secret` directly); (2) `/oauth/authorize` has no built-in login page — it reads a configurable session key (default `"user_id"`, override via `.subject_session_key()`) from an existing app session via `crate::session::SessionStore` to identify the resource owner, minting a code immediately if present or redirecting to a configurable `login_url` (default `/login`) with `?return_to=...` if absent; building that login page is the embedding application's responsibility. Authorization codes and refresh tokens are opaque random strings held in-memory (`Mutex<HashMap<String, _>>`), not JWTs themselves — codes are single-use (removed from the map on exchange) and short-lived (60s). `client_store::ClientStore` (`.new().add(OAuthClient{...}).get(client_id)`) has no `::from_env()`/`::from_db()` — a list of clients with per-client secrets has no natural flat-env-var encoding, unlike `OidcConfig`'s one-client-per-process shape.

`saml::SamlSp` (`sso-saml` feature, implies `sso`) — SAML 2.0 Service Provider, the SAML counterpart to `OidcAuth`. A `Middleware` intercepting `GET /saml/metadata`, `GET /saml/login` (renders an auto-submitting HTML form POSTing an unsigned `AuthnRequest` — HTTP-POST binding, not HTTP-Redirect, since this crate has no DEFLATE dependency and requests are never signed since this crate has no private-key PEM parser, the same reason `AuthServer` uses HS256), `POST /saml/acs`, `GET /saml/logout` (local session destruction only, no IdP `LogoutRequest`, mirroring `OidcAuth`'s own logout). Four submodules: `saml/xml.rs` — a small hand-rolled XML parser (not `quick-xml`, deliberately: this crate hand-rolls every text format it parses; the parser rejects any `<!DOCTYPE>` outright, eliminating XXE by never implementing entity resolution) tracking each `XmlNode`'s exact byte range in the source document; `saml/der.rs` — a minimal DER/ASN.1 TLV walker that locates an X.509 certificate's `subjectPublicKeyInfo` field (skipping `tbsCertificate`'s preceding fields by length) and hands the slice to `rsa::pkcs8::DecodePublicKey` — not a general X.509 parser; `saml/metadata.rs` — `SamlIdpMetadata::from_file`/`::from_url`/`::from_str`, preferring an `HTTP-POST`-binding `SingleSignOnService` entry; `saml/assertion.rs` — signature verification and claim extraction.

**Signature verification is byte-exact against the transmitted document, not full XML C14N** — `parse_and_verify` computes the SHA-256 digest over the `Assertion`'s own raw byte range with the `Signature` element's raw byte range excised (the enveloped-signature transform, applied to literal bytes rather than a canonicalized form), and verifies `SignatureValue` (RSA-SHA256 only) over `SignedInfo`'s raw byte range. This is a deliberate scope decision (documented in `assertion.rs`'s module docs), not an oversight: it fails closed, not open — an IdP that reformats XML between signing and transmission would have legitimate logins rejected, never would a forged assertion be accepted. **XSW (XML Signature Wrapping) mitigation is structural**: `parse_and_verify` requires exactly one `Assertion` in the response (rejecting zero or more than one), requires `Signature` to be a direct child of that same `Assertion`, requires `Reference/@URI` to point at that assertion's own `ID`, and reads every claim from that same parsed node — never a second search pass an attacker could point elsewhere. `EncryptedAssertion` is rejected outright (no XML decryption implemented).

`saml::AttributeMap` (`.new().map(saml_attribute_name, field)`) translates raw SAML attribute names into `saml::SamlClaims { name_id: String, attributes: HashMap<String, String> }` — **not** `jwks::OidcClaims`, since `OidcClaims` was deliberately built without a `groups` field (see above) and this feature's own design sketch maps exactly a `groups` attribute; reusing `OidcClaims` would silently drop it. `SamlSp::claims(request)`/`::name_id()`/`::attr(request, field)` read the `X-Rws-Saml-Claims` header the same way `OidcAuth`'s equivalents read `X-Rws-Oidc-Claims`.

`sso-saml` adds no new Cargo dependency beyond what `sso` already pulls in — the spec's own suggestion of a `quick-xml` dependency was not taken (see above).