use axum::body::Body;
use axum::response::Response;
use bytes::Bytes;
use http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, IF_RANGE, RANGE};
use http::{HeaderMap, HeaderValue, StatusCode};
use crate::etag::ETag;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RangeResolution {
Full,
Partial {
start: u64,
end: u64,
total: u64,
},
Unsatisfiable {
total: u64,
},
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Validator<'a> {
etag: Option<&'a ETag>,
last_modified: Option<&'a str>,
}
impl<'a> Validator<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
etag: None,
last_modified: None,
}
}
#[must_use]
pub const fn with_etag(mut self, etag: &'a ETag) -> Self {
self.etag = Some(etag);
self
}
#[must_use]
pub const fn with_last_modified(mut self, last_modified: &'a str) -> Self {
self.last_modified = Some(last_modified);
self
}
}
#[must_use]
pub fn resolve(
req_headers: &HeaderMap,
total: u64,
validator: Option<Validator<'_>>,
) -> RangeResolution {
let Some(range_value) = req_headers.get(RANGE) else {
return RangeResolution::Full;
};
let Ok(range_str) = range_value.to_str() else {
return RangeResolution::Full;
};
if let Some(if_range) = req_headers.get(IF_RANGE).and_then(|v| v.to_str().ok())
&& !if_range_matches(if_range, validator.as_ref())
{
return RangeResolution::Full;
}
parse_range(range_str, total)
}
enum SpecOutcome {
Invalid,
Satisfiable { start: u64, end: u64 },
Unsatisfiable,
}
fn parse_range(range_str: &str, total: u64) -> RangeResolution {
let Some(rest) = range_str.trim().strip_prefix("bytes=") else {
return RangeResolution::Full;
};
let mut saw_unsatisfiable = false;
for spec in rest.split(',') {
let spec = spec.trim();
if spec.is_empty() {
continue;
}
match parse_single_spec(spec, total) {
SpecOutcome::Invalid => {}
SpecOutcome::Satisfiable { start, end } => {
return RangeResolution::Partial { start, end, total };
}
SpecOutcome::Unsatisfiable => {
saw_unsatisfiable = true;
}
}
}
if saw_unsatisfiable {
RangeResolution::Unsatisfiable { total }
} else {
RangeResolution::Full
}
}
fn parse_single_spec(spec: &str, total: u64) -> SpecOutcome {
if let Some(suffix) = spec.strip_prefix('-') {
let Ok(n) = suffix.trim().parse::<u64>() else {
return SpecOutcome::Invalid;
};
if n == 0 || total == 0 {
return SpecOutcome::Unsatisfiable;
}
let start = total.saturating_sub(n);
return SpecOutcome::Satisfiable {
start,
end: total - 1,
};
}
let Some((start_s, end_s)) = spec.split_once('-') else {
return SpecOutcome::Invalid;
};
let Ok(start) = start_s.trim().parse::<u64>() else {
return SpecOutcome::Invalid;
};
if end_s.trim().is_empty() {
if total == 0 || start >= total {
return SpecOutcome::Unsatisfiable;
}
return SpecOutcome::Satisfiable {
start,
end: total - 1,
};
}
let Ok(end) = end_s.trim().parse::<u64>() else {
return SpecOutcome::Invalid;
};
if start > end {
return SpecOutcome::Invalid;
}
if total == 0 || start >= total {
return SpecOutcome::Unsatisfiable;
}
SpecOutcome::Satisfiable {
start,
end: end.min(total - 1),
}
}
fn if_range_matches(if_range: &str, validator: Option<&Validator<'_>>) -> bool {
let Some(validator) = validator else {
return false;
};
let trimmed = if_range.trim();
if trimmed.starts_with('"') || trimmed.starts_with("W/") {
if trimmed.starts_with("W/") {
return false;
}
let Some(etag) = validator.etag else {
return false;
};
if etag.is_weak() {
return false;
}
trimmed.trim_matches('"') == etag.tag()
} else {
validator
.last_modified
.is_some_and(|lm| lm.trim() == trimmed)
}
}
fn slice_inclusive(full: &Bytes, start: u64, end: u64) -> Bytes {
if full.is_empty() {
return Bytes::new();
}
let last = full.len() - 1;
let lo = usize::try_from(start).unwrap_or(usize::MAX).min(last);
let hi = usize::try_from(end).unwrap_or(usize::MAX).min(last);
if lo > hi {
return Bytes::new();
}
full.slice(lo..=hi)
}
#[must_use]
pub fn partial_bytes_response(resolution: &RangeResolution, full: Bytes) -> Response<Body> {
match *resolution {
RangeResolution::Full => {
let len = full.len();
let mut response = Response::new(Body::from(full));
set_accept_ranges(response.headers_mut());
response
.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from(len));
response
}
RangeResolution::Partial { start, end, total } => {
let slice = slice_inclusive(&full, start, end);
let len = slice.len();
let mut response = Response::new(Body::from(slice));
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
let headers = response.headers_mut();
set_accept_ranges(headers);
if let Ok(v) = HeaderValue::from_str(&content_range_value(start, end, total)) {
headers.insert(CONTENT_RANGE, v);
}
headers.insert(CONTENT_LENGTH, HeaderValue::from(len));
response
}
RangeResolution::Unsatisfiable { total } => {
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
let headers = response.headers_mut();
set_accept_ranges(headers);
if let Ok(v) = HeaderValue::from_str(&unsatisfied_content_range(total)) {
headers.insert(CONTENT_RANGE, v);
}
headers.insert(CONTENT_LENGTH, HeaderValue::from(0));
response
}
}
}
#[must_use]
pub fn content_range_value(start: u64, end: u64, total: u64) -> String {
format!("bytes {start}-{end}/{total}")
}
#[must_use]
pub fn unsatisfied_content_range(total: u64) -> String {
format!("bytes */{total}")
}
pub fn set_accept_ranges(headers: &mut HeaderMap) {
headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
}
#[cfg(test)]
mod tests {
use super::*;
fn headers_with_range(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(RANGE, HeaderValue::from_str(value).unwrap());
h
}
#[test]
fn no_range_header_is_full() {
assert_eq!(resolve(&HeaderMap::new(), 10, None), RangeResolution::Full);
}
#[test]
fn simple_closed_range() {
assert_eq!(
resolve(&headers_with_range("bytes=0-3"), 10, None),
RangeResolution::Partial {
start: 0,
end: 3,
total: 10
}
);
}
#[test]
fn open_ended_range_extends_to_eof() {
assert_eq!(
resolve(&headers_with_range("bytes=5-"), 10, None),
RangeResolution::Partial {
start: 5,
end: 9,
total: 10
}
);
}
#[test]
fn suffix_range_takes_last_n_bytes() {
assert_eq!(
resolve(&headers_with_range("bytes=-4"), 10, None),
RangeResolution::Partial {
start: 6,
end: 9,
total: 10
}
);
}
#[test]
fn suffix_range_clamps_when_larger_than_total() {
assert_eq!(
resolve(&headers_with_range("bytes=-100"), 10, None),
RangeResolution::Partial {
start: 0,
end: 9,
total: 10
}
);
}
#[test]
fn closed_end_is_clamped_to_last_byte() {
assert_eq!(
resolve(&headers_with_range("bytes=2-999"), 10, None),
RangeResolution::Partial {
start: 2,
end: 9,
total: 10
}
);
}
#[test]
fn multi_range_collapses_to_first_satisfiable() {
assert_eq!(
resolve(&headers_with_range("bytes=0-3,5-7"), 10, None),
RangeResolution::Partial {
start: 0,
end: 3,
total: 10
}
);
}
#[test]
fn multi_range_skips_leading_unsatisfiable() {
assert_eq!(
resolve(&headers_with_range("bytes=100-200,2-4"), 10, None),
RangeResolution::Partial {
start: 2,
end: 4,
total: 10
}
);
}
#[test]
fn start_beyond_eof_is_unsatisfiable() {
assert_eq!(
resolve(&headers_with_range("bytes=100-"), 10, None),
RangeResolution::Unsatisfiable { total: 10 }
);
}
#[test]
fn non_numeric_range_is_full() {
assert_eq!(
resolve(&headers_with_range("bytes=abc"), 10, None),
RangeResolution::Full
);
}
#[test]
fn backwards_range_is_full() {
assert_eq!(
resolve(&headers_with_range("bytes=5-2"), 10, None),
RangeResolution::Full
);
}
#[test]
fn non_bytes_unit_is_full() {
assert_eq!(
resolve(&headers_with_range("items=0-3"), 10, None),
RangeResolution::Full
);
}
#[test]
fn zero_total_range_is_unsatisfiable() {
assert_eq!(
resolve(&headers_with_range("bytes=0-3"), 0, None),
RangeResolution::Unsatisfiable { total: 0 }
);
}
#[test]
fn zero_suffix_is_unsatisfiable() {
assert_eq!(
resolve(&headers_with_range("bytes=-0"), 10, None),
RangeResolution::Unsatisfiable { total: 10 }
);
}
#[test]
fn if_range_matching_etag_honours_range() {
let etag = ETag::strong("v1");
let mut h = headers_with_range("bytes=0-3");
h.insert(IF_RANGE, etag.header_value());
let validator = Validator::new().with_etag(&etag);
assert_eq!(
resolve(&h, 10, Some(validator)),
RangeResolution::Partial {
start: 0,
end: 3,
total: 10
}
);
}
#[test]
fn if_range_stale_etag_falls_back_to_full() {
let current = ETag::strong("v2");
let mut h = headers_with_range("bytes=0-3");
h.insert(IF_RANGE, HeaderValue::from_static("\"v1\""));
let validator = Validator::new().with_etag(¤t);
assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
}
#[test]
fn if_range_weak_etag_never_matches() {
let weak = ETag::weak("v1");
let mut h = headers_with_range("bytes=0-3");
h.insert(IF_RANGE, HeaderValue::from_static("W/\"v1\""));
let validator = Validator::new().with_etag(&weak);
assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
}
#[test]
fn if_range_matching_last_modified_honours_range() {
let lm = "Wed, 21 Oct 2015 07:28:00 GMT";
let mut h = headers_with_range("bytes=0-3");
h.insert(
IF_RANGE,
HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
);
let validator = Validator::new().with_last_modified(lm);
assert_eq!(
resolve(&h, 10, Some(validator)),
RangeResolution::Partial {
start: 0,
end: 3,
total: 10
}
);
}
#[test]
fn if_range_stale_last_modified_falls_back_to_full() {
let lm = "Wed, 21 Oct 2015 07:28:00 GMT";
let mut h = headers_with_range("bytes=0-3");
h.insert(
IF_RANGE,
HeaderValue::from_static("Tue, 20 Oct 2015 00:00:00 GMT"),
);
let validator = Validator::new().with_last_modified(lm);
assert_eq!(resolve(&h, 10, Some(validator)), RangeResolution::Full);
}
#[test]
fn if_range_without_validator_falls_back_to_full() {
let mut h = headers_with_range("bytes=0-3");
h.insert(IF_RANGE, HeaderValue::from_static("\"v1\""));
assert_eq!(resolve(&h, 10, None), RangeResolution::Full);
}
#[tokio::test]
async fn partial_response_slices_body_and_sets_headers() {
use http_body_util::BodyExt as _;
let full = Bytes::from_static(b"0123456789");
let resolution = RangeResolution::Partial {
start: 2,
end: 5,
total: 10,
};
let resp = partial_bytes_response(&resolution, full);
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(resp.headers().get(CONTENT_RANGE).unwrap(), "bytes 2-5/10");
assert_eq!(resp.headers().get(CONTENT_LENGTH).unwrap(), "4");
assert_eq!(resp.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"2345");
}
#[tokio::test]
async fn full_response_sets_accept_ranges_and_length() {
let full = Bytes::from_static(b"0123456789");
let resp = partial_bytes_response(&RangeResolution::Full, full);
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get(ACCEPT_RANGES).unwrap(), "bytes");
assert_eq!(resp.headers().get(CONTENT_LENGTH).unwrap(), "10");
}
#[tokio::test]
async fn unsatisfiable_response_is_416_with_star_content_range() {
let full = Bytes::from_static(b"0123456789");
let resp = partial_bytes_response(&RangeResolution::Unsatisfiable { total: 10 }, full);
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(resp.headers().get(CONTENT_RANGE).unwrap(), "bytes */10");
}
#[test]
fn content_range_helpers_format_correctly() {
assert_eq!(content_range_value(0, 3, 10), "bytes 0-3/10");
assert_eq!(unsatisfied_content_range(10), "bytes */10");
}
}