Skip to content

Commit e7f8c8a

Browse files
committed
Optimize compiletime
1 parent 8d60e88 commit e7f8c8a

48 files changed

Lines changed: 1690 additions & 542 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/vespera/src/multipart.rs

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ impl std::error::Error for TypedMultipartError {
205205
}
206206

207207
impl TypedMultipartError {
208+
/// Build an invalid-enum error while bounding the attacker-controlled value stored in it.
209+
#[must_use]
210+
pub fn invalid_enum_value(field_name: String, value: &str) -> Self {
211+
Self::InvalidEnumValue {
212+
field_name,
213+
value: truncate_reflected_value(value).into_owned(),
214+
}
215+
}
216+
208217
/// The offending field name when the error carries one — used as the
209218
/// `path` in the JSON error envelope.
210219
fn field_name(&self) -> Option<&str> {
@@ -410,12 +419,45 @@ impl<'a> MeteredField<'a> {
410419

411420
/// Read the whole field into owned bytes, metering every chunk against the
412421
/// aggregate cap.
413-
pub async fn bytes(mut self) -> Result<axum::body::Bytes, TypedMultipartError> {
414-
let mut acc: Vec<u8> = Vec::new();
422+
pub async fn bytes(self) -> Result<axum::body::Bytes, TypedMultipartError> {
423+
self.bytes_with_limit_inner(None, 0)
424+
.await
425+
.map(axum::body::Bytes::from)
426+
}
427+
428+
/// Read the whole field into owned bytes with a hard per-field limit.
429+
///
430+
/// The limit is checked before copying each chunk into the accumulator, and
431+
/// every chunk is still counted against the request-wide aggregate cap.
432+
pub async fn bytes_with_limit(
433+
self,
434+
limit_bytes: usize,
435+
initial_capacity: usize,
436+
) -> Result<axum::body::Bytes, TypedMultipartError> {
437+
self.bytes_with_limit_inner(Some(limit_bytes), initial_capacity)
438+
.await
439+
.map(axum::body::Bytes::from)
440+
}
441+
442+
async fn bytes_with_limit_inner(
443+
mut self,
444+
limit: Option<usize>,
445+
initial_capacity: usize,
446+
) -> Result<Vec<u8>, TypedMultipartError> {
447+
let capacity = limit.map_or(initial_capacity, |limit| initial_capacity.min(limit));
448+
let mut acc: Vec<u8> = Vec::with_capacity(capacity);
415449
while let Some(chunk) = self.chunk().await? {
450+
if let Some(limit) = limit
451+
&& acc.len().saturating_add(chunk.len()) > limit
452+
{
453+
return Err(TypedMultipartError::FieldTooLarge {
454+
field_name: self.name().unwrap_or_default().to_string(),
455+
limit_bytes: limit,
456+
});
457+
}
416458
acc.extend_from_slice(&chunk);
417459
}
418-
Ok(axum::body::Bytes::from(acc))
460+
Ok(acc)
419461
}
420462
}
421463

@@ -807,35 +849,19 @@ where
807849
/// When a limit is set the cumulative size is checked after each chunk
808850
/// and an over-limit chunk is rejected *before* it is copied in.
809851
async fn read_field_data(
810-
mut field: MeteredField<'_>,
852+
field: MeteredField<'_>,
811853
limit: Option<usize>,
812854
initial_capacity: usize,
813-
) -> Result<(MeteredField<'_>, Vec<u8>), TypedMultipartError> {
855+
) -> Result<(String, Vec<u8>), TypedMultipartError> {
814856
// Part counting now happens once per part in the derived loop
815857
// (`register_multipart_part`), so the field parsers no longer count.
816858
// Initial capacity is independent from the hard byte limit: tiny scalar
817859
// fields keep the 256B cap without preallocating 256B per bool/number.
818-
let capacity = limit.map_or(initial_capacity, |limit| initial_capacity.min(limit));
819-
let mut buf = Vec::with_capacity(capacity);
820-
// `MeteredField::chunk` already counts each chunk against the request-wide
821-
// `max_total_bytes` aggregate cap, so the per-field reader no longer calls
822-
// `register_multipart_bytes` itself (doing so would double-count).
823-
while let Some(chunk) = field.chunk().await? {
824-
if let Some(limit) = limit
825-
&& buf.len().saturating_add(chunk.len()) > limit
826-
{
827-
// Reject BEFORE copying the over-limit chunk into the
828-
// buffer — same acceptance condition (total <= limit),
829-
// no wasted copy.
830-
return Err(TypedMultipartError::FieldTooLarge {
831-
field_name: field.name().unwrap_or_default().to_string(),
832-
limit_bytes: limit,
833-
});
834-
}
835-
buf.extend_from_slice(&chunk);
836-
}
837-
838-
Ok((field, buf))
860+
let field_name = field.name().unwrap_or_default().to_string();
861+
let buf = field
862+
.bytes_with_limit_inner(limit, initial_capacity)
863+
.await?;
864+
Ok((field_name, buf))
839865
}
840866

841867
/// Default cap for tiny scalar multipart fields when no explicit

crates/vespera/src/multipart/scalar_parsers.rs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use std::borrow::Cow;
1010

1111
use super::{
1212
DEFAULT_STRING_FIELD_LIMIT_BYTES, MeteredField, STRING_INITIAL_CAPACITY_BYTES,
13-
TINY_SCALAR_INITIAL_CAPACITY_BYTES, TryFromFieldWithState, TypedMultipartError, read_field_data,
14-
str_to_bool, tiny_scalar_limit,
13+
TINY_SCALAR_INITIAL_CAPACITY_BYTES, TryFromFieldWithState, TypedMultipartError,
14+
read_field_data, str_to_bool, tiny_scalar_limit, truncate_reflected_value,
1515
};
1616

1717
impl<S: Send + Sync> TryFromFieldWithState<S> for String {
@@ -25,10 +25,10 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for String {
2525
// `Some(usize::MAX)` (set by the derive macro) and stays unbounded;
2626
// an explicit byte size wins as `Some(n)`.
2727
let limit = limit_bytes.unwrap_or(DEFAULT_STRING_FIELD_LIMIT_BYTES);
28-
let (field, data) =
28+
let (field_name, data) =
2929
read_field_data(field, Some(limit), STRING_INITIAL_CAPACITY_BYTES).await?;
3030
Self::from_utf8(data).map_err(|e| TypedMultipartError::WrongFieldType {
31-
field_name: field.name().unwrap_or_default().to_string(),
31+
field_name,
3232
wanted: Cow::Borrowed("String"),
3333
source: e.to_string(),
3434
})
@@ -43,21 +43,24 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for bool {
4343
limit_bytes: Option<usize>,
4444
_state: &S,
4545
) -> Result<Self, TypedMultipartError> {
46-
let (field, data) = read_field_data(
46+
let (field_name, data) = read_field_data(
4747
field,
4848
Some(tiny_scalar_limit(limit_bytes)),
4949
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
5050
)
5151
.await?;
5252
let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType {
53-
field_name: field.name().unwrap_or_default().to_string(),
53+
field_name: field_name.clone(),
5454
wanted: Cow::Borrowed("bool"),
5555
source: e.to_string(),
5656
})?;
5757
str_to_bool(text).ok_or_else(|| TypedMultipartError::WrongFieldType {
58-
field_name: field.name().unwrap_or_default().to_string(),
58+
field_name,
5959
wanted: Cow::Borrowed("bool"),
60-
source: format!("invalid boolean value: `{text}`"),
60+
source: format!(
61+
"invalid boolean value: `{}`",
62+
truncate_reflected_value(text)
63+
),
6164
})
6265
}
6366
}
@@ -73,21 +76,21 @@ macro_rules! impl_try_from_field_for_number {
7376
limit_bytes: Option<usize>,
7477
_state: &S,
7578
) -> Result<Self, TypedMultipartError> {
76-
let (field, data) = read_field_data(
79+
let (field_name, data) = read_field_data(
7780
field,
7881
Some(tiny_scalar_limit(limit_bytes)),
7982
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
8083
).await?;
8184
let text = std::str::from_utf8(&data).map_err(|e| {
8285
TypedMultipartError::WrongFieldType {
83-
field_name: field.name().unwrap_or_default().to_string(),
86+
field_name: field_name.clone(),
8487
wanted: Cow::Borrowed(stringify!($ty)),
8588
source: e.to_string(),
8689
}
8790
})?;
8891
text.trim().parse::<$ty>().map_err(|e| {
8992
TypedMultipartError::WrongFieldType {
90-
field_name: field.name().unwrap_or_default().to_string(),
93+
field_name,
9194
wanted: Cow::Borrowed(stringify!($ty)),
9295
source: e.to_string(),
9396
}
@@ -110,22 +113,22 @@ impl<S: Send + Sync> TryFromFieldWithState<S> for char {
110113
limit_bytes: Option<usize>,
111114
_state: &S,
112115
) -> Result<Self, TypedMultipartError> {
113-
let (field, data) = read_field_data(
116+
let (field_name, data) = read_field_data(
114117
field,
115118
Some(tiny_scalar_limit(limit_bytes)),
116119
TINY_SCALAR_INITIAL_CAPACITY_BYTES,
117120
)
118121
.await?;
119122
let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType {
120-
field_name: field.name().unwrap_or_default().to_string(),
123+
field_name: field_name.clone(),
121124
wanted: Cow::Borrowed("char"),
122125
source: e.to_string(),
123126
})?;
124127
let mut chars = text.chars();
125128
match (chars.next(), chars.next()) {
126129
(Some(c), None) => Ok(c),
127130
_ => Err(TypedMultipartError::WrongFieldType {
128-
field_name: field.name().unwrap_or_default().to_string(),
131+
field_name,
129132
wanted: Cow::Borrowed("char"),
130133
source: "expected exactly one character".to_string(),
131134
}),

crates/vespera/src/multipart/tests.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,42 @@ fn test_error_display_invalid_enum_value() {
203203
);
204204
}
205205

206+
#[test]
207+
fn invalid_enum_value_constructor_stores_bounded_value() {
208+
// A clearly-oversized attacker value (far beyond the cap), so the bounded
209+
// reflection is unambiguously shorter than the input. The real security
210+
// property is the CONSTANT ceiling (`cap + marker`), which holds no matter
211+
// how huge the input is — a value only marginally over the cap can render a
212+
// few chars longer than the input once the marker is appended, but it is
213+
// still bounded, so that constant bound is what we assert.
214+
let oversized = "가".repeat(MAX_REFLECTED_VALUE_CHARS * 4);
215+
let err = TypedMultipartError::invalid_enum_value("status".to_string(), &oversized);
216+
217+
match err {
218+
TypedMultipartError::InvalidEnumValue { value, .. } => {
219+
assert!(value.ends_with("... (truncated)"));
220+
assert!(
221+
value.chars().count()
222+
<= MAX_REFLECTED_VALUE_CHARS + "... (truncated)".chars().count()
223+
);
224+
assert!(value.chars().count() < oversized.chars().count());
225+
}
226+
_ => panic!("expected InvalidEnumValue"),
227+
}
228+
}
229+
230+
#[test]
231+
fn invalid_bool_message_reflects_bounded_value() {
232+
let oversized = "x".repeat(MAX_REFLECTED_VALUE_CHARS + 10);
233+
let message = format!(
234+
"invalid boolean value: `{}`",
235+
truncate_reflected_value(&oversized)
236+
);
237+
238+
assert!(message.contains("... (truncated)"));
239+
assert!(!message.contains(&oversized));
240+
}
241+
206242
#[test]
207243
fn test_error_display_nameless_field() {
208244
let err = TypedMultipartError::NamelessField;

crates/vespera/src/validated.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
//!
55
//! ```ignore
66
//! use vespera::{Validated, Schema, axum::Json};
7+
//! use garde::Validate;
78
//!
8-
//! #[derive(serde::Deserialize, Schema)]
9+
//! #[derive(serde::Deserialize, Schema, Validate)]
910
//! struct CreateUser {
1011
//! #[schema(min_length = 3, max_length = 32)]
12+
//! #[garde(length(min = 3, max = 32))]
1113
//! username: String,
1214
//! }
1315
//!
@@ -34,7 +36,11 @@ use ::axum::{
3436
};
3537
use ::garde::Validate;
3638
use ::serde::{Serialize, Serializer, ser::SerializeStruct};
37-
use std::{fmt::Display, marker::PhantomData};
39+
use std::{
40+
fmt::Display,
41+
marker::PhantomData,
42+
ops::{Deref, DerefMut},
43+
};
3844

3945
/// Extractor wrapper that validates the inner extractor's output via
4046
/// [`garde::Validate`] before handing it to the handler.
@@ -151,6 +157,32 @@ impl<C, T> ValidatedWith<C, T> {
151157
pub fn into_inner(self) -> T {
152158
self.0
153159
}
160+
161+
/// Borrow the extracted value.
162+
#[must_use]
163+
pub const fn get(&self) -> &T {
164+
&self.0
165+
}
166+
167+
/// Mutably borrow the extracted value.
168+
#[must_use]
169+
pub const fn get_mut(&mut self) -> &mut T {
170+
&mut self.0
171+
}
172+
}
173+
174+
impl<C, T> Deref for ValidatedWith<C, T> {
175+
type Target = T;
176+
177+
fn deref(&self) -> &Self::Target {
178+
&self.0
179+
}
180+
}
181+
182+
impl<C, T> DerefMut for ValidatedWith<C, T> {
183+
fn deref_mut(&mut self) -> &mut Self::Target {
184+
&mut self.0
185+
}
154186
}
155187

156188
impl<U> ValidatePayload for Json<U>

crates/vespera_core/src/openapi.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,21 @@ pub struct Contact {
4242
pub struct License {
4343
/// License name
4444
pub name: String,
45+
/// SPDX license expression or identifier.
46+
#[serde(skip_serializing_if = "Option::is_none")]
47+
pub identifier: Option<String>,
4548
/// License URL
4649
#[serde(skip_serializing_if = "Option::is_none")]
4750
pub url: Option<String>,
4851
}
4952

53+
#[allow(clippy::ref_option)] // serde skip_serializing_if mandates &Option<T> signature
54+
fn is_empty_components(value: &Option<Components>) -> bool {
55+
value
56+
.as_ref()
57+
.is_none_or(|components| !has_any_component_map(components))
58+
}
59+
5060
/// API information
5161
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5262
#[serde(rename_all = "camelCase")]
@@ -132,7 +142,7 @@ pub struct OpenApi {
132142
/// Path definitions
133143
pub paths: BTreeMap<String, PathItem>,
134144
/// Components (reusable components)
135-
#[serde(skip_serializing_if = "Option::is_none")]
145+
#[serde(skip_serializing_if = "is_empty_components")]
136146
pub components: Option<Components>,
137147
/// Security requirements
138148
#[serde(skip_serializing_if = "Option::is_none")]
@@ -216,6 +226,8 @@ fn merge_path_item(into: &mut PathItem, other: PathItem) {
216226
parameters,
217227
summary,
218228
description,
229+
ref_path,
230+
servers,
219231
} = other;
220232
if into.get.is_none() {
221233
into.get = get;
@@ -250,6 +262,12 @@ fn merge_path_item(into: &mut PathItem, other: PathItem) {
250262
if into.description.is_none() {
251263
into.description = description;
252264
}
265+
if into.ref_path.is_none() {
266+
into.ref_path = ref_path;
267+
}
268+
if into.servers.is_none() {
269+
into.servers = servers;
270+
}
253271
}
254272

255273
impl OpenApi {

0 commit comments

Comments
 (0)