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        let head = head_agent
396            .head(uri)
397            .call()
398            .map_err(|e| Error::other(format!("HTTP HEAD {uri}: {e}")))?;
399
400        let status = head.status();
401        if !status.is_success() {
402            return Err(Error::other(format!("HTTP HEAD {uri}: status {status}")));
403        }
404        let headers = head.headers();
405        let total_len = headers
406            .get("content-length")
407            .and_then(|v| v.to_str().ok())
408            .and_then(|s| s.parse::<u64>().ok())
409            .ok_or_else(|| {
410                Error::Unsupported(format!("HTTP HEAD {uri}: missing Content-Length"))
411            })?;
412        let accept_ranges = headers
413            .get("accept-ranges")
414            .and_then(|v| v.to_str().ok())
415            .unwrap_or("");
416        if !accept_ranges.eq_ignore_ascii_case("bytes") {
417            return Err(Error::Unsupported(format!(
418                "HTTP HEAD {uri}: server does not advertise byte ranges (Accept-Ranges: '{accept_ranges}')"
419            )));
420        }
421        // Capture a STRONG validator per RFC 9110 §13.1.5 so subsequent
422        // range GETs can carry `If-Range: <validator>` and surface a
423        // mid-stream mutation cleanly. ETag takes precedence (§8.8.3
424        // is "more reliable for validation than a modification date"
425        // and the strong/weak distinction is grammatical); fall back to
426        // Last-Modified only when §8.8.2.2's "Date - Last-Modified >= 1s"
427        // rule promotes it from implicitly-weak to strong.
428        let etag_raw = headers
429            .get("etag")
430            .and_then(|v| v.to_str().ok())
431            .map(str::trim);
432        let last_modified = headers
433            .get("last-modified")
434            .and_then(|v| v.to_str().ok())
435            .map(str::trim);
436        let date_hdr = headers
437            .get("date")
438            .and_then(|v| v.to_str().ok())
439            .map(str::trim);
440        let validator = derive_strong_validator(etag_raw, last_modified, date_hdr);
441        Ok(Self {
442            uri: uri.to_owned(),
443            total_len,
444            pos: 0,
445            agent: scoped,
446            validator,
447            body: None,
448        })
449    }
450
451    pub fn len(&self) -> u64 {
452        self.total_len
453    }
454
455    pub fn is_empty(&self) -> bool {
456        self.total_len == 0
457    }
458
459    fn agent_ref(&self) -> &Agent {
460        self.agent.as_ref().unwrap_or_else(|| agent())
461    }
462
463    fn issue_range(&mut self) -> io::Result<()> {
464        if self.pos >= self.total_len {
465            self.body = None;
466            return Ok(());
467        }
468        let range = format!("bytes={}-", self.pos);
469        // RFC 9110 §13.1.5: when we have a strong validator from HEAD,
470        // attach `If-Range: <validator>` so a mid-stream mutation flips
471        // the response from 206 to 200 (the §13.1.5 short-circuit) and
472        // we can surface it as a hard error below rather than silently
473        // re-anchor the byte offset against a different representation.
474        let if_range = self.validator.as_ref().map(|v| v.as_if_range().to_owned());
475        let sent_if_range = if_range.is_some();
476        let mut req = self.agent_ref().get(&self.uri).header("Range", &range);
477        if let Some(ref v) = if_range {
478            req = req.header("If-Range", v.as_str());
479        }
480        let resp = req
481            .call()
482            .map_err(|e| io::Error::other(format!("HTTP GET {} {}: {e}", self.uri, range)))?;
483        let status = resp.status();
484        // RFC 9110 §15.5.17: a 416 (Range Not Satisfiable) means the
485        // server has rejected our requested range. §14.4 SHOULDs a
486        // `Content-Range: bytes */<complete-length>` header in this
487        // case, naming the server's current authoritative resource
488        // length. We surface that length so the caller can distinguish
489        // "past EOF" from "resource shrank mid-stream" (the HEAD said N
490        // and a later GET reports M < self.pos).
491        if status == 416 {
492            let cr_raw = resp
493                .headers()
494                .get("content-range")
495                .and_then(|v| v.to_str().ok())
496                .map(str::to_owned);
497            if let Some(cr) = cr_raw.as_deref() {
498                match parse_byte_unsatisfied_range(cr) {
499                    Ok(complete) => {
500                        return Err(io::Error::other(format!(
501                            "HTTP 416 {} {}: server reports complete-length {complete} (HEAD observed {}, requested pos {})",
502                            self.uri, range, self.total_len, self.pos
503                        )));
504                    }
505                    Err(e) => {
506                        return Err(io::Error::other(format!(
507                            "HTTP 416 {} {}: invalid Content-Range '{cr}': {e}",
508                            self.uri, range
509                        )));
510                    }
511                }
512            }
513            // §14.4 SHOULD, not MUST — a 416 with no Content-Range is
514            // unusual but legal. Still treat it as a hard error since
515            // the read can't proceed; just say so plainly.
516            return Err(io::Error::other(format!(
517                "HTTP 416 {} {}: server rejected range (no Content-Range body)",
518                self.uri, range
519            )));
520        }
521        if !(status == 206 || status == 200) {
522            return Err(io::Error::other(format!(
523                "HTTP GET {} {}: status {status}",
524                self.uri, range
525            )));
526        }
527        // RFC 9110 §13.1.5: when we sent `If-Range`, a 200 means the
528        // server's current validator did NOT match ours — i.e. the
529        // representation has been replaced since HEAD. Silently
530        // resuming on the new bytes would re-anchor every later
531        // file-offset view against a different resource; that's a
532        // misalignment bug, not a soft fallback. Surface as fatal.
533        if status == 200 && sent_if_range {
534            return Err(io::Error::other(format!(
535                "HTTP 200 {} {}: If-Range validator did not match — \
536                 representation changed since HEAD (origin/cache mutation)",
537                self.uri, range
538            )));
539        }
540        let pos = self.pos;
541        let total_len = self.total_len;
542        // RFC 9110 §8.6: "a server MUST NOT send Content-Length in [a
543        // HEAD] response unless its field value equals the decimal
544        // number of octets that would have been sent in the content of
545        // a response if the same request had used the GET method."
546        // So a 200-fallback (full-body GET, §3.1) whose Content-Length
547        // contradicts the HEAD-observed total is a resource resize we
548        // cannot recover from in-band — surface as a hard error rather
549        // than drain a now-wrong-sized prefix and read short.
550        let get_content_length = resp
551            .headers()
552            .get("content-length")
553            .and_then(|v| v.to_str().ok())
554            .and_then(|s| s.parse::<u64>().ok());
555        // Per RFC 7233 §3.1, a server that does not support (or chooses
556        // to ignore) Range MAY answer a range request with a full 200
557        // response. In that case we must drop the prefix [0, self.pos)
558        // before exposing bytes to the reader so the demuxer keeps a
559        // consistent file-offset view. We only walk this branch when
560        // we did NOT send If-Range; a 200 with If-Range is the
561        // mid-stream-mutation case handled above.
562        let skip_prefix = if status == 200 { pos } else { 0 };
563        if status == 200 {
564            if let Some(cl) = get_content_length {
565                if cl != total_len {
566                    return Err(io::Error::other(format!(
567                        "HTTP 200 {} {}: Content-Length {cl} != HEAD-observed total {total_len} \
568                         (RFC 9110 §8.6 — resource resized between HEAD and GET)",
569                        self.uri, range
570                    )));
571                }
572            }
573        }
574        if status == 206 {
575            // RFC 7233 §4.2: validate the server's Content-Range echo
576            // covers what we asked for and is self-consistent. A 206
577            // without Content-Range, or with a multipart/byteranges
578            // (handled at the body layer, not via Content-Range), or
579            // with a different first-byte-pos than self.pos, would
580            // silently misalign every subsequent read.
581            let cr_raw = resp
582                .headers()
583                .get("content-range")
584                .ok_or_else(|| {
585                    io::Error::other(format!(
586                        "HTTP 206 {} {}: missing Content-Range",
587                        self.uri, range
588                    ))
589                })?
590                .to_str()
591                .map_err(|_| {
592                    io::Error::other(format!(
593                        "HTTP 206 {} {}: non-ASCII Content-Range",
594                        self.uri, range
595                    ))
596                })?
597                .to_owned();
598            let parsed = parse_byte_content_range(&cr_raw).map_err(|e| {
599                io::Error::other(format!(
600                    "HTTP 206 {} {}: invalid Content-Range '{cr_raw}': {e}",
601                    self.uri, range
602                ))
603            })?;
604            // first-byte-pos MUST match the position we asked for. The
605            // server is allowed to satisfy a partial subrange, but
606            // not to slide the start.
607            if parsed.first != pos {
608                return Err(io::Error::other(format!(
609                    "HTTP 206 {} {}: Content-Range first-byte-pos {} != requested pos {}",
610                    self.uri, range, parsed.first, pos
611                )));
612            }
613            // complete-length, when concrete, must equal the size we
614            // recorded at HEAD. A mid-stream resource resize is a
615            // cache/origin mismatch we cannot recover from in-band.
616            if let Some(complete) = parsed.complete {
617                if complete != total_len {
618                    return Err(io::Error::other(format!(
619                        "HTTP 206 {} {}: Content-Range complete-length {complete} != known total {total_len}",
620                        self.uri, range
621                    )));
622                }
623            }
624            // last-byte-pos must lie inside the representation we
625            // expect.
626            if parsed.last >= total_len {
627                return Err(io::Error::other(format!(
628                    "HTTP 206 {} {}: Content-Range last-byte-pos {} >= total {}",
629                    self.uri, range, parsed.last, total_len
630                )));
631            }
632            // RFC 9110 §8.6: when a 206 carries Content-Length, it MUST
633            // be the byte count of the body actually being sent — i.e.
634            // for a single-range 206 (the only form we ask for), it
635            // equals `last - first + 1`. A mismatch is either a
636            // multipart/byteranges body (we never request multi-range,
637            // and Content-Type would be `multipart/byteranges` not the
638            // raw representation type) or an outright framing bug;
639            // either way the demuxer's byte-offset view will drift if
640            // we proceed.
641            if let Some(cl) = get_content_length {
642                let expected = parsed.last - parsed.first + 1;
643                if cl != expected {
644                    return Err(io::Error::other(format!(
645                        "HTTP 206 {} {}: Content-Length {cl} != Content-Range span {expected} \
646                         (RFC 9110 §8.6)",
647                        self.uri, range
648                    )));
649                }
650            }
651        }
652        // ureq 3: Body owns the stream; into_body().into_reader() yields
653        // a `Read` that pulls from the wire as bytes are requested.
654        let mut reader: Box<dyn Read + Send> = Box::new(resp.into_body().into_reader());
655        if skip_prefix > 0 {
656            // Drain the prefix in 8 KiB chunks rather than allocating
657            // a single huge buffer for very large seek offsets.
658            let mut remaining = skip_prefix;
659            let mut buf = [0u8; 8 * 1024];
660            while remaining > 0 {
661                let want = remaining.min(buf.len() as u64) as usize;
662                let n = reader.read(&mut buf[..want])?;
663                if n == 0 {
664                    return Err(io::Error::new(
665                        io::ErrorKind::UnexpectedEof,
666                        format!(
667                            "HTTP 200 {}: EOF after draining {} of {skip_prefix} prefix bytes",
668                            self.uri,
669                            skip_prefix - remaining
670                        ),
671                    ));
672                }
673                remaining -= n as u64;
674            }
675        }
676        self.body = Some(reader);
677        Ok(())
678    }
679}
680
681/// Parsed `Content-Range: bytes <first>-<last>/<complete-or-*>`.
682///
683/// Public-but-internal: lives in the crate so the validator and the
684/// unit tests can share one parser. We deliberately do NOT export it —
685/// any future surface change shouldn't drag this representation into
686/// the public API.
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688struct ByteContentRange {
689    first: u64,
690    last: u64,
691    /// `None` when the server emitted `*` for complete-length.
692    complete: Option<u64>,
693}
694
695/// Parse a `Content-Range` field value per RFC 7233 §4.2
696/// (`bytes <first>-<last>/<complete|*>`), enforcing the §4.2 validity
697/// rules on the spot:
698///
699/// * range unit MUST be `bytes` (case-insensitive — RFC 7230 §3.2.6
700///   tokens are case-insensitive; ABNF in §4.2 happens to use the
701///   lowercase literal).
702/// * `last >= first` (otherwise §4.2 "byte-range-resp ... last-byte-pos
703///   value less than its first-byte-pos" is invalid).
704/// * `complete > last` (§4.2 "complete-length value less than or equal
705///   to its last-byte-pos" is invalid).
706///
707/// Unsatisfied-range (`bytes */N`) is intentionally rejected here — it
708/// is a 416 payload, never a 206 payload, so its arrival on a 206 is
709/// itself invalid.
710fn parse_byte_content_range(s: &str) -> std::result::Result<ByteContentRange, &'static str> {
711    let s = s.trim();
712    // unit SP byte-range-resp
713    let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
714    if !unit.eq_ignore_ascii_case("bytes") {
715        return Err("range unit is not 'bytes'");
716    }
717    let rest = rest.trim_start();
718    // byte-range-resp = first "-" last "/" (complete / "*")
719    // unsatisfied-range = "*/" complete  — rejected for 206.
720    if rest.starts_with('*') {
721        return Err("unsatisfied-range ('*/N') not valid on 206");
722    }
723    let (range_part, complete_part) = rest
724        .split_once('/')
725        .ok_or("missing '/' before complete-length")?;
726    let (first_s, last_s) = range_part
727        .split_once('-')
728        .ok_or("missing '-' between first-byte-pos and last-byte-pos")?;
729    let first: u64 = first_s.parse().map_err(|_| "first-byte-pos is not a u64")?;
730    let last: u64 = last_s.parse().map_err(|_| "last-byte-pos is not a u64")?;
731    if last < first {
732        return Err("last-byte-pos < first-byte-pos");
733    }
734    let complete = if complete_part == "*" {
735        None
736    } else {
737        let c: u64 = complete_part
738            .parse()
739            .map_err(|_| "complete-length is not a u64 or '*'")?;
740        if c <= last {
741            return Err("complete-length <= last-byte-pos");
742        }
743        Some(c)
744    };
745    Ok(ByteContentRange {
746        first,
747        last,
748        complete,
749    })
750}
751
752/// Parse a `Content-Range: bytes */<complete-length>` field value per
753/// RFC 9110 §14.4 — the *unsatisfied-range* form a server SHOULD return
754/// alongside a 416 (Range Not Satisfiable) response (§15.5.17).
755///
756/// Returns the server's authoritative complete-length on success. The
757/// 416 status itself tells the caller the range was rejected; this
758/// parser only extracts the length value so the caller can compare it
759/// against what was observed at HEAD construction and tell whether the
760/// rejection is "past EOF" or "resource shrank mid-stream."
761///
762/// Rejects the canonical `range-resp` form (`bytes first-last/complete`)
763/// — a 416 body is the unsatisfied-range form per §14.4, never a
764/// range-resp.
765fn parse_byte_unsatisfied_range(s: &str) -> std::result::Result<u64, &'static str> {
766    let s = s.trim();
767    let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
768    if !unit.eq_ignore_ascii_case("bytes") {
769        return Err("range unit is not 'bytes'");
770    }
771    let rest = rest.trim_start();
772    let complete_part = rest
773        .strip_prefix("*/")
774        .ok_or("not an unsatisfied-range ('*/N' expected)")?;
775    let complete: u64 = complete_part
776        .parse()
777        .map_err(|_| "complete-length is not a u64")?;
778    Ok(complete)
779}
780
781/// Parse an `ETag` field value per RFC 9110 §8.8.3 grammar:
782///
783/// ```text
784/// entity-tag = [ weak ] opaque-tag
785/// weak       = %s"W/"
786/// opaque-tag = DQUOTE *etagc DQUOTE
787/// etagc      = %x21 / %x23-7E / obs-text
788/// ```
789///
790/// Returns `Some((is_weak, full_wire_form))` on a syntactically valid
791/// entity-tag, `None` on anything else (an unset or malformed value
792/// must not surface as a usable validator).
793///
794/// `full_wire_form` is the input including the surrounding DQUOTEs and
795/// the `W/` prefix if any — what we'd echo back in `If-Range`.
796fn parse_entity_tag(s: &str) -> Option<(bool, String)> {
797    let s = s.trim();
798    // `weak` is case-sensitive `W/` per §8.8.3 (`%s"W/"`).
799    let (is_weak, body) = if let Some(rest) = s.strip_prefix("W/") {
800        (true, rest)
801    } else {
802        (false, s)
803    };
804    let body = body.strip_prefix('"')?;
805    let body = body.strip_suffix('"')?;
806    // §8.8.3 etagc = %x21 / %x23-7E / obs-text. We accept obs-text
807    // (%x80-FF) too, since RFC 9110 §5.5 permits it as a deprecated
808    // historical byte class.
809    for b in body.bytes() {
810        let ok = b == 0x21 || (0x23..=0x7E).contains(&b) || b >= 0x80;
811        if !ok {
812            return None;
813        }
814    }
815    Some((is_weak, s.to_owned()))
816}
817
818/// Parse an HTTP-date per RFC 9110 §5.6.7 / §IMF-fixdate into a
819/// (year, month, day, hour, minute, second) tuple. Only IMF-fixdate
820/// (`Sun, 06 Nov 1994 08:49:37 GMT`) is supported — §5.6.7 says senders
821/// MUST emit IMF-fixdate; the obsolete `rfc850` and `asctime` forms are
822/// "MUST accept" on the receiving side but we use this parser only for
823/// our own strong-validator promotion test, where IMF-fixdate is what
824/// every modern origin emits. A None means "couldn't promote to strong"
825/// which is the safe outcome.
826fn parse_imf_fixdate(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
827    // Expect: "Wkd, DD Mon YYYY HH:MM:SS GMT"
828    let s = s.trim();
829    let bytes = s.as_bytes();
830    if bytes.len() < 29 {
831        return None;
832    }
833    // bytes[3] = ',', bytes[4] = ' '
834    if bytes[3] != b',' || bytes[4] != b' ' {
835        return None;
836    }
837    let day: u8 = s.get(5..7)?.parse().ok()?;
838    if bytes[7] != b' ' {
839        return None;
840    }
841    let mon = match &s[8..11] {
842        "Jan" => 1,
843        "Feb" => 2,
844        "Mar" => 3,
845        "Apr" => 4,
846        "May" => 5,
847        "Jun" => 6,
848        "Jul" => 7,
849        "Aug" => 8,
850        "Sep" => 9,
851        "Oct" => 10,
852        "Nov" => 11,
853        "Dec" => 12,
854        _ => return None,
855    };
856    if bytes[11] != b' ' {
857        return None;
858    }
859    let year: i32 = s.get(12..16)?.parse().ok()?;
860    if bytes[16] != b' ' {
861        return None;
862    }
863    let hour: u8 = s.get(17..19)?.parse().ok()?;
864    if bytes[19] != b':' {
865        return None;
866    }
867    let minute: u8 = s.get(20..22)?.parse().ok()?;
868    if bytes[22] != b':' {
869        return None;
870    }
871    let second: u8 = s.get(23..25)?.parse().ok()?;
872    if &s[25..] != " GMT" {
873        return None;
874    }
875    Some((year, mon, day, hour, minute, second))
876}
877
878/// Convert an IMF-fixdate (already in UTC per §5.6.7 "GMT") into a
879/// strictly-monotonic 64-bit second count from a fixed epoch. Used only
880/// for the §8.8.2.2 "Date >= Last-Modified + 1 s" comparison, so any
881/// epoch that yields a total ordering on real-world HTTP dates is fine.
882/// We pick (year-2000)*31_557_600 + ... — close enough for the
883/// strict-greater test; not used as a wall-clock value.
884fn imf_seconds(d: (i32, u8, u8, u8, u8, u8)) -> i64 {
885    let (y, mo, da, h, mi, s) = d;
886    // Cumulative days at start of month, Jan=0..Dec=334 (non-leap),
887    // good enough for ordering within the same year — across years
888    // the 365-day step dominates.
889    const CUM: [u16; 13] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
890    let day_of_year = CUM[(mo - 1) as usize] as i64 + (da as i64 - 1);
891    let day_index = (y as i64 - 2000) * 365 + day_of_year;
892    day_index * 86_400 + (h as i64) * 3_600 + (mi as i64) * 60 + s as i64
893}
894
895/// Decide which validator (if any) we may use as a strong `If-Range`
896/// value per RFC 9110 §13.1.5 + §8.8.2.2 + §8.8.3.
897///
898/// Returns `Some(StrongValidator::Etag(_))` when the HEAD's `ETag` is a
899/// syntactically valid strong entity-tag (no `W/` prefix). Otherwise
900/// returns `Some(StrongValidator::LastModified(_))` only when both
901/// `Last-Modified` and `Date` parse as IMF-fixdate and the §8.8.2.2
902/// promotion rule holds (`Date >= Last-Modified + 1 second`). Returns
903/// `None` otherwise — the read path then issues plain Range GETs with
904/// no `If-Range`.
905fn derive_strong_validator(
906    etag: Option<&str>,
907    last_modified: Option<&str>,
908    date: Option<&str>,
909) -> Option<StrongValidator> {
910    if let Some(raw) = etag {
911        if let Some((is_weak, wire)) = parse_entity_tag(raw) {
912            if !is_weak {
913                return Some(StrongValidator::Etag(wire));
914            }
915        }
916    }
917    if let (Some(lm), Some(dt)) = (last_modified, date) {
918        if let (Some(lm_p), Some(dt_p)) = (parse_imf_fixdate(lm), parse_imf_fixdate(dt)) {
919            // §8.8.2.2: promotion requires Date strictly greater than
920            // Last-Modified (at 1-second resolution). `dt > lm` is the
921            // clippy-idiomatic spelling of `dt >= lm + 1` on integers.
922            if imf_seconds(dt_p) > imf_seconds(lm_p) {
923                return Some(StrongValidator::LastModified(lm.to_owned()));
924            }
925        }
926    }
927    None
928}
929
930impl Read for HttpSource {
931    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
932        if out.is_empty() {
933            return Ok(0);
934        }
935        if self.pos >= self.total_len {
936            return Ok(0);
937        }
938        loop {
939            if self.body.is_none() {
940                self.issue_range()?;
941            }
942            let body = self.body.as_mut().expect("body just issued");
943            let n = body.read(out)?;
944            if n > 0 {
945                self.pos += n as u64;
946                return Ok(n);
947            }
948            self.body = None;
949            if self.pos >= self.total_len {
950                return Ok(0);
951            }
952        }
953    }
954}
955
956impl Seek for HttpSource {
957    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
958        let new_pos: u64 = match from {
959            SeekFrom::Start(n) => n,
960            SeekFrom::Current(d) => add_signed(self.pos, d)?,
961            SeekFrom::End(d) => add_signed(self.total_len, d)?,
962        };
963        if new_pos > self.total_len {
964            return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek past end"));
965        }
966        if new_pos != self.pos {
967            self.body = None;
968            self.pos = new_pos;
969        }
970        Ok(new_pos)
971    }
972}
973
974fn add_signed(base: u64, delta: i64) -> io::Result<u64> {
975    if delta >= 0 {
976        base.checked_add(delta as u64)
977            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek overflow"))
978    } else {
979        base.checked_sub(delta.unsigned_abs())
980            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek before start"))
981    }
982}
983
984/// Header-parser wrappers exposed for the cargo-fuzz harness under
985/// `fuzz/`. Not part of the stable public surface; gated behind the
986/// `fuzz` cargo feature so the published artefact carries the same
987/// crate boundary it always had.
988///
989/// The wrappers return `bool` (parse succeeded vs not) — the fuzz
990/// contract under test is that none of these parsers panic on any
991/// byte string, not that any particular byte string parses.
992#[cfg(feature = "fuzz")]
993#[doc(hidden)]
994pub mod __fuzz {
995    /// Fuzz-only wrapper for [`super::parse_byte_content_range`].
996    pub fn parse_byte_content_range(s: &str) -> bool {
997        super::parse_byte_content_range(s).is_ok()
998    }
999    /// Fuzz-only wrapper for [`super::parse_byte_unsatisfied_range`].
1000    pub fn parse_byte_unsatisfied_range(s: &str) -> bool {
1001        super::parse_byte_unsatisfied_range(s).is_ok()
1002    }
1003    /// Fuzz-only wrapper for [`super::parse_entity_tag`].
1004    pub fn parse_entity_tag(s: &str) -> bool {
1005        super::parse_entity_tag(s).is_some()
1006    }
1007    /// Fuzz-only wrapper for [`super::parse_imf_fixdate`].
1008    pub fn parse_imf_fixdate(s: &str) -> bool {
1009        super::parse_imf_fixdate(s).is_some()
1010    }
1011    /// Fuzz-only wrapper for [`super::derive_strong_validator`]. The
1012    /// caller splits the input on NUL bytes into up to three optional
1013    /// header values (etag, last-modified, date) so the fuzzer can
1014    /// drive all 8 input-presence combinations.
1015    pub fn derive_strong_validator(etag: Option<&str>, lm: Option<&str>, date: Option<&str>) {
1016        let _ = super::derive_strong_validator(etag, lm, date);
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use std::collections::BTreeSet;
1024
1025    #[test]
1026    fn register_via_runtime_context_installs_http_and_https_schemes() {
1027        let mut ctx = RuntimeContext::new();
1028        register(&mut ctx);
1029        let schemes: BTreeSet<&str> = ctx.sources.schemes().collect();
1030        assert!(
1031            schemes.contains("http"),
1032            "register did not install http; got {schemes:?}"
1033        );
1034        assert!(
1035            schemes.contains("https"),
1036            "register did not install https; got {schemes:?}"
1037        );
1038    }
1039
1040    #[test]
1041    fn register_source_installs_http_and_https_schemes_on_bare_registry() {
1042        let mut reg = SourceRegistry::new();
1043        register_source(&mut reg);
1044        let schemes: BTreeSet<&str> = reg.schemes().collect();
1045        assert!(schemes.contains("http"));
1046        assert!(schemes.contains("https"));
1047    }
1048
1049    #[test]
1050    fn http_config_default_matches_documented_surface() {
1051        let c = HttpConfig::default();
1052        assert_eq!(c.max_redirects(), 10);
1053        assert!(c.max_redirects_will_error());
1054        assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::Never);
1055        assert_eq!(c.user_agent(), None);
1056        assert!(!c.https_only());
1057        assert_eq!(c.timeout_global(), None);
1058        assert_eq!(c.timeout_connect(), None);
1059    }
1060
1061    #[test]
1062    fn http_config_builder_threads_values_through() {
1063        let c = HttpConfig::builder()
1064            .max_redirects(3)
1065            .max_redirects_will_error(false)
1066            .redirect_auth_policy(RedirectAuthPolicy::SameHost)
1067            .user_agent("oxideav-test/0.0")
1068            .https_only(true)
1069            .timeout_global(Some(Duration::from_secs(30)))
1070            .timeout_connect(Some(Duration::from_secs(5)))
1071            .build();
1072        assert_eq!(c.max_redirects(), 3);
1073        assert!(!c.max_redirects_will_error());
1074        assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::SameHost);
1075        assert_eq!(c.user_agent(), Some("oxideav-test/0.0"));
1076        assert!(c.https_only());
1077        assert_eq!(c.timeout_global(), Some(Duration::from_secs(30)));
1078        assert_eq!(c.timeout_connect(), Some(Duration::from_secs(5)));
1079    }
1080
1081    #[test]
1082    fn agent_from_config_succeeds_for_every_redirect_policy() {
1083        // Just exercise the construction path — failure modes are
1084        // panics inside ureq, which would surface here.
1085        for p in [RedirectAuthPolicy::Never, RedirectAuthPolicy::SameHost] {
1086            let c = HttpConfig::builder().redirect_auth_policy(p).build();
1087            let _ = agent_from(&c);
1088        }
1089    }
1090
1091    #[test]
1092    fn http_config_clone_is_cheap_and_independent() {
1093        let a = HttpConfig::builder()
1094            .user_agent("alpha/1.0")
1095            .max_redirects(7)
1096            .build();
1097        let b = a.clone();
1098        assert_eq!(a.user_agent(), b.user_agent());
1099        assert_eq!(a.max_redirects(), b.max_redirects());
1100    }
1101
1102    #[test]
1103    fn install_default_config_is_one_shot() {
1104        // We run this test in isolation per-process effectively — the
1105        // OnceLock keeps state, so try once then assert the second
1106        // attempt is rejected. Tests run in arbitrary order; if the
1107        // default agent has already materialised via another test,
1108        // the first install attempt itself may be rejected. Either
1109        // outcome must trip the "already installed" guard at some
1110        // point.
1111        let first = install_default_config(HttpConfig::default());
1112        // Force the agent to materialise so the second attempt is
1113        // guaranteed to be rejected even if the first succeeded.
1114        let _ = agent();
1115        let second = install_default_config(HttpConfig::default());
1116        assert!(
1117            first.is_err() || second.is_err(),
1118            "expected at least one install to be rejected once the agent is built (first={first:?}, second={second:?})"
1119        );
1120        assert_eq!(second, Err(ConfigAlreadyInstalled));
1121    }
1122
1123    #[test]
1124    fn config_already_installed_implements_error_trait() {
1125        let e: &dyn std::error::Error = &ConfigAlreadyInstalled;
1126        assert!(e.to_string().contains("already"));
1127    }
1128
1129    // -- RFC 7233 §4.2 Content-Range parser ----------------------------------
1130
1131    #[test]
1132    fn content_range_parses_canonical_form() {
1133        let r = parse_byte_content_range("bytes 42-1233/1234").unwrap();
1134        assert_eq!(
1135            r,
1136            ByteContentRange {
1137                first: 42,
1138                last: 1233,
1139                complete: Some(1234),
1140            }
1141        );
1142    }
1143
1144    #[test]
1145    fn content_range_accepts_star_complete_length() {
1146        // RFC 7233 §4.2: "An asterisk character ('*') in place of the
1147        // complete-length indicates that the representation length was
1148        // unknown when the header field was generated."
1149        let r = parse_byte_content_range("bytes 42-1233/*").unwrap();
1150        assert_eq!(r.first, 42);
1151        assert_eq!(r.last, 1233);
1152        assert_eq!(r.complete, None);
1153    }
1154
1155    #[test]
1156    fn content_range_accepts_full_resource_first_last() {
1157        let r = parse_byte_content_range("bytes 0-0/1").unwrap();
1158        assert_eq!(r.first, 0);
1159        assert_eq!(r.last, 0);
1160        assert_eq!(r.complete, Some(1));
1161    }
1162
1163    #[test]
1164    fn content_range_unit_is_case_insensitive() {
1165        // RFC 7230 §3.2.6 tokens are case-insensitive.
1166        assert!(parse_byte_content_range("BYTES 0-9/10").is_ok());
1167        assert!(parse_byte_content_range("Bytes 0-9/10").is_ok());
1168    }
1169
1170    #[test]
1171    fn content_range_rejects_unknown_unit() {
1172        // RFC 7233 §4.2: "If a 206 (Partial Content) response contains
1173        // a Content-Range header field with a range unit (Section 2)
1174        // that the recipient does not understand, the recipient MUST
1175        // NOT attempt to recombine it with a stored representation."
1176        assert!(parse_byte_content_range("frames 0-9/100").is_err());
1177    }
1178
1179    #[test]
1180    fn content_range_rejects_last_lt_first() {
1181        // §4.2: invalid if last-byte-pos < first-byte-pos.
1182        assert!(parse_byte_content_range("bytes 100-50/200").is_err());
1183    }
1184
1185    #[test]
1186    fn content_range_rejects_complete_le_last() {
1187        // §4.2: invalid if complete-length <= last-byte-pos.
1188        assert!(parse_byte_content_range("bytes 0-100/100").is_err());
1189        assert!(parse_byte_content_range("bytes 0-100/50").is_err());
1190    }
1191
1192    #[test]
1193    fn content_range_rejects_unsatisfied_payload_on_206() {
1194        // §4.2 unsatisfied-range = "*/" complete is a 416 payload, not
1195        // a 206 payload — we reject it at parse time.
1196        assert!(parse_byte_content_range("bytes */1234").is_err());
1197    }
1198
1199    #[test]
1200    fn unsatisfied_range_parses_canonical_form() {
1201        // RFC 9110 §14.4 example: `bytes */1234`.
1202        let n = parse_byte_unsatisfied_range("bytes */1234").unwrap();
1203        assert_eq!(n, 1234);
1204    }
1205
1206    #[test]
1207    fn unsatisfied_range_unit_is_case_insensitive() {
1208        assert_eq!(
1209            parse_byte_unsatisfied_range("BYTES */47022").unwrap(),
1210            47022
1211        );
1212        assert_eq!(
1213            parse_byte_unsatisfied_range("Bytes */47022").unwrap(),
1214            47022
1215        );
1216    }
1217
1218    #[test]
1219    fn unsatisfied_range_rejects_unknown_unit() {
1220        assert!(parse_byte_unsatisfied_range("frames */1234").is_err());
1221    }
1222
1223    #[test]
1224    fn unsatisfied_range_rejects_range_resp_form() {
1225        // §14.4: a 416 body is unsatisfied-range, never the canonical
1226        // range-resp form (first-last/complete).
1227        assert!(parse_byte_unsatisfied_range("bytes 0-9/10").is_err());
1228        assert!(parse_byte_unsatisfied_range("bytes 42-1233/1234").is_err());
1229    }
1230
1231    #[test]
1232    fn unsatisfied_range_rejects_malformed() {
1233        for bad in [
1234            "",
1235            "bytes",
1236            "bytes */",  // no digits after '*/'
1237            "bytes */x", // non-numeric complete
1238            "bytes /1234",
1239            "*/1234", // missing unit + SP
1240        ] {
1241            assert!(
1242                parse_byte_unsatisfied_range(bad).is_err(),
1243                "expected reject for {bad:?}"
1244            );
1245        }
1246    }
1247
1248    #[test]
1249    fn content_range_rejects_malformed_strings() {
1250        for bad in [
1251            "",
1252            "bytes",
1253            "bytes 0-9",                       // missing /complete
1254            "bytes 09/10",                     // missing -
1255            "bytes a-9/10",                    // non-numeric first
1256            "bytes 0-b/10",                    // non-numeric last
1257            "bytes 0-9/x",                     // non-numeric complete
1258            "bytes 18446744073709551616-0/10", // u64 overflow on first
1259            "0-9/10",                          // missing unit + SP
1260        ] {
1261            assert!(
1262                parse_byte_content_range(bad).is_err(),
1263                "expected reject for {bad:?}"
1264            );
1265        }
1266    }
1267
1268    // -- Local-TCP end-to-end tests ------------------------------------------
1269    //
1270    // These spin up a single-shot std::net::TcpListener on 127.0.0.1:0,
1271    // accept N connections, hand-craft minimal HTTP/1.1 responses, and
1272    // verify our HttpSource's Content-Range validator catches the
1273    // bad-server cases and accepts the canonical-server cases. They do
1274    // not depend on external network reachability.
1275
1276    use std::io::Write as _;
1277    use std::net::TcpListener;
1278    use std::sync::mpsc;
1279    use std::thread;
1280
1281    /// Spawn a minimal HTTP/1.1 server on 127.0.0.1:0 that responds to
1282    /// `HEAD` requests with `head_resp` and to `GET` requests with
1283    /// `get_resp`. Each response is the literal byte string the test
1284    /// supplies (status line + headers + CRLF + body, no
1285    /// chunked-encoding).
1286    fn spawn_server(
1287        head_resp: &'static [u8],
1288        get_resp: &'static [u8],
1289    ) -> (String, mpsc::Receiver<()>) {
1290        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1291        let port = listener.local_addr().unwrap().port();
1292        let (tx, rx) = mpsc::channel::<()>();
1293        thread::spawn(move || {
1294            // Accept up to 4 connections so HEAD + GET (and one retry)
1295            // all land. ureq may use separate connections.
1296            for _ in 0..4 {
1297                let Ok((mut stream, _)) = listener.accept() else {
1298                    break;
1299                };
1300                let mut buf = [0u8; 4096];
1301                use std::io::Read as _;
1302                let n = stream.read(&mut buf).unwrap_or(0);
1303                if n == 0 {
1304                    continue;
1305                }
1306                let req = &buf[..n];
1307                let resp = if req.starts_with(b"HEAD ") {
1308                    head_resp
1309                } else {
1310                    get_resp
1311                };
1312                let _ = stream.write_all(resp);
1313                let _ = stream.flush();
1314            }
1315            let _ = tx.send(());
1316        });
1317        (format!("http://127.0.0.1:{port}/x"), rx)
1318    }
1319
1320    const HEAD_10B_BYTES: &[u8] = b"HTTP/1.1 200 OK\r\n\
1321        Content-Length: 10\r\n\
1322        Accept-Ranges: bytes\r\n\
1323        Connection: close\r\n\
1324        \r\n";
1325
1326    fn make_get_206(content_range: &str, body: &[u8]) -> Vec<u8> {
1327        let mut v = format!(
1328            "HTTP/1.1 206 Partial Content\r\n\
1329             Content-Length: {}\r\n\
1330             Content-Range: {content_range}\r\n\
1331             Connection: close\r\n\
1332             \r\n",
1333            body.len()
1334        )
1335        .into_bytes();
1336        v.extend_from_slice(body);
1337        v
1338    }
1339
1340    #[test]
1341    fn local_server_canonical_206_succeeds() {
1342        // Static GET response: Content-Range echoes the requested
1343        // open-ended `bytes=0-`, body is the full 10 B.
1344        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1345            Content-Length: 10\r\n\
1346            Content-Range: bytes 0-9/10\r\n\
1347            Connection: close\r\n\
1348            \r\n\
1349            0123456789";
1350        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1351        let mut src = HttpSource::open(&uri).expect("open");
1352        let mut buf = [0u8; 10];
1353        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1354        assert_eq!(&buf, b"0123456789");
1355    }
1356
1357    #[test]
1358    fn local_server_206_without_content_range_is_rejected() {
1359        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1360            Content-Length: 10\r\n\
1361            Connection: close\r\n\
1362            \r\n\
1363            0123456789";
1364        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1365        let mut src = HttpSource::open(&uri).expect("open");
1366        let mut buf = [0u8; 10];
1367        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1368        assert!(
1369            err.to_string().contains("missing Content-Range"),
1370            "wrong error: {err}"
1371        );
1372    }
1373
1374    #[test]
1375    fn local_server_206_with_wrong_first_pos_is_rejected() {
1376        // We ask for bytes=0-; server replies with bytes 3-9/10 — that
1377        // would silently misalign the demuxer.
1378        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1379            Content-Length: 7\r\n\
1380            Content-Range: bytes 3-9/10\r\n\
1381            Connection: close\r\n\
1382            \r\n\
1383            3456789";
1384        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1385        let mut src = HttpSource::open(&uri).expect("open");
1386        let mut buf = [0u8; 10];
1387        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1388        assert!(
1389            err.to_string().contains("first-byte-pos"),
1390            "wrong error: {err}"
1391        );
1392    }
1393
1394    #[test]
1395    fn local_server_206_with_resource_resize_is_rejected() {
1396        // HEAD said 10; 206 says complete-length 20 — origin/cache
1397        // disagreement we cannot recover from in-band.
1398        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1399            Content-Length: 10\r\n\
1400            Content-Range: bytes 0-9/20\r\n\
1401            Connection: close\r\n\
1402            \r\n\
1403            0123456789";
1404        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1405        let mut src = HttpSource::open(&uri).expect("open");
1406        let mut buf = [0u8; 10];
1407        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1408        assert!(
1409            err.to_string().contains("complete-length"),
1410            "wrong error: {err}"
1411        );
1412    }
1413
1414    #[test]
1415    fn local_server_206_with_star_complete_is_accepted() {
1416        // RFC 7233 §4.2 explicitly permits `*` complete-length when
1417        // the server doesn't know the total. We must accept it.
1418        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1419            Content-Length: 10\r\n\
1420            Content-Range: bytes 0-9/*\r\n\
1421            Connection: close\r\n\
1422            \r\n\
1423            0123456789";
1424        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1425        let mut src = HttpSource::open(&uri).expect("open");
1426        let mut buf = [0u8; 10];
1427        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1428        assert_eq!(&buf, b"0123456789");
1429    }
1430
1431    #[test]
1432    fn local_server_200_after_seek_drops_prefix() {
1433        // Server ignores Range entirely and serves full body with 200.
1434        // We seek to byte 4 first; the driver must drain bytes 0..4
1435        // before exposing byte 4 onward to the reader.
1436        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
1437            Content-Length: 10\r\n\
1438            Connection: close\r\n\
1439            \r\n\
1440            0123456789";
1441        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1442        let mut src = HttpSource::open(&uri).expect("open");
1443        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
1444        let mut buf = [0u8; 6];
1445        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1446        assert_eq!(&buf, b"456789");
1447    }
1448
1449    #[test]
1450    fn local_server_416_with_unsatisfied_range_surfaces_complete_length() {
1451        // RFC 9110 §15.5.17 + §14.4: a 416 carries `bytes */<complete>`
1452        // naming the server's authoritative current length. We seek
1453        // past EOF (HEAD said 10, seek to 8, server reports current
1454        // length 5 — i.e. resource shrank) and expect the error message
1455        // to surface BOTH the server's reported length and the
1456        // HEAD-observed length so the caller can distinguish the case.
1457        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
1458            Content-Length: 0\r\n\
1459            Content-Range: bytes */5\r\n\
1460            Connection: close\r\n\
1461            \r\n";
1462        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1463        let mut src = HttpSource::open(&uri).expect("open");
1464        // Seek to byte 8 — HEAD said 10 so this is in-bounds from our
1465        // pov; the server's 416 then tells us the resource is now 5.
1466        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
1467        let mut buf = [0u8; 1];
1468        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1469        let msg = err.to_string();
1470        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
1471        assert!(
1472            msg.contains("complete-length 5"),
1473            "wrong error (no 'complete-length 5'): {msg}"
1474        );
1475        assert!(
1476            msg.contains("HEAD observed 10"),
1477            "wrong error (no 'HEAD observed 10'): {msg}"
1478        );
1479    }
1480
1481    #[test]
1482    fn local_server_416_without_content_range_still_errors_cleanly() {
1483        // §14.4 makes the Content-Range a SHOULD on 416, not a MUST. A
1484        // 416 without the body still needs a clean error path that
1485        // names the status.
1486        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
1487            Content-Length: 0\r\n\
1488            Connection: close\r\n\
1489            \r\n";
1490        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1491        let mut src = HttpSource::open(&uri).expect("open");
1492        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
1493        let mut buf = [0u8; 1];
1494        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1495        let msg = err.to_string();
1496        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
1497        assert!(
1498            msg.contains("no Content-Range"),
1499            "wrong error (no 'no Content-Range'): {msg}"
1500        );
1501    }
1502
1503    #[test]
1504    fn local_server_416_with_invalid_content_range_reports_parse_error() {
1505        // A 416 whose Content-Range is malformed — the read still
1506        // fails, but the error names the parse failure rather than
1507        // surfacing nonsense as a length.
1508        static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
1509            Content-Length: 0\r\n\
1510            Content-Range: bytes 0-9/10\r\n\
1511            Connection: close\r\n\
1512            \r\n";
1513        // 'bytes 0-9/10' is a range-resp form, not unsatisfied-range —
1514        // §14.4 says the 416 body should be `*/N`. Anything else is a
1515        // server bug; we surface the parse error.
1516        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1517        let mut src = HttpSource::open(&uri).expect("open");
1518        std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
1519        let mut buf = [0u8; 1];
1520        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1521        let msg = err.to_string();
1522        assert!(msg.contains("416"), "wrong error (no 416): {msg}");
1523        assert!(
1524            msg.contains("invalid Content-Range"),
1525            "wrong error (no 'invalid Content-Range'): {msg}"
1526        );
1527    }
1528
1529    // Silence "unused" warning when these helpers aren't all picked up
1530    // — make_get_206 is a convenience the next test round may use.
1531    #[allow(dead_code)]
1532    fn _keep_helper() {
1533        let _ = make_get_206("bytes 0-9/10", b"0123456789");
1534    }
1535
1536    // -- RFC 9110 §13.1.5 If-Range strong-validator path ---------------------
1537
1538    #[test]
1539    fn entity_tag_parses_strong_tag() {
1540        let (weak, wire) = parse_entity_tag("\"xyzzy\"").unwrap();
1541        assert!(!weak);
1542        assert_eq!(wire, "\"xyzzy\"");
1543    }
1544
1545    #[test]
1546    fn entity_tag_parses_weak_tag() {
1547        let (weak, wire) = parse_entity_tag("W/\"xyzzy\"").unwrap();
1548        assert!(weak);
1549        assert_eq!(wire, "W/\"xyzzy\"");
1550    }
1551
1552    #[test]
1553    fn entity_tag_parses_empty_opaque() {
1554        // §8.8.3 example: ETag: ""
1555        let (weak, wire) = parse_entity_tag("\"\"").unwrap();
1556        assert!(!weak);
1557        assert_eq!(wire, "\"\"");
1558    }
1559
1560    #[test]
1561    fn entity_tag_rejects_unquoted_value() {
1562        // The grammar requires DQUOTE-wrapped opaque-tag.
1563        assert!(parse_entity_tag("xyzzy").is_none());
1564        assert!(parse_entity_tag("W/xyzzy").is_none());
1565    }
1566
1567    #[test]
1568    fn entity_tag_weak_marker_is_case_sensitive() {
1569        // §8.8.3: weak = %s"W/" — case-sensitive. A lowercase w/ is
1570        // not a weakness indicator; it becomes part of the opaque-tag
1571        // which then fails the DQUOTE-wrap test.
1572        assert!(parse_entity_tag("w/\"xyzzy\"").is_none());
1573    }
1574
1575    #[test]
1576    fn entity_tag_rejects_disallowed_inner_bytes() {
1577        // etagc forbids DQUOTE (0x22) and most controls.
1578        assert!(parse_entity_tag("\"hello\"world\"").is_none());
1579        assert!(parse_entity_tag("\"tab\there\"").is_none()); // 0x09
1580    }
1581
1582    #[test]
1583    fn imf_fixdate_parses_canonical_example() {
1584        // RFC 9110 §5.6.7 canonical form.
1585        let d = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
1586        assert_eq!(d, (1994, 11, 6, 8, 49, 37));
1587    }
1588
1589    #[test]
1590    fn imf_fixdate_rejects_non_imf_forms() {
1591        // Obsolete rfc850 and asctime forms — we only need to parse
1592        // IMF-fixdate for the strong-validator promotion test.
1593        assert!(parse_imf_fixdate("Sunday, 06-Nov-94 08:49:37 GMT").is_none());
1594        assert!(parse_imf_fixdate("Sun Nov  6 08:49:37 1994").is_none());
1595        // Missing GMT suffix.
1596        assert!(parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37").is_none());
1597        // Bogus month name.
1598        assert!(parse_imf_fixdate("Sun, 06 Foo 1994 08:49:37 GMT").is_none());
1599    }
1600
1601    #[test]
1602    fn imf_seconds_orders_within_one_second_correctly() {
1603        // §8.8.2.2 needs strict-greater-than-or-equal at 1-second
1604        // resolution. Confirm the ordering primitive supports that.
1605        let lm = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
1606        let dt_eq = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
1607        let dt_p1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:38 GMT").unwrap();
1608        let dt_m1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:36 GMT").unwrap();
1609        assert!(imf_seconds(dt_eq) == imf_seconds(lm));
1610        assert!(imf_seconds(dt_p1) == imf_seconds(lm) + 1);
1611        assert!(imf_seconds(dt_m1) == imf_seconds(lm) - 1);
1612    }
1613
1614    #[test]
1615    fn derive_strong_validator_prefers_strong_etag() {
1616        let v = derive_strong_validator(
1617            Some("\"xyz\""),
1618            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1619            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1620        );
1621        assert_eq!(v, Some(StrongValidator::Etag("\"xyz\"".into())));
1622    }
1623
1624    #[test]
1625    fn derive_strong_validator_skips_weak_etag_falls_to_strong_last_modified() {
1626        // Weak ETag → not usable for If-Range. Last-Modified is
1627        // promotable because Date is 5 s after it.
1628        let v = derive_strong_validator(
1629            Some("W/\"xyz\""),
1630            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1631            Some("Sun, 06 Nov 1994 08:49:42 GMT"),
1632        );
1633        assert_eq!(
1634            v,
1635            Some(StrongValidator::LastModified(
1636                "Sun, 06 Nov 1994 08:49:37 GMT".into()
1637            ))
1638        );
1639    }
1640
1641    #[test]
1642    fn derive_strong_validator_rejects_lm_when_date_within_one_second() {
1643        // Date == Last-Modified is NOT strong per §8.8.2.2 — needs at
1644        // least 1 s of separation. The result must be None (we fall
1645        // back to issuing the GET without If-Range).
1646        let v = derive_strong_validator(
1647            None,
1648            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1649            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1650        );
1651        assert_eq!(v, None);
1652    }
1653
1654    #[test]
1655    fn derive_strong_validator_rejects_lm_without_date() {
1656        // §8.8.2.2 promotion needs both timestamps — without Date we
1657        // can't reason about clock skew, so stay weak (= None).
1658        let v = derive_strong_validator(None, Some("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1659        assert_eq!(v, None);
1660    }
1661
1662    #[test]
1663    fn derive_strong_validator_handles_missing_headers() {
1664        assert_eq!(derive_strong_validator(None, None, None), None);
1665    }
1666
1667    #[test]
1668    fn derive_strong_validator_skips_malformed_etag_falls_through() {
1669        // A malformed ETag is treated as absent — try Last-Modified.
1670        let v = derive_strong_validator(
1671            Some("not-quoted"),
1672            Some("Sun, 06 Nov 1994 08:49:37 GMT"),
1673            Some("Sun, 06 Nov 1994 08:49:42 GMT"),
1674        );
1675        assert_eq!(
1676            v,
1677            Some(StrongValidator::LastModified(
1678                "Sun, 06 Nov 1994 08:49:37 GMT".into()
1679            ))
1680        );
1681    }
1682
1683    /// Variant of `spawn_server` that captures the GET request bytes
1684    /// in a channel so the test can verify our `If-Range` field made
1685    /// it onto the wire exactly as expected.
1686    fn spawn_server_capturing_get(
1687        head_resp: &'static [u8],
1688        get_resp: &'static [u8],
1689    ) -> (String, mpsc::Receiver<Vec<u8>>) {
1690        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1691        let port = listener.local_addr().unwrap().port();
1692        let (tx, rx) = mpsc::channel::<Vec<u8>>();
1693        thread::spawn(move || {
1694            for _ in 0..4 {
1695                let Ok((mut stream, _)) = listener.accept() else {
1696                    break;
1697                };
1698                let mut buf = [0u8; 4096];
1699                use std::io::Read as _;
1700                let n = stream.read(&mut buf).unwrap_or(0);
1701                if n == 0 {
1702                    continue;
1703                }
1704                let req = &buf[..n];
1705                let resp = if req.starts_with(b"HEAD ") {
1706                    head_resp
1707                } else {
1708                    // GET — record the full request bytes so the test
1709                    // can grep for If-Range presence.
1710                    let _ = tx.send(req.to_vec());
1711                    get_resp
1712                };
1713                let _ = stream.write_all(resp);
1714                let _ = stream.flush();
1715            }
1716        });
1717        (format!("http://127.0.0.1:{port}/x"), rx)
1718    }
1719
1720    /// HEAD response that supplies a strong ETag so the driver will
1721    /// elect to send `If-Range: "v1"` on the next GET.
1722    const HEAD_10B_WITH_STRONG_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
1723        Content-Length: 10\r\n\
1724        Accept-Ranges: bytes\r\n\
1725        ETag: \"v1\"\r\n\
1726        Connection: close\r\n\
1727        \r\n";
1728
1729    #[test]
1730    fn local_server_strong_etag_emits_if_range_header_on_get() {
1731        // Server replies 206 normally; we just need to confirm the
1732        // outgoing GET request line carried `If-Range: "v1"`.
1733        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1734            Content-Length: 10\r\n\
1735            Content-Range: bytes 0-9/10\r\n\
1736            Connection: close\r\n\
1737            \r\n\
1738            0123456789";
1739        let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
1740        let mut src = HttpSource::open(&uri).expect("open");
1741        let mut buf = [0u8; 10];
1742        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1743        let req = captured.recv().expect("captured GET request");
1744        let s = String::from_utf8_lossy(&req);
1745        assert!(
1746            s.to_ascii_lowercase().contains("if-range: \"v1\""),
1747            "GET did not carry If-Range; got:\n{s}"
1748        );
1749    }
1750
1751    /// HEAD response with a weak ETag and no Last-Modified — driver
1752    /// must not invent a validator.
1753    const HEAD_10B_WITH_WEAK_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
1754        Content-Length: 10\r\n\
1755        Accept-Ranges: bytes\r\n\
1756        ETag: W/\"v1\"\r\n\
1757        Connection: close\r\n\
1758        \r\n";
1759
1760    #[test]
1761    fn local_server_weak_etag_does_not_emit_if_range_header() {
1762        // §13.1.5: "A client MUST NOT generate an If-Range header
1763        // field containing an entity tag that is marked as weak."
1764        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1765            Content-Length: 10\r\n\
1766            Content-Range: bytes 0-9/10\r\n\
1767            Connection: close\r\n\
1768            \r\n\
1769            0123456789";
1770        let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_WEAK_ETAG, GET);
1771        let mut src = HttpSource::open(&uri).expect("open");
1772        let mut buf = [0u8; 10];
1773        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1774        let req = captured.recv().expect("captured GET request");
1775        let s = String::from_utf8_lossy(&req);
1776        assert!(
1777            !s.to_ascii_lowercase().contains("if-range:"),
1778            "GET unexpectedly carried If-Range with a weak ETag; got:\n{s}"
1779        );
1780    }
1781
1782    #[test]
1783    fn local_server_200_with_if_range_set_is_fatal_mutation() {
1784        // §13.1.5 short-circuit: when the validator we sent does NOT
1785        // match, the server omits the Range honour and sends 200 with
1786        // the full new representation. Our driver must surface that
1787        // as a hard error rather than silently re-anchoring the byte
1788        // offset against a different resource.
1789        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
1790            Content-Length: 10\r\n\
1791            Connection: close\r\n\
1792            \r\n\
1793            ABCDEFGHIJ";
1794        let (uri, _captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
1795        let mut src = HttpSource::open(&uri).expect("open");
1796        let mut buf = [0u8; 10];
1797        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1798        let msg = err.to_string();
1799        assert!(
1800            msg.contains("If-Range"),
1801            "wrong error (no If-Range mention): {msg}"
1802        );
1803        assert!(
1804            msg.contains("changed since HEAD"),
1805            "wrong error (no mutation phrasing): {msg}"
1806        );
1807    }
1808
1809    #[test]
1810    fn local_server_no_validator_still_drains_prefix_on_200() {
1811        // Sanity: when HEAD supplies no usable validator, we did NOT
1812        // send If-Range, so the §3.1 "server ignores Range, sends 200"
1813        // soft-fallback (drain prefix) still works. This confirms we
1814        // did not regress the existing path.
1815        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
1816            Content-Length: 10\r\n\
1817            Connection: close\r\n\
1818            \r\n\
1819            0123456789";
1820        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1821        let mut src = HttpSource::open(&uri).expect("open");
1822        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
1823        let mut buf = [0u8; 6];
1824        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1825        assert_eq!(&buf, b"456789");
1826    }
1827
1828    // -- RFC 9110 §8.6 Content-Length sanity ---------------------------------
1829
1830    #[test]
1831    fn local_server_200_fallback_content_length_mismatch_is_fatal() {
1832        // §8.6: HEAD's Content-Length MUST equal what a GET would have
1833        // sent. So a 200-fallback (server ignored Range, served full
1834        // body) carrying a Content-Length different from the HEAD's is
1835        // a mid-stream resize disguised as a §3.1 soft-fallback. The
1836        // demuxer would drain a now-wrong-sized prefix and read short;
1837        // surface as a hard error instead.
1838        //
1839        // HEAD says 10 bytes, GET says 5 — the body actually shipped is
1840        // 5 bytes so this is also a real wire condition (not just a
1841        // header lie).
1842        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
1843            Content-Length: 5\r\n\
1844            Connection: close\r\n\
1845            \r\n\
1846            01234";
1847        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1848        let mut src = HttpSource::open(&uri).expect("open");
1849        let mut buf = [0u8; 10];
1850        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1851        let msg = err.to_string();
1852        assert!(
1853            msg.contains("Content-Length 5"),
1854            "wrong error (no 'Content-Length 5'): {msg}"
1855        );
1856        assert!(
1857            msg.contains("HEAD-observed total 10"),
1858            "wrong error (no HEAD-observed mention): {msg}"
1859        );
1860        assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
1861    }
1862
1863    #[test]
1864    fn local_server_200_fallback_without_content_length_is_accepted() {
1865        // §8.6 makes Content-Length a SHOULD on responses (not a MUST
1866        // outside specific cases). A 200 with no Content-Length cannot
1867        // be cross-checked against HEAD; we still walk the §3.1
1868        // drain-prefix path. (HTTP/1.1 framing here uses Connection:
1869        // close to delimit the body — RFC 9112 §6.3 case 7.)
1870        static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
1871            Connection: close\r\n\
1872            \r\n\
1873            0123456789";
1874        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1875        let mut src = HttpSource::open(&uri).expect("open");
1876        std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
1877        let mut buf = [0u8; 6];
1878        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1879        assert_eq!(&buf, b"456789");
1880    }
1881
1882    #[test]
1883    fn local_server_206_content_length_mismatch_is_fatal() {
1884        // §8.6: a 206's Content-Length is the count of bytes actually
1885        // being sent. For our open-ended single-range request the
1886        // implied span is `last - first + 1`. A header value that
1887        // disagrees would let the downstream reader drift past the
1888        // end of the satisfied range silently.
1889        //
1890        // We ask for bytes=0-; server replies with Content-Range
1891        // 'bytes 0-9/10' (span 10) but Content-Length 5 (lie). The
1892        // mismatch is what we surface, not whatever short body would
1893        // arrive.
1894        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1895            Content-Length: 5\r\n\
1896            Content-Range: bytes 0-9/10\r\n\
1897            Connection: close\r\n\
1898            \r\n\
1899            01234";
1900        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1901        let mut src = HttpSource::open(&uri).expect("open");
1902        let mut buf = [0u8; 10];
1903        let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
1904        let msg = err.to_string();
1905        assert!(
1906            msg.contains("Content-Length 5"),
1907            "wrong error (no 'Content-Length 5'): {msg}"
1908        );
1909        assert!(
1910            msg.contains("Content-Range span 10"),
1911            "wrong error (no 'Content-Range span 10'): {msg}"
1912        );
1913        assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
1914    }
1915
1916    #[test]
1917    fn local_server_206_matching_content_length_is_accepted() {
1918        // Sanity: Content-Length 10 + Content-Range 'bytes 0-9/10'
1919        // (span 10) is the canonical happy path and must continue to
1920        // succeed.
1921        static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
1922            Content-Length: 10\r\n\
1923            Content-Range: bytes 0-9/10\r\n\
1924            Connection: close\r\n\
1925            \r\n\
1926            0123456789";
1927        let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
1928        let mut src = HttpSource::open(&uri).expect("open");
1929        let mut buf = [0u8; 10];
1930        std::io::Read::read_exact(&mut src, &mut buf).expect("read");
1931        assert_eq!(&buf, b"0123456789");
1932    }
1933}