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",
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
49pub 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
64pub 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
74pub 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
80pub 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 eprintln!("robots: fetch failed for {robots_url}: {e}; treating as allow");
116 return Ok(());
117 }
118 };
119
120 if !resp.status().is_success() {
121 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}