From ca8ad308ee2ef34bae6f96b1705f977142505535 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 1/7] feat(hyperlane)!: add runtime TLS, HTTP/2 and HTTP/3 Select server protocols at runtime with independent TLS configuration. Keep HTTP/1 without TLS as the default and reject HTTP/3 without TLS. - Reuse routing and middleware through streaming protocol adapters. - Own listener state and preserve bounded TLS shutdown and upgrades. - Add real-network framing, flow-control and lifecycle regressions. - Clear historical accessor-contract debt without weakening the audit. - Bump the published workspace from 21.13.4 to 21.13.5. BREAKING CHANGE: Stream stores a boxed Transport rather than TcpStream. WebSocketFrame fin/mask and Proxy port getters now return values instead of references. Downstream TCP-specific access and dereferences must be updated. Public accessor fields also gain direct field access. --- Cargo.toml | 42 +- README.md | 111 ++++ core/Cargo.toml | 14 +- core/README.md | 111 ++++ core/src/config/const.rs | 8 + core/src/config/enum.rs | 18 + core/src/config/impl.rs | 56 ++ core/src/config/mod.rs | 6 +- core/src/config/struct.rs | 38 +- core/src/context/struct.rs | 8 +- core/src/error/enum.rs | 4 + core/src/lib.rs | 26 +- core/src/listener/const.rs | 37 ++ core/src/listener/fn.rs | 481 ++++++++++++++ core/src/listener/mod.rs | 6 + core/src/protocol/aliases/mod.rs | 5 + core/src/protocol/aliases/type.rs | 47 ++ core/src/protocol/constants/const.rs | 57 ++ core/src/protocol/constants/mod.rs | 3 + core/src/protocol/framing/mod.rs | 5 + core/src/protocol/framing/trait.rs | 76 +++ core/src/protocol/http2/fn.rs | 261 ++++++++ core/src/protocol/http2/impl.rs | 161 +++++ core/src/protocol/http2/mod.rs | 7 + core/src/protocol/http2/struct.rs | 25 + core/src/protocol/http3/fn.rs | 259 ++++++++ core/src/protocol/http3/impl.rs | 144 +++++ core/src/protocol/http3/mod.rs | 7 + core/src/protocol/http3/struct.rs | 20 + core/src/protocol/lifecycle/impl.rs | 28 + core/src/protocol/lifecycle/mod.rs | 6 + core/src/protocol/lifecycle/struct.rs | 26 + core/src/protocol/mod.rs | 17 + core/src/protocol/pump/fn.rs | 194 ++++++ core/src/protocol/pump/mod.rs | 6 + core/src/protocol/pump/struct.rs | 10 + core/src/protocol/request/fn.rs | 391 ++++++++++++ core/src/protocol/request/mod.rs | 5 + core/src/protocol/response/fn.rs | 236 +++++++ core/src/protocol/response/mod.rs | 5 + core/src/protocol/transport/fn.rs | 28 + core/src/protocol/transport/impl.rs | 248 ++++++++ core/src/protocol/transport/mod.rs | 7 + core/src/protocol/transport/struct.rs | 49 ++ core/src/route/struct.rs | 8 +- core/src/server/impl.rs | 273 ++++---- core/src/server/struct.rs | 14 +- core/src/tls/fn.rs | 101 +++ core/src/tls/mod.rs | 5 + core/tests/fixtures/tls/ca.pem | 20 + .../fixtures/tls/invalid-certificate.pem | 1 + .../fixtures/tls/invalid-private-key.pem | 3 + core/tests/fixtures/tls/leaf-chain.pem | 40 ++ core/tests/fixtures/tls/leaf.key | 28 + core/tests/fixtures/tls/leaf.pem | 20 + .../fixtures/tls/malformed-certificate.pem | 3 + core/tests/fixtures/tls/mismatched-leaf.key | 28 + .../fixtures/tls/truncated-certificate.pem | 3 + core/tests/fixtures/tls/untrusted-ca.pem | 20 + .../fixtures/tls/untrusted-leaf-chain.pem | 40 ++ core/tests/fixtures/tls/untrusted-leaf.pem | 20 + core/tests/mod.rs | 21 +- core/tests/protocol/common/const.rs | 88 +++ core/tests/protocol/common/fn.rs | 263 ++++++++ core/tests/protocol/common/gate.rs | 145 +++++ core/tests/protocol/common/mod.rs | 8 + core/tests/protocol/common/server.rs | 238 +++++++ core/tests/protocol/http1/fn.rs | 419 +++++++++++++ core/tests/protocol/http1/mod.rs | 3 + core/tests/protocol/http2/assert.rs | 386 ++++++++++++ core/tests/protocol/http2/fn.rs | 184 ++++++ core/tests/protocol/http2/mod.rs | 7 + core/tests/protocol/http2/type.rs | 10 + core/tests/protocol/http3/assert.rs | 295 +++++++++ core/tests/protocol/http3/fn.rs | 243 ++++++++ core/tests/protocol/http3/mod.rs | 7 + core/tests/protocol/http3/type.rs | 3 + core/tests/protocol/limits/assert.rs | 588 ++++++++++++++++++ core/tests/protocol/limits/const.rs | 37 ++ core/tests/protocol/limits/fn.rs | 319 ++++++++++ core/tests/protocol/limits/impl.rs | 156 +++++ core/tests/protocol/limits/mod.rs | 9 + core/tests/protocol/limits/struct.rs | 85 +++ core/tests/protocol/middleware/fn.rs | 22 + core/tests/protocol/middleware/impl.rs | 384 ++++++++++++ core/tests/protocol/middleware/mod.rs | 7 + core/tests/protocol/middleware/struct.rs | 33 + core/tests/protocol/mod.rs | 20 + core/tests/protocol/runtime/fn.rs | 103 +++ core/tests/protocol/runtime/mod.rs | 3 + core/tests/protocol/tls/fn.rs | 439 +++++++++++++ core/tests/protocol/tls/mod.rs | 3 + core/tests/protocol/websocket/const.rs | 26 + core/tests/protocol/websocket/fn.rs | 327 ++++++++++ core/tests/protocol/websocket/impl.rs | 54 ++ core/tests/protocol/websocket/mod.rs | 9 + core/tests/protocol/websocket/struct.rs | 3 + core/tests/protocol/wire/assert.rs | 232 +++++++ core/tests/protocol/wire/mod.rs | 3 + plugin/websocket/src/struct.rs | 18 +- request/src/common/struct.rs | 2 +- request/src/request/config/struct.rs | 14 +- request/src/request/http_request/impl.rs | 23 +- request/src/request/proxy/struct.rs | 1 + type/src/cookie/struct.rs | 18 +- type/src/lib.rs | 7 +- type/src/panic/struct.rs | 6 +- type/src/request/struct.rs | 4 +- type/src/response/struct.rs | 4 +- type/src/stream/impl.rs | 118 +++- type/src/stream/mod.rs | 3 +- type/src/stream/struct.rs | 35 +- type/src/stream/trait.rs | 29 + type/src/websocket_frame/impl.rs | 2 +- type/src/websocket_frame/struct.rs | 2 + type/tests/mod.rs | 7 + type/tests/stream/fn.rs | 123 ++++ type/tests/stream/mod.rs | 3 + type/tests/stream/struct.rs | 55 ++ 119 files changed, 9357 insertions(+), 225 deletions(-) create mode 100644 core/src/config/const.rs create mode 100644 core/src/config/enum.rs create mode 100644 core/src/listener/const.rs create mode 100644 core/src/listener/fn.rs create mode 100644 core/src/listener/mod.rs create mode 100644 core/src/protocol/aliases/mod.rs create mode 100644 core/src/protocol/aliases/type.rs create mode 100644 core/src/protocol/constants/const.rs create mode 100644 core/src/protocol/constants/mod.rs create mode 100644 core/src/protocol/framing/mod.rs create mode 100644 core/src/protocol/framing/trait.rs create mode 100644 core/src/protocol/http2/fn.rs create mode 100644 core/src/protocol/http2/impl.rs create mode 100644 core/src/protocol/http2/mod.rs create mode 100644 core/src/protocol/http2/struct.rs create mode 100644 core/src/protocol/http3/fn.rs create mode 100644 core/src/protocol/http3/impl.rs create mode 100644 core/src/protocol/http3/mod.rs create mode 100644 core/src/protocol/http3/struct.rs create mode 100644 core/src/protocol/lifecycle/impl.rs create mode 100644 core/src/protocol/lifecycle/mod.rs create mode 100644 core/src/protocol/lifecycle/struct.rs create mode 100644 core/src/protocol/mod.rs create mode 100644 core/src/protocol/pump/fn.rs create mode 100644 core/src/protocol/pump/mod.rs create mode 100644 core/src/protocol/pump/struct.rs create mode 100644 core/src/protocol/request/fn.rs create mode 100644 core/src/protocol/request/mod.rs create mode 100644 core/src/protocol/response/fn.rs create mode 100644 core/src/protocol/response/mod.rs create mode 100644 core/src/protocol/transport/fn.rs create mode 100644 core/src/protocol/transport/impl.rs create mode 100644 core/src/protocol/transport/mod.rs create mode 100644 core/src/protocol/transport/struct.rs create mode 100644 core/src/tls/fn.rs create mode 100644 core/src/tls/mod.rs create mode 100644 core/tests/fixtures/tls/ca.pem create mode 100644 core/tests/fixtures/tls/invalid-certificate.pem create mode 100644 core/tests/fixtures/tls/invalid-private-key.pem create mode 100644 core/tests/fixtures/tls/leaf-chain.pem create mode 100644 core/tests/fixtures/tls/leaf.key create mode 100644 core/tests/fixtures/tls/leaf.pem create mode 100644 core/tests/fixtures/tls/malformed-certificate.pem create mode 100644 core/tests/fixtures/tls/mismatched-leaf.key create mode 100644 core/tests/fixtures/tls/truncated-certificate.pem create mode 100644 core/tests/fixtures/tls/untrusted-ca.pem create mode 100644 core/tests/fixtures/tls/untrusted-leaf-chain.pem create mode 100644 core/tests/fixtures/tls/untrusted-leaf.pem create mode 100644 core/tests/protocol/common/const.rs create mode 100644 core/tests/protocol/common/fn.rs create mode 100644 core/tests/protocol/common/gate.rs create mode 100644 core/tests/protocol/common/mod.rs create mode 100644 core/tests/protocol/common/server.rs create mode 100644 core/tests/protocol/http1/fn.rs create mode 100644 core/tests/protocol/http1/mod.rs create mode 100644 core/tests/protocol/http2/assert.rs create mode 100644 core/tests/protocol/http2/fn.rs create mode 100644 core/tests/protocol/http2/mod.rs create mode 100644 core/tests/protocol/http2/type.rs create mode 100644 core/tests/protocol/http3/assert.rs create mode 100644 core/tests/protocol/http3/fn.rs create mode 100644 core/tests/protocol/http3/mod.rs create mode 100644 core/tests/protocol/http3/type.rs create mode 100644 core/tests/protocol/limits/assert.rs create mode 100644 core/tests/protocol/limits/const.rs create mode 100644 core/tests/protocol/limits/fn.rs create mode 100644 core/tests/protocol/limits/impl.rs create mode 100644 core/tests/protocol/limits/mod.rs create mode 100644 core/tests/protocol/limits/struct.rs create mode 100644 core/tests/protocol/middleware/fn.rs create mode 100644 core/tests/protocol/middleware/impl.rs create mode 100644 core/tests/protocol/middleware/mod.rs create mode 100644 core/tests/protocol/middleware/struct.rs create mode 100644 core/tests/protocol/mod.rs create mode 100644 core/tests/protocol/runtime/fn.rs create mode 100644 core/tests/protocol/runtime/mod.rs create mode 100644 core/tests/protocol/tls/fn.rs create mode 100644 core/tests/protocol/tls/mod.rs create mode 100644 core/tests/protocol/websocket/const.rs create mode 100644 core/tests/protocol/websocket/fn.rs create mode 100644 core/tests/protocol/websocket/impl.rs create mode 100644 core/tests/protocol/websocket/mod.rs create mode 100644 core/tests/protocol/websocket/struct.rs create mode 100644 core/tests/protocol/wire/assert.rs create mode 100644 core/tests/protocol/wire/mod.rs create mode 100644 type/src/stream/trait.rs create mode 100644 type/tests/stream/struct.rs diff --git a/Cargo.toml b/Cargo.toml index 3bbb276b..65fc9c07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "21.13.4" +version = "21.13.5" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -34,20 +34,24 @@ members = [ ] [workspace.dependencies] -hyperlane = { path = ".", version = "21.13.4" } -http-type = { path = "type", version = "21.13.4" } -hyperlane-cli = { path = "cli", version = "21.13.4" } -hyperlane-core = { path = "core", version = "21.13.4" } -http-request = { path = "request", version = "21.13.4" } -http-compress = { path = "compress", version = "21.13.4" } -http-constant = { path = "constant", version = "21.13.4" } -hyperlane-macros = { path = "macros", version = "21.13.4" } -hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.4" } +hyperlane = { path = ".", version = "21.13.5" } +http-type = { path = "type", version = "21.13.5" } +hyperlane-cli = { path = "cli", version = "21.13.5" } +hyperlane-core = { path = "core", version = "21.13.5" } +http-request = { path = "request", version = "21.13.5" } +http-compress = { path = "compress", version = "21.13.5" } +http-constant = { path = "constant", version = "21.13.5" } +hyperlane-macros = { path = "macros", version = "21.13.5" } +hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.5" } +h3 = "0.0.8" +h2 = "0.4.20" hex = "0.4.3" md5 = "0.8.1" url = "2.5.8" +http = "1.5.0" log = "0.4.34" +futures = "0.3" rand = "0.10.2" redis = "1.4.1" rinja = "0.3.5" @@ -56,6 +60,7 @@ snafu = "0.9.2" which = "8.0.5" ahash = "0.8.12" brotli = "9.0.0" +bytes = "1.12.1" quote = "1.0.47" regex = "1.13.1" base64 = "0.23.1" @@ -66,6 +71,7 @@ lettre = "0.11.22" scraper = "0.27.0" sysinfo = "0.39.6" chunkify = "10.2.6" +h3-quinn = "0.0.10" num_cpus = "1.17.0" twox-hash = "2.1.4" future-fn = "10.2.6" @@ -93,7 +99,6 @@ compare_version = "2.0.14" serde_urlencoded = "0.7.1" tokio-broadcast = "10.2.6" instrument-level = "10.2.6" -rustls-pki-types = "1.15.1" bin-encode-decode = "1.1.51" recoverable-spawn = "3.9.30" hyperlane-broadcast = "2.0.13" @@ -106,6 +111,7 @@ serde = { version = "1.0.229", features = ["derive"] } getrandom = { version = "0.4.3", features = ["sys_rng"] } rust_decimal = { version = "1.42.1", features = ["serde"] } cipher = { version = "0.5.2", features = ["block-padding"] } +rustls-pki-types = { version = "1.15.1", features = ["std"] } ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } utoipa-rapidoc = { version = "7.0.0", features = ["actix-web"] } jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } @@ -115,10 +121,6 @@ sqlx = { version = "0.9.0", features = ["mysql", "postgres", "runtime-tokio"] } notify = { version = "8.2.0", default-features = false, features = [ "macos_fsevent", ] } -rustls = { version = "0.23.45", default-features = false, features = [ - "ring", - "tls12", -] } tokio-rustls = { version = "0.26.6", default-features = false, features = [ "ring", "tls12", @@ -128,6 +130,16 @@ utoipa-swagger-ui = { version = "9.0.2", features = [ "actix-web", "debug-embed", ] } +rustls = { version = "0.23.45", default-features = false, features = [ + "std", + "ring", + "tls12", +] } +quinn = { version = "0.11.12", default-features = false, features = [ + "log", + "rustls-ring", + "runtime-tokio", +] } reqwest = { version = "0.13.4", features = [ "gzip", "json", diff --git a/README.md b/README.md index 037ce235..e038e306 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,117 @@ cargo add hyperlane git clone https://github.com/hyperlane-dev/hyperlane-quick-start.git ``` +## Runtime protocol and TLS + +The listening protocol and the TLS material are two independent settings. A server +speaks exactly one wire protocol, selected at runtime from the configuration; TLS is +terminated on top of it, or left off for plaintext. There is no automatic protocol +detection, no multi-protocol listener and no fallback between them: an `http1` +listener never answers HTTP/2, and an `http2` listener never answers HTTP/1.1. + +```rust +use hyperlane::{ + Server, ServerConfig, ServerControlHook, ServerError, ServerProtocol, TlsConfig, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut config = ServerConfig::new("127.0.0.1:8443".to_string(), None, None); + config.set_protocol(ServerProtocol::Http2).set_tls(Some(TlsConfig::new( + "/etc/hyperlane/cert-chain.pem".to_string(), + "/etc/hyperlane/privkey.pem".to_string(), + ))); + + let mut server = Server::default(); + server.server_config(config); + let control: ServerControlHook = server.run().await?; + control.wait().await; + Ok(()) +} +``` + +`ServerConfig::new` keeps its three argument signature and always yields plaintext +HTTP/1; choose a different protocol with `set_protocol`. Omitting both `protocol` and +`tls` from a JSON configuration therefore keeps the historical behaviour. The example +above only shows configuration and startup: register routes on `server` before +`run()` if the server is meant to answer anything. + +```rust +use hyperlane::{ServerConfig, ServerProtocol, TlsConfig}; + +fn main() { + // A config with neither field keeps the historical behaviour. + let config: ServerConfig = ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\"}") + .expect("a config without protocol or tls must still load"); + assert_eq!(config.get_protocol(), ServerProtocol::Http1); + assert!(config.get_tls().is_none(), "an omitted tls field is plaintext"); + assert_eq!(config.get_alpn_protocol(), b"http/1.1" as &[u8]); + + // The three argument constructor cannot select a protocol or tls. + let legacy: ServerConfig = ServerConfig::new("127.0.0.1:8080".to_string(), None, None); + assert_eq!(legacy.get_protocol(), ServerProtocol::Http1); + assert!(legacy.get_tls().is_none()); + + // The two settings are independent, in both directions. + let mut selected: ServerConfig = ServerConfig::default(); + selected.set_protocol(ServerProtocol::Http2); + assert!(selected.get_tls().is_none(), "choosing a protocol does not enable tls"); + selected.set_tls(Some(TlsConfig::new("chain.pem".to_string(), "key.pem".to_string()))); + assert_eq!(selected.get_protocol(), ServerProtocol::Http2); + assert_eq!(selected.get_alpn_protocol(), b"h2" as &[u8]); + selected.set_tls(None); + assert_eq!(selected.get_protocol(), ServerProtocol::Http2, "clearing tls keeps the protocol"); + assert!(selected.get_tls().is_none()); + + // An unknown protocol name is an error, not a silent fallback to http1. + assert!(ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\",\"protocol\":\"http4\"}").is_err()); +} +``` + +### Protocol and TLS matrix + +| `ServerProtocol` | JSON name | Transport | Without `tls` | With `tls` | +| --- | --- | --- | --- | --- | +| `Http1` (default) | `http1` | TCP | HTTP/1.1 over a plain socket, no ALPN | HTTP/1.1 over TLS, ALPN `http/1.1`, TLS 1.2 or 1.3 | +| `Http2` | `http2` | TCP | HTTP/2 with prior knowledge, no ALPN and no TLS | HTTP/2 over TLS, ALPN `h2`, TLS 1.2 or 1.3 | +| `Http3` | `http3` | UDP (QUIC) | refused: `run()` fails with `MissingTlsForHttp3` and the UDP port stays unbound | HTTP/3 over QUIC, ALPN `h3`, TLS 1.3 only | + +Notes that the table does not make obvious: + +- `ServerProtocol` is a unit-only enum chosen at runtime, not by a compile-time + feature: the crates declare no `[features]` table, so one binary carries all three + protocols and the configuration decides which one a given listener speaks. +- HTTP/1 and HTTP/2 listen on TCP; HTTP/3 binds a UDP socket. A firewall rule for HTTP/3 + is a UDP rule, and a port opened for TCP does not serve HTTP/3. +- HTTP/3 carries TLS inside the QUIC handshake, so `tls` is mandatory for `Http3`. + QUIC admits TLS 1.3 only, so `http3` over TLS never negotiates 1.2; `http1` and + `http2` over TLS accept either. +- The PEM files are read when the listener is created, not when the configuration is + built, so a configuration can be deserialised, cloned and compared without touching the + disk. An unreadable, malformed, truncated, or mismatched certificate chain and key is + reported by `run()` before the socket is bound. +- An unknown `protocol` value in JSON is an error. It does not resolve to `http1`. + +### Negotiating with a server + +Exactly one ALPN identifier is advertised, the one belonging to the selected protocol. +A client that offers no ALPN the server accepts is refused rather than downgraded. An +HTTP/2 client needs prior knowledge to talk to a plaintext listener, and an HTTP/3 +client must speak QUIC, since an HTTP/3 listener never accepts a TCP connection. + +`http1` over TLS still serves a client that negotiates no ALPN at all, because clients +predating ALPN send none and must keep working. `http2` and `http3` require the +negotiated identifier to be their own, and each is refused differently. An `http2` +session that completes the handshake without `h2` is dropped by the listener, because +answering an HTTP/2 request under HTTP/1.1 rules would be worse than refusing the +connection. An `http3` client that does not offer `h3` fails the QUIC handshake itself +(RFC 9001 requires the `no_application_protocol` error), before any request is read. + +WebSocket support is unchanged and remains HTTP/1 only: a route upgrades over HTTP/1. +The HTTP/2 extended CONNECT and HTTP/3 CONNECT handshake required by RFC 8441 and +RFC 9220 are not implemented, so do not point a WebSocket client at an `http2` or `http3` +listener. + ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/core/Cargo.toml b/core/Cargo.toml index 116fdf4d..869973ad 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,10 +14,18 @@ exclude.workspace = true [dependencies] http-type = { workspace = true } +h2 = { workspace = true } +h3 = { workspace = true } +http = { workspace = true } +bytes = { workspace = true } +quinn = { workspace = true } regex = { workspace = true } serde = { workspace = true } +tokio = { workspace = true } +rustls = { workspace = true } +futures = { workspace = true } +h3-quinn = { workspace = true } inventory = { workspace = true } +tokio-rustls = { workspace = true } lombok-macros = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } +rustls-pki-types = { workspace = true } diff --git a/core/README.md b/core/README.md index 037ce235..503414ad 100644 --- a/core/README.md +++ b/core/README.md @@ -32,6 +32,117 @@ cargo add hyperlane git clone https://github.com/hyperlane-dev/hyperlane-quick-start.git ``` +## Runtime protocol and TLS + +The listening protocol and the TLS material are two independent settings. A server +speaks exactly one wire protocol, selected at runtime from the configuration; TLS is +terminated on top of it, or left off for plaintext. There is no automatic protocol +detection, no multi-protocol listener and no fallback between them: an `http1` +listener never answers HTTP/2, and an `http2` listener never answers HTTP/1.1. + +```rust +use hyperlane_core::{ + Server, ServerConfig, ServerControlHook, ServerError, ServerProtocol, TlsConfig, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut config = ServerConfig::new("127.0.0.1:8443".to_string(), None, None); + config.set_protocol(ServerProtocol::Http2).set_tls(Some(TlsConfig::new( + "/etc/hyperlane/cert-chain.pem".to_string(), + "/etc/hyperlane/privkey.pem".to_string(), + ))); + + let mut server = Server::default(); + server.server_config(config); + let control: ServerControlHook = server.run().await?; + control.wait().await; + Ok(()) +} +``` + +`ServerConfig::new` keeps its three argument signature and always yields plaintext +HTTP/1; choose a different protocol with `set_protocol`. Omitting both `protocol` and +`tls` from a JSON configuration therefore keeps the historical behaviour. The example +above only shows configuration and startup: register routes on `server` before +`run()` if the server is meant to answer anything. + +```rust +use hyperlane_core::{ServerConfig, ServerProtocol, TlsConfig}; + +fn main() { + // A config with neither field keeps the historical behaviour. + let config: ServerConfig = ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\"}") + .expect("a config without protocol or tls must still load"); + assert_eq!(config.get_protocol(), ServerProtocol::Http1); + assert!(config.get_tls().is_none(), "an omitted tls field is plaintext"); + assert_eq!(config.get_alpn_protocol(), b"http/1.1" as &[u8]); + + // The three argument constructor cannot select a protocol or tls. + let legacy: ServerConfig = ServerConfig::new("127.0.0.1:8080".to_string(), None, None); + assert_eq!(legacy.get_protocol(), ServerProtocol::Http1); + assert!(legacy.get_tls().is_none()); + + // The two settings are independent, in both directions. + let mut selected: ServerConfig = ServerConfig::default(); + selected.set_protocol(ServerProtocol::Http2); + assert!(selected.get_tls().is_none(), "choosing a protocol does not enable tls"); + selected.set_tls(Some(TlsConfig::new("chain.pem".to_string(), "key.pem".to_string()))); + assert_eq!(selected.get_protocol(), ServerProtocol::Http2); + assert_eq!(selected.get_alpn_protocol(), b"h2" as &[u8]); + selected.set_tls(None); + assert_eq!(selected.get_protocol(), ServerProtocol::Http2, "clearing tls keeps the protocol"); + assert!(selected.get_tls().is_none()); + + // An unknown protocol name is an error, not a silent fallback to http1. + assert!(ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\",\"protocol\":\"http4\"}").is_err()); +} +``` + +### Protocol and TLS matrix + +| `ServerProtocol` | JSON name | Transport | Without `tls` | With `tls` | +| --- | --- | --- | --- | --- | +| `Http1` (default) | `http1` | TCP | HTTP/1.1 over a plain socket, no ALPN | HTTP/1.1 over TLS, ALPN `http/1.1`, TLS 1.2 or 1.3 | +| `Http2` | `http2` | TCP | HTTP/2 with prior knowledge, no ALPN and no TLS | HTTP/2 over TLS, ALPN `h2`, TLS 1.2 or 1.3 | +| `Http3` | `http3` | UDP (QUIC) | refused: `run()` fails with `MissingTlsForHttp3` and the UDP port stays unbound | HTTP/3 over QUIC, ALPN `h3`, TLS 1.3 only | + +Notes that the table does not make obvious: + +- `ServerProtocol` is a unit-only enum chosen at runtime, not by a compile-time + feature: the crate declares no `[features]` table, so one binary carries all three + protocols and the configuration decides which one a given listener speaks. +- HTTP/1 and HTTP/2 listen on TCP; HTTP/3 binds a UDP socket. A firewall rule for HTTP/3 + is a UDP rule, and a port opened for TCP does not serve HTTP/3. +- HTTP/3 carries TLS inside the QUIC handshake, so `tls` is mandatory for `Http3`. + QUIC admits TLS 1.3 only, so `http3` over TLS never negotiates 1.2; `http1` and + `http2` over TLS accept either. +- The PEM files are read when the listener is created, not when the configuration is + built, so a configuration can be deserialised, cloned and compared without touching the + disk. An unreadable, malformed, truncated, or mismatched certificate chain and key is + reported by `run()` before the socket is bound. +- An unknown `protocol` value in JSON is an error. It does not resolve to `http1`. + +### Negotiating with a server + +Exactly one ALPN identifier is advertised, the one belonging to the selected protocol. +A client that offers no ALPN the server accepts is refused rather than downgraded. An +HTTP/2 client needs prior knowledge to talk to a plaintext listener, and an HTTP/3 +client must speak QUIC, since an HTTP/3 listener never accepts a TCP connection. + +`http1` over TLS still serves a client that negotiates no ALPN at all, because clients +predating ALPN send none and must keep working. `http2` and `http3` require the +negotiated identifier to be their own, and each is refused differently. An `http2` +session that completes the handshake without `h2` is dropped by the listener, because +answering an HTTP/2 request under HTTP/1.1 rules would be worse than refusing the +connection. An `http3` client that does not offer `h3` fails the QUIC handshake itself +(RFC 9001 requires the `no_application_protocol` error), before any request is read. + +WebSocket support is unchanged and remains HTTP/1 only: a route upgrades over HTTP/1. +The HTTP/2 extended CONNECT and HTTP/3 CONNECT handshake required by RFC 8441 and +RFC 9220 are not implemented, so do not point a WebSocket client at an `http2` or `http3` +listener. + ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/core/src/config/const.rs b/core/src/config/const.rs new file mode 100644 index 00000000..3ba799ef --- /dev/null +++ b/core/src/config/const.rs @@ -0,0 +1,8 @@ +/// ALPN identifier advertised for HTTP/1.1. +pub(crate) const ALPN_HTTP1: &[u8] = b"http/1.1"; + +/// ALPN identifier advertised for HTTP/2 over TLS. +pub(crate) const ALPN_HTTP2: &[u8] = b"h2"; + +/// ALPN identifier advertised for HTTP/3 over QUIC. +pub(crate) const ALPN_HTTP3: &[u8] = b"h3"; diff --git a/core/src/config/enum.rs b/core/src/config/enum.rs new file mode 100644 index 00000000..eb295c06 --- /dev/null +++ b/core/src/config/enum.rs @@ -0,0 +1,18 @@ +use super::*; + +/// The wire protocol a server connection speaks. +/// +/// The variant is selected at runtime from the server configuration, never at +/// compile time: one binary serves every protocol it is configured for. +#[derive(Clone, Copy, Debug, Default, Deserialize, DisplayDebug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerProtocol { + /// HTTP/1.0 and HTTP/1.1 over a plain or TLS terminated byte stream. + #[default] + Http1, + /// HTTP/2, either with prior knowledge over a plain socket or negotiated + /// through `h2` ALPN over TLS. + Http2, + /// HTTP/3 over QUIC, which always terminates TLS inside its own handshake. + Http3, +} diff --git a/core/src/config/impl.rs b/core/src/config/impl.rs index af89ca57..bf4380a4 100644 --- a/core/src/config/impl.rs +++ b/core/src/config/impl.rs @@ -1,5 +1,49 @@ use super::*; +/// Implementation block for ServerProtocol. +impl ServerProtocol { + /// Returns the ALPN protocol identifier used to negotiate this protocol. + /// + /// The value is sent verbatim in the TLS handshake, so it must be the + /// wire identifier and not the human-readable version string. + /// + /// # Returns + /// + /// - `&'static [u8]` - The ALPN protocol identifier bytes. + #[inline(always)] + pub fn get_alpn_protocol(&self) -> &'static [u8] { + match self { + Self::Http1 => ALPN_HTTP1, + Self::Http2 => ALPN_HTTP2, + Self::Http3 => ALPN_HTTP3, + } + } + + /// Returns the HTTP version negotiated by this protocol. + /// + /// # Returns + /// + /// - `HttpVersion` - The protocol's HTTP version. + #[inline(always)] + pub fn get_version(&self) -> HttpVersion { + match self { + Self::Http1 => HttpVersion::Http1_1, + Self::Http2 => HttpVersion::Http2, + Self::Http3 => HttpVersion::Http3, + } + } + + /// Returns `true` when this protocol runs over QUIC rather than TCP. + /// + /// # Returns + /// + /// - `bool` - `true` for QUIC transports, `false` for TCP transports. + #[inline(always)] + pub fn is_quic(&self) -> bool { + matches!(self, Self::Http3) + } +} + /// Provides a default implementation for ServerConfig. impl Default for ServerConfig { /// Creates a new ServerConfig instance with default values. @@ -13,6 +57,8 @@ impl Default for ServerConfig { address: Server::format_bind_address(DEFAULT_HOST, DEFAULT_WEB_PORT), nodelay: DEFAULT_NODELAY, ttl: DEFAULT_TTI, + protocol: ServerProtocol::default(), + tls: None, } } } @@ -34,4 +80,14 @@ impl ServerConfig { { serde_json::from_str(json.as_ref()) } + + /// Returns the negotiated ALPN protocol identifier for this configuration. + /// + /// # Returns + /// + /// - `&'static [u8]` - The ALPN identifier of the configured protocol. + #[inline(always)] + pub fn get_alpn_protocol(&self) -> &'static [u8] { + self.get_protocol().get_alpn_protocol() + } } diff --git a/core/src/config/mod.rs b/core/src/config/mod.rs index 4dfc79f4..9fa8872b 100644 --- a/core/src/config/mod.rs +++ b/core/src/config/mod.rs @@ -1,6 +1,10 @@ +mod r#const; +mod r#enum; mod r#impl; mod r#struct; -pub use r#struct::*; +pub use {r#enum::*, r#struct::*}; + +pub(crate) use r#const::*; use super::*; diff --git a/core/src/config/struct.rs b/core/src/config/struct.rs index 8862fdcc..0b3a8c23 100644 --- a/core/src/config/struct.rs +++ b/core/src/config/struct.rs @@ -1,5 +1,21 @@ use super::*; +/// TLS material a server needs to terminate a connection. +/// +/// The certificate and key are stored as filesystem paths and only read when a +/// listener is created, so a configuration can be deserialized, compared and +/// cloned without touching the disk. +/// +/// The two path fields are part of the public surface: `TlsConfig::new` is the +/// documented constructor and applications read back what they configured. +#[derive(Clone, CustomDebug, Data, Deserialize, DisplayDebug, Eq, New, PartialEq, Serialize)] +pub struct TlsConfig { + /// Path to the PEM encoded certificate chain presented to clients. + pub certificate_path: String, + /// Path to the PEM encoded private key matching the leaf certificate. + pub private_key_path: String, +} + /// Represents the server configuration. /// /// This structure holds all the settings for the HTTP and WebSocket server, @@ -8,11 +24,27 @@ use super::*; pub struct ServerConfig { /// The address the server will bind to. #[set(type(AsRef))] - pub(super) address: String, + pub address: String, /// The `TCP_NODELAY` option for sockets. #[get(type(copy))] - pub(super) nodelay: Option, + pub nodelay: Option, /// The `IP_TTL` option for sockets. #[get(type(copy))] - pub(super) ttl: Option, + pub ttl: Option, + /// The wire protocol the listener speaks, defaulting to HTTP/1. + /// + /// Skipped by `new` so the three argument constructor keeps its signature; + /// a configuration built that way speaks HTTP/1 until a setter changes it. + #[get(type(copy))] + #[new(skip)] + #[serde(default)] + pub protocol: ServerProtocol, + /// TLS material used to terminate connections, or `None` for plaintext. + /// + /// Skipped by `new` for the same reason as `protocol`; the setters take an + /// `Option`, so `set_tls(None)` restores plaintext without a separate call. + #[get(type(clone))] + #[new(skip)] + #[serde(default)] + pub tls: Option, } diff --git a/core/src/context/struct.rs b/core/src/context/struct.rs index 9995b1bb..c575275e 100644 --- a/core/src/context/struct.rs +++ b/core/src/context/struct.rs @@ -7,11 +7,11 @@ use super::*; #[derive(Clone, CustomDebug, Data, DisplayDebug)] pub struct Context { /// The incoming HTTP request. - pub(super) request: Request, + pub request: Request, /// The outgoing HTTP response. - pub(super) response: Response, + pub response: Response, /// Parameters extracted from the route path. - pub(super) route_params: RouteParams, + pub route_params: RouteParams, /// A collection of custom attributes for sharing data within the request lifecycle. - pub(super) attributes: ThreadSafeAttributeStore, + pub attributes: ThreadSafeAttributeStore, } diff --git a/core/src/error/enum.rs b/core/src/error/enum.rs index abeb8175..d1a90818 100644 --- a/core/src/error/enum.rs +++ b/core/src/error/enum.rs @@ -5,6 +5,10 @@ use super::*; pub enum ServerError { /// An error occurred while trying to bind to a TCP socket. TcpBind(String), + /// An error occurred while loading or configuring TLS material. + Tls(String), + /// HTTP/3 was selected without TLS, which the protocol requires. + MissingTlsForHttp3, /// An unknown or unexpected error occurred. Unknown(String), /// An error occurred while reading an HTTP request. diff --git a/core/src/lib.rs b/core/src/lib.rs index dd455fae..c5326e77 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,34 +13,58 @@ mod config; mod context; mod error; mod hook; +mod listener; +mod protocol; mod route; mod server; +mod tls; pub use {config::*, context::*, error::*, hook::*, route::*, server::*}; pub use {http_type::*, inventory}; +pub(crate) use {listener::*, protocol::*, tls::*}; + use std::{ + any::Any, cmp::Ordering, collections::HashSet, - future::Future, + future::{Future, poll_fn}, hash::{Hash, Hasher}, io::{self, Write, stderr, stdout}, mem, + net::{AddrParseError, SocketAddr}, + num::ParseIntError, + panic::AssertUnwindSafe, pin::Pin, ptr, + str::{Split, SplitWhitespace, Utf8Error, from_utf8}, sync::Arc, + task::{self, Poll}, + time::Duration, }; use { + bytes::Bytes, + futures::FutureExt, + h3::error::Code, inventory::collect, lombok_macros::*, + quinn::{self, Endpoint, Incoming, VarInt}, regex::Regex, + rustls::pki_types::{ + CertificateDer, PrivateKeyDer, + pem::{self, PemObject}, + }, serde::{Deserialize, Serialize}, tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}, + join, net::{TcpListener, TcpStream}, spawn, sync::watch::{Receiver, Sender, channel}, task::JoinHandle, + time::timeout, }, + tokio_rustls::TlsAcceptor, }; diff --git a/core/src/listener/const.rs b/core/src/listener/const.rs new file mode 100644 index 00000000..5c17bef2 --- /dev/null +++ b/core/src/listener/const.rs @@ -0,0 +1,37 @@ +/// The QUIC application error code sent to peers when the server stops. +/// +/// A close frame still needs an error code even for an orderly shutdown, and a +/// zero code is the one every implementation reports as an unspecified error +/// rather than as an application condition of its own. +pub(crate) const QUIC_CLOSE_CODE: u32 = 0; + +/// The human readable reason carried by the shutdown close frame. +/// +/// It is sent as the close frame's reason string so a peer logs why its +/// connection ended instead of an opaque code. +pub(crate) const QUIC_CLOSE_REASON: &[u8] = b"hyperlane server shutdown"; + +/// The value the accept loop publishes once it has stopped serving. +/// +/// The flag is stored rather than merely signalled: a caller that reaches `wait` +/// after the loop already finished must observe the finished state, while a +/// caller that arrives first must block until the loop really stops. +pub(crate) const SERVER_STOPPED: bool = true; + +/// The value the accept loop publishes while it is still serving. +pub(crate) const SERVER_RUNNING: bool = false; + +/// The reason recorded when the HTTP/2 preface never arrives. +/// +/// The handshake is bounded like every other read, so a client that connects +/// and stays silent is reported as an elapsed handshake rather than holding a +/// connection slot until the process exits. +pub(crate) const H2_HANDSHAKE_TIMEOUT: &str = "http2 handshake exceeded the read timeout"; + +/// The read timeout that means "no timeout at all". +/// +/// The request configuration expresses "read without a deadline" as the +/// sentinel value `u64::MAX`. Mapping it to a `Duration` of the same number of +/// milliseconds would be a ~584,000 year deadline, so the sentinel is kept and +/// read as a real absence of a bound rather than as an enormous one. +pub(crate) const NO_TIMEOUT_MS: u64 = u64::MAX; diff --git a/core/src/listener/fn.rs b/core/src/listener/fn.rs new file mode 100644 index 00000000..c9dd3dd7 --- /dev/null +++ b/core/src/listener/fn.rs @@ -0,0 +1,481 @@ +use super::*; + +/// Accepts TCP connections and serves each one with the configured protocol. +/// +/// HTTP/1.1 and HTTP/2 share this listener. When TLS is configured the +/// connection is configured at the socket level first, then wrapped and +/// handshaken with a bound so a client that opens a connection and never +/// completes the handshake cannot stall the loop. HTTP/2 additionally requires +/// its own preface or upgrade, and an HTTP/1.1 selection serves the connection +/// directly. +/// +/// Shutdown is not an abort from outside. The loop watches the shutdown signal +/// itself, and on a signal it drops the listener before it returns, so the port +/// is already released by the time the loop publishes its stopped state and a +/// `wait` that follows is observably after the release. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves each request. +/// - `RequestConfig` - The parsing limits and read timeouts for connections. +/// - `ServerProtocol` - The protocol to serve on accepted connections. +/// - `Option>` - The TLS configuration, when present. +/// - `TcpListener` - The bound listener to accept connections from. +/// - `Receiver<()>` - The shutdown signal the loop selects on. +/// - `Sender` - Publishes whether the loop is still serving. +/// +/// # Returns +/// +/// - `()` - Resolves once the accept loop stops. +pub(crate) async fn tcp_accept( + server: Arc, + request_config: RequestConfig, + protocol: ServerProtocol, + stream_tls: Option>, + tcp_listener: TcpListener, + mut shutdown_receiver: Receiver<()>, + running_sender: Sender, +) { + loop { + let accepted: Option<(TcpStream, SocketAddr)> = tokio::select! { + biased; + _ = shutdown_receiver.changed() => None, + accepted = tcp_listener.accept() => accepted.ok(), + }; + let Some((stream, remote)) = accepted else { + break; + }; + let server: Arc = server.clone(); + let tls: Option> = stream_tls.clone(); + spawn(async move { + serve_tcp_connection(server, request_config, protocol, tls, stream, remote).await; + }); + } + drop(tcp_listener); + let _: Result<(), tokio::sync::watch::error::SendError> = + running_sender.send(SERVER_STOPPED); +} + +/// Serves one accepted TCP connection. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `Option>` - The TLS configuration, when present. +/// - `TcpStream` - The accepted socket, before its socket options are applied. +/// - `SocketAddr` - The remote address of the accepted socket. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_tcp_connection( + server: Arc, + request_config: RequestConfig, + protocol: ServerProtocol, + stream_tls: Option>, + stream: TcpStream, + remote: SocketAddr, +) { + let local: Option = stream.local_addr().ok(); + configure_socket(&stream, &server); + let Some(tls) = stream_tls else { + serve_plain_tcp(server, request_config, protocol, stream, local, remote).await; + return; + }; + let Ok(handshake) = + bounded_handshake(request_config, TlsAcceptor::from(tls).accept(stream)).await + else { + return; + }; + let Ok(handshake) = handshake else { + return; + }; + if !negotiated_protocol_matches(&handshake, &protocol) { + return; + } + serve_tls_stream( + server, + request_config, + protocol, + TlsTransport::new(handshake), + local, + remote, + ) + .await; +} + +/// Reports whether a negotiated TLS session speaks the protocol being served. +/// +/// Advertising the ALPN identifier is only half the contract: a client may +/// complete the handshake without offering any protocol at all, and treating +/// that as a successful negotiation would answer an HTTP/2 request under +/// HTTP/1.1 rules. HTTP/2 therefore requires the negotiated identifier to be +/// its own. HTTP/1.1 keeps accepting a session with no ALPN, because clients +/// that predate ALPN negotiate nothing and must still be served. +/// +/// # Arguments +/// +/// - `&tokio_rustls::server::TlsStream` - The negotiated session. +/// - `&ServerProtocol` - The protocol this connection is being served as. +/// +/// # Returns +/// +/// - `bool` - `true` when the session may be served under this protocol. +pub(crate) fn negotiated_protocol_matches( + session: &tokio_rustls::server::TlsStream, + protocol: &ServerProtocol, +) -> bool { + let (_, connection): (&TcpStream, &rustls::ServerConnection) = session.get_ref(); + match connection.alpn_protocol() { + Some(negotiated) => negotiated == protocol.get_alpn_protocol(), + None => protocol != &ServerProtocol::Http2, + } +} + +/// Reports whether a QUIC connection negotiated HTTP/3. +/// +/// QUIC requires TLS 1.3 and carries the handshake inside its own transport, +/// so the negotiated identifier is the only statement of intent a client can +/// make. A connection that negotiated nothing, or something other than `h3`, +/// is closed rather than served: it is a client aimed at a different protocol, +/// and answering it under HTTP/3 rules would produce a response it cannot read. +/// +/// # Arguments +/// +/// - `&quinn::Connection` - The established QUIC connection. +/// +/// # Returns +/// +/// - `bool` - `true` when the connection negotiated the `h3` identifier. +pub(crate) fn negotiated_quic_protocol_matches(connection: &quinn::Connection) -> bool { + let Some(handshake) = connection.handshake_data() else { + return false; + }; + match handshake.downcast::() { + Ok(data) => data.protocol.as_deref() == Some(ServerProtocol::Http3.get_alpn_protocol()), + Err(_) => false, + } +} + +/// Bounds a protocol handshake with the configured read timeout. +/// +/// The bound covers the whole handshake rather than each byte of it: a client +/// that trickles one byte at a time must not be able to hold a connection open +/// forever by staying under the timeout on every individual read. +/// +/// # Arguments +/// +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `F` - The handshake future to bound. +/// +/// # Returns +/// +/// - `Result` - The handshake result, or +/// the elapsed that stopped it. +pub(crate) async fn bounded_handshake( + request_config: RequestConfig, + handshake: F, +) -> Result +where + F: Future, +{ + let read_timeout_ms: u64 = request_config.get_read_timeout_ms(); + if read_timeout_ms == NO_TIMEOUT_MS { + return Ok(handshake.await); + } + timeout(Duration::from_millis(read_timeout_ms), handshake).await +} + +/// Applies the configured socket options before any TLS handshake starts. +/// +/// # Arguments +/// +/// - `&TcpStream` - The accepted socket to configure. +/// - `&Arc` - The server holding the socket options to apply. +/// +/// # Returns +/// +/// - `()` - Resolves once the options are applied. +pub(crate) fn configure_socket(stream: &TcpStream, server: &Arc) { + let server_config: &ServerConfig = server.get_server_config(); + if let Some(nodelay) = server_config.get_nodelay() { + let _: Result<(), io::Error> = stream.set_nodelay(nodelay); + } + if let Some(ttl) = server_config.get_ttl() { + let _: Result<(), io::Error> = stream.set_ttl(ttl); + } +} + +/// Serves a plaintext connection, selecting the protocol it speaks. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `TcpStream` - The accepted socket. +/// - `Option` - The local address of the connection. +/// - `SocketAddr` - The remote address of the connection. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_plain_tcp( + server: Arc, + request_config: RequestConfig, + protocol: ServerProtocol, + stream: TcpStream, + local: Option, + remote: SocketAddr, +) { + match protocol { + ServerProtocol::Http2 => { + serve_h2_connection( + server, + request_config, + local.unwrap_or(remote), + remote, + bounded_handshake(request_config, h2::server::handshake(stream)) + .await + .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) + .and_then(|connection: Result<_, h2::Error>| { + connection.map_err(|error: h2::Error| error.to_string()) + }), + ) + .await; + } + _ => { + serve_h1_connection(server, request_config, stream).await; + } + } +} + +/// Serves a connection whose transport is already negotiated. +/// +/// HTTP/2 is served by handing the session to its own connection loop, which +/// keeps its own control flow; HTTP/1.1 is served by the existing keep alive +/// pipeline over a `Stream` wrapping the session. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `TlsTransport` - The negotiated session carrying the connection. +/// - `Option` - The local address of the connection. +/// - `SocketAddr` - The remote address of the connection. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_tls_stream( + server: Arc, + request_config: RequestConfig, + protocol: ServerProtocol, + transport: TlsTransport, + local: Option, + remote: SocketAddr, +) { + match protocol { + ServerProtocol::Http2 => { + let session: TlsTransport = transport; + serve_h2_connection( + server, + request_config, + local.unwrap_or(remote), + remote, + bounded_handshake(request_config, h2::server::handshake(session)) + .await + .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) + .and_then(|connection: Result<_, h2::Error>| { + connection.map_err(|error: h2::Error| error.to_string()) + }), + ) + .await; + } + _ => { + serve_h1_transport(server, request_config, transport).await; + } + } +} + +/// Accepts QUIC connections and serves each one with HTTP/3. +/// +/// Shutdown here is not an abort either, and it cannot be one alone: dropping +/// the loop would leave the UDP socket bound and every established connection +/// running. The loop therefore closes the endpoint first, which tells peers the +/// server is going away and makes the accept future resolve, then waits for the +/// connections to drain before publishing the stopped state and dropping the +/// socket. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves each request. +/// - `RequestConfig` - The parsing limits and read timeouts for connections. +/// - `Endpoint` - The bound QUIC endpoint to accept connections from. +/// - `Receiver<()>` - The shutdown signal the loop selects on. +/// - `Sender` - Publishes whether the loop is still serving. +/// +/// # Returns +/// +/// - `Result<(), Box>` - `Ok(())` once the endpoint has been closed and +/// drained, or the failure that stopped the loop early. +pub(crate) async fn quic_accept( + server: Arc, + request_config: RequestConfig, + endpoint: Endpoint, + mut shutdown_receiver: Receiver<()>, + running_sender: Sender, +) -> Result<(), Box> { + let local: SocketAddr = endpoint + .local_addr() + .map_err(|error: io::Error| ServerError::from(error))?; + loop { + let connecting: Option = tokio::select! { + biased; + _ = shutdown_receiver.changed() => None, + accepted = endpoint.accept() => accepted, + }; + let Some(connecting) = connecting else { + break; + }; + let remote: SocketAddr = connecting.remote_address(); + let server: Arc = server.clone(); + spawn(async move { + let Ok(connection) = connecting.await else { + return; + }; + if !negotiated_quic_protocol_matches(&connection) { + connection.close(VarInt::from_u32(QUIC_CLOSE_CODE), QUIC_CLOSE_REASON); + return; + } + let Ok(h3_connection) = h3::server::Connection::::new( + h3_quinn::Connection::new(connection), + ) + .await + else { + return; + }; + serve_h3_connection(server, request_config, local, remote, h3_connection).await; + }); + } + let close_code: VarInt = VarInt::from_u32(QUIC_CLOSE_CODE); + endpoint.close(close_code, QUIC_CLOSE_REASON); + endpoint.wait_idle().await; + drop(endpoint); + let _: Result<(), tokio::sync::watch::error::SendError> = + running_sender.send(SERVER_STOPPED); + Ok(()) +} + +/// Serves one plaintext HTTP/1.1 connection through the existing pipeline. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `TcpStream` - The accepted socket. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_h1_connection( + server: Arc, + request_config: RequestConfig, + stream: TcpStream, +) { + let mut stream: Stream = Stream::new(stream, request_config, false); + serve_h1_stream(server, &mut stream).await; +} + +/// Serves one HTTP/1.1 connection whose transport is already negotiated. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `RequestConfig` - The parsing limits and read timeouts for the connection. +/// - `TlsTransport` - The negotiated session carrying the connection. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +/// +/// A TLS session is not closed by dropping it: rustls treats an abrupt drop as +/// `UnexpectedEof`, which a well behaved peer reports as a truncated +/// connection rather than an orderly shutdown. The write half is therefore shut +/// down explicitly so the close notify alert reaches the peer. A finite read +/// timeout bounds shutdown so a peer that stops reading cannot hold the +/// connection open forever; the no-timeout sentinel disables that bound. +pub(crate) async fn serve_h1_transport( + server: Arc, + request_config: RequestConfig, + transport: TlsTransport, +) { + let mut stream: Stream = Stream::from_transport(transport, request_config, false); + serve_h1_stream(server, &mut stream).await; + let closed: Result, tokio::time::error::Elapsed> = + bounded_handshake(request_config, stream.get_mut_stream().shutdown()).await; + match closed { + Ok(Ok(())) => {} + Ok(Err(_)) => {} + Err(_) => {} + } +} + +/// Runs the keep alive request loop of one HTTP/1.1 connection. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the connection. +/// - `&mut Stream` - The stream carrying the connection. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_h1_stream(server: Arc, stream: &mut Stream) { + server.serve_h1_connection_stream(stream).await; +} + +/// Runs one connection future and reports the panic payload if it unwinds. +/// +/// The accept loop must survive a handler that panics, and the panic hooks must +/// see the same data a join error would have carried, so the connection future +/// is unwound here rather than being spawned and forgotten. +/// +/// # Arguments +/// +/// - `F` - The connection future to run. +/// +/// # Returns +/// +/// - `Option` - `None` when the connection finished normally. +pub(crate) async fn catch_connection_panic(connection: F) -> Option +where + F: Future, +{ + match AssertUnwindSafe(connection).catch_unwind().await { + Ok(()) => None, + Err(payload) => Some(panic_data_from_payload(payload)), + } +} + +/// Converts an unwound panic payload into the framework panic representation. +/// +/// # Arguments +/// +/// - `Box` - The payload the unwind produced. +/// +/// # Returns +/// +/// - `PanicData` - The panic message and payload, if either could be read. +pub(crate) fn panic_data_from_payload(payload: Box) -> PanicData { + match payload.downcast::() { + Ok(message) => PanicData::new(Some(*message), None, None), + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => PanicData::new(Some((*message).to_owned()), None, None), + Err(_) => PanicData::new(None, None, None), + }, + } +} diff --git a/core/src/listener/mod.rs b/core/src/listener/mod.rs new file mode 100644 index 00000000..09524dbe --- /dev/null +++ b/core/src/listener/mod.rs @@ -0,0 +1,6 @@ +mod r#const; +mod r#fn; + +pub(crate) use {r#const::*, r#fn::*}; + +use super::*; diff --git a/core/src/protocol/aliases/mod.rs b/core/src/protocol/aliases/mod.rs new file mode 100644 index 00000000..c4650230 --- /dev/null +++ b/core/src/protocol/aliases/mod.rs @@ -0,0 +1,5 @@ +mod r#type; + +pub(crate) use r#type::*; + +use super::*; diff --git a/core/src/protocol/aliases/type.rs b/core/src/protocol/aliases/type.rs new file mode 100644 index 00000000..7434ab20 --- /dev/null +++ b/core/src/protocol/aliases/type.rs @@ -0,0 +1,47 @@ +use super::*; + +/// A bidirectional in-memory byte pipe joining a handler to a protocol stream. +pub(crate) type DuplexStream = tokio::io::DuplexStream; + +/// The response or request headers in their protocol representation. +pub(crate) type ProtocolHeaders = http::HeaderMap; + +/// The bidirectional stream one HTTP/3 request runs over. +pub(crate) type RequestH3Stream = h3::server::RequestStream, Bytes>; + +/// The HTTP/2 connection half shared by every request stream of one connection. +pub(crate) type ConnectionH2 = h2::server::Connection; + +/// The bare line feed byte. +pub(crate) const LF_U8: u8 = b'\n'; + +/// The carriage return byte. +pub(crate) const CR_U8: u8 = b'\r'; + +/// The line separator used when splitting an HTTP/1.1 head into lines. +pub(crate) const CRLF: &str = "\r\n"; + +/// The prefix every HTTP version string carries in a status line. +pub(crate) const HTTP_VERSION_PREFIX: &str = "HTTP/"; + +/// The line separator used when splitting a head into lines. +pub(crate) type SplitLines<'a> = Split<'a, &'static str>; + +/// The separator between the tokens of a comma separated header value. +pub(crate) const COMMA_STR: &str = ","; + +/// The line feed character used when trimming a head line. +pub(crate) const LF_CHAR: char = '\n'; + +/// The reason returned when a response head carries no version line. +pub(crate) const MISSING_RESPONSE_VERSION: &str = "response head has no version line"; + +/// The reason returned when a response head carries no status code. +pub(crate) const MISSING_RESPONSE_STATUS: &str = "response head has no status code"; + +/// The reason returned when a transport carries no socket identity. +pub(crate) const NO_ADDRESS: &str = "transport has no socket address"; + +/// The reason returned when a collected body disagrees with its declared length. +pub(crate) const CONTENT_LENGTH_MISMATCH: &str = + "request body length does not match its content-length"; diff --git a/core/src/protocol/constants/const.rs b/core/src/protocol/constants/const.rs new file mode 100644 index 00000000..f26c4ef6 --- /dev/null +++ b/core/src/protocol/constants/const.rs @@ -0,0 +1,57 @@ +/// Pseudo-header prefix used by HTTP/2 and HTTP/3 header maps. +pub(crate) const PSEUDO_HEADER_PREFIX: &str = ":"; + +/// Size of the buffer used to read a protocol request body chunk by chunk. +pub(crate) const PROTOCOL_BODY_CHUNK: usize = 8 * 1024; + +/// The character separating the path from the query in a request target. +pub(crate) const QUESTION_MARK: char = '?'; + +/// The header naming the trailers section of a message. +pub(crate) const TRAILER_HEADER: &str = "trailer"; + +/// Buffer size of the duplex pipe joining the pipeline to a protocol stream. +pub(crate) const PROTOCOL_STREAM_CAPACITY: usize = 32 * 1024; + +/// Maximum size of a serialized response head accepted from the pipeline. +pub(crate) const RESPONSE_HEAD_MAX: usize = 16 * 1024; + +/// The reason returned when a response head exceeds its bound. +pub(crate) const RESPONSE_HEAD_TOO_LONG: &str = "response head exceeds its bound"; + +/// The reason returned when the pipeline closed without writing a head. +pub(crate) const MISSING_RESPONSE_HEAD: &str = "response ended before a head was written"; + +/// The reason returned when a head announces a transfer coding the framing pump +/// cannot reproduce. +/// +/// HTTP/2 and HTTP/3 do not carry a chunked body: the data frames are the +/// framing. Forwarding the chunk size lines as body bytes would hand the client +/// a body it must not have to decode, so the head is refused before any frame is +/// emitted and the stream can be reset with a real error. +pub(crate) const UNSUPPORTED_TRANSFER_ENCODING: &str = + "response transfer-encoding is not supported on this protocol"; + +/// The reason returned when a status line carries no code the protocol allows. +pub(crate) const INVALID_RESPONSE_STATUS: &str = "response head has an unusable status code"; + +/// The reason returned when a header line is not `name: value`. +pub(crate) const MALFORMED_RESPONSE_HEADER: &str = "response head has a malformed header line"; + +/// The reason returned when the body is shorter than the announced length. +pub(crate) const TRUNCATED_RESPONSE_BODY: &str = + "response ended before the announced content length arrived"; + +/// The reason returned when the body runs past the announced length. +pub(crate) const OVERFLOWING_RESPONSE_BODY: &str = + "response carried more bytes than the announced content length"; + +/// The reason returned when a send stream is gone before capacity is granted. +pub(crate) const MISSING_SEND_CAPACITY: &str = + "the send stream was closed before capacity was granted"; + +/// The smallest status code a protocol can carry. +pub(crate) const MIN_STATUS_CODE: usize = 100; + +/// The largest status code a protocol can carry. +pub(crate) const MAX_STATUS_CODE: usize = 999; diff --git a/core/src/protocol/constants/mod.rs b/core/src/protocol/constants/mod.rs new file mode 100644 index 00000000..1c7d0d10 --- /dev/null +++ b/core/src/protocol/constants/mod.rs @@ -0,0 +1,3 @@ +mod r#const; + +pub(crate) use r#const::*; diff --git a/core/src/protocol/framing/mod.rs b/core/src/protocol/framing/mod.rs new file mode 100644 index 00000000..d4681c4c --- /dev/null +++ b/core/src/protocol/framing/mod.rs @@ -0,0 +1,5 @@ +mod r#trait; + +pub(crate) use r#trait::*; + +use super::*; diff --git a/core/src/protocol/framing/trait.rs b/core/src/protocol/framing/trait.rs new file mode 100644 index 00000000..45e12ce9 --- /dev/null +++ b/core/src/protocol/framing/trait.rs @@ -0,0 +1,76 @@ +use super::*; + +/// The write side of a multiplexed protocol stream, as the pump sees it. +/// +/// The pump is protocol agnostic: it decides when the head is complete and +/// which bytes are body, and the implementation decides how that becomes +/// frames. HTTP/2 and HTTP/3 differ in how a data chunk is framed and how the +/// stream ends, but not in when those points occur. +/// +/// The pump never calls `send_data` for a response that forbids a body, so an +/// implementation does not have to re-derive the `HEAD` and no-content rule. +/// A head arrives exactly once and always before any data frame, because the +/// pump withholds the frames until the head has been validated; an +/// implementation may therefore assume the order it sees. +/// +/// The frame types are boxed so the futures stay small enough to be spawned +/// without each implementation erasing into a large one. +pub(crate) trait ResponseFramer { + /// Sends the response head as the first frame of the stream. + /// + /// Called once per stream, before any data frame. The head carries no + /// hop by hop header the protocol may not carry and no length field on a + /// response that has no body; the pump already removed both. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a FramedHead` - The parsed head to frame. + /// - `bool` - Whether the response forbids a body. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_head<'a>( + &'a mut self, + head: &'a FramedHead, + bodyless: bool, + ) -> Pin> + Send + 'a>>; + + /// Sends one body chunk as a data frame. + /// + /// Never called with an empty slice, and never called for a response that + /// forbids a body. The bytes are the payload the handler produced, already + /// free of any framing bytes. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a [u8]` - The body bytes to frame. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_data<'a>( + &'a mut self, + data: &'a [u8], + ) -> Pin> + Send + 'a>>; + + /// Closes the stream with the end of stream flag. + /// + /// Reached only when the body ended and matched the length the head + /// announced. A response that was truncated or overlong fails the pump + /// without calling this, so the stream is left unfinished for the caller to + /// reset. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn finish<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>>; +} diff --git a/core/src/protocol/http2/fn.rs b/core/src/protocol/http2/fn.rs new file mode 100644 index 00000000..e2ea37c1 --- /dev/null +++ b/core/src/protocol/http2/fn.rs @@ -0,0 +1,261 @@ +use super::*; + +/// Serves one HTTP/2 connection until it closes. +/// +/// The connection half stays polled for the whole lifetime of the connection: +/// it owns flow control and the settings exchange for every stream, so parking +/// it while a handler runs would stall the other requests multiplexed onto the +/// same connection. Each request is therefore spawned onto its own task while +/// this loop keeps polling. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves each request. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The `value` argument. +/// - `Result, String>` - The negotiated HTTP/2 connection, or +/// +pub(crate) async fn serve_h2_connection( + server: Arc, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + connection: Result, String>, +) where + T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, +{ + let mut connection: ConnectionH2 = match connection { + Ok(connection) => connection, + Err(_) => return, + }; + loop { + match connection.accept().await { + Some(Ok((request, send_response))) => { + spawn(h2_request_task( + server.clone(), + request_config, + local, + remote, + request, + send_response, + )); + } + Some(Err(_)) => return, + None => return, + } + } +} + +/// Runs the framework lifecycle over one HTTP/2 request stream. +/// +/// The lifecycle and the framing pump run concurrently: the lifecycle writes +/// HTTP/1.1 shaped bytes into a duplex pipe and the pump frames them onto the +/// stream as they arrive. Awaiting both keeps the head and every body chunk on +/// the wire before the handler's completion is observed. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the request. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The local address of the carrying connection. +/// - `http::Request` - The decoded protocol request. +/// - `h2::server::SendResponse` - The response half of the stream. +/// +pub(crate) async fn h2_request_task( + server: Arc, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + request: http::Request, + send_response: h2::server::SendResponse, +) { + let (parts, mut recv_stream): (http::request::Parts, h2::RecvStream) = request.into_parts(); + let uri: &http::Uri = &parts.uri; + let mut framer: H2Framer = H2Framer::new(send_response); + let mut body: Vec = Vec::new(); + if let Err(error) = + collect_h2_body(&mut recv_stream, &request_config, &parts.headers, &mut body).await + { + h2_fail_request( + &server, + &mut framer, + request_config, + local, + remote, + HttpVersion::Http2, + error, + ) + .await; + return; + } + let built: Result = build_protocol_request( + &request_config, + HttpVersion::Http2, + parts.method.clone(), + &request_target(uri.path(), uri.query()), + uri, + &parts.headers, + body, + ); + let request: Request = match built { + Ok(request) => request, + Err(error) => { + h2_fail_request( + &server, + &mut framer, + request_config, + local, + remote, + HttpVersion::Http2, + error, + ) + .await; + return; + } + }; + let is_head: bool = request.get_method().is_head(); + let (stream, peer): (Stream, DuplexStream) = + build_duplex_transport(request_config, Some(local), Some(remote)); + let lifecycle: ServerLifecycle = ServerLifecycle::new(server, request); + let joined: Result<((), ()), ResponseError> = futures::try_join!( + async { + lifecycle.run(stream).await; + Ok::<(), ResponseError>(()) + }, + pump_response(&mut framer, peer, RESPONSE_HEAD_MAX, is_head) + ); + let pump_result: Result<(), ResponseError> = joined.map(|(_, pumped): ((), ())| pumped); + if pump_result.is_err() { + framer.reset(); + } +} + +/// Answers a request that failed before a handler could run. +/// +/// The error reaches the existing request error hooks over the same duplex pipe +/// and the same framing pump a real response takes, so the error handler writes +/// an HTTP/1.1 shaped head that is framed as HTTP/2 on the wire instead of the +/// stream ending silently. +/// +/// # Arguments +/// +/// - `&Arc` - The server whose error hooks answer the request. +/// - `&mut H2Framer` - The framer owning the stream's frames. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The local address of the carrying connection. +/// - `HttpVersion` - The real protocol version to stamp on the error context. +/// - `RequestError` - The failure that stopped the request. +/// +pub(crate) async fn h2_fail_request( + server: &Arc, + framer: &mut H2Framer, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + version: HttpVersion, + error: RequestError, +) { + let mut ctx: Context = Context::default(); + let mut request: Request = Request::default(); + request.set_version(version.clone()); + ctx.set_request(request); + ctx.get_mut_response().reset().set_version(version); + let (mut stream, peer): (Stream, DuplexStream) = + build_duplex_transport(request_config, Some(local), Some(remote)); + let is_head: bool = false; + let server: Arc = server.clone(); + let (_, pump_result): ((), Result<(), ResponseError>) = join!( + async move { + server + .handle_request_error(&mut stream, &mut ctx, &error) + .await; + drop(stream); + }, + pump_response(framer, peer, RESPONSE_HEAD_MAX, is_head) + ); + if pump_result.is_err() { + framer.reset(); + } +} + +/// Collects an HTTP/2 request body into a bounded buffer. +/// +/// `Ok(None)` from the protocol is end of body and completes collection; only +/// a read that never returns is a timeout. The bound is one deadline for the +/// whole request read rather than a fresh one per chunk, so a client that +/// dribbles one byte at a time cannot hold a stream open forever. A config that +/// asks for the permissive low security timeout applies no deadline at all, +/// matching the HTTP/1.1 reader. Receive capacity is released as each chunk is +/// drained, so a handler that reads slowly cannot stall the window. +/// +/// # Arguments +/// +/// - `&mut h2::RecvStream` - The receive half of the request stream. +/// - `&RequestConfig` - The parsing limits to enforce. +/// - `&ProtocolHeaders` - The protocol request headers carrying the declared length. +/// - `&mut Vec` - The buffer to collect the body into. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the +pub(crate) async fn collect_h2_body( + recv_stream: &mut h2::RecvStream, + request_config: &RequestConfig, + headers: &ProtocolHeaders, + body: &mut Vec, +) -> Result<(), RequestError> { + let max_body_size: usize = request_config.get_max_body_size(); + let declared: Option = declared_content_length(headers, max_body_size)?; + let read_timeout_ms: u64 = request_config.get_read_timeout_ms(); + if read_timeout_ms == DEFAULT_LOW_SECURITY_READ_TIMEOUT_MS { + collect_h2_body_until_end(recv_stream, max_body_size, body).await?; + } else { + let bound: Duration = Duration::from_millis(read_timeout_ms); + match timeout( + bound, + collect_h2_body_until_end(recv_stream, max_body_size, body), + ) + .await + { + Ok(collected) => collected?, + Err(_) => return Err(RequestError::ReadTimeout(HttpStatus::RequestTimeout)), + } + } + match declared { + None => Ok(()), + Some(declared) => match body.len().cmp(&declared) { + Ordering::Equal => Ok(()), + _ => Err(RequestError::Request(CONTENT_LENGTH_MISMATCH.to_owned())), + }, + } +} + +/// Drains an HTTP/2 request stream until end of body or a limit is violated. +/// +/// # Arguments +/// +/// - `&mut h2::RecvStream` - The receive half of the request stream. +/// - `usize` - The maximum number of body bytes to accept. +/// - `&mut Vec` - The buffer to collect the body into. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the +async fn collect_h2_body_until_end( + recv_stream: &mut h2::RecvStream, + max_body_size: usize, + body: &mut Vec, +) -> Result<(), RequestError> { + loop { + let received: Option> = recv_stream.data().await; + let Some(received) = received else { + return Ok(()); + }; + let data: Bytes = + received.map_err(|error: h2::Error| RequestError::Request(error.to_string()))?; + let data_len: usize = data.len(); + let collected: Result<(), RequestError> = push_bounded_body(max_body_size, body, data); + let _: Result<(), h2::Error> = recv_stream.flow_control().release_capacity(data_len); + collected?; + } +} diff --git a/core/src/protocol/http2/impl.rs b/core/src/protocol/http2/impl.rs new file mode 100644 index 00000000..6316dc7d --- /dev/null +++ b/core/src/protocol/http2/impl.rs @@ -0,0 +1,161 @@ +use super::*; + +impl H2Framer { + /// Creates a framer over one request stream. + /// + /// # Arguments + /// + /// - `h2::server::SendResponse` - The response half of the stream. + /// + pub(crate) fn new(send_response: h2::server::SendResponse) -> Self { + Self { + send_response: Some(send_response), + send_stream: None, + finished: false, + } + } + + /// Resets the real stream after the framing pump failed. + /// + /// Dropping a half written stream without a reset leaves the peer waiting + /// for the frames that will never arrive, so the failure is reported to the + /// client instead of being swallowed. The reset is queued rather than + /// awaited: polling it would wait for the peer to acknowledge a stream the + /// peer may already have abandoned, and this task must not linger. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + pub(crate) fn reset(&mut self) { + if let Some(mut send_response) = self.get_mut_send_response().take() { + send_response.send_reset(h2::Reason::INTERNAL_ERROR); + } + if let Some(mut send_stream) = self.get_mut_send_stream().take() { + send_stream.send_reset(h2::Reason::INTERNAL_ERROR); + } + } +} + +impl ResponseFramer for H2Framer { + /// Sends the response head as the first HEADERS frame of the stream. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a FramedHead` - The parsed head to frame. + /// - `bool` - Whether the response forbids a body. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_head<'a>( + &'a mut self, + head: &'a FramedHead, + bodyless: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let nominated: Vec = connection_nominated_headers(&head.headers); + let mut builder: http::response::Builder = + http::Response::builder().status(head.status_code as u16); + for (name, value) in head.headers.iter() { + let key: &str = name.as_str(); + let nominated_by_connection: bool = nominated + .iter() + .any(|nominated: &String| nominated.as_str() == key); + if key.starts_with(PSEUDO_HEADER_PREFIX) + || is_hop_by_hop(key) + || nominated_by_connection + { + continue; + } + if let Ok(value) = http::HeaderValue::from_bytes(value.as_bytes()) { + builder = builder.header(name.clone(), value); + } + } + let response: http::Response<()> = builder + .body(()) + .map_err(|error: http::Error| ResponseError::Send(error.to_string()))?; + let mut send_response: h2::server::SendResponse = self + .get_mut_send_response() + .take() + .ok_or(ResponseError::Terminated)?; + if bodyless { + self.set_finished(true); + send_response + .send_response(response, true) + .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + return Ok(()); + } + let send_stream: h2::SendStream = + send_response + .send_response(response, false) + .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + self.set_send_stream(Some(send_stream)); + Ok(()) + }) + } + + /// Sends one body chunk as a data frame sized by the granted capacity. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a [u8]` - The body bytes to frame. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_data<'a>( + &'a mut self, + data: &'a [u8], + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if data.is_empty() || self.get_finished() { + return Ok(()); + } + let Some(mut send_stream) = self.get_mut_send_stream().take() else { + return Err(ResponseError::Terminated); + }; + let mut rest: &[u8] = data; + while !rest.is_empty() { + let granted: usize = next_h2_capacity(&mut send_stream, rest.len()).await?; + let take: usize = granted.min(rest.len()); + let chunk: Bytes = Bytes::copy_from_slice(&rest[..take]); + rest = &rest[take..]; + send_stream + .send_data(chunk, false) + .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + } + self.set_send_stream(Some(send_stream)); + Ok(()) + }) + } + + /// Closes the stream with the end of stream flag. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn finish<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if self.get_finished() { + return Ok(()); + } + self.set_finished(true); + let Some(mut send_stream) = self.get_mut_send_stream().take() else { + return Ok(()); + }; + send_stream + .send_data(Bytes::new(), true) + .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + Ok(()) + }) + } +} diff --git a/core/src/protocol/http2/mod.rs b/core/src/protocol/http2/mod.rs new file mode 100644 index 00000000..a128df75 --- /dev/null +++ b/core/src/protocol/http2/mod.rs @@ -0,0 +1,7 @@ +mod r#fn; +mod r#impl; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/core/src/protocol/http2/struct.rs b/core/src/protocol/http2/struct.rs new file mode 100644 index 00000000..68ec822c --- /dev/null +++ b/core/src/protocol/http2/struct.rs @@ -0,0 +1,25 @@ +use super::*; + +/// Frames a response onto an HTTP/2 request stream as the pump produces it. +/// +/// The framing code moves a stream half out of the field with `take` before it +/// can write on it, so both stream fields are reached through their mutable +/// accessor rather than a shared one. +#[derive(Data)] +pub(crate) struct H2Framer { + /// The response half, used only for the head. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(super) send_response: Option>, + /// The stream half, used for every data frame. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(super) send_stream: Option>, + /// Whether the end of stream has already been sent. + #[get(type(copy), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) finished: bool, +} diff --git a/core/src/protocol/http3/fn.rs b/core/src/protocol/http3/fn.rs new file mode 100644 index 00000000..1fa27c15 --- /dev/null +++ b/core/src/protocol/http3/fn.rs @@ -0,0 +1,259 @@ +use super::*; + +/// Serves one HTTP/3 connection until it closes. +/// +/// As with HTTP/2, the connection half owns the settings and control streams for +/// every request on it, so it stays polled while each request runs on its own +/// task. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves each request. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The local address of the carrying connection. +/// - `h3::server::Connection` - The HTTP/3 connection. +/// +pub(crate) async fn serve_h3_connection( + server: Arc, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + mut connection: h3::server::Connection, +) { + loop { + let resolver: Option> = + match connection.accept().await { + Ok(resolver) => resolver, + Err(_) => return, + }; + let Some(resolver) = resolver else { + return; + }; + spawn(h3_request_task( + server.clone(), + request_config, + local, + remote, + resolver, + )); + } +} + +/// Runs the framework lifecycle over one HTTP/3 request stream. +/// +/// # Arguments +/// +/// - `Arc` - The server whose pipeline serves the request. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The local address of the carrying connection. +/// - `h3::server::RequestResolver` - The resolver for +/// +pub(crate) async fn h3_request_task( + server: Arc, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + resolver: h3::server::RequestResolver, +) { + let (head, request_stream): (http::Request<()>, RequestH3Stream) = + match resolver.resolve_request().await { + Ok(resolved) => resolved, + Err(_) => return, + }; + let (parts, ()): (http::request::Parts, ()) = head.into_parts(); + let uri: &http::Uri = &parts.uri; + let mut framer: H3Framer = H3Framer::new(request_stream); + let mut body: Vec = Vec::new(); + match collect_h3_body(&mut framer, &request_config, &parts.headers, &mut body).await { + Ok(()) => {} + Err(error) => { + h3_fail_request( + &server, + &mut framer, + request_config, + local, + remote, + HttpVersion::Http3, + error, + ) + .await; + return; + } + } + let built: Result = build_protocol_request( + &request_config, + HttpVersion::Http3, + parts.method.clone(), + &request_target(uri.path(), uri.query()), + uri, + &parts.headers, + body, + ); + let request: Request = match built { + Ok(request) => request, + Err(error) => { + h3_fail_request( + &server, + &mut framer, + request_config, + local, + remote, + HttpVersion::Http3, + error, + ) + .await; + return; + } + }; + let is_head: bool = request.get_method().is_head(); + let (stream, peer): (Stream, DuplexStream) = + build_duplex_transport(request_config, Some(local), Some(remote)); + let lifecycle: ServerLifecycle = ServerLifecycle::new(server, request); + let joined: Result<((), ()), ResponseError> = futures::try_join!( + async { + lifecycle.run(stream).await; + Ok::<(), ResponseError>(()) + }, + pump_response(&mut framer, peer, RESPONSE_HEAD_MAX, is_head) + ); + let pump_result: Result<(), ResponseError> = joined.map(|(_, pumped): ((), ())| pumped); + if pump_result.is_err() { + framer.reset(); + } +} + +/// Collects an HTTP/3 request body into a bounded buffer. +/// +/// One deadline covers the whole request read rather than a fresh one per +/// chunk, so a client that dribbles bytes cannot hold the stream open forever, +/// and a config asking for the permissive low security timeout applies no +/// deadline at all, matching the HTTP/1.1 and HTTP/2 readers. +/// +/// # Arguments +/// +/// - `&mut H3Framer` - The framer holding the request stream's receive half. +/// - `&RequestConfig` - The parsing limits to enforce. +/// - `&ProtocolHeaders` - The protocol request headers carrying the declared length. +/// - `&mut Vec` - The buffer to collect the body into. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete and matches +pub(crate) async fn collect_h3_body( + framer: &mut H3Framer, + request_config: &RequestConfig, + headers: &ProtocolHeaders, + body: &mut Vec, +) -> Result<(), RequestError> { + let max_body_size: usize = request_config.get_max_body_size(); + let declared: Option = declared_content_length(headers, max_body_size)?; + let read_timeout_ms: u64 = request_config.get_read_timeout_ms(); + if read_timeout_ms == DEFAULT_LOW_SECURITY_READ_TIMEOUT_MS { + collect_h3_body_until_end(framer, max_body_size, body).await?; + } else { + let bound: Duration = Duration::from_millis(read_timeout_ms); + match timeout( + bound, + collect_h3_body_until_end(framer, max_body_size, body), + ) + .await + { + Ok(collected) => collected?, + Err(_) => return Err(RequestError::ReadTimeout(HttpStatus::RequestTimeout)), + } + } + match declared { + None => Ok(()), + Some(declared) => match body.len().cmp(&declared) { + Ordering::Equal => Ok(()), + _ => Err(RequestError::Request(CONTENT_LENGTH_MISMATCH.to_owned())), + }, + } +} + +/// Drains an HTTP/3 request stream until end of body or a limit is violated. +/// +/// The stream stays owned by the framer and is borrowed across each await, so a +/// deadline that cancels this future cancels only the read: the framer keeps +/// the real QUIC stream and the error hooks can still answer on it. +/// +/// # Arguments +/// +/// - `&mut H3Framer` - The framer holding the request stream's receive half. +/// - `usize` - The maximum number of body bytes to accept. +/// - `&mut Vec` - The buffer to collect the body into. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the +async fn collect_h3_body_until_end( + framer: &mut H3Framer, + max_body_size: usize, + body: &mut Vec, +) -> Result<(), RequestError> { + loop { + let Some(request_stream) = framer.get_mut_request_stream().as_mut() else { + return Err(RequestError::Request(CONTENT_LENGTH_MISMATCH.to_owned())); + }; + let received: Result, h3::error::StreamError> = request_stream.recv_data().await; + match received { + Ok(Some(mut data)) => { + let remaining: usize = bytes::Buf::remaining(&data); + let chunk: Bytes = bytes::Buf::copy_to_bytes(&mut data, remaining); + let collected: Result<(), RequestError> = + push_bounded_body(max_body_size, body, chunk); + collected?; + } + Ok(None) => return Ok(()), + Err(error) => return Err(RequestError::Request(error.to_string())), + } + } +} + +/// Answers a request that failed before a handler could run. +/// +/// The error reaches the existing request error hooks over the same duplex pipe +/// and the same framing pump a real response takes, so the error handler writes +/// an HTTP/1.1 shaped head that is framed as HTTP/3 on the wire instead of the +/// stream ending silently. +/// +/// # Arguments +/// +/// - `&Arc` - The server whose error hooks answer the request. +/// - `&mut H3Framer` - The framer owning the stream's frames. +/// - `RequestConfig` - The parsing limits and read timeout for the connection. +/// - `SocketAddr` - The local address of the carrying connection. +/// - `HttpVersion` - The real protocol version to stamp on the error context. +/// - `RequestError` - The failure that stopped the request. +/// +pub(crate) async fn h3_fail_request( + server: &Arc, + framer: &mut H3Framer, + request_config: RequestConfig, + local: SocketAddr, + remote: SocketAddr, + version: HttpVersion, + error: RequestError, +) { + let mut ctx: Context = Context::default(); + let mut request: Request = Request::default(); + request.set_version(version.clone()); + ctx.set_request(request); + ctx.get_mut_response().reset().set_version(version); + let (mut stream, peer): (Stream, DuplexStream) = + build_duplex_transport(request_config, Some(local), Some(remote)); + let is_head: bool = false; + let server: Arc = server.clone(); + let (_, pump_result): ((), Result<(), ResponseError>) = join!( + async move { + server + .handle_request_error(&mut stream, &mut ctx, &error) + .await; + drop(stream); + }, + pump_response(framer, peer, RESPONSE_HEAD_MAX, is_head) + ); + if pump_result.is_err() { + framer.reset(); + } +} diff --git a/core/src/protocol/http3/impl.rs b/core/src/protocol/http3/impl.rs new file mode 100644 index 00000000..c116192e --- /dev/null +++ b/core/src/protocol/http3/impl.rs @@ -0,0 +1,144 @@ +use super::*; + +impl H3Framer { + /// Creates a framer over one request stream. + /// + /// # Arguments + /// + /// - `RequestH3Stream` - The request stream to frame onto. + /// + pub(crate) fn new(request_stream: RequestH3Stream) -> Self { + Self { + request_stream: Some(request_stream), + finished: false, + } + } + + /// Resets the real stream after the framing pump failed. + /// + /// Dropping a half written stream without a reset leaves the peer waiting + /// for the frames that will never arrive, so the failure is reported to the + /// client instead of being swallowed. The stream is dropped here, which is + /// what stops this task from holding the request open. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + pub(crate) fn reset(&mut self) { + let Some(mut request_stream) = self.get_mut_request_stream().take() else { + return; + }; + request_stream.stop_stream(Code::H3_INTERNAL_ERROR); + } +} + +impl ResponseFramer for H3Framer { + /// Sends the response head as the first HEADERS frame of the stream. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a FramedHead` - The parsed head to frame. + /// - `bool` - Whether the response forbids a body. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_head<'a>( + &'a mut self, + head: &'a FramedHead, + _bodyless: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let nominated: Vec = connection_nominated_headers(&head.headers); + let mut builder: http::response::Builder = + http::Response::builder().status(head.status_code as u16); + for (name, value) in head.headers.iter() { + let key: &str = name.as_str(); + let nominated_by_connection: bool = nominated + .iter() + .any(|nominated: &String| nominated.as_str() == key); + if key.starts_with(PSEUDO_HEADER_PREFIX) + || is_hop_by_hop(key) + || nominated_by_connection + { + continue; + } + if let Ok(value) = http::HeaderValue::from_bytes(value.as_bytes()) { + builder = builder.header(name.clone(), value); + } + } + let response: http::Response<()> = builder + .body(()) + .map_err(|error: http::Error| ResponseError::Send(error.to_string()))?; + let Some(mut request_stream) = self.get_mut_request_stream().take() else { + return Err(ResponseError::Terminated); + }; + request_stream + .send_response(response) + .await + .map_err(|error: h3::error::StreamError| ResponseError::Send(error.to_string()))?; + self.set_request_stream(Some(request_stream)); + Ok(()) + }) + } + + /// Sends one body chunk as a data frame. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// - `&'a [u8]` - The body bytes to frame. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn send_data<'a>( + &'a mut self, + data: &'a [u8], + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if data.is_empty() || self.get_finished() { + return Ok(()); + } + let Some(mut request_stream) = self.get_mut_request_stream().take() else { + return Err(ResponseError::Terminated); + }; + request_stream + .send_data(Bytes::copy_from_slice(data)) + .await + .map_err(|error: h3::error::StreamError| ResponseError::Send(error.to_string()))?; + self.set_request_stream(Some(request_stream)); + Ok(()) + }) + } + + /// Closes the stream with the end of stream flag. + /// + /// # Arguments + /// + /// - `&'a mut self` - The `value` argument. + /// + /// # Returns + /// + /// - `Pin> + Send + 'a>>` - Resolves once the operation has completed. + fn finish<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if self.get_finished() { + return Ok(()); + } + self.set_finished(true); + let Some(mut request_stream) = self.get_mut_request_stream().take() else { + return Ok(()); + }; + request_stream + .finish() + .await + .map_err(|error: h3::error::StreamError| ResponseError::Send(error.to_string()))?; + Ok(()) + }) + } +} diff --git a/core/src/protocol/http3/mod.rs b/core/src/protocol/http3/mod.rs new file mode 100644 index 00000000..a128df75 --- /dev/null +++ b/core/src/protocol/http3/mod.rs @@ -0,0 +1,7 @@ +mod r#fn; +mod r#impl; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/core/src/protocol/http3/struct.rs b/core/src/protocol/http3/struct.rs new file mode 100644 index 00000000..7d7b29dc --- /dev/null +++ b/core/src/protocol/http3/struct.rs @@ -0,0 +1,20 @@ +use super::*; + +/// Frames a response onto an HTTP/3 request stream as the pump produces it. +/// +/// The framing code moves the QUIC stream out of the field with `take` before +/// it can write on it, so the stream field is reached through its mutable +/// accessor rather than a shared one. +#[derive(Data)] +pub(crate) struct H3Framer { + /// The request stream the frames are written to. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(super) request_stream: Option, + /// Whether the end of stream has already been sent. + #[get(type(copy), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) finished: bool, +} diff --git a/core/src/protocol/lifecycle/impl.rs b/core/src/protocol/lifecycle/impl.rs new file mode 100644 index 00000000..32c1ee6f --- /dev/null +++ b/core/src/protocol/lifecycle/impl.rs @@ -0,0 +1,28 @@ +use super::*; + +impl ServerLifecycle { + /// Creates a lifecycle for one protocol request. + /// + /// # Arguments + /// + /// - `Arc` - The server whose pipeline serves the request. + /// - `Request` - The request converted from the protocol head and body. + /// + pub(crate) fn new(server: Arc, request: Request) -> Self { + Self { server, request } + } + + /// Runs the pipeline and returns once the handler and its writes finish. + /// + /// # Arguments + /// + /// - `Self` - The `stream` argument. + /// - `Stream` - The stream the pipeline writes its response onto. + /// + pub(crate) async fn run(self, mut stream: Stream) { + let mut ctx: Context = Context::default(); + self.get_server() + .run_protocol_lifecycle(&mut stream, &mut ctx, self.get_request()) + .await; + } +} diff --git a/core/src/protocol/lifecycle/mod.rs b/core/src/protocol/lifecycle/mod.rs new file mode 100644 index 00000000..5a079a81 --- /dev/null +++ b/core/src/protocol/lifecycle/mod.rs @@ -0,0 +1,6 @@ +mod r#impl; +mod r#struct; + +pub(crate) use r#struct::*; + +use super::*; diff --git a/core/src/protocol/lifecycle/struct.rs b/core/src/protocol/lifecycle/struct.rs new file mode 100644 index 00000000..55ffefa9 --- /dev/null +++ b/core/src/protocol/lifecycle/struct.rs @@ -0,0 +1,26 @@ +use super::*; + +/// Runs the framework lifecycle for a request that arrived on a protocol stream. +/// +/// The lifecycle is the same one an HTTP/1.1 request runs: the request +/// middleware, the matched route and the response middleware, in that order, +/// against the same context and the same error and panic hooks. It receives a +/// real `Stream` over a duplex pipe rather than a socket, so a handler that +/// writes a response, splits a write or streams takes exactly the code path it +/// takes on HTTP/1.1, and the framing pump observes those writes as they happen. +/// +/// Dropping the stream when the lifecycle returns closes the pipe, which is how +/// the pump learns the response has ended. +#[derive(Data, Debug)] +pub(crate) struct ServerLifecycle { + /// The server whose pipeline serves the request. + #[get(type(clone), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) server: Arc, + /// The request converted from the protocol head and body. + #[get(type(clone), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) request: Request, +} diff --git a/core/src/protocol/mod.rs b/core/src/protocol/mod.rs new file mode 100644 index 00000000..b836c381 --- /dev/null +++ b/core/src/protocol/mod.rs @@ -0,0 +1,17 @@ +mod r#aliases; +mod r#constants; +mod r#framing; +mod r#http2; +mod r#http3; +mod r#lifecycle; +mod r#pump; +mod r#request; +mod r#response; +mod r#transport; + +pub(crate) use { + r#aliases::*, r#constants::*, r#framing::*, r#http2::*, r#http3::*, r#lifecycle::*, r#pump::*, + r#request::*, r#response::*, r#transport::*, +}; + +use super::*; diff --git a/core/src/protocol/pump/fn.rs b/core/src/protocol/pump/fn.rs new file mode 100644 index 00000000..7888b697 --- /dev/null +++ b/core/src/protocol/pump/fn.rs @@ -0,0 +1,194 @@ +use super::*; + +/// Increments a response head and forwards every body chunk that follows it. +/// +/// The pump owns the write side of a protocol stream. It reads the HTTP/1.1 +/// shaped bytes the request lifecycle wrote, frames the head exactly once as +/// soon as the blank line ends the header block, and forwards each chunk after +/// it as its own data frame. Running it alongside the lifecycle, rather than +/// after it, is what lets a raw send, a split send or a server sent event +/// stream reach the client before the handler returns. +/// +/// The framing is decided as early as the bytes allow. A head that cannot be +/// represented on this protocol at all — one announcing a transfer coding the +/// framing does not carry, one with a status code or a header line the protocol +/// cannot encode — is refused before any frame is emitted, so nothing has been +/// committed to the wire. +/// +/// A body that does not match the length it announced is a different case and +/// cannot be caught that early: a short body is only short once the pipe ends. +/// The head and the body bytes read before that point have already been framed, +/// so a truncated or overlong response surfaces as a failed pump with the +/// stream left for the caller to reset, never as a clean `finish()`. The client +/// sees a stream that ends without the bytes the head promised, which is the +/// honest signal; it is not told the response completed. +/// +/// # Arguments +/// +/// - `&mut F` - The `value` argument. +/// - `DuplexStream` - The read end of the pipe the lifecycle writes onto. +/// - `usize` - The maximum number of head bytes buffered while the head is pending. +/// - `bool` - Whether the request method forbids a response body. +/// +/// # Returns +/// +/// - `Result<(), ResponseError>` - `Ok(())` once the lifecycle dropped its end +pub(crate) async fn pump_response( + framer: &mut F, + mut reader: DuplexStream, + max_head_size: usize, + is_head: bool, +) -> Result<(), ResponseError> +where + F: ResponseFramer, +{ + let mut pending: Vec = Vec::new(); + let mut read_chunk: Vec = vec![0; PROTOCOL_BODY_CHUNK]; + let mut head: Option = None; + let mut body_len: usize = 0; + let mut suppress_body: bool = is_head; + let mut announced_len: Option = None; + loop { + let read: usize = tokio::io::AsyncReadExt::read(&mut reader, &mut read_chunk).await?; + if read == 0 { + break; + } + if head.is_none() { + pending.extend_from_slice(&read_chunk[..read]); + let Some(body_start) = find_head_end(&pending) else { + if pending.len() > max_head_size { + return Err(ResponseError::Send(RESPONSE_HEAD_TOO_LONG.to_owned())); + } + continue; + }; + if body_start > max_head_size { + return Err(ResponseError::Send(RESPONSE_HEAD_TOO_LONG.to_owned())); + } + let (status_code, mut headers): (usize, ProtocolHeaders) = + parse_response_head(&pending[..body_start])?; + validate_status_code(status_code)?; + if has_transfer_encoding(&headers) { + return Err(ResponseError::Send( + UNSUPPORTED_TRANSFER_ENCODING.to_owned(), + )); + } + announced_len = parse_content_length(&headers)?; + suppress_body = is_bodyless_status(status_code, is_head); + if hides_entity_length(status_code, is_head) { + headers.remove(CONTENT_LENGTH); + } + let head_body: &[u8] = if suppress_body { + &[] + } else { + &pending[body_start..] + }; + let parsed: FramedHead = FramedHead { + status_code, + headers, + }; + framer.send_head(&parsed, suppress_body).await?; + if !head_body.is_empty() { + let overflowed: bool = check_body_budget(announced_len, body_len, head_body.len()); + body_len += head_body.len(); + if overflowed { + return Err(ResponseError::Send(OVERFLOWING_RESPONSE_BODY.to_owned())); + } + framer.send_data(head_body).await?; + } + head = Some(parsed); + pending.clear(); + continue; + } + if suppress_body { + continue; + } + let overflowed: bool = check_body_budget(announced_len, body_len, read); + body_len += read; + if overflowed { + return Err(ResponseError::Send(OVERFLOWING_RESPONSE_BODY.to_owned())); + } + framer.send_data(&read_chunk[..read]).await?; + } + let Some(head) = head else { + return Err(ResponseError::Send(MISSING_RESPONSE_HEAD.to_owned())); + }; + if !suppress_body + && let Some(announced) = announced_len + && body_len != announced + { + return Err(ResponseError::Send(TRUNCATED_RESPONSE_BODY.to_owned())); + } + framer.finish().await?; + let _framed: FramedHead = head; + let _forwarded: usize = body_len; + Ok(()) +} + +/// Reports whether a response may not announce an entity length. +/// +/// A length may only survive on a response that does promise a representation: +/// a `HEAD` and a `304` both describe the entity a matching `GET` would return +/// and carry no body bytes of their own, so their length is real information +/// about the message and stays. The informational statuses and `204` promise no +/// representation at all, so a length there contradicts the message itself and +/// is dropped — for `HEAD` exactly as it is for every other method, because the +/// status decides this and not the method. +/// +/// # Arguments +/// +/// - `usize` - The response status code. +/// - `bool` - Whether the request method was `HEAD`. +/// +/// # Returns +/// +/// - `bool` - `true` when the head must not carry a content length. +pub(crate) fn hides_entity_length(status_code: usize, _is_head: bool) -> bool { + status_code < HttpStatus::Ok.code() || status_code == HttpStatus::NoContent.code() +} + +/// Reports whether one more body chunk runs past the announced length. +/// +/// The check happens before the chunk is forwarded, so an overlong body is +/// refused at the byte that exceeded the head rather than after the client has +/// already received a body it will treat as complete. +/// +/// # Arguments +/// +/// - `Option` - The length the head announced, if it announced one. +/// - `usize` - The body bytes already forwarded. +/// +/// # Returns +/// +/// - `bool` - `true` when forwarding them would exceed the announced length. +pub(crate) fn check_body_budget( + announced_len: Option, + forwarded: usize, + incoming: usize, +) -> bool { + match announced_len { + Some(announced) => forwarded.saturating_add(incoming) > announced, + None => false, + } +} + +/// Rejects a status code the protocol cannot carry on the wire. +/// +/// A code outside the three digit range is not a status at all, and the framer +/// would fail on it while building the response — after the head has already +/// been accepted as valid. Checking it here keeps one status decision in one +/// place. +/// +/// # Arguments +/// +/// - `usize` - The parsed status code. +/// +/// # Returns +/// +/// - `Result<(), ResponseError>` - `Ok(())` when the protocol may carry the code. +pub(crate) fn validate_status_code(status_code: usize) -> Result<(), ResponseError> { + let is_code: bool = (MIN_STATUS_CODE..=MAX_STATUS_CODE).contains(&status_code); + if !is_code { + return Err(ResponseError::Send(INVALID_RESPONSE_STATUS.to_owned())); + } + Ok(()) +} diff --git a/core/src/protocol/pump/mod.rs b/core/src/protocol/pump/mod.rs new file mode 100644 index 00000000..0c6d0215 --- /dev/null +++ b/core/src/protocol/pump/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/core/src/protocol/pump/struct.rs b/core/src/protocol/pump/struct.rs new file mode 100644 index 00000000..238d01b2 --- /dev/null +++ b/core/src/protocol/pump/struct.rs @@ -0,0 +1,10 @@ +use super::*; + +/// A response head that has been parsed and is ready to be framed. +#[derive(Clone, Debug, DisplayDebug)] +pub(crate) struct FramedHead { + /// The response status code. + pub(crate) status_code: usize, + /// The protocol headers to send as the first frame of the stream. + pub(crate) headers: ProtocolHeaders, +} diff --git a/core/src/protocol/request/fn.rs b/core/src/protocol/request/fn.rs new file mode 100644 index 00000000..1cd8591d --- /dev/null +++ b/core/src/protocol/request/fn.rs @@ -0,0 +1,391 @@ +use super::*; + +/// Builds a framework request from a protocol request head and body. +/// +/// HTTP/2 and HTTP/3 both deliver a decoded header map plus a body stream, and +/// both are converted here into the same request the HTTP/1.1 parser produces, +/// so routing, middleware and hooks above this point cannot tell the protocols +/// apart. The version stamped on the request is the real one, not the default. +/// +/// Pseudo headers and hop by hop headers are dropped rather than trusted, and +/// the security limits from the request config are enforced against the decoded +/// values, so an oversized head is rejected exactly like an oversized HTTP/1.1 +/// head. A repeated header keeps every value instead of collapsing to the last. +/// +/// # Arguments +/// +/// - `&RequestConfig` - The parsing limits to enforce. +/// - `HttpVersion` - The real protocol version to stamp on the request. +/// - `http::Method` - The request method decoded by the protocol library. +/// - `&str` - The `value` argument. +/// - `&http::Uri` - The request URI, which carries the authority. +/// - `&ProtocolHeaders` - The protocol request headers. +/// - `Vec` - The already collected request body. +/// +/// # Returns +/// +/// - `Result` - The converted request, or the first limit violation. +pub fn build_protocol_request( + config: &RequestConfig, + version: HttpVersion, + method: http::Method, + target: &str, + target_uri: &http::Uri, + headers: &ProtocolHeaders, + body: Vec, +) -> Result { + let max_path_size: usize = config.get_max_path_size(); + if target.len() > max_path_size && max_path_size != DEFAULT_LOW_SECURITY_MAX_PATH_SIZE { + return Err(RequestError::PathTooLong(HttpStatus::URITooLong)); + } + let max_body_size: usize = config.get_max_body_size(); + if body.len() > max_body_size && max_body_size != DEFAULT_LOW_SECURITY_MAX_BODY_SIZE { + return Err(RequestError::ContentLengthTooLarge( + HttpStatus::PayloadTooLarge, + )); + } + let mut request: Request = Request::default(); + let query_index: Option = target.find(QUESTION_MARK); + let hash_index: Option = target.find(HASH); + fill_request_querys( + split_target_query(target, query_index, hash_index), + request.get_mut_querys(), + ); + request + .get_mut_path() + .push_str(split_target_path(target, query_index, hash_index)); + request.set_method(protocol_method(method)); + request.set_version(version); + fill_request_headers(headers, config, &mut request)?; + request.set_host(resolve_request_host(headers, target_uri)); + request.get_mut_body().clear(); + request.get_mut_body().extend_from_slice(&body); + Ok(request) +} + +/// Resolves the authority of a protocol request. +/// +/// HTTP/2 and HTTP/3 carry the authority in a pseudo header, while HTTP/1.1 +/// carries it in `host`, so both are consulted before falling back to a value +/// that keeps the request usable rather than empty. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The protocol request headers. +/// - `&http::Uri` - The `uri` argument. +/// +/// # Returns +/// +/// - `String` - The resolved request host. +pub fn resolve_request_host(headers: &ProtocolHeaders, uri: &http::Uri) -> String { + if let Some(authority) = uri.authority() { + return authority.as_str().to_owned(); + } + for name in [HOST, COLON_AUTHORITY] { + if let Some(value) = headers.get(name) + && let Ok(value) = value.to_str() + { + return value.to_owned(); + } + } + EMPTY_STR.to_owned() +} + +/// Extracts the query string from a protocol request target. +/// +/// # Arguments +/// +/// - `&str` - The request target. +/// - `Option` - The index of the `?` separator, if present. +/// +/// # Returns +/// +/// - `&str` - The query portion, or an empty string when the target carries none. +pub fn split_target_query( + target: &str, + query_index: Option, + hash_index: Option, +) -> &str { + query_index.map_or(EMPTY_STR, |query_index: usize| { + let rest: &str = &target[query_index + 1..]; + match hash_index { + None => rest, + Some(hash_index) if hash_index <= query_index => rest, + Some(hash_index) => &rest[..hash_index - query_index - 1], + } + }) +} + +/// Extracts the path portion from a protocol request target. +/// +/// # Arguments +/// +/// - `&str` - The request target. +/// - `Option` - The index of the `?` separator, if present. +/// +/// # Returns +/// +/// - `&str` - The path portion without query or fragment. +pub fn split_target_path( + target: &str, + query_index: Option, + hash_index: Option, +) -> &str { + match query_index.or(hash_index) { + Some(separator_index) => &target[..separator_index], + None => target, + } +} + +/// Parses a query string into the request's query map. +/// +/// A key that repeats keeps every value, because a single valued map would +/// discard all but the last occurrence without reporting anything. +/// +/// # Arguments +/// +/// - `&str` - The query string. +/// - `&mut RequestQuerys` - The map to fill. +/// +pub fn fill_request_querys(query: &str, querys: &mut RequestQuerys) { + if query.is_empty() { + return; + } + for pair in query.split(AND) { + if let Some((key, value)) = pair.split_once(EQUAL) { + if !key.is_empty() { + querys + .entry(key.to_string()) + .or_default() + .push_back(value.to_string()); + } + } else if !pair.is_empty() { + querys.entry(pair.to_string()).or_default(); + } + } +} + +/// Decodes a protocol method into the framework method enum. +/// +/// # Arguments +/// +/// - `http::Method` - The method decoded by the protocol library. +/// +/// # Returns +/// +/// - `RequestMethod` - The framework method. +pub fn protocol_method(method: http::Method) -> RequestMethod { + method + .as_str() + .parse::() + .unwrap_or(Method::Unknown(method.as_str().to_owned())) +} + +/// Copies protocol headers into the request, enforcing the head limits. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The protocol request headers. +/// - `&RequestConfig` - The parsing limits to enforce. +/// - `&mut Request` - The request to fill. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` when every header was accepted, or +pub fn fill_request_headers( + headers: &ProtocolHeaders, + config: &RequestConfig, + request: &mut Request, +) -> Result<(), RequestError> { + let mut header_count: usize = 0; + for (name, value) in headers.iter() { + let key: &str = name.as_str(); + if key.starts_with(PSEUDO_HEADER_PREFIX) || is_hop_by_hop(key) { + continue; + } + let Ok(value) = value.to_str() else { + continue; + }; + header_count += 1; + if header_count > config.get_max_header_count() + && config.get_max_header_count() != DEFAULT_LOW_SECURITY_MAX_HEADER_COUNT + { + return Err(RequestError::TooManyHeaders( + HttpStatus::RequestHeaderFieldsTooLarge, + )); + } + if key.len() > config.get_max_header_key_size() + && config.get_max_header_key_size() != DEFAULT_LOW_SECURITY_MAX_HEADER_KEY_SIZE + { + return Err(RequestError::HeaderKeyTooLong(HttpStatus::BadRequest)); + } + if value.len() > config.get_max_header_value_size() + && config.get_max_header_value_size() != DEFAULT_LOW_SECURITY_MAX_HEADER_VALUE_SIZE + { + return Err(RequestError::HeaderValueTooLong(HttpStatus::BadRequest)); + } + request + .get_mut_headers() + .entry(key.to_owned()) + .or_default() + .push_back(value.to_owned()); + } + Ok(()) +} + +/// Reports whether a header is hop by hop for the HTTP/2 and HTTP/3 framing. +/// +/// # Arguments +/// +/// - `&str` - The lowercased header name. +/// +/// # Returns +/// +/// - `bool` - `true` when the header must not cross the protocol boundary. +pub fn is_hop_by_hop(name: &str) -> bool { + matches!( + name, + CONNECTION + | KEEP_ALIVE + | PROXY_AUTHENTICATE + | PROXY_AUTHORIZATION + | TE + | TRAILER_HEADER + | TRANSFER_ENCODING + | UPGRADE + ) +} + +/// Reads the body length a protocol request declares. +/// +/// A request is not required to declare a length, so an absent header is +/// reported as none and the collected body is accepted on its own terms. A +/// declared length larger than the configured cap is refused before the body is +/// read, and an unparseable length is refused rather than treated as zero, so a +/// request that claims a body it does not send cannot reach a handler. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The protocol request headers. +/// - `usize` - The maximum number of body bytes to accept. +/// +/// # Returns +/// +/// - `Result, RequestError>` - The declared length when one is +pub fn declared_content_length( + headers: &ProtocolHeaders, + max_body_size: usize, +) -> Result, RequestError> { + let Some(value) = headers.get(CONTENT_LENGTH) else { + return Ok(None); + }; + let Ok(value) = value.to_str() else { + return Err(RequestError::InvalidContentLength(HttpStatus::BadRequest)); + }; + let declared: usize = parse_ascii_length(value)?; + if declared > max_body_size && max_body_size != DEFAULT_LOW_SECURITY_MAX_BODY_SIZE { + return Err(RequestError::ContentLengthTooLarge( + HttpStatus::PayloadTooLarge, + )); + } + Ok(Some(declared)) +} + +/// Parses a declared body length from one header field value. +/// +/// The length is a run of ASCII digits and nothing else. `str::parse` accepts +/// a leading `+`, so a header announcing `+1` would otherwise be read as `1` — +/// a value the sender could not have meant, and one that makes the declared +/// length disagree with the bytes that follow. An empty value is refused for +/// the same reason: it is not a length at all, and treating it as zero would +/// turn a malformed head into a truncated-but-successful request. +/// +/// # Arguments +/// +/// - `&str` - The raw header field value. +/// +/// # Returns +/// +/// - `Result` - The declared length, or the refusal. +pub(crate) fn parse_ascii_length(value: &str) -> Result { + let digits: &str = value.trim(); + if digits.is_empty() || !digits.bytes().all(|byte: u8| byte.is_ascii_digit()) { + return Err(RequestError::InvalidContentLength(HttpStatus::BadRequest)); + } + digits + .parse::() + .map_err(|_: ParseIntError| RequestError::InvalidContentLength(HttpStatus::BadRequest)) +} + +/// Reports whether a status code forbids a response body. +/// +/// A `HEAD` response carries the length the matching `GET` would have but no +/// bytes, and the no-content statuses carry none either. +/// +/// # Arguments +/// +/// - `usize` - The response status code. +/// - `bool` - Whether the request method was `HEAD`. +/// +/// # Returns +/// +/// - `bool` - `true` when no body bytes may be emitted. +pub fn is_bodyless_status(status_code: usize, is_head: bool) -> bool { + is_head + || status_code < 200 + || status_code == HttpStatus::NoContent.code() + || status_code == HttpStatus::NotModified.code() +} + +/// Collects a protocol request body into a bounded buffer. +/// +/// The framework request carries its body as bytes, so the stream is drained +/// into a single buffer. The cap is enforced as the body grows rather than +/// after it is complete, so an oversized upload is rejected while it is being +/// read instead of after it has already been buffered. +/// +/// # Arguments +/// +/// - `usize` - The maximum number of body bytes to accept. +/// - `&mut Vec` - The buffer to append into. +/// - `Bytes` - The `value` argument. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` while the body stays within the cap, +pub fn push_bounded_body( + limit: usize, + body: &mut Vec, + chunk: Bytes, +) -> Result<(), RequestError> { + if body.len() + chunk.len() > limit && limit != DEFAULT_LOW_SECURITY_MAX_BODY_SIZE { + return Err(RequestError::ContentLengthTooLarge( + HttpStatus::PayloadTooLarge, + )); + } + body.extend_from_slice(chunk.as_ref()); + Ok(()) +} + +/// Joins a request path and its query into the target carried on the wire. +/// +/// # Arguments +/// +/// - `&str` - The request path. +/// - `Option<&str>` - The request query string, when one is present. +/// +/// # Returns +/// +/// - `String` - The target including its query string and query separator. +pub fn request_target(path: &str, query: Option<&str>) -> String { + match query { + Some(query) if !query.is_empty() => { + let mut target: String = String::with_capacity(path.len() + query.len() + 1); + target.push_str(path); + target.push(QUESTION_MARK); + target.push_str(query); + target + } + _ => path.to_owned(), + } +} diff --git a/core/src/protocol/request/mod.rs b/core/src/protocol/request/mod.rs new file mode 100644 index 00000000..910cac30 --- /dev/null +++ b/core/src/protocol/request/mod.rs @@ -0,0 +1,5 @@ +mod r#fn; + +pub(crate) use r#fn::*; + +use super::*; diff --git a/core/src/protocol/response/fn.rs b/core/src/protocol/response/fn.rs new file mode 100644 index 00000000..b13a6400 --- /dev/null +++ b/core/src/protocol/response/fn.rs @@ -0,0 +1,236 @@ +use super::*; + +/// Finds the end of an HTTP/1.1 response head inside buffered bytes. +/// +/// The header block is terminated by a bare line ending, so a complete head ends +/// at the first empty line. When no such line has arrived the write only extends +/// the buffer and no head is framed yet, which is what keeps a head that arrives +/// in several writes from being framed as a truncated one. +/// +/// # Arguments +/// +/// - `&[u8]` - The buffered response bytes. +/// +/// # Returns +/// +/// - `Option` - The byte offset where the body starts, when a complete +pub fn find_head_end(buffer: &[u8]) -> Option { + let mut index: usize = 0; + while index < buffer.len() { + let rest: &[u8] = &buffer[index..]; + let line_len: usize = find_line_end(rest)?; + let is_blank: bool = rest[..line_len] + .iter() + .all(|byte: &u8| *byte == CR_U8 || *byte == LF_U8); + if is_blank { + return Some(index + line_len); + } + index += line_len; + } + None +} + +/// Finds the length of the next line, including its terminator. +/// +/// # Arguments +/// +/// - `&[u8]` - The bytes starting at a line boundary. +/// +/// # Returns +/// +/// - `Option` - The line length including its terminator, or `None` when +pub fn find_line_end(bytes: &[u8]) -> Option { + bytes + .iter() + .position(|byte: &u8| *byte == LF_U8) + .map(|index: usize| index + 1) +} + +/// Parses a serialized response head into a status code and protocol headers. +/// +/// The status line contributes the status code and each header line is decoded +/// on its own. Every line is checked: a head that is already committed to the +/// wire cannot be taken back, so dropping a line the parser does not understand +/// would ship a response that silently lost a header. A line without a +/// separator, a name that is not a valid header name, or a value carrying a byte +/// a field value cannot hold is refused instead. +/// +/// A repeated header is appended rather than replaced, so a second `Set-Cookie` +/// survives the parse as its own field value. +/// +/// Any HTTP version is accepted in the status line, including the HTTP/2 and +/// HTTP/3 forms the framework writes for a request that arrived on a +/// multiplexed stream. The version on the status line describes the internal +/// serialization of the response object, not the version this stream is +/// answered with, which stays the real protocol on the wire. +/// +/// # Arguments +/// +/// - `&[u8]` - The response head bytes, up to and including the blank line. +/// +/// # Returns +/// +/// - `Result<(usize, ProtocolHeaders), ResponseError>` - The status code and the +pub fn parse_response_head(head: &[u8]) -> Result<(usize, ProtocolHeaders), ResponseError> { + let text: &str = + from_utf8(head).map_err(|error: Utf8Error| ResponseError::Send(error.to_string()))?; + let mut lines: SplitLines<'_> = text.split(CRLF); + let status_line: &str = lines.next().unwrap_or(EMPTY_STR); + let mut fields: SplitWhitespace<'_> = status_line.split_whitespace(); + let version: &str = fields.next().unwrap_or(EMPTY_STR); + if !version.starts_with(HTTP_VERSION_PREFIX) { + return Err(ResponseError::Send(MISSING_RESPONSE_VERSION.to_owned())); + } + let status_text: &str = fields.next().unwrap_or(EMPTY_STR); + if status_text.is_empty() { + return Err(ResponseError::Send(MISSING_RESPONSE_STATUS.to_owned())); + } + let status_code: usize = status_text + .parse::() + .map_err(|error: ParseIntError| ResponseError::Send(error.to_string()))?; + let mut headers: ProtocolHeaders = http::HeaderMap::new(); + for line in lines { + let line: &str = line.trim_end_matches(LF_CHAR); + if line.is_empty() { + continue; + } + let Some((name, value)) = line.split_once(COLON) else { + return Err(ResponseError::Send(MALFORMED_RESPONSE_HEADER.to_owned())); + }; + let name: &str = name.trim(); + if name.is_empty() { + return Err(ResponseError::Send(MALFORMED_RESPONSE_HEADER.to_owned())); + } + let Ok(header_name) = http::HeaderName::from_bytes(name.as_bytes()) else { + return Err(ResponseError::Send(MALFORMED_RESPONSE_HEADER.to_owned())); + }; + let Ok(header_value) = http::HeaderValue::from_bytes(value.trim().as_bytes()) else { + return Err(ResponseError::Send(MALFORMED_RESPONSE_HEADER.to_owned())); + }; + headers.append(header_name, header_value); + } + Ok((status_code, headers)) +} + +/// Reads the transfer coding a response head announces, if it announces one. +/// +/// Only `chunked` can describe a body whose length the head does not carry, and +/// neither HTTP/2 nor HTTP/3 can carry it: their data frames are the framing. +/// A head that announces any coding at all is therefore rejected by the pump +/// before a single frame is emitted, rather than stripped and delivered as a +/// body the client would have to decode. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The decoded response headers. +/// +/// # Returns +/// +/// - `bool` - `true` when the head carries a `transfer-encoding` field. +pub fn has_transfer_encoding(headers: &ProtocolHeaders) -> bool { + headers.contains_key(TRANSFER_ENCODING) +} + +/// Reads the body length a response head announces. +/// +/// A head may repeat `content-length`; every occurrence is validated the same +/// way and the values must agree, because two different lengths describe two +/// different bodies and picking one of them hides the disagreement from the +/// client. A repeated field repeating the same value is accepted rather than +/// refused — the request parser makes the same allowance, and a duplicate that +/// agrees is not a disagreement. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The decoded response headers. +/// +/// # Returns +/// +/// - `Result, ResponseError>` - The announced length, `None` when +pub fn parse_content_length(headers: &ProtocolHeaders) -> Result, ResponseError> { + let mut announced: Option = None; + for value in headers.get_all(CONTENT_LENGTH).iter() { + let Ok(value) = value.to_str() else { + return Err(ResponseError::Send(MALFORMED_RESPONSE_HEADER.to_owned())); + }; + let length: usize = parse_ascii_length(value) + .map_err(|error: RequestError| ResponseError::Send(error.to_string()))?; + match announced { + Some(previous) if previous != length => { + return Err(ResponseError::Send(OVERFLOWING_RESPONSE_BODY.to_owned())); + } + _ => announced = Some(length), + } + } + Ok(announced) +} + +/// Collects the headers a `Connection` field nominates as hop by hop. +/// +/// A `Connection` header may name further headers that are hop by hop for this +/// message only, so removing the fixed list alone would forward a header the +/// sender explicitly scoped to the connection that is no longer being +/// negotiated. +/// +/// The `connection` field itself is part of the answer: forwarding it would ask +/// the next hop to honour a connection token that no longer describes anything, +/// so it is nominated along with everything it names. +/// +/// # Arguments +/// +/// - `&ProtocolHeaders` - The headers to inspect. +/// +/// # Returns +/// +/// - `Vec` - The lowercased header names nominated by `Connection`. +pub fn connection_nominated_headers(headers: &ProtocolHeaders) -> Vec { + let mut nominated: Vec = vec![CONNECTION.to_owned()]; + for value in headers.get_all(CONNECTION).iter() { + let Ok(value) = value.to_str() else { + continue; + }; + for token in value.split(COMMA_STR) { + let token: String = token.trim().to_ascii_lowercase(); + if !token.is_empty() && !nominated.contains(&token) { + nominated.push(token); + } + } + } + nominated +} + +/// Waits until the send stream may carry at least one more byte. +/// +/// The granted capacity can be smaller than the amount requested, so the +/// returned value is what the caller may send now, not what it asked for. A +/// stream that closes before granting is a failure, not a grant of everything +/// the caller asked for: reporting `wanted` there would hand the framer a +/// window the peer never granted and turn a dead stream into an unbounded loop. +/// +/// # Arguments +/// +/// - `&mut h2::SendStream` - The stream half to reserve capacity on. +/// - `usize` - The number of bytes the caller intends to send. +/// +/// # Returns +/// +/// - `Result` - The granted capacity, or the first +pub(crate) async fn next_h2_capacity( + send_stream: &mut h2::SendStream, + wanted: usize, +) -> Result { + send_stream.reserve_capacity(wanted); + loop { + let granted: Option> = + poll_fn(|context: &mut task::Context<'_>| send_stream.poll_capacity(context)).await; + match granted { + Some(Ok(capacity)) => { + if capacity > 0 { + return Ok(capacity.min(wanted)); + } + } + Some(Err(error)) => return Err(ResponseError::Send(error.to_string())), + None => return Err(ResponseError::Send(MISSING_SEND_CAPACITY.to_owned())), + } + } +} diff --git a/core/src/protocol/response/mod.rs b/core/src/protocol/response/mod.rs new file mode 100644 index 00000000..910cac30 --- /dev/null +++ b/core/src/protocol/response/mod.rs @@ -0,0 +1,5 @@ +mod r#fn; + +pub(crate) use r#fn::*; + +use super::*; diff --git a/core/src/protocol/transport/fn.rs b/core/src/protocol/transport/fn.rs new file mode 100644 index 00000000..8b31b3c4 --- /dev/null +++ b/core/src/protocol/transport/fn.rs @@ -0,0 +1,28 @@ +use super::*; + +/// Builds the transport pair one protocol request stream runs over. +/// +/// # Arguments +/// +/// - `RequestConfig` - The parsing limits and read timeout for the stream. +/// - `Option` - The local address of the carrying connection. +/// +/// # Returns +/// +/// - `(Stream, DuplexStream)` - The pipeline's stream and the pump's read end. +pub(crate) fn build_duplex_transport( + request_config: RequestConfig, + local: Option, + remote: Option, +) -> (Stream, DuplexStream) { + let (pipe, peer): (DuplexStream, DuplexStream) = tokio::io::duplex(PROTOCOL_STREAM_CAPACITY); + let transport: DuplexTransport = DuplexTransport { + pipe, + local, + remote, + }; + ( + Stream::from_transport(transport, request_config, false), + peer, + ) +} diff --git a/core/src/protocol/transport/impl.rs b/core/src/protocol/transport/impl.rs new file mode 100644 index 00000000..e560610e --- /dev/null +++ b/core/src/protocol/transport/impl.rs @@ -0,0 +1,248 @@ +use super::*; + +impl Transport for DuplexTransport { + /// Reports the address of the remote end of the carrying connection. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + /// # Returns + /// + /// - `Result` - The connection's remote address. + fn peer_addr(&self) -> Result { + self.get_remote() + .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, NO_ADDRESS)) + } + + /// Reports the address of the local end of the carrying connection. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + /// # Returns + /// + /// - `Result` - The connection's local address. + fn local_addr(&self) -> Result { + self.get_local() + .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, NO_ADDRESS)) + } +} + +impl AsyncRead for DuplexTransport { + /// Reads from the half the framing pump writes nothing into. + /// + /// The lifecycle never reads a response back, so the read half is drained to + /// `EOF` rather than blocking the pipeline on a write that will never come. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned reader. + /// - `&mut task::Context<'_>` - The task context. + /// - `&mut ReadBuf<'_>` - The buffer to read into. + /// + /// # Returns + /// + /// - `Poll>` - Ready with zero bytes once drained. + fn poll_read( + self: Pin<&mut Self>, + _context: &mut task::Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + buffer.advance(0); + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for DuplexTransport { + /// Writes response bytes into the pipe the framing pump reads. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// - `&[u8]` - The bytes to write. + /// + /// # Returns + /// + /// - `Poll>` - Ready with the number of bytes written. + fn poll_write( + self: Pin<&mut Self>, + context: &mut task::Context<'_>, + bytes: &[u8], + ) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_pipe()).poll_write(context, bytes) + } + + /// Flushes the pipe, which the duplex pair propagates to the pump. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// + /// # Returns + /// + /// - `Poll>` - Ready once the pipe is flushed. + fn poll_flush(self: Pin<&mut Self>, context: &mut task::Context<'_>) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_pipe()).poll_flush(context) + } + + /// Shuts the write half down, which the pump observes as end of stream. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// + /// # Returns + /// + /// - `Poll>` - Ready once the pipe is shut down. + fn poll_shutdown( + self: Pin<&mut Self>, + context: &mut task::Context<'_>, + ) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_pipe()).poll_shutdown(context) + } +} + +impl TlsTransport { + /// Wraps a negotiated TLS session. + /// + /// # Arguments + /// + /// - `tokio_rustls::server::TlsStream` - The negotiated session. + /// + pub(crate) fn new(session: tokio_rustls::server::TlsStream) -> Self { + Self { session } + } +} + +impl TlsTransport { + /// Borrows the negotiated session. + /// + /// The generated accessors cannot express this read: the transport only + /// ever lends the session out, so it is handed back as a shared reference + /// rather than by value, and a `&self` method has no mutable accessor to + /// take it through. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + /// # Returns + /// + /// - `&tokio_rustls::server::TlsStream` - The negotiated session. + pub(crate) fn get_session_ref(&self) -> &tokio_rustls::server::TlsStream { + &self.session + } +} + +impl Transport for TlsTransport { + /// Reports the address of the remote end of the session. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + /// # Returns + /// + /// - `Result` - The peer address of the socket. + fn peer_addr(&self) -> Result { + TcpStream::peer_addr(self.get_session_ref().get_ref().0) + } + + /// Reports the address of the local end of the session. + /// + /// # Arguments + /// + /// - `&Self` - The `value` argument. + /// + /// # Returns + /// + /// - `Result` - The local address of the socket. + fn local_addr(&self) -> Result { + TcpStream::local_addr(self.get_session_ref().get_ref().0) + } +} + +impl AsyncRead for TlsTransport { + /// Reads decrypted bytes from the negotiated session. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned reader. + /// - `&mut task::Context<'_>` - The task context. + /// - `&mut ReadBuf<'_>` - The buffer to read into. + /// + /// # Returns + /// + /// - `Poll>` - Ready with the bytes read. + fn poll_read( + self: Pin<&mut Self>, + context: &mut task::Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_session()).poll_read(context, buffer) + } +} + +impl AsyncWrite for TlsTransport { + /// Writes encrypted bytes onto the negotiated session. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// - `&[u8]` - The bytes to write. + /// + /// # Returns + /// + /// - `Poll>` - Ready with the number of bytes written. + fn poll_write( + self: Pin<&mut Self>, + context: &mut task::Context<'_>, + bytes: &[u8], + ) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_session()).poll_write(context, bytes) + } + + /// Flushes the negotiated session. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// + /// # Returns + /// + /// - `Poll>` - Ready once the session is flushed. + fn poll_flush(self: Pin<&mut Self>, context: &mut task::Context<'_>) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_session()).poll_flush(context) + } + + /// Shuts the negotiated session down. + /// + /// # Arguments + /// + /// - `Pin<&mut Self>` - The pinned writer. + /// - `&mut task::Context<'_>` - The task context. + /// + /// # Returns + /// + /// - `Poll>` - Ready once the session is shut down. + fn poll_shutdown( + self: Pin<&mut Self>, + context: &mut task::Context<'_>, + ) -> Poll> { + let this: &mut Self = self.get_mut(); + Pin::new(&mut this.get_mut_session()).poll_shutdown(context) + } +} diff --git a/core/src/protocol/transport/mod.rs b/core/src/protocol/transport/mod.rs new file mode 100644 index 00000000..a128df75 --- /dev/null +++ b/core/src/protocol/transport/mod.rs @@ -0,0 +1,7 @@ +mod r#fn; +mod r#impl; +mod r#struct; + +pub(crate) use {r#fn::*, r#struct::*}; + +use super::*; diff --git a/core/src/protocol/transport/struct.rs b/core/src/protocol/transport/struct.rs new file mode 100644 index 00000000..3937fe78 --- /dev/null +++ b/core/src/protocol/transport/struct.rs @@ -0,0 +1,49 @@ +use super::*; + +/// A transport that carries an HTTP/2 or HTTP/3 request stream to the pipeline. +/// +/// HTTP/2 and HTTP/3 deliver a request as a bidirectional stream, and the +/// framework pipeline writes HTTP/1.1 shaped bytes onto it: a status line, a +/// header block, then body bytes. A duplex pipe joins the two halves: the +/// lifecycle writes into one end exactly as it would write to a socket, and a +/// concurrent pump reads the other end, frames the head once it is complete and +/// forwards every following chunk as a data frame. A handler that flushes +/// progressively therefore reaches the client before it returns, which a sink +/// that collected everything and framed it afterwards could not do. +/// +/// The transport itself performs no framing. It only exposes the pipe to the +/// framework and reports the addresses of the connection carrying it. +#[derive(Data, Debug)] +pub(crate) struct DuplexTransport { + /// The half the framework pipeline writes onto. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(super) pipe: DuplexStream, + /// The local address of the connection carrying this stream. + #[get(type(copy), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) local: Option, + /// The remote address of the connection carrying this stream. + #[get(type(copy), pub(crate))] + #[get_mut(skip)] + #[set(pub(crate))] + pub(super) remote: Option, +} + +/// Wraps a TLS session so it satisfies the framework transport contract. +/// +/// An HTTP/1.1 request over TLS must present the same `Stream` surface as a +/// plaintext one, so the negotiated session is carried with the address +/// information the framework reads from it. The session is the real socket, +/// so keep alive, upgrades and raw writes behave exactly as they do in +/// plaintext. +#[derive(Data, Debug)] +pub(crate) struct TlsTransport { + /// The negotiated TLS session over the accepted socket. + #[get(skip)] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(super) session: tokio_rustls::server::TlsStream, +} diff --git a/core/src/route/struct.rs b/core/src/route/struct.rs index 52caa0e3..f3ffa681 100644 --- a/core/src/route/struct.rs +++ b/core/src/route/struct.rs @@ -19,21 +19,21 @@ pub struct RoutePattern( /// 3. `regex_route` - For complex matching based on regular expressions. /// /// When a request comes in, the matcher checks these categories in order to find the appropriate hook. -#[derive(Clone, CustomDebug, DisplayDebug, Getter, GetterMut, Setter)] +#[derive(Clone, CustomDebug, Data, DisplayDebug)] pub struct RouteMatcher { /// A hash map for storing and quickly retrieving handlers for static route. /// These are route without any variable path segments. #[set(skip)] #[debug(skip)] - pub(super) static_route: ServerHookMap, + pub static_route: ServerHookMap, /// A layered map of dynamic routes grouped by segment count. /// Routes are organized by path segment count for efficient filtering during matching. #[set(skip)] #[debug(skip)] - pub(super) dynamic_route: ServerHookPatternRoute, + pub dynamic_route: ServerHookPatternRoute, /// A layered map of regex routes grouped by segment count. /// Routes with tail regex patterns can match paths with more segments. #[set(skip)] #[debug(skip)] - pub(super) regex_route: ServerHookPatternRoute, + pub regex_route: ServerHookPatternRoute, } diff --git a/core/src/server/impl.rs b/core/src/server/impl.rs index 0afe14f4..78b3fc2f 100644 --- a/core/src/server/impl.rs +++ b/core/src/server/impl.rs @@ -537,61 +537,6 @@ impl Server { Self::flush_stderr(); } - /// Spawns a task handler for a given stream and hook. - /// - /// # Arguments - /// - /// - `&'static self` - The server instance whose task-panic hooks are used. - /// - `usize` - The addresses of the stream and of the context. - /// - `F` - The hook to execute. - /// - /// # Safety - /// - /// - The address is guaranteed to be a valid `Context` instance - /// that was previously converted from a reference and is managed by the runtime. - async fn task_handler(&'static self, stream_address: usize, ctx_address: usize, hook: F) - where - F: Future + Send + 'static, - { - if let Err(error) = spawn(hook).await - && error.is_panic() - { - let ctx: &mut Context = ctx_address.into(); - let stream: &mut Stream = stream_address.into(); - let panic: PanicData = PanicData::from_join_error(error); - ctx.set_task_panic(panic) - .get_mut_response() - .set_status_code(HttpStatus::InternalServerError.code()); - stream.set_closed(false); - for hook in self.get_task_panic().iter() { - if hook(stream, ctx).await.is_reject() { - break; - } - } - unsafe { - let _: Box = Box::from_raw(ctx); - let _: Box = Box::from_raw(stream); - } - }; - } - - /// Configures socket options for a newly accepted `TcpStream`. - /// - /// This applies settings like `TCP_NODELAY`, and `IP_TTL` from the server's configuration. - /// - /// # Arguments - /// - /// - `&TcpStream` - A reference to the `TcpStream` to configure. - fn configure_stream(&self, stream: &TcpStream) { - let config: &ServerConfig = self.get_server_config(); - if let Some(nodelay) = config.try_get_nodelay() { - let _: Result<(), io::Error> = stream.set_nodelay(nodelay); - } - if let Some(ttl) = config.try_get_ttl() { - let _: Result<(), io::Error> = stream.set_ttl(ttl); - } - } - /// Executes trait-based request middleware in sequence. /// /// # Arguments @@ -699,7 +644,12 @@ impl Server { /// # Returns /// /// - `bool` - A boolean indicating whether the connection should be kept alive. - async fn request_hook(&self, stream: &mut Stream, ctx: &mut Context, request: Request) -> bool { + pub(crate) async fn request_hook( + &self, + stream: &mut Stream, + ctx: &mut Context, + request: Request, + ) -> bool { let keep_alive: bool = request.is_enable_keep_alive(); let version: RequestVersion = request.get_version().clone(); let route: RequestPath = request.get_path().clone(); @@ -751,16 +701,14 @@ impl Server { /// Handles a single client connection, determining whether it's an HTTP or WebSocket request. /// /// It reads the initial request from the stream and dispatches it to the appropriate hook. + /// Both arguments are borrowed: the stream and the context belong to the + /// caller, so this leaves them alive for whatever runs next — the panic + /// hooks after an unwind, or the next request on a keep alive connection. /// /// # Arguments /// /// - `&mut Stream` - The `Stream` for the current request. /// - `&mut Context` - The `Context` for the current request. - /// - /// # Safety - /// - /// - The `ctx` is a valid pointer to a `Context` that was - /// originally created via `Box::into_raw` and is now being reclaimed. async fn handle_connection(&self, stream: &mut Stream, ctx: &mut Context) { match stream.try_get_http_request().await { Ok(request) => { @@ -770,62 +718,182 @@ impl Server { self.handle_request_error(stream, ctx, &error).await; } } - unsafe { - let _: Box = Box::from_raw(ctx); - let _: Box = Box::from_raw(stream); + } + + /// Serves one HTTP/1.1 connection through the existing keep alive pipeline. + /// + /// This is the same pipeline a plaintext connection has always used: the + /// request parser runs in a loop, so keep alive, upgrades and raw writes + /// behave exactly as they did before TLS and the multiplexed protocols + /// existed. A handler that panics is caught here and reported through the + /// existing task panic hooks, so the accept loop survives it and the hooks + /// see the panic while the stream and context are still alive. + /// + /// # Arguments + /// + /// - `&mut Stream` - The stream carrying the connection. + /// + /// # Returns + /// + /// - `()` - Resolves once the connection is finished. + pub(crate) async fn serve_h1_connection_stream(&self, stream: &mut Stream) { + let mut ctx: Context = Context::default(); + let panicked: Option = + catch_connection_panic(self.handle_connection(stream, &mut ctx)).await; + let Some(panic) = panicked else { + return; + }; + ctx.set_task_panic(panic) + .get_mut_response() + .set_status_code(HttpStatus::InternalServerError.code()); + stream.set_closed(false); + for hook in self.get_task_panic().iter() { + if hook(stream, &mut ctx).await.is_reject() { + break; + } } } - /// Enters a loop to accept incoming TCP connections and spawn handlers for them. + /// Serves one request that arrived on a multiplexed protocol stream. + /// + /// The lifecycle is the one an HTTP/1.1 request runs: the request + /// middleware, the matched route and the response middleware, in that + /// order, against the same context and the same error hook. A panic inside + /// it is caught the same way a connection panic is. /// /// # Arguments /// - /// - `&'static self` - The server instance that owns the accept loop. - /// - `&TcpListener` - A reference to the `TcpListener` to accept connections from. - async fn tcp_accept(&'static self, tcp_listener: &TcpListener) { - loop { - if let Ok((stream, _)) = tcp_listener.accept().await { - self.configure_stream(&stream); - let request_config: RequestConfig = *self.get_request_config(); - let stream: &'static mut Stream = - Box::leak(Box::new(Stream::new(stream, request_config, false))); - let ctx: &'static mut Context = Box::leak(Box::new(Context::default())); - spawn(self.task_handler( - stream.into(), - ctx.into(), - self.handle_connection(stream, ctx), - )); + /// - `&mut Stream` - The stream the pipeline writes its response onto. + /// - `&mut Context` - The context for the current request. + /// - `Request` - The request converted from the protocol head and body. + /// + /// # Returns + /// + /// - `()` - Resolves once the pipeline has finished writing. + pub(crate) async fn run_protocol_lifecycle( + &self, + stream: &mut Stream, + ctx: &mut Context, + request: Request, + ) { + let panicked: Option = catch_connection_panic(async { + let _keep_alive: bool = self.request_hook(stream, ctx, request).await; + }) + .await; + let Some(panic) = panicked else { + return; + }; + ctx.set_task_panic(panic) + .get_mut_response() + .set_status_code(HttpStatus::InternalServerError.code()); + stream.set_closed(false); + for hook in self.get_task_panic().iter() { + if hook(stream, ctx).await.is_reject() { + break; } } } - /// Starts the server, binds to the configured address, and begins listening for connections. + /// Starts the server, binds to the configured address, and begins listening. + /// + /// The configured protocol selects the listener. HTTP/1.1 and HTTP/2 share + /// the TCP listener, the latter speaking its own preface and optionally + /// wrapping the connection in TLS, while HTTP/3 binds a QUIC UDP endpoint. + /// Invalid TLS material and an HTTP/3 configuration without TLS are both + /// rejected before the socket is bound, so a misconfigured server fails to + /// start rather than accepting connections it cannot serve. /// - /// This is the main entry point to launch the server. It will initialize the panic hook, - /// create a TCP listener, and then enter the connection acceptance loop in a background task. + /// The accept loop owns a clone of the server instead of borrowing it, so + /// running a short lived `Server` stays safe and dropping it leaves no + /// leaked allocation behind. /// /// # Returns /// - /// Returns a `Result` containing a shutdown function on success. - /// Calling this function will shut down the server by aborting its main task. - /// Returns an error if the server fails to start. + /// - `Result>` - The shutdown hook on + /// success, or the failure that prevented the server from binding. pub async fn run(&self) -> Result> { - let bind_address: &String = self.get_server_config().get_address(); - let tcp_listener: TcpListener = TcpListener::bind(&bind_address) - .await - .map_err(|error: io::Error| Box::new(ServerError::from(error)))?; - let server: &'static Self = unsafe { self.leak() }; - let (wait_sender, wait_receiver) = channel(()); - let (shutdown_sender, mut shutdown_receiver) = channel(()); - let accept_connections: JoinHandle<()> = spawn(async move { - server.tcp_accept(&tcp_listener).await; - let _: Result<(), tokio::sync::watch::error::SendError<()>> = wait_sender.send(()); - }); + let server_config: &ServerConfig = self.get_server_config(); + let protocol: ServerProtocol = server_config.get_protocol(); + let tls_config: Option = server_config.get_tls().clone(); + if protocol == ServerProtocol::Http3 && tls_config.is_none() { + return Err(Box::new(ServerError::MissingTlsForHttp3)); + } + let stream_tls: Option> = match tls_config.clone() { + Some(_) if protocol == ServerProtocol::Http3 => None, + Some(config) => Some(build_stream_tls_config(&config, &protocol)?), + None => None, + }; + let bind_address: &String = server_config.get_address(); + let server: Arc = Arc::new(Self::clone(self)); + let request_config: RequestConfig = *self.get_request_config(); + let (shutdown_sender, shutdown_receiver) = channel(()); + let (running_sender, running_receiver) = channel(SERVER_RUNNING); + let _: JoinHandle<()> = match protocol { + ServerProtocol::Http3 => { + let Some(config) = tls_config.clone() else { + return Err(Box::new(ServerError::MissingTlsForHttp3)); + }; + let quic_config: Arc = Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(build_quic_tls_config( + &config, + )?) + .map_err( + |error: quinn::crypto::rustls::NoInitialCipherSuite| { + Box::new(ServerError::Tls(format!( + "no compatible cipher suite for quic: {}", + error + ))) + }, + )?, + ); + let bind_addr: SocketAddr = + bind_address + .parse::() + .map_err(|error: AddrParseError| { + Box::new(ServerError::Tls(format!( + "invalid bind address {}: {}", + bind_address, error + ))) + })?; + let endpoint: Endpoint = + Endpoint::server(quinn::ServerConfig::with_crypto(quic_config), bind_addr) + .map_err(|error: io::Error| Box::new(ServerError::from(error)))?; + spawn(async move { + let _: Result<(), Box> = quic_accept( + server, + request_config, + endpoint, + shutdown_receiver, + running_sender, + ) + .await; + }) + } + _ => { + let tcp_listener: TcpListener = TcpListener::bind(&bind_address) + .await + .map_err(|error: io::Error| Box::new(ServerError::from(error)))?; + spawn(async move { + tcp_accept( + server, + request_config, + protocol, + stream_tls, + tcp_listener, + shutdown_receiver, + running_sender, + ) + .await; + }) + } + }; let wait_hook: ServerControlHookHandler<()> = Arc::new(move || { - let mut wait_receiver_clone: Receiver<()> = wait_receiver.clone(); + let mut running_receiver_clone: Receiver = running_receiver.clone(); Box::pin(async move { - let _: Result<(), tokio::sync::watch::error::RecvError> = - wait_receiver_clone.changed().await; + while !*running_receiver_clone.borrow_and_update() { + let _: Result<(), tokio::sync::watch::error::RecvError> = + running_receiver_clone.changed().await; + } }) }); let shutdown_hook: ServerControlHookHandler<()> = Arc::new(move || { @@ -835,11 +903,6 @@ impl Server { shutdown_sender_clone.send(()); }) }); - spawn(async move { - let _: Result<(), tokio::sync::watch::error::RecvError> = - shutdown_receiver.changed().await; - accept_connections.abort(); - }); let mut server_control_hook: ServerControlHook = ServerControlHook::default(); server_control_hook.set_shutdown_hook(shutdown_hook); server_control_hook.set_wait_hook(wait_hook); diff --git a/core/src/server/struct.rs b/core/src/server/struct.rs index a052a147..0bb9eaa3 100644 --- a/core/src/server/struct.rs +++ b/core/src/server/struct.rs @@ -7,26 +7,26 @@ use super::*; #[derive(Clone, CustomDebug, Data, DisplayDebug)] pub struct Server { /// Stores the server's configuration settings, such as address, port, and timeouts. - pub(super) server_config: ServerConfig, + pub server_config: ServerConfig, /// The configuration for HTTP request. - pub(super) request_config: RequestConfig, + pub request_config: RequestConfig, /// The routing component responsible for matching incoming requests to their registered handlers. #[set(skip)] - pub(super) route_matcher: RouteMatcher, + pub route_matcher: RouteMatcher, /// A collection of request error handlers that are invoked when a request error occurs during HTTP request processing. #[debug(skip)] #[set(skip)] - pub(super) request_error: ServerHookList, + pub request_error: ServerHookList, /// A collection of task panic handlers that are invoked when a panic occurs during request processing. #[debug(skip)] #[set(skip)] - pub(super) task_panic: ServerHookList, + pub task_panic: ServerHookList, /// A collection of request middleware handlers. #[debug(skip)] #[set(skip)] - pub(super) request_middleware: ServerHookList, + pub request_middleware: ServerHookList, /// A collection of response middleware handlers. #[debug(skip)] #[set(skip)] - pub(super) response_middleware: ServerHookList, + pub response_middleware: ServerHookList, } diff --git a/core/src/tls/fn.rs b/core/src/tls/fn.rs new file mode 100644 index 00000000..e335a088 --- /dev/null +++ b/core/src/tls/fn.rs @@ -0,0 +1,101 @@ +use super::*; + +/// Builds a rustls server configuration from the configured TLS material. +/// +/// Only one ALPN identifier is advertised: the one belonging to the selected +/// protocol. Advertising a second one would let a client silently fall back to +/// HTTP/1.1 while the caller believes it negotiated the newer protocol, so the +/// handshake fails instead of answering under the wrong version. +/// +/// # Arguments +/// +/// - `&TlsConfig` - The certificate chain and private key paths to load. +/// - `&[u8]` - The single ALPN protocol identifier to advertise. +/// - `&[&'static rustls::SupportedProtocolVersion]` - The TLS versions to offer. +/// +/// # Returns +/// +/// - `Result, Box>` - The TLS configuration, or +/// the first failure while reading the PEM material. +pub fn build_server_tls_config( + tls: &TlsConfig, + alpn: &[u8], + versions: &[&'static rustls::SupportedProtocolVersion], +) -> Result, Box> { + let certificate_chain: Vec> = + CertificateDer::pem_file_iter(tls.get_certificate_path()) + .map_err(|error: pem::Error| ServerError::Tls(tls_error_string(&error)))? + .collect::>, pem::Error>>() + .map_err(|error: pem::Error| ServerError::Tls(tls_error_string(&error)))?; + let private_key: PrivateKeyDer<'static> = + PrivateKeyDer::from_pem_file(tls.get_private_key_path()) + .map_err(|error: pem::Error| ServerError::Tls(tls_error_string(&error)))?; + let mut server_config: rustls::ServerConfig = rustls::ServerConfig::builder_with_provider( + Arc::new(rustls::crypto::ring::default_provider()), + ) + .with_protocol_versions(versions) + .map_err(|error: rustls::Error| Box::new(ServerError::Tls(error.to_string())))? + .with_no_client_auth() + .with_single_cert(certificate_chain, private_key) + .map_err(|error: rustls::Error| Box::new(ServerError::Tls(error.to_string())))?; + server_config.alpn_protocols = vec![alpn.to_vec()]; + Ok(Arc::new(server_config)) +} + +/// Builds the rustls configuration a stream listener terminates its handshake with. +/// +/// # Arguments +/// +/// - `&TlsConfig` - The certificate chain and private key paths to load. +/// - `&ServerProtocol` - The protocol whose ALPN identifier is advertised. +/// +/// # Returns +/// +/// - `Result, Box>` - The TLS configuration, or +/// the first failure while reading the PEM material. +pub fn build_stream_tls_config( + tls: &TlsConfig, + protocol: &ServerProtocol, +) -> Result, Box> { + let versions: &[&'static rustls::SupportedProtocolVersion] = + &[&rustls::version::TLS13, &rustls::version::TLS12]; + build_server_tls_config(tls, protocol.get_alpn_protocol(), versions) +} + +/// Builds the rustls configuration a QUIC endpoint terminates its handshake with. +/// +/// QUIC carries the handshake inside its own transport, which admits TLS 1.3 +/// only, so no other version is offered. Zero round trip time is left disabled: +/// replaying an early request without waiting for the handshake is a +/// correctness hazard that the protocol selection does not ask for. +/// +/// # Arguments +/// +/// - `&TlsConfig` - The certificate chain and private key paths to load. +/// +/// # Returns +/// +/// - `Result, Box>` - The TLS configuration, or +/// the first failure while reading the PEM material. +pub fn build_quic_tls_config( + tls: &TlsConfig, +) -> Result, Box> { + let versions: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13]; + build_server_tls_config(tls, ServerProtocol::Http3.get_alpn_protocol(), versions) +} + +/// Renders a PEM error into the server error payload. +/// +/// The error enum is non exhaustive and carries borrowed data, so it is rendered +/// through its display implementation instead of being destructured. +/// +/// # Arguments +/// +/// - `&pem::Error` - The failure reported while reading PEM material. +/// +/// # Returns +/// +/// - `String` - The rendered failure. +fn tls_error_string(error: &pem::Error) -> String { + format!("{error}") +} diff --git a/core/src/tls/mod.rs b/core/src/tls/mod.rs new file mode 100644 index 00000000..910cac30 --- /dev/null +++ b/core/src/tls/mod.rs @@ -0,0 +1,5 @@ +mod r#fn; + +pub(crate) use r#fn::*; + +use super::*; diff --git a/core/tests/fixtures/tls/ca.pem b/core/tests/fixtures/tls/ca.pem new file mode 100644 index 00000000..c4ea71c1 --- /dev/null +++ b/core/tests/fixtures/tls/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUDqWSpNbkuYAWUBub6oiYQTZaUrswDQYJKoZIhvcNAQEL +BQAwNjEaMBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVy +bGFuZS10ZXN0czAgFw0yMDAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAwMFowNjEa +MBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVybGFuZS10 +ZXN0czCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJthDZ+SnEHTw7Di ++NHXOATUT4k0om5hOEljIjMm01X0DonRfLByPwj+Th0/9yy9QHlrPS2NZSyNPrUH +Cs7CQ8NEfiHMhJryXQFSsj0UfWF7ifp+YwU2iHUwMV+vXnoYWfHcmIGunz8o8/Zn +9c2Grvxzf86lYgFYCHmoI+E3S3B/2Tu3TpIM/BnIGfeTJLbsgjQlGm24B3PsesxW +wmohxWzHV2huTZjyNXo5A0GB+XREYOGOnNNQDXW9LEQ+oMFZ2Y+K+w8SojqwACxO +HvbXUeO/dZOa9Eb+KMpWZ2UdmIEUHkQDhzLXuS2XReF6e8fN3R2kD4f2f/tti5M7 +1vUJBl0CAwEAAaNFMEMwHQYDVR0OBBYEFHEHCNvh/CSv4qwW6N01NriE4ZAwMBIG +A1UdEwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA +A4IBAQBJ+L0tH+RLj4eBTqwAqY7Oi2d5DbK05p6ft9o+n7Nz4V4pXvDsMmNBy3D3 +tZ3e99/+r0K6OFp+P9cZ+BE2W4SHOIv2JE/XTysyB0R/HBVH5U7zSkjIUM/wn0cg +kNe5/ziFwfneI4O7piRlZ+W2VJHpe3Ja5UC8nwYCqHyg2kCwl388Tpi6qWll1VqB +766Nxy1sfr6bVPgDCwwIeXET2H4L3DIVgzKawtKfVfgu3aJXlVR3cBbIldV+X0og +fg+3Trwbxyg9nMT7ZKCtqW0TMz8b1wWbIhmfLR6zZiKH2a1Zp0AimDoh2wd2S3WO +vPPiLiX9K2KPpoNX1b6Zzi+Ha0A2 +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/invalid-certificate.pem b/core/tests/fixtures/tls/invalid-certificate.pem new file mode 100644 index 00000000..3b706753 --- /dev/null +++ b/core/tests/fixtures/tls/invalid-certificate.pem @@ -0,0 +1 @@ +hyperlane test fixture: this file is intentionally not a PEM document diff --git a/core/tests/fixtures/tls/invalid-private-key.pem b/core/tests/fixtures/tls/invalid-private-key.pem new file mode 100644 index 00000000..1ad8e9fa --- /dev/null +++ b/core/tests/fixtures/tls/invalid-private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +this is not a key +-----END PRIVATE KEY----- diff --git a/core/tests/fixtures/tls/leaf-chain.pem b/core/tests/fixtures/tls/leaf-chain.pem new file mode 100644 index 00000000..acbfeac4 --- /dev/null +++ b/core/tests/fixtures/tls/leaf-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDRTCCAi2gAwIBAgIUX+oHTgPiNU49EeY5l5hkXt/YZgQwDQYJKoZIhvcNAQEL +BQAwNjEaMBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVy +bGFuZS10ZXN0czAgFw0yMDAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAwMFowLjES +MBAGA1UEAwwJbG9jYWxob3N0MRgwFgYDVQQKDA9oeXBlcmxhbmUtdGVzdHMwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTwE/Rxbk0nbowVMr5CYnp+PH8 +K0NzgbWl2isQtHmz9SVexGJMJvg/pWFAhyRHm8zu3tX/u1h6Yfw5wxe1+VSWBNu7 +xXG1OqEKWLXHikoM9g+mSIBNnwMhzXONACTwr6pR/yJZ9fCTwaNI681Pjbh5Lq6K +OdjlmfToHq4bPUp7ud/GQevDdRQEQR+LjSVMgPVdkbXOeB5XqpAkMsOcQ7Z4uVfx +IiMJnaT20HN3OV73FEbNrv3F5M6ti3s+aDHyIUGCGn+pNGz4aNt7/oFf9R8SH0sG +/dMfdtTroH7SEBpH25nZ8hfWqzTkuatramvxXwz4NgKgOPQJeks/mbvqNvlHAgMB +AAGjUTBPMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B +AQsFAAOCAQEAhDn4l4g4LzHolwbiNH/hnYiCtq7BlGRM021gdZDaOARBEgRvb2M1 +/x0eUdcclIoGXpHztfHUg/EAmTBrMCCSwKHdXOOvNOZPX0HtlXbrWOmAW9ZlnIJE +BaTaEfPk5tTPn2NcqQ6vhuDLBBmvNG2K+UWE40keBOm2YbPlKbUOwhvgNOaQDue7 +2e3CkWEaty7yIvKHaHV8/kkF0gDc13UuYnnUXvTCNspYAhlNuKAdoqSRUicSmgiW +CK0PuNQx601PmJV4Qav1XkHsyeBtJzOZK5fVCMkIZdClF07EcdAnmI4O+gHh+Bl4 +6RDQB9KK/btWSmguV6v/vQZzgXWjzDXMPg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUDqWSpNbkuYAWUBub6oiYQTZaUrswDQYJKoZIhvcNAQEL +BQAwNjEaMBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVy +bGFuZS10ZXN0czAgFw0yMDAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAwMFowNjEa +MBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVybGFuZS10 +ZXN0czCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJthDZ+SnEHTw7Di ++NHXOATUT4k0om5hOEljIjMm01X0DonRfLByPwj+Th0/9yy9QHlrPS2NZSyNPrUH +Cs7CQ8NEfiHMhJryXQFSsj0UfWF7ifp+YwU2iHUwMV+vXnoYWfHcmIGunz8o8/Zn +9c2Grvxzf86lYgFYCHmoI+E3S3B/2Tu3TpIM/BnIGfeTJLbsgjQlGm24B3PsesxW +wmohxWzHV2huTZjyNXo5A0GB+XREYOGOnNNQDXW9LEQ+oMFZ2Y+K+w8SojqwACxO +HvbXUeO/dZOa9Eb+KMpWZ2UdmIEUHkQDhzLXuS2XReF6e8fN3R2kD4f2f/tti5M7 +1vUJBl0CAwEAAaNFMEMwHQYDVR0OBBYEFHEHCNvh/CSv4qwW6N01NriE4ZAwMBIG +A1UdEwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA +A4IBAQBJ+L0tH+RLj4eBTqwAqY7Oi2d5DbK05p6ft9o+n7Nz4V4pXvDsMmNBy3D3 +tZ3e99/+r0K6OFp+P9cZ+BE2W4SHOIv2JE/XTysyB0R/HBVH5U7zSkjIUM/wn0cg +kNe5/ziFwfneI4O7piRlZ+W2VJHpe3Ja5UC8nwYCqHyg2kCwl388Tpi6qWll1VqB +766Nxy1sfr6bVPgDCwwIeXET2H4L3DIVgzKawtKfVfgu3aJXlVR3cBbIldV+X0og +fg+3Trwbxyg9nMT7ZKCtqW0TMz8b1wWbIhmfLR6zZiKH2a1Zp0AimDoh2wd2S3WO +vPPiLiX9K2KPpoNX1b6Zzi+Ha0A2 +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/leaf.key b/core/tests/fixtures/tls/leaf.key new file mode 100644 index 00000000..ce24bf29 --- /dev/null +++ b/core/tests/fixtures/tls/leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCTwE/Rxbk0nbow +VMr5CYnp+PH8K0NzgbWl2isQtHmz9SVexGJMJvg/pWFAhyRHm8zu3tX/u1h6Yfw5 +wxe1+VSWBNu7xXG1OqEKWLXHikoM9g+mSIBNnwMhzXONACTwr6pR/yJZ9fCTwaNI +681Pjbh5Lq6KOdjlmfToHq4bPUp7ud/GQevDdRQEQR+LjSVMgPVdkbXOeB5XqpAk +MsOcQ7Z4uVfxIiMJnaT20HN3OV73FEbNrv3F5M6ti3s+aDHyIUGCGn+pNGz4aNt7 +/oFf9R8SH0sG/dMfdtTroH7SEBpH25nZ8hfWqzTkuatramvxXwz4NgKgOPQJeks/ +mbvqNvlHAgMBAAECggEAPOgh/0+KsW3zusi5gz+zYogE+Pa8r3jg8KWm6xP6t3xv +E0nRW6MU4ufB11/eTMalDDM/9FO+fXS3DPinZZSKke0ex6D05shsal93T/Jp5D3q +5ZktitHp8J1I7O8KS02e4H5UItGHguFn9j9+OzUovovH16NRDhURKD69du0lLysn +588qCIi1wLn13j2PJsxPRDTHgUdMy6UJ0xN4ETNJWDrDHEvWPVMwdomQnRX7/Z20 +TL6AaCNzxZFFxOmzN6lHeg3UDKglNbFB7Qw93gE/NgNsa4Z/cgvDdU2SzJAkd6cP +1P/7hcV2r/r0cfr0aMtLOnjDEL9iEqsOKdXtur/5AQKBgQDJxmisO7hXIt4NA4ds +xrHuGh5rMY2kkgLbFnDhQxCwE5YpaZvZlVzOvoJuWuKFCOMZN04RTxpGJ8IzNsPl +Dt9z5Vgzo3fZaaQQyGyKDgJU8+nViuKGadsQbyvjOQxBiRPtIGcMF+MopBGh6MEV +sIIuSKd5aX/s+10A0dB5FFpcsQKBgQC7dTThm7lrJZ2vOOxZpZ1kpxkfMakSkiox +6Bth4yLanwPXgw/aO/2xXIVxYDMi0pUSzesljz1+z0Vr51pjKXzKBJBvGPOrm+XY +5LXe9v0MwmG1BV2G2SGqjNHs163ymOs7qH0EQvq4by+wisdLwalb3TsFJM8i7sHv +fXB+ytDTdwKBgD6uYz/2AWsYStDSItTY2Nd1x7NXHoBcV4TF8OUZl2qLex98Tk0G +iGE1b4FmvG3Fd/8SXi8kWq4mlb6hKbPTrfjr6z2Hb6jbl3sbOBSfC3xmlWKgFYTa +B5KQZprc0wDc25LTeQ+ZsBik91FL5Aef4KEOMvCVpIbzA05I37FtcDDRAoGBAJFP +j8h7IEZQiGU1qPDtEo7dgPnPUxJUP2UkWWWN3oy9ywqaTAWNj2CXKdHzEnCh8HD9 +MpSQ7siH/3Zfho9rr5Ih65xTKUATvx5cXCcUKqBjZb4fBJrtYc2FD42auvpdU6uR +NifBi/zAI6WkTk0JODE/0upv2ClqDueIw1Vl/m6NAoGBAI3hjWxWSlS/CJBudkh2 +5ckibed12JC68n+RwgeT76qpeAfEh/EjNfG/bOJm7tkO7MRyemfl0K3JzM8+YzCN +Gpw3yS6fVH9GU3ttJW3ktoXxueGDvrG/lLZjm6BxkaQ5RC6u4uWPXpepsgODoSM9 +TZJK+QJZKRSHFsZTx/y+VAiI +-----END PRIVATE KEY----- diff --git a/core/tests/fixtures/tls/leaf.pem b/core/tests/fixtures/tls/leaf.pem new file mode 100644 index 00000000..1253f9ee --- /dev/null +++ b/core/tests/fixtures/tls/leaf.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRTCCAi2gAwIBAgIUX+oHTgPiNU49EeY5l5hkXt/YZgQwDQYJKoZIhvcNAQEL +BQAwNjEaMBgGA1UEAwwRaHlwZXJsYW5lLXRlc3QtY2ExGDAWBgNVBAoMD2h5cGVy +bGFuZS10ZXN0czAgFw0yMDAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAwMFowLjES +MBAGA1UEAwwJbG9jYWxob3N0MRgwFgYDVQQKDA9oeXBlcmxhbmUtdGVzdHMwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTwE/Rxbk0nbowVMr5CYnp+PH8 +K0NzgbWl2isQtHmz9SVexGJMJvg/pWFAhyRHm8zu3tX/u1h6Yfw5wxe1+VSWBNu7 +xXG1OqEKWLXHikoM9g+mSIBNnwMhzXONACTwr6pR/yJZ9fCTwaNI681Pjbh5Lq6K +OdjlmfToHq4bPUp7ud/GQevDdRQEQR+LjSVMgPVdkbXOeB5XqpAkMsOcQ7Z4uVfx +IiMJnaT20HN3OV73FEbNrv3F5M6ti3s+aDHyIUGCGn+pNGz4aNt7/oFf9R8SH0sG +/dMfdtTroH7SEBpH25nZ8hfWqzTkuatramvxXwz4NgKgOPQJeks/mbvqNvlHAgMB +AAGjUTBPMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B +AQsFAAOCAQEAhDn4l4g4LzHolwbiNH/hnYiCtq7BlGRM021gdZDaOARBEgRvb2M1 +/x0eUdcclIoGXpHztfHUg/EAmTBrMCCSwKHdXOOvNOZPX0HtlXbrWOmAW9ZlnIJE +BaTaEfPk5tTPn2NcqQ6vhuDLBBmvNG2K+UWE40keBOm2YbPlKbUOwhvgNOaQDue7 +2e3CkWEaty7yIvKHaHV8/kkF0gDc13UuYnnUXvTCNspYAhlNuKAdoqSRUicSmgiW +CK0PuNQx601PmJV4Qav1XkHsyeBtJzOZK5fVCMkIZdClF07EcdAnmI4O+gHh+Bl4 +6RDQB9KK/btWSmguV6v/vQZzgXWjzDXMPg== +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/malformed-certificate.pem b/core/tests/fixtures/tls/malformed-certificate.pem new file mode 100644 index 00000000..5bebe408 --- /dev/null +++ b/core/tests/fixtures/tls/malformed-certificate.pem @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +not base64 at all !!!! #### +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/mismatched-leaf.key b/core/tests/fixtures/tls/mismatched-leaf.key new file mode 100644 index 00000000..f937cdd5 --- /dev/null +++ b/core/tests/fixtures/tls/mismatched-leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4TKAZ3498YcBO +zB0smvqa8PRyV58CRDtDVgnclIzv4SEB2CJTH0uOCg2UTrtZqqF+lFzNer5xRubF +oMFYzu3Q/5D4CTUZovCML0bOsfDchpSoaPeFfhn69JP9MsT0j1W6JKIjQgy57Gts +wu+OOLk2KdpLLyfiRkiA39wRZnwVcp831FPiL1ap69MRIVBOp/Yje88IACqaNMGW +SScgLHYxibWo3qVWzzQpq1F1XyTqsRyExPGsHMKpJ8UKTIstIXDdECbh7GohMNU7 +mq0Oq1bs/WyB5yEfyy1bzndnCn8vTKjV28HfBrHrDj0a3jjiI5vt0r0JhEk6yTCF +LafvnkOpAgMBAAECggEARBpgoR75HTvb/eI0N6rvRzkaA3S4d/Rxt9C/5ShpqizV +byKIkJHEcDDr0q2DUr+YXLeDUZT1cdOC7G2adPBGdhAyaE2ytw38eLjxn+imOj9r +CI59RPGTtSYS16IeoMakpZvq5D+Ah2XDTpX8QuBYJJkzpsu+n8FcAm92mWF3Ic8w +DveHjprYVTZYXSwVX7I4nhc5oGdKVv7Ed+mylvVG3F3fhjmdA8eQgHR/49ZRsl8W +WemcmgmM6XWOTmj+2RXTSM3xWW82CiTKbBdqyUUFTEiVJE0ofervIj7B37hZ3uVp +ojBs7IwReuOSon3sT98GYCjdWCPsuunCMps4BhgB3wKBgQDmtF0g+/8z6+lLU09C +4LBFB24x3eBw5qJPVSguOrwIFmeTDKxzFAHbYl8Cx7KNIQnf+J0txCFc/6WAludX +ucnMUmDO6AInV3Y8Ei0rTH+KuGOc9Glf1zCSED2kHGqo/zg1ynLM0HGSLOrhh4Qx +3sk9Sw5sf/VaSPr5CLy65nwAvwKBgQDMgbcYHHW8Jr6BgoCtbAbbkyK7ct+e/xV7 +sTnO+HUkLrE35L9uYOxI5UQ3i7lATRqzIwM4u9zmlD2YodnhJnQMTHkU7opJyvDp +JGs66nQTRSwDDiFXZPi9WkVdLEB7L5XY2f2VHFs/ilRpXVbWSfJt0v5wP/i/x3AH +dXMJ6XftlwKBgAQ96rxxhba5rrdw17M+GF+cjF0kXD0XMILO6Qc4kz5+0va0M/OF +uacD/wU5EMJhiiHxG0q9IXPpS2y62ZQWWWyFUzxarbd1dzoRh30VUf8F9ubq44kc +lssIu2lqEFf9IVZz23qClqP+aW8uI7MbCo4y9Bfwnl1DnEG59GZnWH5XAoGADD3b +iurxNUEQeEoqj7b+thgZpSTcM/VLAOt77WK8CF1vKExeFSpiGn721Whcv3eeGIkJ +AVVlhLvs7/0IQMz/d1kAins49e/a+em/LXipWBn0533qCwQ1S4hizJAAkB8qFh/+ +aPWYlLYgFWqL7yngBCj0KB38MuaqUxf5uecq0aMCgYEAp0AcykLdLMPO/siQf0Ih +rw8hMKzwzLyXyls48idqWQLTjcc904jWM7Cn1Yq9KNYEG+YWCk51KXxIG//GYD3P +p3JpJJS0A+vY4aDWUP7aryMOel+OS2ZJ4AoAKf3Grnxp2dZZqrzzTIuZrobUsKpF +4DYTtJJNtV59yBhTInjy+7g= +-----END PRIVATE KEY----- diff --git a/core/tests/fixtures/tls/truncated-certificate.pem b/core/tests/fixtures/tls/truncated-certificate.pem new file mode 100644 index 00000000..18977e60 --- /dev/null +++ b/core/tests/fixtures/tls/truncated-certificate.pem @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +MIIDRTCCAi2gAwIBAgIUX+oHTgPiNU49EeY5l5hkXt/YZgQwDQYJKoZIhvcNAQEL +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/untrusted-ca.pem b/core/tests/fixtures/tls/untrusted-ca.pem new file mode 100644 index 00000000..649470d6 --- /dev/null +++ b/core/tests/fixtures/tls/untrusted-ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUNtWDDjKb+YY1S28FF6Gn3J1OyuAwDQYJKoZIhvcNAQEL +BQAwOzEfMB0GA1UEAwwWaHlwZXJsYW5lLXVudHJ1c3RlZC1jYTEYMBYGA1UECgwP +aHlwZXJsYW5lLXRlc3RzMCAXDTIwMDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw +WjA7MR8wHQYDVQQDDBZoeXBlcmxhbmUtdW50cnVzdGVkLWNhMRgwFgYDVQQKDA9o +eXBlcmxhbmUtdGVzdHMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCr +IlmHGaC7Hh4lJA442VdEDiIvzYUOtSlbXcysOTLSxQJ4t2k3NcHu7IIslwFP+Jl5 +Bs0bptkqtyn2/Ikf3LIEsOl0XrZCqL5u/Tc1WNuVqjT2vEniU8b+EE/mrQxhiJGa +XlwS/HJ1sTNga56BjH4Du1CsDhw1pR2zKE5o/cHva+4JAEUclrMbEmJQ6wz9MHUZ +l0GzOGKJ9uSc1jNFFkyoe2kMvWC34RRBs4MmK22JxG/7W5Rt7wL+6NRjWLtuCUoO +P2Ul8tTCW4R1F6GTIa43JlBso5hO1/xnM/sYsrI9NkfK17aCTy9D8JjnzyZv9rVq +VVyBA3LYgyO06+ceU2ybAgMBAAGjRTBDMB0GA1UdDgQWBBS86D+/hqSnhst3fBvU +Kga/BLC+hzASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjANBgkq +hkiG9w0BAQsFAAOCAQEALvimiMOP3QB9HnXAoWGjHZBJiTTSHx190b1SqlBBzEVN +QNB/yleMMbfH/eWkNJNM/CKqNcy0eIihxgtMxZqY1NMBSlsWlTBj0iOOc5C8OoVs +TBiHuLl+TGb8ztTzmn0OtaRaISLbfJcd81C6AGs0yJTca/qbAhmDFwOjIuvK7UsY +pk3beyjK831qj36OHhJ5Ol/Qgz/d/1ZrNHnEYYfqx8T8CCweOVWmiuj7y9/b53kx +q6NVz82+J+ou/CSUqHqCY+Cm/jiRNAusMV1pp3iIMkH9rbpjD2hyn5az+orWD0KF +Z4uY9gTWMebLhvUFspkSDLR8EL7JIq6b0nkFZ+7vsQ== +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/untrusted-leaf-chain.pem b/core/tests/fixtures/tls/untrusted-leaf-chain.pem new file mode 100644 index 00000000..ed175f58 --- /dev/null +++ b/core/tests/fixtures/tls/untrusted-leaf-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIUbEpX9uv0hd7DnlTrPle447Zw+gYwDQYJKoZIhvcNAQEL +BQAwOzEfMB0GA1UEAwwWaHlwZXJsYW5lLXVudHJ1c3RlZC1jYTEYMBYGA1UECgwP +aHlwZXJsYW5lLXRlc3RzMCAXDTIwMDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw +WjAuMRIwEAYDVQQDDAlsb2NhbGhvc3QxGDAWBgNVBAoMD2h5cGVybGFuZS10ZXN0 +czCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALhMoBnfj3xhwE7MHSya ++prw9HJXnwJEO0NWCdyUjO/hIQHYIlMfS44KDZROu1mqoX6UXM16vnFG5sWgwVjO +7dD/kPgJNRmi8IwvRs6x8NyGlKho94V+Gfr0k/0yxPSPVbokoiNCDLnsa2zC7444 +uTYp2ksvJ+JGSIDf3BFmfBVynzfUU+IvVqnr0xEhUE6n9iN7zwgAKpo0wZZJJyAs +djGJtajepVbPNCmrUXVfJOqxHITE8awcwqknxQpMiy0hcN0QJuHsaiEw1TuarQ6r +Vuz9bIHnIR/LLVvOd2cKfy9MqNXbwd8GsesOPRreOOIjm+3SvQmESTrJMIUtp++e +Q6kCAwEAAaNRME8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l +BAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqG +SIb3DQEBCwUAA4IBAQBoJ61h83clx7X9DhQprMD+G1cV37l9ukaPJY+qqYAugrHX +a4yPsMvJwfgReuRYheeOJ7latZGFz9UyyOPtfqUuDTziJ0e1aa/cM6FoLAxAIGXl +bKt57V948pRXI7Zc1Imkw0AsYzQYgZTJXI0wEvZv5NOMsACG3MuzZEF1l3QqlUer +y+PN9RHJGul9d9JBZQYUymapodCbrKK2WspgR4LLRYCLN8qlBEDGOnTWIvXc5Yc+ +T4jgeFCq0NbxsaJFECkjTCWg2EzpHY+Emnousko46sVSZ96umS6VMZkw/8IbZQgF +kXu7uinhJt0GJqoRQV7ZJzvQvqASyFWWnQJ8CdCB +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUNtWDDjKb+YY1S28FF6Gn3J1OyuAwDQYJKoZIhvcNAQEL +BQAwOzEfMB0GA1UEAwwWaHlwZXJsYW5lLXVudHJ1c3RlZC1jYTEYMBYGA1UECgwP +aHlwZXJsYW5lLXRlc3RzMCAXDTIwMDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw +WjA7MR8wHQYDVQQDDBZoeXBlcmxhbmUtdW50cnVzdGVkLWNhMRgwFgYDVQQKDA9o +eXBlcmxhbmUtdGVzdHMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCr +IlmHGaC7Hh4lJA442VdEDiIvzYUOtSlbXcysOTLSxQJ4t2k3NcHu7IIslwFP+Jl5 +Bs0bptkqtyn2/Ikf3LIEsOl0XrZCqL5u/Tc1WNuVqjT2vEniU8b+EE/mrQxhiJGa +XlwS/HJ1sTNga56BjH4Du1CsDhw1pR2zKE5o/cHva+4JAEUclrMbEmJQ6wz9MHUZ +l0GzOGKJ9uSc1jNFFkyoe2kMvWC34RRBs4MmK22JxG/7W5Rt7wL+6NRjWLtuCUoO +P2Ul8tTCW4R1F6GTIa43JlBso5hO1/xnM/sYsrI9NkfK17aCTy9D8JjnzyZv9rVq +VVyBA3LYgyO06+ceU2ybAgMBAAGjRTBDMB0GA1UdDgQWBBS86D+/hqSnhst3fBvU +Kga/BLC+hzASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjANBgkq +hkiG9w0BAQsFAAOCAQEALvimiMOP3QB9HnXAoWGjHZBJiTTSHx190b1SqlBBzEVN +QNB/yleMMbfH/eWkNJNM/CKqNcy0eIihxgtMxZqY1NMBSlsWlTBj0iOOc5C8OoVs +TBiHuLl+TGb8ztTzmn0OtaRaISLbfJcd81C6AGs0yJTca/qbAhmDFwOjIuvK7UsY +pk3beyjK831qj36OHhJ5Ol/Qgz/d/1ZrNHnEYYfqx8T8CCweOVWmiuj7y9/b53kx +q6NVz82+J+ou/CSUqHqCY+Cm/jiRNAusMV1pp3iIMkH9rbpjD2hyn5az+orWD0KF +Z4uY9gTWMebLhvUFspkSDLR8EL7JIq6b0nkFZ+7vsQ== +-----END CERTIFICATE----- diff --git a/core/tests/fixtures/tls/untrusted-leaf.pem b/core/tests/fixtures/tls/untrusted-leaf.pem new file mode 100644 index 00000000..694057af --- /dev/null +++ b/core/tests/fixtures/tls/untrusted-leaf.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIUbEpX9uv0hd7DnlTrPle447Zw+gYwDQYJKoZIhvcNAQEL +BQAwOzEfMB0GA1UEAwwWaHlwZXJsYW5lLXVudHJ1c3RlZC1jYTEYMBYGA1UECgwP +aHlwZXJsYW5lLXRlc3RzMCAXDTIwMDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw +WjAuMRIwEAYDVQQDDAlsb2NhbGhvc3QxGDAWBgNVBAoMD2h5cGVybGFuZS10ZXN0 +czCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALhMoBnfj3xhwE7MHSya ++prw9HJXnwJEO0NWCdyUjO/hIQHYIlMfS44KDZROu1mqoX6UXM16vnFG5sWgwVjO +7dD/kPgJNRmi8IwvRs6x8NyGlKho94V+Gfr0k/0yxPSPVbokoiNCDLnsa2zC7444 +uTYp2ksvJ+JGSIDf3BFmfBVynzfUU+IvVqnr0xEhUE6n9iN7zwgAKpo0wZZJJyAs +djGJtajepVbPNCmrUXVfJOqxHITE8awcwqknxQpMiy0hcN0QJuHsaiEw1TuarQ6r +Vuz9bIHnIR/LLVvOd2cKfy9MqNXbwd8GsesOPRreOOIjm+3SvQmESTrJMIUtp++e +Q6kCAwEAAaNRME8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l +BAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqG +SIb3DQEBCwUAA4IBAQBoJ61h83clx7X9DhQprMD+G1cV37l9ukaPJY+qqYAugrHX +a4yPsMvJwfgReuRYheeOJ7latZGFz9UyyOPtfqUuDTziJ0e1aa/cM6FoLAxAIGXl +bKt57V948pRXI7Zc1Imkw0AsYzQYgZTJXI0wEvZv5NOMsACG3MuzZEF1l3QqlUer +y+PN9RHJGul9d9JBZQYUymapodCbrKK2WspgR4LLRYCLN8qlBEDGOnTWIvXc5Yc+ +T4jgeFCq0NbxsaJFECkjTCWg2EzpHY+Emnousko46sVSZ96umS6VMZkw/8IbZQgF +kXu7uinhJt0GJqoRQV7ZJzvQvqASyFWWnQJ8CdCB +-----END CERTIFICATE----- diff --git a/core/tests/mod.rs b/core/tests/mod.rs index 648e8fe8..0257fb42 100644 --- a/core/tests/mod.rs +++ b/core/tests/mod.rs @@ -1,22 +1,35 @@ mod config; mod context; mod error; +mod protocol; mod route; mod server; use hyperlane_core::*; use std::{ + any::Any, + collections::VecDeque, io, - net::TcpListener, - sync::{Arc, OnceLock}, + net::{AddrParseError, SocketAddr, TcpListener, UdpSocket}, + path::PathBuf, + sync::{Arc, Mutex, OnceLock}, time::{Duration, Instant}, }; +use bytes::{Buf, Bytes}; +use h3::error::Code; +use rustls::{ + ClientConfig, RootCertStore, + pki_types::{CertificateDer, ServerName, pem::PemObject}, +}; use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::TcpStream, spawn, + sync::Notify, task::JoinHandle, - time::sleep, + time::{sleep, timeout}, }; + +pub(crate) type ClientTlsStream = tokio_rustls::client::TlsStream; diff --git a/core/tests/protocol/common/const.rs b/core/tests/protocol/common/const.rs new file mode 100644 index 00000000..3cf91906 --- /dev/null +++ b/core/tests/protocol/common/const.rs @@ -0,0 +1,88 @@ +pub(crate) const FIXTURE_DIR_NAME: &str = "tests/fixtures/tls"; + +pub(crate) const CA_PEM_NAME: &str = "ca.pem"; +pub(crate) const LEAF_CHAIN_PEM_NAME: &str = "leaf-chain.pem"; +pub(crate) const LEAF_KEY_NAME: &str = "leaf.key"; +pub(crate) const MISMATCHED_LEAF_KEY_NAME: &str = "mismatched-leaf.key"; +pub(crate) const MALFORMED_CERTIFICATE_PEM_NAME: &str = "malformed-certificate.pem"; +pub(crate) const TRUNCATED_CERTIFICATE_PEM_NAME: &str = "truncated-certificate.pem"; +pub(crate) const INVALID_PRIVATE_KEY_PEM_NAME: &str = "invalid-private-key.pem"; +pub(crate) const MISSING_PEM_NAME: &str = "this-file-does-not-exist.pem"; + +pub(crate) const WIRE_HTTP1: &str = "http1"; +pub(crate) const WIRE_HTTP2: &str = "http2"; +pub(crate) const WIRE_HTTP3: &str = "http3"; +pub(crate) const WIRE_UNKNOWN: &str = "http9"; + +pub(crate) const ALPN_HTTP1: &[u8] = b"http/1.1"; +pub(crate) const ALPN_HTTP2: &[u8] = b"h2"; +pub(crate) const ALPN_HTTP3: &[u8] = b"h3"; + +pub(crate) const LOOPBACK_HOST: &str = "127.0.0.1"; +pub(crate) const TLS_SERVER_NAME: &str = "localhost"; + +pub(crate) const CONNECT_TIMEOUT_SECS: u64 = 15; +pub(crate) const REQUEST_TIMEOUT_SECS: u64 = 20; +pub(crate) const GATE_TIMEOUT_SECS: u64 = 20; +pub(crate) const SERVER_BOOT_MILLIS: u64 = 120; +pub(crate) const SHUTDOWN_PROBE_MILLIS: u64 = 250; +pub(crate) const SHUTDOWN_TIMEOUT_SECS: u64 = 15; +pub(crate) const FAST_STREAM_BUDGET_MILLIS: u64 = 1500; + +pub(crate) const PROBE_PATH: &str = "/probe"; +pub(crate) const PROBE_QUERY: &str = "name=hyperlane&kind=protocol"; +pub(crate) const PROBE_QUERY_KEYS: [&str; 2] = ["name", "kind"]; +pub(crate) const PROBE_QUERY_EXPECTED: &str = "name=hyperlane;kind=protocol"; +pub(crate) const PROBE_METHOD: &str = "GET"; +pub(crate) const PROBE_HEADER_KEY: &str = "x-probe"; +pub(crate) const PROBE_HEADER_VALUE: &str = "probe-value"; +pub(crate) const PROBE_REPEATED_HEADERS: [&str; 2] = [PROBE_HEADER_VALUE, PROBE_SECOND_VALUE]; +pub(crate) const PROBE_HEADER_EXPECTED: &str = "probe-value;probe-second"; +pub(crate) const PROBE_METHOD_HEADER: &str = "x-probe-method"; +pub(crate) const PROBE_PATH_HEADER: &str = "x-probe-path"; +pub(crate) const PROBE_QUERY_HEADER: &str = "x-probe-query"; +pub(crate) const PROBE_HEADER_ECHO_HEADER: &str = "x-probe-header"; +pub(crate) const PROBE_VERSION_HEADER: &str = "x-probe-version"; + +pub(crate) const ECHO_ROUTE_PATH: &str = "/echo"; +pub(crate) const SLOW_ROUTE_PATH: &str = "/slow"; +pub(crate) const SPLIT_ROUTE_PATH: &str = "/split"; +pub(crate) const COOKIE_ROUTE_PATH: &str = "/cookie"; +pub(crate) const LARGE_ROUTE_PATH: &str = "/large"; +pub(crate) const BINARY_ROUTE_PATH: &str = "/binary"; +pub(crate) const SSE_ROUTE_PATH: &str = "/sse"; +pub(crate) const SSE_ROUTE_PATH_HTTP2: &str = "/sse-h2"; +pub(crate) const SSE_ROUTE_PATH_HTTP3: &str = "/sse-h3"; +pub(crate) const SLOW_ROUTE_PATH_HTTP3: &str = "/slow-h3"; +pub(crate) const VERSION_ROUTE_PATH: &str = "/version"; +pub(crate) const HOP_BY_HOP_ROUTE_PATH: &str = "/hop-by-hop"; +pub(crate) const HOP_BY_HOP_CLEAN_ROUTE_PATH: &str = "/hop-by-hop-clean"; +pub(crate) const SOCKET_METADATA_ROUTE_PATH: &str = "/socket-metadata"; +pub(crate) const UNFRAMED_BODY_ROUTE_PATH: &str = "/unframed"; + +pub(crate) const SLOW_DONE_BODY: &str = "slow-done"; +pub(crate) const COOKIE_SENT_BODY: &str = "cookie-sent"; +pub(crate) const HOP_BY_HOP_SENT_BODY: &str = "hop-by-hop-sent"; +pub(crate) const FIRST_COOKIE_NAME: &str = "dup"; +pub(crate) const FIRST_COOKIE_VALUE: &str = "one"; +pub(crate) const SECOND_COOKIE_NAME: &str = "dup2"; +pub(crate) const SECOND_COOKIE_VALUE: &str = "two"; +pub(crate) const PRESERVED_HEADER_KEY: &str = "x-end-to-end"; +pub(crate) const PRESERVED_HEADER_VALUE: &str = "kept"; +pub(crate) const INTERNAL_CONNECTION_HEADER: &str = "x-internal"; +pub(crate) const INTERNAL_CONNECTION_VALUE: &str = "secret"; + +pub(crate) const HOSTILE_BYTES_LEN: usize = 1024; +pub(crate) const ABOVE_FLOW_CONTROL_WINDOW_LEN: usize = 1024 * 1024; + +pub(crate) const SSE_FIRST_CHUNK: &str = "data:first\n\n"; +pub(crate) const SSE_SECOND_CHUNK: &str = "data:second\n\n"; +pub(crate) const SPLIT_HEAD_BODY: &str = "head-part"; +pub(crate) const SPLIT_TAIL_BODY: &str = "tail-part"; +pub(crate) const SPLIT_BODY_LEN: usize = 18; +pub(crate) const SPLIT_FRAGMENT_LEN: usize = 5; +pub(crate) const UNFRAMED_BODY: &str = "body-with-no-content-length"; +pub(crate) const SOCKET_METADATA_SEPARATOR: char = '|'; +pub(crate) const SERVER_CONFIG_LEGACY_ADDRESS: &str = "127.0.0.1:8080"; + +const PROBE_SECOND_VALUE: &str = "probe-second"; diff --git a/core/tests/protocol/common/fn.rs b/core/tests/protocol/common/fn.rs new file mode 100644 index 00000000..0300f3f9 --- /dev/null +++ b/core/tests/protocol/common/fn.rs @@ -0,0 +1,263 @@ +use super::*; + +pub(crate) fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(FIXTURE_DIR_NAME) + .join(name) +} + +pub(crate) fn trusted_tls_config() -> TlsConfig { + TlsConfig::new( + fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(), + ) +} + +pub(crate) fn install_trusted_ca(roots: &mut RootCertStore) { + let ca: CertificateDer<'static> = + CertificateDer::from_pem_file(fixture_path(CA_PEM_NAME)).expect("fixture CA must exist"); + roots + .add(ca) + .expect("fixture CA must be a valid trust anchor certificate"); +} + +fn alpn_client_config(alpn: &[u8]) -> Arc { + let mut roots: RootCertStore = RootCertStore::empty(); + install_trusted_ca(&mut roots); + let mut config: ClientConfig = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![alpn.to_vec()]; + Arc::new(config) +} + +fn no_alpn_client_config() -> Arc { + let mut roots: RootCertStore = RootCertStore::empty(); + install_trusted_ca(&mut roots); + Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) +} + +pub(crate) fn negotiated_alpn(stream: &ClientTlsStream) -> Option> { + stream + .get_ref() + .1 + .alpn_protocol() + .map(|alpn: &[u8]| alpn.to_vec()) +} + +pub(crate) fn hostile_binary_payload() -> Vec { + let mut payload: Vec = (0..=255).map(|value: u8| value).collect(); + payload.extend_from_slice(b"\r\n\r\n"); + payload.extend_from_slice(b"GET /admin HTTP/1.1\r\nHost: localhost\r\n\r\n"); + payload.extend_from_slice(&[0u8, 1u8, 2u8]); + while payload.len() < HOSTILE_BYTES_LEN { + payload.push((payload.len() % 251) as u8); + } + payload.truncate(HOSTILE_BYTES_LEN); + payload +} + +pub(crate) fn large_binary_payload() -> Vec { + let mut payload: Vec = Vec::with_capacity(ABOVE_FLOW_CONTROL_WINDOW_LEN); + let mut seed: u32 = 0x1357_9bdf; + while payload.len() < ABOVE_FLOW_CONTROL_WINDOW_LEN { + for _ in 0..1024 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + payload.extend_from_slice(&seed.to_be_bytes()); + } + } + payload.truncate(ABOVE_FLOW_CONTROL_WINDOW_LEN); + payload +} + +pub(crate) fn http1_request(method: &str, target: &str, body: &[u8]) -> Vec { + http1_request_with_headers(method, target, PROBE_REPEATED_HEADERS, body) +} + +pub(crate) fn http1_request_with_headers( + method: &str, + target: &str, + header_values: [&str; 2], + body: &[u8], +) -> Vec { + let mut head: String = format!("{method} {target} HTTP/1.1{HTTP_BR}"); + head.push_str(&format!("Host: {TLS_SERVER_NAME}{HTTP_BR}")); + for value in header_values { + head.push_str(&format!("{PROBE_HEADER_KEY}: {value}{HTTP_BR}")); + } + head.push_str(&format!("Content-Length: {}{HTTP_BR}", body.len())); + head.push_str(HTTP_BR); + let mut request: Vec = head.into_bytes(); + request.extend_from_slice(body); + request +} + +pub(crate) fn http1_close_request(method: &str, target: &str) -> Vec { + let mut head: String = format!("{method} {target} HTTP/1.1{HTTP_BR}"); + head.push_str(&format!("Host: {TLS_SERVER_NAME}{HTTP_BR}")); + head.push_str(&format!("Content-Length: 0{HTTP_BR}")); + head.push_str(&format!("{CONNECTION}: close{HTTP_BR}")); + head.push_str(HTTP_BR); + head.into_bytes() +} + +pub(crate) fn response_header_value(head: &str, key: &str) -> String { + let needle: String = format!("{}:", key.to_ascii_lowercase()); + let mut found: Vec = Vec::new(); + for line in head.lines() { + let Some((name, rest)) = line.split_once(':') else { + continue; + }; + if name.trim().to_ascii_lowercase() == needle.trim_end_matches(':') { + found.push(rest.trim().to_string()); + } + } + found.join(",") +} + +pub(crate) async fn read_more(stream: &mut TcpStream, sink: &mut Vec) { + let mut chunk: [u8; 8192] = [0; 8192]; + let bound: Duration = Duration::from_secs(2); + match timeout(bound, stream.read(&mut chunk)).await { + Ok(Ok(read)) => { + if read > 0 { + sink.extend_from_slice(&chunk[..read]); + } + } + Ok(Err(_)) => {} + Err(_) => {} + } +} + +pub(crate) async fn http1_head_until(stream: &mut T, delimiter: &str) -> String +where + T: tokio::io::AsyncRead + Unpin, +{ + let mut collected: Vec = Vec::new(); + let mut chunk: [u8; 8192] = [0; 8192]; + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + while Instant::now() < deadline { + let text: String = String::from_utf8_lossy(&collected).to_string(); + if text.contains(delimiter) { + return text; + } + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + let read: usize = match timeout(remaining, stream.read(&mut chunk)).await { + Ok(Ok(count)) => count, + Ok(Err(error)) => { + panic!("the response head read failed before the delimiter arrived: {error}") + } + Err(_) => panic!("the response head must arrive inside its timeout"), + }; + if read == 0 { + return String::from_utf8_lossy(&collected).to_string(); + } + collected.extend_from_slice(&chunk[..read]); + } + panic!("the response head never carried its {delimiter} delimiter") +} + +pub(crate) async fn tls_exchange( + mut stream: ClientTlsStream, + request: Vec, +) -> io::Result> { + write_bounded(&mut stream, &request).await?; + stream.flush().await?; + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + let mut received: Vec = Vec::new(); + let mut chunk: [u8; 16 * 1024] = [0; 16 * 1024]; + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the tls exchange did not reach end of stream inside its timeout", + )); + } + match timeout(remaining, stream.read(&mut chunk)).await { + Ok(Ok(0)) => break, + Ok(Ok(read)) => received.extend_from_slice(&chunk[..read]), + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the tls exchange did not reach end of stream inside its timeout", + )); + } + } + } + Ok(received) +} + +pub(crate) async fn connect_tls(port: u16, alpn: &[u8]) -> io::Result { + let name: ServerName<'static> = ServerName::try_from(TLS_SERVER_NAME).map_err( + |error: rustls::pki_types::InvalidDnsNameError| { + io::Error::new(io::ErrorKind::InvalidInput, error.to_string()) + }, + )?; + let connector: tokio_rustls::TlsConnector = + tokio_rustls::TlsConnector::from(alpn_client_config(alpn)); + let socket: TcpStream = connect_tcp(port).await?; + let handshake: tokio_rustls::Connect = connector.connect(name, socket); + let bound: Duration = Duration::from_secs(CONNECT_TIMEOUT_SECS); + match timeout(bound, handshake).await { + Ok(outcome) => outcome, + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + "tls handshake timed out", + )), + } +} + +pub(crate) async fn connect_tls_untrusted(port: u16) -> Result<(), String> { + let name: ServerName<'static> = ServerName::try_from(TLS_SERVER_NAME) + .map_err(|error: rustls::pki_types::InvalidDnsNameError| error.to_string())?; + let connector: tokio_rustls::TlsConnector = + tokio_rustls::TlsConnector::from(untrusted_client_config(ALPN_HTTP2)); + let socket: TcpStream = connect_tcp(port) + .await + .map_err(|error: io::Error| error.to_string())?; + let bound: Duration = Duration::from_secs(CONNECT_TIMEOUT_SECS); + let outcome: io::Result = timeout(bound, connector.connect(name, socket)) + .await + .map_err(|_| "handshake timed out".to_string())?; + match outcome { + Ok(_) => Ok(()), + Err(_) => Err("an untrusted chain must fail verification".to_string()), + } +} + +fn untrusted_client_config(alpn: &[u8]) -> Arc { + let roots: RootCertStore = RootCertStore::empty(); + let mut config: ClientConfig = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![alpn.to_vec()]; + Arc::new(config) +} + +pub(crate) async fn connect_tls_without_alpn(port: u16) -> io::Result { + let name: ServerName<'static> = ServerName::try_from(TLS_SERVER_NAME).map_err( + |error: rustls::pki_types::InvalidDnsNameError| { + io::Error::new(io::ErrorKind::InvalidInput, error.to_string()) + }, + )?; + let connector: tokio_rustls::TlsConnector = + tokio_rustls::TlsConnector::from(no_alpn_client_config()); + let socket: TcpStream = connect_tcp(port).await?; + let handshake: tokio_rustls::Connect = connector.connect(name, socket); + let bound: Duration = Duration::from_secs(CONNECT_TIMEOUT_SECS); + match timeout(bound, handshake).await { + Ok(outcome) => outcome, + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + "tls handshake timed out", + )), + } +} diff --git a/core/tests/protocol/common/gate.rs b/core/tests/protocol/common/gate.rs new file mode 100644 index 00000000..1e52f3b7 --- /dev/null +++ b/core/tests/protocol/common/gate.rs @@ -0,0 +1,145 @@ +use super::*; + +#[derive(Clone, Copy)] +pub(crate) enum GateSlot { + SlowHttp2, + SlowHttp3, + SseHttp1, + SseHttp2, + SseHttp3, +} + +pub(crate) struct RouteGate { + slow_entered: Notify, + slow_release: Notify, + sse_first_chunk_sent: Notify, + sse_release: Notify, +} + +impl RouteGate { + fn new() -> Self { + Self { + slow_entered: Notify::new(), + slow_release: Notify::new(), + sse_first_chunk_sent: Notify::new(), + sse_release: Notify::new(), + } + } +} + +pub(crate) fn route_gate(slot: GateSlot) -> &'static RouteGate { + static SLOW_HTTP2: OnceLock = OnceLock::new(); + static SLOW_HTTP3: OnceLock = OnceLock::new(); + static SSE_HTTP1: OnceLock = OnceLock::new(); + static SSE_HTTP2: OnceLock = OnceLock::new(); + static SSE_HTTP3: OnceLock = OnceLock::new(); + match slot { + GateSlot::SlowHttp2 => SLOW_HTTP2.get_or_init(RouteGate::new), + GateSlot::SlowHttp3 => SLOW_HTTP3.get_or_init(RouteGate::new), + GateSlot::SseHttp1 => SSE_HTTP1.get_or_init(RouteGate::new), + GateSlot::SseHttp2 => SSE_HTTP2.get_or_init(RouteGate::new), + GateSlot::SseHttp3 => SSE_HTTP3.get_or_init(RouteGate::new), + } +} + +pub(crate) type GateLease = tokio::sync::MutexGuard<'static, ()>; + +pub(crate) async fn gate_lease(slot: GateSlot) -> GateLease { + slot_mutex(slot).lock().await +} + +fn slot_mutex(slot: GateSlot) -> &'static tokio::sync::Mutex<()> { + static SLOW_HTTP2: OnceLock> = OnceLock::new(); + static SLOW_HTTP3: OnceLock> = OnceLock::new(); + static SSE_HTTP1: OnceLock> = OnceLock::new(); + static SSE_HTTP2: OnceLock> = OnceLock::new(); + static SSE_HTTP3: OnceLock> = OnceLock::new(); + match slot { + GateSlot::SlowHttp2 => SLOW_HTTP2.get_or_init(|| tokio::sync::Mutex::new(())), + GateSlot::SlowHttp3 => SLOW_HTTP3.get_or_init(|| tokio::sync::Mutex::new(())), + GateSlot::SseHttp1 => SSE_HTTP1.get_or_init(|| tokio::sync::Mutex::new(())), + GateSlot::SseHttp2 => SSE_HTTP2.get_or_init(|| tokio::sync::Mutex::new(())), + GateSlot::SseHttp3 => SSE_HTTP3.get_or_init(|| tokio::sync::Mutex::new(())), + } +} + +impl RouteGate { + pub(crate) fn mark_slow_entered(&self) { + self.slow_entered.notify_one(); + } + + pub(crate) async fn await_slow_entered(&self) { + let bound: Duration = Duration::from_secs(GATE_TIMEOUT_SECS); + timeout(bound, self.slow_entered.notified()) + .await + .expect("the gated slow route must be entered within the gate timeout"); + } + + pub(crate) async fn await_slow_release(&self) { + let bound: Duration = Duration::from_secs(GATE_TIMEOUT_SECS); + timeout(bound, self.slow_release.notified()) + .await + .expect("the gated slow route must be released within the gate timeout"); + } + + pub(crate) fn release_slow(&self) { + self.slow_release.notify_one(); + } + + pub(crate) fn mark_sse_first_chunk_sent(&self) { + self.sse_first_chunk_sent.notify_one(); + } + + pub(crate) async fn await_first_chunk_sent(&self) { + let bound: Duration = Duration::from_secs(GATE_TIMEOUT_SECS); + timeout(bound, self.sse_first_chunk_sent.notified()) + .await + .expect("the first sse chunk must be sent within the gate timeout"); + } + + pub(crate) async fn await_sse_release(&self) { + let bound: Duration = Duration::from_secs(GATE_TIMEOUT_SECS); + timeout(bound, self.sse_release.notified()) + .await + .expect("the sse handler must be released within the gate timeout"); + } + + pub(crate) fn release_sse(&self) { + self.sse_release.notify_one(); + } +} + +pub(crate) fn joined_query(request: &Request, keys: [&str; 2]) -> String { + let mut collected: Vec = Vec::new(); + for key in keys { + let value: Option> = request.try_get_query(key); + match value { + Some(entries) => { + for entry in entries { + collected.push(format!("{key}={entry}")); + } + } + None => collected.push(format!("{key}=")), + } + } + collected.join(";") +} + +pub(crate) fn joined_header(request: &Request, key: &str) -> String { + let value: Option> = request.try_get_header(key); + match value { + Some(entries) => entries.into_iter().collect::>().join(";"), + None => String::new(), + } +} + +pub(crate) fn fragment_writes(data: &[u8]) -> Vec> { + let mut pieces: Vec> = Vec::new(); + let mut offset: usize = 0; + while offset < data.len() { + let end: usize = usize::min(offset + SPLIT_FRAGMENT_LEN, data.len()); + pieces.push(data[offset..end].to_vec()); + offset = end; + } + pieces +} diff --git a/core/tests/protocol/common/mod.rs b/core/tests/protocol/common/mod.rs new file mode 100644 index 00000000..636a9859 --- /dev/null +++ b/core/tests/protocol/common/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod r#const; +pub(crate) mod r#fn; +pub(crate) mod r#gate; +pub(crate) mod r#server; + +use {r#const::*, r#server::*}; + +use super::*; diff --git a/core/tests/protocol/common/server.rs b/core/tests/protocol/common/server.rs new file mode 100644 index 00000000..ca1cfb28 --- /dev/null +++ b/core/tests/protocol/common/server.rs @@ -0,0 +1,238 @@ +use super::*; + +pub(crate) fn reserve_tcp_port() -> u16 { + let listener: TcpListener = TcpListener::bind(format!("{LOOPBACK_HOST}:0")) + .expect("loopback TCP port must be reservable"); + let port: u16 = listener + .local_addr() + .expect("reserved listener must report a local address") + .port(); + drop(listener); + port +} + +pub(crate) fn reserve_udp_port() -> u16 { + let socket: UdpSocket = UdpSocket::bind(format!("{LOOPBACK_HOST}:0")) + .expect("loopback UDP port must be reservable"); + let port: u16 = socket + .local_addr() + .expect("reserved socket must report a local address") + .port(); + drop(socket); + port +} + +pub(crate) fn protocol_config( + address: &str, + protocol: ServerProtocol, + tls: Option, +) -> ServerConfig { + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(address) + .set_protocol(protocol) + .set_tls(tls); + config +} + +pub(crate) async fn start_protocol_server( + protocol: ServerProtocol, + tls: Option, +) -> Result<(ServerControlHook, u16), Box> { + let mut server: Server = Server::default(); + let address: String = if protocol.is_quic() { + format!("{LOOPBACK_HOST}:{}", reserve_udp_port()) + } else { + format!("{LOOPBACK_HOST}:{}", reserve_tcp_port()) + }; + server.server_config(protocol_config(&address, protocol, tls)); + register_probe_hooks(&mut server); + let control: ServerControlHook = server.run().await?; + drop(server); + sleep(Duration::from_millis(SERVER_BOOT_MILLIS)).await; + let port: u16 = address + .rsplit(':') + .next() + .and_then(|value: &str| value.parse::().ok()) + .expect("bind address must carry a numeric port"); + Ok((control, port)) +} + +pub(crate) async fn start_dropped_caller_server() +-> Result<(ServerControlHook, u16), Box> { + let address: String = format!("{LOOPBACK_HOST}:{}", reserve_tcp_port()); + let control: ServerControlHook = { + let mut server: Server = Server::default(); + server.server_config(protocol_config(&address, ServerProtocol::Http1, None)); + register_probe_hooks(&mut server); + server.run().await? + }; + sleep(Duration::from_millis(SERVER_BOOT_MILLIS)).await; + let port: u16 = address + .rsplit(':') + .next() + .and_then(|value: &str| value.parse::().ok()) + .expect("bind address must carry a numeric port"); + Ok((control, port)) +} + +pub(crate) async fn assert_wait_pending_then_complete(control: &ServerControlHook) { + let probe: ServerControlHook = control.clone(); + let pending: JoinHandle<()> = spawn(async move { + probe.wait().await; + }); + tokio::time::sleep(Duration::from_millis(SHUTDOWN_PROBE_MILLIS)).await; + assert!( + !pending.is_finished(), + "wait must still be pending before shutdown is requested" + ); + control.shutdown().await; + timeout(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), pending) + .await + .expect("wait must complete once shutdown has been requested") + .expect("the wait task must not panic"); +} + +pub(crate) async fn connect_tcp(port: u16) -> io::Result { + timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + TcpStream::connect((LOOPBACK_HOST, port)), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "tcp connect timed out"))? +} + +pub(crate) async fn raw_http1_exchange(port: u16, request: &[u8]) -> io::Result> { + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), async move { + let mut stream: TcpStream = connect_tcp(port).await?; + stream.write_all(request).await?; + stream.flush().await?; + let mut received: Vec = Vec::new(); + let mut chunk: [u8; 16 * 1024] = [0; 16 * 1024]; + loop { + match stream.read(&mut chunk).await { + Ok(0) => break, + Ok(read) => received.extend_from_slice(&chunk[..read]), + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(error), + } + } + Ok(received) + }) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "http1 exchange timed out"))? +} + +pub(crate) fn response_head(raw: &[u8]) -> String { + let text: String = String::from_utf8_lossy(raw).to_string(); + let end: usize = text.find(HTTP_DOUBLE_BR).unwrap_or(text.len()); + text[..end].to_string() +} + +pub(crate) fn response_head_and_body(raw: &[u8]) -> (String, Vec) { + match raw + .windows(HTTP_DOUBLE_BR_BYTES.len()) + .position(|window: &[u8]| window == HTTP_DOUBLE_BR_BYTES) + { + Some(index) => ( + String::from_utf8_lossy(&raw[..index]).to_string(), + raw[index + HTTP_DOUBLE_BR_BYTES.len()..].to_vec(), + ), + None => (String::from_utf8_lossy(raw).to_string(), Vec::new()), + } +} + +pub(crate) fn count_http1_responses(buffer: &[u8]) -> usize { + let mut count: usize = 0; + let mut position: usize = 0; + while let Some(index) = buffer[position.min(buffer.len())..] + .windows(HTTP_DOUBLE_BR_BYTES.len()) + .position(|window: &[u8]| window == HTTP_DOUBLE_BR_BYTES) + { + let head_end: usize = position + index; + let head: String = String::from_utf8_lossy(&buffer[position..head_end]).to_string(); + let mut content_length: Option = None; + for line in head.split(HTTP_BR) { + if let Some((key, value)) = line.split_once(':') + && key.trim().eq_ignore_ascii_case(CONTENT_LENGTH) + { + content_length = value.trim().parse::().ok(); + } + } + let body_start: usize = head_end + HTTP_DOUBLE_BR_BYTES.len(); + match content_length { + Some(length) if buffer.len() >= body_start + length => { + count += 1; + position = body_start + length; + } + _ => break, + } + } + count +} + +pub(crate) async fn read_http1_head( + stream: &mut T, + expected_body: usize, +) -> io::Result<(String, Vec)> +where + T: tokio::io::AsyncRead + Unpin, +{ + let mut collected: Vec = Vec::new(); + let mut chunk: [u8; 16 * 1024] = [0; 16 * 1024]; + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + while Instant::now() < deadline { + if let Some(index) = collected + .windows(HTTP_DOUBLE_BR_BYTES.len()) + .position(|window: &[u8]| window == HTTP_DOUBLE_BR_BYTES) + { + let body_start: usize = index + HTTP_DOUBLE_BR_BYTES.len(); + if collected.len() >= body_start + expected_body { + break; + } + } + let bound: Duration = Duration::from_secs(REQUEST_TIMEOUT_SECS); + let read: usize = match timeout(bound, stream.read(&mut chunk)).await { + Ok(Ok(count)) => count, + Ok(Err(error)) => return Err(error), + Err(_) => break, + }; + if read == 0 { + break; + } + collected.extend_from_slice(&chunk[..read]); + } + Ok(response_head_and_body(&collected)) +} + +pub(crate) async fn write_bounded(stream: &mut T, data: &[u8]) -> io::Result<()> +where + T: tokio::io::AsyncWrite + Unpin, +{ + timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + stream.write_all(data), + ) + .await + .map_err(|_: tokio::time::error::Elapsed| { + io::Error::new(io::ErrorKind::TimedOut, "socket write timed out") + })? +} + +pub(crate) async fn exchange_on(stream: &mut TcpStream, request: &[u8]) -> Vec { + write_bounded(stream, request) + .await + .expect("request write must not time out"); + stream.flush().await.expect("request flush must succeed"); + let mut received: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + let mut chunk: [u8; 16 * 1024] = [0; 16 * 1024]; + while Instant::now() < deadline { + match stream.read(&mut chunk).await { + Ok(0) => break, + Ok(read) => received.extend_from_slice(&chunk[..read]), + Err(_) => break, + } + } + received +} diff --git a/core/tests/protocol/http1/fn.rs b/core/tests/protocol/http1/fn.rs new file mode 100644 index 00000000..e7b16931 --- /dev/null +++ b/core/tests/protocol/http1/fn.rs @@ -0,0 +1,419 @@ +use super::*; + +#[tokio::test] +async fn http1_plaintext_serves_default_protocol_over_tcp() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let request: Vec = http1_close_request("GET", VERSION_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("plaintext http1 exchange must complete"); + let head: String = response_head(&raw); + assert!( + head.starts_with("HTTP/1.1 200"), + "expected an HTTP/1.1 200 head, got: {head}" + ); + let (_, body): (String, Vec) = response_head_and_body(&raw); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http1_1) + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_reads_path_query_and_header_through_middleware() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let target: String = format!("{PROBE_PATH}?{}", PROBE_QUERY); + let request: Vec = http1_request_with_headers("GET", &target, PROBE_REPEATED_HEADERS, &[]); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("plaintext http1 exchange must complete"); + let head: String = response_head(&raw); + assert_eq!( + response_header_value(&head, PROBE_METHOD_HEADER), + PROBE_METHOD, + "the handler must observe the exact request method, got: {head}" + ); + assert_eq!( + response_header_value(&head, PROBE_PATH_HEADER), + PROBE_PATH, + "the handler must observe the exact request path, got: {head}" + ); + assert_eq!( + response_header_value(&head, PROBE_QUERY_HEADER), + PROBE_QUERY_EXPECTED, + "every query key must be read back with its value, got: {head}" + ); + assert_eq!( + response_header_value(&head, PROBE_HEADER_ECHO_HEADER), + PROBE_HEADER_EXPECTED, + "a repeated request header must keep every value in order, got: {head}" + ); + assert_eq!( + response_header_value(&head, PROBE_VERSION_HEADER), + format!("{:?}", HttpVersion::Http1_1), + "the handler must observe HTTP/1.1, got: {head}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_tls_negotiates_http11_alpn_against_validated_chain() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + let stream: ClientTlsStream = connect_tls(port, ALPN_HTTP1) + .await + .expect("validating client must complete the handshake"); + let alpn: Option> = negotiated_alpn(&stream); + assert_eq!( + alpn, + Some(ALPN_HTTP1.to_vec()), + "the server must select the http/1.1 ALPN identifier" + ); + let request: Vec = http1_close_request("GET", VERSION_ROUTE_PATH); + let raw: Vec = tls_exchange(stream, request) + .await + .expect("tls http1 exchange must complete"); + let head: String = response_head(&raw); + assert!( + head.starts_with("HTTP/1.1 200"), + "expected HTTP/1.1 over TLS, got: {head}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_tls_rejects_a_client_whose_alpn_never_matches() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + let outcome: io::Result = connect_tls(port, ALPN_HTTP2).await; + assert!( + outcome.is_err(), + "an http1 listener must refuse an h2-only client instead of silently downgrading" + ); + let valid: io::Result = connect_tls(port, ALPN_HTTP1).await; + assert!( + valid.is_ok(), + "a rejected client must not disturb a later valid connection" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_binary_post_round_trips_hostile_bytes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let payload: Vec = hostile_binary_payload(); + let request: Vec = http1_request("POST", BINARY_ROUTE_PATH, &payload); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("binary post must complete"); + let (_, body): (String, Vec) = response_head_and_body(&raw); + assert_eq!( + body, payload, + "every hostile byte must survive the http1 body round trip" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_refuses_a_truncated_content_length_body() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let head: Vec = format!( + "POST {BINARY_ROUTE_PATH} HTTP/1.1{HTTP_BR}Host: {TLS_SERVER_NAME}{HTTP_BR}Content-Length: 100{HTTP_BR}{HTTP_BR}" + ) + .into_bytes(); + let mut request: Vec = head; + request.extend_from_slice(b"only-ten!!"); + if let Ok(raw) = raw_http1_exchange(port, &request).await { + let text: String = response_head(&raw); + assert!( + !text.contains(" 200"), + "a truncated body must not be answered as a complete request, got: {text}" + ); + } + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_streams_split_writes_before_the_handler_finishes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let request: Vec = http1_close_request("GET", SPLIT_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("split write exchange must complete"); + let head: String = response_head(&raw); + assert!( + !head.to_lowercase().contains(CONTENT_LENGTH), + "an event-stream response must not be framed with a content-length, got: {head}" + ); + let (_, body): (String, Vec) = response_head_and_body(&raw); + let text: String = String::from_utf8_lossy(&body).to_string(); + assert_eq!( + text, + format!("{SPLIT_HEAD_BODY}{SPLIT_TAIL_BODY}"), + "every separately written fragment must reach the client in order, got: {text:?}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_tls_streams_split_writes_through_the_tls_listener() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + let stream: ClientTlsStream = connect_tls(port, ALPN_HTTP1) + .await + .expect("validating client must complete the handshake"); + let request: Vec = http1_close_request("GET", SPLIT_ROUTE_PATH); + let raw: Vec = tls_exchange(stream, request) + .await + .expect("split write exchange must complete"); + let head: String = response_head(&raw); + assert!( + !head.to_lowercase().contains(CONTENT_LENGTH), + "an event-stream response must not be framed with a content-length, got: {head}" + ); + let (_, body): (String, Vec) = response_head_and_body(&raw); + let text: String = String::from_utf8_lossy(&body).to_string(); + assert_eq!( + text, + format!("{SPLIT_HEAD_BODY}{SPLIT_TAIL_BODY}"), + "every separately written fragment must survive TLS in order, got: {text:?}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_reports_the_real_local_and_peer_addresses_of_the_socket() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let mut stream: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let client_local: SocketAddr = stream + .local_addr() + .expect("a connected socket must report its local address"); + let request: Vec = http1_close_request("GET", SOCKET_METADATA_ROUTE_PATH); + let raw: Vec = exchange_on(&mut stream, &request).await; + let (_, body): (String, Vec) = response_head_and_body(&raw); + let text: String = String::from_utf8_lossy(&body).to_string(); + let (reported_local, reported_peer): (String, String) = + match text.split_once(SOCKET_METADATA_SEPARATOR) { + Some(pair) => (pair.0.to_string(), pair.1.to_string()), + None => panic!("the route must report both addresses, got: {text:?}"), + }; + let expected_local: SocketAddr = format!("{LOOPBACK_HOST}:{port}") + .parse() + .expect("the bind address must parse"); + assert_eq!( + reported_local, + expected_local.to_string(), + "the handler must see the bound local address, not a fabricated one" + ); + assert_eq!( + reported_peer, + client_local.to_string(), + "the handler must see this connection's own peer address" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_sse_chunk_reaches_the_client_before_the_handler_finishes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let _lease: GateLease = gate_lease(GateSlot::SseHttp1).await; + let mut stream: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let request: Vec = http1_close_request("GET", SSE_ROUTE_PATH); + write_bounded(&mut stream, &request) + .await + .expect("sse request must be written"); + let mut received: Vec = Vec::new(); + let first_chunk_deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + while !String::from_utf8_lossy(&received).contains(SSE_FIRST_CHUNK) { + assert!( + Instant::now() < first_chunk_deadline, + "the first sse chunk must arrive while the handler is still blocked, got: {:?}", + String::from_utf8_lossy(&received) + ); + read_more(&mut stream, &mut received).await; + } + assert!( + !String::from_utf8_lossy(&received).contains(SSE_SECOND_CHUNK), + "the second chunk must not appear before the handler is released" + ); + route_gate(GateSlot::SseHttp1).release_sse(); + let completion_deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + while !String::from_utf8_lossy(&received).contains(SSE_SECOND_CHUNK) { + assert!( + Instant::now() < completion_deadline, + "the second sse chunk must arrive once the handler is released" + ); + read_more(&mut stream, &mut received).await; + } + control.shutdown().await; +} + +#[tokio::test] +async fn http1_tls_sse_chunk_reaches_the_client_before_the_handler_finishes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + let _lease: GateLease = gate_lease(GateSlot::SseHttp1).await; + let mut stream: ClientTlsStream = connect_tls(port, ALPN_HTTP1) + .await + .expect("validating client must complete the handshake"); + let request: Vec = http1_close_request("GET", SSE_ROUTE_PATH); + write_bounded(&mut stream, &request) + .await + .expect("sse request must be written"); + let first: String = http1_head_until(&mut stream, SSE_FIRST_CHUNK).await; + assert!( + first.contains(SSE_FIRST_CHUNK), + "the first chunk must arrive over tls while the handler is blocked, got: {first:?}" + ); + assert!( + !first.contains(SSE_SECOND_CHUNK), + "the second chunk must wait for the handler to be released, got: {first:?}" + ); + route_gate(GateSlot::SseHttp1).release_sse(); + let second: String = http1_head_until(&mut stream, SSE_SECOND_CHUNK).await; + assert!( + second.contains(SSE_SECOND_CHUNK), + "the second chunk must arrive once the tls handler is released, got: {second:?}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_sse_response_carries_the_event_stream_content_type() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let _lease: GateLease = gate_lease(GateSlot::SseHttp1).await; + let mut stream: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let request: Vec = http1_close_request("GET", SSE_ROUTE_PATH); + write_bounded(&mut stream, &request) + .await + .expect("sse request must be written"); + let head: String = http1_head_until(&mut stream, HTTP_DOUBLE_BR).await; + assert!( + head.to_lowercase().contains(TEXT_EVENT_STREAM), + "the sse route must send an event-stream content type, got: {head}" + ); + route_gate(GateSlot::SseHttp1).release_sse(); + let completed: String = http1_head_until(&mut stream, SSE_SECOND_CHUNK).await; + assert!( + completed.contains(SSE_SECOND_CHUNK), + "the sse response must run to its end once released, got: {completed:?}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_repeats_a_duplicate_response_cookie() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + let request: Vec = http1_close_request("GET", COOKIE_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("cookie exchange must complete"); + let head: String = response_head(&raw); + let cookies: usize = head.to_lowercase().matches(SET_COOKIE).count(); + assert_eq!( + cookies, 2, + "both set-cookie headers must survive, got: {head}" + ); + assert!( + head.contains("dup=one") && head.contains("dup2=two"), + "both cookie values must be present, got: {head}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_answers_four_pipelined_requests_on_one_connection() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + sleep(Duration::from_millis(SERVER_BOOT_MILLIS)).await; + let mut stream: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let mut batch: Vec = Vec::new(); + for _ in 0..4 { + batch.extend_from_slice(&http1_request("GET", VERSION_ROUTE_PATH, &[])); + } + write_bounded(&mut stream, &batch) + .await + .expect("pipelined batch must be written"); + let mut received: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + let mut chunk: [u8; 16 * 1024] = [0; 16 * 1024]; + while Instant::now() < deadline && count_http1_responses(&received) < 4 { + match stream.read(&mut chunk).await { + Ok(0) => break, + Ok(read) => received.extend_from_slice(&chunk[..read]), + Err(_) => break, + } + } + assert_eq!( + count_http1_responses(&received), + 4, + "every pipelined request must be answered on the same connection" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_plaintext_shutdown_releases_the_bound_tcp_port() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 plaintext server must start"); + assert_wait_pending_then_complete(&control).await; + let rebound: io::Result = TcpListener::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "port {port} must be free again after shutdown and wait" + ); +} + +#[tokio::test] +async fn http1_tls_shutdown_releases_the_bound_tcp_port() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + assert_wait_pending_then_complete(&control).await; + let rebound: io::Result = TcpListener::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "port {port} must be free again after a tls shutdown and wait" + ); +} diff --git a/core/tests/protocol/http1/mod.rs b/core/tests/protocol/http1/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/core/tests/protocol/http1/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/core/tests/protocol/http2/assert.rs b/core/tests/protocol/http2/assert.rs new file mode 100644 index 00000000..6bc39d2b --- /dev/null +++ b/core/tests/protocol/http2/assert.rs @@ -0,0 +1,386 @@ +use super::*; + +#[tokio::test] +async fn http2_plaintext_reports_the_real_local_and_peer_addresses_of_the_connection() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let socket: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let client_local: SocketAddr = socket + .local_addr() + .expect("a connected socket must report its local address"); + let (mut sender, connection) = timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + h2::client::handshake(socket), + ) + .await + .expect("h2 prior-knowledge handshake must not time out") + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let (status, _version, body): H2Reply = h2_request( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{SOCKET_METADATA_ROUTE_PATH}"), + &[], + ) + .await + .expect("h2 socket metadata request must complete"); + assert_eq!(status, 200); + let text: String = String::from_utf8_lossy(&body).to_string(); + let (reported_local, reported_peer): (String, String) = + match text.split_once(SOCKET_METADATA_SEPARATOR) { + Some(pair) => (pair.0.to_string(), pair.1.to_string()), + None => panic!("the route must report both addresses, got: {text:?}"), + }; + let expected_local: SocketAddr = format!("{LOOPBACK_HOST}:{port}") + .parse() + .expect("the bind address must parse"); + assert_eq!( + reported_local, + expected_local.to_string(), + "an h2 stream must carry the connection's real local address" + ); + assert_eq!( + reported_peer, + client_local.to_string(), + "an h2 stream must carry the connection's real peer address, not a fabricated one" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_streams_a_body_that_never_announced_a_content_length() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let (status, headers, body): H2HeadReply = h2_request_with_headers( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{UNFRAMED_BODY_ROUTE_PATH}"), + &[], + ) + .await + .expect("an unframed h2 body must be accepted"); + assert_eq!(status, 200); + assert!( + !headers.contains_key(CONTENT_LENGTH), + "the head must not announce a length for a body framed by data frames" + ); + assert_eq!( + String::from_utf8_lossy(&body), + UNFRAMED_BODY, + "a body with no content-length must still reach the client in full" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_plaintext_prior_knowledge_serves_http2_over_tcp() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let (status, version, body): H2Reply = h2_request( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"), + &[], + ) + .await + .expect("h2 plaintext request must complete"); + assert_eq!(status, 200); + assert_eq!( + version, + http::Version::HTTP_2, + "the response must be framed as HTTP/2, not downgraded" + ); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http2) + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_plaintext_prior_knowledge_reads_path_query_and_header() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let target: String = format!( + "http://{TLS_SERVER_NAME}:{port}{PROBE_PATH}?{}", + PROBE_QUERY + ); + let (status, _version, _body): H2Reply = h2_request(&mut sender, "GET", &target, &[]) + .await + .expect("h2 probe request must complete"); + assert_eq!(status, 200); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_tls_negotiates_h2_alpn_against_validated_chain() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, Some(trusted_tls_config())) + .await + .expect("http2 tls server must start"); + let (alpn, (mut sender, connection)): (Option>, H2TlsSession) = h2_tls_alpn(port) + .await + .expect("h2 tls handshake must succeed"); + assert_eq!( + alpn, + Some(ALPN_HTTP2.to_vec()), + "the server must select the h2 ALPN identifier" + ); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let (status, version, body): H2Reply = h2_request( + &mut sender, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"), + &[], + ) + .await + .expect("h2 tls request must complete"); + assert_eq!(status, 200); + assert_eq!(version, http::Version::HTTP_2); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http2) + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_tls_rejects_a_client_offering_only_http11() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, Some(trusted_tls_config())) + .await + .expect("http2 tls server must start"); + let outcome: io::Result = connect_tls(port, ALPN_HTTP1).await; + assert!( + outcome.is_err(), + "an http2 listener must refuse an http/1.1-only client instead of downgrading" + ); + let valid: io::Result = connect_tls(port, ALPN_HTTP2).await; + assert!( + valid.is_ok(), + "a rejected client must not disturb a later valid connection" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_plaintext_binary_post_round_trips_hostile_bytes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let payload: Vec = hostile_binary_payload(); + let (status, version, body): H2Reply = h2_request( + &mut sender, + "POST", + &format!("http://{TLS_SERVER_NAME}:{port}{BINARY_ROUTE_PATH}"), + &payload, + ) + .await + .expect("h2 binary post must complete"); + assert_eq!(status, 200); + assert_eq!(version, http::Version::HTTP_2); + assert_eq!( + body, payload, + "every hostile byte must survive the h2 body round trip" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_plaintext_delivers_a_response_above_the_flow_control_window() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let expected: Vec = large_binary_payload(); + let (status, _version, body): H2Reply = h2_request( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{LARGE_ROUTE_PATH}"), + &[], + ) + .await + .expect("large h2 response must complete"); + assert_eq!(status, 200); + assert_eq!( + body.len(), + expected.len(), + "the whole multi-megabyte body must arrive" + ); + assert!( + body == expected, + "the large response must be byte identical past the flow control window" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_one_connection_answers_a_fast_stream_while_a_slow_stream_is_blocked() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let (sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 prior-knowledge handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let _lease: GateLease = gate_lease(GateSlot::SlowHttp2).await; + let order: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let slow_target: String = format!("http://{TLS_SERVER_NAME}:{port}{SLOW_ROUTE_PATH}"); + let slow_order: Arc>> = Arc::clone(&order); + let mut slow_sender: h2::client::SendRequest = sender.clone(); + let slow: JoinHandle> = spawn(async move { + let outcome: Result = + h2_request(&mut slow_sender, "GET", &slow_target, &[]).await; + slow_order.lock().expect("order lock").push("slow"); + outcome + }); + + route_gate(GateSlot::SlowHttp2).await_slow_entered().await; + + let fast_target: String = format!("http://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"); + let fast_order: Arc>> = Arc::clone(&order); + let mut fast_sender: h2::client::SendRequest = sender.clone(); + let fast: JoinHandle> = spawn(async move { + let outcome: Result = + h2_request(&mut fast_sender, "GET", &fast_target, &[]).await; + fast_order.lock().expect("order lock").push("fast"); + outcome + }); + + let budget: Duration = Duration::from_millis(FAST_STREAM_BUDGET_MILLIS); + let fast_result: Result = timeout(budget, fast) + .await + .expect("the fast stream must complete while the slow handler is still blocked") + .expect("the fast stream task must not panic"); + assert!( + fast_result.is_ok(), + "the fast request must succeed while the slow handler is held" + ); + assert_eq!( + *order.lock().expect("order lock"), + vec!["fast"], + "only the fast stream may complete while the slow handler is blocked" + ); + + route_gate(GateSlot::SlowHttp2).release_slow(); + let slow_result: Result = + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), slow) + .await + .expect("the released slow stream must finish inside its timeout") + .expect("the slow stream task must not panic"); + assert!(slow_result.is_ok()); + assert_eq!( + *order.lock().expect("order lock"), + vec!["fast", "slow"], + "the fast stream must complete before the released slow one" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_plaintext_shutdown_releases_the_bound_tcp_port() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + assert_wait_pending_then_complete(&control).await; + let rebound: io::Result = TcpListener::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "port {port} must be free again after shutdown and wait" + ); +} + +#[tokio::test] +async fn http2_sse_chunk_reaches_the_client_before_the_handler_finishes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 plaintext server must start"); + let _lease: GateLease = gate_lease(GateSlot::SseHttp2).await; + let target: String = format!("http://{TLS_SERVER_NAME}:{port}{SSE_ROUTE_PATH_HTTP2}"); + let reading: JoinHandle<(Vec, bool)> = + spawn(async move { h2_stream_until(port, &target, SSE_FIRST_CHUNK).await }); + route_gate(GateSlot::SseHttp2) + .await_first_chunk_sent() + .await; + let (collected, matched): (Vec, bool) = + timeout(Duration::from_secs(FAST_STREAM_BUDGET_MILLIS), reading) + .await + .expect("the first sse chunk must be readable while the handler is still blocked") + .expect("the reader task must not panic"); + let text: String = String::from_utf8_lossy(&collected).to_string(); + assert!( + matched, + "the first sse chunk must be readable while the handler is still blocked, got: {text:?}" + ); + assert!( + !text.contains(SSE_SECOND_CHUNK), + "the second chunk must wait for the handler to be released, got: {text:?}" + ); + route_gate(GateSlot::SseHttp2).release_sse(); + control.shutdown().await; +} diff --git a/core/tests/protocol/http2/fn.rs b/core/tests/protocol/http2/fn.rs new file mode 100644 index 00000000..1a5bbc40 --- /dev/null +++ b/core/tests/protocol/http2/fn.rs @@ -0,0 +1,184 @@ +use super::*; + +pub(crate) async fn h2_plaintext_session( + port: u16, +) -> Result< + ( + h2::client::SendRequest, + h2::client::Connection, + ), + String, +> { + let socket: TcpStream = connect_tcp(port) + .await + .map_err(|error: io::Error| format!("h2 plaintext connect failed: {error}"))?; + timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + h2::client::handshake(socket), + ) + .await + .map_err(|_| "h2 plaintext handshake timed out".to_string())? + .map_err(|error: h2::Error| format!("h2 plaintext handshake failed: {error}")) +} + +pub(crate) async fn h2_tls_alpn(port: u16) -> Result<(Option>, H2TlsSession), String> { + let stream: ClientTlsStream = connect_tls(port, ALPN_HTTP2) + .await + .map_err(|error: io::Error| format!("h2 tls connect failed: {error}"))?; + let alpn: Option> = negotiated_alpn(&stream); + let (send, connection) = timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + h2::client::handshake(stream), + ) + .await + .map_err(|_| "h2 tls handshake timed out".to_string())? + .map_err(|error: h2::Error| format!("h2 tls handshake failed: {error}"))?; + Ok((alpn, (send, connection))) +} + +pub(crate) async fn h2_request( + sender: &mut h2::client::SendRequest, + method: &str, + target: &str, + body: &[u8], +) -> Result { + let request: http::Request<()> = http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE) + .body(()) + .map_err(|error: http::Error| format!("h2 request build failed: {error}"))?; + let (response, mut send_stream) = sender + .send_request(request, body.is_empty()) + .map_err(|error: h2::Error| format!("h2 send_request failed: {error}"))?; + if !body.is_empty() { + send_stream + .send_data(Bytes::copy_from_slice(body), true) + .map_err(|error: h2::Error| format!("h2 send_data failed: {error}"))?; + } + let response: http::Response = + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), response) + .await + .map_err(|_| "h2 response timed out".to_string())? + .map_err(|error: h2::Error| format!("h2 response failed: {error}"))?; + let status: u16 = response.status().as_u16(); + let version: http::Version = response.version(); + let mut received: h2::RecvStream = response.into_body(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("h2 body timed out".to_string()); + } + match timeout(remaining, received.data()).await { + Ok(Some(Ok(chunk))) => { + let _released: Result<(), h2::Error> = + received.flow_control().release_capacity(chunk.len()); + collected.extend_from_slice(&chunk); + } + Ok(Some(Err(error))) => return Err(format!("h2 body read failed: {error}")), + Ok(None) => break, + Err(_) => return Err("h2 body timed out".to_string()), + } + } + Ok((status, version, collected)) +} + +pub(crate) async fn h2_request_with_headers( + sender: &mut h2::client::SendRequest, + method: &str, + target: &str, + body: &[u8], +) -> Result { + let request: http::Request<()> = http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE) + .body(()) + .map_err(|error: http::Error| format!("h2 request build failed: {error}"))?; + let (response, mut send_stream) = sender + .send_request(request, body.is_empty()) + .map_err(|error: h2::Error| format!("h2 send_request failed: {error}"))?; + if !body.is_empty() { + send_stream + .send_data(Bytes::copy_from_slice(body), true) + .map_err(|error: h2::Error| format!("h2 send_data failed: {error}"))?; + } + let response: http::Response = + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), response) + .await + .map_err(|_| "h2 response timed out".to_string())? + .map_err(|error: h2::Error| format!("h2 response failed: {error}"))?; + let status: u16 = response.status().as_u16(); + let headers: http::HeaderMap = response.headers().clone(); + let mut received: h2::RecvStream = response.into_body(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("h2 body timed out".to_string()); + } + match timeout(remaining, received.data()).await { + Ok(Some(Ok(chunk))) => { + let _released: Result<(), h2::Error> = + received.flow_control().release_capacity(chunk.len()); + collected.extend_from_slice(&chunk); + } + Ok(Some(Err(error))) => return Err(format!("h2 body read failed: {error}")), + Ok(None) => break, + Err(_) => return Err("h2 body timed out".to_string()), + } + } + Ok((status, headers, collected)) +} + +pub(crate) async fn h2_stream_until(port: u16, target: &str, needle: &str) -> (Vec, bool) { + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let request: http::Request<()> = http::Request::builder() + .method("GET") + .uri(target) + .body(()) + .expect("request must build"); + let (response, _send_stream) = sender + .send_request(request, true) + .expect("h2 request must be accepted"); + let response: http::Response = + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), response) + .await + .expect("h2 response head must arrive") + .expect("h2 response must not fail"); + let mut body: h2::RecvStream = response.into_body(); + let mut collected: Vec = Vec::new(); + let mut matched: bool = false; + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + if String::from_utf8_lossy(&collected).contains(needle) { + matched = true; + break; + } + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match timeout(remaining, body.data()).await { + Ok(Some(Ok(chunk))) => { + let _released: Result<(), h2::Error> = + body.flow_control().release_capacity(chunk.len()); + collected.extend_from_slice(&chunk); + } + Ok(Some(Err(_))) => break, + Ok(None) => break, + Err(_) => break, + } + } + drop(sender); + driving.abort(); + (collected, matched) +} diff --git a/core/tests/protocol/http2/mod.rs b/core/tests/protocol/http2/mod.rs new file mode 100644 index 00000000..b959baa2 --- /dev/null +++ b/core/tests/protocol/http2/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod r#assert; +pub(crate) mod r#fn; +pub(crate) mod r#type; + +use {r#fn::*, r#type::*}; + +use super::*; diff --git a/core/tests/protocol/http2/type.rs b/core/tests/protocol/http2/type.rs new file mode 100644 index 00000000..daa8141c --- /dev/null +++ b/core/tests/protocol/http2/type.rs @@ -0,0 +1,10 @@ +use super::*; + +pub(crate) type H2TlsSession = ( + h2::client::SendRequest, + h2::client::Connection, +); + +pub(crate) type H2Reply = (u16, http::Version, Vec); + +pub(crate) type H2HeadReply = (u16, http::HeaderMap, Vec); diff --git a/core/tests/protocol/http3/assert.rs b/core/tests/protocol/http3/assert.rs new file mode 100644 index 00000000..0623ed20 --- /dev/null +++ b/core/tests/protocol/http3/assert.rs @@ -0,0 +1,295 @@ +use super::*; + +#[tokio::test] +async fn http3_over_quic_reports_the_real_local_and_peer_addresses_of_the_connection() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let (endpoint, connection): (quinn::Endpoint, quinn::Connection) = + connect_quic(port, ALPN_HTTP3) + .await + .expect("a quic client must connect"); + let client_local: SocketAddr = endpoint + .local_addr() + .expect("the client endpoint must report its own udp address"); + let (status, _headers, body, _alpn): H3HeadReply = h3_exchange_on( + connection, + None, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{SOCKET_METADATA_ROUTE_PATH}"), + &[], + ) + .await + .expect("h3 socket metadata request must complete"); + assert_eq!(status, 200); + let text: String = String::from_utf8_lossy(&body).to_string(); + let (reported_local, reported_peer): (String, String) = + match text.split_once(SOCKET_METADATA_SEPARATOR) { + Some(pair) => (pair.0.to_string(), pair.1.to_string()), + None => panic!("the route must report both addresses, got: {text:?}"), + }; + let expected_local: SocketAddr = format!("{LOOPBACK_HOST}:{port}") + .parse() + .expect("the bind address must parse"); + assert_eq!( + reported_local, + expected_local.to_string(), + "an h3 stream must carry the connection's real local address, not a fabricated one" + ); + assert_eq!( + reported_peer, + client_local.to_string(), + "an h3 stream must carry the connection's real peer address" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_streams_a_body_that_never_announced_a_content_length() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let (status, headers, body, _alpn): H3HeadReply = h3_request_with_headers( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{UNFRAMED_BODY_ROUTE_PATH}"), + &[], + ) + .await + .expect("an unframed h3 body must be accepted"); + assert_eq!(status, 200); + assert!( + !headers.contains_key(CONTENT_LENGTH), + "the head must not announce a length for a body framed by data frames" + ); + assert_eq!( + String::from_utf8_lossy(&body), + UNFRAMED_BODY, + "a body with no content-length must still reach the client in full" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_over_quic_negotiates_h3_alpn_and_tls13() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let (status, version, body, alpn): H3Reply = h3_request( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"), + &[], + ) + .await + .expect("h3 request must complete"); + assert_eq!(status, 200); + assert_eq!( + version, + http::Version::HTTP_3, + "the response must be framed as HTTP/3, not downgraded" + ); + assert_eq!( + alpn, + Some(ALPN_HTTP3.to_vec()), + "QUIC must negotiate the h3 ALPN identifier" + ); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http3) + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_over_quic_reads_path_query_and_header() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let target: String = format!( + "https://{TLS_SERVER_NAME}:{port}{PROBE_PATH}?{}", + PROBE_QUERY + ); + let (status, version, _body, _alpn): H3Reply = h3_request(port, "GET", &target, &[]) + .await + .expect("h3 probe request must complete"); + assert_eq!(status, 200); + assert_eq!(version, http::Version::HTTP_3); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_over_quic_binary_post_round_trips_hostile_bytes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let payload: Vec = hostile_binary_payload(); + let (status, version, body, _alpn): H3Reply = h3_request( + port, + "POST", + &format!("https://{TLS_SERVER_NAME}:{port}{BINARY_ROUTE_PATH}"), + &payload, + ) + .await + .expect("h3 binary post must complete"); + assert_eq!(status, 200); + assert_eq!(version, http::Version::HTTP_3); + assert_eq!( + body, payload, + "every hostile byte must survive the h3 body round trip" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_over_quic_delivers_a_response_above_the_flow_control_window() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let expected: Vec = large_binary_payload(); + let (status, version, body, _alpn): H3Reply = h3_request( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{LARGE_ROUTE_PATH}"), + &[], + ) + .await + .expect("large h3 response must complete"); + assert_eq!(status, 200); + assert_eq!(version, http::Version::HTTP_3); + assert_eq!(body.len(), expected.len()); + assert!( + body == expected, + "the large response must be byte identical past the flow control window" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_over_quic_shutdown_releases_the_bound_udp_port() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + assert_wait_pending_then_complete(&control).await; + let rebound: io::Result = UdpSocket::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "udp port {port} must be free again after shutdown and wait" + ); +} + +#[tokio::test] +async fn http3_over_quic_rejects_a_client_offering_only_h2() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let outcome: Result<(quinn::Endpoint, quinn::Connection), String> = + connect_quic(port, ALPN_HTTP2).await; + assert!( + outcome.is_err(), + "an http3 listener must refuse an h2-only QUIC client instead of downgrading" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn h3_sse_chunk_reaches_the_client_before_the_handler_finishes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let _lease: GateLease = gate_lease(GateSlot::SseHttp3).await; + let target: String = format!("https://{TLS_SERVER_NAME}:{port}{SSE_ROUTE_PATH_HTTP3}"); + let reading: JoinHandle<(Vec, bool)> = + spawn(async move { h3_stream_until(port, &target, SSE_FIRST_CHUNK).await }); + route_gate(GateSlot::SseHttp3) + .await_first_chunk_sent() + .await; + let (collected, matched): (Vec, bool) = + timeout(Duration::from_secs(FAST_STREAM_BUDGET_MILLIS), reading) + .await + .expect("the first sse chunk must be readable while the handler is still blocked") + .expect("the reader task must not panic"); + let text: String = String::from_utf8_lossy(&collected).to_string(); + assert!( + matched, + "the first sse chunk must be readable while the handler is still blocked, got: {text:?}" + ); + assert!( + !text.contains(SSE_SECOND_CHUNK), + "the second chunk must wait for the handler to be released, got: {text:?}" + ); + route_gate(GateSlot::SseHttp3).release_sse(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_one_connection_answers_a_fast_stream_while_a_slow_stream_is_blocked() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let _lease: GateLease = gate_lease(GateSlot::SlowHttp3).await; + let order: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let slow_order: Arc>> = Arc::clone(&order); + let slow: JoinHandle> = spawn(async move { + let outcome: Result = h3_request( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{SLOW_ROUTE_PATH_HTTP3}"), + &[], + ) + .await; + slow_order.lock().expect("order lock").push("slow"); + outcome + }); + + route_gate(GateSlot::SlowHttp3).await_slow_entered().await; + + let fast_order: Arc>> = Arc::clone(&order); + let fast: JoinHandle> = spawn(async move { + let outcome: Result = h3_request( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"), + &[], + ) + .await; + fast_order.lock().expect("order lock").push("fast"); + outcome + }); + + let budget: Duration = Duration::from_millis(FAST_STREAM_BUDGET_MILLIS); + let fast_result: Result = timeout(budget, fast) + .await + .expect("the fast stream must complete while the slow handler is still blocked") + .expect("the fast stream task must not panic"); + assert!( + fast_result.is_ok(), + "the fast request must succeed while the slow handler is held" + ); + assert_eq!( + *order.lock().expect("order lock"), + vec!["fast"], + "only the fast stream may complete while the slow handler is blocked" + ); + + route_gate(GateSlot::SlowHttp3).release_slow(); + let slow_result: Result = + timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), slow) + .await + .expect("the released slow stream must finish inside its timeout") + .expect("the slow stream task must not panic"); + assert!(slow_result.is_ok()); + assert_eq!(*order.lock().expect("order lock"), vec!["fast", "slow"]); + control.shutdown().await; +} diff --git a/core/tests/protocol/http3/fn.rs b/core/tests/protocol/http3/fn.rs new file mode 100644 index 00000000..b3711c5c --- /dev/null +++ b/core/tests/protocol/http3/fn.rs @@ -0,0 +1,243 @@ +use super::*; + +fn quic_client_config(alpn: &[u8]) -> quinn::ClientConfig { + let mut roots: RootCertStore = RootCertStore::empty(); + install_trusted_ca(&mut roots); + let mut config: ClientConfig = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![alpn.to_vec()]; + quinn::ClientConfig::new(Arc::new( + quinn::crypto::rustls::QuicClientConfig::try_from(config) + .expect("the ring provider supports the QUIC initial cipher suite"), + )) +} + +pub(crate) async fn connect_quic( + port: u16, + alpn: &[u8], +) -> Result<(quinn::Endpoint, quinn::Connection), String> { + let bind: SocketAddr = format!("{LOOPBACK_HOST}:0") + .parse() + .map_err(|error: AddrParseError| format!("loopback bind address is invalid: {error}"))?; + let mut endpoint: quinn::Endpoint = quinn::Endpoint::client(bind) + .map_err(|error: io::Error| format!("quic endpoint bind failed: {error}"))?; + endpoint.set_default_client_config(quic_client_config(alpn)); + let address: SocketAddr = format!("{LOOPBACK_HOST}:{port}") + .parse() + .map_err(|error: AddrParseError| format!("loopback address is invalid: {error}"))?; + let connecting: quinn::Connecting = endpoint + .connect(address, TLS_SERVER_NAME) + .map_err(|error: quinn::ConnectError| format!("quic connect failed: {error}"))?; + let connection: quinn::Connection = + timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS), connecting) + .await + .map_err(|_: tokio::time::error::Elapsed| "quic connect timed out".to_string())? + .map_err(|error: quinn::ConnectionError| format!("quic connect failed: {error}"))?; + Ok((endpoint, connection)) +} + +fn negotiated_quic_alpn(connection: &quinn::Connection) -> Option> { + let data: Option> = connection.handshake_data(); + match data { + Some(data) => match data.downcast::() { + Ok(handshake) => handshake.protocol, + Err(_) => None, + }, + None => None, + } +} + +pub(crate) async fn h3_request_with_headers( + port: u16, + method: &str, + target: &str, + body: &[u8], +) -> Result { + let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = + connect_quic(port, ALPN_HTTP3).await?; + let alpn: Option> = negotiated_quic_alpn(&connection); + h3_exchange_on(connection, alpn, method, target, body).await +} + +pub(crate) async fn h3_exchange_on( + connection: quinn::Connection, + alpn: Option>, + method: &str, + target: &str, + body: &[u8], +) -> Result { + let (mut driver, mut client) = h3::client::new(h3_quinn::Connection::new(connection.clone())) + .await + .map_err(|error: h3::error::ConnectionError| format!("h3 handshake failed: {error}"))?; + let driving: JoinHandle<()> = spawn(async move { + let _final: h3::error::ConnectionError = driver.wait_idle().await; + }); + let request: http::Request<()> = http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE) + .body(()) + .map_err(|error: http::Error| format!("request build failed: {error}"))?; + let mut stream: h3::client::RequestStream, Bytes> = client + .send_request(request) + .await + .map_err(|error: h3::error::StreamError| format!("send_request failed: {error}"))?; + if !body.is_empty() { + stream + .send_data(Bytes::copy_from_slice(body)) + .await + .map_err(|error: h3::error::StreamError| format!("send_data failed: {error}"))?; + } + stream + .finish() + .await + .map_err(|error: h3::error::StreamError| format!("finish failed: {error}"))?; + let response: http::Response<()> = timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + stream.recv_response(), + ) + .await + .map_err(|_: tokio::time::error::Elapsed| "h3 response timed out".to_string())? + .map_err(|error: h3::error::StreamError| format!("recv_response failed: {error}"))?; + let status: u16 = response.status().as_u16(); + let headers: http::HeaderMap = response.headers().clone(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("h3 body timed out".to_string()); + } + match timeout(remaining, stream.recv_data()).await { + Ok(Ok(Some(chunk))) => collected.extend_from_slice(chunk.chunk()), + Ok(Ok(None)) => break, + Ok(Err(error)) => return Err(format!("h3 body read failed: {error}")), + Err(_) => return Err("h3 body timed out".to_string()), + } + } + drop(client); + driving.abort(); + connection.close(quinn::VarInt::from_u32(0), b"done"); + Ok((status, headers, collected, alpn)) +} + +pub(crate) async fn h3_stream_until(port: u16, target: &str, needle: &str) -> (Vec, bool) { + let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = + connect_quic(port, ALPN_HTTP3) + .await + .expect("quic connect must succeed"); + let (mut driver, mut client) = h3::client::new(h3_quinn::Connection::new(connection.clone())) + .await + .expect("h3 handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _final: h3::error::ConnectionError = driver.wait_idle().await; + }); + let request: http::Request<()> = http::Request::builder() + .method("GET") + .uri(target) + .body(()) + .expect("request must build"); + let mut stream: h3::client::RequestStream, Bytes> = client + .send_request(request) + .await + .expect("h3 request must be accepted"); + stream.finish().await.expect("h3 request must finish"); + let _response: http::Response<()> = timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + stream.recv_response(), + ) + .await + .expect("the h3 response head must arrive inside its timeout") + .expect("the h3 response head must not fail"); + let mut body: h3::client::RequestStream, Bytes> = stream; + let mut collected: Vec = Vec::new(); + let mut matched: bool = false; + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + if String::from_utf8_lossy(&collected).contains(needle) { + matched = true; + break; + } + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match timeout(remaining, body.recv_data()).await { + Ok(Ok(Some(chunk))) => collected.extend_from_slice(chunk.chunk()), + Ok(Ok(None)) => break, + Ok(Err(error)) => { + panic!("the h3 body read failed before the needle arrived: {error}") + } + Err(_) => break, + } + } + drop(client); + driving.abort(); + connection.close(quinn::VarInt::from_u32(0), b"done"); + (collected, matched) +} + +pub(crate) async fn h3_request( + port: u16, + method: &str, + target: &str, + body: &[u8], +) -> Result { + let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = + connect_quic(port, ALPN_HTTP3).await?; + let alpn: Option> = negotiated_quic_alpn(&connection); + let (mut driver, mut client) = h3::client::new(h3_quinn::Connection::new(connection.clone())) + .await + .map_err(|error: h3::error::ConnectionError| format!("h3 handshake failed: {error}"))?; + let driving: JoinHandle<()> = spawn(async move { + let _final: h3::error::ConnectionError = driver.wait_idle().await; + }); + let request: http::Request<()> = http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE) + .body(()) + .map_err(|error: http::Error| format!("request build failed: {error}"))?; + let mut stream: h3::client::RequestStream, Bytes> = client + .send_request(request) + .await + .map_err(|error: h3::error::StreamError| format!("send_request failed: {error}"))?; + if !body.is_empty() { + stream + .send_data(Bytes::copy_from_slice(body)) + .await + .map_err(|error: h3::error::StreamError| format!("send_data failed: {error}"))?; + } + stream + .finish() + .await + .map_err(|error: h3::error::StreamError| format!("finish failed: {error}"))?; + let response: http::Response<()> = timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + stream.recv_response(), + ) + .await + .map_err(|_: tokio::time::error::Elapsed| "h3 response timed out".to_string())? + .map_err(|error: h3::error::StreamError| format!("recv_response failed: {error}"))?; + let status: u16 = response.status().as_u16(); + let version: http::Version = response.version(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("h3 body timed out".to_string()); + } + match timeout(remaining, stream.recv_data()).await { + Ok(Ok(Some(chunk))) => collected.extend_from_slice(chunk.chunk()), + Ok(Ok(None)) => break, + Ok(Err(error)) => return Err(format!("h3 body read failed: {error}")), + Err(_) => return Err("h3 body timed out".to_string()), + } + } + drop(client); + driving.abort(); + connection.close(quinn::VarInt::from_u32(0), b"done"); + Ok((status, version, collected, alpn)) +} diff --git a/core/tests/protocol/http3/mod.rs b/core/tests/protocol/http3/mod.rs new file mode 100644 index 00000000..b959baa2 --- /dev/null +++ b/core/tests/protocol/http3/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod r#assert; +pub(crate) mod r#fn; +pub(crate) mod r#type; + +use {r#fn::*, r#type::*}; + +use super::*; diff --git a/core/tests/protocol/http3/type.rs b/core/tests/protocol/http3/type.rs new file mode 100644 index 00000000..bfff397d --- /dev/null +++ b/core/tests/protocol/http3/type.rs @@ -0,0 +1,3 @@ +pub(crate) type H3Reply = (u16, http::Version, Vec, Option>); + +pub(crate) type H3HeadReply = (u16, http::HeaderMap, Vec, Option>); diff --git a/core/tests/protocol/limits/assert.rs b/core/tests/protocol/limits/assert.rs new file mode 100644 index 00000000..826daa4f --- /dev/null +++ b/core/tests/protocol/limits/assert.rs @@ -0,0 +1,588 @@ +use super::*; + +#[tokio::test] +async fn http2_head_response_carries_headers_without_a_body() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let request: http::Request<()> = + h2_build_request("HEAD", &h2_target(port, HEAD_ROUTE_PATH), None) + .expect("h2 head request must build"); + let (response, _send_stream) = + h2_open_stream(&mut sender, request, true).expect("h2 head stream must open"); + let (status, _headers, body): (u16, http::HeaderMap, Vec) = h2_await_response(response) + .await + .unwrap_or_else(|error: String| panic!("h2 HEAD response failed: {error}")); + assert_eq!(status, WIRE_OK_STATUS); + assert!( + body.is_empty(), + "a HEAD response must carry no body bytes, got {} bytes", + body.len() + ); + let after: (u16, http::HeaderMap, Vec) = h2_get(&mut sender, port, BODY_ECHO_ROUTE_PATH) + .await + .expect("the same connection must serve a later valid request"); + assert_eq!(after.0, WIRE_OK_STATUS); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_no_content_and_not_modified_strip_the_body_bytes() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + for (path, expected) in [ + (NO_CONTENT_ROUTE_PATH, WIRE_NO_CONTENT_STATUS), + (NOT_MODIFIED_ROUTE_PATH, WIRE_NOT_MODIFIED_STATUS), + ] { + let request: http::Request<()> = + h2_build_request("GET", &h2_target(port, path), None).expect("h2 request must build"); + let (response, _send_stream) = + h2_open_stream(&mut sender, request, true).expect("h2 stream must open"); + let (status, headers, body): (u16, http::HeaderMap, Vec) = h2_await_response(response) + .await + .unwrap_or_else(|error: String| panic!("h2 {expected} response failed: {error}")); + assert_eq!(status, expected); + assert!( + body.is_empty(), + "status {expected} must not carry body bytes, got {:?}", + String::from_utf8_lossy(&body) + ); + if expected == WIRE_NO_CONTENT_STATUS { + assert!( + header_value(&headers, CONTENT_LENGTH).is_none(), + "204 must not announce a content length, got headers: {headers:?}" + ); + } + } + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_head_and_no_body_statuses_carry_no_body() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http3, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http3 limits server must start"); + for (method, path, expected) in [ + ("HEAD", HEAD_ROUTE_PATH, WIRE_OK_STATUS), + ("GET", NO_CONTENT_ROUTE_PATH, WIRE_NO_CONTENT_STATUS), + ("GET", NOT_MODIFIED_ROUTE_PATH, WIRE_NOT_MODIFIED_STATUS), + ] { + let (status, headers, body): (u16, http::HeaderMap, Vec) = + h3_exchange(port, method, &h3_target(port, path), &[]) + .await + .unwrap_or_else(|error: String| panic!("h3 {method} {path} failed: {error}")); + assert_eq!(status, expected, "h3 {method} {path} answered {status}"); + assert!( + body.is_empty(), + "h3 {method} {path} must carry no body bytes, got {}", + body.len() + ); + if expected == WIRE_NO_CONTENT_STATUS { + assert!( + header_value(&headers, CONTENT_LENGTH).is_none(), + "h3 204 must not announce a content length, got headers: {headers:?}" + ); + } + } + control.shutdown().await; +} + +#[tokio::test] +async fn http2_raw_chunked_framing_decodes_or_is_rejected_explicitly() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let request: http::Request<()> = + h2_build_request("GET", &h2_target(port, RAW_CHUNKED_ROUTE_PATH), None) + .expect("h2 chunked request must build"); + let (response, _send_stream) = + h2_open_stream(&mut sender, request, true).expect("h2 stream must open"); + match h2_await_outcome(response).await { + LimitsOutcome::Replied(status, headers, body) => { + assert_eq!( + status, WIRE_OK_STATUS, + "a decodable chunked response must not be downgraded, got status {status}" + ); + assert!( + !headers.contains_key(CHUNKED_TRANSFER_HEADER), + "a transfer coding is hop by hop on http2 and must be stripped, got: {headers:?}" + ); + let text: String = String::from_utf8_lossy(&body).to_string(); + assert!( + text.contains(CHUNKED_PAYLOAD) && !text.contains(CHUNK_SIZE_LINE), + "a chunked body must reach the client decoded, never as chunk size bytes, got: {text:?}" + ); + } + LimitsOutcome::H2Reset(error) => { + assert!( + error.reason() == Some(h2::Reason::INTERNAL_ERROR), + "an undecodable chunked body must be refused as an explicit internal reset, got: {error:?}" + ); + } + LimitsOutcome::H3Reset(error) => { + panic!("an http2 request must never be answered with an http3 reset: {error:?}"); + } + LimitsOutcome::TimedOut => { + panic!("the raw chunked request must be answered or explicitly rejected, never stall"); + } + LimitsOutcome::SetupFailed => { + panic!("the raw chunked request must reach the server, never fail in the fixture"); + } + } + let after: (u16, http::HeaderMap, Vec) = h2_get(&mut sender, port, BODY_ECHO_ROUTE_PATH) + .await + .expect("the same connection must serve a later valid request"); + assert_eq!(after.0, WIRE_OK_STATUS); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_raw_chunked_framing_decodes_or_is_rejected_explicitly() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http3, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http3 limits server must start"); + match h3_exchange_outcome(port, "GET", &h3_target(port, RAW_CHUNKED_ROUTE_PATH), &[]).await { + LimitsOutcome::Replied(status, headers, body) => { + assert_eq!( + status, WIRE_OK_STATUS, + "a decodable chunked response must not be downgraded, got status {status}" + ); + assert!( + !headers.contains_key(RAW_MARKER_HEADER), + "the raw marker header does not exist on an http3 response, got: {headers:?}" + ); + let text: String = String::from_utf8_lossy(&body).to_string(); + assert!( + text.contains(CHUNKED_PAYLOAD) && !text.contains(CHUNK_SIZE_LINE), + "a chunked body must reach the client decoded, never as chunk size bytes, got: {text:?}" + ); + } + LimitsOutcome::H3Reset(error) => { + assert!( + h3_reset_is_internal(&error), + "an undecodable chunked body must be refused as an explicit internal reset, got: {error:?}" + ); + } + LimitsOutcome::H2Reset(error) => { + panic!("an http3 request must never be answered with an http2 reset: {error:?}"); + } + LimitsOutcome::TimedOut => { + panic!("the raw chunked request must be answered or explicitly rejected, never stall"); + } + LimitsOutcome::SetupFailed => { + panic!("the raw chunked request must reach the server, never fail in the fixture"); + } + } + let after: (u16, http::HeaderMap, Vec) = + h3_exchange(port, "GET", &h3_target(port, BODY_ECHO_ROUTE_PATH), &[]) + .await + .unwrap_or_else(|error: String| panic!("the later valid request failed: {error}")); + assert_eq!(after.0, WIRE_OK_STATUS); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_oversized_body_hits_max_body_size_and_the_request_error_hook() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let before: (u16, http::HeaderMap, Vec) = h2_get(&mut sender, port, BODY_ECHO_ROUTE_PATH) + .await + .expect("a valid request before the oversized one must succeed"); + assert_eq!(before.0, WIRE_OK_STATUS); + let payload: Vec = oversized_payload(); + let request: http::Request<()> = h2_build_request( + "POST", + &h2_target(port, BODY_ECHO_ROUTE_PATH), + Some(payload.len()), + ) + .expect("h2 oversized request must build"); + let (response, mut send_stream) = + h2_open_stream(&mut sender, request, false).expect("h2 stream must open"); + match h2_push_body_typed(&mut send_stream, &payload, true) { + Ok(()) => {} + Err(error) => { + assert!( + error.is_reset() || error.is_go_away(), + "an oversized body must be rejected as a stream reset, not a local failure, got: {error}" + ); + } + } + if let LimitsOutcome::Replied(status, _headers, body) = h2_await_outcome(response).await { + assert_ne!( + status, WIRE_OK_STATUS, + "a body above max_body_size must never be accepted as a normal 200" + ); + assert!( + body.len() < payload.len(), + "an oversized body must never be echoed whole, got {} of {}", + body.len(), + payload.len() + ); + } + assert!( + request_error_seen(), + "an oversized body must reach the request_error hook" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_oversized_body_hits_max_body_size_and_the_request_error_hook() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http3, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http3 limits server must start"); + let before: (u16, http::HeaderMap, Vec) = + h3_exchange(port, "GET", &h3_target(port, BODY_ECHO_ROUTE_PATH), &[]) + .await + .unwrap_or_else(|error: String| panic!("the baseline h3 request failed: {error}")); + assert_eq!(before.0, WIRE_OK_STATUS); + let payload: Vec = oversized_payload(); + let outcome: Result<(u16, http::HeaderMap, Vec), String> = h3_exchange( + port, + "POST", + &h3_target(port, BODY_ECHO_ROUTE_PATH), + &payload, + ) + .await; + if let Ok((status, _headers, body)) = outcome { + assert_ne!( + status, WIRE_OK_STATUS, + "a body above max_body_size must never be accepted as a normal 200" + ); + assert!( + body.len() < payload.len(), + "an oversized body must never be echoed whole, got {} of {}", + body.len(), + payload.len() + ); + } + assert!( + request_error_seen(), + "an oversized body must reach the request_error hook" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_incomplete_body_hits_the_read_timeout_without_blocking_a_valid_request() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let payload: Vec = declared_short_payload(); + let stalled: http::Request<()> = h2_build_request( + "POST", + &h2_target(port, BODY_ECHO_ROUTE_PATH), + Some(WRONG_CONTENT_LENGTH), + ) + .expect("h2 incomplete request must build"); + let (stalled_response, mut stalled_send) = + h2_open_stream(&mut sender, stalled, false).expect("stalled stream must open"); + h2_push_body(&mut stalled_send, &payload, false).expect("the partial body must be accepted"); + assert!( + h2_still_pending(stalled_response, CONCURRENT_BUDGET_MS).await, + "an incomplete body must stay pending until the read timeout fires" + ); + let valid: http::Request<()> = + h2_build_request("GET", &h2_target(port, BODY_ECHO_ROUTE_PATH), None) + .expect("h2 valid request must build"); + let (valid_response, _valid_send) = + h2_open_stream(&mut sender, valid, true).expect("valid stream must open"); + let (status, headers, body): (u16, http::HeaderMap, Vec) = timeout( + Duration::from_millis(CONCURRENT_BUDGET_MS), + h2_await_response(valid_response), + ) + .await + .unwrap_or_else(|_: tokio::time::error::Elapsed| { + panic!("the valid request was blocked by the stalled one") + }) + .expect("the valid request must complete"); + assert_eq!(status, WIRE_OK_STATUS); + assert!(body.is_empty()); + assert_eq!( + header_value(&headers, BODY_ECHO_HEADER).as_deref(), + Some("0"), + "the valid request must be answered from its own empty body, got headers: {headers:?}" + ); + drop(stalled_send); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_incomplete_body_hits_the_read_timeout_without_blocking_a_valid_request() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http3, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http3 limits server must start"); + let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = + connect_quic(port, ALPN_HTTP3) + .await + .expect("quic connect must succeed"); + let (mut driver, mut client) = h3::client::new(h3_quinn::Connection::new(connection.clone())) + .await + .expect("h3 handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _final: h3::error::ConnectionError = driver.wait_idle().await; + }); + let payload: Vec = declared_short_payload(); + let stalled: http::Request<()> = http::Request::builder() + .method("POST") + .uri(h3_target(port, BODY_ECHO_ROUTE_PATH)) + .header(CONTENT_LENGTH, WRONG_CONTENT_LENGTH.to_string()) + .body(()) + .expect("h3 incomplete request must build"); + let mut stalled_stream: h3::client::RequestStream, Bytes> = client + .send_request(stalled) + .await + .expect("stalled stream must open"); + stalled_stream + .send_data(Bytes::copy_from_slice(&payload)) + .await + .expect("the partial body must be accepted"); + let still_pending: bool = timeout( + Duration::from_millis(CONCURRENT_BUDGET_MS), + stalled_stream.recv_response(), + ) + .await + .is_err(); + assert!( + still_pending, + "an incomplete body must stay pending until the read timeout fires" + ); + let valid: http::Request<()> = http::Request::builder() + .method("GET") + .uri(h3_target(port, BODY_ECHO_ROUTE_PATH)) + .body(()) + .expect("h3 valid request must build"); + let mut valid_stream: h3::client::RequestStream, Bytes> = client + .send_request(valid) + .await + .expect("valid stream must open"); + valid_stream + .finish() + .await + .expect("the valid request must finish"); + let (status, headers, body): (u16, http::HeaderMap, Vec) = timeout( + Duration::from_millis(CONCURRENT_BUDGET_MS), + h3_collect(&mut valid_stream), + ) + .await + .unwrap_or_else(|_: tokio::time::error::Elapsed| { + panic!("the valid request was blocked by the stalled one") + }) + .expect("the valid request must complete"); + assert_eq!(status, WIRE_OK_STATUS); + assert!(body.is_empty()); + assert_eq!( + header_value(&headers, BODY_ECHO_HEADER).as_deref(), + Some("0"), + "the valid request must be answered from its own empty body, got headers: {headers:?}" + ); + drop(client); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_wrong_declared_content_length_never_yields_a_truncated_success() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let payload: Vec = declared_short_payload(); + let request: http::Request<()> = h2_build_request( + "POST", + &h2_target(port, BODY_ECHO_ROUTE_PATH), + Some(WRONG_CONTENT_LENGTH), + ) + .expect("h2 truncated request must build"); + let (response, mut send_stream) = + h2_open_stream(&mut sender, request, false).expect("h2 stream must open"); + h2_push_body(&mut send_stream, &payload, false).expect("the partial body must be accepted"); + if let Ok((status, headers, body)) = h2_await_response(response).await { + assert_ne!( + status, WIRE_OK_STATUS, + "a short body against a larger declared length must never succeed" + ); + assert!( + body.is_empty() || body.len() != payload.len(), + "a truncated body must not be handed to the handler as if complete, got {} bytes", + body.len() + ); + assert_ne!( + header_value(&headers, BODY_ECHO_HEADER).as_deref(), + Some(DECLARED_BODY_TEXT), + "the handler must not observe the declared length as received, got headers: {headers:?}" + ); + } + drop(send_stream); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_panicking_route_reaches_the_task_panic_hook() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http2, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http2 limits server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 plaintext handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let request: http::Request<()> = + h2_build_request("GET", &h2_target(port, PANIC_ROUTE_PATH), None) + .expect("h2 panic request must build"); + let (response, _send_stream) = + h2_open_stream(&mut sender, request, true).expect("h2 stream must open"); + if let Ok((status, headers, _body)) = h2_await_response(response).await { + assert_eq!( + status, WIRE_SERVER_ERROR_STATUS, + "a panicking route must answer 500, got headers: {headers:?}" + ); + assert_eq!( + header_value(&headers, TASK_PANIC_MARKER_HEADER).as_deref(), + Some("seen"), + "the task_panic hook must own the response, got headers: {headers:?}" + ); + } + assert!( + task_panic_seen(), + "a panicking route must reach the task_panic hook" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_panicking_route_reaches_the_task_panic_hook() { + let _guard: tokio::sync::MutexGuard<'_, ()> = limits_test_lock().lock().await; + reset_signals(); + let (control, port): (ServerControlHook, u16) = start_limits_server( + ServerProtocol::Http3, + limits_request_config(MAX_BODY_SIZE, READ_TIMEOUT_MS), + ) + .await + .expect("http3 limits server must start"); + if let Ok((status, headers, _body)) = + h3_exchange(port, "GET", &h3_target(port, PANIC_ROUTE_PATH), &[]).await + { + assert_eq!( + status, WIRE_SERVER_ERROR_STATUS, + "a panicking route must answer 500, got headers: {headers:?}" + ); + assert_eq!( + header_value(&headers, TASK_PANIC_MARKER_HEADER).as_deref(), + Some("seen"), + "the task_panic hook must own the response, got headers: {headers:?}" + ); + } + assert!( + task_panic_seen(), + "a panicking route must reach the task_panic hook" + ); + control.shutdown().await; +} diff --git a/core/tests/protocol/limits/const.rs b/core/tests/protocol/limits/const.rs new file mode 100644 index 00000000..c1bd13ff --- /dev/null +++ b/core/tests/protocol/limits/const.rs @@ -0,0 +1,37 @@ +pub(crate) const HEAD_ROUTE_PATH: &str = "/head-body"; +pub(crate) const NO_CONTENT_ROUTE_PATH: &str = "/no-content"; +pub(crate) const NOT_MODIFIED_ROUTE_PATH: &str = "/not-modified"; +pub(crate) const RAW_CHUNKED_ROUTE_PATH: &str = "/raw-chunked"; +pub(crate) const PANIC_ROUTE_PATH: &str = "/panic"; +pub(crate) const BODY_ECHO_ROUTE_PATH: &str = "/body-echo"; + +pub(crate) const HEAD_BODY_TEXT: &str = "head-method-body"; +pub(crate) const OK_STATUS: usize = 200; +pub(crate) const NO_CONTENT_STATUS: usize = 204; +pub(crate) const NOT_MODIFIED_STATUS: usize = 304; +pub(crate) const TOO_LARGE_STATUS: usize = 413; +pub(crate) const SERVER_ERROR_STATUS: usize = 500; + +pub(crate) const WIRE_OK_STATUS: u16 = OK_STATUS as u16; +pub(crate) const WIRE_NO_CONTENT_STATUS: u16 = NO_CONTENT_STATUS as u16; +pub(crate) const WIRE_NOT_MODIFIED_STATUS: u16 = NOT_MODIFIED_STATUS as u16; +pub(crate) const WIRE_SERVER_ERROR_STATUS: u16 = SERVER_ERROR_STATUS as u16; + +pub(crate) const CHUNKED_PAYLOAD: &str = "chunked-decoded-payload"; +pub(crate) const CHUNK_SIZE_LINE: &str = "10\r\n"; +pub(crate) const TASK_PANIC_BODY: &str = "task-panic-handled"; +pub(crate) const REQUEST_ERROR_BODY: &str = "request-error-handled"; + +pub(crate) const MAX_BODY_SIZE: usize = 1024; +pub(crate) const OVERSIZED_BODY_LEN: usize = 4096; +pub(crate) const WRONG_CONTENT_LENGTH: usize = 512; +pub(crate) const DECLARED_BODY_LEN: usize = 32; +pub(crate) const DECLARED_BODY_TEXT: &str = "32"; +pub(crate) const READ_TIMEOUT_MS: u64 = 1500; +pub(crate) const CONCURRENT_BUDGET_MS: u64 = 1200; +pub(crate) const INCOMPLETE_BODY_PREFIX: &[u8] = b"incomplete-"; +pub(crate) const BODY_ECHO_HEADER: &str = "x-body-len"; +pub(crate) const RAW_MARKER_HEADER: &str = "x-raw-marker"; +pub(crate) const CHUNKED_TRANSFER_HEADER: &str = "transfer-encoding"; +pub(crate) const REQUEST_ERROR_MARKER_HEADER: &str = "x-request-error"; +pub(crate) const TASK_PANIC_MARKER_HEADER: &str = "x-task-panic"; diff --git a/core/tests/protocol/limits/fn.rs b/core/tests/protocol/limits/fn.rs new file mode 100644 index 00000000..c4afaa74 --- /dev/null +++ b/core/tests/protocol/limits/fn.rs @@ -0,0 +1,319 @@ +use super::*; + +pub(crate) fn limits_request_config(max_body_size: usize, read_timeout_ms: u64) -> RequestConfig { + RequestConfig { + max_body_size, + read_timeout_ms, + ..RequestConfig::default() + } +} + +fn register_limits_hooks(server: &mut Server) -> &mut Server { + server.route::(HEAD_ROUTE_PATH); + server.route::(NO_CONTENT_ROUTE_PATH); + server.route::(NOT_MODIFIED_ROUTE_PATH); + server.route::(RAW_CHUNKED_ROUTE_PATH); + server.route::(PANIC_ROUTE_PATH); + server.route::(BODY_ECHO_ROUTE_PATH); + server.response_middleware::(); + server.task_panic::(); + server.request_error::(); + server +} + +pub(crate) async fn start_limits_server( + protocol: ServerProtocol, + request_config: RequestConfig, +) -> Result<(ServerControlHook, u16), Box> { + let mut server: Server = Server::default(); + let address: String = if protocol.is_quic() { + format!("{LOOPBACK_HOST}:{}", reserve_udp_port()) + } else { + format!("{LOOPBACK_HOST}:{}", reserve_tcp_port()) + }; + let tls: Option = if protocol.is_quic() { + Some(trusted_tls_config()) + } else { + None + }; + server.server_config(protocol_config(&address, protocol, tls)); + server.request_config(request_config); + register_limits_hooks(&mut server); + let control: ServerControlHook = server.run().await?; + drop(server); + sleep(Duration::from_millis(SERVER_BOOT_MILLIS)).await; + let port: u16 = address + .rsplit(':') + .next() + .and_then(|value: &str| value.parse::().ok()) + .expect("bind address must carry a numeric port"); + Ok((control, port)) +} + +pub(crate) fn h2_build_request( + method: &str, + target: &str, + content_length: Option, +) -> Result, String> { + let mut builder: http::request::Builder = http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE); + if let Some(length) = content_length { + builder = builder.header(CONTENT_LENGTH, length.to_string()); + } + builder + .body(()) + .map_err(|error: http::Error| format!("h2 request build failed: {error}")) +} + +pub(crate) fn h2_open_stream( + sender: &mut h2::client::SendRequest, + request: http::Request<()>, + end_of_stream: bool, +) -> Result<(h2::client::ResponseFuture, h2::SendStream), String> { + sender + .send_request(request, end_of_stream) + .map_err(|error: h2::Error| format!("h2 send_request failed: {error}")) +} + +pub(crate) fn h2_push_body( + send_stream: &mut h2::SendStream, + body: &[u8], + end_of_stream: bool, +) -> Result<(), String> { + send_stream + .send_data(Bytes::copy_from_slice(body), end_of_stream) + .map_err(|error: h2::Error| format!("h2 send_data failed: {error}")) +} + +pub(crate) fn h2_push_body_typed( + send_stream: &mut h2::SendStream, + body: &[u8], + end_of_stream: bool, +) -> Result<(), h2::Error> { + send_stream.send_data(Bytes::copy_from_slice(body), end_of_stream) +} + +pub(crate) async fn h2_await_response( + response: h2::client::ResponseFuture, +) -> Result<(u16, http::HeaderMap, Vec), String> { + match h2_await_outcome(response).await { + LimitsOutcome::Replied(status, headers, body) => Ok((status, headers, body)), + LimitsOutcome::H2Reset(error) => Err(format!("h2 stream reset: {error:?}")), + LimitsOutcome::H3Reset(error) => Err(format!("h3 stream reset: {error:?}")), + LimitsOutcome::TimedOut => Err("h2 exchange timed out".to_string()), + LimitsOutcome::SetupFailed => Err("h2 response future must resolve".to_string()), + } +} + +pub(crate) async fn h2_await_outcome(response: h2::client::ResponseFuture) -> LimitsOutcome { + let received: Result, h2::Error> = + match timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), response).await { + Ok(received) => received, + Err(_) => return LimitsOutcome::TimedOut, + }; + let received: http::Response = match received { + Ok(received) => received, + Err(error) => return LimitsOutcome::H2Reset(error), + }; + let status: u16 = received.status().as_u16(); + let headers: http::HeaderMap = received.headers().clone(); + let mut body_stream: h2::RecvStream = received.into_body(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + let chunk: Result>, tokio::time::error::Elapsed> = + timeout(remaining, body_stream.data()).await; + match chunk { + Err(_) => return LimitsOutcome::TimedOut, + Ok(None) => break, + Ok(Some(Err(error))) => return LimitsOutcome::H2Reset(error), + Ok(Some(Ok(chunk))) => { + let released: Result<(), h2::Error> = + body_stream.flow_control().release_capacity(chunk.len()); + collected.extend_from_slice(&chunk); + let _: Result<(), h2::Error> = released; + } + } + } + LimitsOutcome::Replied(status, headers, collected) +} + +pub(crate) async fn h2_still_pending(response: h2::client::ResponseFuture, budget_ms: u64) -> bool { + let outcome: Result< + Result, h2::Error>, + tokio::time::error::Elapsed, + > = timeout(Duration::from_millis(budget_ms), response).await; + outcome.is_err() +} + +pub(crate) async fn h2_get( + sender: &mut h2::client::SendRequest, + port: u16, + path: &str, +) -> Result<(u16, http::HeaderMap, Vec), String> { + let request: http::Request<()> = + h2_build_request("GET", &h2_target(port, path), None).map_err(|error: String| error)?; + let (response, _send_stream) = + h2_open_stream(sender, request, true).map_err(|error: String| error)?; + h2_await_response(response).await +} + +pub(crate) fn h2_target(port: u16, path: &str) -> String { + format!("http://{TLS_SERVER_NAME}:{port}{path}") +} + +pub(crate) fn h3_target(port: u16, path: &str) -> String { + format!("https://{TLS_SERVER_NAME}:{port}{path}") +} + +pub(crate) fn h3_reset_is_internal(error: &h3::error::StreamError) -> bool { + match error { + h3::error::StreamError::StreamError { code, .. } => *code == Code::H3_INTERNAL_ERROR, + h3::error::StreamError::RemoteTerminate { code, .. } => *code == Code::H3_INTERNAL_ERROR, + _ => false, + } +} + +pub(crate) fn header_value(headers: &http::HeaderMap, key: &str) -> Option { + headers + .get(key) + .map(|value: &http::HeaderValue| value.to_str().unwrap_or_default().to_owned()) +} + +pub(crate) fn oversized_payload() -> Vec { + let mut payload: Vec = Vec::with_capacity(OVERSIZED_BODY_LEN); + let mut seed: u32 = 0x2468_1357; + while payload.len() < OVERSIZED_BODY_LEN { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + payload.extend_from_slice(&seed.to_be_bytes()); + } + payload.truncate(OVERSIZED_BODY_LEN); + payload +} + +pub(crate) fn declared_short_payload() -> Vec { + let mut payload: Vec = INCOMPLETE_BODY_PREFIX.to_vec(); + payload.resize(DECLARED_BODY_LEN, b'a'); + payload +} + +pub(crate) async fn h3_collect( + stream: &mut h3::client::RequestStream, Bytes>, +) -> Result<(u16, http::HeaderMap, Vec), String> { + match h3_collect_outcome(stream).await { + LimitsOutcome::Replied(status, headers, body) => Ok((status, headers, body)), + LimitsOutcome::H3Reset(error) => Err(format!("h3 stream reset: {error:?}")), + LimitsOutcome::TimedOut => Err("h3 exchange timed out".to_string()), + LimitsOutcome::H2Reset(error) => Err(format!("h2 reset on an h3 exchange: {error:?}")), + LimitsOutcome::SetupFailed => Err("h3 response must resolve".to_string()), + } +} + +async fn h3_collect_outcome( + stream: &mut h3::client::RequestStream, Bytes>, +) -> LimitsOutcome { + let received: Result, h3::error::StreamError> = match timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + stream.recv_response(), + ) + .await + { + Ok(received) => received, + Err(_) => return LimitsOutcome::TimedOut, + }; + let received: http::Response<()> = match received { + Ok(received) => received, + Err(error) => return LimitsOutcome::H3Reset(error), + }; + let status: u16 = received.status().as_u16(); + let headers: http::HeaderMap = received.headers().clone(); + let mut collected: Vec = Vec::new(); + let deadline: Instant = Instant::now() + Duration::from_secs(REQUEST_TIMEOUT_SECS); + loop { + let remaining: Duration = deadline.saturating_duration_since(Instant::now()); + match timeout(remaining, stream.recv_data()).await { + Err(_) => return LimitsOutcome::TimedOut, + Ok(Err(error)) => return LimitsOutcome::H3Reset(error), + Ok(Ok(None)) => break, + Ok(Ok(Some(mut chunk))) => { + collected.extend_from_slice(&chunk.copy_to_bytes(chunk.remaining())); + } + } + } + LimitsOutcome::Replied(status, headers, collected) +} + +pub(crate) async fn h3_exchange( + port: u16, + method: &str, + target: &str, + body: &[u8], +) -> Result<(u16, http::HeaderMap, Vec), String> { + match h3_exchange_outcome(port, method, target, body).await { + LimitsOutcome::Replied(status, headers, body) => Ok((status, headers, body)), + LimitsOutcome::H3Reset(error) => Err(format!("h3 stream reset: {error:?}")), + LimitsOutcome::TimedOut => Err("h3 exchange timed out".to_string()), + LimitsOutcome::H2Reset(error) => Err(format!("h2 reset on an h3 exchange: {error:?}")), + LimitsOutcome::SetupFailed => Err("h3 exchange could not be set up".to_string()), + } +} + +pub(crate) async fn h3_exchange_outcome( + port: u16, + method: &str, + target: &str, + body: &[u8], +) -> LimitsOutcome { + let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = + match connect_quic(port, ALPN_HTTP3).await { + Ok(pair) => pair, + Err(_) => return LimitsOutcome::SetupFailed, + }; + let (mut driver, mut client) = + match h3::client::new(h3_quinn::Connection::new(connection.clone())).await { + Ok(pair) => pair, + Err(_) => return LimitsOutcome::SetupFailed, + }; + let driving: JoinHandle<()> = spawn(async move { + let _final: h3::error::ConnectionError = driver.wait_idle().await; + }); + let outcome: LimitsOutcome = h3_drive(&mut client, method, target, body).await; + drop(client); + driving.abort(); + connection.close(quinn::VarInt::from_u32(0), b"done"); + outcome +} + +async fn h3_drive( + client: &mut h3::client::SendRequest, + method: &str, + target: &str, + body: &[u8], +) -> LimitsOutcome { + let request: http::Request<()> = match http::Request::builder() + .method(method) + .uri(target) + .header(PROBE_HEADER_KEY, PROBE_HEADER_VALUE) + .body(()) + { + Ok(request) => request, + Err(_) => return LimitsOutcome::SetupFailed, + }; + let mut stream: h3::client::RequestStream, Bytes> = + match client.send_request(request).await { + Ok(stream) => stream, + Err(error) => return LimitsOutcome::H3Reset(error), + }; + if !body.is_empty() + && let Err(error) = stream.send_data(Bytes::copy_from_slice(body)).await + { + return LimitsOutcome::H3Reset(error); + } + if let Err(error) = stream.finish().await { + return LimitsOutcome::H3Reset(error); + } + h3_collect_outcome(&mut stream).await +} diff --git a/core/tests/protocol/limits/impl.rs b/core/tests/protocol/limits/impl.rs new file mode 100644 index 00000000..4e28a0f7 --- /dev/null +++ b/core/tests/protocol/limits/impl.rs @@ -0,0 +1,156 @@ +use super::*; + +const PANIC_PAYLOAD: &str = "limits-panic-payload"; + +impl ServerHook for HeadBodyRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + ctx.get_mut_response() + .set_status_code(OK_STATUS) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_body(HEAD_BODY_TEXT); + Status::Continue + } +} + +impl ServerHook for NoContentRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + ctx.get_mut_response() + .set_status_code(NO_CONTENT_STATUS) + .set_header("x-no-content", "limits") + .set_body(CHUNKED_PAYLOAD); + Status::Continue + } +} + +impl ServerHook for NotModifiedRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + ctx.get_mut_response() + .set_status_code(NOT_MODIFIED_STATUS) + .set_header("x-not-modified", "limits") + .set_body(CHUNKED_PAYLOAD); + Status::Continue + } +} + +impl ServerHook for RawChunkedRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, _: &mut Context) -> Status { + let mut head: String = format!("{HTTP_VERSION_1_1} {OK_STATUS}{HTTP_BR}"); + head.push_str(&format!("{CONTENT_TYPE}: {TEXT_PLAIN}{HTTP_BR}")); + head.push_str(&format!("{RAW_MARKER_HEADER}: chunked{HTTP_BR}")); + head.push_str(&format!("{CHUNKED_TRANSFER_HEADER}: {CHUNKED}{HTTP_BR}")); + head.push_str(&format!("{CONNECTION}: {CLOSE}{HTTP_BR}")); + head.push_str(HTTP_BR); + if stream.try_send(head.into_bytes()).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + let framed: Vec = + format!("{CHUNK_SIZE_LINE}{CHUNKED_PAYLOAD}{HTTP_BR}0{HTTP_BR}{HTTP_BR}").into_bytes(); + if stream.try_send(framed).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + stream.set_closed(true); + Status::Reject + } +} + +impl ServerHook for PanicRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, _: &mut Context) -> Status { + panic!("{PANIC_PAYLOAD}"); + } +} + +impl ServerHook for BodyEchoRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let body: Vec = ctx.get_request().get_body().clone(); + let length: String = body.len().to_string(); + ctx.get_mut_response() + .set_status_code(OK_STATUS) + .set_header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .set_header(BODY_ECHO_HEADER, &length) + .set_body(&body); + Status::Continue + } +} + +impl ServerHook for LimitsResponseSender { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx.get_mut_response().build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + Status::Continue + } +} + +impl ServerHook for RequestErrorRecorder { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + mark_request_error(); + let data: Vec = ctx + .get_mut_response() + .set_status_code(TOO_LARGE_STATUS) + .set_header(REQUEST_ERROR_MARKER_HEADER, "seen") + .set_body(REQUEST_ERROR_BODY) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + Status::Reject + } +} + +impl ServerHook for TaskPanicRecorder { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + mark_task_panic(); + let data: Vec = ctx + .get_mut_response() + .set_status_code(SERVER_ERROR_STATUS) + .set_header(TASK_PANIC_MARKER_HEADER, "seen") + .set_body(TASK_PANIC_BODY) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + Status::Reject + } +} diff --git a/core/tests/protocol/limits/mod.rs b/core/tests/protocol/limits/mod.rs new file mode 100644 index 00000000..7e2c0bdf --- /dev/null +++ b/core/tests/protocol/limits/mod.rs @@ -0,0 +1,9 @@ +mod r#assert; +mod r#const; +mod r#fn; +mod r#impl; +mod r#struct; + +use {r#const::*, r#fn::*, r#struct::*}; + +use super::*; diff --git a/core/tests/protocol/limits/struct.rs b/core/tests/protocol/limits/struct.rs new file mode 100644 index 00000000..170248c0 --- /dev/null +++ b/core/tests/protocol/limits/struct.rs @@ -0,0 +1,85 @@ +use super::*; + +pub(crate) struct HeadBodyRoute; + +pub(crate) struct NoContentRoute; + +pub(crate) struct NotModifiedRoute; + +pub(crate) struct RawChunkedRoute; + +pub(crate) struct PanicRoute; + +pub(crate) struct BodyEchoRoute; + +pub(crate) struct LimitsResponseSender; + +pub(crate) struct RequestErrorRecorder; + +pub(crate) struct TaskPanicRecorder; + +pub(crate) enum LimitsOutcome { + Replied(u16, http::HeaderMap, Vec), + H2Reset(h2::Error), + H3Reset(h3::error::StreamError), + TimedOut, + SetupFailed, +} + +struct LimitsSignals { + request_error: Mutex, + task_panic: Mutex, +} + +fn limits_signals() -> &'static LimitsSignals { + static SIGNALS: OnceLock = OnceLock::new(); + SIGNALS.get_or_init(|| LimitsSignals { + request_error: Mutex::new(false), + task_panic: Mutex::new(false), + }) +} + +fn flag_set(signal: &Mutex) { + if let Ok(mut held) = signal.lock() { + *held = true; + } +} + +fn flag_cleared(signal: &Mutex) { + if let Ok(mut held) = signal.lock() { + *held = false; + } +} + +fn flag_read(signal: &Mutex) -> bool { + match signal.lock() { + Ok(held) => *held, + Err(_) => false, + } +} + +pub(crate) fn mark_request_error() { + flag_set(&limits_signals().request_error); +} + +pub(crate) fn mark_task_panic() { + flag_set(&limits_signals().task_panic); +} + +pub(crate) fn request_error_seen() -> bool { + flag_read(&limits_signals().request_error) +} + +pub(crate) fn task_panic_seen() -> bool { + flag_read(&limits_signals().task_panic) +} + +pub(crate) fn reset_signals() { + flag_cleared(&limits_signals().request_error); + flag_cleared(&limits_signals().task_panic); +} + +pub(crate) fn limits_test_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + &LOCK +} diff --git a/core/tests/protocol/middleware/fn.rs b/core/tests/protocol/middleware/fn.rs new file mode 100644 index 00000000..769334cc --- /dev/null +++ b/core/tests/protocol/middleware/fn.rs @@ -0,0 +1,22 @@ +use super::*; + +pub(crate) fn register_probe_hooks(server: &mut Server) -> &mut Server { + server.request_middleware::(); + server.response_middleware::(); + server.route::(VERSION_ROUTE_PATH); + server.route::(ECHO_ROUTE_PATH); + server.route::(SLOW_ROUTE_PATH); + server.route::(SPLIT_ROUTE_PATH); + server.route::(COOKIE_ROUTE_PATH); + server.route::(LARGE_ROUTE_PATH); + server.route::(BINARY_ROUTE_PATH); + server.route::(SSE_ROUTE_PATH); + server.route::(SSE_ROUTE_PATH_HTTP2); + server.route::(SSE_ROUTE_PATH_HTTP3); + server.route::(SLOW_ROUTE_PATH_HTTP3); + server.route::(HOP_BY_HOP_ROUTE_PATH); + server.route::(HOP_BY_HOP_CLEAN_ROUTE_PATH); + server.route::(SOCKET_METADATA_ROUTE_PATH); + server.route::(UNFRAMED_BODY_ROUTE_PATH); + server +} diff --git a/core/tests/protocol/middleware/impl.rs b/core/tests/protocol/middleware/impl.rs new file mode 100644 index 00000000..4ff14aa2 --- /dev/null +++ b/core/tests/protocol/middleware/impl.rs @@ -0,0 +1,384 @@ +use super::*; + +impl ServerHook for ProbeMiddleware { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let request: &Request = ctx.get_request(); + let path: String = request.get_path().clone(); + let method: String = request.get_method().to_string(); + let query: String = joined_query(request, PROBE_QUERY_KEYS); + let header: String = joined_header(request, PROBE_HEADER_KEY); + let version: String = format!("{:?}", request.get_version()); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_header(PROBE_METHOD_HEADER, &method) + .set_header(PROBE_PATH_HEADER, &path) + .set_header(PROBE_QUERY_HEADER, &query) + .set_header(PROBE_HEADER_ECHO_HEADER, &header) + .set_header(PROBE_VERSION_HEADER, &version) + .set_body(String::new()); + Status::Continue + } +} + +impl ServerHook for ProbeResponseMiddleware { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx.get_mut_response().build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + Status::Continue + } +} + +impl ServerHook for EchoRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let body: Vec = ctx.get_request().get_body().clone(); + let version: String = format!("{:?}", ctx.get_request().get_version()); + let payload: Vec = body + .iter() + .map(|value: &u8| value.wrapping_add(1)) + .collect::>(); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .set_header("x-request-version", &version) + .set_body(&payload); + Status::Continue + } +} + +impl ServerHook for GatedSlowRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let gate: &RouteGate = route_gate(GateSlot::SlowHttp2); + gate.mark_slow_entered(); + gate.await_slow_release().await; + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_body(SLOW_DONE_BODY); + Status::Continue + } +} + +impl ServerHook for GatedSlowRouteHttp3 { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let gate: &RouteGate = route_gate(GateSlot::SlowHttp3); + gate.mark_slow_entered(); + gate.await_slow_release().await; + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_body(SLOW_DONE_BODY); + Status::Continue + } +} + +impl ServerHook for SplitWriteRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx + .get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_EVENT_STREAM) + .set_header(CACHE_CONTROL, NO_CACHE) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + for piece in fragment_writes(SPLIT_HEAD_BODY.as_bytes()) { + if stream.try_send(piece).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + } + if stream.try_send(SPLIT_TAIL_BODY).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + stream.set_closed(true); + Status::Reject + } +} + +impl ServerHook for DuplicateCookieRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let first: String = CookieBuilder::new(FIRST_COOKIE_NAME, FIRST_COOKIE_VALUE) + .http_only() + .build(); + let second: String = CookieBuilder::new(SECOND_COOKIE_NAME, SECOND_COOKIE_VALUE) + .http_only() + .build(); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .add_header(SET_COOKIE, &first) + .add_header(SET_COOKIE, &second) + .set_header(CONNECTION, KEEP_ALIVE) + .set_body(COOKIE_SENT_BODY); + Status::Continue + } +} + +impl ServerHook for LargeResponseRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let payload: Vec = large_binary_payload(); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .set_body(&payload); + Status::Continue + } +} + +impl ServerHook for BinaryEchoRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let body: Vec = ctx.get_request().get_body().clone(); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .set_body(&body); + Status::Continue + } +} + +impl ServerHook for GatedSseStreamRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx + .get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_EVENT_STREAM) + .set_header(CACHE_CONTROL, NO_CACHE) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + if stream.try_send(SSE_FIRST_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + let gate: &RouteGate = route_gate(GateSlot::SseHttp1); + gate.mark_sse_first_chunk_sent(); + gate.await_sse_release().await; + if stream.try_send(SSE_SECOND_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + stream.set_closed(true); + Status::Reject + } +} + +impl ServerHook for GatedSseStreamRouteHttp2 { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx + .get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_EVENT_STREAM) + .set_header(CACHE_CONTROL, NO_CACHE) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + if stream.try_send(SSE_FIRST_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + let gate: &RouteGate = route_gate(GateSlot::SseHttp2); + gate.mark_sse_first_chunk_sent(); + gate.await_sse_release().await; + if stream.try_send(SSE_SECOND_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + stream.set_closed(true); + Status::Reject + } +} + +impl ServerHook for GatedSseStreamRouteHttp3 { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx + .get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_EVENT_STREAM) + .set_header(CACHE_CONTROL, NO_CACHE) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + if stream.try_send(SSE_FIRST_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + let gate: &RouteGate = route_gate(GateSlot::SseHttp3); + gate.mark_sse_first_chunk_sent(); + gate.await_sse_release().await; + if stream.try_send(SSE_SECOND_CHUNK).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + stream.set_closed(true); + Status::Reject + } +} + +impl ServerHook for VersionRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let version: String = format!("{:?}", ctx.get_request().get_version()); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_body(&version); + Status::Continue + } +} + +impl ServerHook for HopByHopStrippingMiddleware { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let named: String = format!("{INTERNAL_CONNECTION_HEADER}, {KEEP_ALIVE}"); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_header(CONNECTION, &named) + .set_header(INTERNAL_CONNECTION_HEADER, INTERNAL_CONNECTION_VALUE) + .set_header(TRANSFER_ENCODING, CHUNKED) + .set_header(UPGRADE, "h2c") + .set_header(PRESERVED_HEADER_KEY, PRESERVED_HEADER_VALUE) + .set_body(HOP_BY_HOP_SENT_BODY); + Status::Continue + } +} + +impl ServerHook for HopByHopStrippingRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, _: &mut Stream, ctx: &mut Context) -> Status { + let named: String = format!("{INTERNAL_CONNECTION_HEADER}, {KEEP_ALIVE}"); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_header(CONNECTION, &named) + .set_header(KEEP_ALIVE, "timeout=5") + .set_header(INTERNAL_CONNECTION_HEADER, INTERNAL_CONNECTION_VALUE) + .set_header(UPGRADE, "h2c") + .set_header(TE, "trailers") + .set_header(PRESERVED_HEADER_KEY, PRESERVED_HEADER_VALUE) + .set_body(HOP_BY_HOP_SENT_BODY); + Status::Continue + } +} + +impl ServerHook for SocketMetadataRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let local: SocketAddr = stream + .get_local_addr() + .expect("a serving stream must report its local address"); + let peer: SocketAddr = stream + .get_peer_addr() + .expect("a serving stream must report its peer address"); + let body: String = format!("{local}|{peer}"); + ctx.get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_PLAIN) + .set_body(&body); + Status::Continue + } +} + +impl ServerHook for UnframedBodyRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + let data: Vec = ctx + .get_mut_response() + .set_status_code(200) + .set_header(CONTENT_TYPE, TEXT_EVENT_STREAM) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + for piece in fragment_writes(UNFRAMED_BODY.as_bytes()) { + if stream.try_send(piece).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + } + stream.set_closed(true); + Status::Reject + } +} diff --git a/core/tests/protocol/middleware/mod.rs b/core/tests/protocol/middleware/mod.rs new file mode 100644 index 00000000..1bb02b30 --- /dev/null +++ b/core/tests/protocol/middleware/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod r#fn; +pub(crate) mod r#impl; +pub(crate) mod r#struct; + +use r#struct::*; + +use super::*; diff --git a/core/tests/protocol/middleware/struct.rs b/core/tests/protocol/middleware/struct.rs new file mode 100644 index 00000000..8a077cd3 --- /dev/null +++ b/core/tests/protocol/middleware/struct.rs @@ -0,0 +1,33 @@ +pub(crate) struct ProbeMiddleware; + +pub(crate) struct ProbeResponseMiddleware; + +pub(crate) struct EchoRoute; + +pub(crate) struct GatedSlowRoute; + +pub(crate) struct GatedSlowRouteHttp3; + +pub(crate) struct SplitWriteRoute; + +pub(crate) struct DuplicateCookieRoute; + +pub(crate) struct LargeResponseRoute; + +pub(crate) struct BinaryEchoRoute; + +pub(crate) struct GatedSseStreamRoute; + +pub(crate) struct GatedSseStreamRouteHttp2; + +pub(crate) struct GatedSseStreamRouteHttp3; + +pub(crate) struct VersionRoute; + +pub(crate) struct HopByHopStrippingMiddleware; + +pub(crate) struct HopByHopStrippingRoute; + +pub(crate) struct SocketMetadataRoute; + +pub(crate) struct UnframedBodyRoute; diff --git a/core/tests/protocol/mod.rs b/core/tests/protocol/mod.rs new file mode 100644 index 00000000..ab1ca5b0 --- /dev/null +++ b/core/tests/protocol/mod.rs @@ -0,0 +1,20 @@ +mod r#common; +mod r#http1; +mod r#http2; +mod r#http3; +mod r#limits; +mod r#middleware; +mod r#runtime; +mod r#tls; +mod r#websocket; +mod r#wire; + +use r#common::{r#const::*, r#fn::*, r#gate::*, r#server::*}; + +use r#http2::{r#fn::*, r#type::*}; + +use r#http3::{r#fn::*, r#type::*}; + +use r#middleware::r#fn::*; + +use super::*; diff --git a/core/tests/protocol/runtime/fn.rs b/core/tests/protocol/runtime/fn.rs new file mode 100644 index 00000000..25dcc3a9 --- /dev/null +++ b/core/tests/protocol/runtime/fn.rs @@ -0,0 +1,103 @@ +use super::*; + +#[tokio::test] +async fn http1_default_config_serves_over_plaintext_tcp() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("the default plaintext server must start"); + let raw: Vec = raw_http1_exchange(port, &http1_close_request("GET", VERSION_ROUTE_PATH)) + .await + .expect("the plaintext exchange must complete"); + let head: String = response_head(&raw); + assert!( + head.starts_with("HTTP/1.1 200"), + "the default protocol must answer over HTTP/1.1, got: {head}" + ); + control.shutdown().await; + control.wait().await; +} + +#[tokio::test] +async fn http3_without_tls_is_rejected_before_binding() { + let address: String = format!("{LOOPBACK_HOST}:{}", reserve_tcp_port()); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(address.clone()) + .set_protocol(ServerProtocol::Http3); + let mut server: Server = Server::default(); + server.server_config(config); + let result: Result> = server.run().await; + let Err(error) = result else { + panic!("HTTP/3 without TLS must fail before the socket is bound"); + }; + assert!( + matches!(*error, ServerError::MissingTlsForHttp3), + "the rejection must name the missing TLS material, got: {error:?}" + ); + let rebound: io::Result = TcpListener::bind(&address); + assert!(rebound.is_ok(), "the rejected run must leave the port free"); +} + +#[tokio::test] +async fn shutdown_releases_the_port_before_wait_returns() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("a plaintext server must start"); + let raw: Vec = raw_http1_exchange(port, &http1_close_request("GET", VERSION_ROUTE_PATH)) + .await + .expect("the exchange before shutdown must complete"); + assert!( + response_head(&raw).starts_with("HTTP/1.1 200"), + "the server must serve before it is shut down" + ); + assert_wait_pending_then_complete(&control).await; + let rebound: io::Result = TcpListener::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "the port must be released by the time wait returns" + ); +} + +#[tokio::test] +async fn dropping_the_caller_server_keeps_the_listener_serving() { + let (control, port): (ServerControlHook, u16) = start_dropped_caller_server() + .await + .expect("a server whose caller is dropped must still serve"); + let raw: Vec = raw_http1_exchange(port, &http1_close_request("GET", VERSION_ROUTE_PATH)) + .await + .expect("the exchange must complete after the caller was dropped"); + let head: String = response_head(&raw); + assert!( + head.starts_with("HTTP/1.1 200"), + "the accepted tasks must own the server, got: {head}" + ); + control.shutdown().await; + control.wait().await; +} + +#[tokio::test] +async fn unreadable_tls_material_is_rejected_before_binding() { + let address: String = format!("{LOOPBACK_HOST}:{}", reserve_tcp_port()); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(address.clone()) + .set_protocol(ServerProtocol::Http1) + .set_tls(Some(TlsConfig::new( + MISSING_PEM_NAME.to_string(), + MISSING_PEM_NAME.to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + let result: Result> = server.run().await; + let Err(error) = result else { + panic!("unreadable TLS material must fail before the socket is bound"); + }; + assert!( + matches!(*error, ServerError::Tls(_)), + "the failure must be reported as a TLS error, got: {error:?}" + ); + let rebound: io::Result = TcpListener::bind(&address); + assert!(rebound.is_ok(), "the rejected run must leave the port free"); +} diff --git a/core/tests/protocol/runtime/mod.rs b/core/tests/protocol/runtime/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/core/tests/protocol/runtime/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/core/tests/protocol/tls/fn.rs b/core/tests/protocol/tls/fn.rs new file mode 100644 index 00000000..48208987 --- /dev/null +++ b/core/tests/protocol/tls/fn.rs @@ -0,0 +1,439 @@ +use super::*; + +#[test] +fn tls_config_new_keeps_the_paths_it_was_given() { + let chain: String = fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string(); + let key: String = fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(); + let config: TlsConfig = TlsConfig::new(chain.clone(), key.clone()); + assert_eq!(config.get_certificate_path(), &chain); + assert_eq!(config.get_private_key_path(), &key); +} + +#[test] +fn server_protocol_defaults_to_http1() { + let protocol: ServerProtocol = ServerProtocol::default(); + assert_eq!(protocol, ServerProtocol::Http1); +} + +fn protocol_from_wire_name(name: &str) -> Option { + let json: String = format!("{{\"address\":\"127.0.0.1:8080\",\"protocol\":\"{name}\"}}"); + match ServerConfig::from_json(json) { + Ok(config) => Some(config.get_protocol()), + Err(_) => None, + } +} + +#[test] +fn server_protocol_wire_names_are_http1_http2_and_http3() { + assert_eq!( + protocol_from_wire_name(WIRE_HTTP1), + Some(ServerProtocol::Http1) + ); + assert_eq!( + protocol_from_wire_name(WIRE_HTTP2), + Some(ServerProtocol::Http2) + ); + assert_eq!( + protocol_from_wire_name(WIRE_HTTP3), + Some(ServerProtocol::Http3) + ); +} + +#[test] +fn an_omitted_protocol_field_loads_as_http1() { + let config: ServerConfig = + ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\"}").expect("legacy json must load"); + assert_eq!(config.get_protocol(), ServerProtocol::Http1); +} + +#[test] +fn a_legacy_config_without_protocol_or_tls_still_loads_as_plaintext_http1() { + let legacy: &str = "{\"address\":\"127.0.0.1:8080\"}"; + let config: ServerConfig = ServerConfig::from_json(legacy).expect("legacy json must load"); + assert_eq!(config.get_address(), "127.0.0.1:8080"); + assert_eq!( + config.get_protocol(), + ServerProtocol::Http1, + "an omitted protocol must resolve to the historical default" + ); + assert!( + config.get_tls().is_none(), + "a legacy config must carry no tls material" + ); + assert_eq!( + config.get_alpn_protocol(), + ALPN_HTTP1, + "a legacy config must advertise the http/1.1 alpn" + ); +} + +#[test] +fn the_three_argument_constructor_keeps_speaking_http1_without_tls() { + let legacy: String = SERVER_CONFIG_LEGACY_ADDRESS.to_string(); + let config: ServerConfig = ServerConfig::new(legacy, None, None); + assert_eq!(*config.get_address(), SERVER_CONFIG_LEGACY_ADDRESS); + + assert_eq!(config.get_protocol(), ServerProtocol::Http1); + assert!(config.get_tls().is_none()); +} + +#[test] +fn a_config_built_from_json_and_rebuilt_through_the_constructor_agree() { + let from_json: ServerConfig = + ServerConfig::from_json("{\"address\":\"127.0.0.1:9090\",\"protocol\":\"http2\"}") + .expect("json config must load"); + let other: String = "127.0.0.1:9090".to_string(); + let built: ServerConfig = ServerConfig::new(other, None, None); + assert_eq!(from_json.get_protocol(), ServerProtocol::Http2); + assert_eq!( + built.get_protocol(), + ServerProtocol::Http1, + "the old constructor cannot select a protocol, so it stays on http1" + ); + assert_eq!(from_json.get_address(), built.get_address()); +} + +#[test] +fn an_unknown_protocol_wire_name_is_rejected() { + assert_eq!( + protocol_from_wire_name(WIRE_UNKNOWN), + None, + "an unknown protocol name must not silently fall back to http1" + ); +} + +#[test] +fn a_protocol_and_tls_config_round_trips_through_json() { + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address("127.0.0.1:8443") + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(), + ))); + let encoded: String = format!( + "{{\"address\":\"127.0.0.1:8443\",\"protocol\":\"{WIRE_HTTP2}\",\"tls\":{{\"certificate_path\":\"{}\",\"private_key_path\":\"{}\"}}}}", + fixture_path(LEAF_CHAIN_PEM_NAME).to_string_lossy(), + fixture_path(LEAF_KEY_NAME).to_string_lossy() + ); + let decoded: ServerConfig = ServerConfig::from_json(encoded).expect("config must decode"); + assert_eq!(decoded.get_protocol(), ServerProtocol::Http2); + let decoded_tls: Option = decoded.get_tls().clone(); + match decoded_tls { + Some(value) => { + assert_eq!( + *value.get_certificate_path(), + fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string() + ); + } + None => panic!("the tls block must survive the round trip"), + } +} + +#[test] +fn clearing_tls_leaves_the_protocol_choice_untouched() { + let mut config: ServerConfig = ServerConfig::default(); + config + .set_protocol(ServerProtocol::Http3) + .set_tls(Some(TlsConfig::new("a".to_string(), "b".to_string()))) + .set_tls(None); + assert_eq!(config.get_protocol(), ServerProtocol::Http3); + assert!(config.get_tls().is_none()); +} + +#[tokio::test] +async fn a_truncated_certificate_is_rejected_before_the_listener_binds() { + let port: u16 = reserve_tcp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(TRUNCATED_CERTIFICATE_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "a truncated certificate must fail configuration, not start a listener" + ); + let rebound: io::Result = TcpListener::bind(("127.0.0.1", port)); + assert!( + rebound.is_ok(), + "a rejected certificate must leave port {port} unbound" + ); +} + +#[tokio::test] +async fn a_malformed_certificate_is_rejected_before_the_listener_binds() { + let port: u16 = reserve_tcp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(MALFORMED_CERTIFICATE_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "a malformed certificate must fail configuration, not start a listener" + ); + let rebound: io::Result = TcpListener::bind(("127.0.0.1", port)); + assert!( + rebound.is_ok(), + "a rejected certificate must leave port {port} unbound" + ); +} + +#[tokio::test] +async fn an_invalid_private_key_is_rejected_before_the_listener_binds() { + let port: u16 = reserve_tcp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(INVALID_PRIVATE_KEY_PEM_NAME) + .to_string_lossy() + .to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "an invalid private key must fail configuration, not start a listener" + ); + let rebound: io::Result = TcpListener::bind(("127.0.0.1", port)); + assert!( + rebound.is_ok(), + "a rejected key must leave port {port} unbound" + ); +} + +#[tokio::test] +async fn a_certificate_and_key_from_different_chains_are_rejected() { + let port: u16 = reserve_tcp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(LEAF_CHAIN_PEM_NAME) + .to_string_lossy() + .to_string(), + fixture_path(MISMATCHED_LEAF_KEY_NAME) + .to_string_lossy() + .to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "a certificate and key from different chains must not pair up" + ); + let rebound: io::Result = TcpListener::bind(("127.0.0.1", port)); + assert!( + rebound.is_ok(), + "a rejected key pair must leave port {port} unbound" + ); +} + +#[tokio::test] +async fn a_missing_certificate_file_is_rejected_before_the_listener_binds() { + let port: u16 = reserve_tcp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http2) + .set_tls(Some(TlsConfig::new( + fixture_path(MISSING_PEM_NAME).to_string_lossy().to_string(), + fixture_path(LEAF_KEY_NAME).to_string_lossy().to_string(), + ))); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "a missing certificate file must fail configuration" + ); +} + +#[tokio::test] +async fn http3_without_tls_is_refused_and_never_binds_the_udp_port() { + let port: u16 = reserve_udp_port(); + let mut config: ServerConfig = ServerConfig::default(); + config + .set_address(format!("{LOOPBACK_HOST}:{port}")) + .set_protocol(ServerProtocol::Http3) + .set_tls(None); + let mut server: Server = Server::default(); + server.server_config(config); + register_probe_hooks(&mut server); + let outcome: Result> = server.run().await; + assert!( + outcome.is_err(), + "http3 over quic without tls must be refused, tls is mandatory" + ); + let rebound: io::Result = UdpSocket::bind(format!("{LOOPBACK_HOST}:{port}")); + assert!( + rebound.is_ok(), + "a refused http3 start must leave udp port {port} unbound" + ); +} + +#[tokio::test] +async fn an_http2_tls_client_that_offers_no_alpn_is_not_served() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, Some(trusted_tls_config())) + .await + .expect("http2 tls server must start"); + let mut stream: ClientTlsStream = connect_tls_without_alpn(port) + .await + .expect("the handshake itself must still complete"); + let mut observed: Vec = Vec::new(); + let mut chunk: [u8; 512] = [0; 512]; + if let Ok(Ok(read)) = timeout( + Duration::from_millis(SHUTDOWN_PROBE_MILLIS), + stream.read(&mut chunk), + ) + .await + { + observed.extend_from_slice(&chunk[..read]); + } + assert!( + observed.is_empty(), + "a client that negotiated no protocol must not receive an http2 response, got: {:?}", + String::from_utf8_lossy(&observed) + ); + control.shutdown().await; + control.wait().await; +} + +#[tokio::test] +async fn an_http1_tls_client_that_offers_no_alpn_is_still_served() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, Some(trusted_tls_config())) + .await + .expect("http1 tls server must start"); + let mut stream: ClientTlsStream = connect_tls_without_alpn(port) + .await + .expect("a legacy client without alpn must complete the handshake"); + let request: String = format!( + "GET {VERSION_ROUTE_PATH} HTTP/1.1\r\nHost: {TLS_SERVER_NAME}\r\nConnection: close\r\n\r\n" + ); + let written: io::Result<()> = write_bounded(&mut stream, request.as_bytes()).await; + assert!(written.is_ok(), "the request must reach the server"); + let mut collected: Vec = Vec::new(); + let mut chunk: [u8; 512] = [0; 512]; + if let Ok(Ok(read)) = timeout( + Duration::from_millis(SHUTDOWN_PROBE_MILLIS), + stream.read(&mut chunk), + ) + .await + { + collected.extend_from_slice(&chunk[..read]); + } + let (head, body): (String, Vec) = response_head_and_body(&collected); + assert!( + head.starts_with("HTTP/1.1 200"), + "a no-alpn client must still be served over http1, got: {head}" + ); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http1_1) + ); + control.shutdown().await; + control.wait().await; +} + +#[tokio::test] +async fn a_client_that_does_not_trust_the_server_ca_is_refused() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, Some(trusted_tls_config())) + .await + .expect("http2 tls server must start"); + let outcome: Result<(), String> = connect_tls_untrusted(port).await; + assert!( + outcome.is_err(), + "a client without the fixture ca in its trust store must be refused" + ); + let (sender, connection) = h2_plaintext_session(port) + .await + .expect("the server must keep serving a properly trusting client"); + drop(sender); + drop(connection); + control.shutdown().await; +} + +#[tokio::test] +async fn an_infinite_read_timeout_tls_close_still_delivers_close_notify() { + let port: u16 = reserve_tcp_port(); + let address: String = format!("{LOOPBACK_HOST}:{port}"); + let mut server: Server = Server::default(); + server + .server_config(protocol_config( + &address, + ServerProtocol::Http1, + Some(trusted_tls_config()), + )) + .request_config(RequestConfig { + read_timeout_ms: u64::MAX, + ..RequestConfig::default() + }); + register_probe_hooks(&mut server); + let control: ServerControlHook = server + .run() + .await + .expect("http1 tls server must start with an infinite read timeout"); + drop(server); + sleep(Duration::from_millis(SERVER_BOOT_MILLIS)).await; + let stream: ClientTlsStream = connect_tls(port, ALPN_HTTP1) + .await + .expect("validating client must complete the handshake"); + let request: Vec = http1_close_request("GET", VERSION_ROUTE_PATH); + let exchanged: Vec = timeout( + Duration::from_secs(REQUEST_TIMEOUT_SECS), + tls_exchange(stream, request), + ) + .await + .expect("the tls exchange must not outlive the client timeout") + .expect("the tls exchange must complete without a transport error"); + let (head, body): (String, Vec) = response_head_and_body(&exchanged); + assert!( + head.starts_with("HTTP/1.1 200"), + "an infinite read timeout must still serve the request, got: {head}" + ); + assert_eq!( + String::from_utf8_lossy(&body), + format!("{:?}", HttpVersion::Http1_1), + "the body must arrive complete before the session is closed" + ); + control.shutdown().await; +} diff --git a/core/tests/protocol/tls/mod.rs b/core/tests/protocol/tls/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/core/tests/protocol/tls/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/core/tests/protocol/websocket/const.rs b/core/tests/protocol/websocket/const.rs new file mode 100644 index 00000000..6b3da962 --- /dev/null +++ b/core/tests/protocol/websocket/const.rs @@ -0,0 +1,26 @@ +pub(crate) const WEBSOCKET_ROUTE_PATH: &str = "/ws"; +pub(crate) const WEBSOCKET_PROTOCOL_VERSION: &str = "13"; +pub(crate) const WEBSOCKET_SWITCHING_LINE: &str = "HTTP/1.1 101"; +pub(crate) const WEBSOCKET_UPGRADE_VALUE: &str = "websocket"; +pub(crate) const WEBSOCKET_CONNECTION_VALUE: &str = "Upgrade"; + +pub(crate) const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +pub(crate) const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +pub(crate) const WEBSOCKET_FIN_BIT: u8 = 0b1000_0000; +pub(crate) const WEBSOCKET_MASK_BIT: u8 = 0b1000_0000; +pub(crate) const WEBSOCKET_BINARY_OPCODE: u8 = 0x02; +pub(crate) const WEBSOCKET_CLOSE_OPCODE: u8 = 0x08; +pub(crate) const WEBSOCKET_EXTENDED_LENGTH_MARK: u8 = 126; +pub(crate) const WEBSOCKET_MAX_SHORT_FRAME: usize = 0xFFFF; +pub(crate) const WEBSOCKET_MAX_LENGTH_MARK: u8 = 127; +pub(crate) const RFC6455_HELLO_MASK: [u8; 4] = [0x37, 0xFA, 0x21, 0x3D]; +pub(crate) const WEBSOCKET_TEXT_OPCODE: u8 = 0x01; +pub(crate) const RFC6455_HELLO_PAYLOAD: &str = "Hello"; +pub(crate) const RFC6455_HELLO_FRAME: [u8; 11] = [ + 0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58, +]; +pub(crate) const WEBSOCKET_CLIENT_MASK: [u8; 4] = [0x37, 0xFA, 0x21, 0x3D]; + +pub(crate) const WEBSOCKET_READ_CHUNK_LEN: usize = 8192; +pub(crate) const WEBSOCKET_EXCHANGE_TIMEOUT_SECS: u64 = 30; diff --git a/core/tests/protocol/websocket/fn.rs b/core/tests/protocol/websocket/fn.rs new file mode 100644 index 00000000..caba1202 --- /dev/null +++ b/core/tests/protocol/websocket/fn.rs @@ -0,0 +1,327 @@ +use super::*; + +fn websocket_upgrade_request(key: &str) -> Vec { + let mut head: String = + format!("GET {WEBSOCKET_ROUTE_PATH} HTTP/1.1{HTTP_BR}Host: {TLS_SERVER_NAME}{HTTP_BR}"); + head.push_str(&format!("{UPGRADE}: {WEBSOCKET_UPGRADE_VALUE}{HTTP_BR}")); + head.push_str(&format!( + "{CONNECTION}: {WEBSOCKET_CONNECTION_VALUE}{HTTP_BR}" + )); + head.push_str(&format!("{SEC_WEBSOCKET_VERSION}: ")); + head.push_str(WEBSOCKET_PROTOCOL_VERSION); + head.push_str(HTTP_BR); + head.push_str(&format!("{SEC_WEBSOCKET_KEY}: {key}{HTTP_BR}")); + head.push_str(HTTP_BR); + head.into_bytes() +} + +fn client_masked_frame(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec { + let mut frame: Vec = vec![WEBSOCKET_FIN_BIT | opcode]; + if payload.len() < WEBSOCKET_EXTENDED_LENGTH_MARK as usize { + frame.push(WEBSOCKET_MASK_BIT | payload.len() as u8); + } else if payload.len() <= WEBSOCKET_MAX_SHORT_FRAME { + frame.push(WEBSOCKET_MASK_BIT | WEBSOCKET_EXTENDED_LENGTH_MARK); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } else { + frame.push(WEBSOCKET_MASK_BIT | WEBSOCKET_MAX_LENGTH_MARK); + frame.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + } + frame.extend_from_slice(&mask); + for (index, byte) in payload.iter().enumerate() { + frame.push(byte ^ mask[index % 4]); + } + frame +} + +fn decode_unmasked_frame(raw: &[u8]) -> Option<(Vec, Vec)> { + if raw.len() < 2 { + return None; + } + if raw[1] & WEBSOCKET_MASK_BIT != 0 { + panic!("a server frame must never be masked, got: {raw:?}"); + } + let short_length: usize = (raw[1] & !WEBSOCKET_MASK_BIT) as usize; + let mut cursor: usize = 2; + let payload_len: usize = if short_length == WEBSOCKET_EXTENDED_LENGTH_MARK as usize { + if raw.len() < cursor + 2 { + return None; + } + let value: usize = u16::from_be_bytes([raw[cursor], raw[cursor + 1]]) as usize; + cursor += 2; + value + } else { + short_length + }; + if raw.len() < cursor + payload_len { + return None; + } + Some(( + raw[..cursor].to_vec(), + raw[cursor..cursor + payload_len].to_vec(), + )) +} + +async fn read_websocket_frame(stream: &mut T) -> io::Result<(Vec, Vec)> +where + T: AsyncRead + Unpin, +{ + let bound: Duration = Duration::from_secs(WEBSOCKET_EXCHANGE_TIMEOUT_SECS); + let mut collected: Vec = Vec::new(); + loop { + if let Some(frame) = decode_unmasked_frame(&collected) { + return Ok(frame); + } + let mut chunk: [u8; WEBSOCKET_READ_CHUNK_LEN] = [0; WEBSOCKET_READ_CHUNK_LEN]; + let read: usize = match timeout(bound, stream.read(&mut chunk)).await { + Ok(Ok(count)) => count, + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the echoed websocket frame did not arrive inside its timeout", + )); + } + }; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "the connection ended before the echoed frame arrived", + )); + } + collected.extend_from_slice(&chunk[..read]); + } +} + +async fn open_websocket(stream: &mut T, key: &str) -> io::Result +where + T: AsyncRead + AsyncWrite + Unpin, +{ + let request: Vec = websocket_upgrade_request(key); + write_bounded(stream, &request).await?; + stream.flush().await?; + let (head, _leftover): (String, Vec) = read_http1_head(stream, 0).await?; + if !head.starts_with(WEBSOCKET_SWITCHING_LINE) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("the handshake must switch protocols, got: {head}"), + )); + } + Ok(head) +} + +async fn echo_one_masked_frame(stream: &mut T, payload: &[u8]) -> io::Result<(Vec, Vec)> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + let frame: Vec = + client_masked_frame(WEBSOCKET_BINARY_OPCODE, payload, WEBSOCKET_CLIENT_MASK); + write_bounded(stream, &frame).await?; + stream.flush().await?; + read_websocket_frame(stream).await +} + +async fn close_websocket(stream: &mut T) -> io::Result<()> +where + T: AsyncWrite + Unpin, +{ + let frame: Vec = client_masked_frame(WEBSOCKET_CLOSE_OPCODE, &[], WEBSOCKET_CLIENT_MASK); + write_bounded(stream, &frame).await?; + stream.flush().await?; + let bound: Duration = Duration::from_secs(WEBSOCKET_EXCHANGE_TIMEOUT_SECS); + match timeout(bound, stream.shutdown()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(error), + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + "the client half close did not finish inside its timeout", + )), + } +} + +async fn start_websocket_server( + tls: Option, +) -> Result<(ServerControlHook, u16), Box> { + let port: u16 = reserve_tcp_port(); + let address: String = format!("{LOOPBACK_HOST}:{port}"); + let mut config: ServerConfig = ServerConfig::default(); + config.set_address(&address).set_tls(tls); + let mut server: Server = Server::default(); + server + .server_config(config) + .request_middleware::() + .route::(WEBSOCKET_ROUTE_PATH); + let control: ServerControlHook = server.run().await?; + drop(server); + let probe: io::Result = connect_tcp(port).await; + assert!( + probe.is_ok(), + "the loopback websocket listener must accept once run resolved" + ); + Ok((control, port)) +} + +fn assert_websocket_head(head: &str) { + assert!( + head.starts_with(WEBSOCKET_SWITCHING_LINE), + "an upgraded request must answer 101, got: {head}" + ); + assert_eq!( + response_header_value(head, SEC_WEBSOCKET_ACCEPT), + RFC6455_SAMPLE_ACCEPT, + "the accept key must be the rfc6455 value for this key, got: {head}" + ); + assert_eq!( + response_header_value(head, UPGRADE), + WEBSOCKET_UPGRADE_VALUE, + "the upgraded response must name the websocket protocol, got: {head}" + ); + assert_eq!( + response_header_value(head, CONNECTION).to_ascii_lowercase(), + WEBSOCKET_CONNECTION_VALUE.to_ascii_lowercase(), + "the upgraded response must switch the connection, got: {head}" + ); +} + +fn assert_echoed_binary_frame(head: Vec, echoed: Vec, sent: &[u8]) { + let mut expected_head: Vec = vec![WEBSOCKET_FIN_BIT | WEBSOCKET_BINARY_OPCODE]; + let payload_len: usize = sent.len(); + if payload_len < WEBSOCKET_EXTENDED_LENGTH_MARK as usize { + expected_head.push(payload_len as u8); + } else { + expected_head.push(WEBSOCKET_EXTENDED_LENGTH_MARK); + expected_head.extend_from_slice(&(payload_len as u16).to_be_bytes()); + } + assert_eq!( + head, expected_head, + "the echo must be one final unmasked binary frame with the rfc6455 length encoding" + ); + assert_eq!( + echoed.len(), + payload_len, + "the echoed frame must carry every byte the client sent" + ); + assert_eq!( + echoed, sent, + "hostile binary bytes must survive the upgrade unmasked and byte exact" + ); +} + +fn assert_port_released(port: u16) { + let rebound: io::Result = TcpListener::bind((LOOPBACK_HOST, port)); + assert!( + rebound.is_ok(), + "a stopped websocket server must release tcp port {port}, got: {:?}", + rebound.err() + ); +} + +#[tokio::test] +async fn websocket_plaintext_upgrade_echoes_hostile_binary_frames() { + let (control, port): (ServerControlHook, u16) = start_websocket_server(None) + .await + .expect("the default plaintext http1 websocket server must start"); + let bound: Duration = Duration::from_secs(WEBSOCKET_EXCHANGE_TIMEOUT_SECS); + let outcome: io::Result<()> = match timeout(bound, async { + let mut stream: TcpStream = connect_tcp(port).await?; + let head: String = open_websocket(&mut stream, RFC6455_SAMPLE_KEY).await?; + assert_websocket_head(&head); + let sent: Vec = hostile_binary_payload(); + let (frame_head, echoed): (Vec, Vec) = + echo_one_masked_frame(&mut stream, &sent).await?; + assert_echoed_binary_frame(frame_head, echoed, &sent); + close_websocket(&mut stream).await?; + Ok(()) + }) + .await + { + Ok(value) => value, + Err(_) => panic!("the plaintext websocket exchange must finish inside its timeout"), + }; + assert!( + outcome.is_ok(), + "the plaintext websocket exchange failed: {outcome:?}" + ); + assert_wait_pending_then_complete(&control).await; + assert_port_released(port); +} + +#[tokio::test] +async fn websocket_tls_upgrade_echoes_hostile_binary_frames() { + let (control, port): (ServerControlHook, u16) = + start_websocket_server(Some(trusted_tls_config())) + .await + .expect("the default http1 websocket server must start over tls"); + let bound: Duration = Duration::from_secs(WEBSOCKET_EXCHANGE_TIMEOUT_SECS); + let outcome: io::Result<()> = match timeout(bound, async { + let mut stream: ClientTlsStream = connect_tls(port, ALPN_HTTP1).await?; + let alpn: Option> = negotiated_alpn(&stream); + assert_eq!( + alpn, + Some(ALPN_HTTP1.to_vec()), + "the tls websocket server must negotiate http/1.1" + ); + let head: String = open_websocket(&mut stream, RFC6455_SAMPLE_KEY).await?; + assert_websocket_head(&head); + let sent: Vec = hostile_binary_payload(); + let (frame_head, echoed): (Vec, Vec) = + echo_one_masked_frame(&mut stream, &sent).await?; + assert_echoed_binary_frame(frame_head, echoed, &sent); + close_websocket(&mut stream).await?; + Ok(()) + }) + .await + { + Ok(value) => value, + Err(_) => panic!("the tls websocket exchange must finish inside its timeout"), + }; + assert!( + outcome.is_ok(), + "the tls websocket exchange failed: {outcome:?}" + ); + assert_wait_pending_then_complete(&control).await; + assert_port_released(port); +} +#[test] +fn the_client_encoder_reproduces_the_rfc6455_masked_hello_frame() { + let frame: Vec = client_masked_frame( + WEBSOCKET_TEXT_OPCODE, + RFC6455_HELLO_PAYLOAD.as_bytes(), + RFC6455_HELLO_MASK, + ); + assert_eq!( + frame, + RFC6455_HELLO_FRAME.to_vec(), + "the client encoder must emit the exact masked frame bytes rfc6455 publishes" + ); +} + +#[test] +fn a_client_frame_never_carries_a_length_byte_of_its_own() { + let short_frame: Vec = + client_masked_frame(WEBSOCKET_BINARY_OPCODE, b"abc", RFC6455_HELLO_MASK); + assert_eq!( + short_frame[1], + WEBSOCKET_MASK_BIT | 3, + "the mask bit and the seven bit length share the second header byte" + ); + assert_eq!( + short_frame.len(), + 9, + "2 header bytes, 4 mask bytes, 3 payload bytes" + ); + let long_frame: Vec = client_masked_frame( + WEBSOCKET_BINARY_OPCODE, + &hostile_binary_payload(), + WEBSOCKET_CLIENT_MASK, + ); + assert_eq!( + long_frame[1], + WEBSOCKET_MASK_BIT | WEBSOCKET_EXTENDED_LENGTH_MARK, + "a payload past 125 bytes announces its length with the 126 marker" + ); + assert_eq!( + &long_frame[2..4], + &(hostile_binary_payload().len() as u16).to_be_bytes(), + "the extended length must be the big endian payload size" + ); +} diff --git a/core/tests/protocol/websocket/impl.rs b/core/tests/protocol/websocket/impl.rs new file mode 100644 index 00000000..af5032a4 --- /dev/null +++ b/core/tests/protocol/websocket/impl.rs @@ -0,0 +1,54 @@ +use super::*; + +impl ServerHook for WebsocketUpgradeMiddleware { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, ctx: &mut Context) -> Status { + if !ctx.get_request().is_ws_upgrade_type() { + return Status::Continue; + } + if let Some(key) = &ctx.get_request().try_get_header_back(SEC_WEBSOCKET_KEY) { + let accept_key: String = WebSocketFrame::generate_accept_key(key); + let data: Vec = ctx + .get_mut_response() + .set_version(HttpVersion::Http1_1) + .set_status_code(101) + .set_header(UPGRADE, WEBSOCKET) + .set_header(CONNECTION, WEBSOCKET_CONNECTION_VALUE) + .set_header(SEC_WEBSOCKET_ACCEPT, &accept_key) + .set_body(Vec::new()) + .build(); + if stream.try_send(data).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + } + Status::Continue + } +} + +impl WebsocketEchoRoute { + async fn try_send_frame(&self, stream: &mut Stream, body: &[u8]) -> Result<(), ResponseError> { + let frame_list: Vec = WebSocketFrame::create_frame_list(body); + stream.try_send_list(&frame_list).await + } +} + +impl ServerHook for WebsocketEchoRoute { + async fn new(_: &mut Stream, _: &mut Context) -> Self { + Self + } + + async fn handle(self, stream: &mut Stream, _: &mut Context) -> Status { + while let Ok(body) = stream.try_get_websocket_request().await { + let payload: Vec = body; + if self.try_send_frame(stream, &payload).await.is_err() { + stream.set_closed(true); + return Status::Reject; + } + } + Status::Continue + } +} diff --git a/core/tests/protocol/websocket/mod.rs b/core/tests/protocol/websocket/mod.rs new file mode 100644 index 00000000..cfc5e851 --- /dev/null +++ b/core/tests/protocol/websocket/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod r#const; +pub(crate) mod r#fn; +pub(crate) mod r#impl; +pub(crate) mod r#struct; + +use r#const::*; +use r#struct::*; + +use super::*; diff --git a/core/tests/protocol/websocket/struct.rs b/core/tests/protocol/websocket/struct.rs new file mode 100644 index 00000000..46960e60 --- /dev/null +++ b/core/tests/protocol/websocket/struct.rs @@ -0,0 +1,3 @@ +pub(crate) struct WebsocketUpgradeMiddleware; + +pub(crate) struct WebsocketEchoRoute; diff --git a/core/tests/protocol/wire/assert.rs b/core/tests/protocol/wire/assert.rs new file mode 100644 index 00000000..5e5c088b --- /dev/null +++ b/core/tests/protocol/wire/assert.rs @@ -0,0 +1,232 @@ +use super::*; + +#[tokio::test] +async fn http1_and_http2_plaintext_listeners_never_claim_the_same_tcp_port() { + let (first, first_port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 server must start"); + let (second, second_port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 server must start"); + assert_ne!( + first_port, second_port, + "each listener must own a distinct tcp port" + ); + let request: Vec = http1_close_request("GET", VERSION_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(first_port, &request) + .await + .expect("the http1 listener must answer a plain http1 request"); + assert!(response_head(&raw).starts_with("HTTP/1.1 200")); + first.shutdown().await; + second.shutdown().await; +} + +#[tokio::test] +async fn an_http1_listener_refuses_a_prior_knowledge_http2_client() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("the client handshake future resolves before the wire is settled"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let target: String = format!("http://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"); + let outcome: Result = h2_request(&mut sender, "GET", &target, &[]).await; + assert!( + outcome.is_err(), + "an http1 listener must not answer an http2 request on the wire, got: {outcome:?}" + ); + drop(sender); + driving.abort(); + let request: Vec = http1_close_request("GET", VERSION_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("the http1 listener must still serve plain http1"); + assert!(response_head(&raw).starts_with("HTTP/1.1 200")); + control.shutdown().await; +} + +#[tokio::test] +async fn an_http2_listener_does_not_answer_a_plain_http1_request() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 server must start"); + let request: &[u8] = b"GET /version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + if let Ok(raw) = raw_http1_exchange(port, request).await { + let head: String = response_head(&raw); + assert!( + !head.starts_with("HTTP/1.1 200"), + "an http2 listener must not answer an http1 request, got: {head}" + ); + } + control.shutdown().await; +} + +#[tokio::test] +async fn an_http3_listener_does_not_answer_a_tcp_request() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 server must start"); + let request: &[u8] = b"GET /version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + if let Ok(raw) = raw_http1_exchange(port, request).await { + let head: String = response_head(&raw); + assert!( + !head.starts_with("HTTP/1.1 200"), + "an http3 listener binds udp only and must not answer tcp, got: {head}" + ); + } + control.shutdown().await; +} + +#[tokio::test] +async fn http2_strips_every_hop_by_hop_header_but_keeps_end_to_end_ones() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let (status, headers, body): (u16, http::HeaderMap, Vec) = h2_request_with_headers( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{HOP_BY_HOP_CLEAN_ROUTE_PATH}"), + &[], + ) + .await + .expect("the hop-by-hop route must answer"); + assert_eq!(status, 200); + for name in [ + CONNECTION, + KEEP_ALIVE, + UPGRADE, + TE, + INTERNAL_CONNECTION_HEADER, + ] { + assert!( + !headers.contains_key(name), + "{name} is hop by hop on http2 and must not reach the client, got: {headers:?}" + ); + } + assert_eq!( + headers + .get(PRESERVED_HEADER_KEY) + .map(|value: &http::HeaderValue| value.to_str().unwrap_or_default()), + Some(PRESERVED_HEADER_VALUE), + "an end-to-end header must survive the stripping, got: {headers:?}" + ); + assert_eq!(String::from_utf8_lossy(&body), HOP_BY_HOP_SENT_BODY); + let (status, _version, body): (u16, http::Version, Vec) = h2_request( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{HOP_BY_HOP_CLEAN_ROUTE_PATH}"), + &[], + ) + .await + .expect("the hop-by-hop route must answer a second time on the same connection"); + assert_eq!(status, 200); + assert_eq!(String::from_utf8_lossy(&body), HOP_BY_HOP_SENT_BODY); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http2_refuses_a_response_announcing_a_chunked_transfer_encoding() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http2, None) + .await + .expect("http2 server must start"); + let (mut sender, connection) = h2_plaintext_session(port) + .await + .expect("h2 handshake must succeed"); + let driving: JoinHandle<()> = spawn(async move { + let _result: Result<(), h2::Error> = connection.await; + }); + let outcome: Result = h2_request( + &mut sender, + "GET", + &format!("http://{TLS_SERVER_NAME}:{port}{HOP_BY_HOP_ROUTE_PATH}"), + &[], + ) + .await; + assert!( + outcome.is_err(), + "a head announcing a transfer coding must be refused, never delivered as a success, got: {outcome:?}" + ); + drop(sender); + driving.abort(); + control.shutdown().await; +} + +#[tokio::test] +async fn http3_refuses_a_response_announcing_a_chunked_transfer_encoding() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http3, Some(trusted_tls_config())) + .await + .expect("http3 quic server must start"); + let outcome: Result = h3_request( + port, + "GET", + &format!("https://{TLS_SERVER_NAME}:{port}{HOP_BY_HOP_ROUTE_PATH}"), + &[], + ) + .await; + assert!( + outcome.is_err(), + "a head announcing a transfer coding must be refused, never delivered as a success, got: {outcome:?}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_keeps_its_connection_header_because_it_is_end_to_end_on_http1() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 server must start"); + let request: Vec = http1_close_request("GET", HOP_BY_HOP_ROUTE_PATH); + let raw: Vec = raw_http1_exchange(port, &request) + .await + .expect("http1 exchange must complete"); + let head: String = response_head(&raw).to_lowercase(); + assert!( + head.contains(CONNECTION), + "an http1 response keeps its connection header, got: {head}" + ); + control.shutdown().await; +} + +#[tokio::test] +async fn http1_split_writes_reassemble_into_one_body_across_several_writes() { + let (control, port): (ServerControlHook, u16) = + start_protocol_server(ServerProtocol::Http1, None) + .await + .expect("http1 server must start"); + let mut stream: TcpStream = connect_tcp(port).await.expect("socket must connect"); + let request: Vec = http1_close_request("GET", SPLIT_ROUTE_PATH); + write_bounded(&mut stream, &request) + .await + .expect("request must be written"); + let (head, body): (String, Vec) = read_http1_head(&mut stream, SPLIT_BODY_LEN) + .await + .expect("the split response head must be readable"); + assert!(head.starts_with("HTTP/1.1 200"), "got: {head}"); + let text: String = String::from_utf8_lossy(&body).to_string(); + assert_eq!( + text, + format!("{SPLIT_HEAD_BODY}{SPLIT_TAIL_BODY}"), + "fragments written separately must reassemble into one body" + ); + control.shutdown().await; +} diff --git a/core/tests/protocol/wire/mod.rs b/core/tests/protocol/wire/mod.rs new file mode 100644 index 00000000..354c95a9 --- /dev/null +++ b/core/tests/protocol/wire/mod.rs @@ -0,0 +1,3 @@ +mod r#assert; + +use super::*; diff --git a/plugin/websocket/src/struct.rs b/plugin/websocket/src/struct.rs index 22a8bece..e9249391 100644 --- a/plugin/websocket/src/struct.rs +++ b/plugin/websocket/src/struct.rs @@ -9,7 +9,7 @@ pub struct WebSocket { /// The internal broadcast map. /// /// This map is used for managing WebSocket message distribution. - pub(super) broadcast_map: BroadcastMap>, + pub broadcast_map: BroadcastMap>, } /// Configuration for a WebSocket connection. @@ -25,42 +25,42 @@ pub struct WebSocket { pub struct WebSocketConfig<'a, B: BroadcastTypeTrait> { /// The underlying stream for this WebSocket connection. #[set(skip)] - pub(super) stream: &'a mut Stream, + pub stream: &'a mut Stream, /// The Hyperlane context. /// /// This context is associated with this WebSocket connection. #[set(skip)] - pub(super) context: &'a mut Context, + pub context: &'a mut Context, /// The capacity. /// /// This is the capacity of the broadcast sender channel. #[get(type(copy))] #[set(skip)] - pub(super) capacity: Capacity, + pub capacity: Capacity, /// The broadcast type. /// /// This defines the type of broadcast this WebSocket connection will participate in /// (point-to-point or point-to-group). #[set(skip)] - pub(super) broadcast_type: BroadcastType, + pub broadcast_type: BroadcastType, /// The connected hook handler. /// /// This hook is executed when the WebSocket connection is established. #[set(skip)] - pub(super) connected_hook: ServerHookHandler, + pub connected_hook: ServerHookHandler, /// The request hook handler. /// /// This hook is executed when a new request is received on the WebSocket. #[set(skip)] - pub(super) request_hook: ServerHookHandler, + pub request_hook: ServerHookHandler, /// The sended hook handler. /// /// This hook is executed after a message has been successfully sent over the WebSocket. #[set(skip)] - pub(super) sended_hook: ServerHookHandler, + pub sended_hook: ServerHookHandler, /// The closed hook handler. /// /// This hook is executed when the WebSocket connection is closed. #[set(skip)] - pub(super) closed_hook: ServerHookHandler, + pub closed_hook: ServerHookHandler, } diff --git a/request/src/common/struct.rs b/request/src/common/struct.rs index b29cd6f1..83b84666 100644 --- a/request/src/common/struct.rs +++ b/request/src/common/struct.rs @@ -13,5 +13,5 @@ use super::*; #[derive(Clone, Debug, Default, Eq, Getter, PartialEq, Serialize)] pub struct Body { /// Raw body bytes. - pub(super) bytes: Vec, + pub bytes: Vec, } diff --git a/request/src/request/config/struct.rs b/request/src/request/config/struct.rs index 58a757c5..e89cf2ec 100644 --- a/request/src/request/config/struct.rs +++ b/request/src/request/config/struct.rs @@ -12,22 +12,22 @@ use super::*; pub struct RequestConfig { /// Per-read buffer size in bytes. #[get(type(copy))] - pub(crate) buffer_size: usize, + pub buffer_size: usize, /// Request timeout in milliseconds. #[get(type(copy))] - pub(super) timeout: u64, + pub timeout: u64, /// Maximum number of redirects to follow (0 = no redirect). #[get(type(copy))] - pub(super) max_redirect_times: usize, + pub max_redirect_times: usize, /// HTTP version (`HttpVersion::Http1_1` / `HttpVersion::Http2`). - pub(super) http_version: HttpVersion, + pub http_version: HttpVersion, /// Whether to follow redirects automatically. #[get(type(copy))] - pub(super) redirect: bool, + pub redirect: bool, /// Whether to automatically decode response bodies (gzip / deflate / br). #[get(type(copy))] - pub(super) decode: bool, + pub decode: bool, /// Optional proxy configuration. `None` means direct connection. #[get(type(clone))] - pub(super) proxy: Option, + pub proxy: Option, } diff --git a/request/src/request/http_request/impl.rs b/request/src/request/http_request/impl.rs index 8831763d..d260d56c 100644 --- a/request/src/request/http_request/impl.rs +++ b/request/src/request/http_request/impl.rs @@ -733,7 +733,7 @@ impl HttpRequest { proxy: &Proxy, ) -> Result { let timeout: Duration = Duration::from_millis(self.get_config().get_timeout()); - let tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), *proxy.get_port())) + let tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), proxy.get_port())) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_read_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; @@ -823,7 +823,7 @@ impl HttpRequest { proxy: &Proxy, ) -> Result { let timeout: Duration = Duration::from_millis(self.get_config().get_timeout()); - let mut tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), *proxy.get_port())) + let mut tcp: TcpStream = TcpStream::connect((proxy.get_host().clone(), proxy.get_port())) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; tcp.set_read_timeout(Some(timeout)) .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; @@ -971,12 +971,10 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let tcp: http_type::tokio::net::TcpStream = http_type::tokio::net::TcpStream::connect(( - proxy.get_host().clone(), - *proxy.get_port(), - )) - .await - .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; + let tcp: http_type::tokio::net::TcpStream = + http_type::tokio::net::TcpStream::connect((proxy.get_host().clone(), proxy.get_port())) + .await + .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; let mut proxy_stream: BoxAsyncReadWrite = if proxy.get_proxy_type() == &ProxyType::Https { let roots: RootCertStore = self.get_tmp().get_root_cert().clone(); let tls_cfg: ClientConfig = ClientConfig::builder() @@ -1070,11 +1068,10 @@ impl HttpRequest { target_port: u16, proxy: &Proxy, ) -> Result { - let mut tcp: http_type::tokio::net::TcpStream = http_type::tokio::net::TcpStream::connect( - (proxy.get_host().clone(), *proxy.get_port()), - ) - .await - .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; + let mut tcp: http_type::tokio::net::TcpStream = + http_type::tokio::net::TcpStream::connect((proxy.get_host().clone(), proxy.get_port())) + .await + .map_err(|e: io::Error| RequestError::Request(e.to_string()))?; let auth_methods: Vec = if proxy.get_username().is_some() && proxy.get_password().is_some() { vec![0x05, 0x02, 0x00, 0x02] diff --git a/request/src/request/proxy/struct.rs b/request/src/request/proxy/struct.rs index 1c2177be..95787835 100644 --- a/request/src/request/proxy/struct.rs +++ b/request/src/request/proxy/struct.rs @@ -22,6 +22,7 @@ pub struct Proxy { /// Proxy server hostname or IP. pub host: String, /// Proxy server port. + #[get(type(copy))] pub port: u16, /// Optional username for proxy auth. #[get(type(clone))] diff --git a/type/src/cookie/struct.rs b/type/src/cookie/struct.rs index 556f9975..bf361126 100644 --- a/type/src/cookie/struct.rs +++ b/type/src/cookie/struct.rs @@ -8,34 +8,34 @@ use super::*; pub struct CookieBuilder { /// Cookie name identifier. #[set(type(AsRef))] - pub(super) name: CookieKey, + pub name: CookieKey, /// Cookie value content. #[set(type(AsRef))] - pub(super) value: CookieValue, + pub value: CookieValue, /// Optional expiration date/time string. #[set(skip)] - pub(super) expires: Option, + pub expires: Option, /// Optional maximum age in seconds. #[get(type(copy))] #[set(skip)] - pub(super) max_age: Option, + pub max_age: Option, /// Optional domain scope for the cookie. #[set(skip)] - pub(super) domain: Option, + pub domain: Option, /// Optional path scope for the cookie. #[set(skip)] - pub(super) path: Option, + pub path: Option, /// Optional flag indicating secure (HTTPS-only) transmission. #[get(type(copy))] #[set(skip)] - pub(super) secure: Option, + pub secure: Option, /// Optional flag preventing JavaScript access. #[get(type(copy))] #[set(skip)] - pub(super) http_only: Option, + pub http_only: Option, /// Optional SameSite policy setting. #[set(skip)] - pub(super) same_site: Option, + pub same_site: Option, } /// Parser for HTTP Cookie headers. diff --git a/type/src/lib.rs b/type/src/lib.rs index 4d2f5339..bf42ee07 100644 --- a/type/src/lib.rs +++ b/type/src/lib.rs @@ -49,7 +49,7 @@ use std::{ hash::Hash, io::{self, ErrorKind}, mem, - net::IpAddr, + net::{IpAddr, SocketAddr}, num::ParseIntError, pin::Pin, rc::Rc, @@ -69,7 +69,10 @@ use { lombok_macros::*, serde::{Deserialize, Serialize, de::DeserializeOwned}, tokio::{ - io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, ReadBuf}, + io::{ + AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, + ReadBuf, + }, net::TcpStream, runtime::Handle, sync::{ diff --git a/type/src/panic/struct.rs b/type/src/panic/struct.rs index 5ea2c535..1cb5bfaf 100644 --- a/type/src/panic/struct.rs +++ b/type/src/panic/struct.rs @@ -11,10 +11,10 @@ use super::*; pub struct PanicData { /// The message associated with the panic. /// This is `None` if the panic payload is not a string. - pub(super) message: Option, + pub message: Option, /// The source code location where the panic occurred. - pub(super) location: Option, + pub location: Option, /// The payload of the panic, often a string literal. /// The hook attempts to downcast it to a `&str` or `String`. - pub(super) payload: Option, + pub payload: Option, } diff --git a/type/src/request/struct.rs b/type/src/request/struct.rs index c3483ec1..e931cb73 100644 --- a/type/src/request/struct.rs +++ b/type/src/request/struct.rs @@ -41,9 +41,7 @@ pub struct RequestConfig { /// /// The fields stay `pub`: `type/tests/request` builds fixtures through them, so /// this is published API and §17.14 does not recall it (semver). -#[derive( - Clone, Debug, Deserialize, DisplayDebug, Eq, Getter, GetterMut, PartialEq, Serialize, Setter, -)] +#[derive(Clone, Data, Debug, Deserialize, DisplayDebug, Eq, PartialEq, Serialize)] pub struct Request { /// HTTP request method. pub method: RequestMethod, diff --git a/type/src/response/struct.rs b/type/src/response/struct.rs index 9f74ecd3..becb0ad9 100644 --- a/type/src/response/struct.rs +++ b/type/src/response/struct.rs @@ -4,9 +4,7 @@ use super::*; /// /// The fields stay `pub`: `type/tests/response` reads them directly, so this is /// published API and §17.14 does not recall it (semver). -#[derive( - Clone, Debug, Deserialize, DisplayDebug, Eq, Getter, GetterMut, PartialEq, Serialize, Setter, -)] +#[derive(Clone, Data, Debug, Deserialize, DisplayDebug, Eq, PartialEq, Serialize)] pub struct Response { /// The HTTP version used in the response. pub version: ResponseVersion, diff --git a/type/src/stream/impl.rs b/type/src/stream/impl.rs index c7d25d2a..4098343a 100644 --- a/type/src/stream/impl.rs +++ b/type/src/stream/impl.rs @@ -141,6 +141,30 @@ impl Lifetime for Stream { } } +/// Implementation of `Transport` trait for the plain TCP socket. +/// +/// The socket is the reference transport: it already carries a socket +/// identity, so its addresses are reported directly. +impl Transport for TcpStream { + /// Reports the address of the remote end of the socket. + /// + /// # Returns + /// + /// - `Result` - The peer address of the socket. + fn peer_addr(&self) -> Result { + TcpStream::peer_addr(self) + } + + /// Reports the address of the local end of the socket. + /// + /// # Returns + /// + /// - `Result` - The local address of the socket. + fn local_addr(&self) -> Result { + TcpStream::local_addr(self) + } +} + impl Drop for PooledReader<'_> { /// Releases the buffer back to the pool for reuse by later requests. fn drop(&mut self) { @@ -171,11 +195,11 @@ impl AsyncRead for PooledReader<'_> { buf: &mut ReadBuf<'_>, ) -> Poll> { let this: &mut Self = self.get_mut(); - if *this.get_start() < *this.get_end() { - let available: usize = *this.get_end() - *this.get_start(); + if this.get_start() < this.get_end() { + let available: usize = this.get_end() - this.get_start(); let amount: usize = available.min(buf.remaining()); - let end: usize = *this.get_start() + amount; - buf.put_slice(&this.get_buffer()[*this.get_start()..end]); + let end: usize = this.get_start() + amount; + buf.put_slice(&this.get_buffer()[this.get_start()..end]); this.set_start(end); return Poll::Ready(Ok(())); } @@ -229,12 +253,88 @@ impl AsyncBufRead for PooledReader<'_> { /// - `usize` - The number of bytes to consume. fn consume(self: Pin<&mut Self>, amount: usize) { let this: &mut Self = self.get_mut(); - let new_start: usize = (*this.get_start() + amount).min(*this.get_end()); + let new_start: usize = (this.get_start() + amount).min(this.get_end()); this.set_start(new_start); } } impl Stream { + /// Boxes a transport into a `Stream` for the request pipeline. + /// + /// This is the single construction path: a plain TCP socket, a TLS session + /// and a per-request duplex stream all enter here, so nothing above the + /// transport line has to know which one it is serving. The transport is + /// type erased immediately, which keeps `Stream` a fixed size regardless of + /// the concrete protocol adapter behind it. + /// + /// # Arguments + /// + /// - `T` - The transport type to serve the request cycle over. + /// - `RequestConfig` - The parsing limits and read timeout for the cycle. + /// - `bool` - Whether the connection starts already closed. + /// + /// # Returns + /// + /// - `Self` - A stream serving bytes through the boxed transport. + pub fn from_transport(transport: T, request_config: RequestConfig, closed: bool) -> Self + where + T: Transport + 'static, + { + Self { + stream: Box::new(transport), + request_config, + closed, + leftover: Vec::new(), + leftover_start: 0, + leftover_end: 0, + } + } + + /// Boxes a TCP socket into a `Stream` for the request pipeline. + /// + /// Kept as the socket-facing constructor so the accept loop keeps its + /// existing call shape; it delegates to `from_transport`, so there is one + /// construction path rather than two. + /// + /// # Arguments + /// + /// - `TcpStream` - The accepted socket to serve the request cycle over. + /// - `RequestConfig` - The parsing limits and read timeout for the cycle. + /// - `bool` - Whether the connection starts already closed. + /// + /// # Returns + /// + /// - `Self` - A stream serving bytes through the boxed socket. + pub fn new(stream: TcpStream, request_config: RequestConfig, closed: bool) -> Self { + Self::from_transport(stream, request_config, closed) + } + + /// Reports the address of the remote end of the transport. + /// + /// Delegates to the transport itself, so a transport without a socket + /// identity answers with an error rather than a fabricated address. + /// + /// # Returns + /// + /// - `Result` - The peer address reported by the transport. + #[inline(always)] + pub fn get_peer_addr(&self) -> Result { + self.get_stream().peer_addr() + } + + /// Reports the address of the local end of the transport. + /// + /// Delegates to the transport itself, so a transport without a socket + /// identity answers with an error rather than a fabricated address. + /// + /// # Returns + /// + /// - `Result` - The local address reported by the transport. + #[inline(always)] + pub fn get_local_addr(&self) -> Result { + self.get_stream().local_addr() + } + /// Checks if the connection should be kept alive. /// /// This method evaluates whether the connection should remain open based on @@ -311,8 +411,8 @@ impl Stream { Request::fill_http_body(&mut reader, request.get_mut_body(), content_size).await?; // Preserve bytes read ahead for the next pipelined request: they have // already left the socket and cannot be read again. - let reader_start: usize = *reader.get_start(); - let reader_end: usize = *reader.get_end(); + let reader_start: usize = reader.get_start(); + let reader_end: usize = reader.get_end(); let pending: Vec = if reader_start < reader_end { reader.get_buffer()[reader_start..reader_end].to_vec() } else { @@ -450,7 +550,7 @@ impl Stream { duration_opt: Option, is_client_response: &mut bool, ) -> Result, RequestError> { - let stream: &mut TcpStream = self.get_mut_stream(); + let stream: &mut Box = self.get_mut_stream(); if let Some(duration) = duration_opt { return match timeout(duration, stream.read(buffer)).await { Ok(result) => match result { @@ -526,7 +626,7 @@ impl Stream { if self.get_closed() { return Err(ResponseError::ConnectionClosed); } - let stream: &mut TcpStream = self.get_mut_stream(); + let stream: &mut Box = self.get_mut_stream(); for data in data_iter { stream.write_all(data.as_ref()).await?; } diff --git a/type/src/stream/mod.rs b/type/src/stream/mod.rs index c312841a..10e87130 100644 --- a/type/src/stream/mod.rs +++ b/type/src/stream/mod.rs @@ -2,9 +2,10 @@ mod r#fn; mod r#impl; mod r#static; mod r#struct; +mod r#trait; mod r#type; -pub use {r#struct::*, r#type::*}; +pub use {r#struct::*, r#trait::*, r#type::*}; pub(crate) use {r#fn::*, r#static::*}; diff --git a/type/src/stream/struct.rs b/type/src/stream/struct.rs index 30da1f1e..b10824f7 100644 --- a/type/src/stream/struct.rs +++ b/type/src/stream/struct.rs @@ -1,10 +1,14 @@ use super::*; -#[derive(CustomDebug, Data, DisplayDebug, New)] +#[derive(CustomDebug, Data, DisplayDebug)] pub struct Stream { - #[get_mut(pub(super))] - #[set(pub(super))] - pub(super) stream: TcpStream, + /// The bidirectional byte pipe the request/response cycle runs over. + /// + /// The concrete type is erased behind `Transport`, so a plain TCP socket, a + /// TLS session and a per-request duplex stream are interchangeable here. + /// Address metadata comes from the transport itself, so a transport without + /// a socket identity reports an error instead of a fabricated address. + pub stream: Box, #[get_mut(pub(super))] #[set(pub(super))] pub(super) request_config: RequestConfig, @@ -12,54 +16,51 @@ pub struct Stream { #[get_mut(pub(super))] pub(super) closed: bool, /// Bytes read ahead of the current request, already consumed from the - /// socket but not yet parsed into a request. + /// transport but not yet parsed into a request. /// - /// A single socket read can deliver bytes belonging to the next pipelined + /// A single read can deliver bytes belonging to the next pipelined /// request. Those bytes are carried here between requests so /// HTTP pipelining is not silently dropped. #[get_mut(pub(super))] #[set(pub(super))] - #[new(skip)] pub(super) leftover: Vec, /// Offset of the first unconsumed byte in `leftover`. #[get(type(copy))] #[get_mut(pub(super))] #[set(pub(super))] - #[new(skip)] pub(super) leftover_start: usize, /// Offset one past the last valid byte in `leftover`. #[get(type(copy))] #[get_mut(pub(super))] #[set(pub(super))] - #[new(skip)] pub(super) leftover_end: usize, } -/// A buffered reader over a `TcpStream` with a reusable read buffer. +/// A buffered reader over a transport with a reusable read buffer. /// -/// This reader batches socket reads into an internal buffer to reduce -/// syscall count, and returns its buffer to a thread-local pool on drop -/// so keep-alive requests avoid repeated buffer allocation. +/// This reader batches reads into an internal buffer to reduce syscall count, +/// and returns its buffer to a thread-local pool on drop so keep-alive +/// requests avoid repeated buffer allocation. #[derive(Data, New)] pub(crate) struct PooledReader<'a> { - /// The underlying TCP stream being read. + /// The underlying transport being read. #[get(pub(super))] #[get_mut(pub(super))] #[set(pub(super))] - pub(super) stream: &'a mut TcpStream, + pub(super) stream: &'a mut Box, /// The reusable read buffer holding unconsumed bytes in `start..end`. #[get(pub(super))] #[get_mut(pub(super))] #[set(pub(super))] pub(super) buffer: Vec, /// The index of the first unconsumed byte in the buffer. - #[get(pub(super))] + #[get(pub(super), type(copy))] #[get_mut(pub(super))] #[set(pub(super))] #[new(skip)] pub(super) start: usize, /// The index one past the last valid byte in the buffer. - #[get(pub(super))] + #[get(pub(super), type(copy))] #[get_mut(pub(super))] #[set(pub(super))] #[new(skip)] diff --git a/type/src/stream/trait.rs b/type/src/stream/trait.rs new file mode 100644 index 00000000..5459e8b3 --- /dev/null +++ b/type/src/stream/trait.rs @@ -0,0 +1,29 @@ +use super::*; + +/// A bidirectional byte pipe that a request/response cycle can run over. +/// +/// The transport is the only part of the server that knows what is carrying +/// the bytes. Everything above this trait is protocol agnostic: a plain TCP +/// socket, a TLS session and a per-request duplex stream are the same thing to +/// the parsing pipeline once they are type erased behind `Transport`. +/// +/// Implementations report their own socket metadata, so a transport without a +/// socket identity (a duplex pair, an in-memory pipe) answers with an error +/// instead of pretending to have an address. +pub trait Transport: AsyncRead + AsyncWrite + Debug + Unpin + Send + Sync { + /// Reports the address of the remote end of the transport. + /// + /// # Returns + /// + /// - `Result` - The remote address, or an error when the + /// transport has no socket identity. + fn peer_addr(&self) -> Result; + + /// Reports the address of the local end of the transport. + /// + /// # Returns + /// + /// - `Result` - The local address, or an error when the + /// transport has no socket identity. + fn local_addr(&self) -> Result; +} diff --git a/type/src/websocket_frame/impl.rs b/type/src/websocket_frame/impl.rs index 91068813..c46db233 100644 --- a/type/src/websocket_frame/impl.rs +++ b/type/src/websocket_frame/impl.rs @@ -567,7 +567,7 @@ impl WebSocketFrame { ) -> Result, RequestError> { let payload_data: &[u8] = self.get_payload_data(); full_frame.extend_from_slice(payload_data); - if *self.get_fin() { + if self.get_fin() { return Ok(Some(full_frame.clone())); } Ok(None) diff --git a/type/src/websocket_frame/struct.rs b/type/src/websocket_frame/struct.rs index c3e3894f..8ed3531d 100644 --- a/type/src/websocket_frame/struct.rs +++ b/type/src/websocket_frame/struct.rs @@ -4,10 +4,12 @@ use super::*; #[derive(Clone, Debug, DisplayDebug, Eq, Getter, PartialEq)] pub struct WebSocketFrame { /// FIN flag indicating if this is the final frame. + #[get(type(copy))] pub fin: bool, /// Opcode indicating the frame type (text, binary, etc.). pub opcode: WebSocketOpcode, /// Mask flag indicating if the payload is masked. + #[get(type(copy))] pub mask: bool, /// The payload data of the frame. pub payload_data: Vec, diff --git a/type/tests/mod.rs b/type/tests/mod.rs index 9cff6b24..889292c7 100644 --- a/type/tests/mod.rs +++ b/type/tests/mod.rs @@ -30,20 +30,27 @@ use http_type::*; use std::{ collections::VecDeque, + fmt::{self, Debug, Formatter}, io::{self, ErrorKind}, mem, + net::SocketAddr, num::ParseIntError, + pin::Pin, ptr, rc::Rc, + result::Result, sync::{ Arc, atomic::{self, AtomicBool, AtomicUsize}, }, + task::{Context, Poll}, }; use { serde::Deserialize, tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf, duplex}, + net::{TcpListener, TcpStream}, runtime::Handle, spawn, sync::{RwLockReadGuard, RwLockWriteGuard}, diff --git a/type/tests/stream/fn.rs b/type/tests/stream/fn.rs index 401b77e1..54505dea 100644 --- a/type/tests/stream/fn.rs +++ b/type/tests/stream/fn.rs @@ -13,3 +13,126 @@ fn test_socket_port_type_alias() { let socket_port: SocketPort = 8080; assert_eq!(socket_port, 8080_u16); } + +#[tokio::test] +async fn test_from_transport_wraps_duplex_as_transport() { + let (mut client, server): (DuplexStream, DuplexStream) = duplex(64); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::from_transport(TestTransport::new(server), config, false); + assert!(!stream.get_closed()); + assert!(stream.is_keep_alive(true)); + assert!(!stream.is_keep_alive(false)); + let sent: Vec = b"ping".to_vec(); + assert!(stream.try_send(&sent).await.is_ok()); + assert!(stream.try_flush().await.is_ok()); + let mut received: Vec = vec![0_u8; 4]; + client.read_exact(&mut received).await.unwrap(); + assert_eq!(received, b"ping".to_vec()); +} + +#[tokio::test] +async fn test_from_transport_reads_http_request_over_duplex() { + let (mut client, server): (DuplexStream, DuplexStream) = duplex(1024); + let raw: Vec = b"GET /trans HTTP/1.1\r\nHost: localhost\r\n\r\n".to_vec(); + client.write_all(&raw).await.unwrap(); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::from_transport(TestTransport::new(server), config, false); + let request: Request = stream.try_get_http_request().await.unwrap(); + assert_eq!(*request.get_method(), RequestMethod::Get); + assert_eq!(*request.get_path(), "/trans"); + assert_eq!(*request.get_version(), RequestVersion::Http1_1); + assert_eq!(*request.get_host(), "localhost"); + assert!(request.get_body().is_empty()); +} + +#[tokio::test] +async fn test_from_transport_send_list_over_duplex() { + let (mut client, server): (DuplexStream, DuplexStream) = duplex(64); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::from_transport(TestTransport::new(server), config, false); + let parts: Vec<&[u8]> = vec![b"HTTP/1.1 200 OK\r\n", b"Content-Length: 0\r\n\r\n"]; + assert!(stream.try_send_list(parts).await.is_ok()); + assert!(stream.try_flush().await.is_ok()); + let mut received: Vec = vec![0_u8; 38]; + client.read_exact(&mut received).await.unwrap(); + assert_eq!(&received[..4], b"HTTP"); + assert_eq!(&received[36..], b"\r\n"); +} + +#[tokio::test] +async fn test_from_transport_closed_rejects_writes() { + let (_client, server): (DuplexStream, DuplexStream) = duplex(64); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::from_transport(TestTransport::new(server), config, true); + assert!(stream.get_closed()); + assert!(!stream.is_keep_alive(true)); + assert!(stream.try_send(b"nope").await.is_err()); + assert!(stream.try_send_list(vec![b"nope"]).await.is_err()); + assert!(stream.try_flush().await.is_err()); + assert!(stream.try_get_http_request().await.is_err()); +} + +#[tokio::test] +async fn test_from_transport_transport_without_socket_address_reports_error() { + let (_client, server): (DuplexStream, DuplexStream) = duplex(64); + let config: RequestConfig = RequestConfig::default(); + let stream: Stream = Stream::from_transport(TestTransport::new(server), config, false); + assert!(stream.get_peer_addr().is_err()); + assert!(stream.get_local_addr().is_err()); + assert!(stream.get_stream().peer_addr().is_err()); + assert!(stream.get_stream().local_addr().is_err()); +} + +#[tokio::test] +async fn test_new_tcp_stream_reports_peer_and_local_address() { + let listener: TcpListener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bound: SocketAddr = listener.local_addr().unwrap(); + let client: TcpStream = TcpStream::connect(bound).await.unwrap(); + let (server, peer): (TcpStream, SocketAddr) = listener.accept().await.unwrap(); + assert_ne!(peer, bound); + let config: RequestConfig = RequestConfig::default(); + let stream: Stream = Stream::new(server, config, false); + assert_eq!(stream.get_peer_addr().unwrap(), peer); + assert_eq!(stream.get_local_addr().unwrap(), bound); + assert_eq!(stream.get_stream().peer_addr().unwrap(), peer); + drop(client); +} + +#[tokio::test] +async fn test_new_tcp_stream_parses_request_over_socket() { + let listener: TcpListener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bound: SocketAddr = listener.local_addr().unwrap(); + let mut client: TcpStream = TcpStream::connect(bound).await.unwrap(); + let (server, peer): (TcpStream, SocketAddr) = listener.accept().await.unwrap(); + let raw: Vec = + b"POST /tcp HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\nbody".to_vec(); + client.write_all(&raw).await.unwrap(); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::new(server, config, false); + let request: Request = stream.try_get_http_request().await.unwrap(); + assert_eq!(*request.get_method(), RequestMethod::Post); + assert_eq!(*request.get_path(), "/tcp"); + assert_eq!(request.get_body(), b"body"); + assert_eq!(stream.get_peer_addr().unwrap(), peer); + assert_eq!(stream.get_local_addr().unwrap(), bound); + let response: Vec = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(); + assert!(stream.try_send(&response).await.is_ok()); + assert!(stream.try_flush().await.is_ok()); + let mut echoed: Vec = vec![0_u8; 38]; + client.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed[..4], b"HTTP"); +} + +#[tokio::test] +async fn test_from_transport_keeps_pipelined_request_in_leftover() { + let (mut client, server): (DuplexStream, DuplexStream) = duplex(2048); + let first: Vec = b"GET /one HTTP/1.1\r\nHost: localhost\r\n\r\n".to_vec(); + let second: Vec = b"GET /two HTTP/1.1\r\nHost: localhost\r\n\r\n".to_vec(); + client.write_all(&[first, second].concat()).await.unwrap(); + let config: RequestConfig = RequestConfig::default(); + let mut stream: Stream = Stream::from_transport(TestTransport::new(server), config, false); + let one: Request = stream.try_get_http_request().await.unwrap(); + assert_eq!(*one.get_path(), "/one"); + let two: Request = stream.try_get_http_request().await.unwrap(); + assert_eq!(*two.get_path(), "/two"); +} diff --git a/type/tests/stream/mod.rs b/type/tests/stream/mod.rs index e41822a8..39914eb5 100644 --- a/type/tests/stream/mod.rs +++ b/type/tests/stream/mod.rs @@ -1,3 +1,6 @@ mod r#fn; +mod r#struct; + +use r#struct::*; use super::*; diff --git a/type/tests/stream/struct.rs b/type/tests/stream/struct.rs new file mode 100644 index 00000000..481a106d --- /dev/null +++ b/type/tests/stream/struct.rs @@ -0,0 +1,55 @@ +use super::*; + +pub(crate) struct TestTransport { + stream: DuplexStream, +} + +impl TestTransport { + pub(crate) fn new(stream: DuplexStream) -> Self { + Self { stream } + } +} + +impl Transport for TestTransport { + fn peer_addr(&self) -> Result { + Err(ErrorKind::Unsupported.into()) + } + + fn local_addr(&self) -> Result { + Err(ErrorKind::Unsupported.into()) + } +} + +impl AsyncRead for TestTransport { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for TestTransport { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } +} + +impl Debug for TestTransport { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("TestTransport") + } +} From dc8e7076587727e25bd83a4c99d8fee7e4c345dc Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 2/7] fix(hyperlane): satisfy protocol source standards Replace protocol type annotations with crate-private aliases and remove function-body blank lines without changing execution behavior. Restore the root and core READMEs byte-for-byte to the fixed PR baseline. The complete 21.13.6 worktree passed check, strict Clippy, 822 tests and three parallel runs of all 87 protocol tests. The matching isolated committed tree passed all 54 standards checks through real hooks. Formal performance acceptance is still pending; keep PR 47 as a draft. --- README.md | 111 ---------------------------- core/README.md | 111 ---------------------------- core/src/listener/fn.rs | 8 +- core/src/protocol/aliases/type.rs | 21 ++++++ core/src/protocol/http2/fn.rs | 20 ++--- core/src/protocol/http2/impl.rs | 17 ++--- core/src/protocol/http3/impl.rs | 4 +- core/src/protocol/request/fn.rs | 8 +- core/src/tls/fn.rs | 37 +++++----- core/src/tls/mod.rs | 3 +- core/src/tls/type.rs | 8 ++ core/tests/protocol/http2/assert.rs | 5 -- core/tests/protocol/http3/assert.rs | 5 -- core/tests/protocol/tls/fn.rs | 1 - 14 files changed, 76 insertions(+), 283 deletions(-) create mode 100644 core/src/tls/type.rs diff --git a/README.md b/README.md index e038e306..037ce235 100644 --- a/README.md +++ b/README.md @@ -32,117 +32,6 @@ cargo add hyperlane git clone https://github.com/hyperlane-dev/hyperlane-quick-start.git ``` -## Runtime protocol and TLS - -The listening protocol and the TLS material are two independent settings. A server -speaks exactly one wire protocol, selected at runtime from the configuration; TLS is -terminated on top of it, or left off for plaintext. There is no automatic protocol -detection, no multi-protocol listener and no fallback between them: an `http1` -listener never answers HTTP/2, and an `http2` listener never answers HTTP/1.1. - -```rust -use hyperlane::{ - Server, ServerConfig, ServerControlHook, ServerError, ServerProtocol, TlsConfig, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let mut config = ServerConfig::new("127.0.0.1:8443".to_string(), None, None); - config.set_protocol(ServerProtocol::Http2).set_tls(Some(TlsConfig::new( - "/etc/hyperlane/cert-chain.pem".to_string(), - "/etc/hyperlane/privkey.pem".to_string(), - ))); - - let mut server = Server::default(); - server.server_config(config); - let control: ServerControlHook = server.run().await?; - control.wait().await; - Ok(()) -} -``` - -`ServerConfig::new` keeps its three argument signature and always yields plaintext -HTTP/1; choose a different protocol with `set_protocol`. Omitting both `protocol` and -`tls` from a JSON configuration therefore keeps the historical behaviour. The example -above only shows configuration and startup: register routes on `server` before -`run()` if the server is meant to answer anything. - -```rust -use hyperlane::{ServerConfig, ServerProtocol, TlsConfig}; - -fn main() { - // A config with neither field keeps the historical behaviour. - let config: ServerConfig = ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\"}") - .expect("a config without protocol or tls must still load"); - assert_eq!(config.get_protocol(), ServerProtocol::Http1); - assert!(config.get_tls().is_none(), "an omitted tls field is plaintext"); - assert_eq!(config.get_alpn_protocol(), b"http/1.1" as &[u8]); - - // The three argument constructor cannot select a protocol or tls. - let legacy: ServerConfig = ServerConfig::new("127.0.0.1:8080".to_string(), None, None); - assert_eq!(legacy.get_protocol(), ServerProtocol::Http1); - assert!(legacy.get_tls().is_none()); - - // The two settings are independent, in both directions. - let mut selected: ServerConfig = ServerConfig::default(); - selected.set_protocol(ServerProtocol::Http2); - assert!(selected.get_tls().is_none(), "choosing a protocol does not enable tls"); - selected.set_tls(Some(TlsConfig::new("chain.pem".to_string(), "key.pem".to_string()))); - assert_eq!(selected.get_protocol(), ServerProtocol::Http2); - assert_eq!(selected.get_alpn_protocol(), b"h2" as &[u8]); - selected.set_tls(None); - assert_eq!(selected.get_protocol(), ServerProtocol::Http2, "clearing tls keeps the protocol"); - assert!(selected.get_tls().is_none()); - - // An unknown protocol name is an error, not a silent fallback to http1. - assert!(ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\",\"protocol\":\"http4\"}").is_err()); -} -``` - -### Protocol and TLS matrix - -| `ServerProtocol` | JSON name | Transport | Without `tls` | With `tls` | -| --- | --- | --- | --- | --- | -| `Http1` (default) | `http1` | TCP | HTTP/1.1 over a plain socket, no ALPN | HTTP/1.1 over TLS, ALPN `http/1.1`, TLS 1.2 or 1.3 | -| `Http2` | `http2` | TCP | HTTP/2 with prior knowledge, no ALPN and no TLS | HTTP/2 over TLS, ALPN `h2`, TLS 1.2 or 1.3 | -| `Http3` | `http3` | UDP (QUIC) | refused: `run()` fails with `MissingTlsForHttp3` and the UDP port stays unbound | HTTP/3 over QUIC, ALPN `h3`, TLS 1.3 only | - -Notes that the table does not make obvious: - -- `ServerProtocol` is a unit-only enum chosen at runtime, not by a compile-time - feature: the crates declare no `[features]` table, so one binary carries all three - protocols and the configuration decides which one a given listener speaks. -- HTTP/1 and HTTP/2 listen on TCP; HTTP/3 binds a UDP socket. A firewall rule for HTTP/3 - is a UDP rule, and a port opened for TCP does not serve HTTP/3. -- HTTP/3 carries TLS inside the QUIC handshake, so `tls` is mandatory for `Http3`. - QUIC admits TLS 1.3 only, so `http3` over TLS never negotiates 1.2; `http1` and - `http2` over TLS accept either. -- The PEM files are read when the listener is created, not when the configuration is - built, so a configuration can be deserialised, cloned and compared without touching the - disk. An unreadable, malformed, truncated, or mismatched certificate chain and key is - reported by `run()` before the socket is bound. -- An unknown `protocol` value in JSON is an error. It does not resolve to `http1`. - -### Negotiating with a server - -Exactly one ALPN identifier is advertised, the one belonging to the selected protocol. -A client that offers no ALPN the server accepts is refused rather than downgraded. An -HTTP/2 client needs prior knowledge to talk to a plaintext listener, and an HTTP/3 -client must speak QUIC, since an HTTP/3 listener never accepts a TCP connection. - -`http1` over TLS still serves a client that negotiates no ALPN at all, because clients -predating ALPN send none and must keep working. `http2` and `http3` require the -negotiated identifier to be their own, and each is refused differently. An `http2` -session that completes the handshake without `h2` is dropped by the listener, because -answering an HTTP/2 request under HTTP/1.1 rules would be worse than refusing the -connection. An `http3` client that does not offer `h3` fails the QUIC handshake itself -(RFC 9001 requires the `no_application_protocol` error), before any request is read. - -WebSocket support is unchanged and remains HTTP/1 only: a route upgrades over HTTP/1. -The HTTP/2 extended CONNECT and HTTP/3 CONNECT handshake required by RFC 8441 and -RFC 9220 are not implemented, so do not point a WebSocket client at an `http2` or `http3` -listener. - ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/core/README.md b/core/README.md index 503414ad..037ce235 100644 --- a/core/README.md +++ b/core/README.md @@ -32,117 +32,6 @@ cargo add hyperlane git clone https://github.com/hyperlane-dev/hyperlane-quick-start.git ``` -## Runtime protocol and TLS - -The listening protocol and the TLS material are two independent settings. A server -speaks exactly one wire protocol, selected at runtime from the configuration; TLS is -terminated on top of it, or left off for plaintext. There is no automatic protocol -detection, no multi-protocol listener and no fallback between them: an `http1` -listener never answers HTTP/2, and an `http2` listener never answers HTTP/1.1. - -```rust -use hyperlane_core::{ - Server, ServerConfig, ServerControlHook, ServerError, ServerProtocol, TlsConfig, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let mut config = ServerConfig::new("127.0.0.1:8443".to_string(), None, None); - config.set_protocol(ServerProtocol::Http2).set_tls(Some(TlsConfig::new( - "/etc/hyperlane/cert-chain.pem".to_string(), - "/etc/hyperlane/privkey.pem".to_string(), - ))); - - let mut server = Server::default(); - server.server_config(config); - let control: ServerControlHook = server.run().await?; - control.wait().await; - Ok(()) -} -``` - -`ServerConfig::new` keeps its three argument signature and always yields plaintext -HTTP/1; choose a different protocol with `set_protocol`. Omitting both `protocol` and -`tls` from a JSON configuration therefore keeps the historical behaviour. The example -above only shows configuration and startup: register routes on `server` before -`run()` if the server is meant to answer anything. - -```rust -use hyperlane_core::{ServerConfig, ServerProtocol, TlsConfig}; - -fn main() { - // A config with neither field keeps the historical behaviour. - let config: ServerConfig = ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\"}") - .expect("a config without protocol or tls must still load"); - assert_eq!(config.get_protocol(), ServerProtocol::Http1); - assert!(config.get_tls().is_none(), "an omitted tls field is plaintext"); - assert_eq!(config.get_alpn_protocol(), b"http/1.1" as &[u8]); - - // The three argument constructor cannot select a protocol or tls. - let legacy: ServerConfig = ServerConfig::new("127.0.0.1:8080".to_string(), None, None); - assert_eq!(legacy.get_protocol(), ServerProtocol::Http1); - assert!(legacy.get_tls().is_none()); - - // The two settings are independent, in both directions. - let mut selected: ServerConfig = ServerConfig::default(); - selected.set_protocol(ServerProtocol::Http2); - assert!(selected.get_tls().is_none(), "choosing a protocol does not enable tls"); - selected.set_tls(Some(TlsConfig::new("chain.pem".to_string(), "key.pem".to_string()))); - assert_eq!(selected.get_protocol(), ServerProtocol::Http2); - assert_eq!(selected.get_alpn_protocol(), b"h2" as &[u8]); - selected.set_tls(None); - assert_eq!(selected.get_protocol(), ServerProtocol::Http2, "clearing tls keeps the protocol"); - assert!(selected.get_tls().is_none()); - - // An unknown protocol name is an error, not a silent fallback to http1. - assert!(ServerConfig::from_json("{\"address\":\"127.0.0.1:8080\",\"protocol\":\"http4\"}").is_err()); -} -``` - -### Protocol and TLS matrix - -| `ServerProtocol` | JSON name | Transport | Without `tls` | With `tls` | -| --- | --- | --- | --- | --- | -| `Http1` (default) | `http1` | TCP | HTTP/1.1 over a plain socket, no ALPN | HTTP/1.1 over TLS, ALPN `http/1.1`, TLS 1.2 or 1.3 | -| `Http2` | `http2` | TCP | HTTP/2 with prior knowledge, no ALPN and no TLS | HTTP/2 over TLS, ALPN `h2`, TLS 1.2 or 1.3 | -| `Http3` | `http3` | UDP (QUIC) | refused: `run()` fails with `MissingTlsForHttp3` and the UDP port stays unbound | HTTP/3 over QUIC, ALPN `h3`, TLS 1.3 only | - -Notes that the table does not make obvious: - -- `ServerProtocol` is a unit-only enum chosen at runtime, not by a compile-time - feature: the crate declares no `[features]` table, so one binary carries all three - protocols and the configuration decides which one a given listener speaks. -- HTTP/1 and HTTP/2 listen on TCP; HTTP/3 binds a UDP socket. A firewall rule for HTTP/3 - is a UDP rule, and a port opened for TCP does not serve HTTP/3. -- HTTP/3 carries TLS inside the QUIC handshake, so `tls` is mandatory for `Http3`. - QUIC admits TLS 1.3 only, so `http3` over TLS never negotiates 1.2; `http1` and - `http2` over TLS accept either. -- The PEM files are read when the listener is created, not when the configuration is - built, so a configuration can be deserialised, cloned and compared without touching the - disk. An unreadable, malformed, truncated, or mismatched certificate chain and key is - reported by `run()` before the socket is bound. -- An unknown `protocol` value in JSON is an error. It does not resolve to `http1`. - -### Negotiating with a server - -Exactly one ALPN identifier is advertised, the one belonging to the selected protocol. -A client that offers no ALPN the server accepts is refused rather than downgraded. An -HTTP/2 client needs prior knowledge to talk to a plaintext listener, and an HTTP/3 -client must speak QUIC, since an HTTP/3 listener never accepts a TCP connection. - -`http1` over TLS still serves a client that negotiates no ALPN at all, because clients -predating ALPN send none and must keep working. `http2` and `http3` require the -negotiated identifier to be their own, and each is refused differently. An `http2` -session that completes the handshake without `h2` is dropped by the listener, because -answering an HTTP/2 request under HTTP/1.1 rules would be worse than refusing the -connection. An `http3` client that does not offer `h3` fails the QUIC handshake itself -(RFC 9001 requires the `no_application_protocol` error), before any request is read. - -WebSocket support is unchanged and remains HTTP/1 only: a route upgrades over HTTP/1. -The HTTP/2 extended CONNECT and HTTP/3 CONNECT handshake required by RFC 8441 and -RFC 9220 are not implemented, so do not point a WebSocket client at an `http2` or `http3` -listener. - ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/core/src/listener/fn.rs b/core/src/listener/fn.rs index c9dd3dd7..3d60668f 100644 --- a/core/src/listener/fn.rs +++ b/core/src/listener/fn.rs @@ -240,8 +240,8 @@ pub(crate) async fn serve_plain_tcp( bounded_handshake(request_config, h2::server::handshake(stream)) .await .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) - .and_then(|connection: Result<_, h2::Error>| { - connection.map_err(|error: h2::Error| error.to_string()) + .and_then(|connection: Result<_, H2Error>| { + connection.map_err(|error: H2Error| error.to_string()) }), ) .await; @@ -289,8 +289,8 @@ pub(crate) async fn serve_tls_stream( bounded_handshake(request_config, h2::server::handshake(session)) .await .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) - .and_then(|connection: Result<_, h2::Error>| { - connection.map_err(|error: h2::Error| error.to_string()) + .and_then(|connection: Result<_, H2Error>| { + connection.map_err(|error: H2Error| error.to_string()) }), ) .await; diff --git a/core/src/protocol/aliases/type.rs b/core/src/protocol/aliases/type.rs index 7434ab20..26217824 100644 --- a/core/src/protocol/aliases/type.rs +++ b/core/src/protocol/aliases/type.rs @@ -12,6 +12,27 @@ pub(crate) type RequestH3Stream = h3::server::RequestStream = h2::server::Connection; +/// The failure one HTTP/2 stream operation reports. +pub(crate) type H2Error = h2::Error; + +/// The receive half of one HTTP/2 request stream. +pub(crate) type H2RecvStream = h2::RecvStream; + +/// The stream half carrying the body of one HTTP/2 response. +pub(crate) type H2SendStream = h2::SendStream; + +/// A request head in its protocol representation. +pub(crate) type ProtocolRequest = http::Request; + +/// A response head in its protocol representation. +pub(crate) type ProtocolResponse = http::Response; + +/// The failure one protocol head builder reports. +pub(crate) type ProtocolBuildError = http::Error; + +/// The request method in its protocol representation. +pub(crate) type ProtocolMethod = http::Method; + /// The bare line feed byte. pub(crate) const LF_U8: u8 = b'\n'; diff --git a/core/src/protocol/http2/fn.rs b/core/src/protocol/http2/fn.rs index e2ea37c1..1e11dc87 100644 --- a/core/src/protocol/http2/fn.rs +++ b/core/src/protocol/http2/fn.rs @@ -58,7 +58,7 @@ pub(crate) async fn serve_h2_connection( /// - `Arc` - The server whose pipeline serves the request. /// - `RequestConfig` - The parsing limits and read timeout for the connection. /// - `SocketAddr` - The local address of the carrying connection. -/// - `http::Request` - The decoded protocol request. +/// - `ProtocolRequest` - The decoded protocol request. /// - `h2::server::SendResponse` - The response half of the stream. /// pub(crate) async fn h2_request_task( @@ -66,10 +66,10 @@ pub(crate) async fn h2_request_task( request_config: RequestConfig, local: SocketAddr, remote: SocketAddr, - request: http::Request, + request: ProtocolRequest, send_response: h2::server::SendResponse, ) { - let (parts, mut recv_stream): (http::request::Parts, h2::RecvStream) = request.into_parts(); + let (parts, mut recv_stream): (http::request::Parts, H2RecvStream) = request.into_parts(); let uri: &http::Uri = &parts.uri; let mut framer: H2Framer = H2Framer::new(send_response); let mut body: Vec = Vec::new(); @@ -190,7 +190,7 @@ pub(crate) async fn h2_fail_request( /// /// # Arguments /// -/// - `&mut h2::RecvStream` - The receive half of the request stream. +/// - `&mut H2RecvStream` - The receive half of the request stream. /// - `&RequestConfig` - The parsing limits to enforce. /// - `&ProtocolHeaders` - The protocol request headers carrying the declared length. /// - `&mut Vec` - The buffer to collect the body into. @@ -199,7 +199,7 @@ pub(crate) async fn h2_fail_request( /// /// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the pub(crate) async fn collect_h2_body( - recv_stream: &mut h2::RecvStream, + recv_stream: &mut H2RecvStream, request_config: &RequestConfig, headers: &ProtocolHeaders, body: &mut Vec, @@ -234,7 +234,7 @@ pub(crate) async fn collect_h2_body( /// /// # Arguments /// -/// - `&mut h2::RecvStream` - The receive half of the request stream. +/// - `&mut H2RecvStream` - The receive half of the request stream. /// - `usize` - The maximum number of body bytes to accept. /// - `&mut Vec` - The buffer to collect the body into. /// @@ -242,20 +242,20 @@ pub(crate) async fn collect_h2_body( /// /// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the async fn collect_h2_body_until_end( - recv_stream: &mut h2::RecvStream, + recv_stream: &mut H2RecvStream, max_body_size: usize, body: &mut Vec, ) -> Result<(), RequestError> { loop { - let received: Option> = recv_stream.data().await; + let received: Option> = recv_stream.data().await; let Some(received) = received else { return Ok(()); }; let data: Bytes = - received.map_err(|error: h2::Error| RequestError::Request(error.to_string()))?; + received.map_err(|error: H2Error| RequestError::Request(error.to_string()))?; let data_len: usize = data.len(); let collected: Result<(), RequestError> = push_bounded_body(max_body_size, body, data); - let _: Result<(), h2::Error> = recv_stream.flow_control().release_capacity(data_len); + let _: Result<(), H2Error> = recv_stream.flow_control().release_capacity(data_len); collected?; } } diff --git a/core/src/protocol/http2/impl.rs b/core/src/protocol/http2/impl.rs index 6316dc7d..204dd50f 100644 --- a/core/src/protocol/http2/impl.rs +++ b/core/src/protocol/http2/impl.rs @@ -73,9 +73,9 @@ impl ResponseFramer for H2Framer { builder = builder.header(name.clone(), value); } } - let response: http::Response<()> = builder + let response: ProtocolResponse<()> = builder .body(()) - .map_err(|error: http::Error| ResponseError::Send(error.to_string()))?; + .map_err(|error: ProtocolBuildError| ResponseError::Send(error.to_string()))?; let mut send_response: h2::server::SendResponse = self .get_mut_send_response() .take() @@ -84,13 +84,12 @@ impl ResponseFramer for H2Framer { self.set_finished(true); send_response .send_response(response, true) - .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + .map_err(|error: H2Error| ResponseError::Send(error.to_string()))?; return Ok(()); } - let send_stream: h2::SendStream = - send_response - .send_response(response, false) - .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + let send_stream: H2SendStream = send_response + .send_response(response, false) + .map_err(|error: H2Error| ResponseError::Send(error.to_string()))?; self.set_send_stream(Some(send_stream)); Ok(()) }) @@ -125,7 +124,7 @@ impl ResponseFramer for H2Framer { rest = &rest[take..]; send_stream .send_data(chunk, false) - .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + .map_err(|error: H2Error| ResponseError::Send(error.to_string()))?; } self.set_send_stream(Some(send_stream)); Ok(()) @@ -154,7 +153,7 @@ impl ResponseFramer for H2Framer { }; send_stream .send_data(Bytes::new(), true) - .map_err(|error: h2::Error| ResponseError::Send(error.to_string()))?; + .map_err(|error: H2Error| ResponseError::Send(error.to_string()))?; Ok(()) }) } diff --git a/core/src/protocol/http3/impl.rs b/core/src/protocol/http3/impl.rs index c116192e..0ec6b518 100644 --- a/core/src/protocol/http3/impl.rs +++ b/core/src/protocol/http3/impl.rs @@ -69,9 +69,9 @@ impl ResponseFramer for H3Framer { builder = builder.header(name.clone(), value); } } - let response: http::Response<()> = builder + let response: ProtocolResponse<()> = builder .body(()) - .map_err(|error: http::Error| ResponseError::Send(error.to_string()))?; + .map_err(|error: ProtocolBuildError| ResponseError::Send(error.to_string()))?; let Some(mut request_stream) = self.get_mut_request_stream().take() else { return Err(ResponseError::Terminated); }; diff --git a/core/src/protocol/request/fn.rs b/core/src/protocol/request/fn.rs index 1cd8591d..6b2ea5e4 100644 --- a/core/src/protocol/request/fn.rs +++ b/core/src/protocol/request/fn.rs @@ -16,7 +16,7 @@ use super::*; /// /// - `&RequestConfig` - The parsing limits to enforce. /// - `HttpVersion` - The real protocol version to stamp on the request. -/// - `http::Method` - The request method decoded by the protocol library. +/// - `ProtocolMethod` - The request method decoded by the protocol library. /// - `&str` - The `value` argument. /// - `&http::Uri` - The request URI, which carries the authority. /// - `&ProtocolHeaders` - The protocol request headers. @@ -28,7 +28,7 @@ use super::*; pub fn build_protocol_request( config: &RequestConfig, version: HttpVersion, - method: http::Method, + method: ProtocolMethod, target: &str, target_uri: &http::Uri, headers: &ProtocolHeaders, @@ -169,12 +169,12 @@ pub fn fill_request_querys(query: &str, querys: &mut RequestQuerys) { /// /// # Arguments /// -/// - `http::Method` - The method decoded by the protocol library. +/// - `ProtocolMethod` - The method decoded by the protocol library. /// /// # Returns /// /// - `RequestMethod` - The framework method. -pub fn protocol_method(method: http::Method) -> RequestMethod { +pub fn protocol_method(method: ProtocolMethod) -> RequestMethod { method .as_str() .parse::() diff --git a/core/src/tls/fn.rs b/core/src/tls/fn.rs index e335a088..97ecf1cf 100644 --- a/core/src/tls/fn.rs +++ b/core/src/tls/fn.rs @@ -11,17 +11,17 @@ use super::*; /// /// - `&TlsConfig` - The certificate chain and private key paths to load. /// - `&[u8]` - The single ALPN protocol identifier to advertise. -/// - `&[&'static rustls::SupportedProtocolVersion]` - The TLS versions to offer. +/// - `&[&'static SupportedProtocolVersion]` - The TLS versions to offer. /// /// # Returns /// -/// - `Result, Box>` - The TLS configuration, or +/// - `Result, Box>` - The TLS configuration, or /// the first failure while reading the PEM material. pub fn build_server_tls_config( tls: &TlsConfig, alpn: &[u8], - versions: &[&'static rustls::SupportedProtocolVersion], -) -> Result, Box> { + versions: &[&'static SupportedProtocolVersion], +) -> Result, Box> { let certificate_chain: Vec> = CertificateDer::pem_file_iter(tls.get_certificate_path()) .map_err(|error: pem::Error| ServerError::Tls(tls_error_string(&error)))? @@ -30,14 +30,13 @@ pub fn build_server_tls_config( let private_key: PrivateKeyDer<'static> = PrivateKeyDer::from_pem_file(tls.get_private_key_path()) .map_err(|error: pem::Error| ServerError::Tls(tls_error_string(&error)))?; - let mut server_config: rustls::ServerConfig = rustls::ServerConfig::builder_with_provider( - Arc::new(rustls::crypto::ring::default_provider()), - ) - .with_protocol_versions(versions) - .map_err(|error: rustls::Error| Box::new(ServerError::Tls(error.to_string())))? - .with_no_client_auth() - .with_single_cert(certificate_chain, private_key) - .map_err(|error: rustls::Error| Box::new(ServerError::Tls(error.to_string())))?; + let mut server_config: TlsServerConfig = + TlsServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_protocol_versions(versions) + .map_err(|error: TlsError| Box::new(ServerError::Tls(error.to_string())))? + .with_no_client_auth() + .with_single_cert(certificate_chain, private_key) + .map_err(|error: TlsError| Box::new(ServerError::Tls(error.to_string())))?; server_config.alpn_protocols = vec![alpn.to_vec()]; Ok(Arc::new(server_config)) } @@ -51,13 +50,13 @@ pub fn build_server_tls_config( /// /// # Returns /// -/// - `Result, Box>` - The TLS configuration, or +/// - `Result, Box>` - The TLS configuration, or /// the first failure while reading the PEM material. pub fn build_stream_tls_config( tls: &TlsConfig, protocol: &ServerProtocol, -) -> Result, Box> { - let versions: &[&'static rustls::SupportedProtocolVersion] = +) -> Result, Box> { + let versions: &[&'static SupportedProtocolVersion] = &[&rustls::version::TLS13, &rustls::version::TLS12]; build_server_tls_config(tls, protocol.get_alpn_protocol(), versions) } @@ -75,12 +74,10 @@ pub fn build_stream_tls_config( /// /// # Returns /// -/// - `Result, Box>` - The TLS configuration, or +/// - `Result, Box>` - The TLS configuration, or /// the first failure while reading the PEM material. -pub fn build_quic_tls_config( - tls: &TlsConfig, -) -> Result, Box> { - let versions: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13]; +pub fn build_quic_tls_config(tls: &TlsConfig) -> Result, Box> { + let versions: &[&'static SupportedProtocolVersion] = &[&rustls::version::TLS13]; build_server_tls_config(tls, ServerProtocol::Http3.get_alpn_protocol(), versions) } diff --git a/core/src/tls/mod.rs b/core/src/tls/mod.rs index 910cac30..815f6d1c 100644 --- a/core/src/tls/mod.rs +++ b/core/src/tls/mod.rs @@ -1,5 +1,6 @@ mod r#fn; +mod r#type; -pub(crate) use r#fn::*; +pub(crate) use {r#fn::*, r#type::*}; use super::*; diff --git a/core/src/tls/type.rs b/core/src/tls/type.rs new file mode 100644 index 00000000..ffd1ca35 --- /dev/null +++ b/core/src/tls/type.rs @@ -0,0 +1,8 @@ +/// One TLS version the handshake may offer. +pub(crate) type SupportedProtocolVersion = rustls::SupportedProtocolVersion; + +/// The TLS configuration one listener hands to a rustls handshake. +pub(crate) type TlsServerConfig = rustls::ServerConfig; + +/// The failure one rustls configuration step reports. +pub(crate) type TlsError = rustls::Error; diff --git a/core/tests/protocol/http2/assert.rs b/core/tests/protocol/http2/assert.rs index 6bc39d2b..afc49ebe 100644 --- a/core/tests/protocol/http2/assert.rs +++ b/core/tests/protocol/http2/assert.rs @@ -285,7 +285,6 @@ async fn http2_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block }); let _lease: GateLease = gate_lease(GateSlot::SlowHttp2).await; let order: Arc>> = Arc::new(Mutex::new(Vec::new())); - let slow_target: String = format!("http://{TLS_SERVER_NAME}:{port}{SLOW_ROUTE_PATH}"); let slow_order: Arc>> = Arc::clone(&order); let mut slow_sender: h2::client::SendRequest = sender.clone(); @@ -295,9 +294,7 @@ async fn http2_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block slow_order.lock().expect("order lock").push("slow"); outcome }); - route_gate(GateSlot::SlowHttp2).await_slow_entered().await; - let fast_target: String = format!("http://{TLS_SERVER_NAME}:{port}{VERSION_ROUTE_PATH}"); let fast_order: Arc>> = Arc::clone(&order); let mut fast_sender: h2::client::SendRequest = sender.clone(); @@ -307,7 +304,6 @@ async fn http2_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block fast_order.lock().expect("order lock").push("fast"); outcome }); - let budget: Duration = Duration::from_millis(FAST_STREAM_BUDGET_MILLIS); let fast_result: Result = timeout(budget, fast) .await @@ -322,7 +318,6 @@ async fn http2_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block vec!["fast"], "only the fast stream may complete while the slow handler is blocked" ); - route_gate(GateSlot::SlowHttp2).release_slow(); let slow_result: Result = timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), slow) diff --git a/core/tests/protocol/http3/assert.rs b/core/tests/protocol/http3/assert.rs index 0623ed20..61e5599d 100644 --- a/core/tests/protocol/http3/assert.rs +++ b/core/tests/protocol/http3/assert.rs @@ -239,7 +239,6 @@ async fn http3_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block .expect("http3 quic server must start"); let _lease: GateLease = gate_lease(GateSlot::SlowHttp3).await; let order: Arc>> = Arc::new(Mutex::new(Vec::new())); - let slow_order: Arc>> = Arc::clone(&order); let slow: JoinHandle> = spawn(async move { let outcome: Result = h3_request( @@ -252,9 +251,7 @@ async fn http3_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block slow_order.lock().expect("order lock").push("slow"); outcome }); - route_gate(GateSlot::SlowHttp3).await_slow_entered().await; - let fast_order: Arc>> = Arc::clone(&order); let fast: JoinHandle> = spawn(async move { let outcome: Result = h3_request( @@ -267,7 +264,6 @@ async fn http3_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block fast_order.lock().expect("order lock").push("fast"); outcome }); - let budget: Duration = Duration::from_millis(FAST_STREAM_BUDGET_MILLIS); let fast_result: Result = timeout(budget, fast) .await @@ -282,7 +278,6 @@ async fn http3_one_connection_answers_a_fast_stream_while_a_slow_stream_is_block vec!["fast"], "only the fast stream may complete while the slow handler is blocked" ); - route_gate(GateSlot::SlowHttp3).release_slow(); let slow_result: Result = timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS), slow) diff --git a/core/tests/protocol/tls/fn.rs b/core/tests/protocol/tls/fn.rs index 48208987..815b950a 100644 --- a/core/tests/protocol/tls/fn.rs +++ b/core/tests/protocol/tls/fn.rs @@ -74,7 +74,6 @@ fn the_three_argument_constructor_keeps_speaking_http1_without_tls() { let legacy: String = SERVER_CONFIG_LEGACY_ADDRESS.to_string(); let config: ServerConfig = ServerConfig::new(legacy, None, None); assert_eq!(*config.get_address(), SERVER_CONFIG_LEGACY_ADDRESS); - assert_eq!(config.get_protocol(), ServerProtocol::Http1); assert!(config.get_tls().is_none()); } From fa75639bede0d6d7fb892c25eb20a63a08a577a1 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 3/7] chore(hyperlane): bump workspace version to 21.13.6 Update the shared package version and all nine local dependency versions. All nine workspace packages inherit the same version. README bytes remain unchanged from the fixed PR baseline. Formal performance acceptance is still pending; this does not authorize a merge or publication. --- Cargo.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 65fc9c07..2581bff9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "21.13.5" +version = "21.13.6" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -34,15 +34,15 @@ members = [ ] [workspace.dependencies] -hyperlane = { path = ".", version = "21.13.5" } -http-type = { path = "type", version = "21.13.5" } -hyperlane-cli = { path = "cli", version = "21.13.5" } -hyperlane-core = { path = "core", version = "21.13.5" } -http-request = { path = "request", version = "21.13.5" } -http-compress = { path = "compress", version = "21.13.5" } -http-constant = { path = "constant", version = "21.13.5" } -hyperlane-macros = { path = "macros", version = "21.13.5" } -hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.5" } +hyperlane = { path = ".", version = "21.13.6" } +http-type = { path = "type", version = "21.13.6" } +hyperlane-cli = { path = "cli", version = "21.13.6" } +hyperlane-core = { path = "core", version = "21.13.6" } +http-request = { path = "request", version = "21.13.6" } +http-compress = { path = "compress", version = "21.13.6" } +http-constant = { path = "constant", version = "21.13.6" } +hyperlane-macros = { path = "macros", version = "21.13.6" } +hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.6" } h3 = "0.0.8" h2 = "0.4.20" From 1f9362e0c8246a96c735211beefa0f30c5f25e82 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 4/7] refactor(hyperlane): group protocol arguments and update ctares dependencies Replace long internal signatures with responsibility-specific carriers. Update the 16 ctares workspace dependency declarations to 10.2.8. Preserve runtime protocol selection, independent TLS and streaming. The fixed R3 performance cohort is complete but did not pass its gate. Retain the invalid and inconclusive observations; keep PR #47 a draft. --- Cargo.toml | 32 +++--- core/src/listener/fn.rs | 129 ++++++++--------------- core/src/listener/mod.rs | 3 +- core/src/listener/struct.rs | 22 ++++ core/src/protocol/aliases/type.rs | 3 + core/src/protocol/http2/fn.rs | 85 +++++++-------- core/src/protocol/http2/impl.rs | 2 + core/src/protocol/http2/struct.rs | 20 ++++ core/src/protocol/http3/fn.rs | 90 ++++++++-------- core/src/protocol/http3/impl.rs | 2 + core/src/protocol/http3/struct.rs | 20 ++++ core/src/protocol/lifecycle/impl.rs | 1 + core/src/protocol/request/fn.rs | 30 +++--- core/src/protocol/request/mod.rs | 3 +- core/src/protocol/request/struct.rs | 26 +++++ core/src/protocol/transport/fn.rs | 12 ++- core/src/protocol/transport/impl.rs | 8 ++ core/src/protocol/transport/struct.rs | 37 +++++++ core/src/server/impl.rs | 32 +++--- core/tests/protocol/http3/assert.rs | 13 +-- core/tests/protocol/http3/fn.rs | 24 +++-- core/tests/protocol/http3/type.rs | 8 ++ request/src/request/http_request/impl.rs | 2 + type/src/stream/impl.rs | 2 + type/src/websocket_frame/impl.rs | 2 + 25 files changed, 365 insertions(+), 243 deletions(-) create mode 100644 core/src/listener/struct.rs create mode 100644 core/src/protocol/request/struct.rs diff --git a/Cargo.toml b/Cargo.toml index 2581bff9..6ae52d7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,11 +70,11 @@ dotenvy = "0.15.7" lettre = "0.11.22" scraper = "0.27.0" sysinfo = "0.39.6" -chunkify = "10.2.6" +chunkify = "10.2.8" h3-quinn = "0.0.10" num_cpus = "1.17.0" twox-hash = "2.1.4" -future-fn = "10.2.6" +future-fn = "10.2.8" inventory = "0.3.24" once_cell = "1.21.4" simd-json = "0.18.1" @@ -83,27 +83,27 @@ serde_with = "3.21.0" serde_yaml = "0.9.34" tracing-log = "0.2.0" urlencoding = "2.1.3" -hot-restart = "10.2.6" -jwt-service = "10.2.6" +hot-restart = "10.2.8" +jwt-service = "10.2.8" serde-xml-rs = "0.8.2" serde_json = "1.0.151" webpki-roots = "1.0.9" -clonelicious = "10.2.6" -color-output = "10.2.6" +clonelicious = "10.2.8" +color-output = "10.2.8" proc-macro2 = "1.0.107" -lombok-macros = "10.2.6" -file-operation = "10.2.6" -server-manager = "10.2.6" +lombok-macros = "10.2.8" +file-operation = "10.2.8" +server-manager = "10.2.8" bytemuck_derive = "1.11.0" -compare_version = "2.0.14" +compare_version = "10.2.8" serde_urlencoded = "0.7.1" -tokio-broadcast = "10.2.6" -instrument-level = "10.2.6" -bin-encode-decode = "1.1.51" -recoverable-spawn = "3.9.30" +tokio-broadcast = "10.2.8" +instrument-level = "10.2.8" +bin-encode-decode = "10.2.8" +recoverable-spawn = "10.2.8" hyperlane-broadcast = "2.0.13" -std-macro-extensions = "10.2.6" -recoverable-thread-pool = "2.4.28" +std-macro-extensions = "10.2.8" +recoverable-thread-pool = "10.2.8" syn = { version = "3.0.6", features = ["full"] } aes = { version = "0.9.1", features = ["hazmat"] } tokio = { version = "1.53.1", features = ["full"] } diff --git a/core/src/listener/fn.rs b/core/src/listener/fn.rs index 3d60668f..80afa9c3 100644 --- a/core/src/listener/fn.rs +++ b/core/src/listener/fn.rs @@ -16,26 +16,23 @@ use super::*; /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves each request. -/// - `RequestConfig` - The parsing limits and read timeouts for connections. -/// - `ServerProtocol` - The protocol to serve on accepted connections. +/// - `ConnectionEnv` - The server, limits and protocol for the connection. /// - `Option>` - The TLS configuration, when present. -/// - `TcpListener` - The bound listener to accept connections from. -/// - `Receiver<()>` - The shutdown signal the loop selects on. -/// - `Sender` - Publishes whether the loop is still serving. +/// - `AcceptSignal` - The bound listener and the loop's shutdown channels. /// /// # Returns /// /// - `()` - Resolves once the accept loop stops. pub(crate) async fn tcp_accept( - server: Arc, - request_config: RequestConfig, - protocol: ServerProtocol, + env: ConnectionEnv, stream_tls: Option>, - tcp_listener: TcpListener, - mut shutdown_receiver: Receiver<()>, - running_sender: Sender, + signal: AcceptSignal, ) { + let AcceptSignal { + listener: tcp_listener, + mut shutdown_receiver, + running_sender, + } = signal; loop { let accepted: Option<(TcpStream, SocketAddr)> = tokio::select! { biased; @@ -45,10 +42,10 @@ pub(crate) async fn tcp_accept( let Some((stream, remote)) = accepted else { break; }; - let server: Arc = server.clone(); + let env: ConnectionEnv = env.clone(); let tls: Option> = stream_tls.clone(); spawn(async move { - serve_tcp_connection(server, request_config, protocol, tls, stream, remote).await; + serve_tcp_connection(env, tls, stream, remote).await; }); } drop(tcp_listener); @@ -60,9 +57,7 @@ pub(crate) async fn tcp_accept( /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves the connection. -/// - `RequestConfig` - The parsing limits and read timeouts for the connection. -/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `ConnectionEnv` - The server, limits and protocol for the connection. /// - `Option>` - The TLS configuration, when present. /// - `TcpStream` - The accepted socket, before its socket options are applied. /// - `SocketAddr` - The remote address of the accepted socket. @@ -71,39 +66,33 @@ pub(crate) async fn tcp_accept( /// /// - `()` - Resolves once the connection is finished. pub(crate) async fn serve_tcp_connection( - server: Arc, - request_config: RequestConfig, - protocol: ServerProtocol, + env: ConnectionEnv, stream_tls: Option>, stream: TcpStream, remote: SocketAddr, ) { let local: Option = stream.local_addr().ok(); - configure_socket(&stream, &server); + configure_socket(&stream, &env.server); + let peers: StreamPeers = StreamPeers { + local: local.unwrap_or(remote), + remote, + }; let Some(tls) = stream_tls else { - serve_plain_tcp(server, request_config, protocol, stream, local, remote).await; + serve_plain_tcp(env, peers, stream).await; return; }; let Ok(handshake) = - bounded_handshake(request_config, TlsAcceptor::from(tls).accept(stream)).await + bounded_handshake(env.request_config, TlsAcceptor::from(tls).accept(stream)).await else { return; }; let Ok(handshake) = handshake else { return; }; - if !negotiated_protocol_matches(&handshake, &protocol) { + if !negotiated_protocol_matches(&handshake, &env.protocol) { return; } - serve_tls_stream( - server, - request_config, - protocol, - TlsTransport::new(handshake), - local, - remote, - ) - .await; + serve_tls_stream(env, peers, TlsTransport::new(handshake)).await; } /// Reports whether a negotiated TLS session speaks the protocol being served. @@ -212,42 +201,27 @@ pub(crate) fn configure_socket(stream: &TcpStream, server: &Arc) { /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves the connection. -/// - `RequestConfig` - The parsing limits and read timeouts for the connection. -/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `ConnectionEnv` - The server, limits and protocol for the connection. +/// - `StreamPeers` - The addresses of the connection. /// - `TcpStream` - The accepted socket. -/// - `Option` - The local address of the connection. -/// - `SocketAddr` - The remote address of the connection. /// /// # Returns /// /// - `()` - Resolves once the connection is finished. -pub(crate) async fn serve_plain_tcp( - server: Arc, - request_config: RequestConfig, - protocol: ServerProtocol, - stream: TcpStream, - local: Option, - remote: SocketAddr, -) { - match protocol { +pub(crate) async fn serve_plain_tcp(env: ConnectionEnv, peers: StreamPeers, stream: TcpStream) { + match env.protocol { ServerProtocol::Http2 => { - serve_h2_connection( - server, - request_config, - local.unwrap_or(remote), - remote, - bounded_handshake(request_config, h2::server::handshake(stream)) + let handshake: Result, String> = + bounded_handshake(env.request_config, h2::server::handshake(stream)) .await .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) .and_then(|connection: Result<_, H2Error>| { connection.map_err(|error: H2Error| error.to_string()) - }), - ) - .await; + }); + serve_h2_connection(env, peers, handshake).await; } _ => { - serve_h1_connection(server, request_config, stream).await; + serve_h1_connection(env.server, env.request_config, stream).await; } } } @@ -260,43 +234,32 @@ pub(crate) async fn serve_plain_tcp( /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves the connection. -/// - `RequestConfig` - The parsing limits and read timeouts for the connection. -/// - `ServerProtocol` - The protocol to serve on this connection. +/// - `ConnectionEnv` - The server, limits and protocol for the connection. +/// - `StreamPeers` - The addresses of the connection. /// - `TlsTransport` - The negotiated session carrying the connection. -/// - `Option` - The local address of the connection. -/// - `SocketAddr` - The remote address of the connection. /// /// # Returns /// /// - `()` - Resolves once the connection is finished. pub(crate) async fn serve_tls_stream( - server: Arc, - request_config: RequestConfig, - protocol: ServerProtocol, + env: ConnectionEnv, + peers: StreamPeers, transport: TlsTransport, - local: Option, - remote: SocketAddr, ) { - match protocol { + match env.protocol { ServerProtocol::Http2 => { let session: TlsTransport = transport; - serve_h2_connection( - server, - request_config, - local.unwrap_or(remote), - remote, - bounded_handshake(request_config, h2::server::handshake(session)) + let handshake: Result, String> = + bounded_handshake(env.request_config, h2::server::handshake(session)) .await .map_err(|_: tokio::time::error::Elapsed| H2_HANDSHAKE_TIMEOUT.to_owned()) .and_then(|connection: Result<_, H2Error>| { connection.map_err(|error: H2Error| error.to_string()) - }), - ) - .await; + }); + serve_h2_connection(env, peers, handshake).await; } _ => { - serve_h1_transport(server, request_config, transport).await; + serve_h1_transport(env.server, env.request_config, transport).await; } } } @@ -312,8 +275,7 @@ pub(crate) async fn serve_tls_stream( /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves each request. -/// - `RequestConfig` - The parsing limits and read timeouts for connections. +/// - `ConnectionEnv` - The server, limits and protocol for the connection. /// - `Endpoint` - The bound QUIC endpoint to accept connections from. /// - `Receiver<()>` - The shutdown signal the loop selects on. /// - `Sender` - Publishes whether the loop is still serving. @@ -323,8 +285,7 @@ pub(crate) async fn serve_tls_stream( /// - `Result<(), Box>` - `Ok(())` once the endpoint has been closed and /// drained, or the failure that stopped the loop early. pub(crate) async fn quic_accept( - server: Arc, - request_config: RequestConfig, + env: ConnectionEnv, endpoint: Endpoint, mut shutdown_receiver: Receiver<()>, running_sender: Sender, @@ -342,7 +303,7 @@ pub(crate) async fn quic_accept( break; }; let remote: SocketAddr = connecting.remote_address(); - let server: Arc = server.clone(); + let env: ConnectionEnv = env.clone(); spawn(async move { let Ok(connection) = connecting.await else { return; @@ -358,7 +319,7 @@ pub(crate) async fn quic_accept( else { return; }; - serve_h3_connection(server, request_config, local, remote, h3_connection).await; + serve_h3_connection(env, StreamPeers { local, remote }, h3_connection).await; }); } let close_code: VarInt = VarInt::from_u32(QUIC_CLOSE_CODE); diff --git a/core/src/listener/mod.rs b/core/src/listener/mod.rs index 09524dbe..f1ce331a 100644 --- a/core/src/listener/mod.rs +++ b/core/src/listener/mod.rs @@ -1,6 +1,7 @@ mod r#const; mod r#fn; +mod r#struct; -pub(crate) use {r#const::*, r#fn::*}; +pub(crate) use {r#const::*, r#fn::*, r#struct::*}; use super::*; diff --git a/core/src/listener/struct.rs b/core/src/listener/struct.rs new file mode 100644 index 00000000..273d97aa --- /dev/null +++ b/core/src/listener/struct.rs @@ -0,0 +1,22 @@ +use super::*; + +/// The accepted connection one accept loop is serving. +/// +/// The listener, shutdown receiver and running-state sender are owned by one +/// accept loop. They are moved together and destructured when the loop starts, +/// so they stop being three more parameters repeated at every accept-loop entry +/// point. The loop drops the listener before it returns. +/// +/// The two halves belong to two different `watch` channels: the receiver waits +/// on the shutdown signal, the sender publishes whether the loop is still +/// serving, and the other half of each channel stays with the server's control +/// hooks. +#[derive(Debug)] +pub(crate) struct AcceptSignal { + /// The bound listener to accept connections from. + pub(crate) listener: TcpListener, + /// The shutdown signal the loop selects on. + pub(crate) shutdown_receiver: Receiver<()>, + /// Publishes whether the loop is still serving. + pub(crate) running_sender: Sender, +} diff --git a/core/src/protocol/aliases/type.rs b/core/src/protocol/aliases/type.rs index 26217824..ccc3f426 100644 --- a/core/src/protocol/aliases/type.rs +++ b/core/src/protocol/aliases/type.rs @@ -9,6 +9,9 @@ pub(crate) type ProtocolHeaders = http::HeaderMap; /// The bidirectional stream one HTTP/3 request runs over. pub(crate) type RequestH3Stream = h3::server::RequestStream, Bytes>; +/// The resolver an HTTP/3 connection hands back for one request stream. +pub(crate) type RequestResolverH3 = h3::server::RequestResolver; + /// The HTTP/2 connection half shared by every request stream of one connection. pub(crate) type ConnectionH2 = h2::server::Connection; diff --git a/core/src/protocol/http2/fn.rs b/core/src/protocol/http2/fn.rs index 1e11dc87..e51b1d8b 100644 --- a/core/src/protocol/http2/fn.rs +++ b/core/src/protocol/http2/fn.rs @@ -10,16 +10,15 @@ use super::*; /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves each request. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The `value` argument. +/// - `ConnectionEnv` - The server, limits and protocol of the connection. +/// - `StreamPeers` - The addresses of the connection. /// - `Result, String>` - The negotiated HTTP/2 connection, or /// +/// `StreamPeers` travels separately from `ConnectionEnv` because the connection +/// half itself does not read it; only the per-request tasks it spawns do. pub(crate) async fn serve_h2_connection( - server: Arc, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, + env: ConnectionEnv, + peers: StreamPeers, connection: Result, String>, ) where T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, @@ -32,10 +31,11 @@ pub(crate) async fn serve_h2_connection( match connection.accept().await { Some(Ok((request, send_response))) => { spawn(h2_request_task( - server.clone(), - request_config, - local, - remote, + H2RequestEnv { + server: env.server.clone(), + request_config: env.request_config, + peers, + }, request, send_response, )); @@ -55,33 +55,33 @@ pub(crate) async fn serve_h2_connection( /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves the request. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The local address of the carrying connection. +/// - `H2RequestEnv` - The server, limits and connection addresses. /// - `ProtocolRequest` - The decoded protocol request. /// - `h2::server::SendResponse` - The response half of the stream. /// pub(crate) async fn h2_request_task( - server: Arc, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, + env: H2RequestEnv, request: ProtocolRequest, send_response: h2::server::SendResponse, ) { + let H2RequestEnv { + server, + request_config, + peers, + } = env; let (parts, mut recv_stream): (http::request::Parts, H2RecvStream) = request.into_parts(); - let uri: &http::Uri = &parts.uri; let mut framer: H2Framer = H2Framer::new(send_response); let mut body: Vec = Vec::new(); if let Err(error) = collect_h2_body(&mut recv_stream, &request_config, &parts.headers, &mut body).await { h2_fail_request( - &server, + &H2RequestEnv { + server, + request_config, + peers, + }, &mut framer, - request_config, - local, - remote, HttpVersion::Http2, error, ) @@ -90,22 +90,23 @@ pub(crate) async fn h2_request_task( } let built: Result = build_protocol_request( &request_config, - HttpVersion::Http2, - parts.method.clone(), - &request_target(uri.path(), uri.query()), - uri, - &parts.headers, - body, + ProtocolRequestHead { + version: HttpVersion::Http2, + method: parts.method.clone(), + parts, + body, + }, ); let request: Request = match built { Ok(request) => request, Err(error) => { h2_fail_request( - &server, + &H2RequestEnv { + server, + request_config, + peers, + }, &mut framer, - request_config, - local, - remote, HttpVersion::Http2, error, ) @@ -114,8 +115,7 @@ pub(crate) async fn h2_request_task( } }; let is_head: bool = request.get_method().is_head(); - let (stream, peer): (Stream, DuplexStream) = - build_duplex_transport(request_config, Some(local), Some(remote)); + let (stream, peer): (Stream, DuplexStream) = build_duplex_transport(request_config, peers); let lifecycle: ServerLifecycle = ServerLifecycle::new(server, request); let joined: Result<((), ()), ResponseError> = futures::try_join!( async { @@ -137,21 +137,18 @@ pub(crate) async fn h2_request_task( /// an HTTP/1.1 shaped head that is framed as HTTP/2 on the wire instead of the /// stream ending silently. /// +/// Borrows the request environment while framing the error response. +/// /// # Arguments /// -/// - `&Arc` - The server whose error hooks answer the request. +/// - `&H2RequestEnv` - The server, limits and connection addresses. /// - `&mut H2Framer` - The framer owning the stream's frames. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The local address of the carrying connection. /// - `HttpVersion` - The real protocol version to stamp on the error context. /// - `RequestError` - The failure that stopped the request. /// pub(crate) async fn h2_fail_request( - server: &Arc, + env: &H2RequestEnv, framer: &mut H2Framer, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, version: HttpVersion, error: RequestError, ) { @@ -161,9 +158,9 @@ pub(crate) async fn h2_fail_request( ctx.set_request(request); ctx.get_mut_response().reset().set_version(version); let (mut stream, peer): (Stream, DuplexStream) = - build_duplex_transport(request_config, Some(local), Some(remote)); + build_duplex_transport(env.request_config, env.peers); let is_head: bool = false; - let server: Arc = server.clone(); + let server: Arc = env.server.clone(); let (_, pump_result): ((), Result<(), ResponseError>) = join!( async move { server diff --git a/core/src/protocol/http2/impl.rs b/core/src/protocol/http2/impl.rs index 204dd50f..77627eab 100644 --- a/core/src/protocol/http2/impl.rs +++ b/core/src/protocol/http2/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`H2Framer`]. impl H2Framer { /// Creates a framer over one request stream. /// @@ -37,6 +38,7 @@ impl H2Framer { } } +/// Implements [`ResponseFramer`] for [`H2Framer`]. impl ResponseFramer for H2Framer { /// Sends the response head as the first HEADERS frame of the stream. /// diff --git a/core/src/protocol/http2/struct.rs b/core/src/protocol/http2/struct.rs index 68ec822c..0a00b367 100644 --- a/core/src/protocol/http2/struct.rs +++ b/core/src/protocol/http2/struct.rs @@ -23,3 +23,23 @@ pub(crate) struct H2Framer { #[set(pub(crate))] pub(super) finished: bool, } + +/// The environment one HTTP/2 request stream is served in. +/// +/// A request stream needs the server that serves it, the limits and read timeout +/// its head and body are bounded by, and the addresses the duplex transport +/// reports to the framework. Carried together they stop being the same four +/// parameters repeated in the same order at every layer of the HTTP/2 path. +/// +/// Every field but the server handle is `Copy`. The handle is an `Arc` the +/// connection loop already cloned, so passing the environment by value moves that +/// handle instead of cloning it again. +#[derive(Clone, Debug)] +pub(crate) struct H2RequestEnv { + /// The server whose pipeline serves the request. + pub(crate) server: Arc, + /// The parsing limits and read timeout for the connection. + pub(crate) request_config: RequestConfig, + /// The addresses of the connection carrying the request. + pub(crate) peers: StreamPeers, +} diff --git a/core/src/protocol/http3/fn.rs b/core/src/protocol/http3/fn.rs index 1fa27c15..9467ddeb 100644 --- a/core/src/protocol/http3/fn.rs +++ b/core/src/protocol/http3/fn.rs @@ -8,16 +8,15 @@ use super::*; /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves each request. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The local address of the carrying connection. +/// - `ConnectionEnv` - The server, limits and protocol of the connection. +/// - `StreamPeers` - The addresses of the connection. /// - `h3::server::Connection` - The HTTP/3 connection. /// +/// `StreamPeers` travels separately from `ConnectionEnv` because the connection +/// half itself does not read it; only the per-request tasks it spawns do. pub(crate) async fn serve_h3_connection( - server: Arc, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, + env: ConnectionEnv, + peers: StreamPeers, mut connection: h3::server::Connection, ) { loop { @@ -30,10 +29,11 @@ pub(crate) async fn serve_h3_connection( return; }; spawn(h3_request_task( - server.clone(), - request_config, - local, - remote, + H3RequestEnv { + server: env.server.clone(), + request_config: env.request_config, + peers, + }, resolver, )); } @@ -43,36 +43,33 @@ pub(crate) async fn serve_h3_connection( /// /// # Arguments /// -/// - `Arc` - The server whose pipeline serves the request. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The local address of the carrying connection. -/// - `h3::server::RequestResolver` - The resolver for +/// - `H3RequestEnv` - The server, limits and connection addresses. +/// - `RequestResolverH3` - The resolver for the request stream. /// -pub(crate) async fn h3_request_task( - server: Arc, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, - resolver: h3::server::RequestResolver, -) { +pub(crate) async fn h3_request_task(env: H3RequestEnv, resolver: RequestResolverH3) { + let H3RequestEnv { + server, + request_config, + peers, + } = env; let (head, request_stream): (http::Request<()>, RequestH3Stream) = match resolver.resolve_request().await { Ok(resolved) => resolved, Err(_) => return, }; let (parts, ()): (http::request::Parts, ()) = head.into_parts(); - let uri: &http::Uri = &parts.uri; let mut framer: H3Framer = H3Framer::new(request_stream); let mut body: Vec = Vec::new(); match collect_h3_body(&mut framer, &request_config, &parts.headers, &mut body).await { Ok(()) => {} Err(error) => { h3_fail_request( - &server, + &H3RequestEnv { + server, + request_config, + peers, + }, &mut framer, - request_config, - local, - remote, HttpVersion::Http3, error, ) @@ -82,22 +79,23 @@ pub(crate) async fn h3_request_task( } let built: Result = build_protocol_request( &request_config, - HttpVersion::Http3, - parts.method.clone(), - &request_target(uri.path(), uri.query()), - uri, - &parts.headers, - body, + ProtocolRequestHead { + version: HttpVersion::Http3, + method: parts.method.clone(), + parts, + body, + }, ); let request: Request = match built { Ok(request) => request, Err(error) => { h3_fail_request( - &server, + &H3RequestEnv { + server, + request_config, + peers, + }, &mut framer, - request_config, - local, - remote, HttpVersion::Http3, error, ) @@ -106,8 +104,7 @@ pub(crate) async fn h3_request_task( } }; let is_head: bool = request.get_method().is_head(); - let (stream, peer): (Stream, DuplexStream) = - build_duplex_transport(request_config, Some(local), Some(remote)); + let (stream, peer): (Stream, DuplexStream) = build_duplex_transport(request_config, peers); let lifecycle: ServerLifecycle = ServerLifecycle::new(server, request); let joined: Result<((), ()), ResponseError> = futures::try_join!( async { @@ -217,21 +214,18 @@ async fn collect_h3_body_until_end( /// an HTTP/1.1 shaped head that is framed as HTTP/3 on the wire instead of the /// stream ending silently. /// +/// Borrows the request environment while framing the error response. +/// /// # Arguments /// -/// - `&Arc` - The server whose error hooks answer the request. +/// - `&H3RequestEnv` - The server, limits and connection addresses. /// - `&mut H3Framer` - The framer owning the stream's frames. -/// - `RequestConfig` - The parsing limits and read timeout for the connection. -/// - `SocketAddr` - The local address of the carrying connection. /// - `HttpVersion` - The real protocol version to stamp on the error context. /// - `RequestError` - The failure that stopped the request. /// pub(crate) async fn h3_fail_request( - server: &Arc, + env: &H3RequestEnv, framer: &mut H3Framer, - request_config: RequestConfig, - local: SocketAddr, - remote: SocketAddr, version: HttpVersion, error: RequestError, ) { @@ -241,9 +235,9 @@ pub(crate) async fn h3_fail_request( ctx.set_request(request); ctx.get_mut_response().reset().set_version(version); let (mut stream, peer): (Stream, DuplexStream) = - build_duplex_transport(request_config, Some(local), Some(remote)); + build_duplex_transport(env.request_config, env.peers); let is_head: bool = false; - let server: Arc = server.clone(); + let server: Arc = env.server.clone(); let (_, pump_result): ((), Result<(), ResponseError>) = join!( async move { server diff --git a/core/src/protocol/http3/impl.rs b/core/src/protocol/http3/impl.rs index 0ec6b518..5433a61b 100644 --- a/core/src/protocol/http3/impl.rs +++ b/core/src/protocol/http3/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`H3Framer`]. impl H3Framer { /// Creates a framer over one request stream. /// @@ -33,6 +34,7 @@ impl H3Framer { } } +/// Implements [`ResponseFramer`] for [`H3Framer`]. impl ResponseFramer for H3Framer { /// Sends the response head as the first HEADERS frame of the stream. /// diff --git a/core/src/protocol/http3/struct.rs b/core/src/protocol/http3/struct.rs index 7d7b29dc..0b4ac9f0 100644 --- a/core/src/protocol/http3/struct.rs +++ b/core/src/protocol/http3/struct.rs @@ -18,3 +18,23 @@ pub(crate) struct H3Framer { #[set(pub(crate))] pub(super) finished: bool, } + +/// The environment one HTTP/3 request stream is served in. +/// +/// A request stream needs the server that serves it, the limits and read timeout +/// its head and body are bounded by, and the addresses the duplex transport +/// reports to the framework. Carried together they stop being the same four +/// parameters repeated in the same order at every layer of the HTTP/3 path. +/// +/// Every field but the server handle is `Copy`. The handle is an `Arc` the +/// connection loop already cloned, so passing the environment by value moves that +/// handle instead of cloning it again. +#[derive(Clone, Debug)] +pub(crate) struct H3RequestEnv { + /// The server whose pipeline serves the request. + pub(crate) server: Arc, + /// The parsing limits and read timeout for the connection. + pub(crate) request_config: RequestConfig, + /// The addresses of the connection carrying the request. + pub(crate) peers: StreamPeers, +} diff --git a/core/src/protocol/lifecycle/impl.rs b/core/src/protocol/lifecycle/impl.rs index 32c1ee6f..6b1df25d 100644 --- a/core/src/protocol/lifecycle/impl.rs +++ b/core/src/protocol/lifecycle/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Inherent implementation of [`ServerLifecycle`]. impl ServerLifecycle { /// Creates a lifecycle for one protocol request. /// diff --git a/core/src/protocol/request/fn.rs b/core/src/protocol/request/fn.rs index 6b2ea5e4..90314a57 100644 --- a/core/src/protocol/request/fn.rs +++ b/core/src/protocol/request/fn.rs @@ -15,25 +15,23 @@ use super::*; /// # Arguments /// /// - `&RequestConfig` - The parsing limits to enforce. -/// - `HttpVersion` - The real protocol version to stamp on the request. -/// - `ProtocolMethod` - The request method decoded by the protocol library. -/// - `&str` - The `value` argument. -/// - `&http::Uri` - The request URI, which carries the authority. -/// - `&ProtocolHeaders` - The protocol request headers. -/// - `Vec` - The already collected request body. +/// - `ProtocolRequestHead` - The decoded head and the collected body. /// /// # Returns /// /// - `Result` - The converted request, or the first limit violation. pub fn build_protocol_request( config: &RequestConfig, - version: HttpVersion, - method: ProtocolMethod, - target: &str, - target_uri: &http::Uri, - headers: &ProtocolHeaders, - body: Vec, + head: ProtocolRequestHead, ) -> Result { + let ProtocolRequestHead { + version, + method, + parts, + body, + } = head; + let uri: &http::Uri = &parts.uri; + let target: String = request_target(uri.path(), uri.query()); let max_path_size: usize = config.get_max_path_size(); if target.len() > max_path_size && max_path_size != DEFAULT_LOW_SECURITY_MAX_PATH_SIZE { return Err(RequestError::PathTooLong(HttpStatus::URITooLong)); @@ -48,16 +46,16 @@ pub fn build_protocol_request( let query_index: Option = target.find(QUESTION_MARK); let hash_index: Option = target.find(HASH); fill_request_querys( - split_target_query(target, query_index, hash_index), + split_target_query(&target, query_index, hash_index), request.get_mut_querys(), ); request .get_mut_path() - .push_str(split_target_path(target, query_index, hash_index)); + .push_str(split_target_path(&target, query_index, hash_index)); request.set_method(protocol_method(method)); request.set_version(version); - fill_request_headers(headers, config, &mut request)?; - request.set_host(resolve_request_host(headers, target_uri)); + fill_request_headers(&parts.headers, config, &mut request)?; + request.set_host(resolve_request_host(&parts.headers, uri)); request.get_mut_body().clear(); request.get_mut_body().extend_from_slice(&body); Ok(request) diff --git a/core/src/protocol/request/mod.rs b/core/src/protocol/request/mod.rs index 910cac30..0c6d0215 100644 --- a/core/src/protocol/request/mod.rs +++ b/core/src/protocol/request/mod.rs @@ -1,5 +1,6 @@ mod r#fn; +mod r#struct; -pub(crate) use r#fn::*; +pub(crate) use {r#fn::*, r#struct::*}; use super::*; diff --git a/core/src/protocol/request/struct.rs b/core/src/protocol/request/struct.rs new file mode 100644 index 00000000..295e312f --- /dev/null +++ b/core/src/protocol/request/struct.rs @@ -0,0 +1,26 @@ +use super::*; + +/// The decoded head and body of one protocol request. +/// +/// HTTP/2 and HTTP/3 both deliver a request as a protocol head plus a body that +/// has already been collected under the connection's limits, and both are turned +/// into the same framework request here. The parts travel as one value because +/// they only mean anything together: a method without the URI it was sent to, or +/// a body without the `content-length` header that declared it, describes no +/// request at all. +/// +/// The head parts are taken by value because the request is built once and never +/// read again by the protocol path. This owns the head parts and the collected +/// body; the conversion borrows the URI and the headers out of those owned +/// parts. +#[derive(Debug)] +pub(crate) struct ProtocolRequestHead { + /// The real protocol version to stamp on the request. + pub(crate) version: HttpVersion, + /// The request method decoded by the protocol library. + pub(crate) method: ProtocolMethod, + /// The decoded request head, carrying the URI and the headers. + pub(crate) parts: http::request::Parts, + /// The already collected request body. + pub(crate) body: Vec, +} diff --git a/core/src/protocol/transport/fn.rs b/core/src/protocol/transport/fn.rs index 8b31b3c4..71a51a5a 100644 --- a/core/src/protocol/transport/fn.rs +++ b/core/src/protocol/transport/fn.rs @@ -2,24 +2,26 @@ use super::*; /// Builds the transport pair one protocol request stream runs over. /// +/// The peers are carried as one value because the transport reports both ends +/// of the same connection. +/// /// # Arguments /// /// - `RequestConfig` - The parsing limits and read timeout for the stream. -/// - `Option` - The local address of the carrying connection. +/// - `StreamPeers` - The addresses of the carrying connection. /// /// # Returns /// /// - `(Stream, DuplexStream)` - The pipeline's stream and the pump's read end. pub(crate) fn build_duplex_transport( request_config: RequestConfig, - local: Option, - remote: Option, + peers: StreamPeers, ) -> (Stream, DuplexStream) { let (pipe, peer): (DuplexStream, DuplexStream) = tokio::io::duplex(PROTOCOL_STREAM_CAPACITY); let transport: DuplexTransport = DuplexTransport { pipe, - local, - remote, + local: Some(peers.local), + remote: Some(peers.remote), }; ( Stream::from_transport(transport, request_config, false), diff --git a/core/src/protocol/transport/impl.rs b/core/src/protocol/transport/impl.rs index e560610e..4957e091 100644 --- a/core/src/protocol/transport/impl.rs +++ b/core/src/protocol/transport/impl.rs @@ -1,5 +1,6 @@ use super::*; +/// Implements [`Transport`] for [`DuplexTransport`]. impl Transport for DuplexTransport { /// Reports the address of the remote end of the carrying connection. /// @@ -30,6 +31,7 @@ impl Transport for DuplexTransport { } } +/// Implements [`AsyncRead`] for [`DuplexTransport`]. impl AsyncRead for DuplexTransport { /// Reads from the half the framing pump writes nothing into. /// @@ -55,6 +57,7 @@ impl AsyncRead for DuplexTransport { } } +/// Implements [`AsyncWrite`] for [`DuplexTransport`]. impl AsyncWrite for DuplexTransport { /// Writes response bytes into the pipe the framing pump reads. /// @@ -110,6 +113,7 @@ impl AsyncWrite for DuplexTransport { } } +/// Inherent implementation of [`TlsTransport`]. impl TlsTransport { /// Wraps a negotiated TLS session. /// @@ -122,6 +126,7 @@ impl TlsTransport { } } +/// Inherent implementation of [`TlsTransport`]. impl TlsTransport { /// Borrows the negotiated session. /// @@ -142,6 +147,7 @@ impl TlsTransport { } } +/// Implements [`Transport`] for [`TlsTransport`]. impl Transport for TlsTransport { /// Reports the address of the remote end of the session. /// @@ -170,6 +176,7 @@ impl Transport for TlsTransport { } } +/// Implements [`AsyncRead`] for [`TlsTransport`]. impl AsyncRead for TlsTransport { /// Reads decrypted bytes from the negotiated session. /// @@ -192,6 +199,7 @@ impl AsyncRead for TlsTransport { } } +/// Implements [`AsyncWrite`] for [`TlsTransport`]. impl AsyncWrite for TlsTransport { /// Writes encrypted bytes onto the negotiated session. /// diff --git a/core/src/protocol/transport/struct.rs b/core/src/protocol/transport/struct.rs index 3937fe78..167cc0f3 100644 --- a/core/src/protocol/transport/struct.rs +++ b/core/src/protocol/transport/struct.rs @@ -47,3 +47,40 @@ pub(crate) struct TlsTransport { #[set(pub(crate))] pub(super) session: tokio_rustls::server::TlsStream, } + +/// The environment one accepted connection is served in. +/// +/// Every protocol path needs the same three things: the server whose pipeline +/// runs each request, the parsing limits and read timeouts those requests are +/// bounded by, and the protocol this connection is served as. Carried as one +/// value they stop being the same three parameters repeated in the same order at +/// every layer. +/// +/// The server handle is held in an `Arc` exactly as before: one clone per spawned +/// connection, and one per request task, which is the count the previous +/// signature already forced. `Clone` copies the handle and adds one to the number +/// of existing clones of that `Arc`. The other two fields are `Copy`, so they are +/// carried by value. +#[derive(Clone, Debug)] +pub(crate) struct ConnectionEnv { + /// The server whose pipeline serves each request. + pub(crate) server: Arc, + /// The parsing limits and read timeouts for the connection. + pub(crate) request_config: RequestConfig, + /// The protocol served on this connection. + pub(crate) protocol: ServerProtocol, +} + +/// The addresses of the connection carrying one request stream. +/// +/// The duplex transport reports these to the framework, so a handler that reads +/// them sees the real connection rather than a fabricated one. They are the same +/// two values on every protocol path and the duplex transport is always built +/// from both, so they travel together. +#[derive(Clone, Copy, Debug)] +pub(crate) struct StreamPeers { + /// The local address of the carrying connection. + pub(crate) local: SocketAddr, + /// The remote address of the carrying connection. + pub(crate) remote: SocketAddr, +} diff --git a/core/src/server/impl.rs b/core/src/server/impl.rs index 78b3fc2f..af892a3e 100644 --- a/core/src/server/impl.rs +++ b/core/src/server/impl.rs @@ -858,30 +858,34 @@ impl Server { let endpoint: Endpoint = Endpoint::server(quinn::ServerConfig::with_crypto(quic_config), bind_addr) .map_err(|error: io::Error| Box::new(ServerError::from(error)))?; + let env: ConnectionEnv = ConnectionEnv { + server, + request_config, + protocol, + }; spawn(async move { - let _: Result<(), Box> = quic_accept( - server, - request_config, - endpoint, - shutdown_receiver, - running_sender, - ) - .await; + let _: Result<(), Box> = + quic_accept(env, endpoint, shutdown_receiver, running_sender).await; }) } _ => { let tcp_listener: TcpListener = TcpListener::bind(&bind_address) .await .map_err(|error: io::Error| Box::new(ServerError::from(error)))?; + let env: ConnectionEnv = ConnectionEnv { + server, + request_config, + protocol, + }; spawn(async move { tcp_accept( - server, - request_config, - protocol, + env, stream_tls, - tcp_listener, - shutdown_receiver, - running_sender, + AcceptSignal { + listener: tcp_listener, + shutdown_receiver, + running_sender, + }, ) .await; }) diff --git a/core/tests/protocol/http3/assert.rs b/core/tests/protocol/http3/assert.rs index 61e5599d..fab07426 100644 --- a/core/tests/protocol/http3/assert.rs +++ b/core/tests/protocol/http3/assert.rs @@ -13,13 +13,14 @@ async fn http3_over_quic_reports_the_real_local_and_peer_addresses_of_the_connec let client_local: SocketAddr = endpoint .local_addr() .expect("the client endpoint must report its own udp address"); - let (status, _headers, body, _alpn): H3HeadReply = h3_exchange_on( + let target: String = format!("https://{TLS_SERVER_NAME}:{port}{SOCKET_METADATA_ROUTE_PATH}"); + let (status, _headers, body, _alpn): H3HeadReply = h3_exchange_on(H3Exchange { connection, - None, - "GET", - &format!("https://{TLS_SERVER_NAME}:{port}{SOCKET_METADATA_ROUTE_PATH}"), - &[], - ) + alpn: None, + method: "GET", + target: &target, + body: &[], + }) .await .expect("h3 socket metadata request must complete"); assert_eq!(status, 200); diff --git a/core/tests/protocol/http3/fn.rs b/core/tests/protocol/http3/fn.rs index b3711c5c..1df307f8 100644 --- a/core/tests/protocol/http3/fn.rs +++ b/core/tests/protocol/http3/fn.rs @@ -57,16 +57,24 @@ pub(crate) async fn h3_request_with_headers( let (_endpoint, connection): (quinn::Endpoint, quinn::Connection) = connect_quic(port, ALPN_HTTP3).await?; let alpn: Option> = negotiated_quic_alpn(&connection); - h3_exchange_on(connection, alpn, method, target, body).await + h3_exchange_on(H3Exchange { + connection, + alpn, + method, + target, + body, + }) + .await } -pub(crate) async fn h3_exchange_on( - connection: quinn::Connection, - alpn: Option>, - method: &str, - target: &str, - body: &[u8], -) -> Result { +pub(crate) async fn h3_exchange_on(exchange: H3Exchange<'_>) -> Result { + let H3Exchange { + connection, + alpn, + method, + target, + body, + } = exchange; let (mut driver, mut client) = h3::client::new(h3_quinn::Connection::new(connection.clone())) .await .map_err(|error: h3::error::ConnectionError| format!("h3 handshake failed: {error}"))?; diff --git a/core/tests/protocol/http3/type.rs b/core/tests/protocol/http3/type.rs index bfff397d..7c98f2a4 100644 --- a/core/tests/protocol/http3/type.rs +++ b/core/tests/protocol/http3/type.rs @@ -1,3 +1,11 @@ pub(crate) type H3Reply = (u16, http::Version, Vec, Option>); pub(crate) type H3HeadReply = (u16, http::HeaderMap, Vec, Option>); + +pub(crate) struct H3Exchange<'a> { + pub(crate) connection: quinn::Connection, + pub(crate) alpn: Option>, + pub(crate) method: &'a str, + pub(crate) target: &'a str, + pub(crate) body: &'a [u8], +} diff --git a/request/src/request/http_request/impl.rs b/request/src/request/http_request/impl.rs index d260d56c..05c65abb 100644 --- a/request/src/request/http_request/impl.rs +++ b/request/src/request/http_request/impl.rs @@ -3,6 +3,7 @@ use super::*; impl AsyncReadWrite for T {} impl ReadWrite for T {} +/// Inherent implementation of [`HttpRequest`]. impl HttpRequest { /// Send the HTTP request synchronously, returning the parsed response. /// @@ -1181,6 +1182,7 @@ impl HttpRequest { } } +/// Inherent implementation of [`HttpRequest`]. impl HttpRequest { /// Create a GET request for `url`. /// diff --git a/type/src/stream/impl.rs b/type/src/stream/impl.rs index 4098343a..0c78f2c1 100644 --- a/type/src/stream/impl.rs +++ b/type/src/stream/impl.rs @@ -165,6 +165,7 @@ impl Transport for TcpStream { } } +/// Implements [`Drop`] for [`PooledReader`]. impl Drop for PooledReader<'_> { /// Releases the buffer back to the pool for reuse by later requests. fn drop(&mut self) { @@ -258,6 +259,7 @@ impl AsyncBufRead for PooledReader<'_> { } } +/// Inherent implementation of [`Stream`]. impl Stream { /// Boxes a transport into a `Stream` for the request pipeline. /// diff --git a/type/src/websocket_frame/impl.rs b/type/src/websocket_frame/impl.rs index c46db233..55cc8e86 100644 --- a/type/src/websocket_frame/impl.rs +++ b/type/src/websocket_frame/impl.rs @@ -21,6 +21,7 @@ impl Default for WebSocketFrame { } } +/// Inherent implementation of [`WebSocketOpcode`]. impl WebSocketOpcode { /// Creates a `WebSocketOpcode` from a raw u8 value. /// @@ -153,6 +154,7 @@ impl WebSocketOpcode { } } +/// Inherent implementation of [`WebSocketFrame`]. impl WebSocketFrame { /// Decodes a WebSocket frame from the provided data slice. /// From 3673ce53424018e6d388131631051e1adf82b063 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 5/7] chore(hyperlane): bump workspace version to 21.13.7 Synchronize all nine local package requirements with the workspace version. Keep every README unchanged and retain the performance merge blocker. --- Cargo.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6ae52d7a..9126005e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "21.13.6" +version = "21.13.7" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -34,15 +34,15 @@ members = [ ] [workspace.dependencies] -hyperlane = { path = ".", version = "21.13.6" } -http-type = { path = "type", version = "21.13.6" } -hyperlane-cli = { path = "cli", version = "21.13.6" } -hyperlane-core = { path = "core", version = "21.13.6" } -http-request = { path = "request", version = "21.13.6" } -http-compress = { path = "compress", version = "21.13.6" } -http-constant = { path = "constant", version = "21.13.6" } -hyperlane-macros = { path = "macros", version = "21.13.6" } -hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.6" } +hyperlane = { path = ".", version = "21.13.7" } +http-type = { path = "type", version = "21.13.7" } +hyperlane-cli = { path = "cli", version = "21.13.7" } +hyperlane-core = { path = "core", version = "21.13.7" } +http-request = { path = "request", version = "21.13.7" } +http-compress = { path = "compress", version = "21.13.7" } +http-constant = { path = "constant", version = "21.13.7" } +hyperlane-macros = { path = "macros", version = "21.13.7" } +hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.7" } h3 = "0.0.8" h2 = "0.4.20" From 4a93c47e214087657ba7fc2a3dffd1aab461c880 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Sun, 18 Oct 2026 08:52:21 +0800 Subject: [PATCH 6/7] fix(hyperlane): leave local dependency version synchronization to CI Keep the workspace package version at 21.13.7 and retain the explicit ctares 10.2.8 upgrades. Restore the nine local path requirements to their pre-bump values; the release workflow owns synchronization. The failed R3 cohort remains historical evidence for the earlier manifest. No current-source performance acceptance is claimed. --- Cargo.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9126005e..b2d780d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,15 +34,15 @@ members = [ ] [workspace.dependencies] -hyperlane = { path = ".", version = "21.13.7" } -http-type = { path = "type", version = "21.13.7" } -hyperlane-cli = { path = "cli", version = "21.13.7" } -hyperlane-core = { path = "core", version = "21.13.7" } -http-request = { path = "request", version = "21.13.7" } -http-compress = { path = "compress", version = "21.13.7" } -http-constant = { path = "constant", version = "21.13.7" } -hyperlane-macros = { path = "macros", version = "21.13.7" } -hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.7" } +hyperlane = { path = ".", version = "21.13.6" } +http-type = { path = "type", version = "21.13.6" } +hyperlane-cli = { path = "cli", version = "21.13.6" } +hyperlane-core = { path = "core", version = "21.13.6" } +http-request = { path = "request", version = "21.13.6" } +http-compress = { path = "compress", version = "21.13.6" } +http-constant = { path = "constant", version = "21.13.6" } +hyperlane-macros = { path = "macros", version = "21.13.6" } +hyperlane-plugin-websocket = { path = "plugin/websocket", version = "21.13.6" } h3 = "0.0.8" h2 = "0.4.20" From caed4b288baccf818a5ad42e039c334cd02969a2 Mon Sep 17 00:00:00 2001 From: vshengbro Date: Mon, 19 Oct 2026 12:31:50 +0800 Subject: [PATCH 7/7] chore: bump workspace version to 21.14.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b2d780d7..70504dcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "21.13.7" +version = "21.14.0" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"]