@@ -69,6 +69,7 @@ impl std::fmt::Debug for Multipart {
6969// streaming encoder. Asymmetry is intentional.
7070pub ( crate ) enum MultipartInner {
7171 Incoming {
72+ subtype : MultipartSubtype ,
7273 boundary : String ,
7374 multipart : multer:: Multipart < ' static > ,
7475 } ,
@@ -103,6 +104,20 @@ impl MultipartSubtype {
103104 Self :: Custom ( s) => s. as_ref ( ) ,
104105 }
105106 }
107+
108+ /// Parses the subtype from a `multipart/<subtype>[; ...]` Content-Type header value.
109+ /// Falls back to [`Self::FormData`] if the value is malformed (caller has already
110+ /// confirmed a boundary exists, so the value is structurally a multipart Content-Type).
111+ fn from_content_type ( value : & str ) -> Self {
112+ let after_slash = value. split_once ( '/' ) . map ( |( _, rest) | rest) . unwrap_or ( "" ) ;
113+ let token = after_slash. split ( ';' ) . next ( ) . unwrap_or ( "" ) . trim ( ) ;
114+ match token {
115+ "" | "form-data" => Self :: FormData ,
116+ "mixed" => Self :: Mixed ,
117+ "byteranges" => Self :: ByteRanges ,
118+ other => Self :: Custom ( Cow :: Owned ( other. to_owned ( ) ) ) ,
119+ }
120+ }
106121}
107122
108123impl Multipart {
@@ -141,9 +156,38 @@ impl Multipart {
141156 }
142157
143158 #[ inline]
159+ /// Extracts the `boundary` parameter from a `multipart/*` Content-Type header.
160+ /// Subtype-agnostic — accepts any `multipart/<subtype>`, not just form-data —
161+ /// because volga supports forwarding `byteranges`, `mixed`, etc.
144162 fn parse_boundary ( headers : & HeaderMap ) -> Option < String > {
145163 let content_type = headers. get ( CONTENT_TYPE ) ?. to_str ( ) . ok ( ) ?;
146- multer:: parse_boundary ( content_type) . ok ( )
164+ let lower = content_type. to_ascii_lowercase ( ) ;
165+ if !lower. trim_start ( ) . starts_with ( "multipart/" ) {
166+ return None ;
167+ }
168+ let idx = lower. find ( "boundary=" ) ?;
169+ let raw = content_type[ idx + "boundary=" . len ( ) ..] . trim_start ( ) ;
170+ let boundary = if let Some ( rest) = raw. strip_prefix ( '"' ) {
171+ rest. split_once ( '"' ) . map ( |( b, _) | b) ?
172+ } else {
173+ raw. split ( |c : char | c == ';' || c. is_whitespace ( ) )
174+ . next ( )
175+ . filter ( |s| !s. is_empty ( ) ) ?
176+ } ;
177+ if boundary. is_empty ( ) {
178+ None
179+ } else {
180+ Some ( boundary. to_string ( ) )
181+ }
182+ }
183+
184+ #[ inline]
185+ fn parse_subtype ( headers : & HeaderMap ) -> MultipartSubtype {
186+ headers
187+ . get ( CONTENT_TYPE )
188+ . and_then ( |v| v. to_str ( ) . ok ( ) )
189+ . map ( MultipartSubtype :: from_content_type)
190+ . unwrap_or ( MultipartSubtype :: FormData )
147191 }
148192
149193 /// Consumes self and returns the inner enum.
@@ -237,6 +281,7 @@ impl Multipart {
237281 /// Errors if called on an already-outgoing multipart.
238282 pub fn into_outgoing ( self ) -> Result < Self , Error > {
239283 let MultipartInner :: Incoming {
284+ subtype,
240285 boundary,
241286 mut multipart,
242287 } = self . inner
@@ -253,13 +298,13 @@ impl Multipart {
253298 . await
254299 . map_err( MultipartError :: read_error) ?
255300 {
256- yield field_to_part( field) ? ;
301+ yield field_to_part( field) ;
257302 }
258303 } ;
259304
260305 Ok ( Self {
261306 inner : MultipartInner :: Outgoing {
262- subtype : MultipartSubtype :: FormData ,
307+ subtype,
263308 boundary,
264309 parts : Box :: pin ( parts_stream) ,
265310 } ,
@@ -284,10 +329,12 @@ impl<'a> TryFrom<Payload<'a>> for Multipart {
284329 } ;
285330 let boundary =
286331 Self :: parse_boundary ( & parts. headers ) . ok_or ( MultipartError :: invalid_boundary ( ) ) ?;
332+ let subtype = Self :: parse_subtype ( & parts. headers ) ;
287333 let stream = body. into_data_stream ( ) ;
288334 let multipart = multer:: Multipart :: new ( stream, boundary. clone ( ) ) ;
289335 Ok ( Multipart {
290336 inner : MultipartInner :: Incoming {
337+ subtype,
291338 boundary,
292339 multipart,
293340 } ,
@@ -315,17 +362,15 @@ impl FromPayload for Multipart {
315362
316363/// Converts a single [`multer::Field`] into a [`Part`] whose body is a stream that
317364/// drains chunks lazily from the field. No buffering.
318- /// Errors if the field's name or filename produces an invalid `Content-Disposition`
319- /// header value (e.g. CR/LF in upstream-supplied bytes).
320- fn field_to_part ( mut field : multer:: Field < ' static > ) -> Result < Part , Error > {
321- use crate :: headers:: { ContentType , Header } ;
322-
323- let name = field. name ( ) . unwrap_or ( "" ) . to_owned ( ) ;
324- let filename = field. file_name ( ) . map ( |s| s. to_owned ( ) ) ;
325- let content_type_header = field. content_type ( ) . map ( |m| {
326- Header :: < ContentType > :: from_bytes ( m. as_ref ( ) . as_bytes ( ) )
327- . unwrap_or_else ( |_| ContentType :: stream ( ) )
328- } ) ;
365+ ///
366+ /// Forwards every per-part header verbatim — `Content-Type`, `Content-Disposition`
367+ /// (preserving `filename*` and other parameters), `Content-Range`, plus any custom
368+ /// header — so proxy / forwarding flows produce a semantically-equivalent body.
369+ fn field_to_part ( mut field : multer:: Field < ' static > ) -> Part {
370+ use crate :: headers:: { ContentDisposition , ContentType , Header } ;
371+
372+ // Snapshot headers before `field.chunk()` takes a mutable borrow.
373+ let headers = field. headers ( ) . clone ( ) ;
329374
330375 let body_stream = async_stream:: try_stream! {
331376 while let Some ( chunk) = field
@@ -336,13 +381,22 @@ fn field_to_part(mut field: multer::Field<'static>) -> Result<Part, Error> {
336381 yield chunk;
337382 }
338383 } ;
339- let body = PartBody :: Stream ( Box :: pin ( body_stream) ) ;
384+ let mut part = Part :: new ( PartBody :: Stream ( Box :: pin ( body_stream) ) ) ;
340385
341- let mut part = Part :: new ( body) . try_with_disposition ( & name, filename. as_deref ( ) ) ?;
342- if let Some ( ct) = content_type_header {
343- part = part. with_content_type ( ct) ;
386+ for ( name, value) in headers. iter ( ) {
387+ if name == CONTENT_TYPE {
388+ if let Ok ( ct) = Header :: < ContentType > :: from_bytes ( value. as_bytes ( ) ) {
389+ part = part. with_content_type ( ct) ;
390+ }
391+ } else if name == crate :: headers:: CONTENT_DISPOSITION {
392+ if let Ok ( cd) = Header :: < ContentDisposition > :: from_bytes ( value. as_bytes ( ) ) {
393+ part = part. with_disposition_raw ( cd) ;
394+ }
395+ } else {
396+ part = part. with_header_raw ( name. clone ( ) , value. clone ( ) ) ;
397+ }
344398 }
345- Ok ( part)
399+ part
346400}
347401
348402/// Encodes an outgoing parts stream into an HTTP body. Wraps `encoder::encode`
@@ -594,6 +648,71 @@ mod tests {
594648 ) ;
595649 }
596650
651+ #[ tokio:: test]
652+ async fn into_outgoing_preserves_incoming_subtype ( ) {
653+ // Inbound is multipart/byteranges; into_outgoing must keep that subtype on the
654+ // response Content-Type instead of rewriting to multipart/form-data.
655+ let body = "--BNDRY\r \n Content-Range: bytes 0-4/10\r \n Content-Type: text/plain\r \n \r \n first\r \n --BNDRY--\r \n " ;
656+ let req = Request :: get ( "/" )
657+ . header ( CONTENT_TYPE , "multipart/byteranges; boundary=BNDRY" )
658+ . body ( HttpBody :: full ( body) )
659+ . unwrap ( ) ;
660+ let ( parts, body) = req. into_parts ( ) ;
661+ let mp = Multipart :: from_payload ( Payload :: Full ( & parts, body) )
662+ . await
663+ . unwrap ( ) ;
664+
665+ let outgoing = mp. into_outgoing ( ) . unwrap ( ) ;
666+ let ct = outgoing. content_type_header ( ) . unwrap ( ) ;
667+ let ct_str = ct. as_ref ( ) . to_str ( ) . unwrap ( ) ;
668+ assert ! (
669+ ct_str. starts_with( "multipart/byteranges" ) ,
670+ "expected byteranges to survive forwarding, got: {ct_str}"
671+ ) ;
672+ }
673+
674+ #[ tokio:: test]
675+ async fn into_outgoing_forwards_per_part_headers ( ) {
676+ use crate :: http:: IntoResponse ;
677+ use http_body_util:: BodyExt ;
678+
679+ // Source part has Content-Range, a filename* parameter on Content-Disposition,
680+ // and a custom header — none of which the form-data builder API would set.
681+ // All must survive the proxy round-trip.
682+ let body = "--BNDRY\r \n \
683+ Content-Disposition: form-data; name=\" upload\" ; filename=\" plain.txt\" ; filename*=UTF-8''r%C3%A9sum%C3%A9.txt\r \n \
684+ Content-Type: text/plain; charset=utf-8\r \n \
685+ Content-Range: bytes 0-4/10\r \n \
686+ X-Custom-Trace: trace-abc\r \n \
687+ \r \n \
688+ hello\r \n --BNDRY--\r \n ";
689+ let req = Request :: get ( "/" )
690+ . header ( CONTENT_TYPE , "multipart/form-data; boundary=BNDRY" )
691+ . body ( HttpBody :: full ( body) )
692+ . unwrap ( ) ;
693+ let ( parts, body) = req. into_parts ( ) ;
694+ let mp = Multipart :: from_payload ( Payload :: Full ( & parts, body) )
695+ . await
696+ . unwrap ( ) ;
697+
698+ let resp = mp. into_outgoing ( ) . unwrap ( ) . into_response ( ) . unwrap ( ) ;
699+ let bytes = resp
700+ . into_inner ( )
701+ . into_body ( )
702+ . collect ( )
703+ . await
704+ . unwrap ( )
705+ . to_bytes ( ) ;
706+ let wire = std:: str:: from_utf8 ( & bytes) . unwrap ( ) ;
707+
708+ assert ! (
709+ wire. contains( "filename*=UTF-8''r%C3%A9sum%C3%A9.txt" ) ,
710+ "got: {wire}"
711+ ) ;
712+ assert ! ( wire. contains( "content-range: bytes 0-4/10" ) , "got: {wire}" ) ;
713+ assert ! ( wire. contains( "x-custom-trace: trace-abc" ) , "got: {wire}" ) ;
714+ }
715+
597716 #[ tokio:: test]
598717 async fn into_outgoing_propagates_parse_error ( ) {
599718 use crate :: http:: IntoResponse ;
0 commit comments