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}