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