doiget_core/http.rs
1// allow: outbound-network
2//! Centralized HTTP client wrapper. All `Source` impls fetch through here.
3//!
4//! Security defaults per `docs/SECURITY.md`:
5//! - rustls TLS only (no openssl, no native-tls — enforced by `deny.toml`)
6//! - HTTPS-only redirect policy (file://, data://, http:// rejected)
7//! - Per-source redirect host allowlist (`docs/REDIRECT_ALLOWLIST.md`)
8//! - Body size cap ([`crate::PDF_MAX_BYTES`] = 100 MB)
9//! - Per-request timeouts (connect 10s, read 60s, total 300s)
10//! - PDF magic-byte check on the first 5 bytes (`%PDF-`)
11//! - User-Agent: `doiget/<version> (+https://github.com/QAtlasHub/doiget)`
12//!
13//! See `docs/SECURITY.md` §1.2-1.3 / §1.10 and `docs/REDIRECT_ALLOWLIST.md`.
14//!
15//! # Architectural note: per-source `reqwest::Client`
16//!
17//! `reqwest::redirect::Policy::custom` receives only an `Attempt` value, which
18//! exposes the next URL and previous URL chain but **not** the original
19//! request's headers. That makes the "tag the request with `X-Doiget-Source`
20//! and inspect it from inside the redirect closure" approach infeasible on
21//! `reqwest 0.13.x`. Instead, [`HttpClient`] holds one
22//! [`reqwest::Client`] per source — each client's redirect closure captures
23//! that source's [`SourceAllowlist`] so cross-source confusion is impossible
24//! by construction.
25
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::sync::Once;
29use std::time::Duration;
30
31use bytes::{Bytes, BytesMut};
32use futures_util::StreamExt;
33use reqwest::redirect::Policy;
34use reqwest::{Client, ClientBuilder, Url};
35use thiserror::Error;
36
37use crate::{PDF_MAX_BYTES, VERSION};
38
39/// PDF magic-byte prefix per the PDF 1.7 specification (ISO 32000-1 §7.5.2).
40/// `b"%PDF-"`.
41const PDF_MAGIC: [u8; 5] = [0x25, 0x50, 0x44, 0x46, 0x2D];
42
43/// Hard cap on redirect chain length. Matches `reqwest`'s default of 10.
44/// Re-asserted here so the value is reviewed alongside the other security
45/// defaults in this module rather than inheriting silently from upstream.
46const MAX_REDIRECTS: usize = 10;
47
48/// Connect timeout per `docs/SECURITY.md` §1.2 (Slowloris row).
49const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
50
51/// Read (idle-between-bytes) timeout per `docs/SECURITY.md` §1.2.
52const READ_TIMEOUT: Duration = Duration::from_secs(60);
53
54/// Total per-request timeout per `docs/SECURITY.md` §1.2.
55const TOTAL_TIMEOUT: Duration = Duration::from_secs(300);
56
57/// Max retry attempts AFTER the first try, for transient failures only
58/// (connect/timeout/mid-stream network errors and the transient HTTP
59/// status set). 3 retries → up to 4 total attempts. See issue #117.
60const MAX_FETCH_RETRIES: u32 = 3;
61
62/// Base delay for the exponential backoff (`base * 2^attempt`, jittered).
63const RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
64
65/// Hard ceiling on any single backoff / `Retry-After` sleep. Keeps the
66/// worst-case retry chain comfortably inside [`TOTAL_TIMEOUT`].
67const RETRY_MAX_DELAY: Duration = Duration::from_secs(30);
68
69/// HTTP status codes worth retrying: request timeout, rate-limited, and
70/// the transient 5xx family. A plain 500 is included because upstreams
71/// (Crossref/Unpaywall) intermittently 500 under load. 4xx other than
72/// 408/429 are caller/permanent and never retried.
73fn is_transient_status(code: u16) -> bool {
74 matches!(code, 408 | 429 | 500 | 502 | 503 | 504)
75}
76
77/// A `reqwest::Error` is transient iff it is a connect or timeout
78/// failure or a mid-body transfer error. Redirect-policy aborts
79/// (allowlist denial), builder errors, and decode errors are NOT
80/// transient — retrying them cannot help and would mask a real denial.
81fn reqwest_is_transient(e: &reqwest::Error) -> bool {
82 (e.is_timeout() || e.is_connect() || e.is_body()) && !e.is_redirect()
83}
84
85/// Parse a `Retry-After` header expressed as integer seconds (the
86/// HTTP-date form is accepted by the RFC but rare for these APIs and
87/// deliberately ignored for the MVP — we fall back to exponential
88/// backoff in that case). Capped at [`RETRY_MAX_DELAY`].
89fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
90 let secs: u64 = headers
91 .get(reqwest::header::RETRY_AFTER)?
92 .to_str()
93 .ok()?
94 .trim()
95 .parse()
96 .ok()?;
97 Some(Duration::from_secs(secs).min(RETRY_MAX_DELAY))
98}
99
100/// Exponential backoff with decorrelated jitter. `RETRY_BASE_DELAY *
101/// 2^attempt`, capped at [`RETRY_MAX_DELAY`], plus 0..base jitter so a
102/// fleet of clients does not thunder back in lockstep. Jitter is derived
103/// from the wall-clock subsec nanos rather than pulling in an RNG
104/// dependency — adequate decorrelation for backoff, not a security
105/// primitive.
106fn backoff_delay(attempt: u32) -> Duration {
107 let factor = 1u64 << attempt.min(20);
108 let base_ms = RETRY_BASE_DELAY.as_millis() as u64;
109 let capped_ms = base_ms
110 .saturating_mul(factor)
111 .min(RETRY_MAX_DELAY.as_millis() as u64);
112 let jitter_ms = std::time::SystemTime::now()
113 .duration_since(std::time::UNIX_EPOCH)
114 .map(|d| (d.subsec_nanos() as u64) % base_ms.max(1))
115 .unwrap_or(0);
116 Duration::from_millis(capped_ms.saturating_add(jitter_ms))
117}
118
119// ---------------------------------------------------------------------------
120// SourceAllowlist
121// ---------------------------------------------------------------------------
122
123/// Per-source allowlist entry. Matches the schema in
124/// What a [`HttpClient::probe`] observed (issue #407).
125///
126/// `body_bytes` is load-bearing, not decoration: a publisher WAF answers a
127/// scripted client with `202 Accepted` and an empty body, which a
128/// status-only report reads as success. Status plus body size separates
129/// "the publisher served me" from "the publisher is holding me at a bot
130/// challenge".
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ProbeOutcome {
133 /// HTTP status of the (post-redirect) response.
134 pub status: u16,
135 /// Bytes of body received.
136 pub body_bytes: usize,
137 /// Host of the final URL, after any allowlisted redirects.
138 pub final_host: Option<String>,
139}
140
141/// `docs/REDIRECT_ALLOWLIST.md` §2.
142#[derive(Debug, Clone)]
143#[non_exhaustive]
144pub struct SourceAllowlist {
145 /// Source key. MUST match a `source` value in `docs/SOURCES.md` §1
146 /// (e.g. `crossref`, `unpaywall`, `arxiv`).
147 pub source: String,
148 /// Each pattern is either a literal FQDN or a `*.<suffix>` glob (matches
149 /// the suffix and any subdomain — see `docs/REDIRECT_ALLOWLIST.md` §2.2
150 /// matching rule).
151 pub redirect_hosts: Vec<String>,
152}
153
154impl SourceAllowlist {
155 /// Construct a new allowlist entry.
156 pub fn new(source: impl Into<String>, redirect_hosts: Vec<String>) -> Self {
157 Self {
158 source: source.into(),
159 redirect_hosts,
160 }
161 }
162
163 /// Returns `true` if `host` matches any pattern in this allowlist.
164 ///
165 /// Matching is byte-level on the lowercased ASCII form of the host.
166 /// Callers MUST lowercase upstream; this method also lowercases as a
167 /// defense-in-depth measure but treats the result as ASCII (Punycode
168 /// is the caller's responsibility per `docs/REDIRECT_ALLOWLIST.md`
169 /// §2.2 rule 4).
170 pub fn matches(&self, host: &str) -> bool {
171 let host_lc = host.to_ascii_lowercase();
172 self.redirect_hosts
173 .iter()
174 .any(|pat| host_matches_pattern(&host_lc, pat))
175 }
176
177 /// Returns `true` if a request to `host` may proceed under this
178 /// allowlist: either it is on the list, or it is a transparent DOI
179 /// resolver ([`is_transparent_resolver`]).
180 ///
181 /// **This, not [`matches`](Self::matches), is what an adjudication site
182 /// calls.** `matches` answers "is this host on the list", which is a
183 /// question about the list; `permits` answers "may we go here", which is
184 /// the question every gate is actually asking. Keeping them separate
185 /// means the resolver set is not silently reported as part of any
186 /// source's `expected_hosts`.
187 ///
188 /// The five adjudication sites -- the pre-fetch OA-URL check in
189 /// `orchestrator`, the two redirect-policy closures, `probe`, and the
190 /// pre-check `doiget config doctor --network` runs before calling
191 /// `probe` -- all route through here so they cannot disagree. #533 was
192 /// found because only one of them was ever walked end to end, and the
193 /// doctor's was missed on the first pass at this very doc comment: it
194 /// said "four" while still calling `matches`, which would have had the
195 /// command that explains allowlist refusals reproduce #533 the moment a
196 /// resolver host entered its probe list.
197 #[must_use]
198 pub fn permits(&self, host: &str) -> bool {
199 is_transparent_resolver(host) || self.matches(host)
200 }
201}
202
203/// Hosts that are addressing, not hosting.
204///
205/// `doi.org` is the indirection layer every DOI passes through, and
206/// Unpaywall routinely reports it AS the OA location: for the gold cc-by
207/// paper in #533, `best_oa_location.url` is literally
208/// `https://doi.org/10.1002/pcn5.205` with no `url_for_pdf`. Adjudicating
209/// that hop as if it were a content host had two consequences, both wrong:
210///
211/// * the chain was refused at the FIRST hop, before it ever reached the
212/// publisher -- whose host, `*.wiley.com`, was already on the list; and
213/// * the denial's remediation told the user to allowlist `doi.org`, which
214/// does not widen the trusted surface toward one publisher. It removes
215/// the bound entirely, because every DOI in existence resolves through
216/// it. An agent following that advice would get the PDF and silently
217/// lose the invariant the allowlist exists to hold (ADR-0027).
218///
219/// These hosts are therefore FOLLOWED but never allowlisted, never named as
220/// remediation, and never counted as the source of the content. The host
221/// that actually serves the bytes is adjudicated exactly as before, so this
222/// is transparent to the invariant rather than an exception to it.
223///
224/// One edge the sentence above does not cover: a chain that TERMINATES at a
225/// resolver -- a `200` straight from `doi.org` rather than the `302` it
226/// exists to send -- is served by a host `permits` allowed and `matches`
227/// would not. The bound still holds in practice because these three hosts are
228/// operated by the DOI Foundation and CNRI and do not serve article bytes,
229/// which is why the set is closed and exact; but the invariant is "the
230/// resolver is trusted to redirect", not "only allowlisted hosts ever send
231/// bytes".
232///
233/// # Why a closed set and not "the terminal host"
234///
235/// #533's first suggestion was to adjudicate only the final host of a
236/// chain. That is a bigger change than it looks: it would let a chain
237/// traverse ANY host so long as it ended somewhere allowed, and every hop
238/// still sees the request. A named, closed set of resolvers keeps the bound.
239///
240/// # Why exact hosts and no wildcards
241///
242/// `*.doi.org` would sweep in `www.doi.org`, which is the DOI Foundation's
243/// website, not a resolver -- and any other subdomain the Foundation ever
244/// stands up. Each entry here is a host measured to 302 straight to the
245/// publisher (2026-08-30, `10.1002/pcn5.205`):
246///
247/// | host | what it is |
248/// |---|---|
249/// | `doi.org` | the canonical DOI resolver |
250/// | `dx.doi.org` | its long-standing alias, still in live metadata |
251/// | `hdl.handle.net` | the Handle System resolver `doi.org` proxies |
252const TRANSPARENT_RESOLVER_HOSTS: &[&str] = &["doi.org", "dx.doi.org", "hdl.handle.net"];
253
254/// Whether `host` is a DOI resolver rather than a content host (#533).
255///
256/// Exact match against `TRANSPARENT_RESOLVER_HOSTS`, deliberately without
257/// wildcard support: `evil-doi.org`, `doi.org.evil.test` and `www.doi.org`
258/// are all NOT resolvers, and the first two are what an attacker would
259/// register.
260#[must_use]
261pub fn is_transparent_resolver(host: &str) -> bool {
262 let host_lc = host.to_ascii_lowercase();
263 TRANSPARENT_RESOLVER_HOSTS.contains(&host_lc.as_str())
264}
265
266/// Returns `true` if `host` (already lowercased) matches `pattern` per
267/// `docs/REDIRECT_ALLOWLIST.md` §2.2.
268fn host_matches_pattern(host: &str, pattern: &str) -> bool {
269 let pat_lc = pattern.to_ascii_lowercase();
270 if let Some(suffix) = pat_lc.strip_prefix("*.") {
271 // Suffix-glob: matches `<suffix>` exactly OR `*.<suffix>`.
272 host == suffix || host.ends_with(&format!(".{}", suffix))
273 } else {
274 // Exact-FQDN: byte-identical (after lowercasing both sides).
275 host == pat_lc
276 }
277}
278
279/// Hard-coded Phase 1 allowlist for Tier 1 sources. Sourced from
280/// `docs/REDIRECT_ALLOWLIST.md` §3.
281///
282/// Marked `Phase 1; revisit during real fetches` in the spec — entries
283/// flagged `(unverified)` (e.g. arXiv subdomain redirect behavior) MUST be
284/// confirmed or removed before Phase 1 is closed; see §3.3 of the spec.
285pub fn tier_1_allowlist() -> Vec<SourceAllowlist> {
286 vec![
287 // §3.1 crossref
288 SourceAllowlist::new(
289 "crossref",
290 vec!["api.crossref.org".to_string(), "*.crossref.org".to_string()],
291 ),
292 // §3.2 unpaywall
293 SourceAllowlist::new("unpaywall", vec!["api.unpaywall.org".to_string()]),
294 // §3.3 arxiv
295 SourceAllowlist::new(
296 "arxiv",
297 vec![
298 "arxiv.org".to_string(),
299 "export.arxiv.org".to_string(),
300 "*.arxiv.org".to_string(),
301 ],
302 ),
303 ]
304}
305
306/// Hard-coded Phase 4 allowlist for Tier 2 metadata sources (OpenAlex,
307/// Semantic Scholar, DOAJ). Sourced from `docs/SOURCES.md` §1 (the Tier 2
308/// table) and `docs/REDIRECT_ALLOWLIST.md` §3 (same redirect-allowlist
309/// policy as Tier 1, distinct source keys).
310///
311/// Returned hosts:
312///
313/// - `"openalex"` → `api.openalex.org` (production OpenAlex REST API).
314/// - `"semantic_scholar"` → `api.semanticscholar.org` (S2 Graph API base).
315/// - `"doaj"` → `doaj.org` + `*.doaj.org` (DOAJ public API; wildcard
316/// covers `api.doaj.org` and any v4+ subdomain split).
317///
318/// Per `docs/SOURCES.md` §4 "OpenAlex / Semantic Scholar / DOAJ", these
319/// sources are **metadata-only**: their `Source::fetch` impls MUST
320/// return `pdf_bytes: None`. The redirect closure in [`HttpClient`]
321/// uses this list to deny redirects to off-list hosts under each Tier
322/// 2 source key — identical mechanism to Tier 1, but the per-tool
323/// capability gate (`profile.metadata.openalex` etc.) is layered on
324/// top so the network surface remains capability-aware.
325pub fn tier_2_allowlist() -> Vec<SourceAllowlist> {
326 vec![
327 SourceAllowlist::new("openalex", vec!["api.openalex.org".to_string()]),
328 SourceAllowlist::new(
329 "semantic_scholar",
330 vec!["api.semanticscholar.org".to_string()],
331 ),
332 SourceAllowlist::new(
333 "doaj",
334 vec!["doaj.org".to_string(), "*.doaj.org".to_string()],
335 ),
336 // DataCite REST — DOI resolution for the second registration
337 // agency (#414). Distinct from `doaj.org`: that host serves
338 // article records, this one is the DOI registry API.
339 SourceAllowlist::new("datacite", vec!["api.datacite.org".to_string()]),
340 // HAL — French national OA repository, Solr-style search API
341 // (#418). `api.archives-ouvertes.fr` is the API host; the
342 // deposit landing pages live on `hal.science`, which is reached
343 // through the `oa-publisher` key (via `trust_oa_registries`),
344 // not this one.
345 SourceAllowlist::new("hal", vec!["api.archives-ouvertes.fr".to_string()]),
346 // OpenAIRE Graph API v1 (#416). The legacy `/search/publications`
347 // endpoint on the same host is unstable (503s) and deliberately
348 // unused; only the Graph path is called.
349 SourceAllowlist::new("openaire", vec!["api.openaire.eu".to_string()]),
350 // CORE REST v3 (#417). Optional bearer key; same host either way.
351 SourceAllowlist::new("core", vec!["api.core.ac.uk".to_string()]),
352 // Europe PMC REST (#415). This is the EBI API host; the OA PDF it
353 // points at lives on `europepmc.org`, which is already on the
354 // `oa-publisher` key and is where the download actually happens.
355 SourceAllowlist::new("europe-pmc", vec!["www.ebi.ac.uk".to_string()]),
356 ]
357}
358
359/// Always-compiled allowlist for the **discovery search** call path
360/// (ADR-0031).
361///
362/// Registers `api.openalex.org` under the `"openalex"` source key so the
363/// Tier-1 `discovery::paper_search` (`GET /works?search=`) can reach the
364/// endpoint in the **default `oa-only` binary** — unlike
365/// [`tier_2_allowlist`], which the CLI only wires in under
366/// `#[cfg(feature = "metadata")]` (#516; it was `citation` until then,
367/// which left every other Tier-2 source `UnknownSource` in a
368/// `metadata`-only build).
369///
370/// Discovery search is classified as Tier 1 OA metadata (read-only, never
371/// paywalled, never a PDF — same risk class as Crossref/Unpaywall), so its
372/// transport allowlist must exist regardless of the `metadata`/`citation`
373/// features (ADR-0031 D1/D2). The CLI's `build_http_client` extends the
374/// production allowlist with this **unconditionally**; in `metadata`
375/// builds [`tier_2_allowlist`] re-registers the identical
376/// `"openalex" → api.openalex.org` entry, which is a harmless idempotent
377/// `HashMap` overwrite in [`HttpClient::new`].
378pub fn discovery_allowlist() -> Vec<SourceAllowlist> {
379 vec![SourceAllowlist::new(
380 "openalex",
381 vec!["api.openalex.org".to_string()],
382 )]
383}
384
385/// Always-compiled allowlist for the **full-text extraction** call path
386/// (ADR-0032).
387///
388/// Registers `ar5iv.labs.arxiv.org` under a dedicated `"ar5iv"` source key
389/// so [`crate::paper_text::paper_text`] (`GET /html/<arxiv-id>`) can reach
390/// the ar5iv LaTeXML-XHTML renderer in the **default `oa-only` binary** —
391/// the same always-on posture as [`discovery_allowlist`].
392///
393/// The host is an arXiv subdomain (`*.arxiv.org` already matches it under
394/// the [`tier_1_allowlist`] `"arxiv"` key), so this adds no new
395/// registrable domain to the network surface — it only registers the host
396/// under a **distinct source key** so the provenance trail records that
397/// extracted text came from the ar5iv HTML renderer, not the arXiv
398/// PDF/Atom API (ADR-0032 D3). Full-text extraction is classified Tier-1
399/// OA metadata (read-only, OA, never a PDF reinterpretation), so its
400/// transport allowlist must exist regardless of any feature gate
401/// (ADR-0032 D2). The CLI's `build_http_client` extends the production
402/// allowlist with this **unconditionally**.
403pub fn fulltext_allowlist() -> Vec<SourceAllowlist> {
404 vec![SourceAllowlist::new(
405 "ar5iv",
406 vec!["ar5iv.labs.arxiv.org".to_string()],
407 )]
408}
409
410/// Hard-coded Phase 5a allowlist for the Springer Nature OA TDM
411/// source. Compile-gated by the `tdm-springer` Cargo feature so
412/// default release binaries never include the host pattern (per
413/// ADR-0002 and `docs/SOURCES.md` §3).
414///
415/// Returned entry:
416/// - `"tdm-springer"` → `api.springernature.com` (production base) +
417/// `*.springernature.com` (covers load-balancing subdomains; the
418/// redirect closure denies anything outside the wildcard).
419///
420/// Per `docs/SOURCES.md` §4 "TDM sources (Phase 5)", a fetch under
421/// this source key requires ALL THREE gates: Cargo feature compiled
422/// in, `DOIGET_KEY_SPRINGER` env var present, and
423/// `DOIGET_AGREE_TDM_SPRINGER=1`. The `CapabilityProfile` gate
424/// enforces the env-var pair; this allowlist is the transport gate.
425#[cfg(feature = "tdm-springer")]
426pub fn tier_3_springer_allowlist() -> Vec<SourceAllowlist> {
427 vec![SourceAllowlist::new(
428 "tdm-springer",
429 vec![
430 "api.springernature.com".to_string(),
431 "*.springernature.com".to_string(),
432 ],
433 )]
434}
435
436/// Hard-coded Phase 5b allowlist for the APS Harvest TDM source.
437/// Compile-gated by the `tdm-aps` Cargo feature so default release
438/// binaries never include the host pattern (per ADR-0002 and
439/// `docs/SOURCES.md` §3).
440///
441/// Returned entry:
442/// - `"tdm-aps"` → `harvest.aps.org` (production base) +
443/// `*.aps.org` (covers load-balancing subdomains; the redirect
444/// closure denies anything outside the wildcard).
445///
446/// Three-gate activation: Cargo feature compiled in,
447/// `DOIGET_KEY_APS` env var present, and `DOIGET_AGREE_TDM_APS=1`.
448/// The `CapabilityProfile` gate enforces the env-var pair; this
449/// allowlist is the transport gate.
450#[cfg(feature = "tdm-aps")]
451pub fn tier_3_aps_allowlist() -> Vec<SourceAllowlist> {
452 vec![SourceAllowlist::new(
453 "tdm-aps",
454 vec!["harvest.aps.org".to_string(), "*.aps.org".to_string()],
455 )]
456}
457
458/// Hard-coded Phase 5c allowlist for the Elsevier ScienceDirect TDM
459/// source. Compile-gated by the `tdm-elsevier` Cargo feature so
460/// default release binaries never include the host pattern (per
461/// ADR-0002 and `docs/SOURCES.md` §3).
462///
463/// Returned entry:
464/// - `"tdm-elsevier"` → `api.elsevier.com` (production base) +
465/// `*.elsevier.com` (covers load-balancing subdomains; the
466/// redirect closure denies anything outside the wildcard).
467///
468/// Three-gate activation: Cargo feature compiled in,
469/// `DOIGET_KEY_ELSEVIER` env var present, and
470/// `DOIGET_AGREE_TDM_ELSEVIER=1`. The `CapabilityProfile` gate
471/// enforces the env-var pair; this allowlist is the transport gate.
472#[cfg(feature = "tdm-elsevier")]
473pub fn tier_3_elsevier_allowlist() -> Vec<SourceAllowlist> {
474 vec![SourceAllowlist::new(
475 "tdm-elsevier",
476 vec!["api.elsevier.com".to_string(), "*.elsevier.com".to_string()],
477 )]
478}
479
480/// Hard-coded allowlist for the IEEE Xplore TDM source (#430).
481/// Compile-gated by the `tdm-ieee` Cargo feature so default release
482/// binaries never include the host pattern (per ADR-0002 and
483/// `docs/SOURCES.md` §3).
484///
485/// Returned entry:
486/// - `"tdm-ieee"` → `ieeexploreapi.ieee.org` (production base) +
487/// `*.ieee.org` (covers load-balancing subdomains; the redirect
488/// closure denies anything outside the wildcard).
489///
490/// Note the API host is deliberately NOT `ieeexplore.ieee.org`, the web
491/// front end: ADR-0039 records that the front end answers a scripted
492/// client with `202` and an empty body regardless of entitlement, which
493/// is why the TDM API is the supported route at all.
494///
495/// Three-gate activation: Cargo feature compiled in, `DOIGET_KEY_IEEE`
496/// env var present, and `DOIGET_AGREE_TDM_IEEE=1`. The
497/// `CapabilityProfile` gate enforces the env-var pair; this allowlist is
498/// the transport gate.
499#[cfg(feature = "tdm-ieee")]
500pub fn tier_3_ieee_allowlist() -> Vec<SourceAllowlist> {
501 vec![SourceAllowlist::new(
502 "tdm-ieee",
503 vec![
504 "ieeexploreapi.ieee.org".to_string(),
505 "*.ieee.org".to_string(),
506 ],
507 )]
508}
509
510/// Every Tier-3 TDM allowlist this build actually compiled in.
511///
512/// #454: the three per-publisher builders above had no caller. Both client
513/// builders — `doiget_cli::commands::fetch::build_http_client` and its MCP
514/// twin — assemble the client by naming a list of allowlist functions, and
515/// neither named these. So #444 taught the orchestrator to reach the
516/// sources and the transport then refused a source key it had never been
517/// told about: `UnknownSource { source_key: "tdm-aps" }`, which reads like
518/// an internal error rather than a missing registration.
519///
520/// One function rather than three `#[cfg]` blocks at each call site: a
521/// fourth publisher is then a single edit here, and the two client builders
522/// cannot drift apart — which is the drift that produced this bug.
523///
524/// Empty in a default build, where no Tier-3 feature is compiled in.
525#[must_use]
526pub fn tier_3_allowlists() -> Vec<SourceAllowlist> {
527 #[allow(unused_mut)]
528 let mut out: Vec<SourceAllowlist> = Vec::new();
529 #[cfg(feature = "tdm-aps")]
530 out.extend(tier_3_aps_allowlist());
531 #[cfg(feature = "tdm-elsevier")]
532 out.extend(tier_3_elsevier_allowlist());
533 #[cfg(feature = "tdm-springer")]
534 out.extend(tier_3_springer_allowlist());
535 #[cfg(feature = "tdm-ieee")]
536 out.extend(tier_3_ieee_allowlist());
537 out
538}
539
540/// Hard-coded Phase 1 allowlist for the synthetic `"oa-publisher"` source —
541/// the publisher / preprint / repository hosts to which Unpaywall's
542/// `best_oa_location.url` (or `url_for_pdf`) typically resolves.
543///
544/// **Status: informed-best-effort.** Per `docs/REDIRECT_ALLOWLIST.md` §3,
545/// every entry below is a documented OA-publisher host pulled from the
546/// public DOI / OA discovery surface as of this function's authoring; they
547/// are **not** a substitute for empirical validation. Entries marked
548/// `(unverified)` MUST be confirmed by a real fetch or removed before
549/// Phase 1 is closed.
550///
551/// The orchestrator (`doiget-cli::commands::fetch::fetch_doi`) calls
552/// [`HttpClient::fetch_pdf`] under the `"oa-publisher"` source key when
553/// Unpaywall returns an OA URL. If the OA host is not in this list, the
554/// PDF leg is denied (`HttpError::RedirectDenied`) and the orchestrator
555/// falls back to metadata-only success (the `informed-best-effort`
556/// posture from the spec section above).
557pub fn oa_publisher_allowlist() -> Vec<SourceAllowlist> {
558 vec![SourceAllowlist::new(
559 "oa-publisher",
560 vec![
561 // Springer Nature OA imprints. Springer / SpringerOpen / Nature
562 // OA URLs all resolve under one of these registrable suffixes.
563 // (unverified) — confirm by replaying real Unpaywall responses.
564 "*.springer.com".to_string(),
565 "*.springeropen.com".to_string(),
566 "*.springernature.com".to_string(),
567 "*.nature.com".to_string(),
568 // Wiley OA. (unverified)
569 "*.wiley.com".to_string(),
570 // Elsevier OA route only — the TDM gated path is a separate
571 // source (`tdm-elsevier`, Phase 5c) and is not covered here.
572 // (unverified)
573 "*.elsevier.com".to_string(),
574 "*.sciencedirect.com".to_string(),
575 // Frontiers. (unverified)
576 "*.frontiersin.org".to_string(),
577 // MDPI. (unverified)
578 "*.mdpi.com".to_string(),
579 // PLOS. (unverified)
580 "*.plos.org".to_string(),
581 // Preprint servers — biorxiv / medrxiv. (unverified)
582 "*.biorxiv.org".to_string(),
583 "*.medrxiv.org".to_string(),
584 // Europe PMC + NIH PMC. (unverified)
585 "europepmc.org".to_string(),
586 "*.europepmc.org".to_string(),
587 "*.nih.gov".to_string(),
588 "*.ncbi.nlm.nih.gov".to_string(),
589 // Physics-society / diamond-OA hosts. UNLIKE the entries
590 // above, these are EMPIRICALLY VERIFIED: a real `doiget batch`
591 // over 30 OpenAlex-OA finite-temperature-MPS DOIs observed
592 // Unpaywall `best_oa_location` resolving to these hosts and
593 // being denied (#193, REDIRECT_ALLOWLIST.md §3.4, ADR-0027).
594 // APS — journals.aps.org / link.aps.org (green & gold OA;
595 // society host; `*.aps.org` is also trusted under the separate
596 // `tdm-aps` Tier-3 source key WHEN that feature is compiled
597 // in — `tier_3_aps_allowlist` is `#[cfg(feature = "tdm-aps")]`
598 // and absent from default release builds).
599 "*.aps.org".to_string(),
600 // SciPost — diamond OA, community-run physics publisher.
601 "scipost.org".to_string(),
602 "*.scipost.org".to_string(),
603 // IOP Publishing — iopscience.iop.org (New J. Phys. etc.).
604 "*.iop.org".to_string(),
605 // DOAJ — the canonical redirect host for gold-OA journal
606 // content. ADR-0037: this domain was ALREADY trusted in this
607 // file under the `"doaj"` metadata key (`tier_2_allowlist`),
608 // which the CLI wires in only under
609 // `#[cfg(feature = "citation")]` — so the two keys disagreed
610 // about a host the project had already accepted, and a stock
611 // build could not reach it at all. Promoted here on the
612 // ADR-0027 precedent that made `*.aps.org` unconditional
613 // rather than feature-gated. The apex is listed separately
614 // because a single-suffix wildcard does not match it and the
615 // observed redirect (10.1109/access.2024.3495502, #405)
616 // targeted the bare apex.
617 "doaj.org".to_string(),
618 "*.doaj.org".to_string(),
619 // arXiv — already on the `arxiv` tier-1 allowlist, but the
620 // Unpaywall-driven path uses the `oa-publisher` source key,
621 // so we mirror the host list here too. See REDIRECT_ALLOWLIST.md
622 // §3.3 for the underlying entries.
623 "arxiv.org".to_string(),
624 "*.arxiv.org".to_string(),
625 ],
626 )]
627}
628
629// ---------------------------------------------------------------------------
630// HttpError
631// ---------------------------------------------------------------------------
632
633/// Errors that can arise during HTTP fetches.
634#[derive(Debug, Error)]
635#[non_exhaustive]
636pub enum HttpError {
637 /// Transport / DNS / TLS failure or other `reqwest`-level error. Note
638 /// that `reqwest` surfaces a redirect-policy abort (via `Attempt::error`)
639 /// as a `reqwest::Error` carrying the source error — callers seeing
640 /// `Network` for what they believed was a redirect violation should
641 /// inspect the inner error chain.
642 #[error("network error: {0}")]
643 Network(#[from] reqwest::Error),
644 /// Redirect target host did not match any pattern in the source's
645 /// `redirect_hosts`. See `docs/REDIRECT_ALLOWLIST.md` §2.2.
646 ///
647 /// Field naming: `source_key` rather than `source` because `thiserror`
648 /// auto-treats a field literally named `source` as a `#[source]` error
649 /// chain link (which would require the field to implement `std::error::Error`).
650 ///
651 /// `expected_hosts` carries a snapshot of the source's allowlist
652 /// patterns at the time of the denial — populated for the structured
653 /// `denial_context.expected` channel introduced by ADR-0023 §4
654 /// (NORMATIVE mapping table). Cloning the patterns into the error
655 /// keeps the `From<&HttpError> for Option<DenialContext>` impl from
656 /// having to re-look-up the allowlist by `source_key`. May be empty
657 /// when the rejection happened before any allowlist was matched
658 /// (e.g. URL had no host component at all).
659 #[error("redirect target {host} not in allowlist for source {source_key}")]
660 RedirectDenied {
661 /// Source key whose allowlist rejected the redirect.
662 source_key: String,
663 /// The lowercased host that was rejected.
664 host: String,
665 /// Snapshot of the source's `redirect_hosts` at denial time.
666 /// Surfaces as `denial_context.expected` (ADR-0023 §4).
667 expected_hosts: Vec<String>,
668 },
669 /// Redirect target had a scheme other than `https`. See
670 /// `docs/SECURITY.md` §1.3.
671 #[error("redirect to non-HTTPS scheme: {scheme}")]
672 InsecureRedirect {
673 /// The disallowed scheme (e.g. `http`, `file`, `data`).
674 scheme: String,
675 },
676 /// Body would exceed [`PDF_MAX_BYTES`] either by a `Content-Length`
677 /// hint or by accumulated streamed bytes. See `docs/SECURITY.md` §1.2.
678 #[error("body too large: {actual} bytes (cap = {cap})")]
679 OversizedBody {
680 /// Observed size (header value or accumulated bytes).
681 actual: u64,
682 /// Hard upper bound (always [`PDF_MAX_BYTES`]).
683 cap: u64,
684 },
685 /// PDF magic-byte mismatch — the body does not start with `%PDF-`.
686 /// We deliberately do NOT use `Content-Type` (publishers misbehave —
687 /// the magic byte is the trustworthy signal per `docs/SECURITY.md`
688 /// §1.2 "Magic-byte mismatch" row).
689 #[error("PDF magic-byte mismatch: got {got:?}")]
690 NotAPdf {
691 /// First five bytes of the response body (zero-padded if shorter).
692 got: [u8; 5],
693 },
694 /// Server returned a non-2xx status.
695 #[error("HTTP {status} from {url}")]
696 HttpStatus {
697 /// HTTP status code.
698 status: u16,
699 /// The URL that produced the status.
700 url: String,
701 /// The server's own `Retry-After`, in milliseconds, when it sent one
702 /// on the response that ended the attempt (#506).
703 ///
704 /// `parse_retry_after` already read this header, but only on the
705 /// retry path -- the terminal `return` discarded it, so by the time
706 /// the error reached a caller the number was gone and
707 /// `error.retry_after_ms` looked impossible to fill honestly. It is
708 /// not: the LAST response carries its own `Retry-After`, and that is
709 /// the one the caller should wait.
710 ///
711 /// `None` when the server sent no header. Deliberately not
712 /// substituted with `backoff_delay` -- doiget's internal backoff is a
713 /// guess about the server, and handing a caller a guess wearing the
714 /// name of a server-supplied value is the defect this field exists to
715 /// avoid.
716 retry_after_ms: Option<u64>,
717 },
718 /// No allowlist entry exists for this source. The caller asked
719 /// [`HttpClient`] to fetch on behalf of a source that wasn't passed to
720 /// [`HttpClient::new`].
721 ///
722 /// See note on `RedirectDenied` for why the field is `source_key`.
723 #[error("no allowlist registered for source {source_key}")]
724 UnknownSource {
725 /// The unregistered source key.
726 source_key: String,
727 },
728 /// A header name or value passed to
729 /// [`HttpClient::fetch_bytes_with_headers`] was not a valid HTTP
730 /// header. The header parser only accepts the visible-ASCII subset
731 /// per RFC 7230 §3.2; control characters and non-ASCII bytes are
732 /// rejected before the request is even built. Surfaces as
733 /// `ErrorCode::InternalError` at the public boundary (callers
734 /// supplying bad headers are responsible for fixing the call site;
735 /// not a denial in the ADR-0023 sense).
736 #[error("invalid HTTP header `{name}`: {reason}")]
737 InvalidHeader {
738 /// The header name as supplied by the caller.
739 name: String,
740 /// `"name"` or `"value"` — which side failed parsing.
741 reason: String,
742 },
743}
744
745// ---------------------------------------------------------------------------
746// HttpError -> Option<DenialContext> (ADR-0023 §4 mapping table)
747// ---------------------------------------------------------------------------
748
749/// Map an [`HttpError`] reference to the structured [`crate::DenialContext`]
750/// channel introduced by ADR-0023.
751///
752/// Returns `Some(_)` for the four denial classes named in ADR-0023 §4
753/// (`RedirectDenied`, `OversizedBody`, `NotAPdf`, `InsecureRedirect`) and
754/// `None` for every other variant — `Network`, `HttpStatus`,
755/// `UnknownSource` are not denials in the ADR-0023 sense (they are
756/// transport / upstream / programming-error signals, not allowlist or
757/// cap rejections).
758///
759/// The `&HttpError` borrow form is used (rather than `HttpError`) so the
760/// caller — typically the orchestrator that already needs the original
761/// error for `error.message` and the `From<HttpError> for ErrorCode`
762/// collapse — does not have to clone the error to produce the optional
763/// structured side-channel.
764impl From<&HttpError> for Option<crate::DenialContext> {
765 fn from(e: &HttpError) -> Self {
766 use crate::{DenialContext, DenialReason};
767 match e {
768 HttpError::RedirectDenied {
769 source_key,
770 host,
771 expected_hosts,
772 } => Some(DenialContext {
773 reason: DenialReason::RedirectNotInAllowlist,
774 source: Some(source_key.clone()),
775 attempted: Some(host.clone()),
776 expected: Some(expected_hosts.clone()),
777 hop_index: None,
778 cap: None,
779 actual: None,
780 }),
781 HttpError::OversizedBody { actual, cap } => Some(DenialContext {
782 reason: DenialReason::SizeCapExceeded,
783 source: None,
784 attempted: None,
785 // The size-cap reason has no allowlist channel; use
786 // `None` to signal "field not populated by producer"
787 // rather than `Some(vec![])` (which would mean "explicit
788 // empty allowlist"). See `DenialContext::expected` docs.
789 expected: None,
790 hop_index: None,
791 cap: Some(*cap),
792 actual: Some(*actual),
793 }),
794 HttpError::NotAPdf { got } => Some(DenialContext {
795 reason: DenialReason::ContentTypeMismatch,
796 source: None,
797 // ADR-0023 §4 mapping table: hex-encode the first 5 bytes
798 // for the `attempted` field. `format!("{:02x}...")` is
799 // chosen over `hex::encode` to avoid pulling the
800 // additional dep into this conversion path; the result is
801 // bit-identical (lowercase, zero-padded).
802 attempted: Some(format!(
803 "{:02x}{:02x}{:02x}{:02x}{:02x}",
804 got[0], got[1], got[2], got[3], got[4]
805 )),
806 expected: Some(vec!["%PDF-".to_string()]),
807 hop_index: None,
808 cap: None,
809 actual: None,
810 }),
811 HttpError::InsecureRedirect { scheme } => Some(DenialContext {
812 reason: DenialReason::InsecureScheme,
813 source: None,
814 attempted: Some(format!("{}:...", scheme)),
815 expected: Some(vec!["https".to_string()]),
816 hop_index: None,
817 cap: None,
818 actual: None,
819 }),
820 // `reqwest` wraps a custom error returned by the redirect
821 // policy closure (`attempt.error(HttpError::RedirectDenied{..})`
822 // / `attempt.error(HttpError::InsecureRedirect{..})`) inside a
823 // `reqwest::Error`, which surfaces here as `HttpError::Network`.
824 // Without source-chain walking, production redirect denials —
825 // the most operationally important denial class — would never
826 // produce a `DenialContext`, defeating the whole point of
827 // ADR-0023.
828 //
829 // Walk the `std::error::Error::source()` chain on the inner
830 // `reqwest::Error` and downcast each link to `&HttpError`. If
831 // a wrapped `HttpError` is found, recurse via this same `From`
832 // impl. Otherwise the network error is a "real" transport /
833 // DNS / TLS failure with no denial semantics — return `None`.
834 //
835 // `std::error::Error::source(e)` is fully-qualified to
836 // disambiguate against the inherent (and unrelated)
837 // `reqwest::Error::source()`.
838 HttpError::Network(e) => {
839 let mut source: Option<&(dyn std::error::Error + 'static)> =
840 std::error::Error::source(e);
841 while let Some(s) = source {
842 if let Some(http_err) = s.downcast_ref::<HttpError>() {
843 return Option::<crate::DenialContext>::from(http_err);
844 }
845 source = s.source();
846 }
847 None
848 }
849 // The remaining variants are not "denials" in the ADR-0023
850 // sense — HttpStatus/UnknownSource are upstream / programming-
851 // error signals; InvalidHeader is a caller-bug signal.
852 HttpError::HttpStatus { .. }
853 | HttpError::UnknownSource { .. }
854 | HttpError::InvalidHeader { .. } => None,
855 }
856 }
857}
858
859// ---------------------------------------------------------------------------
860// HttpClient
861// ---------------------------------------------------------------------------
862
863/// Workspace-wide HTTP client with the security defaults applied.
864///
865/// Internally holds one `reqwest::Client` per source. Construct via
866/// [`HttpClient::new`] with the full set of allowlists the calling process
867/// will need.
868#[derive(Clone, Debug)]
869pub struct HttpClient {
870 /// One [`reqwest::Client`] per source. Each client carries a redirect
871 /// policy that captures only that source's allowlist. `Arc` so cloning
872 /// is cheap.
873 clients: Arc<HashMap<String, Client>>,
874 /// The exact [`SourceAllowlist`] each per-source client was built from,
875 /// keyed by source. The redirect closure inside each `reqwest::Client`
876 /// captures its allowlist *by move*, so it cannot be read back from the
877 /// client itself. This map keeps the identical `SourceAllowlist`
878 /// available to callers that must perform a *pre-fetch* host check on a
879 /// metadata-discovered URL (issue #145 / `docs/REDIRECT_ALLOWLIST.md`
880 /// §1: the allowlist is consulted "on the OA URL discovered through
881 /// metadata sources before the actual PDF fetch is issued", not only on
882 /// redirect hops). Storing the same value here — rather than re-deriving
883 /// it from [`oa_publisher_allowlist`] at the call site — guarantees the
884 /// pre-check and the redirect closure can never drift, and that the
885 /// check works under the test constructors too (which register a
886 /// wiremock host as the allowlist).
887 allowlists: Arc<HashMap<String, SourceAllowlist>>,
888}
889
890impl HttpClient {
891 /// Build a client with rustls + redirect-allowlist + size cap +
892 /// timeouts.
893 ///
894 /// `allowlists` MUST cover every source whose URL might be passed in;
895 /// fetches against unregistered sources return
896 /// [`HttpError::UnknownSource`].
897 ///
898 /// # Errors
899 ///
900 /// Returns the underlying `reqwest::Error` if `ClientBuilder::build`
901 /// fails (typically a TLS-backend init failure).
902 pub fn new(allowlists: Vec<SourceAllowlist>) -> Result<Self, reqwest::Error> {
903 let ua = format!("doiget/{} (+https://github.com/QAtlasHub/doiget)", VERSION);
904 Self::new_with_user_agent(allowlists, &ua)
905 }
906
907 /// Build a client with a custom `User-Agent` header.
908 ///
909 /// Used by `doiget batch --user-agent` to override the default UA for
910 /// hosts that classify the default string as a bot.
911 pub fn new_with_user_agent(
912 allowlists: Vec<SourceAllowlist>,
913 user_agent: &str,
914 ) -> Result<Self, reqwest::Error> {
915 let mut clients = HashMap::with_capacity(allowlists.len());
916 let mut allowlist_map = HashMap::with_capacity(allowlists.len());
917 for entry in allowlists {
918 let source = entry.source.clone();
919 allowlist_map.insert(source.clone(), entry.clone());
920 let client = build_client(entry, user_agent)?;
921 clients.insert(source, client);
922 }
923 Ok(Self {
924 clients: Arc::new(clients),
925 allowlists: Arc::new(allowlist_map),
926 })
927 }
928
929 /// The [`SourceAllowlist`] this client was built with for `source`, or
930 /// `None` if `source` was not registered.
931 ///
932 /// This is the *identical* value captured by the per-source redirect
933 /// closure (see [`HttpClient`]'s `allowlists` field doc). It exists so
934 /// the orchestrator can apply the `docs/REDIRECT_ALLOWLIST.md` §1
935 /// pre-fetch host check on a metadata-discovered OA URL — the URL that
936 /// is fetched *without* necessarily passing through a redirect hop —
937 /// using the same source of truth the redirect closure uses, so the two
938 /// can never disagree. Callers MUST use this for the `"oa-publisher"`
939 /// leg only; the initial template-constructed URL is exempt per
940 /// `docs/REDIRECT_ALLOWLIST.md` §6.
941 pub fn source_allowlist(&self, source: &str) -> Option<&SourceAllowlist> {
942 self.allowlists.get(source)
943 }
944
945 /// Fetch a URL, treating it as a JSON or text body. Caps at
946 /// [`PDF_MAX_BYTES`].
947 ///
948 /// Returns the response body bytes plus the effective final URL after
949 /// redirects (post-allowlist verification — every hop has already been
950 /// validated by the time this returns).
951 ///
952 /// # Errors
953 ///
954 /// Any [`HttpError`] variant.
955 pub async fn fetch_bytes(&self, source: &str, url: Url) -> Result<(Bytes, Url), HttpError> {
956 self.fetch_inner(source, url, &[], false).await
957 }
958
959 /// Like [`Self::fetch_bytes`] but attaches additional request
960 /// headers to the outgoing GET. The headers are validated up-front
961 /// against the visible-ASCII subset (RFC 7230 §3.2); any failure
962 /// returns [`HttpError::InvalidHeader`] before the request is sent.
963 ///
964 /// Used by Tier-3 TDM sources that authenticate via a header
965 /// (APS Harvest `X-API-Key`, Elsevier ScienceDirect `X-ELS-APIKey`).
966 /// Header values appear on the wire only — they are never logged.
967 ///
968 /// # Errors
969 ///
970 /// Any [`HttpError`] variant including [`HttpError::InvalidHeader`].
971 pub async fn fetch_bytes_with_headers(
972 &self,
973 source: &str,
974 url: Url,
975 headers: &[(&str, &str)],
976 ) -> Result<(Bytes, Url), HttpError> {
977 self.fetch_inner(source, url, headers, false).await
978 }
979
980 /// Fetch a URL expected to be a PDF. Same as [`Self::fetch_bytes`] plus
981 /// the magic-byte check on the first 5 bytes
982 /// (`%PDF-` = `[0x25, 0x50, 0x44, 0x46, 0x2D]`). Mismatch returns
983 /// [`HttpError::NotAPdf`].
984 ///
985 /// # Errors
986 ///
987 /// Any [`HttpError`] variant including [`HttpError::NotAPdf`].
988 pub async fn fetch_pdf(&self, source: &str, url: Url) -> Result<(Bytes, Url), HttpError> {
989 self.fetch_inner(source, url, &[], true).await
990 }
991
992 /// [`Self::fetch_pdf`] with request headers, for sources that
993 /// authenticate by header rather than by query parameter.
994 ///
995 /// This is the pairing the Tier-3 content leg needs (#458): APS Harvest
996 /// wants `X-API-Key` *and* `Accept: application/pdf` on the same request
997 /// that must be magic-byte checked. `fetch_bytes_with_headers` would
998 /// send the headers and skip the check, which is exactly how a
999 /// publisher error page or a WAF holding response — both 200s with a
1000 /// body — would end up written to `<safekey>.pdf`.
1001 ///
1002 /// Header values are sent on the wire only; they are never logged and
1003 /// never echoed into an error message (#146).
1004 ///
1005 /// # Errors
1006 ///
1007 /// Any [`HttpError`] variant including [`HttpError::NotAPdf`].
1008 pub async fn fetch_pdf_with_headers(
1009 &self,
1010 source: &str,
1011 url: Url,
1012 headers: &[(&str, &str)],
1013 ) -> Result<(Bytes, Url), HttpError> {
1014 self.fetch_inner(source, url, headers, true).await
1015 }
1016
1017 /// Single diagnostic request against `url`, reporting what came back
1018 /// instead of turning it into an error.
1019 ///
1020 /// This is the primitive behind `doiget config doctor --network`
1021 /// (issue #407). It differs from [`Self::fetch_bytes`] in three ways
1022 /// that matter for a diagnostic:
1023 ///
1024 /// - **Non-2xx is data, not failure.** "403" is the answer to the
1025 /// question the user is asking, so it comes back in
1026 /// [`ProbeOutcome::status`] rather than as `HttpError::HttpStatus`.
1027 /// - **No retries.** A probe that silently retried would hide the
1028 /// very flakiness it is meant to expose, and would multiply load on
1029 /// a publisher for a question that is not a fetch.
1030 /// - **The host is checked against the source allowlist up front.**
1031 /// A doctor that probed arbitrary user-supplied hosts would be an
1032 /// SSRF gadget wearing a diagnostic hat; the allowlist is the same
1033 /// one a real fetch would enforce, which is also what makes
1034 /// "not allowlisted" a meaningful answer.
1035 ///
1036 /// The body is read so that [`ProbeOutcome::body_bytes`] can
1037 /// distinguish a real `200` from the `202` + empty-body holding
1038 /// response publisher WAFs return to scripted clients — the case in
1039 /// #407 that a status code alone cannot diagnose. The read is capped
1040 /// by the same size limits as any other fetch.
1041 ///
1042 /// # Errors
1043 ///
1044 /// [`HttpError::UnknownSource`] if `source` is not registered,
1045 /// [`HttpError::RedirectDenied`] if the host is off the allowlist
1046 /// (before any request is sent), or [`HttpError::Network`] for a
1047 /// transport failure — a timeout IS the diagnosis, so it is returned
1048 /// rather than retried.
1049 pub async fn probe(&self, source: &str, url: Url) -> Result<ProbeOutcome, HttpError> {
1050 let client = self
1051 .clients
1052 .get(source)
1053 .ok_or_else(|| HttpError::UnknownSource {
1054 source_key: source.to_string(),
1055 })?;
1056 let host = url.host_str().unwrap_or_default().to_string();
1057 let allow = self
1058 .source_allowlist(source)
1059 .ok_or_else(|| HttpError::UnknownSource {
1060 source_key: source.to_string(),
1061 })?;
1062 if !allow.permits(&host) {
1063 return Err(HttpError::RedirectDenied {
1064 source_key: source.to_string(),
1065 host,
1066 expected_hosts: allow.redirect_hosts.clone(),
1067 });
1068 }
1069 let response = client.get(url).send().await.map_err(HttpError::Network)?;
1070 let status = response.status().as_u16();
1071 let final_host = response.url().host_str().map(str::to_string);
1072 let body_bytes = response
1073 .bytes()
1074 .await
1075 .map(|b| b.len())
1076 .map_err(HttpError::Network)?;
1077 Ok(ProbeOutcome {
1078 status,
1079 body_bytes,
1080 final_host,
1081 })
1082 }
1083
1084 async fn fetch_inner(
1085 &self,
1086 source: &str,
1087 url: Url,
1088 headers: &[(&str, &str)],
1089 check_pdf_magic: bool,
1090 ) -> Result<(Bytes, Url), HttpError> {
1091 // Normalise legacy `http://` URLs returned by OpenAlex /
1092 // Unpaywall metadata before send. See `upgrade_http_to_https`
1093 // for the rationale (TLS posture preserved per ADR-0020) and
1094 // the loopback carve-out.
1095 let url = upgrade_http_to_https(url);
1096
1097 let client = self
1098 .clients
1099 .get(source)
1100 .ok_or_else(|| HttpError::UnknownSource {
1101 source_key: source.to_string(),
1102 })?;
1103
1104 // Parse headers up-front so an invalid name/value fails BEFORE
1105 // we touch the network. `HeaderName::from_bytes` / `HeaderValue::from_str`
1106 // accept the visible-ASCII subset only (RFC 7230 §3.2).
1107 let mut header_map = reqwest::header::HeaderMap::with_capacity(headers.len());
1108 for (name, value) in headers {
1109 let hn = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
1110 HttpError::InvalidHeader {
1111 name: (*name).to_string(),
1112 reason: "name".to_string(),
1113 }
1114 })?;
1115 let hv = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
1116 HttpError::InvalidHeader {
1117 name: (*name).to_string(),
1118 reason: "value".to_string(),
1119 }
1120 })?;
1121 header_map.insert(hn, hv);
1122 }
1123
1124 // Bounded retry loop (issue #117). Only transient classes are
1125 // retried — connect/timeout/mid-stream network errors and the
1126 // transient HTTP status set. Allowlist denials, NotAPdf,
1127 // OversizedBody, 4xx (non-408/429) are deterministic and return
1128 // on the first occurrence. GET is idempotent so a retried
1129 // attempt re-streams the body from scratch.
1130 let mut attempt: u32 = 0;
1131 loop {
1132 let send_result = client
1133 .get(url.clone())
1134 .headers(header_map.clone())
1135 .send()
1136 .await;
1137 let response = match send_result {
1138 Ok(r) => r,
1139 Err(e) => {
1140 if attempt < MAX_FETCH_RETRIES && reqwest_is_transient(&e) {
1141 let d = backoff_delay(attempt);
1142 tracing::warn!(
1143 source,
1144 attempt,
1145 delay_ms = d.as_millis() as u64,
1146 error = %e,
1147 "transient send failure; retrying"
1148 );
1149 tokio::time::sleep(d).await;
1150 attempt += 1;
1151 continue;
1152 }
1153 return Err(HttpError::Network(e));
1154 }
1155 };
1156 let final_url = response.url().clone();
1157
1158 // Status check before body read so we can fail fast.
1159 let status = response.status();
1160 if !status.is_success() {
1161 let code = status.as_u16();
1162 if attempt < MAX_FETCH_RETRIES && is_transient_status(code) {
1163 // Prefer the server's `Retry-After` over our backoff
1164 // when present (429/503 commonly carry it).
1165 let d = parse_retry_after(response.headers())
1166 .unwrap_or_else(|| backoff_delay(attempt));
1167 tracing::warn!(
1168 source,
1169 attempt,
1170 status = code,
1171 delay_ms = d.as_millis() as u64,
1172 "transient HTTP status; retrying"
1173 );
1174 tokio::time::sleep(d).await;
1175 attempt += 1;
1176 continue;
1177 }
1178 return Err(HttpError::HttpStatus {
1179 status: code,
1180 // Read from the response that ENDED the attempt, so it is
1181 // the server's number rather than ours (#506).
1182 retry_after_ms: parse_retry_after(response.headers())
1183 .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
1184 // Issue #146: Springer Nature authenticates via an
1185 // `api_key` URL query parameter, and IEEE via
1186 // `apikey` (#430) — neither documents a header
1187 // path. This error string is logged and may
1188 // surface to the user, so strip either spelling
1189 // before it leaves the client. A no-op for every
1190 // other source, none of which puts a secret in
1191 // the query string.
1192 url: redact_api_key_query(&final_url),
1193 });
1194 }
1195
1196 // Content-Length fast-path: if header is present and exceeds
1197 // the cap, fail without reading any body (deterministic — not
1198 // retried). Per `docs/SECURITY.md` §1.2.
1199 if let Some(len) = response.content_length() {
1200 if len > PDF_MAX_BYTES {
1201 return Err(HttpError::OversizedBody {
1202 actual: len,
1203 cap: PDF_MAX_BYTES,
1204 });
1205 }
1206 }
1207
1208 // Stream body and enforce the cap as bytes accumulate. A
1209 // mid-stream transport error is transient (retry); an
1210 // oversized body is deterministic (return).
1211 let mut buf = BytesMut::new();
1212 let mut stream = response.bytes_stream();
1213 let mut oversized_at: Option<u64> = None;
1214 let mut stream_err: Option<reqwest::Error> = None;
1215 while let Some(chunk) = stream.next().await {
1216 let chunk = match chunk {
1217 Ok(c) => c,
1218 Err(e) => {
1219 stream_err = Some(e);
1220 break;
1221 }
1222 };
1223 let projected = (buf.len() as u64).saturating_add(chunk.len() as u64);
1224 if projected > PDF_MAX_BYTES {
1225 oversized_at = Some(projected);
1226 break;
1227 }
1228 buf.extend_from_slice(&chunk);
1229 }
1230 if let Some(actual) = oversized_at {
1231 return Err(HttpError::OversizedBody {
1232 actual,
1233 cap: PDF_MAX_BYTES,
1234 });
1235 }
1236 if let Some(e) = stream_err {
1237 if attempt < MAX_FETCH_RETRIES && reqwest_is_transient(&e) {
1238 let d = backoff_delay(attempt);
1239 tracing::warn!(
1240 source,
1241 attempt,
1242 delay_ms = d.as_millis() as u64,
1243 error = %e,
1244 "transient mid-stream failure; retrying"
1245 );
1246 tokio::time::sleep(d).await;
1247 attempt += 1;
1248 continue;
1249 }
1250 return Err(HttpError::Network(e));
1251 }
1252 let body = buf.freeze();
1253
1254 if check_pdf_magic {
1255 let mut got = [0u8; 5];
1256 let n = body.len().min(5);
1257 got[..n].copy_from_slice(&body[..n]);
1258 if got != PDF_MAGIC {
1259 return Err(HttpError::NotAPdf { got });
1260 }
1261 }
1262
1263 return Ok((body, final_url));
1264 }
1265 }
1266}
1267
1268/// Return `url` rendered as a string with the value of any `api_key`
1269/// query parameter replaced by `REDACTED` (issue #146).
1270///
1271/// Springer Nature's TDM API authenticates **only** via an `api_key`
1272/// query parameter — there is no header-auth path upstream — so the key
1273/// is unavoidably in the request URL. This keeps it out of *our* log
1274/// and error sinks (the `HttpError::HttpStatus` string in particular,
1275/// which is `tracing`-logged and can surface to the user). It is a
1276/// structural no-op for every other source, none of which carry a
1277/// secret in the query string. Other pairs and their order are
1278/// preserved; a URL with no `api_key` pair is rendered unchanged.
1279fn redact_api_key_query(url: &url::Url) -> String {
1280 /// Every spelling a source puts a secret under. Springer uses
1281 /// `api_key`; IEEE (#430) uses `apikey`, one word. A source that
1282 /// invents a third spelling and does not add it here leaks its key
1283 /// into `HttpError::HttpStatus`, which is `tracing`-logged.
1284 const API_KEY_PARAMS: &[&str] = &["api_key", "apikey"];
1285 let is_secret = |k: &str| API_KEY_PARAMS.contains(&k);
1286 if url.query_pairs().all(|(k, _)| !is_secret(&k)) {
1287 return url.to_string();
1288 }
1289 let mut redacted = url.clone();
1290 let pairs: Vec<(String, String)> = url
1291 .query_pairs()
1292 .map(|(k, v)| {
1293 if is_secret(&k) {
1294 (k.into_owned(), "REDACTED".to_string())
1295 } else {
1296 (k.into_owned(), v.into_owned())
1297 }
1298 })
1299 .collect();
1300 redacted.query_pairs_mut().clear().extend_pairs(pairs);
1301 redacted.to_string()
1302}
1303
1304/// Test-oriented [`HttpClient`] constructor. Originally `cfg(test)`; now
1305/// also reachable from the `doiget-cli` orchestrator's integration tests
1306/// (which live outside this crate and therefore cannot see `cfg(test)`-gated
1307/// items). The constructor name retains its `for_tests_allow_http` signal —
1308/// production code MUST use [`HttpClient::new`] with [`tier_1_allowlist`].
1309#[allow(clippy::expect_used)]
1310impl HttpClient {
1311 /// Build a test-oriented `HttpClient` against an `http://` wiremock
1312 /// origin. The redirect closure still rejects insecure schemes — we only
1313 /// relax `https_only` at the connection level so wiremock can serve.
1314 /// This is acceptable because the redirect closure (which is the
1315 /// security-load-bearing path) is exercised by the
1316 /// `redirect_to_http_is_rejected_by_closure` test below.
1317 ///
1318 /// Production callers MUST use [`HttpClient::new`] with
1319 /// [`tier_1_allowlist`] — the `for_tests_allow_http` suffix is the load-
1320 /// bearing signal that this constructor lifts the initial-leg HTTPS-only
1321 /// requirement.
1322 pub fn new_for_tests_allow_http(source: &str, allowlist_host: &str) -> Self {
1323 let allowlist = SourceAllowlist::new(source, vec![allowlist_host.to_string()]);
1324 let client = build_client_allow_http(allowlist.clone()).expect("test client builds");
1325 let mut map = HashMap::new();
1326 let mut allowlist_map = HashMap::new();
1327 allowlist_map.insert(allowlist.source.clone(), allowlist.clone());
1328 map.insert(allowlist.source.clone(), client);
1329 Self {
1330 clients: Arc::new(map),
1331 allowlists: Arc::new(allowlist_map),
1332 }
1333 }
1334
1335 /// Multi-source variant of [`HttpClient::new_for_tests_allow_http`].
1336 ///
1337 /// Builds a relaxed-`https_only` client per `(source, allowlist_host)`
1338 /// pair. Used by the `doiget-cli` orchestrator's integration tests when
1339 /// more than one upstream needs to be wiremocked simultaneously
1340 /// (e.g. Crossref + Unpaywall against two different mock servers).
1341 /// Production callers MUST use [`HttpClient::new`] with
1342 /// [`tier_1_allowlist`].
1343 pub fn new_for_tests_allow_http_multi(entries: &[(&str, &str)]) -> Self {
1344 let mut map = HashMap::with_capacity(entries.len());
1345 let mut allowlist_map = HashMap::with_capacity(entries.len());
1346 for (source, host) in entries {
1347 let allowlist = SourceAllowlist::new(*source, vec![host.to_string()]);
1348 let client = build_client_allow_http(allowlist.clone()).expect("test client builds");
1349 allowlist_map.insert(allowlist.source.clone(), allowlist.clone());
1350 map.insert(allowlist.source.clone(), client);
1351 }
1352 Self {
1353 clients: Arc::new(map),
1354 allowlists: Arc::new(allowlist_map),
1355 }
1356 }
1357}
1358
1359fn build_client_allow_http(allowlist: SourceAllowlist) -> Result<Client, reqwest::Error> {
1360 ensure_crypto_provider();
1361 let allowlist_for_closure = allowlist.clone();
1362 let redirect_policy = Policy::custom(move |attempt| {
1363 let scheme = attempt.url().scheme().to_string();
1364 let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
1365 let prev_count = attempt.previous().len();
1366 if scheme != "https" {
1367 return attempt.error(HttpError::InsecureRedirect { scheme });
1368 }
1369 if prev_count >= MAX_REDIRECTS {
1370 return attempt.stop();
1371 }
1372 let host = match host_opt {
1373 Some(h) => h,
1374 None => {
1375 return attempt.error(HttpError::RedirectDenied {
1376 source_key: allowlist_for_closure.source.clone(),
1377 host: String::new(),
1378 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1379 });
1380 }
1381 };
1382 if !allowlist_for_closure.permits(&host) {
1383 return attempt.error(HttpError::RedirectDenied {
1384 source_key: allowlist_for_closure.source.clone(),
1385 host,
1386 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1387 });
1388 }
1389 attempt.follow()
1390 });
1391 ClientBuilder::new()
1392 // `https_only(false)` only at this scope — production builders
1393 // (the public `HttpClient::new`) keep it on.
1394 .https_only(false)
1395 .redirect(redirect_policy)
1396 .connect_timeout(CONNECT_TIMEOUT)
1397 .timeout(TOTAL_TIMEOUT)
1398 .read_timeout(READ_TIMEOUT)
1399 .user_agent(format!(
1400 "doiget/{} (+https://github.com/QAtlasHub/doiget)",
1401 VERSION
1402 ))
1403 .tls_backend_rustls()
1404 .build()
1405}
1406
1407// ---------------------------------------------------------------------------
1408// ClientBuilder helpers
1409// ---------------------------------------------------------------------------
1410
1411/// Install the `ring` `rustls` crypto provider as the process default,
1412/// exactly once.
1413///
1414/// reqwest is built with the `rustls-no-provider` feature (ADR-0020
1415/// Amendment 1: drop aws-lc-rs so `cargo install` needs no cmake/C
1416/// toolchain and musl-static builds cleanly). With no bundled provider,
1417/// `reqwest::ClientBuilder::build` calls
1418/// `rustls::crypto::CryptoProvider::get_default()` and **panics**
1419/// (`"No provider set"`) unless a process-default provider was installed
1420/// first. Every client constructor below calls this; the `Once` makes it
1421/// safe to invoke from many sites and from concurrent tests.
1422fn ensure_crypto_provider() {
1423 static INIT: Once = Once::new();
1424 INIT.call_once(|| {
1425 // `install_default` errors only if a provider is already set;
1426 // under `Once` that is unreachable, but ignore it rather than
1427 // panic (another linked crate could have installed one first).
1428 let _ = rustls::crypto::ring::default_provider().install_default();
1429 });
1430}
1431
1432/// Public entry point for callers that build their own `reqwest::Client`
1433/// outside of [`HttpClient`] and need the process-default TLS provider
1434/// installed first (ADR-0020 Amendment 1).
1435///
1436/// Safe to call multiple times; the underlying `Once` makes it idempotent.
1437pub fn init_tls() {
1438 ensure_crypto_provider();
1439}
1440
1441/// Upgrade an `http://` URL to `https://` for legacy publisher
1442/// metadata. Loopback hosts (`localhost`, any RFC 6761 `.localhost`
1443/// TLD subdomain, `127.0.0.0/8`, `::1`, IPv4-mapped IPv6 loopback)
1444/// are returned unchanged so the `new_for_tests_allow_http*` wiremock
1445/// path continues to talk plain HTTP to the local fixture server.
1446///
1447/// Non-`http` schemes (`https`, `file`, anything else) and cannot-be-
1448/// base URLs are returned unchanged. The function is total: it never
1449/// panics and never returns an error.
1450///
1451/// # Audit / posture
1452///
1453/// On a successful upgrade the function emits a `tracing::info!` event
1454/// so the rewrite appears in the operator's default-level structured
1455/// log. On the (in-practice unreachable) `set_scheme` failure path a
1456/// `tracing::warn!` event is emitted before returning the original
1457/// URL; the production client's `https_only(true)` then rejects the
1458/// send with a clear network error, preserving the TLS posture
1459/// established by ADR-0020.
1460///
1461/// # `Domain("localhost")` arm subtlety
1462///
1463/// The url crate resolves the bare host `localhost` to `127.0.0.1`
1464/// (Ipv4 variant) when parsing an `http://` URL, so the `Domain` arm
1465/// does NOT fire for that case (the `Ipv4` arm catches it). The arm
1466/// IS load-bearing for the RFC 6761 `.localhost` TLD (e.g.
1467/// `myservice.localhost`, `api.localhost`), which the url crate does
1468/// NOT auto-resolve to an IP and keeps as `Host::Domain`.
1469fn upgrade_http_to_https(url: Url) -> Url {
1470 if url.scheme() != "http" {
1471 return url;
1472 }
1473 match url.host() {
1474 None => {
1475 // Cannot-be-base URL (e.g. `http:foo`) — `set_scheme`
1476 // would reject the conversion.
1477 return url;
1478 }
1479 Some(url::Host::Domain(d)) if is_localhost_domain(d) => return url,
1480 Some(url::Host::Ipv4(ip)) if ip.is_loopback() => return url,
1481 Some(url::Host::Ipv6(ip)) if is_ipv6_loopback(ip) => return url,
1482 Some(_) => {}
1483 }
1484 let mut upgraded = url.clone();
1485 if upgraded.set_scheme("https").is_err() {
1486 // url-crate `set_scheme` is documented to fail only for
1487 // cannot-be-base URLs and a few cross-family transitions;
1488 // `http -> https` is supported because both are "special"
1489 // schemes. The fallback below is defence-in-depth.
1490 tracing::warn!(
1491 url = %url,
1492 "set_scheme(http -> https) failed unexpectedly; \
1493 sending original URL — https_only(true) will reject",
1494 );
1495 return url;
1496 }
1497 tracing::info!(
1498 original = %url,
1499 upgraded = %upgraded,
1500 "upgraded http -> https for legacy publisher metadata"
1501 );
1502 upgraded
1503}
1504
1505/// `true` for the `localhost` literal and any RFC 6761 `.localhost`
1506/// TLD subdomain (`myservice.localhost`, `api.localhost`, etc.).
1507/// ASCII-case-insensitive per host-name conventions.
1508fn is_localhost_domain(d: &str) -> bool {
1509 if d.eq_ignore_ascii_case("localhost") {
1510 return true;
1511 }
1512 let suffix = ".localhost";
1513 let d_bytes = d.as_bytes();
1514 let s_bytes = suffix.as_bytes();
1515 if d_bytes.len() <= s_bytes.len() {
1516 return false;
1517 }
1518 let tail = &d_bytes[d_bytes.len() - s_bytes.len()..];
1519 tail.eq_ignore_ascii_case(s_bytes)
1520}
1521
1522/// `true` for `::1` and any IPv4-mapped loopback
1523/// (`::ffff:127.0.0.0/8`). `Ipv6Addr::is_loopback()` covers only `::1`,
1524/// so dual-stack callers that hit `[::ffff:127.0.0.1]` would otherwise
1525/// be silently upgraded.
1526fn is_ipv6_loopback(ip: std::net::Ipv6Addr) -> bool {
1527 if ip.is_loopback() {
1528 return true;
1529 }
1530 matches!(ip.to_ipv4_mapped(), Some(v4) if v4.is_loopback())
1531}
1532
1533fn build_client(allowlist: SourceAllowlist, ua: &str) -> Result<Client, reqwest::Error> {
1534 ensure_crypto_provider();
1535
1536 let user_agent = ua.to_string();
1537
1538 // Redirect policy: capture the per-source allowlist by value. The
1539 // closure is called for every redirect hop — there is no global
1540 // fallback, every hop is checked. Hard cap at MAX_REDIRECTS via the
1541 // attempt counter (mirrors reqwest's built-in limit).
1542 let allowlist_for_closure = allowlist.clone();
1543 let redirect_policy = Policy::custom(move |attempt| {
1544 // Inspect the candidate URL via owned copies so we can move
1545 // `attempt` into `error()` / `follow()` / `stop()` later without
1546 // the borrow checker complaining about an outstanding borrow of
1547 // `attempt`.
1548 let scheme = attempt.url().scheme().to_string();
1549 let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
1550 let prev_count = attempt.previous().len();
1551
1552 // 1. Reject non-HTTPS up front. The `https_only(true)` builder
1553 // flag below also catches this, but we want the dedicated
1554 // `InsecureRedirect` error path (not a generic `https_only`
1555 // abort) — see `docs/SECURITY.md` §1.3.
1556 if scheme != "https" {
1557 return attempt.error(HttpError::InsecureRedirect { scheme });
1558 }
1559
1560 // 2. Hop limit (`docs/SECURITY.md` §1.3 redirect_limit row).
1561 if prev_count >= MAX_REDIRECTS {
1562 return attempt.stop();
1563 }
1564
1565 // 3. Allowlist check on the candidate target host.
1566 // `host_str()` is `None` for URLs without a host (e.g. data
1567 // URIs); treat that as an allowlist miss.
1568 let host = match host_opt {
1569 Some(h) => h,
1570 None => {
1571 return attempt.error(HttpError::RedirectDenied {
1572 source_key: allowlist_for_closure.source.clone(),
1573 host: String::new(),
1574 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1575 });
1576 }
1577 };
1578 if !allowlist_for_closure.permits(&host) {
1579 return attempt.error(HttpError::RedirectDenied {
1580 source_key: allowlist_for_closure.source.clone(),
1581 host,
1582 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1583 });
1584 }
1585
1586 attempt.follow()
1587 });
1588
1589 ClientBuilder::new()
1590 .https_only(true)
1591 .redirect(redirect_policy)
1592 .connect_timeout(CONNECT_TIMEOUT)
1593 .timeout(TOTAL_TIMEOUT)
1594 .read_timeout(READ_TIMEOUT)
1595 .user_agent(user_agent)
1596 // `tls_backend_rustls()` is the non-deprecated equivalent of the
1597 // older `use_rustls_tls()`. The workspace pins reqwest with
1598 // `rustls-no-provider` (ADR-0020 Amendment 1), so this is a
1599 // re-assertion at builder level rather than a feature switch; the
1600 // `ring` provider installed by `ensure_crypto_provider()` above
1601 // is what reqwest picks up via `CryptoProvider::get_default()`.
1602 .tls_backend_rustls()
1603 .build()
1604}
1605
1606// ---------------------------------------------------------------------------
1607// Tests
1608// ---------------------------------------------------------------------------
1609
1610#[cfg(test)]
1611#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1612mod tests {
1613 use super::*;
1614
1615 /// Every Tier-2 `Source` MUST have a transport allowlist entry under
1616 /// its own `name()`, or `HttpClient::fetch_bytes` rejects it with
1617 /// `UnknownSource` in production.
1618 ///
1619 /// This was not hypothetical: #414 shipped `DataCiteSource` with no
1620 /// `tier_2_allowlist` entry. Every unit test passed because they build
1621 /// their client with `new_for_tests_allow_http("datacite", ..)`, which
1622 /// registers the key itself — so the tests could not see the gap, and
1623 /// only a real fetch would have. Enumerating the names here means
1624 /// adding a source without its allowlist entry fails at `cargo test`.
1625 /// #442 sibling of the Tier-2 guard. A source with no allowlist entry
1626 /// fails `UnknownSource` in production while every unit test passes,
1627 /// because `new_for_tests_allow_http` registers the key itself — the
1628 /// DataCite near-miss in 0.8.8. Now that Tier 3 is actually reached,
1629 /// the same trap applies to it.
1630 ///
1631 /// **This guard is necessary and not sufficient**, and #454 is the
1632 /// proof: it asserts the builder *returns* the key, which stayed true
1633 /// for three releases while no client was ever handed the list, so a
1634 /// production fetch died at exactly the `UnknownSource` described
1635 /// above. The sufficient half is
1636 /// `the_production_client_registers_every_tier_3_source_key`, in
1637 /// `doiget-cli` and `doiget-mcp` — it asserts the client, which is the
1638 /// object the fetch goes through. Keep both: this one localises the
1639 /// failure to the list, that one catches the list never arriving.
1640 #[cfg(any(
1641 feature = "tdm-aps",
1642 feature = "tdm-elsevier",
1643 feature = "tdm-springer",
1644 feature = "tdm-ieee"
1645 ))]
1646 #[test]
1647 fn every_tier_3_source_has_a_transport_allowlist_entry() {
1648 use crate::source::Source as _;
1649 let mut checked = 0_usize;
1650
1651 #[cfg(feature = "tdm-aps")]
1652 {
1653 let src = crate::sources::tdm_aps::TdmApsSource::new();
1654 let reg: Vec<String> = tier_3_aps_allowlist()
1655 .iter()
1656 .map(|a| a.source.clone())
1657 .collect();
1658 assert!(
1659 reg.iter().any(|r| r == src.name()),
1660 "source `{}` has no tier_3_aps_allowlist entry; a production fetch would fail \
1661 UnknownSource. registered: {reg:?}",
1662 src.name()
1663 );
1664 checked += 1;
1665 }
1666 #[cfg(feature = "tdm-elsevier")]
1667 {
1668 let src = crate::sources::tdm_elsevier::TdmElsevierSource::new();
1669 let reg: Vec<String> = tier_3_elsevier_allowlist()
1670 .iter()
1671 .map(|a| a.source.clone())
1672 .collect();
1673 assert!(
1674 reg.iter().any(|r| r == src.name()),
1675 "source `{}` has no tier_3_elsevier_allowlist entry; a production fetch would \
1676 fail UnknownSource. registered: {reg:?}",
1677 src.name()
1678 );
1679 checked += 1;
1680 }
1681 #[cfg(feature = "tdm-springer")]
1682 {
1683 let src = crate::sources::tdm_springer::TdmSpringerSource::new();
1684 let reg: Vec<String> = tier_3_springer_allowlist()
1685 .iter()
1686 .map(|a| a.source.clone())
1687 .collect();
1688 assert!(
1689 reg.iter().any(|r| r == src.name()),
1690 "source `{}` has no tier_3_springer_allowlist entry; a production fetch would \
1691 fail UnknownSource. registered: {reg:?}",
1692 src.name()
1693 );
1694 checked += 1;
1695 }
1696 #[cfg(feature = "tdm-ieee")]
1697 {
1698 let src = crate::sources::tdm_ieee::TdmIeeeSource::new();
1699 let reg: Vec<String> = tier_3_ieee_allowlist()
1700 .iter()
1701 .map(|a| a.source.clone())
1702 .collect();
1703 assert!(
1704 reg.iter().any(|r| r == src.name()),
1705 "source `{}` has no tier_3_ieee_allowlist entry; a production fetch would \
1706 fail UnknownSource. registered: {reg:?}",
1707 src.name()
1708 );
1709 checked += 1;
1710 }
1711
1712 assert!(checked > 0, "the guard must have checked something");
1713 }
1714
1715 #[test]
1716 #[cfg(feature = "metadata")]
1717 fn every_tier_2_source_has_a_transport_allowlist_entry() {
1718 use crate::source::Source as _;
1719 // Bind first: `name()` borrows from the source, so the values must
1720 // outlive the collection.
1721 let openalex = crate::sources::openalex::OpenalexSource::new(String::new());
1722 let s2 = crate::sources::s2::S2Source::new(None);
1723 let doaj = crate::sources::doaj::DoajSource::new();
1724 let datacite = crate::sources::datacite::DataCiteSource::new();
1725 let hal = crate::sources::hal::HalSource::new();
1726 let openaire = crate::sources::openaire::OpenAireSource::new();
1727 let core = crate::sources::core_oa::CoreSource::new();
1728 let epmc = crate::sources::europepmc::EuropePmcSource::new();
1729 let names: Vec<&str> = vec![
1730 openalex.name(),
1731 s2.name(),
1732 doaj.name(),
1733 datacite.name(),
1734 hal.name(),
1735 openaire.name(),
1736 core.name(),
1737 epmc.name(),
1738 ];
1739 let registered: Vec<String> = tier_2_allowlist()
1740 .iter()
1741 .map(|a| a.source.clone())
1742 .collect();
1743 for n in names {
1744 assert!(
1745 registered.iter().any(|r| r == n),
1746 "source `{n}` has no tier_2_allowlist entry; a production fetch would fail \
1747 UnknownSource. registered: {registered:?}"
1748 );
1749 }
1750 }
1751
1752 /// ADR-0037: `doaj.org` must be reachable on the `oa-publisher` key with
1753 /// NO config file and NO feature flags — that is the whole point of
1754 /// promoting it. Pinned on the apex specifically: a single-suffix
1755 /// wildcard does not match an apex, and the redirect that motivated
1756 /// #405 (10.1109/access.2024.3495502, IEEE Access gold OA) targeted the
1757 /// bare apex.
1758 /// #533, reproduced against the REAL allowlist rather than a fixture.
1759 ///
1760 /// Harada & Kato 2024 is gold OA, cc-by, and Unpaywall's
1761 /// `best_oa_location.url` for it is literally `https://doi.org/10.1002/
1762 /// pcn5.205` with no `url_for_pdf` (verified against the live API,
1763 /// 2026-08-30). The chain was refused at that first hop -- while
1764 /// `*.wiley.com`, where it lands, was already on the list.
1765 #[test]
1766 fn a_doi_resolver_hop_is_permitted_without_being_allowlisted() {
1767 let lists = oa_publisher_allowlist();
1768 let oa = lists
1769 .iter()
1770 .find(|a| a.source == "oa-publisher")
1771 .expect("oa-publisher entry");
1772
1773 // The two questions stay distinct: a resolver is permitted, but it is
1774 // NOT on the list and must never be reported as if it were.
1775 assert!(oa.permits("doi.org"), "the hop the report was refused at");
1776 assert!(
1777 !oa.matches("doi.org"),
1778 "a resolver must not be ON the allowlist -- `expected_hosts` is \
1779 shown to the user as what to trust, and doi.org is every DOI"
1780 );
1781 assert!(
1782 !oa.redirect_hosts.iter().any(|h| h.contains("doi.org")),
1783 "and it must not leak into the expected-host list: {:?}",
1784 oa.redirect_hosts
1785 );
1786
1787 // The host the chain actually lands on was trusted all along. This is
1788 // what makes the refusal a layer error rather than a missing entry.
1789 assert!(
1790 oa.permits("onlinelibrary.wiley.com"),
1791 "the publisher was already covered by *.wiley.com: {:?}",
1792 oa.redirect_hosts
1793 );
1794
1795 // And the gate still gates.
1796 assert!(!oa.permits("evil.example.com"));
1797 }
1798
1799 /// Exact hosts, no wildcards. `*.doi.org` would sweep in `www.doi.org`,
1800 /// which is the DOI Foundation's website and not a resolver, plus
1801 /// whatever else is ever stood up there; and the impostor hosts below are
1802 /// precisely what an attacker registers.
1803 #[test]
1804 fn resolver_impostor_hosts_are_not_transparent() {
1805 for host in [
1806 "doi.org",
1807 "dx.doi.org",
1808 "hdl.handle.net",
1809 "DOI.ORG",
1810 "Dx.Doi.Org",
1811 ] {
1812 assert!(is_transparent_resolver(host), "{host} is a resolver");
1813 }
1814 for host in [
1815 "www.doi.org",
1816 "doi.org.evil.test",
1817 "evil-doi.org",
1818 "notdoi.org",
1819 "a.doi.org",
1820 "handle.net",
1821 "",
1822 ] {
1823 assert!(!is_transparent_resolver(host), "{host} is NOT a resolver");
1824 }
1825 }
1826
1827 /// Transparency is a property of the resolver, not of one source: a
1828 /// resolver hop is addressing wherever it appears.
1829 #[test]
1830 fn every_tier_1_source_treats_a_resolver_hop_as_addressing() {
1831 for a in tier_1_allowlist() {
1832 assert!(
1833 a.permits("doi.org"),
1834 "source {} refuses the addressing layer",
1835 a.source
1836 );
1837 assert!(
1838 !a.matches("doi.org"),
1839 "source {} lists a resolver as a content host",
1840 a.source
1841 );
1842 }
1843 }
1844
1845 #[test]
1846 fn doaj_is_on_the_default_oa_publisher_allowlist() {
1847 let lists = oa_publisher_allowlist();
1848 let oa = lists
1849 .iter()
1850 .find(|a| a.source == "oa-publisher")
1851 .expect("oa-publisher entry");
1852 assert!(
1853 oa.matches("doaj.org"),
1854 "apex must match: {:?}",
1855 oa.redirect_hosts
1856 );
1857 assert!(oa.matches("www.doaj.org"), "subdomains must match");
1858 assert!(
1859 !oa.matches("doaj.org.evil.test"),
1860 "suffix confusion must not match"
1861 );
1862 }
1863
1864 /// The `"doaj"` metadata key and the `"oa-publisher"` PDF-redirect key
1865 /// must now agree about DOAJ. Their disagreement was the defect ADR-0037
1866 /// fixed; this pins that they cannot silently drift apart again.
1867 #[test]
1868 fn doaj_metadata_and_oa_publisher_keys_agree() {
1869 let meta = tier_2_allowlist();
1870 let doaj = meta.iter().find(|a| a.source == "doaj").expect("doaj key");
1871 let lists = oa_publisher_allowlist();
1872 let oa = lists
1873 .iter()
1874 .find(|a| a.source == "oa-publisher")
1875 .expect("oa key");
1876 for pat in &doaj.redirect_hosts {
1877 let sample = pat.strip_prefix("*.").unwrap_or(pat);
1878 assert!(
1879 oa.matches(sample),
1880 "{pat} is trusted on the doaj key but not on oa-publisher"
1881 );
1882 }
1883 }
1884 use wiremock::matchers::{method, path};
1885 use wiremock::{Mock, MockServer, ResponseTemplate};
1886
1887 // ---------------------------------------------------------------
1888 // http -> https scheme upgrade (#220) — pure unit tests, no network.
1889 // ---------------------------------------------------------------
1890
1891 #[test]
1892 fn upgrade_http_to_https_rewrites_public_http_url() {
1893 let input = Url::parse("http://link.aps.org/pdf/10.1103/PhysRev.123.456").unwrap();
1894 let out = upgrade_http_to_https(input.clone());
1895 assert_eq!(out.scheme(), "https");
1896 assert_eq!(out.host_str(), Some("link.aps.org"));
1897 assert_eq!(out.path(), "/pdf/10.1103/PhysRev.123.456");
1898 }
1899
1900 #[test]
1901 fn upgrade_http_to_https_preserves_port_path_query_fragment() {
1902 let input = Url::parse("http://example.org:8080/a/b?q=1#frag").unwrap();
1903 let out = upgrade_http_to_https(input);
1904 assert_eq!(out.as_str(), "https://example.org:8080/a/b?q=1#frag");
1905 }
1906
1907 #[test]
1908 fn upgrade_http_to_https_is_idempotent_on_https() {
1909 let input = Url::parse("https://api.crossref.org/works/10.1234/foo").unwrap();
1910 let out = upgrade_http_to_https(input.clone());
1911 assert_eq!(out, input);
1912 }
1913
1914 #[test]
1915 fn upgrade_http_to_https_skips_localhost() {
1916 // wiremock binds to `127.0.0.1:PORT`; the loopback exception
1917 // is the load-bearing rule that keeps `new_for_tests_allow_http*`
1918 // working alongside the production fetch path.
1919 let input = Url::parse("http://localhost:7878/pdf").unwrap();
1920 let out = upgrade_http_to_https(input.clone());
1921 assert_eq!(out, input, "localhost MUST NOT be upgraded");
1922 }
1923
1924 #[test]
1925 fn upgrade_http_to_https_skips_127_loopback_block() {
1926 for host in ["127.0.0.1", "127.0.0.42", "127.255.255.254"] {
1927 let raw = format!("http://{host}:1234/x");
1928 let input = Url::parse(&raw).unwrap();
1929 let out = upgrade_http_to_https(input.clone());
1930 assert_eq!(out, input, "host `{host}` MUST NOT be upgraded");
1931 }
1932 }
1933
1934 #[test]
1935 fn upgrade_http_to_https_skips_ipv6_loopback() {
1936 let input = Url::parse("http://[::1]:9000/path").unwrap();
1937 let out = upgrade_http_to_https(input.clone());
1938 assert_eq!(out, input, "IPv6 loopback MUST NOT be upgraded");
1939 }
1940
1941 #[test]
1942 fn upgrade_http_to_https_preserves_case_in_path() {
1943 // Some publishers (e.g. APS legacy redirects) use mixed-case
1944 // path segments; upgrade must NOT lowercase or canonicalise.
1945 let input = Url::parse("http://link.aps.org/PDF/10.1103/PhysRevB.109.045136").unwrap();
1946 let out = upgrade_http_to_https(input);
1947 assert_eq!(out.path(), "/PDF/10.1103/PhysRevB.109.045136");
1948 }
1949
1950 // ---- Review-pass extensions ------------------------------------
1951
1952 #[test]
1953 fn upgrade_http_to_https_skips_dot_localhost_tld() {
1954 // RFC 6761 reserves the entire `.localhost` TLD for loopback.
1955 // A developer running `http://myservice.localhost:8080/` MUST
1956 // NOT see their URL silently upgraded to https.
1957 for raw in [
1958 "http://myservice.localhost/",
1959 "http://api.localhost:8080/x",
1960 "http://a.b.LOCALHOST/y",
1961 ] {
1962 let input = Url::parse(raw).unwrap();
1963 let out = upgrade_http_to_https(input.clone());
1964 assert_eq!(out, input, "{raw} MUST NOT be upgraded");
1965 }
1966 }
1967
1968 #[test]
1969 fn upgrade_http_to_https_skips_ipv4_mapped_ipv6_loopback() {
1970 // `::ffff:127.0.0.1` is the IPv4-mapped IPv6 form of 127.0.0.1.
1971 // `Ipv6Addr::is_loopback()` alone returns false for this form,
1972 // so dual-stack callers binding wiremock to it would be
1973 // silently upgraded without the `to_ipv4_mapped()` check.
1974 for raw in [
1975 "http://[::ffff:127.0.0.1]:9000/x",
1976 "http://[::ffff:127.0.0.42]/y",
1977 ] {
1978 let input = Url::parse(raw).unwrap();
1979 let out = upgrade_http_to_https(input.clone());
1980 assert_eq!(out, input, "{raw} MUST NOT be upgraded");
1981 }
1982 }
1983
1984 #[test]
1985 fn upgrade_http_to_https_is_noop_on_non_http_schemes() {
1986 // The first guard (`url.scheme() != "http"`) covers everything
1987 // that isn't http: https (idempotent), file, data, ftp...
1988 for raw in [
1989 "https://api.crossref.org/works/10.1234/foo",
1990 "file:///etc/passwd",
1991 "data:text/plain,hello",
1992 "ftp://ftp.example.org/papers/",
1993 ] {
1994 let input = Url::parse(raw).unwrap();
1995 let out = upgrade_http_to_https(input.clone());
1996 assert_eq!(
1997 out, input,
1998 "{raw} non-http scheme MUST be returned unchanged"
1999 );
2000 }
2001 }
2002
2003 #[test]
2004 fn upgrade_http_to_https_http_url_always_has_host() {
2005 // The url crate's parser enforces authority for "special"
2006 // schemes (`http`, `https`, `ws`, `wss`, `ftp`, `file`).
2007 // `Url::parse("http:foo")` synthesises a Domain("foo")
2008 // authority, so an http URL with `host() == None` is
2009 // unreachable from `Url::parse`. The `None` arm in
2010 // `upgrade_http_to_https` is defence-in-depth only — pinned
2011 // here so a future url-crate behavior change is caught.
2012 let url = Url::parse("http:foo").expect("parse");
2013 assert!(
2014 url.host().is_some(),
2015 "http URLs always carry a host per WHATWG URL spec"
2016 );
2017 // The fn still produces a sensible result (upgrade applies).
2018 let out = upgrade_http_to_https(url.clone());
2019 assert_eq!(out.scheme(), "https");
2020 }
2021
2022 #[test]
2023 fn upgrade_http_to_https_skips_localhost_case_insensitive() {
2024 // The literal `localhost` is resolved by the url crate to
2025 // `127.0.0.1` (Ipv4) at parse time for `http://` URLs, so the
2026 // Ipv4 arm catches lowercase. The Domain-arm coverage is
2027 // load-bearing only for the `.localhost` TLD case, but we
2028 // still pin the casefold semantics in case the url crate
2029 // changes its parsing rules.
2030 for raw in ["http://LOCALHOST/", "http://Localhost:8080/x"] {
2031 let input = Url::parse(raw).unwrap();
2032 let out = upgrade_http_to_https(input.clone());
2033 assert_eq!(out, input, "{raw} MUST NOT be upgraded");
2034 }
2035 }
2036
2037 #[test]
2038 fn is_localhost_domain_matches_literal_and_tld_suffix() {
2039 assert!(is_localhost_domain("localhost"));
2040 assert!(is_localhost_domain("LOCALHOST"));
2041 assert!(is_localhost_domain("api.localhost"));
2042 assert!(is_localhost_domain("nested.api.localhost"));
2043 assert!(is_localhost_domain("X.LocalHost"));
2044 assert!(!is_localhost_domain("localhost.example.org"));
2045 assert!(!is_localhost_domain("notlocalhost"));
2046 assert!(!is_localhost_domain(""));
2047 assert!(!is_localhost_domain(".localhost")); // empty label not valid
2048 }
2049
2050 #[test]
2051 fn is_ipv6_loopback_covers_both_pure_and_mapped() {
2052 use std::net::Ipv6Addr;
2053 assert!(is_ipv6_loopback(Ipv6Addr::LOCALHOST)); // ::1
2054 assert!(is_ipv6_loopback("::ffff:127.0.0.1".parse().unwrap()));
2055 assert!(is_ipv6_loopback("::ffff:127.0.0.42".parse().unwrap()));
2056 assert!(!is_ipv6_loopback("::".parse().unwrap()));
2057 assert!(!is_ipv6_loopback("2001:db8::1".parse().unwrap()));
2058 // IPv4-mapped non-loopback must NOT be considered loopback.
2059 assert!(!is_ipv6_loopback("::ffff:1.2.3.4".parse().unwrap()));
2060 }
2061
2062 // ---------------------------------------------------------------
2063 // Allowlist matching — pure unit tests, no network.
2064 // ---------------------------------------------------------------
2065
2066 #[test]
2067 fn tier_1_allowlist_includes_crossref() {
2068 let lists = tier_1_allowlist();
2069 let crossref = lists
2070 .iter()
2071 .find(|a| a.source == "crossref")
2072 .expect("crossref entry");
2073 assert!(
2074 crossref
2075 .redirect_hosts
2076 .iter()
2077 .any(|h| h.contains("crossref.org")),
2078 "crossref allowlist must contain a crossref.org pattern; got {:?}",
2079 crossref.redirect_hosts,
2080 );
2081 }
2082
2083 #[test]
2084 fn tier_1_allowlist_includes_unpaywall_and_arxiv() {
2085 let lists = tier_1_allowlist();
2086 assert!(lists.iter().any(|a| a.source == "unpaywall"));
2087 assert!(lists.iter().any(|a| a.source == "arxiv"));
2088 }
2089
2090 #[test]
2091 fn fulltext_allowlist_registers_ar5iv_host_under_distinct_key() {
2092 // ADR-0032 D3: the ar5iv renderer is registered under its own
2093 // `"ar5iv"` source key (not `"arxiv"`) so provenance distinguishes
2094 // full-text HTML from the arXiv PDF/Atom API.
2095 let lists = fulltext_allowlist();
2096 assert_eq!(lists.len(), 1, "exactly one full-text source entry");
2097 let ar5iv = &lists[0];
2098 assert_eq!(ar5iv.source, "ar5iv");
2099 assert!(ar5iv.matches("ar5iv.labs.arxiv.org"));
2100 // It is also an arXiv subdomain — the existing `*.arxiv.org` glob
2101 // already covers the host, so no new registrable domain is added.
2102 let arxiv = tier_1_allowlist()
2103 .into_iter()
2104 .find(|a| a.source == "arxiv")
2105 .expect("arxiv entry");
2106 assert!(
2107 arxiv.matches("ar5iv.labs.arxiv.org"),
2108 "ar5iv host must fall under the existing *.arxiv.org surface"
2109 );
2110 }
2111
2112 #[test]
2113 fn oa_publisher_allowlist_groups_under_one_synthetic_source() {
2114 // The OA-publisher fan-out from Unpaywall's `best_oa_location.url`
2115 // is keyed under a single synthetic `"oa-publisher"` source so the
2116 // orchestrator can pass that one source key to
2117 // `HttpClient::fetch_pdf`. See `docs/REDIRECT_ALLOWLIST.md` §3 (the
2118 // informed-best-effort note) and the function-level docs in
2119 // [`oa_publisher_allowlist`].
2120 let lists = oa_publisher_allowlist();
2121 assert_eq!(lists.len(), 1, "exactly one synthetic source entry");
2122 assert_eq!(lists[0].source, "oa-publisher");
2123 }
2124
2125 #[test]
2126 fn oa_publisher_allowlist_matches_known_oa_hosts() {
2127 let lists = oa_publisher_allowlist();
2128 let oa = lists
2129 .iter()
2130 .find(|a| a.source == "oa-publisher")
2131 .expect("oa-publisher entry");
2132 // Spot-check a representative entry per host family.
2133 assert!(oa.matches("link.springer.com"));
2134 assert!(oa.matches("nature.com"));
2135 assert!(oa.matches("onlinelibrary.wiley.com"));
2136 assert!(oa.matches("www.frontiersin.org"));
2137 assert!(oa.matches("www.mdpi.com"));
2138 assert!(oa.matches("journals.plos.org"));
2139 assert!(oa.matches("www.biorxiv.org"));
2140 assert!(oa.matches("europepmc.org"));
2141 assert!(oa.matches("www.ncbi.nlm.nih.gov"));
2142 assert!(oa.matches("arxiv.org"));
2143 // #193: physics-society / diamond-OA hosts (empirically observed
2144 // as Unpaywall best_oa_location targets in the dogfood run).
2145 assert!(oa.matches("link.aps.org"));
2146 assert!(oa.matches("journals.aps.org"));
2147 assert!(oa.matches("scipost.org"));
2148 assert!(oa.matches("www.scipost.org"));
2149 assert!(oa.matches("iopscience.iop.org"));
2150 // Document intent of the `*.<suffix>` form: per
2151 // `REDIRECT_ALLOWLIST.md` §2.2 rule 3 it matches the bare
2152 // registrable domain AND any subdomain. Unpaywall has not been
2153 // observed returning bare-domain PDF URLs for these publishers,
2154 // but accepting them is consistent with every other `*.` entry in
2155 // this list (e.g. `arxiv.org` matched by `*.arxiv.org`) and is
2156 // what the matching rule already implements.
2157 assert!(oa.matches("aps.org"));
2158 assert!(oa.matches("iop.org"));
2159 // Multi-level subdomains also match (e.g. SciPost's deep paths);
2160 // documents the wildcard scope rather than testing a known URL.
2161 assert!(oa.matches("submissions.scipost.org"));
2162 // Negative: an attacker host is not covered.
2163 assert!(!oa.matches("attacker.test"));
2164 // Negative: dot-boundary safety for the new entries — a different
2165 // suffix that merely ends with the registrable name must NOT match.
2166 assert!(!oa.matches("notaps.org"));
2167 assert!(!oa.matches("evilscipost.org"));
2168 assert!(!oa.matches("notiop.org"));
2169 // Negative: dot-boundary safety — `*.springer.com` must not match
2170 // `notspringer.com`.
2171 assert!(!oa.matches("notspringer.com"));
2172 }
2173
2174 #[test]
2175 fn allowlist_matches_exact_fqdn() {
2176 let a = SourceAllowlist::new("crossref", vec!["api.crossref.org".to_string()]);
2177 assert!(a.matches("api.crossref.org"));
2178 assert!(!a.matches("crossref.org"));
2179 assert!(!a.matches("xapi.crossref.org"));
2180 }
2181
2182 #[test]
2183 fn allowlist_matches_subdomain_glob() {
2184 // Per docs/REDIRECT_ALLOWLIST.md §2.2 rule 3: `*.<suffix>`
2185 // matches both `<suffix>` itself AND any `*.<suffix>` subdomain,
2186 // but never matches a different suffix that happens to end with
2187 // `<suffix>` without a dot boundary.
2188 let a = SourceAllowlist::new("crossref", vec!["*.crossref.org".to_string()]);
2189 assert!(a.matches("doi.crossref.org"));
2190 assert!(a.matches("crossref.org"));
2191 assert!(!a.matches("notcrossref.org"));
2192 assert!(!a.matches("crossref.org.attacker.test"));
2193 }
2194
2195 #[test]
2196 fn allowlist_matches_is_case_insensitive() {
2197 let a = SourceAllowlist::new("crossref", vec!["API.crossref.ORG".to_string()]);
2198 assert!(a.matches("api.crossref.org"));
2199 assert!(a.matches("API.CROSSREF.ORG"));
2200 }
2201
2202 #[test]
2203 fn allowlist_with_no_redirect_hosts_matches_nothing() {
2204 // §2.2 rule 5: an empty `redirect_hosts` means "no redirects
2205 // permitted from this source".
2206 let a = SourceAllowlist::new("ghost", Vec::<String>::new());
2207 assert!(!a.matches("anything.test"));
2208 assert!(!a.matches(""));
2209 }
2210
2211 // ---------------------------------------------------------------
2212 // PDF magic-byte handling — tests on the body-parsing path. We
2213 // exercise the magic-byte branch via the public API against a
2214 // wiremock server so the assertion runs through the full
2215 // streaming codepath.
2216 // ---------------------------------------------------------------
2217
2218 /// Build a test-only `HttpClient` against an `http://` wiremock
2219 /// origin.
2220 ///
2221 /// Slice 5 (PR #84 advisory item A4 refactor): this helper now
2222 /// delegates to the public
2223 /// [`HttpClient::new_for_tests_allow_http`] constructor (defined
2224 /// just above the test module) instead of re-implementing the
2225 /// redirect-policy + `https_only(false)` builder. The two
2226 /// implementations had drifted into duplicates — keeping a private
2227 /// re-implementation only meant a future security tweak to the
2228 /// builder would silently leave the tests on a stale path.
2229 fn build_test_client_for_http(source: &str, allowlist_host: &str) -> HttpClient {
2230 HttpClient::new_for_tests_allow_http(source, allowlist_host)
2231 }
2232
2233 #[tokio::test]
2234 async fn pdf_magic_byte_match_succeeds() {
2235 let server = MockServer::start().await;
2236 let body = b"%PDF-1.7\n...some pdf bytes...".to_vec();
2237 Mock::given(method("GET"))
2238 .and(path("/paper.pdf"))
2239 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
2240 .mount(&server)
2241 .await;
2242 let host = server
2243 .uri()
2244 .parse::<Url>()
2245 .unwrap()
2246 .host_str()
2247 .unwrap()
2248 .to_string();
2249 let client = build_test_client_for_http("crossref", &host);
2250 let url: Url = format!("{}/paper.pdf", server.uri()).parse().unwrap();
2251 let (got_body, _final_url) = client.fetch_pdf("crossref", url).await.expect("ok");
2252 assert_eq!(&got_body[..], &body[..]);
2253 }
2254
2255 #[tokio::test]
2256 async fn pdf_magic_byte_mismatch_rejects() {
2257 let server = MockServer::start().await;
2258 Mock::given(method("GET"))
2259 .and(path("/not_a_pdf"))
2260 .respond_with(
2261 ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
2262 )
2263 .mount(&server)
2264 .await;
2265 let host = server
2266 .uri()
2267 .parse::<Url>()
2268 .unwrap()
2269 .host_str()
2270 .unwrap()
2271 .to_string();
2272 let client = build_test_client_for_http("crossref", &host);
2273 let url: Url = format!("{}/not_a_pdf", server.uri()).parse().unwrap();
2274 let err = client
2275 .fetch_pdf("crossref", url)
2276 .await
2277 .expect_err("not pdf");
2278 match err {
2279 HttpError::NotAPdf { got } => {
2280 assert_eq!(&got, b"<html");
2281 }
2282 other => panic!("expected NotAPdf, got {:?}", other),
2283 }
2284 }
2285
2286 #[tokio::test]
2287 async fn fetch_bytes_does_not_check_pdf_magic() {
2288 // The non-PDF path returns the body unchanged regardless of
2289 // magic bytes. This pins the boundary between the JSON/text
2290 // path and the PDF path.
2291 let server = MockServer::start().await;
2292 Mock::given(method("GET"))
2293 .and(path("/data.json"))
2294 .respond_with(
2295 ResponseTemplate::new(200).set_body_bytes(br#"{"hello":"world"}"#.to_vec()),
2296 )
2297 .mount(&server)
2298 .await;
2299 let host = server
2300 .uri()
2301 .parse::<Url>()
2302 .unwrap()
2303 .host_str()
2304 .unwrap()
2305 .to_string();
2306 let client = build_test_client_for_http("crossref", &host);
2307 let url: Url = format!("{}/data.json", server.uri()).parse().unwrap();
2308 let (body, _final_url) = client.fetch_bytes("crossref", url).await.expect("ok");
2309 assert_eq!(&body[..], br#"{"hello":"world"}"#);
2310 }
2311
2312 #[tokio::test]
2313 async fn oversized_body_via_content_length_short_circuits() {
2314 // Wiremock can advertise a `Content-Length` larger than the body
2315 // it actually serves; hyper accepts the mismatch and our
2316 // fast-path check fires before any body bytes are consumed.
2317 let server = MockServer::start().await;
2318 let oversized = PDF_MAX_BYTES + 1;
2319 Mock::given(method("GET"))
2320 .and(path("/huge"))
2321 .respond_with(
2322 ResponseTemplate::new(200)
2323 .insert_header("content-length", oversized.to_string().as_str())
2324 .set_body_bytes(b"%PDF-".to_vec()),
2325 )
2326 .mount(&server)
2327 .await;
2328 let host = server
2329 .uri()
2330 .parse::<Url>()
2331 .unwrap()
2332 .host_str()
2333 .unwrap()
2334 .to_string();
2335 let client = build_test_client_for_http("crossref", &host);
2336 let url: Url = format!("{}/huge", server.uri()).parse().unwrap();
2337 let err = client
2338 .fetch_bytes("crossref", url)
2339 .await
2340 .expect_err("should reject");
2341 match err {
2342 HttpError::OversizedBody { actual, cap } => {
2343 assert!(actual > cap, "actual {} should exceed cap {}", actual, cap);
2344 assert_eq!(cap, PDF_MAX_BYTES);
2345 }
2346 // The mismatched Content-Length may also trip an underlying
2347 // transport error before our fast-path runs. Either outcome
2348 // satisfies the security goal (the transfer was aborted
2349 // without buffering 100 GB), so accept Network here as a
2350 // wiremock idiosyncrasy rather than a contract relaxation.
2351 HttpError::Network(_) => {}
2352 other => panic!("expected OversizedBody or Network, got {:?}", other),
2353 }
2354 }
2355
2356 #[tokio::test]
2357 async fn unknown_source_rejected() {
2358 let client = HttpClient::new(tier_1_allowlist()).expect("client builds");
2359 let url: Url = "https://api.crossref.org/works/10.1234/x".parse().unwrap();
2360 let err = client
2361 .fetch_bytes("not-a-source", url)
2362 .await
2363 .expect_err("unknown source");
2364 match err {
2365 HttpError::UnknownSource { source_key } => {
2366 assert_eq!(source_key, "not-a-source")
2367 }
2368 other => panic!("expected UnknownSource, got {:?}", other),
2369 }
2370 }
2371
2372 #[tokio::test]
2373 async fn http_status_error_surfaces() {
2374 let server = MockServer::start().await;
2375 Mock::given(method("GET"))
2376 .and(path("/missing"))
2377 .respond_with(ResponseTemplate::new(404))
2378 .mount(&server)
2379 .await;
2380 let host = server
2381 .uri()
2382 .parse::<Url>()
2383 .unwrap()
2384 .host_str()
2385 .unwrap()
2386 .to_string();
2387 let client = build_test_client_for_http("crossref", &host);
2388 let url: Url = format!("{}/missing", server.uri()).parse().unwrap();
2389 let err = client.fetch_bytes("crossref", url).await.expect_err("404");
2390 match err {
2391 HttpError::HttpStatus { status, .. } => assert_eq!(status, 404),
2392 other => panic!("expected HttpStatus, got {:?}", other),
2393 }
2394 }
2395
2396 // ---------------------------------------------------------------
2397 // Redirect policy tests — drive the closure via wiremock 30x
2398 // responses pointing at insecure / off-allowlist targets. With
2399 // `https_only(true)` on the production builder the request never
2400 // leaves the initial leg — we run these against the test builder
2401 // (which relaxes `https_only` for the *initial* leg only) so the
2402 // redirect closure is reached and exercised.
2403 // ---------------------------------------------------------------
2404
2405 #[tokio::test]
2406 async fn redirect_to_http_is_rejected_by_closure() {
2407 let server = MockServer::start().await;
2408 Mock::given(method("GET"))
2409 .and(path("/redir"))
2410 .respond_with(
2411 ResponseTemplate::new(302).insert_header("location", "http://attacker.test/file"),
2412 )
2413 .mount(&server)
2414 .await;
2415 let host = server
2416 .uri()
2417 .parse::<Url>()
2418 .unwrap()
2419 .host_str()
2420 .unwrap()
2421 .to_string();
2422 let client = build_test_client_for_http("crossref", &host);
2423 let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
2424 let err = client
2425 .fetch_bytes("crossref", url)
2426 .await
2427 .expect_err("redirect to http rejected");
2428 match err {
2429 HttpError::Network(e) => {
2430 let msg = format!("{:?}", e);
2431 assert!(
2432 msg.contains("InsecureRedirect") || msg.contains("non-HTTPS"),
2433 "expected insecure-redirect signal in error chain, got {}",
2434 msg
2435 );
2436 }
2437 other => panic!("expected Network(InsecureRedirect), got {:?}", other),
2438 }
2439 }
2440
2441 #[tokio::test]
2442 async fn redirect_outside_allowlist_is_rejected_by_closure() {
2443 let server = MockServer::start().await;
2444 Mock::given(method("GET"))
2445 .and(path("/redir"))
2446 .respond_with(
2447 ResponseTemplate::new(302).insert_header("location", "https://attacker.test/file"),
2448 )
2449 .mount(&server)
2450 .await;
2451 let host = server
2452 .uri()
2453 .parse::<Url>()
2454 .unwrap()
2455 .host_str()
2456 .unwrap()
2457 .to_string();
2458 let client = build_test_client_for_http("crossref", &host);
2459 let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
2460 let err = client
2461 .fetch_bytes("crossref", url)
2462 .await
2463 .expect_err("redirect to attacker rejected");
2464 match err {
2465 HttpError::Network(e) => {
2466 let msg = format!("{:?}", e);
2467 assert!(
2468 msg.contains("RedirectDenied") || msg.contains("not in allowlist"),
2469 "expected redirect-denied signal in error chain, got {}",
2470 msg
2471 );
2472 }
2473 other => panic!("expected Network(RedirectDenied), got {:?}", other),
2474 }
2475 }
2476
2477 #[tokio::test]
2478 async fn redirect_to_allowlisted_https_host_is_followed_by_closure() {
2479 // 302 to an https host that IS in the allowlist. The redirect
2480 // dispatch will fail (DNS won't resolve `mirror.allowed.test`)
2481 // but the closure must NOT short-circuit — failure mode is a
2482 // transport error, not InsecureRedirect / RedirectDenied.
2483 let server = MockServer::start().await;
2484 Mock::given(method("GET"))
2485 .and(path("/redir"))
2486 .respond_with(
2487 ResponseTemplate::new(302)
2488 .insert_header("location", "https://mirror.allowed.test/file"),
2489 )
2490 .mount(&server)
2491 .await;
2492 let initial_host = server
2493 .uri()
2494 .parse::<Url>()
2495 .unwrap()
2496 .host_str()
2497 .unwrap()
2498 .to_string();
2499 // Allow the initial host AND the redirect target host.
2500 let allowlist = SourceAllowlist::new(
2501 "crossref",
2502 vec![initial_host.clone(), "*.allowed.test".to_string()],
2503 );
2504 let allowlist_for_closure = allowlist.clone();
2505 let policy = Policy::custom(move |attempt| {
2506 let scheme = attempt.url().scheme().to_string();
2507 let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
2508 if scheme != "https" {
2509 return attempt.error(HttpError::InsecureRedirect { scheme });
2510 }
2511 let h = match host_opt {
2512 Some(h) => h,
2513 None => {
2514 return attempt.error(HttpError::RedirectDenied {
2515 source_key: allowlist_for_closure.source.clone(),
2516 host: String::new(),
2517 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
2518 });
2519 }
2520 };
2521 // `permits`, mirroring production: this closure is a local copy
2522 // of `build_client`'s policy, and a copy that adjudicates
2523 // differently tests something that does not ship (#533).
2524 if !allowlist_for_closure.permits(&h) {
2525 return attempt.error(HttpError::RedirectDenied {
2526 source_key: allowlist_for_closure.source.clone(),
2527 host: h,
2528 expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
2529 });
2530 }
2531 attempt.follow()
2532 });
2533 ensure_crypto_provider();
2534 let raw_client = ClientBuilder::new()
2535 .https_only(false)
2536 .redirect(policy)
2537 .connect_timeout(CONNECT_TIMEOUT)
2538 .timeout(Duration::from_secs(5))
2539 .user_agent("doiget/test")
2540 .tls_backend_rustls()
2541 .build()
2542 .expect("client builds");
2543 let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
2544 let err = raw_client.get(url).send().await.expect_err("DNS fails");
2545 // The error should NOT carry our InsecureRedirect / RedirectDenied
2546 // marker — the closure approved the redirect.
2547 let msg = format!("{:?}", err);
2548 assert!(
2549 !msg.contains("RedirectDenied") && !msg.contains("InsecureRedirect"),
2550 "closure short-circuited an allowed redirect: {}",
2551 msg,
2552 );
2553 }
2554
2555 #[test]
2556 fn http_client_clone_is_cheap() {
2557 // Sanity: cloning shares the inner Arc<HashMap<...>>.
2558 let a = HttpClient::new(tier_1_allowlist()).expect("builds");
2559 let b = a.clone();
2560 assert_eq!(a.clients.len(), b.clients.len());
2561 assert!(Arc::ptr_eq(&a.clients, &b.clients));
2562 }
2563
2564 // ---------------------------------------------------------------
2565 // HttpError -> Option<DenialContext> (ADR-0023 §4 mapping)
2566 // ---------------------------------------------------------------
2567
2568 #[test]
2569 fn denial_from_redirect_denied_carries_attempted_and_expected() {
2570 use crate::{DenialContext, DenialReason};
2571 let e = HttpError::RedirectDenied {
2572 source_key: "crossref".to_string(),
2573 host: "evil.example.com".to_string(),
2574 expected_hosts: vec!["api.crossref.org".to_string(), "*.crossref.org".to_string()],
2575 };
2576 let dc: Option<DenialContext> = (&e).into();
2577 let dc = dc.expect("RedirectDenied -> Some(DenialContext)");
2578 assert_eq!(dc.reason, DenialReason::RedirectNotInAllowlist);
2579 assert_eq!(dc.source.as_deref(), Some("crossref"));
2580 assert_eq!(dc.attempted.as_deref(), Some("evil.example.com"));
2581 assert_eq!(
2582 dc.expected.as_deref(),
2583 Some(&["api.crossref.org".to_string(), "*.crossref.org".to_string()][..])
2584 );
2585 assert!(dc.cap.is_none());
2586 assert!(dc.actual.is_none());
2587 assert!(dc.hop_index.is_none());
2588 }
2589
2590 #[test]
2591 fn denial_from_oversized_body_carries_cap_and_actual() {
2592 use crate::{DenialContext, DenialReason};
2593 let e = HttpError::OversizedBody {
2594 actual: 209_715_200,
2595 cap: PDF_MAX_BYTES,
2596 };
2597 let dc: Option<DenialContext> = (&e).into();
2598 let dc = dc.expect("OversizedBody -> Some(DenialContext)");
2599 assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
2600 assert_eq!(dc.cap, Some(PDF_MAX_BYTES));
2601 assert_eq!(dc.actual, Some(209_715_200));
2602 assert!(dc.source.is_none());
2603 assert!(dc.attempted.is_none());
2604 // OversizedBody has no allowlist channel: producer leaves
2605 // `expected` at `None` (NOT `Some(vec![])`). See the field doc on
2606 // `DenialContext::expected` for the disambiguation.
2607 assert!(dc.expected.is_none());
2608 }
2609
2610 #[test]
2611 fn denial_from_not_a_pdf_hex_encodes_got_bytes() {
2612 use crate::{DenialContext, DenialReason};
2613 // First 5 bytes of "<html" — what the magic-byte check sees when
2614 // a publisher returns an HTML interstitial instead of a PDF.
2615 let e = HttpError::NotAPdf {
2616 got: [0x3c, 0x68, 0x74, 0x6d, 0x6c],
2617 };
2618 let dc: Option<DenialContext> = (&e).into();
2619 let dc = dc.expect("NotAPdf -> Some(DenialContext)");
2620 assert_eq!(dc.reason, DenialReason::ContentTypeMismatch);
2621 assert_eq!(dc.attempted.as_deref(), Some("3c68746d6c"));
2622 assert_eq!(dc.expected.as_deref(), Some(&["%PDF-".to_string()][..]));
2623 }
2624
2625 #[test]
2626 fn denial_from_insecure_redirect_marks_insecure_scheme() {
2627 use crate::{DenialContext, DenialReason};
2628 let e = HttpError::InsecureRedirect {
2629 scheme: "http".to_string(),
2630 };
2631 let dc: Option<DenialContext> = (&e).into();
2632 let dc = dc.expect("InsecureRedirect -> Some(DenialContext)");
2633 // ADR-0023 §4 (post-incorporation review): InsecureRedirect maps
2634 // to its own dedicated `InsecureScheme` reason, not the host-
2635 // allowlist reason — they are semantically distinct denials.
2636 assert_eq!(dc.reason, DenialReason::InsecureScheme);
2637 assert_eq!(dc.attempted.as_deref(), Some("http:..."));
2638 assert_eq!(dc.expected.as_deref(), Some(&["https".to_string()][..]));
2639 }
2640
2641 #[test]
2642 fn denial_from_non_denial_variants_returns_none() {
2643 use crate::DenialContext;
2644 // Network / HttpStatus / UnknownSource are not denials; they
2645 // map to None per ADR-0023 §4.
2646 let e = HttpError::HttpStatus {
2647 status: 503,
2648 retry_after_ms: None,
2649 url: "https://api.crossref.org/works/x".to_string(),
2650 };
2651 let dc: Option<DenialContext> = (&e).into();
2652 assert!(dc.is_none(), "HttpStatus must not produce a DenialContext");
2653
2654 let e = HttpError::UnknownSource {
2655 source_key: "ghost".to_string(),
2656 };
2657 let dc: Option<DenialContext> = (&e).into();
2658 assert!(
2659 dc.is_none(),
2660 "UnknownSource must not produce a DenialContext"
2661 );
2662 }
2663
2664 // ---------------------------------------------------------------
2665 // Issue #117 — transient retry / backoff. Real time: wiremock
2666 // serves over real localhost IO and tokio `start_paused` is
2667 // incompatible with that (it auto-advances past reqwest's
2668 // timeout). Backoff is small enough that the slowest case
2669 // (persistent 503, 3 retries ≈ 3.5s) stays within the suite budget.
2670 // ---------------------------------------------------------------
2671
2672 fn host_of(server: &MockServer) -> String {
2673 server
2674 .uri()
2675 .parse::<Url>()
2676 .unwrap()
2677 .host_str()
2678 .unwrap()
2679 .to_string()
2680 }
2681
2682 #[tokio::test]
2683 async fn transient_503_then_200_succeeds() {
2684 let server = MockServer::start().await;
2685 // Catch-all 200 mounted first (lowest precedence); the
2686 // single-shot 503 mounted last takes precedence for the first
2687 // request only, then falls through to the 200.
2688 Mock::given(method("GET"))
2689 .and(path("/p"))
2690 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"ok":1}"#))
2691 .mount(&server)
2692 .await;
2693 Mock::given(method("GET"))
2694 .and(path("/p"))
2695 .respond_with(ResponseTemplate::new(503))
2696 .up_to_n_times(1)
2697 .mount(&server)
2698 .await;
2699
2700 let client = build_test_client_for_http("crossref", &host_of(&server));
2701 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2702 let (body, _) = client
2703 .fetch_bytes("crossref", url)
2704 .await
2705 .expect("503-then-200 must succeed after one retry");
2706 assert_eq!(&body[..], br#"{"ok":1}"#);
2707 }
2708
2709 #[tokio::test]
2710 async fn persistent_503_exhausts_and_returns_httpstatus() {
2711 let server = MockServer::start().await;
2712 Mock::given(method("GET"))
2713 .and(path("/p"))
2714 .respond_with(ResponseTemplate::new(503))
2715 .mount(&server)
2716 .await;
2717
2718 let client = build_test_client_for_http("crossref", &host_of(&server));
2719 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2720 let err = client
2721 .fetch_bytes("crossref", url)
2722 .await
2723 .expect_err("persistent 503 must exhaust retries");
2724 match err {
2725 HttpError::HttpStatus { status, .. } => assert_eq!(status, 503),
2726 other => panic!("expected HttpStatus 503, got {other:?}"),
2727 }
2728 // First attempt + MAX_FETCH_RETRIES retries.
2729 let reqs = server
2730 .received_requests()
2731 .await
2732 .expect("wiremock records requests");
2733 assert_eq!(reqs.len(), (MAX_FETCH_RETRIES + 1) as usize);
2734 }
2735
2736 #[tokio::test]
2737 async fn retry_after_429_then_200_succeeds() {
2738 let server = MockServer::start().await;
2739 Mock::given(method("GET"))
2740 .and(path("/p"))
2741 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
2742 .mount(&server)
2743 .await;
2744 Mock::given(method("GET"))
2745 .and(path("/p"))
2746 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "1"))
2747 .up_to_n_times(1)
2748 .mount(&server)
2749 .await;
2750
2751 let client = build_test_client_for_http("crossref", &host_of(&server));
2752 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2753 let (body, _) = client
2754 .fetch_bytes("crossref", url)
2755 .await
2756 .expect("429+Retry-After then 200 must succeed");
2757 assert_eq!(&body[..], b"ok");
2758 }
2759
2760 /// #506: the server's own `Retry-After` survives to the caller.
2761 ///
2762 /// `parse_retry_after` already read this header, but only on the RETRY
2763 /// path -- the terminal `return` discarded it, so by the time the error
2764 /// reached a caller the number was gone and `error.retry_after_ms` looked
2765 /// impossible to fill honestly. It is not: the response that ends the
2766 /// attempt carries its own header, and that is the one to wait.
2767 #[tokio::test]
2768 async fn a_terminal_429_carries_the_servers_retry_after() {
2769 let server = MockServer::start().await;
2770 // 429 on every attempt, so the retries are exhausted and the error is
2771 // the terminal one -- the case that used to lose the header.
2772 Mock::given(method("GET"))
2773 .and(path("/p"))
2774 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "7"))
2775 .mount(&server)
2776 .await;
2777
2778 let client = build_test_client_for_http("crossref", &host_of(&server));
2779 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2780 let err = client
2781 .fetch_bytes("crossref", url)
2782 .await
2783 .expect_err("every attempt 429s");
2784
2785 match err {
2786 HttpError::HttpStatus {
2787 status,
2788 retry_after_ms,
2789 ..
2790 } => {
2791 assert_eq!(status, 429);
2792 assert_eq!(
2793 retry_after_ms,
2794 Some(7_000),
2795 "the SERVER's number, in ms, not our backoff"
2796 );
2797 }
2798 other => panic!("expected HttpStatus, got {other:?}"),
2799 }
2800 }
2801
2802 /// No header, no number. Backfilling from `backoff_delay` would hand the
2803 /// caller a guess about the server wearing the name of a value the server
2804 /// supplied -- the defect this field exists to avoid.
2805 #[tokio::test]
2806 async fn a_terminal_429_without_the_header_carries_no_number() {
2807 let server = MockServer::start().await;
2808 Mock::given(method("GET"))
2809 .and(path("/p"))
2810 .respond_with(ResponseTemplate::new(429))
2811 .mount(&server)
2812 .await;
2813
2814 let client = build_test_client_for_http("crossref", &host_of(&server));
2815 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2816 let err = client
2817 .fetch_bytes("crossref", url)
2818 .await
2819 .expect_err("every attempt 429s");
2820
2821 match err {
2822 HttpError::HttpStatus { retry_after_ms, .. } => assert_eq!(retry_after_ms, None),
2823 other => panic!("expected HttpStatus, got {other:?}"),
2824 }
2825 }
2826
2827 #[tokio::test]
2828 async fn permanent_404_is_not_retried() {
2829 let server = MockServer::start().await;
2830 Mock::given(method("GET"))
2831 .and(path("/p"))
2832 .respond_with(ResponseTemplate::new(404))
2833 .mount(&server)
2834 .await;
2835
2836 let client = build_test_client_for_http("crossref", &host_of(&server));
2837 let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2838 let _ = client
2839 .fetch_bytes("crossref", url)
2840 .await
2841 .expect_err("404 must fail");
2842 let reqs = server
2843 .received_requests()
2844 .await
2845 .expect("wiremock records requests");
2846 assert_eq!(reqs.len(), 1, "4xx (non-408/429) must NOT be retried");
2847 }
2848}