stygian_graph/ports/robots_policy.rs
1//! Robots-policy guard port — closes guide failure mode #9.
2//!
3//! `RobotsPolicyGuard` is the consumer-owned port trait that both recon
4//! and production paths use to ask "may I fetch this URL?". The
5//! [`RobotsPolicy`](crate::domain::policy::RobotsPolicy) value type
6//! lives in the domain layer and is shared; only the guard implementation
7//! (the part that actually fetches + parses `robots.txt`) is swappable.
8//!
9//! Default adapters shipped by `stygian-graph`:
10//!
11//! - [`PermissiveRobotsGuard`](crate::ports::robots_policy::PermissiveRobotsGuard) — returns `Allow` for every URL. Used
12//! when the operator has explicitly opted in to `IgnoreSilently` or
13//! the guard has not been wired up. Safe default for unit tests.
14//! - (Reserved for future adapters) — `CachedRobotsGuard` backed by a
15//! real `robots.txt` fetch + parse.
16
17use std::fmt;
18
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21
22use crate::domain::error::{GraphError, Result, StygianError};
23use crate::domain::policy::{RobotsDecision, RobotsPolicy};
24
25/// Reason captured in the [`RobotsDecision::Forbid`] variant.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ForbidReason {
29 /// `robots.txt` disallows this URL.
30 Disallow,
31 /// `robots.txt` rate-limits this URL (e.g. `Crawl-delay` > configured max).
32 RateLimited,
33 /// `robots.txt` is missing but the operator has set
34 /// [`RobotsPolicy::Obey`] — refuse by default rather than silently
35 /// passing through.
36 MissingRobotsTxt,
37}
38
39/// Port trait: ask a guard whether a URL is permitted under the
40/// configured [`RobotsPolicy`].
41///
42/// Adapters must be `Send + Sync` so they can be stored in an `Arc` and
43/// shared between the recon path and the production path. The guard is
44/// invoked at pipeline-build time (so forbidden URLs surface before any
45/// production traffic) and again at execute time (so policy drift
46/// between spec-build and run is caught).
47#[async_trait]
48pub trait RobotsPolicyGuard: Send + Sync {
49 /// Stable name for diagnostics — usually the upstream source
50 /// (`"manual"`, `"robots_txt"`, `"cached"`).
51 fn name(&self) -> &'static str;
52
53 /// Decide whether a single URL may be fetched.
54 ///
55 /// # Errors
56 ///
57 /// Returns [`crate::domain::error::GraphError::ExecutionFailed`] if the guard cannot reach
58 /// its data source (e.g. network failure during a live
59 /// `robots.txt` lookup).
60 async fn decide(&self, url: &str) -> Result<RobotsDecision>;
61}
62
63/// Apply a [`RobotsPolicy`] to a [`RobotsDecision`]. The single source
64/// of truth for how a decision maps to a concrete action.
65///
66/// This helper exists so both recon and production use the *same*
67/// reducer. Without it, two callers could each implement their own
68/// `match` and reintroduce the very bug T111 exists to prevent.
69#[must_use]
70pub fn apply_policy(policy: RobotsPolicy, decision: RobotsDecision) -> PolicyOutcome {
71 use RobotsDecision as D;
72 use RobotsPolicy as P;
73
74 match (policy, decision) {
75 // Obey + Forbid → refuse with the guard's reason.
76 (P::Obey, D::Forbid { reason }) => PolicyOutcome::Refuse { reason },
77 // Obey + Unknown → refuse with a default reason. We refuse
78 // (rather than allow) because the guide's rule of thumb is
79 // "spec is built from pages the shipped spider may be forbidden
80 // to fetch" — Unknown under Obey is the dangerous case.
81 (P::Obey, D::Unknown) => PolicyOutcome::Refuse {
82 reason: "robots guard returned Unknown under Obey policy".to_string(),
83 },
84
85 // IgnoreWithAudit + Forbid → fetch + audit (the only arm that
86 // emits a non-default outcome).
87 (P::IgnoreWithAudit, D::Forbid { reason }) => PolicyOutcome::FetchWithAudit { reason },
88
89 // Every other (policy, decision) pair maps to plain `Fetch`:
90 // Obey + Allow,
91 // IgnoreWithAudit + Allow,
92 // IgnoreWithAudit + Unknown,
93 // IgnoreSilently + Allow | Forbid | Unknown.
94 // The `Fetch` arm intentionally collapses these so the
95 // reducer stays a single source of truth.
96 _ => PolicyOutcome::Fetch,
97 }
98}
99
100/// The reducer's output — what the caller should do for this URL.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum PolicyOutcome {
104 /// Fetch the URL.
105 Fetch,
106 /// Fetch the URL and record the guard's reason as an audit event.
107 FetchWithAudit {
108 /// Reason supplied by the guard — usually the matched rule.
109 reason: String,
110 },
111 /// Do not fetch the URL.
112 Refuse {
113 /// Reason supplied by the guard — usually the matched rule.
114 reason: String,
115 },
116}
117
118impl fmt::Display for PolicyOutcome {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::Fetch => f.write_str("fetch"),
122 Self::FetchWithAudit { reason } => write!(f, "fetch_with_audit({reason})"),
123 Self::Refuse { reason } => write!(f, "refuse({reason})"),
124 }
125 }
126}
127
128/// No-op guard: returns [`RobotsDecision::Allow`] for every URL.
129///
130/// This is the default adapter shipped with `stygian-graph`. It exists
131/// so pipelines can wire a guard in place without taking on a real
132/// `robots.txt` fetcher. Real implementations are out of scope for
133/// T111 — see the module docs for the planned `CachedRobotsGuard`.
134///
135/// When the operator sets [`RobotsPolicy::Obey`] and the guard returns
136/// `Allow`, the outcome is `Fetch` — i.e. the guard is the single
137/// source of truth on which URLs are permitted. With
138/// `PermissiveRobotsGuard`, *every* URL is permitted, so Obey behaves
139/// as if `robots.txt` has been explicitly fetched and parsed to allow
140/// everything.
141#[derive(Debug, Clone, Default)]
142pub struct PermissiveRobotsGuard;
143
144#[async_trait]
145impl RobotsPolicyGuard for PermissiveRobotsGuard {
146 fn name(&self) -> &'static str {
147 "permissive"
148 }
149
150 async fn decide(&self, _url: &str) -> Result<RobotsDecision> {
151 Ok(RobotsDecision::Allow {
152 reason: "permissive guard: no opinion".to_string(),
153 })
154 }
155}
156
157/// Wrap a [`PermissiveRobotsGuard`] so the result is always a fresh
158/// `Arc` — convenient for callers that need a stable port-owned type.
159#[must_use]
160pub fn permissive_guard() -> std::sync::Arc<dyn RobotsPolicyGuard> {
161 std::sync::Arc::new(PermissiveRobotsGuard)
162}
163
164/// Validate that the supplied [`RobotsPolicy`] + [`RobotsDecision`]
165/// combination is internally consistent.
166///
167/// Returns `Err(StygianError::Graph(GraphError::InvalidPipeline(...)))`
168/// if a pipeline tries to enforce `Obey` while shipping a no-op guard
169/// that returns `Allow` for everything — that combination would mean
170/// the pipeline claims to obey robots.txt but cannot, since it has no
171/// data source to check against. The intent is to surface this at
172/// pipeline-build time rather than silently passing every URL through.
173pub fn validate_guard_pair(policy: RobotsPolicy, guard: &dyn RobotsPolicyGuard) -> Result<()> {
174 if matches!(policy, RobotsPolicy::Obey) && guard.name() == "permissive" {
175 return Err(StygianError::Graph(GraphError::InvalidPipeline(
176 "robots_policy = Obey requires a non-permissive RobotsPolicyGuard; \
177 ship a real robots.txt fetcher or downgrade the policy to \
178 IgnoreSilently"
179 .to_string(),
180 )));
181 }
182 Ok(())
183}
184
185#[cfg(test)]
186#[allow(
187 clippy::unwrap_used,
188 clippy::expect_used,
189 clippy::panic,
190 clippy::indexing_slicing
191)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn obey_allow_fetches() {
197 let out = apply_policy(
198 RobotsPolicy::Obey,
199 RobotsDecision::Allow {
200 reason: "ok".to_string(),
201 },
202 );
203 assert_eq!(out, PolicyOutcome::Fetch);
204 }
205
206 #[test]
207 fn obey_forbid_refuses() {
208 let out = apply_policy(
209 RobotsPolicy::Obey,
210 RobotsDecision::Forbid {
211 reason: "Disallow: /private".to_string(),
212 },
213 );
214 assert_eq!(
215 out,
216 PolicyOutcome::Refuse {
217 reason: "Disallow: /private".to_string()
218 }
219 );
220 }
221
222 #[test]
223 fn obey_unknown_refuses() {
224 let out = apply_policy(RobotsPolicy::Obey, RobotsDecision::Unknown);
225 match out {
226 PolicyOutcome::Refuse { reason } => {
227 assert!(reason.contains("Unknown"));
228 }
229 other => panic!("expected Refuse, got {other:?}"),
230 }
231 }
232
233 #[test]
234 fn ignore_with_audit_forbid_fetches_with_audit() {
235 let out = apply_policy(
236 RobotsPolicy::IgnoreWithAudit,
237 RobotsDecision::Forbid {
238 reason: "Disallow: /x".to_string(),
239 },
240 );
241 assert_eq!(
242 out,
243 PolicyOutcome::FetchWithAudit {
244 reason: "Disallow: /x".to_string()
245 }
246 );
247 }
248
249 #[test]
250 fn ignore_with_audit_unknown_fetches() {
251 let out = apply_policy(RobotsPolicy::IgnoreWithAudit, RobotsDecision::Unknown);
252 assert_eq!(out, PolicyOutcome::Fetch);
253 }
254
255 #[test]
256 fn ignore_silently_always_fetches() {
257 for decision in [
258 RobotsDecision::Allow {
259 reason: "ok".to_string(),
260 },
261 RobotsDecision::Forbid {
262 reason: "no".to_string(),
263 },
264 RobotsDecision::Unknown,
265 ] {
266 assert_eq!(
267 apply_policy(RobotsPolicy::IgnoreSilently, decision),
268 PolicyOutcome::Fetch
269 );
270 }
271 }
272
273 #[tokio::test]
274 async fn permissive_guard_allows_everything() {
275 let guard = PermissiveRobotsGuard;
276 assert_eq!(guard.name(), "permissive");
277 let d = guard.decide("https://example.com/anything").await.unwrap();
278 assert!(d.is_allowed());
279 }
280
281 #[test]
282 fn validate_guard_pair_rejects_obey_with_permissive() {
283 let guard = PermissiveRobotsGuard;
284 let err = validate_guard_pair(RobotsPolicy::Obey, &guard).unwrap_err();
285 let msg = format!("{err}");
286 assert!(msg.contains("Obey"), "msg should mention policy: {msg}");
287 assert!(
288 msg.contains("permissive"),
289 "msg should mention guard: {msg}"
290 );
291 }
292
293 #[test]
294 fn validate_guard_pair_accepts_ignore_silently_with_permissive() {
295 let guard = PermissiveRobotsGuard;
296 validate_guard_pair(RobotsPolicy::IgnoreSilently, &guard).unwrap();
297 validate_guard_pair(RobotsPolicy::IgnoreWithAudit, &guard).unwrap();
298 }
299
300 #[test]
301 fn policy_outcome_display_is_human_readable() {
302 assert_eq!(PolicyOutcome::Fetch.to_string(), "fetch");
303 assert_eq!(
304 PolicyOutcome::FetchWithAudit {
305 reason: "x".to_string()
306 }
307 .to_string(),
308 "fetch_with_audit(x)"
309 );
310 assert_eq!(
311 PolicyOutcome::Refuse {
312 reason: "y".to_string()
313 }
314 .to_string(),
315 "refuse(y)"
316 );
317 }
318}