Skip to content

Commit 438e65f

Browse files
committed
improve
1 parent d7d0044 commit 438e65f

22 files changed

Lines changed: 814 additions & 160 deletions

File tree

crates/vespera/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,11 @@ trybuild = "1"
9595
name = "validation"
9696
harness = false
9797

98+
# multipart `4xx`/`422` error-envelope serialization before/after A/B bench
99+
# (same borrowing-`Serialize` win as `validation`, on `TypedMultipartError`).
100+
[[bench]]
101+
name = "multipart_error"
102+
harness = false
103+
98104
[lints]
99105
workspace = true
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//! Before/after A/B benchmark for the multipart `4xx`/`422` error-envelope
2+
//! serialization (the cold but attacker-reachable malformed-input path).
3+
//!
4+
//! Both arms serialize the **same** [`TypedMultipartError`] to the **same**
5+
//! bytes (`{"errors":[{"message":...,"path":...}]}`):
6+
//!
7+
//! - `before`: the original implementation — materialize the public message
8+
//! with `error.to_string()` (one heap `String` per error) and serialize an
9+
//! owned-`&str` envelope.
10+
//! - `after`: the shipped implementation — a borrowing `Serialize` chain that
11+
//! streams the error's own `Display` straight into `serde_json` via
12+
//! `collect_str` (zero per-error `String` allocation), mirroring the
13+
//! `Validated<T>` 422 serializer in `multipart.rs`.
14+
//!
15+
//! The delta is the per-error `String` allocation the change removes. Both
16+
//! arms assert byte-identical output so the bench can never silently drift
17+
//! from the real envelope contract.
18+
19+
use std::borrow::Cow;
20+
21+
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
22+
use serde::{Serialize, Serializer, ser::SerializeStruct};
23+
use vespera::multipart::TypedMultipartError;
24+
25+
/// A realistic client-caused multipart error (the common 422 case): a scalar
26+
/// field whose value failed to parse. Its `Display` carries the field name,
27+
/// the wanted type, and the parse error — representative envelope work.
28+
fn fixture() -> TypedMultipartError {
29+
TypedMultipartError::WrongFieldType {
30+
field_name: "age".to_owned(),
31+
wanted: Cow::Borrowed("u8"),
32+
source: "number too large to fit in target type".to_owned(),
33+
}
34+
}
35+
36+
/// The offending field name doubles as the envelope `path`.
37+
const PATH: &str = "age";
38+
39+
// ── AFTER: shipped borrowing Serialize chain (mirror of multipart.rs) ─
40+
41+
fn serialize_after(err: &TypedMultipartError) -> Vec<u8> {
42+
struct Message<'a>(&'a TypedMultipartError);
43+
impl Serialize for Message<'_> {
44+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
45+
// Client-caused variant → stream its `Display` with no `String`.
46+
s.collect_str(self.0)
47+
}
48+
}
49+
struct OneError<'a> {
50+
err: &'a TypedMultipartError,
51+
path: &'a str,
52+
}
53+
impl Serialize for OneError<'_> {
54+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
55+
let mut st = s.serialize_struct("MultipartOneError", 2)?;
56+
st.serialize_field("message", &Message(self.err))?;
57+
st.serialize_field("path", self.path)?;
58+
st.end()
59+
}
60+
}
61+
struct Envelope<'a> {
62+
err: &'a TypedMultipartError,
63+
path: &'a str,
64+
}
65+
impl Serialize for Envelope<'_> {
66+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
67+
let mut st = s.serialize_struct("MultipartErrorEnvelope", 1)?;
68+
st.serialize_field(
69+
"errors",
70+
&[OneError {
71+
err: self.err,
72+
path: self.path,
73+
}],
74+
)?;
75+
st.end()
76+
}
77+
}
78+
serde_json::to_vec(&Envelope { err, path: PATH }).expect("infallible")
79+
}
80+
81+
// ── BEFORE: original owned-`String` message implementation ───────────
82+
83+
fn serialize_before(err: &TypedMultipartError) -> Vec<u8> {
84+
#[derive(Serialize)]
85+
struct OneError<'a> {
86+
message: &'a str,
87+
path: &'a str,
88+
}
89+
#[derive(Serialize)]
90+
struct Envelope<'a> {
91+
errors: [OneError<'a>; 1],
92+
}
93+
let message = err.to_string();
94+
serde_json::to_vec(&Envelope {
95+
errors: [OneError {
96+
message: &message,
97+
path: PATH,
98+
}],
99+
})
100+
.expect("infallible")
101+
}
102+
103+
fn bench_multipart_error_envelope(c: &mut Criterion) {
104+
let err = fixture();
105+
106+
// Guard: the two implementations MUST produce identical bytes, so the
107+
// A/B compares the same observable work — never a shortcut.
108+
assert_eq!(
109+
serialize_before(&err),
110+
serialize_after(&err),
111+
"before/after multipart error-envelope bytes diverged"
112+
);
113+
114+
let mut group = c.benchmark_group("multipart_error_envelope");
115+
group.bench_with_input(
116+
BenchmarkId::new("owned_string_before", "WrongFieldType"),
117+
&err,
118+
|b, e| b.iter(|| serialize_before(std::hint::black_box(e))),
119+
);
120+
group.bench_with_input(
121+
BenchmarkId::new("borrowing_serialize_after", "WrongFieldType"),
122+
&err,
123+
|b, e| b.iter(|| serialize_after(std::hint::black_box(e))),
124+
);
125+
group.finish();
126+
}
127+
128+
criterion_group!(benches, bench_multipart_error_envelope);
129+
criterion_main!(benches);

crates/vespera/src/multipart.rs

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -165,20 +165,48 @@ impl TypedMultipartError {
165165
}
166166
}
167167

168-
/// Public-facing message for the JSON error envelope.
168+
/// Serialize the canonical `4xx`/`422` JSON error envelope
169+
/// (`{"errors":[{"message":...,"path":...}]}`) for this error — byte-
170+
/// identical to `Validated<T>`'s envelope so JNI hoisting and clients
171+
/// treat both uniformly.
169172
///
170-
/// `Other` wraps internal I/O / blocking-task failures whose source
171-
/// string can leak implementation details (temp-file paths, OS error
172-
/// text); it is the only `500` variant, so it returns a stable, generic
173-
/// message. Every other variant returns its `Display` (already safe —
174-
/// it describes a client-supplied field problem). The full `Display`
175-
/// (including `Other`'s `source`) stays available for server-side
176-
/// logging via the `std::error::Error` impl.
177-
fn response_message(&self) -> Cow<'_, str> {
178-
if matches!(self, Self::Other { .. }) {
179-
Cow::Borrowed("internal error while processing multipart request")
173+
/// The message streams through [`MultipartMessage`]: `Other` (the only
174+
/// `500`, whose source can leak temp-file paths / OS text) yields a stable
175+
/// generic string; every other (client-caused) variant streams its own
176+
/// `Display` with NO intermediate `String`. `path` is the offending field
177+
/// name when known, else empty. Infallible in practice; the fallback keeps
178+
/// this request-time path panic-free instead of unwinding in a handler.
179+
fn error_body(&self) -> Vec<u8> {
180+
serde_json::to_vec(&MultipartErrorEnvelope {
181+
errors: [MultipartOneError {
182+
message: MultipartMessage(self),
183+
path: self.field_name().unwrap_or(""),
184+
}],
185+
})
186+
.unwrap_or_else(|_| br#"{"errors":[{"message":"serialization error","path":""}]}"#.to_vec())
187+
}
188+
}
189+
190+
/// Stable, source-free public message for the only `500` variant (`Other`),
191+
/// whose wrapped `source` can leak temp-file paths / OS error text. Every
192+
/// other variant is client-caused and safe to expose verbatim.
193+
const MULTIPART_INTERNAL_ERROR_MSG: &str = "internal error while processing multipart request";
194+
195+
/// Streams a multipart error's public message straight into the serializer
196+
/// with NO intermediate `String`: `Other` becomes [`MULTIPART_INTERNAL_ERROR_MSG`];
197+
/// every other (client-caused) variant streams its own `Display` via
198+
/// `collect_str`. Byte-identical to the previous `to_string()`-then-serialize
199+
/// path (serde escapes a `collect_str` stream exactly like an equal `&str`)
200+
/// but allocation-free on the common client-error path — mirroring
201+
/// `Validated<T>`'s 422 serializer.
202+
struct MultipartMessage<'a>(&'a TypedMultipartError);
203+
204+
impl serde::Serialize for MultipartMessage<'_> {
205+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
206+
if matches!(self.0, TypedMultipartError::Other { .. }) {
207+
serializer.serialize_str(MULTIPART_INTERNAL_ERROR_MSG)
180208
} else {
181-
Cow::Owned(self.to_string())
209+
serializer.collect_str(self.0)
182210
}
183211
}
184212
}
@@ -191,7 +219,7 @@ impl TypedMultipartError {
191219
/// map/array/object intermediate).
192220
#[derive(serde::Serialize)]
193221
struct MultipartOneError<'a> {
194-
message: &'a str,
222+
message: MultipartMessage<'a>,
195223
path: &'a str,
196224
}
197225

@@ -221,24 +249,9 @@ impl IntoResponse for TypedMultipartError {
221249
Self::FieldTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
222250
Self::Other { .. } => StatusCode::INTERNAL_SERVER_ERROR,
223251
};
224-
// Serialize the canonical 422 envelope (see module-scope
225-
// `MultipartErrorEnvelope` / `MultipartOneError`); `path` is the
226-
// offending field name when known, else empty.
227-
let path = self.field_name().unwrap_or("");
228-
let message = self.response_message();
229-
let body = serde_json::to_vec(&MultipartErrorEnvelope {
230-
errors: [MultipartOneError {
231-
message: &message,
232-
path,
233-
}],
234-
})
235-
// Serializing a struct of two `&str` is infallible in practice; the
236-
// fallback keeps this request-time error path panic-free (matching
237-
// `Validated<T>`'s 422 envelope) by emitting a minimal valid envelope
238-
// instead of unwinding inside a handler.
239-
.unwrap_or_else(|_| {
240-
br#"{"errors":[{"message":"serialization error","path":""}]}"#.to_vec()
241-
});
252+
// Serialize the canonical 422 envelope (see `error_body` /
253+
// module-scope `MultipartErrorEnvelope`).
254+
let body = self.error_body();
242255
(
243256
status,
244257
[(

crates/vespera/src/multipart/tests.rs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,34 @@ fn test_error_display_duplicate_field() {
6565
}
6666

6767
#[test]
68-
fn other_error_response_message_hides_internal_source() {
68+
fn other_error_body_hides_internal_source() {
6969
// The internal source (e.g. a temp-file path / OS error) must NOT
70-
// leak into the public 500 response message.
70+
// leak into the public 500 response body — assert on the ACTUAL
71+
// serialized envelope (the production path), not an intermediate.
7172
let err = TypedMultipartError::Other {
7273
source: "/tmp/vespera-upload-7f3a.part: No such file or directory".to_string(),
7374
};
75+
let body = String::from_utf8(err.error_body()).expect("envelope is UTF-8");
7476
assert_eq!(
75-
err.response_message(),
76-
"internal error while processing multipart request"
77+
body,
78+
r#"{"errors":[{"message":"internal error while processing multipart request","path":""}]}"#
7779
);
7880
assert!(
79-
!err.response_message().contains("/tmp/"),
80-
"internal source path leaked into response message"
81+
!body.contains("/tmp/"),
82+
"internal source path leaked into response body"
8183
);
8284
// Display still exposes the source for server-side logging.
8385
assert!(err.to_string().contains("/tmp/"));
84-
// Non-Other variants keep their (client-safe) Display message.
86+
// Non-Other variants stream their (client-safe) Display message verbatim,
87+
// byte-identical to the prior `to_string()` path.
8588
let missing = TypedMultipartError::MissingField {
8689
field_name: "avatar".to_string(),
8790
};
88-
assert_eq!(missing.response_message(), "Missing field: `avatar`");
91+
let missing_body = String::from_utf8(missing.error_body()).expect("envelope is UTF-8");
92+
assert_eq!(
93+
missing_body,
94+
r#"{"errors":[{"message":"Missing field: `avatar`","path":"avatar"}]}"#
95+
);
8996
}
9097

9198
#[test]

crates/vespera_core/src/openapi.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,21 +151,48 @@ fn merge_component_map<V>(
151151
self_map: &mut Option<BTreeMap<String, V>>,
152152
other_map: Option<BTreeMap<String, V>>,
153153
) {
154-
let Some(other_map) = other_map else { return };
154+
let Some(other_map) = non_empty_component_map(other_map) else {
155+
return;
156+
};
155157
let target = self_map.get_or_insert_with(BTreeMap::new);
156158
for (name, value) in other_map {
157159
target.entry(name).or_insert(value);
158160
}
159161
}
160162

163+
fn non_empty_component_map<V>(map: Option<BTreeMap<String, V>>) -> Option<BTreeMap<String, V>> {
164+
map.filter(|entries| !entries.is_empty())
165+
}
166+
161167
fn has_any_component_map(components: &Components) -> bool {
162-
components.schemas.is_some()
163-
|| components.responses.is_some()
164-
|| components.parameters.is_some()
165-
|| components.examples.is_some()
166-
|| components.request_bodies.is_some()
167-
|| components.headers.is_some()
168-
|| components.security_schemes.is_some()
168+
components
169+
.schemas
170+
.as_ref()
171+
.is_some_and(|entries| !entries.is_empty())
172+
|| components
173+
.responses
174+
.as_ref()
175+
.is_some_and(|entries| !entries.is_empty())
176+
|| components
177+
.parameters
178+
.as_ref()
179+
.is_some_and(|entries| !entries.is_empty())
180+
|| components
181+
.examples
182+
.as_ref()
183+
.is_some_and(|entries| !entries.is_empty())
184+
|| components
185+
.request_bodies
186+
.as_ref()
187+
.is_some_and(|entries| !entries.is_empty())
188+
|| components
189+
.headers
190+
.as_ref()
191+
.is_some_and(|entries| !entries.is_empty())
192+
|| components
193+
.security_schemes
194+
.as_ref()
195+
.is_some_and(|entries| !entries.is_empty())
169196
}
170197

171198
/// Merge `other`'s per-method operations into `into` with **self-wins**

0 commit comments

Comments
 (0)