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::evict::{MAX_COOKIES_GLOBAL, MAX_COOKIES_PER_DOMAIN};
use super::*;

#[test]
fn basic_set_and_get() {
    let jar = Jar::new();
    jar.set_cookie(
        &url::Url::parse("https://example.com").unwrap(),
        "sid",
        "abc123",
    );
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com").unwrap(), "sid"),
        Some("abc123".into())
    );
}

#[test]
fn cookie_ordering_chrome_style() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/app/page").unwrap();

    jar.store_set_cookie("a=1; Path=/", &url);
    jar.store_set_cookie("b=2; Path=/app", &url);
    jar.store_set_cookie("c=3; Path=/app/page", &url);

    let header = jar.cookie_header(&url).unwrap();
    assert!(
        header.starts_with("c=3"),
        "expected c=3 first, got: {header}"
    );
    assert!(header.contains("b=2"));
    assert!(header.ends_with("a=1"), "expected a=1 last, got: {header}");
}

#[test]
#[expect(
    clippy::disallowed_methods,
    reason = "sync test needs wall-clock separation for SystemTime ordering; disallow-rule targets async blocking"
)]
fn creation_time_ordering() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();

    jar.store_set_cookie("first=1; Path=/", &url);
    std::thread::sleep(std::time::Duration::from_millis(10));
    jar.store_set_cookie("second=2; Path=/", &url);

    let header = jar.cookie_header(&url).unwrap();
    assert!(
        header.find("first=1").unwrap() < header.find("second=2").unwrap(),
        "older cookie should come first: {header}"
    );
}

#[test]
fn set_cookie_response() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();

    jar.store_response_cookies(
        &[
            "session=abc; Path=/; Secure; HttpOnly",
            "theme=dark; Path=/",
        ],
        &url,
    );

    let header = jar.cookie_header(&url).unwrap();
    assert!(header.contains("session=abc"));
    assert!(header.contains("theme=dark"));
}

#[test]
fn load_and_export() {
    let jar = Jar::new();
    jar.load_cookies(
        "a=1; b=2",
        &url::Url::parse("https://example.com/page").unwrap(),
    );
    let export = jar
        .cookie_header(&url::Url::parse("https://example.com/other").unwrap())
        .unwrap_or_default();
    assert_eq!(export, "a=1; b=2");
}

#[test]
fn same_path_cookies_keep_creation_order() {
    let jar = Jar::new();
    jar.load_cookies(
        "zeta=r; alpha=i; mid=v",
        &url::Url::parse("https://www.example.com/page").unwrap(),
    );
    jar.set_cookie(
        &url::Url::parse("https://www.example.com/").unwrap(),
        "late",
        "abc123",
    );

    let export = jar
        .cookie_header(&url::Url::parse("https://www.example.com/v1/items").unwrap())
        .unwrap_or_default();
    assert_eq!(export, "zeta=r; alpha=i; mid=v; late=abc123");
}

#[test]
#[expect(
    clippy::disallowed_methods,
    reason = "sync test needs wall-clock separation for SystemTime ordering; disallow-rule targets async blocking"
)]
fn replacement_preserves_original_creation_order() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();
    jar.store_set_cookie("first=old; Path=/", &url);
    std::thread::sleep(std::time::Duration::from_millis(10));
    jar.store_set_cookie("second=2; Path=/", &url);
    std::thread::sleep(std::time::Duration::from_millis(10));
    jar.store_set_cookie("first=new; Path=/", &url);

    let header = jar.cookie_header(&url).unwrap();
    assert_eq!(header, "first=new; second=2");
}

#[test]
fn longer_path_cookies_precede_same_path_creation_order() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/cart/items").unwrap();
    jar.store_response_cookies(
        &["root=1; Path=/", "deep=1; Path=/cart", "tail=1; Path=/"],
        &url,
    );

    let export = jar
        .cookie_header(&url::Url::parse("https://example.com/cart/items").unwrap())
        .unwrap_or_default();
    assert_eq!(export, "deep=1; root=1; tail=1");
}

#[test]
fn expired_cookies_not_returned() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();

    jar.store_set_cookie("gone=bye; Max-Age=0", &url);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com").unwrap(), "gone"),
        None
    );
}

#[test]
fn per_domain_eviction() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();

    for i in 0..=MAX_COOKIES_PER_DOMAIN {
        jar.store_set_cookie(&format!("c{}=v{}; Path=/", i, i), &url);
    }

    let inner = lock(&jar.inner);
    let count = inner
        .cookies
        .get("example.com")
        .map(|v| v.len())
        .unwrap_or(0);
    assert!(
        count <= MAX_COOKIES_PER_DOMAIN,
        "expected <= {MAX_COOKIES_PER_DOMAIN}, got {count}"
    );
}

#[test]
fn samesite_none_requires_secure() {
    let jar = Jar::new();
    let url = Url::parse("https://example.com/").unwrap();

    jar.store_set_cookie("bad=val; SameSite=None", &url);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com").unwrap(), "bad"),
        None
    );

    jar.store_set_cookie("good=val; SameSite=None; Secure", &url);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com").unwrap(), "good"),
        Some("val".into())
    );
}

#[test]
fn samesite_enforced_on_cross_site_requests() {
    let jar = Jar::new();
    let set = Url::parse("https://example.com/").unwrap();
    jar.store_set_cookie("strict=1; SameSite=Strict", &set);
    jar.store_set_cookie("lax=1; SameSite=Lax", &set);
    jar.store_set_cookie("none=1; SameSite=None; Secure", &set);

    let req = Url::parse("https://example.com/page").unwrap();

    let same = jar.cookie_header_for(&req, false, true).unwrap();
    assert!(same.contains("strict=1") && same.contains("lax=1") && same.contains("none=1"));

    let cross_get = jar.cookie_header_for(&req, true, true).unwrap();
    assert!(!cross_get.contains("strict=1"), "{cross_get}");
    assert!(cross_get.contains("lax=1") && cross_get.contains("none=1"));

    let cross_post = jar.cookie_header_for(&req, true, false).unwrap();
    assert!(!cross_post.contains("strict=1") && !cross_post.contains("lax=1"));
    assert!(cross_post.contains("none=1"));
}

#[test]
fn insecure_origin_cannot_overwrite_secure_cookie() {
    let jar = Jar::new();
    let https = Url::parse("https://example.com/").unwrap();
    let http = Url::parse("http://example.com/").unwrap();

    jar.store_set_cookie("session=good; Secure; Path=/", &https);
    jar.store_set_cookie("session=evil; Path=/", &http);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com/").unwrap(), "session"),
        Some("good".into()),
        "plaintext overwrite of a Secure cookie must be refused"
    );

    jar.store_set_cookie("session=; Path=/; Max-Age=0", &http);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com/").unwrap(), "session"),
        Some("good".into()),
        "plaintext deletion of a Secure cookie must be refused"
    );

    jar.store_set_cookie("session=rotated; Secure; Path=/", &https);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com/").unwrap(), "session"),
        Some("rotated".into())
    );
    jar.store_set_cookie("session=; Path=/; Max-Age=0", &https);
    assert_eq!(
        jar.get_cookie(&url::Url::parse("https://example.com/").unwrap(), "session"),
        None
    );
}

#[test]
fn secure_cookie_not_sent_over_http() {
    let jar = Jar::new();
    let https = Url::parse("https://example.com/").unwrap();
    jar.store_set_cookie("tok=secret; Secure; SameSite=None", &https);

    assert!(
        jar.get_cookie(&url::Url::parse("https://example.com").unwrap(), "tok")
            .is_some()
    );
    assert!(
        jar.get_cookie(&url::Url::parse("http://example.com").unwrap(), "tok")
            .is_none()
    );
}

#[test]
fn remove_named_removes_every_match() {
    let jar = Jar::new();
    jar.set_cookie(&url::Url::parse("https://a.example.com").unwrap(), "k", "1");
    jar.set_cookie(&url::Url::parse("https://b.example.com").unwrap(), "k", "2");
    assert_eq!(jar.all_cookies().len(), 2);
    assert_eq!(jar.remove_named("k"), 2);
    assert!(jar.all_cookies().is_empty());
    assert_eq!(jar.remove_named("k"), 0);
}

#[test]
fn serde_round_trip_preserves_cross_subdomain_attribution() {
    let jar = Jar::new();
    jar.store_set_cookie(
        "auth=secret; Path=/; Secure",
        &Url::parse("https://api.example.com").unwrap(),
    );
    jar.store_set_cookie(
        "shared=value; Domain=example.com; Path=/; Secure",
        &Url::parse("https://www.example.com").unwrap(),
    );
    jar.store_set_cookie(
        "wwwonly=val; Path=/",
        &Url::parse("https://www.example.com").unwrap(),
    );
    assert_eq!(jar.all_cookies().len(), 3);

    let json = serde_json::to_string(&jar).expect("serialize");
    let restored: Jar = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(restored.all_cookies().len(), 3);

    assert_eq!(
        restored.get_cookie(&url::Url::parse("https://api.example.com").unwrap(), "auth"),
        Some("secret".into()),
        "auth must be visible on api.example.com"
    );
    assert_eq!(
        restored.get_cookie(&url::Url::parse("https://www.example.com").unwrap(), "auth"),
        None,
        "auth must NOT leak to www.example.com (host-only)"
    );
    assert_eq!(
        restored.get_cookie(
            &url::Url::parse("https://www.example.com").unwrap(),
            "shared"
        ),
        Some("value".into())
    );
    assert_eq!(
        restored.get_cookie(
            &url::Url::parse("https://api.example.com").unwrap(),
            "shared"
        ),
        Some("value".into()),
        "Domain=example.com cookie must reach every subdomain"
    );
    assert_eq!(
        restored.get_cookie(
            &url::Url::parse("https://www.example.com").unwrap(),
            "wwwonly"
        ),
        Some("val".into())
    );
}

#[test]
fn serde_empty_jar() {
    let jar = Jar::new();
    let json = serde_json::to_string(&jar).expect("serialize");
    assert_eq!(json, "[]");
    let restored: Jar = serde_json::from_str(&json).expect("deserialize");
    assert!(restored.all_cookies().is_empty());
}

#[test]
fn serialize_order_is_stable_across_runs() {
    let jar = Jar::new();
    let u1 = Url::parse("https://www.example.com/").unwrap();
    let u2 = Url::parse("https://api.example.com/").unwrap();
    let u3 = Url::parse("https://api.example.com/").unwrap();
    jar.store_set_cookie("z=last; Path=/", &u1);
    jar.store_set_cookie("a=first; Path=/", &u2);
    jar.store_set_cookie("m=mid; Path=/account", &u3);
    jar.store_set_cookie("m=mid; Path=/", &u3);

    let s1 = serde_json::to_string(&jar).unwrap();
    let s2 = serde_json::to_string(&jar).unwrap();
    let s3 = serde_json::to_string(&jar).unwrap();
    assert_eq!(s1, s2, "serialization must be byte-stable");
    assert_eq!(s2, s3, "serialization must be byte-stable");
}

#[test]
fn churn_across_hosts_leaves_no_empty_domain_buckets() {
    let jar = Jar::new();
    let host = |i: usize| url::Url::parse(&format!("https://h{i}.example/")).unwrap();
    for i in 0..MAX_COOKIES_GLOBAL + 100 {
        jar.store_set_cookie("a=1", &host(i));
    }
    jar.store_set_cookie("a=1; Max-Age=0", &host(MAX_COOKIES_GLOBAL + 99));

    let live = jar.all_cookies().len();
    assert_eq!(
        format!("{jar:?}"),
        format!("Jar {{ domains: {live}, total: {live} }}")
    );
}

#[test]
fn deleting_a_cookie_leaves_other_sites_alone() {
    let bank = url::Url::parse("https://bank.test/").unwrap();
    let evil = url::Url::parse("http://evil.test/").unwrap();
    let jar = Jar::new();
    jar.store_set_cookie("sid=keep; Path=/; Secure", &bank);
    jar.store_set_cookie("sid=1; Path=/", &evil);
    jar.store_set_cookie("sid=; Path=/; Max-Age=0", &evil);

    assert_eq!(jar.get_cookie(&bank, "sid"), Some("keep".into()));
    assert_eq!(jar.get_cookie(&evil, "sid"), None);
}