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                "--ignore-robots requires --i-accept-robots-risk",
37                "Pass both flags together when you intentionally skip robots.txt",
38            )),
39            (false, true) => Err(CliError::with_suggestion(
40                ErrorKind::Usage,
41                "--i-accept-robots-risk requires --ignore-robots",
42                "Pass both flags together when you intentionally skip robots.txt",
43            )),
44        }
45    }
46}
47
48/// Returns true when path is allowed by a simple robots Disallow set.
49/// Empty disallow list means allow all. Exact prefix match on path.
50pub fn path_allowed(path: &str, disallows: &[&str]) -> bool {
51    if disallows.is_empty() {
52        return true;
53    }
54    let path = if path.is_empty() { "/" } else { path };
55    !disallows.iter().any(|d| {
56        if d.is_empty() {
57            return false;
58        }
59        path.starts_with(d)
60    })
61}
62
63/// Schemes that skip robots.txt (local / non-network).
64pub fn scheme_skips_robots(url: &str) -> bool {
65    let lower = url.trim().to_ascii_lowercase();
66    lower.starts_with("about:")
67        || lower.starts_with("file:")
68        || lower.starts_with("data:")
69        || lower.starts_with("blob:")
70        || lower == "about:blank"
71}
72
73/// Check URL against robots.txt body for a user-agent using DefaultMatcher.
74pub fn url_allowed_by_robots_body(robots_body: &str, user_agent: &str, url: &str) -> bool {
75    let mut matcher = DefaultMatcher::default();
76    matcher.one_agent_allowed_by_robots(robots_body, user_agent, url)
77}
78
79/// Fetch origin robots.txt and enforce honor policy (async, one-shot).
80pub async fn enforce_robots(
81    url: &str,
82    policy: RobotsPolicy,
83    user_agent: &str,
84) -> Result<(), CliError> {
85    if matches!(policy, RobotsPolicy::Ignore) || scheme_skips_robots(url) {
86        return Ok(());
87    }
88
89    let parsed = Url::parse(url).map_err(|e| {
90        CliError::with_suggestion(
91            ErrorKind::Usage,
92            format!("invalid URL for robots check: {e}"),
93            "Pass an absolute http(s) URL or about:blank/file://",
94        )
95    })?;
96
97    if parsed.scheme() != "http" && parsed.scheme() != "https" {
98        return Ok(());
99    }
100
101    let origin = parsed.origin().ascii_serialization();
102    let robots_url = format!("{origin}/robots.txt");
103
104    let client = reqwest::Client::builder()
105        .timeout(std::time::Duration::from_secs(5))
106        .user_agent(user_agent)
107        .build()
108        .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
109
110    let resp = match client.get(&robots_url).send().await {
111        Ok(r) => r,
112        Err(e) => {
113            // PRD: fetch failure → allow with warning on stderr
114            eprintln!("robots: fetch failed for {robots_url}: {e}; treating as allow");
115            return Ok(());
116        }
117    };
118
119    if !resp.status().is_success() {
120        // Missing robots → allow
121        return Ok(());
122    }
123
124    let body = resp
125        .text()
126        .await
127        .map_err(|e| CliError::new(ErrorKind::Io, format!("robots body: {e}")))?;
128
129    if url_allowed_by_robots_body(&body, user_agent, url) {
130        return Ok(());
131    }
132
133    Err(CliError::with_suggestion(
134        ErrorKind::Data,
135        format!("robots.txt disallows URL: {url}"),
136        "Use --ignore-robots --i-accept-robots-risk only when you accept the risk",
137    ))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn honor_disallow_prefix() {
146        assert!(!path_allowed("/private/x", &["/private"]));
147        assert!(path_allowed("/public/x", &["/private"]));
148        assert!(path_allowed("/any", &[]));
149    }
150
151    #[test]
152    fn scheme_skips_local() {
153        assert!(scheme_skips_robots("about:blank"));
154        assert!(scheme_skips_robots("file:///tmp/x.html"));
155        assert!(!scheme_skips_robots("https://example.com/"));
156    }
157
158    #[test]
159    fn policy_requires_both_flags() {
160        assert!(matches!(
161            RobotsPolicy::from_flags(false, false).unwrap(),
162            RobotsPolicy::Honor
163        ));
164        assert!(matches!(
165            RobotsPolicy::from_flags(true, true).unwrap(),
166            RobotsPolicy::Ignore
167        ));
168        assert!(RobotsPolicy::from_flags(true, false).is_err());
169        assert!(RobotsPolicy::from_flags(false, true).is_err());
170    }
171
172    #[test]
173    fn default_matcher_blocks_disallow_all() {
174        let body = "user-agent: *\ndisallow: /\n";
175        assert!(!url_allowed_by_robots_body(
176            body,
177            "browser-automation-cli",
178            "https://example.com/secret"
179        ));
180    }
181
182    #[test]
183    fn default_matcher_allows_empty() {
184        assert!(url_allowed_by_robots_body(
185            "",
186            "browser-automation-cli",
187            "https://example.com/"
188        ));
189    }
190}