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)
.call()
.map_err(|e| Error::other(format!("HTTP HEAD {uri}: {e}")))?;
let status = head.status();
if !status.is_success() {
return Err(Error::other(format!("HTTP HEAD {uri}: status {status}")));
}
let headers = head.headers();
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 = headers
.get("accept-ranges")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !accept_ranges.eq_ignore_ascii_case("bytes") {
return Err(Error::Unsupported(format!(
"HTTP HEAD {uri}: server does not advertise byte ranges (Accept-Ranges: '{accept_ranges}')"
)));
}
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);
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);
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
)));
}
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 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)
}
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 = match &s[8..11] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
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 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_imf_fixdate(lm), parse_imf_fixdate(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"))
}
}
#[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 derive_strong_validator(etag: Option<&str>, lm: Option<&str>, date: Option<&str>) {
let _ = super::derive_strong_validator(etag, lm, date);
}
}
#[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 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()
))
);
}
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");
}
}