use std::collections::HashMap;
use std::time::Duration;
use url::Url;
pub struct RobotsTxtManager {
cache: HashMap<String, RobotsRules>,
user_agent: String,
}
struct RobotsRules {
rules: Vec<(bool, String)>,
crawl_delay: Option<f64>,
}
struct RobotsGroup {
agents: Vec<String>,
rules: Vec<(bool, String)>,
crawl_delay: Option<f64>,
}
pub struct FetchedRobots(RobotsRules);
impl RobotsTxtManager {
pub fn new(user_agent: &str) -> Self {
Self {
cache: HashMap::new(),
user_agent: user_agent.to_string(),
}
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
pub fn origin_and_key(url: &str) -> Option<(String, String)> {
let u = Url::parse(url).ok()?;
let host = u.host_str()?;
let origin = match u.port() {
Some(port) => format!("{}://{}:{}", u.scheme(), host, port),
None => format!("{}://{}", u.scheme(), host),
};
Some((origin.clone(), origin))
}
pub async fn fetch_rules(user_agent: &str, origin: &str) -> FetchedRobots {
let url = if origin.contains("://") {
format!("{}/robots.txt", origin)
} else {
format!("https://{}/robots.txt", origin)
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let rules = match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(text) => Self::parse_robots(&text, user_agent),
Err(_) => RobotsRules::allow_all(),
},
_ => RobotsRules::allow_all(),
};
FetchedRobots(rules)
}
pub fn insert_rules(&mut self, key: &str, rules: FetchedRobots) {
self.cache.insert(key.to_string(), rules.0);
}
pub async fn fetch_robots(&mut self, domain: &str) {
let key = if domain.contains("://") {
domain.to_string()
} else {
format!("https://{}", domain)
};
let rules = Self::fetch_rules(&self.user_agent, domain).await;
self.insert_rules(&key, rules);
}
pub fn is_allowed(&self, url: &str) -> bool {
let key = match Self::origin_and_key(url) {
Some((_, key)) => key,
None => return true,
};
let parsed = match Url::parse(url) {
Ok(u) => u,
Err(_) => return true,
};
let mut target = parsed.path().to_string();
if let Some(q) = parsed.query() {
target.push('?');
target.push_str(q);
}
match self.cache.get(&key) {
Some(rules) => rules.is_path_allowed(&target),
None => true,
}
}
pub fn crawl_delay(&self, key: &str) -> Option<f64> {
self.cache.get(key).and_then(|r| r.crawl_delay)
}
pub fn has_domain(&self, key: &str) -> bool {
self.cache.contains_key(key)
}
fn parse_robots(text: &str, user_agent: &str) -> RobotsRules {
let ua_lower = user_agent.to_lowercase();
let ua_token = ua_lower
.split(['/', ' '])
.next()
.unwrap_or(&ua_lower)
.to_string();
let groups = parse_groups(text);
let specific: Vec<&RobotsGroup> = groups
.iter()
.filter(|g| g.agents.iter().any(|a| a == &ua_lower || a == &ua_token))
.collect();
let chosen: Vec<&RobotsGroup> = if specific.is_empty() {
groups
.iter()
.filter(|g| g.agents.iter().any(|a| a == "*"))
.collect()
} else {
specific
};
if chosen.is_empty() {
return RobotsRules::allow_all();
}
RobotsRules {
rules: chosen.iter().flat_map(|g| g.rules.clone()).collect(),
crawl_delay: chosen.iter().find_map(|g| g.crawl_delay),
}
}
}
impl RobotsRules {
fn allow_all() -> Self {
Self {
rules: Vec::new(),
crawl_delay: None,
}
}
fn is_path_allowed(&self, target: &str) -> bool {
let mut best: Option<(usize, bool)> = None;
for (allow, pattern) in &self.rules {
if pattern_matches(pattern, target) {
let specificity = pattern.len();
best = match best {
None => Some((specificity, *allow)),
Some((best_len, best_allow)) => {
if specificity > best_len
|| (specificity == best_len && *allow && !best_allow)
{
Some((specificity, *allow))
} else {
Some((best_len, best_allow))
}
}
};
}
}
best.map(|(_, allow)| allow).unwrap_or(true)
}
}
fn pattern_matches(pattern: &str, target: &str) -> bool {
let (pat, anchored) = match pattern.strip_suffix('$') {
Some(p) => (p, true),
None => (pattern, false),
};
let parts: Vec<&str> = pat.split('*').collect();
if parts.len() == 1 {
return if anchored {
target == parts[0]
} else {
target.starts_with(parts[0])
};
}
if !target.starts_with(parts[0]) {
return false;
}
let mut pos = parts[0].len();
let (middle, limit) = if anchored {
let last = parts[parts.len() - 1];
if !target.ends_with(last) || target.len() < pos + last.len() {
return false;
}
(&parts[1..parts.len() - 1], target.len() - last.len())
} else {
(&parts[1..], target.len())
};
for part in middle {
if part.is_empty() {
continue; }
match target[pos..limit].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
pos <= limit
}
fn parse_groups(text: &str) -> Vec<RobotsGroup> {
let mut groups: Vec<RobotsGroup> = Vec::new();
let mut current: Option<RobotsGroup> = None;
let mut saw_directive = false;
for raw in text.lines() {
let line = match raw.find('#') {
Some(idx) => raw[..idx].trim(),
None => raw.trim(),
};
if line.is_empty() {
continue;
}
if let Some(rest) = strip_prefix_ci(line, "user-agent:") {
if saw_directive {
if let Some(g) = current.take() {
groups.push(g);
}
saw_directive = false;
}
let g = current.get_or_insert_with(|| RobotsGroup {
agents: Vec::new(),
rules: Vec::new(),
crawl_delay: None,
});
g.agents.push(rest.trim().to_lowercase());
} else if let Some(g) = current.as_mut() {
if let Some(rest) = strip_prefix_ci(line, "disallow:") {
let path = rest.trim();
if !path.is_empty() {
g.rules.push((false, path.to_string()));
}
saw_directive = true;
} else if let Some(rest) = strip_prefix_ci(line, "allow:") {
let path = rest.trim();
if !path.is_empty() {
g.rules.push((true, path.to_string()));
}
saw_directive = true;
} else if let Some(rest) = strip_prefix_ci(line, "crawl-delay:") {
if let Ok(d) = rest.trim().parse::<f64>() {
if d.is_finite() && (0.0..=86_400.0).contains(&d) {
g.crawl_delay = Some(d);
}
}
saw_directive = true;
}
}
}
if let Some(g) = current.take() {
groups.push(g);
}
groups
}
fn strip_prefix_ci<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.len() >= prefix.len()
&& line.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
Some(&line[prefix.len()..])
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mgr_with(txt: &str, ua: &str, key: &str) -> RobotsTxtManager {
let mut mgr = RobotsTxtManager::new(ua);
mgr.insert_rules(key, FetchedRobots(RobotsTxtManager::parse_robots(txt, ua)));
mgr
}
#[test]
fn multi_agent_group_applies_to_each_agent() {
let txt = "User-agent: MyBot\nUser-agent: Googlebot\nDisallow: /private\n";
for ua in ["MyBot", "Googlebot"] {
let mgr = mgr_with(txt, ua, "https://example.com");
assert!(!mgr.is_allowed("https://example.com/private/x"));
assert!(mgr.is_allowed("https://example.com/public"));
}
}
#[test]
fn specific_agent_wins_over_wildcard() {
let txt = "User-agent: *\nDisallow: /all\n\nUser-agent: MyBot\nDisallow: /mine\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/mine"));
assert!(mgr.is_allowed("https://example.com/all"));
}
#[test]
fn wildcard_applies_when_no_specific_match() {
let txt = "User-agent: *\nDisallow: /all\n";
let mgr = mgr_with(txt, "OtherBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/all"));
}
#[test]
fn unknown_agent_with_no_wildcard_is_allow_all() {
let txt = "User-agent: SomeoneElse\nDisallow: /\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(mgr.is_allowed("https://example.com/anything"));
}
#[test]
fn crawl_delay_is_parsed() {
let txt = "User-agent: *\nCrawl-delay: 5\n";
let rules = RobotsTxtManager::parse_robots(txt, "MyBot");
assert_eq!(rules.crawl_delay, Some(5.0));
}
#[test]
fn hostile_crawl_delay_values_are_rejected() {
for bad in ["inf", "-inf", "NaN", "1e300", "-5", "999999999"] {
let txt = format!("User-agent: *\nCrawl-delay: {}\n", bad);
let rules = RobotsTxtManager::parse_robots(&txt, "MyBot");
assert_eq!(rules.crawl_delay, None, "value {:?} must be rejected", bad);
}
let rules = RobotsTxtManager::parse_robots("User-agent: *\nCrawl-delay: 2.5\n", "MyBot");
assert_eq!(rules.crawl_delay, Some(2.5));
}
#[test]
fn allow_overrides_broader_disallow() {
let txt = "User-agent: *\nDisallow: /\nAllow: /public\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(mgr.is_allowed("https://example.com/public"));
assert!(mgr.is_allowed("https://example.com/public/page"));
assert!(!mgr.is_allowed("https://example.com/private"));
assert!(!mgr.is_allowed("https://example.com/"));
}
#[test]
fn longest_match_wins_regardless_of_order() {
let txt = "User-agent: *\nAllow: /a\nDisallow: /a/b\nAllow: /a/b/c\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(mgr.is_allowed("https://example.com/a/x"));
assert!(!mgr.is_allowed("https://example.com/a/b/x"));
assert!(mgr.is_allowed("https://example.com/a/b/c/x"));
}
#[test]
fn allow_wins_specificity_ties() {
let txt = "User-agent: *\nDisallow: /page\nAllow: /page\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(mgr.is_allowed("https://example.com/page"));
}
#[test]
fn star_wildcard_matches_inside_paths() {
let txt = "User-agent: *\nDisallow: /*?sessionid\nDisallow: /*sessionid=\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/page?sessionid=42"));
assert!(!mgr.is_allowed("https://example.com/a/b?x=1&sessionid=2"));
assert!(mgr.is_allowed("https://example.com/page"));
let only_direct = mgr_with(
"User-agent: *\nDisallow: /*?sessionid\n",
"MyBot",
"https://example.com",
);
assert!(only_direct.is_allowed("https://example.com/a/b?x=1&sessionid=2"));
}
#[test]
fn dollar_anchors_to_end() {
let txt = "User-agent: *\nDisallow: /*.php$\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/index.php"));
assert!(!mgr.is_allowed("https://example.com/a.php.php"));
assert!(mgr.is_allowed("https://example.com/a.php.bak"));
}
#[test]
fn pattern_matching_edge_cases() {
assert!(pattern_matches("/", "/anything"));
assert!(pattern_matches("/a*b*c", "/a-x-b-y-c-z"));
assert!(!pattern_matches("/a*b$", "/a-b-tail"));
assert!(pattern_matches("/a*b$", "/a-x-b"));
assert!(pattern_matches("/a**b", "/ab"));
assert!(pattern_matches("/exact$", "/exact"));
assert!(!pattern_matches("/exact$", "/exact/more"));
assert!(!pattern_matches("/aa*aa$", "/aaa"));
}
#[test]
fn inline_comments_are_stripped() {
let txt = "User-agent: * # everyone\nDisallow: /private # keep out\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/private/x"));
assert!(mgr.is_allowed("https://example.com/open"));
}
#[test]
fn multiple_groups_for_same_agent_are_combined() {
let txt = "User-agent: MyBot\nDisallow: /one\n\nUser-agent: MyBot\nDisallow: /two\n";
let mgr = mgr_with(txt, "MyBot", "https://example.com");
assert!(!mgr.is_allowed("https://example.com/one"));
assert!(!mgr.is_allowed("https://example.com/two"));
assert!(mgr.is_allowed("https://example.com/three"));
}
#[test]
fn origin_and_key_preserve_scheme_and_port() {
assert_eq!(
RobotsTxtManager::origin_and_key("http://shop.example:8080/x?y=1"),
Some((
"http://shop.example:8080".to_string(),
"http://shop.example:8080".to_string()
))
);
assert_eq!(
RobotsTxtManager::origin_and_key("https://example.com/path"),
Some((
"https://example.com".to_string(),
"https://example.com".to_string()
))
);
assert_eq!(
RobotsTxtManager::origin_and_key("https://example.com:443/x"),
Some((
"https://example.com".to_string(),
"https://example.com".to_string()
))
);
assert_eq!(RobotsTxtManager::origin_and_key("data:text/plain,hi"), None);
}
#[test]
fn distinct_ports_have_distinct_rules() {
let mut mgr = RobotsTxtManager::new("MyBot");
mgr.insert_rules(
"http://example.com:8080",
FetchedRobots(RobotsTxtManager::parse_robots(
"User-agent: *\nDisallow: /\n",
"MyBot",
)),
);
assert!(!mgr.is_allowed("http://example.com:8080/x"));
assert!(mgr.is_allowed("http://example.com/x"));
}
#[test]
fn distinct_schemes_have_distinct_rules() {
let mut mgr = RobotsTxtManager::new("MyBot");
mgr.insert_rules(
"http://example.com",
FetchedRobots(RobotsTxtManager::parse_robots(
"User-agent: *\nDisallow: /\n",
"MyBot",
)),
);
assert!(!mgr.is_allowed("http://example.com/x"));
assert!(mgr.is_allowed("https://example.com/x"));
}
#[test]
fn user_agent_product_token_matches_group() {
let txt = "User-agent: MyBot\nDisallow: /private\n";
let mgr = mgr_with(
txt,
"MyBot/1.0 (+https://example.com/bot)",
"https://example.com",
);
assert!(!mgr.is_allowed("https://example.com/private/x"));
assert!(mgr.is_allowed("https://example.com/open"));
}
#[test]
fn insert_rules_stores_fetched_rules_under_domain() {
let mut mgr = RobotsTxtManager::new("MyBot");
assert!(!mgr.has_domain("https://example.com"));
assert_eq!(mgr.user_agent(), "MyBot");
let rules = FetchedRobots(RobotsTxtManager::parse_robots(
"User-agent: *\nDisallow: /private\nCrawl-delay: 2\n",
"MyBot",
));
mgr.insert_rules("https://example.com", rules);
assert!(mgr.has_domain("https://example.com"));
assert!(!mgr.is_allowed("https://example.com/private/page"));
assert!(mgr.is_allowed("https://example.com/public"));
assert_eq!(mgr.crawl_delay("https://example.com"), Some(2.0));
}
#[tokio::test]
async fn fetch_robots_normalizes_bare_domain_to_https_key() {
let mut mgr = RobotsTxtManager::new("MyBot");
mgr.fetch_robots("unreachable.invalid").await;
assert!(mgr.has_domain("https://unreachable.invalid"));
assert!(mgr.is_allowed("https://unreachable.invalid/anything"));
}
}
#[cfg(test)]
mod proptests {
use super::pattern_matches;
use proptest::prelude::*;
fn regex_oracle(pattern: &str, target: &str) -> bool {
let (pat, anchored) = match pattern.strip_suffix('$') {
Some(p) => (p, true),
None => (pattern, false),
};
let mut re = String::from("^");
for c in pat.chars() {
if c == '*' {
re.push_str(".*");
} else {
re.push_str(®ex::escape(&c.to_string()));
}
}
if anchored {
re.push('$');
}
regex::Regex::new(&re)
.expect("oracle regex is valid by construction")
.is_match(target)
}
proptest! {
#[test]
fn wildcard_matcher_agrees_with_regex_oracle(
pattern in "[ab