1use 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 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 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 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
40pub 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
56pub 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 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 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}