Skip to main content

tf_transport/
lib.rs

1//! First-party transport primitives for TrustForge-owned clients.
2//!
3//! This crate currently owns the plain HTTP/1.1 client used for local
4//! daemon calls. HTTPS/TLS/QUIC/HTTP3 are intentionally not hidden here
5//! yet; they will land as named, audit-gated transport backends.
6
7use 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
208/// Split `http://host[:port]/path` into (address, host header, path).
209fn 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}