//! One-shot HTTP/1.1 over loopback: what `/json/version` needs to hand over a
//! websocket URL, and what the API half of the matrix runs on.
//!
//! Plaintext only, and that is a design boundary rather than a gap: the matrix
//! drives a console the harness itself started on 127.0.0.1. Reaching a public
//! site would mean TLS, and gunnar's `sitetest/` already owns the live-site
//! walk.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
pub struct Resp {
pub status: u16,
pub body: String,
pub headers: Vec<(String, String)>,
}
impl Resp {
pub fn header(&self, name: &str) -> Option<&str> {
let n = name.to_ascii_lowercase();
self.headers.iter().find(|(k, _)| *k == n).map(|(_, v)| v.as_str())
}
pub fn json(&self) -> Result<crate::json::Json, String> {
crate::json::parse(&self.body).map_err(|e| format!("{e} in: {}", cut(&self.body, 200)))
}
}
pub fn cut(s: &str, n: usize) -> String {
let one: String = s.chars().filter(|c| *c != '\n' && *c != '\r').collect();
if one.chars().count() <= n {
one
} else {
one.chars().take(n).collect::<String>() + "…"
}
}
pub fn req(
hostport: &str,
method: &str,
path: &str,
headers: &[(&str, String)],
body: Option<&str>,
) -> Result<Resp, String> {
let s = TcpStream::connect(hostport).map_err(|e| format!("connect {hostport}: {e}"))?;
s.set_read_timeout(Some(Duration::from_secs(30))).ok();
s.set_nodelay(true).ok();
let mut s = s;
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: {hostport}\r\nConnection: close\r\n");
for (k, v) in headers {
head.push_str(&format!("{k}: {v}\r\n"));
}
if let Some(b) = body {
head.push_str(&format!("Content-Length: {}\r\n", b.len()));
}
head.push_str("\r\n");
s.write_all(head.as_bytes()).map_err(|e| format!("write: {e}"))?;
if let Some(b) = body {
s.write_all(b.as_bytes()).map_err(|e| format!("write body: {e}"))?;
}
s.flush().ok();
// Read the header, then exactly as much body as the header promises.
// NOT `read_to_end`: Chrome's /json endpoints answer `Connection: close`
// with a socket they keep open anyway, so reading to EOF waits for the read
// timeout on every single poll — which is how a 20-second launch deadline
// expired after one attempt against a browser that had already started.
let mut raw: Vec<u8> = Vec::new();
let mut buf = [0u8; 16 * 1024];
let split = loop {
if let Some(i) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
break i;
}
let n = s.read(&mut buf).map_err(|e| format!("read header: {e}"))?;
if n == 0 {
return Err("the peer closed before the header was complete".into());
}
raw.extend_from_slice(&buf[..n]);
};
let head = String::from_utf8_lossy(&raw[..split]).to_string();
let mut lines = head.lines();
let status = lines
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse::<u16>().ok())
.ok_or_else(|| format!("no status line in {}", cut(&head, 80)))?;
let headers: Vec<(String, String)> = lines
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_owned()))
.collect();
let find = |name: &str| headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str());
let chunked = find("transfer-encoding")
.map(|v| v.to_ascii_lowercase().contains("chunked"))
.unwrap_or(false);
let want: Option<usize> = find("content-length").and_then(|v| v.trim().parse().ok());
let body_at = split + 4;
loop {
let have = raw.len() - body_at;
if let Some(n) = want {
if have >= n {
break;
}
} else if chunked {
if raw[body_at..].windows(5).any(|w| w == b"0\r\n\r\n") {
break;
}
}
let n = s.read(&mut buf).map_err(|e| format!("read body: {e}"))?;
if n == 0 {
break;
}
raw.extend_from_slice(&buf[..n]);
}
let raw_body = &raw[body_at..];
let body = if chunked {
dechunk(raw_body)?
} else {
let end = want.map(|n| body_at + n).unwrap_or(raw.len());
String::from_utf8_lossy(&raw[body_at..end.min(raw.len())]).to_string()
};
Ok(Resp { status, body, headers })
}
fn dechunk(b: &[u8]) -> Result<String, String> {
let mut out = Vec::new();
let mut i = 0usize;
loop {
let nl = b[i..]
.windows(2)
.position(|w| w == b"\r\n")
.ok_or("chunk header has no CRLF")?;
let size_line = String::from_utf8_lossy(&b[i..i + nl]).to_string();
let size = usize::from_str_radix(size_line.split(';').next().unwrap_or("").trim(), 16)
.map_err(|_| format!("bad chunk size {size_line:?}"))?;
i += nl + 2;
if size == 0 {
return String::from_utf8(out).map_err(|e| format!("not utf-8: {e}"));
}
if i + size > b.len() {
return Err("truncated chunk".into());
}
out.extend_from_slice(&b[i..i + size]);
i += size + 2;
}
}