vorma 0.86.0-pre.7

Rust-first full-stack web framework: typed views and resources, generated TypeScript contracts, and a Vite-integrated frontend.
Documentation
//! Static public assets: the response body that streams a file from disk, the
//! content type of a file name, and the answer to a range request.

use std::io;
use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll, ready};

use bytes::Bytes;
use http::HeaderMap;
use http::header::{IF_RANGE, RANGE};
use http_body::{Body, Frame, SizeHint};
use tokio::fs::File;
use tokio::io::{AsyncRead, ReadBuf};

/// The most bytes that one frame of a streamed file holds.
const MAX_FRAME_LEN: u64 = 64 * 1024;

/// The body of a response from [`StaticPublicAssetsService`](crate::StaticPublicAssetsService).
///
/// A static public asset's body reads the file in frames while the host server
/// writes the response, so the file is never held whole in memory. Its size
/// hint is the exact number of bytes that remain.
#[derive(Debug)]
pub struct StaticPublicAssetBody {
	/// A body that is already in memory, such as an error status's text.
	buffered: Option<Bytes>,
	/// The open file, read until `remaining_file_len` reaches zero.
	file: Option<File>,
	/// The bytes of `file` that the body has not yet produced. For the body of
	/// a `HEAD` response, which opens no file, this is the file's length.
	remaining_file_len: u64,
}

impl StaticPublicAssetBody {
	/// The body of a `GET` response: `length` bytes of the file from its
	/// current position, read as the host polls the body.
	pub(crate) fn from_file(file: File, length: u64) -> Self {
		Self {
			buffered: None,
			file: Some(file),
			remaining_file_len: length,
		}
	}

	/// The body of a `HEAD` response: no frames, and the length that the
	/// `GET` response would have.
	pub(crate) fn from_length_only(length: u64) -> Self {
		Self {
			buffered: None,
			file: None,
			remaining_file_len: length,
		}
	}

	/// A body that is already in memory.
	pub(crate) fn from_bytes(bytes: Bytes) -> Self {
		Self {
			buffered: Some(bytes),
			file: None,
			remaining_file_len: 0,
		}
	}
}

impl Body for StaticPublicAssetBody {
	type Data = Bytes;
	type Error = io::Error;

	fn poll_frame(
		self: Pin<&mut Self>,
		ctx: &mut Context<'_>,
	) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
		let body = self.get_mut();
		if let Some(bytes) = body.buffered.take() {
			return Poll::Ready(Some(Ok(Frame::data(bytes))));
		}
		let Some(file) = body.file.as_mut() else {
			return Poll::Ready(None);
		};
		if body.remaining_file_len == 0 {
			return Poll::Ready(None);
		}
		let frame_bytes = body.remaining_file_len.min(MAX_FRAME_LEN) as usize;
		let mut chunk = vec![0; frame_bytes];
		let mut read_buffer = ReadBuf::new(&mut chunk);
		ready!(Pin::new(file).poll_read(ctx, &mut read_buffer))?;
		let read_bytes = read_buffer.filled().len();
		if read_bytes == 0 {
			return Poll::Ready(Some(Err(io::Error::new(
				io::ErrorKind::UnexpectedEof,
				"the file ended before its length at the start of the response",
			))));
		}
		chunk.truncate(read_bytes);
		body.remaining_file_len -= read_bytes as u64;
		Poll::Ready(Some(Ok(Frame::data(Bytes::from(chunk)))))
	}

	fn size_hint(&self) -> SizeHint {
		let buffered = self.buffered.as_ref().map_or(0, Bytes::len) as u64;
		SizeHint::with_exact(buffered + self.remaining_file_len)
	}
}

/// The content types where the content type for the file name's extension
/// differs from `mime_guess`'s. Each is the type that IANA registers for the
/// format. Where IANA registers none, it is the type that browsers and
/// servers commonly use.
const CONTENT_TYPES_BY_EXTENSION: [(&str, &str); 17] = [
	// IANA registers `text/javascript`; `application/javascript` is obsolete.
	("cjs", "text/javascript"),
	("mjs", "text/javascript"),
	// IANA registers `font/otf`, `font/woff`, and `font/collection`.
	("otc", "font/collection"),
	("otf", "font/otf"),
	("woff", "font/woff"),
	// IANA registers `image/vnd.microsoft.icon`.
	("ico", "image/vnd.microsoft.icon"),
	// IANA registers `application/xml` and `application/xslt+xml`.
	("xml", "application/xml"),
	("xsl", "application/xslt+xml"),
	("xslt", "application/xslt+xml"),
	// IANA registers `application/yaml` and `application/toml`.
	("toml", "application/toml"),
	("yaml", "application/yaml"),
	("yml", "application/yaml"),
	// IANA registers `video/matroska`.
	("mkv", "video/matroska"),
	// No IANA type exists for source maps and JSX; these are the common ones.
	("jsx", "text/jsx"),
	("map", "application/json"),
	// A `.ts` or `.mts` file in a public directory is an MPEG transport
	// stream segment (`video/mp2t`), as in HLS, not TypeScript.
	("mts", "video/mp2t"),
	("ts", "video/mp2t"),
];

/// The content type of a file, from its name's extension.
pub(crate) fn read_content_type(file_name: &str) -> String {
	let extension = Path::new(file_name)
		.extension()
		.and_then(|extension| extension.to_str())
		.map(str::to_ascii_lowercase);
	if let Some(extension) = &extension
		&& let Some((_, content_type)) = CONTENT_TYPES_BY_EXTENSION
			.iter()
			.find(|(listed, _)| listed == extension)
	{
		return (*content_type).to_owned();
	}
	mime_guess::from_path(file_name)
		.first_or_octet_stream()
		.essence_str()
		.to_owned()
}

/// A run of a file's bytes: `length` bytes from offset `start`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ByteRange {
	pub(crate) start: u64,
	pub(crate) length: u64,
}

/// How a `GET` or `HEAD` request for a static public asset is answered.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StaticPublicAssetAnswer {
	/// `200` with the whole file.
	Whole,
	/// `206` with one range of the file.
	Partial(ByteRange),
	/// `416`: the `Range` header selects no byte of the file, or is invalid.
	RangeNotSatisfiable,
}

/// Decides the answer from the request's `Range` and `If-Range` headers.
///
/// A static public asset has no validator, so a request with `If-Range` cannot
/// confirm one, and RFC 9110 section 13.1.5 requires the whole file. A
/// `Range` header with several satisfiable ranges also gets the whole file,
/// which RFC 9110 section 14.2 permits, and so does any `Range` header for an
/// empty file.
pub(crate) fn decide_static_public_asset_answer(
	headers: &HeaderMap,
	length: u64,
) -> StaticPublicAssetAnswer {
	let Some(range_header) = headers.get(RANGE) else {
		return StaticPublicAssetAnswer::Whole;
	};
	if headers.contains_key(IF_RANGE) {
		return StaticPublicAssetAnswer::Whole;
	}
	let Ok(range_header) = range_header.to_str() else {
		return StaticPublicAssetAnswer::RangeNotSatisfiable;
	};
	match parse_ranges(range_header, length) {
		Err(RangeError::Invalid) => StaticPublicAssetAnswer::RangeNotSatisfiable,
		Err(RangeError::NoOverlap) if length == 0 => StaticPublicAssetAnswer::Whole,
		Err(RangeError::NoOverlap) => StaticPublicAssetAnswer::RangeNotSatisfiable,
		Ok(ranges) => match ranges.as_slice() {
			[range] => StaticPublicAssetAnswer::Partial(*range),
			_ => StaticPublicAssetAnswer::Whole,
		},
	}
}

#[derive(Debug, PartialEq, Eq)]
enum RangeError {
	/// The header is not a list of byte ranges.
	Invalid,
	/// Every range starts at or after the end of the file.
	NoOverlap,
}

/// The satisfiable ranges of a `Range: bytes=...` header for a file of
/// `length` bytes, in header order.
fn parse_ranges(header: &str, length: u64) -> Result<Vec<ByteRange>, RangeError> {
	const UNIT: &str = "bytes=";
	let specs = header
		.get(..UNIT.len())
		.filter(|unit| unit.eq_ignore_ascii_case(UNIT))
		.map(|_| &header[UNIT.len()..])
		.ok_or(RangeError::Invalid)?;
	let mut ranges = Vec::new();
	let mut is_any_spec_unsatisfiable = false;
	for spec in specs
		.split(',')
		.map(str::trim)
		.filter(|spec| !spec.is_empty())
	{
		let (first, last) = spec.split_once('-').ok_or(RangeError::Invalid)?;
		let (first, last) = (first.trim(), last.trim());
		if first.is_empty() {
			// A suffix range: the last `last` bytes.
			let suffix_length = parse_decimal(last)?;
			if suffix_length == 0 || length == 0 {
				is_any_spec_unsatisfiable = true;
				continue;
			}
			let suffix_length = suffix_length.min(length);
			ranges.push(ByteRange {
				start: length - suffix_length,
				length: suffix_length,
			});
			continue;
		}
		let start = parse_decimal(first)?;
		let end = if last.is_empty() {
			None
		} else {
			Some(parse_decimal(last)?)
		};
		if end.is_some_and(|end| start > end) {
			return Err(RangeError::Invalid);
		}
		if start >= length {
			is_any_spec_unsatisfiable = true;
			continue;
		}
		let end = end.map_or(length - 1, |end| end.min(length - 1));
		ranges.push(ByteRange {
			start,
			length: end - start + 1,
		});
	}
	if ranges.is_empty() {
		return Err(if is_any_spec_unsatisfiable {
			RangeError::NoOverlap
		} else {
			RangeError::Invalid
		});
	}
	Ok(ranges)
}

/// A non-negative decimal integer, with digits only.
fn parse_decimal(text: &str) -> Result<u64, RangeError> {
	if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
		return Err(RangeError::Invalid);
	}
	text.parse().map_err(|_| RangeError::Invalid)
}