1use std::time::Duration;
8
9use tokio::io::{AsyncReadExt, AsyncWriteExt};
10use tokio::net::TcpStream;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Method {
14 Get,
15 Post,
16}
17
18impl Method {
19 fn as_str(self) -> &'static str {
20 match self {
21 Method::Get => "GET",
22 Method::Post => "POST",
23 }
24 }
25}
26
27#[derive(Debug)]
28pub struct HttpRequest {
29 method: Method,
30 url: String,
31 headers: Vec<(String, String)>,
32 body: Vec<u8>,
33 timeout: Duration,
34 max_response_bytes: usize,
35}
36
37#[derive(Debug)]
38pub struct HttpResponse {
39 pub status: u16,
40 pub headers: Vec<(String, String)>,
41 pub body: Vec<u8>,
42}
43
44#[derive(Debug)]
45pub enum HttpError {
46 UnsupportedUrl(String),
47 InvalidHeader(String),
48 Io(std::io::Error),
49 Malformed(&'static str),
50 MalformedDetail(String),
51 TimedOut,
52}
53
54impl std::fmt::Display for HttpError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 HttpError::UnsupportedUrl(u) => write!(f, "unsupported URL (http:// only): {u}"),
58 HttpError::InvalidHeader(h) => write!(f, "invalid HTTP header: {h}"),
59 HttpError::Io(e) => write!(f, "io: {e}"),
60 HttpError::Malformed(what) => write!(f, "malformed HTTP response: {what}"),
61 HttpError::MalformedDetail(what) => write!(f, "malformed HTTP response: {what}"),
62 HttpError::TimedOut => write!(f, "request timed out"),
63 }
64 }
65}
66
67impl std::error::Error for HttpError {}
68
69impl From<std::io::Error> for HttpError {
70 fn from(e: std::io::Error) -> Self {
71 HttpError::Io(e)
72 }
73}
74
75impl HttpRequest {
76 pub fn new(method: Method, url: impl Into<String>) -> Self {
77 Self {
78 method,
79 url: url.into(),
80 headers: Vec::new(),
81 body: Vec::new(),
82 timeout: Duration::from_secs(5),
83 max_response_bytes: 16 * 1024 * 1024,
84 }
85 }
86
87 pub fn get(url: impl Into<String>) -> Self {
88 Self::new(Method::Get, url)
89 }
90
91 pub fn post(url: impl Into<String>) -> Self {
92 Self::new(Method::Post, url)
93 }
94
95 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
96 self.headers.push((name.into(), value.into()));
97 self
98 }
99
100 pub fn bearer_auth(self, token: impl AsRef<str>) -> Self {
101 let token = token.as_ref();
102 if token.is_empty() {
103 self
104 } else {
105 self.header("Authorization", format!("Bearer {token}"))
106 }
107 }
108
109 pub fn json_body(self, bytes: Vec<u8>) -> Self {
110 self.header("Content-Type", "application/json").body(bytes)
111 }
112
113 pub fn body(mut self, bytes: Vec<u8>) -> Self {
114 self.body = bytes;
115 self
116 }
117
118 pub fn timeout(mut self, timeout: Duration) -> Self {
119 self.timeout = timeout;
120 self
121 }
122
123 pub fn max_response_bytes(mut self, max: usize) -> Self {
124 self.max_response_bytes = max;
125 self
126 }
127
128 pub async fn send(self) -> Result<HttpResponse, HttpError> {
129 tokio::time::timeout(self.timeout, send_inner(self))
130 .await
131 .map_err(|_| HttpError::TimedOut)?
132 }
133}
134
135pub async fn get_text(url: &str, timeout: Duration) -> Result<String, HttpError> {
136 let resp = HttpRequest::get(url).timeout(timeout).send().await?;
137 String::from_utf8(resp.body).map_err(|_| HttpError::Malformed("response body is not utf-8"))
138}
139
140async fn send_inner(req: HttpRequest) -> Result<HttpResponse, HttpError> {
141 validate_headers(&req.headers)?;
142 let (addr, host, path) = parse_url(&req.url)?;
143 let mut stream = TcpStream::connect(&addr).await?;
144
145 let mut raw_request = format!(
146 "{} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n",
147 req.method.as_str()
148 );
149 for (name, value) in &req.headers {
150 raw_request.push_str(name);
151 raw_request.push_str(": ");
152 raw_request.push_str(value);
153 raw_request.push_str("\r\n");
154 }
155 if !req.body.is_empty() || req.method == Method::Post {
156 raw_request.push_str(&format!("Content-Length: {}\r\n", req.body.len()));
157 }
158 raw_request.push_str("\r\n");
159 stream.write_all(raw_request.as_bytes()).await?;
160 if !req.body.is_empty() {
161 stream.write_all(&req.body).await?;
162 }
163
164 let mut raw = Vec::with_capacity(4096);
165 let mut buf = [0u8; 8192];
166 loop {
167 let n = stream.read(&mut buf).await?;
168 if n == 0 {
169 break;
170 }
171 raw.extend_from_slice(&buf[..n]);
172 if raw.len() > req.max_response_bytes {
173 return Err(HttpError::Malformed("response exceeds configured cap"));
174 }
175 if response_complete(&raw)? {
176 break;
177 }
178 }
179 parse_response(&raw)
180}
181
182fn validate_headers(headers: &[(String, String)]) -> Result<(), HttpError> {
183 for (name, value) in headers {
184 if name.is_empty()
185 || name
186 .bytes()
187 .any(|b| b <= 0x20 || b == b':' || b == b'\r' || b == b'\n')
188 {
189 return Err(HttpError::InvalidHeader(name.clone()));
190 }
191 if is_reserved_request_header(name) {
192 return Err(HttpError::InvalidHeader(name.clone()));
193 }
194 if value.bytes().any(|b| b == b'\r' || b == b'\n') {
195 return Err(HttpError::InvalidHeader(name.clone()));
196 }
197 }
198 Ok(())
199}
200
201fn is_reserved_request_header(name: &str) -> bool {
202 matches!(
203 name.to_ascii_lowercase().as_str(),
204 "host" | "connection" | "content-length" | "transfer-encoding"
205 )
206}
207
208fn parse_url(url: &str) -> Result<(String, String, String), HttpError> {
210 let rest = url
211 .strip_prefix("http://")
212 .ok_or_else(|| HttpError::UnsupportedUrl(url.to_string()))?;
213 let (authority, path) = match rest.find(['/', '?']) {
214 Some(i) if rest.as_bytes()[i] == b'?' => (&rest[..i], format!("/{}", &rest[i..])),
215 Some(i) => (&rest[..i], rest[i..].to_string()),
216 None => (rest, "/".to_string()),
217 };
218 if authority.is_empty()
219 || authority.contains('@')
220 || authority.contains('#')
221 || authority.bytes().any(|b| b <= 0x20 || b == b'/')
222 {
223 return Err(HttpError::UnsupportedUrl(url.to_string()));
224 }
225
226 let (addr, host_header) = if let Some(after_bracket) = authority.strip_prefix('[') {
227 let end = after_bracket
228 .find(']')
229 .ok_or_else(|| HttpError::UnsupportedUrl(url.to_string()))?;
230 let host = &after_bracket[..end];
231 let rest = &after_bracket[end + 1..];
232 if host.is_empty() {
233 return Err(HttpError::UnsupportedUrl(url.to_string()));
234 }
235 let port = if rest.is_empty() {
236 "80"
237 } else {
238 rest.strip_prefix(':')
239 .filter(|p| valid_port(p))
240 .ok_or_else(|| HttpError::UnsupportedUrl(url.to_string()))?
241 };
242 (format!("[{host}]:{port}"), authority.to_string())
243 } else if authority.matches(':').count() > 1 {
244 return Err(HttpError::UnsupportedUrl(url.to_string()));
245 } else if let Some((host, port)) = authority.rsplit_once(':') {
246 if host.is_empty() || !valid_port(port) {
247 return Err(HttpError::UnsupportedUrl(url.to_string()));
248 }
249 (authority.to_string(), authority.to_string())
250 } else {
251 (format!("{authority}:80"), authority.to_string())
252 };
253 Ok((addr, host_header, path))
254}
255
256fn valid_port(port: &str) -> bool {
257 !port.is_empty() && port.parse::<u16>().is_ok()
258}
259
260fn find_header_end(raw: &[u8]) -> Option<usize> {
261 raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
262}
263
264fn response_complete(raw: &[u8]) -> Result<bool, HttpError> {
265 let Some(header_end) = find_header_end(raw) else {
266 return Ok(false);
267 };
268 let head = String::from_utf8_lossy(&raw[..header_end]);
269 let framing = response_framing(&head)?;
270 if let Some(len) = framing.content_length {
271 return Ok(raw.len() >= header_end + len);
272 }
273 if framing.chunked {
274 return Ok(decode_chunked(&raw[header_end..]).is_ok());
275 }
276 Ok(false)
277}
278
279#[derive(Debug, Default)]
280struct ResponseFraming {
281 content_length: Option<usize>,
282 chunked: bool,
283}
284
285fn response_framing(head: &str) -> Result<ResponseFraming, HttpError> {
286 let mut framing = ResponseFraming::default();
287 for line in head.lines().skip(1) {
288 if line.is_empty() {
289 break;
290 }
291 let Some((name, value)) = line.split_once(':') else {
292 return Err(HttpError::MalformedDetail(format!(
293 "header line without colon: {line}"
294 )));
295 };
296 if name.bytes().any(|b| b <= 0x20) {
297 return Err(HttpError::MalformedDetail(format!(
298 "invalid header name: {name}"
299 )));
300 }
301 if value.starts_with(' ') && value.trim_start().starts_with([' ', '\t']) {
302 return Err(HttpError::Malformed("obsolete folded header"));
303 }
304 if name.eq_ignore_ascii_case("content-length") {
305 let parsed = value
306 .trim()
307 .parse::<usize>()
308 .map_err(|_| HttpError::Malformed("bad content-length"))?;
309 match framing.content_length {
310 Some(existing) if existing != parsed => {
311 return Err(HttpError::Malformed("conflicting content-length"));
312 }
313 Some(_) => {}
314 None => framing.content_length = Some(parsed),
315 }
316 } else if name.eq_ignore_ascii_case("transfer-encoding") {
317 let codings = value
318 .split(',')
319 .map(|v| v.trim().to_ascii_lowercase())
320 .filter(|v| !v.is_empty())
321 .collect::<Vec<_>>();
322 if codings.last().is_some_and(|v| v == "chunked") {
323 framing.chunked = true;
324 } else if codings.iter().any(|v| v == "chunked") {
325 return Err(HttpError::Malformed("chunked transfer-coding is not final"));
326 }
327 }
328 }
329 if framing.chunked && framing.content_length.is_some() {
330 return Err(HttpError::Malformed(
331 "both transfer-encoding and content-length present",
332 ));
333 }
334 Ok(framing)
335}
336
337fn decode_chunked(mut rest: &[u8]) -> Result<Vec<u8>, HttpError> {
338 let mut out = Vec::new();
339 loop {
340 let line_end = rest
341 .windows(2)
342 .position(|w| w == b"\r\n")
343 .ok_or(HttpError::Malformed("incomplete chunk header"))?;
344 let size_text = std::str::from_utf8(&rest[..line_end])
345 .map_err(|_| HttpError::Malformed("chunk header is not utf-8"))?;
346 let size_part = size_text
347 .trim()
348 .split(';')
349 .next()
350 .ok_or(HttpError::Malformed("empty chunk size"))?;
351 let size = usize::from_str_radix(size_part, 16)
352 .map_err(|_| HttpError::Malformed("bad chunk size"))?;
353 rest = &rest[line_end + 2..];
354 if size == 0 {
355 if rest.windows(2).position(|w| w == b"\r\n").is_none() {
356 return Err(HttpError::Malformed("incomplete chunk trailers"));
357 }
358 return Ok(out);
359 }
360 if rest.len() < size + 2 || &rest[size..size + 2] != b"\r\n" {
361 return Err(HttpError::Malformed("incomplete chunk body"));
362 }
363 out.extend_from_slice(&rest[..size]);
364 rest = &rest[size + 2..];
365 }
366}
367
368fn parse_response(raw: &[u8]) -> Result<HttpResponse, HttpError> {
369 let header_end = find_header_end(raw).ok_or(HttpError::Malformed("no header terminator"))?;
370 let head = String::from_utf8_lossy(&raw[..header_end]).to_string();
371 let status_line = head.lines().next().ok_or(HttpError::Malformed("empty"))?;
372 let mut parts = status_line.split_whitespace();
373 let version = parts.next().ok_or(HttpError::Malformed("status line"))?;
374 if !version.starts_with("HTTP/1.") {
375 return Err(HttpError::Malformed("not HTTP/1.x"));
376 }
377 let status: u16 = parts
378 .next()
379 .and_then(|s| s.parse().ok())
380 .ok_or(HttpError::Malformed("status code"))?;
381 let framing = response_framing(&head)?;
382 let headers = parse_headers(&head)?;
383 let body_raw = &raw[header_end..];
384 let body = if let Some(len) = framing.content_length {
385 if body_raw.len() < len {
386 return Err(HttpError::Malformed("body shorter than content-length"));
387 }
388 body_raw[..len].to_vec()
389 } else if framing.chunked {
390 decode_chunked(body_raw)?
391 } else {
392 body_raw.to_vec()
393 };
394 Ok(HttpResponse {
395 status,
396 headers,
397 body,
398 })
399}
400
401fn parse_headers(head: &str) -> Result<Vec<(String, String)>, HttpError> {
402 let mut headers = Vec::new();
403 for line in head.lines().skip(1) {
404 if line.is_empty() {
405 break;
406 }
407 let Some((name, value)) = line.split_once(':') else {
408 return Err(HttpError::Malformed("header line without colon"));
409 };
410 headers.push((name.trim().to_string(), value.trim().to_string()));
411 }
412 Ok(headers)
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn parses_content_length_response() {
421 let raw =
422 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
423 let r = parse_response(raw).unwrap();
424 assert_eq!(r.status, 200);
425 assert_eq!(r.body, b"{}");
426 assert_eq!(
427 r.headers,
428 vec![
429 ("Content-Type".to_string(), "application/json".to_string()),
430 ("Content-Length".to_string(), "2".to_string())
431 ]
432 );
433 }
434
435 #[test]
436 fn parses_chunked_response() {
437 let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\n{\"a\r\n2\r\n\":\r\n2\r\n1}\r\n0\r\n\r\n";
438 let r = parse_response(raw).unwrap();
439 assert_eq!(r.body, b"{\"a\":1}".to_vec());
440 }
441
442 #[test]
443 fn rejects_https() {
444 assert!(matches!(
445 parse_url("https://example.com/x"),
446 Err(HttpError::UnsupportedUrl(_))
447 ));
448 }
449
450 #[test]
451 fn parses_query_only_and_ipv6_urls() {
452 assert_eq!(
453 parse_url("http://example.com?x=1").unwrap(),
454 (
455 "example.com:80".to_string(),
456 "example.com".to_string(),
457 "/?x=1".to_string()
458 )
459 );
460 assert_eq!(
461 parse_url("http://[::1]:8080/v1").unwrap(),
462 (
463 "[::1]:8080".to_string(),
464 "[::1]:8080".to_string(),
465 "/v1".to_string()
466 )
467 );
468 }
469
470 #[test]
471 fn rejects_ambiguous_authorities() {
472 for url in [
473 "http://user@example.com/",
474 "http://example.com:abc/",
475 "http://::1/",
476 "http://exa mple.com/",
477 ] {
478 assert!(
479 matches!(parse_url(url), Err(HttpError::UnsupportedUrl(_))),
480 "{url}"
481 );
482 }
483 }
484
485 #[test]
486 fn rejects_header_injection() {
487 let err = validate_headers(&[("X-Test".into(), "ok\r\nbad".into())]).unwrap_err();
488 assert!(matches!(err, HttpError::InvalidHeader(_)));
489 }
490
491 #[test]
492 fn rejects_reserved_request_headers() {
493 let err = validate_headers(&[("Content-Length".into(), "1".into())]).unwrap_err();
494 assert!(matches!(err, HttpError::InvalidHeader(_)));
495 }
496
497 #[test]
498 fn status_errors_surface() {
499 let raw = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 4\r\n\r\ndeny";
500 let r = parse_response(raw).unwrap();
501 assert_eq!(r.status, 403);
502 assert_eq!(r.body, b"deny");
503 }
504
505 #[test]
506 fn rejects_conflicting_response_framing() {
507 let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\nab";
508 assert!(matches!(
509 parse_response(raw),
510 Err(HttpError::Malformed("conflicting content-length"))
511 ));
512
513 let raw =
514 b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n";
515 assert!(matches!(
516 parse_response(raw),
517 Err(HttpError::Malformed(
518 "both transfer-encoding and content-length present"
519 ))
520 ));
521 }
522}