Skip to main content

taba_client/http/
mod.rs

1//! HTTP/1.1 as pure functions, with no input or output (doc 19a, section 18.10.3): building the request,
2//! interpreting the response (`Content-Length`, `chunked` or until the connection ends) and reading the address.
3//! The transport (TCP, TLS) only carries bytes back and forth.
4
5use std::io::{self, BufRead, Read};
6
7/// Cap on a response body: a batch of large base64 messages fits with room to spare.
8pub const MAX_BODY: usize = 256 * 1024 * 1024;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Request {
12    pub method: String,
13    /// Path plus query, already encoded (`/topics?limit=5`).
14    pub target: String,
15    pub headers: Vec<(String, String)>,
16    pub body: Vec<u8>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Response {
21    pub status: u16,
22    pub headers: Vec<(String, String)>,
23    pub body: Vec<u8>,
24}
25
26impl Response {
27    pub fn header(&self, name: &str) -> Option<&str> {
28        self.headers
29            .iter()
30            .find(|(k, _)| k.eq_ignore_ascii_case(name))
31            .map(|(_, v)| v.as_str())
32    }
33}
34
35/// A server's address, read from `http://host:port` or `https://host:port/prefix`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Url {
38    pub https: bool,
39    pub host: String,
40    pub port: u16,
41    /// Path prefix, with no trailing slash (empty at the root).
42    pub base_path: String,
43}
44
45impl Url {
46    pub fn parse(text: &str) -> Result<Url, String> {
47        let text = text.trim();
48        let (https, rest) = if let Some(rest) = text.strip_prefix("https://") {
49            (true, rest)
50        } else if let Some(rest) = text.strip_prefix("http://") {
51            (false, rest)
52        } else {
53            return Err(format!(
54                "the address `{text}` must start with http:// or https://"
55            ));
56        };
57        let (authority, path) = match rest.find('/') {
58            Some(i) => (&rest[..i], &rest[i..]),
59            None => (rest, ""),
60        };
61        if authority.is_empty() || authority.contains('@') {
62            return Err(format!("the address `{text}` has no valid host"));
63        }
64        let default_port = if https { 443 } else { 80 };
65        let (host, port) = if let Some(inner) = authority.strip_prefix('[') {
66            // Literal IPv6: `[::1]` or `[::1]:8080`.
67            let end = inner
68                .find(']')
69                .ok_or_else(|| format!("the address `{text}` has an IPv6 without `]`"))?;
70            let port = match &inner[end + 1..] {
71                "" => default_port,
72                p => parse_port(p.strip_prefix(':').ok_or("invalid port")?, text)?,
73            };
74            (inner[..end].to_owned(), port)
75        } else {
76            match authority.rsplit_once(':') {
77                Some((host, port)) => (host.to_owned(), parse_port(port, text)?),
78                None => (authority.to_owned(), default_port),
79            }
80        };
81        if host.is_empty() {
82            return Err(format!("the address `{text}` has no valid host"));
83        }
84        Ok(Url {
85            https,
86            host,
87            port,
88            base_path: path.trim_end_matches('/').to_owned(),
89        })
90    }
91
92    /// The value of the `Host` header.
93    pub fn host_header(&self) -> String {
94        let host = if self.host.contains(':') {
95            format!("[{}]", self.host)
96        } else {
97            self.host.clone()
98        };
99        let default_port = if self.https { 443 } else { 80 };
100        if self.port == default_port {
101            host
102        } else {
103            format!("{host}:{}", self.port)
104        }
105    }
106
107    /// The same address, with another port.
108    pub fn with_port(&self, port: u16) -> Url {
109        Url {
110            port,
111            ..self.clone()
112        }
113    }
114
115    /// The address as text (`http://host:port`), without the path prefix.
116    pub fn origin(&self) -> String {
117        format!(
118            "{}://{}",
119            if self.https { "https" } else { "http" },
120            self.host_header()
121        )
122    }
123}
124
125fn parse_port(port: &str, text: &str) -> Result<u16, String> {
126    port.parse::<u16>()
127        .ok()
128        .filter(|p| *p != 0)
129        .ok_or_else(|| format!("the address `{text}` has an invalid port"))
130}
131
132/// Encodes a query component: only `A-Za-z0-9-._~` passes as is.
133pub fn percent_encode(s: &str) -> String {
134    let mut out = String::with_capacity(s.len());
135    for b in s.bytes() {
136        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
137            out.push(b as char);
138        } else {
139            out.push_str(&format!("%{b:02X}"));
140        }
141    }
142    out
143}
144
145/// Path plus query (`/topics/x/messages?maxMessages=2`). The path segments already come
146/// encoded by the caller.
147pub fn build_target(base_path: &str, path: &str, query: &[(String, String)]) -> String {
148    let mut target = format!("{base_path}{path}");
149    for (n, (key, value)) in query.iter().enumerate() {
150        target.push(if n == 0 { '?' } else { '&' });
151        target.push_str(&percent_encode(key));
152        target.push('=');
153        target.push_str(&percent_encode(value));
154    }
155    target
156}
157
158/// The request bytes. Adds `Host`, `Content-Length` and `Connection: close`.
159pub fn encode_request(host: &str, req: &Request) -> Vec<u8> {
160    let mut head = format!("{} {} HTTP/1.1\r\nHost: {host}\r\n", req.method, req.target);
161    for (k, v) in &req.headers {
162        head.push_str(&format!("{k}: {v}\r\n"));
163    }
164    if !req.body.is_empty() || matches!(req.method.as_str(), "POST" | "PUT" | "PATCH") {
165        head.push_str(&format!("Content-Length: {}\r\n", req.body.len()));
166    }
167    head.push_str("Connection: close\r\n\r\n");
168    let mut bytes = head.into_bytes();
169    bytes.extend_from_slice(&req.body);
170    bytes
171}
172
173#[derive(Debug)]
174pub enum ReadError {
175    /// Read failure; `received` says whether any byte of the response had already arrived.
176    Io {
177        error: io::Error,
178        received: bool,
179    },
180    Malformed(&'static str),
181}
182
183fn io_err(error: io::Error, received: bool) -> ReadError {
184    ReadError::Io { error, received }
185}
186
187fn read_line<R: BufRead>(r: &mut R, received: bool) -> Result<String, ReadError> {
188    let mut line = Vec::new();
189    let n = r
190        .take(64 * 1024)
191        .read_until(b'\n', &mut line)
192        .map_err(|e| io_err(e, received))?;
193    if n == 0 {
194        return Err(io_err(io::ErrorKind::UnexpectedEof.into(), received));
195    }
196    if line.last() != Some(&b'\n') {
197        return Err(ReadError::Malformed("header line without an end"));
198    }
199    while matches!(line.last(), Some(b'\n' | b'\r')) {
200        line.pop();
201    }
202    String::from_utf8(line).map_err(|_| ReadError::Malformed("header that is not UTF-8"))
203}
204
205/// Reads a complete response. `1xx` responses are skipped.
206pub fn read_response<R: BufRead>(r: &mut R) -> Result<Response, ReadError> {
207    let (status, headers) = loop {
208        let line = read_line(r, false)?;
209        let mut parts = line.splitn(3, ' ');
210        let version = parts.next().unwrap_or_default();
211        if !version.starts_with("HTTP/1.") {
212            return Err(ReadError::Malformed("invalid status line"));
213        }
214        let status: u16 = parts
215            .next()
216            .and_then(|s| s.parse().ok())
217            .ok_or(ReadError::Malformed("invalid status code"))?;
218        let mut headers = Vec::new();
219        loop {
220            let line = read_line(r, true)?;
221            if line.is_empty() {
222                break;
223            }
224            let (name, value) = line
225                .split_once(':')
226                .ok_or(ReadError::Malformed("header without ':'"))?;
227            headers.push((name.trim().to_owned(), value.trim().to_owned()));
228        }
229        if !(100..200).contains(&status) {
230            break (status, headers);
231        }
232    };
233
234    let header = |name: &str| {
235        headers
236            .iter()
237            .find(|(k, _)| k.eq_ignore_ascii_case(name))
238            .map(|(_, v)| v.as_str())
239    };
240    let chunked =
241        header("transfer-encoding").is_some_and(|v| v.to_ascii_lowercase().contains("chunked"));
242    let length = match header("content-length") {
243        Some(v) => Some(
244            v.parse::<usize>()
245                .map_err(|_| ReadError::Malformed("invalid Content-Length"))?,
246        ),
247        None => None,
248    };
249
250    let body = if matches!(status, 204 | 304) {
251        Vec::new()
252    } else if chunked {
253        read_chunked(r)?
254    } else if let Some(n) = length {
255        if n > MAX_BODY {
256            return Err(ReadError::Malformed("body too large"));
257        }
258        let mut body = vec![0; n];
259        r.read_exact(&mut body).map_err(|e| io_err(e, true))?;
260        body
261    } else {
262        // Without a length: the body goes until the connection ends. A TLS closed without `close_notify` arrives
263        // as `UnexpectedEof`, and here that is the end.
264        let mut body = Vec::new();
265        match r.take(MAX_BODY as u64 + 1).read_to_end(&mut body) {
266            Ok(_) => {}
267            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {}
268            Err(e) => return Err(io_err(e, true)),
269        }
270        if body.len() > MAX_BODY {
271            return Err(ReadError::Malformed("body too large"));
272        }
273        body
274    };
275    Ok(Response {
276        status,
277        headers,
278        body,
279    })
280}
281
282fn read_chunked<R: BufRead>(r: &mut R) -> Result<Vec<u8>, ReadError> {
283    let mut body = Vec::new();
284    loop {
285        let line = read_line(r, true)?;
286        let size_text = line.split(';').next().unwrap_or_default().trim();
287        let size = usize::from_str_radix(size_text, 16)
288            .map_err(|_| ReadError::Malformed("invalid chunk size"))?;
289        if size == 0 {
290            // Trailers until the empty line.
291            while !read_line(r, true)?.is_empty() {}
292            return Ok(body);
293        }
294        if body.len() + size > MAX_BODY {
295            return Err(ReadError::Malformed("body too large"));
296        }
297        let start = body.len();
298        body.resize(start + size, 0);
299        r.read_exact(&mut body[start..])
300            .map_err(|e| io_err(e, true))?;
301        if !read_line(r, true)?.is_empty() {
302            return Err(ReadError::Malformed("chunk without a CRLF at the end"));
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests;