use alloc::string::String;
use alloc::sync::Arc;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt::{self, Write as _};
use embassy_net::dns::DnsQueryType;
use embassy_net::tcp::TcpSocket;
use embassy_net::{IpEndpoint, Stack};
use embassy_time::{Duration, with_timeout};
use embedded_io_async::{Read, Write};
use rustls::ClientConfig;
use crate::tls::{self, TlsStream};
const PREVIEW: usize = 256;
#[derive(Debug, Clone, Copy)]
pub struct Client {
pub user_agent: &'static str,
pub timeout: Duration,
pub response_max: usize,
pub tcp_buffer: usize,
}
impl Client {
pub const DEFAULT: Self = Client {
user_agent: "rpi-kickstart",
timeout: Duration::from_secs(30),
response_max: 4 * 1024,
tcp_buffer: 4 * 1024,
};
pub async fn send(
&self,
stack: Stack<'_>,
config: Arc<ClientConfig>,
request: &Request<'_>,
) -> Result<Response, Error> {
match with_timeout(self.timeout, self.exchange(stack, config, request)).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout),
}
}
async fn exchange(
&self,
stack: Stack<'_>,
config: Arc<ClientConfig>,
request: &Request<'_>,
) -> Result<Response, Error> {
let addresses = stack
.dns_query(request.host, DnsQueryType::A)
.await
.map_err(|_| Error::Dns)?;
let address = *addresses.first().ok_or(Error::NoAddress)?;
let mut rx_buffer = vec![0u8; self.tcp_buffer];
let mut tx_buffer = vec![0u8; self.tcp_buffer];
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket
.connect(IpEndpoint::new(address, request.port))
.await
.map_err(|_| Error::Connect)?;
let mut tls = TlsStream::connect(socket, config, request.host)
.await
.map_err(Error::Tls)?;
tls.write_all(head(request, self.user_agent).as_bytes())
.await
.map_err(|_| Error::Io)?;
if !request.body.is_empty() {
tls.write_all(request.body).await.map_err(|_| Error::Io)?;
}
tls.flush().await.map_err(|_| Error::Io)?;
let mut buffer = vec![0u8; self.response_max];
let mut filled = 0;
while filled < buffer.len() {
match tls.read(&mut buffer[filled..]).await {
Ok(0) => break,
Ok(n) => filled += n,
Err(_) if filled > 0 => break,
Err(_) => return Err(Error::Io),
}
}
parse(&buffer[..filled])
}
}
#[derive(Debug, Clone, Copy)]
pub struct Request<'a> {
pub method: &'a str,
pub host: &'a str,
pub port: u16,
pub path: &'a str,
pub headers: &'a [(&'a str, &'a str)],
pub body: &'a [u8],
}
impl<'a> Request<'a> {
pub fn get(host: &'a str, path: &'a str) -> Self {
Request {
method: "GET",
host,
port: 443,
path,
headers: &[],
body: &[],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub status: u16,
pub status_line: String,
pub body: Vec<u8>,
}
impl Response {
pub fn preview(&self) -> String {
let body = &self.body[..self.body.len().min(PREVIEW)];
let cleaned: String = String::from_utf8_lossy(body)
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
String::from(cleaned.trim())
}
}
#[derive(Debug)]
pub enum Error {
Dns,
NoAddress,
Connect,
Tls(tls::Error),
Io,
BadResponse,
Timeout,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Dns => f.write_str("name would not resolve"),
Error::NoAddress => f.write_str("name resolved to nothing"),
Error::Connect => f.write_str("could not connect"),
Error::Tls(e) => write!(f, "{e}"),
Error::Io => f.write_str("connection failed mid-exchange"),
Error::BadResponse => f.write_str("not an HTTP response"),
Error::Timeout => f.write_str("timed out"),
}
}
}
impl core::error::Error for Error {}
fn head(request: &Request<'_>, user_agent: &str) -> String {
let mut text = String::new();
let _ = write!(
text,
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nUser-Agent: {user_agent}\r\n",
request.method, request.path, request.host
);
if request.method != "GET" {
let _ = write!(text, "Content-Length: {}\r\n", request.body.len());
}
for (name, value) in request.headers {
let _ = write!(text, "{name}: {value}\r\n");
}
text.push_str("\r\n");
text
}
fn parse(response: &[u8]) -> Result<Response, Error> {
let line_end = response
.iter()
.position(|&b| b == b'\n')
.ok_or(Error::BadResponse)?;
let line = core::str::from_utf8(&response[..line_end])
.map_err(|_| Error::BadResponse)?
.trim_end();
if !line.starts_with("HTTP/") {
return Err(Error::BadResponse);
}
let status: u16 = line
.split_whitespace()
.nth(1)
.ok_or(Error::BadResponse)?
.parse()
.map_err(|_| Error::BadResponse)?;
let body = match response.windows(4).position(|window| window == b"\r\n\r\n") {
Some(at) => {
let (head, body) = (&response[..at], &response[at + 4..]);
if is_chunked(head) {
dechunk(body).ok_or(Error::BadResponse)?
} else {
Vec::from(body)
}
}
None => Vec::new(),
};
Ok(Response {
status,
status_line: String::from(line),
body,
})
}
fn is_chunked(head: &[u8]) -> bool {
head.split(|&b| b == b'\n').any(|line| {
let Some(colon) = line.iter().position(|&b| b == b':') else {
return false;
};
line[..colon]
.trim_ascii()
.eq_ignore_ascii_case(b"transfer-encoding")
&& line[colon + 1..]
.windows(7)
.any(|word| word.eq_ignore_ascii_case(b"chunked"))
})
}
fn dechunk(mut body: &[u8]) -> Option<Vec<u8>> {
let mut whole = Vec::new();
loop {
let line_end = body.iter().position(|&b| b == b'\n')?;
let line = body[..line_end].trim_ascii();
let size = line.split(|&b| b == b';').next()?;
let size = usize::from_str_radix(core::str::from_utf8(size).ok()?, 16).ok()?;
body = body.get(line_end + 1..)?;
if size == 0 {
return Some(whole);
}
whole.extend_from_slice(body.get(..size)?);
body = &body[size..];
while let Some((&first, rest)) = body.split_first() {
if first != b'\r' && first != b'\n' {
break;
}
body = rest;
if first == b'\n' {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_head_names_the_host_and_closes() {
let get = Request::get("api.example.com", "/v1/x?y=1");
assert_eq!(
head(&get, "board"),
"GET /v1/x?y=1 HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\
User-Agent: board\r\n\r\n"
);
let post = Request {
method: "POST",
headers: &[("Authorization", "Bearer t")],
body: b"hello",
..Request::get("ntfy.example.com", "/topic")
};
let text = head(&post, "board");
assert!(text.contains("Content-Length: 5\r\n"), "{text}");
assert!(text.ends_with("Authorization: Bearer t\r\n\r\n"), "{text}");
}
#[test]
fn a_plain_body_comes_back_as_it_arrived() {
let response = parse(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.status_line, "HTTP/1.1 200 OK");
assert_eq!(response.body, b"hello");
}
#[test]
fn a_chunked_body_is_reassembled() {
let wire = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\
5\r\nhello\r\n7;ext=1\r\n, world\r\n0\r\nTrailer: x\r\n\r\n";
assert_eq!(parse(wire).unwrap().body, b"hello, world");
let wire = b"HTTP/1.1 200 OK\r\ntransfer-encoding: Chunked\r\n\r\n3\nabc\n0\n\n";
assert_eq!(parse(wire).unwrap().body, b"abc");
}
#[test]
fn a_chunked_body_cut_short_is_refused_not_shortened() {
let wire = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n10\r\nonly six";
assert!(matches!(parse(wire), Err(Error::BadResponse)));
}
#[test]
fn what_is_not_a_response() {
for wire in [
&b""[..],
b"garbage\r\n",
b"HTTP/1.1 abc OK\r\n\r\n",
b"SSH-2.0-x\r\n",
] {
assert!(matches!(parse(wire), Err(Error::BadResponse)), "{wire:?}");
}
let response = parse(b"HTTP/1.1 204 No Content\r\n").unwrap();
assert_eq!((response.status, response.body.len()), (204, 0));
}
#[test]
fn a_refusal_previews_safely() {
let mut body = b"bad\r\n\x1b[2Jtoken".to_vec();
body.extend(core::iter::repeat_n(b'x', 1000));
let response = Response {
status: 401,
status_line: String::from("HTTP/1.1 401 Unauthorized"),
body,
};
let preview = response.preview();
assert!(preview.starts_with("bad [2Jtoken"), "{preview}");
assert!(preview.len() <= PREVIEW);
}
}