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}