Gunnar

nordisk/gunnar-ui

uitest/src/http.rs

//! 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;
    }
}