Skip to main content

http_stat/
http_request.rs

1// Copyright 2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// This file implements HTTP request functionality with support for HTTP/1.1, HTTP/2, and HTTP/3
16// It includes features like DNS resolution, TLS handshake, and request/response handling
17
18use super::error::{Error, Result};
19use super::stats::{HttpStat, ALPN_HTTP1, ALPN_HTTP2};
20use bytes::Bytes;
21use http::request::Builder;
22use http::HeaderValue;
23use http::Request;
24use http::Uri;
25use http::{HeaderMap, Method};
26use http_body_util::Full;
27use rustls::client::{ClientSessionMemoryCache, ClientSessionStore};
28use std::net::IpAddr;
29use std::str::FromStr;
30use std::sync::Arc;
31use std::time::Duration;
32use std::time::Instant;
33
34// Version information from Cargo.toml
35const VERSION: &str = env!("CARGO_PKG_VERSION");
36
37// Handle request error and update statistics
38pub(crate) fn finish_with_error(
39    mut stat: HttpStat,
40    error: impl ToString,
41    start: Instant,
42) -> HttpStat {
43    stat.error = Some(error.to_string());
44    stat.total = Some(start.elapsed());
45    stat
46}
47
48/// A single `--connect-to HOST1:PORT1:HOST2:PORT2` entry.
49///
50/// When the request target matches `(src_host, src_port)`, the TCP connection is
51/// established to `(dst_host, dst_port)`. TLS SNI and the HTTP `Host` header still
52/// use the original hostname — only the actual TCP destination changes.
53///
54/// Empty `src_host` / `src_port` act as wildcards. Empty `dst_host` / absent `dst_port`
55/// keep the original value.
56#[derive(Debug, Clone)]
57pub struct ConnectTo {
58    src_host: String,
59    src_port: Option<u16>,
60    pub dst_host: String,
61    pub dst_port: Option<u16>,
62}
63
64fn parse_host_segment(s: &str) -> (String, &str) {
65    if let Some(rest) = s.strip_prefix('[') {
66        // IPv6 bracketed: [addr]...
67        if let Some(end) = rest.find(']') {
68            return (rest[..end].to_string(), &rest[end + 1..]);
69        }
70    }
71    // Plain host: take up to first ':'
72    let colon = s.find(':').unwrap_or(s.len());
73    (s[..colon].to_string(), &s[colon..])
74}
75
76impl ConnectTo {
77    /// Parse `HOST1:PORT1:HOST2:PORT2`. Any field may be empty; IPv6 uses `[addr]`.
78    pub fn parse(s: &str) -> Option<Self> {
79        let (src_host, rest) = parse_host_segment(s);
80        let rest = rest.strip_prefix(':')?; // require separator after HOST1
81
82        // PORT1 up to next ':'
83        let colon = rest.find(':')?;
84        let src_port = if rest[..colon].is_empty() {
85            None
86        } else {
87            Some(rest[..colon].parse().ok()?)
88        };
89        let rest = &rest[colon + 1..];
90
91        // HOST2
92        let (dst_host, rest) = parse_host_segment(rest);
93
94        // Optional ':PORT2'
95        let port2_str = rest.strip_prefix(':').unwrap_or(rest);
96        let dst_port = if port2_str.is_empty() {
97            None
98        } else {
99            Some(port2_str.parse().ok()?)
100        };
101
102        Some(ConnectTo {
103            src_host,
104            src_port,
105            dst_host,
106            dst_port,
107        })
108    }
109
110    /// Returns `true` if this entry applies to the given `(host, port)`.
111    pub fn matches(&self, host: &str, port: u16) -> bool {
112        let host_ok = self.src_host.is_empty() || self.src_host.eq_ignore_ascii_case(host);
113        let port_ok = self.src_port.is_none() || self.src_port == Some(port);
114        host_ok && port_ok
115    }
116}
117
118// HttpRequest struct to hold request configuration
119#[derive(Default, Debug, Clone)]
120pub struct HttpRequest {
121    pub uri: Uri,                                // Target URI
122    pub method: Option<String>,                  // HTTP method (GET, POST, etc.)
123    pub alpn_protocols: Vec<String>,             // Supported ALPN protocols
124    pub resolve: Option<IpAddr>,                 // Custom DNS resolution
125    pub headers: Option<HeaderMap<HeaderValue>>, // Custom HTTP headers
126    pub ip_version: Option<i32>,                 // IP version (4 for IPv4, 6 for IPv6)
127    pub skip_verify: bool,                       // Skip TLS certificate verification
128    pub body: Option<Bytes>,                     // Request body
129    pub dns_servers: Option<Vec<String>>,        // DNS servers
130    pub dns_timeout: Option<Duration>,           // DNS resolution timeout
131    pub tcp_timeout: Option<Duration>,           // TCP connection timeout
132    pub tls_timeout: Option<Duration>,           // TLS handshake timeout
133    pub request_timeout: Option<Duration>,       // HTTP request timeout
134    pub quic_timeout: Option<Duration>,          // QUIC connection timeout
135    pub client_cert: Option<Vec<u8>>,            // PEM-encoded client certificate (mTLS)
136    pub client_key: Option<Vec<u8>>,             // PEM-encoded client private key (mTLS)
137    pub proxy: Option<String>,                   // Proxy URL (http://, https://, socks5://)
138    pub use_absolute_uri: bool,                  // Send absolute URI (HTTP forward proxy)
139    pub connect_to: Vec<String>,                 // --connect-to HOST1:PORT1:HOST2:PORT2 overrides
140    pub bind_addr: Option<IpAddr>,               // Local source IP to bind before connecting
141    /// Maximum response body bytes to buffer. The whole body is held in
142    /// memory, so an unbounded response could otherwise OOM the process;
143    /// when the limit is exceeded the transfer aborts with an error.
144    /// `None` = unlimited (the CLI applies its own default cap).
145    pub max_body_size: Option<usize>,
146    /// Optional shared TLS session store. When set, the rustls `ClientConfig`
147    /// is wired with `Resumption::store(...)` and `enable_early_data = true`,
148    /// so subsequent requests sharing this store can perform a resumed
149    /// handshake and attempt 0-RTT. Intended for the `-n` benchmark loop:
150    /// install one cache before the loop and clone it onto every request.
151    pub tls_session_store: Option<Arc<dyn ClientSessionStore>>,
152}
153
154impl HttpRequest {
155    pub fn get_port(&self) -> u16 {
156        let scheme = self.uri.scheme_str().unwrap_or("");
157        let default_port = if matches!(scheme, "https" | "grpcs") {
158            443
159        } else {
160            80
161        };
162        self.uri.port_u16().unwrap_or(default_port)
163    }
164    // Build HTTP request with proper headers
165    pub fn builder(&self, is_http1: bool) -> Builder {
166        let uri = &self.uri;
167        let method = if let Some(method) = &self.method {
168            Method::from_str(method).unwrap_or(Method::GET)
169        } else {
170            Method::GET
171        };
172        let mut builder = if is_http1 && !self.use_absolute_uri {
173            if let Some(value) = uri.path_and_query() {
174                Request::builder().uri(value.to_string())
175            } else {
176                Request::builder().uri(uri)
177            }
178        } else {
179            Request::builder().uri(uri)
180        };
181        builder = builder.method(method);
182        let mut set_host = false;
183        let mut set_user_agent = false;
184
185        // Add custom headers if provided
186        if let Some(headers) = &self.headers {
187            for (key, value) in headers.iter() {
188                builder = builder.header(key, value);
189                // HeaderName::as_str() is always canonical lower-case, so no
190                // allocation or explicit lowercasing is needed here.
191                match key.as_str() {
192                    "host" => set_host = true,
193                    "user-agent" => set_user_agent = true,
194                    _ => {}
195                }
196            }
197        }
198
199        // Set default Host header if not provided
200        if !set_host {
201            if let Some(host) = uri.host() {
202                let port = self.get_port();
203                if port != 80 && port != 443 {
204                    builder = builder.header("Host", format!("{host}:{port}"));
205                } else {
206                    builder = builder.header("Host", host);
207                }
208            }
209        }
210
211        // Set default User-Agent if not provided
212        if !set_user_agent {
213            builder = builder.header("User-Agent", format!("httpstat.rs/{VERSION}"));
214        }
215        builder
216    }
217}
218
219/// Create an in-memory TLS session store suitable for sharing across multiple
220/// requests (e.g. across a benchmark `-n` loop). Pass the returned `Arc` into
221/// `HttpRequest::tls_session_store` on every request that should be able to
222/// resume a previous TLS session.
223pub fn new_tls_session_store(capacity: usize) -> Arc<dyn ClientSessionStore> {
224    Arc::new(ClientSessionMemoryCache::new(capacity))
225}
226
227// Convert string URL to HttpRequest
228impl TryFrom<&str> for HttpRequest {
229    type Error = Error;
230
231    fn try_from(url: &str) -> Result<Self> {
232        let prefixes = ["http://", "https://", "grpc://", "grpcs://"];
233
234        let value = if prefixes.iter().any(|prefix| url.starts_with(prefix)) {
235            url.to_string()
236        } else {
237            format!("http://{url}")
238        };
239        let uri = value.parse::<Uri>().map_err(|e| Error::Uri { source: e })?;
240        Ok(Self {
241            uri,
242            alpn_protocols: vec![ALPN_HTTP2.to_string(), ALPN_HTTP1.to_string()],
243            ..Default::default()
244        })
245    }
246}
247
248// Convert HttpRequest to hyper Request
249impl TryFrom<&HttpRequest> for Request<Full<Bytes>> {
250    type Error = Error;
251    fn try_from(req: &HttpRequest) -> Result<Self> {
252        req.builder(true)
253            .body(Full::new(req.body.clone().unwrap_or_default()))
254            .map_err(|e| Error::Http { source: e })
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    // ---- parse_host_segment ----
263    #[test]
264    fn parse_host_segment_plain_and_bracketed() {
265        assert_eq!(parse_host_segment("host:443"), ("host".to_string(), ":443"));
266        assert_eq!(parse_host_segment("[::1]:443"), ("::1".to_string(), ":443"));
267        assert_eq!(parse_host_segment("host"), ("host".to_string(), ""));
268    }
269
270    // ---- ConnectTo::parse / matches ----
271    #[test]
272    fn connect_to_full_entry() {
273        let c = ConnectTo::parse("example.com:443:1.2.3.4:8443").unwrap();
274        assert_eq!(c.dst_host, "1.2.3.4");
275        assert_eq!(c.dst_port, Some(8443));
276        assert!(c.matches("example.com", 443));
277        assert!(c.matches("EXAMPLE.COM", 443)); // host match is case-insensitive
278        assert!(!c.matches("other.com", 443));
279        assert!(!c.matches("example.com", 80));
280    }
281
282    #[test]
283    fn connect_to_wildcards() {
284        // empty source host → matches any host on that port
285        let any_host = ConnectTo::parse(":443:1.2.3.4:8443").unwrap();
286        assert!(any_host.matches("whatever.com", 443));
287        assert!(!any_host.matches("whatever.com", 80));
288
289        // empty source port → matches that host on any port
290        let any_port = ConnectTo::parse("example.com::1.2.3.4:8443").unwrap();
291        assert!(any_port.matches("example.com", 1));
292        assert!(any_port.matches("example.com", 65535));
293        assert!(!any_port.matches("other.com", 443));
294    }
295
296    #[test]
297    fn connect_to_ipv6_and_optional_dst_port() {
298        let dst6 = ConnectTo::parse("example.com:443:[2001:db8::1]:8443").unwrap();
299        assert_eq!(dst6.dst_host, "2001:db8::1");
300        assert_eq!(dst6.dst_port, Some(8443));
301
302        let src6 = ConnectTo::parse("[2001:db8::1]:443:1.2.3.4:8443").unwrap();
303        assert!(src6.matches("2001:db8::1", 443));
304
305        // omitted destination port → None (keep the original)
306        let no_dst_port = ConnectTo::parse("example.com:443:1.2.3.4").unwrap();
307        assert_eq!(no_dst_port.dst_host, "1.2.3.4");
308        assert_eq!(no_dst_port.dst_port, None);
309    }
310
311    #[test]
312    fn connect_to_rejects_malformed() {
313        assert!(ConnectTo::parse("example.com").is_none()); // no ':' after host
314        assert!(ConnectTo::parse("example.com:443").is_none()); // missing destination
315        assert!(ConnectTo::parse("example.com:notaport:1.2.3.4:8443").is_none());
316        // bad src port
317    }
318
319    // ---- HttpRequest::try_from ----
320    #[test]
321    fn try_from_adds_scheme_and_default_alpn() {
322        let req = HttpRequest::try_from("example.com").unwrap();
323        assert_eq!(req.uri.scheme_str(), Some("http"));
324        assert_eq!(
325            req.alpn_protocols,
326            vec![ALPN_HTTP2.to_string(), ALPN_HTTP1.to_string()]
327        );
328
329        assert_eq!(
330            HttpRequest::try_from("https://example.com/path")
331                .unwrap()
332                .uri
333                .scheme_str(),
334            Some("https")
335        );
336        assert_eq!(
337            HttpRequest::try_from("grpc://svc:50051")
338                .unwrap()
339                .uri
340                .scheme_str(),
341            Some("grpc")
342        );
343    }
344
345    // ---- get_port ----
346    #[test]
347    fn get_port_defaults_by_scheme() {
348        assert_eq!(HttpRequest::try_from("http://x").unwrap().get_port(), 80);
349        assert_eq!(HttpRequest::try_from("https://x").unwrap().get_port(), 443);
350        assert_eq!(HttpRequest::try_from("grpcs://x").unwrap().get_port(), 443);
351        assert_eq!(
352            HttpRequest::try_from("http://x:8080").unwrap().get_port(),
353            8080
354        );
355    }
356
357    // ---- builder ----
358    #[test]
359    fn builder_sets_default_host_and_user_agent() {
360        let req = HttpRequest::try_from("http://example.com/path").unwrap();
361        let r = req.builder(true).body(()).unwrap();
362        assert_eq!(r.headers().get("host").unwrap(), "example.com");
363        assert!(r
364            .headers()
365            .get("user-agent")
366            .unwrap()
367            .to_str()
368            .unwrap()
369            .starts_with("httpstat.rs/"));
370    }
371
372    #[test]
373    fn builder_includes_nondefault_port_in_host() {
374        let req = HttpRequest::try_from("http://example.com:8080/").unwrap();
375        let r = req.builder(true).body(()).unwrap();
376        assert_eq!(r.headers().get("host").unwrap(), "example.com:8080");
377    }
378
379    #[test]
380    fn builder_respects_custom_host_header() {
381        let mut req = HttpRequest::try_from("http://example.com/").unwrap();
382        let mut hm = HeaderMap::new();
383        hm.insert(http::header::HOST, HeaderValue::from_static("custom.test"));
384        req.headers = Some(hm);
385        let r = req.builder(true).body(()).unwrap();
386        assert_eq!(r.headers().get("host").unwrap(), "custom.test");
387    }
388}