Skip to content

Commit ad343f1

Browse files
committed
fix: multipart
1 parent 008dda4 commit ad343f1

3 files changed

Lines changed: 147 additions & 26 deletions

File tree

.github/workflows/fuzz.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
workflow_dispatch:
99

1010
env:
11+
RUST_NIGHTLY: nightly-2026-05-03
1112
CARGO_TERM_COLOR: always
1213
ASAN_OPTIONS: quarantine_size_mb=1:malloc_context_size=0
1314

@@ -18,11 +19,11 @@ jobs:
1819
- uses: actions/checkout@v4
1920
- uses: dtolnay/rust-toolchain@nightly
2021
with:
21-
toolchain: nightly-2026-05-03
22+
toolchain: ${{ env.RUST_NIGHTLY }}
2223
- name: Install cargo-fuzz
2324
run: cargo install cargo-fuzz --locked
2425
- name: Build fuzz targets
25-
run: cargo +nightly fuzz build
26+
run: cargo +${{ env.RUST_NIGHTLY }} fuzz build
2627

2728
fuzz_smoke:
2829
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
@@ -32,13 +33,13 @@ jobs:
3233
- uses: actions/checkout@v4
3334
- uses: dtolnay/rust-toolchain@nightly
3435
with:
35-
toolchain: nightly-2026-05-03
36+
toolchain: ${{ env.RUST_NIGHTLY }}
3637
- name: Install cargo-fuzz
3738
run: cargo install cargo-fuzz --locked
3839
- name: Smoke run router
39-
run: cargo +nightly fuzz run fuzz_router_match -- -max_len=512 -max_total_time=45 -rss_limit_mb=512
40+
run: cargo +${{ env.RUST_NIGHTLY }} fuzz run fuzz_router_match -- -max_len=512 -max_total_time=45 -rss_limit_mb=512
4041
- name: Smoke run query
41-
run: cargo +nightly fuzz run fuzz_query_decode -- -max_len=1024 -max_total_time=45 -rss_limit_mb=512
42+
run: cargo +${{ env.RUST_NIGHTLY }} fuzz run fuzz_query_decode -- -max_len=1024 -max_total_time=45 -rss_limit_mb=512
4243

4344
fuzz_nightly:
4445
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
@@ -60,8 +61,8 @@ jobs:
6061
- uses: actions/checkout@v4
6162
- uses: dtolnay/rust-toolchain@nightly
6263
with:
63-
toolchain: nightly-2026-05-03
64+
toolchain: ${{ env.RUST_NIGHTLY }}
6465
- name: Install cargo-fuzz
6566
run: cargo install cargo-fuzz --locked
6667
- name: Nightly fuzz run
67-
run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_len=${{ matrix.max_len }} -max_total_time=300 -rss_limit_mb=512
68+
run: cargo +${{ env.RUST_NIGHTLY }} fuzz run ${{ matrix.target }} -- -max_len=${{ matrix.max_len }} -max_total_time=300 -rss_limit_mb=512

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1818

1919
## Changed
2020
* HSTS default `max_age` is now 1 year (31,536,000 s); previously 30 days. Aligns with the [HSTS preload list](https://hstspreload.org/) requirement (#190).
21+
* `Multipart` request parsing accepts any `multipart/*` subtype (previously only `multipart/form-data`). Required for forwarding `multipart/byteranges`, `multipart/mixed`, etc.
2122

2223
## Breaking Changes
2324
* `HstsConfig::with_preload()` panics if `max_age < 1 year`; `HstsConfig::with_max_age(...)` panics if called when `preload` is enabled and the new value is below 1 year (#190).

volga/src/http/endpoints/args/multipart.rs

Lines changed: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ impl std::fmt::Debug for Multipart {
6969
// streaming encoder. Asymmetry is intentional.
7070
pub(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

108123
impl 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\nContent-Range: bytes 0-4/10\r\nContent-Type: text/plain\r\n\r\nfirst\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

Comments
 (0)