Skip to main content

browser_automation_cli/
robots.rs

1//! robots.txt honor for one-shot invocations (no cross-process cache).
2//!
3//! Default policy is honor. Override requires BOTH `--ignore-robots` and
4//! `--i-accept-robots-risk`. Uses `robotstxt::DefaultMatcher` (Google port).
5
6use robotstxt::DefaultMatcher;
7use url::Url;
8
9use crate::error::{CliError, ErrorKind};
10
11/// Effective robots policy for one invocation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RobotsPolicy {
14    /// Honor robots.txt rules (default).
15    Honor,
16    /// Skip robots.txt only when dual risk flags are set.
17    Ignore,
18}
19
20impl RobotsPolicy {
21    /// Stable string for JSON and logs (`honor` | `ignore`).
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::Honor => "honor",
25            Self::Ignore => "ignore",
26        }
27    }
28
29    /// Build policy from CLI flags. Ignore only when both flags are set.
30    pub fn from_flags(ignore_robots: bool, accept_risk: bool) -> Result<Self, CliError> {
31        match (ignore_robots, accept_risk) {
32            (false, false) => Ok(Self::Honor),
33            (true, true) => Ok(Self::Ignore),
34            (true, false) => Err(CliError::with_suggestion(
35                ErrorKind::Usage,
36                // message stays EN (stable); suggestion localized at emit via catalog map
37                "--ignore-robots requires --i-accept-robots-risk",
38                "Pass both flags together when you intentionally skip robots.txt",
39            )),
40            (false, true) => Err(CliError::with_suggestion(
41                ErrorKind::Usage,
42                "--i-accept-robots-risk requires --ignore-robots",
43                "Pass both flags together when you intentionally skip robots.txt",
44            )),
45        }
46    }
47}
48
49/// Returns true when path is allowed by a simple robots Disallow set.
50/// Empty disallow list means allow all. Exact prefix match on path.
51pub fn path_allowed(path: &str, disallows: &[&str]) -> bool {
52    if disallows.is_empty() {
53        return true;
54    }
55    let path = if path.is_empty() { "/" } else { path };
56    !disallows.iter().any(|d| {
57        if d.is_empty() {
58            return false;
59        }
60        path.starts_with(d)
61    })
62}
63
64/// Schemes that skip robots.txt (local / non-network).
65pub fn scheme_skips_robots(url: &str) -> bool {
66    let lower = url.trim().to_ascii_lowercase();
67    lower.starts_with("about:")
68        || lower.starts_with("file:")
69        || lower.starts_with("data:")
70        || lower.starts_with("blob:")
71        || lower == "about:blank"
72}
73
74/// Check URL against robots.txt body for a user-agent using DefaultMatcher.
75pub fn url_allowed_by_robots_body(robots_body: &str, user_agent: &str, url: &str) -> bool {
76    let mut matcher = DefaultMatcher::default();
77    matcher.one_agent_allowed_by_robots(robots_body, user_agent, url)
78}
79
80/// Fetch origin robots.txt and enforce honor policy (async, one-shot).
81pub async fn enforce_robots(
82    url: &str,
83    policy: RobotsPolicy,
84    user_agent: &str,
85) -> Result<(), CliError> {
86    if matches!(policy, RobotsPolicy::Ignore) || scheme_skips_robots(url) {
87        return Ok(());
88    }
89
90    let parsed = Url::parse(url).map_err(|e| {
91        CliError::with_suggestion(
92            ErrorKind::Usage,
93            format!("invalid URL for robots check: {e}"),
94            "Pass an absolute http(s) URL or about:blank/file://",
95        )
96    })?;
97
98    if parsed.scheme() != "http" && parsed.scheme() != "https" {
99        return Ok(());
100    }
101
102    let origin = parsed.origin().ascii_serialization();
103    let robots_url = format!("{origin}/robots.txt");
104
105    let client = reqwest::Client::builder()
106        .timeout(std::time::Duration::from_secs(5))
107        .user_agent(user_agent)
108        .build()
109        .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
110
111    let resp = match client.get(&robots_url).send().await {
112        Ok(r) => r,
113        Err(e) => {
114            // PRD: fetch failure → allow with warning on stderr
115            eprintln!("robots: fetch failed for {robots_url}: {e}; treating as allow");
116            return Ok(());
117        }
118    };
119
120    if !resp.status().is_success() {
121        // Missing robots → allow
122        return Ok(());
123    }
124
125    let body = resp
126        .text()
127        .await
128        .map_err(|e| CliError::new(ErrorKind::Io, format!("robots body: {e}")))?;
129
130    if url_allowed_by_robots_body(&body, user_agent, url) {
131        return Ok(());
132    }
133
134    Err(CliError::with_suggestion(
135        ErrorKind::Data,
136        format!("robots.txt disallows URL: {url}"),
137        "Use --ignore-robots --i-accept-robots-risk only when you accept the risk",
138    ))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn honor_disallow_prefix() {
147        assert!(!path_allowed("/private/x", &["/private"]));
148        assert!(path_allowed("/public/x", &["/private"]));
149        assert!(path_allowed("/any", &[]));
150    }
151
152    #[test]
153    fn scheme_skips_local() {
154        assert!(scheme_skips_robots("about:blank"));
155        assert!(scheme_skips_robots("file:///tmp/x.html"));
156        assert!(!scheme_skips_robots("https://example.com/"));
157    }
158
159    #[test]
160    fn policy_requires_both_flags() {
161        assert!(matches!(
162            RobotsPolicy::from_flags(false, false).unwrap(),
163            RobotsPolicy::Honor
164        ));
165        assert!(matches!(
166            RobotsPolicy::from_flags(true, true).unwrap(),
167            RobotsPolicy::Ignore
168        ));
169        assert!(RobotsPolicy::from_flags(true, false).is_err());
170        assert!(RobotsPolicy::from_flags(false, true).is_err());
171    }
172
173    #[test]
174    fn default_matcher_blocks_disallow_all() {
175        let body = "user-agent: *\ndisallow: /\n";
176        assert!(!url_allowed_by_robots_body(
177            body,
178            "browser-automation-cli",
179            "https://example.com/secret"
180        ));
181    }
182
183    #[test]
184    fn default_matcher_allows_empty() {
185        assert!(url_allowed_by_robots_body(
186            "",
187            "browser-automation-cli",
188            "https://example.com/"
189        ));
190    }
191}