diff --git a/Cargo.toml b/Cargo.toml index 3bbb276b..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.4" +version = "21.14.0" 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.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" 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" @@ -65,10 +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" @@ -77,28 +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" -rustls-pki-types = "1.15.1" -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"] } @@ -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/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/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..80afa9c3 --- /dev/null +++ b/core/src/listener/fn.rs @@ -0,0 +1,442 @@ +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 +/// +/// - `ConnectionEnv` - The server, limits and protocol for the connection. +/// - `Option>` - The TLS configuration, when present. +/// - `AcceptSignal` - The bound listener and the loop's shutdown channels. +/// +/// # Returns +/// +/// - `()` - Resolves once the accept loop stops. +pub(crate) async fn tcp_accept( + env: ConnectionEnv, + stream_tls: Option>, + signal: AcceptSignal, +) { + let AcceptSignal { + listener: tcp_listener, + mut shutdown_receiver, + running_sender, + } = signal; + 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 env: ConnectionEnv = env.clone(); + let tls: Option> = stream_tls.clone(); + spawn(async move { + serve_tcp_connection(env, 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 +/// +/// - `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. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_tcp_connection( + env: ConnectionEnv, + stream_tls: Option>, + stream: TcpStream, + remote: SocketAddr, +) { + let local: Option = stream.local_addr().ok(); + configure_socket(&stream, &env.server); + let peers: StreamPeers = StreamPeers { + local: local.unwrap_or(remote), + remote, + }; + let Some(tls) = stream_tls else { + serve_plain_tcp(env, peers, stream).await; + return; + }; + let Ok(handshake) = + bounded_handshake(env.request_config, TlsAcceptor::from(tls).accept(stream)).await + else { + return; + }; + let Ok(handshake) = handshake else { + return; + }; + if !negotiated_protocol_matches(&handshake, &env.protocol) { + return; + } + serve_tls_stream(env, peers, TlsTransport::new(handshake)).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 +/// +/// - `ConnectionEnv` - The server, limits and protocol for the connection. +/// - `StreamPeers` - The addresses of the connection. +/// - `TcpStream` - The accepted socket. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_plain_tcp(env: ConnectionEnv, peers: StreamPeers, stream: TcpStream) { + match env.protocol { + ServerProtocol::Http2 => { + 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()) + }); + serve_h2_connection(env, peers, handshake).await; + } + _ => { + serve_h1_connection(env.server, env.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 +/// +/// - `ConnectionEnv` - The server, limits and protocol for the connection. +/// - `StreamPeers` - The addresses of the connection. +/// - `TlsTransport` - The negotiated session carrying the connection. +/// +/// # Returns +/// +/// - `()` - Resolves once the connection is finished. +pub(crate) async fn serve_tls_stream( + env: ConnectionEnv, + peers: StreamPeers, + transport: TlsTransport, +) { + match env.protocol { + ServerProtocol::Http2 => { + let session: TlsTransport = transport; + 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()) + }); + serve_h2_connection(env, peers, handshake).await; + } + _ => { + serve_h1_transport(env.server, env.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 +/// +/// - `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. +/// +/// # 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( + env: ConnectionEnv, + 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 env: ConnectionEnv = env.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(env, StreamPeers { 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..f1ce331a --- /dev/null +++ b/core/src/listener/mod.rs @@ -0,0 +1,7 @@ +mod r#const; +mod r#fn; +mod r#struct; + +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/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..ccc3f426 --- /dev/null +++ b/core/src/protocol/aliases/type.rs @@ -0,0 +1,71 @@ +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 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; + +/// 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'; + +/// 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..e51b1d8b --- /dev/null +++ b/core/src/protocol/http2/fn.rs @@ -0,0 +1,258 @@ +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 +/// +/// - `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( + env: ConnectionEnv, + peers: StreamPeers, + 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( + H2RequestEnv { + server: env.server.clone(), + request_config: env.request_config, + peers, + }, + 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 +/// +/// - `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( + 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 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( + &H2RequestEnv { + server, + request_config, + peers, + }, + &mut framer, + HttpVersion::Http2, + error, + ) + .await; + return; + } + let built: Result = build_protocol_request( + &request_config, + ProtocolRequestHead { + version: HttpVersion::Http2, + method: parts.method.clone(), + parts, + body, + }, + ); + let request: Request = match built { + Ok(request) => request, + Err(error) => { + h2_fail_request( + &H2RequestEnv { + server, + request_config, + peers, + }, + &mut framer, + HttpVersion::Http2, + error, + ) + .await; + return; + } + }; + let is_head: bool = request.get_method().is_head(); + 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 { + 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. +/// +/// Borrows the request environment while framing the error response. +/// +/// # Arguments +/// +/// - `&H2RequestEnv` - The server, limits and connection addresses. +/// - `&mut H2Framer` - The framer owning the stream's frames. +/// - `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( + env: &H2RequestEnv, + framer: &mut H2Framer, + 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(env.request_config, env.peers); + let is_head: bool = false; + let server: Arc = env.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 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. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the +pub(crate) async fn collect_h2_body( + recv_stream: &mut H2RecvStream, + 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 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. +/// +/// # Returns +/// +/// - `Result<(), RequestError>` - `Ok(())` once the body is complete, or the +async fn collect_h2_body_until_end( + recv_stream: &mut H2RecvStream, + 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: 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<(), 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 new file mode 100644 index 00000000..77627eab --- /dev/null +++ b/core/src/protocol/http2/impl.rs @@ -0,0 +1,162 @@ +use super::*; + +/// Inherent implementation of [`H2Framer`]. +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); + } + } +} + +/// Implements [`ResponseFramer`] for [`H2Framer`]. +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: ProtocolResponse<()> = builder + .body(()) + .map_err(|error: ProtocolBuildError| 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: H2Error| ResponseError::Send(error.to_string()))?; + return Ok(()); + } + 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(()) + }) + } + + /// 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: H2Error| 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: H2Error| 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..0a00b367 --- /dev/null +++ b/core/src/protocol/http2/struct.rs @@ -0,0 +1,45 @@ +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, +} + +/// 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 new file mode 100644 index 00000000..9467ddeb --- /dev/null +++ b/core/src/protocol/http3/fn.rs @@ -0,0 +1,253 @@ +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 +/// +/// - `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( + env: ConnectionEnv, + peers: StreamPeers, + 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( + H3RequestEnv { + server: env.server.clone(), + request_config: env.request_config, + peers, + }, + resolver, + )); + } +} + +/// Runs the framework lifecycle over one HTTP/3 request stream. +/// +/// # Arguments +/// +/// - `H3RequestEnv` - The server, limits and connection addresses. +/// - `RequestResolverH3` - The resolver for the request stream. +/// +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 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( + &H3RequestEnv { + server, + request_config, + peers, + }, + &mut framer, + HttpVersion::Http3, + error, + ) + .await; + return; + } + } + let built: Result = build_protocol_request( + &request_config, + ProtocolRequestHead { + version: HttpVersion::Http3, + method: parts.method.clone(), + parts, + body, + }, + ); + let request: Request = match built { + Ok(request) => request, + Err(error) => { + h3_fail_request( + &H3RequestEnv { + server, + request_config, + peers, + }, + &mut framer, + HttpVersion::Http3, + error, + ) + .await; + return; + } + }; + let is_head: bool = request.get_method().is_head(); + 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 { + 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. +/// +/// Borrows the request environment while framing the error response. +/// +/// # Arguments +/// +/// - `&H3RequestEnv` - The server, limits and connection addresses. +/// - `&mut H3Framer` - The framer owning the stream's frames. +/// - `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( + env: &H3RequestEnv, + framer: &mut H3Framer, + 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(env.request_config, env.peers); + let is_head: bool = false; + let server: Arc = env.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..5433a61b --- /dev/null +++ b/core/src/protocol/http3/impl.rs @@ -0,0 +1,146 @@ +use super::*; + +/// Inherent implementation of [`H3Framer`]. +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); + } +} + +/// Implements [`ResponseFramer`] for [`H3Framer`]. +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: ProtocolResponse<()> = builder + .body(()) + .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); + }; + 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..0b4ac9f0 --- /dev/null +++ b/core/src/protocol/http3/struct.rs @@ -0,0 +1,40 @@ +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, +} + +/// 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 new file mode 100644 index 00000000..6b1df25d --- /dev/null +++ b/core/src/protocol/lifecycle/impl.rs @@ -0,0 +1,29 @@ +use super::*; + +/// Inherent implementation of [`ServerLifecycle`]. +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..90314a57 --- /dev/null +++ b/core/src/protocol/request/fn.rs @@ -0,0 +1,389 @@ +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. +/// - `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, + 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)); + } + 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(&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) +} + +/// 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 +/// +/// - `ProtocolMethod` - The method decoded by the protocol library. +/// +/// # Returns +/// +/// - `RequestMethod` - The framework method. +pub fn protocol_method(method: ProtocolMethod) -> 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..0c6d0215 --- /dev/null +++ b/core/src/protocol/request/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/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/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..71a51a5a --- /dev/null +++ b/core/src/protocol/transport/fn.rs @@ -0,0 +1,30 @@ +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. +/// - `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, + peers: StreamPeers, +) -> (Stream, DuplexStream) { + let (pipe, peer): (DuplexStream, DuplexStream) = tokio::io::duplex(PROTOCOL_STREAM_CAPACITY); + let transport: DuplexTransport = DuplexTransport { + pipe, + local: Some(peers.local), + remote: Some(peers.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..4957e091 --- /dev/null +++ b/core/src/protocol/transport/impl.rs @@ -0,0 +1,256 @@ +use super::*; + +/// Implements [`Transport`] for [`DuplexTransport`]. +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)) + } +} + +/// Implements [`AsyncRead`] for [`DuplexTransport`]. +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(())) + } +} + +/// Implements [`AsyncWrite`] for [`DuplexTransport`]. +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) + } +} + +/// Inherent implementation of [`TlsTransport`]. +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 } + } +} + +/// Inherent implementation of [`TlsTransport`]. +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 + } +} + +/// Implements [`Transport`] for [`TlsTransport`]. +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) + } +} + +/// Implements [`AsyncRead`] for [`TlsTransport`]. +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) + } +} + +/// Implements [`AsyncWrite`] for [`TlsTransport`]. +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..167cc0f3 --- /dev/null +++ b/core/src/protocol/transport/struct.rs @@ -0,0 +1,86 @@ +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, +} + +/// 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/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..af892a3e 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,186 @@ 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)))?; + let env: ConnectionEnv = ConnectionEnv { + server, + request_config, + protocol, + }; + spawn(async move { + 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( + env, + stream_tls, + AcceptSignal { + listener: 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 +907,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..97ecf1cf --- /dev/null +++ b/core/src/tls/fn.rs @@ -0,0 +1,98 @@ +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 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 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: 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)) +} + +/// 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 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 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..815f6d1c --- /dev/null +++ b/core/src/tls/mod.rs @@ -0,0 +1,6 @@ +mod r#fn; +mod r#type; + +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/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..afc49ebe --- /dev/null +++ b/core/tests/protocol/http2/assert.rs @@ -0,0 +1,381 @@ +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..fab07426 --- /dev/null +++ b/core/tests/protocol/http3/assert.rs @@ -0,0 +1,291 @@ +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 target: String = format!("https://{TLS_SERVER_NAME}:{port}{SOCKET_METADATA_ROUTE_PATH}"); + let (status, _headers, body, _alpn): H3HeadReply = h3_exchange_on(H3Exchange { + connection, + alpn: None, + method: "GET", + target: &target, + body: &[], + }) + .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..1df307f8 --- /dev/null +++ b/core/tests/protocol/http3/fn.rs @@ -0,0 +1,251 @@ +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(H3Exchange { + connection, + alpn, + method, + target, + body, + }) + .await +} + +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}"))?; + 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..7c98f2a4 --- /dev/null +++ b/core/tests/protocol/http3/type.rs @@ -0,0 +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/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..815b950a --- /dev/null +++ b/core/tests/protocol/tls/fn.rs @@ -0,0 +1,438 @@ +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..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. /// @@ -733,7 +734,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 +824,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 +972,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 +1069,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] @@ -1184,6 +1182,7 @@ impl HttpRequest { } } +/// Inherent implementation of [`HttpRequest`]. impl HttpRequest { /// Create a GET request for `url`. /// 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..0c78f2c1 100644 --- a/type/src/stream/impl.rs +++ b/type/src/stream/impl.rs @@ -141,6 +141,31 @@ 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) + } +} + +/// Implements [`Drop`] for [`PooledReader`]. impl Drop for PooledReader<'_> { /// Releases the buffer back to the pool for reuse by later requests. fn drop(&mut self) { @@ -171,11 +196,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 +254,89 @@ 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); } } +/// Inherent implementation of [`Stream`]. 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 +413,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 +552,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 +628,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..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. /// @@ -567,7 +569,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") + } +}