leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use super::*;

fn bare_response(audit_tls: Option<Arc<crate::audit::AuditTlsCache>>) -> Response {
    Response {
        status: http::StatusCode::OK,
        version: HttpVersion::Http2,
        headers: http::HeaderMap::new(),
        trailers: Vec::new(),
        body: ResponseBody::Buffered(Vec::new()),
        url: Url::parse("https://example.test/").unwrap(),
        redirect_chain: Vec::new(),
        request_headers: vec![
            (":method".to_string(), "GET".to_string()),
            ("accept-language".to_string(), "en-US,en;q=0.9".to_string()),
            ("referer".to_string(), "https://example.test/".to_string()),
        ],
        tls: None,
        request_method: "GET".to_string(),
        audit_tls,
        audit_cache: OnceLock::new(),
        timing: ResponseTiming::default(),
        compression: crate::core::CompressionConfig::default(),
        attempts: 1,
        proxy: None,
    }
}

fn sample_cache() -> Arc<crate::audit::AuditTlsCache> {
    Arc::new(crate::audit::AuditTlsCache {
        ja4: "t13d1516h2_8daaf6152771_d8a2da3f94cd".to_string(),
        ja3: "771,4865-4866,0-23,29-23,0".to_string(),
        h2_fingerprint: "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p".to_string(),
        ja4t: "64240_2-1-3-1-1-4_1460_8".to_string(),
        permutes_extensions: false,
    })
}

#[test]
fn audit_is_none_without_tls_context() {
    let resp = bare_response(None);
    assert!(resp.audit().is_none());
}

#[test]
fn audit_surfaces_cached_connection_fingerprints() {
    let cache = sample_cache();
    let resp = bare_response(Some(cache.clone()));
    let audit = resp.audit().expect("audit present when tls context set");
    assert_eq!(audit.ja4, cache.ja4);
    assert_eq!(audit.ja3, cache.ja3);
    assert_eq!(audit.h2_fingerprint, cache.h2_fingerprint);
    assert_eq!(audit.ja4t, cache.ja4t);
    assert_eq!(
        audit.ja4h.split('_').count(),
        4,
        "JA4H shape: {}",
        audit.ja4h
    );
}

#[test]
fn audit_memoises_across_calls() {
    let resp = bare_response(Some(sample_cache()));
    let first = resp.audit().unwrap() as *const _;
    let second = resp.audit().unwrap() as *const _;
    assert_eq!(first, second, "audit() must memoise, not recompute");
}

#[cfg(feature = "charset")]
#[tokio::test]
async fn text_decodes_declared_charset() {
    let mut resp = bare_response(None);
    resp.headers = [(
        http::HeaderName::from_static("content-type"),
        http::HeaderValue::from_static("text/html; charset=windows-1252"),
    )]
    .into_iter()
    .collect();
    resp.body = ResponseBody::Buffered(vec![0xE9, 0xA9]);
    assert_eq!(resp.text().await.unwrap(), "é©");
}

#[cfg(feature = "charset")]
#[tokio::test]
async fn text_charset_param_is_case_insensitive_and_unquoted() {
    let mut resp = bare_response(None);
    resp.headers = [(
        http::HeaderName::from_static("content-type"),
        http::HeaderValue::from_static("text/plain; Charset=\"Shift_JIS\""),
    )]
    .into_iter()
    .collect();
    resp.body = ResponseBody::Buffered(vec![0x82, 0xA0]);
    assert_eq!(resp.text().await.unwrap(), "あ");
}

#[cfg(feature = "charset")]
#[tokio::test]
async fn text_defaults_to_utf8_without_charset() {
    let mut resp = bare_response(None);
    resp.body = ResponseBody::Buffered("héllo".as_bytes().to_vec());
    assert_eq!(resp.text().await.unwrap(), "héllo");
}

#[cfg(feature = "charset")]
#[tokio::test]
async fn declared_charset_overrides_text_with_charset_default() {
    let mut resp = bare_response(None);
    resp.headers = [(
        http::HeaderName::from_static("content-type"),
        http::HeaderValue::from_static("text/plain; charset=utf-8"),
    )]
    .into_iter()
    .collect();
    resp.body = ResponseBody::Buffered("héllo".as_bytes().to_vec());
    assert_eq!(
        resp.text_with_charset("windows-1252").await.unwrap(),
        "héllo"
    );
}

fn leg(reused: bool, connect_ms: Option<u32>, send_ms: u32, total_ms: u32) -> ResponseTiming {
    ResponseTiming {
        reused,
        connect_ms,
        send_ms,
        total_ms,
    }
}

#[test]
fn timing_single_leg_equals_that_leg() {
    let mut acc = ResponseTiming::accumulator();
    let only = leg(true, None, 12, 15);
    acc.add_leg(&only);
    assert_eq!(acc, only);
}

#[test]
fn timing_cold_then_warm_redirect_sums_and_marks_not_reused() {
    let mut acc = ResponseTiming::accumulator();
    acc.add_leg(&leg(false, Some(40), 60, 105));
    acc.add_leg(&leg(true, None, 800, 800));
    assert!(!acc.reused);
    assert_eq!(acc.connect_ms, Some(40));
    assert_eq!(acc.send_ms, 860);
    assert_eq!(acc.total_ms, 905);
}

#[test]
fn timing_all_warm_stays_reused_with_no_connect() {
    let mut acc = ResponseTiming::accumulator();
    acc.add_leg(&leg(true, None, 5, 6));
    acc.add_leg(&leg(true, None, 7, 8));
    assert!(acc.reused);
    assert_eq!(acc.connect_ms, None);
    assert_eq!(acc.total_ms, 14);
}

#[test]
fn timing_two_cold_legs_sum_connect() {
    let mut acc = ResponseTiming::accumulator();
    acc.add_leg(&leg(false, Some(30), 10, 45));
    acc.add_leg(&leg(false, Some(25), 12, 40));
    assert_eq!(acc.connect_ms, Some(55));
    assert!(!acc.reused);
}

#[tokio::test]
async fn bytes_on_a_taken_stream_reports_a_body_error() {
    let mut resp = bare_response(None);
    resp.body = ResponseBody::Taken;
    let err = resp.bytes().await.unwrap_err();
    assert_eq!(err.kind(), Kind::Body, "expected a body error, got {err:?}");
    assert!(err.to_string().contains("into_stream"), "{err}");
}

#[tokio::test]
async fn text_reads_a_buffered_body_and_a_stream() {
    let mut resp = bare_response(None);
    resp.body = ResponseBody::Buffered(b"hi".to_vec());
    assert_eq!(resp.text().await.unwrap(), "hi");

    let mut resp = bare_response(None);
    resp.body = ResponseBody::Streaming(BodyStream::from_bytes(bytes::Bytes::from_static(b"hi")));
    assert_eq!(resp.text().await.unwrap(), "hi");
}

#[test]
fn timing_add_leg_saturates_not_wraps() {
    let mut acc = ResponseTiming::accumulator();
    acc.add_leg(&leg(false, Some(u32::MAX), u32::MAX, u32::MAX));
    acc.add_leg(&leg(false, Some(10), 10, 10));
    assert_eq!(acc.total_ms, u32::MAX);
    assert_eq!(acc.send_ms, u32::MAX);
    assert_eq!(acc.connect_ms, Some(u32::MAX));
}