Skip to main content

oxideav_http/
lib.rs

1//! HTTP/HTTPS source driver for oxideav.
2//!
3//! Implements `Read + Seek` over HTTP `Range` requests so any container
4//! demuxer that takes a `Box<dyn BytesSource>` can read directly from a
5//! URL. Servers must support `Range: bytes=…` (most static-file hosts
6//! do; we verify with a `HEAD` at construction).
7//!
8//! Wire it into a [`oxideav_core::RuntimeContext`] with [`register`]:
9//! both `http` and `https` register as bytes-shape sources, so
10//! `ctx.sources.open(uri)` yields `SourceOutput::Bytes(_)`. For callers
11//! that hold a bare [`oxideav_source::SourceRegistry`] use
12//! [`register_source`] directly.
13//!
14//! ```no_run
15//! let mut ctx = oxideav_core::RuntimeContext::new();
16//! oxideav_http::register(&mut ctx);
17//! let _r = ctx.sources.open("https://example.com/clip.mp4").unwrap();
18//! ```
19//!
20//! ## Configuring the underlying agent
21//!
22//! By default the driver uses a process-wide `ureq` agent with library
23//! defaults. To tighten policy (redirect cap, downgrade-safe
24//! `Authorization` handling, custom `User-Agent`, timeouts, https-only
25//! mode) build an [`HttpConfig`] and either:
26//!
27//! 1. Install it once at startup with [`install_default_config`], so
28//!    every `ctx.sources.open("http://…")` call honours it; or
29//! 2. Open a one-off source with [`HttpSource::open_with_config`],
30//!    leaving the registry-default agent untouched.
31//!
32//! ```no_run
33//! let cfg = oxideav_http::HttpConfig::builder()
34//!     .max_redirects(5)
35//!     .user_agent("my-app/1.0")
36//!     .https_only(true)
37//!     .build();
38//! oxideav_http::install_default_config(cfg).ok();
39//! ```
40//!
41//! ## Mid-stream mutation detection (RFC 9110 §13.1.5)
42//!
43//! When the origin's `HEAD` reply carries a STRONG validator (an ETag
44//! without the `W/` weakness prefix, or a `Last-Modified` whose
45//! companion `Date` header is at least one second later — the §8.8.2.2
46//! promotion rule), every subsequent `Range: bytes=N-` GET is sent
47//! with `If-Range: <validator>`. Per §13.1.5 the server then EITHER
48//! satisfies the range (`206 Partial Content` — happy path) OR ignores
49//! `Range` and returns the full new representation (`200 OK`). The
50//! driver treats the latter as a fatal mid-stream-mutation error
51//! rather than silently re-anchoring the byte offset against a
52//! different resource.
53
54use std::io::{self, Read, Seek, SeekFrom};
55use std::sync::OnceLock;
56use std::time::Duration;
57
58use oxideav_core::BytesSource;
59use oxideav_core::RuntimeContext;
60use oxideav_core::{Error, Result};
61use oxideav_source::SourceRegistry;
62use ureq::Agent;
63
64/// Install the `http` + `https` schemes into a full runtime context.
65///
66/// This is the unified entry point every sibling crate exposes; it
67/// just forwards to [`register_source`] on `ctx.sources`.
68pub fn register(ctx: &mut RuntimeContext) {
69    register_source(&mut ctx.sources);
70}
71
72oxideav_core::register!("http", register);
73
74/// Register the `http` and `https` schemes on a bare
75/// [`SourceRegistry`] as bytes sources. Both schemes share the same
76/// opener (`open_http`).
77///
78/// Most callers should use [`register`] instead — it threads through a
79/// full [`RuntimeContext`].
80pub fn register_source(registry: &mut SourceRegistry) {
81    registry.register_bytes("http", open_http);
82    registry.register_bytes("https", open_http);
83}
84
85/// Open a URL as a seekable byte source.
86pub fn open_http(uri: &str) -> Result<Box<dyn BytesSource>> {
87    let src = HttpSource::open(uri)?;
88    Ok(Box::new(src))
89}
90
91// ---------------------------------------------------------------------------
92// Configuration
93// ---------------------------------------------------------------------------
94
95/// Policy for re-sending an `Authorization` header across an HTTP
96/// redirect.
97///
98/// Mirrors `ureq::config::RedirectAuthHeaders` — exposed here so the
99/// public surface stays independent of which underlying client we wire
100/// in. Default is [`RedirectAuthPolicy::Never`].
101#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
102pub enum RedirectAuthPolicy {
103    /// Strip `Authorization` on every redirect.
104    #[default]
105    Never,
106    /// Keep `Authorization` only when the target shares the origin host
107    /// and the scheme does not downgrade (e.g. `https` → `https` is
108    /// fine, `https` → `http` is not).
109    SameHost,
110}
111
112/// Tunable policy for the HTTP/HTTPS source driver.
113///
114/// Build with [`HttpConfig::builder`]; finalised with
115/// [`HttpConfigBuilder::build`]. A default `HttpConfig` matches the
116/// library's pre-r77 behaviour: ureq defaults, no extra restrictions,
117/// `Authorization` stripped on redirect.
118///
119/// `HttpConfig` is a thin policy struct; it does *not* itself hold the
120/// underlying agent. Either install one globally with
121/// [`install_default_config`] (one-shot, before the first open call)
122/// or pass to [`HttpSource::open_with_config`] per-request.
123#[derive(Debug, Clone)]
124pub struct HttpConfig {
125    max_redirects: u32,
126    max_redirects_will_error: bool,
127    redirect_auth_policy: RedirectAuthPolicy,
128    user_agent: Option<String>,
129    https_only: bool,
130    timeout_global: Option<Duration>,
131    timeout_connect: Option<Duration>,
132}
133
134impl Default for HttpConfig {
135    fn default() -> Self {
136        // ureq 3 defaults: 10 redirects, error on cap, no user-agent
137        // override, no https-only, no timeouts. We override
138        // `redirect_auth_policy` to `Never` (matches ureq's own
139        // default but pin it explicitly for surface clarity).
140        Self {
141            max_redirects: 10,
142            max_redirects_will_error: true,
143            redirect_auth_policy: RedirectAuthPolicy::Never,
144            user_agent: None,
145            https_only: false,
146            timeout_global: None,
147            timeout_connect: None,
148        }
149    }
150}
151
152impl HttpConfig {
153    /// Start a builder pre-populated with the library defaults.
154    pub fn builder() -> HttpConfigBuilder {
155        HttpConfigBuilder {
156            inner: Self::default(),
157        }
158    }
159
160    /// Maximum number of redirects the client will follow before
161    /// giving up.
162    pub fn max_redirects(&self) -> u32 {
163        self.max_redirects
164    }
165
166    /// Whether exceeding [`Self::max_redirects`] surfaces as an error
167    /// (`true`) or returns the final 3xx response (`false`).
168    pub fn max_redirects_will_error(&self) -> bool {
169        self.max_redirects_will_error
170    }
171
172    /// Redirect handling for the `Authorization` header.
173    pub fn redirect_auth_policy(&self) -> RedirectAuthPolicy {
174        self.redirect_auth_policy
175    }
176
177    /// Custom `User-Agent` header value, if set.
178    pub fn user_agent(&self) -> Option<&str> {
179        self.user_agent.as_deref()
180    }
181
182    /// Whether to reject all `http://` requests (including redirect
183    /// targets) at the agent level.
184    pub fn https_only(&self) -> bool {
185        self.https_only
186    }
187
188    /// End-to-end timeout for an entire call (DNS through body read).
189    pub fn timeout_global(&self) -> Option<Duration> {
190        self.timeout_global
191    }
192
193    /// Maximum time to establish a connection (TCP + TLS handshake).
194    pub fn timeout_connect(&self) -> Option<Duration> {
195        self.timeout_connect
196    }
197}
198
199/// Builder for [`HttpConfig`].
200#[derive(Debug, Clone)]
201pub struct HttpConfigBuilder {
202    inner: HttpConfig,
203}
204
205impl HttpConfigBuilder {
206    /// Cap the redirect chain. ureq's default is 10.
207    pub fn max_redirects(mut self, n: u32) -> Self {
208        self.inner.max_redirects = n;
209        self
210    }
211
212    /// If `true`, hitting [`HttpConfigBuilder::max_redirects`] is an
213    /// error (default); if `false`, the last 3xx response is returned.
214    pub fn max_redirects_will_error(mut self, v: bool) -> Self {
215        self.inner.max_redirects_will_error = v;
216        self
217    }
218
219    /// How `Authorization` should be carried across redirects. Default
220    /// is [`RedirectAuthPolicy::Never`].
221    pub fn redirect_auth_policy(mut self, p: RedirectAuthPolicy) -> Self {
222        self.inner.redirect_auth_policy = p;
223        self
224    }
225
226    /// Override the `User-Agent` request header. ureq's own default
227    /// (`ureq/<version>`) is used when this is left unset.
228    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
229        self.inner.user_agent = Some(ua.into());
230        self
231    }
232
233    /// When `true`, the agent refuses any non-`https` URL, including
234    /// redirect targets. Default `false`.
235    pub fn https_only(mut self, v: bool) -> Self {
236        self.inner.https_only = v;
237        self
238    }
239
240    /// End-to-end timeout for an entire call. None = unlimited.
241    pub fn timeout_global(mut self, d: Option<Duration>) -> Self {
242        self.inner.timeout_global = d;
243        self
244    }
245
246    /// Connect timeout (TCP + TLS handshake). None = unlimited.
247    pub fn timeout_connect(mut self, d: Option<Duration>) -> Self {
248        self.inner.timeout_connect = d;
249        self
250    }
251
252    /// Finalise the policy.
253    pub fn build(self) -> HttpConfig {
254        self.inner
255    }
256}
257
258fn agent_from(cfg: &HttpConfig) -> Agent {
259    let mut b = Agent::config_builder()
260        .max_redirects(cfg.max_redirects)
261        .max_redirects_will_error(cfg.max_redirects_will_error)
262        .https_only(cfg.https_only)
263        .redirect_auth_headers(match cfg.redirect_auth_policy {
264            RedirectAuthPolicy::Never => ureq::config::RedirectAuthHeaders::Never,
265            RedirectAuthPolicy::SameHost => ureq::config::RedirectAuthHeaders::SameHost,
266        })
267        // Inspect 4xx/5xx ourselves so we can give RFC 9110 §15.5.17 +
268        // §14.4 treatment to 416 (Range Not Satisfiable). With the
269        // status-as-error default, the response's Content-Range body is
270        // surfaced as an opaque status string and the unsatisfied-range
271        // payload is lost before our handler ever sees it.
272        .http_status_as_error(false);
273    if let Some(ua) = cfg.user_agent.as_deref() {
274        b = b.user_agent(ua);
275    }
276    b = b
277        .timeout_global(cfg.timeout_global)
278        .timeout_connect(cfg.timeout_connect);
279    b.build().new_agent()
280}
281
282// ---------------------------------------------------------------------------
283// Default / global agent
284// ---------------------------------------------------------------------------
285
286static DEFAULT_CONFIG: OnceLock<HttpConfig> = OnceLock::new();
287static DEFAULT_AGENT: OnceLock<Agent> = OnceLock::new();
288
289/// Returned by [`install_default_config`] when the global agent has
290/// already been materialised (either by a prior `install_default_config`
291/// call or by a `register`/`open` call that consumed the default).
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub struct ConfigAlreadyInstalled;
294
295impl std::fmt::Display for ConfigAlreadyInstalled {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.write_str("oxideav-http default config has already been installed")
298    }
299}
300
301impl std::error::Error for ConfigAlreadyInstalled {}
302
303/// Install a process-wide [`HttpConfig`] that the default scheme
304/// openers ([`register`], [`register_source`], [`open_http`]) will use.
305///
306/// One-shot: returns [`ConfigAlreadyInstalled`] if either the config or
307/// the agent has already been built. Call this once during program
308/// start-up, *before* the first source-registry open. Per-call
309/// overrides remain available through [`HttpSource::open_with_config`]
310/// even after a default has been installed.
311pub fn install_default_config(cfg: HttpConfig) -> std::result::Result<(), ConfigAlreadyInstalled> {
312    if DEFAULT_AGENT.get().is_some() {
313        return Err(ConfigAlreadyInstalled);
314    }
315    DEFAULT_CONFIG.set(cfg).map_err(|_| ConfigAlreadyInstalled)
316}
317
318fn agent() -> &'static Agent {
319    DEFAULT_AGENT.get_or_init(|| {
320        let cfg = DEFAULT_CONFIG.get().cloned().unwrap_or_default();
321        agent_from(&cfg)
322    })
323}
324
325// ---------------------------------------------------------------------------
326// HttpSource
327// ---------------------------------------------------------------------------
328
329/// Strong validator captured at HEAD construction, used as the
330/// `If-Range` value on subsequent `Range` GETs per RFC 9110 §13.1.5.
331///
332/// Per §13.1.5 a client MUST NOT send a weak entity tag in `If-Range`,
333/// and a `Last-Modified`-based `If-Range` is only legitimate when the
334/// `Last-Modified` value is a strong validator per §8.8.2.2 (here:
335/// the HEAD response's `Date` is at least one second after its
336/// `Last-Modified`). When neither condition holds we record `None`
337/// and the GET path goes out without `If-Range`.
338#[derive(Debug, Clone, PartialEq, Eq)]
339enum StrongValidator {
340    /// `ETag: "<opaque>"` — already verified strong (no `W/` prefix).
341    /// Stored as the wire form (with surrounding double quotes).
342    Etag(String),
343    /// `Last-Modified: <HTTP-date>` deemed strong by the §8.8.2.2
344    /// "Date - Last-Modified >= 1s" rule. Stored as the wire form.
345    LastModified(String),
346}
347
348impl StrongValidator {
349    /// Render as a complete `If-Range` field value.
350    fn as_if_range(&self) -> &str {
351        match self {
352            StrongValidator::Etag(s) => s,
353            StrongValidator::LastModified(s) => s,
354        }
355    }
356}
357
358/// `ReadSeek` over an HTTP/HTTPS resource, using `Range` requests.
359pub struct HttpSource {
360    uri: String,
361    total_len: u64,
362    pos: u64,
363    /// Per-source agent. When `None` we use the shared default agent.
364    agent: Option<Agent>,
365    /// Strong validator captured at HEAD, replayed as `If-Range` on
366    /// every subsequent range GET so the server replaces a stale
367    /// 206 with a 200 — which we surface as a fatal mid-stream
368    /// mutation rather than silently re-anchor.
369    validator: Option<StrongValidator>,
370    /// Active response body for the current contiguous read run, if any.
371    body: Option<Box<dyn Read + Send>>,
372}
373
374impl HttpSource {
375    /// Open a URL with the process-wide default agent.
376    ///
377    /// See [`install_default_config`] for how to tune that agent
378    /// once at startup. For one-off overrides without touching the
379    /// global agent, use [`HttpSource::open_with_config`].
380    pub fn open(uri: &str) -> Result<Self> {
381        Self::open_impl(uri, None)
382    }
383
384    /// Open a URL using a one-off agent built from `cfg`.
385    ///
386    /// The agent is owned by the returned `HttpSource` and dropped
387    /// when it goes out of scope; the process-wide default agent is
388    /// unaffected.
389    pub fn open_with_config(uri: &str, cfg: &HttpConfig) -> Result<Self> {
390        Self::open_impl(uri, Some(agent_from(cfg)))
391    }
392
393    fn open_impl(uri: &str, scoped: Option<Agent>) -> Result<Self> {
394        let head_agent: &Agent = scoped.as_ref().unwrap_or_else(|| agent());
395        // RFC 9110 §12.5.3 rule 1: "If no Accept-Encoding header field
396        // is in the request, any content coding is considered
397        // acceptable by the user agent." The driver's whole byte-offset
398        // model (Content-Length recorded here, Content-Range echoes,
399        // the §3.1 prefix drain) assumes the wire bytes ARE the
400        // representation bytes a demuxer consumes, so any content
401        // coding is in fact unacceptable. `Accept-Encoding: identity`
402        // lists only the §12.5.3 "no encoding" synonym, making every
403        // real coding fall under rule 3's "not listed" and steering a
404        // conformant server to "send a response without any content
405        // coding".
406        let head = head_agent
407            .head(uri)
408            .header("Accept-Encoding", "identity")
409            .call()
410            .map_err(|e| Error::other(format!("HTTP HEAD {uri}: {e}")))?;
411
412        let status = head.status();
413        if !status.is_success() {
414            // RFC 9110 §10.2.3: when sent with 503 (Service Unavailable),
415            // Retry-After indicates how long the service is expected to
416            // be unavailable; with 3xx (Redirection) responses it
417            // indicates the minimum time before a follow-up request.
418            // §10.2.3 says the field MAY also accompany 429 (RFC 6585).
419            // We surface the parsed value in the error message so a
420            // caller wiring back-off doesn't have to also fish the
421            // header out of a now-consumed response. The driver itself
422            // does NOT sleep — interpreting an absolute UTC date
423            // requires a clock the source does not own, and back-off
424            // strategy belongs in the caller.
425            let retry_after = head
426                .headers()
427                .get("retry-after")
428                .and_then(|v| v.to_str().ok())
429                .map(normalize_obs_fold);
430            // RFC 7230 §3.2.4: normalise obs-fold "prior to interpreting
431            // the field value". `format_retry_after_hint` is the
432            // interpretation step here, so the normalisation must
433            // happen before it sees the input.
434            let retry_msg = retry_after
435                .as_deref()
436                .map(format_retry_after_hint)
437                .unwrap_or_default();
438            return Err(Error::other(format!(
439                "HTTP HEAD {uri}: status {status}{retry_msg}"
440            )));
441        }
442        let headers = head.headers();
443        // RFC 9110 §8.4: "the representation is defined in terms of the
444        // coded form, and all other metadata about the representation
445        // is about the coded form unless otherwise noted". So if a
446        // content coding is in effect, the Content-Length we are about
447        // to record — and every byte-range offset we will later request
448        // against it — describes the CODED bytes, not the media bytes a
449        // demuxer expects. The driver decodes no content codings, so a
450        // coded representation is unusable: refuse at open with the
451        // coding names rather than hand a demuxer compressed bytes (or
452        // a coded-length total) it would misparse downstream. The check
453        // walks every Content-Encoding field line (the §8.4 `#` list
454        // form may be split across lines per §5.6.1) and tolerates only
455        // the redundant `identity` token. Obs-fold is normalised before
456        // interpretation per RFC 7230 §3.2.4.
457        let mut head_codings: Vec<String> = Vec::new();
458        for v in headers.get_all("content-encoding") {
459            match v.to_str() {
460                Ok(s) => head_codings.extend(non_identity_content_codings(&normalize_obs_fold(s))),
461                // A field value we cannot even read as a string cannot
462                // name a coding we know how to leave alone — fail
463                // toward rejection, never toward silent acceptance.
464                Err(_) => head_codings.push("<non-ASCII content-coding>".to_owned()),
465            }
466        }
467        if !head_codings.is_empty() {
468            return Err(Error::Unsupported(format!(
469                "HTTP HEAD {uri}: representation carries Content-Encoding {head_codings:?} \
470                 despite 'Accept-Encoding: identity' (RFC 9110 §12.5.3); per §8.4 the \
471                 Content-Length and byte ranges then describe the coded form, which the \
472                 driver does not decode"
473            )));
474        }
475        let total_len = headers
476            .get("content-length")
477            .and_then(|v| v.to_str().ok())
478            .and_then(|s| s.parse::<u64>().ok())
479            .ok_or_else(|| {
480                Error::Unsupported(format!("HTTP HEAD {uri}: missing Content-Length"))
481            })?;
482        // RFC 9110 §14.3: `Accept-Ranges = 1#range-unit`. Use the §5.6.1
483        // list parser instead of a bare equality so a server returning
484        // `Accept-Ranges: bytes, foo-unit` (legitimate per §14.3) is
485        // accepted, and so an explicit `Accept-Ranges: none` (§14.3's
486        // reserved-token advice) is reported distinctly from "header
487        // absent" — the former is the server saying "do not attempt",
488        // the latter is silence (§14.3 makes the header advisory, not
489        // mandatory, even for range-capable servers).
490        // RFC 7230 §3.2.4: normalise obs-fold "prior to interpreting
491        // the field value". `Accept-Ranges` is a §5.6.1 list whose
492        // delimiter (`,`) makes it a plausible target for obs-folding
493        // by older origins or proxies, so the normalisation is wired
494        // here before the §14.3 list parser runs.
495        let accept_ranges_owned = headers
496            .get("accept-ranges")
497            .and_then(|v| v.to_str().ok())
498            .map(normalize_obs_fold);
499        let accept_ranges_raw = accept_ranges_owned.as_deref().unwrap_or("");
500        match parse_accept_ranges(accept_ranges_raw) {
501            AcceptRanges::Bytes => {}
502            AcceptRanges::None => {
503                return Err(Error::Unsupported(format!(
504                    "HTTP HEAD {uri}: server explicitly refused range support (Accept-Ranges: none, RFC 9110 §14.3)"
505                )));
506            }
507            AcceptRanges::Other(units) => {
508                return Err(Error::Unsupported(format!(
509                    "HTTP HEAD {uri}: server advertises range units {units:?} but not 'bytes' (RFC 9110 §14.3)"
510                )));
511            }
512            AcceptRanges::Absent => {
513                // §14.3: "A client MAY generate range requests
514                // regardless of having received an Accept-Ranges
515                // field." But the present driver's correctness model
516                // (validate Content-Range echo etc.) needs the server
517                // to actually satisfy them, and a HEAD that omits the
518                // hint is also far more likely to refuse. Preserve
519                // the historical refusal here so we don't quietly
520                // start issuing Range GETs against servers we'd have
521                // refused before; the message is now distinct.
522                return Err(Error::Unsupported(format!(
523                    "HTTP HEAD {uri}: server did not advertise Accept-Ranges (RFC 9110 §14.3)"
524                )));
525            }
526        }
527        // Capture a STRONG validator per RFC 9110 §13.1.5 so subsequent
528        // range GETs can carry `If-Range: <validator>` and surface a
529        // mid-stream mutation cleanly. ETag takes precedence (§8.8.3
530        // is "more reliable for validation than a modification date"
531        // and the strong/weak distinction is grammatical); fall back to
532        // Last-Modified only when §8.8.2.2's "Date - Last-Modified >= 1s"
533        // rule promotes it from implicitly-weak to strong.
534        let etag_raw = headers
535            .get("etag")
536            .and_then(|v| v.to_str().ok())
537            .map(str::trim);
538        let last_modified = headers
539            .get("last-modified")
540            .and_then(|v| v.to_str().ok())
541            .map(str::trim);
542        let date_hdr = headers
543            .get("date")
544            .and_then(|v| v.to_str().ok())
545            .map(str::trim);
546        let validator = derive_strong_validator(etag_raw, last_modified, date_hdr);
547        // RFC 9110 §12.5.5: a `Vary` header reports the request fields
548        // the origin used to select this representation via proactive
549        // content negotiation (§12). The driver issues a HEAD, records
550        // `total_len`/`validator`, then satisfies reads with separate
551        // Range GETs — so it must know the representation it ranges over
552        // is the one the HEAD measured. The wildcard form is the unsafe
553        // case: §12.5.5 says it "signals that other aspects of the
554        // request might have played a role in selecting the response
555        // representation, possibly including aspects outside the message
556        // syntax (e.g., the client's network address)". The driver
557        // cannot reproduce such out-of-band aspects across requests, so
558        // a `Vary: *` resource may serve a *different* representation on
559        // the very next Range GET — with a different length the recorded
560        // `total_len` no longer describes, and different bytes a demuxer
561        // would silently misparse. The `If-Range` path (§13.1.5) catches
562        // exactly this kind of mid-stream divergence, but only when a
563        // strong validator exists to carry. So `Vary: *` is fatal *only*
564        // when no strong validator was captured; with one in hand, a
565        // representation swap re-materialises as the §13.1.5 200-fallback
566        // we already treat as a fatal mid-stream mutation in the GET
567        // path. The concrete-field-name form (§12.5.5 form 2) is always
568        // safe here: the driver sends a fixed, identical request header
569        // set on the HEAD and every Range GET, so negotiation that keys
570        // only on request fields lands on the same representation each
571        // time. Obs-fold is normalised before interpretation per RFC
572        // 7230 §3.2.4 (`Vary` is a §5.6.1 comma list, a plausible
573        // obs-fold target for older origins/proxies).
574        let vary_owned = headers
575            .get("vary")
576            .and_then(|v| v.to_str().ok())
577            .map(normalize_obs_fold);
578        if let Some(vary_raw) = vary_owned.as_deref() {
579            if parse_vary(vary_raw) == Vary::Wildcard && validator.is_none() {
580                return Err(Error::Unsupported(format!(
581                    "HTTP HEAD {uri}: response carries 'Vary: *' (RFC 9110 §12.5.5) with no \
582                     strong validator (ETag / promotable Last-Modified) — the origin warns the \
583                     representation may be selected on aspects outside the message syntax, so a \
584                     later Range GET could serve a different representation the driver cannot \
585                     detect (no If-Range guard per §13.1.5)"
586                )));
587            }
588        }
589        Ok(Self {
590            uri: uri.to_owned(),
591            total_len,
592            pos: 0,
593            agent: scoped,
594            validator,
595            body: None,
596        })
597    }
598
599    pub fn len(&self) -> u64 {
600        self.total_len
601    }
602
603    pub fn is_empty(&self) -> bool {
604        self.total_len == 0
605    }
606
607    fn agent_ref(&self) -> &Agent {
608        self.agent.as_ref().unwrap_or_else(|| agent())
609    }
610
611    fn issue_range(&mut self) -> io::Result<()> {
612        if self.pos >= self.total_len {
613            self.body = None;
614            return Ok(());
615        }
616        let range = format!("bytes={}-", self.pos);
617        // RFC 9110 §13.1.5: when we have a strong validator from HEAD,
618        // attach `If-Range: <validator>` so a mid-stream mutation flips
619        // the response from 206 to 200 (the §13.1.5 short-circuit) and
620        // we can surface it as a hard error below rather than silently
621        // re-anchor the byte offset against a different representation.
622        let if_range = self.validator.as_ref().map(|v| v.as_if_range().to_owned());
623        let sent_if_range = if_range.is_some();
624        // RFC 9110 §12.5.3: list only `identity` so a conformant server
625        // never applies a content coding — the byte offsets in `Range`
626        // and the Content-Range echo must keep describing the same
627        // bytes the demuxer reads (§8.4: representation metadata is
628        // about the coded form). See the HEAD-side comment in
629        // `open_impl` for the full rationale.
630        let mut req = self
631            .agent_ref()
632            .get(&self.uri)
633            .header("Range", &range)
634            .header("Accept-Encoding", "identity");
635        if let Some(ref v) = if_range {
636            req = req.header("If-Range", v.as_str());
637        }
638        let resp = req
639            .call()
640            .map_err(|e| io::Error::other(format!("HTTP GET {} {}: {e}", self.uri, range)))?;
641        let status = resp.status();
642        // RFC 9110 §15.5.17: a 416 (Range Not Satisfiable) means the
643        // server has rejected our requested range. §14.4 SHOULDs a
644        // `Content-Range: bytes */<complete-length>` header in this
645        // case, naming the server's current authoritative resource
646        // length. We surface that length so the caller can distinguish
647        // "past EOF" from "resource shrank mid-stream" (the HEAD said N
648        // and a later GET reports M < self.pos).
649        if status == 416 {
650            let cr_raw = resp
651                .headers()
652                .get("content-range")
653                .and_then(|v| v.to_str().ok())
654                .map(str::to_owned);
655            if let Some(cr) = cr_raw.as_deref() {
656                match parse_byte_unsatisfied_range(cr) {
657                    Ok(complete) => {
658                        return Err(io::Error::other(format!(
659                            "HTTP 416 {} {}: server reports complete-length {complete} (HEAD observed {}, requested pos {})",
660                            self.uri, range, self.total_len, self.pos
661                        )));
662                    }
663                    Err(e) => {
664                        return Err(io::Error::other(format!(
665                            "HTTP 416 {} {}: invalid Content-Range '{cr}': {e}",
666                            self.uri, range
667                        )));
668                    }
669                }
670            }
671            // §14.4 SHOULD, not MUST — a 416 with no Content-Range is
672            // unusual but legal. Still treat it as a hard error since
673            // the read can't proceed; just say so plainly.
674            return Err(io::Error::other(format!(
675                "HTTP 416 {} {}: server rejected range (no Content-Range body)",
676                self.uri, range
677            )));
678        }
679        if !(status == 206 || status == 200) {
680            return Err(io::Error::other(format!(
681                "HTTP GET {} {}: status {status}",
682                self.uri, range
683            )));
684        }
685        // RFC 9110 §8.4 + §12.5.3: we sent `Accept-Encoding: identity`,
686        // so a response that nevertheless carries a real content coding
687        // means the server ignored the field. Every byte the reader is
688        // about to see — and the Content-Length / Content-Range
689        // metadata validated below — would describe the coded form
690        // (§8.4), so the read cannot proceed. Checked before any other
691        // metadata is interpreted: a §8.6 length-mismatch diagnostic on
692        // a gzip-coded body would name the symptom, not the cause.
693        // §5.6.1: the `#` list may be split across field lines, so walk
694        // them all; obs-fold normalised first per RFC 7230 §3.2.4.
695        let mut get_codings: Vec<String> = Vec::new();
696        for v in resp.headers().get_all("content-encoding") {
697            match v.to_str() {
698                Ok(s) => get_codings.extend(non_identity_content_codings(&normalize_obs_fold(s))),
699                Err(_) => get_codings.push("<non-ASCII content-coding>".to_owned()),
700            }
701        }
702        if !get_codings.is_empty() {
703            return Err(io::Error::other(format!(
704                "HTTP {status} {} {}: response carries Content-Encoding {get_codings:?} \
705                 despite 'Accept-Encoding: identity' (RFC 9110 §12.5.3) — per §8.4 the \
706                 body and byte-range metadata describe the coded form, not the media bytes",
707                self.uri, range
708            )));
709        }
710        // RFC 9110 §13.1.5: when we sent `If-Range`, a 200 means the
711        // server's current validator did NOT match ours — i.e. the
712        // representation has been replaced since HEAD. Silently
713        // resuming on the new bytes would re-anchor every later
714        // file-offset view against a different resource; that's a
715        // misalignment bug, not a soft fallback. Surface as fatal.
716        if status == 200 && sent_if_range {
717            return Err(io::Error::other(format!(
718                "HTTP 200 {} {}: If-Range validator did not match — \
719                 representation changed since HEAD (origin/cache mutation)",
720                self.uri, range
721            )));
722        }
723        let pos = self.pos;
724        let total_len = self.total_len;
725        // RFC 9110 §8.6: "a server MUST NOT send Content-Length in [a
726        // HEAD] response unless its field value equals the decimal
727        // number of octets that would have been sent in the content of
728        // a response if the same request had used the GET method."
729        // So a 200-fallback (full-body GET, §3.1) whose Content-Length
730        // contradicts the HEAD-observed total is a resource resize we
731        // cannot recover from in-band — surface as a hard error rather
732        // than drain a now-wrong-sized prefix and read short.
733        let get_content_length = resp
734            .headers()
735            .get("content-length")
736            .and_then(|v| v.to_str().ok())
737            .and_then(|s| s.parse::<u64>().ok());
738        // Per RFC 7233 §3.1, a server that does not support (or chooses
739        // to ignore) Range MAY answer a range request with a full 200
740        // response. In that case we must drop the prefix [0, self.pos)
741        // before exposing bytes to the reader so the demuxer keeps a
742        // consistent file-offset view. We only walk this branch when
743        // we did NOT send If-Range; a 200 with If-Range is the
744        // mid-stream-mutation case handled above.
745        let skip_prefix = if status == 200 { pos } else { 0 };
746        if status == 200 {
747            if let Some(cl) = get_content_length {
748                if cl != total_len {
749                    return Err(io::Error::other(format!(
750                        "HTTP 200 {} {}: Content-Length {cl} != HEAD-observed total {total_len} \
751                         (RFC 9110 §8.6 — resource resized between HEAD and GET)",
752                        self.uri, range
753                    )));
754                }
755            }
756        }
757        if status == 206 {
758            // RFC 9110 §15.3.7.2 ("A server MUST NOT generate a multipart
759            // response to a request for a single range") + §14.6
760            // (multipart/byteranges media type). The driver only ever
761            // requests `Range: bytes=N-` (a single range), so a 206
762            // whose Content-Type is `multipart/byteranges` is a server
763            // bug. Surface a clean cite rather than letting the body
764            // surface as the binary representation type and have the
765            // §8.6 / Content-Range invariants light up downstream with
766            // confusing diagnostics: multipart bodies carry per-part
767            // Content-Range headers, the top-level Content-Range is
768            // either absent or names the synthetic outer span, and
769            // either way the demuxer would parse boundary delimiters
770            // as if they were bitstream bytes.
771            let ct_raw = resp
772                .headers()
773                .get("content-type")
774                .and_then(|v| v.to_str().ok())
775                .unwrap_or("");
776            if is_multipart_byteranges_content_type(ct_raw) {
777                return Err(io::Error::other(format!(
778                    "HTTP 206 {} {}: server returned multipart/byteranges to a single-range \
779                     request (RFC 9110 §15.3.7.2 MUST NOT). Content-Type: {ct_raw:?}",
780                    self.uri, range
781                )));
782            }
783            // RFC 7233 §4.2: validate the server's Content-Range echo
784            // covers what we asked for and is self-consistent. A 206
785            // without Content-Range, or with a multipart/byteranges
786            // (handled at the body layer, not via Content-Range), or
787            // with a different first-byte-pos than self.pos, would
788            // silently misalign every subsequent read.
789            let cr_raw = resp
790                .headers()
791                .get("content-range")
792                .ok_or_else(|| {
793                    io::Error::other(format!(
794                        "HTTP 206 {} {}: missing Content-Range",
795                        self.uri, range
796                    ))
797                })?
798                .to_str()
799                .map_err(|_| {
800                    io::Error::other(format!(
801                        "HTTP 206 {} {}: non-ASCII Content-Range",
802                        self.uri, range
803                    ))
804                })?
805                .to_owned();
806            let parsed = parse_byte_content_range(&cr_raw).map_err(|e| {
807                io::Error::other(format!(
808                    "HTTP 206 {} {}: invalid Content-Range '{cr_raw}': {e}",
809                    self.uri, range
810                ))
811            })?;
812            // first-byte-pos MUST match the position we asked for. The
813            // server is allowed to satisfy a partial subrange, but
814            // not to slide the start.
815            if parsed.first != pos {
816                return Err(io::Error::other(format!(
817                    "HTTP 206 {} {}: Content-Range first-byte-pos {} != requested pos {}",
818                    self.uri, range, parsed.first, pos
819                )));
820            }
821            // complete-length, when concrete, must equal the size we
822            // recorded at HEAD. A mid-stream resource resize is a
823            // cache/origin mismatch we cannot recover from in-band.
824            if let Some(complete) = parsed.complete {
825                if complete != total_len {
826                    return Err(io::Error::other(format!(
827                        "HTTP 206 {} {}: Content-Range complete-length {complete} != known total {total_len}",
828                        self.uri, range
829                    )));
830                }
831            }
832            // last-byte-pos must lie inside the representation we
833            // expect.
834            if parsed.last >= total_len {
835                return Err(io::Error::other(format!(
836                    "HTTP 206 {} {}: Content-Range last-byte-pos {} >= total {}",
837                    self.uri, range, parsed.last, total_len
838                )));
839            }
840            // RFC 9110 §8.6: when a 206 carries Content-Length, it MUST
841            // be the byte count of the body actually being sent — i.e.
842            // for a single-range 206 (the only form we ask for), it
843            // equals `last - first + 1`. A mismatch is either a
844            // multipart/byteranges body (we never request multi-range,
845            // and Content-Type would be `multipart/byteranges` not the
846            // raw representation type) or an outright framing bug;
847            // either way the demuxer's byte-offset view will drift if
848            // we proceed.
849            if let Some(cl) = get_content_length {
850                let expected = parsed.last - parsed.first + 1;
851                if cl != expected {
852                    return Err(io::Error::other(format!(
853                        "HTTP 206 {} {}: Content-Length {cl} != Content-Range span {expected} \
854                         (RFC 9110 §8.6)",
855                        self.uri, range
856                    )));
857                }
858            }
859        }
860        // ureq 3: Body owns the stream; into_body().into_reader() yields
861        // a `Read` that pulls from the wire as bytes are requested.
862        let mut reader: Box<dyn Read + Send> = Box::new(resp.into_body().into_reader());
863        if skip_prefix > 0 {
864            // Drain the prefix in 8 KiB chunks rather than allocating
865            // a single huge buffer for very large seek offsets.
866            let mut remaining = skip_prefix;
867            let mut buf = [0u8; 8 * 1024];
868            while remaining > 0 {
869                let want = remaining.min(buf.len() as u64) as usize;
870                let n = reader.read(&mut buf[..want])?;
871                if n == 0 {
872                    return Err(io::Error::new(
873                        io::ErrorKind::UnexpectedEof,
874                        format!(
875                            "HTTP 200 {}: EOF after draining {} of {skip_prefix} prefix bytes",
876                            self.uri,
877                            skip_prefix - remaining
878                        ),
879                    ));
880                }
881                remaining -= n as u64;
882            }
883        }
884        self.body = Some(reader);
885        Ok(())
886    }
887}
888
889/// Parsed `Content-Range: bytes <first>-<last>/<complete-or-*>`.
890///
891/// Public-but-internal: lives in the crate so the validator and the
892/// unit tests can share one parser. We deliberately do NOT export it —
893/// any future surface change shouldn't drag this representation into
894/// the public API.
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896struct ByteContentRange {
897    first: u64,
898    last: u64,
899    /// `None` when the server emitted `*` for complete-length.
900    complete: Option<u64>,
901}
902
903/// Parse a `Content-Range` field value per RFC 7233 §4.2
904/// (`bytes <first>-<last>/<complete|*>`), enforcing the §4.2 validity
905/// rules on the spot:
906///
907/// * range unit MUST be `bytes` (case-insensitive — RFC 7230 §3.2.6
908///   tokens are case-insensitive; ABNF in §4.2 happens to use the
909///   lowercase literal).
910/// * `last >= first` (otherwise §4.2 "byte-range-resp ... last-byte-pos
911///   value less than its first-byte-pos" is invalid).
912/// * `complete > last` (§4.2 "complete-length value less than or equal
913///   to its last-byte-pos" is invalid).
914///
915/// Unsatisfied-range (`bytes */N`) is intentionally rejected here — it
916/// is a 416 payload, never a 206 payload, so its arrival on a 206 is
917/// itself invalid.
918fn parse_byte_content_range(s: &str) -> std::result::Result<ByteContentRange, &'static str> {
919    let s = s.trim();
920    // unit SP byte-range-resp
921    let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
922    if !unit.eq_ignore_ascii_case("bytes") {
923        return Err("range unit is not 'bytes'");
924    }
925    let rest = rest.trim_start();
926    // byte-range-resp = first "-" last "/" (complete / "*")
927    // unsatisfied-range = "*/" complete  — rejected for 206.
928    if rest.starts_with('*') {
929        return Err("unsatisfied-range ('*/N') not valid on 206");
930    }
931    let (range_part, complete_part) = rest
932        .split_once('/')
933        .ok_or("missing '/' before complete-length")?;
934    let (first_s, last_s) = range_part
935        .split_once('-')
936        .ok_or("missing '-' between first-byte-pos and last-byte-pos")?;
937    let first: u64 = first_s.parse().map_err(|_| "first-byte-pos is not a u64")?;
938    let last: u64 = last_s.parse().map_err(|_| "last-byte-pos is not a u64")?;
939    if last < first {
940        return Err("last-byte-pos < first-byte-pos");
941    }
942    let complete = if complete_part == "*" {
943        None
944    } else {
945        let c: u64 = complete_part
946            .parse()
947            .map_err(|_| "complete-length is not a u64 or '*'")?;
948        if c <= last {
949            return Err("complete-length <= last-byte-pos");
950        }
951        Some(c)
952    };
953    Ok(ByteContentRange {
954        first,
955        last,
956        complete,
957    })
958}
959
960/// Parse a `Content-Range: bytes */<complete-length>` field value per
961/// RFC 9110 §14.4 — the *unsatisfied-range* form a server SHOULD return
962/// alongside a 416 (Range Not Satisfiable) response (§15.5.17).
963///
964/// Returns the server's authoritative complete-length on success. The
965/// 416 status itself tells the caller the range was rejected; this
966/// parser only extracts the length value so the caller can compare it
967/// against what was observed at HEAD construction and tell whether the
968/// rejection is "past EOF" or "resource shrank mid-stream."
969///
970/// Rejects the canonical `range-resp` form (`bytes first-last/complete`)
971/// — a 416 body is the unsatisfied-range form per §14.4, never a
972/// range-resp.
973fn parse_byte_unsatisfied_range(s: &str) -> std::result::Result<u64, &'static str> {
974    let s = s.trim();
975    let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
976    if !unit.eq_ignore_ascii_case("bytes") {
977        return Err("range unit is not 'bytes'");
978    }
979    let rest = rest.trim_start();
980    let complete_part = rest
981        .strip_prefix("*/")
982        .ok_or("not an unsatisfied-range ('*/N' expected)")?;
983    let complete: u64 = complete_part
984        .parse()
985        .map_err(|_| "complete-length is not a u64")?;
986    Ok(complete)
987}
988
989/// What a `Accept-Ranges` response field tells the client about the
990/// server's willingness to satisfy a range request, per RFC 9110 §14.3.
991///
992/// §14.3 ABNF:
993///
994/// ```text
995/// Accept-Ranges     = acceptable-ranges
996/// acceptable-ranges = 1#range-unit
997/// range-unit        = token            ; §14.1
998/// ```
999///
1000/// `1#X` is the list construction from RFC 9110 §5.6.1: a non-empty
1001/// comma-separated list, OWS-tolerant on both sides of each comma. So
1002/// `Accept-Ranges: bytes, foo-unit` legitimately advertises both units.
1003/// The §14.1 range-unit names are case-insensitive (§14.1 cross-refs
1004/// the §3.2.6 token rule).
1005///
1006/// §14.3 reserves the unit name `none` (lowercase by example, but a
1007/// recipient treats it case-insensitively per §3.2.6 token equality)
1008/// for the explicit "do not attempt a range request" advice. The list
1009/// constructor in §5.6.1 explicitly tolerates a sender producing
1010/// `none` alongside other units — the recipient SHOULD treat that as
1011/// a contradiction, but §14.3 does not name a hard rule so the
1012/// helper below reports `Other` rather than rejecting outright when
1013/// `none` appears next to a real unit.
1014#[derive(Debug, Clone, PartialEq, Eq)]
1015enum AcceptRanges {
1016    /// Server advertised at least one range unit that includes `bytes`
1017    /// (case-insensitive). Range requests are expected to work.
1018    Bytes,
1019    /// Server advertised exactly `none` (case-insensitive, single
1020    /// element) — explicit advice not to attempt a range request per
1021    /// §14.3 "The range unit 'none' is reserved for this purpose".
1022    None,
1023    /// Server advertised one or more range units but none of them is
1024    /// `bytes`. Carries the original token list (lowercased, trimmed)
1025    /// for diagnostic surfacing. Driver treats this like "no range
1026    /// support" for the current driver (which only knows the `bytes`
1027    /// unit), but distinguishes the message so a caller can tell
1028    /// "server speaks ranges, just not in our unit" apart from "server
1029    /// declined ranges entirely".
1030    Other(Vec<String>),
1031    /// The header was absent or contained only empty list elements.
1032    /// §14.3 doesn't mandate the field's presence even when the server
1033    /// supports ranges; this is informational, not a refusal.
1034    Absent,
1035}
1036
1037/// Parse a `Accept-Ranges` field value per RFC 9110 §14.3 ABNF.
1038///
1039/// Returns the categorised classification described on [`AcceptRanges`].
1040///
1041/// Empty list elements (e.g. the trailing element of `"bytes,"`) are
1042/// silently dropped per §5.6.1 — the list construction expressly
1043/// permits empty members and the recipient is meant to skip them.
1044fn parse_accept_ranges(s: &str) -> AcceptRanges {
1045    // §5.6.1: split on comma, then strip OWS on each element. Empty
1046    // elements (zero-length after trim) are tolerated and dropped.
1047    let mut tokens: Vec<String> = Vec::new();
1048    let mut had_none = false;
1049    let mut had_bytes = false;
1050    for part in s.split(',') {
1051        let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
1052        if tok.is_empty() {
1053            continue;
1054        }
1055        // §3.2.6 token validity: every byte in 1*tchar. We do not
1056        // hard-fail on a non-token element; we just skip it so a
1057        // misbehaving server that puts garbage in one slot doesn't
1058        // black-hole the legitimate `bytes` next to it. The validity
1059        // gate is intentionally permissive — the §14.3 advice value
1060        // is the token itself, not how it was framed.
1061        if !is_token(tok) {
1062            continue;
1063        }
1064        let lower = tok.to_ascii_lowercase();
1065        if lower == "bytes" {
1066            had_bytes = true;
1067        }
1068        if lower == "none" {
1069            had_none = true;
1070        }
1071        tokens.push(lower);
1072    }
1073    if tokens.is_empty() {
1074        return AcceptRanges::Absent;
1075    }
1076    if had_bytes {
1077        // §14.3 implicit rule: a server that advertises `bytes` MAY
1078        // also advertise other units. The recipient acts on `bytes`.
1079        return AcceptRanges::Bytes;
1080    }
1081    if had_none && tokens.len() == 1 {
1082        // §14.3 explicit "none" advice — single element, lowercase
1083        // by example.
1084        return AcceptRanges::None;
1085    }
1086    AcceptRanges::Other(tokens)
1087}
1088
1089/// Classification of a HEAD response's `Vary` header for the driver's
1090/// content-negotiation stability check, per RFC 9110 §12.5.5.
1091///
1092/// The driver opens a resource with a single HEAD, records its length
1093/// and validator, then satisfies every later read with an independent
1094/// `Range` GET. That pattern silently assumes the origin maps the
1095/// target URI to *one* representation that stays put for the lifetime
1096/// of the source. `Vary` is exactly the signal that this assumption
1097/// can be false:
1098///
1099/// > To inform user agent recipients that this response was subject to
1100/// > content negotiation (§12) and a different representation might be
1101/// > sent in a subsequent request if other values are provided in the
1102/// > listed header fields (proactive negotiation). — §12.5.5
1103///
1104/// §12.5.5 ABNF: `Vary = #( "*" / field-name )` — a §5.6.1 list whose
1105/// members are either the wildcard `*` or request field-names.
1106#[derive(Debug, Clone, PartialEq, Eq)]
1107enum Vary {
1108    /// The field was absent or listed only empty members. §12.5.5 does
1109    /// not require the field even on a negotiated resource, so this is
1110    /// "no warning", not "no negotiation".
1111    Absent,
1112    /// A list containing `*`. §12.5.5: this "signals that other aspects
1113    /// of the request might have played a role in selecting the response
1114    /// representation, possibly including aspects outside the message
1115    /// syntax (e.g., the client's network address)". The driver cannot
1116    /// reproduce such aspects deterministically across its HEAD and the
1117    /// later Range GETs, so the representation it ranges over is not
1118    /// guaranteed to be the one the HEAD measured.
1119    Wildcard,
1120    /// A list of concrete request field-names (the selecting header
1121    /// fields). The driver sends a fixed, identical header set on the
1122    /// HEAD and on every Range GET (`Accept-Encoding: identity`, no
1123    /// `Accept-Language`/`Accept` overrides, etc.), so as long as the
1124    /// origin negotiates purely on those request fields the selected
1125    /// representation is stable across the source's lifetime. Carries
1126    /// the lowercased field-names for diagnostics.
1127    Fields(Vec<String>),
1128}
1129
1130/// Parse a `Vary` field value per RFC 9110 §12.5.5 ABNF
1131/// (`Vary = #( "*" / field-name )`).
1132///
1133/// `#` is the §5.6.1 list construction: comma-separated, OWS-tolerant
1134/// on each side of the comma, empty members dropped. A member of `*`
1135/// anywhere in the list makes the whole value a [`Vary::Wildcard`] —
1136/// §12.5.5 treats `*` as a list member, not a standalone form, and one
1137/// `*` poisons the determinism of every read regardless of the other
1138/// members. Otherwise each member is a `field-name` (a §5.6.2 token);
1139/// non-token members are dropped (a single malformed slot must not mask
1140/// a real selecting field next to it, mirroring `parse_accept_ranges`).
1141/// Field-names are case-insensitive (§5.1), so they are lowercased.
1142fn parse_vary(s: &str) -> Vary {
1143    let mut fields: Vec<String> = Vec::new();
1144    for part in s.split(',') {
1145        let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
1146        if tok.is_empty() {
1147            continue;
1148        }
1149        if tok == "*" {
1150            // §12.5.5: a list containing the member "*" — once seen, the
1151            // whole value is the wildcard form. Short-circuit so a later
1152            // garbage member can't downgrade it.
1153            return Vary::Wildcard;
1154        }
1155        if !is_token(tok) {
1156            continue;
1157        }
1158        fields.push(tok.to_ascii_lowercase());
1159    }
1160    if fields.is_empty() {
1161        Vary::Absent
1162    } else {
1163        Vary::Fields(fields)
1164    }
1165}
1166
1167/// Parse a `Content-Encoding` field value per RFC 9110 §8.4
1168/// (`Content-Encoding = #content-coding`, `content-coding = token`)
1169/// and return the codings that actually transform the bytes — i.e.
1170/// every list element except the reserved `identity` token.
1171///
1172/// §8.4.1: "All content codings are case-insensitive", so each kept
1173/// element is lowercased. §12.5.3 defines `identity` as "a synonym
1174/// for 'no encoding'", and §8.4 says it "SHOULD NOT be included" in
1175/// Content-Encoding — a server that sends it anyway is therefore
1176/// tolerated as a no-op rather than rejected. §5.6.1 empty list
1177/// elements (`gzip,,`) are dropped.
1178///
1179/// Fail-direction note: unlike `parse_accept_ranges`, a non-`token`
1180/// list element is KEPT (trimmed + lowercased), not skipped. There,
1181/// skipping a garbage slot protects the legitimate `bytes` next to it
1182/// from being black-holed; here, skipping a garbage slot would
1183/// silently ACCEPT a response whose body was transformed by a coding
1184/// whose name we could not even parse — an unparseable coding is
1185/// still a coding the driver cannot undo, so it must surface in the
1186/// rejection diagnostic instead of vanishing.
1187///
1188/// An empty return means the representation is un-coded (or only
1189/// redundantly `identity`-coded) and the byte-offset model holds.
1190fn non_identity_content_codings(s: &str) -> Vec<String> {
1191    let mut out = Vec::new();
1192    for part in s.split(',') {
1193        let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
1194        if tok.is_empty() {
1195            // §5.6.1: empty list elements are tolerated and dropped.
1196            continue;
1197        }
1198        let lower = tok.to_ascii_lowercase();
1199        if lower == "identity" {
1200            continue;
1201        }
1202        out.push(lower);
1203    }
1204    out
1205}
1206
1207/// RFC 9110 §5.6.2 token grammar:
1208///
1209/// ```text
1210/// token  = 1*tchar
1211/// tchar  = "!" / "#" / "$" / "%" / "&" / "'" / "*"
1212///        / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
1213///        / DIGIT / ALPHA
1214/// ```
1215fn is_token(s: &str) -> bool {
1216    !s.is_empty() && s.bytes().all(is_tchar)
1217}
1218
1219/// Normalize obsolete line folding (`obs-fold`) in a header field value
1220/// per RFC 7230 §3.2.4.
1221///
1222/// The §3.2 ABNF allows `field-value = *( field-content / obs-fold )`
1223/// where `obs-fold = CRLF 1*( SP / HTAB )`. §3.2.4 makes the following
1224/// hard requirement on a user agent that receives an obs-fold in a
1225/// response (not within a `message/http` container):
1226///
1227/// > "A user agent that receives an obs-fold in a response message
1228/// > that is not within a message/http container MUST replace each
1229/// > received obs-fold with one or more SP octets prior to
1230/// > interpreting the field value."
1231///
1232/// This helper performs exactly that normalisation. Each maximal run
1233/// of `CRLF (SP/HTAB)+` inside `s` is collapsed to a single ASCII
1234/// space (`0x20`). The "one or more SP" wording leaves the count to
1235/// the recipient; one SP is the smallest stable choice — it preserves
1236/// the token-boundary signal that the original whitespace carried (so
1237/// e.g. an obs-folded comma-separated list still tokenises the same
1238/// way) without padding the value with arbitrary whitespace that
1239/// downstream parsers would just have to trim again.
1240///
1241/// The function is *only* a normaliser — it does not reject the input
1242/// or report whether folding was found. §3.2.4 also permits a
1243/// recipient to reject obs-folded requests with 400, but a user agent
1244/// receiving a response has the MUST-normalise obligation, not a
1245/// MUST-reject option, so the API surface here only models the
1246/// normalisation outcome.
1247///
1248/// Returns a `Cow::Borrowed(s)` when no obs-fold occurrence is found
1249/// (the common case) so cold-path callers stay allocation-free. A
1250/// `Cow::Owned(_)` is returned when at least one fold was collapsed.
1251///
1252/// Bare CR or bare LF (not part of a CRLF pair) and CRLF NOT followed
1253/// by SP/HTAB are left untouched — those are not obs-fold per the
1254/// §3.2 ABNF; deciding what to do with them is the caller's job (the
1255/// framing layer typically rejects them as line-terminators, which is
1256/// out of scope for a field-value normaliser).
1257///
1258/// Examples (informal):
1259///
1260/// ```text
1261/// "abc\r\n def"     -> "abc def"
1262/// "abc\r\n\t def"   -> "abc def"   (multiple SP/HTAB collapsed to 1)
1263/// "abc\r\n  \tdef"  -> "abc def"
1264/// "a\r\n b\r\n\tc"  -> "a b c"     (two folds, two collapses)
1265/// "abc def"         -> "abc def"   (no fold, no allocation)
1266/// "abc\r\nxyz"      -> "abc\r\nxyz" (CRLF not followed by SP/HTAB:
1267///                                    not obs-fold; left as-is)
1268/// "abc\rdef"        -> "abc\rdef"  (bare CR: not obs-fold)
1269/// ```
1270fn normalize_obs_fold(s: &str) -> std::borrow::Cow<'_, str> {
1271    use std::borrow::Cow;
1272    // Fast scan on the &[u8] view to locate the first real obs-fold.
1273    // SP / HTAB / CR / LF are all single-byte ASCII; obs-text (%x80-FF)
1274    // is multi-byte in UTF-8 but never matches the CRLF + SP/HTAB
1275    // pattern, so byte-level matching is safe.
1276    let bytes = s.as_bytes();
1277    let mut first_fold: Option<usize> = None;
1278    let mut i = 0;
1279    while i + 2 < bytes.len() {
1280        if bytes[i] == b'\r'
1281            && bytes[i + 1] == b'\n'
1282            && (bytes[i + 2] == b' ' || bytes[i + 2] == b'\t')
1283        {
1284            first_fold = Some(i);
1285            break;
1286        }
1287        i += 1;
1288    }
1289    let start = match first_fold {
1290        Some(p) => p,
1291        None => return Cow::Borrowed(s),
1292    };
1293    // At least one fold confirmed. Build the owned form as bytes; the
1294    // output is always UTF-8 because we only ever copy original bytes
1295    // verbatim (preserving any obs-text multi-byte sequences in their
1296    // entirety) or inject a single ASCII SP per collapsed fold.
1297    let mut out: Vec<u8> = Vec::with_capacity(s.len());
1298    out.extend_from_slice(&bytes[..start]);
1299    let mut i = start;
1300    while i < bytes.len() {
1301        if i + 2 < bytes.len()
1302            && bytes[i] == b'\r'
1303            && bytes[i + 1] == b'\n'
1304            && (bytes[i + 2] == b' ' || bytes[i + 2] == b'\t')
1305        {
1306            // §3.2.4: replace the obs-fold with "one or more SP
1307            // octets". We emit exactly one SP — the smallest stable
1308            // choice — and consume the maximal trailing SP/HTAB run.
1309            out.push(b' ');
1310            i += 2;
1311            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1312                i += 1;
1313            }
1314        } else {
1315            // Not a fold start — copy this byte through verbatim.
1316            // Multi-byte UTF-8 sequences are preserved because each
1317            // continuation byte is also copied as-is by the next
1318            // loop iteration; we never split a code point.
1319            out.push(bytes[i]);
1320            i += 1;
1321        }
1322    }
1323    // SAFETY-equivalent: the output is byte-for-byte derived from a
1324    // valid &str by (a) verbatim byte copies and (b) insertion of
1325    // ASCII SP (0x20), neither of which can invalidate UTF-8. We
1326    // still call the checked constructor to avoid `unsafe`.
1327    match String::from_utf8(out) {
1328        Ok(s2) => Cow::Owned(s2),
1329        // Unreachable in practice (see above), but if a future edit
1330        // ever breaks the byte-preserving invariant we'd rather fall
1331        // back to the un-normalised input than silently corrupt the
1332        // field value.
1333        Err(_) => Cow::Borrowed(s),
1334    }
1335}
1336
1337/// Unwrap a `quoted-string` per RFC 9110 §5.6.4 into its logical
1338/// value (the byte sequence the producer meant to convey, with every
1339/// `quoted-pair` collapsed to the octet that followed the backslash).
1340///
1341/// ```text
1342/// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
1343/// qdtext        = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
1344/// quoted-pair   = "\" ( HTAB / SP / VCHAR / obs-text )
1345/// ```
1346///
1347/// §5.6.4 makes the unescape a hard MUST: "Recipients that process the
1348/// value of a quoted-string MUST handle a quoted-pair as if it were
1349/// replaced by the octet following the backslash." Any caller that
1350/// inspects a quoted-string's contents — media-type parameters per
1351/// §5.6.6 / §8.3.1, auth-params per §11.4, the `Link` header's
1352/// `title="…"` per RFC 8288, etc. — must run the input through this
1353/// step before pattern-matching on its bytes; otherwise a server's
1354/// `"foo\"bar"` reads as a value boundary instead of a literal `"`.
1355///
1356/// Returns `None` when the input is not a syntactically valid
1357/// `quoted-string`:
1358///
1359/// - missing leading or trailing `"`,
1360/// - any inner byte that is neither `qdtext` nor a `quoted-pair`,
1361/// - a trailing lone `\` with no octet to escape,
1362/// - a `\` followed by an octet outside the §5.6.4 `quoted-pair` RHS
1363///   (`HTAB / SP / VCHAR / obs-text`; notably bare CR/LF cannot be
1364///   quoted-paired — they would unbalance the field line).
1365///
1366/// On success returns the unescaped logical value. When the body
1367/// carries no `\`-escapes the return is a borrow of the input slice
1368/// (zero allocations on the common path).
1369///
1370/// Currently exercised by the unit-test suite and the cargo-fuzz
1371/// `parse_headers` harness (through the `__fuzz` re-export gate); no
1372/// in-driver caller yet, since the §15.3.7.2 multipart rejection
1373/// only needs the bare type/subtype and the §8.8.3 `entity-tag`
1374/// production explicitly excludes `quoted-pair` from `etagc`. The
1375/// primitive is in place ready to back any future per-parameter
1376/// inspection a §5.6.6 / §8.3.1 / §11.4 parser would need.
1377#[allow(dead_code)]
1378fn unquote_string(s: &str) -> Option<std::borrow::Cow<'_, str>> {
1379    use std::borrow::Cow;
1380    let bytes = s.as_bytes();
1381    // Must be DQUOTE-wrapped and at least two bytes long for the
1382    // empty `""` case.
1383    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
1384        return None;
1385    }
1386    let inner = &bytes[1..bytes.len() - 1];
1387    // Fast pre-scan: validate every byte and detect whether any
1388    // quoted-pair is present. If none, we can return a Cow::Borrowed
1389    // slice of the original &str (zero allocation).
1390    let mut i = 0;
1391    let mut has_escape = false;
1392    while i < inner.len() {
1393        let b = inner[i];
1394        if b == b'\\' {
1395            // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
1396            // VCHAR = %x21-7E; obs-text = %x80-FF.
1397            let nxt = *inner.get(i + 1)?;
1398            let ok = nxt == 0x09 || nxt == 0x20 || (0x21..=0x7E).contains(&nxt) || nxt >= 0x80;
1399            if !ok {
1400                return None;
1401            }
1402            has_escape = true;
1403            i += 2;
1404        } else {
1405            // qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
1406            let ok = b == 0x09
1407                || b == 0x20
1408                || b == 0x21
1409                || (0x23..=0x5B).contains(&b)
1410                || (0x5D..=0x7E).contains(&b)
1411                || b >= 0x80;
1412            if !ok {
1413                return None;
1414            }
1415            i += 1;
1416        }
1417    }
1418    if !has_escape {
1419        // SAFETY-equivalent: the inner slice is a substring of `s`
1420        // bounded on both ends by an ASCII byte (`"`), so the byte
1421        // offsets [1, len-1] fall on UTF-8 code-point boundaries.
1422        return Some(Cow::Borrowed(
1423            std::str::from_utf8(inner).expect("inner slice is UTF-8 by construction"),
1424        ));
1425    }
1426    // Slow path: collapse each quoted-pair.
1427    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
1428    let mut i = 0;
1429    while i < inner.len() {
1430        let b = inner[i];
1431        if b == b'\\' {
1432            // Validated above; emit the next octet verbatim.
1433            out.push(inner[i + 1]);
1434            i += 2;
1435        } else {
1436            out.push(b);
1437            i += 1;
1438        }
1439    }
1440    // The body is byte-for-byte derived from a valid UTF-8 slice by
1441    // copying either a qdtext octet or the octet following a `\`,
1442    // both of which originated as bytes within `s`. The escape's
1443    // RHS may break a multi-byte UTF-8 sequence boundary if a
1444    // sender backslash-escaped a single continuation byte, so we
1445    // must run a checked conversion rather than assume validity.
1446    match String::from_utf8(out) {
1447        Ok(decoded) => Some(Cow::Owned(decoded)),
1448        Err(_) => None,
1449    }
1450}
1451
1452/// Parse a `comment` production per RFC 9110 §5.6.5 into its logical
1453/// text — the byte sequence between the outermost parentheses, with
1454/// every `quoted-pair` collapsed to the octet that followed the
1455/// backslash and the (balanced) nested-comment delimiters preserved
1456/// verbatim as part of the text.
1457///
1458/// ```text
1459/// comment = "(" *( ctext / quoted-pair / comment ) ")"
1460/// ctext   = HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text
1461/// ```
1462///
1463/// §5.6.5 only permits comments "in fields containing 'comment' as part
1464/// of their field value definition" — `User-Agent` / `Server` (§10.1.5
1465/// / §10.2.4 `product *( RWS ( product / comment ) )`), `Via`
1466/// (RFC 9110 §7.6.3), and the `Warning` field of RFC 7234 §5.5 all carry
1467/// a §5.6.5 `comment`. This crate issues unauthenticated `HEAD` / `Range`
1468/// requests and does not yet act on any of those response fields, so
1469/// there is no in-driver caller; the primitive completes the §5.6
1470/// generic-syntax family (§5.6.1 list, §5.6.2 token, §5.6.4
1471/// quoted-string, §5.6.6 parameters, §5.6.7 date are already present)
1472/// and is exported for the cargo-fuzz harness so any panic mode is
1473/// found by fuzzing.
1474///
1475/// Grammar notes the parser honours:
1476///
1477/// - The outermost `(` … `)` are required and stripped; the return is
1478///   the inner content. An empty comment `()` decodes to the empty
1479///   string.
1480/// - `ctext` is `HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text`.
1481///   Note the holes at `%x28` `(`, `%x29` `)`, and `%x5C` `\`: a bare
1482///   one of those characters is NOT `ctext` and only carries meaning
1483///   through the `comment` recursion (`(`/`)`) or the `quoted-pair`
1484///   escape (`\`).
1485/// - A `quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )` (§5.6.4) is
1486///   collapsed to the single octet after the backslash, mirroring the
1487///   §5.6.4 MUST applied by [`unquote_string`]. A bare `\` followed by
1488///   an octet outside that RHS (notably bare CR / LF, which would
1489///   unbalance the field line) is rejected.
1490/// - Nested comments recurse: `(a (b) c)` is one comment whose text is
1491///   `a (b) c` — the inner parentheses are preserved because they are
1492///   part of the comment's logical content, not stripped a second time.
1493///   Parentheses must be balanced; an unbalanced `(` or a `)` past the
1494///   matching close is rejected.
1495///
1496/// Returns `None` for any input that is not a single syntactically
1497/// valid `comment`: missing outer parens, content after the matching
1498/// close paren, an unbalanced paren, an illegal bare byte, or a
1499/// dangling / illegal `quoted-pair`. On the escape-free, single-level
1500/// happy path the return borrows the input slice (zero allocations);
1501/// only a `quoted-pair` forces the owned slow path.
1502///
1503/// The recursion depth is bounded by an explicit counter rather than
1504/// real call-stack recursion, so a deeply nested adversarial input
1505/// (`((((…))))`) cannot overflow the stack.
1506#[allow(dead_code)]
1507fn parse_comment(s: &str) -> Option<std::borrow::Cow<'_, str>> {
1508    use std::borrow::Cow;
1509    let bytes = s.as_bytes();
1510    // Must be paren-wrapped and at least two bytes for the empty `()`.
1511    if bytes.len() < 2 || bytes[0] != b'(' || bytes[bytes.len() - 1] != b')' {
1512        return None;
1513    }
1514    let inner = &bytes[1..bytes.len() - 1];
1515    // Fast pre-scan: validate every byte, track nesting depth, and
1516    // detect whether any quoted-pair is present. If none, the inner
1517    // slice can be returned borrowed.
1518    let mut i = 0usize;
1519    let mut depth: u32 = 0; // nesting below the outermost comment
1520    let mut has_escape = false;
1521    while i < inner.len() {
1522        let b = inner[i];
1523        match b {
1524            b'\\' => {
1525                // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
1526                // VCHAR = %x21-7E; obs-text = %x80-FF.
1527                let nxt = *inner.get(i + 1)?;
1528                let ok = nxt == 0x09 || nxt == 0x20 || (0x21..=0x7E).contains(&nxt) || nxt >= 0x80;
1529                if !ok {
1530                    return None;
1531                }
1532                has_escape = true;
1533                i += 2;
1534            }
1535            b'(' => {
1536                depth += 1;
1537                i += 1;
1538            }
1539            b')' => {
1540                if depth == 0 {
1541                    // A close paren at the top level ends the outermost
1542                    // comment early — there would be trailing content
1543                    // after the matching `)`, so the whole input is not
1544                    // a single comment.
1545                    return None;
1546                }
1547                depth -= 1;
1548                i += 1;
1549            }
1550            // ctext = HTAB / SP / %x21-27 / %x2A-5B / %x5D-7E / obs-text
1551            0x09 | 0x20 => i += 1,
1552            0x21..=0x27 | 0x2A..=0x5B | 0x5D..=0x7E => i += 1,
1553            _ if b >= 0x80 => i += 1, // obs-text
1554            _ => return None,         // bare control byte etc.
1555        }
1556    }
1557    // Every nested `(` opened in `inner` must have been closed before
1558    // the outermost `)` we stripped; otherwise the parens are
1559    // unbalanced.
1560    if depth != 0 {
1561        return None;
1562    }
1563    if !has_escape {
1564        // The inner slice is a substring of `s` bounded on both ends by
1565        // an ASCII byte (`(` / `)`), so [1, len-1] fall on UTF-8 code
1566        // point boundaries.
1567        return Some(Cow::Borrowed(
1568            std::str::from_utf8(inner).expect("inner slice is UTF-8 by construction"),
1569        ));
1570    }
1571    // Slow path: collapse each quoted-pair, preserving nested-comment
1572    // delimiters verbatim.
1573    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
1574    let mut i = 0usize;
1575    while i < inner.len() {
1576        let b = inner[i];
1577        if b == b'\\' {
1578            // Validated above; emit the next octet verbatim.
1579            out.push(inner[i + 1]);
1580            i += 2;
1581        } else {
1582            out.push(b);
1583            i += 1;
1584        }
1585    }
1586    // The escape's RHS may sever a multi-byte UTF-8 sequence boundary
1587    // if a sender backslash-escaped a single continuation byte, so run
1588    // a checked conversion rather than assume validity (same posture as
1589    // `unquote_string`).
1590    match String::from_utf8(out) {
1591        Ok(decoded) => Some(Cow::Owned(decoded)),
1592        Err(_) => None,
1593    }
1594}
1595
1596/// Parse a `parameters` production per RFC 9110 §5.6.6 into an ordered
1597/// `Vec<(name, value)>` of `(lowercase-name, decoded-value)` pairs.
1598///
1599/// ```text
1600/// parameters      = *( OWS ";" OWS [ parameter ] )
1601/// parameter       = parameter-name "=" parameter-value
1602/// parameter-name  = token
1603/// parameter-value = ( token / quoted-string )
1604/// ```
1605///
1606/// Used by callers that have already split a field value's "main" item
1607/// off of the parameters tail — e.g. the §8.3.1 media type
1608/// (`type/subtype`) is followed by `*( OWS ";" OWS parameter )`, the
1609/// §12.5.1 `Accept` field's q-factor lives in the same construction, and
1610/// the §11.4 `WWW-Authenticate` challenges carry `realm="…", scope=token`
1611/// auth-params using equivalent shape. The caller passes the tail
1612/// starting from the first `;` (or starting from an empty/whitespace-only
1613/// slice if no parameters were attached), and we return one entry per
1614/// `parameter` that parses; empty list elements (e.g. `;; charset=utf-8`)
1615/// are silently ignored, matching the §5.6.1 recipient note that empty
1616/// elements do not contribute. The function never panics on arbitrary
1617/// input.
1618///
1619/// Behaviour:
1620///
1621/// - **Names**: lowercased on the way out (§5.6.6: "Parameter names are
1622///   case-insensitive"). A `parameter-name` that is not a valid §5.6.2
1623///   token causes that whole entry to be skipped, not a hard reject of
1624///   the surrounding list — same defensive choice as the
1625///   `parse_accept_ranges` recipient logic.
1626/// - **Values**: when the value is `( token )` we return it verbatim
1627///   (case preservation is the caller's job per §5.6.6's "Parameter
1628///   values might or might not be case-sensitive, depending on the
1629///   semantics of the parameter name"); when the value is
1630///   `( quoted-string )` we run it through [`unquote_string`] so any
1631///   `quoted-pair` is collapsed per §5.6.4's MUST and the consumer
1632///   receives the logical octet sequence (boundaries, charsets, realms)
1633///   ready to pattern-match.
1634/// - **No whitespace around `=`**: §5.6.6's informational note says
1635///   "Parameters do not allow whitespace (not even 'bad' whitespace)
1636///   around the '=' character." A parameter that has SP / HTAB before
1637///   or after `=` is skipped (not a list-fatal reject), matching the
1638///   "ignore the bad slot" defensive posture.
1639/// - **Missing `=`**: a slot like `; foo` (token with no `=value` tail)
1640///   is skipped — §5.6.6's `parameter = parameter-name "=" parameter-value`
1641///   makes the `=` a required production; some §8.3.1 in-the-wild
1642///   senders strip the value, but recipients here decline to invent one.
1643/// - **Quoted-string boundary**: the `;` split honours quoted-strings —
1644///   a `;` inside a `"…"` body (e.g. `boundary="a;b"`) is not a slot
1645///   terminator. Backslash escapes inside the quoted-string are
1646///   respected by the splitter (so `"a\";b"` reads the `\"` as a
1647///   quoted-pair and continues past it).
1648/// - **Optional leading `;`**: callers that hand the tail starting from
1649///   the first byte after the main item (which is usually `;` but can be
1650///   `OWS ;`) get the same result as callers that hand the tail starting
1651///   from the OWS-stripped post-`;` position; the function consumes
1652///   leading whitespace and an optional `;` before the first parameter.
1653///
1654/// Returns an empty `Vec` for empty / whitespace-only / `;`-only inputs.
1655/// Allocates one `String` per kept name and one per quoted-string body
1656/// containing at least one `quoted-pair`; token-shape values reuse the
1657/// input via `String::from(&str)`.
1658#[allow(dead_code)]
1659fn parse_parameters(s: &str) -> Vec<(String, String)> {
1660    let mut out: Vec<(String, String)> = Vec::new();
1661    let bytes = s.as_bytes();
1662    let mut i = 0usize;
1663    // Outer loop: each iteration consumes one slot (between two `;`
1664    // boundaries) and either pushes a (name, value) pair or skips it.
1665    loop {
1666        // 1. Strip OWS (SP / HTAB only — §5.6.3 OWS).
1667        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1668            i += 1;
1669        }
1670        // 2. A leading `;` (or any subsequent slot boundary) is consumed
1671        //    once here; if the slot started directly at a parameter,
1672        //    skip this step.
1673        if i < bytes.len() && bytes[i] == b';' {
1674            i += 1;
1675            continue;
1676        }
1677        if i >= bytes.len() {
1678            break;
1679        }
1680        // 3. Find the end of this slot. The slot ends at the next
1681        //    top-level `;` (i.e. a `;` not inside a quoted-string).
1682        let slot_start = i;
1683        let mut in_quote = false;
1684        while i < bytes.len() {
1685            let b = bytes[i];
1686            if in_quote {
1687                if b == b'\\' {
1688                    // Skip the next octet (the quoted-pair RHS) so that
1689                    // `\"` inside the body does not close the quote.
1690                    if i + 1 < bytes.len() {
1691                        i += 2;
1692                        continue;
1693                    }
1694                    // Trailing lone backslash inside a quoted-string —
1695                    // the slot is malformed; let the per-slot parser
1696                    // see the body and reject it.
1697                    i += 1;
1698                } else if b == b'"' {
1699                    in_quote = false;
1700                    i += 1;
1701                } else {
1702                    i += 1;
1703                }
1704            } else if b == b';' {
1705                break;
1706            } else if b == b'"' {
1707                in_quote = true;
1708                i += 1;
1709            } else {
1710                i += 1;
1711            }
1712        }
1713        let slot = &s[slot_start..i];
1714        // 4. Try to parse the slot as a single parameter. Anything
1715        //    that doesn't fit `name=value` (token + `=` + token/qstr)
1716        //    is skipped per the §5.6.1 "tolerate empty / garbage
1717        //    list slots" recipient posture.
1718        if let Some(pair) = parse_one_parameter(slot) {
1719            out.push(pair);
1720        }
1721        // 5. Slot terminator: consume the `;` (if any) and loop.
1722        if i < bytes.len() && bytes[i] == b';' {
1723            i += 1;
1724        }
1725    }
1726    out
1727}
1728
1729/// Single-parameter parser: `parameter-name "=" parameter-value`. Returns
1730/// `Some((lowercase-name, decoded-value))` on a syntactically valid
1731/// `parameter`, `None` for empty / whitespace-only / no-`=` / bad-token
1732/// / whitespace-around-`=` / malformed-quoted-string slots.
1733fn parse_one_parameter(slot: &str) -> Option<(String, String)> {
1734    // §5.6.3 OWS strip on the slot edges. The split-into-slots step
1735    // above already stripped OWS at the start of the slot, but an OWS
1736    // *trail* (between the last value byte and the next `;`) is normal
1737    // in a §5.6.6 list and must be removed here. We trim both ends in
1738    // case the slot came from a no-`;`-yet single-call.
1739    let slot = slot.trim_matches(|c: char| c == ' ' || c == '\t');
1740    if slot.is_empty() {
1741        return None;
1742    }
1743    let eq = slot.find('=')?;
1744    let name = &slot[..eq];
1745    let value = &slot[eq + 1..];
1746    // §5.6.6 note: "Parameters do not allow whitespace (not even 'bad'
1747    // whitespace) around the '=' character." If the byte just before
1748    // `=` or the byte just after `=` is SP / HTAB, reject the slot.
1749    if name
1750        .as_bytes()
1751        .last()
1752        .map(|&b| b == b' ' || b == b'\t')
1753        .unwrap_or(false)
1754    {
1755        return None;
1756    }
1757    if value
1758        .as_bytes()
1759        .first()
1760        .map(|&b| b == b' ' || b == b'\t')
1761        .unwrap_or(false)
1762    {
1763        return None;
1764    }
1765    // §5.6.6: `parameter-name = token` — reject any non-token name so
1766    // that downstream consumers can rely on it. (We do not gate on
1767    // emptiness specifically; `is_token` rejects empty inputs.)
1768    if !is_token(name) {
1769        return None;
1770    }
1771    // §5.6.6: `parameter-value = ( token / quoted-string )`. If the
1772    // value starts with DQUOTE, route it through the §5.6.4 unwrap
1773    // (which collapses any quoted-pair). Otherwise it MUST be a token.
1774    let decoded_value = if value.starts_with('"') {
1775        unquote_string(value)?.into_owned()
1776    } else if is_token(value) {
1777        value.to_owned()
1778    } else {
1779        return None;
1780    };
1781    Some((name.to_ascii_lowercase(), decoded_value))
1782}
1783
1784/// A parsed §8.3.1 `media-type`: `(lowercased type, lowercased subtype,
1785/// §5.6.6 parameters)`.
1786type MediaType = (String, String, Vec<(String, String)>);
1787
1788/// Parse a `media-type` field value per RFC 9110 §8.3.1 grammar:
1789///
1790/// ```text
1791/// media-type = type "/" subtype parameters
1792/// type       = token
1793/// subtype    = token
1794/// ```
1795///
1796/// (`parameters` is the §5.6.6 production already parsed by
1797/// [`parse_parameters`].)
1798///
1799/// Returns `Some((type, subtype, params))` where `type` and `subtype`
1800/// are the lowercased tokens — §8.3.1: "The type and subtype tokens are
1801/// case-insensitive." — and `params` is the §5.6.6 `Vec<(name, value)>`
1802/// of the trailing parameters (already lowercase-named and quoted-pair
1803/// decoded). Returns `None` when the value is not a syntactically valid
1804/// `media-type`: a missing `/`, an empty / non-`token` type or subtype,
1805/// or a slash that does not separate exactly one type from one subtype.
1806///
1807/// Parameter *values* are NOT case-folded here — §8.3.1: "Parameter
1808/// values might or might not be case-sensitive, depending on the
1809/// semantics of the parameter name." A consumer that knows a given
1810/// parameter is case-insensitive (e.g. `charset` per §8.3.2 / [RFC2046]
1811/// §4.1.2) folds the value itself.
1812///
1813/// The OWS posture matches the rest of the driver: leading / trailing
1814/// OWS on the whole value is trimmed, and OWS between the type/subtype
1815/// and the first `;` is tolerated (§8.3.1's `parameters` opens with
1816/// `*( OWS ";" OWS … )`, so the gap before the first `;` is OWS).
1817///
1818/// This is the §8.3.1 composition the §5.6.6 parameters helper was built
1819/// to enable — e.g. a `charset` extractor on `Content-Type` becomes a
1820/// `parse_media_type(ct)` then a case-insensitive `params` lookup for
1821/// `"charset"`, with the §8.3.2 case-insensitive fold applied by the
1822/// caller. No in-driver caller exists yet (the §15.3.7.2 multipart
1823/// rejection only needs the bare `type/subtype` prefix and uses the
1824/// narrower [`is_multipart_byteranges_content_type`]); the primitive is
1825/// in place ready to back any future per-parameter media-type
1826/// inspection.
1827#[allow(dead_code)]
1828fn parse_media_type(s: &str) -> Option<MediaType> {
1829    // §5.6.3 OWS strip on the whole value.
1830    let s = s.trim_matches([' ', '\t']);
1831    if s.is_empty() {
1832        return None;
1833    }
1834    // Split off the §5.6.6 `parameters` tail at the first *top-level*
1835    // `;`. A `;` cannot appear in `type` or `subtype` (both are `token`,
1836    // and `;` is not a `tchar` per §5.6.2), so the first `;` in the
1837    // value unambiguously opens the parameters tail — no quoted-string
1838    // awareness is needed at this split (the type/subtype part has no
1839    // quoted-strings).
1840    let (media, params_tail) = match s.split_once(';') {
1841        Some((m, rest)) => (m, Some(rest)),
1842        None => (s, None),
1843    };
1844    // `parameters` opens with `*( OWS ";" OWS … )`, so OWS between the
1845    // subtype and the first `;` is legal; trim it off the media part.
1846    let media = media.trim_end_matches([' ', '\t']);
1847    // §8.3.1: `type "/" subtype`. Exactly one `/` separating two tokens.
1848    let (ty, sub) = media.split_once('/')?;
1849    // A second `/` in the subtype would make it a non-`token` (`/` is
1850    // not a `tchar`), so `is_token(sub)` already rejects it; the
1851    // explicit `split_once` takes only the first `/`.
1852    if !is_token(ty) || !is_token(sub) {
1853        return None;
1854    }
1855    // §8.3.1: type and subtype are case-insensitive — normalise to
1856    // lowercase so a consumer can compare without re-folding.
1857    let ty = ty.to_ascii_lowercase();
1858    let sub = sub.to_ascii_lowercase();
1859    // §5.6.6 parameters tail (empty when there was no `;`). The helper
1860    // already tolerates a leading `;` / OWS, so the post-`;` remainder
1861    // is handed straight through.
1862    let params = match params_tail {
1863        Some(rest) => parse_parameters(rest),
1864        None => Vec::new(),
1865    };
1866    Some((ty, sub, params))
1867}
1868
1869/// Parse an `ETag` field value per RFC 9110 §8.8.3 grammar:
1870///
1871/// ```text
1872/// entity-tag = [ weak ] opaque-tag
1873/// weak       = %s"W/"
1874/// opaque-tag = DQUOTE *etagc DQUOTE
1875/// etagc      = %x21 / %x23-7E / obs-text
1876/// ```
1877///
1878/// Returns `Some((is_weak, full_wire_form))` on a syntactically valid
1879/// entity-tag, `None` on anything else (an unset or malformed value
1880/// must not surface as a usable validator).
1881///
1882/// `full_wire_form` is the input including the surrounding DQUOTEs and
1883/// the `W/` prefix if any — what we'd echo back in `If-Range`.
1884fn parse_entity_tag(s: &str) -> Option<(bool, String)> {
1885    let s = s.trim();
1886    // `weak` is case-sensitive `W/` per §8.8.3 (`%s"W/"`).
1887    let (is_weak, body) = if let Some(rest) = s.strip_prefix("W/") {
1888        (true, rest)
1889    } else {
1890        (false, s)
1891    };
1892    let body = body.strip_prefix('"')?;
1893    let body = body.strip_suffix('"')?;
1894    // §8.8.3 etagc = %x21 / %x23-7E / obs-text. We accept obs-text
1895    // (%x80-FF) too, since RFC 9110 §5.5 permits it as a deprecated
1896    // historical byte class.
1897    for b in body.bytes() {
1898        let ok = b == 0x21 || (0x23..=0x7E).contains(&b) || b >= 0x80;
1899        if !ok {
1900            return None;
1901        }
1902    }
1903    Some((is_weak, s.to_owned()))
1904}
1905
1906/// Parse the canonical IMF-fixdate form per RFC 9110 §5.6.7 into a
1907/// (year, month, day, hour, minute, second) tuple. Only IMF-fixdate
1908/// (`Sun, 06 Nov 1994 08:49:37 GMT`) is parsed here; the obsolete
1909/// `rfc850-date` and `asctime-date` forms have their own parsers and
1910/// the unified [`parse_http_date`] entry point dispatches between them.
1911fn parse_imf_fixdate(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
1912    // Expect: "Wkd, DD Mon YYYY HH:MM:SS GMT"
1913    let s = s.trim();
1914    let bytes = s.as_bytes();
1915    if bytes.len() < 29 {
1916        return None;
1917    }
1918    // bytes[3] = ',', bytes[4] = ' '
1919    if bytes[3] != b',' || bytes[4] != b' ' {
1920        return None;
1921    }
1922    let day: u8 = s.get(5..7)?.parse().ok()?;
1923    if bytes[7] != b' ' {
1924        return None;
1925    }
1926    let mon = parse_month_abbr(s.get(8..11)?)?;
1927    if bytes[11] != b' ' {
1928        return None;
1929    }
1930    let year: i32 = s.get(12..16)?.parse().ok()?;
1931    if bytes[16] != b' ' {
1932        return None;
1933    }
1934    let hour: u8 = s.get(17..19)?.parse().ok()?;
1935    if bytes[19] != b':' {
1936        return None;
1937    }
1938    let minute: u8 = s.get(20..22)?.parse().ok()?;
1939    if bytes[22] != b':' {
1940        return None;
1941    }
1942    let second: u8 = s.get(23..25)?.parse().ok()?;
1943    if &s[25..] != " GMT" {
1944        return None;
1945    }
1946    Some((year, mon, day, hour, minute, second))
1947}
1948
1949/// Three-letter month abbreviation → 1..=12, per RFC 9110 §5.6.7
1950/// `month` ABNF (case-sensitive). Shared between every HTTP-date form.
1951fn parse_month_abbr(s: &str) -> Option<u8> {
1952    match s {
1953        "Jan" => Some(1),
1954        "Feb" => Some(2),
1955        "Mar" => Some(3),
1956        "Apr" => Some(4),
1957        "May" => Some(5),
1958        "Jun" => Some(6),
1959        "Jul" => Some(7),
1960        "Aug" => Some(8),
1961        "Sep" => Some(9),
1962        "Oct" => Some(10),
1963        "Nov" => Some(11),
1964        "Dec" => Some(12),
1965        _ => None,
1966    }
1967}
1968
1969/// Parse the obsolete `rfc850-date` form per RFC 9110 §5.6.7 ABNF:
1970///
1971/// ```text
1972/// rfc850-date  = day-name-l "," SP date2 SP time-of-day SP GMT
1973/// date2        = day "-" month "-" 2DIGIT          ; e.g. 02-Jun-82
1974/// day-name-l   = "Monday" / "Tuesday" / "Wednesday"
1975///              / "Thursday" / "Friday" / "Saturday" / "Sunday"
1976/// ```
1977///
1978/// A typical example: `Sunday, 06-Nov-94 08:49:37 GMT`.
1979///
1980/// The 2-digit year is interpreted per §5.6.7's MUST: a value that would
1981/// otherwise be more than 50 years in the future is wrapped to the most
1982/// recent year in the past with the same last two digits. The reference
1983/// year for the 50-year window is fixed at 2026 here — the rolling-clock
1984/// approximation. Any non-zero margin is fine for the §8.8.2.2 "Date >=
1985/// Last-Modified + 1 s" comparison, which is the only consumer.
1986fn parse_rfc850_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
1987    let s = s.trim();
1988    // Split on ", " to separate the (variable-length) day-name-l from
1989    // the fixed-width "DD-Mon-YY HH:MM:SS GMT" tail.
1990    let (wkd, rest) = s.split_once(", ")?;
1991    // Validate day-name-l literally — keeps us from accepting
1992    // IMF-fixdate's three-letter "Sun, " accidentally (the comma+SP
1993    // split would otherwise allow it; rejecting non-l names here makes
1994    // each parser own its own grammar).
1995    match wkd {
1996        "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday" => {}
1997        _ => return None,
1998    }
1999    let bytes = rest.as_bytes();
2000    // "DD-Mon-YY HH:MM:SS GMT" = 22 bytes
2001    if bytes.len() != 22 {
2002        return None;
2003    }
2004    let day: u8 = rest.get(0..2)?.parse().ok()?;
2005    if bytes[2] != b'-' {
2006        return None;
2007    }
2008    let mon = parse_month_abbr(rest.get(3..6)?)?;
2009    if bytes[6] != b'-' {
2010        return None;
2011    }
2012    let yy: u32 = rest.get(7..9)?.parse().ok()?;
2013    if bytes[9] != b' ' {
2014        return None;
2015    }
2016    let hour: u8 = rest.get(10..12)?.parse().ok()?;
2017    if bytes[12] != b':' {
2018        return None;
2019    }
2020    let minute: u8 = rest.get(13..15)?.parse().ok()?;
2021    if bytes[15] != b':' {
2022        return None;
2023    }
2024    let second: u8 = rest.get(16..18)?.parse().ok()?;
2025    if &rest[18..] != " GMT" {
2026        return None;
2027    }
2028    Some((rfc850_expand_year(yy), mon, day, hour, minute, second))
2029}
2030
2031/// Expand a 2-digit year per RFC 9110 §5.6.7's sliding-window rule:
2032/// "Recipients of a timestamp value in rfc850-date format ... MUST
2033/// interpret a timestamp that appears to be more than 50 years in the
2034/// future as representing the most recent year in the past that had the
2035/// same last two digits."
2036///
2037/// Reference year is a compile-time constant (`REF_YEAR_2DIGIT_BASE`)
2038/// chosen to keep us inside the 50-year window for current traffic.
2039/// A 2-digit year `yy` first maps to `cc*100 + yy` for the current
2040/// century; if that lands more than 50 years past `REF_YEAR`, the
2041/// previous century is used.
2042fn rfc850_expand_year(yy: u32) -> i32 {
2043    // The §5.6.7 rule is "more than 50 years in the future"; we anchor
2044    // the window at a fixed reference rather than the system clock so
2045    // the parser is deterministic across machines / time.
2046    const REF_YEAR: i32 = 2026;
2047    let century = (REF_YEAR / 100) * 100;
2048    let candidate = century + yy as i32;
2049    if candidate - REF_YEAR > 50 {
2050        candidate - 100
2051    } else {
2052        candidate
2053    }
2054}
2055
2056/// Parse the obsolete `asctime-date` form per RFC 9110 §5.6.7 ABNF:
2057///
2058/// ```text
2059/// asctime-date = day-name SP date3 SP time-of-day SP year
2060/// date3        = month SP ( 2DIGIT / ( SP 1DIGIT ))  ; e.g. Jun  2
2061/// day-name     = "Mon" / "Tue" / "Wed" / "Thu" / "Fri" / "Sat" / "Sun"
2062/// ```
2063///
2064/// Typical example: `Sun Nov  6 08:49:37 1994`. Note the day field is
2065/// either two digits or `SP + 1 digit` — the single-space-padded form
2066/// is what ANSI C `asctime()` emits for days 1..9.
2067///
2068/// §5.6.7: "values in the asctime format are assumed to be in UTC".
2069fn parse_asctime_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
2070    // Format is exactly: "Www Mmm DD HH:MM:SS YYYY" (24 chars) when day
2071    // has 2 digits, and "Www Mmm  D HH:MM:SS YYYY" (24 chars) when day
2072    // has 1 digit (SP-padded). Both are 24 bytes total.
2073    let s = s.trim();
2074    let bytes = s.as_bytes();
2075    if bytes.len() != 24 {
2076        return None;
2077    }
2078    let wkd = s.get(0..3)?;
2079    match wkd {
2080        "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun" => {}
2081        _ => return None,
2082    }
2083    if bytes[3] != b' ' {
2084        return None;
2085    }
2086    let mon = parse_month_abbr(s.get(4..7)?)?;
2087    if bytes[7] != b' ' {
2088        return None;
2089    }
2090    // date3: either "DD" or " D"
2091    let day_field = s.get(8..10)?;
2092    let day: u8 = if let Some(b) = day_field.strip_prefix(' ') {
2093        // SP + 1 digit form — accept only one digit.
2094        if b.len() != 1 || !b.as_bytes()[0].is_ascii_digit() {
2095            return None;
2096        }
2097        b.parse().ok()?
2098    } else {
2099        day_field.parse().ok()?
2100    };
2101    if bytes[10] != b' ' {
2102        return None;
2103    }
2104    let hour: u8 = s.get(11..13)?.parse().ok()?;
2105    if bytes[13] != b':' {
2106        return None;
2107    }
2108    let minute: u8 = s.get(14..16)?.parse().ok()?;
2109    if bytes[16] != b':' {
2110        return None;
2111    }
2112    let second: u8 = s.get(17..19)?.parse().ok()?;
2113    if bytes[19] != b' ' {
2114        return None;
2115    }
2116    let year: i32 = s.get(20..24)?.parse().ok()?;
2117    Some((year, mon, day, hour, minute, second))
2118}
2119
2120/// Unified HTTP-date parser per RFC 9110 §5.6.7. Tries IMF-fixdate
2121/// first (the form every modern origin emits — §5.6.7 senders MUST
2122/// emit this), then falls back to `rfc850-date`, then `asctime-date`.
2123///
2124/// §5.6.7 makes accepting all three forms a MUST on the recipient side;
2125/// this is the single entry point that satisfies it.
2126fn parse_http_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
2127    parse_imf_fixdate(s)
2128        .or_else(|| parse_rfc850_date(s))
2129        .or_else(|| parse_asctime_date(s))
2130}
2131
2132/// A parsed `Retry-After` header value per RFC 9110 §10.2.3.
2133///
2134/// Servers send this header to indicate how long a user agent ought to
2135/// wait before issuing a follow-up request. The grammar is
2136///
2137/// ```text
2138/// Retry-After   = HTTP-date / delay-seconds
2139/// delay-seconds = 1*DIGIT
2140/// ```
2141///
2142/// We surface both variants — the delay-seconds form as a
2143/// [`std::time::Duration`] for direct sleep-arithmetic, the HTTP-date
2144/// form as the same six-tuple `(year, month, day, hour, minute,
2145/// second)` that the §5.6.7 parsers return so the caller can compare
2146/// against its own clock without dragging a date-time crate in. The
2147/// driver does NOT itself wall-clock the date — interpreting "wait
2148/// until this absolute time" requires a clock the source does not
2149/// own.
2150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2151pub enum RetryAfter {
2152    /// `Retry-After: <N>` — delay this many seconds from receipt of
2153    /// the response. §10.2.3 mandates a non-negative decimal integer
2154    /// (`1*DIGIT`).
2155    Delay(Duration),
2156    /// `Retry-After: <HTTP-date>` — wait until this absolute UTC time.
2157    /// All three §5.6.7 forms are accepted on the receiver side
2158    /// (IMF-fixdate / rfc850-date / asctime-date), matching the MUST
2159    /// in §5.6.7.
2160    Date {
2161        /// Four-digit calendar year (1-9999, but the §5.6.7 sliding
2162        /// window for rfc850-date constrains real-world values to
2163        /// roughly REF_YEAR-49..=REF_YEAR+49).
2164        year: i32,
2165        /// Calendar month, 1-12.
2166        month: u8,
2167        /// Day of month, 1-31.
2168        day: u8,
2169        /// Hour, 0-23.
2170        hour: u8,
2171        /// Minute, 0-59.
2172        minute: u8,
2173        /// Second, 0-60 (the §5.6.7 grammar allows 60 for leap
2174        /// seconds; the parser does not range-check it specially).
2175        second: u8,
2176    },
2177}
2178
2179/// Parse a `Retry-After` field value per RFC 9110 §10.2.3.
2180///
2181/// ABNF: `Retry-After = HTTP-date / delay-seconds` where
2182/// `delay-seconds = 1*DIGIT`. The grammar is `delay-seconds`
2183/// disjoint from `HTTP-date`, so we try `delay-seconds` first
2184/// (cheaper, ambiguity-free — pure digits cannot match any of the
2185/// three HTTP-date forms which all start with an alphabetic weekday
2186/// name or token) then fall back to `parse_http_date`.
2187///
2188/// Returns `None` on syntactically invalid input (leading sign,
2189/// non-numeric characters in the delay-seconds form that also fail
2190/// every HTTP-date form, empty input, etc.). §10.2.3 makes
2191/// `delay-seconds` "a non-negative decimal integer" — we reject a
2192/// leading `+` or `-` even though Rust's `u64::parse` would refuse
2193/// `-` natively, because `+` would otherwise parse successfully.
2194pub fn parse_retry_after(s: &str) -> Option<RetryAfter> {
2195    // §10.2.3 doesn't itself say "OWS-tolerant" but every other
2196    // field value in §5.6 is trimmed of surrounding OWS at field-
2197    // parse time; we match that convention here.
2198    let s = s.trim();
2199    if s.is_empty() {
2200        return None;
2201    }
2202    // delay-seconds = 1*DIGIT. A leading sign would parse via
2203    // `u64::from_str` for `+` only; reject explicitly so the
2204    // §10.2.3 "non-negative decimal integer" rule is enforced
2205    // grammar-strictly rather than accidentally.
2206    if s.bytes().all(|b| b.is_ascii_digit()) {
2207        if let Ok(n) = s.parse::<u64>() {
2208            return Some(RetryAfter::Delay(Duration::from_secs(n)));
2209        }
2210        // All-digit but overflows u64 — §10.2.3 has no upper
2211        // bound, but a value that doesn't fit u64 seconds (≈ 584
2212        // billion years) is not a real-world Retry-After. Surface
2213        // as "unparseable" rather than silently saturating.
2214        return None;
2215    }
2216    let (year, month, day, hour, minute, second) = parse_http_date(s)?;
2217    Some(RetryAfter::Date {
2218        year,
2219        month,
2220        day,
2221        hour,
2222        minute,
2223        second,
2224    })
2225}
2226
2227/// Render a `Retry-After` field value into a parenthesised hint suitable
2228/// for appending to an error message — `" (Retry-After: 120 s)"` for the
2229/// `delay-seconds` form, `" (Retry-After: 1999-12-31T23:59:59 UTC)"` for
2230/// the HTTP-date form, `" (Retry-After: <raw>, unparseable per RFC 9110
2231/// §10.2.3)"` when the field is set but does not match either grammar.
2232///
2233/// Returns the empty string when `raw.trim().is_empty()` so the caller
2234/// can append unconditionally. The `UTC` suffix mirrors §5.6.7's "values
2235/// in the asctime format are assumed to be in UTC" / "GMT" semantics
2236/// — every §5.6.7 form is wall-clock UTC.
2237fn format_retry_after_hint(raw: &str) -> String {
2238    let trimmed = raw.trim();
2239    if trimmed.is_empty() {
2240        return String::new();
2241    }
2242    match parse_retry_after(trimmed) {
2243        Some(RetryAfter::Delay(d)) => format!(" (Retry-After: {} s)", d.as_secs()),
2244        Some(RetryAfter::Date {
2245            year,
2246            month,
2247            day,
2248            hour,
2249            minute,
2250            second,
2251        }) => {
2252            format!(
2253                " (Retry-After: {year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02} UTC)"
2254            )
2255        }
2256        None => format!(" (Retry-After: {trimmed:?}, unparseable per RFC 9110 §10.2.3)"),
2257    }
2258}
2259
2260/// Detect a `Content-Type: multipart/byteranges[; …]` field value per
2261/// RFC 9110 §14.6 / §15.3.7. The media-type proper is case-insensitive
2262/// (§8.3.1: "type, subtype, and parameter name tokens are
2263/// case-insensitive"); the boundary parameter is required by §14.6 but
2264/// we do not need to extract it — every multipart 206 we ever see is a
2265/// server bug because the driver only ever requests single-range, and
2266/// §15.3.7.2 makes "A server MUST NOT generate a multipart response to
2267/// a request for a single range" a hard MUST NOT.
2268///
2269/// Tolerant of OWS before/after the media type per §5.6.3 and of
2270/// trailing `; key=value` parameters per §8.3 (we discard them — the
2271/// only thing the driver acts on is the type/subtype match).
2272fn is_multipart_byteranges_content_type(s: &str) -> bool {
2273    let s = s.trim();
2274    // §8.3 media-type ABNF: `type "/" subtype *( OWS ";" OWS parameter )`.
2275    // Split off the first `;` so trailing parameters (boundary=…, etc.)
2276    // don't affect the type/subtype match.
2277    let media = match s.split_once(';') {
2278        Some((m, _)) => m.trim_end(),
2279        None => s,
2280    };
2281    media.eq_ignore_ascii_case("multipart/byteranges")
2282}
2283
2284/// Convert an IMF-fixdate (already in UTC per §5.6.7 "GMT") into a
2285/// strictly-monotonic 64-bit second count from a fixed epoch. Used only
2286/// for the §8.8.2.2 "Date >= Last-Modified + 1 s" comparison, so any
2287/// epoch that yields a total ordering on real-world HTTP dates is fine.
2288/// We pick (year-2000)*31_557_600 + ... — close enough for the
2289/// strict-greater test; not used as a wall-clock value.
2290fn imf_seconds(d: (i32, u8, u8, u8, u8, u8)) -> i64 {
2291    let (y, mo, da, h, mi, s) = d;
2292    // Cumulative days at start of month, Jan=0..Dec=334 (non-leap),
2293    // good enough for ordering within the same year — across years
2294    // the 365-day step dominates.
2295    const CUM: [u16; 13] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
2296    let day_of_year = CUM[(mo - 1) as usize] as i64 + (da as i64 - 1);
2297    let day_index = (y as i64 - 2000) * 365 + day_of_year;
2298    day_index * 86_400 + (h as i64) * 3_600 + (mi as i64) * 60 + s as i64
2299}
2300
2301/// Decide which validator (if any) we may use as a strong `If-Range`
2302/// value per RFC 9110 §13.1.5 + §8.8.2.2 + §8.8.3.
2303///
2304/// Returns `Some(StrongValidator::Etag(_))` when the HEAD's `ETag` is a
2305/// syntactically valid strong entity-tag (no `W/` prefix). Otherwise
2306/// returns `Some(StrongValidator::LastModified(_))` only when both
2307/// `Last-Modified` and `Date` parse as one of the §5.6.7 HTTP-date
2308/// forms (IMF-fixdate / rfc850-date / asctime-date — §5.6.7 makes
2309/// accepting all three a MUST on recipients) AND the §8.8.2.2
2310/// promotion rule holds (`Date >= Last-Modified + 1 second`). The two
2311/// headers do not need to share the same date form. Returns `None`
2312/// otherwise — the read path then issues plain Range GETs with no
2313/// `If-Range`.
2314fn derive_strong_validator(
2315    etag: Option<&str>,
2316    last_modified: Option<&str>,
2317    date: Option<&str>,
2318) -> Option<StrongValidator> {
2319    if let Some(raw) = etag {
2320        if let Some((is_weak, wire)) = parse_entity_tag(raw) {
2321            if !is_weak {
2322                return Some(StrongValidator::Etag(wire));
2323            }
2324        }
2325    }
2326    if let (Some(lm), Some(dt)) = (last_modified, date) {
2327        // §5.6.7 makes accepting all three HTTP-date forms a MUST on
2328        // recipients: try IMF-fixdate first, fall back to rfc850-date
2329        // and asctime-date. Either header may legitimately use any
2330        // form even though §5.6.7 requires senders to emit IMF-fixdate
2331        // — older proxies and §5.6.7's own "be robust in parsing"
2332        // guidance mean we still see the obsolete forms in the wild.
2333        if let (Some(lm_p), Some(dt_p)) = (parse_http_date(lm), parse_http_date(dt)) {
2334            // §8.8.2.2: promotion requires Date strictly greater than
2335            // Last-Modified (at 1-second resolution). `dt > lm` is the
2336            // clippy-idiomatic spelling of `dt >= lm + 1` on integers.
2337            if imf_seconds(dt_p) > imf_seconds(lm_p) {
2338                return Some(StrongValidator::LastModified(lm.to_owned()));
2339            }
2340        }
2341    }
2342    None
2343}
2344
2345impl Read for HttpSource {
2346    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
2347        if out.is_empty() {
2348            return Ok(0);
2349        }
2350        if self.pos >= self.total_len {
2351            return Ok(0);
2352        }
2353        loop {
2354            if self.body.is_none() {
2355                self.issue_range()?;
2356            }
2357            let body = self.body.as_mut().expect("body just issued");
2358            let n = body.read(out)?;
2359            if n > 0 {
2360                self.pos += n as u64;
2361                return Ok(n);
2362            }
2363            self.body = None;
2364            if self.pos >= self.total_len {
2365                return Ok(0);
2366            }
2367        }
2368    }
2369}
2370
2371impl Seek for HttpSource {
2372    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
2373        let new_pos: u64 = match from {
2374            SeekFrom::Start(n) => n,
2375            SeekFrom::Current(d) => add_signed(self.pos, d)?,
2376            SeekFrom::End(d) => add_signed(self.total_len, d)?,
2377        };
2378        if new_pos > self.total_len {
2379            return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek past end"));
2380        }
2381        if new_pos != self.pos {
2382            self.body = None;
2383            self.pos = new_pos;
2384        }
2385        Ok(new_pos)
2386    }
2387}
2388
2389fn add_signed(base: u64, delta: i64) -> io::Result<u64> {
2390    if delta >= 0 {
2391        base.checked_add(delta as u64)
2392            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek overflow"))
2393    } else {
2394        base.checked_sub(delta.unsigned_abs())
2395            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek before start"))
2396    }
2397}
2398
2399/// The `delta-seconds` saturation value mandated by RFC 9111 §1.2.2.
2400///
2401/// §1.2.2: "If a cache receives a delta-seconds value greater than the
2402/// greatest integer it can represent, or if any of its subsequent
2403/// calculations overflows, the cache MUST consider the value to be
2404/// 2147483648 (2^31) or the greatest positive integer it can
2405/// conveniently represent." We store directive arguments in a `u64`
2406/// (comfortably wider than the §1.2.2 "at least 31 bits" floor), so the
2407/// only saturation path is an argument whose decimal digits overflow
2408/// `u64`; those clamp to this constant rather than being dropped.
2409pub const DELTA_SECONDS_MAX: u64 = 2_147_483_648;
2410
2411/// Parse a `delta-seconds` argument (RFC 9111 §1.2.2 `delta-seconds =
2412/// 1*DIGIT`) with the §1.2.2 overflow-saturation rule applied.
2413///
2414/// Returns `Some(n)` for a non-empty all-ASCII-digit string, saturating
2415/// any value that exceeds `u64::MAX` to [`DELTA_SECONDS_MAX`] per the
2416/// §1.2.2 MUST. Returns `None` for the empty string or any non-digit
2417/// byte (a leading `+`/`-`, embedded space, or a quoted-string form) —
2418/// every directive that takes a `delta-seconds` argument is defined with
2419/// the token form only (§5.2.2.1 / §5.2.1.x: "A sender MUST NOT generate
2420/// the quoted-string form"), and §4.2.1 directs a recipient to treat a
2421/// directive "with non-integer content" as making the response stale, so
2422/// a non-numeric argument is reported as absent rather than silently
2423/// coerced.
2424fn parse_delta_seconds(arg: &str) -> Option<u64> {
2425    if arg.is_empty() || !arg.bytes().all(|b| b.is_ascii_digit()) {
2426        return None;
2427    }
2428    // §1.2.2: detect overflow and saturate rather than wrap; an
2429    // out-of-range count must "not be treated as a negative value in
2430    // later calculations".
2431    Some(arg.parse::<u64>().unwrap_or(DELTA_SECONDS_MAX))
2432}
2433
2434/// A parsed `Cache-Control` field value per RFC 9111 §5.2.
2435///
2436/// The §5.2 grammar `Cache-Control = #cache-directive` and
2437/// `cache-directive = token [ "=" ( token / quoted-string ) ]` is a
2438/// single production shared by request directives (§5.2.1) and response
2439/// directives (§5.2.2); a recipient parses the wire form the same way in
2440/// both roles and applies role-appropriate semantics afterwards, so this
2441/// one struct carries every §5.2.1 / §5.2.2 directive. Fields are `None`
2442/// / `false` / empty when the corresponding directive is absent.
2443///
2444/// §5.2.3: "A cache MUST ignore unrecognized cache directives." Any
2445/// directive token not defined in §5.2.1 / §5.2.2 is preserved in
2446/// [`extensions`](CacheControl::extensions) (so a behavioural-extension
2447/// consumer can still inspect it) rather than discarded — ignoring is a
2448/// behaviour, not a parse failure.
2449///
2450/// Duplicate-directive policy follows RFC 9111 §4.2.1: "When there is
2451/// more than one value present for a given directive … either the first
2452/// occurrence should be used or the response should be considered
2453/// stale." This parser takes the first-occurrence option for every
2454/// valued directive (later duplicates of `max-age`, `s-maxage`, etc. are
2455/// dropped). A boolean directive appearing more than once is idempotent.
2456#[derive(Debug, Clone, Default, PartialEq, Eq)]
2457pub struct CacheControl {
2458    /// `max-age` (§5.2.1.1 request / §5.2.2.1 response) `delta-seconds`,
2459    /// §1.2.2-saturated. `None` when absent or carrying a non-`1*DIGIT`
2460    /// argument.
2461    pub max_age: Option<u64>,
2462    /// `s-maxage` (§5.2.2.10) `delta-seconds`, §1.2.2-saturated.
2463    pub s_maxage: Option<u64>,
2464    /// `max-stale` (§5.2.1.2). `Some(None)` is the no-argument form
2465    /// ("accept a stale response of any age"); `Some(Some(n))` carries
2466    /// the §1.2.2-saturated bound. `None` when the directive is absent.
2467    pub max_stale: Option<Option<u64>>,
2468    /// `min-fresh` (§5.2.1.3) `delta-seconds`, §1.2.2-saturated.
2469    pub min_fresh: Option<u64>,
2470    /// Unqualified `no-cache` — §5.2.1.4 (request) / the argument-free
2471    /// §5.2.2.4 (response) form. `true` whenever a `no-cache` directive
2472    /// is present without an argument.
2473    pub no_cache: bool,
2474    /// Qualified `no-cache="field1, field2"` field names (§5.2.2.4),
2475    /// lowercased ("Field names are case-insensitive"). Empty unless the
2476    /// quoted-string argument form is present.
2477    pub no_cache_fields: Vec<String>,
2478    /// `no-store` (§5.2.1.5 request / §5.2.2.5 response).
2479    pub no_store: bool,
2480    /// `no-transform` (§5.2.1.6 request / §5.2.2.6 response).
2481    pub no_transform: bool,
2482    /// `only-if-cached` (§5.2.1.7, request directive).
2483    pub only_if_cached: bool,
2484    /// `must-revalidate` (§5.2.2.2, response directive).
2485    pub must_revalidate: bool,
2486    /// `must-understand` (§5.2.2.3, response directive).
2487    pub must_understand: bool,
2488    /// Unqualified `private` (§5.2.2.7, response directive).
2489    pub private: bool,
2490    /// Qualified `private="field1, field2"` field names (§5.2.2.7),
2491    /// lowercased. Empty unless the quoted-string argument form is
2492    /// present.
2493    pub private_fields: Vec<String>,
2494    /// `proxy-revalidate` (§5.2.2.8, response directive).
2495    pub proxy_revalidate: bool,
2496    /// `public` (§5.2.2.9, response directive).
2497    pub public: bool,
2498    /// Unrecognized / extension directives (§5.2.3), preserved as
2499    /// `(lowercased token, optional decoded argument)` in wire order.
2500    pub extensions: Vec<(String, Option<String>)>,
2501}
2502
2503/// Parse a `Cache-Control` field value per RFC 9111 §5.2.
2504///
2505/// ABNF (§5.2):
2506///
2507/// ```text
2508/// Cache-Control   = #cache-directive
2509/// cache-directive = token [ "=" ( token / quoted-string ) ]
2510/// ```
2511///
2512/// The `#`-list (RFC 9110 §5.6.1) is split on top-level commas with
2513/// quoted-string awareness (a comma inside a `"…"` argument does not end
2514/// a directive), empty elements are skipped per §5.6.1's "tolerate empty
2515/// list elements" recipient posture, and surrounding OWS (§5.6.3) is
2516/// trimmed from each element. An `obs-fold` (RFC 7230 §3.2.4) anywhere
2517/// in the value is normalised to a single SP before splitting.
2518///
2519/// §5.2: "Cache directives are identified by a token, to be compared
2520/// case-insensitively" — directive names are lowercased before
2521/// dispatch. "[Directives] have an optional argument that can use both
2522/// token and quoted-string syntax. For the directives defined below that
2523/// define arguments, recipients ought to accept both forms" — so the
2524/// argument is read via the §5.6.4 quoted-string unwrap when it opens
2525/// with DQUOTE, and as a bare token otherwise, regardless of which form
2526/// §5.2 nominally requires the *sender* to use.
2527///
2528/// Recognized directives populate the typed [`CacheControl`] fields;
2529/// `delta-seconds` arguments are validated and §1.2.2-saturated via
2530/// [`parse_delta_seconds`] (a non-numeric argument leaves the field
2531/// `None` — §4.2.1 stale-on-non-integer). The qualified `#field-name`
2532/// forms of `no-cache` / `private` (§5.2.2.4 / §5.2.2.7) split their
2533/// decoded quoted-string argument into lowercased field names. Unknown
2534/// directive tokens land in [`CacheControl::extensions`] (§5.2.3 "ignore
2535/// unrecognized" — preserved, not dropped). Duplicate valued directives
2536/// keep the first occurrence (§4.2.1).
2537///
2538/// This is always a structural parse: a malformed element (a bad token
2539/// name, OWS around the `=`, an unterminated quoted-string) is skipped,
2540/// never a hard error, matching the recipient robustness the rest of the
2541/// driver applies to §5.6.1 list fields.
2542pub fn parse_cache_control(s: &str) -> CacheControl {
2543    let normalized = normalize_obs_fold(s);
2544    let mut cc = CacheControl::default();
2545    for elem in split_directive_list(&normalized) {
2546        // cache-directive = token [ "=" ( token / quoted-string ) ].
2547        // Split on the FIRST `=`; a `=` inside the quoted-string body is
2548        // protected because the qstr only appears on the value side.
2549        let (raw_name, raw_arg) = match elem.split_once('=') {
2550            Some((n, v)) => (n, Some(v)),
2551            None => (elem, None),
2552        };
2553        // §5.6.3 OWS is not permitted around the `=` in a directive
2554        // (cache-directive has no OWS between token and "="); but a
2555        // recipient trimming the element edges is harmless and matches
2556        // the parse_one_parameter posture. Trim element-edge OWS only.
2557        let name = raw_name.trim_matches(|c: char| c == ' ' || c == '\t');
2558        if !is_token(name) {
2559            // Empty or non-token directive name — skip (e.g. a stray
2560            // comma element, or `"=foo"`).
2561            continue;
2562        }
2563        let lname = name.to_ascii_lowercase();
2564        // Decode the argument: quoted-string (§5.6.4 unwrap) when it
2565        // opens with DQUOTE, otherwise a bare token. A present-but-empty
2566        // or malformed argument decodes to None for that element.
2567        let arg: Option<String> = match raw_arg {
2568            None => None,
2569            Some(v) => {
2570                let v = v.trim_matches(|c: char| c == ' ' || c == '\t');
2571                if v.starts_with('"') {
2572                    // §5.6.4 quoted-string form: unwrap and collapse any
2573                    // quoted-pair. An unterminated qstr decodes to None.
2574                    unquote_string(v).map(|c| c.into_owned())
2575                } else if is_token(v) {
2576                    Some(v.to_owned())
2577                } else {
2578                    // Empty or non-token bare argument — treat the
2579                    // directive as argument-less for recognized booleans,
2580                    // and as a no-argument extension otherwise.
2581                    None
2582                }
2583            }
2584        };
2585        apply_directive(&mut cc, &lname, arg);
2586    }
2587    cc
2588}
2589
2590/// Dispatch one parsed `(lowercased-name, optional-decoded-argument)`
2591/// directive into the typed [`CacheControl`] accumulator per RFC 9111
2592/// §5.2.1 / §5.2.2, honouring §4.2.1 first-occurrence-wins for valued
2593/// directives and §5.2.3 extension preservation.
2594fn apply_directive(cc: &mut CacheControl, name: &str, arg: Option<String>) {
2595    // delta-seconds helper: first-occurrence-wins (§4.2.1) on the
2596    // already-populated slot.
2597    fn set_delta(slot: &mut Option<u64>, arg: &Option<String>) {
2598        if slot.is_none() {
2599            if let Some(a) = arg {
2600                *slot = parse_delta_seconds(a);
2601            }
2602        }
2603    }
2604    // #field-name argument splitter for the qualified no-cache / private
2605    // forms (§5.2.2.4 / §5.2.2.7): a comma list of field-names, each a
2606    // token, lowercased ("Field names are case-insensitive").
2607    fn field_names(arg: &str) -> Vec<String> {
2608        arg.split(',')
2609            .map(|f| f.trim_matches(|c: char| c == ' ' || c == '\t'))
2610            .filter(|f| is_token(f))
2611            .map(|f| f.to_ascii_lowercase())
2612            .collect()
2613    }
2614    match name {
2615        "max-age" => set_delta(&mut cc.max_age, &arg),
2616        "s-maxage" => set_delta(&mut cc.s_maxage, &arg),
2617        "min-fresh" => set_delta(&mut cc.min_fresh, &arg),
2618        "max-stale" => {
2619            // §5.2.1.2: "If no value is assigned to max-stale, then the
2620            // client will accept a stale response of any age." Model
2621            // no-arg as Some(None), valued as Some(Some(n)). First
2622            // occurrence wins (§4.2.1).
2623            if cc.max_stale.is_none() {
2624                cc.max_stale = Some(arg.as_deref().and_then(parse_delta_seconds));
2625            }
2626        }
2627        "no-cache" => {
2628            // §5.2.2.4: unqualified (no arg) vs qualified (#field-name in
2629            // a quoted-string). A present argument is the qualified form.
2630            match arg {
2631                Some(a) => {
2632                    let names = field_names(&a);
2633                    if cc.no_cache_fields.is_empty() {
2634                        cc.no_cache_fields = names;
2635                    }
2636                }
2637                None => cc.no_cache = true,
2638            }
2639        }
2640        "private" => match arg {
2641            Some(a) => {
2642                let names = field_names(&a);
2643                if cc.private_fields.is_empty() {
2644                    cc.private_fields = names;
2645                }
2646            }
2647            None => cc.private = true,
2648        },
2649        "no-store" => cc.no_store = true,
2650        "no-transform" => cc.no_transform = true,
2651        "only-if-cached" => cc.only_if_cached = true,
2652        "must-revalidate" => cc.must_revalidate = true,
2653        "must-understand" => cc.must_understand = true,
2654        "proxy-revalidate" => cc.proxy_revalidate = true,
2655        "public" => cc.public = true,
2656        // §5.2.3: ignore unrecognized directives — preserved (not
2657        // dropped) so behavioural-extension consumers can still see them.
2658        _ => cc.extensions.push((name.to_owned(), arg)),
2659    }
2660}
2661
2662/// Split a `Cache-Control` (or any RFC 9110 §5.6.1 `#`-list whose
2663/// elements may contain a quoted-string argument) value into trimmed,
2664/// non-empty elements on top-level commas.
2665///
2666/// A comma inside a `"…"` quoted-string (with `\`-escaped octets
2667/// honoured per §5.6.4 `quoted-pair`) does NOT split the list — this is
2668/// what lets `no-cache="x-foo, x-bar"` survive as one directive. Empty
2669/// elements (from `a,,b` or a leading/trailing comma) are dropped per
2670/// §5.6.1, and each surviving element is OWS-trimmed (§5.6.3).
2671fn split_directive_list(s: &str) -> Vec<&str> {
2672    let bytes = s.as_bytes();
2673    let mut out: Vec<&str> = Vec::new();
2674    let mut start = 0usize;
2675    let mut in_quote = false;
2676    let mut i = 0usize;
2677    while i < bytes.len() {
2678        let b = bytes[i];
2679        if in_quote {
2680            if b == b'\\' && i + 1 < bytes.len() {
2681                // quoted-pair: skip the escaped octet so a `\"` or `\,`
2682                // inside the body is not mistaken for a delimiter.
2683                i += 2;
2684                continue;
2685            } else if b == b'"' {
2686                in_quote = false;
2687            }
2688        } else if b == b'"' {
2689            in_quote = true;
2690        } else if b == b',' {
2691            let elem = s[start..i].trim_matches(|c: char| c == ' ' || c == '\t');
2692            if !elem.is_empty() {
2693                out.push(elem);
2694            }
2695            start = i + 1;
2696        }
2697        i += 1;
2698    }
2699    let elem = s[start..].trim_matches(|c: char| c == ' ' || c == '\t');
2700    if !elem.is_empty() {
2701        out.push(elem);
2702    }
2703    out
2704}
2705
2706/// One parsed `challenge` from a `WWW-Authenticate` (or
2707/// `Proxy-Authenticate`) field value, per RFC 9110 §11.3:
2708///
2709/// ```text
2710/// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
2711/// ```
2712///
2713/// A challenge is an authentication-scheme name optionally followed by
2714/// EITHER a single `token68` blob (the base64-ish form used by schemes
2715/// like Negotiate / NTLM) OR a comma-separated list of `auth-param`
2716/// name/value pairs (the form used by Basic / Digest, e.g.
2717/// `realm="…"`). The two argument shapes are mutually exclusive within
2718/// one challenge, so [`token68`](Challenge::token68) and
2719/// [`params`](Challenge::params) are never both populated.
2720#[derive(Debug, Clone, Default, PartialEq, Eq)]
2721pub struct Challenge {
2722    /// `auth-scheme` (§11.1) — "a case-insensitive token to identify the
2723    /// authentication scheme", lowercased here so `Basic` / `basic`
2724    /// compare equal.
2725    pub scheme: String,
2726    /// The `token68` form (§11.2) when the scheme carried a single
2727    /// base64/base32/base16-style blob instead of `auth-param`s. `None`
2728    /// when the challenge had no argument or carried `auth-param`s.
2729    pub token68: Option<String>,
2730    /// `auth-param` list (§11.2) as `(lowercased-name, decoded-value)`
2731    /// pairs in wire order. Names are matched case-insensitively (§11.2);
2732    /// values are read through the §5.6.4 quoted-string unwrap when
2733    /// DQUOTE-wrapped and kept verbatim when in `token` form (value
2734    /// case-sensitivity is scheme-specific, so the value case is not
2735    /// folded). Empty when the challenge had no argument or carried a
2736    /// `token68`.
2737    pub params: Vec<(String, String)>,
2738}
2739
2740/// Parse a `WWW-Authenticate` / `Proxy-Authenticate` field value into a
2741/// list of [`Challenge`]s, per RFC 9110 §11.6.1:
2742///
2743/// ```text
2744/// WWW-Authenticate = #challenge
2745/// challenge        = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
2746/// auth-scheme      = token
2747/// auth-param       = token BWS "=" BWS ( token / quoted-string )
2748/// token68          = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~"
2749///                        / "+" / "/" ) *"="
2750/// ```
2751///
2752/// ## The §11.6.1 list ambiguity
2753///
2754/// Both the challenge list AND the `auth-param` list inside a challenge
2755/// are comma-separated, so a flat top-level comma split cannot by itself
2756/// tell "next challenge" from "next `auth-param` of the current
2757/// challenge". §11.6.1 spells out the worked example:
2758///
2759/// ```text
2760/// WWW-Authenticate: Basic realm="simple", Newauth realm="apps",
2761///                   type=1, title="Login to \"apps\""
2762/// ```
2763///
2764/// which is **two** challenges: `Basic` with `realm="simple"`, and
2765/// `Newauth` with `realm="apps", type=1, title="Login to \"apps\""`.
2766///
2767/// The disambiguation rule this parser applies, after a quoted-string-
2768/// aware top-level comma split (§5.6.1 `#`-list, so a comma inside a
2769/// `"…"` value never splits): each list element is classified as either
2770///
2771/// - a **bare `auth-param`** (`token BWS "=" …`) — it has NO scheme of
2772///   its own, so it attaches to the challenge currently being built; or
2773/// - a **challenge head** (`auth-scheme` alone, or `auth-scheme 1*SP
2774///   <arg>`) — it starts a new challenge.
2775///
2776/// An element is a bare `auth-param` when its first `token` is
2777/// immediately followed (modulo §11.2 BWS around `=`) by `=` with no
2778/// intervening `1*SP` scheme/argument boundary. Everything else opens a
2779/// new challenge, whose first whitespace-delimited token is the
2780/// `auth-scheme` and whose remainder (if any) is the first `token68` or
2781/// first `auth-param`.
2782///
2783/// ## Token68 vs auth-param within a challenge
2784///
2785/// §11.2's `token68` may end in `*"="`, which collides with the
2786/// `auth-param` `=`. The parser treats a challenge argument as
2787/// `token68` only when it is one whitespace-free run whose body is all
2788/// `token68` characters with any `=` confined to a trailing pad run AND
2789/// it is not of `name=value` `auth-param` shape; otherwise the
2790/// remainder is parsed as the `#auth-param` list. Once a challenge has
2791/// committed to `auth-param`s, subsequent bare `auth-param` elements
2792/// attach to it.
2793///
2794/// ## Robustness
2795///
2796/// Matching the rest of the driver's §5.6.1 list handling, malformed
2797/// pieces are skipped rather than failing the whole parse: an empty
2798/// list element (the §11.6.1 "comma, whitespace, comma" note calls this
2799/// harmless), an `auth-param` slot that is not `token = (token /
2800/// quoted-string)`, or a leading bare `auth-param` with no challenge to
2801/// attach to are all dropped while the surrounding well-formed
2802/// challenges survive. An `obs-fold` (RFC 7230 §3.2.4) anywhere in the
2803/// value is normalised to a single SP before parsing.
2804///
2805/// The same grammar backs `Proxy-Authenticate` (§11.7.1) and, with the
2806/// `credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]`
2807/// production being identical, a single `Authorization` /
2808/// `Proxy-Authorization` value (which carries exactly one challenge-
2809/// shaped `credentials`); a caller wanting just the credentials reads
2810/// the first element of the returned `Vec`.
2811pub fn parse_www_authenticate(s: &str) -> Vec<Challenge> {
2812    let normalized = normalize_obs_fold(s);
2813    // §5.6.1 `#`-list: quoted-string-aware top-level comma split. We
2814    // reuse split_directive_list — its quoted-string awareness and
2815    // §5.6.3 OWS-trim / empty-drop are exactly the §11.6.1 list posture.
2816    let elements = split_directive_list(&normalized);
2817    let mut out: Vec<Challenge> = Vec::new();
2818
2819    for elem in elements {
2820        // Classify: does this element start a new challenge, or is it a
2821        // bare auth-param attaching to the current one?
2822        if let Some((scheme, rest)) = split_challenge_head(elem) {
2823            // New challenge. `scheme` is the auth-scheme token; `rest`
2824            // (already OWS-trimmed) is the first token68 / first
2825            // auth-param, or empty.
2826            let mut ch = Challenge {
2827                scheme: scheme.to_ascii_lowercase(),
2828                token68: None,
2829                params: Vec::new(),
2830            };
2831            if !rest.is_empty() {
2832                if let Some(t68) = as_token68(rest) {
2833                    ch.token68 = Some(t68.to_owned());
2834                } else if let Some(pair) = parse_one_auth_param(rest) {
2835                    ch.params.push(pair);
2836                }
2837                // A non-empty rest that is neither a token68 nor a valid
2838                // auth-param is dropped (malformed first argument); the
2839                // scheme survives so a caller still sees the challenge.
2840            }
2841            out.push(ch);
2842        } else {
2843            // Bare auth-param: attach to the challenge in progress. A
2844            // challenge that committed to token68 cannot also take
2845            // auth-params (§11.3 mutual exclusivity); in that case, and
2846            // when there is no current challenge at all, the orphan slot
2847            // is dropped.
2848            if let Some(cur) = out.last_mut() {
2849                if cur.token68.is_none() {
2850                    if let Some(pair) = parse_one_auth_param(elem) {
2851                        cur.params.push(pair);
2852                    }
2853                }
2854            }
2855        }
2856    }
2857    out
2858}
2859
2860/// Classify a §5.6.1 list element as a challenge head.
2861///
2862/// Returns `Some((auth-scheme, rest))` when the element opens a new
2863/// challenge — i.e. its first `token` is the `auth-scheme` and is NOT
2864/// immediately followed (modulo §11.2 BWS) by `=` (which would make the
2865/// element a bare `auth-param` of the *current* challenge). `rest` is the
2866/// OWS-trimmed remainder after the `1*SP` that separates the scheme from
2867/// its first `token68` / `auth-param` (empty when the challenge is just a
2868/// bare scheme).
2869///
2870/// Returns `None` when the element is a bare `auth-param`
2871/// (`token BWS "=" …`) or has no leading `token` at all.
2872fn split_challenge_head(elem: &str) -> Option<(&str, &str)> {
2873    let bytes = elem.as_bytes();
2874    // The element is already OWS-trimmed by split_directive_list, so it
2875    // starts at the first non-OWS byte. Read the leading token run.
2876    let mut i = 0usize;
2877    while i < bytes.len() && is_tchar(bytes[i]) {
2878        i += 1;
2879    }
2880    if i == 0 {
2881        // No leading token (starts with `=`, a quote, whitespace, …):
2882        // not a challenge head.
2883        return None;
2884    }
2885    let head = &elem[..i];
2886    // What follows the leading token?
2887    //   - end of element        -> bare scheme, no argument.
2888    //   - 1*SP then more        -> scheme + (token68 / first auth-param).
2889    //   - [BWS] "=" …           -> bare auth-param (NOT a challenge head).
2890    // Peek past optional BWS (SP / HTAB — §11.2 allows "bad" whitespace
2891    // around the `=`).
2892    let mut j = i;
2893    while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
2894        j += 1;
2895    }
2896    if j >= bytes.len() {
2897        // Bare scheme (possibly with trailing whitespace already trimmed).
2898        return Some((head, ""));
2899    }
2900    if bytes[j] == b'=' {
2901        // `token [BWS] "="` — this element is an auth-param, not a
2902        // challenge head. Defer to the caller's bare-auth-param branch.
2903        return None;
2904    }
2905    if j == i {
2906        // No SP/HTAB separator and the abutting byte is not `=`: a
2907        // non-tchar byte directly follows the leading token run (e.g.
2908        // `bad()` or `Basic"x"`). This is neither a clean `auth-scheme`
2909        // (which would have consumed the whole run as one token) nor a
2910        // bare `auth-param` (`token [BWS] "="`), so it is malformed. Defer
2911        // to the caller's bare-auth-param branch, which will drop it (it
2912        // has no valid `token = value` shape) rather than fabricating a
2913        // spurious challenge from the truncated leading token.
2914        return None;
2915    }
2916    // There was at least one SP/HTAB before a non-`=` byte: this is the
2917    // `auth-scheme 1*SP <arg>` form.
2918    let rest = elem[j..].trim_matches(|c: char| c == ' ' || c == '\t');
2919    Some((head, rest))
2920}
2921
2922/// `token68` recogniser (RFC 9110 §11.2):
2923///
2924/// ```text
2925/// token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
2926/// ```
2927///
2928/// Returns `Some(s)` when `s` is a non-empty `token68`: a `1*` run of the
2929/// 66 unreserved-plus characters followed by an optional trailing run of
2930/// `=` pad octets, AND `s` is NOT of `auth-param` `name=value` shape
2931/// (which the §11.6.1 ambiguity note resolves toward auth-param). A
2932/// `token68` never contains whitespace or an interior `=` that is
2933/// followed by a non-`=` octet.
2934fn as_token68(s: &str) -> Option<&str> {
2935    let bytes = s.as_bytes();
2936    if bytes.is_empty() {
2937        return None;
2938    }
2939    // Count the leading body run (no `=`).
2940    let mut i = 0usize;
2941    while i < bytes.len() && is_token68_body(bytes[i]) {
2942        i += 1;
2943    }
2944    if i == 0 {
2945        // token68 requires 1* body chars before the pad.
2946        return None;
2947    }
2948    // Everything from i on MUST be `=` (the `*"="` pad).
2949    if bytes[i..].iter().all(|&b| b == b'=') {
2950        Some(s)
2951    } else {
2952        None
2953    }
2954}
2955
2956/// Single `auth-param` parser (RFC 9110 §11.2):
2957///
2958/// ```text
2959/// auth-param = token BWS "=" BWS ( token / quoted-string )
2960/// ```
2961///
2962/// Returns `Some((lowercased-name, decoded-value))` on a well-formed
2963/// `auth-param`. Unlike the §5.6.6 `parameter` (which forbids whitespace
2964/// around `=`), §11.2 explicitly allows BWS ("bad" whitespace) on BOTH
2965/// sides of the `=`, so this parser trims SP/HTAB around the separator.
2966/// The value is read through the §5.6.4 quoted-string unwrap when
2967/// DQUOTE-wrapped and kept verbatim as a `token` otherwise. Returns
2968/// `None` for a missing `=`, a non-`token` name, or a value that is
2969/// neither a valid `token` nor a valid `quoted-string`.
2970fn parse_one_auth_param(slot: &str) -> Option<(String, String)> {
2971    let slot = slot.trim_matches(|c: char| c == ' ' || c == '\t');
2972    let eq = slot.find('=')?;
2973    // §11.2 BWS: trim SP/HTAB around `=` on both sides.
2974    let name = slot[..eq].trim_matches(|c: char| c == ' ' || c == '\t');
2975    let value = slot[eq + 1..].trim_matches(|c: char| c == ' ' || c == '\t');
2976    if !is_token(name) {
2977        return None;
2978    }
2979    let lname = name.to_ascii_lowercase();
2980    if value.starts_with('"') {
2981        // §5.6.4 quoted-string: unwrap + collapse quoted-pair. An
2982        // unterminated / malformed quoted-string yields None (slot
2983        // dropped).
2984        let decoded = unquote_string(value)?.into_owned();
2985        Some((lname, decoded))
2986    } else if is_token(value) {
2987        Some((lname, value.to_owned()))
2988    } else {
2989        None
2990    }
2991}
2992
2993/// `tchar` (RFC 9110 §5.6.2) membership test on a single byte — the
2994/// byte-level companion to [`is_token`].
2995fn is_tchar(b: u8) -> bool {
2996    b.is_ascii_alphanumeric()
2997        || matches!(
2998            b,
2999            b'!' | b'#'
3000                | b'$'
3001                | b'%'
3002                | b'&'
3003                | b'\''
3004                | b'*'
3005                | b'+'
3006                | b'-'
3007                | b'.'
3008                | b'^'
3009                | b'_'
3010                | b'`'
3011                | b'|'
3012                | b'~'
3013        )
3014}
3015
3016/// `token68` body-character (RFC 9110 §11.2, excluding the trailing `=`
3017/// pad) membership test on a single byte.
3018fn is_token68_body(b: u8) -> bool {
3019    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
3020}
3021
3022/// Header-parser wrappers exposed for the cargo-fuzz harness under
3023/// `fuzz/`. Not part of the stable public surface; gated behind the
3024/// `fuzz` cargo feature so the published artefact carries the same
3025/// crate boundary it always had.
3026///
3027/// The wrappers return `bool` (parse succeeded vs not) — the fuzz
3028/// contract under test is that none of these parsers panic on any
3029/// byte string, not that any particular byte string parses.
3030#[cfg(feature = "fuzz")]
3031#[doc(hidden)]
3032pub mod __fuzz {
3033    /// Fuzz-only wrapper for [`super::parse_byte_content_range`].
3034    pub fn parse_byte_content_range(s: &str) -> bool {
3035        super::parse_byte_content_range(s).is_ok()
3036    }
3037    /// Fuzz-only wrapper for [`super::parse_byte_unsatisfied_range`].
3038    pub fn parse_byte_unsatisfied_range(s: &str) -> bool {
3039        super::parse_byte_unsatisfied_range(s).is_ok()
3040    }
3041    /// Fuzz-only wrapper for [`super::parse_entity_tag`].
3042    pub fn parse_entity_tag(s: &str) -> bool {
3043        super::parse_entity_tag(s).is_some()
3044    }
3045    /// Fuzz-only wrapper for [`super::parse_imf_fixdate`].
3046    pub fn parse_imf_fixdate(s: &str) -> bool {
3047        super::parse_imf_fixdate(s).is_some()
3048    }
3049    /// Fuzz-only wrapper for [`super::parse_rfc850_date`].
3050    pub fn parse_rfc850_date(s: &str) -> bool {
3051        super::parse_rfc850_date(s).is_some()
3052    }
3053    /// Fuzz-only wrapper for [`super::parse_asctime_date`].
3054    pub fn parse_asctime_date(s: &str) -> bool {
3055        super::parse_asctime_date(s).is_some()
3056    }
3057    /// Fuzz-only wrapper for [`super::parse_http_date`] (the unified
3058    /// §5.6.7 entry point covering IMF-fixdate / rfc850-date /
3059    /// asctime-date).
3060    pub fn parse_http_date(s: &str) -> bool {
3061        super::parse_http_date(s).is_some()
3062    }
3063    /// Fuzz-only wrapper for [`super::parse_retry_after`] (RFC 9110
3064    /// §10.2.3 — `HTTP-date / delay-seconds`).
3065    pub fn parse_retry_after(s: &str) -> bool {
3066        super::parse_retry_after(s).is_some()
3067    }
3068    /// Fuzz-only wrapper for [`super::derive_strong_validator`]. The
3069    /// caller splits the input on NUL bytes into up to three optional
3070    /// header values (etag, last-modified, date) so the fuzzer can
3071    /// drive all 8 input-presence combinations.
3072    pub fn derive_strong_validator(etag: Option<&str>, lm: Option<&str>, date: Option<&str>) {
3073        let _ = super::derive_strong_validator(etag, lm, date);
3074    }
3075    /// Fuzz-only wrapper for [`super::parse_accept_ranges`] (RFC 9110
3076    /// §14.3 `acceptable-ranges = 1#range-unit`). Returns a small
3077    /// integer tag (0=Bytes, 1=None, 2=Other, 3=Absent) so the fuzzer
3078    /// can drive every classification branch.
3079    pub fn parse_accept_ranges(s: &str) -> u8 {
3080        match super::parse_accept_ranges(s) {
3081            super::AcceptRanges::Bytes => 0,
3082            super::AcceptRanges::None => 1,
3083            super::AcceptRanges::Other(_) => 2,
3084            super::AcceptRanges::Absent => 3,
3085        }
3086    }
3087    /// Fuzz-only wrapper for [`super::parse_vary`] (RFC 9110 §12.5.5
3088    /// `Vary = #( "*" / field-name )`). Returns a small integer tag
3089    /// (0=Absent, 1=Wildcard, 2=Fields) so the fuzzer can drive every
3090    /// classification branch.
3091    pub fn parse_vary(s: &str) -> u8 {
3092        match super::parse_vary(s) {
3093            super::Vary::Absent => 0,
3094            super::Vary::Wildcard => 1,
3095            super::Vary::Fields(_) => 2,
3096        }
3097    }
3098    /// Fuzz-only wrapper for
3099    /// [`super::is_multipart_byteranges_content_type`] (RFC 9110 §8.3
3100    /// + §14.6 / §15.3.7.2).
3101    pub fn is_multipart_byteranges_content_type(s: &str) -> bool {
3102        super::is_multipart_byteranges_content_type(s)
3103    }
3104    /// Fuzz-only wrapper for [`super::format_retry_after_hint`] —
3105    /// exercises the RFC 9110 §10.2.3 surfacing path used by the HEAD
3106    /// non-success branch. Returns the rendered hint so the fuzzer can
3107    /// verify the function never panics on arbitrary input.
3108    pub fn format_retry_after_hint(s: &str) -> String {
3109        super::format_retry_after_hint(s)
3110    }
3111    /// Fuzz-only wrapper for [`super::normalize_obs_fold`] — exercises
3112    /// the RFC 7230 §3.2.4 obs-fold normaliser on arbitrary input.
3113    /// Returns the normalised string so the fuzzer can verify the
3114    /// function never panics and always yields valid UTF-8.
3115    pub fn normalize_obs_fold(s: &str) -> String {
3116        super::normalize_obs_fold(s).into_owned()
3117    }
3118    /// Fuzz-only wrapper for [`super::unquote_string`] — exercises the
3119    /// RFC 9110 §5.6.4 `quoted-string` unwrap (DQUOTE-stripping +
3120    /// `quoted-pair` collapsing) on arbitrary input. Returns the
3121    /// decoded value when the input parses, `None` otherwise; either
3122    /// outcome must be reachable without a panic.
3123    pub fn unquote_string(s: &str) -> Option<String> {
3124        super::unquote_string(s).map(|c| c.into_owned())
3125    }
3126    /// Fuzz-only wrapper for [`super::parse_comment`] — exercises the
3127    /// RFC 9110 §5.6.5 `comment` grammar (outer-paren strip, nested
3128    /// comment recursion with balanced-paren tracking, `quoted-pair`
3129    /// collapse) on arbitrary input. Returns the decoded comment text
3130    /// when the input is a single valid comment, `None` otherwise —
3131    /// both outcomes must be reachable without a panic (and without a
3132    /// stack overflow on deeply nested `((((…))))` input), and any
3133    /// returned string must be valid UTF-8.
3134    pub fn parse_comment(s: &str) -> Option<String> {
3135        super::parse_comment(s).map(|c| c.into_owned())
3136    }
3137    /// Fuzz-only wrapper for [`super::parse_parameters`] — exercises the
3138    /// RFC 9110 §5.6.6 `parameters` grammar (semicolon-delimited
3139    /// `name=value` slots with quoted-string-aware splitting) on
3140    /// arbitrary input. Returns the count of recognised parameters so
3141    /// the fuzzer can drive both the zero-parameter and many-parameter
3142    /// branches; the function must never panic, and every returned
3143    /// `(name, value)` pair must be valid UTF-8.
3144    pub fn parse_parameters(s: &str) -> usize {
3145        super::parse_parameters(s).len()
3146    }
3147    /// Fuzz-only wrapper for [`super::parse_media_type`] — exercises the
3148    /// RFC 9110 §8.3.1 `media-type = type "/" subtype parameters` grammar
3149    /// on arbitrary input. Returns `Some(parameter-count)` when the value
3150    /// is a syntactically valid media-type, `None` otherwise — both
3151    /// outcomes must be reachable without a panic, and any returned
3152    /// `(type, subtype, params)` strings must be valid UTF-8.
3153    pub fn non_identity_content_codings(s: &str) -> usize {
3154        super::non_identity_content_codings(s).len()
3155    }
3156
3157    pub fn parse_media_type(s: &str) -> Option<usize> {
3158        super::parse_media_type(s).map(|(_, _, p)| p.len())
3159    }
3160    /// Fuzz-only wrapper for [`super::parse_cache_control`] — exercises
3161    /// the RFC 9111 §5.2 `Cache-Control = #cache-directive` grammar
3162    /// (quoted-string-aware comma splitting, token/quoted-string
3163    /// arguments, §1.2.2 delta-seconds saturation, §5.2.3 extension
3164    /// preservation) on arbitrary input. Returns the total count of
3165    /// populated directive slots so the fuzzer can drive both the empty
3166    /// and many-directive branches; the parser must never panic and any
3167    /// returned strings must be valid UTF-8.
3168    pub fn parse_cache_control(s: &str) -> usize {
3169        let cc = super::parse_cache_control(s);
3170        let mut n = cc.extensions.len()
3171            + cc.no_cache_fields.len()
3172            + cc.private_fields.len()
3173            + usize::from(cc.max_age.is_some())
3174            + usize::from(cc.s_maxage.is_some())
3175            + usize::from(cc.max_stale.is_some())
3176            + usize::from(cc.min_fresh.is_some());
3177        for flag in [
3178            cc.no_cache,
3179            cc.no_store,
3180            cc.no_transform,
3181            cc.only_if_cached,
3182            cc.must_revalidate,
3183            cc.must_understand,
3184            cc.private,
3185            cc.proxy_revalidate,
3186            cc.public,
3187        ] {
3188            n += usize::from(flag);
3189        }
3190        n
3191    }
3192    /// Fuzz-only wrapper for [`super::parse_www_authenticate`] —
3193    /// exercises the RFC 9110 §11.6.1 `WWW-Authenticate = #challenge`
3194    /// grammar (the §11.6.1 challenge/auth-param comma ambiguity, the
3195    /// §11.2 `token68` vs `auth-param` discrimination, BWS-around-`=`
3196    /// tolerance, quoted-string-aware splitting, §5.6.4 value unwrap) on
3197    /// arbitrary input. Returns the count of recognised challenges so the
3198    /// fuzzer can drive both the empty and many-challenge branches; the
3199    /// parser must never panic and every returned scheme / param / token68
3200    /// string must be valid UTF-8.
3201    pub fn parse_www_authenticate(s: &str) -> usize {
3202        super::parse_www_authenticate(s).len()
3203    }
3204}
3205
3206#[cfg(test)]
3207mod tests {
3208    use super::*;
3209    use std::collections::BTreeSet;
3210
3211    #[test]
3212    fn register_via_runtime_context_installs_http_and_https_schemes() {
3213        let mut ctx = RuntimeContext::new();
3214        register(&mut ctx);
3215        let schemes: BTreeSet<&str> = ctx.sources.schemes().collect();
3216        assert!(
3217            schemes.contains("http"),
3218            "register did not install http; got {schemes:?}"
3219        );
3220        assert!(
3221            schemes.contains("https"),
3222            "register did not install https; got {schemes:?}"
3223        );
3224    }
3225
3226    #[test]
3227    fn register_source_installs_http_and_https_schemes_on_bare_registry() {
3228        let mut reg = SourceRegistry::new();
3229        register_source(&mut reg);
3230        let schemes: BTreeSet<&str> = reg.schemes().collect();
3231        assert!(schemes.contains("http"));
3232        assert!(schemes.contains("https"));
3233    }
3234
3235    #[test]
3236    fn http_config_default_matches_documented_surface() {
3237        let c = HttpConfig::default();
3238        assert_eq!(c.max_redirects(), 10);
3239        assert!(c.max_redirects_will_error());
3240        assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::Never);
3241        assert_eq!(c.user_agent(), None);
3242        assert!(!c.https_only());
3243        assert_eq!(c.timeout_global(), None);
3244        assert_eq!(c.timeout_connect(), None);
3245    }
3246
3247    #[test]
3248    fn http_config_builder_threads_values_through() {
3249        let c = HttpConfig::builder()
3250            .max_redirects(3)
3251            .max_redirects_will_error(false)
3252            .redirect_auth_policy(RedirectAuthPolicy::SameHost)
3253            .user_agent("oxideav-test/0.0")
3254            .https_only(true)
3255            .timeout_global(Some(Duration::from_secs(30)))
3256            .timeout_connect(Some(Duration::from_secs(5)))
3257            .build();
3258        assert_eq!(c.max_redirects(), 3);
3259        assert!(!c.max_redirects_will_error());
3260        assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::SameHost);
3261        assert_eq!(c.user_agent(), Some("oxideav-test/0.0"));
3262        assert!(c.https_only());
3263        assert_eq!(c.timeout_global(), Some(Duration::from_secs(30)));
3264        assert_eq!(c.timeout_connect(), Some(Duration::from_secs(5)));
3265    }
3266
3267    #[test]
3268    fn agent_from_config_succeeds_for_every_redirect_policy() {
3269        // Just exercise the construction path — failure modes are
3270        // panics inside ureq, which would surface here.
3271        for p in [RedirectAuthPolicy::Never, RedirectAuthPolicy::SameHost] {
3272            let c = HttpConfig::builder().redirect_auth_policy(p).build();
3273            let _ = agent_from(&c);
3274        }
3275    }
3276
3277    #[test]
3278    fn http_config_clone_is_cheap_and_independent() {
3279        let a = HttpConfig::builder()
3280            .user_agent("alpha/1.0")
3281            .max_redirects(7)
3282            .build();
3283        let b = a.clone();
3284        assert_eq!(a.user_agent(), b.user_agent());
3285        assert_eq!(a.max_redirects(), b.max_redirects());
3286    }
3287
3288    #[test]
3289    fn install_default_config_is_one_shot() {
3290        // We run this test in isolation per-process effectively — the
3291        // OnceLock keeps state, so try once then assert the second
3292        // attempt is rejected. Tests run in arbitrary order; if the
3293        // default agent has already materialised via another test,
3294        // the first install attempt itself may be rejected. Either
3295        // outcome must trip the "already installed" guard at some
3296        // point.
3297        let first = install_default_config(HttpConfig::default());
3298        // Force the agent to materialise so the second attempt is
3299        // guaranteed to be rejected even if the first succeeded.
3300        let _ = agent();
3301        let second = install_default_config(HttpConfig::default());
3302        assert!(
3303            first.is_err() || second.is_err(),
3304            "expected at least one install to be rejected once the agent is built (first={first:?}, second={second:?})"
3305        );
3306        assert_eq!(second, Err(ConfigAlreadyInstalled));
3307    }
3308
3309    #[test]
3310    fn config_already_installed_implements_error_trait() {
3311        let e: &dyn std::error::Error = &ConfigAlreadyInstalled;
3312        assert!(e.to_string().contains("already"));
3313    }
3314
3315    // -- RFC 7233 §4.2 Content-Range parser ----------------------------------
3316
3317    #[test]
3318    fn content_range_parses_canonical_form() {
3319        let r = parse_byte_content_range("bytes 42-1233/1234").unwrap();
3320        assert_eq!(
3321            r,
3322            ByteContentRange {
3323                first: 42,
3324                last: 1233,
3325                complete: Some(1234),
3326            }
3327        );
3328    }
3329
3330    #[test]
3331    fn content_range_accepts_star_complete_length() {
3332        // RFC 7233 §4.2: "An asterisk character ('*') in place of the
3333        // complete-length indicates that the representation length was
3334        // unknown when the header field was generated."
3335        let r = parse_byte_content_range("bytes 42-1233/*").unwrap();
3336        assert_eq!(r.first, 42);
3337        assert_eq!(r.last, 1233);
3338        assert_eq!(r.complete, None);
3339    }
3340
3341    #[test]
3342    fn content_range_accepts_full_resource_first_last() {
3343        let r = parse_byte_content_range("bytes 0-0/1").unwrap();
3344        assert_eq!(r.first, 0);
3345        assert_eq!(r.last, 0);
3346        assert_eq!(r.complete, Some(1));
3347    }
3348
3349    #[test]
3350    fn content_range_unit_is_case_insensitive() {
3351        // RFC 7230 §3.2.6 tokens are case-insensitive.
3352        assert!(parse_byte_content_range("BYTES 0-9/10").is_ok());
3353        assert!(parse_byte_content_range("Bytes 0-9/10").is_ok());
3354    }
3355
3356    #[test]
3357    fn content_range_rejects_unknown_unit() {
3358        // RFC 7233 §4.2: "If a 206 (Partial Content) response contains
3359        // a Content-Range header field with a range unit (Section 2)
3360        // that the recipient does not understand, the recipient MUST
3361        // NOT attempt to recombine it with a stored representation."
3362        assert!(parse_byte_content_range("frames 0-9/100").is_err());
3363    }
3364
3365    #[test]
3366    fn content_range_rejects_last_lt_first() {
3367        // §4.2: invalid if last-byte-pos < first-byte-pos.
3368        assert!(parse_byte_content_range("bytes 100-50/200").is_err());
3369    }
3370
3371    #[test]
3372    fn content_range_rejects_complete_le_last() {
3373        // §4.2: invalid if complete-length <= last-byte-pos.
3374        assert!(parse_byte_content_range("bytes 0-100/100").is_err());
3375        assert!(parse_byte_content_range("bytes 0-100/50").is_err());
3376    }
3377
3378    #[test]
3379    fn content_range_rejects_unsatisfied_payload_on_206() {
3380        // §4.2 unsatisfied-range = "*/" complete is a 416 payload, not
3381        // a 206 payload — we reject it at parse time.
3382        assert!(parse_byte_content_range("bytes */1234").is_err());
3383    }
3384
3385    #[test]
3386    fn unsatisfied_range_parses_canonical_form() {
3387        // RFC 9110 §14.4 example: `bytes */1234`.
3388        let n = parse_byte_unsatisfied_range("bytes */1234").unwrap();
3389        assert_eq!(n, 1234);
3390    }
3391
3392    #[test]
3393    fn unsatisfied_range_unit_is_case_insensitive() {
3394        assert_eq!(
3395            parse_byte_unsatisfied_range("BYTES */47022").unwrap(),
3396            47022
3397        );
3398        assert_eq!(
3399            parse_byte_unsatisfied_range("Bytes */47022").unwrap(),
3400            47022
3401        );
3402    }
3403
3404    #[test]
3405    fn unsatisfied_range_rejects_unknown_unit() {
3406        assert!(parse_byte_unsatisfied_range("frames */1234").is_err());
3407    }
3408
3409    #[test]
3410    fn unsatisfied_range_rejects_range_resp_form() {
3411        // §14.4: a 416 body is unsatisfied-range, never the canonical
3412        // range-resp form (first-last/complete).
3413        assert!(parse_byte_unsatisfied_range("bytes 0-9/10").is_err());
3414        assert!(parse_byte_unsatisfied_range("bytes 42-1233/1234").is_err());
3415    }
3416
3417    #[test]
3418    fn unsatisfied_range_rejects_malformed() {
3419        for bad in [
3420            "",
3421            "bytes",
3422            "bytes */",  // no digits after '*/'
3423            "bytes */x", // non-numeric complete
3424            "bytes /1234",
3425            "*/1234", // missing unit + SP
3426        ] {
3427            assert!(
3428                parse_byte_unsatisfied_range(bad).is_err(),
3429                "expected reject for {bad:?}"
3430            );
3431        }
3432    }
3433
3434    #[test]
3435    fn content_range_rejects_malformed_strings() {
3436        for bad in [
3437            "",
3438            "bytes",
3439            "bytes 0-9",                       // missing /complete
3440            "bytes 09/10",                     // missing -
3441            "bytes a-9/10",                    // non-numeric first
3442            "bytes 0-b/10",                    // non-numeric last
3443            "bytes 0-9/x",                     // non-numeric complete
3444            "bytes 18446744073709551616-0/10", // u64 overflow on first
3445            "0-9/10",                          // missing unit + SP
3446        ] {
3447            assert!(
3448                parse_byte_content_range(bad).is_err(),
3449                "expected reject for {bad:?}"
3450            );
3451        }
3452    }
3453
3454    // -- Local-TCP end-to-end tests ------------------------------------------
3455    //
3456    // These spin up a single-shot std::net::TcpListener on 127.0.0.1:0,
3457    // accept N connections, hand-craft minimal HTTP/1.1 responses, and
3458    // verify our HttpSource's Content-Range validator catches the
3459    // bad-server cases and accepts the canonical-server cases. They do
3460    // not depend on external network reachability.
3461
3462    use std::io::Write as _;
3463    use std::net::TcpListener;
3464    use std::sync::mpsc;
3465    use std::thread;
3466
3467    /// Spawn a minimal HTTP/1.1 server on 127.0.0.1:0 that responds to
3468    /// `HEAD` requests with `head_resp` and to `GET` requests with
3469    /// `get_resp`. Each response is the literal byte string the test
3470    /// supplies (status line + headers + CRLF + body, no
3471    /// chunked-encoding).
3472    fn spawn_server(
3473        head_resp: &'static [u8],
3474        get_resp: &'static [u8],
3475    ) -> (String, mpsc::Receiver<()>) {
3476        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3477        let port = listener.local_addr().unwrap().port();
3478        let (tx, rx) = mpsc::channel::<()>();
3479        thread::spawn(move || {
3480            // Accept up to 4 connections so HEAD + GET (and one retry)
3481            // all land. ureq may use separate connections.
3482            for _ in 0..4 {
3483                let Ok((mut stream, _)) = listener.accept() else {
3484                    break;
3485                };
3486                let mut buf = [0u8; 4096];
3487                use std::io::Read as _;
3488                let n = stream.read(&mut buf).unwrap_or(0);
3489                if n == 0 {
3490                    continue;
3491                }
3492                let req = &buf[..n];
3493                let resp = if req.starts_with(b"HEAD ") {
3494                    head_resp
3495                } else {
3496                    get_resp
3497                };
3498                let _ = stream.write_all(resp);
3499                let _ = stream.flush();
3500            }
3501            let _ = tx.send(());
3502        });
3503        (format!("http://127.0.0.1:{port}/x"), rx)
3504    }
3505
3506    const HEAD_10B_BYTES: &[u8] = b"HTTP/1.1 200 OK\r\n\
3507        Content-Length: 10\r\n\
3508        Accept-Ranges: bytes\r\n\
3509        Connection: close\r\n\
3510        \r\n";
3511
3512    fn make_get_206(content_range: &str, body: &[u8]) -> Vec<u8> {
3513        let mut v = format!(
3514            "HTTP/1.1 206 Partial Content\r\n\
3515             Content-Length: {}\r\n\
3516             Content-Range: {content_range}\r\n\
3517             Connection: close\r\n\
3518             \r\n",
3519            body.len()
3520        )
3521        .into_bytes();
3522        v.extend_from_slice(body);
3523        v
3524    }
3525
3526    #[test]
3527    fn local_server_canonical_206_succeeds() {
3528        // Static GET response: Content-Range echoes the requested
3529        // open-ended `bytes=0-`, body is the full 10 B.
3530        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
3531            Content-Length: 10\r\n\
3532            Content-Range: bytes 0-9/10\r\n\
3533            Connection: close\r\n\
3534            \r\n\
3535            0123456789";
3536        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3537        let mut src = HttpSource::open(&uri).expect("open");
3538        let mut buf = [0u8; 10];
3539        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
3540        assert_eq!(&buf, b"0123456789");
3541    }
3542
3543    #[test]
3544    fn local_server_206_without_content_range_is_rejected() {
3545        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
3546            Content-Length: 10\r\n\
3547            Connection: close\r\n\
3548            \r\n\
3549            0123456789";
3550        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3551        let mut src = HttpSource::open(&uri).expect("open");
3552        let mut buf = [0u8; 10];
3553        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3554        assert!(
3555            err.to_string().contains("missing Content-Range"),
3556            "wrong error: {err}"
3557        );
3558    }
3559
3560    #[test]
3561    fn local_server_206_with_wrong_first_pos_is_rejected() {
3562        // We ask for bytes=0-; server replies with bytes 3-9/10 — that
3563        // would silently misalign the demuxer.
3564        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
3565            Content-Length: 7\r\n\
3566            Content-Range: bytes 3-9/10\r\n\
3567            Connection: close\r\n\
3568            \r\n\
3569            3456789";
3570        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3571        let mut src = HttpSource::open(&uri).expect("open");
3572        let mut buf = [0u8; 10];
3573        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3574        assert!(
3575            err.to_string().contains("first-byte-pos"),
3576            "wrong error: {err}"
3577        );
3578    }
3579
3580    #[test]
3581    fn local_server_206_with_resource_resize_is_rejected() {
3582        // HEAD said 10; 206 says complete-length 20 — origin/cache
3583        // disagreement we cannot recover from in-band.
3584        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
3585            Content-Length: 10\r\n\
3586            Content-Range: bytes 0-9/20\r\n\
3587            Connection: close\r\n\
3588            \r\n\
3589            0123456789";
3590        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3591        let mut src = HttpSource::open(&uri).expect("open");
3592        let mut buf = [0u8; 10];
3593        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3594        assert!(
3595            err.to_string().contains("complete-length"),
3596            "wrong error: {err}"
3597        );
3598    }
3599
3600    #[test]
3601    fn local_server_206_with_star_complete_is_accepted() {
3602        // RFC 7233 §4.2 explicitly permits `*` complete-length when
3603        // the server doesn't know the total. We must accept it.
3604        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
3605            Content-Length: 10\r\n\
3606            Content-Range: bytes 0-9/*\r\n\
3607            Connection: close\r\n\
3608            \r\n\
3609            0123456789";
3610        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3611        let mut src = HttpSource::open(&uri).expect("open");
3612        let mut buf = [0u8; 10];
3613        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
3614        assert_eq!(&buf, b"0123456789");
3615    }
3616
3617    #[test]
3618    fn local_server_200_after_seek_drops_prefix() {
3619        // Server ignores Range entirely and serves full body with 200.
3620        // We seek to byte 4 first; the driver must drain bytes 0..4
3621        // before exposing byte 4 onward to the reader.
3622        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
3623            Content-Length: 10\r\n\
3624            Connection: close\r\n\
3625            \r\n\
3626            0123456789";
3627        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3628        let mut src = HttpSource::open(&uri).expect("open");
3629        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
3630        let mut buf = [0u8; 6];
3631        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
3632        assert_eq!(&buf, b"456789");
3633    }
3634
3635    #[test]
3636    fn local_server_416_with_unsatisfied_range_surfaces_complete_length() {
3637        // RFC 9110 §15.5.17 + §14.4: a 416 carries `bytes */<complete>`
3638        // naming the server's authoritative current length. We seek
3639        // past EOF (HEAD said 10, seek to 8, server reports current
3640        // length 5 — i.e. resource shrank) and expect the error message
3641        // to surface BOTH the server's reported length and the
3642        // HEAD-observed length so the caller can distinguish the case.
3643        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
3644            Content-Length: 0\r\n\
3645            Content-Range: bytes */5\r\n\
3646            Connection: close\r\n\
3647            \r\n";
3648        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3649        let mut src = HttpSource::open(&uri).expect("open");
3650        // Seek to byte 8 — HEAD said 10 so this is in-bounds from our
3651        // pov; the server's 416 then tells us the resource is now 5.
3652        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
3653        let mut buf = [0u8; 1];
3654        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3655        let msg = err.to_string();
3656        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
3657        assert!(
3658            msg.contains("complete-length 5"),
3659            "wrong error (no 'complete-length 5'): {msg}"
3660        );
3661        assert!(
3662            msg.contains("HEAD observed 10"),
3663            "wrong error (no 'HEAD observed 10'): {msg}"
3664        );
3665    }
3666
3667    #[test]
3668    fn local_server_416_without_content_range_still_errors_cleanly() {
3669        // §14.4 makes the Content-Range a SHOULD on 416, not a MUST. A
3670        // 416 without the body still needs a clean error path that
3671        // names the status.
3672        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
3673            Content-Length: 0\r\n\
3674            Connection: close\r\n\
3675            \r\n";
3676        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3677        let mut src = HttpSource::open(&uri).expect("open");
3678        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
3679        let mut buf = [0u8; 1];
3680        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3681        let msg = err.to_string();
3682        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
3683        assert!(
3684            msg.contains("no Content-Range"),
3685            "wrong error (no 'no Content-Range'): {msg}"
3686        );
3687    }
3688
3689    #[test]
3690    fn local_server_416_with_invalid_content_range_reports_parse_error() {
3691        // A 416 whose Content-Range is malformed — the read still
3692        // fails, but the error names the parse failure rather than
3693        // surfacing nonsense as a length.
3694        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
3695            Content-Length: 0\r\n\
3696            Content-Range: bytes 0-9/10\r\n\
3697            Connection: close\r\n\
3698            \r\n";
3699        // 'bytes 0-9/10' is a range-resp form, not unsatisfied-range —
3700        // §14.4 says the 416 body should be `*/N`. Anything else is a
3701        // server bug; we surface the parse error.
3702        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
3703        let mut src = HttpSource::open(&uri).expect("open");
3704        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
3705        let mut buf = [0u8; 1];
3706        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
3707        let msg = err.to_string();
3708        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
3709        assert!(
3710            msg.contains("invalid Content-Range"),
3711            "wrong error (no 'invalid Content-Range'): {msg}"
3712        );
3713    }
3714
3715    // Silence "unused" warning when these helpers aren't all picked up
3716    // — make_get_206 is a convenience the next test round may use.
3717    #[allow(dead_code)]
3718    fn _keep_helper() {
3719        let _ = make_get_206("bytes 0-9/10", b"0123456789");
3720    }
3721
3722    // -- RFC 9110 §13.1.5 If-Range strong-validator path ---------------------
3723
3724    #[test]
3725    fn entity_tag_parses_strong_tag() {
3726        let (weak, wire) = parse_entity_tag("\"xyzzy\"").unwrap();
3727        assert!(!weak);
3728        assert_eq!(wire, "\"xyzzy\"");
3729    }
3730
3731    #[test]
3732    fn entity_tag_parses_weak_tag() {
3733        let (weak, wire) = parse_entity_tag("W/\"xyzzy\"").unwrap();
3734        assert!(weak);
3735        assert_eq!(wire, "W/\"xyzzy\"");
3736    }
3737
3738    #[test]
3739    fn entity_tag_parses_empty_opaque() {
3740        // §8.8.3 example: ETag: ""
3741        let (weak, wire) = parse_entity_tag("\"\"").unwrap();
3742        assert!(!weak);
3743        assert_eq!(wire, "\"\"");
3744    }
3745
3746    #[test]
3747    fn entity_tag_rejects_unquoted_value() {
3748        // The grammar requires DQUOTE-wrapped opaque-tag.
3749        assert!(parse_entity_tag("xyzzy").is_none());
3750        assert!(parse_entity_tag("W/xyzzy").is_none());
3751    }
3752
3753    #[test]
3754    fn entity_tag_weak_marker_is_case_sensitive() {
3755        // §8.8.3: weak = %s"W/" — case-sensitive. A lowercase w/ is
3756        // not a weakness indicator; it becomes part of the opaque-tag
3757        // which then fails the DQUOTE-wrap test.
3758        assert!(parse_entity_tag("w/\"xyzzy\"").is_none());
3759    }
3760
3761    #[test]
3762    fn entity_tag_rejects_disallowed_inner_bytes() {
3763        // etagc forbids DQUOTE (0x22) and most controls.
3764        assert!(parse_entity_tag("\"hello\"world\"").is_none());
3765        assert!(parse_entity_tag("\"tab\there\"").is_none()); // 0x09
3766    }
3767
3768    #[test]
3769    fn imf_fixdate_parses_canonical_example() {
3770        // RFC 9110 §5.6.7 canonical form.
3771        let d = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
3772        assert_eq!(d, (1994, 11, 6, 8, 49, 37));
3773    }
3774
3775    #[test]
3776    fn imf_fixdate_rejects_non_imf_forms() {
3777        // The IMF-fixdate parser itself is strict — the §5.6.7 MUST-
3778        // accept on the other two forms is handled by `parse_http_date`
3779        // dispatching to the dedicated rfc850 / asctime parsers.
3780        assert!(parse_imf_fixdate("Sunday, 06-Nov-94 08:49:37 GMT").is_none());
3781        assert!(parse_imf_fixdate("Sun Nov  6 08:49:37 1994").is_none());
3782        // Missing GMT suffix.
3783        assert!(parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37").is_none());
3784        // Bogus month name.
3785        assert!(parse_imf_fixdate("Sun, 06 Foo 1994 08:49:37 GMT").is_none());
3786    }
3787
3788    // -- RFC 9110 §5.6.7 obsolete rfc850-date form ---------------------------
3789
3790    #[test]
3791    fn rfc850_date_parses_canonical_example() {
3792        // §5.6.7 example. 2-digit year 94 expands to 1994 under the
3793        // sliding-window rule (REF_YEAR=2026; 94→2094 is >50 years
3794        // future → roll back a century).
3795        let d = parse_rfc850_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
3796        assert_eq!(d, (1994, 11, 6, 8, 49, 37));
3797    }
3798
3799    #[test]
3800    fn rfc850_date_parses_every_long_weekday_name() {
3801        for wkd in [
3802            "Monday",
3803            "Tuesday",
3804            "Wednesday",
3805            "Thursday",
3806            "Friday",
3807            "Saturday",
3808            "Sunday",
3809        ] {
3810            let s = format!("{wkd}, 01-Jan-00 00:00:00 GMT");
3811            assert!(parse_rfc850_date(&s).is_some(), "rejected {s:?}");
3812        }
3813    }
3814
3815    #[test]
3816    fn rfc850_date_window_expands_year_per_section_5_6_7() {
3817        // §5.6.7 MUST: a 2-digit year > 50 years in the future maps to
3818        // the most recent past year with the same last two digits.
3819        // REF_YEAR = 2026.
3820        //
3821        // 26 → 2026 (current ref year).
3822        // 76 → 2076 (50 years out — still within the window, "more than
3823        //   50 years in the future" is the trigger so 50 itself stays).
3824        // 77 → 1977 (51 years out → wrap).
3825        // 00 → 2000 (24 years past → unchanged).
3826        // 99 → 1999 (-27 years → unchanged because not in the future).
3827        let yr = |s: &str| parse_rfc850_date(s).unwrap().0;
3828        assert_eq!(yr("Monday, 01-Jan-26 00:00:00 GMT"), 2026);
3829        assert_eq!(yr("Monday, 01-Jan-76 00:00:00 GMT"), 2076);
3830        assert_eq!(yr("Monday, 01-Jan-77 00:00:00 GMT"), 1977);
3831        assert_eq!(yr("Monday, 01-Jan-00 00:00:00 GMT"), 2000);
3832        assert_eq!(yr("Monday, 01-Jan-99 00:00:00 GMT"), 1999);
3833    }
3834
3835    #[test]
3836    fn rfc850_date_rejects_short_weekday_names() {
3837        // The short three-letter "Sun" form is IMF-fixdate, not
3838        // rfc850-date. The rfc850 parser must reject it (parse_http_date
3839        // dispatches the right form).
3840        assert!(parse_rfc850_date("Sun, 06-Nov-94 08:49:37 GMT").is_none());
3841    }
3842
3843    #[test]
3844    fn rfc850_date_rejects_malformed_strings() {
3845        for bad in [
3846            "",
3847            "Sunday 06-Nov-94 08:49:37 GMT",   // missing comma
3848            "Sunday, 06-Nov-94 08:49:37",      // missing GMT
3849            "Sunday, 06/Nov/94 08:49:37 GMT",  // wrong separator
3850            "Sunday, 06-Foo-94 08:49:37 GMT",  // bad month
3851            "Sunday, AB-Nov-94 08:49:37 GMT",  // non-digit day
3852            "Sunday, 06-Nov-9X 08:49:37 GMT",  // non-digit year
3853            "Sunday, 06-Nov-94 08:49:37  GMT", // extra space (§5.6.7 forbids)
3854        ] {
3855            assert!(
3856                parse_rfc850_date(bad).is_none(),
3857                "expected reject for {bad:?}"
3858            );
3859        }
3860    }
3861
3862    // -- RFC 9110 §5.6.7 obsolete asctime-date form ---------------------------
3863
3864    #[test]
3865    fn asctime_date_parses_canonical_example() {
3866        // §5.6.7 example. Double space after month means day is the
3867        // SP-padded single-digit form.
3868        let d = parse_asctime_date("Sun Nov  6 08:49:37 1994").unwrap();
3869        assert_eq!(d, (1994, 11, 6, 8, 49, 37));
3870    }
3871
3872    #[test]
3873    fn asctime_date_parses_two_digit_day() {
3874        // Day 06 written with leading zero is NOT what asctime emits,
3875        // but §5.6.7 date3 = month SP ( 2DIGIT / ( SP 1DIGIT )) so
3876        // "Nov 06" is a valid 2DIGIT alternative. Be lenient per
3877        // §5.6.7's "be robust in parsing" note.
3878        let d = parse_asctime_date("Mon Nov 30 23:59:59 1994").unwrap();
3879        assert_eq!(d, (1994, 11, 30, 23, 59, 59));
3880    }
3881
3882    #[test]
3883    fn asctime_date_rejects_malformed_strings() {
3884        for bad in [
3885            "",
3886            "Sun Nov  6 08:49:37 199",  // short year (23 bytes total)
3887            "Sun XYZ  6 08:49:37 1994", // bad month
3888            "Foo Nov  6 08:49:37 1994", // bad day-name
3889            "Sun Nov  6 08-49-37 1994", // wrong time separator
3890            "Sun, Nov 6 08:49:37 1994", // stray comma (changes layout)
3891            "Sun Nov   6 08:49:37 199", // 23 bytes — short
3892        ] {
3893            assert!(
3894                parse_asctime_date(bad).is_none(),
3895                "expected reject for {bad:?}"
3896            );
3897        }
3898    }
3899
3900    // -- §5.6.7 unified HTTP-date parser dispatches all three forms ----------
3901
3902    #[test]
3903    fn http_date_accepts_all_three_5_6_7_forms() {
3904        // §5.6.7 MUST: a recipient MUST accept all three HTTP-date
3905        // formats. The unified entry point covers that.
3906        assert!(parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").is_some()); // IMF-fixdate
3907        assert!(parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").is_some()); // rfc850
3908        assert!(parse_http_date("Sun Nov  6 08:49:37 1994").is_some()); // asctime
3909    }
3910
3911    #[test]
3912    fn http_date_returns_same_components_across_forms() {
3913        // The three §5.6.7 examples all denote the same UTC instant.
3914        // The parser must produce identical (y, mo, d, h, mi, s) tuples
3915        // for each so downstream §8.8.2.2 second-comparison works
3916        // regardless of which form an origin emits.
3917        let imf = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
3918        let rfc850 = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
3919        let asctime = parse_http_date("Sun Nov  6 08:49:37 1994").unwrap();
3920        assert_eq!(imf, rfc850);
3921        assert_eq!(imf, asctime);
3922    }
3923
3924    #[test]
3925    fn http_date_rejects_garbage() {
3926        for bad in ["", "not a date", "1994-11-06T08:49:37Z", "Sun, BAD"] {
3927            assert!(
3928                parse_http_date(bad).is_none(),
3929                "expected reject for {bad:?}"
3930            );
3931        }
3932    }
3933
3934    #[test]
3935    fn derive_strong_validator_accepts_rfc850_dates() {
3936        // Strong-validator promotion path (§13.1.5 + §8.8.2.2) now
3937        // works for rfc850-date headers, not just IMF-fixdate.
3938        let v = derive_strong_validator(
3939            None,
3940            Some("Sunday, 06-Nov-94 08:49:37 GMT"),
3941            Some("Sunday, 06-Nov-94 08:49:42 GMT"),
3942        );
3943        assert_eq!(
3944            v,
3945            Some(StrongValidator::LastModified(
3946                "Sunday, 06-Nov-94 08:49:37 GMT".into()
3947            ))
3948        );
3949    }
3950
3951    #[test]
3952    fn derive_strong_validator_accepts_asctime_dates() {
3953        // Same path, asctime-date forms. The Last-Modified value is
3954        // echoed back verbatim as the If-Range field, which is what
3955        // §13.1.5 requires (we don't normalise the wire form).
3956        let v = derive_strong_validator(
3957            None,
3958            Some("Sun Nov  6 08:49:37 1994"),
3959            Some("Sun Nov  6 08:49:42 1994"),
3960        );
3961        assert_eq!(
3962            v,
3963            Some(StrongValidator::LastModified(
3964                "Sun Nov  6 08:49:37 1994".into()
3965            ))
3966        );
3967    }
3968
3969    #[test]
3970    fn derive_strong_validator_accepts_mixed_forms_across_headers() {
3971        // §5.6.7 doesn't constrain different fields in a single message
3972        // to use the same form. Last-Modified in IMF-fixdate + Date in
3973        // rfc850-date must still promote when the 1-second rule holds.
3974        let v = derive_strong_validator(
3975            None,
3976            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
3977            Some("Sunday, 06-Nov-94 08:49:42 GMT"),
3978        );
3979        assert!(matches!(v, Some(StrongValidator::LastModified(_))));
3980    }
3981
3982    #[test]
3983    fn imf_seconds_orders_within_one_second_correctly() {
3984        // §8.8.2.2 needs strict-greater-than-or-equal at 1-second
3985        // resolution. Confirm the ordering primitive supports that.
3986        let lm = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
3987        let dt_eq = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
3988        let dt_p1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:38 GMT").unwrap();
3989        let dt_m1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:36 GMT").unwrap();
3990        assert!(imf_seconds(dt_eq) == imf_seconds(lm));
3991        assert!(imf_seconds(dt_p1) == imf_seconds(lm) + 1);
3992        assert!(imf_seconds(dt_m1) == imf_seconds(lm) - 1);
3993    }
3994
3995    #[test]
3996    fn derive_strong_validator_prefers_strong_etag() {
3997        let v = derive_strong_validator(
3998            Some("\"xyz\""),
3999            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4000            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4001        );
4002        assert_eq!(v, Some(StrongValidator::Etag("\"xyz\"".into())));
4003    }
4004
4005    #[test]
4006    fn derive_strong_validator_skips_weak_etag_falls_to_strong_last_modified() {
4007        // Weak ETag → not usable for If-Range. Last-Modified is
4008        // promotable because Date is 5 s after it.
4009        let v = derive_strong_validator(
4010            Some("W/\"xyz\""),
4011            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4012            Some("Sun, 06 Nov 1994 08:49:42 GMT"),
4013        );
4014        assert_eq!(
4015            v,
4016            Some(StrongValidator::LastModified(
4017                "Sun, 06 Nov 1994 08:49:37 GMT".into()
4018            ))
4019        );
4020    }
4021
4022    #[test]
4023    fn derive_strong_validator_rejects_lm_when_date_within_one_second() {
4024        // Date == Last-Modified is NOT strong per §8.8.2.2 — needs at
4025        // least 1 s of separation. The result must be None (we fall
4026        // back to issuing the GET without If-Range).
4027        let v = derive_strong_validator(
4028            None,
4029            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4030            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4031        );
4032        assert_eq!(v, None);
4033    }
4034
4035    #[test]
4036    fn derive_strong_validator_rejects_lm_without_date() {
4037        // §8.8.2.2 promotion needs both timestamps — without Date we
4038        // can't reason about clock skew, so stay weak (= None).
4039        let v = derive_strong_validator(None, Some("Sun, 06 Nov 1994 08:49:37 GMT"), None);
4040        assert_eq!(v, None);
4041    }
4042
4043    #[test]
4044    fn derive_strong_validator_handles_missing_headers() {
4045        assert_eq!(derive_strong_validator(None, None, None), None);
4046    }
4047
4048    #[test]
4049    fn derive_strong_validator_skips_malformed_etag_falls_through() {
4050        // A malformed ETag is treated as absent — try Last-Modified.
4051        let v = derive_strong_validator(
4052            Some("not-quoted"),
4053            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
4054            Some("Sun, 06 Nov 1994 08:49:42 GMT"),
4055        );
4056        assert_eq!(
4057            v,
4058            Some(StrongValidator::LastModified(
4059                "Sun, 06 Nov 1994 08:49:37 GMT".into()
4060            ))
4061        );
4062    }
4063
4064    // -- RFC 9110 §10.2.3 Retry-After parser ---------------------------------
4065
4066    #[test]
4067    fn retry_after_parses_canonical_delay_seconds_form() {
4068        // §10.2.3 example: "Retry-After: 120" means wait two minutes.
4069        assert_eq!(
4070            parse_retry_after("120"),
4071            Some(RetryAfter::Delay(Duration::from_secs(120)))
4072        );
4073    }
4074
4075    #[test]
4076    fn retry_after_parses_zero_delay() {
4077        // §10.2.3 delay-seconds = 1*DIGIT — zero is in-range.
4078        assert_eq!(
4079            parse_retry_after("0"),
4080            Some(RetryAfter::Delay(Duration::from_secs(0)))
4081        );
4082    }
4083
4084    #[test]
4085    fn retry_after_parses_large_delay_within_u64() {
4086        // A small CDN reasonably emits "Retry-After: 86400" (one day).
4087        assert_eq!(
4088            parse_retry_after("86400"),
4089            Some(RetryAfter::Delay(Duration::from_secs(86_400)))
4090        );
4091    }
4092
4093    #[test]
4094    fn retry_after_trims_surrounding_whitespace() {
4095        // Field-value parsers in this crate are OWS-tolerant per §5.6
4096        // convention.
4097        assert_eq!(
4098            parse_retry_after("   42   "),
4099            Some(RetryAfter::Delay(Duration::from_secs(42)))
4100        );
4101    }
4102
4103    #[test]
4104    fn retry_after_parses_imf_fixdate_form() {
4105        // §10.2.3 example: "Retry-After: Fri, 31 Dec 1999 23:59:59 GMT".
4106        assert_eq!(
4107            parse_retry_after("Fri, 31 Dec 1999 23:59:59 GMT"),
4108            Some(RetryAfter::Date {
4109                year: 1999,
4110                month: 12,
4111                day: 31,
4112                hour: 23,
4113                minute: 59,
4114                second: 59,
4115            })
4116        );
4117    }
4118
4119    #[test]
4120    fn retry_after_parses_rfc850_date_form() {
4121        // §5.6.7 MUSTs receiver acceptance of the obsolete rfc850
4122        // form — Retry-After inherits that grammar.
4123        assert_eq!(
4124            parse_retry_after("Sunday, 06-Nov-94 08:49:37 GMT"),
4125            Some(RetryAfter::Date {
4126                year: 1994,
4127                month: 11,
4128                day: 6,
4129                hour: 8,
4130                minute: 49,
4131                second: 37,
4132            })
4133        );
4134    }
4135
4136    #[test]
4137    fn retry_after_parses_asctime_form() {
4138        // §5.6.7 MUSTs receiver acceptance of the obsolete asctime
4139        // form.
4140        assert_eq!(
4141            parse_retry_after("Sun Nov  6 08:49:37 1994"),
4142            Some(RetryAfter::Date {
4143                year: 1994,
4144                month: 11,
4145                day: 6,
4146                hour: 8,
4147                minute: 49,
4148                second: 37,
4149            })
4150        );
4151    }
4152
4153    #[test]
4154    fn retry_after_rejects_empty_input() {
4155        assert_eq!(parse_retry_after(""), None);
4156        assert_eq!(parse_retry_after("   "), None);
4157    }
4158
4159    #[test]
4160    fn retry_after_rejects_signed_delay_seconds() {
4161        // §10.2.3 delay-seconds = 1*DIGIT (non-negative decimal
4162        // integer); a leading sign is grammar-invalid even though
4163        // `u64::parse` would refuse it for the negative form.
4164        assert_eq!(parse_retry_after("-1"), None);
4165        // The crucial case: `+5` would round-trip through
4166        // `u64::from_str` on some toolchains; reject explicitly via
4167        // the all-digit gate.
4168        assert_eq!(parse_retry_after("+5"), None);
4169    }
4170
4171    #[test]
4172    fn retry_after_rejects_decimal_or_hex_delay() {
4173        // Pure 1*DIGIT — no fractions, no hex prefix.
4174        assert_eq!(parse_retry_after("12.5"), None);
4175        assert_eq!(parse_retry_after("0x10"), None);
4176        assert_eq!(parse_retry_after("1_000"), None);
4177    }
4178
4179    #[test]
4180    fn retry_after_rejects_delay_with_trailing_garbage() {
4181        // "120s" looks like a unit-bearing delay but §10.2.3 grammar
4182        // is bare digits.
4183        assert_eq!(parse_retry_after("120s"), None);
4184        assert_eq!(parse_retry_after("120 seconds"), None);
4185    }
4186
4187    #[test]
4188    fn retry_after_rejects_u64_overflow() {
4189        // §10.2.3 has no upper bound, but a value that doesn't fit
4190        // u64 seconds (≈ 584 billion years) is not a real-world
4191        // Retry-After. We surface None rather than saturate.
4192        // 2^64 = 18446744073709551616 — one beyond u64::MAX.
4193        assert_eq!(parse_retry_after("18446744073709551616"), None);
4194    }
4195
4196    #[test]
4197    fn retry_after_rejects_malformed_date() {
4198        // Not a §5.6.7 date in any of the three accepted forms.
4199        assert_eq!(parse_retry_after("never"), None);
4200        assert_eq!(parse_retry_after("Tomorrow at noon"), None);
4201        assert_eq!(parse_retry_after("Fri, 31 Dec 1999 23:59:59 UTC"), None);
4202    }
4203
4204    #[test]
4205    fn retry_after_disambiguates_digit_only_from_date() {
4206        // §10.2.3 grammar is `HTTP-date / delay-seconds` — disjoint
4207        // (a pure-digit string cannot match any of the three HTTP-
4208        // date forms which all begin with an alphabetic weekday
4209        // name or token). Confirm we pick the delay branch for a
4210        // pure-digit input even though "1994" could be misread as
4211        // a year fragment by a sloppy parser.
4212        assert_eq!(
4213            parse_retry_after("1994"),
4214            Some(RetryAfter::Delay(Duration::from_secs(1994)))
4215        );
4216    }
4217
4218    #[test]
4219    fn retry_after_examples_from_spec_appendix_round_trip() {
4220        // §10.2.3 names two example values verbatim. Pin both so a
4221        // future regression cannot silently change behaviour on the
4222        // canonical inputs.
4223        let a = parse_retry_after("Fri, 31 Dec 1999 23:59:59 GMT");
4224        let b = parse_retry_after("120");
4225        assert!(matches!(a, Some(RetryAfter::Date { year: 1999, .. })));
4226        assert!(matches!(b, Some(RetryAfter::Delay(d)) if d == Duration::from_secs(120)));
4227    }
4228
4229    /// Variant of `spawn_server` that captures the GET request bytes
4230    /// in a channel so the test can verify our `If-Range` field made
4231    /// it onto the wire exactly as expected.
4232    fn spawn_server_capturing_get(
4233        head_resp: &'static [u8],
4234        get_resp: &'static [u8],
4235    ) -> (String, mpsc::Receiver<Vec<u8>>) {
4236        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
4237        let port = listener.local_addr().unwrap().port();
4238        let (tx, rx) = mpsc::channel::<Vec<u8>>();
4239        thread::spawn(move || {
4240            for _ in 0..4 {
4241                let Ok((mut stream, _)) = listener.accept() else {
4242                    break;
4243                };
4244                let mut buf = [0u8; 4096];
4245                use std::io::Read as _;
4246                let n = stream.read(&mut buf).unwrap_or(0);
4247                if n == 0 {
4248                    continue;
4249                }
4250                let req = &buf[..n];
4251                let resp = if req.starts_with(b"HEAD ") {
4252                    head_resp
4253                } else {
4254                    // GET — record the full request bytes so the test
4255                    // can grep for If-Range presence.
4256                    let _ = tx.send(req.to_vec());
4257                    get_resp
4258                };
4259                let _ = stream.write_all(resp);
4260                let _ = stream.flush();
4261            }
4262        });
4263        (format!("http://127.0.0.1:{port}/x"), rx)
4264    }
4265
4266    /// HEAD response that supplies a strong ETag so the driver will
4267    /// elect to send `If-Range: "v1"` on the next GET.
4268    const HEAD_10B_WITH_STRONG_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
4269        Content-Length: 10\r\n\
4270        Accept-Ranges: bytes\r\n\
4271        ETag: \"v1\"\r\n\
4272        Connection: close\r\n\
4273        \r\n";
4274
4275    #[test]
4276    fn local_server_strong_etag_emits_if_range_header_on_get() {
4277        // Server replies 206 normally; we just need to confirm the
4278        // outgoing GET request line carried `If-Range: "v1"`.
4279        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4280            Content-Length: 10\r\n\
4281            Content-Range: bytes 0-9/10\r\n\
4282            Connection: close\r\n\
4283            \r\n\
4284            0123456789";
4285        let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
4286        let mut src = HttpSource::open(&uri).expect("open");
4287        let mut buf = [0u8; 10];
4288        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4289        let req = captured.recv().expect("captured GET request");
4290        let s = String::from_utf8_lossy(&req);
4291        assert!(
4292            s.to_ascii_lowercase().contains("if-range: \"v1\""),
4293            "GET did not carry If-Range; got:\n{s}"
4294        );
4295    }
4296
4297    /// HEAD response with a weak ETag and no Last-Modified — driver
4298    /// must not invent a validator.
4299    const HEAD_10B_WITH_WEAK_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
4300        Content-Length: 10\r\n\
4301        Accept-Ranges: bytes\r\n\
4302        ETag: W/\"v1\"\r\n\
4303        Connection: close\r\n\
4304        \r\n";
4305
4306    #[test]
4307    fn local_server_weak_etag_does_not_emit_if_range_header() {
4308        // §13.1.5: "A client MUST NOT generate an If-Range header
4309        // field containing an entity tag that is marked as weak."
4310        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4311            Content-Length: 10\r\n\
4312            Content-Range: bytes 0-9/10\r\n\
4313            Connection: close\r\n\
4314            \r\n\
4315            0123456789";
4316        let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_WEAK_ETAG, GET);
4317        let mut src = HttpSource::open(&uri).expect("open");
4318        let mut buf = [0u8; 10];
4319        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4320        let req = captured.recv().expect("captured GET request");
4321        let s = String::from_utf8_lossy(&req);
4322        assert!(
4323            !s.to_ascii_lowercase().contains("if-range:"),
4324            "GET unexpectedly carried If-Range with a weak ETag; got:\n{s}"
4325        );
4326    }
4327
4328    #[test]
4329    fn local_server_200_with_if_range_set_is_fatal_mutation() {
4330        // §13.1.5 short-circuit: when the validator we sent does NOT
4331        // match, the server omits the Range honour and sends 200 with
4332        // the full new representation. Our driver must surface that
4333        // as a hard error rather than silently re-anchoring the byte
4334        // offset against a different resource.
4335        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4336            Content-Length: 10\r\n\
4337            Connection: close\r\n\
4338            \r\n\
4339            ABCDEFGHIJ";
4340        let (uri, _captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
4341        let mut src = HttpSource::open(&uri).expect("open");
4342        let mut buf = [0u8; 10];
4343        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
4344        let msg = err.to_string();
4345        assert!(
4346            msg.contains("If-Range"),
4347            "wrong error (no If-Range mention): {msg}"
4348        );
4349        assert!(
4350            msg.contains("changed since HEAD"),
4351            "wrong error (no mutation phrasing): {msg}"
4352        );
4353    }
4354
4355    #[test]
4356    fn local_server_no_validator_still_drains_prefix_on_200() {
4357        // Sanity: when HEAD supplies no usable validator, we did NOT
4358        // send If-Range, so the §3.1 "server ignores Range, sends 200"
4359        // soft-fallback (drain prefix) still works. This confirms we
4360        // did not regress the existing path.
4361        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4362            Content-Length: 10\r\n\
4363            Connection: close\r\n\
4364            \r\n\
4365            0123456789";
4366        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4367        let mut src = HttpSource::open(&uri).expect("open");
4368        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
4369        let mut buf = [0u8; 6];
4370        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4371        assert_eq!(&buf, b"456789");
4372    }
4373
4374    // -- RFC 9110 §8.6 Content-Length sanity ---------------------------------
4375
4376    #[test]
4377    fn local_server_200_fallback_content_length_mismatch_is_fatal() {
4378        // §8.6: HEAD's Content-Length MUST equal what a GET would have
4379        // sent. So a 200-fallback (server ignored Range, served full
4380        // body) carrying a Content-Length different from the HEAD's is
4381        // a mid-stream resize disguised as a §3.1 soft-fallback. The
4382        // demuxer would drain a now-wrong-sized prefix and read short;
4383        // surface as a hard error instead.
4384        //
4385        // HEAD says 10 bytes, GET says 5 — the body actually shipped is
4386        // 5 bytes so this is also a real wire condition (not just a
4387        // header lie).
4388        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4389            Content-Length: 5\r\n\
4390            Connection: close\r\n\
4391            \r\n\
4392            01234";
4393        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4394        let mut src = HttpSource::open(&uri).expect("open");
4395        let mut buf = [0u8; 10];
4396        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
4397        let msg = err.to_string();
4398        assert!(
4399            msg.contains("Content-Length 5"),
4400            "wrong error (no 'Content-Length 5'): {msg}"
4401        );
4402        assert!(
4403            msg.contains("HEAD-observed total 10"),
4404            "wrong error (no HEAD-observed mention): {msg}"
4405        );
4406        assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
4407    }
4408
4409    #[test]
4410    fn local_server_200_fallback_without_content_length_is_accepted() {
4411        // §8.6 makes Content-Length a SHOULD on responses (not a MUST
4412        // outside specific cases). A 200 with no Content-Length cannot
4413        // be cross-checked against HEAD; we still walk the §3.1
4414        // drain-prefix path. (HTTP/1.1 framing here uses Connection:
4415        // close to delimit the body — RFC 9112 §6.3 case 7.)
4416        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4417            Connection: close\r\n\
4418            \r\n\
4419            0123456789";
4420        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4421        let mut src = HttpSource::open(&uri).expect("open");
4422        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
4423        let mut buf = [0u8; 6];
4424        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4425        assert_eq!(&buf, b"456789");
4426    }
4427
4428    #[test]
4429    fn local_server_206_content_length_mismatch_is_fatal() {
4430        // §8.6: a 206's Content-Length is the count of bytes actually
4431        // being sent. For our open-ended single-range request the
4432        // implied span is `last - first + 1`. A header value that
4433        // disagrees would let the downstream reader drift past the
4434        // end of the satisfied range silently.
4435        //
4436        // We ask for bytes=0-; server replies with Content-Range
4437        // 'bytes 0-9/10' (span 10) but Content-Length 5 (lie). The
4438        // mismatch is what we surface, not whatever short body would
4439        // arrive.
4440        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4441            Content-Length: 5\r\n\
4442            Content-Range: bytes 0-9/10\r\n\
4443            Connection: close\r\n\
4444            \r\n\
4445            01234";
4446        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4447        let mut src = HttpSource::open(&uri).expect("open");
4448        let mut buf = [0u8; 10];
4449        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
4450        let msg = err.to_string();
4451        assert!(
4452            msg.contains("Content-Length 5"),
4453            "wrong error (no 'Content-Length 5'): {msg}"
4454        );
4455        assert!(
4456            msg.contains("Content-Range span 10"),
4457            "wrong error (no 'Content-Range span 10'): {msg}"
4458        );
4459        assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
4460    }
4461
4462    #[test]
4463    fn local_server_206_matching_content_length_is_accepted() {
4464        // Sanity: Content-Length 10 + Content-Range 'bytes 0-9/10'
4465        // (span 10) is the canonical happy path and must continue to
4466        // succeed.
4467        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4468            Content-Length: 10\r\n\
4469            Content-Range: bytes 0-9/10\r\n\
4470            Connection: close\r\n\
4471            \r\n\
4472            0123456789";
4473        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4474        let mut src = HttpSource::open(&uri).expect("open");
4475        let mut buf = [0u8; 10];
4476        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4477        assert_eq!(&buf, b"0123456789");
4478    }
4479
4480    // -- RFC 9110 §14.3 Accept-Ranges parser ---------------------------------
4481
4482    #[test]
4483    fn accept_ranges_bare_bytes_is_bytes() {
4484        // §14.3 canonical example: `Accept-Ranges: bytes`.
4485        assert_eq!(parse_accept_ranges("bytes"), AcceptRanges::Bytes);
4486    }
4487
4488    #[test]
4489    fn accept_ranges_is_case_insensitive_on_unit_name() {
4490        // §3.2.6 token equality is case-insensitive; §14.1 cross-refs
4491        // it. So `BYTES` and `Bytes` must both classify as bytes.
4492        assert_eq!(parse_accept_ranges("BYTES"), AcceptRanges::Bytes);
4493        assert_eq!(parse_accept_ranges("Bytes"), AcceptRanges::Bytes);
4494        assert_eq!(parse_accept_ranges("byTeS"), AcceptRanges::Bytes);
4495    }
4496
4497    #[test]
4498    fn accept_ranges_explicit_none_is_distinct_from_absent() {
4499        // §14.3 reserves `none` as the explicit-refusal token. The
4500        // driver must distinguish that from "header simply absent"
4501        // — the former is the server actively saying "don't try",
4502        // the latter is informational silence (§14.3 makes the
4503        // header advisory).
4504        assert_eq!(parse_accept_ranges("none"), AcceptRanges::None);
4505        assert_eq!(parse_accept_ranges("NONE"), AcceptRanges::None);
4506        assert_eq!(parse_accept_ranges(""), AcceptRanges::Absent);
4507        assert_eq!(parse_accept_ranges("   "), AcceptRanges::Absent);
4508    }
4509
4510    #[test]
4511    fn accept_ranges_list_with_bytes_anywhere_is_bytes() {
4512        // §14.3 ABNF: `acceptable-ranges = 1#range-unit`. A list
4513        // construction (RFC 9110 §5.6.1) is comma-separated with
4514        // OWS tolerance. `bytes` anywhere in the list means the
4515        // server supports byte ranges.
4516        assert_eq!(parse_accept_ranges("bytes, foo"), AcceptRanges::Bytes);
4517        assert_eq!(parse_accept_ranges("foo, bytes"), AcceptRanges::Bytes);
4518        assert_eq!(parse_accept_ranges("foo,bytes,bar"), AcceptRanges::Bytes);
4519        assert_eq!(
4520            parse_accept_ranges("  bytes  ,  baz-units  "),
4521            AcceptRanges::Bytes
4522        );
4523    }
4524
4525    #[test]
4526    fn accept_ranges_unknown_units_only_is_other() {
4527        // Server advertises ranges, but not in the `bytes` unit we
4528        // speak. The classification carries the lowercased token
4529        // list so a caller can surface what the server actually
4530        // offered.
4531        match parse_accept_ranges("foo-unit") {
4532            AcceptRanges::Other(v) => assert_eq!(v, vec!["foo-unit"]),
4533            other => panic!("expected Other, got {other:?}"),
4534        }
4535        match parse_accept_ranges("Foo, Bar") {
4536            AcceptRanges::Other(v) => assert_eq!(v, vec!["foo", "bar"]),
4537            other => panic!("expected Other, got {other:?}"),
4538        }
4539    }
4540
4541    #[test]
4542    fn accept_ranges_none_alongside_real_units_is_other_not_none() {
4543        // §14.3 doesn't address the contradictory "none + bytes"
4544        // sender, but does name `none` as a single-element advice
4545        // value. Treat any list-form containing `none` as `Other`
4546        // rather than `None` so a misconfigured server doesn't
4547        // accidentally lock us out of an advertised `bytes`.
4548        // (bytes-included path is covered above; here we cover the
4549        // "none + other-unit" no-bytes contradiction.)
4550        match parse_accept_ranges("none, foo") {
4551            AcceptRanges::Other(v) => {
4552                assert!(v.contains(&"none".to_string()));
4553                assert!(v.contains(&"foo".to_string()));
4554            }
4555            other => panic!("expected Other for none+foo, got {other:?}"),
4556        }
4557    }
4558
4559    #[test]
4560    fn accept_ranges_empty_list_elements_are_skipped() {
4561        // §5.6.1 explicitly tolerates empty list members.
4562        assert_eq!(parse_accept_ranges("bytes,,"), AcceptRanges::Bytes);
4563        assert_eq!(parse_accept_ranges(",bytes,"), AcceptRanges::Bytes);
4564        assert_eq!(parse_accept_ranges(",,,"), AcceptRanges::Absent);
4565    }
4566
4567    #[test]
4568    fn accept_ranges_non_token_elements_are_skipped_not_fatal() {
4569        // A server putting garbage (containing characters illegal in
4570        // a §5.6.2 token — here, a space inside what should be one
4571        // token) in one slot must not black-hole the legitimate
4572        // `bytes` next to it.
4573        assert_eq!(
4574            parse_accept_ranges("bytes, foo bar baz"),
4575            AcceptRanges::Bytes
4576        );
4577        // Likewise, garbage-only input falls through to Absent (not
4578        // a panic).
4579        assert_eq!(parse_accept_ranges("hello world"), AcceptRanges::Absent);
4580    }
4581
4582    #[test]
4583    fn is_token_accepts_tchar_classes() {
4584        // §5.6.2 tchar coverage spot-check.
4585        assert!(is_token("bytes"));
4586        assert!(is_token("foo-bar"));
4587        assert!(is_token("foo.bar"));
4588        assert!(is_token("ABC123"));
4589        assert!(is_token("!#$%&'*+-.^_`|~"));
4590        assert!(!is_token(""));
4591        assert!(!is_token("foo bar"));
4592        assert!(!is_token("foo,bar"));
4593        assert!(!is_token("foo/bar"));
4594        assert!(!is_token("\"quoted\""));
4595    }
4596
4597    // -- RFC 9110 §12.5.5 Vary parser ----------------------------------------
4598
4599    #[test]
4600    fn vary_absent_on_empty_or_whitespace() {
4601        // No usable members → Absent (no negotiation warning).
4602        assert_eq!(parse_vary(""), Vary::Absent);
4603        assert_eq!(parse_vary("   "), Vary::Absent);
4604        // §5.6.1 empty list members are dropped; all-empty → Absent.
4605        assert_eq!(parse_vary(",,,"), Vary::Absent);
4606        assert_eq!(parse_vary(" , , "), Vary::Absent);
4607    }
4608
4609    #[test]
4610    fn vary_wildcard_bare_and_in_list() {
4611        // §12.5.5: "A list containing the member '*' signals that other
4612        // aspects of the request might have played a role..." — bare or
4613        // mixed, the value is the wildcard form.
4614        assert_eq!(parse_vary("*"), Vary::Wildcard);
4615        assert_eq!(parse_vary("Accept-Encoding, *"), Vary::Wildcard);
4616        assert_eq!(parse_vary("*, Accept-Language"), Vary::Wildcard);
4617        // OWS around the wildcard member is tolerated per §5.6.1.
4618        assert_eq!(parse_vary("  *  "), Vary::Wildcard);
4619    }
4620
4621    #[test]
4622    fn vary_wildcard_short_circuits_past_garbage() {
4623        // Once `*` is seen, a later malformed member cannot downgrade
4624        // the classification away from the unsafe wildcard form.
4625        assert_eq!(parse_vary("*, foo bar"), Vary::Wildcard);
4626    }
4627
4628    #[test]
4629    fn vary_field_names_are_lowercased_and_listed() {
4630        // §12.5.5 form 2: a list of selecting request field-names.
4631        // §5.1 field-names are case-insensitive → lowercased.
4632        match parse_vary("Accept-Encoding, Accept-Language") {
4633            Vary::Fields(v) => {
4634                assert_eq!(v, vec!["accept-encoding", "accept-language"]);
4635            }
4636            other => panic!("expected Fields, got {other:?}"),
4637        }
4638    }
4639
4640    #[test]
4641    fn vary_non_token_members_are_skipped_not_fatal() {
4642        // A malformed slot (space inside a would-be token) must not
4643        // black-hole the legitimate field-name next to it — mirrors
4644        // the §5.6.2-token discipline in parse_accept_ranges.
4645        match parse_vary("accept-encoding, foo bar") {
4646            Vary::Fields(v) => assert_eq!(v, vec!["accept-encoding"]),
4647            other => panic!("expected Fields, got {other:?}"),
4648        }
4649        // A list of only garbage members falls through to Absent, not a
4650        // panic and not a spurious wildcard.
4651        assert_eq!(parse_vary("foo bar, baz qux"), Vary::Absent);
4652    }
4653
4654    #[test]
4655    fn vary_quoted_star_is_a_field_name_not_wildcard() {
4656        // §12.5.5's wildcard member is the bare token `*`. A member that
4657        // merely contains `*` as part of a longer token (e.g. `x-*`) is
4658        // a (peculiar) field-name, not the wildcard form. `*` itself is
4659        // a valid §5.6.2 tchar, so `x-*` parses as a Fields member.
4660        match parse_vary("x-*") {
4661            Vary::Fields(v) => assert_eq!(v, vec!["x-*"]),
4662            other => panic!("expected Fields for x-*, got {other:?}"),
4663        }
4664    }
4665
4666    // -- Content-Encoding (RFC 9110 §8.4 / §12.5.3) --------------------------
4667
4668    #[test]
4669    fn content_codings_empty_and_absent_are_acceptable() {
4670        // No header value (or one that is all empty §5.6.1 list slots)
4671        // means no coding — the byte-offset model holds.
4672        assert!(non_identity_content_codings("").is_empty());
4673        assert!(non_identity_content_codings("   ").is_empty());
4674        assert!(non_identity_content_codings(",,,").is_empty());
4675    }
4676
4677    #[test]
4678    fn content_codings_identity_is_tolerated_as_no_op() {
4679        // §12.5.3: identity is "a synonym for 'no encoding'". §8.4 says
4680        // it SHOULD NOT appear in Content-Encoding, but a server that
4681        // sends it anyway has not coded anything — tolerate, don't
4682        // reject.
4683        assert!(non_identity_content_codings("identity").is_empty());
4684        assert!(non_identity_content_codings("Identity").is_empty());
4685        assert!(non_identity_content_codings("identity, identity").is_empty());
4686    }
4687
4688    #[test]
4689    fn content_codings_real_codings_are_reported_lowercased() {
4690        // §8.4.1: "All content codings are case-insensitive."
4691        assert_eq!(non_identity_content_codings("gzip"), ["gzip"]);
4692        assert_eq!(non_identity_content_codings("GZIP"), ["gzip"]);
4693        // §8.4.1.3 names x-gzip as a SHOULD-equivalent of gzip; the
4694        // driver decodes neither, so it is simply reported under its
4695        // own (lowercased) name.
4696        assert_eq!(non_identity_content_codings("x-gzip"), ["x-gzip"]);
4697        assert_eq!(non_identity_content_codings("compress"), ["compress"]);
4698        assert_eq!(non_identity_content_codings("deflate"), ["deflate"]);
4699    }
4700
4701    #[test]
4702    fn content_codings_list_preserves_application_order() {
4703        // §8.4: the sender MUST list codings "in the order in which
4704        // they were applied" — preserve that order in the diagnostic.
4705        assert_eq!(
4706            non_identity_content_codings("gzip, deflate"),
4707            ["gzip", "deflate"]
4708        );
4709        // identity slots vanish; the real codings keep their order.
4710        assert_eq!(
4711            non_identity_content_codings("identity, gzip , identity, compress"),
4712            ["gzip", "compress"]
4713        );
4714        // §5.6.1 empty slots are dropped without eating neighbours.
4715        assert_eq!(non_identity_content_codings(",gzip,,"), ["gzip"]);
4716    }
4717
4718    #[test]
4719    fn content_codings_non_token_garbage_is_kept_not_skipped() {
4720        // Opposite fail-direction from parse_accept_ranges: a coding
4721        // name we cannot even parse is still a transformation we
4722        // cannot undo, so it must surface in the rejection diagnostic
4723        // rather than be silently skipped (which would ACCEPT the
4724        // coded body).
4725        assert_eq!(non_identity_content_codings("gzip stream"), ["gzip stream"]);
4726        assert_eq!(non_identity_content_codings("identity, ???"), ["???"]);
4727    }
4728
4729    #[test]
4730    fn local_server_head_with_content_encoding_gzip_is_unsupported() {
4731        // §8.4: representation metadata describes the coded form, so a
4732        // coded representation's Content-Length (and any byte range
4733        // against it) counts compressed bytes the demuxer cannot use.
4734        // The driver must refuse at open, naming the coding + cite.
4735        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4736            Content-Length: 10\r\n\
4737            Accept-Ranges: bytes\r\n\
4738            Content-Encoding: gzip\r\n\
4739            Connection: close\r\n\
4740            \r\n";
4741        const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4742            Content-Length: 10\r\n\
4743            Connection: close\r\n\
4744            \r\n\
4745            0123456789";
4746        let (uri, _done) = spawn_server(HEAD, GET);
4747        let err = match HttpSource::open(&uri) {
4748            Ok(_) => panic!("open must refuse a content-coded representation"),
4749            Err(e) => e,
4750        };
4751        let msg = format!("{err}");
4752        assert!(msg.contains("gzip"), "missing coding name: {msg}");
4753        assert!(msg.contains("8.4"), "missing §8.4 cite: {msg}");
4754        assert!(
4755            matches!(err, Error::Unsupported(_)),
4756            "expected Unsupported, got: {err:?}"
4757        );
4758    }
4759
4760    #[test]
4761    fn local_server_head_with_content_encoding_identity_is_accepted() {
4762        // §12.5.3 identity = "no encoding"; §8.4 SHOULD NOT send it,
4763        // but tolerating it costs nothing and the bytes are un-coded.
4764        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4765            Content-Length: 10\r\n\
4766            Accept-Ranges: bytes\r\n\
4767            Content-Encoding: identity\r\n\
4768            Connection: close\r\n\
4769            \r\n";
4770        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4771            Content-Length: 10\r\n\
4772            Content-Range: bytes 0-9/10\r\n\
4773            Connection: close\r\n\
4774            \r\n\
4775            0123456789";
4776        let (uri, _done) = spawn_server(HEAD, GET);
4777        let mut src = HttpSource::open(&uri).expect("identity coding must be tolerated");
4778        let mut buf = [0u8; 10];
4779        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4780        assert_eq!(&buf, b"0123456789");
4781    }
4782
4783    #[test]
4784    fn local_server_206_with_content_encoding_gzip_is_rejected() {
4785        // The HEAD was clean but the GET came back coded — the server
4786        // ignored our `Accept-Encoding: identity`. The reader must
4787        // never see the coded bytes.
4788        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4789            Content-Length: 10\r\n\
4790            Content-Range: bytes 0-9/10\r\n\
4791            Content-Encoding: gzip\r\n\
4792            Connection: close\r\n\
4793            \r\n\
4794            0123456789";
4795        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4796        let mut src = HttpSource::open(&uri).expect("open");
4797        let mut buf = [0u8; 10];
4798        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
4799        let msg = err.to_string();
4800        assert!(msg.contains("Content-Encoding"), "wrong error: {msg}");
4801        assert!(msg.contains("gzip"), "missing coding name: {msg}");
4802    }
4803
4804    #[test]
4805    fn local_server_200_fallback_with_content_encoding_gzip_is_rejected() {
4806        // Same offence on the RFC 7233 §3.1 full-body fallback: the
4807        // §3.1 prefix drain counts representation bytes, which a
4808        // coding would silently redefine.
4809        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4810            Content-Length: 10\r\n\
4811            Content-Encoding: gzip\r\n\
4812            Connection: close\r\n\
4813            \r\n\
4814            0123456789";
4815        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
4816        let mut src = HttpSource::open(&uri).expect("open");
4817        let mut buf = [0u8; 10];
4818        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
4819        let msg = err.to_string();
4820        assert!(msg.contains("Content-Encoding"), "wrong error: {msg}");
4821    }
4822
4823    #[test]
4824    fn local_server_get_request_carries_accept_encoding_identity() {
4825        // §12.5.3: the request side of the contract — every range GET
4826        // must list only `identity` so a conformant server never
4827        // applies a coding in the first place.
4828        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4829            Content-Length: 10\r\n\
4830            Content-Range: bytes 0-9/10\r\n\
4831            Connection: close\r\n\
4832            \r\n\
4833            0123456789";
4834        let (uri, captured) = spawn_server_capturing_get(HEAD_10B_BYTES, GET);
4835        let mut src = HttpSource::open(&uri).expect("open");
4836        let mut buf = [0u8; 10];
4837        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4838        let req = captured.recv().expect("captured GET request");
4839        let s = String::from_utf8_lossy(&req).to_ascii_lowercase();
4840        assert!(
4841            s.contains("accept-encoding: identity"),
4842            "GET did not carry Accept-Encoding: identity; got:\n{s}"
4843        );
4844    }
4845
4846    #[test]
4847    fn local_server_head_request_carries_accept_encoding_identity() {
4848        // Same contract on the opening HEAD: the Content-Length we
4849        // record must be negotiated over the un-coded representation.
4850        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4851            Content-Length: 10\r\n\
4852            Content-Range: bytes 0-9/10\r\n\
4853            Connection: close\r\n\
4854            \r\n\
4855            0123456789";
4856        let (uri, captured) = spawn_server_capturing_all(HEAD_10B_BYTES, GET);
4857        let _src = HttpSource::open(&uri).expect("open");
4858        let req = captured.recv().expect("captured HEAD request");
4859        let s = String::from_utf8_lossy(&req).to_ascii_lowercase();
4860        assert!(
4861            s.starts_with("head "),
4862            "first captured request not HEAD:\n{s}"
4863        );
4864        assert!(
4865            s.contains("accept-encoding: identity"),
4866            "HEAD did not carry Accept-Encoding: identity; got:\n{s}"
4867        );
4868    }
4869
4870    /// Variant of `spawn_server` that captures EVERY request's bytes
4871    /// (HEAD and GET alike) so a test can inspect the opening HEAD.
4872    fn spawn_server_capturing_all(
4873        head_resp: &'static [u8],
4874        get_resp: &'static [u8],
4875    ) -> (String, mpsc::Receiver<Vec<u8>>) {
4876        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
4877        let port = listener.local_addr().unwrap().port();
4878        let (tx, rx) = mpsc::channel::<Vec<u8>>();
4879        thread::spawn(move || {
4880            for _ in 0..4 {
4881                let Ok((mut stream, _)) = listener.accept() else {
4882                    break;
4883                };
4884                let mut buf = [0u8; 4096];
4885                use std::io::Read as _;
4886                let n = stream.read(&mut buf).unwrap_or(0);
4887                if n == 0 {
4888                    continue;
4889                }
4890                let req = &buf[..n];
4891                let _ = tx.send(req.to_vec());
4892                let resp = if req.starts_with(b"HEAD ") {
4893                    head_resp
4894                } else {
4895                    get_resp
4896                };
4897                let _ = stream.write_all(resp);
4898                let _ = stream.flush();
4899            }
4900        });
4901        (format!("http://127.0.0.1:{port}/x"), rx)
4902    }
4903
4904    #[test]
4905    fn local_server_accept_ranges_none_is_unsupported() {
4906        // §14.3: `Accept-Ranges: none` is the server's explicit advice
4907        // to not attempt a range request. The driver surfaces this as
4908        // a distinct Unsupported error so a caller can tell it apart
4909        // from "header absent" and "header advertises some other
4910        // unit".
4911        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4912            Content-Length: 10\r\n\
4913            Accept-Ranges: none\r\n\
4914            Connection: close\r\n\
4915            \r\n";
4916        const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4917            Content-Length: 10\r\n\
4918            Connection: close\r\n\
4919            \r\n\
4920            0123456789";
4921        let (uri, _done) = spawn_server(HEAD, GET);
4922        let err = match HttpSource::open(&uri) {
4923            Ok(_) => panic!("open must refuse Accept-Ranges: none"),
4924            Err(e) => e,
4925        };
4926        let msg = format!("{err}");
4927        assert!(
4928            msg.contains("explicitly refused") || msg.contains("Accept-Ranges: none"),
4929            "wrong error (no §14.3 'none' refusal phrasing): {msg}"
4930        );
4931        assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
4932    }
4933
4934    #[test]
4935    fn local_server_accept_ranges_list_with_bytes_is_accepted() {
4936        // §14.3 §5.6.1: a list-form Accept-Ranges that includes
4937        // `bytes` legitimately advertises byte-range support. Must
4938        // not be refused just because there is a second unit.
4939        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4940            Content-Length: 10\r\n\
4941            Accept-Ranges: bytes, foo-unit\r\n\
4942            Connection: close\r\n\
4943            \r\n";
4944        const GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
4945            Content-Length: 10\r\n\
4946            Content-Range: bytes 0-9/10\r\n\
4947            Connection: close\r\n\
4948            \r\n\
4949            0123456789";
4950        let (uri, _done) = spawn_server(HEAD, GET);
4951        let mut src = HttpSource::open(&uri).expect("open ok");
4952        let mut buf = [0u8; 10];
4953        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
4954        assert_eq!(&buf, b"0123456789");
4955    }
4956
4957    #[test]
4958    fn local_server_accept_ranges_only_unknown_unit_is_unsupported() {
4959        // §14.3: server speaks ranges, just not in our unit. Surface
4960        // distinctly from "none" and from "absent" — the message
4961        // names the unit(s) the server actually advertised.
4962        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4963            Content-Length: 10\r\n\
4964            Accept-Ranges: foo-unit\r\n\
4965            Connection: close\r\n\
4966            \r\n";
4967        const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4968            Content-Length: 10\r\n\
4969            Connection: close\r\n\
4970            \r\n\
4971            0123456789";
4972        let (uri, _done) = spawn_server(HEAD, GET);
4973        let err = match HttpSource::open(&uri) {
4974            Ok(_) => panic!("open must refuse non-bytes-only"),
4975            Err(e) => e,
4976        };
4977        let msg = format!("{err}");
4978        assert!(
4979            msg.contains("foo-unit") || msg.contains("not 'bytes'"),
4980            "error must surface the offered unit(s): {msg}"
4981        );
4982        assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
4983    }
4984
4985    #[test]
4986    fn local_server_accept_ranges_absent_is_unsupported_distinct_message() {
4987        // §14.3 makes the header advisory — but the driver's
4988        // correctness model relies on the server actually satisfying
4989        // Range, so we preserve the historical refusal here. The
4990        // message must distinguish "absent" from "none" and from
4991        // "other unit" so a caller can tell what happened.
4992        const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
4993            Content-Length: 10\r\n\
4994            Connection: close\r\n\
4995            \r\n";
4996        const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
4997            Content-Length: 10\r\n\
4998            Connection: close\r\n\
4999            \r\n\
5000            0123456789";
5001        let (uri, _done) = spawn_server(HEAD, GET);
5002        let err = match HttpSource::open(&uri) {
5003            Ok(_) => panic!("open must refuse absent Accept-Ranges"),
5004            Err(e) => e,
5005        };
5006        let msg = format!("{err}");
5007        assert!(
5008            msg.contains("did not advertise") || msg.contains("Accept-Ranges"),
5009            "wrong error (no absent-header phrasing): {msg}"
5010        );
5011        assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
5012    }
5013
5014    // -------------------------------------------------------------
5015    // RFC 9110 §14.6 / §15.3.7.2: multipart/byteranges media type
5016    // -------------------------------------------------------------
5017
5018    #[test]
5019    fn is_multipart_byteranges_accepts_canonical_form() {
5020        assert!(is_multipart_byteranges_content_type("multipart/byteranges"));
5021    }
5022
5023    #[test]
5024    fn is_multipart_byteranges_accepts_boundary_parameter() {
5025        // §14.6: the media type carries a required `boundary` parameter
5026        // in real-world use; the type/subtype match must succeed
5027        // regardless.
5028        assert!(is_multipart_byteranges_content_type(
5029            "multipart/byteranges; boundary=THIS_STRING_SEPARATES"
5030        ));
5031    }
5032
5033    #[test]
5034    fn is_multipart_byteranges_is_case_insensitive_per_section_8_3_1() {
5035        // §8.3.1: "type, subtype, and parameter name tokens are
5036        // case-insensitive". A server that capitalises the type-name
5037        // is still §14.6.
5038        assert!(is_multipart_byteranges_content_type(
5039            "Multipart/ByteRanges; boundary=x"
5040        ));
5041        assert!(is_multipart_byteranges_content_type("MULTIPART/BYTERANGES"));
5042    }
5043
5044    #[test]
5045    fn is_multipart_byteranges_tolerates_ows_around_value() {
5046        // §5.6.3 OWS handling — Content-Range fields are field-content
5047        // and OWS-trimmed at the parser level.
5048        assert!(is_multipart_byteranges_content_type(
5049            "  multipart/byteranges  "
5050        ));
5051        assert!(is_multipart_byteranges_content_type(
5052            "multipart/byteranges ; boundary=x"
5053        ));
5054    }
5055
5056    #[test]
5057    fn is_multipart_byteranges_rejects_other_types() {
5058        // Negatives: the typical single-range Content-Type is the raw
5059        // representation's media type. Don't false-positive on it.
5060        for t in [
5061            "application/pdf",
5062            "video/mp4",
5063            "image/jpeg",
5064            "multipart/form-data; boundary=x",
5065            "multipart/mixed; boundary=x",
5066            "text/plain",
5067            "",
5068        ] {
5069            assert!(
5070                !is_multipart_byteranges_content_type(t),
5071                "false-positive on {t:?}"
5072            );
5073        }
5074    }
5075
5076    #[test]
5077    fn is_multipart_byteranges_does_not_false_positive_on_prefix_subtype() {
5078        // `multipart/byteranges-foo` is NOT §14.6 even though it shares
5079        // a prefix — confirm token-equality, not prefix-match.
5080        assert!(!is_multipart_byteranges_content_type(
5081            "multipart/byteranges-foo"
5082        ));
5083    }
5084
5085    #[test]
5086    fn local_server_206_with_multipart_byteranges_is_rejected() {
5087        // §15.3.7.2: "A server MUST NOT generate a multipart response
5088        // to a request for a single range." We only ever ask for one
5089        // range. A 206 carrying Content-Type: multipart/byteranges is a
5090        // server bug; surface a clean §15.3.7 cite rather than letting
5091        // the body's boundary delimiter sneak into the demuxer's
5092        // bitstream view.
5093        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
5094            Content-Length: 10\r\n\
5095            Content-Type: multipart/byteranges; boundary=THIS_STRING_SEPARATES\r\n\
5096            Content-Range: bytes 0-9/10\r\n\
5097            Connection: close\r\n\
5098            \r\n\
5099            0123456789";
5100        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
5101        let mut src = HttpSource::open(&uri).expect("open ok");
5102        let mut buf = [0u8; 10];
5103        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
5104        let msg = err.to_string();
5105        assert!(
5106            msg.contains("multipart/byteranges"),
5107            "error must name the offending media type: {msg}"
5108        );
5109        assert!(msg.contains("§15.3.7"), "error must cite §15.3.7: {msg}");
5110    }
5111
5112    #[test]
5113    fn local_server_206_with_uppercase_multipart_byteranges_is_rejected() {
5114        // §8.3.1 case-insensitivity: an origin that title-cases the
5115        // media type must still be detected as §15.3.7.2-illegal.
5116        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
5117            Content-Length: 10\r\n\
5118            Content-Type: Multipart/ByteRanges; boundary=x\r\n\
5119            Content-Range: bytes 0-9/10\r\n\
5120            Connection: close\r\n\
5121            \r\n\
5122            0123456789";
5123        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
5124        let mut src = HttpSource::open(&uri).expect("open ok");
5125        let mut buf = [0u8; 10];
5126        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
5127        let msg = err.to_string();
5128        assert!(
5129            msg.contains("multipart/byteranges") || msg.contains("Multipart/ByteRanges"),
5130            "error must name the offending media type: {msg}"
5131        );
5132    }
5133
5134    #[test]
5135    fn local_server_206_with_video_mp4_content_type_is_accepted() {
5136        // Sanity: a 206 with the raw representation media type must
5137        // still succeed — only multipart/byteranges flips the new
5138        // §15.3.7.2 guard.
5139        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
5140            Content-Length: 10\r\n\
5141            Content-Type: video/mp4\r\n\
5142            Content-Range: bytes 0-9/10\r\n\
5143            Connection: close\r\n\
5144            \r\n\
5145            0123456789";
5146        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
5147        let mut src = HttpSource::open(&uri).expect("open ok");
5148        let mut buf = [0u8; 10];
5149        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
5150        assert_eq!(&buf, b"0123456789");
5151    }
5152
5153    // -------------------------------------------------------------
5154    // RFC 9110 §10.2.3: Retry-After surfacing on HEAD non-success
5155    // -------------------------------------------------------------
5156
5157    #[test]
5158    fn format_retry_after_hint_renders_delay_seconds_form() {
5159        let h = format_retry_after_hint("120");
5160        assert_eq!(h, " (Retry-After: 120 s)");
5161    }
5162
5163    #[test]
5164    fn format_retry_after_hint_renders_zero_delay() {
5165        let h = format_retry_after_hint("0");
5166        assert_eq!(h, " (Retry-After: 0 s)");
5167    }
5168
5169    #[test]
5170    fn format_retry_after_hint_renders_imf_fixdate_form() {
5171        // §10.2.3 canonical example.
5172        let h = format_retry_after_hint("Fri, 31 Dec 1999 23:59:59 GMT");
5173        assert_eq!(h, " (Retry-After: 1999-12-31T23:59:59 UTC)");
5174    }
5175
5176    #[test]
5177    fn format_retry_after_hint_renders_rfc850_form_uniformly() {
5178        // §5.6.7 MUST-accept the obsolete form. The rendered hint must
5179        // canonicalise to the same ISO-8601-ish surface so the caller
5180        // gets a stable shape regardless of which form the origin used.
5181        let h = format_retry_after_hint("Sunday, 06-Nov-94 08:49:37 GMT");
5182        // Year 1994 wraps under the §5.6.7 sliding window from `94`.
5183        assert!(
5184            h.contains("1994-11-06T08:49:37 UTC"),
5185            "rfc850 canonicalisation: {h}"
5186        );
5187    }
5188
5189    #[test]
5190    fn format_retry_after_hint_renders_asctime_form_uniformly() {
5191        let h = format_retry_after_hint("Sun Nov  6 08:49:37 1994");
5192        assert!(
5193            h.contains("1994-11-06T08:49:37 UTC"),
5194            "asctime canonicalisation: {h}"
5195        );
5196    }
5197
5198    #[test]
5199    fn format_retry_after_hint_surfaces_unparseable_value() {
5200        // §10.2.3 grammar is strict — a unit-bearing form is rejected
5201        // by parse_retry_after. The hint helper should still produce a
5202        // diagnostic that names the raw value and the §10.2.3 cite so
5203        // the caller can see the origin's bug rather than a silent
5204        // "no Retry-After hint".
5205        let h = format_retry_after_hint("Tomorrow at noon");
5206        assert!(h.contains("Tomorrow at noon"), "unparseable hint: {h}");
5207        assert!(h.contains("unparseable"), "no diagnostic: {h}");
5208        assert!(h.contains("§10.2.3"), "no cite: {h}");
5209    }
5210
5211    #[test]
5212    fn format_retry_after_hint_skips_empty_input() {
5213        // Sentinel: an empty / whitespace-only value collapses to "",
5214        // so the caller's `format!("status {status}{hint}")` does not
5215        // emit a parenthesised empty.
5216        assert_eq!(format_retry_after_hint(""), "");
5217        assert_eq!(format_retry_after_hint("   "), "");
5218        assert_eq!(format_retry_after_hint("\t"), "");
5219    }
5220
5221    #[test]
5222    fn format_retry_after_hint_trims_surrounding_ows() {
5223        // §5.6.3 OWS — leading/trailing horizontal whitespace is field
5224        // framing, not value semantics.
5225        let h = format_retry_after_hint("  120  ");
5226        assert_eq!(h, " (Retry-After: 120 s)");
5227    }
5228
5229    /// Spawn a minimal HEAD-only server that always returns
5230    /// `head_resp`. Used by the §10.2.3 Retry-After tests which never
5231    /// reach the GET stage.
5232    fn spawn_head_only(head_resp: &'static [u8]) -> String {
5233        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
5234        let port = listener.local_addr().unwrap().port();
5235        thread::spawn(move || {
5236            for _ in 0..2 {
5237                let Ok((mut stream, _)) = listener.accept() else {
5238                    break;
5239                };
5240                let mut buf = [0u8; 4096];
5241                use std::io::Read as _;
5242                let _ = stream.read(&mut buf).unwrap_or(0);
5243                let _ = stream.write_all(head_resp);
5244                let _ = stream.flush();
5245            }
5246        });
5247        format!("http://127.0.0.1:{port}/x")
5248    }
5249
5250    #[test]
5251    fn local_server_head_503_surfaces_retry_after_delay() {
5252        // §10.2.3: "When sent with a 503 (Service Unavailable)
5253        // response, Retry-After indicates how long the service is
5254        // expected to be unavailable to the requesting client." The
5255        // HEAD non-success branch must surface the parsed delay so the
5256        // caller wiring back-off doesn't have to refetch the header.
5257        const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
5258            Content-Length: 0\r\n\
5259            Retry-After: 120\r\n\
5260            Connection: close\r\n\
5261            \r\n";
5262        let uri = spawn_head_only(HEAD);
5263        let err = match HttpSource::open(&uri) {
5264            Ok(_) => panic!("503 must error"),
5265            Err(e) => e,
5266        };
5267        let msg = format!("{err}");
5268        assert!(msg.contains("503"), "wrong error (no 503): {msg}");
5269        assert!(
5270            msg.contains("Retry-After: 120 s"),
5271            "Retry-After delay must surface in the message: {msg}"
5272        );
5273    }
5274
5275    #[test]
5276    fn local_server_head_429_surfaces_retry_after_date() {
5277        // §10.2.3 also accompanies 429 (Too Many Requests, RFC 6585)
5278        // with a Retry-After. Test the HTTP-date form alongside the
5279        // delay-seconds form.
5280        const HEAD: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
5281            Content-Length: 0\r\n\
5282            Retry-After: Fri, 31 Dec 1999 23:59:59 GMT\r\n\
5283            Connection: close\r\n\
5284            \r\n";
5285        let uri = spawn_head_only(HEAD);
5286        let err = match HttpSource::open(&uri) {
5287            Ok(_) => panic!("429 must error"),
5288            Err(e) => e,
5289        };
5290        let msg = format!("{err}");
5291        assert!(msg.contains("429"), "wrong error (no 429): {msg}");
5292        assert!(
5293            msg.contains("1999-12-31T23:59:59 UTC"),
5294            "Retry-After date must surface canonicalised in the message: {msg}"
5295        );
5296    }
5297
5298    #[test]
5299    fn local_server_head_503_without_retry_after_omits_hint() {
5300        // Sentinel: a 503 with no Retry-After must NOT carry a
5301        // parenthesised empty hint. The error stays "status 503" as
5302        // before.
5303        const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
5304            Content-Length: 0\r\n\
5305            Connection: close\r\n\
5306            \r\n";
5307        let uri = spawn_head_only(HEAD);
5308        let err = match HttpSource::open(&uri) {
5309            Ok(_) => panic!("503 must error"),
5310            Err(e) => e,
5311        };
5312        let msg = format!("{err}");
5313        assert!(msg.contains("503"), "wrong error (no 503): {msg}");
5314        assert!(
5315            !msg.contains("Retry-After"),
5316            "must not emit a Retry-After hint when none was sent: {msg}"
5317        );
5318    }
5319
5320    // -----------------------------------------------------------------
5321    // RFC 7230 §3.2.4 obs-fold normalisation
5322    // -----------------------------------------------------------------
5323
5324    #[test]
5325    fn obs_fold_absent_returns_borrowed_unchanged() {
5326        // No `\r\n` at all — must short-circuit to Cow::Borrowed
5327        // without allocating.
5328        let s = "bytes=0-1023";
5329        let out = normalize_obs_fold(s);
5330        assert_eq!(out, "bytes=0-1023");
5331        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
5332    }
5333
5334    #[test]
5335    fn obs_fold_crlf_without_sp_or_htab_is_not_a_fold() {
5336        // §3.2 ABNF: `obs-fold = CRLF 1*( SP / HTAB )`. A CRLF NOT
5337        // followed by SP/HTAB is not an obs-fold; the function must
5338        // leave it alone (the framing layer will flag it).
5339        let s = "abc\r\nxyz";
5340        let out = normalize_obs_fold(s);
5341        assert_eq!(out, "abc\r\nxyz");
5342        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
5343    }
5344
5345    #[test]
5346    fn obs_fold_bare_cr_or_lf_is_not_a_fold() {
5347        // Only the full `\r\n` sequence opens an obs-fold per §3.2.
5348        // Bare CR followed by SP, or bare LF followed by SP, must
5349        // pass through unmodified.
5350        let s = "abc\r def";
5351        let out = normalize_obs_fold(s);
5352        assert_eq!(out, "abc\r def");
5353        let s = "abc\n def";
5354        let out = normalize_obs_fold(s);
5355        assert_eq!(out, "abc\n def");
5356    }
5357
5358    #[test]
5359    fn obs_fold_single_sp_collapses_to_one_sp() {
5360        // The minimal §3.2.4 case: `CRLF SP` between two field-vchar
5361        // runs must become a single SP.
5362        let s = "abc\r\n def";
5363        let out = normalize_obs_fold(s);
5364        assert_eq!(out, "abc def");
5365        assert!(matches!(out, std::borrow::Cow::Owned(_)));
5366    }
5367
5368    #[test]
5369    fn obs_fold_single_htab_collapses_to_one_sp() {
5370        // §3.2.4: HTAB is alternative-equivalent to SP inside the
5371        // fold continuation. Both collapse to a single SP.
5372        let s = "abc\r\n\tdef";
5373        let out = normalize_obs_fold(s);
5374        assert_eq!(out, "abc def");
5375    }
5376
5377    #[test]
5378    fn obs_fold_multiple_sp_htab_collapses_to_one_sp() {
5379        // `obs-fold = CRLF 1*( SP / HTAB )` — the continuation is
5380        // a maximal run of any mix of SP/HTAB. We collapse the whole
5381        // run to one SP.
5382        let s = "abc\r\n  \t \tdef";
5383        let out = normalize_obs_fold(s);
5384        assert_eq!(out, "abc def");
5385    }
5386
5387    #[test]
5388    fn obs_fold_multiple_folds_each_collapse_independently() {
5389        // Two distinct obs-fold occurrences must each become exactly
5390        // one SP — the count of folds in the input equals the count
5391        // of replacement SPs in the output.
5392        let s = "a\r\n b\r\n\tc";
5393        let out = normalize_obs_fold(s);
5394        assert_eq!(out, "a b c");
5395    }
5396
5397    #[test]
5398    fn obs_fold_at_start_of_value_collapses() {
5399        // The §3.2 ABNF puts obs-fold inside `field-value`, so a
5400        // value that begins with an obs-fold (the recipient passes
5401        // through a non-stripped leading OWS context) must still
5402        // collapse cleanly.
5403        let s = "\r\n abc";
5404        let out = normalize_obs_fold(s);
5405        assert_eq!(out, " abc");
5406    }
5407
5408    #[test]
5409    fn obs_fold_trailing_crlf_without_continuation_is_not_a_fold() {
5410        // A trailing `\r\n` with no SP/HTAB after it is the framing
5411        // line-terminator, not an obs-fold. Pass through unmodified.
5412        let s = "abc\r\n";
5413        let out = normalize_obs_fold(s);
5414        assert_eq!(out, "abc\r\n");
5415        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
5416    }
5417
5418    #[test]
5419    fn obs_fold_preserves_obs_text_bytes() {
5420        // §3.2 allows `obs-text = %x80-FF` inside field-vchar. Such
5421        // bytes appear as multi-byte UTF-8 sequences in &str. The
5422        // normaliser must not split or mangle them.
5423        // U+00E9 ('é') is two UTF-8 bytes: 0xC3 0xA9.
5424        let s = "ab\u{00e9}\r\n cd\u{00e9}";
5425        let out = normalize_obs_fold(s);
5426        assert_eq!(out, "ab\u{00e9} cd\u{00e9}");
5427    }
5428
5429    #[test]
5430    fn obs_fold_does_not_touch_intra_field_whitespace_runs() {
5431        // The §3.2 ABNF allows `1*( SP / HTAB )` inside
5432        // field-content; only CRLF-prefixed runs are obs-fold. A
5433        // plain `   ` in the middle of a value is data and must be
5434        // left exactly as is.
5435        let s = "abc   def\t\tghi";
5436        let out = normalize_obs_fold(s);
5437        assert_eq!(out, "abc   def\t\tghi");
5438        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
5439    }
5440
5441    #[test]
5442    fn obs_fold_empty_input_is_borrowed_empty() {
5443        // Defensive: an empty field-value (§3.2 `*( field-content /
5444        // obs-fold )` permits zero occurrences) must short-circuit.
5445        let s = "";
5446        let out = normalize_obs_fold(s);
5447        assert_eq!(out, "");
5448        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
5449    }
5450
5451    #[test]
5452    fn obs_fold_then_non_fold_crlf_handles_each_independently() {
5453        // Mixed input: one real fold, then a CRLF that is NOT a
5454        // fold. The fold collapses; the trailing CRLF survives.
5455        let s = "a\r\n b\r\nc";
5456        let out = normalize_obs_fold(s);
5457        assert_eq!(out, "a b\r\nc");
5458    }
5459
5460    #[test]
5461    fn obs_fold_inside_quoted_string_is_still_normalised() {
5462        // §3.2.4 has no exception for quoted-string spans; the
5463        // normalisation runs at the field-value layer, before any
5464        // grammar-specific parser. A folded value inside a
5465        // quoted-string still loses the `CRLF SP` framing — the
5466        // quoted-string parser sees a single SP and treats it as a
5467        // literal space, which matches what the sender intended
5468        // before the §3.2.4-deprecated line-folding pass.
5469        let s = "\"a\r\n b\"";
5470        let out = normalize_obs_fold(s);
5471        assert_eq!(out, "\"a b\"");
5472    }
5473
5474    #[test]
5475    fn obs_fold_chained_folds_back_to_back_collapse_once_each() {
5476        // Two folds with no field-vchar between them is unusual but
5477        // not ill-formed: `CRLF SP CRLF SP`. Per §3.2.4 each fold
5478        // collapses to one SP, so the output is two SPs.
5479        let s = "a\r\n \r\n b";
5480        let out = normalize_obs_fold(s);
5481        assert_eq!(out, "a  b");
5482    }
5483
5484    #[test]
5485    fn obs_fold_existing_parsers_remain_obs_fold_agnostic() {
5486        // Sanity coupling: the function's contract is "normalise
5487        // before interpreting the field value", so feeding a folded
5488        // input directly to one of the §5.6.7 / §10.2.3 parsers
5489        // would still fail (they don't know about folds). After
5490        // normalisation, the same parser succeeds. This pins the
5491        // §3.2.4 "prior to interpreting" ordering.
5492        let folded = "Wed, 21 Oct 2026\r\n 07:28:00 GMT";
5493        // Pre-normalisation: the parser sees CRLF SP and rejects.
5494        assert!(parse_imf_fixdate(folded).is_none());
5495        // Post-normalisation: a single SP collapses cleanly into
5496        // the expected single-space separator.
5497        let normalised = normalize_obs_fold(folded);
5498        assert!(parse_imf_fixdate(&normalised).is_some());
5499    }
5500
5501    // --- §5.6.4 quoted-string unwrap ---------------------------------
5502
5503    #[test]
5504    fn unquote_string_empty_pair_decodes_to_empty_borrowed() {
5505        // The minimal §5.6.4 input is `""` (two DQUOTEs, no content).
5506        // It is well-formed and decodes to the empty string. With no
5507        // escapes present the return must be a borrow (the inner
5508        // slice is the empty &str inside the input).
5509        let v = unquote_string("\"\"").unwrap();
5510        assert_eq!(&*v, "");
5511        assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
5512    }
5513
5514    #[test]
5515    fn unquote_string_escape_free_returns_cow_borrowed() {
5516        // No `\` in the body — the §5.6.4 unescape pass is a no-op
5517        // and the implementation must short-circuit to Cow::Borrowed
5518        // so a hot path that never sees escapes stays allocation-free.
5519        let v = unquote_string("\"plain text\"").unwrap();
5520        assert_eq!(&*v, "plain text");
5521        assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
5522    }
5523
5524    #[test]
5525    fn unquote_string_quoted_dquote_collapses_to_one_dquote() {
5526        // `quoted-pair = "\" ( … VCHAR … )` — DQUOTE is VCHAR, so the
5527        // pair is well-formed and the §5.6.4 MUST collapses it to the
5528        // single octet that followed the backslash.
5529        let v = unquote_string("\"a\\\"b\"").unwrap();
5530        assert_eq!(&*v, "a\"b");
5531        assert!(matches!(v, std::borrow::Cow::Owned(_)));
5532    }
5533
5534    #[test]
5535    fn unquote_string_quoted_backslash_collapses_to_one_backslash() {
5536        // Same rule applied to `\` itself.
5537        let v = unquote_string("\"a\\\\b\"").unwrap();
5538        assert_eq!(&*v, "a\\b");
5539    }
5540
5541    #[test]
5542    fn unquote_string_missing_leading_dquote_rejects() {
5543        assert!(unquote_string("hello\"").is_none());
5544    }
5545
5546    #[test]
5547    fn unquote_string_missing_trailing_dquote_rejects() {
5548        assert!(unquote_string("\"hello").is_none());
5549    }
5550
5551    #[test]
5552    fn unquote_string_single_dquote_rejects() {
5553        // One byte cannot match the `DQUOTE *(...) DQUOTE` shape.
5554        assert!(unquote_string("\"").is_none());
5555    }
5556
5557    #[test]
5558    fn unquote_string_unwrapped_rejects() {
5559        assert!(unquote_string("hello").is_none());
5560    }
5561
5562    #[test]
5563    fn unquote_string_empty_rejects() {
5564        assert!(unquote_string("").is_none());
5565    }
5566
5567    #[test]
5568    fn unquote_string_trailing_lone_backslash_rejects() {
5569        // `\` with nothing after it cannot satisfy the
5570        // `quoted-pair = "\" ( … )` rule — there is no octet to
5571        // escape.
5572        assert!(unquote_string("\"abc\\\"").is_none());
5573    }
5574
5575    #[test]
5576    fn unquote_string_quoted_pair_with_cr_or_lf_rejects() {
5577        // `quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )` — CR
5578        // (0x0D) and LF (0x0A) are control bytes outside that RHS,
5579        // and a quoted-pair'd bare line-ending would unbalance the
5580        // field line at the framing layer.
5581        let cr = [b'"', b'a', b'\\', b'\r', b'b', b'"'];
5582        assert!(unquote_string(std::str::from_utf8(&cr).unwrap()).is_none());
5583        let lf = [b'"', b'a', b'\\', b'\n', b'b', b'"'];
5584        assert!(unquote_string(std::str::from_utf8(&lf).unwrap()).is_none());
5585    }
5586
5587    #[test]
5588    fn unquote_string_bare_qdtext_excluded_byte_rejects() {
5589        // `qdtext` excludes `"` (0x22) and `\` (0x5C). A bare `"`
5590        // inside the body without an escape would terminate the
5591        // string at the framing parser; our unwrap, given a slice
5592        // that includes the inner `"`, must refuse rather than
5593        // silently truncate.
5594        // We construct `"a"b"` — bytes 0x22 0x61 0x22 0x62 0x22 —
5595        // where the middle 0x22 is bare qdtext (excluded), so the
5596        // unwrap must refuse.
5597        assert!(unquote_string("\"a\"b\"").is_none());
5598        // Bare `\` (without a following octet pair) is the trailing
5599        // case covered above; bare `\` followed by a non-escapable
5600        // byte is the CR/LF case. There's no remaining bare-qdtext
5601        // path for `\` to reach this branch through.
5602    }
5603
5604    #[test]
5605    fn unquote_string_bare_control_byte_rejects() {
5606        // `qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text`.
5607        // Bare BEL (0x07) is below the SP/HTAB range and outside any
5608        // qdtext slot, so the body fails validation.
5609        let bel = [b'"', b'a', 0x07, b'b', b'"'];
5610        assert!(unquote_string(std::str::from_utf8(&bel).unwrap()).is_none());
5611    }
5612
5613    #[test]
5614    fn unquote_string_obs_text_byte_in_body_accepted() {
5615        // `qdtext` includes `obs-text = %x80-FF`. A literal high
5616        // byte is well-formed `qdtext` — the unwrap must accept it
5617        // and the decoded value must round-trip through valid UTF-8.
5618        // U+00E9 (é) is 0xC3 0xA9 — both bytes are in the obs-text
5619        // range, the resulting borrow is the same UTF-8 sequence.
5620        let v = unquote_string("\"\u{00e9}\"").unwrap();
5621        assert_eq!(&*v, "\u{00e9}");
5622        // No `\` present, so borrowing is required for the hot path.
5623        assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
5624    }
5625
5626    #[test]
5627    fn unquote_string_escape_preserving_obs_text_byte_accepted() {
5628        // `quoted-pair`'s RHS is `HTAB / SP / VCHAR / obs-text`, so
5629        // an escaped obs-text byte is permitted and must collapse to
5630        // that byte. We escape only the first byte of U+00E9 so the
5631        // pair RHS is exactly one obs-text octet (0xC3), then let
5632        // the trailing continuation byte (0xA9) fall through as
5633        // bare qdtext. The decoded UTF-8 is the same `é`.
5634        let inp: [u8; 5] = [b'"', b'\\', 0xC3, 0xA9, b'"'];
5635        let v = unquote_string(std::str::from_utf8(&inp).unwrap()).unwrap();
5636        assert_eq!(v.as_bytes(), &[0xC3, 0xA9]);
5637    }
5638
5639    #[test]
5640    fn unquote_string_only_unescapes_in_slow_path() {
5641        // Belt-and-braces invariant: an input that contains at least
5642        // one `\` must yield `Cow::Owned` even if the decoded value
5643        // happens to equal a substring of the input. This guards
5644        // against a future "optimisation" that aliased the body
5645        // through Cow::Borrowed when no character moved.
5646        let v = unquote_string("\"a\\bc\"").unwrap();
5647        assert_eq!(&*v, "abc");
5648        assert!(matches!(v, std::borrow::Cow::Owned(_)));
5649    }
5650
5651    #[test]
5652    fn unquote_string_decodes_used_in_media_type_parameter_rhs() {
5653        // §5.6.6 says `parameter-value = ( token / quoted-string )`.
5654        // A media type carrying `name="weird;value"` — where the
5655        // body contains the parameter delimiter `;` — must round
5656        // through the §5.6.4 unwrap before the consumer pattern-
5657        // matches on its contents. This is a coupling test: it
5658        // pins the §5.6.4 → §5.6.6 layering.
5659        let v = unquote_string("\"weird;value\"").unwrap();
5660        assert_eq!(&*v, "weird;value");
5661        // §8.3.1's `boundary="..."` parameter on multipart media
5662        // types is the most common in-the-wild caller of this
5663        // primitive; the unwrap must hand back the inner bytes
5664        // verbatim regardless of which bytes those are.
5665        let v = unquote_string("\"--my\\\"boundary--\"").unwrap();
5666        assert_eq!(&*v, "--my\"boundary--");
5667    }
5668
5669    // --- §5.6.5 comment -----------------------------------------------
5670
5671    #[test]
5672    fn parse_comment_empty_decodes_to_empty_borrowed() {
5673        // §5.6.5 `comment = "(" *( … ) ")"` — an empty comment `()`
5674        // is well-formed and its logical text is the empty string. No
5675        // escapes ⇒ borrowed.
5676        let v = parse_comment("()").unwrap();
5677        assert_eq!(&*v, "");
5678        assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
5679    }
5680
5681    #[test]
5682    fn parse_comment_escape_free_returns_cow_borrowed() {
5683        // A flat comment with only `ctext` returns a borrow of the
5684        // inner slice (zero allocation on the happy path).
5685        let v = parse_comment("(gzip is fine)").unwrap();
5686        assert_eq!(&*v, "gzip is fine");
5687        assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
5688    }
5689
5690    #[test]
5691    fn parse_comment_nested_preserves_inner_parens() {
5692        // §5.6.5 recursion: `(a (b) c)` is ONE comment whose logical
5693        // text is `a (b) c` — only the outermost parens are stripped;
5694        // the inner pair is part of the content.
5695        let v = parse_comment("(a (b) c)").unwrap();
5696        assert_eq!(&*v, "a (b) c");
5697        // Deeper nesting balances correctly.
5698        let v = parse_comment("(x ((y)) z)").unwrap();
5699        assert_eq!(&*v, "x ((y)) z");
5700    }
5701
5702    #[test]
5703    fn parse_comment_quoted_pair_collapsed_per_5_6_4() {
5704        // §5.6.5 admits `quoted-pair` inside a comment; §5.6.4's
5705        // `quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )` is
5706        // collapsed to the escaped octet. Here the escaped `(` and `)`
5707        // and `\` are literal text, not comment/escape syntax.
5708        let v = parse_comment("(a \\( b \\) c \\\\ d)").unwrap();
5709        assert_eq!(&*v, "a ( b ) c \\ d");
5710        assert!(matches!(v, std::borrow::Cow::Owned(_)));
5711    }
5712
5713    #[test]
5714    fn parse_comment_user_agent_shape() {
5715        // The most common in-the-wild §5.6.5 comment is the parenthical
5716        // in a `User-Agent` / `Server` `product *( RWS comment )` value.
5717        let v = parse_comment("(Macintosh; Intel Mac OS X 10_15)").unwrap();
5718        assert_eq!(&*v, "Macintosh; Intel Mac OS X 10_15");
5719    }
5720
5721    #[test]
5722    fn parse_comment_missing_outer_parens_rejected() {
5723        assert!(parse_comment("no parens").is_none());
5724        assert!(parse_comment("(unclosed").is_none());
5725        assert!(parse_comment("unopened)").is_none());
5726        // A single byte cannot carry both `(` and `)`.
5727        assert!(parse_comment("(").is_none());
5728        assert!(parse_comment(")").is_none());
5729        assert!(parse_comment("").is_none());
5730    }
5731
5732    #[test]
5733    fn parse_comment_unbalanced_parens_rejected() {
5734        // One extra opener: depth never returns to zero.
5735        assert!(parse_comment("(a (b)").is_none());
5736        // A top-level closer ends the comment early, leaving trailing
5737        // content after the matching `)`.
5738        assert!(parse_comment("(a) b)").is_none());
5739        assert!(parse_comment("(a)(b)").is_none());
5740    }
5741
5742    #[test]
5743    fn parse_comment_bare_control_byte_rejected() {
5744        // §5.6.5 `ctext` excludes the controls below HTAB/SP except the
5745        // two it lists (HTAB, SP). A bare CR / LF / NUL is not ctext.
5746        assert!(parse_comment("(a\rb)").is_none());
5747        assert!(parse_comment("(a\nb)").is_none());
5748        assert!(parse_comment("(a\u{0}b)").is_none());
5749        // HTAB and SP ARE ctext.
5750        assert_eq!(&*parse_comment("(a\tb c)").unwrap(), "a\tb c");
5751    }
5752
5753    #[test]
5754    fn parse_comment_dangling_or_illegal_quoted_pair_rejected() {
5755        // Trailing lone `\` with nothing to escape.
5756        assert!(parse_comment("(abc\\)").is_none());
5757        // `\` followed by a bare CR/LF is outside the §5.6.4
5758        // quoted-pair RHS (would unbalance the field line).
5759        assert!(parse_comment("(a\\\rb)").is_none());
5760        assert!(parse_comment("(a\\\nb)").is_none());
5761    }
5762
5763    #[test]
5764    fn parse_comment_obs_text_byte_accepted() {
5765        // §5.6.5 `ctext` admits obs-text (%x80-FF). A UTF-8 multibyte
5766        // sequence is all obs-text bytes and survives unescaped.
5767        let v = parse_comment("(café)").unwrap();
5768        assert_eq!(&*v, "café");
5769        // And as an escaped octet, the obs-text byte is emitted verbatim
5770        // (here the escaped multibyte forms valid UTF-8 in aggregate).
5771        let v = parse_comment("(a \\é b)").unwrap();
5772        assert_eq!(&*v, "a é b");
5773    }
5774
5775    // --- §5.6.6 parameters ---------------------------------------------
5776
5777    #[test]
5778    fn parse_parameters_empty_input_yields_no_entries() {
5779        // §5.6.6 `parameters = *( OWS ";" OWS [ parameter ] )` — zero
5780        // repetitions is the canonical empty case (a media type with
5781        // no attached parameters).
5782        assert!(parse_parameters("").is_empty());
5783    }
5784
5785    #[test]
5786    fn parse_parameters_whitespace_only_yields_no_entries() {
5787        // §5.6.3 OWS in any quantity must not produce phantom entries.
5788        assert!(parse_parameters("   \t  ").is_empty());
5789    }
5790
5791    #[test]
5792    fn parse_parameters_semicolon_only_yields_no_entries() {
5793        // §5.6.1 recipient requirement: "A recipient MUST parse and
5794        // ignore … empty list elements." A trail of bare semicolons is
5795        // the in-the-wild equivalent on the §5.6.6 parameter list.
5796        assert!(parse_parameters(";").is_empty());
5797        assert!(parse_parameters(";;;").is_empty());
5798        assert!(parse_parameters("; ; ;").is_empty());
5799    }
5800
5801    #[test]
5802    fn parse_parameters_single_token_value() {
5803        // Canonical `; name=token` from §5.6.6 example domain (e.g.
5804        // `Content-Type: text/plain; charset=utf-8` after the main
5805        // type/subtype has been split off).
5806        let p = parse_parameters("; charset=utf-8");
5807        assert_eq!(p, vec![("charset".to_owned(), "utf-8".to_owned())]);
5808    }
5809
5810    #[test]
5811    fn parse_parameters_leading_semicolon_optional() {
5812        // Whether the caller strips the leading `;` or hands it through,
5813        // the parser must produce the same output. Both shapes are
5814        // legal §5.6.6 tails.
5815        let with_semi = parse_parameters("; charset=utf-8");
5816        let without_semi = parse_parameters("charset=utf-8");
5817        assert_eq!(with_semi, without_semi);
5818    }
5819
5820    #[test]
5821    fn parse_parameters_name_lowercased_per_5_6_6_case_insensitivity() {
5822        // §5.6.6: "Parameter names are case-insensitive." We emit
5823        // lowercase so downstream pattern-matches use a stable form.
5824        let p = parse_parameters("; CHARSET=UTF-8");
5825        // Name lowercased; value preserved verbatim (case-sensitivity
5826        // of the value depends on the semantics of the name — §5.6.6).
5827        assert_eq!(p, vec![("charset".to_owned(), "UTF-8".to_owned())]);
5828    }
5829
5830    #[test]
5831    fn parse_parameters_quoted_string_value_unwrapped() {
5832        // `parameter-value = ( token / quoted-string )`. When the value
5833        // is a quoted-string the parser MUST unwrap it through §5.6.4
5834        // so a consumer sees the logical octet sequence.
5835        let p = parse_parameters("; boundary=\"--foo\"");
5836        assert_eq!(p, vec![("boundary".to_owned(), "--foo".to_owned())]);
5837    }
5838
5839    #[test]
5840    fn parse_parameters_quoted_pair_collapsed_per_5_6_4() {
5841        // A `\"` inside the body is a §5.6.4 quoted-pair and MUST
5842        // collapse to a single `"` in the decoded value. This is the
5843        // exact case the unquote helper exists for.
5844        let p = parse_parameters("; boundary=\"--my\\\"boundary--\"");
5845        assert_eq!(
5846            p,
5847            vec![("boundary".to_owned(), "--my\"boundary--".to_owned())],
5848        );
5849    }
5850
5851    #[test]
5852    fn parse_parameters_semicolon_inside_quoted_string_not_a_separator() {
5853        // The split-into-slots step MUST honour quoted-strings: a `;`
5854        // inside a `"…"` body is part of the value, not a list
5855        // terminator. Otherwise `boundary="a;b"` would be sliced into
5856        // `boundary="a` + `b"`, each of which would then fail to parse
5857        // and the legitimate value would silently disappear.
5858        let p = parse_parameters("; boundary=\"a;b\"");
5859        assert_eq!(p, vec![("boundary".to_owned(), "a;b".to_owned())]);
5860    }
5861
5862    #[test]
5863    fn parse_parameters_escaped_dquote_inside_quoted_string_not_a_close() {
5864        // A `\"` inside the body is a quoted-pair, NOT the closing
5865        // DQUOTE of the value. The splitter MUST respect that or the
5866        // remaining `;` would slice the value in two.
5867        let p = parse_parameters("; name=\"a\\\";b\"");
5868        assert_eq!(p, vec![("name".to_owned(), "a\";b".to_owned())]);
5869    }
5870
5871    #[test]
5872    fn parse_parameters_multiple_entries_preserved_in_order() {
5873        // §5.6.6 doesn't mandate an ordering for downstream consumers,
5874        // but our return Vec preserves input order so a caller that
5875        // wants "first wins" or "last wins" can implement either policy.
5876        let p = parse_parameters("; charset=utf-8; format=flowed; delsp=yes");
5877        assert_eq!(
5878            p,
5879            vec![
5880                ("charset".to_owned(), "utf-8".to_owned()),
5881                ("format".to_owned(), "flowed".to_owned()),
5882                ("delsp".to_owned(), "yes".to_owned()),
5883            ],
5884        );
5885    }
5886
5887    #[test]
5888    fn parse_parameters_empty_slot_silently_skipped() {
5889        // §5.6.1: "A recipient MUST parse and ignore a reasonable
5890        // number of empty list elements." Same applies to the §5.6.6
5891        // parameter list — an empty `; ; ` slot in the middle does
5892        // not nuke the surrounding good entries.
5893        let p = parse_parameters("; charset=utf-8; ; format=flowed");
5894        assert_eq!(
5895            p,
5896            vec![
5897                ("charset".to_owned(), "utf-8".to_owned()),
5898                ("format".to_owned(), "flowed".to_owned()),
5899            ],
5900        );
5901    }
5902
5903    #[test]
5904    fn parse_parameters_missing_equals_skipped() {
5905        // §5.6.6: `parameter = parameter-name "=" parameter-value`. The
5906        // `=` is a required production; a bare token slot is malformed
5907        // and we skip it rather than fabricate a value.
5908        let p = parse_parameters("; charset=utf-8; bogus; format=flowed");
5909        assert_eq!(
5910            p,
5911            vec![
5912                ("charset".to_owned(), "utf-8".to_owned()),
5913                ("format".to_owned(), "flowed".to_owned()),
5914            ],
5915        );
5916    }
5917
5918    #[test]
5919    fn parse_parameters_whitespace_around_equals_skipped() {
5920        // §5.6.6 informational note: "Parameters do not allow
5921        // whitespace (not even 'bad' whitespace) around the '='
5922        // character." Any SP / HTAB before or after `=` makes the
5923        // parameter ill-formed and we skip it. The legitimate
5924        // neighbours on either side remain.
5925        let p = parse_parameters("; a=1; b = 2; c=3");
5926        assert_eq!(
5927            p,
5928            vec![
5929                ("a".to_owned(), "1".to_owned()),
5930                ("c".to_owned(), "3".to_owned()),
5931            ],
5932        );
5933        // Also covers the "only-before" and "only-after" sub-cases.
5934        let only_before = parse_parameters("; b =2");
5935        assert!(only_before.is_empty());
5936        let only_after = parse_parameters("; b= 2");
5937        assert!(only_after.is_empty());
5938    }
5939
5940    #[test]
5941    fn parse_parameters_non_token_name_skipped() {
5942        // §5.6.6: `parameter-name = token`. A name byte outside the
5943        // §5.6.2 tchar set (e.g. SP inside what should be one token)
5944        // is ill-formed. The whole slot is skipped, the rest of the
5945        // list survives.
5946        let p = parse_parameters("; bad name=v; ok=v");
5947        assert_eq!(p, vec![("ok".to_owned(), "v".to_owned())]);
5948    }
5949
5950    #[test]
5951    fn parse_parameters_non_token_unquoted_value_skipped() {
5952        // §5.6.6: `parameter-value = ( token / quoted-string )`. An
5953        // unquoted value that includes a non-token byte (e.g. SP) is
5954        // neither shape and must be skipped — a sender that wanted
5955        // SP in the value should have quoted-string'd it.
5956        let p = parse_parameters("; bad=a b; ok=v");
5957        assert_eq!(p, vec![("ok".to_owned(), "v".to_owned())]);
5958    }
5959
5960    #[test]
5961    fn parse_parameters_malformed_quoted_string_value_skipped() {
5962        // An unterminated `"…` is not a valid §5.6.4 quoted-string,
5963        // so the §5.6.6 slot it occupies is not a valid `parameter`
5964        // and must be skipped — the value never silently truncates.
5965        let p = parse_parameters("; bad=\"unterminated; ok=v");
5966        // The splitter walks past the `;` inside the (open) quoted
5967        // span looking for the close DQUOTE, runs off the end, and
5968        // hands the whole tail to the per-slot parser which rejects
5969        // it. Net effect: zero entries.
5970        assert!(p.is_empty(), "got: {p:?}");
5971    }
5972
5973    #[test]
5974    fn parse_parameters_ows_around_semicolons_tolerated() {
5975        // §5.6.3 OWS is permitted on both sides of each `;` (the
5976        // `*( OWS ";" OWS [ parameter ] )` production). Tabs and
5977        // spaces in any quantity must not affect the parsed entries.
5978        let p = parse_parameters(" \t ;  \t a=1 \t ;\tb=2\t");
5979        assert_eq!(
5980            p,
5981            vec![
5982                ("a".to_owned(), "1".to_owned()),
5983                ("b".to_owned(), "2".to_owned()),
5984            ],
5985        );
5986    }
5987
5988    #[test]
5989    fn parse_parameters_quoted_value_with_obs_text_byte_preserved() {
5990        // §5.6.4 qdtext includes obs-text = %x80-FF. A multi-byte
5991        // UTF-8 sequence inside a quoted-string body must survive
5992        // unwrap intact — the helper's checked UTF-8 reconstruction
5993        // covers this end of the contract.
5994        let p = parse_parameters("; title=\"caf\u{00e9}\"");
5995        assert_eq!(p, vec![("title".to_owned(), "caf\u{00e9}".to_owned())]);
5996    }
5997
5998    #[test]
5999    fn parse_parameters_realm_auth_param_shape_handles_quoted_value() {
6000        // §11.4 `WWW-Authenticate` challenges carry `realm="…"` plus
6001        // other auth-params. Auth-params themselves are `,`-separated
6002        // (§11.2), not `;`-separated — so this §5.6.6 parser is the
6003        // wrong tool for a full challenge, but a caller that has
6004        // already split on `,` and is processing a single
6005        // `realm="…"` slot must get the value unwrapped cleanly.
6006        let p = parse_parameters("; realm=\"Protected Area\"");
6007        assert_eq!(p, vec![("realm".to_owned(), "Protected Area".to_owned())]);
6008    }
6009
6010    #[test]
6011    fn parse_parameters_comma_inside_value_is_not_a_separator() {
6012        // §5.6.6 makes `;` the slot terminator; `,` is not. A `,` in
6013        // an unquoted token-shape value would simply not be a valid
6014        // token byte (§5.6.2 tchar excludes `,`) and the whole slot
6015        // would be skipped. Confirm the splitter doesn't accidentally
6016        // treat `,` as a slot end (which would silently truncate the
6017        // value at the `,`).
6018        let p = parse_parameters("; bad=a,b");
6019        // The value `a,b` is not a §5.6.2 token (`,` is not tchar),
6020        // so the slot is rejected as a whole — net result: zero
6021        // entries. We are NOT silently keeping the prefix `a` and
6022        // dropping `,b`; either we accept the whole thing or we
6023        // skip the whole slot.
6024        assert!(p.is_empty(), "got: {p:?}");
6025    }
6026
6027    #[test]
6028    fn parse_parameters_token_value_with_dot_underscore_dash_accepted() {
6029        // §5.6.2 tchar includes `.` `_` `-` — values like `text/plain`
6030        // identifiers, MIME subtype tails, and protocol versions
6031        // (`http/1.1` would tokenise as `http/1.1` if `/` were a tchar,
6032        // which it isn't; but `1.1`, `my_thing`, `app-name` all do).
6033        let p = parse_parameters("; v=1.1; tag=my_thing; ua=app-name");
6034        assert_eq!(
6035            p,
6036            vec![
6037                ("v".to_owned(), "1.1".to_owned()),
6038                ("tag".to_owned(), "my_thing".to_owned()),
6039                ("ua".to_owned(), "app-name".to_owned()),
6040            ],
6041        );
6042    }
6043
6044    #[test]
6045    fn parse_parameters_coupling_with_unquote_string_layering() {
6046        // Coupling test: pin the §5.6.6 → §5.6.4 layering. A value
6047        // that round-trips through `unquote_string` directly MUST
6048        // match the same value pulled out of `parse_parameters`. This
6049        // catches a future change that fork-decodes the quoted-string
6050        // inside `parse_parameters` rather than delegating.
6051        let direct = unquote_string("\"--my\\\"boundary--\"").unwrap();
6052        let via_params = parse_parameters("; boundary=\"--my\\\"boundary--\"")
6053            .into_iter()
6054            .next()
6055            .unwrap()
6056            .1;
6057        assert_eq!(&*direct, via_params);
6058    }
6059
6060    // --- RFC 9110 §8.3.1 media-type (parse_media_type) ----------------
6061
6062    #[test]
6063    fn parse_media_type_bare_type_subtype_no_parameters() {
6064        // §8.3.1 `media-type = type "/" subtype parameters` with an
6065        // empty parameters tail.
6066        let (ty, sub, params) = parse_media_type("text/html").unwrap();
6067        assert_eq!(ty, "text");
6068        assert_eq!(sub, "html");
6069        assert!(params.is_empty());
6070    }
6071
6072    #[test]
6073    fn parse_media_type_lowercases_type_and_subtype_per_8_3_1() {
6074        // §8.3.1: "The type and subtype tokens are case-insensitive."
6075        // `Text/HTML` and `text/html` describe the same media type.
6076        let (ty, sub, _) = parse_media_type("Text/HTML").unwrap();
6077        assert_eq!(ty, "text");
6078        assert_eq!(sub, "html");
6079    }
6080
6081    #[test]
6082    fn parse_media_type_canonical_charset_example() {
6083        // §8.3.1 worked example: `text/html;charset=utf-8`.
6084        let (ty, sub, params) = parse_media_type("text/html;charset=utf-8").unwrap();
6085        assert_eq!(ty, "text");
6086        assert_eq!(sub, "html");
6087        assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
6088    }
6089
6090    #[test]
6091    fn parse_media_type_quoted_charset_example() {
6092        // §8.3.1 worked example: `text/html; charset="utf-8"` — the
6093        // quoted value unwraps to the same logical octets per §5.6.4,
6094        // and OWS after the `;` is tolerated by the §5.6.6 helper.
6095        let (ty, sub, params) = parse_media_type("text/html; charset=\"utf-8\"").unwrap();
6096        assert_eq!(ty, "text");
6097        assert_eq!(sub, "html");
6098        assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
6099    }
6100
6101    #[test]
6102    fn parse_media_type_does_not_case_fold_parameter_value() {
6103        // §8.3.1: "Parameter values might or might not be
6104        // case-sensitive." The helper preserves the value verbatim;
6105        // the §8.3.2 case-insensitive charset fold is the caller's job.
6106        let (_, _, params) = parse_media_type("text/html;charset=UTF-8").unwrap();
6107        assert_eq!(params, vec![("charset".to_owned(), "UTF-8".to_owned())]);
6108    }
6109
6110    #[test]
6111    fn parse_media_type_lowercases_parameter_name_per_5_6_6() {
6112        // §5.6.6: parameter-name is case-insensitive (the §5.6.6 helper
6113        // lowercases it); the §8.3.1 worked example `Charset` folds.
6114        let (_, _, params) = parse_media_type("Text/HTML;Charset=\"utf-8\"").unwrap();
6115        assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
6116    }
6117
6118    #[test]
6119    fn parse_media_type_multiple_parameters_preserved_in_order() {
6120        let (ty, sub, params) =
6121            parse_media_type("application/dash+xml; profiles=\"a,b\"; foo=bar").unwrap();
6122        assert_eq!(ty, "application");
6123        assert_eq!(sub, "dash+xml");
6124        assert_eq!(
6125            params,
6126            vec![
6127                ("profiles".to_owned(), "a,b".to_owned()),
6128                ("foo".to_owned(), "bar".to_owned()),
6129            ]
6130        );
6131    }
6132
6133    #[test]
6134    fn parse_media_type_ows_around_value_and_before_semicolon_tolerated() {
6135        // Leading/trailing OWS on the whole value is trimmed, and the
6136        // OWS between the subtype and the first `;` is part of the
6137        // §8.3.1 `parameters` `*( OWS ";" OWS … )` opener.
6138        let (ty, sub, params) =
6139            parse_media_type("  multipart/byteranges ; boundary=ABC  ").unwrap();
6140        assert_eq!(ty, "multipart");
6141        assert_eq!(sub, "byteranges");
6142        assert_eq!(params, vec![("boundary".to_owned(), "ABC".to_owned())]);
6143    }
6144
6145    #[test]
6146    fn parse_media_type_boundary_with_quoted_semicolon_preserved() {
6147        // The §5.6.6 splitter is quoted-string-aware: a `;` inside the
6148        // boundary value is part of the value, not a new slot.
6149        let (_, _, params) = parse_media_type("multipart/byteranges; boundary=\"a;b\"").unwrap();
6150        assert_eq!(params, vec![("boundary".to_owned(), "a;b".to_owned())]);
6151    }
6152
6153    #[test]
6154    fn parse_media_type_missing_slash_rejected() {
6155        // §8.3.1 requires `type "/" subtype` — no `/` is not a
6156        // media-type.
6157        assert!(parse_media_type("text").is_none());
6158        assert!(parse_media_type("text;charset=utf-8").is_none());
6159    }
6160
6161    #[test]
6162    fn parse_media_type_empty_type_or_subtype_rejected() {
6163        // `type` / `subtype` are `token`, and `is_token` rejects empty.
6164        assert!(parse_media_type("/html").is_none());
6165        assert!(parse_media_type("text/").is_none());
6166        assert!(parse_media_type("/").is_none());
6167    }
6168
6169    #[test]
6170    fn parse_media_type_non_token_type_or_subtype_rejected() {
6171        // A second `/` makes the subtype a non-`token` (`/` is not a
6172        // `tchar` per §5.6.2); a space inside either token is likewise
6173        // not a `tchar`.
6174        assert!(parse_media_type("text/ht/ml").is_none());
6175        assert!(parse_media_type("te xt/html").is_none());
6176        assert!(parse_media_type("text/ht ml").is_none());
6177    }
6178
6179    #[test]
6180    fn parse_media_type_empty_and_whitespace_input_rejected() {
6181        assert!(parse_media_type("").is_none());
6182        assert!(parse_media_type("   ").is_none());
6183        assert!(parse_media_type("\t").is_none());
6184    }
6185
6186    #[test]
6187    fn parse_media_type_garbage_parameter_slots_skipped_media_survives() {
6188        // The §5.6.6 helper's defensive posture flows through: a
6189        // garbage parameter slot is dropped, but the type/subtype and
6190        // the legitimate sibling parameter survive.
6191        let (ty, sub, params) = parse_media_type("text/plain; =novalue; charset=utf-8").unwrap();
6192        assert_eq!(ty, "text");
6193        assert_eq!(sub, "plain");
6194        assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
6195    }
6196
6197    #[test]
6198    fn parse_media_type_coupling_with_is_multipart_byteranges() {
6199        // Coupling test: the narrow §15.3.7.2 multipart predicate and
6200        // the general §8.3.1 parser must agree on the type/subtype of a
6201        // boundary-bearing multipart/byteranges value.
6202        let ct = "multipart/byteranges; boundary=END";
6203        assert!(is_multipart_byteranges_content_type(ct));
6204        let (ty, sub, _) = parse_media_type(ct).unwrap();
6205        assert_eq!(format!("{ty}/{sub}"), "multipart/byteranges");
6206    }
6207
6208    #[test]
6209    fn local_server_head_503_with_malformed_retry_after_surfaces_diagnostic() {
6210        // §10.2.3 grammar is strict — "soon" matches neither
6211        // delay-seconds nor any HTTP-date form. The hint must surface
6212        // the raw value + the §10.2.3 cite so the caller can see the
6213        // origin's bug rather than silently dropping the field.
6214        const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
6215            Content-Length: 0\r\n\
6216            Retry-After: soon\r\n\
6217            Connection: close\r\n\
6218            \r\n";
6219        let uri = spawn_head_only(HEAD);
6220        let err = match HttpSource::open(&uri) {
6221            Ok(_) => panic!("503 must error"),
6222            Err(e) => e,
6223        };
6224        let msg = format!("{err}");
6225        assert!(
6226            msg.contains("\"soon\"") && msg.contains("unparseable"),
6227            "malformed Retry-After must surface raw + diagnostic: {msg}"
6228        );
6229        assert!(msg.contains("§10.2.3"), "missing §10.2.3 cite: {msg}");
6230    }
6231
6232    // -- RFC 9111 §5.2 Cache-Control parser ----------------------------------
6233
6234    #[test]
6235    fn cache_control_empty_value_yields_default() {
6236        assert_eq!(parse_cache_control(""), CacheControl::default());
6237        assert_eq!(parse_cache_control("   "), CacheControl::default());
6238        // §5.6.1: empty list elements are skipped.
6239        assert_eq!(parse_cache_control(",,"), CacheControl::default());
6240    }
6241
6242    #[test]
6243    fn cache_control_boolean_directives_set_flags() {
6244        let cc = parse_cache_control(
6245            "no-store, no-transform, only-if-cached, must-revalidate, \
6246             must-understand, proxy-revalidate, public",
6247        );
6248        assert!(cc.no_store);
6249        assert!(cc.no_transform);
6250        assert!(cc.only_if_cached);
6251        assert!(cc.must_revalidate);
6252        assert!(cc.must_understand);
6253        assert!(cc.proxy_revalidate);
6254        assert!(cc.public);
6255        // No valued / qualified slots were touched.
6256        assert!(cc.max_age.is_none());
6257        assert!(cc.no_cache_fields.is_empty());
6258        assert!(cc.extensions.is_empty());
6259    }
6260
6261    #[test]
6262    fn cache_control_directive_names_are_case_insensitive() {
6263        // §5.2: "identified by a token, to be compared case-insensitively".
6264        let cc = parse_cache_control("No-Store, MAX-AGE=60, Public");
6265        assert!(cc.no_store);
6266        assert_eq!(cc.max_age, Some(60));
6267        assert!(cc.public);
6268    }
6269
6270    #[test]
6271    fn cache_control_max_age_token_argument() {
6272        // §5.2.2.1: token form `max-age=5`.
6273        let cc = parse_cache_control("max-age=300");
6274        assert_eq!(cc.max_age, Some(300));
6275        assert_eq!(parse_cache_control("max-age=0").max_age, Some(0));
6276    }
6277
6278    #[test]
6279    fn cache_control_s_maxage_and_min_fresh() {
6280        let cc = parse_cache_control("s-maxage=120, min-fresh=20");
6281        assert_eq!(cc.s_maxage, Some(120));
6282        assert_eq!(cc.min_fresh, Some(20));
6283    }
6284
6285    #[test]
6286    fn cache_control_delta_seconds_overflow_saturates() {
6287        // §1.2.2: a delta-seconds that overflows MUST clamp to 2^31,
6288        // never wrap or be treated as negative.
6289        let huge = "max-age=99999999999999999999999999999";
6290        assert_eq!(parse_cache_control(huge).max_age, Some(DELTA_SECONDS_MAX));
6291    }
6292
6293    #[test]
6294    fn cache_control_non_integer_delta_seconds_is_absent() {
6295        // §4.2.1: a directive "with non-integer content" makes the
6296        // response stale — we report the slot as absent rather than
6297        // coercing a garbage argument.
6298        assert!(parse_cache_control("max-age=abc").max_age.is_none());
6299        assert!(parse_cache_control("max-age=-5").max_age.is_none());
6300        assert!(parse_cache_control("max-age=").max_age.is_none());
6301        assert!(parse_cache_control("max-age").max_age.is_none());
6302    }
6303
6304    #[test]
6305    fn cache_control_delta_seconds_accepts_quoted_argument_on_receipt() {
6306        // §5.2: senders MUST use the token form, but "recipients ought to
6307        // accept both forms".
6308        assert_eq!(parse_cache_control("max-age=\"60\"").max_age, Some(60));
6309    }
6310
6311    #[test]
6312    fn cache_control_max_stale_no_arg_vs_valued() {
6313        // §5.2.1.2: bare max-stale accepts a stale response of any age.
6314        assert_eq!(parse_cache_control("max-stale").max_stale, Some(None));
6315        assert_eq!(
6316            parse_cache_control("max-stale=10").max_stale,
6317            Some(Some(10))
6318        );
6319        assert_eq!(parse_cache_control("").max_stale, None);
6320    }
6321
6322    #[test]
6323    fn cache_control_no_cache_unqualified_vs_qualified() {
6324        // §5.2.2.4: unqualified no-cache.
6325        let bare = parse_cache_control("no-cache");
6326        assert!(bare.no_cache);
6327        assert!(bare.no_cache_fields.is_empty());
6328        // Qualified form: quoted #field-name list, names lowercased.
6329        let qual = parse_cache_control("no-cache=\"Set-Cookie, X-Foo\"");
6330        assert!(!qual.no_cache);
6331        assert_eq!(qual.no_cache_fields, vec!["set-cookie", "x-foo"]);
6332    }
6333
6334    #[test]
6335    fn cache_control_private_unqualified_vs_qualified() {
6336        // §5.2.2.7.
6337        assert!(parse_cache_control("private").private);
6338        let qual = parse_cache_control("private=\"Authorization\"");
6339        assert!(!qual.private);
6340        assert_eq!(qual.private_fields, vec!["authorization"]);
6341    }
6342
6343    #[test]
6344    fn cache_control_quoted_comma_does_not_split_directive() {
6345        // The comma inside the quoted #field-name argument must not end
6346        // the no-cache directive and start a new one.
6347        let cc = parse_cache_control("no-cache=\"a, b\", max-age=5");
6348        assert_eq!(cc.no_cache_fields, vec!["a", "b"]);
6349        assert_eq!(cc.max_age, Some(5));
6350    }
6351
6352    #[test]
6353    fn cache_control_unknown_directive_preserved_as_extension() {
6354        // §5.2.3: ignore unrecognized directives — preserved here so an
6355        // extension consumer can still inspect them.
6356        let cc = parse_cache_control("immutable, community=\"UCI\", max-age=1");
6357        assert_eq!(cc.max_age, Some(1));
6358        assert_eq!(
6359            cc.extensions,
6360            vec![
6361                ("immutable".to_owned(), None),
6362                ("community".to_owned(), Some("UCI".to_owned())),
6363            ]
6364        );
6365    }
6366
6367    #[test]
6368    fn cache_control_duplicate_valued_directive_keeps_first() {
6369        // §4.2.1: first occurrence is used for a repeated valued directive.
6370        assert_eq!(
6371            parse_cache_control("max-age=10, max-age=99").max_age,
6372            Some(10)
6373        );
6374        assert_eq!(
6375            parse_cache_control("max-stale=1, max-stale=2").max_stale,
6376            Some(Some(1))
6377        );
6378    }
6379
6380    #[test]
6381    fn cache_control_ows_around_elements_tolerated() {
6382        // §5.6.3 OWS around each #-list element.
6383        let cc = parse_cache_control("  no-store ,  max-age=7  ");
6384        assert!(cc.no_store);
6385        assert_eq!(cc.max_age, Some(7));
6386    }
6387
6388    #[test]
6389    fn cache_control_obs_fold_normalised_before_split() {
6390        // RFC 7230 §3.2.4: an obs-fold becomes SP before list splitting.
6391        let cc = parse_cache_control("max-age=30,\r\n no-cache");
6392        assert_eq!(cc.max_age, Some(30));
6393        assert!(cc.no_cache);
6394    }
6395
6396    #[test]
6397    fn cache_control_malformed_element_skipped_not_fatal() {
6398        // A non-token name and a bare `=value` element are skipped; the
6399        // surrounding good directives still parse.
6400        let cc = parse_cache_control("no-store, =bad, ba()d=x, public");
6401        assert!(cc.no_store);
6402        assert!(cc.public);
6403        assert!(cc.extensions.is_empty());
6404    }
6405
6406    #[test]
6407    fn parse_delta_seconds_boundary() {
6408        assert_eq!(parse_delta_seconds("0"), Some(0));
6409        assert_eq!(parse_delta_seconds("2147483648"), Some(DELTA_SECONDS_MAX));
6410        assert_eq!(parse_delta_seconds(""), None);
6411        assert_eq!(parse_delta_seconds("+1"), None);
6412        assert_eq!(parse_delta_seconds("1 "), None);
6413    }
6414
6415    // ----------------------------------------------------------------
6416    // RFC 9110 §11.6.1 WWW-Authenticate challenge-list parser.
6417    // ----------------------------------------------------------------
6418
6419    fn ch(scheme: &str, params: &[(&str, &str)]) -> Challenge {
6420        Challenge {
6421            scheme: scheme.to_owned(),
6422            token68: None,
6423            params: params
6424                .iter()
6425                .map(|(n, v)| (n.to_string(), v.to_string()))
6426                .collect(),
6427        }
6428    }
6429
6430    #[test]
6431    fn www_auth_empty_yields_no_challenges() {
6432        assert!(parse_www_authenticate("").is_empty());
6433        assert!(parse_www_authenticate("   ").is_empty());
6434        // The §11.6.1 "comma, whitespace, comma" harmless-empty note.
6435        assert!(parse_www_authenticate(", ,").is_empty());
6436    }
6437
6438    #[test]
6439    fn www_auth_bare_scheme_no_args() {
6440        assert_eq!(
6441            parse_www_authenticate("Negotiate"),
6442            vec![ch("negotiate", &[])]
6443        );
6444    }
6445
6446    #[test]
6447    fn www_auth_scheme_lowercased_case_insensitive() {
6448        // §11.1: auth-scheme is a case-insensitive token.
6449        assert_eq!(
6450            parse_www_authenticate("BASIC realm=\"x\""),
6451            vec![ch("basic", &[("realm", "x")])]
6452        );
6453    }
6454
6455    #[test]
6456    fn www_auth_single_challenge_one_param() {
6457        assert_eq!(
6458            parse_www_authenticate("Basic realm=\"simple\""),
6459            vec![ch("basic", &[("realm", "simple")])]
6460        );
6461    }
6462
6463    #[test]
6464    fn www_auth_token68_form() {
6465        // §11.2: token68 carries a base64-ish blob (here with `=` pad).
6466        let got = parse_www_authenticate("Negotiate a87421bK3m");
6467        assert_eq!(got.len(), 1);
6468        assert_eq!(got[0].scheme, "negotiate");
6469        assert_eq!(got[0].token68.as_deref(), Some("a87421bK3m"));
6470        assert!(got[0].params.is_empty());
6471    }
6472
6473    #[test]
6474    fn www_auth_token68_with_trailing_pad() {
6475        let got = parse_www_authenticate("Negotiate TlRMTVNTUAACAAAA==");
6476        assert_eq!(got.len(), 1);
6477        assert_eq!(got[0].token68.as_deref(), Some("TlRMTVNTUAACAAAA=="));
6478    }
6479
6480    #[test]
6481    fn www_auth_rfc_worked_example_two_challenges() {
6482        // The canonical §11.6.1 example: two challenges, the second with
6483        // three auth-params including a quoted-pair-escaped DQUOTE.
6484        let got = parse_www_authenticate(
6485            "Basic realm=\"simple\", Newauth realm=\"apps\", type=1, title=\"Login to \\\"apps\\\"\"",
6486        );
6487        assert_eq!(
6488            got,
6489            vec![
6490                ch("basic", &[("realm", "simple")]),
6491                ch(
6492                    "newauth",
6493                    &[
6494                        ("realm", "apps"),
6495                        ("type", "1"),
6496                        ("title", "Login to \"apps\""),
6497                    ],
6498                ),
6499            ]
6500        );
6501    }
6502
6503    #[test]
6504    fn www_auth_param_name_lowercased_value_case_preserved() {
6505        // §11.2: param names case-insensitive (lowercased); value case is
6506        // scheme-specific so it is preserved verbatim.
6507        let got = parse_www_authenticate("Digest Realm=\"MixedCase\"");
6508        assert_eq!(got, vec![ch("digest", &[("realm", "MixedCase")])]);
6509    }
6510
6511    #[test]
6512    fn www_auth_bws_around_equals_tolerated() {
6513        // §11.2: auth-param permits BWS on both sides of `=`, unlike the
6514        // §5.6.6 parameter production.
6515        assert_eq!(
6516            parse_www_authenticate("Basic realm = \"x\""),
6517            vec![ch("basic", &[("realm", "x")])]
6518        );
6519    }
6520
6521    #[test]
6522    fn www_auth_token_value_form() {
6523        // auth-param value may be a bare token (here `type=1`).
6524        assert_eq!(
6525            parse_www_authenticate("Newauth type=1"),
6526            vec![ch("newauth", &[("type", "1")])]
6527        );
6528    }
6529
6530    #[test]
6531    fn www_auth_quoted_comma_does_not_split() {
6532        // A comma inside a quoted-string value is part of the value, not a
6533        // list delimiter.
6534        assert_eq!(
6535            parse_www_authenticate("Basic realm=\"a, b\""),
6536            vec![ch("basic", &[("realm", "a, b")])]
6537        );
6538    }
6539
6540    #[test]
6541    fn www_auth_two_basic_realms_distinct_challenges() {
6542        // §11.5: a response can carry multiple challenges with the same
6543        // scheme but different realms.
6544        assert_eq!(
6545            parse_www_authenticate("Basic realm=\"a\", Basic realm=\"b\""),
6546            vec![
6547                ch("basic", &[("realm", "a")]),
6548                ch("basic", &[("realm", "b")]),
6549            ]
6550        );
6551    }
6552
6553    #[test]
6554    fn www_auth_malformed_param_skipped_challenge_survives() {
6555        // A param slot that is not token=(token/quoted-string) is dropped;
6556        // the challenge and its good sibling params survive.
6557        assert_eq!(
6558            parse_www_authenticate("Digest realm=\"r\", bad(), nonce=\"n\""),
6559            vec![ch("digest", &[("realm", "r"), ("nonce", "n")])]
6560        );
6561    }
6562
6563    #[test]
6564    fn www_auth_unterminated_quoted_string_param_dropped() {
6565        // An unterminated quoted-string value cannot be a slot delimiter
6566        // boundary either; the splitter keeps it as one element and the
6567        // per-slot parser drops the malformed value.
6568        let got = parse_www_authenticate("Basic realm=\"oops");
6569        assert_eq!(got, vec![ch("basic", &[])]);
6570    }
6571
6572    #[test]
6573    fn www_auth_leading_bare_param_with_no_challenge_dropped() {
6574        // A bare auth-param with no preceding challenge has nothing to
6575        // attach to; it is dropped.
6576        assert!(parse_www_authenticate("realm=\"orphan\"").is_empty());
6577    }
6578
6579    #[test]
6580    fn www_auth_param_after_token68_challenge_dropped() {
6581        // §11.3 mutual exclusivity: a challenge that committed to token68
6582        // cannot also take auth-params. The stray param is dropped.
6583        let got = parse_www_authenticate("Negotiate abc123, realm=\"x\"");
6584        assert_eq!(got.len(), 1);
6585        assert_eq!(got[0].token68.as_deref(), Some("abc123"));
6586        assert!(got[0].params.is_empty());
6587    }
6588
6589    #[test]
6590    fn www_auth_obs_fold_normalised_before_parse() {
6591        // RFC 7230 §3.2.4: an obs-fold becomes SP before list splitting.
6592        assert_eq!(
6593            parse_www_authenticate("Basic realm=\"a\",\r\n Digest realm=\"b\""),
6594            vec![
6595                ch("basic", &[("realm", "a")]),
6596                ch("digest", &[("realm", "b")]),
6597            ]
6598        );
6599    }
6600
6601    #[test]
6602    fn www_auth_scheme_then_first_param_then_more() {
6603        // Scheme + first auth-param on the head element, then trailing
6604        // auth-params attach to that same challenge.
6605        assert_eq!(
6606            parse_www_authenticate("Digest realm=\"r\", qop=\"auth\", nonce=\"n\""),
6607            vec![ch(
6608                "digest",
6609                &[("realm", "r"), ("qop", "auth"), ("nonce", "n")]
6610            )]
6611        );
6612    }
6613
6614    #[test]
6615    fn www_auth_token68_not_confused_with_param() {
6616        // A `name=value`-shaped first argument is an auth-param, never a
6617        // token68 (the §11.6.1 ambiguity resolves toward auth-param).
6618        let got = parse_www_authenticate("Custom foo=bar");
6619        assert_eq!(got, vec![ch("custom", &[("foo", "bar")])]);
6620    }
6621
6622    #[test]
6623    fn as_token68_recogniser() {
6624        assert_eq!(as_token68("abcXYZ09-._~+/"), Some("abcXYZ09-._~+/"));
6625        assert_eq!(as_token68("abc=="), Some("abc=="));
6626        assert_eq!(as_token68("=abc"), None); // pad-before-body
6627        assert_eq!(as_token68("ab=cd"), None); // interior `=` then body
6628        assert_eq!(as_token68(""), None);
6629        assert_eq!(as_token68("a b"), None); // whitespace excluded
6630    }
6631
6632    #[test]
6633    fn parse_one_auth_param_shapes() {
6634        assert_eq!(
6635            parse_one_auth_param("Realm=\"x\""),
6636            Some(("realm".to_string(), "x".to_string()))
6637        );
6638        assert_eq!(
6639            parse_one_auth_param("type = 1"),
6640            Some(("type".to_string(), "1".to_string()))
6641        );
6642        assert_eq!(parse_one_auth_param("noequals"), None);
6643        assert_eq!(parse_one_auth_param("=novalue"), None);
6644        assert_eq!(parse_one_auth_param("bad()=x"), None);
6645        assert_eq!(parse_one_auth_param("k=\"unterminated"), None);
6646    }
6647}