leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use super::super::{Session, SessionBuilder};
use crate::core::error::Kind;
use crate::core::response::{HttpVersion, Response};
use crate::{Body, ContentEncoding};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::test]
async fn plaintext_http_uses_h1_and_preserves_duplicate_headers() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut req = Vec::new();
        let mut tmp = [0u8; 1024];
        loop {
            let n = socket.read(&mut tmp).await.unwrap();
            assert!(n > 0, "client closed before request headers");
            req.extend_from_slice(&tmp[..n]);
            if req.windows(4).any(|w| w == b"\r\n\r\n") {
                break;
            }
        }

        let text = String::from_utf8_lossy(&req);
        assert!(text.starts_with("GET /wire?q=1 HTTP/1.1\r\n"), "{text}");
        assert!(text.contains(&format!("\r\nHost: {addr}\r\n")), "{text}");
        assert!(text.contains("\r\nUser-Agent: "), "{text}");
        assert!(text.contains("\r\nAccept-Encoding: "), "{text}");
        assert!(text.contains("\r\nConnection: keep-alive\r\n"), "{text}");
        let first = text.find("x-dup: one").unwrap();
        let second = text.find("x-dup: two").unwrap();
        assert!(first < second, "{text}");

        socket
                .write_all(
                    b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nset-cookie: a=1\r\nset-cookie: b=2\r\n\r\nok",
                )
                .await
                .unwrap();
    });

    let session = Session::browser(crate::Browser::default());
    let resp = session
        .request(http::Method::GET, format!("http://{addr}/wire?q=1"))
        .header("x-dup", "one")
        .header("x-dup", "two")
        .send()
        .await
        .unwrap();

    assert_eq!(resp.version(), HttpVersion::Http1_1);
    assert_eq!(
        resp.headers()
            .get_all(http::header::SET_COOKIE)
            .iter()
            .filter_map(|v| v.to_str().ok())
            .collect::<Vec<_>>(),
        vec!["a=1", "b=2"]
    );
    assert_eq!(resp.text().await.unwrap(), "ok");
    server.await.unwrap();
}

#[tokio::test]
async fn owned() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut req = Vec::new();
        let mut tmp = [0u8; 1024];
        loop {
            let n = socket.read(&mut tmp).await.unwrap();
            assert!(n > 0, "client closed before request headers");
            req.extend_from_slice(&tmp[..n]);
            if req.windows(4).any(|w| w == b"\r\n\r\n") {
                break;
            }
        }
        socket
            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
            .await
            .unwrap();
    });
    let req = http::Request::builder()
        .method(http::Method::GET)
        .uri(format!("http://{addr}/owned"))
        .body(Body::from(Vec::new()))
        .unwrap();
    let resp = Session::browser(crate::Browser::default())
        .execute(req)
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(resp.text().await.unwrap(), "ok");
    server.await.unwrap();
}

async fn one_shot_get(builder: SessionBuilder, path: &str) -> Response {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut tmp = [0u8; 1024];
        let mut req = Vec::new();
        loop {
            let n = socket.read(&mut tmp).await.unwrap();
            if n == 0 {
                break;
            }
            req.extend_from_slice(&tmp[..n]);
            if req.windows(4).any(|w| w == b"\r\n\r\n") {
                break;
            }
        }
        socket
            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
            .await
            .unwrap();
    });
    let session = builder.build().unwrap();
    let resp = session.get(format!("http://{addr}{path}")).await.unwrap();
    server.await.unwrap();
    resp
}

#[tokio::test]
async fn audit_is_off_by_default() {
    let resp = one_shot_get(Session::builder(), "/x").await;
    assert!(
        resp.audit().is_none(),
        "audit() should be None unless opted in"
    );
    assert!(
        resp.request_headers().next().is_none(),
        "request headers should not be retained unless opted in"
    );
}

#[tokio::test]
async fn audit_opt_in_populates_fingerprints_and_headers() {
    let resp = one_shot_get(Session::builder().audit(true), "/x").await;
    let audit = resp
        .audit()
        .expect("audit() should be Some when .audit(true) is set");
    assert!(!audit.ja4.is_empty(), "JA4 should be populated");
    assert_eq!(
        audit.ja4h.split('_').count(),
        4,
        "JA4H shape: {}",
        audit.ja4h
    );
    assert!(
        resp.request_headers().any(|(k, _)| k == "user-agent"),
        "request headers should be retained when audit is on"
    );
}

#[cfg(feature = "compression-gzip")]
#[tokio::test]
async fn compress_sets_header_and_puts_compressed_bytes_on_the_wire() {
    use std::io::Read;

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = Vec::new();
        let mut tmp = [0u8; 1024];
        let (buf, body_start, content_len) = loop {
            let n = socket.read(&mut tmp).await.unwrap();
            assert!(n > 0, "client closed before full request");
            buf.extend_from_slice(&tmp[..n]);
            if let Some(hdr_end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
                let head = String::from_utf8_lossy(&buf[..hdr_end]).to_lowercase();
                let content_len: usize = head
                    .split("\r\n")
                    .find_map(|l| l.strip_prefix("content-length:"))
                    .map(|v| v.trim().parse().unwrap())
                    .expect("content-length header present");
                let body_start = hdr_end + 4;
                if buf.len() >= body_start + content_len {
                    break (buf, body_start, content_len);
                }
            }
        };

        let head = String::from_utf8_lossy(&buf[..body_start]).to_lowercase();
        assert!(
            head.contains("content-encoding: gzip"),
            "compressed request must declare content-encoding: gzip:\n{head}"
        );

        let mut decoded = Vec::new();
        flate2::read::GzDecoder::new(&buf[body_start..body_start + content_len])
            .read_to_end(&mut decoded)
            .unwrap();

        socket
            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
            .await
            .unwrap();
        decoded
    });

    let payload = b"the quick brown fox jumps over the lazy dog. ".repeat(64);
    let resp = Session::browser(crate::Browser::default())
        .post(format!("http://{addr}/upload"))
        .body(payload.clone())
        .compress(ContentEncoding::Gzip)
        .send()
        .await
        .unwrap();

    assert_eq!(resp.status(), 200);
    let decoded = server.await.unwrap();
    assert_eq!(
        decoded, payload,
        "server must decode back to the original body"
    );
}

#[tokio::test]
async fn compress_rejects_streaming_body() {
    let body = Body::stream(
        futures_util::stream::iter(vec![Ok::<_, std::io::Error>(bytes::Bytes::from_static(
            b"chunk",
        ))]),
        None,
    );
    let err = Session::browser(crate::Browser::default())
        .post("http://127.0.0.1:9/x")
        .body(body)
        .compress(ContentEncoding::Gzip)
        .send()
        .await
        .unwrap_err();
    assert_eq!(err.kind(), Kind::Body, "got: {err:?}");
}

#[tokio::test]
async fn unsupported_scheme_proxy_is_refused_not_sent_in_cleartext() {
    let err = Session::browser(crate::Browser::default())
        .request(http::Method::GET, "https://example.test/")
        .proxy("ftp://user:secret@127.0.0.1:1")
        .send()
        .await
        .unwrap_err();
    let msg = format!("{err}").to_lowercase();
    assert!(
        msg.contains("ftp") && msg.contains("unsupported proxy scheme"),
        "expected an unsupported-scheme cleartext-refusal error, got: {msg}"
    );
}

#[cfg(feature = "compression-gzip")]
#[tokio::test]
async fn compress_strips_stale_caller_content_length() {
    use std::io::Read;

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = Vec::new();
        let mut tmp = [0u8; 1024];
        let (buf, body_start, content_len) = loop {
            let n = socket.read(&mut tmp).await.unwrap();
            assert!(n > 0, "client closed before full request");
            buf.extend_from_slice(&tmp[..n]);
            if let Some(hdr_end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
                let head = String::from_utf8_lossy(&buf[..hdr_end]).to_lowercase();
                assert_eq!(
                    head.matches("content-length:").count(),
                    1,
                    "exactly one content-length expected:\n{head}"
                );
                let content_len: usize = head
                    .split("\r\n")
                    .find_map(|l| l.strip_prefix("content-length:"))
                    .map(|v| v.trim().parse().unwrap())
                    .expect("content-length header present");
                assert!(
                    content_len < 10_000,
                    "stale caller content-length leaked to the wire: {content_len}"
                );
                let body_start = hdr_end + 4;
                if buf.len() >= body_start + content_len {
                    break (buf, body_start, content_len);
                }
            }
        };

        let mut decoded = Vec::new();
        flate2::read::GzDecoder::new(&buf[body_start..body_start + content_len])
            .read_to_end(&mut decoded)
            .unwrap();
        socket
            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
            .await
            .unwrap();
        decoded
    });

    let payload = b"the quick brown fox jumps over the lazy dog. ".repeat(64);
    let resp = Session::browser(crate::Browser::default())
        .post(format!("http://{addr}/upload"))
        .header("content-length", "999999")
        .body(payload.clone())
        .compress(ContentEncoding::Gzip)
        .send()
        .await
        .unwrap();

    assert_eq!(resp.status(), 200);
    let decoded = server.await.unwrap();
    assert_eq!(decoded, payload);
}

#[tokio::test]
async fn https_scheme_proxy_is_accepted_and_dialed_over_tls() {
    let err = Session::browser(crate::Browser::default())
        .request(http::Method::GET, "https://example.test/")
        .proxy("https://user:secret@127.0.0.1:1")
        .send()
        .await
        .unwrap_err();
    let msg = format!("{err}").to_lowercase();
    assert!(
        !msg.contains("cleartext") && !msg.contains("unsupported proxy scheme"),
        "https proxy must be supported, got: {msg}"
    );
}

#[tokio::test]
async fn json_builder_returns_error_instead_of_panicking() {
    struct BadJson;

    impl serde::Serialize for BadJson {
        fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
        where
            S: serde::Serializer,
        {
            Err(serde::ser::Error::custom("intentional test error"))
        }
    }

    let session = Session::browser(crate::Browser::default());
    let err = session
        .post("http://127.0.0.1:9/no-network")
        .json(&BadJson)
        .send()
        .await
        .unwrap_err();
    assert_eq!(err.kind(), Kind::Json);
}