Skip to main content

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
178/// Returns `true` if `host` (already lowercased) matches `pattern` per
179/// `docs/REDIRECT_ALLOWLIST.md` §2.2.
180fn host_matches_pattern(host: &str, pattern: &str) -> bool {
181    let pat_lc = pattern.to_ascii_lowercase();
182    if let Some(suffix) = pat_lc.strip_prefix("*.") {
183        // Suffix-glob: matches `<suffix>` exactly OR `*.<suffix>`.
184        host == suffix || host.ends_with(&format!(".{}", suffix))
185    } else {
186        // Exact-FQDN: byte-identical (after lowercasing both sides).
187        host == pat_lc
188    }
189}
190
191/// Hard-coded Phase 1 allowlist for Tier 1 sources. Sourced from
192/// `docs/REDIRECT_ALLOWLIST.md` §3.
193///
194/// Marked `Phase 1; revisit during real fetches` in the spec — entries
195/// flagged `(unverified)` (e.g. arXiv subdomain redirect behavior) MUST be
196/// confirmed or removed before Phase 1 is closed; see §3.3 of the spec.
197pub fn tier_1_allowlist() -> Vec<SourceAllowlist> {
198    vec![
199        // §3.1 crossref
200        SourceAllowlist::new(
201            "crossref",
202            vec!["api.crossref.org".to_string(), "*.crossref.org".to_string()],
203        ),
204        // §3.2 unpaywall
205        SourceAllowlist::new("unpaywall", vec!["api.unpaywall.org".to_string()]),
206        // §3.3 arxiv
207        SourceAllowlist::new(
208            "arxiv",
209            vec![
210                "arxiv.org".to_string(),
211                "export.arxiv.org".to_string(),
212                "*.arxiv.org".to_string(),
213            ],
214        ),
215    ]
216}
217
218/// Hard-coded Phase 4 allowlist for Tier 2 metadata sources (OpenAlex,
219/// Semantic Scholar, DOAJ). Sourced from `docs/SOURCES.md` §1 (the Tier 2
220/// table) and `docs/REDIRECT_ALLOWLIST.md` §3 (same redirect-allowlist
221/// policy as Tier 1, distinct source keys).
222///
223/// Returned hosts:
224///
225/// - `"openalex"` → `api.openalex.org` (production OpenAlex REST API).
226/// - `"semantic_scholar"` → `api.semanticscholar.org` (S2 Graph API base).
227/// - `"doaj"` → `doaj.org` + `*.doaj.org` (DOAJ public API; wildcard
228///   covers `api.doaj.org` and any v4+ subdomain split).
229///
230/// Per `docs/SOURCES.md` §4 "OpenAlex / Semantic Scholar / DOAJ", these
231/// sources are **metadata-only**: their `Source::fetch` impls MUST
232/// return `pdf_bytes: None`. The redirect closure in [`HttpClient`]
233/// uses this list to deny redirects to off-list hosts under each Tier
234/// 2 source key — identical mechanism to Tier 1, but the per-tool
235/// capability gate (`profile.metadata.openalex` etc.) is layered on
236/// top so the network surface remains capability-aware.
237pub fn tier_2_allowlist() -> Vec<SourceAllowlist> {
238    vec![
239        SourceAllowlist::new("openalex", vec!["api.openalex.org".to_string()]),
240        SourceAllowlist::new(
241            "semantic_scholar",
242            vec!["api.semanticscholar.org".to_string()],
243        ),
244        SourceAllowlist::new(
245            "doaj",
246            vec!["doaj.org".to_string(), "*.doaj.org".to_string()],
247        ),
248    ]
249}
250
251/// Always-compiled allowlist for the **discovery search** call path
252/// (ADR-0031).
253///
254/// Registers `api.openalex.org` under the `"openalex"` source key so the
255/// Tier-1 `discovery::paper_search` (`GET /works?search=`) can reach the
256/// endpoint in the **default `oa-only` binary** — unlike
257/// [`tier_2_allowlist`], which the CLI only wires in under
258/// `#[cfg(feature = "citation")]`.
259///
260/// Discovery search is classified as Tier 1 OA metadata (read-only, never
261/// paywalled, never a PDF — same risk class as Crossref/Unpaywall), so its
262/// transport allowlist must exist regardless of the `metadata`/`citation`
263/// features (ADR-0031 D1/D2). The CLI's `build_http_client` extends the
264/// production allowlist with this **unconditionally**; in `citation`
265/// builds [`tier_2_allowlist`] re-registers the identical
266/// `"openalex" → api.openalex.org` entry, which is a harmless idempotent
267/// `HashMap` overwrite in [`HttpClient::new`].
268pub fn discovery_allowlist() -> Vec<SourceAllowlist> {
269    vec![SourceAllowlist::new(
270        "openalex",
271        vec!["api.openalex.org".to_string()],
272    )]
273}
274
275/// Always-compiled allowlist for the **full-text extraction** call path
276/// (ADR-0032).
277///
278/// Registers `ar5iv.labs.arxiv.org` under a dedicated `"ar5iv"` source key
279/// so [`crate::paper_text::paper_text`] (`GET /html/<arxiv-id>`) can reach
280/// the ar5iv LaTeXML-XHTML renderer in the **default `oa-only` binary** —
281/// the same always-on posture as [`discovery_allowlist`].
282///
283/// The host is an arXiv subdomain (`*.arxiv.org` already matches it under
284/// the [`tier_1_allowlist`] `"arxiv"` key), so this adds no new
285/// registrable domain to the network surface — it only registers the host
286/// under a **distinct source key** so the provenance trail records that
287/// extracted text came from the ar5iv HTML renderer, not the arXiv
288/// PDF/Atom API (ADR-0032 D3). Full-text extraction is classified Tier-1
289/// OA metadata (read-only, OA, never a PDF reinterpretation), so its
290/// transport allowlist must exist regardless of any feature gate
291/// (ADR-0032 D2). The CLI's `build_http_client` extends the production
292/// allowlist with this **unconditionally**.
293pub fn fulltext_allowlist() -> Vec<SourceAllowlist> {
294    vec![SourceAllowlist::new(
295        "ar5iv",
296        vec!["ar5iv.labs.arxiv.org".to_string()],
297    )]
298}
299
300/// Hard-coded Phase 5a allowlist for the Springer Nature OA TDM
301/// source. Compile-gated by the `tdm-springer` Cargo feature so
302/// default release binaries never include the host pattern (per
303/// ADR-0002 and `docs/SOURCES.md` §3).
304///
305/// Returned entry:
306/// - `"tdm-springer"` → `api.springernature.com` (production base) +
307///   `*.springernature.com` (covers load-balancing subdomains; the
308///   redirect closure denies anything outside the wildcard).
309///
310/// Per `docs/SOURCES.md` §4 "TDM sources (Phase 5)", a fetch under
311/// this source key requires ALL THREE gates: Cargo feature compiled
312/// in, `DOIGET_KEY_SPRINGER` env var present, and
313/// `DOIGET_AGREE_TDM_SPRINGER=1`. The `CapabilityProfile` gate
314/// enforces the env-var pair; this allowlist is the transport gate.
315#[cfg(feature = "tdm-springer")]
316pub fn tier_3_springer_allowlist() -> Vec<SourceAllowlist> {
317    vec![SourceAllowlist::new(
318        "tdm-springer",
319        vec![
320            "api.springernature.com".to_string(),
321            "*.springernature.com".to_string(),
322        ],
323    )]
324}
325
326/// Hard-coded Phase 5b allowlist for the APS Harvest TDM source.
327/// Compile-gated by the `tdm-aps` Cargo feature so default release
328/// binaries never include the host pattern (per ADR-0002 and
329/// `docs/SOURCES.md` §3).
330///
331/// Returned entry:
332/// - `"tdm-aps"` → `harvest.aps.org` (production base) +
333///   `*.aps.org` (covers load-balancing subdomains; the redirect
334///   closure denies anything outside the wildcard).
335///
336/// Three-gate activation: Cargo feature compiled in,
337/// `DOIGET_KEY_APS` env var present, and `DOIGET_AGREE_TDM_APS=1`.
338/// The `CapabilityProfile` gate enforces the env-var pair; this
339/// allowlist is the transport gate.
340#[cfg(feature = "tdm-aps")]
341pub fn tier_3_aps_allowlist() -> Vec<SourceAllowlist> {
342    vec![SourceAllowlist::new(
343        "tdm-aps",
344        vec!["harvest.aps.org".to_string(), "*.aps.org".to_string()],
345    )]
346}
347
348/// Hard-coded Phase 5c allowlist for the Elsevier ScienceDirect TDM
349/// source. Compile-gated by the `tdm-elsevier` Cargo feature so
350/// default release binaries never include the host pattern (per
351/// ADR-0002 and `docs/SOURCES.md` §3).
352///
353/// Returned entry:
354/// - `"tdm-elsevier"` → `api.elsevier.com` (production base) +
355///   `*.elsevier.com` (covers load-balancing subdomains; the
356///   redirect closure denies anything outside the wildcard).
357///
358/// Three-gate activation: Cargo feature compiled in,
359/// `DOIGET_KEY_ELSEVIER` env var present, and
360/// `DOIGET_AGREE_TDM_ELSEVIER=1`. The `CapabilityProfile` gate
361/// enforces the env-var pair; this allowlist is the transport gate.
362#[cfg(feature = "tdm-elsevier")]
363pub fn tier_3_elsevier_allowlist() -> Vec<SourceAllowlist> {
364    vec![SourceAllowlist::new(
365        "tdm-elsevier",
366        vec!["api.elsevier.com".to_string(), "*.elsevier.com".to_string()],
367    )]
368}
369
370/// Hard-coded Phase 1 allowlist for the synthetic `"oa-publisher"` source —
371/// the publisher / preprint / repository hosts to which Unpaywall's
372/// `best_oa_location.url` (or `url_for_pdf`) typically resolves.
373///
374/// **Status: informed-best-effort.** Per `docs/REDIRECT_ALLOWLIST.md` §3,
375/// every entry below is a documented OA-publisher host pulled from the
376/// public DOI / OA discovery surface as of this function's authoring; they
377/// are **not** a substitute for empirical validation. Entries marked
378/// `(unverified)` MUST be confirmed by a real fetch or removed before
379/// Phase 1 is closed.
380///
381/// The orchestrator (`doiget-cli::commands::fetch::fetch_doi`) calls
382/// [`HttpClient::fetch_pdf`] under the `"oa-publisher"` source key when
383/// Unpaywall returns an OA URL. If the OA host is not in this list, the
384/// PDF leg is denied (`HttpError::RedirectDenied`) and the orchestrator
385/// falls back to metadata-only success (the `informed-best-effort`
386/// posture from the spec section above).
387pub fn oa_publisher_allowlist() -> Vec<SourceAllowlist> {
388    vec![SourceAllowlist::new(
389        "oa-publisher",
390        vec![
391            // Springer Nature OA imprints. Springer / SpringerOpen / Nature
392            // OA URLs all resolve under one of these registrable suffixes.
393            // (unverified) — confirm by replaying real Unpaywall responses.
394            "*.springer.com".to_string(),
395            "*.springeropen.com".to_string(),
396            "*.springernature.com".to_string(),
397            "*.nature.com".to_string(),
398            // Wiley OA. (unverified)
399            "*.wiley.com".to_string(),
400            // Elsevier OA route only — the TDM gated path is a separate
401            // source (`tdm-elsevier`, Phase 5c) and is not covered here.
402            // (unverified)
403            "*.elsevier.com".to_string(),
404            "*.sciencedirect.com".to_string(),
405            // Frontiers. (unverified)
406            "*.frontiersin.org".to_string(),
407            // MDPI. (unverified)
408            "*.mdpi.com".to_string(),
409            // PLOS. (unverified)
410            "*.plos.org".to_string(),
411            // Preprint servers — biorxiv / medrxiv. (unverified)
412            "*.biorxiv.org".to_string(),
413            "*.medrxiv.org".to_string(),
414            // Europe PMC + NIH PMC. (unverified)
415            "europepmc.org".to_string(),
416            "*.europepmc.org".to_string(),
417            "*.nih.gov".to_string(),
418            "*.ncbi.nlm.nih.gov".to_string(),
419            // Physics-society / diamond-OA hosts. UNLIKE the entries
420            // above, these are EMPIRICALLY VERIFIED: a real `doiget batch`
421            // over 30 OpenAlex-OA finite-temperature-MPS DOIs observed
422            // Unpaywall `best_oa_location` resolving to these hosts and
423            // being denied (#193, REDIRECT_ALLOWLIST.md §3.4, ADR-0027).
424            // APS — journals.aps.org / link.aps.org (green & gold OA;
425            // society host; `*.aps.org` is also trusted under the separate
426            // `tdm-aps` Tier-3 source key WHEN that feature is compiled
427            // in — `tier_3_aps_allowlist` is `#[cfg(feature = "tdm-aps")]`
428            // and absent from default release builds).
429            "*.aps.org".to_string(),
430            // SciPost — diamond OA, community-run physics publisher.
431            "scipost.org".to_string(),
432            "*.scipost.org".to_string(),
433            // IOP Publishing — iopscience.iop.org (New J. Phys. etc.).
434            "*.iop.org".to_string(),
435            // arXiv — already on the `arxiv` tier-1 allowlist, but the
436            // Unpaywall-driven path uses the `oa-publisher` source key,
437            // so we mirror the host list here too. See REDIRECT_ALLOWLIST.md
438            // §3.3 for the underlying entries.
439            "arxiv.org".to_string(),
440            "*.arxiv.org".to_string(),
441        ],
442    )]
443}
444
445// ---------------------------------------------------------------------------
446// HttpError
447// ---------------------------------------------------------------------------
448
449/// Errors that can arise during HTTP fetches.
450#[derive(Debug, Error)]
451#[non_exhaustive]
452pub enum HttpError {
453    /// Transport / DNS / TLS failure or other `reqwest`-level error. Note
454    /// that `reqwest` surfaces a redirect-policy abort (via `Attempt::error`)
455    /// as a `reqwest::Error` carrying the source error — callers seeing
456    /// `Network` for what they believed was a redirect violation should
457    /// inspect the inner error chain.
458    #[error("network error: {0}")]
459    Network(#[from] reqwest::Error),
460    /// Redirect target host did not match any pattern in the source's
461    /// `redirect_hosts`. See `docs/REDIRECT_ALLOWLIST.md` §2.2.
462    ///
463    /// Field naming: `source_key` rather than `source` because `thiserror`
464    /// auto-treats a field literally named `source` as a `#[source]` error
465    /// chain link (which would require the field to implement `std::error::Error`).
466    ///
467    /// `expected_hosts` carries a snapshot of the source's allowlist
468    /// patterns at the time of the denial — populated for the structured
469    /// `denial_context.expected` channel introduced by ADR-0023 §4
470    /// (NORMATIVE mapping table). Cloning the patterns into the error
471    /// keeps the `From<&HttpError> for Option<DenialContext>` impl from
472    /// having to re-look-up the allowlist by `source_key`. May be empty
473    /// when the rejection happened before any allowlist was matched
474    /// (e.g. URL had no host component at all).
475    #[error("redirect target {host} not in allowlist for source {source_key}")]
476    RedirectDenied {
477        /// Source key whose allowlist rejected the redirect.
478        source_key: String,
479        /// The lowercased host that was rejected.
480        host: String,
481        /// Snapshot of the source's `redirect_hosts` at denial time.
482        /// Surfaces as `denial_context.expected` (ADR-0023 §4).
483        expected_hosts: Vec<String>,
484    },
485    /// Redirect target had a scheme other than `https`. See
486    /// `docs/SECURITY.md` §1.3.
487    #[error("redirect to non-HTTPS scheme: {scheme}")]
488    InsecureRedirect {
489        /// The disallowed scheme (e.g. `http`, `file`, `data`).
490        scheme: String,
491    },
492    /// Body would exceed [`PDF_MAX_BYTES`] either by a `Content-Length`
493    /// hint or by accumulated streamed bytes. See `docs/SECURITY.md` §1.2.
494    #[error("body too large: {actual} bytes (cap = {cap})")]
495    OversizedBody {
496        /// Observed size (header value or accumulated bytes).
497        actual: u64,
498        /// Hard upper bound (always [`PDF_MAX_BYTES`]).
499        cap: u64,
500    },
501    /// PDF magic-byte mismatch — the body does not start with `%PDF-`.
502    /// We deliberately do NOT use `Content-Type` (publishers misbehave —
503    /// the magic byte is the trustworthy signal per `docs/SECURITY.md`
504    /// §1.2 "Magic-byte mismatch" row).
505    #[error("PDF magic-byte mismatch: got {got:?}")]
506    NotAPdf {
507        /// First five bytes of the response body (zero-padded if shorter).
508        got: [u8; 5],
509    },
510    /// Server returned a non-2xx status.
511    #[error("HTTP {status} from {url}")]
512    HttpStatus {
513        /// HTTP status code.
514        status: u16,
515        /// The URL that produced the status.
516        url: String,
517    },
518    /// No allowlist entry exists for this source. The caller asked
519    /// [`HttpClient`] to fetch on behalf of a source that wasn't passed to
520    /// [`HttpClient::new`].
521    ///
522    /// See note on `RedirectDenied` for why the field is `source_key`.
523    #[error("no allowlist registered for source {source_key}")]
524    UnknownSource {
525        /// The unregistered source key.
526        source_key: String,
527    },
528    /// A header name or value passed to
529    /// [`HttpClient::fetch_bytes_with_headers`] was not a valid HTTP
530    /// header. The header parser only accepts the visible-ASCII subset
531    /// per RFC 7230 §3.2; control characters and non-ASCII bytes are
532    /// rejected before the request is even built. Surfaces as
533    /// `ErrorCode::InternalError` at the public boundary (callers
534    /// supplying bad headers are responsible for fixing the call site;
535    /// not a denial in the ADR-0023 sense).
536    #[error("invalid HTTP header `{name}`: {reason}")]
537    InvalidHeader {
538        /// The header name as supplied by the caller.
539        name: String,
540        /// `"name"` or `"value"` — which side failed parsing.
541        reason: String,
542    },
543}
544
545// ---------------------------------------------------------------------------
546// HttpError -> Option<DenialContext>  (ADR-0023 §4 mapping table)
547// ---------------------------------------------------------------------------
548
549/// Map an [`HttpError`] reference to the structured [`crate::DenialContext`]
550/// channel introduced by ADR-0023.
551///
552/// Returns `Some(_)` for the four denial classes named in ADR-0023 §4
553/// (`RedirectDenied`, `OversizedBody`, `NotAPdf`, `InsecureRedirect`) and
554/// `None` for every other variant — `Network`, `HttpStatus`,
555/// `UnknownSource` are not denials in the ADR-0023 sense (they are
556/// transport / upstream / programming-error signals, not allowlist or
557/// cap rejections).
558///
559/// The `&HttpError` borrow form is used (rather than `HttpError`) so the
560/// caller — typically the orchestrator that already needs the original
561/// error for `error.message` and the `From<HttpError> for ErrorCode`
562/// collapse — does not have to clone the error to produce the optional
563/// structured side-channel.
564impl From<&HttpError> for Option<crate::DenialContext> {
565    fn from(e: &HttpError) -> Self {
566        use crate::{DenialContext, DenialReason};
567        match e {
568            HttpError::RedirectDenied {
569                source_key,
570                host,
571                expected_hosts,
572            } => Some(DenialContext {
573                reason: DenialReason::RedirectNotInAllowlist,
574                source: Some(source_key.clone()),
575                attempted: Some(host.clone()),
576                expected: Some(expected_hosts.clone()),
577                hop_index: None,
578                cap: None,
579                actual: None,
580            }),
581            HttpError::OversizedBody { actual, cap } => Some(DenialContext {
582                reason: DenialReason::SizeCapExceeded,
583                source: None,
584                attempted: None,
585                // The size-cap reason has no allowlist channel; use
586                // `None` to signal "field not populated by producer"
587                // rather than `Some(vec![])` (which would mean "explicit
588                // empty allowlist"). See `DenialContext::expected` docs.
589                expected: None,
590                hop_index: None,
591                cap: Some(*cap),
592                actual: Some(*actual),
593            }),
594            HttpError::NotAPdf { got } => Some(DenialContext {
595                reason: DenialReason::ContentTypeMismatch,
596                source: None,
597                // ADR-0023 §4 mapping table: hex-encode the first 5 bytes
598                // for the `attempted` field. `format!("{:02x}...")` is
599                // chosen over `hex::encode` to avoid pulling the
600                // additional dep into this conversion path; the result is
601                // bit-identical (lowercase, zero-padded).
602                attempted: Some(format!(
603                    "{:02x}{:02x}{:02x}{:02x}{:02x}",
604                    got[0], got[1], got[2], got[3], got[4]
605                )),
606                expected: Some(vec!["%PDF-".to_string()]),
607                hop_index: None,
608                cap: None,
609                actual: None,
610            }),
611            HttpError::InsecureRedirect { scheme } => Some(DenialContext {
612                reason: DenialReason::InsecureScheme,
613                source: None,
614                attempted: Some(format!("{}:...", scheme)),
615                expected: Some(vec!["https".to_string()]),
616                hop_index: None,
617                cap: None,
618                actual: None,
619            }),
620            // `reqwest` wraps a custom error returned by the redirect
621            // policy closure (`attempt.error(HttpError::RedirectDenied{..})`
622            // / `attempt.error(HttpError::InsecureRedirect{..})`) inside a
623            // `reqwest::Error`, which surfaces here as `HttpError::Network`.
624            // Without source-chain walking, production redirect denials —
625            // the most operationally important denial class — would never
626            // produce a `DenialContext`, defeating the whole point of
627            // ADR-0023.
628            //
629            // Walk the `std::error::Error::source()` chain on the inner
630            // `reqwest::Error` and downcast each link to `&HttpError`. If
631            // a wrapped `HttpError` is found, recurse via this same `From`
632            // impl. Otherwise the network error is a "real" transport /
633            // DNS / TLS failure with no denial semantics — return `None`.
634            //
635            // `std::error::Error::source(e)` is fully-qualified to
636            // disambiguate against the inherent (and unrelated)
637            // `reqwest::Error::source()`.
638            HttpError::Network(e) => {
639                let mut source: Option<&(dyn std::error::Error + 'static)> =
640                    std::error::Error::source(e);
641                while let Some(s) = source {
642                    if let Some(http_err) = s.downcast_ref::<HttpError>() {
643                        return Option::<crate::DenialContext>::from(http_err);
644                    }
645                    source = s.source();
646                }
647                None
648            }
649            // The remaining variants are not "denials" in the ADR-0023
650            // sense — HttpStatus/UnknownSource are upstream / programming-
651            // error signals; InvalidHeader is a caller-bug signal.
652            HttpError::HttpStatus { .. }
653            | HttpError::UnknownSource { .. }
654            | HttpError::InvalidHeader { .. } => None,
655        }
656    }
657}
658
659// ---------------------------------------------------------------------------
660// HttpClient
661// ---------------------------------------------------------------------------
662
663/// Workspace-wide HTTP client with the security defaults applied.
664///
665/// Internally holds one `reqwest::Client` per source. Construct via
666/// [`HttpClient::new`] with the full set of allowlists the calling process
667/// will need.
668#[derive(Clone, Debug)]
669pub struct HttpClient {
670    /// One [`reqwest::Client`] per source. Each client carries a redirect
671    /// policy that captures only that source's allowlist. `Arc` so cloning
672    /// is cheap.
673    clients: Arc<HashMap<String, Client>>,
674    /// The exact [`SourceAllowlist`] each per-source client was built from,
675    /// keyed by source. The redirect closure inside each `reqwest::Client`
676    /// captures its allowlist *by move*, so it cannot be read back from the
677    /// client itself. This map keeps the identical `SourceAllowlist`
678    /// available to callers that must perform a *pre-fetch* host check on a
679    /// metadata-discovered URL (issue #145 / `docs/REDIRECT_ALLOWLIST.md`
680    /// §1: the allowlist is consulted "on the OA URL discovered through
681    /// metadata sources before the actual PDF fetch is issued", not only on
682    /// redirect hops). Storing the same value here — rather than re-deriving
683    /// it from [`oa_publisher_allowlist`] at the call site — guarantees the
684    /// pre-check and the redirect closure can never drift, and that the
685    /// check works under the test constructors too (which register a
686    /// wiremock host as the allowlist).
687    allowlists: Arc<HashMap<String, SourceAllowlist>>,
688}
689
690impl HttpClient {
691    /// Build a client with rustls + redirect-allowlist + size cap +
692    /// timeouts.
693    ///
694    /// `allowlists` MUST cover every source whose URL might be passed in;
695    /// fetches against unregistered sources return
696    /// [`HttpError::UnknownSource`].
697    ///
698    /// # Errors
699    ///
700    /// Returns the underlying `reqwest::Error` if `ClientBuilder::build`
701    /// fails (typically a TLS-backend init failure).
702    pub fn new(allowlists: Vec<SourceAllowlist>) -> Result<Self, reqwest::Error> {
703        let ua = format!("doiget/{} (+https://github.com/QAtlasHub/doiget)", VERSION);
704        Self::new_with_user_agent(allowlists, &ua)
705    }
706
707    /// Build a client with a custom `User-Agent` header.
708    ///
709    /// Used by `doiget batch --user-agent` to override the default UA for
710    /// hosts that classify the default string as a bot.
711    pub fn new_with_user_agent(
712        allowlists: Vec<SourceAllowlist>,
713        user_agent: &str,
714    ) -> Result<Self, reqwest::Error> {
715        let mut clients = HashMap::with_capacity(allowlists.len());
716        let mut allowlist_map = HashMap::with_capacity(allowlists.len());
717        for entry in allowlists {
718            let source = entry.source.clone();
719            allowlist_map.insert(source.clone(), entry.clone());
720            let client = build_client(entry, user_agent)?;
721            clients.insert(source, client);
722        }
723        Ok(Self {
724            clients: Arc::new(clients),
725            allowlists: Arc::new(allowlist_map),
726        })
727    }
728
729    /// The [`SourceAllowlist`] this client was built with for `source`, or
730    /// `None` if `source` was not registered.
731    ///
732    /// This is the *identical* value captured by the per-source redirect
733    /// closure (see [`HttpClient`]'s `allowlists` field doc). It exists so
734    /// the orchestrator can apply the `docs/REDIRECT_ALLOWLIST.md` §1
735    /// pre-fetch host check on a metadata-discovered OA URL — the URL that
736    /// is fetched *without* necessarily passing through a redirect hop —
737    /// using the same source of truth the redirect closure uses, so the two
738    /// can never disagree. Callers MUST use this for the `"oa-publisher"`
739    /// leg only; the initial template-constructed URL is exempt per
740    /// `docs/REDIRECT_ALLOWLIST.md` §6.
741    pub fn source_allowlist(&self, source: &str) -> Option<&SourceAllowlist> {
742        self.allowlists.get(source)
743    }
744
745    /// Fetch a URL, treating it as a JSON or text body. Caps at
746    /// [`PDF_MAX_BYTES`].
747    ///
748    /// Returns the response body bytes plus the effective final URL after
749    /// redirects (post-allowlist verification — every hop has already been
750    /// validated by the time this returns).
751    ///
752    /// # Errors
753    ///
754    /// Any [`HttpError`] variant.
755    pub async fn fetch_bytes(&self, source: &str, url: Url) -> Result<(Bytes, Url), HttpError> {
756        self.fetch_inner(source, url, &[], false).await
757    }
758
759    /// Like [`Self::fetch_bytes`] but attaches additional request
760    /// headers to the outgoing GET. The headers are validated up-front
761    /// against the visible-ASCII subset (RFC 7230 §3.2); any failure
762    /// returns [`HttpError::InvalidHeader`] before the request is sent.
763    ///
764    /// Used by Tier-3 TDM sources that authenticate via a header
765    /// (APS Harvest `X-API-Key`, Elsevier ScienceDirect `X-ELS-APIKey`).
766    /// Header values appear on the wire only — they are never logged.
767    ///
768    /// # Errors
769    ///
770    /// Any [`HttpError`] variant including [`HttpError::InvalidHeader`].
771    pub async fn fetch_bytes_with_headers(
772        &self,
773        source: &str,
774        url: Url,
775        headers: &[(&str, &str)],
776    ) -> Result<(Bytes, Url), HttpError> {
777        self.fetch_inner(source, url, headers, false).await
778    }
779
780    /// Fetch a URL expected to be a PDF. Same as [`Self::fetch_bytes`] plus
781    /// the magic-byte check on the first 5 bytes
782    /// (`%PDF-` = `[0x25, 0x50, 0x44, 0x46, 0x2D]`). Mismatch returns
783    /// [`HttpError::NotAPdf`].
784    ///
785    /// # Errors
786    ///
787    /// Any [`HttpError`] variant including [`HttpError::NotAPdf`].
788    pub async fn fetch_pdf(&self, source: &str, url: Url) -> Result<(Bytes, Url), HttpError> {
789        self.fetch_inner(source, url, &[], true).await
790    }
791
792    /// Single diagnostic request against `url`, reporting what came back
793    /// instead of turning it into an error.
794    ///
795    /// This is the primitive behind `doiget config doctor --network`
796    /// (issue #407). It differs from [`Self::fetch_bytes`] in three ways
797    /// that matter for a diagnostic:
798    ///
799    /// - **Non-2xx is data, not failure.** "403" is the answer to the
800    ///   question the user is asking, so it comes back in
801    ///   [`ProbeOutcome::status`] rather than as `HttpError::HttpStatus`.
802    /// - **No retries.** A probe that silently retried would hide the
803    ///   very flakiness it is meant to expose, and would multiply load on
804    ///   a publisher for a question that is not a fetch.
805    /// - **The host is checked against the source allowlist up front.**
806    ///   A doctor that probed arbitrary user-supplied hosts would be an
807    ///   SSRF gadget wearing a diagnostic hat; the allowlist is the same
808    ///   one a real fetch would enforce, which is also what makes
809    ///   "not allowlisted" a meaningful answer.
810    ///
811    /// The body is read so that [`ProbeOutcome::body_bytes`] can
812    /// distinguish a real `200` from the `202` + empty-body holding
813    /// response publisher WAFs return to scripted clients — the case in
814    /// #407 that a status code alone cannot diagnose. The read is capped
815    /// by the same size limits as any other fetch.
816    ///
817    /// # Errors
818    ///
819    /// [`HttpError::UnknownSource`] if `source` is not registered,
820    /// [`HttpError::RedirectDenied`] if the host is off the allowlist
821    /// (before any request is sent), or [`HttpError::Network`] for a
822    /// transport failure — a timeout IS the diagnosis, so it is returned
823    /// rather than retried.
824    pub async fn probe(&self, source: &str, url: Url) -> Result<ProbeOutcome, HttpError> {
825        let client = self
826            .clients
827            .get(source)
828            .ok_or_else(|| HttpError::UnknownSource {
829                source_key: source.to_string(),
830            })?;
831        let host = url.host_str().unwrap_or_default().to_string();
832        let allow = self
833            .source_allowlist(source)
834            .ok_or_else(|| HttpError::UnknownSource {
835                source_key: source.to_string(),
836            })?;
837        if !allow.matches(&host) {
838            return Err(HttpError::RedirectDenied {
839                source_key: source.to_string(),
840                host,
841                expected_hosts: allow.redirect_hosts.clone(),
842            });
843        }
844        let response = client.get(url).send().await.map_err(HttpError::Network)?;
845        let status = response.status().as_u16();
846        let final_host = response.url().host_str().map(str::to_string);
847        let body_bytes = response
848            .bytes()
849            .await
850            .map(|b| b.len())
851            .map_err(HttpError::Network)?;
852        Ok(ProbeOutcome {
853            status,
854            body_bytes,
855            final_host,
856        })
857    }
858
859    async fn fetch_inner(
860        &self,
861        source: &str,
862        url: Url,
863        headers: &[(&str, &str)],
864        check_pdf_magic: bool,
865    ) -> Result<(Bytes, Url), HttpError> {
866        // Normalise legacy `http://` URLs returned by OpenAlex /
867        // Unpaywall metadata before send. See `upgrade_http_to_https`
868        // for the rationale (TLS posture preserved per ADR-0020) and
869        // the loopback carve-out.
870        let url = upgrade_http_to_https(url);
871
872        let client = self
873            .clients
874            .get(source)
875            .ok_or_else(|| HttpError::UnknownSource {
876                source_key: source.to_string(),
877            })?;
878
879        // Parse headers up-front so an invalid name/value fails BEFORE
880        // we touch the network. `HeaderName::from_bytes` / `HeaderValue::from_str`
881        // accept the visible-ASCII subset only (RFC 7230 §3.2).
882        let mut header_map = reqwest::header::HeaderMap::with_capacity(headers.len());
883        for (name, value) in headers {
884            let hn = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
885                HttpError::InvalidHeader {
886                    name: (*name).to_string(),
887                    reason: "name".to_string(),
888                }
889            })?;
890            let hv = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
891                HttpError::InvalidHeader {
892                    name: (*name).to_string(),
893                    reason: "value".to_string(),
894                }
895            })?;
896            header_map.insert(hn, hv);
897        }
898
899        // Bounded retry loop (issue #117). Only transient classes are
900        // retried — connect/timeout/mid-stream network errors and the
901        // transient HTTP status set. Allowlist denials, NotAPdf,
902        // OversizedBody, 4xx (non-408/429) are deterministic and return
903        // on the first occurrence. GET is idempotent so a retried
904        // attempt re-streams the body from scratch.
905        let mut attempt: u32 = 0;
906        loop {
907            let send_result = client
908                .get(url.clone())
909                .headers(header_map.clone())
910                .send()
911                .await;
912            let response = match send_result {
913                Ok(r) => r,
914                Err(e) => {
915                    if attempt < MAX_FETCH_RETRIES && reqwest_is_transient(&e) {
916                        let d = backoff_delay(attempt);
917                        tracing::warn!(
918                            source,
919                            attempt,
920                            delay_ms = d.as_millis() as u64,
921                            error = %e,
922                            "transient send failure; retrying"
923                        );
924                        tokio::time::sleep(d).await;
925                        attempt += 1;
926                        continue;
927                    }
928                    return Err(HttpError::Network(e));
929                }
930            };
931            let final_url = response.url().clone();
932
933            // Status check before body read so we can fail fast.
934            let status = response.status();
935            if !status.is_success() {
936                let code = status.as_u16();
937                if attempt < MAX_FETCH_RETRIES && is_transient_status(code) {
938                    // Prefer the server's `Retry-After` over our backoff
939                    // when present (429/503 commonly carry it).
940                    let d = parse_retry_after(response.headers())
941                        .unwrap_or_else(|| backoff_delay(attempt));
942                    tracing::warn!(
943                        source,
944                        attempt,
945                        status = code,
946                        delay_ms = d.as_millis() as u64,
947                        "transient HTTP status; retrying"
948                    );
949                    tokio::time::sleep(d).await;
950                    attempt += 1;
951                    continue;
952                }
953                return Err(HttpError::HttpStatus {
954                    status: code,
955                    // Issue #146: Springer Nature authenticates via an
956                    // `api_key` URL query parameter (no header path
957                    // upstream). This error string is logged and may
958                    // surface to the user, so strip any `api_key`
959                    // value before it leaves the client. No other
960                    // source puts a secret in the query string, so
961                    // this is a no-op for them.
962                    url: redact_api_key_query(&final_url),
963                });
964            }
965
966            // Content-Length fast-path: if header is present and exceeds
967            // the cap, fail without reading any body (deterministic — not
968            // retried). Per `docs/SECURITY.md` §1.2.
969            if let Some(len) = response.content_length() {
970                if len > PDF_MAX_BYTES {
971                    return Err(HttpError::OversizedBody {
972                        actual: len,
973                        cap: PDF_MAX_BYTES,
974                    });
975                }
976            }
977
978            // Stream body and enforce the cap as bytes accumulate. A
979            // mid-stream transport error is transient (retry); an
980            // oversized body is deterministic (return).
981            let mut buf = BytesMut::new();
982            let mut stream = response.bytes_stream();
983            let mut oversized_at: Option<u64> = None;
984            let mut stream_err: Option<reqwest::Error> = None;
985            while let Some(chunk) = stream.next().await {
986                let chunk = match chunk {
987                    Ok(c) => c,
988                    Err(e) => {
989                        stream_err = Some(e);
990                        break;
991                    }
992                };
993                let projected = (buf.len() as u64).saturating_add(chunk.len() as u64);
994                if projected > PDF_MAX_BYTES {
995                    oversized_at = Some(projected);
996                    break;
997                }
998                buf.extend_from_slice(&chunk);
999            }
1000            if let Some(actual) = oversized_at {
1001                return Err(HttpError::OversizedBody {
1002                    actual,
1003                    cap: PDF_MAX_BYTES,
1004                });
1005            }
1006            if let Some(e) = stream_err {
1007                if attempt < MAX_FETCH_RETRIES && reqwest_is_transient(&e) {
1008                    let d = backoff_delay(attempt);
1009                    tracing::warn!(
1010                        source,
1011                        attempt,
1012                        delay_ms = d.as_millis() as u64,
1013                        error = %e,
1014                        "transient mid-stream failure; retrying"
1015                    );
1016                    tokio::time::sleep(d).await;
1017                    attempt += 1;
1018                    continue;
1019                }
1020                return Err(HttpError::Network(e));
1021            }
1022            let body = buf.freeze();
1023
1024            if check_pdf_magic {
1025                let mut got = [0u8; 5];
1026                let n = body.len().min(5);
1027                got[..n].copy_from_slice(&body[..n]);
1028                if got != PDF_MAGIC {
1029                    return Err(HttpError::NotAPdf { got });
1030                }
1031            }
1032
1033            return Ok((body, final_url));
1034        }
1035    }
1036}
1037
1038/// Return `url` rendered as a string with the value of any `api_key`
1039/// query parameter replaced by `REDACTED` (issue #146).
1040///
1041/// Springer Nature's TDM API authenticates **only** via an `api_key`
1042/// query parameter — there is no header-auth path upstream — so the key
1043/// is unavoidably in the request URL. This keeps it out of *our* log
1044/// and error sinks (the `HttpError::HttpStatus` string in particular,
1045/// which is `tracing`-logged and can surface to the user). It is a
1046/// structural no-op for every other source, none of which carry a
1047/// secret in the query string. Other pairs and their order are
1048/// preserved; a URL with no `api_key` pair is rendered unchanged.
1049fn redact_api_key_query(url: &url::Url) -> String {
1050    const API_KEY_PARAM: &str = "api_key";
1051    if url.query_pairs().all(|(k, _)| k != API_KEY_PARAM) {
1052        return url.to_string();
1053    }
1054    let mut redacted = url.clone();
1055    let pairs: Vec<(String, String)> = url
1056        .query_pairs()
1057        .map(|(k, v)| {
1058            if k == API_KEY_PARAM {
1059                (k.into_owned(), "REDACTED".to_string())
1060            } else {
1061                (k.into_owned(), v.into_owned())
1062            }
1063        })
1064        .collect();
1065    redacted.query_pairs_mut().clear().extend_pairs(pairs);
1066    redacted.to_string()
1067}
1068
1069/// Test-oriented [`HttpClient`] constructor. Originally `cfg(test)`; now
1070/// also reachable from the `doiget-cli` orchestrator's integration tests
1071/// (which live outside this crate and therefore cannot see `cfg(test)`-gated
1072/// items). The constructor name retains its `for_tests_allow_http` signal —
1073/// production code MUST use [`HttpClient::new`] with [`tier_1_allowlist`].
1074#[allow(clippy::expect_used)]
1075impl HttpClient {
1076    /// Build a test-oriented `HttpClient` against an `http://` wiremock
1077    /// origin. The redirect closure still rejects insecure schemes — we only
1078    /// relax `https_only` at the connection level so wiremock can serve.
1079    /// This is acceptable because the redirect closure (which is the
1080    /// security-load-bearing path) is exercised by the
1081    /// `redirect_to_http_is_rejected_by_closure` test below.
1082    ///
1083    /// Production callers MUST use [`HttpClient::new`] with
1084    /// [`tier_1_allowlist`] — the `for_tests_allow_http` suffix is the load-
1085    /// bearing signal that this constructor lifts the initial-leg HTTPS-only
1086    /// requirement.
1087    pub fn new_for_tests_allow_http(source: &str, allowlist_host: &str) -> Self {
1088        let allowlist = SourceAllowlist::new(source, vec![allowlist_host.to_string()]);
1089        let client = build_client_allow_http(allowlist.clone()).expect("test client builds");
1090        let mut map = HashMap::new();
1091        let mut allowlist_map = HashMap::new();
1092        allowlist_map.insert(allowlist.source.clone(), allowlist.clone());
1093        map.insert(allowlist.source.clone(), client);
1094        Self {
1095            clients: Arc::new(map),
1096            allowlists: Arc::new(allowlist_map),
1097        }
1098    }
1099
1100    /// Multi-source variant of [`HttpClient::new_for_tests_allow_http`].
1101    ///
1102    /// Builds a relaxed-`https_only` client per `(source, allowlist_host)`
1103    /// pair. Used by the `doiget-cli` orchestrator's integration tests when
1104    /// more than one upstream needs to be wiremocked simultaneously
1105    /// (e.g. Crossref + Unpaywall against two different mock servers).
1106    /// Production callers MUST use [`HttpClient::new`] with
1107    /// [`tier_1_allowlist`].
1108    pub fn new_for_tests_allow_http_multi(entries: &[(&str, &str)]) -> Self {
1109        let mut map = HashMap::with_capacity(entries.len());
1110        let mut allowlist_map = HashMap::with_capacity(entries.len());
1111        for (source, host) in entries {
1112            let allowlist = SourceAllowlist::new(*source, vec![host.to_string()]);
1113            let client = build_client_allow_http(allowlist.clone()).expect("test client builds");
1114            allowlist_map.insert(allowlist.source.clone(), allowlist.clone());
1115            map.insert(allowlist.source.clone(), client);
1116        }
1117        Self {
1118            clients: Arc::new(map),
1119            allowlists: Arc::new(allowlist_map),
1120        }
1121    }
1122}
1123
1124fn build_client_allow_http(allowlist: SourceAllowlist) -> Result<Client, reqwest::Error> {
1125    ensure_crypto_provider();
1126    let allowlist_for_closure = allowlist.clone();
1127    let redirect_policy = Policy::custom(move |attempt| {
1128        let scheme = attempt.url().scheme().to_string();
1129        let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
1130        let prev_count = attempt.previous().len();
1131        if scheme != "https" {
1132            return attempt.error(HttpError::InsecureRedirect { scheme });
1133        }
1134        if prev_count >= MAX_REDIRECTS {
1135            return attempt.stop();
1136        }
1137        let host = match host_opt {
1138            Some(h) => h,
1139            None => {
1140                return attempt.error(HttpError::RedirectDenied {
1141                    source_key: allowlist_for_closure.source.clone(),
1142                    host: String::new(),
1143                    expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1144                });
1145            }
1146        };
1147        if !allowlist_for_closure.matches(&host) {
1148            return attempt.error(HttpError::RedirectDenied {
1149                source_key: allowlist_for_closure.source.clone(),
1150                host,
1151                expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1152            });
1153        }
1154        attempt.follow()
1155    });
1156    ClientBuilder::new()
1157        // `https_only(false)` only at this scope — production builders
1158        // (the public `HttpClient::new`) keep it on.
1159        .https_only(false)
1160        .redirect(redirect_policy)
1161        .connect_timeout(CONNECT_TIMEOUT)
1162        .timeout(TOTAL_TIMEOUT)
1163        .read_timeout(READ_TIMEOUT)
1164        .user_agent(format!(
1165            "doiget/{} (+https://github.com/QAtlasHub/doiget)",
1166            VERSION
1167        ))
1168        .tls_backend_rustls()
1169        .build()
1170}
1171
1172// ---------------------------------------------------------------------------
1173// ClientBuilder helpers
1174// ---------------------------------------------------------------------------
1175
1176/// Install the `ring` `rustls` crypto provider as the process default,
1177/// exactly once.
1178///
1179/// reqwest is built with the `rustls-no-provider` feature (ADR-0020
1180/// Amendment 1: drop aws-lc-rs so `cargo install` needs no cmake/C
1181/// toolchain and musl-static builds cleanly). With no bundled provider,
1182/// `reqwest::ClientBuilder::build` calls
1183/// `rustls::crypto::CryptoProvider::get_default()` and **panics**
1184/// (`"No provider set"`) unless a process-default provider was installed
1185/// first. Every client constructor below calls this; the `Once` makes it
1186/// safe to invoke from many sites and from concurrent tests.
1187fn ensure_crypto_provider() {
1188    static INIT: Once = Once::new();
1189    INIT.call_once(|| {
1190        // `install_default` errors only if a provider is already set;
1191        // under `Once` that is unreachable, but ignore it rather than
1192        // panic (another linked crate could have installed one first).
1193        let _ = rustls::crypto::ring::default_provider().install_default();
1194    });
1195}
1196
1197/// Public entry point for callers that build their own `reqwest::Client`
1198/// outside of [`HttpClient`] and need the process-default TLS provider
1199/// installed first (ADR-0020 Amendment 1).
1200///
1201/// Safe to call multiple times; the underlying `Once` makes it idempotent.
1202pub fn init_tls() {
1203    ensure_crypto_provider();
1204}
1205
1206/// Upgrade an `http://` URL to `https://` for legacy publisher
1207/// metadata. Loopback hosts (`localhost`, any RFC 6761 `.localhost`
1208/// TLD subdomain, `127.0.0.0/8`, `::1`, IPv4-mapped IPv6 loopback)
1209/// are returned unchanged so the `new_for_tests_allow_http*` wiremock
1210/// path continues to talk plain HTTP to the local fixture server.
1211///
1212/// Non-`http` schemes (`https`, `file`, anything else) and cannot-be-
1213/// base URLs are returned unchanged. The function is total: it never
1214/// panics and never returns an error.
1215///
1216/// # Audit / posture
1217///
1218/// On a successful upgrade the function emits a `tracing::info!` event
1219/// so the rewrite appears in the operator's default-level structured
1220/// log. On the (in-practice unreachable) `set_scheme` failure path a
1221/// `tracing::warn!` event is emitted before returning the original
1222/// URL; the production client's `https_only(true)` then rejects the
1223/// send with a clear network error, preserving the TLS posture
1224/// established by ADR-0020.
1225///
1226/// # `Domain("localhost")` arm subtlety
1227///
1228/// The url crate resolves the bare host `localhost` to `127.0.0.1`
1229/// (Ipv4 variant) when parsing an `http://` URL, so the `Domain` arm
1230/// does NOT fire for that case (the `Ipv4` arm catches it). The arm
1231/// IS load-bearing for the RFC 6761 `.localhost` TLD (e.g.
1232/// `myservice.localhost`, `api.localhost`), which the url crate does
1233/// NOT auto-resolve to an IP and keeps as `Host::Domain`.
1234fn upgrade_http_to_https(url: Url) -> Url {
1235    if url.scheme() != "http" {
1236        return url;
1237    }
1238    match url.host() {
1239        None => {
1240            // Cannot-be-base URL (e.g. `http:foo`) — `set_scheme`
1241            // would reject the conversion.
1242            return url;
1243        }
1244        Some(url::Host::Domain(d)) if is_localhost_domain(d) => return url,
1245        Some(url::Host::Ipv4(ip)) if ip.is_loopback() => return url,
1246        Some(url::Host::Ipv6(ip)) if is_ipv6_loopback(ip) => return url,
1247        Some(_) => {}
1248    }
1249    let mut upgraded = url.clone();
1250    if upgraded.set_scheme("https").is_err() {
1251        // url-crate `set_scheme` is documented to fail only for
1252        // cannot-be-base URLs and a few cross-family transitions;
1253        // `http -> https` is supported because both are "special"
1254        // schemes. The fallback below is defence-in-depth.
1255        tracing::warn!(
1256            url = %url,
1257            "set_scheme(http -> https) failed unexpectedly; \
1258             sending original URL — https_only(true) will reject",
1259        );
1260        return url;
1261    }
1262    tracing::info!(
1263        original = %url,
1264        upgraded = %upgraded,
1265        "upgraded http -> https for legacy publisher metadata"
1266    );
1267    upgraded
1268}
1269
1270/// `true` for the `localhost` literal and any RFC 6761 `.localhost`
1271/// TLD subdomain (`myservice.localhost`, `api.localhost`, etc.).
1272/// ASCII-case-insensitive per host-name conventions.
1273fn is_localhost_domain(d: &str) -> bool {
1274    if d.eq_ignore_ascii_case("localhost") {
1275        return true;
1276    }
1277    let suffix = ".localhost";
1278    let d_bytes = d.as_bytes();
1279    let s_bytes = suffix.as_bytes();
1280    if d_bytes.len() <= s_bytes.len() {
1281        return false;
1282    }
1283    let tail = &d_bytes[d_bytes.len() - s_bytes.len()..];
1284    tail.eq_ignore_ascii_case(s_bytes)
1285}
1286
1287/// `true` for `::1` and any IPv4-mapped loopback
1288/// (`::ffff:127.0.0.0/8`). `Ipv6Addr::is_loopback()` covers only `::1`,
1289/// so dual-stack callers that hit `[::ffff:127.0.0.1]` would otherwise
1290/// be silently upgraded.
1291fn is_ipv6_loopback(ip: std::net::Ipv6Addr) -> bool {
1292    if ip.is_loopback() {
1293        return true;
1294    }
1295    matches!(ip.to_ipv4_mapped(), Some(v4) if v4.is_loopback())
1296}
1297
1298fn build_client(allowlist: SourceAllowlist, ua: &str) -> Result<Client, reqwest::Error> {
1299    ensure_crypto_provider();
1300
1301    let user_agent = ua.to_string();
1302
1303    // Redirect policy: capture the per-source allowlist by value. The
1304    // closure is called for every redirect hop — there is no global
1305    // fallback, every hop is checked. Hard cap at MAX_REDIRECTS via the
1306    // attempt counter (mirrors reqwest's built-in limit).
1307    let allowlist_for_closure = allowlist.clone();
1308    let redirect_policy = Policy::custom(move |attempt| {
1309        // Inspect the candidate URL via owned copies so we can move
1310        // `attempt` into `error()` / `follow()` / `stop()` later without
1311        // the borrow checker complaining about an outstanding borrow of
1312        // `attempt`.
1313        let scheme = attempt.url().scheme().to_string();
1314        let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
1315        let prev_count = attempt.previous().len();
1316
1317        // 1. Reject non-HTTPS up front. The `https_only(true)` builder
1318        //    flag below also catches this, but we want the dedicated
1319        //    `InsecureRedirect` error path (not a generic `https_only`
1320        //    abort) — see `docs/SECURITY.md` §1.3.
1321        if scheme != "https" {
1322            return attempt.error(HttpError::InsecureRedirect { scheme });
1323        }
1324
1325        // 2. Hop limit (`docs/SECURITY.md` §1.3 redirect_limit row).
1326        if prev_count >= MAX_REDIRECTS {
1327            return attempt.stop();
1328        }
1329
1330        // 3. Allowlist check on the candidate target host.
1331        //    `host_str()` is `None` for URLs without a host (e.g. data
1332        //    URIs); treat that as an allowlist miss.
1333        let host = match host_opt {
1334            Some(h) => h,
1335            None => {
1336                return attempt.error(HttpError::RedirectDenied {
1337                    source_key: allowlist_for_closure.source.clone(),
1338                    host: String::new(),
1339                    expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1340                });
1341            }
1342        };
1343        if !allowlist_for_closure.matches(&host) {
1344            return attempt.error(HttpError::RedirectDenied {
1345                source_key: allowlist_for_closure.source.clone(),
1346                host,
1347                expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
1348            });
1349        }
1350
1351        attempt.follow()
1352    });
1353
1354    ClientBuilder::new()
1355        .https_only(true)
1356        .redirect(redirect_policy)
1357        .connect_timeout(CONNECT_TIMEOUT)
1358        .timeout(TOTAL_TIMEOUT)
1359        .read_timeout(READ_TIMEOUT)
1360        .user_agent(user_agent)
1361        // `tls_backend_rustls()` is the non-deprecated equivalent of the
1362        // older `use_rustls_tls()`. The workspace pins reqwest with
1363        // `rustls-no-provider` (ADR-0020 Amendment 1), so this is a
1364        // re-assertion at builder level rather than a feature switch; the
1365        // `ring` provider installed by `ensure_crypto_provider()` above
1366        // is what reqwest picks up via `CryptoProvider::get_default()`.
1367        .tls_backend_rustls()
1368        .build()
1369}
1370
1371// ---------------------------------------------------------------------------
1372// Tests
1373// ---------------------------------------------------------------------------
1374
1375#[cfg(test)]
1376#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1377mod tests {
1378    use super::*;
1379    use wiremock::matchers::{method, path};
1380    use wiremock::{Mock, MockServer, ResponseTemplate};
1381
1382    // ---------------------------------------------------------------
1383    // http -> https scheme upgrade (#220) — pure unit tests, no network.
1384    // ---------------------------------------------------------------
1385
1386    #[test]
1387    fn upgrade_http_to_https_rewrites_public_http_url() {
1388        let input = Url::parse("http://link.aps.org/pdf/10.1103/PhysRev.123.456").unwrap();
1389        let out = upgrade_http_to_https(input.clone());
1390        assert_eq!(out.scheme(), "https");
1391        assert_eq!(out.host_str(), Some("link.aps.org"));
1392        assert_eq!(out.path(), "/pdf/10.1103/PhysRev.123.456");
1393    }
1394
1395    #[test]
1396    fn upgrade_http_to_https_preserves_port_path_query_fragment() {
1397        let input = Url::parse("http://example.org:8080/a/b?q=1#frag").unwrap();
1398        let out = upgrade_http_to_https(input);
1399        assert_eq!(out.as_str(), "https://example.org:8080/a/b?q=1#frag");
1400    }
1401
1402    #[test]
1403    fn upgrade_http_to_https_is_idempotent_on_https() {
1404        let input = Url::parse("https://api.crossref.org/works/10.1234/foo").unwrap();
1405        let out = upgrade_http_to_https(input.clone());
1406        assert_eq!(out, input);
1407    }
1408
1409    #[test]
1410    fn upgrade_http_to_https_skips_localhost() {
1411        // wiremock binds to `127.0.0.1:PORT`; the loopback exception
1412        // is the load-bearing rule that keeps `new_for_tests_allow_http*`
1413        // working alongside the production fetch path.
1414        let input = Url::parse("http://localhost:7878/pdf").unwrap();
1415        let out = upgrade_http_to_https(input.clone());
1416        assert_eq!(out, input, "localhost MUST NOT be upgraded");
1417    }
1418
1419    #[test]
1420    fn upgrade_http_to_https_skips_127_loopback_block() {
1421        for host in ["127.0.0.1", "127.0.0.42", "127.255.255.254"] {
1422            let raw = format!("http://{host}:1234/x");
1423            let input = Url::parse(&raw).unwrap();
1424            let out = upgrade_http_to_https(input.clone());
1425            assert_eq!(out, input, "host `{host}` MUST NOT be upgraded");
1426        }
1427    }
1428
1429    #[test]
1430    fn upgrade_http_to_https_skips_ipv6_loopback() {
1431        let input = Url::parse("http://[::1]:9000/path").unwrap();
1432        let out = upgrade_http_to_https(input.clone());
1433        assert_eq!(out, input, "IPv6 loopback MUST NOT be upgraded");
1434    }
1435
1436    #[test]
1437    fn upgrade_http_to_https_preserves_case_in_path() {
1438        // Some publishers (e.g. APS legacy redirects) use mixed-case
1439        // path segments; upgrade must NOT lowercase or canonicalise.
1440        let input = Url::parse("http://link.aps.org/PDF/10.1103/PhysRevB.109.045136").unwrap();
1441        let out = upgrade_http_to_https(input);
1442        assert_eq!(out.path(), "/PDF/10.1103/PhysRevB.109.045136");
1443    }
1444
1445    // ---- Review-pass extensions ------------------------------------
1446
1447    #[test]
1448    fn upgrade_http_to_https_skips_dot_localhost_tld() {
1449        // RFC 6761 reserves the entire `.localhost` TLD for loopback.
1450        // A developer running `http://myservice.localhost:8080/` MUST
1451        // NOT see their URL silently upgraded to https.
1452        for raw in [
1453            "http://myservice.localhost/",
1454            "http://api.localhost:8080/x",
1455            "http://a.b.LOCALHOST/y",
1456        ] {
1457            let input = Url::parse(raw).unwrap();
1458            let out = upgrade_http_to_https(input.clone());
1459            assert_eq!(out, input, "{raw} MUST NOT be upgraded");
1460        }
1461    }
1462
1463    #[test]
1464    fn upgrade_http_to_https_skips_ipv4_mapped_ipv6_loopback() {
1465        // `::ffff:127.0.0.1` is the IPv4-mapped IPv6 form of 127.0.0.1.
1466        // `Ipv6Addr::is_loopback()` alone returns false for this form,
1467        // so dual-stack callers binding wiremock to it would be
1468        // silently upgraded without the `to_ipv4_mapped()` check.
1469        for raw in [
1470            "http://[::ffff:127.0.0.1]:9000/x",
1471            "http://[::ffff:127.0.0.42]/y",
1472        ] {
1473            let input = Url::parse(raw).unwrap();
1474            let out = upgrade_http_to_https(input.clone());
1475            assert_eq!(out, input, "{raw} MUST NOT be upgraded");
1476        }
1477    }
1478
1479    #[test]
1480    fn upgrade_http_to_https_is_noop_on_non_http_schemes() {
1481        // The first guard (`url.scheme() != "http"`) covers everything
1482        // that isn't http: https (idempotent), file, data, ftp...
1483        for raw in [
1484            "https://api.crossref.org/works/10.1234/foo",
1485            "file:///etc/passwd",
1486            "data:text/plain,hello",
1487            "ftp://ftp.example.org/papers/",
1488        ] {
1489            let input = Url::parse(raw).unwrap();
1490            let out = upgrade_http_to_https(input.clone());
1491            assert_eq!(
1492                out, input,
1493                "{raw} non-http scheme MUST be returned unchanged"
1494            );
1495        }
1496    }
1497
1498    #[test]
1499    fn upgrade_http_to_https_http_url_always_has_host() {
1500        // The url crate's parser enforces authority for "special"
1501        // schemes (`http`, `https`, `ws`, `wss`, `ftp`, `file`).
1502        // `Url::parse("http:foo")` synthesises a Domain("foo")
1503        // authority, so an http URL with `host() == None` is
1504        // unreachable from `Url::parse`. The `None` arm in
1505        // `upgrade_http_to_https` is defence-in-depth only — pinned
1506        // here so a future url-crate behavior change is caught.
1507        let url = Url::parse("http:foo").expect("parse");
1508        assert!(
1509            url.host().is_some(),
1510            "http URLs always carry a host per WHATWG URL spec"
1511        );
1512        // The fn still produces a sensible result (upgrade applies).
1513        let out = upgrade_http_to_https(url.clone());
1514        assert_eq!(out.scheme(), "https");
1515    }
1516
1517    #[test]
1518    fn upgrade_http_to_https_skips_localhost_case_insensitive() {
1519        // The literal `localhost` is resolved by the url crate to
1520        // `127.0.0.1` (Ipv4) at parse time for `http://` URLs, so the
1521        // Ipv4 arm catches lowercase. The Domain-arm coverage is
1522        // load-bearing only for the `.localhost` TLD case, but we
1523        // still pin the casefold semantics in case the url crate
1524        // changes its parsing rules.
1525        for raw in ["http://LOCALHOST/", "http://Localhost:8080/x"] {
1526            let input = Url::parse(raw).unwrap();
1527            let out = upgrade_http_to_https(input.clone());
1528            assert_eq!(out, input, "{raw} MUST NOT be upgraded");
1529        }
1530    }
1531
1532    #[test]
1533    fn is_localhost_domain_matches_literal_and_tld_suffix() {
1534        assert!(is_localhost_domain("localhost"));
1535        assert!(is_localhost_domain("LOCALHOST"));
1536        assert!(is_localhost_domain("api.localhost"));
1537        assert!(is_localhost_domain("nested.api.localhost"));
1538        assert!(is_localhost_domain("X.LocalHost"));
1539        assert!(!is_localhost_domain("localhost.example.org"));
1540        assert!(!is_localhost_domain("notlocalhost"));
1541        assert!(!is_localhost_domain(""));
1542        assert!(!is_localhost_domain(".localhost")); // empty label not valid
1543    }
1544
1545    #[test]
1546    fn is_ipv6_loopback_covers_both_pure_and_mapped() {
1547        use std::net::Ipv6Addr;
1548        assert!(is_ipv6_loopback(Ipv6Addr::LOCALHOST)); // ::1
1549        assert!(is_ipv6_loopback("::ffff:127.0.0.1".parse().unwrap()));
1550        assert!(is_ipv6_loopback("::ffff:127.0.0.42".parse().unwrap()));
1551        assert!(!is_ipv6_loopback("::".parse().unwrap()));
1552        assert!(!is_ipv6_loopback("2001:db8::1".parse().unwrap()));
1553        // IPv4-mapped non-loopback must NOT be considered loopback.
1554        assert!(!is_ipv6_loopback("::ffff:1.2.3.4".parse().unwrap()));
1555    }
1556
1557    // ---------------------------------------------------------------
1558    // Allowlist matching — pure unit tests, no network.
1559    // ---------------------------------------------------------------
1560
1561    #[test]
1562    fn tier_1_allowlist_includes_crossref() {
1563        let lists = tier_1_allowlist();
1564        let crossref = lists
1565            .iter()
1566            .find(|a| a.source == "crossref")
1567            .expect("crossref entry");
1568        assert!(
1569            crossref
1570                .redirect_hosts
1571                .iter()
1572                .any(|h| h.contains("crossref.org")),
1573            "crossref allowlist must contain a crossref.org pattern; got {:?}",
1574            crossref.redirect_hosts,
1575        );
1576    }
1577
1578    #[test]
1579    fn tier_1_allowlist_includes_unpaywall_and_arxiv() {
1580        let lists = tier_1_allowlist();
1581        assert!(lists.iter().any(|a| a.source == "unpaywall"));
1582        assert!(lists.iter().any(|a| a.source == "arxiv"));
1583    }
1584
1585    #[test]
1586    fn fulltext_allowlist_registers_ar5iv_host_under_distinct_key() {
1587        // ADR-0032 D3: the ar5iv renderer is registered under its own
1588        // `"ar5iv"` source key (not `"arxiv"`) so provenance distinguishes
1589        // full-text HTML from the arXiv PDF/Atom API.
1590        let lists = fulltext_allowlist();
1591        assert_eq!(lists.len(), 1, "exactly one full-text source entry");
1592        let ar5iv = &lists[0];
1593        assert_eq!(ar5iv.source, "ar5iv");
1594        assert!(ar5iv.matches("ar5iv.labs.arxiv.org"));
1595        // It is also an arXiv subdomain — the existing `*.arxiv.org` glob
1596        // already covers the host, so no new registrable domain is added.
1597        let arxiv = tier_1_allowlist()
1598            .into_iter()
1599            .find(|a| a.source == "arxiv")
1600            .expect("arxiv entry");
1601        assert!(
1602            arxiv.matches("ar5iv.labs.arxiv.org"),
1603            "ar5iv host must fall under the existing *.arxiv.org surface"
1604        );
1605    }
1606
1607    #[test]
1608    fn oa_publisher_allowlist_groups_under_one_synthetic_source() {
1609        // The OA-publisher fan-out from Unpaywall's `best_oa_location.url`
1610        // is keyed under a single synthetic `"oa-publisher"` source so the
1611        // orchestrator can pass that one source key to
1612        // `HttpClient::fetch_pdf`. See `docs/REDIRECT_ALLOWLIST.md` §3 (the
1613        // informed-best-effort note) and the function-level docs in
1614        // [`oa_publisher_allowlist`].
1615        let lists = oa_publisher_allowlist();
1616        assert_eq!(lists.len(), 1, "exactly one synthetic source entry");
1617        assert_eq!(lists[0].source, "oa-publisher");
1618    }
1619
1620    #[test]
1621    fn oa_publisher_allowlist_matches_known_oa_hosts() {
1622        let lists = oa_publisher_allowlist();
1623        let oa = lists
1624            .iter()
1625            .find(|a| a.source == "oa-publisher")
1626            .expect("oa-publisher entry");
1627        // Spot-check a representative entry per host family.
1628        assert!(oa.matches("link.springer.com"));
1629        assert!(oa.matches("nature.com"));
1630        assert!(oa.matches("onlinelibrary.wiley.com"));
1631        assert!(oa.matches("www.frontiersin.org"));
1632        assert!(oa.matches("www.mdpi.com"));
1633        assert!(oa.matches("journals.plos.org"));
1634        assert!(oa.matches("www.biorxiv.org"));
1635        assert!(oa.matches("europepmc.org"));
1636        assert!(oa.matches("www.ncbi.nlm.nih.gov"));
1637        assert!(oa.matches("arxiv.org"));
1638        // #193: physics-society / diamond-OA hosts (empirically observed
1639        // as Unpaywall best_oa_location targets in the dogfood run).
1640        assert!(oa.matches("link.aps.org"));
1641        assert!(oa.matches("journals.aps.org"));
1642        assert!(oa.matches("scipost.org"));
1643        assert!(oa.matches("www.scipost.org"));
1644        assert!(oa.matches("iopscience.iop.org"));
1645        // Document intent of the `*.<suffix>` form: per
1646        // `REDIRECT_ALLOWLIST.md` §2.2 rule 3 it matches the bare
1647        // registrable domain AND any subdomain. Unpaywall has not been
1648        // observed returning bare-domain PDF URLs for these publishers,
1649        // but accepting them is consistent with every other `*.` entry in
1650        // this list (e.g. `arxiv.org` matched by `*.arxiv.org`) and is
1651        // what the matching rule already implements.
1652        assert!(oa.matches("aps.org"));
1653        assert!(oa.matches("iop.org"));
1654        // Multi-level subdomains also match (e.g. SciPost's deep paths);
1655        // documents the wildcard scope rather than testing a known URL.
1656        assert!(oa.matches("submissions.scipost.org"));
1657        // Negative: an attacker host is not covered.
1658        assert!(!oa.matches("attacker.test"));
1659        // Negative: dot-boundary safety for the new entries — a different
1660        // suffix that merely ends with the registrable name must NOT match.
1661        assert!(!oa.matches("notaps.org"));
1662        assert!(!oa.matches("evilscipost.org"));
1663        assert!(!oa.matches("notiop.org"));
1664        // Negative: dot-boundary safety — `*.springer.com` must not match
1665        // `notspringer.com`.
1666        assert!(!oa.matches("notspringer.com"));
1667    }
1668
1669    #[test]
1670    fn allowlist_matches_exact_fqdn() {
1671        let a = SourceAllowlist::new("crossref", vec!["api.crossref.org".to_string()]);
1672        assert!(a.matches("api.crossref.org"));
1673        assert!(!a.matches("crossref.org"));
1674        assert!(!a.matches("xapi.crossref.org"));
1675    }
1676
1677    #[test]
1678    fn allowlist_matches_subdomain_glob() {
1679        // Per docs/REDIRECT_ALLOWLIST.md §2.2 rule 3: `*.<suffix>`
1680        // matches both `<suffix>` itself AND any `*.<suffix>` subdomain,
1681        // but never matches a different suffix that happens to end with
1682        // `<suffix>` without a dot boundary.
1683        let a = SourceAllowlist::new("crossref", vec!["*.crossref.org".to_string()]);
1684        assert!(a.matches("doi.crossref.org"));
1685        assert!(a.matches("crossref.org"));
1686        assert!(!a.matches("notcrossref.org"));
1687        assert!(!a.matches("crossref.org.attacker.test"));
1688    }
1689
1690    #[test]
1691    fn allowlist_matches_is_case_insensitive() {
1692        let a = SourceAllowlist::new("crossref", vec!["API.crossref.ORG".to_string()]);
1693        assert!(a.matches("api.crossref.org"));
1694        assert!(a.matches("API.CROSSREF.ORG"));
1695    }
1696
1697    #[test]
1698    fn allowlist_with_no_redirect_hosts_matches_nothing() {
1699        // §2.2 rule 5: an empty `redirect_hosts` means "no redirects
1700        // permitted from this source".
1701        let a = SourceAllowlist::new("ghost", Vec::<String>::new());
1702        assert!(!a.matches("anything.test"));
1703        assert!(!a.matches(""));
1704    }
1705
1706    // ---------------------------------------------------------------
1707    // PDF magic-byte handling — tests on the body-parsing path. We
1708    // exercise the magic-byte branch via the public API against a
1709    // wiremock server so the assertion runs through the full
1710    // streaming codepath.
1711    // ---------------------------------------------------------------
1712
1713    /// Build a test-only `HttpClient` against an `http://` wiremock
1714    /// origin.
1715    ///
1716    /// Slice 5 (PR #84 advisory item A4 refactor): this helper now
1717    /// delegates to the public
1718    /// [`HttpClient::new_for_tests_allow_http`] constructor (defined
1719    /// just above the test module) instead of re-implementing the
1720    /// redirect-policy + `https_only(false)` builder. The two
1721    /// implementations had drifted into duplicates — keeping a private
1722    /// re-implementation only meant a future security tweak to the
1723    /// builder would silently leave the tests on a stale path.
1724    fn build_test_client_for_http(source: &str, allowlist_host: &str) -> HttpClient {
1725        HttpClient::new_for_tests_allow_http(source, allowlist_host)
1726    }
1727
1728    #[tokio::test]
1729    async fn pdf_magic_byte_match_succeeds() {
1730        let server = MockServer::start().await;
1731        let body = b"%PDF-1.7\n...some pdf bytes...".to_vec();
1732        Mock::given(method("GET"))
1733            .and(path("/paper.pdf"))
1734            .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
1735            .mount(&server)
1736            .await;
1737        let host = server
1738            .uri()
1739            .parse::<Url>()
1740            .unwrap()
1741            .host_str()
1742            .unwrap()
1743            .to_string();
1744        let client = build_test_client_for_http("crossref", &host);
1745        let url: Url = format!("{}/paper.pdf", server.uri()).parse().unwrap();
1746        let (got_body, _final_url) = client.fetch_pdf("crossref", url).await.expect("ok");
1747        assert_eq!(&got_body[..], &body[..]);
1748    }
1749
1750    #[tokio::test]
1751    async fn pdf_magic_byte_mismatch_rejects() {
1752        let server = MockServer::start().await;
1753        Mock::given(method("GET"))
1754            .and(path("/not_a_pdf"))
1755            .respond_with(
1756                ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
1757            )
1758            .mount(&server)
1759            .await;
1760        let host = server
1761            .uri()
1762            .parse::<Url>()
1763            .unwrap()
1764            .host_str()
1765            .unwrap()
1766            .to_string();
1767        let client = build_test_client_for_http("crossref", &host);
1768        let url: Url = format!("{}/not_a_pdf", server.uri()).parse().unwrap();
1769        let err = client
1770            .fetch_pdf("crossref", url)
1771            .await
1772            .expect_err("not pdf");
1773        match err {
1774            HttpError::NotAPdf { got } => {
1775                assert_eq!(&got, b"<html");
1776            }
1777            other => panic!("expected NotAPdf, got {:?}", other),
1778        }
1779    }
1780
1781    #[tokio::test]
1782    async fn fetch_bytes_does_not_check_pdf_magic() {
1783        // The non-PDF path returns the body unchanged regardless of
1784        // magic bytes. This pins the boundary between the JSON/text
1785        // path and the PDF path.
1786        let server = MockServer::start().await;
1787        Mock::given(method("GET"))
1788            .and(path("/data.json"))
1789            .respond_with(
1790                ResponseTemplate::new(200).set_body_bytes(br#"{"hello":"world"}"#.to_vec()),
1791            )
1792            .mount(&server)
1793            .await;
1794        let host = server
1795            .uri()
1796            .parse::<Url>()
1797            .unwrap()
1798            .host_str()
1799            .unwrap()
1800            .to_string();
1801        let client = build_test_client_for_http("crossref", &host);
1802        let url: Url = format!("{}/data.json", server.uri()).parse().unwrap();
1803        let (body, _final_url) = client.fetch_bytes("crossref", url).await.expect("ok");
1804        assert_eq!(&body[..], br#"{"hello":"world"}"#);
1805    }
1806
1807    #[tokio::test]
1808    async fn oversized_body_via_content_length_short_circuits() {
1809        // Wiremock can advertise a `Content-Length` larger than the body
1810        // it actually serves; hyper accepts the mismatch and our
1811        // fast-path check fires before any body bytes are consumed.
1812        let server = MockServer::start().await;
1813        let oversized = PDF_MAX_BYTES + 1;
1814        Mock::given(method("GET"))
1815            .and(path("/huge"))
1816            .respond_with(
1817                ResponseTemplate::new(200)
1818                    .insert_header("content-length", oversized.to_string().as_str())
1819                    .set_body_bytes(b"%PDF-".to_vec()),
1820            )
1821            .mount(&server)
1822            .await;
1823        let host = server
1824            .uri()
1825            .parse::<Url>()
1826            .unwrap()
1827            .host_str()
1828            .unwrap()
1829            .to_string();
1830        let client = build_test_client_for_http("crossref", &host);
1831        let url: Url = format!("{}/huge", server.uri()).parse().unwrap();
1832        let err = client
1833            .fetch_bytes("crossref", url)
1834            .await
1835            .expect_err("should reject");
1836        match err {
1837            HttpError::OversizedBody { actual, cap } => {
1838                assert!(actual > cap, "actual {} should exceed cap {}", actual, cap);
1839                assert_eq!(cap, PDF_MAX_BYTES);
1840            }
1841            // The mismatched Content-Length may also trip an underlying
1842            // transport error before our fast-path runs. Either outcome
1843            // satisfies the security goal (the transfer was aborted
1844            // without buffering 100 GB), so accept Network here as a
1845            // wiremock idiosyncrasy rather than a contract relaxation.
1846            HttpError::Network(_) => {}
1847            other => panic!("expected OversizedBody or Network, got {:?}", other),
1848        }
1849    }
1850
1851    #[tokio::test]
1852    async fn unknown_source_rejected() {
1853        let client = HttpClient::new(tier_1_allowlist()).expect("client builds");
1854        let url: Url = "https://api.crossref.org/works/10.1234/x".parse().unwrap();
1855        let err = client
1856            .fetch_bytes("not-a-source", url)
1857            .await
1858            .expect_err("unknown source");
1859        match err {
1860            HttpError::UnknownSource { source_key } => {
1861                assert_eq!(source_key, "not-a-source")
1862            }
1863            other => panic!("expected UnknownSource, got {:?}", other),
1864        }
1865    }
1866
1867    #[tokio::test]
1868    async fn http_status_error_surfaces() {
1869        let server = MockServer::start().await;
1870        Mock::given(method("GET"))
1871            .and(path("/missing"))
1872            .respond_with(ResponseTemplate::new(404))
1873            .mount(&server)
1874            .await;
1875        let host = server
1876            .uri()
1877            .parse::<Url>()
1878            .unwrap()
1879            .host_str()
1880            .unwrap()
1881            .to_string();
1882        let client = build_test_client_for_http("crossref", &host);
1883        let url: Url = format!("{}/missing", server.uri()).parse().unwrap();
1884        let err = client.fetch_bytes("crossref", url).await.expect_err("404");
1885        match err {
1886            HttpError::HttpStatus { status, .. } => assert_eq!(status, 404),
1887            other => panic!("expected HttpStatus, got {:?}", other),
1888        }
1889    }
1890
1891    // ---------------------------------------------------------------
1892    // Redirect policy tests — drive the closure via wiremock 30x
1893    // responses pointing at insecure / off-allowlist targets. With
1894    // `https_only(true)` on the production builder the request never
1895    // leaves the initial leg — we run these against the test builder
1896    // (which relaxes `https_only` for the *initial* leg only) so the
1897    // redirect closure is reached and exercised.
1898    // ---------------------------------------------------------------
1899
1900    #[tokio::test]
1901    async fn redirect_to_http_is_rejected_by_closure() {
1902        let server = MockServer::start().await;
1903        Mock::given(method("GET"))
1904            .and(path("/redir"))
1905            .respond_with(
1906                ResponseTemplate::new(302).insert_header("location", "http://attacker.test/file"),
1907            )
1908            .mount(&server)
1909            .await;
1910        let host = server
1911            .uri()
1912            .parse::<Url>()
1913            .unwrap()
1914            .host_str()
1915            .unwrap()
1916            .to_string();
1917        let client = build_test_client_for_http("crossref", &host);
1918        let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
1919        let err = client
1920            .fetch_bytes("crossref", url)
1921            .await
1922            .expect_err("redirect to http rejected");
1923        match err {
1924            HttpError::Network(e) => {
1925                let msg = format!("{:?}", e);
1926                assert!(
1927                    msg.contains("InsecureRedirect") || msg.contains("non-HTTPS"),
1928                    "expected insecure-redirect signal in error chain, got {}",
1929                    msg
1930                );
1931            }
1932            other => panic!("expected Network(InsecureRedirect), got {:?}", other),
1933        }
1934    }
1935
1936    #[tokio::test]
1937    async fn redirect_outside_allowlist_is_rejected_by_closure() {
1938        let server = MockServer::start().await;
1939        Mock::given(method("GET"))
1940            .and(path("/redir"))
1941            .respond_with(
1942                ResponseTemplate::new(302).insert_header("location", "https://attacker.test/file"),
1943            )
1944            .mount(&server)
1945            .await;
1946        let host = server
1947            .uri()
1948            .parse::<Url>()
1949            .unwrap()
1950            .host_str()
1951            .unwrap()
1952            .to_string();
1953        let client = build_test_client_for_http("crossref", &host);
1954        let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
1955        let err = client
1956            .fetch_bytes("crossref", url)
1957            .await
1958            .expect_err("redirect to attacker rejected");
1959        match err {
1960            HttpError::Network(e) => {
1961                let msg = format!("{:?}", e);
1962                assert!(
1963                    msg.contains("RedirectDenied") || msg.contains("not in allowlist"),
1964                    "expected redirect-denied signal in error chain, got {}",
1965                    msg
1966                );
1967            }
1968            other => panic!("expected Network(RedirectDenied), got {:?}", other),
1969        }
1970    }
1971
1972    #[tokio::test]
1973    async fn redirect_to_allowlisted_https_host_is_followed_by_closure() {
1974        // 302 to an https host that IS in the allowlist. The redirect
1975        // dispatch will fail (DNS won't resolve `mirror.allowed.test`)
1976        // but the closure must NOT short-circuit — failure mode is a
1977        // transport error, not InsecureRedirect / RedirectDenied.
1978        let server = MockServer::start().await;
1979        Mock::given(method("GET"))
1980            .and(path("/redir"))
1981            .respond_with(
1982                ResponseTemplate::new(302)
1983                    .insert_header("location", "https://mirror.allowed.test/file"),
1984            )
1985            .mount(&server)
1986            .await;
1987        let initial_host = server
1988            .uri()
1989            .parse::<Url>()
1990            .unwrap()
1991            .host_str()
1992            .unwrap()
1993            .to_string();
1994        // Allow the initial host AND the redirect target host.
1995        let allowlist = SourceAllowlist::new(
1996            "crossref",
1997            vec![initial_host.clone(), "*.allowed.test".to_string()],
1998        );
1999        let allowlist_for_closure = allowlist.clone();
2000        let policy = Policy::custom(move |attempt| {
2001            let scheme = attempt.url().scheme().to_string();
2002            let host_opt = attempt.url().host_str().map(|h| h.to_ascii_lowercase());
2003            if scheme != "https" {
2004                return attempt.error(HttpError::InsecureRedirect { scheme });
2005            }
2006            let h = match host_opt {
2007                Some(h) => h,
2008                None => {
2009                    return attempt.error(HttpError::RedirectDenied {
2010                        source_key: allowlist_for_closure.source.clone(),
2011                        host: String::new(),
2012                        expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
2013                    });
2014                }
2015            };
2016            if !allowlist_for_closure.matches(&h) {
2017                return attempt.error(HttpError::RedirectDenied {
2018                    source_key: allowlist_for_closure.source.clone(),
2019                    host: h,
2020                    expected_hosts: allowlist_for_closure.redirect_hosts.clone(),
2021                });
2022            }
2023            attempt.follow()
2024        });
2025        ensure_crypto_provider();
2026        let raw_client = ClientBuilder::new()
2027            .https_only(false)
2028            .redirect(policy)
2029            .connect_timeout(CONNECT_TIMEOUT)
2030            .timeout(Duration::from_secs(5))
2031            .user_agent("doiget/test")
2032            .tls_backend_rustls()
2033            .build()
2034            .expect("client builds");
2035        let url: Url = format!("{}/redir", server.uri()).parse().unwrap();
2036        let err = raw_client.get(url).send().await.expect_err("DNS fails");
2037        // The error should NOT carry our InsecureRedirect / RedirectDenied
2038        // marker — the closure approved the redirect.
2039        let msg = format!("{:?}", err);
2040        assert!(
2041            !msg.contains("RedirectDenied") && !msg.contains("InsecureRedirect"),
2042            "closure short-circuited an allowed redirect: {}",
2043            msg,
2044        );
2045    }
2046
2047    #[test]
2048    fn http_client_clone_is_cheap() {
2049        // Sanity: cloning shares the inner Arc<HashMap<...>>.
2050        let a = HttpClient::new(tier_1_allowlist()).expect("builds");
2051        let b = a.clone();
2052        assert_eq!(a.clients.len(), b.clients.len());
2053        assert!(Arc::ptr_eq(&a.clients, &b.clients));
2054    }
2055
2056    // ---------------------------------------------------------------
2057    // HttpError -> Option<DenialContext>  (ADR-0023 §4 mapping)
2058    // ---------------------------------------------------------------
2059
2060    #[test]
2061    fn denial_from_redirect_denied_carries_attempted_and_expected() {
2062        use crate::{DenialContext, DenialReason};
2063        let e = HttpError::RedirectDenied {
2064            source_key: "crossref".to_string(),
2065            host: "evil.example.com".to_string(),
2066            expected_hosts: vec!["api.crossref.org".to_string(), "*.crossref.org".to_string()],
2067        };
2068        let dc: Option<DenialContext> = (&e).into();
2069        let dc = dc.expect("RedirectDenied -> Some(DenialContext)");
2070        assert_eq!(dc.reason, DenialReason::RedirectNotInAllowlist);
2071        assert_eq!(dc.source.as_deref(), Some("crossref"));
2072        assert_eq!(dc.attempted.as_deref(), Some("evil.example.com"));
2073        assert_eq!(
2074            dc.expected.as_deref(),
2075            Some(&["api.crossref.org".to_string(), "*.crossref.org".to_string()][..])
2076        );
2077        assert!(dc.cap.is_none());
2078        assert!(dc.actual.is_none());
2079        assert!(dc.hop_index.is_none());
2080    }
2081
2082    #[test]
2083    fn denial_from_oversized_body_carries_cap_and_actual() {
2084        use crate::{DenialContext, DenialReason};
2085        let e = HttpError::OversizedBody {
2086            actual: 209_715_200,
2087            cap: PDF_MAX_BYTES,
2088        };
2089        let dc: Option<DenialContext> = (&e).into();
2090        let dc = dc.expect("OversizedBody -> Some(DenialContext)");
2091        assert_eq!(dc.reason, DenialReason::SizeCapExceeded);
2092        assert_eq!(dc.cap, Some(PDF_MAX_BYTES));
2093        assert_eq!(dc.actual, Some(209_715_200));
2094        assert!(dc.source.is_none());
2095        assert!(dc.attempted.is_none());
2096        // OversizedBody has no allowlist channel: producer leaves
2097        // `expected` at `None` (NOT `Some(vec![])`). See the field doc on
2098        // `DenialContext::expected` for the disambiguation.
2099        assert!(dc.expected.is_none());
2100    }
2101
2102    #[test]
2103    fn denial_from_not_a_pdf_hex_encodes_got_bytes() {
2104        use crate::{DenialContext, DenialReason};
2105        // First 5 bytes of "<html" — what the magic-byte check sees when
2106        // a publisher returns an HTML interstitial instead of a PDF.
2107        let e = HttpError::NotAPdf {
2108            got: [0x3c, 0x68, 0x74, 0x6d, 0x6c],
2109        };
2110        let dc: Option<DenialContext> = (&e).into();
2111        let dc = dc.expect("NotAPdf -> Some(DenialContext)");
2112        assert_eq!(dc.reason, DenialReason::ContentTypeMismatch);
2113        assert_eq!(dc.attempted.as_deref(), Some("3c68746d6c"));
2114        assert_eq!(dc.expected.as_deref(), Some(&["%PDF-".to_string()][..]));
2115    }
2116
2117    #[test]
2118    fn denial_from_insecure_redirect_marks_insecure_scheme() {
2119        use crate::{DenialContext, DenialReason};
2120        let e = HttpError::InsecureRedirect {
2121            scheme: "http".to_string(),
2122        };
2123        let dc: Option<DenialContext> = (&e).into();
2124        let dc = dc.expect("InsecureRedirect -> Some(DenialContext)");
2125        // ADR-0023 §4 (post-incorporation review): InsecureRedirect maps
2126        // to its own dedicated `InsecureScheme` reason, not the host-
2127        // allowlist reason — they are semantically distinct denials.
2128        assert_eq!(dc.reason, DenialReason::InsecureScheme);
2129        assert_eq!(dc.attempted.as_deref(), Some("http:..."));
2130        assert_eq!(dc.expected.as_deref(), Some(&["https".to_string()][..]));
2131    }
2132
2133    #[test]
2134    fn denial_from_non_denial_variants_returns_none() {
2135        use crate::DenialContext;
2136        // Network / HttpStatus / UnknownSource are not denials; they
2137        // map to None per ADR-0023 §4.
2138        let e = HttpError::HttpStatus {
2139            status: 503,
2140            url: "https://api.crossref.org/works/x".to_string(),
2141        };
2142        let dc: Option<DenialContext> = (&e).into();
2143        assert!(dc.is_none(), "HttpStatus must not produce a DenialContext");
2144
2145        let e = HttpError::UnknownSource {
2146            source_key: "ghost".to_string(),
2147        };
2148        let dc: Option<DenialContext> = (&e).into();
2149        assert!(
2150            dc.is_none(),
2151            "UnknownSource must not produce a DenialContext"
2152        );
2153    }
2154
2155    // ---------------------------------------------------------------
2156    // Issue #117 — transient retry / backoff. Real time: wiremock
2157    // serves over real localhost IO and tokio `start_paused` is
2158    // incompatible with that (it auto-advances past reqwest's
2159    // timeout). Backoff is small enough that the slowest case
2160    // (persistent 503, 3 retries ≈ 3.5s) stays within the suite budget.
2161    // ---------------------------------------------------------------
2162
2163    fn host_of(server: &MockServer) -> String {
2164        server
2165            .uri()
2166            .parse::<Url>()
2167            .unwrap()
2168            .host_str()
2169            .unwrap()
2170            .to_string()
2171    }
2172
2173    #[tokio::test]
2174    async fn transient_503_then_200_succeeds() {
2175        let server = MockServer::start().await;
2176        // Catch-all 200 mounted first (lowest precedence); the
2177        // single-shot 503 mounted last takes precedence for the first
2178        // request only, then falls through to the 200.
2179        Mock::given(method("GET"))
2180            .and(path("/p"))
2181            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"ok":1}"#))
2182            .mount(&server)
2183            .await;
2184        Mock::given(method("GET"))
2185            .and(path("/p"))
2186            .respond_with(ResponseTemplate::new(503))
2187            .up_to_n_times(1)
2188            .mount(&server)
2189            .await;
2190
2191        let client = build_test_client_for_http("crossref", &host_of(&server));
2192        let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2193        let (body, _) = client
2194            .fetch_bytes("crossref", url)
2195            .await
2196            .expect("503-then-200 must succeed after one retry");
2197        assert_eq!(&body[..], br#"{"ok":1}"#);
2198    }
2199
2200    #[tokio::test]
2201    async fn persistent_503_exhausts_and_returns_httpstatus() {
2202        let server = MockServer::start().await;
2203        Mock::given(method("GET"))
2204            .and(path("/p"))
2205            .respond_with(ResponseTemplate::new(503))
2206            .mount(&server)
2207            .await;
2208
2209        let client = build_test_client_for_http("crossref", &host_of(&server));
2210        let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2211        let err = client
2212            .fetch_bytes("crossref", url)
2213            .await
2214            .expect_err("persistent 503 must exhaust retries");
2215        match err {
2216            HttpError::HttpStatus { status, .. } => assert_eq!(status, 503),
2217            other => panic!("expected HttpStatus 503, got {other:?}"),
2218        }
2219        // First attempt + MAX_FETCH_RETRIES retries.
2220        let reqs = server
2221            .received_requests()
2222            .await
2223            .expect("wiremock records requests");
2224        assert_eq!(reqs.len(), (MAX_FETCH_RETRIES + 1) as usize);
2225    }
2226
2227    #[tokio::test]
2228    async fn retry_after_429_then_200_succeeds() {
2229        let server = MockServer::start().await;
2230        Mock::given(method("GET"))
2231            .and(path("/p"))
2232            .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
2233            .mount(&server)
2234            .await;
2235        Mock::given(method("GET"))
2236            .and(path("/p"))
2237            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "1"))
2238            .up_to_n_times(1)
2239            .mount(&server)
2240            .await;
2241
2242        let client = build_test_client_for_http("crossref", &host_of(&server));
2243        let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2244        let (body, _) = client
2245            .fetch_bytes("crossref", url)
2246            .await
2247            .expect("429+Retry-After then 200 must succeed");
2248        assert_eq!(&body[..], b"ok");
2249    }
2250
2251    #[tokio::test]
2252    async fn permanent_404_is_not_retried() {
2253        let server = MockServer::start().await;
2254        Mock::given(method("GET"))
2255            .and(path("/p"))
2256            .respond_with(ResponseTemplate::new(404))
2257            .mount(&server)
2258            .await;
2259
2260        let client = build_test_client_for_http("crossref", &host_of(&server));
2261        let url: Url = format!("{}/p", server.uri()).parse().unwrap();
2262        let _ = client
2263            .fetch_bytes("crossref", url)
2264            .await
2265            .expect_err("404 must fail");
2266        let reqs = server
2267            .received_requests()
2268            .await
2269            .expect("wiremock records requests");
2270        assert_eq!(reqs.len(), 1, "4xx (non-408/429) must NOT be retried");
2271    }
2272}