Commit Diff


commit - 7e68a5d0527a3766aaa202e4bc72dcf36c01436b
commit + ebd3a89fa7acc0d377dff1cb734a8ab2282153d5
blob - /dev/null
blob + 2329657401f3e67067c427b9ccf3fb3bc49a8f07 (mode 644)
--- /dev/null
+++ examples/get.rs
@@ -0,0 +1,52 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+//! Fetch a URL and print its status, headers, and body.
+//!
+//! Usage: ex-get <url>
+
+use std::env;
+use std::process;
+
+fn main() {
+    let url = match env::args().nth(1) {
+        Some(u) => u,
+        None => {
+            eprintln!("usage: ex-get <url>");
+            process::exit(1);
+        }
+    };
+    let resp = match http::get(&url).send() {
+        Ok(r) => r,
+        Err(e) => {
+            eprintln!("ex-get: {e}");
+            process::exit(2);
+        }
+    };
+    println!("{} {} {}", resp.version, resp.status, resp.reason);
+    for (name, value) in resp.headers.iter() {
+        println!("{name}: {value}");
+    }
+    println!();
+    match resp.body_string() {
+        Ok(s) => print!("{s}"),
+        Err(e) => {
+            eprintln!("ex-get: read body: {e}");
+            process::exit(2);
+        }
+    }
+}
blob - a8a2b033e1f69886ce6871a74a6bcc2ddf8d34af
blob + 9bc68ece720239d874c3dbe21714cb39c6d9aba0
--- http.rs
+++ http.rs
@@ -17,7 +17,1795 @@
 
 //! Minimal HTTP/1.0 and HTTP/1.1 library.
 //!
-//! Conforms to RFC 1945 (HTTP/1.0) and RFC 2616 (HTTP/1.1).
+//! Conforms to RFC 1945 (HTTP/1.0) and RFC 2616 (HTTP/1.1).  Provides a
+//! blocking client with TLS via LibreSSL's `libtls`, together with the
+//! underlying protocol primitives: header parsing, chunked transfer-
+//! coding, and message framing.
+//!
+//! The crate links `-ltls` unconditionally.
+//!
+//! # Example
+//!
+//! ```no_run
+//! let resp = http::get("https://example.com/").send()?;
+//! let body = resp.body_string()?;
+//! # Ok::<(), http::Error>(())
+//! ```
 
-/// Library version, injected at build time from `HTTP_VERSION`.
+use std::error;
+use std::ffi::{CStr, CString};
+use std::fmt;
+use std::fmt::Write as _;
+use std::io::{self, BufRead, BufReader, Read, Take, Write};
+use std::net::TcpStream;
+use std::os::fd::AsRawFd;
+use std::os::raw::c_void;
+use std::result;
+use std::str;
+use std::time::Duration;
+
+/// Library version
 pub const VERSION: &str = env!("HTTP_VERSION");
+
+const DEFAULT_TIMEOUT_SECS: u64 = 30;
+const MAX_HEAD_BYTES: usize = 64 * 1024;
+
+//////////////////////////////////////////////////////////////////////////////
+// Error
+//////////////////////////////////////////////////////////////////////////////
+
+/// A library error.  Carries a message and an optional byte position for
+/// parse errors.
+pub struct Error {
+    msg: String,
+    pos: usize,
+}
+
+impl Error {
+    pub fn new(msg: impl Into<String>) -> Self {
+        Self {
+            msg: msg.into(),
+            pos: 0,
+        }
+    }
+
+    pub fn at(msg: impl Into<String>, pos: usize) -> Self {
+        Self {
+            msg: msg.into(),
+            pos,
+        }
+    }
+
+    pub fn message(&self) -> &str {
+        &self.msg
+    }
+
+    pub fn position(&self) -> usize {
+        self.pos
+    }
+}
+
+impl fmt::Debug for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Error")
+            .field("msg", &self.msg)
+            .field("pos", &self.pos)
+            .finish()
+    }
+}
+
+impl fmt::Display for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.pos == 0 {
+            f.write_str(&self.msg)
+        } else {
+            write!(f, "{} at byte {}", self.msg, self.pos)
+        }
+    }
+}
+
+impl error::Error for Error {}
+
+impl From<io::Error> for Error {
+    fn from(e: io::Error) -> Self {
+        Self::new(format!("io: {e}"))
+    }
+}
+
+pub type Result<T> = result::Result<T, Error>;
+
+//////////////////////////////////////////////////////////////////////////////
+// Method
+//////////////////////////////////////////////////////////////////////////////
+
+/// An HTTP request method.  RFC 2616 §5.1.1.  Extension methods are
+/// preserved in the `Other` variant.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Method {
+    Get,
+    Head,
+    Post,
+    Put,
+    Delete,
+    Options,
+    Trace,
+    Connect,
+    Patch,
+    Other(String),
+}
+
+impl Method {
+    pub fn as_str(&self) -> &str {
+        match self {
+            Self::Get => "GET",
+            Self::Head => "HEAD",
+            Self::Post => "POST",
+            Self::Put => "PUT",
+            Self::Delete => "DELETE",
+            Self::Options => "OPTIONS",
+            Self::Trace => "TRACE",
+            Self::Connect => "CONNECT",
+            Self::Patch => "PATCH",
+            Self::Other(s) => s.as_str(),
+        }
+    }
+
+    fn from_bytes(b: &[u8]) -> Self {
+        match b {
+            b"GET" => Self::Get,
+            b"HEAD" => Self::Head,
+            b"POST" => Self::Post,
+            b"PUT" => Self::Put,
+            b"DELETE" => Self::Delete,
+            b"OPTIONS" => Self::Options,
+            b"TRACE" => Self::Trace,
+            b"CONNECT" => Self::Connect,
+            b"PATCH" => Self::Patch,
+            other => Self::Other(
+                str::from_utf8(other).expect("token is ASCII").to_owned(),
+            ),
+        }
+    }
+}
+
+impl fmt::Display for Method {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Version
+//////////////////////////////////////////////////////////////////////////////
+
+/// An HTTP protocol version.  RFC 1945 §3.1, RFC 2616 §3.1.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Version {
+    Http10,
+    Http11,
+}
+
+impl Version {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Http10 => "HTTP/1.0",
+            Self::Http11 => "HTTP/1.1",
+        }
+    }
+}
+
+impl fmt::Display for Version {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Status
+//////////////////////////////////////////////////////////////////////////////
+
+/// An HTTP status code.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Status(pub u16);
+
+impl Status {
+    pub const fn code(self) -> u16 {
+        self.0
+    }
+
+    pub const fn is_informational(self) -> bool {
+        self.0 >= 100 && self.0 < 200
+    }
+
+    pub const fn is_success(self) -> bool {
+        self.0 >= 200 && self.0 < 300
+    }
+
+    pub const fn is_redirection(self) -> bool {
+        self.0 >= 300 && self.0 < 400
+    }
+
+    pub const fn is_client_error(self) -> bool {
+        self.0 >= 400 && self.0 < 500
+    }
+
+    pub const fn is_server_error(self) -> bool {
+        self.0 >= 500 && self.0 < 600
+    }
+
+    /// Canonical reason phrase from RFC 2616 §6.1.1.  Returns `None` for
+    /// unregistered codes.
+    pub const fn canonical_reason(self) -> Option<&'static str> {
+        Some(match self.0 {
+            100 => "Continue",
+            101 => "Switching Protocols",
+            200 => "OK",
+            201 => "Created",
+            202 => "Accepted",
+            203 => "Non-Authoritative Information",
+            204 => "No Content",
+            205 => "Reset Content",
+            206 => "Partial Content",
+            300 => "Multiple Choices",
+            301 => "Moved Permanently",
+            302 => "Found",
+            303 => "See Other",
+            304 => "Not Modified",
+            305 => "Use Proxy",
+            307 => "Temporary Redirect",
+            400 => "Bad Request",
+            401 => "Unauthorized",
+            402 => "Payment Required",
+            403 => "Forbidden",
+            404 => "Not Found",
+            405 => "Method Not Allowed",
+            406 => "Not Acceptable",
+            407 => "Proxy Authentication Required",
+            408 => "Request Timeout",
+            409 => "Conflict",
+            410 => "Gone",
+            411 => "Length Required",
+            412 => "Precondition Failed",
+            413 => "Request Entity Too Large",
+            414 => "Request-URI Too Long",
+            415 => "Unsupported Media Type",
+            416 => "Requested Range Not Satisfiable",
+            417 => "Expectation Failed",
+            500 => "Internal Server Error",
+            501 => "Not Implemented",
+            502 => "Bad Gateway",
+            503 => "Service Unavailable",
+            504 => "Gateway Timeout",
+            505 => "HTTP Version Not Supported",
+            _ => return None,
+        })
+    }
+}
+
+impl fmt::Display for Status {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Headers
+//////////////////////////////////////////////////////////////////////////////
+
+/// A case-insensitive, order-preserving map of header fields.
+#[derive(Clone, Debug, Default)]
+pub struct Headers {
+    entries: Vec<(String, String)>,
+}
+
+impl Headers {
+    pub const fn new() -> Self {
+        Self {
+            entries: Vec::new(),
+        }
+    }
+
+    /// Append a field.  Multiple fields with the same name are allowed
+    /// per RFC 2616 §4.2.
+    pub fn append<N: Into<String>, V: Into<String>>(
+        &mut self,
+        name: N,
+        value: V,
+    ) {
+        self.entries.push((name.into(), value.into()));
+    }
+
+    /// Remove any existing fields with the given name, then append one.
+    pub fn set<N: Into<String>, V: Into<String>>(&mut self, name: N, value: V) {
+        let name = name.into();
+        self.entries.retain(|(n, _)| !ascii_eq(n, &name));
+        self.entries.push((name, value.into()));
+    }
+
+    pub fn get(&self, name: &str) -> Option<&str> {
+        self.entries
+            .iter()
+            .find(|(n, _)| ascii_eq(n, name))
+            .map(|(_, v)| v.as_str())
+    }
+
+    pub fn get_all<'a>(
+        &'a self,
+        name: &'a str,
+    ) -> impl Iterator<Item = &'a str> {
+        self.entries
+            .iter()
+            .filter(move |(n, _)| ascii_eq(n, name))
+            .map(|(_, v)| v.as_str())
+    }
+
+    pub fn contains(&self, name: &str) -> bool {
+        self.get(name).is_some()
+    }
+
+    pub fn remove(&mut self, name: &str) {
+        self.entries.retain(|(n, _)| !ascii_eq(n, name));
+    }
+
+    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
+        self.entries.iter().map(|(n, v)| (n.as_str(), v.as_str()))
+    }
+
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+}
+
+fn ascii_eq(a: &str, b: &str) -> bool {
+    a.len() == b.len()
+        && a.bytes()
+            .zip(b.bytes())
+            .all(|(x, y)| x.eq_ignore_ascii_case(&y))
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Low-level parsing primitives
+//////////////////////////////////////////////////////////////////////////////
+
+const fn is_token(b: u8) -> bool {
+    // RFC 2616 §2.2: token = 1*<any CHAR except CTLs or separators>.
+    matches!(
+        b,
+        b'!' | b'#'
+            | b'$'
+            | b'%'
+            | b'&'
+            | b'\''
+            | b'*'
+            | b'+'
+            | b'-'
+            | b'.'
+            | b'0'..=b'9'
+            | b'A'..=b'Z'
+            | b'^'
+            | b'_'
+            | b'`'
+            | b'a'..=b'z'
+            | b'|'
+            | b'~'
+    )
+}
+
+struct Parser<'a> {
+    buf: &'a [u8],
+    pos: usize,
+}
+
+impl<'a> Parser<'a> {
+    fn new(buf: &'a [u8]) -> Self {
+        Self { buf, pos: 0 }
+    }
+
+    fn peek(&self) -> Option<u8> {
+        self.buf.get(self.pos).copied()
+    }
+
+    fn err<T>(&self, msg: &'static str) -> Result<T> {
+        Err(Error::at(msg, self.pos))
+    }
+
+    fn expect(&mut self, bytes: &[u8]) -> Result<()> {
+        if self.buf[self.pos..].starts_with(bytes) {
+            self.pos += bytes.len();
+            Ok(())
+        } else {
+            self.err("expected literal")
+        }
+    }
+
+    fn expect_sp(&mut self) -> Result<()> {
+        match self.peek() {
+            Some(b' ') => {
+                self.pos += 1;
+                Ok(())
+            }
+            _ => self.err("expected SP"),
+        }
+    }
+
+    /// Consume CRLF, or a bare LF per RFC 2616 §19.3 robustness.
+    fn eat_crlf(&mut self) -> Result<()> {
+        match self.peek() {
+            Some(b'\r') if self.buf.get(self.pos + 1) == Some(&b'\n') => {
+                self.pos += 2;
+                Ok(())
+            }
+            Some(b'\n') => {
+                self.pos += 1;
+                Ok(())
+            }
+            _ => self.err("expected CRLF"),
+        }
+    }
+
+    fn skip_ws(&mut self) {
+        while matches!(self.peek(), Some(b' ') | Some(b'\t')) {
+            self.pos += 1;
+        }
+    }
+
+    fn take_token(&mut self) -> Result<&'a [u8]> {
+        let start = self.pos;
+        while let Some(b) = self.peek() {
+            if !is_token(b) {
+                break;
+            }
+            self.pos += 1;
+        }
+        if self.pos == start {
+            return self.err("expected token");
+        }
+        Ok(&self.buf[start..self.pos])
+    }
+
+    fn take_until_ws(&mut self) -> Result<&'a [u8]> {
+        let start = self.pos;
+        while let Some(b) = self.peek() {
+            if matches!(b, b' ' | b'\t' | b'\r' | b'\n') {
+                break;
+            }
+            self.pos += 1;
+        }
+        if self.pos == start {
+            return self.err("expected non-whitespace");
+        }
+        Ok(&self.buf[start..self.pos])
+    }
+
+    fn take_until_eol(&mut self) -> &'a [u8] {
+        let start = self.pos;
+        while let Some(b) = self.peek() {
+            if b == b'\r' || b == b'\n' {
+                break;
+            }
+            self.pos += 1;
+        }
+        &self.buf[start..self.pos]
+    }
+}
+
+fn parse_version(p: &mut Parser<'_>) -> Result<Version> {
+    p.expect(b"HTTP/")?;
+    let maj = parse_u16(p)?;
+    p.expect(b".")?;
+    let min = parse_u16(p)?;
+    match (maj, min) {
+        (1, 0) => Ok(Version::Http10),
+        (1, 1) => Ok(Version::Http11),
+        _ => p.err("unsupported HTTP version"),
+    }
+}
+
+fn parse_u16(p: &mut Parser<'_>) -> Result<u16> {
+    let start = p.pos;
+    let mut n: u32 = 0;
+    while let Some(b) = p.peek() {
+        if !b.is_ascii_digit() {
+            break;
+        }
+        n = n * 10 + (b - b'0') as u32;
+        if n > u16::MAX as u32 {
+            return p.err("numeric overflow");
+        }
+        p.pos += 1;
+    }
+    if p.pos == start {
+        return p.err("expected digit");
+    }
+    Ok(n as u16)
+}
+
+fn parse_status_code(p: &mut Parser<'_>) -> Result<u16> {
+    let mut d = [0u16; 3];
+    for slot in &mut d {
+        match p.peek() {
+            Some(b) if b.is_ascii_digit() => {
+                *slot = (b - b'0') as u16;
+                p.pos += 1;
+            }
+            _ => return p.err("expected 3-digit status code"),
+        }
+    }
+    Ok(d[0] * 100 + d[1] * 10 + d[2])
+}
+
+fn parse_headers(p: &mut Parser<'_>) -> Result<Headers> {
+    let mut headers = Headers::new();
+    loop {
+        if matches!(p.peek(), Some(b'\r') | Some(b'\n')) {
+            p.eat_crlf()?;
+            return Ok(headers);
+        }
+        let name = p.take_token()?;
+        let name = str::from_utf8(name)
+            .expect("field-name is ASCII")
+            .to_owned();
+
+        if p.peek() != Some(b':') {
+            return p.err("expected ':' after field-name");
+        }
+        p.pos += 1;
+        p.skip_ws();
+
+        let mut value = p.take_until_eol().to_vec();
+        p.eat_crlf()?;
+
+        // obs-fold: continuation lines begin with SP or HT.
+        while matches!(p.peek(), Some(b' ') | Some(b'\t')) {
+            p.skip_ws();
+            let cont = p.take_until_eol();
+            if !value.is_empty() && !cont.is_empty() {
+                value.push(b' ');
+            }
+            value.extend_from_slice(cont);
+            p.eat_crlf()?;
+        }
+
+        while matches!(value.last(), Some(b' ') | Some(b'\t')) {
+            value.pop();
+        }
+
+        let value = String::from_utf8(value)
+            .map_err(|_| Error::at("invalid UTF-8 in field-value", p.pos))?;
+        headers.append(name, value);
+    }
+}
+
+/// Parse an HTTP request head (request-line + header block terminated
+/// by an empty line).  Returns the decoded request and the number of
+/// bytes consumed.  RFC 2616 §5.
+pub fn parse_request(buf: &[u8]) -> Result<(Request, usize)> {
+    let mut p = Parser::new(buf);
+
+    let method = Method::from_bytes(p.take_token()?);
+    p.expect_sp()?;
+
+    let target = p.take_until_ws()?;
+    let target = str::from_utf8(target)
+        .map_err(|_| Error::at("invalid UTF-8 in request-target", p.pos))?
+        .to_owned();
+    p.expect_sp()?;
+
+    let version = parse_version(&mut p)?;
+    p.eat_crlf()?;
+
+    let headers = parse_headers(&mut p)?;
+    Ok((
+        Request {
+            method,
+            target,
+            version,
+            headers,
+        },
+        p.pos,
+    ))
+}
+
+/// Parse an HTTP response head (status-line + header block).
+pub fn parse_response_head(buf: &[u8]) -> Result<(ResponseHead, usize)> {
+    let mut p = Parser::new(buf);
+
+    let version = parse_version(&mut p)?;
+    p.expect_sp()?;
+
+    let code = parse_status_code(&mut p)?;
+    p.expect_sp()?;
+
+    let reason = p.take_until_eol();
+    let reason = str::from_utf8(reason)
+        .map_err(|_| Error::at("invalid UTF-8 in reason-phrase", p.pos))?
+        .trim()
+        .to_owned();
+    p.eat_crlf()?;
+
+    let headers = parse_headers(&mut p)?;
+    Ok((
+        ResponseHead {
+            version,
+            status: Status(code),
+            reason,
+            headers,
+        },
+        p.pos,
+    ))
+}
+
+/// Read an HTTP message head (everything up to and including the empty
+/// line separating the body).  The BufReader is positioned at the first
+/// body byte on return.
+fn read_head<R: BufRead>(r: &mut R) -> Result<Vec<u8>> {
+    let mut buf = Vec::with_capacity(2048);
+    loop {
+        let start = buf.len();
+        let n = r.read_until(b'\n', &mut buf)?;
+        if n == 0 {
+            return Err(Error::new("unexpected EOF reading head"));
+        }
+        if buf.len() > MAX_HEAD_BYTES {
+            return Err(Error::new("head too large"));
+        }
+        let line = &buf[start..];
+        if line == b"\r\n" || line == b"\n" {
+            return Ok(buf);
+        }
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Request (data) and ResponseHead
+//////////////////////////////////////////////////////////////////////////////
+
+/// A parsed HTTP request message head.
+#[derive(Clone, Debug)]
+pub struct Request {
+    pub method: Method,
+    pub target: String,
+    pub version: Version,
+    pub headers: Headers,
+}
+
+impl Request {
+    /// Serialize the request-line + headers to `w`.  The caller writes
+    /// any body bytes separately.
+    pub fn write_head(&self, w: &mut impl Write) -> io::Result<()> {
+        write!(w, "{} {} {}\r\n", self.method, self.target, self.version)?;
+        write_headers(w, &self.headers)
+    }
+}
+
+/// A parsed HTTP response message head.
+#[derive(Clone, Debug)]
+pub struct ResponseHead {
+    pub version: Version,
+    pub status: Status,
+    pub reason: String,
+    pub headers: Headers,
+}
+
+impl ResponseHead {
+    pub fn write_head(&self, w: &mut impl Write) -> io::Result<()> {
+        write!(w, "{} {} {}\r\n", self.version, self.status, self.reason)?;
+        write_headers(w, &self.headers)
+    }
+}
+
+fn write_headers(w: &mut impl Write, h: &Headers) -> io::Result<()> {
+    for (name, value) in h.iter() {
+        write!(w, "{}: {}\r\n", name, value)?;
+    }
+    w.write_all(b"\r\n")
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Body framing (RFC 2616 §4.4)
+//////////////////////////////////////////////////////////////////////////////
+
+/// Framing rule for a message body.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum BodyLength {
+    /// No body — 1xx/204/304 responses, or response to HEAD, or a
+    /// request without Content-Length or Transfer-Encoding.
+    Empty,
+    /// Body of exactly N bytes (Content-Length).
+    Fixed(u64),
+    /// Chunked transfer-coding (RFC 2616 §3.6.1).
+    Chunked,
+    /// Body delimited by connection close (responses only).
+    CloseDelimited,
+}
+
+/// Determine the framing for a response body given the request method,
+/// response status, and response headers.  RFC 2616 §4.4.
+pub fn response_body_length(
+    request_method: &Method,
+    status: Status,
+    headers: &Headers,
+) -> Result<BodyLength> {
+    if *request_method == Method::Head
+        || status.is_informational()
+        || status.0 == 204
+        || status.0 == 304
+    {
+        return Ok(BodyLength::Empty);
+    }
+    body_length_from_headers(headers, false)
+}
+
+/// Determine the framing for a request body given request headers.
+pub fn request_body_length(headers: &Headers) -> Result<BodyLength> {
+    body_length_from_headers(headers, true)
+}
+
+fn body_length_from_headers(
+    headers: &Headers,
+    is_request: bool,
+) -> Result<BodyLength> {
+    if let Some(te) = headers.get("Transfer-Encoding") {
+        if is_final_chunked(te) {
+            return Ok(BodyLength::Chunked);
+        }
+        return Err(Error::new("unsupported Transfer-Encoding"));
+    }
+    if let Some(cl) = headers.get("Content-Length") {
+        let n: u64 = cl
+            .trim()
+            .parse()
+            .map_err(|_| Error::new("invalid Content-Length"))?;
+        return Ok(BodyLength::Fixed(n));
+    }
+    if is_request {
+        Ok(BodyLength::Empty)
+    } else {
+        Ok(BodyLength::CloseDelimited)
+    }
+}
+
+fn is_final_chunked(te: &str) -> bool {
+    te.rsplit(',')
+        .next()
+        .map(|s| s.trim().eq_ignore_ascii_case("chunked"))
+        .unwrap_or(false)
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Chunked transfer-coding (RFC 2616 §3.6.1)
+//////////////////////////////////////////////////////////////////////////////
+
+/// A `Read` adapter that decodes chunked transfer-coding.
+pub struct ChunkedReader<R: BufRead> {
+    inner: R,
+    state: ChunkState,
+}
+
+enum ChunkState {
+    /// Next byte starts a chunk-size line.
+    Size,
+    /// Currently inside a chunk body; N bytes remain.
+    Body(u64),
+    /// Chunk body exhausted; consume trailing CRLF before next size.
+    Tail,
+    /// Zero-length chunk seen; trailer consumed; stream at EOF.
+    Done,
+}
+
+impl<R: BufRead> ChunkedReader<R> {
+    pub fn new(inner: R) -> Self {
+        Self {
+            inner,
+            state: ChunkState::Size,
+        }
+    }
+
+    pub fn into_inner(self) -> R {
+        self.inner
+    }
+}
+
+impl<R: BufRead> Read for ChunkedReader<R> {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        loop {
+            match &mut self.state {
+                ChunkState::Done => return Ok(0),
+                ChunkState::Tail => {
+                    read_crlf(&mut self.inner)?;
+                    self.state = ChunkState::Size;
+                }
+                ChunkState::Size => {
+                    let size = read_chunk_size(&mut self.inner)?;
+                    if size == 0 {
+                        read_trailer(&mut self.inner)?;
+                        self.state = ChunkState::Done;
+                        return Ok(0);
+                    }
+                    self.state = ChunkState::Body(size);
+                }
+                ChunkState::Body(remaining) => {
+                    if buf.is_empty() {
+                        return Ok(0);
+                    }
+                    let want = buf.len().min(*remaining as usize);
+                    let n = self.inner.read(&mut buf[..want])?;
+                    if n == 0 {
+                        return Err(io::Error::new(
+                            io::ErrorKind::UnexpectedEof,
+                            "chunk truncated",
+                        ));
+                    }
+                    *remaining -= n as u64;
+                    if *remaining == 0 {
+                        self.state = ChunkState::Tail;
+                    }
+                    return Ok(n);
+                }
+            }
+        }
+    }
+}
+
+fn read_chunk_size<R: BufRead>(r: &mut R) -> io::Result<u64> {
+    let line = read_line_vec(r)?;
+    let end = line.iter().position(|&b| b == b';').unwrap_or(line.len());
+    let hex = str::from_utf8(&line[..end])
+        .map_err(|_| invalid("non-ASCII chunk size"))?
+        .trim();
+    u64::from_str_radix(hex, 16).map_err(|_| invalid("invalid chunk size"))
+}
+
+fn read_trailer<R: BufRead>(r: &mut R) -> io::Result<()> {
+    loop {
+        let line = read_line_vec(r)?;
+        if line.is_empty() {
+            return Ok(());
+        }
+    }
+}
+
+fn read_crlf<R: BufRead>(r: &mut R) -> io::Result<()> {
+    let line = read_line_vec(r)?;
+    if line.is_empty() {
+        Ok(())
+    } else {
+        Err(invalid("expected CRLF"))
+    }
+}
+
+fn read_line_vec<R: BufRead>(r: &mut R) -> io::Result<Vec<u8>> {
+    let mut buf = Vec::new();
+    let n = r.read_until(b'\n', &mut buf)?;
+    if n == 0 {
+        return Err(io::Error::new(
+            io::ErrorKind::UnexpectedEof,
+            "unexpected EOF",
+        ));
+    }
+    if buf.last() == Some(&b'\n') {
+        buf.pop();
+    }
+    if buf.last() == Some(&b'\r') {
+        buf.pop();
+    }
+    Ok(buf)
+}
+
+fn invalid(msg: &'static str) -> io::Error {
+    io::Error::new(io::ErrorKind::InvalidData, msg)
+}
+
+/// A `Write` adapter that encodes output in chunked transfer-coding.
+/// Call [`finish`](Self::finish) to emit the terminating zero-chunk;
+/// dropping without calling `finish` leaves the stream incomplete.
+pub struct ChunkedWriter<W: Write> {
+    inner: W,
+}
+
+impl<W: Write> ChunkedWriter<W> {
+    pub fn new(inner: W) -> Self {
+        Self { inner }
+    }
+
+    pub fn finish(mut self) -> io::Result<W> {
+        self.inner.write_all(b"0\r\n\r\n")?;
+        Ok(self.inner)
+    }
+}
+
+impl<W: Write> Write for ChunkedWriter<W> {
+    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+        if buf.is_empty() {
+            return Ok(0);
+        }
+        write!(self.inner, "{:x}\r\n", buf.len())?;
+        self.inner.write_all(buf)?;
+        self.inner.write_all(b"\r\n")?;
+        Ok(buf.len())
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        self.inner.flush()
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// URL parsing (internal)
+//////////////////////////////////////////////////////////////////////////////
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum Scheme {
+    Http,
+    Https,
+}
+
+fn parse_url(url: &str) -> Result<(Scheme, &str, u16, &str)> {
+    let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
+        (Scheme::Https, r)
+    } else if let Some(r) = url.strip_prefix("http://") {
+        (Scheme::Http, r)
+    } else {
+        return Err(Error::new("URL must start with http:// or https://"));
+    };
+
+    let (authority, path) = match rest.find('/') {
+        Some(i) => (&rest[..i], &rest[i..]),
+        None => (rest, "/"),
+    };
+
+    if authority.is_empty() {
+        return Err(Error::new("URL missing host"));
+    }
+
+    let (host, port) = match authority.rsplit_once(':') {
+        Some((h, p)) => {
+            let port: u16 =
+                p.parse().map_err(|_| Error::new("invalid port in URL"))?;
+            (h, port)
+        }
+        None => (
+            authority,
+            match scheme {
+                Scheme::Http => 80,
+                Scheme::Https => 443,
+            },
+        ),
+    };
+
+    Ok((scheme, host, port, path))
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// libtls FFI
+//////////////////////////////////////////////////////////////////////////////
+
+mod ffi {
+    use std::os::raw::{c_char, c_int, c_void};
+
+    #[repr(C)]
+    pub struct Tls {
+        _p: [u8; 0],
+    }
+    #[repr(C)]
+    pub struct TlsConfig {
+        _p: [u8; 0],
+    }
+
+    pub const TLS_WANT_POLLIN: isize = -2;
+    pub const TLS_WANT_POLLOUT: isize = -3;
+
+    #[link(name = "tls")]
+    unsafe extern "C" {
+        pub fn tls_init() -> c_int;
+        pub fn tls_config_new() -> *mut TlsConfig;
+        pub fn tls_config_free(c: *mut TlsConfig);
+        pub fn tls_client() -> *mut Tls;
+        pub fn tls_configure(ctx: *mut Tls, cfg: *mut TlsConfig) -> c_int;
+        pub fn tls_connect_socket(
+            ctx: *mut Tls,
+            fd: c_int,
+            servername: *const c_char,
+        ) -> c_int;
+        pub fn tls_handshake(ctx: *mut Tls) -> c_int;
+        pub fn tls_write(ctx: *mut Tls, buf: *const c_void, n: usize) -> isize;
+        pub fn tls_read(ctx: *mut Tls, buf: *mut c_void, n: usize) -> isize;
+        pub fn tls_close(ctx: *mut Tls) -> c_int;
+        pub fn tls_free(ctx: *mut Tls);
+        pub fn tls_error(ctx: *mut Tls) -> *const c_char;
+    }
+}
+
+struct TlsStream {
+    ctx: *mut ffi::Tls,
+    cfg: *mut ffi::TlsConfig,
+    _sock: TcpStream,
+}
+
+impl TlsStream {
+    fn connect(host: &str, sock: TcpStream) -> Result<Self> {
+        unsafe {
+            if ffi::tls_init() != 0 {
+                return Err(Error::new("tls_init failed"));
+            }
+            let cfg = ffi::tls_config_new();
+            if cfg.is_null() {
+                return Err(Error::new("tls_config_new failed"));
+            }
+            let ctx = ffi::tls_client();
+            if ctx.is_null() {
+                ffi::tls_config_free(cfg);
+                return Err(Error::new("tls_client failed"));
+            }
+            if ffi::tls_configure(ctx, cfg) != 0 {
+                let e = tls_errmsg(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(Error::new(format!("tls_configure: {e}")));
+            }
+            let chost = CString::new(host)
+                .map_err(|_| Error::new("host contains NUL"))?;
+            if ffi::tls_connect_socket(ctx, sock.as_raw_fd(), chost.as_ptr())
+                != 0
+            {
+                let e = tls_errmsg(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(Error::new(format!("tls_connect_socket: {e}")));
+            }
+            loop {
+                let r = ffi::tls_handshake(ctx) as isize;
+                if r == 0 {
+                    break;
+                }
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                let e = tls_errmsg(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(Error::new(format!("tls_handshake: {e}")));
+            }
+            Ok(Self {
+                ctx,
+                cfg,
+                _sock: sock,
+            })
+        }
+    }
+}
+
+impl Read for TlsStream {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        unsafe {
+            loop {
+                let r = ffi::tls_read(
+                    self.ctx,
+                    buf.as_mut_ptr() as *mut c_void,
+                    buf.len(),
+                );
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                if r < 0 {
+                    return Err(io::Error::other(format!(
+                        "tls_read: {}",
+                        tls_errmsg(self.ctx)
+                    )));
+                }
+                return Ok(r as usize);
+            }
+        }
+    }
+}
+
+impl Write for TlsStream {
+    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+        unsafe {
+            loop {
+                let r = ffi::tls_write(
+                    self.ctx,
+                    buf.as_ptr() as *const c_void,
+                    buf.len(),
+                );
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                if r < 0 {
+                    return Err(io::Error::other(format!(
+                        "tls_write: {}",
+                        tls_errmsg(self.ctx)
+                    )));
+                }
+                return Ok(r as usize);
+            }
+        }
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        Ok(())
+    }
+}
+
+impl Drop for TlsStream {
+    fn drop(&mut self) {
+        unsafe {
+            if !self.ctx.is_null() {
+                loop {
+                    let r = ffi::tls_close(self.ctx) as isize;
+                    if r == 0
+                        || (r != ffi::TLS_WANT_POLLIN
+                            && r != ffi::TLS_WANT_POLLOUT)
+                    {
+                        break;
+                    }
+                }
+                ffi::tls_free(self.ctx);
+            }
+            if !self.cfg.is_null() {
+                ffi::tls_config_free(self.cfg);
+            }
+        }
+    }
+}
+
+unsafe fn tls_errmsg(ctx: *mut ffi::Tls) -> String {
+    unsafe {
+        let p = ffi::tls_error(ctx);
+        if p.is_null() {
+            "(unknown)".into()
+        } else {
+            CStr::from_ptr(p).to_string_lossy().into_owned()
+        }
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Unified plain/TLS stream
+//////////////////////////////////////////////////////////////////////////////
+
+enum Stream {
+    Plain(TcpStream),
+    Tls(TlsStream),
+}
+
+impl Read for Stream {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        match self {
+            Self::Plain(s) => s.read(buf),
+            Self::Tls(s) => s.read(buf),
+        }
+    }
+}
+
+impl Write for Stream {
+    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+        match self {
+            Self::Plain(s) => s.write(buf),
+            Self::Tls(s) => s.write(buf),
+        }
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        match self {
+            Self::Plain(s) => s.flush(),
+            Self::Tls(s) => s.flush(),
+        }
+    }
+}
+
+fn connect(
+    scheme: Scheme,
+    host: &str,
+    port: u16,
+    timeout: Duration,
+) -> Result<Stream> {
+    let sock = TcpStream::connect((host, port))
+        .map_err(|e| Error::new(format!("connect {host}:{port}: {e}")))?;
+    let _ = sock.set_read_timeout(Some(timeout));
+    let _ = sock.set_write_timeout(Some(timeout));
+    match scheme {
+        Scheme::Http => Ok(Stream::Plain(sock)),
+        Scheme::Https => Ok(Stream::Tls(TlsStream::connect(host, sock)?)),
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Response
+//////////////////////////////////////////////////////////////////////////////
+
+/// A response from a completed HTTP request.  The body is exposed as a
+/// `Read` implementation so large or streaming responses (for example
+/// Server-Sent Events) can be consumed incrementally.
+pub struct Response {
+    pub status: Status,
+    pub version: Version,
+    pub reason: String,
+    pub headers: Headers,
+    body: Body,
+}
+
+enum Body {
+    Empty,
+    Fixed(Take<BufReader<Stream>>),
+    Chunked(ChunkedReader<BufReader<Stream>>),
+    Close(BufReader<Stream>),
+}
+
+impl Response {
+    /// Read the entire body into a `Vec<u8>`.
+    pub fn body_bytes(mut self) -> Result<Vec<u8>> {
+        let mut out = Vec::new();
+        self.read_to_end(&mut out)?;
+        Ok(out)
+    }
+
+    /// Read the entire body into a `String`, expecting valid UTF-8.
+    pub fn body_string(mut self) -> Result<String> {
+        let mut s = String::new();
+        self.read_to_string(&mut s)?;
+        Ok(s)
+    }
+}
+
+impl Read for Response {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        match &mut self.body {
+            Body::Empty => Ok(0),
+            Body::Fixed(r) => r.read(buf),
+            Body::Chunked(r) => r.read(buf),
+            Body::Close(r) => r.read(buf),
+        }
+    }
+}
+
+impl fmt::Debug for Response {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Response")
+            .field("status", &self.status)
+            .field("version", &self.version)
+            .field("reason", &self.reason)
+            .field("headers", &self.headers)
+            .finish()
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Request builder
+//////////////////////////////////////////////////////////////////////////////
+
+/// Fluent builder for outgoing requests.  Construct one via
+/// [`get`], [`post`], [`put`], [`delete`], [`head`] or [`request`].
+pub struct RequestBuilder {
+    method: Method,
+    url: String,
+    headers: Headers,
+    body: Vec<u8>,
+    timeout: Duration,
+}
+
+impl RequestBuilder {
+    fn new(method: Method, url: impl Into<String>) -> Self {
+        Self {
+            method,
+            url: url.into(),
+            headers: Headers::new(),
+            body: Vec::new(),
+            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
+        }
+    }
+
+    /// Append a header.  Multiple calls with the same name append
+    /// multiple fields (RFC 2616 §4.2).
+    pub fn header(
+        mut self,
+        name: impl Into<String>,
+        value: impl Into<String>,
+    ) -> Self {
+        self.headers.append(name, value);
+        self
+    }
+
+    /// Set the request body.  Overwrites any previous body.  The
+    /// Content-Length header is set automatically on send unless the
+    /// caller already set one.
+    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
+        self.body = body.into();
+        self
+    }
+
+    /// Set the body from a list of form pairs, URL-encoded, and set
+    /// Content-Type to application/x-www-form-urlencoded.
+    pub fn form(mut self, pairs: &[(&str, &str)]) -> Self {
+        self.body = url_form(pairs).into_bytes();
+        if !self.headers.contains("Content-Type") {
+            self.headers
+                .set("Content-Type", "application/x-www-form-urlencoded");
+        }
+        self
+    }
+
+    /// Override the read/write timeout.  Default is 30 seconds.
+    pub fn timeout(mut self, timeout: Duration) -> Self {
+        self.timeout = timeout;
+        self
+    }
+
+    /// Send the request and return the response.  The underlying
+    /// connection is closed when the response is dropped.
+    pub fn send(mut self) -> Result<Response> {
+        let (scheme, host, port, path) = parse_url(&self.url)?;
+
+        if !self.headers.contains("Host") {
+            let host_hdr = if (scheme == Scheme::Http && port == 80)
+                || (scheme == Scheme::Https && port == 443)
+            {
+                host.to_string()
+            } else {
+                format!("{host}:{port}")
+            };
+            self.headers.set("Host", host_hdr);
+        }
+        if !self.headers.contains("User-Agent") {
+            self.headers.set("User-Agent", format!("http/{VERSION}"));
+        }
+        if !self.headers.contains("Connection") {
+            self.headers.set("Connection", "close");
+        }
+        if !self.body.is_empty() && !self.headers.contains("Content-Length") {
+            self.headers
+                .set("Content-Length", self.body.len().to_string());
+        }
+
+        let stream = connect(scheme, host, port, self.timeout)?;
+        let mut stream = stream;
+
+        write!(stream, "{} {} HTTP/1.1\r\n", self.method, path)?;
+        for (name, value) in self.headers.iter() {
+            write!(stream, "{}: {}\r\n", name, value)?;
+        }
+        stream.write_all(b"\r\n")?;
+        if !self.body.is_empty() {
+            stream.write_all(&self.body)?;
+        }
+        stream.flush()?;
+
+        let mut reader = BufReader::new(stream);
+        let head = read_head(&mut reader)?;
+        let (h, _) = parse_response_head(&head)?;
+
+        let length = response_body_length(&self.method, h.status, &h.headers)?;
+        let body = match length {
+            BodyLength::Empty => Body::Empty,
+            BodyLength::Fixed(n) => Body::Fixed(reader.take(n)),
+            BodyLength::Chunked => Body::Chunked(ChunkedReader::new(reader)),
+            BodyLength::CloseDelimited => Body::Close(reader),
+        };
+
+        Ok(Response {
+            status: h.status,
+            version: h.version,
+            reason: h.reason,
+            headers: h.headers,
+            body,
+        })
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Top-level constructors
+//////////////////////////////////////////////////////////////////////////////
+
+pub fn get(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Get, url)
+}
+
+pub fn head(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Head, url)
+}
+
+pub fn post(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Post, url)
+}
+
+pub fn put(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Put, url)
+}
+
+pub fn delete(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Delete, url)
+}
+
+pub fn patch(url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(Method::Patch, url)
+}
+
+pub fn request(method: Method, url: impl Into<String>) -> RequestBuilder {
+    RequestBuilder::new(method, url)
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// URL encoding utilities
+//////////////////////////////////////////////////////////////////////////////
+
+/// Percent-encode per RFC 3986 §2.3 (unreserved set).
+pub fn percent_encode(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for &b in s.as_bytes() {
+        match b {
+            b'A'..=b'Z'
+            | b'a'..=b'z'
+            | b'0'..=b'9'
+            | b'-'
+            | b'.'
+            | b'_'
+            | b'~' => out.push(b as char),
+            _ => {
+                let _ = write!(out, "%{b:02X}");
+            }
+        }
+    }
+    out
+}
+
+/// Percent-decode.  `+` is interpreted as space (form convention).
+/// Invalid sequences are emitted literally.
+pub fn percent_decode(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        match bytes[i] {
+            b'+' => {
+                out.push(b' ');
+                i += 1;
+            }
+            b'%' if i + 2 < bytes.len() => {
+                let hex = str::from_utf8(&bytes[i + 1..i + 3])
+                    .ok()
+                    .and_then(|h| u8::from_str_radix(h, 16).ok());
+                match hex {
+                    Some(b) => {
+                        out.push(b);
+                        i += 3;
+                    }
+                    None => {
+                        out.push(bytes[i]);
+                        i += 1;
+                    }
+                }
+            }
+            b => {
+                out.push(b);
+                i += 1;
+            }
+        }
+    }
+    String::from_utf8(out).unwrap_or_default()
+}
+
+/// Serialize a list of key-value pairs as application/x-www-form-urlencoded.
+pub fn url_form(pairs: &[(&str, &str)]) -> String {
+    let mut out = String::new();
+    for (i, (k, v)) in pairs.iter().enumerate() {
+        if i > 0 {
+            out.push('&');
+        }
+        out.push_str(&percent_encode(k));
+        out.push('=');
+        out.push_str(&percent_encode(v));
+    }
+    out
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Tests
+//////////////////////////////////////////////////////////////////////////////
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::Cursor;
+
+    #[test]
+    fn method_round_trip() {
+        for m in [
+            Method::Get,
+            Method::Head,
+            Method::Post,
+            Method::Put,
+            Method::Delete,
+            Method::Options,
+            Method::Trace,
+            Method::Connect,
+            Method::Patch,
+        ] {
+            assert_eq!(Method::from_bytes(m.as_str().as_bytes()), m);
+        }
+    }
+
+    #[test]
+    fn method_extension() {
+        let m = Method::from_bytes(b"FOO");
+        assert_eq!(m, Method::Other("FOO".into()));
+        assert_eq!(m.as_str(), "FOO");
+    }
+
+    #[test]
+    fn status_classes() {
+        assert!(Status(100).is_informational());
+        assert!(Status(204).is_success());
+        assert!(Status(301).is_redirection());
+        assert!(Status(404).is_client_error());
+        assert!(Status(500).is_server_error());
+    }
+
+    #[test]
+    fn status_reasons() {
+        assert_eq!(Status(200).canonical_reason(), Some("OK"));
+        assert_eq!(Status(404).canonical_reason(), Some("Not Found"));
+        assert_eq!(Status(599).canonical_reason(), None);
+    }
+
+    #[test]
+    fn headers_case_insensitive() {
+        let mut h = Headers::new();
+        h.append("Content-Type", "text/plain");
+        assert_eq!(h.get("content-type"), Some("text/plain"));
+        assert_eq!(h.get("CONTENT-TYPE"), Some("text/plain"));
+    }
+
+    #[test]
+    fn headers_set_replaces() {
+        let mut h = Headers::new();
+        h.append("X", "1");
+        h.append("x", "2");
+        h.set("X", "3");
+        assert_eq!(h.get("X"), Some("3"));
+        assert_eq!(h.len(), 1);
+    }
+
+    #[test]
+    fn headers_multi_value() {
+        let mut h = Headers::new();
+        h.append("Set-Cookie", "a=1");
+        h.append("Set-Cookie", "b=2");
+        let all: Vec<&str> = h.get_all("set-cookie").collect();
+        assert_eq!(all, vec!["a=1", "b=2"]);
+    }
+
+    #[test]
+    fn parse_request_simple() {
+        let input = b"GET /foo HTTP/1.1\r\nHost: example.com\r\n\r\n";
+        let (r, n) = parse_request(input).unwrap();
+        assert_eq!(r.method, Method::Get);
+        assert_eq!(r.target, "/foo");
+        assert_eq!(r.version, Version::Http11);
+        assert_eq!(r.headers.get("Host"), Some("example.com"));
+        assert_eq!(n, input.len());
+    }
+
+    #[test]
+    fn parse_request_bare_lf() {
+        // RFC 2616 §19.3 robustness: accept bare LF line endings.
+        let input = b"GET / HTTP/1.0\nHost: example.com\n\n";
+        let (r, _) = parse_request(input).unwrap();
+        assert_eq!(r.method, Method::Get);
+        assert_eq!(r.version, Version::Http10);
+    }
+
+    #[test]
+    fn parse_request_extension_method() {
+        let input = b"FOO / HTTP/1.1\r\n\r\n";
+        let (r, _) = parse_request(input).unwrap();
+        assert_eq!(r.method, Method::Other("FOO".into()));
+    }
+
+    #[test]
+    fn parse_response_simple() {
+        let input = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n";
+        let (h, n) = parse_response_head(input).unwrap();
+        assert_eq!(h.status, Status(200));
+        assert_eq!(h.version, Version::Http11);
+        assert_eq!(h.reason, "OK");
+        assert_eq!(h.headers.get("Content-Length"), Some("5"));
+        assert_eq!(n, input.len());
+    }
+
+    #[test]
+    fn parse_response_multiword_reason() {
+        let input = b"HTTP/1.1 404 Not Found\r\n\r\n";
+        let (h, _) = parse_response_head(input).unwrap();
+        assert_eq!(h.status, Status(404));
+        assert_eq!(h.reason, "Not Found");
+    }
+
+    #[test]
+    fn parse_obs_fold() {
+        // RFC 2616 §2.2 obs-fold: header value continuation.
+        let input =
+            b"GET / HTTP/1.1\r\nX-Long: one\r\n  two\r\n\tthree\r\n\r\n";
+        let (r, _) = parse_request(input).unwrap();
+        assert_eq!(r.headers.get("X-Long"), Some("one two three"));
+    }
+
+    #[test]
+    fn parse_rejects_bad_version() {
+        let input = b"GET / HTTP/9.9\r\n\r\n";
+        assert!(parse_request(input).is_err());
+    }
+
+    #[test]
+    fn parse_rejects_missing_colon() {
+        let input = b"GET / HTTP/1.1\r\nBadHeader\r\n\r\n";
+        assert!(parse_request(input).is_err());
+    }
+
+    #[test]
+    fn body_length_head_is_empty() {
+        let h = Headers::new();
+        let r = response_body_length(&Method::Head, Status(200), &h).unwrap();
+        assert_eq!(r, BodyLength::Empty);
+    }
+
+    #[test]
+    fn body_length_204_is_empty() {
+        let mut h = Headers::new();
+        h.set("Content-Length", "42");
+        let r = response_body_length(&Method::Get, Status(204), &h).unwrap();
+        assert_eq!(r, BodyLength::Empty);
+    }
+
+    #[test]
+    fn body_length_chunked() {
+        let mut h = Headers::new();
+        h.set("Transfer-Encoding", "chunked");
+        let r = response_body_length(&Method::Get, Status(200), &h).unwrap();
+        assert_eq!(r, BodyLength::Chunked);
+    }
+
+    #[test]
+    fn body_length_chunked_wins_over_content_length() {
+        let mut h = Headers::new();
+        h.set("Transfer-Encoding", "chunked");
+        h.set("Content-Length", "10");
+        let r = response_body_length(&Method::Get, Status(200), &h).unwrap();
+        assert_eq!(r, BodyLength::Chunked);
+    }
+
+    #[test]
+    fn body_length_close_delimited_default() {
+        let h = Headers::new();
+        let r = response_body_length(&Method::Get, Status(200), &h).unwrap();
+        assert_eq!(r, BodyLength::CloseDelimited);
+    }
+
+    #[test]
+    fn body_length_request_no_body_default() {
+        let h = Headers::new();
+        let r = request_body_length(&h).unwrap();
+        assert_eq!(r, BodyLength::Empty);
+    }
+
+    #[test]
+    fn chunked_decode() {
+        // RFC 2616 §3.6.1 example.
+        let input =
+            b"4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n";
+        let mut r = ChunkedReader::new(Cursor::new(&input[..]));
+        let mut out = Vec::new();
+        r.read_to_end(&mut out).unwrap();
+        assert_eq!(out, b"Wikipedia in\r\n\r\nchunks.");
+    }
+
+    #[test]
+    fn chunked_decode_with_trailer() {
+        let input = b"5\r\nhello\r\n0\r\nX-Trailer: v\r\n\r\n";
+        let mut r = ChunkedReader::new(Cursor::new(&input[..]));
+        let mut out = Vec::new();
+        r.read_to_end(&mut out).unwrap();
+        assert_eq!(out, b"hello");
+    }
+
+    #[test]
+    fn chunked_decode_with_extension() {
+        let input = b"5;name=foo\r\nhello\r\n0\r\n\r\n";
+        let mut r = ChunkedReader::new(Cursor::new(&input[..]));
+        let mut out = Vec::new();
+        r.read_to_end(&mut out).unwrap();
+        assert_eq!(out, b"hello");
+    }
+
+    #[test]
+    fn chunked_round_trip() {
+        let mut buf = Vec::new();
+        {
+            let mut w = ChunkedWriter::new(&mut buf);
+            w.write_all(b"Hello, ").unwrap();
+            w.write_all(b"world!").unwrap();
+            w.finish().unwrap();
+        }
+        let mut r = ChunkedReader::new(Cursor::new(&buf));
+        let mut out = Vec::new();
+        r.read_to_end(&mut out).unwrap();
+        assert_eq!(out, b"Hello, world!");
+    }
+
+    #[test]
+    fn request_write_head() {
+        let req = Request {
+            method: Method::Post,
+            target: "/x".into(),
+            version: Version::Http11,
+            headers: {
+                let mut h = Headers::new();
+                h.set("Host", "example.com");
+                h
+            },
+        };
+        let mut buf = Vec::new();
+        req.write_head(&mut buf).unwrap();
+        assert_eq!(buf, b"POST /x HTTP/1.1\r\nHost: example.com\r\n\r\n");
+    }
+
+    #[test]
+    fn percent_encode_basic() {
+        assert_eq!(percent_encode("a b+c"), "a%20b%2Bc");
+        assert_eq!(percent_encode("~-._"), "~-._");
+    }
+
+    #[test]
+    fn percent_decode_basic() {
+        assert_eq!(percent_decode("a%20b%2Bc"), "a b+c");
+        assert_eq!(percent_decode("a+b"), "a b");
+    }
+
+    #[test]
+    fn url_form_encoding() {
+        let s = url_form(&[("k1", "v 1"), ("k2", "a&b")]);
+        assert_eq!(s, "k1=v%201&k2=a%26b");
+    }
+
+    #[test]
+    fn parse_url_defaults() {
+        let (s, h, p, path) = parse_url("https://example.com/").unwrap();
+        assert_eq!(s, Scheme::Https);
+        assert_eq!(h, "example.com");
+        assert_eq!(p, 443);
+        assert_eq!(path, "/");
+    }
+
+    #[test]
+    fn parse_url_with_port_and_path() {
+        let (s, h, p, path) =
+            parse_url("http://localhost:8080/api?x=1").unwrap();
+        assert_eq!(s, Scheme::Http);
+        assert_eq!(h, "localhost");
+        assert_eq!(p, 8080);
+        assert_eq!(path, "/api?x=1");
+    }
+
+    #[test]
+    fn parse_url_missing_path() {
+        let (_, _, _, path) = parse_url("http://example.com").unwrap();
+        assert_eq!(path, "/");
+    }
+
+    #[test]
+    fn parse_url_rejects_bad_scheme() {
+        assert!(parse_url("ftp://example.com/").is_err());
+    }
+
+    #[test]
+    fn final_chunked_detection() {
+        assert!(is_final_chunked("chunked"));
+        assert!(is_final_chunked("gzip, chunked"));
+        assert!(is_final_chunked("  chunked  "));
+        assert!(!is_final_chunked("chunked, gzip"));
+        assert!(!is_final_chunked("gzip"));
+    }
+}