use std::fmt;
use super::error::ServeError;
use super::security::LocalOrigin;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Head,
Post,
Put,
Delete,
Other,
}
impl HttpMethod {
pub fn parse(raw: &str) -> Self {
match raw.to_ascii_uppercase().as_str() {
"GET" => Self::Get,
"HEAD" => Self::Head,
"POST" => Self::Post,
"PUT" => Self::Put,
"DELETE" => Self::Delete,
_ => Self::Other,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Get => "GET",
Self::Head => "HEAD",
Self::Post => "POST",
Self::Put => "PUT",
Self::Delete => "DELETE",
Self::Other => "OTHER",
}
}
pub fn is_state_changing(self) -> bool {
matches!(self, Self::Post | Self::Put | Self::Delete)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteDecision {
Matched,
NotFound,
MethodNotAllowed,
}
impl RouteDecision {
pub fn name(self) -> &'static str {
match self {
Self::Matched => "matched",
Self::NotFound => "not_found",
Self::MethodNotAllowed => "method_not_allowed",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct RequestDescriptor<'a> {
pub method: HttpMethod,
pub path: &'a str,
pub route: RouteDecision,
pub has_token: bool,
pub host: Option<&'a str>,
pub origin: Option<&'a str>,
pub queue: QueueAdmission<'a>,
pub content_length: Option<u64>,
pub body_so_far: u64,
pub content_type: Option<&'a str>,
pub is_chunked: bool,
}
#[derive(Clone, Copy)]
pub enum QueueAdmission<'a> {
NotQueued,
Reserve(&'a dyn QueueSlots),
}
impl fmt::Debug for QueueAdmission<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotQueued => f.write_str("NotQueued"),
Self::Reserve(_) => f.write_str("Reserve(<server queue>)"),
}
}
}
impl QueueAdmission<'_> {
fn reserve(&self) -> Option<QueueReservation> {
match self {
Self::NotQueued => None,
Self::Reserve(slots) => slots.reserve(),
}
}
fn is_queued(&self) -> bool {
matches!(self, Self::Reserve(_))
}
}
pub trait QueueSlots {
fn reserve(&self) -> Option<QueueReservation>;
}
pub struct QueueReservation {
release: Option<Box<dyn FnOnce() + Send>>,
}
impl QueueReservation {
pub fn new(release: impl FnOnce() + Send + 'static) -> Self {
Self {
release: Some(Box::new(release)),
}
}
}
impl Drop for QueueReservation {
fn drop(&mut self) {
if let Some(release) = self.release.take() {
release();
}
}
}
impl fmt::Debug for QueueReservation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"QueueReservation({})",
if self.release.is_some() {
"pending"
} else {
"released"
}
)
}
}
impl RequestDescriptor<'_> {
pub fn check_content_type(&self) -> Result<(), ServeError> {
check_content_type(self.content_type)
}
}
#[derive(Debug)]
pub enum AdmissionError {
Route {
path: String,
method: HttpMethod,
decision: RouteDecision,
},
Rejected(ServeError),
}
impl AdmissionError {
pub fn rejected(&self) -> Option<&ServeError> {
match self {
Self::Rejected(error) => Some(error),
Self::Route { .. } => None,
}
}
}
impl From<ServeError> for AdmissionError {
fn from(error: ServeError) -> Self {
Self::Rejected(error)
}
}
#[derive(Debug)]
pub struct Admit {
pub max_body: u64,
pub expected_body: Option<u64>,
pub reservation: Option<QueueReservation>,
}
pub fn admit(
descriptor: &RequestDescriptor<'_>,
policy: &LocalOrigin,
max_body: u64,
) -> Result<Admit, AdmissionError> {
if descriptor.route != RouteDecision::Matched {
return Err(AdmissionError::Route {
path: descriptor.path.to_string(),
method: descriptor.method,
decision: descriptor.route,
});
}
if !descriptor.has_token {
return Err(AdmissionError::Rejected(ServeError::Unauthorized));
}
policy.check_host(descriptor.host)?;
policy.check_origin(descriptor.origin, descriptor.method.is_state_changing())?;
let reservation = match descriptor.queue.is_queued() {
true => match descriptor.queue.reserve() {
Some(reservation) => Some(reservation),
None => return Err(AdmissionError::Rejected(ServeError::Busy)),
},
false => None,
};
let expected_body = if descriptor.is_chunked {
None
} else {
descriptor.content_length
};
if descriptor.body_so_far > max_body {
return Err(AdmissionError::Rejected(ServeError::PayloadTooLarge));
}
if let Some(length) = expected_body {
if length > max_body {
return Err(AdmissionError::Rejected(ServeError::PayloadTooLarge));
}
if descriptor.body_so_far > length {
return Err(AdmissionError::Rejected(ServeError::BadRequest));
}
}
Ok(Admit {
max_body,
expected_body,
reservation,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BodyBudget {
max_body: u64,
received: u64,
}
impl BodyBudget {
pub fn new(max_body: u64) -> Self {
Self {
max_body,
received: 0,
}
}
pub fn max_body(&self) -> u64 {
self.max_body
}
pub fn received(&self) -> u64 {
self.received
}
pub fn remaining(&self) -> u64 {
self.max_body.saturating_sub(self.received)
}
pub fn accept(&mut self, chunk_len: u64) -> Result<(), ServeError> {
if chunk_len > self.remaining() {
return Err(ServeError::PayloadTooLarge);
}
self.received += chunk_len;
Ok(())
}
pub fn finish(&self, expected: Option<u64>) -> Result<(), ServeError> {
if let Some(expected) = expected
&& self.received != expected
{
return Err(ServeError::BadRequest);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChunkOutcome {
Data(Vec<u8>),
End,
Failed(String),
TimedOut,
}
pub trait BodySource {
fn read_timed_out(&self) -> bool;
fn next_chunk(&mut self) -> ChunkOutcome;
}
pub fn read_body(
source: &mut dyn BodySource,
expected: Option<u64>,
max_body: u64,
) -> Result<Vec<u8>, ServeError> {
let mut budget = BodyBudget::new(max_body);
let reserve = expected.unwrap_or(0).min(max_body);
let mut body = Vec::with_capacity(usize::try_from(reserve).unwrap_or(0));
loop {
if source.read_timed_out() {
return Err(ServeError::RequestTimeout);
}
match source.next_chunk() {
ChunkOutcome::End => break,
ChunkOutcome::TimedOut => return Err(ServeError::RequestTimeout),
ChunkOutcome::Failed(_) => return Err(ServeError::BadRequest),
ChunkOutcome::Data(chunk) => {
if let Err(error) = budget.accept(chunk.len() as u64) {
eprintln!(
"serve: request body rejected after {} of at most {} byte(s)",
budget.received(),
budget.max_body()
);
return Err(error);
}
body.extend_from_slice(&chunk);
}
}
}
budget.finish(expected)?;
Ok(body)
}
pub fn check_content_type(content_type: Option<&str>) -> Result<(), ServeError> {
let Some(raw) = content_type else {
return Ok(());
};
let essence = raw
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let accepted = essence == "application/octet-stream" || essence.starts_with("image/");
if accepted && !essence.is_empty() {
return Ok(());
}
Err(ServeError::BadRequest)
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{
AdmissionError, BodyBudget, BodySource, ChunkOutcome, HttpMethod, QueueAdmission,
QueueReservation, QueueSlots, RequestDescriptor, RouteDecision, admit, check_content_type,
read_body,
};
use crate::serve::error::ServeError;
use crate::serve::security::LocalOrigin;
const MAX_BODY: u64 = 1024;
fn policy() -> LocalOrigin {
LocalOrigin::for_port(8760)
}
struct FullQueue {
attempts: AtomicUsize,
}
impl FullQueue {
fn new() -> Self {
Self {
attempts: AtomicUsize::new(0),
}
}
}
impl QueueSlots for FullQueue {
fn reserve(&self) -> Option<QueueReservation> {
self.attempts.fetch_add(1, Ordering::SeqCst);
None
}
}
struct FreeQueue {
released: std::sync::Arc<AtomicUsize>,
}
impl FreeQueue {
fn new() -> Self {
Self {
released: std::sync::Arc::new(AtomicUsize::new(0)),
}
}
fn released(&self) -> usize {
self.released.load(Ordering::SeqCst)
}
}
impl QueueSlots for FreeQueue {
fn reserve(&self) -> Option<QueueReservation> {
let released = std::sync::Arc::clone(&self.released);
Some(QueueReservation::new(move || {
released.fetch_add(1, Ordering::SeqCst);
}))
}
}
fn ok_descriptor() -> RequestDescriptor<'static> {
RequestDescriptor {
method: HttpMethod::Post,
path: "/api/ocr",
route: RouteDecision::Matched,
has_token: true,
host: Some("127.0.0.1:8760"),
origin: Some("http://127.0.0.1:8760"),
queue: QueueAdmission::NotQueued,
content_length: Some(128),
body_so_far: 0,
content_type: Some("application/octet-stream"),
is_chunked: false,
}
}
#[test]
fn a_fully_valid_request_is_admitted() {
let admitted = admit(&ok_descriptor(), &policy(), MAX_BODY).expect("admitted");
assert_eq!(admitted.max_body, MAX_BODY);
assert_eq!(admitted.expected_body, Some(128));
assert!(
admitted.reservation.is_none(),
"a route that does not use a queue must not hold a slot"
);
}
#[test]
fn a_queue_admission_reserves_a_slot_and_releases_it_on_drop() {
let queue = FreeQueue::new();
let mut descriptor = ok_descriptor();
descriptor.queue = QueueAdmission::Reserve(&queue);
let admitted = admit(&descriptor, &policy(), MAX_BODY).expect("admitted");
assert!(admitted.reservation.is_some(), "a slot must be reserved");
assert_eq!(queue.released(), 0);
drop(admitted);
assert_eq!(
queue.released(),
1,
"dropping an uncommitted reservation must return the slot"
);
}
#[test]
fn the_admission_order_is_enforced_step_by_step() {
let full = FullQueue::new();
let full_queue = QueueAdmission::Reserve(&full);
let mut descriptor = ok_descriptor();
descriptor.route = RouteDecision::NotFound;
descriptor.has_token = false;
descriptor.host = Some("evil.example:8760");
descriptor.origin = Some("null");
descriptor.queue = full_queue;
descriptor.content_length = Some(u64::MAX);
match admit(&descriptor, &policy(), MAX_BODY).expect_err("route wins") {
AdmissionError::Route { decision, path, .. } => {
assert_eq!(decision, RouteDecision::NotFound);
assert_eq!(path, "/api/ocr");
}
other => panic!("expected a route rejection, got {other:?}"),
}
descriptor.route = RouteDecision::MethodNotAllowed;
assert!(matches!(
admit(&descriptor, &policy(), MAX_BODY).expect_err("route wins"),
AdmissionError::Route {
decision: RouteDecision::MethodNotAllowed,
..
}
));
let mut descriptor = ok_descriptor();
descriptor.has_token = false;
descriptor.host = Some("evil.example:8760");
descriptor.origin = Some("null");
descriptor.queue = full_queue;
descriptor.content_length = Some(u64::MAX);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("token wins");
assert_eq!(error.rejected().expect("a ServeError").status_code(), 401);
let mut descriptor = ok_descriptor();
descriptor.host = Some("evil.example:8760");
descriptor.origin = Some("null");
descriptor.queue = full_queue;
descriptor.content_length = Some(u64::MAX);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("host wins");
assert_eq!(error.rejected().expect("a ServeError").status_code(), 421);
let mut descriptor = ok_descriptor();
descriptor.origin = None;
descriptor.queue = full_queue;
descriptor.content_length = Some(u64::MAX);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("origin wins");
assert_eq!(error.rejected().expect("a ServeError").status_code(), 403);
let mut descriptor = ok_descriptor();
descriptor.queue = full_queue;
descriptor.content_length = Some(500 * 1024 * 1024);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("queue wins");
let error = error.rejected().expect("a ServeError");
assert_eq!(error.status_code(), 503);
assert_eq!(error.code(), "busy");
assert_eq!(
full.attempts.load(Ordering::SeqCst),
1,
"the queue step must run exactly once for this descriptor"
);
let queue = FreeQueue::new();
let mut descriptor = ok_descriptor();
descriptor.queue = QueueAdmission::Reserve(&queue);
descriptor.content_length = Some(MAX_BODY + 1);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("length wins");
assert_eq!(error.rejected().expect("a ServeError").status_code(), 413);
assert_eq!(
queue.released(),
1,
"a rejected request must return its reserved slot"
);
let mut descriptor = ok_descriptor();
descriptor.content_length = Some(MAX_BODY + 1);
let error = admit(&descriptor, &policy(), MAX_BODY).expect_err("length wins");
let error = error.rejected().expect("a ServeError");
assert_eq!(error.status_code(), 413);
assert_eq!(error.code(), "payload_too_large");
}
#[test]
fn content_length_boundaries_are_inclusive() {
let mut descriptor = ok_descriptor();
descriptor.content_length = Some(MAX_BODY);
assert_eq!(
admit(&descriptor, &policy(), MAX_BODY)
.expect("exactly the limit is allowed")
.expected_body,
Some(MAX_BODY)
);
descriptor.content_length = Some(MAX_BODY + 1);
assert!(matches!(
admit(&descriptor, &policy(), MAX_BODY).expect_err("one byte over"),
AdmissionError::Rejected(ServeError::PayloadTooLarge)
));
descriptor.content_length = Some(0);
assert!(admit(&descriptor, &policy(), MAX_BODY).is_ok());
descriptor.content_length = None;
let admitted = admit(&descriptor, &policy(), MAX_BODY).expect("admitted");
assert_eq!(admitted.expected_body, None);
}
#[test]
fn chunked_requests_skip_the_length_pre_check_but_keep_the_stream_cap() {
let mut descriptor = ok_descriptor();
descriptor.is_chunked = true;
descriptor.content_length = Some(u64::MAX); let admitted = admit(&descriptor, &policy(), MAX_BODY).expect("admitted");
assert_eq!(admitted.expected_body, None);
let mut source = ScriptedSource::new(vec![
ChunkOutcome::Data(vec![0; 512]),
ChunkOutcome::Data(vec![0; 512]),
ChunkOutcome::Data(vec![0; 1]),
]);
let error = read_body(&mut source, admitted.expected_body, admitted.max_body)
.expect_err("the stream cap must apply to chunked bodies");
assert_eq!(error.status_code(), 413);
}
#[test]
fn a_body_already_buffered_beyond_the_limit_is_rejected_before_reading() {
let mut descriptor = ok_descriptor();
descriptor.body_so_far = MAX_BODY + 1;
descriptor.content_length = None;
assert!(matches!(
admit(&descriptor, &policy(), MAX_BODY).expect_err("already over"),
AdmissionError::Rejected(ServeError::PayloadTooLarge)
));
let mut descriptor = ok_descriptor();
descriptor.content_length = Some(10);
descriptor.body_so_far = 11;
assert!(matches!(
admit(&descriptor, &policy(), MAX_BODY).expect_err("inconsistent"),
AdmissionError::Rejected(ServeError::BadRequest)
));
}
#[test]
fn read_requests_do_not_need_an_origin_but_still_need_host_and_token() {
let mut descriptor = ok_descriptor();
descriptor.method = HttpMethod::Get;
descriptor.origin = None;
assert!(admit(&descriptor, &policy(), MAX_BODY).is_ok());
descriptor.host = None;
assert_eq!(
admit(&descriptor, &policy(), MAX_BODY)
.expect_err("host still required")
.rejected()
.expect("ServeError")
.status_code(),
421
);
}
#[test]
fn http_methods_are_classified_for_origin_enforcement() {
assert_eq!(HttpMethod::parse("POST"), HttpMethod::Post);
assert_eq!(HttpMethod::parse("post"), HttpMethod::Post);
assert_eq!(HttpMethod::parse("GET"), HttpMethod::Get);
assert_eq!(HttpMethod::parse("HEAD"), HttpMethod::Head);
assert_eq!(HttpMethod::parse("PUT"), HttpMethod::Put);
assert_eq!(HttpMethod::parse("DELETE"), HttpMethod::Delete);
assert_eq!(HttpMethod::parse("TRACE"), HttpMethod::Other);
assert_eq!(HttpMethod::parse(""), HttpMethod::Other);
for method in [HttpMethod::Post, HttpMethod::Put, HttpMethod::Delete] {
assert!(method.is_state_changing(), "{method:?}");
}
for method in [HttpMethod::Get, HttpMethod::Head, HttpMethod::Other] {
assert!(!method.is_state_changing(), "{method:?}");
}
assert_eq!(HttpMethod::Post.name(), "POST");
}
struct ScriptedSource {
chunks: VecDeque<ChunkOutcome>,
timed_out: bool,
polls: usize,
}
impl ScriptedSource {
fn new(chunks: Vec<ChunkOutcome>) -> Self {
Self {
chunks: chunks.into(),
timed_out: false,
polls: 0,
}
}
}
impl BodySource for ScriptedSource {
fn read_timed_out(&self) -> bool {
self.timed_out
}
fn next_chunk(&mut self) -> ChunkOutcome {
self.polls += 1;
self.chunks.pop_front().unwrap_or(ChunkOutcome::End)
}
}
#[test]
fn body_budget_tracks_the_remaining_allowance_without_overflow() {
let mut budget = BodyBudget::new(MAX_BODY);
assert_eq!(budget.remaining(), MAX_BODY);
budget.accept(MAX_BODY - 1).expect("fits");
assert_eq!(budget.received(), MAX_BODY - 1);
assert_eq!(budget.remaining(), 1);
budget.accept(1).expect("exactly the limit");
assert_eq!(budget.remaining(), 0);
assert!(matches!(
budget.accept(1).expect_err("one byte over"),
ServeError::PayloadTooLarge
));
assert_eq!(
budget.received(),
MAX_BODY,
"a rejected chunk must not be counted"
);
let mut huge = BodyBudget::new(u64::MAX);
huge.accept(u64::MAX - 1).expect("fits");
assert!(matches!(
huge.accept(u64::MAX).expect_err("overflowing sum"),
ServeError::PayloadTooLarge
));
assert_eq!(huge.received(), u64::MAX - 1);
}
#[test]
fn body_budget_finish_detects_a_content_length_mismatch() {
let mut budget = BodyBudget::new(MAX_BODY);
budget.accept(100).expect("fits");
budget.finish(Some(100)).expect("matching length");
assert!(matches!(
budget.finish(Some(101)).expect_err("short body"),
ServeError::BadRequest
));
assert!(matches!(
budget.finish(Some(99)).expect_err("long body"),
ServeError::BadRequest
));
budget.finish(None).expect("no declared length to check");
}
#[test]
fn read_body_concatenates_chunks_and_checks_the_declared_length() {
let mut source = ScriptedSource::new(vec![
ChunkOutcome::Data(vec![1, 2, 3]),
ChunkOutcome::Data(vec![]),
ChunkOutcome::Data(vec![4, 5]),
]);
let body = read_body(&mut source, Some(5), MAX_BODY).expect("read");
assert_eq!(body, vec![1, 2, 3, 4, 5]);
let mut source = ScriptedSource::new(vec![ChunkOutcome::Data(vec![1, 2, 3])]);
let error = read_body(&mut source, Some(5), MAX_BODY).expect_err("short body");
assert_eq!(error.status_code(), 400);
let mut source = ScriptedSource::new(vec![ChunkOutcome::Data(vec![1, 2, 3, 4, 5, 6])]);
let error = read_body(&mut source, Some(5), MAX_BODY).expect_err("long body");
assert_eq!(error.status_code(), 400);
}
#[test]
fn read_body_rejects_a_stream_that_exceeds_the_cap() {
let mut source = ScriptedSource::new(vec![
ChunkOutcome::Data(vec![0; MAX_BODY as usize]),
ChunkOutcome::Data(vec![0]),
]);
let error = read_body(&mut source, None, MAX_BODY).expect_err("over the cap");
assert_eq!(error.status_code(), 413);
assert_eq!(error.code(), "payload_too_large");
let mut source = ScriptedSource::new(vec![ChunkOutcome::Data(vec![0; MAX_BODY as usize])]);
let body = read_body(&mut source, None, MAX_BODY).expect("exactly the cap");
assert_eq!(body.len(), MAX_BODY as usize);
let mut source =
ScriptedSource::new(vec![ChunkOutcome::Data(vec![0; MAX_BODY as usize + 1])]);
assert!(read_body(&mut source, None, MAX_BODY).is_err());
}
#[test]
fn read_body_reports_a_read_timeout() {
let mut source = ScriptedSource {
chunks: VecDeque::new(),
timed_out: true,
polls: 0,
};
let error = read_body(&mut source, None, MAX_BODY).expect_err("timed out before any chunk");
assert_eq!(error.status_code(), 408);
assert_eq!(error.code(), "request_timeout");
assert_eq!(
source.polls, 0,
"a timed-out read must not touch the source"
);
struct TimeoutAfterOne {
first: Option<Vec<u8>>,
}
impl BodySource for TimeoutAfterOne {
fn read_timed_out(&self) -> bool {
self.first.is_none()
}
fn next_chunk(&mut self) -> ChunkOutcome {
match self.first.take() {
Some(chunk) => ChunkOutcome::Data(chunk),
None => ChunkOutcome::TimedOut,
}
}
}
let mut source = TimeoutAfterOne {
first: Some(vec![1, 2, 3]),
};
let error = read_body(&mut source, None, MAX_BODY).expect_err("stalled mid-body");
assert_eq!(error.status_code(), 408);
let mut source = ScriptedSource::new(vec![ChunkOutcome::TimedOut]);
assert_eq!(
read_body(&mut source, None, MAX_BODY)
.expect_err("TimedOut")
.status_code(),
408
);
}
#[test]
fn read_body_maps_a_broken_stream_to_bad_request() {
let mut source = ScriptedSource::new(vec![
ChunkOutcome::Data(vec![1, 2]),
ChunkOutcome::Failed("connection reset".to_string()),
]);
let error = read_body(&mut source, None, MAX_BODY).expect_err("broken stream");
assert_eq!(error.status_code(), 400);
assert_eq!(error.code(), "bad_request");
}
#[test]
fn read_body_accepts_an_empty_body() {
let mut source = ScriptedSource::new(vec![ChunkOutcome::End]);
let body = read_body(&mut source, Some(0), MAX_BODY).expect("an empty body is valid");
assert!(body.is_empty());
}
#[test]
fn only_raw_bodies_and_images_are_accepted_for_decoding() {
for accepted in [
None,
Some("application/octet-stream"),
Some("APPLICATION/OCTET-STREAM"),
Some("image/png"),
Some("image/jpeg; charset=binary"),
] {
assert!(
check_content_type(accepted).is_ok(),
"should be accepted: {accepted:?}"
);
}
for rejected in [
Some("application/x-www-form-urlencoded"),
Some("multipart/form-data; boundary=----x"),
Some("text/plain"),
Some("application/json"),
Some(""),
Some(" "),
] {
let error = check_content_type(rejected)
.expect_err(&format!("should be rejected: {rejected:?}"));
assert_eq!(error.status_code(), 400, "content type: {rejected:?}");
assert_eq!(error.code(), "bad_request");
}
let mut descriptor = ok_descriptor();
assert!(descriptor.check_content_type().is_ok());
descriptor.content_type = Some("multipart/form-data");
assert!(descriptor.check_content_type().is_err());
}
}