net-cat 0.2.0

Minimal hand-rolled HTTP/1.1 client over std::net::TcpStream. Plain HTTP in v0; v0.2.0 adds an optional `tls` feature that wires rustls + webpki-roots Mozilla CA bundle so `https://` URLs work via the same `fetch` / `exchange` entry points. No external HTTP crate; framing and parsing are local. No `mut` beyond FFI carve-outs (`TcpStream::read_to_end`, rustls `Stream::new(&mut conn, &mut sock)`). Sixth sub-crate of a Servo-replacement webview runtime targeting Tauri.
//! TCP transport: write the request, read until EOF, parse the response.
//!
//! `TcpStream::read_to_end` takes `&mut self` and is the only place
//! where `let mut` is permitted (FFI carve-out per CLAUDE.md).

use std::io::{Read, Write};
use std::net::TcpStream;

use crate::error::Error;
use crate::headers::Headers;
use crate::method::Method;
use crate::request::Request;
use crate::response::Response;
use crate::url::Url;

/// Execute `request` over a fresh TCP connection.
///
/// `http://` URLs go over the v0 plain-TCP path; `https://` URLs
/// go over rustls when the `tls` feature is enabled, and otherwise
/// produce [`Error::UnsupportedScheme`].
///
/// # Errors
///
/// Returns [`Error::UnsupportedScheme`] for non-`http`/`https`
/// URLs (or `https://` without the `tls` feature),
/// [`Error::Io`] for TCP connection / read / write failures,
/// and [`Error::Tls`] for handshake / read / write failures from
/// rustls (with the `tls` feature).
pub fn exchange(request: &Request) -> Result<Response, Error> {
    let url = request.url();
    let raw = match url.scheme() {
        "http" => transmit_plain(request, url),
        "https" => transmit_https(request, url),
        _other => Err(Error::UnsupportedScheme {
            scheme: url.scheme().to_owned(),
        }),
    }?;
    parse_response(&raw)
}

fn transmit_plain(request: &Request, url: &Url) -> Result<Vec<u8>, Error> {
    let address = format!("{}:{}", url.host(), url.port());
    let stream = TcpStream::connect(address)?;
    let wire = build_wire_request(request, url);
    write_all(&stream, &wire)?;
    read_to_end(stream)
}

#[cfg(feature = "tls")]
fn transmit_https(request: &Request, url: &Url) -> Result<Vec<u8>, Error> {
    let wire = build_wire_request(request, url);
    crate::tls::exchange_https(url.host(), url.port(), &wire)
}

#[cfg(not(feature = "tls"))]
fn transmit_https(_request: &Request, _url: &Url) -> Result<Vec<u8>, Error> {
    Err(Error::UnsupportedScheme {
        scheme: "https".to_owned(),
    })
}

fn build_wire_request(request: &Request, url: &Url) -> Vec<u8> {
    let request_line = format!(
        "{} {} HTTP/1.1\r\n",
        request.method().as_str(),
        url.request_target()
    );
    let host_header = host_header(url);
    let user_agent = "User-Agent: net-cat/0.1\r\n".to_owned();
    let connection_close = "Connection: close\r\n".to_owned();
    let content_length = content_length_header(request);
    let user_headers = format_headers(request.headers());
    let headers_blob = format!(
        "{request_line}{host_header}{user_agent}{connection_close}{content_length}{user_headers}\r\n"
    );
    let mut wire = headers_blob.into_bytes();
    if !request.body().is_empty() {
        wire.extend_from_slice(request.body());
    }
    wire
}

fn host_header(url: &Url) -> String {
    let default = matches!((url.scheme(), url.port()), ("http", 80) | ("https", 443));
    if default {
        format!("Host: {}\r\n", url.host())
    } else {
        format!("Host: {}:{}\r\n", url.host(), url.port())
    }
}

fn content_length_header(request: &Request) -> String {
    match request.method() {
        Method::Get | Method::Head | Method::Options => String::new(),
        Method::Post | Method::Put | Method::Delete | Method::Patch => {
            format!("Content-Length: {}\r\n", request.body().len())
        }
    }
}

fn format_headers(headers: &Headers) -> String {
    // We supply Host, User-Agent, Connection, and Content-Length
    // ourselves; user-provided versions would conflict.
    headers
        .iter()
        .filter(|(name, _)| {
            !matches!(
                name.to_ascii_lowercase().as_str(),
                "host" | "user-agent" | "connection" | "content-length"
            )
        })
        .fold(String::new(), |acc, (name, value)| {
            format!("{acc}{name}: {value}\r\n")
        })
}

fn write_all(stream: &TcpStream, bytes: &[u8]) -> Result<(), Error> {
    // External `&mut self` carve-out for `Write::write_all`.
    let mut handle = stream;
    handle.write_all(bytes)?;
    handle.flush()?;
    Ok(())
}

fn read_to_end(stream: TcpStream) -> Result<Vec<u8>, Error> {
    // External `&mut self` carve-out for `Read::read_to_end`.
    let mut owned = stream;
    let mut buffer = Vec::new();
    owned.read_to_end(&mut buffer)?;
    Ok(buffer)
}

fn parse_response(raw: &[u8]) -> Result<Response, Error> {
    let split = find_double_crlf(raw).ok_or_else(|| Error::InvalidStatusLine {
        text: String::from_utf8_lossy(raw).into_owned(),
    })?;
    let head = raw.get(..split).unwrap_or(&[]);
    let body_start = split + 4;
    let body: Vec<u8> = raw.get(body_start..).unwrap_or(&[]).to_vec();
    let head_text = std::str::from_utf8(head).map_err(|_| Error::InvalidStatusLine {
        text: String::from_utf8_lossy(head).into_owned(),
    })?;
    let mut lines = head_text.split("\r\n");
    let status_line = lines.next().unwrap_or("");
    let (status, reason) = parse_status_line(status_line)?;
    let headers = lines.try_fold(Headers::new(), |acc, line| {
        if line.is_empty() {
            Ok(acc)
        } else {
            parse_header(line).map(|(name, value)| acc.with(name, value))
        }
    })?;
    Ok(Response::new(status, reason, headers, body))
}

fn find_double_crlf(bytes: &[u8]) -> Option<usize> {
    let separator = b"\r\n\r\n";
    bytes
        .windows(separator.len())
        .position(|window| window == separator)
}

fn parse_status_line(line: &str) -> Result<(u16, String), Error> {
    let mut parts = line.splitn(3, ' ');
    let _version = parts.next().unwrap_or("");
    let status_text = parts.next().unwrap_or("");
    let reason = parts.next().unwrap_or("").to_owned();
    let status = status_text
        .parse::<u16>()
        .map_err(|_| Error::InvalidStatusLine {
            text: line.to_owned(),
        })?;
    Ok((status, reason))
}

fn parse_header(line: &str) -> Result<(String, String), Error> {
    line.split_once(':')
        .map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned()))
        .ok_or_else(|| Error::InvalidHeader {
            line: line.to_owned(),
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;
    use crate::method::Method;
    use crate::request::Request as HttpRequest;
    use crate::url::Url as HttpUrl;

    #[cfg(not(feature = "tls"))]
    #[test]
    fn https_without_tls_feature_is_unsupported() -> Result<(), Error> {
        // Offline-safe: the scheme check inside `transmit_https`
        // returns before any socket work happens when the `tls`
        // feature is absent.
        let url = HttpUrl::parse("https://example.com/")?;
        let request = HttpRequest::new(Method::Get, url);
        let outcome = super::exchange(&request);
        matches!(outcome, Err(Error::UnsupportedScheme { ref scheme }) if scheme == "https")
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "expected UnsupportedScheme(https) without tls feature".to_owned(),
            })
    }

    #[cfg(feature = "tls")]
    #[test]
    fn tls_client_config_builds_without_panic() -> Result<(), Error> {
        // Offline-safe: exercises the rustls config / crypto-provider
        // install path that `exchange_https` runs before opening a
        // socket.  If rustls' default-provider expectations ever drift,
        // this test catches it without touching the network.
        let url = HttpUrl::parse("https://example.com/")?;
        (url.scheme() == "https" && url.port() == 443)
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "https default port should be 443".to_owned(),
            })?;
        crate::tls::install_default_crypto_provider();
        let _config = crate::tls::make_client_config();
        Ok(())
    }

    #[cfg(feature = "tls")]
    #[test]
    fn unknown_scheme_is_unsupported_under_tls() -> Result<(), Error> {
        let url = HttpUrl::parse("gopher://example.com/")?;
        let request = HttpRequest::new(Method::Get, url);
        let outcome = super::exchange(&request);
        matches!(outcome, Err(Error::UnsupportedScheme { ref scheme }) if scheme == "gopher")
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "expected UnsupportedScheme(gopher)".to_owned(),
            })
    }

    #[test]
    fn parse_minimal_response() -> Result<(), Error> {
        let raw = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi";
        let response = parse_response(raw)?;
        (response.status() == 200 && response.body() == b"hi")
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "expected 200 OK with body 'hi'".to_owned(),
            })
    }

    #[test]
    fn parse_empty_body() -> Result<(), Error> {
        let raw = b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n";
        let response = parse_response(raw)?;
        (response.status() == 204 && response.body().is_empty())
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "expected 204".to_owned(),
            })
    }

    #[test]
    fn parse_multiple_headers() -> Result<(), Error> {
        let raw = b"HTTP/1.1 200 OK\r\nServer: net-cat\r\nContent-Type: text/html\r\n\r\n<p>hi</p>";
        let response = parse_response(raw)?;
        let server = response.headers().get("server").unwrap_or("");
        let content_type = response.headers().get("content-type").unwrap_or("");
        (server == "net-cat" && content_type == "text/html")
            .then_some(())
            .ok_or(Error::InvalidStatusLine {
                text: "header round-trip failed".to_owned(),
            })
    }
}