Skip to main content

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}