browser_automation_cli/
robots.rs1use robotstxt::DefaultMatcher;
7use url::Url;
8
9use crate::error::{CliError, ErrorKind};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RobotsPolicy {
14 Honor,
16 Ignore,
18}
19
20impl RobotsPolicy {
21 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Honor => "honor",
25 Self::Ignore => "ignore",
26 }
27 }
28
29 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
48pub 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
63pub 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
73pub 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
79pub 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 eprintln!("robots: fetch failed for {robots_url}: {e}; treating as allow");
115 return Ok(());
116 }
117 };
118
119 if !resp.status().is_success() {
120 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}