Skip to content

Commit 14b03f4

Browse files
committed
Cleanup code
1 parent ef3bb12 commit 14b03f4

32 files changed

Lines changed: 1660 additions & 254 deletions

crates/vespera/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@ pub mod __validation;
172172
#[cfg(feature = "validation")]
173173
mod validated;
174174
#[cfg(feature = "validation")]
175-
pub use validated::{ValidatePayload, Validated};
175+
pub use validated::{
176+
ValidatePayload, ValidatePayloadWith, Validated, ValidatedWith, ValidationContext,
177+
};
176178

177179
/// In-process dispatch — drive an axum Router without a TCP socket.
178180
#[cfg(feature = "inprocess")]

crates/vespera/src/multipart.rs

Lines changed: 203 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
1616
use std::{
1717
borrow::Cow,
18+
cell::RefCell,
1819
fmt,
1920
sync::atomic::{AtomicUsize, Ordering},
2021
};
@@ -81,6 +82,18 @@ pub enum TypedMultipartError {
8182
/// The configured limit in bytes.
8283
limit_bytes: usize,
8384
},
85+
/// The cumulative bytes read across all fields exceeded the request cap.
86+
RequestTooLarge {
87+
/// Name of the field whose chunk crossed the aggregate cap.
88+
field_name: String,
89+
/// The configured aggregate limit in bytes.
90+
limit_bytes: usize,
91+
},
92+
/// The multipart request contained more parts than the configured cap.
93+
TooManyFields {
94+
/// The configured maximum number of fields.
95+
limit_fields: usize,
96+
},
8497
/// A catch-all for other errors during multipart processing.
8598
Other {
8699
/// Description of the error.
@@ -129,6 +142,19 @@ impl fmt::Display for TypedMultipartError {
129142
"Field `{field_name}` exceeds size limit of {limit_bytes} bytes"
130143
)
131144
}
145+
Self::RequestTooLarge {
146+
field_name,
147+
limit_bytes,
148+
} => write!(
149+
f,
150+
"Multipart request exceeds aggregate size limit of {limit_bytes} bytes while reading field `{field_name}`"
151+
),
152+
Self::TooManyFields { limit_fields } => {
153+
write!(
154+
f,
155+
"Multipart request exceeds field count limit of {limit_fields}"
156+
)
157+
}
132158
Self::Other { source } => write!(f, "{source}"),
133159
}
134160
}
@@ -146,6 +172,8 @@ impl std::error::Error for TypedMultipartError {
146172
| Self::InvalidEnumValue { .. }
147173
| Self::NamelessField
148174
| Self::FieldTooLarge { .. }
175+
| Self::RequestTooLarge { .. }
176+
| Self::TooManyFields { .. }
149177
| Self::Other { .. } => None,
150178
}
151179
}
@@ -161,10 +189,12 @@ impl TypedMultipartError {
161189
| Self::DuplicateField { field_name }
162190
| Self::UnknownField { field_name }
163191
| Self::InvalidEnumValue { field_name, .. }
164-
| Self::FieldTooLarge { field_name, .. } => Some(field_name),
192+
| Self::FieldTooLarge { field_name, .. }
193+
| Self::RequestTooLarge { field_name, .. } => Some(field_name),
165194
Self::InvalidRequest { .. }
166195
| Self::InvalidRequestBody { .. }
167196
| Self::NamelessField
197+
| Self::TooManyFields { .. }
168198
| Self::Other { .. } => None,
169199
}
170200
}
@@ -250,7 +280,9 @@ impl IntoResponse for TypedMultipartError {
250280
// unsupported multipart media type. Keep this aligned with
251281
// `Validated<T>`'s validation-failure status.
252282
Self::WrongFieldType { .. } => StatusCode::UNPROCESSABLE_ENTITY,
253-
Self::FieldTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
283+
Self::FieldTooLarge { .. }
284+
| Self::RequestTooLarge { .. }
285+
| Self::TooManyFields { .. } => StatusCode::PAYLOAD_TOO_LARGE,
254286
Self::Other { .. } => StatusCode::INTERNAL_SERVER_ERROR,
255287
};
256288
// Serialize the canonical 422 envelope (see `error_body` /
@@ -441,6 +473,150 @@ impl<T> std::ops::DerefMut for TypedMultipart<T> {
441473
}
442474
}
443475

476+
/// Default aggregate cap for a typed multipart request body.
477+
///
478+
/// This cap is intentionally much higher than axum's built-in
479+
/// [`DefaultBodyLimit`](axum::extract::DefaultBodyLimit) default because the
480+
/// two policies guard different layers: axum may reject the raw HTTP body before
481+
/// Vespera sees it, while this cap still applies when applications explicitly
482+
/// disable or raise axum's body limit for in-process/JNI uploads.
483+
pub const DEFAULT_MULTIPART_MAX_TOTAL_BYTES: usize = 512 * 1024 * 1024; // 512 MiB
484+
485+
/// Default maximum number of parts in a typed multipart request.
486+
pub const DEFAULT_MULTIPART_MAX_FIELDS: usize = 1024;
487+
488+
static DEFAULT_MULTIPART_TOTAL_LIMIT: AtomicUsize =
489+
AtomicUsize::new(DEFAULT_MULTIPART_MAX_TOTAL_BYTES);
490+
static DEFAULT_MULTIPART_FIELD_LIMIT: AtomicUsize = AtomicUsize::new(DEFAULT_MULTIPART_MAX_FIELDS);
491+
492+
/// Aggregate resource policy for [`TypedMultipart`].
493+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494+
pub struct MultipartLimits {
495+
/// Maximum cumulative bytes accepted across all parsed fields.
496+
pub max_total_bytes: usize,
497+
/// Maximum number of parsed fields accepted in one request.
498+
pub max_fields: usize,
499+
}
500+
501+
impl MultipartLimits {
502+
/// Construct an aggregate multipart policy.
503+
#[must_use]
504+
pub const fn new(max_total_bytes: usize, max_fields: usize) -> Self {
505+
Self {
506+
max_total_bytes,
507+
max_fields,
508+
}
509+
}
510+
}
511+
512+
/// Return the process-wide default aggregate multipart policy.
513+
#[must_use]
514+
pub fn default_multipart_limits() -> MultipartLimits {
515+
MultipartLimits::new(
516+
DEFAULT_MULTIPART_TOTAL_LIMIT.load(Ordering::Relaxed),
517+
DEFAULT_MULTIPART_FIELD_LIMIT.load(Ordering::Relaxed),
518+
)
519+
}
520+
521+
/// Set the process-wide default aggregate multipart policy.
522+
///
523+
/// Prefer calling this during application startup, before request handling. For
524+
/// per-route policies use [`TypedMultipartWithLimits`], which avoids global
525+
/// process state and is therefore safer in tests and multi-tenant apps.
526+
pub fn set_default_multipart_limits(limits: MultipartLimits) -> MultipartLimits {
527+
MultipartLimits::new(
528+
DEFAULT_MULTIPART_TOTAL_LIMIT.swap(limits.max_total_bytes, Ordering::Relaxed),
529+
DEFAULT_MULTIPART_FIELD_LIMIT.swap(limits.max_fields, Ordering::Relaxed),
530+
)
531+
}
532+
533+
#[derive(Debug)]
534+
struct MultipartAggregateState {
535+
limits: MultipartLimits,
536+
total_bytes: usize,
537+
fields: usize,
538+
}
539+
540+
impl MultipartAggregateState {
541+
const fn new(limits: MultipartLimits) -> Self {
542+
Self {
543+
limits,
544+
total_bytes: 0,
545+
fields: 0,
546+
}
547+
}
548+
}
549+
550+
tokio::task_local! {
551+
static MULTIPART_AGGREGATE: RefCell<MultipartAggregateState>;
552+
}
553+
554+
fn register_multipart_field() -> Result<(), TypedMultipartError> {
555+
MULTIPART_AGGREGATE
556+
.try_with(|state| {
557+
let mut state = state.borrow_mut();
558+
state.fields = state.fields.saturating_add(1);
559+
if state.fields > state.limits.max_fields {
560+
return Err(TypedMultipartError::TooManyFields {
561+
limit_fields: state.limits.max_fields,
562+
});
563+
}
564+
Ok(())
565+
})
566+
// Field parsers can be unit-tested outside the extractor. In that shape
567+
// there is no request aggregate to update, so per-field limits remain the
568+
// only active guard instead of failing spuriously.
569+
.unwrap_or(Ok(()))
570+
}
571+
572+
fn register_multipart_bytes(field_name: &str, chunk_len: usize) -> Result<(), TypedMultipartError> {
573+
MULTIPART_AGGREGATE
574+
.try_with(|state| {
575+
let mut state = state.borrow_mut();
576+
state.total_bytes = state.total_bytes.saturating_add(chunk_len);
577+
if state.total_bytes > state.limits.max_total_bytes {
578+
return Err(TypedMultipartError::RequestTooLarge {
579+
field_name: field_name.to_owned(),
580+
limit_bytes: state.limits.max_total_bytes,
581+
});
582+
}
583+
Ok(())
584+
})
585+
.unwrap_or(Ok(()))
586+
}
587+
588+
/// Axum extractor variant with const aggregate multipart limits.
589+
///
590+
/// Use this when a route needs a tighter or looser request-level policy than
591+
/// the process default. Per-field `#[form_data(limit = "...")]` caps still
592+
/// apply independently: the effective policy is whichever per-field or
593+
/// aggregate limit is exceeded first.
594+
pub struct TypedMultipartWithLimits<
595+
T,
596+
const MAX_TOTAL_BYTES: usize,
597+
const MAX_FIELDS: usize = DEFAULT_MULTIPART_MAX_FIELDS,
598+
>(pub T);
599+
600+
async fn parse_typed_multipart_with_limits<T, S>(
601+
req: Request,
602+
state: &S,
603+
limits: MultipartLimits,
604+
) -> Result<T, TypedMultipartError>
605+
where
606+
T: TryFromMultipartWithState<S>,
607+
S: Send + Sync + 'static,
608+
{
609+
let mut multipart = axum::extract::Multipart::from_request(req, state)
610+
.await
611+
.map_err(TypedMultipartError::from)?;
612+
MULTIPART_AGGREGATE
613+
.scope(
614+
RefCell::new(MultipartAggregateState::new(limits)),
615+
async move { T::try_from_multipart_with_state(&mut multipart, state).await },
616+
)
617+
.await
618+
}
619+
444620
impl<T, S> FromRequest<S> for TypedMultipart<T>
445621
where
446622
T: TryFromMultipartWithState<S>,
@@ -449,10 +625,27 @@ where
449625
type Rejection = TypedMultipartError;
450626

451627
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
452-
let mut multipart = axum::extract::Multipart::from_request(req, state)
453-
.await
454-
.map_err(TypedMultipartError::from)?;
455-
let value = T::try_from_multipart_with_state(&mut multipart, state).await?;
628+
let value =
629+
parse_typed_multipart_with_limits(req, state, default_multipart_limits()).await?;
630+
Ok(Self(value))
631+
}
632+
}
633+
634+
impl<T, S, const MAX_TOTAL_BYTES: usize, const MAX_FIELDS: usize> FromRequest<S>
635+
for TypedMultipartWithLimits<T, MAX_TOTAL_BYTES, MAX_FIELDS>
636+
where
637+
T: TryFromMultipartWithState<S>,
638+
S: Send + Sync + 'static,
639+
{
640+
type Rejection = TypedMultipartError;
641+
642+
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
643+
let value = parse_typed_multipart_with_limits(
644+
req,
645+
state,
646+
MultipartLimits::new(MAX_TOTAL_BYTES, MAX_FIELDS),
647+
)
648+
.await?;
456649
Ok(Self(value))
457650
}
458651
}
@@ -483,11 +676,13 @@ async fn read_field_data(
483676
limit: Option<usize>,
484677
initial_capacity: usize,
485678
) -> Result<(Field<'_>, Vec<u8>), TypedMultipartError> {
679+
register_multipart_field()?;
486680
// Initial capacity is independent from the hard byte limit: tiny scalar
487681
// fields keep the 256B cap without preallocating 256B per bool/number.
488682
let capacity = limit.map_or(initial_capacity, |limit| initial_capacity.min(limit));
489683
let mut buf = Vec::with_capacity(capacity);
490684
while let Some(chunk) = field.chunk().await? {
685+
register_multipart_bytes(field.name().unwrap_or_default(), chunk.len())?;
491686
if let Some(limit) = limit
492687
&& buf.len().saturating_add(chunk.len()) > limit
493688
{
@@ -709,6 +904,7 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for tempfile::NamedTempFile {
709904
limit_bytes: Option<usize>,
710905
_state: &S,
711906
) -> Result<Self, TypedMultipartError> {
907+
register_multipart_field()?;
712908
// Temp-file creation AND reopen() are both blocking syscalls —
713909
// run them together on the blocking pool so neither stalls the
714910
// async worker (the reopen previously ran inline on the async
@@ -734,6 +930,7 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for tempfile::NamedTempFile {
734930
let limit_bytes = limit_bytes.unwrap_or_else(default_temp_file_field_limit_bytes);
735931
let mut total = 0usize;
736932
while let Some(chunk) = field.chunk().await? {
933+
register_multipart_bytes(field.name().unwrap_or_default(), chunk.len())?;
737934
// `saturating_add` (matching `read_field_data`) prevents a
738935
// pathological chunk size from wrapping `total` and slipping
739936
// past the limit check below.

0 commit comments

Comments
 (0)