codoseo-crawler 0.0.1

The CodoSEO crawler: fetching, robots.txt, sitemaps and streaming field extraction.
Documentation
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::net::IpAddr;

use codoseo_core::Url;
use codoseo_core::crawl::AddressPolicy;
use codoseo_crawler::guard::{GuardError, GuardedResolver, Lookup, check_url, is_blocked};
use reqwest::dns::Resolve;

#[test]
fn private_and_internal_addresses_are_blocked() {
    for ip in [
        "127.0.0.1",
        "10.1.2.3",
        "172.16.0.1",
        "172.31.255.255",
        "192.168.1.1",
        "169.254.169.254",
        "0.0.0.0",
        "100.64.0.1",
        "192.0.0.8",
        "198.18.0.1",
        "224.0.0.1",
        "240.0.0.1",
        "255.255.255.255",
        "::1",
        "::",
        "::127.0.0.1",
        "fc00::1",
        "fd12::1",
        "fe80::1",
        "ff02::1",
        "2001:db8::1",
        "::ffff:127.0.0.1",
        "::ffff:10.0.0.1",
        "64:ff9b::a00:1",
        "2002:7f00:1::",
    ] {
        let parsed: IpAddr = ip.parse().unwrap();
        assert!(is_blocked(parsed), "{ip} should be blocked");
    }
}

#[test]
fn public_addresses_are_allowed() {
    for ip in [
        "8.8.8.8",
        "172.32.0.1",
        "100.128.0.1",
        "2606:4700::1111",
        "64:ff9b::808:808",
    ] {
        let parsed: IpAddr = ip.parse().unwrap();
        assert!(!is_blocked(parsed), "{ip} should be allowed");
    }
}

#[test]
fn ip_literal_hosts_are_checked_directly() {
    let public = AddressPolicy::Public;
    for url in [
        "http://2130706433/",
        "http://0x7f.1/",
        "http://[::1]/",
        "http://10.0.0.1:8080/x",
    ] {
        let err = check_url(&Url::parse(url).unwrap(), public).unwrap_err();
        assert!(
            matches!(err, GuardError::BlockedAddress(_)),
            "{url}: {err:?}"
        );
    }
    assert!(check_url(&Url::parse("http://8.8.8.8/").unwrap(), public).is_ok());
    assert!(check_url(&Url::parse("https://example.com/").unwrap(), public).is_ok());
}

#[test]
fn allow_private_lets_everything_through() {
    let url = Url::parse("http://127.0.0.1:3000/").unwrap();
    assert!(check_url(&url, AddressPolicy::AllowPrivate).is_ok());
}

struct StubLookup(HashMap<&'static str, Vec<IpAddr>>);

impl StubLookup {
    fn new(entries: &[(&'static str, &[&str])]) -> Self {
        StubLookup(
            entries
                .iter()
                .map(|(host, ips)| (*host, ips.iter().map(|ip| ip.parse().unwrap()).collect()))
                .collect(),
        )
    }
}

impl Lookup for StubLookup {
    fn lookup(&self, host: &str) -> impl Future<Output = io::Result<Vec<IpAddr>>> + Send {
        let found = self.0.get(host).cloned();
        async move { found.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such host")) }
    }
}

fn resolver() -> GuardedResolver<StubLookup> {
    GuardedResolver::new(StubLookup::new(&[
        ("evil.test", &["127.0.0.1"]),
        ("ok.test", &["93.184.216.34"]),
        ("mixed.test", &["10.0.0.5", "93.184.216.34"]),
    ]))
}

#[tokio::test]
async fn names_resolving_only_to_blocked_addresses_fail() {
    let err = resolver().lookup_checked("evil.test").await.unwrap_err();
    assert!(
        matches!(err, GuardError::OnlyBlockedAddresses { .. }),
        "{err:?}"
    );
}

#[tokio::test]
async fn blocked_addresses_are_dropped_from_mixed_answers() {
    let addrs = resolver().lookup_checked("mixed.test").await.unwrap();
    assert_eq!(
        addrs.iter().map(|a| a.ip().to_string()).collect::<Vec<_>>(),
        ["93.184.216.34"]
    );
}

#[tokio::test]
async fn lookup_failures_are_reported() {
    let err = resolver().lookup_checked("missing.test").await.unwrap_err();
    assert!(matches!(err, GuardError::Lookup { .. }), "{err:?}");
}

#[tokio::test]
async fn works_as_a_reqwest_resolver() {
    let r = resolver();
    let ok: Vec<_> = r
        .resolve("ok.test".parse().unwrap())
        .await
        .unwrap()
        .collect();
    assert_eq!(ok.len(), 1);
    assert!(r.resolve("evil.test".parse().unwrap()).await.is_err());
}