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 std::time::Duration;

use leyline::testing::{TestResponse, TestServer};
use leyline::{Purpose, Robots, RobotsSource, Session, Url};

const RFC_EXAMPLE: &str = "User-Agent: *
Disallow: *.gif$
Disallow: /example/
Allow: /publications/

User-Agent: foobot
Disallow:/
Allow:/example/page.html
Allow:/example/allowed.gif

User-Agent: barbot
User-Agent: bazbot
Disallow: /example/page.html

User-Agent: quxbot
";

fn at(path: &str) -> Url {
    Url::parse(&format!("https://example.com{path}")).unwrap()
}

#[test]
fn robots_follow_the_rfc_example() {
    for agent in ["*", "OtherBot"] {
        let robots = Robots::parse(RFC_EXAMPLE, agent);
        assert!(!robots.allows(&at("/image.gif")), "{agent}");
        assert!(!robots.allows(&at("/a/b/c.gif")), "{agent}");
        assert!(robots.allows(&at("/image.gif?size=2")), "{agent}");
        assert!(!robots.allows(&at("/example/page.html")), "{agent}");
        assert!(robots.allows(&at("/publications/paper.html")), "{agent}");
        assert!(robots.allows(&at("/robots.txt")), "{agent}");
    }
    let foobot = Robots::parse(RFC_EXAMPLE, "FooBot");
    assert!(!foobot.allows(&at("/")));
    assert!(!foobot.allows(&at("/publications/")));
    assert!(foobot.allows(&at("/example/page.html")));
    assert!(foobot.allows(&at("/example/allowed.gif")));
    assert!(foobot.allows(&at("/robots.txt")));
    for agent in ["barbot", "bazbot"] {
        let robots = Robots::parse(RFC_EXAMPLE, agent);
        assert!(!robots.allows(&at("/example/page.html")), "{agent}");
        assert!(robots.allows(&at("/example/other.html")), "{agent}");
        assert!(robots.allows(&at("/image.gif")), "{agent}");
    }
    assert!(Robots::parse(RFC_EXAMPLE, "quxbot").allows(&at("/example/page.html")));
}

#[test]
fn robots_use_the_longest_match_and_allow_wins_a_tie() {
    let robots = Robots::parse(
        "User-Agent: foobot\nAllow: /example/page/\nDisallow: /example/page/disallowed.gif\nAllow: /tie\nDisallow: /tie\n",
        "foobot",
    );
    assert!(!robots.allows(&at("/example/page/disallowed.gif")));
    assert!(robots.allows(&at("/example/page/other.gif")));
    assert!(robots.allows(&at("/tie")));
}

#[test]
fn robots_merge_groups_and_ignore_rules_outside_groups() {
    let text = "Disallow: /before\nuser-agent: ExampleBot\ndisallow: /foo\ndisallow: /bar\n\nuser-agent: BazBot\ndisallow: /qux\n\nuser-agent: ExampleBot\nsitemap: https://example.com/a.xml\ndisallow: /baz\n";
    let robots = Robots::parse(text, "examplebot");
    for path in ["/foo", "/bar", "/baz"] {
        assert!(!robots.allows(&at(path)), "{path}");
    }
    assert!(robots.allows(&at("/qux")));
    assert!(robots.allows(&at("/before")));
    assert!(Robots::parse(text, "OtherBot").allows(&at("/foo")));
}

#[test]
fn robots_compare_percent_encoded_paths() {
    let robots = Robots::parse(
        "User-agent: *\nDisallow: /foo/bar/%62%61%7A\nDisallow: /tsu/ツ\nDisallow: /path/file-with-a-%2A.html\nDisallow: /exact$\nDisallow: /a/*/c\n",
        "*",
    );
    assert!(!robots.allows(&at("/foo/bar/baz")));
    assert!(!robots.allows(&at("/tsu/%E3%83%84")));
    assert!(!robots.allows(&at("/path/file-with-a-*.html")));
    assert!(robots.allows(&at("/path/file-with-a-x.html")));
    assert!(!robots.allows(&at("/exact")));
    assert!(robots.allows(&at("/exact/more")));
    assert!(!robots.allows(&at("/a/b/c")));
    assert!(!robots.allows(&at("/a/b/b/c/d")));
    assert!(robots.allows(&at("/a/c")));
}

#[test]
fn robots_read_signals_delay_and_sitemaps() {
    let robots = Robots::parse(
        "Sitemap: https://example.com/one.xml\nUser-agent: *\nContent-Signal: search=yes, ai-train=no, use=reference\nCrawl-delay: 2.5\nDisallow: /private/\nSitemap: https://example.com/two.xml\n",
        "*",
    );
    assert!(!robots.allows_use(&at("/page"), Purpose::AiTrain));
    assert!(robots.allows_use(&at("/page"), Purpose::Search));
    assert!(robots.allows_use(&at("/page"), Purpose::AiInput));
    assert_eq!(robots.crawl_delay(), Some(Duration::from_millis(2500)));
    assert_eq!(
        robots.sitemaps(),
        ["https://example.com/one.xml", "https://example.com/two.xml"]
    );
    assert!(!robots.allows(&at("/private/x")));
    assert_eq!(robots.source(), RobotsSource::Parsed);
}

#[tokio::test]
async fn session_robots_applies_the_status_rules() {
    let server = TestServer::http(|request| match request.target.as_str() {
        "/robots.txt" => TestResponse::new(301).header("location", "/moved.txt"),
        "/moved.txt" => TestResponse::new(200).body("User-agent: *\nDisallow: /secret\n"),
        _ => TestResponse::new(404),
    })
    .await
    .unwrap();
    let session = Session::new();
    let robots = session
        .robots(server.url("/deep/page.html"), "*")
        .await
        .unwrap();
    assert!(!robots.allows(&Url::parse(&server.url("/secret")).unwrap()));
    assert!(robots.allows(&Url::parse(&server.url("/open")).unwrap()));
    let targets: Vec<String> = server
        .requests()
        .await
        .into_iter()
        .map(|request| request.target)
        .collect();
    assert_eq!(targets, ["/robots.txt", "/moved.txt"]);

    let missing = TestServer::http(|_| TestResponse::new(404)).await.unwrap();
    let robots = session.robots(missing.url("/"), "*").await.unwrap();
    assert_eq!(robots.source(), RobotsSource::Unavailable);
    assert!(robots.allows(&Url::parse(&missing.url("/anything")).unwrap()));

    let broken = TestServer::http(|_| TestResponse::new(503)).await.unwrap();
    let robots = session.robots(broken.url("/"), "*").await.unwrap();
    assert_eq!(robots.source(), RobotsSource::Unreachable);
    assert!(!robots.allows(&Url::parse(&broken.url("/anything")).unwrap()));
    assert!(robots.allows(&Url::parse(&broken.url("/robots.txt")).unwrap()));

    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    let closed = format!("http://{}/", listener.local_addr().unwrap());
    drop(listener);
    let robots = session.robots(closed.as_str(), "*").await.unwrap();
    assert_eq!(robots.source(), RobotsSource::Unreachable);
}

#[tokio::test]
async fn session_robots_reads_at_least_500_kib() {
    let mut body = "#".repeat(450 * 1024);
    body.push_str("\nUser-agent: *\nDisallow: /late\n");
    let server = TestServer::http(move |_| TestResponse::new(200).body(body.clone()))
        .await
        .unwrap();
    let robots = Session::new().robots(server.url("/"), "*").await.unwrap();
    assert!(!robots.allows(&Url::parse(&server.url("/late")).unwrap()));
}

#[test]
fn robots_read_content_usage_by_the_longest_matching_path() {
    let robots = Robots::parse(
        "User-agent: *\nContent-Usage: train-ai=n, search=y, unknown-label=y\nContent-Usage: /ai-ok/ train-ai=y\nContent-Usage: /no-search/ search=n\nContent-Signal: ai-input=yes\n",
        "*",
    );
    assert!(!robots.allows_use(&at("/page"), Purpose::AiTrain));
    assert!(robots.allows_use(&at("/ai-ok/page"), Purpose::AiTrain));
    assert!(robots.allows_use(&at("/page"), Purpose::Search));
    assert!(!robots.allows_use(&at("/no-search/page"), Purpose::Search));
    assert!(robots.allows_use(&at("/page"), Purpose::AiInput));

    let open = Robots::parse("User-agent: *\nContent-Usage: /ai-ok/ train-ai=y\n", "*");
    assert!(open.allows_use(&at("/ai-ok/page"), Purpose::AiTrain));
    assert!(open.allows_use(&at("/other"), Purpose::AiTrain));
}

#[test]
fn robots_survive_a_huge_crawl_delay() {
    let robots = Robots::parse("User-agent: *\nCrawl-delay: 1e300\n", "*");
    assert!(
        robots
            .crawl_delay()
            .is_some_and(|gap| gap > Duration::from_secs(3600))
    );
}

#[test]
fn robots_start_a_new_group_after_any_group_record() {
    let robots = Robots::parse(
        "User-agent: a\nCrawl-delay: 5\nUser-agent: *\nDisallow: /p\n",
        "*",
    );
    assert_eq!(robots.crawl_delay(), None);
    assert!(!robots.allows(&at("/p")));
    let robots = Robots::parse(
        "User-agent: *\nCrawl-delay: 10\n\nUser-agent: MJ12bot\nDisallow: /\n",
        "*",
    );
    assert_eq!(robots.crawl_delay(), Some(Duration::from_secs(10)));
    assert!(robots.allows(&at("/page")));
}

#[test]
fn robots_match_a_dollar_inside_a_pattern_literally() {
    let robots = Robots::parse("User-agent: *\nDisallow: /price$list\n", "*");
    assert!(!robots.allows(&at("/price$list")));
    assert!(robots.allows(&at("/price")));
}

#[test]
fn robots_reject_a_sign_inside_a_percent_escape() {
    let robots = Robots::parse("User-agent: *\nDisallow: /a%+41\n", "*");
    assert!(robots.allows(&at("/a%041")));
    assert!(!robots.allows(&at("/a%+41")));
}

#[test]
fn robots_read_usage_values_with_parameters() {
    let robots = Robots::parse("User-agent: *\nContent-Usage: train-ai=n;src=site\n", "*");
    assert!(!robots.allows_use(&at("/page"), Purpose::AiTrain));
}

#[test]
fn robots_match_the_product_token_and_only_a_star_means_everyone() {
    let text = "User-agent: Googlebot/2.1\nDisallow: /g\n\nUser-agent: 123\nDisallow: /\n\nUser-agent: *\nDisallow: /all\n";
    let google = Robots::parse(text, "Googlebot/2.1 (+http://www.google.com/bot.html)");
    assert!(!google.allows(&at("/g")));
    assert!(google.allows(&at("/all")));
    let anyone = Robots::parse(text, "*");
    assert!(anyone.allows(&at("/g")));
    assert!(!anyone.allows(&at("/all")));
}

#[tokio::test]
async fn session_robots_treats_rate_limiting_as_unreachable() {
    let server = TestServer::http(|_| TestResponse::new(429).header("retry-after", "0"))
        .await
        .unwrap();
    let robots = Session::new().robots(server.url("/"), "*").await.unwrap();
    assert_eq!(robots.source(), RobotsSource::Unreachable);
    assert!(!robots.allows(&at("/page")));
}

#[tokio::test]
async fn session_robots_drops_the_line_cut_at_the_size_limit() {
    let head = "User-agent: *\nDisallow: /p\n#";
    let tail = "\nAllow: /private-ok\n";
    let cut = 512 * 1024;
    let filler = "x".repeat(cut - head.len() - "\nAllow: /p".len());
    let body = format!("{head}{filler}{tail}");
    let server = TestServer::http(move |request| match request.target.as_str() {
        "/robots.txt" => TestResponse::new(200).body(body.clone()),
        _ => TestResponse::new(404),
    })
    .await
    .unwrap();
    let robots = Session::new().robots(server.url("/"), "*").await.unwrap();
    assert!(!robots.allows(&at("/private-x")));
}

#[test]
fn robots_keep_the_longest_crawl_delay_of_matching_groups() {
    let robots = Robots::parse(
        "User-agent: *\nCrawl-delay: 1\n\nUser-agent: other\nDisallow: /x\n\nUser-agent: *\nCrawl-delay: 5\n",
        "*",
    );
    assert_eq!(robots.crawl_delay(), Some(Duration::from_secs(5)));
}