leyline-http 0.1.0

HTTP client that mimics browsers on the wire
Documentation
#![expect(
    clippy::unwrap_used,
    reason = "test/example harness: unwrap doubles as the assertion - a failed helper panics with the test location"
)]
use leyline::{DigestAuth, ProtocolPolicy, Session};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn read_headers(sock: &mut tokio::net::TcpStream) -> String {
    let mut buf = [0u8; 4096];
    let mut acc = Vec::new();
    loop {
        let n = sock.read(&mut buf).await.unwrap();
        if n == 0 {
            break;
        }
        acc.extend_from_slice(&buf[..n]);
        if acc.windows(4).any(|w| w == b"\r\n\r\n") {
            break;
        }
    }
    String::from_utf8_lossy(&acc).to_string()
}

fn extract_authorization(headers: &str) -> Option<String> {
    for line in headers.split("\r\n") {
        let lower = line.to_ascii_lowercase();
        if let Some(rest) = lower.strip_prefix("authorization: ") {
            let _ = rest;
            let v = line.split_once(": ").unwrap().1.to_string();
            return Some(v);
        }
    }
    None
}

async fn run_digest_server(
    listener: tokio::net::TcpListener,
    algo_wire: &'static str,
    expected_user: &'static str,
    expected_realm: &'static str,
) {
    let (mut sock, _) = listener.accept().await.unwrap();
    let _headers = read_headers(&mut sock).await;
    let challenge = format!(
        "WWW-Authenticate: Digest realm=\"{expected_realm}\", \
         nonce=\"abc123nonce\", qop=\"auth\", algorithm={algo_wire}"
    );
    let resp = format!(
        "HTTP/1.1 401 Unauthorized\r\n\
         {challenge}\r\n\
         content-length: 0\r\nconnection: close\r\n\r\n"
    );
    sock.write_all(resp.as_bytes()).await.unwrap();
    sock.flush().await.unwrap();
    drop(sock);

    let (mut sock, _) = listener.accept().await.unwrap();
    let headers = read_headers(&mut sock).await;
    let auth = extract_authorization(&headers).expect("Authorization header on retry");
    assert!(auth.starts_with("Digest "), "expected Digest, got: {auth}");
    assert!(
        auth.contains(&format!("username=\"{expected_user}\"")),
        "{auth}"
    );
    assert!(auth.contains("nonce=\"abc123nonce\""), "{auth}");
    assert!(auth.contains("response=\""), "{auth}");
    assert!(auth.contains("qop=auth"), "{auth}");
    assert!(auth.contains(&format!("algorithm={algo_wire}")), "{auth}");

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

#[tokio::test]
async fn md5_challenge_round_trip() {
    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 {
        run_digest_server(listener, "MD5", "mufasa", "example.org").await;
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .request(http::Method::GET, format!("http://{addr}/protected"))
        .digest_auth(DigestAuth::new("mufasa", "circle-of-life"))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(resp.text().await.unwrap(), "ok");
    server.await.unwrap();
}

#[tokio::test]
async fn sha256_challenge_round_trip() {
    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 {
        run_digest_server(listener, "SHA-256", "admin", "secure.local").await;
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .request(http::Method::GET, format!("http://{addr}/"))
        .digest_auth(DigestAuth::new("admin", "hunter2"))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    server.await.unwrap();
}

#[tokio::test]
async fn stale_nonce_is_retried_transparently() {
    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 sock, _) = listener.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        sock.write_all(
            b"HTTP/1.1 401 Unauthorized\r\n\
              WWW-Authenticate: Digest realm=\"r\", nonce=\"n1\", qop=\"auth\", algorithm=MD5\r\n\
              content-length: 0\r\nconnection: close\r\n\r\n",
        )
        .await
        .unwrap();
        sock.flush().await.unwrap();
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let headers = read_headers(&mut sock).await;
        let auth = extract_authorization(&headers).expect("auth on first retry");
        assert!(auth.contains("nonce=\"n1\""), "{auth}");
        sock.write_all(
            b"HTTP/1.1 401 Unauthorized\r\n\
              WWW-Authenticate: Digest realm=\"r\", nonce=\"n2\", qop=\"auth\", algorithm=MD5, stale=true\r\n\
              content-length: 0\r\nconnection: close\r\n\r\n",
        )
        .await
        .unwrap();
        sock.flush().await.unwrap();
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let headers = read_headers(&mut sock).await;
        let auth = extract_authorization(&headers).expect("auth on stale retry");
        assert!(auth.contains("nonce=\"n2\""), "{auth}");
        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
            .await
            .unwrap();
        sock.flush().await.unwrap();
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .request(http::Method::GET, format!("http://{addr}/protected"))
        .digest_auth(DigestAuth::new("u", "p"))
        .send()
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        200,
        "stale=true 401 must be retried transparently with the fresh nonce"
    );
    server.await.unwrap();
}

async fn reply(sock: &mut tokio::net::TcpStream, response: &[u8]) {
    sock.write_all(response).await.unwrap();
    sock.flush().await.unwrap();
}

const CHALLENGE_401: &[u8] = b"HTTP/1.1 401 Unauthorized\r\n\
    WWW-Authenticate: Digest realm=\"r\", nonce=\"n1\", qop=\"auth\", algorithm=MD5\r\n\
    content-length: 0\r\nconnection: close\r\n\r\n";

#[tokio::test]
async fn digest_answers_the_redirected_hop_without_replaying_the_post() {
    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 sock, _) = listener.accept().await.unwrap();
        let mut head = read_headers(&mut sock).await;
        while !head.ends_with("x=1") {
            let mut buf = [0u8; 64];
            let n = sock.read(&mut buf).await.unwrap();
            assert!(n > 0, "{head}");
            head.push_str(&String::from_utf8_lossy(&buf[..n]));
        }
        assert!(head.starts_with("POST /submit "), "{head}");
        reply(
            &mut sock,
            b"HTTP/1.1 303 See Other\r\nlocation: /protected\r\n\
              content-length: 0\r\nconnection: close\r\n\r\n",
        )
        .await;
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let head = read_headers(&mut sock).await;
        assert!(head.starts_with("GET /protected "), "{head}");
        reply(&mut sock, CHALLENGE_401).await;
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let head = read_headers(&mut sock).await;
        reply(
            &mut sock,
            b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok",
        )
        .await;
        head
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .post(format!("http://{addr}/submit"))
        .body("x=1")
        .digest_auth(DigestAuth::new("u", "p"))
        .send()
        .await;
    let answered = server.await.unwrap();
    assert!(
        answered.starts_with("GET /protected "),
        "the digest answer must go to the challenged GET, not replay the POST: {answered}"
    );
    let auth = extract_authorization(&answered).expect("authorization on the challenged hop");
    assert!(auth.contains("uri=\"/protected\""), "{auth}");
    assert_eq!(resp.unwrap().status(), 200);
}

#[tokio::test]
async fn digest_answers_a_new_challenge_after_an_authenticated_redirect() {
    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 sock, _) = listener.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        reply(&mut sock, CHALLENGE_401).await;
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        reply(
            &mut sock,
            b"HTTP/1.1 302 Found\r\nlocation: /next\r\n\
              content-length: 0\r\nconnection: close\r\n\r\n",
        )
        .await;
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let head = read_headers(&mut sock).await;
        assert!(head.starts_with("GET /next "), "{head}");
        reply(&mut sock, CHALLENGE_401).await;
        drop(sock);

        let (mut sock, _) = listener.accept().await.unwrap();
        let head = read_headers(&mut sock).await;
        reply(
            &mut sock,
            b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok",
        )
        .await;
        head
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .get(format!("http://{addr}/protected"))
        .digest_auth(DigestAuth::new("u", "p"))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    let answered = server.await.unwrap();
    let auth = extract_authorization(&answered).expect("authorization after the redirect");
    assert!(auth.contains("uri=\"/next\""), "{auth}");
}

#[tokio::test]
async fn digest_challenge_from_a_redirected_origin_is_not_answered() {
    let first = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let other = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let first_addr = first.local_addr().unwrap();
    let other_addr = other.local_addr().unwrap();

    let origin = tokio::spawn(async move {
        let (mut sock, _) = first.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        let redirect = format!(
            "HTTP/1.1 302 Found\r\nlocation: http://{other_addr}/protected\r\n\
             content-length: 0\r\nconnection: close\r\n\r\n"
        );
        reply(&mut sock, redirect.as_bytes()).await;
    });
    let challenger = tokio::spawn(async move {
        let (mut sock, _) = other.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        reply(&mut sock, CHALLENGE_401).await;
        drop(sock);
        tokio::time::timeout(std::time::Duration::from_millis(300), other.accept())
            .await
            .is_err()
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .get(format!("http://{first_addr}/start"))
        .digest_auth(DigestAuth::new("u", "p"))
        .send()
        .await
        .unwrap();
    origin.await.unwrap();
    assert!(
        challenger.await.unwrap(),
        "credentials must not be sent to an origin the caller did not target"
    );
    assert_eq!(resp.status(), 401);
}

#[tokio::test]
async fn non_digest_401_is_passed_through() {
    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 sock, _) = listener.accept().await.unwrap();
        let _ = read_headers(&mut sock).await;
        sock.write_all(
            b"HTTP/1.1 401 Unauthorized\r\n\
              WWW-Authenticate: Basic realm=\"x\"\r\n\
              content-length: 0\r\nconnection: close\r\n\r\n",
        )
        .await
        .unwrap();
        sock.flush().await.unwrap();
    });

    let session = Session::builder()
        .protocol(ProtocolPolicy::Http1)
        .build()
        .unwrap();
    let resp = session
        .request(http::Method::GET, format!("http://{addr}/"))
        .digest_auth(DigestAuth::new("u", "p"))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 401);
    server.await.unwrap();
}