use std::io::{self, Read, Seek, SeekFrom};
use std::sync::OnceLock;
use std::time::Duration;
use oxideav_core::BytesSource;
use oxideav_core::RuntimeContext;
use oxideav_core::{Error, Result};
use oxideav_source::SourceRegistry;
use ureq::Agent;
pub fn register(ctx: &mut RuntimeContext) {
register_source(&mut ctx.sources);
}
oxideav_core::register!("http", register);
pub fn register_source(registry: &mut SourceRegistry) {
registry.register_bytes("http", open_http);
registry.register_bytes("https", open_http);
}
pub fn open_http(uri: &str) -> Result<Box<dyn BytesSource>> {
let src = HttpSource::open(uri)?;
Ok(Box::new(src))
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub enum RedirectAuthPolicy {
#[default]
Never,
SameHost,
}
#[derive(Debug, Clone)]
pub struct HttpConfig {
max_redirects: u32,
max_redirects_will_error: bool,
redirect_auth_policy: RedirectAuthPolicy,
user_agent: Option<String>,
https_only: bool,
timeout_global: Option<Duration>,
timeout_connect: Option<Duration>,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
max_redirects: 10,
max_redirects_will_error: true,
redirect_auth_policy: RedirectAuthPolicy::Never,
user_agent: None,
https_only: false,
timeout_global: None,
timeout_connect: None,
}
}
}
impl HttpConfig {
pub fn builder() -> HttpConfigBuilder {
HttpConfigBuilder {
inner: Self::default(),
}
}
pub fn max_redirects(&self) -> u32 {
self.max_redirects
}
pub fn max_redirects_will_error(&self) -> bool {
self.max_redirects_will_error
}
pub fn redirect_auth_policy(&self) -> RedirectAuthPolicy {
self.redirect_auth_policy
}
pub fn user_agent(&self) -> Option<&str> {
self.user_agent.as_deref()
}
pub fn https_only(&self) -> bool {
self.https_only
}
pub fn timeout_global(&self) -> Option<Duration> {
self.timeout_global
}
pub fn timeout_connect(&self) -> Option<Duration> {
self.timeout_connect
}
}
#[derive(Debug, Clone)]
pub struct HttpConfigBuilder {
inner: HttpConfig,
}
impl HttpConfigBuilder {
pub fn max_redirects(mut self, n: u32) -> Self {
self.inner.max_redirects = n;
self
}
pub fn max_redirects_will_error(mut self, v: bool) -> Self {
self.inner.max_redirects_will_error = v;
self
}
pub fn redirect_auth_policy(mut self, p: RedirectAuthPolicy) -> Self {
self.inner.redirect_auth_policy = p;
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.inner.user_agent = Some(ua.into());
self
}
pub fn https_only(mut self, v: bool) -> Self {
self.inner.https_only = v;
self
}
pub fn timeout_global(mut self, d: Option<Duration>) -> Self {
self.inner.timeout_global = d;
self
}
pub fn timeout_connect(mut self, d: Option<Duration>) -> Self {
self.inner.timeout_connect = d;
self
}
pub fn build(self) -> HttpConfig {
self.inner
}
}
fn agent_from(cfg: &HttpConfig) -> Agent {
let mut b = Agent::config_builder()
.max_redirects(cfg.max_redirects)
.max_redirects_will_error(cfg.max_redirects_will_error)
.https_only(cfg.https_only)
.redirect_auth_headers(match cfg.redirect_auth_policy {
RedirectAuthPolicy::Never => ureq::config::RedirectAuthHeaders::Never,
RedirectAuthPolicy::SameHost => ureq::config::RedirectAuthHeaders::SameHost,
})
.http_status_as_error(false);
if let Some(ua) = cfg.user_agent.as_deref() {
b = b.user_agent(ua);
}
b = b
.timeout_global(cfg.timeout_global)
.timeout_connect(cfg.timeout_connect);
b.build().new_agent()
}
static DEFAULT_CONFIG: OnceLock<HttpConfig> = OnceLock::new();
static DEFAULT_AGENT: OnceLock<Agent> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConfigAlreadyInstalled;
impl std::fmt::Display for ConfigAlreadyInstalled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("oxideav-http default config has already been installed")
}
}
impl std::error::Error for ConfigAlreadyInstalled {}
pub fn install_default_config(cfg: HttpConfig) -> std::result::Result<(), ConfigAlreadyInstalled> {
if DEFAULT_AGENT.get().is_some() {
return Err(ConfigAlreadyInstalled);
}
DEFAULT_CONFIG.set(cfg).map_err(|_| ConfigAlreadyInstalled)
}
fn agent() -> &'static Agent {
DEFAULT_AGENT.get_or_init(|| {
let cfg = DEFAULT_CONFIG.get().cloned().unwrap_or_default();
agent_from(&cfg)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StrongValidator {
Etag(String),
LastModified(String),
}
impl StrongValidator {
fn as_if_range(&self) -> &str {
match self {
StrongValidator::Etag(s) => s,
StrongValidator::LastModified(s) => s,
}
}
}
pub struct HttpSource {
uri: String,
total_len: u64,
pos: u64,
agent: Option<Agent>,
validator: Option<StrongValidator>,
body: Option<Box<dyn Read + Send>>,
}
impl HttpSource {
pub fn open(uri: &str) -> Result<Self> {
Self::open_impl(uri, None)
}
pub fn open_with_config(uri: &str, cfg: &HttpConfig) -> Result<Self> {
Self::open_impl(uri, Some(agent_from(cfg)))
}
fn open_impl(uri: &str, scoped: Option<Agent>) -> Result<Self> {
let head_agent: &Agent = scoped.as_ref().unwrap_or_else(|| agent());
let head = head_agent
.head(uri)
.header("Accept-Encoding", "identity")
.call()
.map_err(|e| Error::other(format!("HTTP HEAD {uri}: {e}")))?;
let status = head.status();
if !status.is_success() {
let retry_after = head
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.map(normalize_obs_fold);
let retry_msg = retry_after
.as_deref()
.map(format_retry_after_hint)
.unwrap_or_default();
return Err(Error::other(format!(
"HTTP HEAD {uri}: status {status}{retry_msg}"
)));
}
let headers = head.headers();
let mut head_codings: Vec<String> = Vec::new();
for v in headers.get_all("content-encoding") {
match v.to_str() {
Ok(s) => head_codings.extend(non_identity_content_codings(&normalize_obs_fold(s))),
Err(_) => head_codings.push("<non-ASCII content-coding>".to_owned()),
}
}
if !head_codings.is_empty() {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: representation carries Content-Encoding {head_codings:?} \
despite 'Accept-Encoding: identity' (RFC 9110 §12.5.3); per §8.4 the \
Content-Length and byte ranges then describe the coded form, which the \
driver does not decode"
)));
}
let total_len = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.ok_or_else(|| {
Error::Unsupported(format!("HTTP HEAD {uri}: missing Content-Length"))
})?;
let accept_ranges_owned = headers
.get("accept-ranges")
.and_then(|v| v.to_str().ok())
.map(normalize_obs_fold);
let accept_ranges_raw = accept_ranges_owned.as_deref().unwrap_or("");
match parse_accept_ranges(accept_ranges_raw) {
AcceptRanges::Bytes => {}
AcceptRanges::None => {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: server explicitly refused range support (Accept-Ranges: none, RFC 9110 §14.3)"
)));
}
AcceptRanges::Other(units) => {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: server advertises range units {units:?} but not 'bytes' (RFC 9110 §14.3)"
)));
}
AcceptRanges::Absent => {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: server did not advertise Accept-Ranges (RFC 9110 §14.3)"
)));
}
}
let etag_raw = headers
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::trim);
let last_modified = headers
.get("last-modified")
.and_then(|v| v.to_str().ok())
.map(str::trim);
let date_hdr = headers
.get("date")
.and_then(|v| v.to_str().ok())
.map(str::trim);
let validator = derive_strong_validator(etag_raw, last_modified, date_hdr);
let vary_owned = headers
.get("vary")
.and_then(|v| v.to_str().ok())
.map(normalize_obs_fold);
if let Some(vary_raw) = vary_owned.as_deref() {
if parse_vary(vary_raw) == Vary::Wildcard && validator.is_none() {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: response carries 'Vary: *' (RFC 9110 §12.5.5) with no \
strong validator (ETag / promotable Last-Modified) — the origin warns the \
representation may be selected on aspects outside the message syntax, so a \
later Range GET could serve a different representation the driver cannot \
detect (no If-Range guard per §13.1.5)"
)));
}
}
Ok(Self {
uri: uri.to_owned(),
total_len,
pos: 0,
agent: scoped,
validator,
body: None,
})
}
pub fn len(&self) -> u64 {
self.total_len
}
pub fn is_empty(&self) -> bool {
self.total_len == 0
}
fn agent_ref(&self) -> &Agent {
self.agent.as_ref().unwrap_or_else(|| agent())
}
fn issue_range(&mut self) -> io::Result<()> {
if self.pos >= self.total_len {
self.body = None;
return Ok(());
}
let range = format!("bytes={}-", self.pos);
let if_range = self.validator.as_ref().map(|v| v.as_if_range().to_owned());
let sent_if_range = if_range.is_some();
let mut req = self
.agent_ref()
.get(&self.uri)
.header("Range", &range)
.header("Accept-Encoding", "identity");
if let Some(ref v) = if_range {
req = req.header("If-Range", v.as_str());
}
let resp = req
.call()
.map_err(|e| io::Error::other(format!("HTTP GET {} {}: {e}", self.uri, range)))?;
let status = resp.status();
if status == 416 {
let cr_raw = resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
if let Some(cr) = cr_raw.as_deref() {
match parse_byte_unsatisfied_range(cr) {
Ok(complete) => {
return Err(io::Error::other(format!(
"HTTP 416 {} {}: server reports complete-length {complete} (HEAD observed {}, requested pos {})",
self.uri, range, self.total_len, self.pos
)));
}
Err(e) => {
return Err(io::Error::other(format!(
"HTTP 416 {} {}: invalid Content-Range '{cr}': {e}",
self.uri, range
)));
}
}
}
return Err(io::Error::other(format!(
"HTTP 416 {} {}: server rejected range (no Content-Range body)",
self.uri, range
)));
}
if !(status == 206 || status == 200) {
return Err(io::Error::other(format!(
"HTTP GET {} {}: status {status}",
self.uri, range
)));
}
let mut get_codings: Vec<String> = Vec::new();
for v in resp.headers().get_all("content-encoding") {
match v.to_str() {
Ok(s) => get_codings.extend(non_identity_content_codings(&normalize_obs_fold(s))),
Err(_) => get_codings.push("<non-ASCII content-coding>".to_owned()),
}
}
if !get_codings.is_empty() {
return Err(io::Error::other(format!(
"HTTP {status} {} {}: response carries Content-Encoding {get_codings:?} \
despite 'Accept-Encoding: identity' (RFC 9110 §12.5.3) — per §8.4 the \
body and byte-range metadata describe the coded form, not the media bytes",
self.uri, range
)));
}
if status == 200 && sent_if_range {
return Err(io::Error::other(format!(
"HTTP 200 {} {}: If-Range validator did not match — \
representation changed since HEAD (origin/cache mutation)",
self.uri, range
)));
}
let pos = self.pos;
let total_len = self.total_len;
let get_content_length = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let skip_prefix = if status == 200 { pos } else { 0 };
if status == 200 {
if let Some(cl) = get_content_length {
if cl != total_len {
return Err(io::Error::other(format!(
"HTTP 200 {} {}: Content-Length {cl} != HEAD-observed total {total_len} \
(RFC 9110 §8.6 — resource resized between HEAD and GET)",
self.uri, range
)));
}
}
}
if status == 206 {
let ct_raw = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if is_multipart_byteranges_content_type(ct_raw) {
return Err(io::Error::other(format!(
"HTTP 206 {} {}: server returned multipart/byteranges to a single-range \
request (RFC 9110 §15.3.7.2 MUST NOT). Content-Type: {ct_raw:?}",
self.uri, range
)));
}
let cr_raw = resp
.headers()
.get("content-range")
.ok_or_else(|| {
io::Error::other(format!(
"HTTP 206 {} {}: missing Content-Range",
self.uri, range
))
})?
.to_str()
.map_err(|_| {
io::Error::other(format!(
"HTTP 206 {} {}: non-ASCII Content-Range",
self.uri, range
))
})?
.to_owned();
let parsed = parse_byte_content_range(&cr_raw).map_err(|e| {
io::Error::other(format!(
"HTTP 206 {} {}: invalid Content-Range '{cr_raw}': {e}",
self.uri, range
))
})?;
if parsed.first != pos {
return Err(io::Error::other(format!(
"HTTP 206 {} {}: Content-Range first-byte-pos {} != requested pos {}",
self.uri, range, parsed.first, pos
)));
}
if let Some(complete) = parsed.complete {
if complete != total_len {
return Err(io::Error::other(format!(
"HTTP 206 {} {}: Content-Range complete-length {complete} != known total {total_len}",
self.uri, range
)));
}
}
if parsed.last >= total_len {
return Err(io::Error::other(format!(
"HTTP 206 {} {}: Content-Range last-byte-pos {} >= total {}",
self.uri, range, parsed.last, total_len
)));
}
if let Some(cl) = get_content_length {
let expected = parsed.last - parsed.first + 1;
if cl != expected {
return Err(io::Error::other(format!(
"HTTP 206 {} {}: Content-Length {cl} != Content-Range span {expected} \
(RFC 9110 §8.6)",
self.uri, range
)));
}
}
}
let mut reader: Box<dyn Read + Send> = Box::new(resp.into_body().into_reader());
if skip_prefix > 0 {
let mut remaining = skip_prefix;
let mut buf = [0u8; 8 * 1024];
while remaining > 0 {
let want = remaining.min(buf.len() as u64) as usize;
let n = reader.read(&mut buf[..want])?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"HTTP 200 {}: EOF after draining {} of {skip_prefix} prefix bytes",
self.uri,
skip_prefix - remaining
),
));
}
remaining -= n as u64;
}
}
self.body = Some(reader);
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ByteContentRange {
first: u64,
last: u64,
complete: Option<u64>,
}
fn parse_byte_content_range(s: &str) -> std::result::Result<ByteContentRange, &'static str> {
let s = s.trim();
let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
if !unit.eq_ignore_ascii_case("bytes") {
return Err("range unit is not 'bytes'");
}
let rest = rest.trim_start();
if rest.starts_with('*') {
return Err("unsatisfied-range ('*/N') not valid on 206");
}
let (range_part, complete_part) = rest
.split_once('/')
.ok_or("missing '/' before complete-length")?;
let (first_s, last_s) = range_part
.split_once('-')
.ok_or("missing '-' between first-byte-pos and last-byte-pos")?;
let first: u64 = first_s.parse().map_err(|_| "first-byte-pos is not a u64")?;
let last: u64 = last_s.parse().map_err(|_| "last-byte-pos is not a u64")?;
if last < first {
return Err("last-byte-pos < first-byte-pos");
}
let complete = if complete_part == "*" {
None
} else {
let c: u64 = complete_part
.parse()
.map_err(|_| "complete-length is not a u64 or '*'")?;
if c <= last {
return Err("complete-length <= last-byte-pos");
}
Some(c)
};
Ok(ByteContentRange {
first,
last,
complete,
})
}
fn parse_byte_unsatisfied_range(s: &str) -> std::result::Result<u64, &'static str> {
let s = s.trim();
let (unit, rest) = s.split_once(' ').ok_or("missing SP after range unit")?;
if !unit.eq_ignore_ascii_case("bytes") {
return Err("range unit is not 'bytes'");
}
let rest = rest.trim_start();
let complete_part = rest
.strip_prefix("*/")
.ok_or("not an unsatisfied-range ('*/N' expected)")?;
let complete: u64 = complete_part
.parse()
.map_err(|_| "complete-length is not a u64")?;
Ok(complete)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AcceptRanges {
Bytes,
None,
Other(Vec<String>),
Absent,
}
fn parse_accept_ranges(s: &str) -> AcceptRanges {
let mut tokens: Vec<String> = Vec::new();
let mut had_none = false;
let mut had_bytes = false;
for part in s.split(',') {
let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
if tok.is_empty() {
continue;
}
if !is_token(tok) {
continue;
}
let lower = tok.to_ascii_lowercase();
if lower == "bytes" {
had_bytes = true;
}
if lower == "none" {
had_none = true;
}
tokens.push(lower);
}
if tokens.is_empty() {
return AcceptRanges::Absent;
}
if had_bytes {
return AcceptRanges::Bytes;
}
if had_none && tokens.len() == 1 {
return AcceptRanges::None;
}
AcceptRanges::Other(tokens)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Vary {
Absent,
Wildcard,
Fields(Vec<String>),
}
fn parse_vary(s: &str) -> Vary {
let mut fields: Vec<String> = Vec::new();
for part in s.split(',') {
let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
if tok.is_empty() {
continue;
}
if tok == "*" {
return Vary::Wildcard;
}
if !is_token(tok) {
continue;
}
fields.push(tok.to_ascii_lowercase());
}
if fields.is_empty() {
Vary::Absent
} else {
Vary::Fields(fields)
}
}
fn non_identity_content_codings(s: &str) -> Vec<String> {
let mut out = Vec::new();
for part in s.split(',') {
let tok = part.trim_matches(|c: char| c == ' ' || c == '\t');
if tok.is_empty() {
continue;
}
let lower = tok.to_ascii_lowercase();
if lower == "identity" {
continue;
}
out.push(lower);
}
out
}
fn is_token(s: &str) -> bool {
!s.is_empty() && s.bytes().all(is_tchar)
}
fn normalize_obs_fold(s: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let bytes = s.as_bytes();
let mut first_fold: Option<usize> = None;
let mut i = 0;
while i + 2 < bytes.len() {
if bytes[i] == b'\r'
&& bytes[i + 1] == b'\n'
&& (bytes[i + 2] == b' ' || bytes[i + 2] == b'\t')
{
first_fold = Some(i);
break;
}
i += 1;
}
let start = match first_fold {
Some(p) => p,
None => return Cow::Borrowed(s),
};
let mut out: Vec<u8> = Vec::with_capacity(s.len());
out.extend_from_slice(&bytes[..start]);
let mut i = start;
while i < bytes.len() {
if i + 2 < bytes.len()
&& bytes[i] == b'\r'
&& bytes[i + 1] == b'\n'
&& (bytes[i + 2] == b' ' || bytes[i + 2] == b'\t')
{
out.push(b' ');
i += 2;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
match String::from_utf8(out) {
Ok(s2) => Cow::Owned(s2),
Err(_) => Cow::Borrowed(s),
}
}
#[allow(dead_code)]
fn unquote_string(s: &str) -> Option<std::borrow::Cow<'_, str>> {
use std::borrow::Cow;
let bytes = s.as_bytes();
if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
return None;
}
let inner = &bytes[1..bytes.len() - 1];
let mut i = 0;
let mut has_escape = false;
while i < inner.len() {
let b = inner[i];
if b == b'\\' {
let nxt = *inner.get(i + 1)?;
let ok = nxt == 0x09 || nxt == 0x20 || (0x21..=0x7E).contains(&nxt) || nxt >= 0x80;
if !ok {
return None;
}
has_escape = true;
i += 2;
} else {
let ok = b == 0x09
|| b == 0x20
|| b == 0x21
|| (0x23..=0x5B).contains(&b)
|| (0x5D..=0x7E).contains(&b)
|| b >= 0x80;
if !ok {
return None;
}
i += 1;
}
}
if !has_escape {
return Some(Cow::Borrowed(
std::str::from_utf8(inner).expect("inner slice is UTF-8 by construction"),
));
}
let mut out: Vec<u8> = Vec::with_capacity(inner.len());
let mut i = 0;
while i < inner.len() {
let b = inner[i];
if b == b'\\' {
out.push(inner[i + 1]);
i += 2;
} else {
out.push(b);
i += 1;
}
}
match String::from_utf8(out) {
Ok(decoded) => Some(Cow::Owned(decoded)),
Err(_) => None,
}
}
#[allow(dead_code)]
fn parse_comment(s: &str) -> Option<std::borrow::Cow<'_, str>> {
use std::borrow::Cow;
let bytes = s.as_bytes();
if bytes.len() < 2 || bytes[0] != b'(' || bytes[bytes.len() - 1] != b')' {
return None;
}
let inner = &bytes[1..bytes.len() - 1];
let mut i = 0usize;
let mut depth: u32 = 0; let mut has_escape = false;
while i < inner.len() {
let b = inner[i];
match b {
b'\\' => {
let nxt = *inner.get(i + 1)?;
let ok = nxt == 0x09 || nxt == 0x20 || (0x21..=0x7E).contains(&nxt) || nxt >= 0x80;
if !ok {
return None;
}
has_escape = true;
i += 2;
}
b'(' => {
depth += 1;
i += 1;
}
b')' => {
if depth == 0 {
return None;
}
depth -= 1;
i += 1;
}
0x09 | 0x20 => i += 1,
0x21..=0x27 | 0x2A..=0x5B | 0x5D..=0x7E => i += 1,
_ if b >= 0x80 => i += 1, _ => return None, }
}
if depth != 0 {
return None;
}
if !has_escape {
return Some(Cow::Borrowed(
std::str::from_utf8(inner).expect("inner slice is UTF-8 by construction"),
));
}
let mut out: Vec<u8> = Vec::with_capacity(inner.len());
let mut i = 0usize;
while i < inner.len() {
let b = inner[i];
if b == b'\\' {
out.push(inner[i + 1]);
i += 2;
} else {
out.push(b);
i += 1;
}
}
match String::from_utf8(out) {
Ok(decoded) => Some(Cow::Owned(decoded)),
Err(_) => None,
}
}
#[allow(dead_code)]
fn parse_parameters(s: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let bytes = s.as_bytes();
let mut i = 0usize;
loop {
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i < bytes.len() && bytes[i] == b';' {
i += 1;
continue;
}
if i >= bytes.len() {
break;
}
let slot_start = i;
let mut in_quote = false;
while i < bytes.len() {
let b = bytes[i];
if in_quote {
if b == b'\\' {
if i + 1 < bytes.len() {
i += 2;
continue;
}
i += 1;
} else if b == b'"' {
in_quote = false;
i += 1;
} else {
i += 1;
}
} else if b == b';' {
break;
} else if b == b'"' {
in_quote = true;
i += 1;
} else {
i += 1;
}
}
let slot = &s[slot_start..i];
if let Some(pair) = parse_one_parameter(slot) {
out.push(pair);
}
if i < bytes.len() && bytes[i] == b';' {
i += 1;
}
}
out
}
fn parse_one_parameter(slot: &str) -> Option<(String, String)> {
let slot = slot.trim_matches(|c: char| c == ' ' || c == '\t');
if slot.is_empty() {
return None;
}
let eq = slot.find('=')?;
let name = &slot[..eq];
let value = &slot[eq + 1..];
if name
.as_bytes()
.last()
.map(|&b| b == b' ' || b == b'\t')
.unwrap_or(false)
{
return None;
}
if value
.as_bytes()
.first()
.map(|&b| b == b' ' || b == b'\t')
.unwrap_or(false)
{
return None;
}
if !is_token(name) {
return None;
}
let decoded_value = if value.starts_with('"') {
unquote_string(value)?.into_owned()
} else if is_token(value) {
value.to_owned()
} else {
return None;
};
Some((name.to_ascii_lowercase(), decoded_value))
}
type MediaType = (String, String, Vec<(String, String)>);
#[allow(dead_code)]
fn parse_media_type(s: &str) -> Option<MediaType> {
let s = s.trim_matches([' ', '\t']);
if s.is_empty() {
return None;
}
let (media, params_tail) = match s.split_once(';') {
Some((m, rest)) => (m, Some(rest)),
None => (s, None),
};
let media = media.trim_end_matches([' ', '\t']);
let (ty, sub) = media.split_once('/')?;
if !is_token(ty) || !is_token(sub) {
return None;
}
let ty = ty.to_ascii_lowercase();
let sub = sub.to_ascii_lowercase();
let params = match params_tail {
Some(rest) => parse_parameters(rest),
None => Vec::new(),
};
Some((ty, sub, params))
}
fn parse_entity_tag(s: &str) -> Option<(bool, String)> {
let s = s.trim();
let (is_weak, body) = if let Some(rest) = s.strip_prefix("W/") {
(true, rest)
} else {
(false, s)
};
let body = body.strip_prefix('"')?;
let body = body.strip_suffix('"')?;
for b in body.bytes() {
let ok = b == 0x21 || (0x23..=0x7E).contains(&b) || b >= 0x80;
if !ok {
return None;
}
}
Some((is_weak, s.to_owned()))
}
fn parse_imf_fixdate(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
let s = s.trim();
let bytes = s.as_bytes();
if bytes.len() < 29 {
return None;
}
if bytes[3] != b',' || bytes[4] != b' ' {
return None;
}
let day: u8 = s.get(5..7)?.parse().ok()?;
if bytes[7] != b' ' {
return None;
}
let mon = parse_month_abbr(s.get(8..11)?)?;
if bytes[11] != b' ' {
return None;
}
let year: i32 = s.get(12..16)?.parse().ok()?;
if bytes[16] != b' ' {
return None;
}
let hour: u8 = s.get(17..19)?.parse().ok()?;
if bytes[19] != b':' {
return None;
}
let minute: u8 = s.get(20..22)?.parse().ok()?;
if bytes[22] != b':' {
return None;
}
let second: u8 = s.get(23..25)?.parse().ok()?;
if &s[25..] != " GMT" {
return None;
}
Some((year, mon, day, hour, minute, second))
}
fn parse_month_abbr(s: &str) -> Option<u8> {
match s {
"Jan" => Some(1),
"Feb" => Some(2),
"Mar" => Some(3),
"Apr" => Some(4),
"May" => Some(5),
"Jun" => Some(6),
"Jul" => Some(7),
"Aug" => Some(8),
"Sep" => Some(9),
"Oct" => Some(10),
"Nov" => Some(11),
"Dec" => Some(12),
_ => None,
}
}
fn parse_rfc850_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
let s = s.trim();
let (wkd, rest) = s.split_once(", ")?;
match wkd {
"Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday" => {}
_ => return None,
}
let bytes = rest.as_bytes();
if bytes.len() != 22 {
return None;
}
let day: u8 = rest.get(0..2)?.parse().ok()?;
if bytes[2] != b'-' {
return None;
}
let mon = parse_month_abbr(rest.get(3..6)?)?;
if bytes[6] != b'-' {
return None;
}
let yy: u32 = rest.get(7..9)?.parse().ok()?;
if bytes[9] != b' ' {
return None;
}
let hour: u8 = rest.get(10..12)?.parse().ok()?;
if bytes[12] != b':' {
return None;
}
let minute: u8 = rest.get(13..15)?.parse().ok()?;
if bytes[15] != b':' {
return None;
}
let second: u8 = rest.get(16..18)?.parse().ok()?;
if &rest[18..] != " GMT" {
return None;
}
Some((rfc850_expand_year(yy), mon, day, hour, minute, second))
}
fn rfc850_expand_year(yy: u32) -> i32 {
const REF_YEAR: i32 = 2026;
let century = (REF_YEAR / 100) * 100;
let candidate = century + yy as i32;
if candidate - REF_YEAR > 50 {
candidate - 100
} else {
candidate
}
}
fn parse_asctime_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
let s = s.trim();
let bytes = s.as_bytes();
if bytes.len() != 24 {
return None;
}
let wkd = s.get(0..3)?;
match wkd {
"Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun" => {}
_ => return None,
}
if bytes[3] != b' ' {
return None;
}
let mon = parse_month_abbr(s.get(4..7)?)?;
if bytes[7] != b' ' {
return None;
}
let day_field = s.get(8..10)?;
let day: u8 = if let Some(b) = day_field.strip_prefix(' ') {
if b.len() != 1 || !b.as_bytes()[0].is_ascii_digit() {
return None;
}
b.parse().ok()?
} else {
day_field.parse().ok()?
};
if bytes[10] != b' ' {
return None;
}
let hour: u8 = s.get(11..13)?.parse().ok()?;
if bytes[13] != b':' {
return None;
}
let minute: u8 = s.get(14..16)?.parse().ok()?;
if bytes[16] != b':' {
return None;
}
let second: u8 = s.get(17..19)?.parse().ok()?;
if bytes[19] != b' ' {
return None;
}
let year: i32 = s.get(20..24)?.parse().ok()?;
Some((year, mon, day, hour, minute, second))
}
fn parse_http_date(s: &str) -> Option<(i32, u8, u8, u8, u8, u8)> {
parse_imf_fixdate(s)
.or_else(|| parse_rfc850_date(s))
.or_else(|| parse_asctime_date(s))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryAfter {
Delay(Duration),
Date {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
},
}
pub fn parse_retry_after(s: &str) -> Option<RetryAfter> {
let s = s.trim();
if s.is_empty() {
return None;
}
if s.bytes().all(|b| b.is_ascii_digit()) {
if let Ok(n) = s.parse::<u64>() {
return Some(RetryAfter::Delay(Duration::from_secs(n)));
}
return None;
}
let (year, month, day, hour, minute, second) = parse_http_date(s)?;
Some(RetryAfter::Date {
year,
month,
day,
hour,
minute,
second,
})
}
fn format_retry_after_hint(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
match parse_retry_after(trimmed) {
Some(RetryAfter::Delay(d)) => format!(" (Retry-After: {} s)", d.as_secs()),
Some(RetryAfter::Date {
year,
month,
day,
hour,
minute,
second,
}) => {
format!(
" (Retry-After: {year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02} UTC)"
)
}
None => format!(" (Retry-After: {trimmed:?}, unparseable per RFC 9110 §10.2.3)"),
}
}
fn is_multipart_byteranges_content_type(s: &str) -> bool {
let s = s.trim();
let media = match s.split_once(';') {
Some((m, _)) => m.trim_end(),
None => s,
};
media.eq_ignore_ascii_case("multipart/byteranges")
}
fn imf_seconds(d: (i32, u8, u8, u8, u8, u8)) -> i64 {
let (y, mo, da, h, mi, s) = d;
const CUM: [u16; 13] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365];
let day_of_year = CUM[(mo - 1) as usize] as i64 + (da as i64 - 1);
let day_index = (y as i64 - 2000) * 365 + day_of_year;
day_index * 86_400 + (h as i64) * 3_600 + (mi as i64) * 60 + s as i64
}
fn derive_strong_validator(
etag: Option<&str>,
last_modified: Option<&str>,
date: Option<&str>,
) -> Option<StrongValidator> {
if let Some(raw) = etag {
if let Some((is_weak, wire)) = parse_entity_tag(raw) {
if !is_weak {
return Some(StrongValidator::Etag(wire));
}
}
}
if let (Some(lm), Some(dt)) = (last_modified, date) {
if let (Some(lm_p), Some(dt_p)) = (parse_http_date(lm), parse_http_date(dt)) {
if imf_seconds(dt_p) > imf_seconds(lm_p) {
return Some(StrongValidator::LastModified(lm.to_owned()));
}
}
}
None
}
impl Read for HttpSource {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
if out.is_empty() {
return Ok(0);
}
if self.pos >= self.total_len {
return Ok(0);
}
loop {
if self.body.is_none() {
self.issue_range()?;
}
let body = self.body.as_mut().expect("body just issued");
let n = body.read(out)?;
if n > 0 {
self.pos += n as u64;
return Ok(n);
}
self.body = None;
if self.pos >= self.total_len {
return Ok(0);
}
}
}
}
impl Seek for HttpSource {
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
let new_pos: u64 = match from {
SeekFrom::Start(n) => n,
SeekFrom::Current(d) => add_signed(self.pos, d)?,
SeekFrom::End(d) => add_signed(self.total_len, d)?,
};
if new_pos > self.total_len {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek past end"));
}
if new_pos != self.pos {
self.body = None;
self.pos = new_pos;
}
Ok(new_pos)
}
}
fn add_signed(base: u64, delta: i64) -> io::Result<u64> {
if delta >= 0 {
base.checked_add(delta as u64)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek overflow"))
} else {
base.checked_sub(delta.unsigned_abs())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek before start"))
}
}
pub const DELTA_SECONDS_MAX: u64 = 2_147_483_648;
fn parse_delta_seconds(arg: &str) -> Option<u64> {
if arg.is_empty() || !arg.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(arg.parse::<u64>().unwrap_or(DELTA_SECONDS_MAX))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CacheControl {
pub max_age: Option<u64>,
pub s_maxage: Option<u64>,
pub max_stale: Option<Option<u64>>,
pub min_fresh: Option<u64>,
pub no_cache: bool,
pub no_cache_fields: Vec<String>,
pub no_store: bool,
pub no_transform: bool,
pub only_if_cached: bool,
pub must_revalidate: bool,
pub must_understand: bool,
pub private: bool,
pub private_fields: Vec<String>,
pub proxy_revalidate: bool,
pub public: bool,
pub extensions: Vec<(String, Option<String>)>,
}
pub fn parse_cache_control(s: &str) -> CacheControl {
let normalized = normalize_obs_fold(s);
let mut cc = CacheControl::default();
for elem in split_directive_list(&normalized) {
let (raw_name, raw_arg) = match elem.split_once('=') {
Some((n, v)) => (n, Some(v)),
None => (elem, None),
};
let name = raw_name.trim_matches(|c: char| c == ' ' || c == '\t');
if !is_token(name) {
continue;
}
let lname = name.to_ascii_lowercase();
let arg: Option<String> = match raw_arg {
None => None,
Some(v) => {
let v = v.trim_matches(|c: char| c == ' ' || c == '\t');
if v.starts_with('"') {
unquote_string(v).map(|c| c.into_owned())
} else if is_token(v) {
Some(v.to_owned())
} else {
None
}
}
};
apply_directive(&mut cc, &lname, arg);
}
cc
}
fn apply_directive(cc: &mut CacheControl, name: &str, arg: Option<String>) {
fn set_delta(slot: &mut Option<u64>, arg: &Option<String>) {
if slot.is_none() {
if let Some(a) = arg {
*slot = parse_delta_seconds(a);
}
}
}
fn field_names(arg: &str) -> Vec<String> {
arg.split(',')
.map(|f| f.trim_matches(|c: char| c == ' ' || c == '\t'))
.filter(|f| is_token(f))
.map(|f| f.to_ascii_lowercase())
.collect()
}
match name {
"max-age" => set_delta(&mut cc.max_age, &arg),
"s-maxage" => set_delta(&mut cc.s_maxage, &arg),
"min-fresh" => set_delta(&mut cc.min_fresh, &arg),
"max-stale" => {
if cc.max_stale.is_none() {
cc.max_stale = Some(arg.as_deref().and_then(parse_delta_seconds));
}
}
"no-cache" => {
match arg {
Some(a) => {
let names = field_names(&a);
if cc.no_cache_fields.is_empty() {
cc.no_cache_fields = names;
}
}
None => cc.no_cache = true,
}
}
"private" => match arg {
Some(a) => {
let names = field_names(&a);
if cc.private_fields.is_empty() {
cc.private_fields = names;
}
}
None => cc.private = true,
},
"no-store" => cc.no_store = true,
"no-transform" => cc.no_transform = true,
"only-if-cached" => cc.only_if_cached = true,
"must-revalidate" => cc.must_revalidate = true,
"must-understand" => cc.must_understand = true,
"proxy-revalidate" => cc.proxy_revalidate = true,
"public" => cc.public = true,
_ => cc.extensions.push((name.to_owned(), arg)),
}
}
fn split_directive_list(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out: Vec<&str> = Vec::new();
let mut start = 0usize;
let mut in_quote = false;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if in_quote {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
} else if b == b'"' {
in_quote = false;
}
} else if b == b'"' {
in_quote = true;
} else if b == b',' {
let elem = s[start..i].trim_matches(|c: char| c == ' ' || c == '\t');
if !elem.is_empty() {
out.push(elem);
}
start = i + 1;
}
i += 1;
}
let elem = s[start..].trim_matches(|c: char| c == ' ' || c == '\t');
if !elem.is_empty() {
out.push(elem);
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Challenge {
pub scheme: String,
pub token68: Option<String>,
pub params: Vec<(String, String)>,
}
pub fn parse_www_authenticate(s: &str) -> Vec<Challenge> {
let normalized = normalize_obs_fold(s);
let elements = split_directive_list(&normalized);
let mut out: Vec<Challenge> = Vec::new();
for elem in elements {
if let Some((scheme, rest)) = split_challenge_head(elem) {
let mut ch = Challenge {
scheme: scheme.to_ascii_lowercase(),
token68: None,
params: Vec::new(),
};
if !rest.is_empty() {
if let Some(t68) = as_token68(rest) {
ch.token68 = Some(t68.to_owned());
} else if let Some(pair) = parse_one_auth_param(rest) {
ch.params.push(pair);
}
}
out.push(ch);
} else {
if let Some(cur) = out.last_mut() {
if cur.token68.is_none() {
if let Some(pair) = parse_one_auth_param(elem) {
cur.params.push(pair);
}
}
}
}
}
out
}
fn split_challenge_head(elem: &str) -> Option<(&str, &str)> {
let bytes = elem.as_bytes();
let mut i = 0usize;
while i < bytes.len() && is_tchar(bytes[i]) {
i += 1;
}
if i == 0 {
return None;
}
let head = &elem[..i];
let mut j = i;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if j >= bytes.len() {
return Some((head, ""));
}
if bytes[j] == b'=' {
return None;
}
if j == i {
return None;
}
let rest = elem[j..].trim_matches(|c: char| c == ' ' || c == '\t');
Some((head, rest))
}
fn as_token68(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
if bytes.is_empty() {
return None;
}
let mut i = 0usize;
while i < bytes.len() && is_token68_body(bytes[i]) {
i += 1;
}
if i == 0 {
return None;
}
if bytes[i..].iter().all(|&b| b == b'=') {
Some(s)
} else {
None
}
}
fn parse_one_auth_param(slot: &str) -> Option<(String, String)> {
let slot = slot.trim_matches(|c: char| c == ' ' || c == '\t');
let eq = slot.find('=')?;
let name = slot[..eq].trim_matches(|c: char| c == ' ' || c == '\t');
let value = slot[eq + 1..].trim_matches(|c: char| c == ' ' || c == '\t');
if !is_token(name) {
return None;
}
let lname = name.to_ascii_lowercase();
if value.starts_with('"') {
let decoded = unquote_string(value)?.into_owned();
Some((lname, decoded))
} else if is_token(value) {
Some((lname, value.to_owned()))
} else {
None
}
}
fn is_tchar(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
fn is_token68_body(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
}
#[cfg(feature = "fuzz")]
#[doc(hidden)]
pub mod __fuzz {
pub fn parse_byte_content_range(s: &str) -> bool {
super::parse_byte_content_range(s).is_ok()
}
pub fn parse_byte_unsatisfied_range(s: &str) -> bool {
super::parse_byte_unsatisfied_range(s).is_ok()
}
pub fn parse_entity_tag(s: &str) -> bool {
super::parse_entity_tag(s).is_some()
}
pub fn parse_imf_fixdate(s: &str) -> bool {
super::parse_imf_fixdate(s).is_some()
}
pub fn parse_rfc850_date(s: &str) -> bool {
super::parse_rfc850_date(s).is_some()
}
pub fn parse_asctime_date(s: &str) -> bool {
super::parse_asctime_date(s).is_some()
}
pub fn parse_http_date(s: &str) -> bool {
super::parse_http_date(s).is_some()
}
pub fn parse_retry_after(s: &str) -> bool {
super::parse_retry_after(s).is_some()
}
pub fn derive_strong_validator(etag: Option<&str>, lm: Option<&str>, date: Option<&str>) {
let _ = super::derive_strong_validator(etag, lm, date);
}
pub fn parse_accept_ranges(s: &str) -> u8 {
match super::parse_accept_ranges(s) {
super::AcceptRanges::Bytes => 0,
super::AcceptRanges::None => 1,
super::AcceptRanges::Other(_) => 2,
super::AcceptRanges::Absent => 3,
}
}
pub fn parse_vary(s: &str) -> u8 {
match super::parse_vary(s) {
super::Vary::Absent => 0,
super::Vary::Wildcard => 1,
super::Vary::Fields(_) => 2,
}
}
pub fn is_multipart_byteranges_content_type(s: &str) -> bool {
super::is_multipart_byteranges_content_type(s)
}
pub fn format_retry_after_hint(s: &str) -> String {
super::format_retry_after_hint(s)
}
pub fn normalize_obs_fold(s: &str) -> String {
super::normalize_obs_fold(s).into_owned()
}
pub fn unquote_string(s: &str) -> Option<String> {
super::unquote_string(s).map(|c| c.into_owned())
}
pub fn parse_comment(s: &str) -> Option<String> {
super::parse_comment(s).map(|c| c.into_owned())
}
pub fn parse_parameters(s: &str) -> usize {
super::parse_parameters(s).len()
}
pub fn non_identity_content_codings(s: &str) -> usize {
super::non_identity_content_codings(s).len()
}
pub fn parse_media_type(s: &str) -> Option<usize> {
super::parse_media_type(s).map(|(_, _, p)| p.len())
}
pub fn parse_cache_control(s: &str) -> usize {
let cc = super::parse_cache_control(s);
let mut n = cc.extensions.len()
+ cc.no_cache_fields.len()
+ cc.private_fields.len()
+ usize::from(cc.max_age.is_some())
+ usize::from(cc.s_maxage.is_some())
+ usize::from(cc.max_stale.is_some())
+ usize::from(cc.min_fresh.is_some());
for flag in [
cc.no_cache,
cc.no_store,
cc.no_transform,
cc.only_if_cached,
cc.must_revalidate,
cc.must_understand,
cc.private,
cc.proxy_revalidate,
cc.public,
] {
n += usize::from(flag);
}
n
}
pub fn parse_www_authenticate(s: &str) -> usize {
super::parse_www_authenticate(s).len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn register_via_runtime_context_installs_http_and_https_schemes() {
let mut ctx = RuntimeContext::new();
register(&mut ctx);
let schemes: BTreeSet<&str> = ctx.sources.schemes().collect();
assert!(
schemes.contains("http"),
"register did not install http; got {schemes:?}"
);
assert!(
schemes.contains("https"),
"register did not install https; got {schemes:?}"
);
}
#[test]
fn register_source_installs_http_and_https_schemes_on_bare_registry() {
let mut reg = SourceRegistry::new();
register_source(&mut reg);
let schemes: BTreeSet<&str> = reg.schemes().collect();
assert!(schemes.contains("http"));
assert!(schemes.contains("https"));
}
#[test]
fn http_config_default_matches_documented_surface() {
let c = HttpConfig::default();
assert_eq!(c.max_redirects(), 10);
assert!(c.max_redirects_will_error());
assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::Never);
assert_eq!(c.user_agent(), None);
assert!(!c.https_only());
assert_eq!(c.timeout_global(), None);
assert_eq!(c.timeout_connect(), None);
}
#[test]
fn http_config_builder_threads_values_through() {
let c = HttpConfig::builder()
.max_redirects(3)
.max_redirects_will_error(false)
.redirect_auth_policy(RedirectAuthPolicy::SameHost)
.user_agent("oxideav-test/0.0")
.https_only(true)
.timeout_global(Some(Duration::from_secs(30)))
.timeout_connect(Some(Duration::from_secs(5)))
.build();
assert_eq!(c.max_redirects(), 3);
assert!(!c.max_redirects_will_error());
assert_eq!(c.redirect_auth_policy(), RedirectAuthPolicy::SameHost);
assert_eq!(c.user_agent(), Some("oxideav-test/0.0"));
assert!(c.https_only());
assert_eq!(c.timeout_global(), Some(Duration::from_secs(30)));
assert_eq!(c.timeout_connect(), Some(Duration::from_secs(5)));
}
#[test]
fn agent_from_config_succeeds_for_every_redirect_policy() {
for p in [RedirectAuthPolicy::Never, RedirectAuthPolicy::SameHost] {
let c = HttpConfig::builder().redirect_auth_policy(p).build();
let _ = agent_from(&c);
}
}
#[test]
fn http_config_clone_is_cheap_and_independent() {
let a = HttpConfig::builder()
.user_agent("alpha/1.0")
.max_redirects(7)
.build();
let b = a.clone();
assert_eq!(a.user_agent(), b.user_agent());
assert_eq!(a.max_redirects(), b.max_redirects());
}
#[test]
fn install_default_config_is_one_shot() {
let first = install_default_config(HttpConfig::default());
let _ = agent();
let second = install_default_config(HttpConfig::default());
assert!(
first.is_err() || second.is_err(),
"expected at least one install to be rejected once the agent is built (first={first:?}, second={second:?})"
);
assert_eq!(second, Err(ConfigAlreadyInstalled));
}
#[test]
fn config_already_installed_implements_error_trait() {
let e: &dyn std::error::Error = &ConfigAlreadyInstalled;
assert!(e.to_string().contains("already"));
}
#[test]
fn content_range_parses_canonical_form() {
let r = parse_byte_content_range("bytes 42-1233/1234").unwrap();
assert_eq!(
r,
ByteContentRange {
first: 42,
last: 1233,
complete: Some(1234),
}
);
}
#[test]
fn content_range_accepts_star_complete_length() {
let r = parse_byte_content_range("bytes 42-1233/*").unwrap();
assert_eq!(r.first, 42);
assert_eq!(r.last, 1233);
assert_eq!(r.complete, None);
}
#[test]
fn content_range_accepts_full_resource_first_last() {
let r = parse_byte_content_range("bytes 0-0/1").unwrap();
assert_eq!(r.first, 0);
assert_eq!(r.last, 0);
assert_eq!(r.complete, Some(1));
}
#[test]
fn content_range_unit_is_case_insensitive() {
assert!(parse_byte_content_range("BYTES 0-9/10").is_ok());
assert!(parse_byte_content_range("Bytes 0-9/10").is_ok());
}
#[test]
fn content_range_rejects_unknown_unit() {
assert!(parse_byte_content_range("frames 0-9/100").is_err());
}
#[test]
fn content_range_rejects_last_lt_first() {
assert!(parse_byte_content_range("bytes 100-50/200").is_err());
}
#[test]
fn content_range_rejects_complete_le_last() {
assert!(parse_byte_content_range("bytes 0-100/100").is_err());
assert!(parse_byte_content_range("bytes 0-100/50").is_err());
}
#[test]
fn content_range_rejects_unsatisfied_payload_on_206() {
assert!(parse_byte_content_range("bytes */1234").is_err());
}
#[test]
fn unsatisfied_range_parses_canonical_form() {
let n = parse_byte_unsatisfied_range("bytes */1234").unwrap();
assert_eq!(n, 1234);
}
#[test]
fn unsatisfied_range_unit_is_case_insensitive() {
assert_eq!(
parse_byte_unsatisfied_range("BYTES */47022").unwrap(),
47022
);
assert_eq!(
parse_byte_unsatisfied_range("Bytes */47022").unwrap(),
47022
);
}
#[test]
fn unsatisfied_range_rejects_unknown_unit() {
assert!(parse_byte_unsatisfied_range("frames */1234").is_err());
}
#[test]
fn unsatisfied_range_rejects_range_resp_form() {
assert!(parse_byte_unsatisfied_range("bytes 0-9/10").is_err());
assert!(parse_byte_unsatisfied_range("bytes 42-1233/1234").is_err());
}
#[test]
fn unsatisfied_range_rejects_malformed() {
for bad in [
"",
"bytes",
"bytes */", "bytes */x", "bytes /1234",
"*/1234", ] {
assert!(
parse_byte_unsatisfied_range(bad).is_err(),
"expected reject for {bad:?}"
);
}
}
#[test]
fn content_range_rejects_malformed_strings() {
for bad in [
"",
"bytes",
"bytes 0-9", "bytes 09/10", "bytes a-9/10", "bytes 0-b/10", "bytes 0-9/x", "bytes 18446744073709551616-0/10", "0-9/10", ] {
assert!(
parse_byte_content_range(bad).is_err(),
"expected reject for {bad:?}"
);
}
}
use std::io::Write as _;
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
fn spawn_server(
head_resp: &'static [u8],
get_resp: &'static [u8],
) -> (String, mpsc::Receiver<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<()>();
thread::spawn(move || {
for _ in 0..4 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 4096];
use std::io::Read as _;
let n = stream.read(&mut buf).unwrap_or(0);
if n == 0 {
continue;
}
let req = &buf[..n];
let resp = if req.starts_with(b"HEAD ") {
head_resp
} else {
get_resp
};
let _ = stream.write_all(resp);
let _ = stream.flush();
}
let _ = tx.send(());
});
(format!("http://127.0.0.1:{port}/x"), rx)
}
const HEAD_10B_BYTES: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes\r\n\
Connection: close\r\n\
\r\n";
fn make_get_206(content_range: &str, body: &[u8]) -> Vec<u8> {
let mut v = format!(
"HTTP/1.1 206 Partial Content\r\n\
Content-Length: {}\r\n\
Content-Range: {content_range}\r\n\
Connection: close\r\n\
\r\n",
body.len()
)
.into_bytes();
v.extend_from_slice(body);
v
}
#[test]
fn local_server_canonical_206_succeeds() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn local_server_206_without_content_range_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
assert!(
err.to_string().contains("missing Content-Range"),
"wrong error: {err}"
);
}
#[test]
fn local_server_206_with_wrong_first_pos_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 7\r\n\
Content-Range: bytes 3-9/10\r\n\
Connection: close\r\n\
\r\n\
3456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
assert!(
err.to_string().contains("first-byte-pos"),
"wrong error: {err}"
);
}
#[test]
fn local_server_206_with_resource_resize_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/20\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
assert!(
err.to_string().contains("complete-length"),
"wrong error: {err}"
);
}
#[test]
fn local_server_206_with_star_complete_is_accepted() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/*\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn local_server_200_after_seek_drops_prefix() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
let mut buf = [0u8; 6];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"456789");
}
#[test]
fn local_server_416_with_unsatisfied_range_surfaces_complete_length() {
static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
Content-Length: 0\r\n\
Content-Range: bytes */5\r\n\
Connection: close\r\n\
\r\n";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
let mut buf = [0u8; 1];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("416"), "wrong error (no 416): {msg}");
assert!(
msg.contains("complete-length 5"),
"wrong error (no 'complete-length 5'): {msg}"
);
assert!(
msg.contains("HEAD observed 10"),
"wrong error (no 'HEAD observed 10'): {msg}"
);
}
#[test]
fn local_server_416_without_content_range_still_errors_cleanly() {
static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
let mut buf = [0u8; 1];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("416"), "wrong error (no 416): {msg}");
assert!(
msg.contains("no Content-Range"),
"wrong error (no 'no Content-Range'): {msg}"
);
}
#[test]
fn local_server_416_with_invalid_content_range_reports_parse_error() {
static GET: &[u8] = b"HTTP/1.1 416 Range Not Satisfiable\r\n\
Content-Length: 0\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(8)).unwrap();
let mut buf = [0u8; 1];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("416"), "wrong error (no 416): {msg}");
assert!(
msg.contains("invalid Content-Range"),
"wrong error (no 'invalid Content-Range'): {msg}"
);
}
#[allow(dead_code)]
fn _keep_helper() {
let _ = make_get_206("bytes 0-9/10", b"0123456789");
}
#[test]
fn entity_tag_parses_strong_tag() {
let (weak, wire) = parse_entity_tag("\"xyzzy\"").unwrap();
assert!(!weak);
assert_eq!(wire, "\"xyzzy\"");
}
#[test]
fn entity_tag_parses_weak_tag() {
let (weak, wire) = parse_entity_tag("W/\"xyzzy\"").unwrap();
assert!(weak);
assert_eq!(wire, "W/\"xyzzy\"");
}
#[test]
fn entity_tag_parses_empty_opaque() {
let (weak, wire) = parse_entity_tag("\"\"").unwrap();
assert!(!weak);
assert_eq!(wire, "\"\"");
}
#[test]
fn entity_tag_rejects_unquoted_value() {
assert!(parse_entity_tag("xyzzy").is_none());
assert!(parse_entity_tag("W/xyzzy").is_none());
}
#[test]
fn entity_tag_weak_marker_is_case_sensitive() {
assert!(parse_entity_tag("w/\"xyzzy\"").is_none());
}
#[test]
fn entity_tag_rejects_disallowed_inner_bytes() {
assert!(parse_entity_tag("\"hello\"world\"").is_none());
assert!(parse_entity_tag("\"tab\there\"").is_none()); }
#[test]
fn imf_fixdate_parses_canonical_example() {
let d = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
assert_eq!(d, (1994, 11, 6, 8, 49, 37));
}
#[test]
fn imf_fixdate_rejects_non_imf_forms() {
assert!(parse_imf_fixdate("Sunday, 06-Nov-94 08:49:37 GMT").is_none());
assert!(parse_imf_fixdate("Sun Nov 6 08:49:37 1994").is_none());
assert!(parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37").is_none());
assert!(parse_imf_fixdate("Sun, 06 Foo 1994 08:49:37 GMT").is_none());
}
#[test]
fn rfc850_date_parses_canonical_example() {
let d = parse_rfc850_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
assert_eq!(d, (1994, 11, 6, 8, 49, 37));
}
#[test]
fn rfc850_date_parses_every_long_weekday_name() {
for wkd in [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] {
let s = format!("{wkd}, 01-Jan-00 00:00:00 GMT");
assert!(parse_rfc850_date(&s).is_some(), "rejected {s:?}");
}
}
#[test]
fn rfc850_date_window_expands_year_per_section_5_6_7() {
let yr = |s: &str| parse_rfc850_date(s).unwrap().0;
assert_eq!(yr("Monday, 01-Jan-26 00:00:00 GMT"), 2026);
assert_eq!(yr("Monday, 01-Jan-76 00:00:00 GMT"), 2076);
assert_eq!(yr("Monday, 01-Jan-77 00:00:00 GMT"), 1977);
assert_eq!(yr("Monday, 01-Jan-00 00:00:00 GMT"), 2000);
assert_eq!(yr("Monday, 01-Jan-99 00:00:00 GMT"), 1999);
}
#[test]
fn rfc850_date_rejects_short_weekday_names() {
assert!(parse_rfc850_date("Sun, 06-Nov-94 08:49:37 GMT").is_none());
}
#[test]
fn rfc850_date_rejects_malformed_strings() {
for bad in [
"",
"Sunday 06-Nov-94 08:49:37 GMT", "Sunday, 06-Nov-94 08:49:37", "Sunday, 06/Nov/94 08:49:37 GMT", "Sunday, 06-Foo-94 08:49:37 GMT", "Sunday, AB-Nov-94 08:49:37 GMT", "Sunday, 06-Nov-9X 08:49:37 GMT", "Sunday, 06-Nov-94 08:49:37 GMT", ] {
assert!(
parse_rfc850_date(bad).is_none(),
"expected reject for {bad:?}"
);
}
}
#[test]
fn asctime_date_parses_canonical_example() {
let d = parse_asctime_date("Sun Nov 6 08:49:37 1994").unwrap();
assert_eq!(d, (1994, 11, 6, 8, 49, 37));
}
#[test]
fn asctime_date_parses_two_digit_day() {
let d = parse_asctime_date("Mon Nov 30 23:59:59 1994").unwrap();
assert_eq!(d, (1994, 11, 30, 23, 59, 59));
}
#[test]
fn asctime_date_rejects_malformed_strings() {
for bad in [
"",
"Sun Nov 6 08:49:37 199", "Sun XYZ 6 08:49:37 1994", "Foo Nov 6 08:49:37 1994", "Sun Nov 6 08-49-37 1994", "Sun, Nov 6 08:49:37 1994", "Sun Nov 6 08:49:37 199", ] {
assert!(
parse_asctime_date(bad).is_none(),
"expected reject for {bad:?}"
);
}
}
#[test]
fn http_date_accepts_all_three_5_6_7_forms() {
assert!(parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").is_some()); assert!(parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").is_some()); assert!(parse_http_date("Sun Nov 6 08:49:37 1994").is_some()); }
#[test]
fn http_date_returns_same_components_across_forms() {
let imf = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
let rfc850 = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
let asctime = parse_http_date("Sun Nov 6 08:49:37 1994").unwrap();
assert_eq!(imf, rfc850);
assert_eq!(imf, asctime);
}
#[test]
fn http_date_rejects_garbage() {
for bad in ["", "not a date", "1994-11-06T08:49:37Z", "Sun, BAD"] {
assert!(
parse_http_date(bad).is_none(),
"expected reject for {bad:?}"
);
}
}
#[test]
fn derive_strong_validator_accepts_rfc850_dates() {
let v = derive_strong_validator(
None,
Some("Sunday, 06-Nov-94 08:49:37 GMT"),
Some("Sunday, 06-Nov-94 08:49:42 GMT"),
);
assert_eq!(
v,
Some(StrongValidator::LastModified(
"Sunday, 06-Nov-94 08:49:37 GMT".into()
))
);
}
#[test]
fn derive_strong_validator_accepts_asctime_dates() {
let v = derive_strong_validator(
None,
Some("Sun Nov 6 08:49:37 1994"),
Some("Sun Nov 6 08:49:42 1994"),
);
assert_eq!(
v,
Some(StrongValidator::LastModified(
"Sun Nov 6 08:49:37 1994".into()
))
);
}
#[test]
fn derive_strong_validator_accepts_mixed_forms_across_headers() {
let v = derive_strong_validator(
None,
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
Some("Sunday, 06-Nov-94 08:49:42 GMT"),
);
assert!(matches!(v, Some(StrongValidator::LastModified(_))));
}
#[test]
fn imf_seconds_orders_within_one_second_correctly() {
let lm = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
let dt_eq = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
let dt_p1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:38 GMT").unwrap();
let dt_m1 = parse_imf_fixdate("Sun, 06 Nov 1994 08:49:36 GMT").unwrap();
assert!(imf_seconds(dt_eq) == imf_seconds(lm));
assert!(imf_seconds(dt_p1) == imf_seconds(lm) + 1);
assert!(imf_seconds(dt_m1) == imf_seconds(lm) - 1);
}
#[test]
fn derive_strong_validator_prefers_strong_etag() {
let v = derive_strong_validator(
Some("\"xyz\""),
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
);
assert_eq!(v, Some(StrongValidator::Etag("\"xyz\"".into())));
}
#[test]
fn derive_strong_validator_skips_weak_etag_falls_to_strong_last_modified() {
let v = derive_strong_validator(
Some("W/\"xyz\""),
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
Some("Sun, 06 Nov 1994 08:49:42 GMT"),
);
assert_eq!(
v,
Some(StrongValidator::LastModified(
"Sun, 06 Nov 1994 08:49:37 GMT".into()
))
);
}
#[test]
fn derive_strong_validator_rejects_lm_when_date_within_one_second() {
let v = derive_strong_validator(
None,
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
);
assert_eq!(v, None);
}
#[test]
fn derive_strong_validator_rejects_lm_without_date() {
let v = derive_strong_validator(None, Some("Sun, 06 Nov 1994 08:49:37 GMT"), None);
assert_eq!(v, None);
}
#[test]
fn derive_strong_validator_handles_missing_headers() {
assert_eq!(derive_strong_validator(None, None, None), None);
}
#[test]
fn derive_strong_validator_skips_malformed_etag_falls_through() {
let v = derive_strong_validator(
Some("not-quoted"),
Some("Sun, 06 Nov 1994 08:49:37 GMT"),
Some("Sun, 06 Nov 1994 08:49:42 GMT"),
);
assert_eq!(
v,
Some(StrongValidator::LastModified(
"Sun, 06 Nov 1994 08:49:37 GMT".into()
))
);
}
#[test]
fn retry_after_parses_canonical_delay_seconds_form() {
assert_eq!(
parse_retry_after("120"),
Some(RetryAfter::Delay(Duration::from_secs(120)))
);
}
#[test]
fn retry_after_parses_zero_delay() {
assert_eq!(
parse_retry_after("0"),
Some(RetryAfter::Delay(Duration::from_secs(0)))
);
}
#[test]
fn retry_after_parses_large_delay_within_u64() {
assert_eq!(
parse_retry_after("86400"),
Some(RetryAfter::Delay(Duration::from_secs(86_400)))
);
}
#[test]
fn retry_after_trims_surrounding_whitespace() {
assert_eq!(
parse_retry_after(" 42 "),
Some(RetryAfter::Delay(Duration::from_secs(42)))
);
}
#[test]
fn retry_after_parses_imf_fixdate_form() {
assert_eq!(
parse_retry_after("Fri, 31 Dec 1999 23:59:59 GMT"),
Some(RetryAfter::Date {
year: 1999,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 59,
})
);
}
#[test]
fn retry_after_parses_rfc850_date_form() {
assert_eq!(
parse_retry_after("Sunday, 06-Nov-94 08:49:37 GMT"),
Some(RetryAfter::Date {
year: 1994,
month: 11,
day: 6,
hour: 8,
minute: 49,
second: 37,
})
);
}
#[test]
fn retry_after_parses_asctime_form() {
assert_eq!(
parse_retry_after("Sun Nov 6 08:49:37 1994"),
Some(RetryAfter::Date {
year: 1994,
month: 11,
day: 6,
hour: 8,
minute: 49,
second: 37,
})
);
}
#[test]
fn retry_after_rejects_empty_input() {
assert_eq!(parse_retry_after(""), None);
assert_eq!(parse_retry_after(" "), None);
}
#[test]
fn retry_after_rejects_signed_delay_seconds() {
assert_eq!(parse_retry_after("-1"), None);
assert_eq!(parse_retry_after("+5"), None);
}
#[test]
fn retry_after_rejects_decimal_or_hex_delay() {
assert_eq!(parse_retry_after("12.5"), None);
assert_eq!(parse_retry_after("0x10"), None);
assert_eq!(parse_retry_after("1_000"), None);
}
#[test]
fn retry_after_rejects_delay_with_trailing_garbage() {
assert_eq!(parse_retry_after("120s"), None);
assert_eq!(parse_retry_after("120 seconds"), None);
}
#[test]
fn retry_after_rejects_u64_overflow() {
assert_eq!(parse_retry_after("18446744073709551616"), None);
}
#[test]
fn retry_after_rejects_malformed_date() {
assert_eq!(parse_retry_after("never"), None);
assert_eq!(parse_retry_after("Tomorrow at noon"), None);
assert_eq!(parse_retry_after("Fri, 31 Dec 1999 23:59:59 UTC"), None);
}
#[test]
fn retry_after_disambiguates_digit_only_from_date() {
assert_eq!(
parse_retry_after("1994"),
Some(RetryAfter::Delay(Duration::from_secs(1994)))
);
}
#[test]
fn retry_after_examples_from_spec_appendix_round_trip() {
let a = parse_retry_after("Fri, 31 Dec 1999 23:59:59 GMT");
let b = parse_retry_after("120");
assert!(matches!(a, Some(RetryAfter::Date { year: 1999, .. })));
assert!(matches!(b, Some(RetryAfter::Delay(d)) if d == Duration::from_secs(120)));
}
fn spawn_server_capturing_get(
head_resp: &'static [u8],
get_resp: &'static [u8],
) -> (String, mpsc::Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<Vec<u8>>();
thread::spawn(move || {
for _ in 0..4 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 4096];
use std::io::Read as _;
let n = stream.read(&mut buf).unwrap_or(0);
if n == 0 {
continue;
}
let req = &buf[..n];
let resp = if req.starts_with(b"HEAD ") {
head_resp
} else {
let _ = tx.send(req.to_vec());
get_resp
};
let _ = stream.write_all(resp);
let _ = stream.flush();
}
});
(format!("http://127.0.0.1:{port}/x"), rx)
}
const HEAD_10B_WITH_STRONG_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes\r\n\
ETag: \"v1\"\r\n\
Connection: close\r\n\
\r\n";
#[test]
fn local_server_strong_etag_emits_if_range_header_on_get() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
let req = captured.recv().expect("captured GET request");
let s = String::from_utf8_lossy(&req);
assert!(
s.to_ascii_lowercase().contains("if-range: \"v1\""),
"GET did not carry If-Range; got:\n{s}"
);
}
const HEAD_10B_WITH_WEAK_ETAG: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes\r\n\
ETag: W/\"v1\"\r\n\
Connection: close\r\n\
\r\n";
#[test]
fn local_server_weak_etag_does_not_emit_if_range_header() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, captured) = spawn_server_capturing_get(HEAD_10B_WITH_WEAK_ETAG, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
let req = captured.recv().expect("captured GET request");
let s = String::from_utf8_lossy(&req);
assert!(
!s.to_ascii_lowercase().contains("if-range:"),
"GET unexpectedly carried If-Range with a weak ETag; got:\n{s}"
);
}
#[test]
fn local_server_200_with_if_range_set_is_fatal_mutation() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
ABCDEFGHIJ";
let (uri, _captured) = spawn_server_capturing_get(HEAD_10B_WITH_STRONG_ETAG, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("If-Range"),
"wrong error (no If-Range mention): {msg}"
);
assert!(
msg.contains("changed since HEAD"),
"wrong error (no mutation phrasing): {msg}"
);
}
#[test]
fn local_server_no_validator_still_drains_prefix_on_200() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
let mut buf = [0u8; 6];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"456789");
}
#[test]
fn local_server_200_fallback_content_length_mismatch_is_fatal() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 5\r\n\
Connection: close\r\n\
\r\n\
01234";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Content-Length 5"),
"wrong error (no 'Content-Length 5'): {msg}"
);
assert!(
msg.contains("HEAD-observed total 10"),
"wrong error (no HEAD-observed mention): {msg}"
);
assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
}
#[test]
fn local_server_200_fallback_without_content_length_is_accepted() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
std::io::Seek::seek(&mut src, SeekFrom::Start(4)).unwrap();
let mut buf = [0u8; 6];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"456789");
}
#[test]
fn local_server_206_content_length_mismatch_is_fatal() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 5\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
01234";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Content-Length 5"),
"wrong error (no 'Content-Length 5'): {msg}"
);
assert!(
msg.contains("Content-Range span 10"),
"wrong error (no 'Content-Range span 10'): {msg}"
);
assert!(msg.contains("§8.6"), "wrong error (no §8.6 cite): {msg}");
}
#[test]
fn local_server_206_matching_content_length_is_accepted() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn accept_ranges_bare_bytes_is_bytes() {
assert_eq!(parse_accept_ranges("bytes"), AcceptRanges::Bytes);
}
#[test]
fn accept_ranges_is_case_insensitive_on_unit_name() {
assert_eq!(parse_accept_ranges("BYTES"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges("Bytes"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges("byTeS"), AcceptRanges::Bytes);
}
#[test]
fn accept_ranges_explicit_none_is_distinct_from_absent() {
assert_eq!(parse_accept_ranges("none"), AcceptRanges::None);
assert_eq!(parse_accept_ranges("NONE"), AcceptRanges::None);
assert_eq!(parse_accept_ranges(""), AcceptRanges::Absent);
assert_eq!(parse_accept_ranges(" "), AcceptRanges::Absent);
}
#[test]
fn accept_ranges_list_with_bytes_anywhere_is_bytes() {
assert_eq!(parse_accept_ranges("bytes, foo"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges("foo, bytes"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges("foo,bytes,bar"), AcceptRanges::Bytes);
assert_eq!(
parse_accept_ranges(" bytes , baz-units "),
AcceptRanges::Bytes
);
}
#[test]
fn accept_ranges_unknown_units_only_is_other() {
match parse_accept_ranges("foo-unit") {
AcceptRanges::Other(v) => assert_eq!(v, vec!["foo-unit"]),
other => panic!("expected Other, got {other:?}"),
}
match parse_accept_ranges("Foo, Bar") {
AcceptRanges::Other(v) => assert_eq!(v, vec!["foo", "bar"]),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn accept_ranges_none_alongside_real_units_is_other_not_none() {
match parse_accept_ranges("none, foo") {
AcceptRanges::Other(v) => {
assert!(v.contains(&"none".to_string()));
assert!(v.contains(&"foo".to_string()));
}
other => panic!("expected Other for none+foo, got {other:?}"),
}
}
#[test]
fn accept_ranges_empty_list_elements_are_skipped() {
assert_eq!(parse_accept_ranges("bytes,,"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges(",bytes,"), AcceptRanges::Bytes);
assert_eq!(parse_accept_ranges(",,,"), AcceptRanges::Absent);
}
#[test]
fn accept_ranges_non_token_elements_are_skipped_not_fatal() {
assert_eq!(
parse_accept_ranges("bytes, foo bar baz"),
AcceptRanges::Bytes
);
assert_eq!(parse_accept_ranges("hello world"), AcceptRanges::Absent);
}
#[test]
fn is_token_accepts_tchar_classes() {
assert!(is_token("bytes"));
assert!(is_token("foo-bar"));
assert!(is_token("foo.bar"));
assert!(is_token("ABC123"));
assert!(is_token("!#$%&'*+-.^_`|~"));
assert!(!is_token(""));
assert!(!is_token("foo bar"));
assert!(!is_token("foo,bar"));
assert!(!is_token("foo/bar"));
assert!(!is_token("\"quoted\""));
}
#[test]
fn vary_absent_on_empty_or_whitespace() {
assert_eq!(parse_vary(""), Vary::Absent);
assert_eq!(parse_vary(" "), Vary::Absent);
assert_eq!(parse_vary(",,,"), Vary::Absent);
assert_eq!(parse_vary(" , , "), Vary::Absent);
}
#[test]
fn vary_wildcard_bare_and_in_list() {
assert_eq!(parse_vary("*"), Vary::Wildcard);
assert_eq!(parse_vary("Accept-Encoding, *"), Vary::Wildcard);
assert_eq!(parse_vary("*, Accept-Language"), Vary::Wildcard);
assert_eq!(parse_vary(" * "), Vary::Wildcard);
}
#[test]
fn vary_wildcard_short_circuits_past_garbage() {
assert_eq!(parse_vary("*, foo bar"), Vary::Wildcard);
}
#[test]
fn vary_field_names_are_lowercased_and_listed() {
match parse_vary("Accept-Encoding, Accept-Language") {
Vary::Fields(v) => {
assert_eq!(v, vec!["accept-encoding", "accept-language"]);
}
other => panic!("expected Fields, got {other:?}"),
}
}
#[test]
fn vary_non_token_members_are_skipped_not_fatal() {
match parse_vary("accept-encoding, foo bar") {
Vary::Fields(v) => assert_eq!(v, vec!["accept-encoding"]),
other => panic!("expected Fields, got {other:?}"),
}
assert_eq!(parse_vary("foo bar, baz qux"), Vary::Absent);
}
#[test]
fn vary_quoted_star_is_a_field_name_not_wildcard() {
match parse_vary("x-*") {
Vary::Fields(v) => assert_eq!(v, vec!["x-*"]),
other => panic!("expected Fields for x-*, got {other:?}"),
}
}
#[test]
fn content_codings_empty_and_absent_are_acceptable() {
assert!(non_identity_content_codings("").is_empty());
assert!(non_identity_content_codings(" ").is_empty());
assert!(non_identity_content_codings(",,,").is_empty());
}
#[test]
fn content_codings_identity_is_tolerated_as_no_op() {
assert!(non_identity_content_codings("identity").is_empty());
assert!(non_identity_content_codings("Identity").is_empty());
assert!(non_identity_content_codings("identity, identity").is_empty());
}
#[test]
fn content_codings_real_codings_are_reported_lowercased() {
assert_eq!(non_identity_content_codings("gzip"), ["gzip"]);
assert_eq!(non_identity_content_codings("GZIP"), ["gzip"]);
assert_eq!(non_identity_content_codings("x-gzip"), ["x-gzip"]);
assert_eq!(non_identity_content_codings("compress"), ["compress"]);
assert_eq!(non_identity_content_codings("deflate"), ["deflate"]);
}
#[test]
fn content_codings_list_preserves_application_order() {
assert_eq!(
non_identity_content_codings("gzip, deflate"),
["gzip", "deflate"]
);
assert_eq!(
non_identity_content_codings("identity, gzip , identity, compress"),
["gzip", "compress"]
);
assert_eq!(non_identity_content_codings(",gzip,,"), ["gzip"]);
}
#[test]
fn content_codings_non_token_garbage_is_kept_not_skipped() {
assert_eq!(non_identity_content_codings("gzip stream"), ["gzip stream"]);
assert_eq!(non_identity_content_codings("identity, ???"), ["???"]);
}
#[test]
fn local_server_head_with_content_encoding_gzip_is_unsupported() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes\r\n\
Content-Encoding: gzip\r\n\
Connection: close\r\n\
\r\n";
const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("open must refuse a content-coded representation"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(msg.contains("gzip"), "missing coding name: {msg}");
assert!(msg.contains("8.4"), "missing §8.4 cite: {msg}");
assert!(
matches!(err, Error::Unsupported(_)),
"expected Unsupported, got: {err:?}"
);
}
#[test]
fn local_server_head_with_content_encoding_identity_is_accepted() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes\r\n\
Content-Encoding: identity\r\n\
Connection: close\r\n\
\r\n";
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let mut src = HttpSource::open(&uri).expect("identity coding must be tolerated");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn local_server_206_with_content_encoding_gzip_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Content-Encoding: gzip\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Content-Encoding"), "wrong error: {msg}");
assert!(msg.contains("gzip"), "missing coding name: {msg}");
}
#[test]
fn local_server_200_fallback_with_content_encoding_gzip_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Content-Encoding: gzip\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Content-Encoding"), "wrong error: {msg}");
}
#[test]
fn local_server_get_request_carries_accept_encoding_identity() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, captured) = spawn_server_capturing_get(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
let req = captured.recv().expect("captured GET request");
let s = String::from_utf8_lossy(&req).to_ascii_lowercase();
assert!(
s.contains("accept-encoding: identity"),
"GET did not carry Accept-Encoding: identity; got:\n{s}"
);
}
#[test]
fn local_server_head_request_carries_accept_encoding_identity() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, captured) = spawn_server_capturing_all(HEAD_10B_BYTES, GET);
let _src = HttpSource::open(&uri).expect("open");
let req = captured.recv().expect("captured HEAD request");
let s = String::from_utf8_lossy(&req).to_ascii_lowercase();
assert!(
s.starts_with("head "),
"first captured request not HEAD:\n{s}"
);
assert!(
s.contains("accept-encoding: identity"),
"HEAD did not carry Accept-Encoding: identity; got:\n{s}"
);
}
fn spawn_server_capturing_all(
head_resp: &'static [u8],
get_resp: &'static [u8],
) -> (String, mpsc::Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<Vec<u8>>();
thread::spawn(move || {
for _ in 0..4 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 4096];
use std::io::Read as _;
let n = stream.read(&mut buf).unwrap_or(0);
if n == 0 {
continue;
}
let req = &buf[..n];
let _ = tx.send(req.to_vec());
let resp = if req.starts_with(b"HEAD ") {
head_resp
} else {
get_resp
};
let _ = stream.write_all(resp);
let _ = stream.flush();
}
});
(format!("http://127.0.0.1:{port}/x"), rx)
}
#[test]
fn local_server_accept_ranges_none_is_unsupported() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: none\r\n\
Connection: close\r\n\
\r\n";
const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("open must refuse Accept-Ranges: none"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("explicitly refused") || msg.contains("Accept-Ranges: none"),
"wrong error (no §14.3 'none' refusal phrasing): {msg}"
);
assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
}
#[test]
fn local_server_accept_ranges_list_with_bytes_is_accepted() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: bytes, foo-unit\r\n\
Connection: close\r\n\
\r\n";
const GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let mut src = HttpSource::open(&uri).expect("open ok");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn local_server_accept_ranges_only_unknown_unit_is_unsupported() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Accept-Ranges: foo-unit\r\n\
Connection: close\r\n\
\r\n";
const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("open must refuse non-bytes-only"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("foo-unit") || msg.contains("not 'bytes'"),
"error must surface the offered unit(s): {msg}"
);
assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
}
#[test]
fn local_server_accept_ranges_absent_is_unsupported_distinct_message() {
const HEAD: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n";
const GET: &[u8] = b"HTTP/1.1 200 OK\r\n\
Content-Length: 10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD, GET);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("open must refuse absent Accept-Ranges"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("did not advertise") || msg.contains("Accept-Ranges"),
"wrong error (no absent-header phrasing): {msg}"
);
assert!(msg.contains("§14.3"), "missing §14.3 cite: {msg}");
}
#[test]
fn is_multipart_byteranges_accepts_canonical_form() {
assert!(is_multipart_byteranges_content_type("multipart/byteranges"));
}
#[test]
fn is_multipart_byteranges_accepts_boundary_parameter() {
assert!(is_multipart_byteranges_content_type(
"multipart/byteranges; boundary=THIS_STRING_SEPARATES"
));
}
#[test]
fn is_multipart_byteranges_is_case_insensitive_per_section_8_3_1() {
assert!(is_multipart_byteranges_content_type(
"Multipart/ByteRanges; boundary=x"
));
assert!(is_multipart_byteranges_content_type("MULTIPART/BYTERANGES"));
}
#[test]
fn is_multipart_byteranges_tolerates_ows_around_value() {
assert!(is_multipart_byteranges_content_type(
" multipart/byteranges "
));
assert!(is_multipart_byteranges_content_type(
"multipart/byteranges ; boundary=x"
));
}
#[test]
fn is_multipart_byteranges_rejects_other_types() {
for t in [
"application/pdf",
"video/mp4",
"image/jpeg",
"multipart/form-data; boundary=x",
"multipart/mixed; boundary=x",
"text/plain",
"",
] {
assert!(
!is_multipart_byteranges_content_type(t),
"false-positive on {t:?}"
);
}
}
#[test]
fn is_multipart_byteranges_does_not_false_positive_on_prefix_subtype() {
assert!(!is_multipart_byteranges_content_type(
"multipart/byteranges-foo"
));
}
#[test]
fn local_server_206_with_multipart_byteranges_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Type: multipart/byteranges; boundary=THIS_STRING_SEPARATES\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open ok");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("multipart/byteranges"),
"error must name the offending media type: {msg}"
);
assert!(msg.contains("§15.3.7"), "error must cite §15.3.7: {msg}");
}
#[test]
fn local_server_206_with_uppercase_multipart_byteranges_is_rejected() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Type: Multipart/ByteRanges; boundary=x\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open ok");
let mut buf = [0u8; 10];
let err = std::io::Read::read_exact(&mut src, &mut buf).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("multipart/byteranges") || msg.contains("Multipart/ByteRanges"),
"error must name the offending media type: {msg}"
);
}
#[test]
fn local_server_206_with_video_mp4_content_type_is_accepted() {
static GET: &[u8] = b"HTTP/1.1 206 Partial Content\r\n\
Content-Length: 10\r\n\
Content-Type: video/mp4\r\n\
Content-Range: bytes 0-9/10\r\n\
Connection: close\r\n\
\r\n\
0123456789";
let (uri, _done) = spawn_server(HEAD_10B_BYTES, GET);
let mut src = HttpSource::open(&uri).expect("open ok");
let mut buf = [0u8; 10];
std::io::Read::read_exact(&mut src, &mut buf).expect("read");
assert_eq!(&buf, b"0123456789");
}
#[test]
fn format_retry_after_hint_renders_delay_seconds_form() {
let h = format_retry_after_hint("120");
assert_eq!(h, " (Retry-After: 120 s)");
}
#[test]
fn format_retry_after_hint_renders_zero_delay() {
let h = format_retry_after_hint("0");
assert_eq!(h, " (Retry-After: 0 s)");
}
#[test]
fn format_retry_after_hint_renders_imf_fixdate_form() {
let h = format_retry_after_hint("Fri, 31 Dec 1999 23:59:59 GMT");
assert_eq!(h, " (Retry-After: 1999-12-31T23:59:59 UTC)");
}
#[test]
fn format_retry_after_hint_renders_rfc850_form_uniformly() {
let h = format_retry_after_hint("Sunday, 06-Nov-94 08:49:37 GMT");
assert!(
h.contains("1994-11-06T08:49:37 UTC"),
"rfc850 canonicalisation: {h}"
);
}
#[test]
fn format_retry_after_hint_renders_asctime_form_uniformly() {
let h = format_retry_after_hint("Sun Nov 6 08:49:37 1994");
assert!(
h.contains("1994-11-06T08:49:37 UTC"),
"asctime canonicalisation: {h}"
);
}
#[test]
fn format_retry_after_hint_surfaces_unparseable_value() {
let h = format_retry_after_hint("Tomorrow at noon");
assert!(h.contains("Tomorrow at noon"), "unparseable hint: {h}");
assert!(h.contains("unparseable"), "no diagnostic: {h}");
assert!(h.contains("§10.2.3"), "no cite: {h}");
}
#[test]
fn format_retry_after_hint_skips_empty_input() {
assert_eq!(format_retry_after_hint(""), "");
assert_eq!(format_retry_after_hint(" "), "");
assert_eq!(format_retry_after_hint("\t"), "");
}
#[test]
fn format_retry_after_hint_trims_surrounding_ows() {
let h = format_retry_after_hint(" 120 ");
assert_eq!(h, " (Retry-After: 120 s)");
}
fn spawn_head_only(head_resp: &'static [u8]) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
for _ in 0..2 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 4096];
use std::io::Read as _;
let _ = stream.read(&mut buf).unwrap_or(0);
let _ = stream.write_all(head_resp);
let _ = stream.flush();
}
});
format!("http://127.0.0.1:{port}/x")
}
#[test]
fn local_server_head_503_surfaces_retry_after_delay() {
const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\n\
Retry-After: 120\r\n\
Connection: close\r\n\
\r\n";
let uri = spawn_head_only(HEAD);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("503 must error"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(msg.contains("503"), "wrong error (no 503): {msg}");
assert!(
msg.contains("Retry-After: 120 s"),
"Retry-After delay must surface in the message: {msg}"
);
}
#[test]
fn local_server_head_429_surfaces_retry_after_date() {
const HEAD: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
Content-Length: 0\r\n\
Retry-After: Fri, 31 Dec 1999 23:59:59 GMT\r\n\
Connection: close\r\n\
\r\n";
let uri = spawn_head_only(HEAD);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("429 must error"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(msg.contains("429"), "wrong error (no 429): {msg}");
assert!(
msg.contains("1999-12-31T23:59:59 UTC"),
"Retry-After date must surface canonicalised in the message: {msg}"
);
}
#[test]
fn local_server_head_503_without_retry_after_omits_hint() {
const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let uri = spawn_head_only(HEAD);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("503 must error"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(msg.contains("503"), "wrong error (no 503): {msg}");
assert!(
!msg.contains("Retry-After"),
"must not emit a Retry-After hint when none was sent: {msg}"
);
}
#[test]
fn obs_fold_absent_returns_borrowed_unchanged() {
let s = "bytes=0-1023";
let out = normalize_obs_fold(s);
assert_eq!(out, "bytes=0-1023");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn obs_fold_crlf_without_sp_or_htab_is_not_a_fold() {
let s = "abc\r\nxyz";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc\r\nxyz");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn obs_fold_bare_cr_or_lf_is_not_a_fold() {
let s = "abc\r def";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc\r def");
let s = "abc\n def";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc\n def");
}
#[test]
fn obs_fold_single_sp_collapses_to_one_sp() {
let s = "abc\r\n def";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc def");
assert!(matches!(out, std::borrow::Cow::Owned(_)));
}
#[test]
fn obs_fold_single_htab_collapses_to_one_sp() {
let s = "abc\r\n\tdef";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc def");
}
#[test]
fn obs_fold_multiple_sp_htab_collapses_to_one_sp() {
let s = "abc\r\n \t \tdef";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc def");
}
#[test]
fn obs_fold_multiple_folds_each_collapse_independently() {
let s = "a\r\n b\r\n\tc";
let out = normalize_obs_fold(s);
assert_eq!(out, "a b c");
}
#[test]
fn obs_fold_at_start_of_value_collapses() {
let s = "\r\n abc";
let out = normalize_obs_fold(s);
assert_eq!(out, " abc");
}
#[test]
fn obs_fold_trailing_crlf_without_continuation_is_not_a_fold() {
let s = "abc\r\n";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc\r\n");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn obs_fold_preserves_obs_text_bytes() {
let s = "ab\u{00e9}\r\n cd\u{00e9}";
let out = normalize_obs_fold(s);
assert_eq!(out, "ab\u{00e9} cd\u{00e9}");
}
#[test]
fn obs_fold_does_not_touch_intra_field_whitespace_runs() {
let s = "abc def\t\tghi";
let out = normalize_obs_fold(s);
assert_eq!(out, "abc def\t\tghi");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn obs_fold_empty_input_is_borrowed_empty() {
let s = "";
let out = normalize_obs_fold(s);
assert_eq!(out, "");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn obs_fold_then_non_fold_crlf_handles_each_independently() {
let s = "a\r\n b\r\nc";
let out = normalize_obs_fold(s);
assert_eq!(out, "a b\r\nc");
}
#[test]
fn obs_fold_inside_quoted_string_is_still_normalised() {
let s = "\"a\r\n b\"";
let out = normalize_obs_fold(s);
assert_eq!(out, "\"a b\"");
}
#[test]
fn obs_fold_chained_folds_back_to_back_collapse_once_each() {
let s = "a\r\n \r\n b";
let out = normalize_obs_fold(s);
assert_eq!(out, "a b");
}
#[test]
fn obs_fold_existing_parsers_remain_obs_fold_agnostic() {
let folded = "Wed, 21 Oct 2026\r\n 07:28:00 GMT";
assert!(parse_imf_fixdate(folded).is_none());
let normalised = normalize_obs_fold(folded);
assert!(parse_imf_fixdate(&normalised).is_some());
}
#[test]
fn unquote_string_empty_pair_decodes_to_empty_borrowed() {
let v = unquote_string("\"\"").unwrap();
assert_eq!(&*v, "");
assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn unquote_string_escape_free_returns_cow_borrowed() {
let v = unquote_string("\"plain text\"").unwrap();
assert_eq!(&*v, "plain text");
assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn unquote_string_quoted_dquote_collapses_to_one_dquote() {
let v = unquote_string("\"a\\\"b\"").unwrap();
assert_eq!(&*v, "a\"b");
assert!(matches!(v, std::borrow::Cow::Owned(_)));
}
#[test]
fn unquote_string_quoted_backslash_collapses_to_one_backslash() {
let v = unquote_string("\"a\\\\b\"").unwrap();
assert_eq!(&*v, "a\\b");
}
#[test]
fn unquote_string_missing_leading_dquote_rejects() {
assert!(unquote_string("hello\"").is_none());
}
#[test]
fn unquote_string_missing_trailing_dquote_rejects() {
assert!(unquote_string("\"hello").is_none());
}
#[test]
fn unquote_string_single_dquote_rejects() {
assert!(unquote_string("\"").is_none());
}
#[test]
fn unquote_string_unwrapped_rejects() {
assert!(unquote_string("hello").is_none());
}
#[test]
fn unquote_string_empty_rejects() {
assert!(unquote_string("").is_none());
}
#[test]
fn unquote_string_trailing_lone_backslash_rejects() {
assert!(unquote_string("\"abc\\\"").is_none());
}
#[test]
fn unquote_string_quoted_pair_with_cr_or_lf_rejects() {
let cr = [b'"', b'a', b'\\', b'\r', b'b', b'"'];
assert!(unquote_string(std::str::from_utf8(&cr).unwrap()).is_none());
let lf = [b'"', b'a', b'\\', b'\n', b'b', b'"'];
assert!(unquote_string(std::str::from_utf8(&lf).unwrap()).is_none());
}
#[test]
fn unquote_string_bare_qdtext_excluded_byte_rejects() {
assert!(unquote_string("\"a\"b\"").is_none());
}
#[test]
fn unquote_string_bare_control_byte_rejects() {
let bel = [b'"', b'a', 0x07, b'b', b'"'];
assert!(unquote_string(std::str::from_utf8(&bel).unwrap()).is_none());
}
#[test]
fn unquote_string_obs_text_byte_in_body_accepted() {
let v = unquote_string("\"\u{00e9}\"").unwrap();
assert_eq!(&*v, "\u{00e9}");
assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn unquote_string_escape_preserving_obs_text_byte_accepted() {
let inp: [u8; 5] = [b'"', b'\\', 0xC3, 0xA9, b'"'];
let v = unquote_string(std::str::from_utf8(&inp).unwrap()).unwrap();
assert_eq!(v.as_bytes(), &[0xC3, 0xA9]);
}
#[test]
fn unquote_string_only_unescapes_in_slow_path() {
let v = unquote_string("\"a\\bc\"").unwrap();
assert_eq!(&*v, "abc");
assert!(matches!(v, std::borrow::Cow::Owned(_)));
}
#[test]
fn unquote_string_decodes_used_in_media_type_parameter_rhs() {
let v = unquote_string("\"weird;value\"").unwrap();
assert_eq!(&*v, "weird;value");
let v = unquote_string("\"--my\\\"boundary--\"").unwrap();
assert_eq!(&*v, "--my\"boundary--");
}
#[test]
fn parse_comment_empty_decodes_to_empty_borrowed() {
let v = parse_comment("()").unwrap();
assert_eq!(&*v, "");
assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn parse_comment_escape_free_returns_cow_borrowed() {
let v = parse_comment("(gzip is fine)").unwrap();
assert_eq!(&*v, "gzip is fine");
assert!(matches!(v, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn parse_comment_nested_preserves_inner_parens() {
let v = parse_comment("(a (b) c)").unwrap();
assert_eq!(&*v, "a (b) c");
let v = parse_comment("(x ((y)) z)").unwrap();
assert_eq!(&*v, "x ((y)) z");
}
#[test]
fn parse_comment_quoted_pair_collapsed_per_5_6_4() {
let v = parse_comment("(a \\( b \\) c \\\\ d)").unwrap();
assert_eq!(&*v, "a ( b ) c \\ d");
assert!(matches!(v, std::borrow::Cow::Owned(_)));
}
#[test]
fn parse_comment_user_agent_shape() {
let v = parse_comment("(Macintosh; Intel Mac OS X 10_15)").unwrap();
assert_eq!(&*v, "Macintosh; Intel Mac OS X 10_15");
}
#[test]
fn parse_comment_missing_outer_parens_rejected() {
assert!(parse_comment("no parens").is_none());
assert!(parse_comment("(unclosed").is_none());
assert!(parse_comment("unopened)").is_none());
assert!(parse_comment("(").is_none());
assert!(parse_comment(")").is_none());
assert!(parse_comment("").is_none());
}
#[test]
fn parse_comment_unbalanced_parens_rejected() {
assert!(parse_comment("(a (b)").is_none());
assert!(parse_comment("(a) b)").is_none());
assert!(parse_comment("(a)(b)").is_none());
}
#[test]
fn parse_comment_bare_control_byte_rejected() {
assert!(parse_comment("(a\rb)").is_none());
assert!(parse_comment("(a\nb)").is_none());
assert!(parse_comment("(a\u{0}b)").is_none());
assert_eq!(&*parse_comment("(a\tb c)").unwrap(), "a\tb c");
}
#[test]
fn parse_comment_dangling_or_illegal_quoted_pair_rejected() {
assert!(parse_comment("(abc\\)").is_none());
assert!(parse_comment("(a\\\rb)").is_none());
assert!(parse_comment("(a\\\nb)").is_none());
}
#[test]
fn parse_comment_obs_text_byte_accepted() {
let v = parse_comment("(café)").unwrap();
assert_eq!(&*v, "café");
let v = parse_comment("(a \\é b)").unwrap();
assert_eq!(&*v, "a é b");
}
#[test]
fn parse_parameters_empty_input_yields_no_entries() {
assert!(parse_parameters("").is_empty());
}
#[test]
fn parse_parameters_whitespace_only_yields_no_entries() {
assert!(parse_parameters(" \t ").is_empty());
}
#[test]
fn parse_parameters_semicolon_only_yields_no_entries() {
assert!(parse_parameters(";").is_empty());
assert!(parse_parameters(";;;").is_empty());
assert!(parse_parameters("; ; ;").is_empty());
}
#[test]
fn parse_parameters_single_token_value() {
let p = parse_parameters("; charset=utf-8");
assert_eq!(p, vec![("charset".to_owned(), "utf-8".to_owned())]);
}
#[test]
fn parse_parameters_leading_semicolon_optional() {
let with_semi = parse_parameters("; charset=utf-8");
let without_semi = parse_parameters("charset=utf-8");
assert_eq!(with_semi, without_semi);
}
#[test]
fn parse_parameters_name_lowercased_per_5_6_6_case_insensitivity() {
let p = parse_parameters("; CHARSET=UTF-8");
assert_eq!(p, vec![("charset".to_owned(), "UTF-8".to_owned())]);
}
#[test]
fn parse_parameters_quoted_string_value_unwrapped() {
let p = parse_parameters("; boundary=\"--foo\"");
assert_eq!(p, vec![("boundary".to_owned(), "--foo".to_owned())]);
}
#[test]
fn parse_parameters_quoted_pair_collapsed_per_5_6_4() {
let p = parse_parameters("; boundary=\"--my\\\"boundary--\"");
assert_eq!(
p,
vec![("boundary".to_owned(), "--my\"boundary--".to_owned())],
);
}
#[test]
fn parse_parameters_semicolon_inside_quoted_string_not_a_separator() {
let p = parse_parameters("; boundary=\"a;b\"");
assert_eq!(p, vec![("boundary".to_owned(), "a;b".to_owned())]);
}
#[test]
fn parse_parameters_escaped_dquote_inside_quoted_string_not_a_close() {
let p = parse_parameters("; name=\"a\\\";b\"");
assert_eq!(p, vec![("name".to_owned(), "a\";b".to_owned())]);
}
#[test]
fn parse_parameters_multiple_entries_preserved_in_order() {
let p = parse_parameters("; charset=utf-8; format=flowed; delsp=yes");
assert_eq!(
p,
vec![
("charset".to_owned(), "utf-8".to_owned()),
("format".to_owned(), "flowed".to_owned()),
("delsp".to_owned(), "yes".to_owned()),
],
);
}
#[test]
fn parse_parameters_empty_slot_silently_skipped() {
let p = parse_parameters("; charset=utf-8; ; format=flowed");
assert_eq!(
p,
vec![
("charset".to_owned(), "utf-8".to_owned()),
("format".to_owned(), "flowed".to_owned()),
],
);
}
#[test]
fn parse_parameters_missing_equals_skipped() {
let p = parse_parameters("; charset=utf-8; bogus; format=flowed");
assert_eq!(
p,
vec![
("charset".to_owned(), "utf-8".to_owned()),
("format".to_owned(), "flowed".to_owned()),
],
);
}
#[test]
fn parse_parameters_whitespace_around_equals_skipped() {
let p = parse_parameters("; a=1; b = 2; c=3");
assert_eq!(
p,
vec![
("a".to_owned(), "1".to_owned()),
("c".to_owned(), "3".to_owned()),
],
);
let only_before = parse_parameters("; b =2");
assert!(only_before.is_empty());
let only_after = parse_parameters("; b= 2");
assert!(only_after.is_empty());
}
#[test]
fn parse_parameters_non_token_name_skipped() {
let p = parse_parameters("; bad name=v; ok=v");
assert_eq!(p, vec![("ok".to_owned(), "v".to_owned())]);
}
#[test]
fn parse_parameters_non_token_unquoted_value_skipped() {
let p = parse_parameters("; bad=a b; ok=v");
assert_eq!(p, vec![("ok".to_owned(), "v".to_owned())]);
}
#[test]
fn parse_parameters_malformed_quoted_string_value_skipped() {
let p = parse_parameters("; bad=\"unterminated; ok=v");
assert!(p.is_empty(), "got: {p:?}");
}
#[test]
fn parse_parameters_ows_around_semicolons_tolerated() {
let p = parse_parameters(" \t ; \t a=1 \t ;\tb=2\t");
assert_eq!(
p,
vec![
("a".to_owned(), "1".to_owned()),
("b".to_owned(), "2".to_owned()),
],
);
}
#[test]
fn parse_parameters_quoted_value_with_obs_text_byte_preserved() {
let p = parse_parameters("; title=\"caf\u{00e9}\"");
assert_eq!(p, vec![("title".to_owned(), "caf\u{00e9}".to_owned())]);
}
#[test]
fn parse_parameters_realm_auth_param_shape_handles_quoted_value() {
let p = parse_parameters("; realm=\"Protected Area\"");
assert_eq!(p, vec![("realm".to_owned(), "Protected Area".to_owned())]);
}
#[test]
fn parse_parameters_comma_inside_value_is_not_a_separator() {
let p = parse_parameters("; bad=a,b");
assert!(p.is_empty(), "got: {p:?}");
}
#[test]
fn parse_parameters_token_value_with_dot_underscore_dash_accepted() {
let p = parse_parameters("; v=1.1; tag=my_thing; ua=app-name");
assert_eq!(
p,
vec![
("v".to_owned(), "1.1".to_owned()),
("tag".to_owned(), "my_thing".to_owned()),
("ua".to_owned(), "app-name".to_owned()),
],
);
}
#[test]
fn parse_parameters_coupling_with_unquote_string_layering() {
let direct = unquote_string("\"--my\\\"boundary--\"").unwrap();
let via_params = parse_parameters("; boundary=\"--my\\\"boundary--\"")
.into_iter()
.next()
.unwrap()
.1;
assert_eq!(&*direct, via_params);
}
#[test]
fn parse_media_type_bare_type_subtype_no_parameters() {
let (ty, sub, params) = parse_media_type("text/html").unwrap();
assert_eq!(ty, "text");
assert_eq!(sub, "html");
assert!(params.is_empty());
}
#[test]
fn parse_media_type_lowercases_type_and_subtype_per_8_3_1() {
let (ty, sub, _) = parse_media_type("Text/HTML").unwrap();
assert_eq!(ty, "text");
assert_eq!(sub, "html");
}
#[test]
fn parse_media_type_canonical_charset_example() {
let (ty, sub, params) = parse_media_type("text/html;charset=utf-8").unwrap();
assert_eq!(ty, "text");
assert_eq!(sub, "html");
assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
}
#[test]
fn parse_media_type_quoted_charset_example() {
let (ty, sub, params) = parse_media_type("text/html; charset=\"utf-8\"").unwrap();
assert_eq!(ty, "text");
assert_eq!(sub, "html");
assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
}
#[test]
fn parse_media_type_does_not_case_fold_parameter_value() {
let (_, _, params) = parse_media_type("text/html;charset=UTF-8").unwrap();
assert_eq!(params, vec![("charset".to_owned(), "UTF-8".to_owned())]);
}
#[test]
fn parse_media_type_lowercases_parameter_name_per_5_6_6() {
let (_, _, params) = parse_media_type("Text/HTML;Charset=\"utf-8\"").unwrap();
assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
}
#[test]
fn parse_media_type_multiple_parameters_preserved_in_order() {
let (ty, sub, params) =
parse_media_type("application/dash+xml; profiles=\"a,b\"; foo=bar").unwrap();
assert_eq!(ty, "application");
assert_eq!(sub, "dash+xml");
assert_eq!(
params,
vec![
("profiles".to_owned(), "a,b".to_owned()),
("foo".to_owned(), "bar".to_owned()),
]
);
}
#[test]
fn parse_media_type_ows_around_value_and_before_semicolon_tolerated() {
let (ty, sub, params) =
parse_media_type(" multipart/byteranges ; boundary=ABC ").unwrap();
assert_eq!(ty, "multipart");
assert_eq!(sub, "byteranges");
assert_eq!(params, vec![("boundary".to_owned(), "ABC".to_owned())]);
}
#[test]
fn parse_media_type_boundary_with_quoted_semicolon_preserved() {
let (_, _, params) = parse_media_type("multipart/byteranges; boundary=\"a;b\"").unwrap();
assert_eq!(params, vec![("boundary".to_owned(), "a;b".to_owned())]);
}
#[test]
fn parse_media_type_missing_slash_rejected() {
assert!(parse_media_type("text").is_none());
assert!(parse_media_type("text;charset=utf-8").is_none());
}
#[test]
fn parse_media_type_empty_type_or_subtype_rejected() {
assert!(parse_media_type("/html").is_none());
assert!(parse_media_type("text/").is_none());
assert!(parse_media_type("/").is_none());
}
#[test]
fn parse_media_type_non_token_type_or_subtype_rejected() {
assert!(parse_media_type("text/ht/ml").is_none());
assert!(parse_media_type("te xt/html").is_none());
assert!(parse_media_type("text/ht ml").is_none());
}
#[test]
fn parse_media_type_empty_and_whitespace_input_rejected() {
assert!(parse_media_type("").is_none());
assert!(parse_media_type(" ").is_none());
assert!(parse_media_type("\t").is_none());
}
#[test]
fn parse_media_type_garbage_parameter_slots_skipped_media_survives() {
let (ty, sub, params) = parse_media_type("text/plain; =novalue; charset=utf-8").unwrap();
assert_eq!(ty, "text");
assert_eq!(sub, "plain");
assert_eq!(params, vec![("charset".to_owned(), "utf-8".to_owned())]);
}
#[test]
fn parse_media_type_coupling_with_is_multipart_byteranges() {
let ct = "multipart/byteranges; boundary=END";
assert!(is_multipart_byteranges_content_type(ct));
let (ty, sub, _) = parse_media_type(ct).unwrap();
assert_eq!(format!("{ty}/{sub}"), "multipart/byteranges");
}
#[test]
fn local_server_head_503_with_malformed_retry_after_surfaces_diagnostic() {
const HEAD: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\n\
Retry-After: soon\r\n\
Connection: close\r\n\
\r\n";
let uri = spawn_head_only(HEAD);
let err = match HttpSource::open(&uri) {
Ok(_) => panic!("503 must error"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("\"soon\"") && msg.contains("unparseable"),
"malformed Retry-After must surface raw + diagnostic: {msg}"
);
assert!(msg.contains("§10.2.3"), "missing §10.2.3 cite: {msg}");
}
#[test]
fn cache_control_empty_value_yields_default() {
assert_eq!(parse_cache_control(""), CacheControl::default());
assert_eq!(parse_cache_control(" "), CacheControl::default());
assert_eq!(parse_cache_control(",,"), CacheControl::default());
}
#[test]
fn cache_control_boolean_directives_set_flags() {
let cc = parse_cache_control(
"no-store, no-transform, only-if-cached, must-revalidate, \
must-understand, proxy-revalidate, public",
);
assert!(cc.no_store);
assert!(cc.no_transform);
assert!(cc.only_if_cached);
assert!(cc.must_revalidate);
assert!(cc.must_understand);
assert!(cc.proxy_revalidate);
assert!(cc.public);
assert!(cc.max_age.is_none());
assert!(cc.no_cache_fields.is_empty());
assert!(cc.extensions.is_empty());
}
#[test]
fn cache_control_directive_names_are_case_insensitive() {
let cc = parse_cache_control("No-Store, MAX-AGE=60, Public");
assert!(cc.no_store);
assert_eq!(cc.max_age, Some(60));
assert!(cc.public);
}
#[test]
fn cache_control_max_age_token_argument() {
let cc = parse_cache_control("max-age=300");
assert_eq!(cc.max_age, Some(300));
assert_eq!(parse_cache_control("max-age=0").max_age, Some(0));
}
#[test]
fn cache_control_s_maxage_and_min_fresh() {
let cc = parse_cache_control("s-maxage=120, min-fresh=20");
assert_eq!(cc.s_maxage, Some(120));
assert_eq!(cc.min_fresh, Some(20));
}
#[test]
fn cache_control_delta_seconds_overflow_saturates() {
let huge = "max-age=99999999999999999999999999999";
assert_eq!(parse_cache_control(huge).max_age, Some(DELTA_SECONDS_MAX));
}
#[test]
fn cache_control_non_integer_delta_seconds_is_absent() {
assert!(parse_cache_control("max-age=abc").max_age.is_none());
assert!(parse_cache_control("max-age=-5").max_age.is_none());
assert!(parse_cache_control("max-age=").max_age.is_none());
assert!(parse_cache_control("max-age").max_age.is_none());
}
#[test]
fn cache_control_delta_seconds_accepts_quoted_argument_on_receipt() {
assert_eq!(parse_cache_control("max-age=\"60\"").max_age, Some(60));
}
#[test]
fn cache_control_max_stale_no_arg_vs_valued() {
assert_eq!(parse_cache_control("max-stale").max_stale, Some(None));
assert_eq!(
parse_cache_control("max-stale=10").max_stale,
Some(Some(10))
);
assert_eq!(parse_cache_control("").max_stale, None);
}
#[test]
fn cache_control_no_cache_unqualified_vs_qualified() {
let bare = parse_cache_control("no-cache");
assert!(bare.no_cache);
assert!(bare.no_cache_fields.is_empty());
let qual = parse_cache_control("no-cache=\"Set-Cookie, X-Foo\"");
assert!(!qual.no_cache);
assert_eq!(qual.no_cache_fields, vec!["set-cookie", "x-foo"]);
}
#[test]
fn cache_control_private_unqualified_vs_qualified() {
assert!(parse_cache_control("private").private);
let qual = parse_cache_control("private=\"Authorization\"");
assert!(!qual.private);
assert_eq!(qual.private_fields, vec!["authorization"]);
}
#[test]
fn cache_control_quoted_comma_does_not_split_directive() {
let cc = parse_cache_control("no-cache=\"a, b\", max-age=5");
assert_eq!(cc.no_cache_fields, vec!["a", "b"]);
assert_eq!(cc.max_age, Some(5));
}
#[test]
fn cache_control_unknown_directive_preserved_as_extension() {
let cc = parse_cache_control("immutable, community=\"UCI\", max-age=1");
assert_eq!(cc.max_age, Some(1));
assert_eq!(
cc.extensions,
vec![
("immutable".to_owned(), None),
("community".to_owned(), Some("UCI".to_owned())),
]
);
}
#[test]
fn cache_control_duplicate_valued_directive_keeps_first() {
assert_eq!(
parse_cache_control("max-age=10, max-age=99").max_age,
Some(10)
);
assert_eq!(
parse_cache_control("max-stale=1, max-stale=2").max_stale,
Some(Some(1))
);
}
#[test]
fn cache_control_ows_around_elements_tolerated() {
let cc = parse_cache_control(" no-store , max-age=7 ");
assert!(cc.no_store);
assert_eq!(cc.max_age, Some(7));
}
#[test]
fn cache_control_obs_fold_normalised_before_split() {
let cc = parse_cache_control("max-age=30,\r\n no-cache");
assert_eq!(cc.max_age, Some(30));
assert!(cc.no_cache);
}
#[test]
fn cache_control_malformed_element_skipped_not_fatal() {
let cc = parse_cache_control("no-store, =bad, ba()d=x, public");
assert!(cc.no_store);
assert!(cc.public);
assert!(cc.extensions.is_empty());
}
#[test]
fn parse_delta_seconds_boundary() {
assert_eq!(parse_delta_seconds("0"), Some(0));
assert_eq!(parse_delta_seconds("2147483648"), Some(DELTA_SECONDS_MAX));
assert_eq!(parse_delta_seconds(""), None);
assert_eq!(parse_delta_seconds("+1"), None);
assert_eq!(parse_delta_seconds("1 "), None);
}
fn ch(scheme: &str, params: &[(&str, &str)]) -> Challenge {
Challenge {
scheme: scheme.to_owned(),
token68: None,
params: params
.iter()
.map(|(n, v)| (n.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn www_auth_empty_yields_no_challenges() {
assert!(parse_www_authenticate("").is_empty());
assert!(parse_www_authenticate(" ").is_empty());
assert!(parse_www_authenticate(", ,").is_empty());
}
#[test]
fn www_auth_bare_scheme_no_args() {
assert_eq!(
parse_www_authenticate("Negotiate"),
vec![ch("negotiate", &[])]
);
}
#[test]
fn www_auth_scheme_lowercased_case_insensitive() {
assert_eq!(
parse_www_authenticate("BASIC realm=\"x\""),
vec![ch("basic", &[("realm", "x")])]
);
}
#[test]
fn www_auth_single_challenge_one_param() {
assert_eq!(
parse_www_authenticate("Basic realm=\"simple\""),
vec![ch("basic", &[("realm", "simple")])]
);
}
#[test]
fn www_auth_token68_form() {
let got = parse_www_authenticate("Negotiate a87421bK3m");
assert_eq!(got.len(), 1);
assert_eq!(got[0].scheme, "negotiate");
assert_eq!(got[0].token68.as_deref(), Some("a87421bK3m"));
assert!(got[0].params.is_empty());
}
#[test]
fn www_auth_token68_with_trailing_pad() {
let got = parse_www_authenticate("Negotiate TlRMTVNTUAACAAAA==");
assert_eq!(got.len(), 1);
assert_eq!(got[0].token68.as_deref(), Some("TlRMTVNTUAACAAAA=="));
}
#[test]
fn www_auth_rfc_worked_example_two_challenges() {
let got = parse_www_authenticate(
"Basic realm=\"simple\", Newauth realm=\"apps\", type=1, title=\"Login to \\\"apps\\\"\"",
);
assert_eq!(
got,
vec![
ch("basic", &[("realm", "simple")]),
ch(
"newauth",
&[
("realm", "apps"),
("type", "1"),
("title", "Login to \"apps\""),
],
),
]
);
}
#[test]
fn www_auth_param_name_lowercased_value_case_preserved() {
let got = parse_www_authenticate("Digest Realm=\"MixedCase\"");
assert_eq!(got, vec![ch("digest", &[("realm", "MixedCase")])]);
}
#[test]
fn www_auth_bws_around_equals_tolerated() {
assert_eq!(
parse_www_authenticate("Basic realm = \"x\""),
vec![ch("basic", &[("realm", "x")])]
);
}
#[test]
fn www_auth_token_value_form() {
assert_eq!(
parse_www_authenticate("Newauth type=1"),
vec![ch("newauth", &[("type", "1")])]
);
}
#[test]
fn www_auth_quoted_comma_does_not_split() {
assert_eq!(
parse_www_authenticate("Basic realm=\"a, b\""),
vec![ch("basic", &[("realm", "a, b")])]
);
}
#[test]
fn www_auth_two_basic_realms_distinct_challenges() {
assert_eq!(
parse_www_authenticate("Basic realm=\"a\", Basic realm=\"b\""),
vec![
ch("basic", &[("realm", "a")]),
ch("basic", &[("realm", "b")]),
]
);
}
#[test]
fn www_auth_malformed_param_skipped_challenge_survives() {
assert_eq!(
parse_www_authenticate("Digest realm=\"r\", bad(), nonce=\"n\""),
vec![ch("digest", &[("realm", "r"), ("nonce", "n")])]
);
}
#[test]
fn www_auth_unterminated_quoted_string_param_dropped() {
let got = parse_www_authenticate("Basic realm=\"oops");
assert_eq!(got, vec![ch("basic", &[])]);
}
#[test]
fn www_auth_leading_bare_param_with_no_challenge_dropped() {
assert!(parse_www_authenticate("realm=\"orphan\"").is_empty());
}
#[test]
fn www_auth_param_after_token68_challenge_dropped() {
let got = parse_www_authenticate("Negotiate abc123, realm=\"x\"");
assert_eq!(got.len(), 1);
assert_eq!(got[0].token68.as_deref(), Some("abc123"));
assert!(got[0].params.is_empty());
}
#[test]
fn www_auth_obs_fold_normalised_before_parse() {
assert_eq!(
parse_www_authenticate("Basic realm=\"a\",\r\n Digest realm=\"b\""),
vec![
ch("basic", &[("realm", "a")]),
ch("digest", &[("realm", "b")]),
]
);
}
#[test]
fn www_auth_scheme_then_first_param_then_more() {
assert_eq!(
parse_www_authenticate("Digest realm=\"r\", qop=\"auth\", nonce=\"n\""),
vec![ch(
"digest",
&[("realm", "r"), ("qop", "auth"), ("nonce", "n")]
)]
);
}
#[test]
fn www_auth_token68_not_confused_with_param() {
let got = parse_www_authenticate("Custom foo=bar");
assert_eq!(got, vec![ch("custom", &[("foo", "bar")])]);
}
#[test]
fn as_token68_recogniser() {
assert_eq!(as_token68("abcXYZ09-._~+/"), Some("abcXYZ09-._~+/"));
assert_eq!(as_token68("abc=="), Some("abc=="));
assert_eq!(as_token68("=abc"), None); assert_eq!(as_token68("ab=cd"), None); assert_eq!(as_token68(""), None);
assert_eq!(as_token68("a b"), None); }
#[test]
fn parse_one_auth_param_shapes() {
assert_eq!(
parse_one_auth_param("Realm=\"x\""),
Some(("realm".to_string(), "x".to_string()))
);
assert_eq!(
parse_one_auth_param("type = 1"),
Some(("type".to_string(), "1".to_string()))
);
assert_eq!(parse_one_auth_param("noequals"), None);
assert_eq!(parse_one_auth_param("=novalue"), None);
assert_eq!(parse_one_auth_param("bad()=x"), None);
assert_eq!(parse_one_auth_param("k=\"unterminated"), None);
}
}