Emit empty multipart body for all cases

Prior to this commit, gh-30953 fixed a case where multipart file parts
were not emitted properly when the body itself is empty.
There are other cases like this, depending on the order and slicing of
data buffers received by the parser. Here, a buffer containing the
entire boundary would not cause an empty file part to be emitted and
instead switch to the next header.

This commit ensures that empty file parts are always emitted as they
should.

Fixes gh-37264
This commit is contained in:
Brian Clozel
2026-09-10 14:23:34 +02:00
parent d3d8e05fa9
commit 1ea92339e2
4 changed files with 49 additions and 12 deletions
@@ -546,6 +546,7 @@ final class MultipartParser extends BaseSubscriber<DataBuffer> {
// iterate over buffers in reverse order
DataBufferUtils.release(boundaryBuffer);
DataBuffer prev;
boolean found = false;
while ((prev = this.queue.pollLast()) != null) {
int prevByteCount = prev.readableByteCount();
int prevLen = prevByteCount + len;
@@ -555,6 +556,7 @@ final class MultipartParser extends BaseSubscriber<DataBuffer> {
DataBufferUtils.release(prev);
enqueue(body);
flush();
found = true;
break;
}
else {
@@ -563,11 +565,21 @@ final class MultipartParser extends BaseSubscriber<DataBuffer> {
len += prevByteCount;
}
}
if (!found) {
// all buffered bytes were boundary bytes: the part had an empty body
emitBody(buffer.factory().allocateBuffer(0), true);
}
}
else /* if (len == 0) */ {
// buffer starts with complete delimiter, flush out the previous buffers
DataBufferUtils.release(boundaryBuffer);
flush();
if (this.queue.isEmpty()) {
// nothing was ever buffered for this part: the part had an empty body
emitBody(buffer.factory().allocateBuffer(0), true);
}
else {
flush();
}
}
changeState(this, new HeadersState(), buffer);
@@ -317,7 +317,7 @@ class DefaultPartHttpMessageReaderTests extends AbstractLeakCheckingTests {
@ParameterizedDefaultPartHttpMessageReaderTest
void emptyLastPart(DefaultPartHttpMessageReader reader) throws InterruptedException {
MockServerHttpRequest request = createRequest(
new ClassPathResource("empty-part.multipart", getClass()), "LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD");
new ClassPathResource("empty-part-last.multipart", getClass()), "LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD");
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap());
@@ -330,6 +330,22 @@ class DefaultPartHttpMessageReaderTests extends AbstractLeakCheckingTests {
latch.await();
}
@ParameterizedDefaultPartHttpMessageReaderTest // gh-37264
void emptyPartNotLast(DefaultPartHttpMessageReader reader) throws InterruptedException {
MockServerHttpRequest request = createRequest(
new ClassPathResource("empty-part.multipart", getClass()), "simple-boundary");
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap());
CountDownLatch latch = new CountDownLatch(2);
StepVerifier.create(result)
.consumeNextWith(part -> testPart(part, "file", "", latch)).as("file")
.consumeNextWith(part -> testPart(part, "action", "asd", latch)).as("action")
.verifyComplete();
latch.await();
}
private void testBrowser(DefaultPartHttpMessageReader reader, Resource resource, String boundary)
throws InterruptedException {
@@ -0,0 +1,13 @@
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD
Content-Disposition: form-data; name="files"; filename="file17312898095703516893.tmp"
Content-Type: application/octet-stream
Content-Length: 0
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD
Content-Disposition: form-data; name="files"; filename="file14790463448453253614.tmp"
Content-Type: application/octet-stream
Content-Length: 0
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD--
@@ -1,13 +1,9 @@
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD
Content-Disposition: form-data; name="files"; filename="file17312898095703516893.tmp"
Content-Type: application/octet-stream
Content-Length: 0
--simple-boundary
Content-Disposition: form-data; name="file"; filename="test.txt"
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD
Content-Disposition: form-data; name="files"; filename="file14790463448453253614.tmp"
Content-Type: application/octet-stream
Content-Length: 0
--simple-boundary
Content-Disposition: form-data; name="action"
--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD--
asd
--simple-boundary--