#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod matching;
use matching::*;
pub const CODEC_SYMBOL: &str = "codec/robots";
pub const MEDIA_TYPES: &[&str] = &["text/plain"];
pub const RFC_MINIMUM_BYTES: usize = 500 * 1024;
pub static RECIPES: sim_cookbook::EmbeddedDir =
include!(concat!(env!("OUT_DIR"), "/cookbook_recipes.rs"));
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RedirectMetadata {
pub hops: usize,
pub final_url: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuleKind {
Allow,
Disallow,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rule {
pub kind: RuleKind,
pub pattern: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Group {
pub user_agents: Vec<String>,
pub rules: Vec<Rule>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RobotsDoc {
pub groups: Vec<Group>,
pub sitemaps: Vec<String>,
pub redirect: Option<RedirectMetadata>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RobotsLimits {
pub max_input_bytes: usize,
pub max_lines: usize,
pub max_rules: usize,
}
impl Default for RobotsLimits {
fn default() -> Self {
Self {
max_input_bytes: RFC_MINIMUM_BYTES,
max_lines: 100_000,
max_rules: 50_000,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RobotsError(pub String);
impl std::fmt::Display for RobotsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for RobotsError {}
pub fn parse_robots(
input: &[u8],
limits: &RobotsLimits,
redirect: Option<RedirectMetadata>,
) -> Result<RobotsDoc, RobotsError> {
let ceiling = limits.max_input_bytes.max(RFC_MINIMUM_BYTES);
if input.len() > ceiling {
return Err(RobotsError("robots input byte limit exceeded".into()));
}
let text = String::from_utf8_lossy(input);
let mut groups = Vec::new();
let mut agents = Vec::new();
let mut rules = Vec::new();
let mut sitemaps = Vec::new();
let mut warnings = Vec::new();
let mut count = 0;
for (line_no, raw) in text.lines().enumerate() {
if line_no >= limits.max_lines {
return Err(RobotsError("robots line limit exceeded".into()));
}
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((field, value)) = line.split_once(':') else {
warnings.push(format!("line {} has no field separator", line_no + 1));
continue;
};
let field = field.trim().to_ascii_lowercase();
let value = value.trim();
match field.as_str() {
"user-agent" => {
if !rules.is_empty() {
groups.push(Group {
user_agents: std::mem::take(&mut agents),
rules: std::mem::take(&mut rules),
});
}
agents.push(value.to_ascii_lowercase())
}
"allow" | "disallow" => {
if agents.is_empty() {
warnings.push(format!("line {} rule precedes user-agent", line_no + 1));
continue;
}
if field == "disallow" && value.is_empty() {
continue;
}
count += 1;
if count > limits.max_rules {
return Err(RobotsError("robots rule limit exceeded".into()));
}
rules.push(Rule {
kind: if field == "allow" {
RuleKind::Allow
} else {
RuleKind::Disallow
},
pattern: value.to_owned(),
})
}
"sitemap" => sitemaps.push(value.to_owned()),
_ => warnings.push(format!("unknown robots field {field}")),
}
}
if !agents.is_empty() {
groups.push(Group {
user_agents: agents,
rules,
});
}
if std::str::from_utf8(input).is_err() {
warnings.push("invalid UTF-8 replaced during decode".into())
}
Ok(RobotsDoc {
groups,
sitemaps,
redirect,
warnings,
})
}
impl RobotsDoc {
pub fn allows(&self, product: &str, path: &str) -> bool {
let product = product.to_ascii_lowercase();
let mut best_agent = 0;
let mut chosen = Vec::new();
for g in &self.groups {
let specificity = g
.user_agents
.iter()
.filter_map(|a| {
if a == "*" {
Some(0)
} else if product.contains(a) {
Some(a.len())
} else {
None
}
})
.max();
if let Some(n) = specificity {
if n > best_agent {
best_agent = n;
chosen.clear();
}
if n == best_agent {
chosen.push(g)
}
}
}
let normalized = normalize_percent(path);
let mut winner: Option<(usize, RuleKind)> = None;
for g in chosen {
for r in &g.rules {
if pattern_matches(&normalize_percent(&r.pattern), &normalized) {
let n = match_len(&r.pattern);
match winner {
None => winner = Some((n, r.kind)),
Some((old, _)) if n > old => winner = Some((n, r.kind)),
Some((old, RuleKind::Disallow))
if n == old && r.kind == RuleKind::Allow =>
{
winner = Some((n, r.kind))
}
_ => {}
}
}
}
}
winner.is_none_or(|(_, k)| k == RuleKind::Allow)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn precedence_table() {
let d=parse_robots(b"User-agent: *\nDisallow: /fish\nAllow: /fish$\nDisallow: /fish*heads\nAllow: /fishheads\n",&Default::default(),None).unwrap();
let cases = [
("/", true),
("/fish", true),
("/fish/", false),
("/fishheads", true),
("/fishXYZheads", false),
];
for (c, want) in cases {
assert_eq!(d.allows("bot", c), want, "{c}")
}
}
#[test]
fn groups_case_and_percent() {
let d=parse_robots(b"User-agent: Bot\nDisallow: /a%2fb\nUser-agent: *\nAllow: /\nSitemap: https://e/s.xml\n",&Default::default(),None).unwrap();
assert!(!d.allows("MyBOT", "/a%2Fb"));
assert!(d.allows("other", "/a%2Fb"));
assert_eq!(d.sitemaps.len(), 1)
}
}