Skip to main content

robotparser_rs/
lib.rs

1//! A faithful Rust port of Python's [`urllib.robotparser`](https://docs.python.org/3/library/urllib.robotparser.html).
2//!
3//! Port target: the standard library `urllib/robotparser.py` (CPython 3.13). Behaviour is verified
4//! against the reference module itself via differential testing.
5//!
6//! Unlike the existing `robotparser` crate (which follows the 1996 `norobots-rfc` draft), this
7//! mirrors what CPython actually does today: a **prefix-match, first-match** algorithm (no `*`/`$`
8//! wildcards, no longest-match precedence), with CPython's exact path normalization, parse state
9//! machine, and user-agent substring matching.
10//!
11//! The network half of `RobotFileParser` (`read()` / `urlopen`) is out of scope — fetch the bytes
12//! yourself and feed the lines to [`RobotFileParser::parse`], exactly as CPython's `read()` does.
13//!
14//! ```
15//! use robotparser_rs::RobotFileParser;
16//!
17//! let mut rp = RobotFileParser::new("http://example.com/robots.txt");
18//! rp.parse(&["User-agent: *", "Disallow: /private", "Allow: /private/ok"]);
19//! assert_eq!(rp.can_fetch("MyBot", "http://example.com/public"), Ok(true));
20//! assert_eq!(rp.can_fetch("MyBot", "http://example.com/private/x"), Ok(false));
21//! ```
22
23use std::fmt;
24
25use urlparse_rs::{quote_from_bytes, unquote_to_bytes, urlsplit, urlunsplit, UrlError};
26
27/// A `Request-rate` directive: `requests` per `seconds`. Port of the `RequestRate` namedtuple.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct RequestRate {
30    pub requests: u64,
31    pub seconds: u64,
32}
33
34/// `quote(unquote(path, surrogateescape), surrogateescape)` — canonicalize percent-encoding. Done
35/// on the raw decoded bytes (equivalent to CPython's surrogateescape round-trip), so any byte
36/// sequence is preserved exactly. Port of `normalize`.
37fn normalize(path: &str) -> String {
38    quote_from_bytes(&unquote_to_bytes(path), "/")
39}
40
41/// Normalize the path, and within a `?query` normalize each maximal run of non-`=&` characters
42/// (leaving the `=`/`&` separators). Port of `normalize_path`.
43fn normalize_path(path: &str) -> String {
44    match path.split_once('?') {
45        Some((path, query)) => {
46            let mut out = normalize(path);
47            out.push('?');
48            // Port of `re.sub(r'[^=&]+', lambda m: normalize(m[0]), query)`.
49            let mut run = String::new();
50            for ch in query.chars() {
51                if ch == '=' || ch == '&' {
52                    if !run.is_empty() {
53                        out.push_str(&normalize(&run));
54                        run.clear();
55                    }
56                    out.push(ch);
57                } else {
58                    run.push(ch);
59                }
60            }
61            if !run.is_empty() {
62                out.push_str(&normalize(&run));
63            }
64            out
65        }
66        None => normalize(path),
67    }
68}
69
70/// A single `Allow:`/`Disallow:` rule. Port of `RuleLine`.
71#[derive(Debug, Clone)]
72struct RuleLine {
73    path: String,
74    allowance: bool,
75}
76
77impl RuleLine {
78    fn new(path: &str, allowance: bool) -> Self {
79        // An empty `Disallow:` value means allow all.
80        let allowance = if path.is_empty() && !allowance { true } else { allowance };
81        RuleLine { path: normalize_path(path), allowance }
82    }
83
84    fn applies_to(&self, filename: &str) -> bool {
85        self.path == "*" || filename.starts_with(&self.path)
86    }
87}
88
89impl fmt::Display for RuleLine {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{}: {}", if self.allowance { "Allow" } else { "Disallow" }, self.path)
92    }
93}
94
95/// One or more user-agents and their rules/delays. Port of `Entry`.
96#[derive(Debug, Clone, Default)]
97struct Entry {
98    useragents: Vec<String>,
99    rulelines: Vec<RuleLine>,
100    delay: Option<i64>,
101    req_rate: Option<RequestRate>,
102}
103
104impl Entry {
105    /// Port of `Entry.applies_to`: the agent token (before the first `/`, lowercased) contains one
106    /// of this entry's agents as a substring, or the entry has the catch-all `*`.
107    fn applies_to(&self, useragent: &str) -> bool {
108        let useragent = useragent.split('/').next().unwrap_or("").to_lowercase();
109        for agent in &self.useragents {
110            if agent == "*" {
111                return true;
112            }
113            if useragent.contains(&agent.to_lowercase()) {
114                return true;
115            }
116        }
117        false
118    }
119
120    /// Port of `Entry.allowance`: the first rule whose path matches decides; default allow.
121    fn allowance(&self, filename: &str) -> bool {
122        for line in &self.rulelines {
123            if line.applies_to(filename) {
124                return line.allowance;
125            }
126        }
127        true
128    }
129}
130
131impl fmt::Display for Entry {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        let mut parts: Vec<String> = Vec::new();
134        for agent in &self.useragents {
135            parts.push(format!("User-agent: {agent}"));
136        }
137        if let Some(delay) = self.delay {
138            parts.push(format!("Crawl-delay: {delay}"));
139        }
140        if let Some(rate) = self.req_rate {
141            parts.push(format!("Request-rate: {}/{}", rate.requests, rate.seconds));
142        }
143        for line in &self.rulelines {
144            parts.push(line.to_string());
145        }
146        f.write_str(&parts.join("\n"))
147    }
148}
149
150/// Reads, parses and answers questions about a single robots.txt file. Port of `RobotFileParser`.
151#[derive(Debug, Clone, Default)]
152pub struct RobotFileParser {
153    entries: Vec<Entry>,
154    sitemaps: Vec<String>,
155    default_entry: Option<Entry>,
156    disallow_all: bool,
157    allow_all: bool,
158    url: String,
159    host: String,
160    path: String,
161    // CPython tracks `last_checked` as a timestamp and only ever tests its truthiness; we model
162    // that as "has parse()/modified() been called?".
163    last_checked: bool,
164}
165
166impl RobotFileParser {
167    /// Create a parser for the robots.txt at `url` (pass `""` for none). Port of `__init__`.
168    pub fn new(url: &str) -> Self {
169        let mut rp = RobotFileParser::default();
170        rp.set_url(url);
171        rp
172    }
173
174    /// Whether the file has been parsed yet. Port of `mtime` (truthiness only — no real clock).
175    pub fn mtime(&self) -> bool {
176        self.last_checked
177    }
178
179    /// Mark the file as just parsed. Port of `modified`.
180    pub fn modified(&mut self) {
181        self.last_checked = true;
182    }
183
184    /// Set the robots.txt URL, caching its host and path. Port of `set_url`. A malformed authority
185    /// leaves host/path empty rather than propagating (CPython would raise here).
186    pub fn set_url(&mut self, url: &str) {
187        self.url = url.to_string();
188        if let Ok(s) = urlsplit(url, "", true) {
189            self.host = s.netloc;
190            self.path = s.path;
191        } else {
192            self.host = String::new();
193            self.path = String::new();
194        }
195    }
196
197    /// The robots.txt URL set via [`new`](Self::new) / [`set_url`](Self::set_url).
198    pub fn url(&self) -> &str {
199        &self.url
200    }
201
202    /// The host of the robots.txt URL.
203    pub fn host(&self) -> &str {
204        &self.host
205    }
206
207    /// The path of the robots.txt URL.
208    pub fn path(&self) -> &str {
209        &self.path
210    }
211
212    fn add_entry(&mut self, entry: Entry) {
213        if entry.useragents.iter().any(|a| a == "*") {
214            // The first catch-all entry becomes the default and is considered last.
215            if self.default_entry.is_none() {
216                self.default_entry = Some(entry);
217            }
218        } else {
219            self.entries.push(entry);
220        }
221    }
222
223    /// Parse the lines of a robots.txt file (already split into lines, no trailing newlines). Port
224    /// of `parse`.
225    pub fn parse<S: AsRef<str>>(&mut self, lines: &[S]) {
226        // states: 0 start, 1 saw user-agent, 2 saw allow/disallow
227        let mut state = 0u8;
228        let mut entry = Entry::default();
229        self.modified();
230        for raw in lines {
231            let raw = raw.as_ref();
232            if raw.is_empty() {
233                match state {
234                    1 => {
235                        entry = Entry::default();
236                        state = 0;
237                    }
238                    2 => {
239                        self.add_entry(std::mem::take(&mut entry));
240                        state = 0;
241                    }
242                    _ => {}
243                }
244            }
245            // strip an optional comment, then surrounding whitespace
246            let line = match raw.find('#') {
247                Some(i) => &raw[..i],
248                None => raw,
249            };
250            let line = line.trim();
251            if line.is_empty() {
252                continue;
253            }
254            let (key, value) = match line.split_once(':') {
255                Some((k, v)) => (k.trim().to_lowercase(), v.trim()),
256                None => continue, // no ':' — CPython's len(line)==2 guard skips it
257            };
258            match key.as_str() {
259                "user-agent" => {
260                    if state == 2 {
261                        self.add_entry(std::mem::take(&mut entry));
262                    }
263                    entry.useragents.push(value.to_string());
264                    state = 1;
265                }
266                "disallow" => {
267                    if state != 0 {
268                        entry.rulelines.push(RuleLine::new(value, false));
269                        state = 2;
270                    }
271                }
272                "allow" => {
273                    if state != 0 {
274                        entry.rulelines.push(RuleLine::new(value, true));
275                        state = 2;
276                    }
277                }
278                "crawl-delay" => {
279                    if state != 0 {
280                        if is_ascii_digits(value)
281                            && let Ok(n) = value.parse::<i64>()
282                        {
283                            entry.delay = Some(n);
284                        }
285                        state = 2;
286                    }
287                }
288                "request-rate" => {
289                    if state != 0 {
290                        let nums: Vec<&str> = value.split('/').collect();
291                        if nums.len() == 2
292                            && is_ascii_digits(nums[0].trim())
293                            && is_ascii_digits(nums[1].trim())
294                            && let (Ok(r), Ok(s)) =
295                                (nums[0].trim().parse::<u64>(), nums[1].trim().parse::<u64>())
296                        {
297                            entry.req_rate = Some(RequestRate { requests: r, seconds: s });
298                        }
299                        state = 2;
300                    }
301                }
302                "sitemap" => {
303                    // Independent of user-agent, so the state is left unchanged.
304                    self.sitemaps.push(value.to_string());
305                }
306                _ => {}
307            }
308        }
309        if state == 2 {
310            self.add_entry(entry);
311        }
312    }
313
314    /// Decide whether `useragent` may fetch `url`, per the parsed robots.txt. Port of `can_fetch`.
315    ///
316    /// Returns `Err` only when the URL's authority is malformed (CPython raises `ValueError` from
317    /// the internal `urlsplit`).
318    pub fn can_fetch(&self, useragent: &str, url: &str) -> Result<bool, UrlError> {
319        if self.disallow_all {
320            return Ok(false);
321        }
322        if self.allow_all {
323            return Ok(true);
324        }
325        // Until the file has been parsed, assume nothing is allowed.
326        if !self.last_checked {
327            return Ok(false);
328        }
329        let parsed = urlsplit(url, "", true)?;
330        let url = urlunsplit("", "", &parsed.path, &parsed.query, &parsed.fragment);
331        let url = normalize_path(&url);
332        let url = if url.is_empty() { "/".to_string() } else { url };
333        for entry in &self.entries {
334            if entry.applies_to(useragent) {
335                return Ok(entry.allowance(&url));
336            }
337        }
338        if let Some(default) = &self.default_entry {
339            return Ok(default.allowance(&url));
340        }
341        // agent not found ==> access granted
342        Ok(true)
343    }
344
345    /// The `Crawl-delay` for `useragent`, or `None`. Port of `crawl_delay`.
346    pub fn crawl_delay(&self, useragent: &str) -> Option<i64> {
347        if !self.last_checked {
348            return None;
349        }
350        for entry in &self.entries {
351            if entry.applies_to(useragent) {
352                return entry.delay;
353            }
354        }
355        self.default_entry.as_ref().and_then(|e| e.delay)
356    }
357
358    /// The `Request-rate` for `useragent`, or `None`. Port of `request_rate`.
359    pub fn request_rate(&self, useragent: &str) -> Option<RequestRate> {
360        if !self.last_checked {
361            return None;
362        }
363        for entry in &self.entries {
364            if entry.applies_to(useragent) {
365                return entry.req_rate;
366            }
367        }
368        self.default_entry.as_ref().and_then(|e| e.req_rate)
369    }
370
371    /// The `Sitemap` URLs, or `None` if there were none. Port of `site_maps`.
372    pub fn site_maps(&self) -> Option<&[String]> {
373        if self.sitemaps.is_empty() {
374            None
375        } else {
376            Some(&self.sitemaps)
377        }
378    }
379}
380
381impl fmt::Display for RobotFileParser {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        let mut entries: Vec<&Entry> = self.entries.iter().collect();
384        if let Some(default) = &self.default_entry {
385            entries.push(default);
386        }
387        let joined: Vec<String> = entries.iter().map(|e| e.to_string()).collect();
388        f.write_str(&joined.join("\n\n"))
389    }
390}
391
392/// Port of CPython's `str.isdigit()` for the cases the parser exercises: a non-empty run of ASCII
393/// digits. (CPython also accepts some Unicode digit code points; those are not modelled.)
394fn is_ascii_digits(s: &str) -> bool {
395    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn parsed(text: &str) -> RobotFileParser {
403        let mut rp = RobotFileParser::new("http://example.com/robots.txt");
404        let lines: Vec<&str> = text.split('\n').collect();
405        rp.parse(&lines);
406        rp
407    }
408
409    #[test]
410    fn basic_allow_disallow() {
411        let rp = parsed("User-agent: *\nDisallow: /private\nAllow: /private/ok");
412        assert_eq!(rp.can_fetch("Bot", "http://example.com/public"), Ok(true));
413        assert_eq!(rp.can_fetch("Bot", "http://example.com/private/x"), Ok(false));
414        // First-match wins: /private matches before /private/ok is considered.
415        assert_eq!(rp.can_fetch("Bot", "http://example.com/private/ok"), Ok(false));
416    }
417
418    #[test]
419    fn empty_disallow_allows_all() {
420        let rp = parsed("User-agent: *\nDisallow:");
421        assert_eq!(rp.can_fetch("Bot", "http://example.com/anything"), Ok(true));
422    }
423
424    #[test]
425    fn not_parsed_denies() {
426        let rp = RobotFileParser::new("http://example.com/robots.txt");
427        assert_eq!(rp.can_fetch("Bot", "http://example.com/"), Ok(false));
428    }
429
430    #[test]
431    fn agent_specific_overrides_default() {
432        let rp = parsed("User-agent: BadBot\nDisallow: /\n\nUser-agent: *\nDisallow: /admin");
433        assert_eq!(rp.can_fetch("BadBot", "http://example.com/x"), Ok(false));
434        assert_eq!(rp.can_fetch("GoodBot", "http://example.com/x"), Ok(true));
435        assert_eq!(rp.can_fetch("GoodBot", "http://example.com/admin"), Ok(false));
436    }
437
438    #[test]
439    fn crawl_delay_and_request_rate() {
440        let rp = parsed("User-agent: *\nCrawl-delay: 10\nRequest-rate: 1/5");
441        assert_eq!(rp.crawl_delay("Bot"), Some(10));
442        assert_eq!(rp.request_rate("Bot"), Some(RequestRate { requests: 1, seconds: 5 }));
443    }
444
445    #[test]
446    fn sitemaps() {
447        let rp = parsed("Sitemap: http://example.com/sitemap.xml\nUser-agent: *\nDisallow:");
448        assert_eq!(rp.site_maps(), Some(&["http://example.com/sitemap.xml".to_string()][..]));
449    }
450}