Skip to main content

doiget_core/
remediation.rs

1//! Machine-readable remediation hints for a denial (#459).
2//!
3//! [`DenialContext`] (ADR-0023) says what was refused. It does not say what
4//! to do about it, and until now the only place that did was CLI text —
5//! the `= help:` block from #443. An agent driving doiget over MCP, or a
6//! CI job reading `batch --json`, got the refusal and nothing else.
7//!
8//! That gap has a concrete cost. A hybrid-OA paper whose only free copy
9//! sits in a university repository is refused with
10//! `redirect_not_in_allowlist`, and is fetchable after **one line** of
11//! config — either the host, or the `trust_academic_repos` flag that
12//! already knows about `*.ac.uk`. An agent that cannot find that line
13//! reports "this paper is not available", which is false.
14//!
15//! So the hints live here, in core, computed once and rendered by every
16//! surface. The CLI's `= help:` block, the MCP envelope and the
17//! `batch --json` record all read the same [`Remediation`] list; #454 is
18//! the recent lesson about what happens when two surfaces each keep their
19//! own copy of a rule.
20
21use serde::Serialize;
22
23use crate::user_extension::{academic_repo_hosts, oa_registry_hosts};
24use crate::{DenialContext, DenialReason};
25
26/// What kind of change would lift this denial.
27///
28/// Deliberately a closed set, and deliberately **not** collapsed into one
29/// "here is a string to paste": the two kinds do different things to the
30/// trusted surface, and a caller has to be able to tell them apart. Adding
31/// a host trusts one publisher. Setting a trust flag trusts a curated
32/// class of hosts (ADR-0028). An agent choosing between them is making a
33/// policy decision on the user's behalf and should be able to see that.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36#[non_exhaustive]
37pub enum RemediationKind {
38    /// Add a host pattern under `[[network.additional_hosts]]`.
39    AdditionalHost,
40    /// Set a `[network]` boolean that trusts a curated host class.
41    TrustFlag,
42}
43
44/// One suggested change, with the reason it is being suggested.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46#[non_exhaustive]
47pub struct Remediation {
48    /// Which kind of change this is.
49    pub kind: RemediationKind,
50    /// The host pattern to add, or the flag name to set.
51    pub value: String,
52    /// Why this entry is offered, phrased for a human reading a log.
53    pub note: String,
54}
55
56/// Suggestions that would lift `denial`, most specific first.
57///
58/// Empty for reasons with no configuration channel — a size cap or a
59/// capability gate is not fixed by editing the allowlist, and offering a
60/// host to add there would be actively misleading.
61#[must_use]
62pub fn for_denial(denial: &DenialContext) -> Vec<Remediation> {
63    let mut out = Vec::new();
64    // Only the host-allowlist reasons have a config channel.
65    // `InsecureScheme` is deliberately excluded: the fix for an `http://`
66    // redirect is not to trust the host, and ADR-0027 offers no opt-out.
67    if !matches!(
68        denial.reason,
69        DenialReason::RedirectNotInAllowlist | DenialReason::HostInBlockList
70    ) {
71        return out;
72    }
73    let Some(host) = denial.attempted.as_deref() else {
74        return out;
75    };
76
77    for (pattern, why) in widening_suggestions(host) {
78        out.push(Remediation {
79            kind: RemediationKind::AdditionalHost,
80            value: pattern,
81            note: why.to_string(),
82        });
83    }
84
85    // The flag comes last but is usually the better answer when it
86    // applies: it is one line, it is curated, and it covers the next
87    // repository as well as this one.
88    if let Some((flag, pattern, why)) = trust_flag_for_host(host) {
89        out.push(Remediation {
90            kind: RemediationKind::TrustFlag,
91            value: flag.to_string(),
92            note: format!("{host} matches {pattern} — {why}"),
93        });
94    }
95    out
96}
97
98/// The `[network]` trust flag that already covers `host`, if any.
99///
100/// Returns the flag name, the curated pattern that matched, and that
101/// pattern's own note, so the caller can say *why* the flag applies rather
102/// than asserting that it does.
103#[must_use]
104pub fn trust_flag_for_host(host: &str) -> Option<(&'static str, String, String)> {
105    let host_lc = host.to_ascii_lowercase();
106    for (flag, hosts) in [
107        ("trust_academic_repos", academic_repo_hosts()),
108        ("trust_oa_registries", oa_registry_hosts()),
109    ] {
110        for h in hosts {
111            if pattern_matches(&host_lc, h.host.as_str()) {
112                return Some((
113                    flag,
114                    h.host.as_str().to_string(),
115                    h.note.unwrap_or_else(|| "a curated host class".to_string()),
116                ));
117            }
118        }
119    }
120    None
121}
122
123/// `docs/REDIRECT_ALLOWLIST.md` §2.2 matching, against an already-lowercased
124/// host.
125///
126/// The same rule `http::SourceAllowlist::matches` applies, kept local
127/// rather than building a throwaway `SourceAllowlist` per call. Both are
128/// three lines and neither is likely to change — but if §2.2 ever does,
129/// this must change with it.
130fn pattern_matches(host_lc: &str, pattern: &str) -> bool {
131    let pat_lc = pattern.to_ascii_lowercase();
132    match pat_lc.strip_prefix("*.") {
133        Some(suffix) => host_lc == suffix || host_lc.ends_with(&format!(".{suffix}")),
134        None => host_lc == pat_lc,
135    }
136}
137
138/// Widening suggestions for a refused host, most specific first (#443).
139///
140/// The `= help:` block used to name only the hop that was just refused, so
141/// a publisher whose PDF sits behind `www.x.org -> pubs.x.org` cost the
142/// user one edit-run cycle per hop. Naming the registrable domain too ends
143/// it in one.
144///
145/// It is also the policy-consistent suggestion. The built-in allowlist is
146/// written almost entirely as registrable-domain wildcards
147/// (`*.springer.com`, `*.wiley.com`, `*.aps.org`), and ADR-0027's stated
148/// mitigation for widening the trusted surface is exactly that they are
149/// "bounded registrable-domain wildcards". Suggesting a bare FQDN was both
150/// more work for the user and narrower than the convention the project
151/// applies to itself. The apex is offered alongside the wildcard because a
152/// single-suffix wildcard does not match it — the reason the built-in list
153/// already carries both forms for `doaj.org`, `arxiv.org` and friends.
154///
155/// Conservative by construction: a suggestion is emitted only when the
156/// derived parent is clearly registrable. Getting this exactly right needs
157/// the public suffix list, and a wrong guess here is not cosmetic — it
158/// would invite the user to trust `*.co.uk`.
159///
160/// Moved here from `doiget-cli` in #459 so the MCP and `batch --json`
161/// surfaces get the same suggestions as the CLI rather than a second
162/// implementation of them.
163#[must_use]
164pub fn widening_suggestions(host: &str) -> Vec<(String, &'static str)> {
165    let mut out = vec![(host.to_string(), "this hop only")];
166    let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect();
167    if labels.len() < 2 || looks_like_public_suffix(&labels) {
168        return out;
169    }
170    if labels.len() == 2 {
171        // Already the apex: the useful widening is its subdomains.
172        out.push((format!("*.{host}"), "and its subdomains"));
173        return out;
174    }
175    let parent_labels = &labels[1..];
176    if looks_like_public_suffix(parent_labels) {
177        return out;
178    }
179    let parent = parent_labels.join(".");
180    // "the whole publisher" was wrong for the dominant case (#478): this
181    // fires most often for institutional repositories, and every host in
182    // the built-in `trust_academic_repos` list is a university.
183    out.push((format!("*.{parent}"), "the whole domain"));
184    out.push((parent, "apex too (a wildcard does not match it)"));
185    out
186}
187
188/// Whether `labels` looks like a public suffix rather than something a
189/// single organisation registered.
190///
191/// Deliberately crude and deliberately over-cautious: the cost of a false
192/// positive is one missing suggestion, and the cost of a false negative is
193/// telling a user to trust every domain under `co.uk`.
194fn looks_like_public_suffix(labels: &[&str]) -> bool {
195    match labels {
196        // A bare TLD.
197        [_] => true,
198        // `co.uk`, `ac.jp`, `com.au`, … — a known second level under a
199        // two-letter ccTLD. `example.co.uk` has three labels and is NOT
200        // caught here, which is correct.
201        [sld, tld] => {
202            tld.len() == 2
203                && matches!(
204                    *sld,
205                    "co" | "com"
206                        | "ne"
207                        | "net"
208                        | "or"
209                        | "org"
210                        | "ac"
211                        | "edu"
212                        | "gov"
213                        | "go"
214                        | "gr"
215                        | "lg"
216                        | "mil"
217                        | "id"
218                        | "in"
219                )
220        }
221        _ => false,
222    }
223}
224
225#[cfg(test)]
226#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
227mod tests {
228    use super::*;
229
230    fn denial(host: &str) -> DenialContext {
231        DenialContext {
232            reason: DenialReason::RedirectNotInAllowlist,
233            source: Some("oa-publisher".to_string()),
234            attempted: Some(host.to_string()),
235            expected: Some(vec!["*.arxiv.org".to_string()]),
236            hop_index: None,
237            cap: None,
238            actual: None,
239        }
240    }
241
242    /// The exact case that motivated #459: a real fetch, refused, that
243    /// `trust_academic_repos` fixes in one line. The flag has to be in the
244    /// output or an agent cannot find it.
245    #[test]
246    fn a_university_repository_offers_the_trust_flag_as_well_as_the_host() {
247        let r = for_denial(&denial("strathprints.strath.ac.uk"));
248        let hosts: Vec<&str> = r
249            .iter()
250            .filter(|x| x.kind == RemediationKind::AdditionalHost)
251            .map(|x| x.value.as_str())
252            .collect();
253        assert_eq!(
254            hosts,
255            vec![
256                "strathprints.strath.ac.uk",
257                "*.strath.ac.uk",
258                "strath.ac.uk"
259            ],
260            "the #443 widening set, unchanged by the move"
261        );
262
263        let flag = r
264            .iter()
265            .find(|x| x.kind == RemediationKind::TrustFlag)
266            .expect("an *.ac.uk host must surface trust_academic_repos");
267        assert_eq!(flag.value, "trust_academic_repos");
268        assert!(
269            flag.note.contains("*.ac.uk"),
270            "say WHICH curated pattern matched, or the flag looks like a guess: {}",
271            flag.note
272        );
273    }
274
275    /// A publisher host has no curated class, so only the host route is
276    /// offered. Suggesting a trust flag that would not have helped is the
277    /// same failure as #442's "go find an API key" — it sends the user
278    /// after the wrong fix.
279    #[test]
280    fn a_publisher_host_offers_no_trust_flag() {
281        let r = for_denial(&denial("pubs.ams.org"));
282        assert!(
283            r.iter().all(|x| x.kind == RemediationKind::AdditionalHost),
284            "no curated class covers ams.org: {r:?}"
285        );
286        assert_eq!(
287            r.iter().map(|x| x.value.as_str()).collect::<Vec<_>>(),
288            vec!["pubs.ams.org", "*.ams.org", "ams.org"]
289        );
290    }
291
292    /// Reasons with no configuration channel must offer nothing. An
293    /// oversized body is not fixed by trusting the host, and saying so
294    /// would be worse than silence.
295    #[test]
296    fn a_reason_with_no_config_channel_suggests_nothing() {
297        for reason in [
298            DenialReason::SizeCapExceeded,
299            DenialReason::InsecureScheme,
300            DenialReason::CapabilityNotGranted,
301        ] {
302            let mut d = denial("example.org");
303            d.reason = reason;
304            assert!(
305                for_denial(&d).is_empty(),
306                "{reason:?} has no allowlist channel, so it must suggest nothing"
307            );
308        }
309    }
310
311    /// Guarding the one mistake that is not cosmetic.
312    #[test]
313    fn a_public_suffix_is_never_offered_for_trust() {
314        for host in ["example.co.uk", "example.ac.jp", "foo.com", "localhost"] {
315            for (pattern, _) in widening_suggestions(host) {
316                assert!(
317                    !matches!(pattern.as_str(), "*.co.uk" | "co.uk" | "*.ac.jp" | "ac.jp"),
318                    "{host} must never suggest trusting a public suffix, got {pattern}"
319                );
320            }
321        }
322    }
323}