Skip to main content

rightkit_qa/
http.rs

1//! Minimal blocking HTTP/1.1 client over loopback `TcpStream`. Every call has an
2//! explicit deadline: a request that never answers is a failed step, not a hang.
3use crate::util::{err, Result};
4use std::io::{Read, Write};
5use std::net::{SocketAddr, TcpStream};
6use std::time::{Duration, Instant};
7
8#[derive(Debug, Clone)]
9pub struct Response {
10    pub status: u16,
11    pub content_type: String,
12    pub body: Vec<u8>,
13    /// Every response header in wire order, names lowercased, values trimmed. Repeated headers appear repeatedly.
14    pub headers: Vec<(String, String)>,
15}
16
17impl Response {
18    pub fn text(&self) -> String {
19        String::from_utf8_lossy(&self.body).into_owned()
20    }
21
22    /// First value of the response header `name` (case-insensitive).
23    pub fn header(&self, name: &str) -> Option<&str> {
24        self.headers
25            .iter()
26            .find(|(k, _)| k.eq_ignore_ascii_case(name))
27            .map(|(_, v)| v.as_str())
28    }
29
30    /// Every value of the response header `name` (case-insensitive), in wire order.
31    pub fn header_all(&self, name: &str) -> Vec<&str> {
32        self.headers
33            .iter()
34            .filter(|(k, _)| k.eq_ignore_ascii_case(name))
35            .map(|(_, v)| v.as_str())
36            .collect()
37    }
38}
39
40/// One request. `auth` is the full `Authorization` header value when present.
41pub fn request(
42    addr: SocketAddr,
43    method: &str,
44    path: &str,
45    auth: Option<&str>,
46    body: Option<&str>,
47    timeout: Duration,
48) -> Result<Response> {
49    request_with(addr, method, path, auth, &[], body, timeout)
50}
51
52fn header_ok(text: &str) -> bool {
53    !text.chars().any(|c| c == '\r' || c == '\n' || c == '\0')
54}
55
56/// Like [`request`] with custom request headers. `headers` are written after the defaults; a `Host`, `Connection`,
57/// `Accept`, `Content-Length` or `Authorization` (when `auth` is given) entry replaces the default of that name, and
58/// a `Content-Type` entry replaces the JSON default sent with a body. A name or value holding CR, LF or NUL, or a
59/// name that is empty or holds `:`, is refused. The response carries every header in [`Response::headers`].
60pub fn request_with(
61    addr: SocketAddr,
62    method: &str,
63    path: &str,
64    auth: Option<&str>,
65    headers: &[(&str, &str)],
66    body: Option<&str>,
67    timeout: Duration,
68) -> Result<Response> {
69    for (name, value) in headers {
70        if name.is_empty() || name.contains(':') || !header_ok(name) || !header_ok(value) {
71            return err(format!("header `{name}` is not a plain header"));
72        }
73    }
74    let custom = |name: &str| headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(name));
75    if !addr.ip().is_loopback() {
76        return err(format!(
77            "QA control endpoint must be loopback, received {addr}"
78        ));
79    }
80    let deadline = Instant::now() + timeout;
81    let mut s = TcpStream::connect_timeout(&addr, timeout.min(Duration::from_secs(5)))?;
82    s.set_write_timeout(Some(timeout))?;
83    let mut head = format!("{method} {path} HTTP/1.1\r\n");
84    if !custom("host") {
85        head.push_str(&format!("Host: {addr}\r\n"));
86    }
87    if !custom("connection") {
88        head.push_str("Connection: close\r\n");
89    }
90    if !custom("accept") {
91        head.push_str("Accept: */*\r\n");
92    }
93    if let Some(a) = auth.filter(|_| !custom("authorization")) {
94        head.push_str(&format!("Authorization: {a}\r\n"));
95    }
96    let payload = body.unwrap_or("");
97    if body.is_some() {
98        if !custom("content-type") {
99            head.push_str("Content-Type: application/json\r\n");
100        }
101        if !custom("content-length") {
102            head.push_str(&format!("Content-Length: {}\r\n", payload.len()));
103        }
104    }
105    for (name, value) in headers {
106        head.push_str(&format!("{name}: {value}\r\n"));
107    }
108    head.push_str("\r\n");
109    s.write_all(head.as_bytes())?;
110    s.write_all(payload.as_bytes())?;
111    let mut buf = Vec::new();
112    let mut chunk = [0u8; 16384];
113    loop {
114        let left = deadline.saturating_duration_since(Instant::now());
115        if left.is_zero() {
116            return err(format!(
117                "{method} {path} exceeded {}ms",
118                timeout.as_millis()
119            ));
120        }
121        s.set_read_timeout(Some(left))?;
122        match s.read(&mut chunk) {
123            Ok(0) => break,
124            Ok(n) => buf.extend_from_slice(&chunk[..n]),
125            Err(e)
126                if matches!(
127                    e.kind(),
128                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
129                ) =>
130            {
131                return err(format!(
132                    "{method} {path} exceeded {}ms",
133                    timeout.as_millis()
134                ))
135            }
136            Err(e) => return Err(e.into()),
137        }
138    }
139    let split = buf
140        .windows(4)
141        .position(|w| w == b"\r\n\r\n")
142        .ok_or_else(|| crate::util::Error("malformed HTTP response".into()))?;
143    let head = String::from_utf8_lossy(&buf[..split]).to_string();
144    let status = head
145        .lines()
146        .next()
147        .and_then(|l| l.split_whitespace().nth(1))
148        .and_then(|c| c.parse().ok())
149        .unwrap_or(0);
150    let content_type = head
151        .lines()
152        .find_map(|l| {
153            l.to_ascii_lowercase()
154                .strip_prefix("content-type:")
155                .map(|v| v.trim().to_string())
156        })
157        .unwrap_or_default();
158    let headers = parse_headers(&head);
159    Ok(Response {
160        status,
161        content_type,
162        body: buf[split + 4..].to_vec(),
163        headers,
164    })
165}
166
167fn parse_headers(head: &str) -> Vec<(String, String)> {
168    head.lines()
169        .skip(1)
170        .filter_map(|l| l.split_once(':'))
171        .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
172        .collect()
173}
174
175pub fn loopback(port: u16) -> SocketAddr {
176    SocketAddr::from(([127, 0, 0, 1], port))
177}
178
179pub fn port_open(port: u16) -> bool {
180    TcpStream::connect_timeout(&loopback(port), Duration::from_millis(250)).is_ok()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::net::TcpListener;
187
188    /// One-shot loopback server: returns the raw request it received, after sending `reply`.
189    fn serve(reply: &'static str) -> (SocketAddr, std::thread::JoinHandle<String>) {
190        let l = TcpListener::bind("127.0.0.1:0").unwrap();
191        let addr = l.local_addr().unwrap();
192        let h = std::thread::spawn(move || {
193            let (mut s, _) = l.accept().unwrap();
194            let mut raw = Vec::new();
195            let mut chunk = [0u8; 4096];
196            loop {
197                let n = s.read(&mut chunk).unwrap();
198                raw.extend_from_slice(&chunk[..n]);
199                if n == 0 || raw.windows(4).any(|w| w == b"\r\n\r\n") {
200                    break;
201                }
202            }
203            // Body bytes may arrive in a later segment; give them a moment so the request text is whole.
204            std::thread::sleep(Duration::from_millis(50));
205            s.set_read_timeout(Some(Duration::from_millis(50))).ok();
206            while let Ok(n) = s.read(&mut chunk) {
207                if n == 0 {
208                    break;
209                }
210                raw.extend_from_slice(&chunk[..n]);
211            }
212            s.write_all(reply.as_bytes()).unwrap();
213            String::from_utf8_lossy(&raw).into_owned()
214        });
215        (addr, h)
216    }
217
218    const REPLY: &str = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nX-Thing: One\r\nSet-Cookie: a=1\r\nset-cookie: b=2\r\nConnection: close\r\n\r\nok";
219
220    #[test]
221    fn custom_headers_are_sent_and_response_headers_returned() {
222        let (addr, h) = serve(REPLY);
223        let r = request_with(
224            addr,
225            "GET",
226            "/x",
227            None,
228            &[("X-Api-Key", "k1"), ("Accept", "text/plain")],
229            None,
230            Duration::from_secs(5),
231        )
232        .unwrap();
233        let sent = h.join().unwrap();
234        assert!(sent.contains("X-Api-Key: k1\r\n"));
235        assert!(sent.contains("Accept: text/plain\r\n"));
236        assert!(!sent.contains("Accept: */*"));
237        assert_eq!(r.status, 200);
238        assert_eq!(r.text(), "ok");
239        assert_eq!(r.header("X-THING"), Some("One"));
240        assert_eq!(r.header_all("set-cookie"), vec!["a=1", "b=2"]);
241        assert_eq!(r.header("missing"), None);
242    }
243
244    #[test]
245    fn custom_header_overrides_defaults_and_plain_request_is_unchanged() {
246        let (addr, h) = serve(REPLY);
247        request_with(
248            addr,
249            "POST",
250            "/x",
251            Some("Bearer a"),
252            &[
253                ("authorization", "Bearer b"),
254                ("Content-Type", "text/plain"),
255            ],
256            Some("hi"),
257            Duration::from_secs(5),
258        )
259        .unwrap();
260        let sent = h.join().unwrap();
261        assert!(sent.contains("authorization: Bearer b\r\n"));
262        assert!(!sent.contains("Bearer a"));
263        assert!(sent.contains("Content-Type: text/plain\r\n"));
264        assert!(!sent.contains("application/json"));
265        assert!(sent.contains("Content-Length: 2\r\n"));
266
267        let (addr, h) = serve(REPLY);
268        let r = request(
269            addr,
270            "POST",
271            "/y",
272            Some("Bearer a"),
273            Some("{}"),
274            Duration::from_secs(5),
275        )
276        .unwrap();
277        let sent = h.join().unwrap();
278        assert!(sent.contains("Authorization: Bearer a\r\n"));
279        assert!(sent.contains("Content-Type: application/json\r\n"));
280        assert_eq!(r.content_type, "text/plain");
281    }
282
283    #[test]
284    fn header_injection_is_refused_before_connecting() {
285        let addr = loopback(1);
286        for bad in [
287            ("X\r\nEvil", "v"),
288            ("X", "v\r\nEvil: 1"),
289            ("", "v"),
290            ("A:B", "v"),
291        ] {
292            let e = request_with(
293                addr,
294                "GET",
295                "/",
296                None,
297                &[bad],
298                None,
299                Duration::from_millis(50),
300            )
301            .unwrap_err();
302            assert!(e.0.contains("not a plain header"), "{}", e.0);
303        }
304    }
305}