use std::ops::RangeInclusive;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, Response, StatusCode, header};
use futures::StreamExt;
use mbr_core::vault::{Vault, VaultPath};
pub(crate) const CHUNK_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RequestedRange {
Whole,
Partial(RangeInclusive<u64>),
Unsatisfiable,
}
pub(crate) fn requested_range(headers: &HeaderMap, size: u64) -> RequestedRange {
let Some(value) = headers.get(header::RANGE) else {
return RequestedRange::Whole;
};
let Ok(value) = value.to_str() else {
return RequestedRange::Unsatisfiable;
};
match http_range_header::parse_range_header(value).and_then(|parsed| parsed.validate(size)) {
Ok(ranges) if ranges.len() == 1 => {
let range = ranges.into_iter().next().unwrap_or(0..=0);
if size == 0 || range.start() > range.end() {
RequestedRange::Unsatisfiable
} else {
RequestedRange::Partial(range)
}
}
_ => RequestedRange::Unsatisfiable,
}
}
pub(crate) fn vault_file_response(
vault: Arc<dyn Vault>,
path: VaultPath,
size: u64,
content_type: &str,
last_modified: Option<String>,
headers: &HeaderMap,
head_only: bool,
) -> Response<Body> {
let mut builder = Response::builder()
.header(header::ACCEPT_RANGES, "bytes")
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(content_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
if let Some(last_modified) = last_modified {
builder = builder.header(header::LAST_MODIFIED, last_modified);
}
let (status, range) = match requested_range(headers, size) {
RequestedRange::Whole => (StatusCode::OK, (size > 0).then(|| 0..=size - 1)),
RequestedRange::Partial(range) => {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{size}", range.start(), range.end()),
);
(StatusCode::PARTIAL_CONTENT, Some(range))
}
RequestedRange::Unsatisfiable => {
return builder
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{size}"))
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("Range Not Satisfiable"))
.unwrap_or_else(|_| Response::new(Body::empty()));
}
};
let length = range.as_ref().map_or(0, |r| r.end() - r.start() + 1);
let body = match range {
Some(range) if !head_only => streamed(vault, path, range),
_ => Body::empty(),
};
builder
.status(status)
.header(header::CONTENT_LENGTH, length)
.body(body)
.unwrap_or_else(|_| Response::new(Body::empty()))
}
fn streamed(vault: Arc<dyn Vault>, path: VaultPath, range: RangeInclusive<u64>) -> Body {
let end = *range.end() + 1;
let stream = futures::stream::unfold(*range.start(), move |offset| {
let vault = Arc::clone(&vault);
let path = path.clone();
async move {
if offset >= end {
return None;
}
let want = usize::try_from(end - offset)
.unwrap_or(usize::MAX)
.min(CHUNK_BYTES);
let read = tokio::task::spawn_blocking(move || vault.read_range(&path, offset, want))
.await
.map_err(std::io::Error::other)
.and_then(|r| r.map_err(std::io::Error::from));
match read {
Ok(bytes) if bytes.is_empty() => Some((
Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)),
end,
)),
Ok(bytes) => {
let next = offset + bytes.len() as u64;
Some((Ok(bytes), next))
}
Err(e) => Some((Err(e), end)),
}
}
});
Body::from_stream(stream.map(|chunk| chunk.map(axum::body::Bytes::from)))
}
#[cfg(test)]
mod tests {
use super::*;
use mbr_core::vault::MemVault;
fn range_header(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::RANGE, HeaderValue::from_str(value).unwrap());
headers
}
#[test]
fn range_grammar_matches_serve_file() {
let none = HeaderMap::new();
assert_eq!(requested_range(&none, 10), RequestedRange::Whole);
let r = |v: &str| requested_range(&range_header(v), 1000);
assert_eq!(r("bytes=0-99"), RequestedRange::Partial(0..=99));
assert_eq!(r("bytes=900-"), RequestedRange::Partial(900..=999));
assert_eq!(r("bytes=-100"), RequestedRange::Partial(900..=999));
assert_eq!(r("bytes=990-5000"), RequestedRange::Partial(990..=999));
assert_eq!(r("bytes=1000-"), RequestedRange::Unsatisfiable);
assert_eq!(r("bytes=-2000"), RequestedRange::Unsatisfiable);
assert_eq!(r("bytes=50-10"), RequestedRange::Unsatisfiable);
assert_eq!(r("items=0-1"), RequestedRange::Unsatisfiable);
assert_eq!(r("bytes=abc"), RequestedRange::Unsatisfiable);
assert_eq!(r("bytes=0-1,5-6"), RequestedRange::Unsatisfiable);
assert_eq!(
requested_range(&range_header("bytes=0-0"), 0),
RequestedRange::Unsatisfiable
);
}
async fn body_of(response: Response<Body>) -> Vec<u8> {
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
fn vault_with(bytes: &[u8]) -> (Arc<dyn Vault>, VaultPath) {
let vault: Arc<dyn Vault> = Arc::new(MemVault::new().with_file("v.bin", bytes));
(vault, VaultPath::new("v.bin").unwrap())
}
#[tokio::test]
async fn whole_partial_and_refused_responses() {
let data: Vec<u8> = (0..=255u8).cycle().take(CHUNK_BYTES * 2 + 17).collect();
let size = data.len() as u64;
let (vault, path) = vault_with(&data);
let respond = |headers: HeaderMap| {
vault_file_response(
Arc::clone(&vault),
path.clone(),
size,
"video/mp4",
Some("Thu, 01 Jan 1970 00:00:00 GMT".into()),
&headers,
false,
)
};
let whole = respond(HeaderMap::new());
assert_eq!(whole.status(), StatusCode::OK);
assert_eq!(whole.headers()[header::ACCEPT_RANGES], "bytes");
assert_eq!(whole.headers()[header::CONTENT_LENGTH], size.to_string());
assert_eq!(whole.headers()[header::CONTENT_TYPE], "video/mp4");
assert!(whole.headers().contains_key(header::LAST_MODIFIED));
assert_eq!(body_of(whole).await, data, "streamed across chunks");
let first = respond(range_header("bytes=0-99"));
assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
first.headers()[header::CONTENT_RANGE],
format!("bytes 0-99/{size}")
);
assert_eq!(first.headers()[header::CONTENT_LENGTH], "100");
assert_eq!(body_of(first).await, data[..100]);
let open = respond(range_header(&format!("bytes={}-", CHUNK_BYTES - 3)));
assert_eq!(open.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(body_of(open).await, data[CHUNK_BYTES - 3..]);
let suffix = respond(range_header("bytes=-10"));
assert_eq!(
suffix.headers()[header::CONTENT_RANGE],
format!("bytes {}-{}/{size}", size - 10, size - 1)
);
assert_eq!(body_of(suffix).await, data[data.len() - 10..]);
for bad in [format!("bytes={size}-"), "bytes=x-y".to_string()] {
let refused = respond(range_header(&bad));
assert_eq!(refused.status(), StatusCode::RANGE_NOT_SATISFIABLE, "{bad}");
assert_eq!(
refused.headers()[header::CONTENT_RANGE],
format!("bytes */{size}")
);
}
}
#[tokio::test]
async fn head_and_empty_files_have_no_body() {
let (vault, path) = vault_with(b"hello");
let head = vault_file_response(
Arc::clone(&vault),
path.clone(),
5,
"text/plain",
None,
&HeaderMap::new(),
true,
);
assert_eq!(head.headers()[header::CONTENT_LENGTH], "5");
assert!(body_of(head).await.is_empty());
let (empty, empty_path) = vault_with(b"");
let response = vault_file_response(
empty,
empty_path,
0,
"text/plain",
None,
&HeaderMap::new(),
false,
);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CONTENT_LENGTH], "0");
assert!(body_of(response).await.is_empty());
}
}