@@ -56,9 +56,14 @@ pub async fn dispatch_parts<'h>(
5656 builder = builder. header ( "content-type" , "application/json" ) ;
5757 }
5858
59- let request = builder
60- . body ( Body :: from ( body_bytes) )
61- . expect ( "request construction should not fail with valid URI" ) ;
59+ // A malformed wire `path` (e.g. a raw space → not a valid
60+ // `http::Uri`) or an invalid header name/value surfaces here as a
61+ // builder error; convert it to a 400 so the contract "every failure
62+ // returns a wire response" holds instead of panicking.
63+ let request = match builder. body ( Body :: from ( body_bytes) ) {
64+ Ok ( req) => req,
65+ Err ( e) => return Err ( ( 400 , format ! ( "invalid request: {e}" ) ) ) ,
66+ } ;
6267
6368 let response = router
6469 . oneshot ( request)
@@ -122,9 +127,14 @@ where
122127 builder = builder. header ( "content-type" , "application/json" ) ;
123128 }
124129
125- let request = builder
126- . body ( Body :: from ( body_bytes) )
127- . expect ( "request construction should not fail with valid URI" ) ;
130+ // A malformed wire `path` (e.g. a raw space → not a valid
131+ // `http::Uri`) or an invalid header name/value surfaces here as a
132+ // builder error; convert it to a 400 so the contract "every failure
133+ // returns a wire response" holds instead of panicking.
134+ let request = match builder. body ( Body :: from ( body_bytes) ) {
135+ Ok ( req) => req,
136+ Err ( e) => return Err ( ( 400 , format ! ( "invalid request: {e}" ) ) ) ,
137+ } ;
128138
129139 let response = router
130140 . oneshot ( request)
@@ -205,7 +215,12 @@ async fn collect_response_parts(response: axum::response::Response) -> ResponseP
205215/// [`http::HeaderMap`].
206216pub fn to_response_envelope_text ( parts : ResponseParts ) -> ResponseEnvelope {
207217 let ( status, headers, body_bytes, metadata) = parts;
208- let body = String :: from_utf8 ( body_bytes. to_vec ( ) ) . unwrap_or_default ( ) ;
218+ // `Vec::from(Bytes)` reuses the underlying buffer when the `Bytes`
219+ // is uniquely owned (the common case for a collected response body),
220+ // copying only for a shared/static slice — unlike `to_vec()`, which
221+ // always allocates and copies. Semantics preserved: a non-UTF-8
222+ // body still yields the empty string.
223+ let body = String :: from_utf8 ( Vec :: from ( body_bytes) ) . unwrap_or_default ( ) ;
209224 ResponseEnvelope {
210225 status,
211226 headers : collect_header_map ( & headers) ,
@@ -250,9 +265,12 @@ pub async fn dispatch_and_split<'h>(
250265 builder = builder. header ( "content-type" , "application/json" ) ;
251266 }
252267
253- let request = builder
254- . body ( body)
255- . expect ( "request construction should not fail with valid URI" ) ;
268+ // Same contract as dispatch_parts: a malformed path/header must
269+ // surface as a 400 wire response, not a panic.
270+ let request = match builder. body ( body) {
271+ Ok ( req) => req,
272+ Err ( e) => return Err ( ( 400 , format ! ( "invalid request: {e}" ) ) ) ,
273+ } ;
256274
257275 let response = router
258276 . oneshot ( request)
@@ -267,3 +285,84 @@ pub async fn dispatch_and_split<'h>(
267285 body,
268286 ) )
269287}
288+
289+ #[ cfg( test) ]
290+ mod tests {
291+ use super :: * ;
292+
293+ fn block_on < F : std:: future:: Future > ( fut : F ) -> F :: Output {
294+ tokio:: runtime:: Builder :: new_current_thread ( )
295+ . enable_all ( )
296+ . build ( )
297+ . expect ( "build current-thread runtime" )
298+ . block_on ( fut)
299+ }
300+
301+ /// A wire `path` that cannot be parsed into an [`http::Uri`] (a raw
302+ /// space is illegal) must surface as an `Err((4xx, _))` the caller
303+ /// turns into a wire response — never a panic. Guards the
304+ /// "all failure modes return a valid wire response" contract for
305+ /// every `request_builder` call site.
306+ #[ test]
307+ fn malformed_path_returns_error_not_panic ( ) {
308+ let result = block_on ( async {
309+ dispatch_parts (
310+ crate :: Router :: new ( ) ,
311+ "GET" ,
312+ "bad path with spaces" ,
313+ "" ,
314+ std:: iter:: empty ( ) ,
315+ Bytes :: new ( ) ,
316+ )
317+ . await
318+ } ) ;
319+ match result {
320+ Err ( ( status, _) ) => assert ! (
321+ ( 400 ..500 ) . contains( & status) ,
322+ "expected 4xx for malformed path, got {status}"
323+ ) ,
324+ Ok ( _) => panic ! ( "malformed path should not produce a successful dispatch" ) ,
325+ }
326+ }
327+
328+ #[ test]
329+ fn malformed_path_streaming_returns_error_not_panic ( ) {
330+ let result = block_on ( async {
331+ let mut sink = |_: & [ u8 ] | { } ;
332+ dispatch_response_streaming (
333+ crate :: Router :: new ( ) ,
334+ "GET" ,
335+ "bad path with spaces" ,
336+ "" ,
337+ std:: iter:: empty ( ) ,
338+ Bytes :: new ( ) ,
339+ & mut sink,
340+ )
341+ . await
342+ } ) ;
343+ assert ! (
344+ result. is_err( ) ,
345+ "streaming dispatch must reject malformed path"
346+ ) ;
347+ }
348+
349+ #[ test]
350+ fn malformed_path_split_returns_error_not_panic ( ) {
351+ let result = block_on ( async {
352+ dispatch_and_split (
353+ crate :: Router :: new ( ) ,
354+ "GET" ,
355+ "bad path with spaces" ,
356+ "" ,
357+ std:: iter:: empty ( ) ,
358+ Body :: empty ( ) ,
359+ false ,
360+ )
361+ . await
362+ } ) ;
363+ assert ! (
364+ result. is_err( ) ,
365+ "dispatch_and_split must reject malformed path"
366+ ) ;
367+ }
368+ }
0 commit comments