From 44302aca93ba37d0f302fe85550bea20f3132bdd Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 26 Mar 2026 15:08:22 +0100 Subject: [PATCH] Add MultipartHttpMessageConverter Prior to this commit, the `FormHttpMessageConverter` would write, but not read, multipart HTTP messages. Reading multipart messages is typicaly performed by Servlet containers with Spring's `MultipartResolver` infrastructure. This commit introduces a new `MultipartHttpMessageConverter` that copies the existing feature for writing multipart messages, borrowed from `FormHttpMessageConverter`. This also introduces a new `MultipartParser` class that is an imperative port of the reactive variant, but keeping it based on the `DataBuffer` abstraction. This will allow us to maintain both side by side more easily. This change also adds new `Part`, `FilePart` and `FormFieldPart` types that will be used when converting multipart messages to `MultiValueMap` maps. Closes gh-36255 --- .../converter/multipart/DefaultParts.java | 297 ++++++++ .../http/converter/multipart/FilePart.java | 72 ++ .../converter/multipart/FormFieldPart.java | 34 + .../MultipartHttpMessageConverter.java | 651 ++++++++++++++++++ .../converter/multipart/MultipartParser.java | 553 +++++++++++++++ .../converter/multipart/MultipartUtils.java | 47 ++ .../http/converter/multipart/Part.java | 69 ++ .../converter/multipart/PartGenerator.java | 394 +++++++++++ .../converter/multipart/package-info.java | 7 + .../MultipartHttpMessageConverterTests.java | 554 +++++++++++++++ .../multipart/MultipartParserTests.java | 282 ++++++++ 11 files changed, 2960 insertions(+) create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java new file mode 100644 index 00000000000..293ef04f178 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java @@ -0,0 +1,297 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; + +/** + * Default implementations of {@link Part} and subtypes. + * + * @author Arjen Poutsma + * @author Brian Clozel + */ +abstract class DefaultParts { + + /** + * Create a new {@link FormFieldPart} with the given parameters. + * @param headers the part headers + * @param value the form field value + * @return the created part + */ + public static FormFieldPart formFieldPart(HttpHeaders headers, String value) { + Assert.notNull(headers, "Headers must not be null"); + Assert.notNull(value, "Value must not be null"); + + return new DefaultFormFieldPart(headers, value); + } + + /** + * Create a new {@link Part} or {@link FilePart} based on a flux of data + * buffers. Returns {@link FilePart} if the {@code Content-Disposition} of + * the given headers contains a filename, or a "normal" {@link Part} + * otherwise. + * @param headers the part headers + * @param dataBuffer the content of the part + * @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()} + */ + public static Part part(HttpHeaders headers, DataBuffer dataBuffer) { + Assert.notNull(headers, "Headers must not be null"); + Assert.notNull(dataBuffer, "DataBuffer must not be null"); + + return partInternal(headers, new DataBufferContent(dataBuffer)); + } + + /** + * Create a new {@link Part} or {@link FilePart} based on the given file. + * Returns {@link FilePart} if the {@code Content-Disposition} of the given + * headers contains a filename, or a "normal" {@link Part} otherwise + * @param headers the part headers + * @param file the file + * @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()} + */ + public static Part part(HttpHeaders headers, Path file) { + Assert.notNull(headers, "Headers must not be null"); + Assert.notNull(file, "File must not be null"); + + return partInternal(headers, new FileContent(file)); + } + + + private static Part partInternal(HttpHeaders headers, Content content) { + String filename = headers.getContentDisposition().getFilename(); + if (filename != null) { + return new DefaultFilePart(headers, content); + } + else { + return new DefaultPart(headers, content); + } + } + + + /** + * Abstract base class for {@link Part} implementations. + */ + private abstract static class AbstractPart implements Part { + + private final HttpHeaders headers; + + protected AbstractPart(HttpHeaders headers) { + Assert.notNull(headers, "HttpHeaders is required"); + this.headers = headers; + } + + @Override + public String name() { + String name = headers().getContentDisposition().getName(); + Assert.state(name != null, "No part name available"); + return name; + } + + @Override + public HttpHeaders headers() { + return this.headers; + } + } + + + /** + * Default implementation of {@link FormFieldPart}. + */ + private static class DefaultFormFieldPart extends AbstractPart implements FormFieldPart { + + private final String value; + + public DefaultFormFieldPart(HttpHeaders headers, String value) { + super(headers); + this.value = value; + } + + @Override + public InputStream content() { + byte[] bytes = this.value.getBytes(MultipartUtils.charset(headers())); + return new ByteArrayInputStream(bytes); + } + + @Override + public String value() { + return this.value; + } + + @Override + public String toString() { + String name = headers().getContentDisposition().getName(); + if (name != null) { + return "DefaultFormFieldPart{" + name() + "}"; + } + else { + return "DefaultFormFieldPart"; + } + } + } + + + /** + * Default implementation of {@link Part}. + */ + private static class DefaultPart extends AbstractPart { + + protected final Content content; + + public DefaultPart(HttpHeaders headers, Content content) { + super(headers); + this.content = content; + } + + @Override + public InputStream content() throws IOException { + return this.content.content(); + } + + @Override + public void delete() throws IOException { + this.content.delete(); + } + + @Override + public String toString() { + String name = headers().getContentDisposition().getName(); + if (name != null) { + return "DefaultPart{" + name + "}"; + } + else { + return "DefaultPart"; + } + } + } + + + /** + * Default implementation of {@link FilePart}. + */ + private static final class DefaultFilePart extends DefaultPart implements FilePart { + + public DefaultFilePart(HttpHeaders headers, Content content) { + super(headers, content); + } + + @Override + public String filename() { + String filename = headers().getContentDisposition().getFilename(); + Assert.state(filename != null, "No filename found"); + return filename; + } + + @Override + public void transferTo(Path dest) throws IOException { + this.content.transferTo(dest); + } + + @Override + public String toString() { + ContentDisposition contentDisposition = headers().getContentDisposition(); + String name = contentDisposition.getName(); + String filename = contentDisposition.getFilename(); + if (name != null) { + return "DefaultFilePart{" + name + " (" + filename + ")}"; + } + else { + return "DefaultFilePart{(" + filename + ")}"; + } + } + } + + + /** + * Part content abstraction. + */ + private interface Content { + + InputStream content() throws IOException; + + void transferTo(Path dest) throws IOException; + + void delete() throws IOException; + } + + + /** + * {@code Content} implementation based on an in-memory {@code InputStream}. + */ + private static final class DataBufferContent implements Content { + + private final DataBuffer content; + + public DataBufferContent(DataBuffer content) { + this.content = content; + } + + @Override + public InputStream content() { + return this.content.asInputStream(); + } + + @Override + public void transferTo(Path dest) throws IOException { + Files.copy(this.content.asInputStream(), dest, StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void delete() throws IOException { + } + } + + + /** + * {@code Content} implementation based on a file. + */ + private static final class FileContent implements Content { + + private final Path file; + + public FileContent(Path file) { + this.file = file; + } + + @Override + public InputStream content() throws IOException { + return Files.newInputStream(this.file.toAbsolutePath(), StandardOpenOption.READ); + } + + @Override + public void transferTo(Path dest) throws IOException { + Files.copy(this.file, dest, StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void delete() throws IOException { + Files.delete(this.file); + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java new file mode 100644 index 00000000000..9d5594a3d0e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +/** + * Specialization of {@link Part} that represents an uploaded file received in + * a multipart request. + * + * @author Brian Clozel + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @since 7.1 + */ +public interface FilePart extends Part { + + /** + * Return the original filename in the client's filesystem. + *

Note: Please keep in mind this filename is supplied + * by the client and should not be used blindly. In addition to not using + * the directory portion, the file name could also contain characters such + * as ".." and others that can be used maliciously. It is recommended to not + * use this filename directly. Preferably generate a unique one and save + * this one somewhere for reference, if necessary. + * @return the original filename, or the empty String if no file has been chosen + * in the multipart form, or {@code null} if not defined or not available + * @see RFC 7578, Section 4.2 + * @see Unrestricted File Upload + */ + String filename(); + + /** + * Convenience method to copy the content of the file in this part to the + * given destination file. If the destination file already exists, it will + * be truncated first. + *

The default implementation delegates to {@link #transferTo(Path)}. + * @param dest the target file + * @throws IllegalStateException if the part isn't a file + * @see #transferTo(Path) + */ + default void transferTo(File dest) throws IOException { + transferTo(dest.toPath()); + } + + /** + * Convenience method to copy the content of the file in this part to the + * given destination file. If the destination file already exists, it will + * be truncated first. + * @param dest the target file + * @throws IllegalStateException if the part isn't a file + * @see #transferTo(File) + */ + void transferTo(Path dest) throws IOException; + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java new file mode 100644 index 00000000000..8a920fb7da7 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + + +/** + * Specialization of {@link Part} for a form field. + * + * @author Brian Clozel + * @author Rossen Stoyanchev + * @since 7.1 + */ +public interface FormFieldPart extends Part { + + /** + * Return the form field value. + */ + String value(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java new file mode 100644 index 00000000000..5526bdbcbf5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java @@ -0,0 +1,651 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.http.converter.ResourceHttpMessageConverter; +import org.springframework.http.converter.SmartHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.MultiValueMap; + +/** + * Implementation of {@link HttpMessageConverter} to read and write + * multipart data (for example, file uploads). + * + *

This converter can read {@code "multipart/form-data"} + * and {@code "multipart/mixed"} messages as + * {@link MultiValueMap MultiValueMap<String, Part>}, and + * write {@link MultiValueMap MultiValueMap<String, Object>} as + * multipart messages. + * + *

On Servlet containers, the reading of multipart messages should be + * delegated to the {@link org.springframework.web.multipart.MultipartResolver}. + * + *

Multipart Data

+ * + *

By default, {@code "multipart/form-data"} is used as the content type when + * {@linkplain #write writing} multipart data. It is also possible to write + * multipart data using other multipart subtypes such as {@code "multipart/mixed"} + * and {@code "multipart/related"}, as long as the multipart subtype is registered + * as a {@linkplain #getSupportedMediaTypes supported media type} and the + * desired multipart subtype is specified as the content type when + * {@linkplain #write writing} the multipart data. Note that {@code "multipart/mixed"} + * is registered as a supported media type by default. + * + *

When writing multipart data, this converter uses other + * {@link HttpMessageConverter HttpMessageConverters} to write the respective + * MIME parts. By default, basic converters are registered for byte array, + * {@code String}, and {@code Resource}. This can be set with the main + * {@link #MultipartHttpMessageConverter(Iterable) constructor}. + * + *

Examples

+ * + *

The following snippet shows how to submit an HTML form using the + * {@code "multipart/form-data"} content type. + * + *

+ * RestClient restClient = RestClient.create();
+ * // MultipartHttpMessageConverter is configured by default
+ *
+ * MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
+ * form.add("field 1", "value 1");
+ * form.add("field 2", "value 2");
+ * form.add("field 2", "value 3");
+ * form.add("field 3", 4);
+ *
+ * ResponseEntity<Void> response = restClient.post()
+ *   .uri("https://example.com/myForm")
+ *   .contentType(MULTIPART_FORM_DATA)
+ *   .body(form)
+ *   .retrieve()
+ *   .toBodilessEntity();
+ * + *

The following snippet shows how to do a file upload using the + * {@code "multipart/form-data"} content type. + * + *

+ * MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
+ * parts.add("field 1", "value 1");
+ * parts.add("file", new ClassPathResource("myFile.jpg"));
+ *
+ * ResponseEntity<Void> response = restClient.post()
+ *   .uri("https://example.com/myForm")
+ *   .contentType(MULTIPART_FORM_DATA)
+ *   .body(parts)
+ *   .retrieve()
+ *   .toBodilessEntity();
+ * + *

The following snippet shows how to decode a multipart response. + * + *

+ * MultiValueMap<String, Part> body = this.restClient.get()
+ * 				.uri("https://example.com/parts/42")
+ * 				.accept(MediaType.MULTIPART_FORM_DATA)
+ * 				.retrieve()
+ * 				.body(new ParameterizedTypeReference<>() {});
+ * + * @author Brian Clozel + * @author Arjen Poutsma + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @author Sam Brannen + * @since 7.1 + * @see org.springframework.util.MultiValueMap + */ +public class MultipartHttpMessageConverter implements SmartHttpMessageConverter> { + + private final List> partConverters; + + private @Nullable Path tempDirectory; + + private List supportedMediaTypes = new ArrayList<>(); + + private Charset charset = StandardCharsets.UTF_8; + + private @Nullable Charset multipartCharset; + + private int maxInMemorySize = 256 * 1024; + + private int maxHeadersSize = 10 * 1024; + + private long maxDiskUsagePerPart = -1; + + private int maxParts = -1; + + /** + * Create a new converter instance with the given converter instances for reading and + * writing parts. + * @param converters the converters to use for reading and writing parts + */ + public MultipartHttpMessageConverter(Iterable> converters) { + this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA); + this.supportedMediaTypes.add(MediaType.MULTIPART_MIXED); + this.supportedMediaTypes.add(MediaType.MULTIPART_RELATED); + + this.partConverters = new ArrayList<>(); + converters.forEach(this.partConverters::add); + } + + /** + * Create a new converter instance with default converter instances for reading and + * writing parts. + * @see ByteArrayHttpMessageConverter + * @see StringHttpMessageConverter + * @see ResourceHttpMessageConverter + */ + public MultipartHttpMessageConverter() { + this(List.of( new ByteArrayHttpMessageConverter(), new StringHttpMessageConverter(), + new ResourceHttpMessageConverter())); + } + + /** + * Set the list of {@link MediaType} objects supported by this converter. + * @see #addSupportedMediaTypes(MediaType...) + * @see #getSupportedMediaTypes() + */ + public void setSupportedMediaTypes(List supportedMediaTypes) { + Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null"); + // Ensure internal list is mutable. + this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes); + } + + /** + * Add {@link MediaType} objects to be supported by this converter. + *

The supplied {@code MediaType} objects will be appended to the list + * of {@linkplain #getSupportedMediaTypes() supported MediaType objects}. + * @param supportedMediaTypes a var-args list of {@code MediaType} objects to add + * @see #setSupportedMediaTypes(List) + */ + public void addSupportedMediaTypes(MediaType... supportedMediaTypes) { + Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null"); + Assert.noNullElements(supportedMediaTypes, "'supportedMediaTypes' must not contain null elements"); + Collections.addAll(this.supportedMediaTypes, supportedMediaTypes); + } + + /** + * {@inheritDoc} + * @see #setSupportedMediaTypes(List) + * @see #addSupportedMediaTypes(MediaType...) + */ + @Override + public List getSupportedMediaTypes() { + return Collections.unmodifiableList(this.supportedMediaTypes); + } + + + /** + * Return the configured converters for MIME parts. + */ + public List> getPartConverters() { + return Collections.unmodifiableList(this.partConverters); + } + + /** + * Set the default character set to use for reading and writing form data when + * the request or response {@code Content-Type} header does not explicitly + * specify it. + *

As of 4.3, this is also used as the default charset for the conversion + * of text bodies in a multipart request. + *

As of 5.0, this is also used for part headers including + * {@code Content-Disposition} (and its filename parameter) unless (the mutually + * exclusive) {@link #setMultipartCharset multipartCharset} is also set, in + * which case part headers are encoded as ASCII and filename is encoded + * with the {@code encoded-word} syntax from RFC 2047. + *

By default, this is set to "UTF-8". + */ + public void setCharset(@Nullable Charset charset) { + if (charset != this.charset) { + this.charset = (charset != null ? charset : StandardCharsets.UTF_8); + applyDefaultCharset(); + } + } + + /** + * Apply the configured charset as a default to registered part converters. + */ + private void applyDefaultCharset() { + for (HttpMessageConverter candidate : this.partConverters) { + if (candidate instanceof AbstractHttpMessageConverter converter) { + // Only override default charset if the converter operates with a charset to begin with... + if (converter.getDefaultCharset() != null) { + converter.setDefaultCharset(this.charset); + } + } + } + } + + /** + * Set the character set to use when writing multipart data to encode file + * names. Encoding is based on the {@code encoded-word} syntax defined in + * RFC 2047 and relies on {@code MimeUtility} from {@code jakarta.mail}. + *

As of 5.0 by default part headers, including {@code Content-Disposition} + * (and its filename parameter) will be encoded based on the setting of + * {@link #setCharset(Charset)} or {@code UTF-8} by default. + * @see Encoded-Word + */ + public void setMultipartCharset(Charset charset) { + this.multipartCharset = charset; + } + + + /** + * Configure the maximum amount of memory that is allowed per headers section of each part. + *

By default, this is set to 10K. + * @param byteCount the maximum amount of memory for headers + */ + public void setMaxHeadersSize(int byteCount) { + this.maxHeadersSize = byteCount; + } + + /** + * Configure the maximum amount of memory allowed per part. + * When the limit is exceeded: + *

    + *
  • File parts are written to a temporary file. + *
  • Non-file parts are rejected with {@link DataBufferLimitException}. + *
+ *

By default, this is set to 256K. + * @param maxInMemorySize the in-memory limit in bytes; if set to -1 the entire + * contents will be stored in memory + */ + public void setMaxInMemorySize(int maxInMemorySize) { + this.maxInMemorySize = maxInMemorySize; + } + + /** + * Configure the maximum amount of disk space allowed for file parts. + *

By default, this is set to -1, meaning that there is no maximum. + *

Note that this property is ignored when + * {@link #setMaxInMemorySize(int) maxInMemorySize} is set to -1. + */ + public void setMaxDiskUsagePerPart(long maxDiskUsagePerPart) { + this.maxDiskUsagePerPart = maxDiskUsagePerPart; + } + + /** + * Specify the maximum number of parts allowed in a given multipart request. + *

By default, this is set to -1, meaning that there is no maximum. + */ + public void setMaxParts(int maxParts) { + this.maxParts = maxParts; + } + + @Override + public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) { + if (!supportsMediaType(mediaType)) { + return false; + } + if (!MultiValueMap.class.isAssignableFrom(elementType.toClass()) || + (!elementType.hasUnresolvableGenerics() && + !Part.class.isAssignableFrom(elementType.getGeneric(1).toClass()))) { + return false; + } + return true; + } + + private boolean supportsMediaType(@Nullable MediaType mediaType) { + if (mediaType == null) { + return true; + } + for (MediaType supportedMediaType : getSupportedMediaTypes()) { + if (supportedMediaType.includes(mediaType)) { + return true; + } + } + return false; + } + + @Override + public MultiValueMap read(ResolvableType type, HttpInputMessage message, @Nullable Map hints) throws IOException, HttpMessageNotReadableException { + + Charset headersCharset = MultipartUtils.charset(message.getHeaders()); + byte[] boundary = boundary(message, headersCharset); + if (boundary == null) { + throw new HttpMessageNotReadableException("No multipart boundary found in Content-Type: \"" + + message.getHeaders().getContentType() + "\"", message); + } + PartGenerator partListener = new PartGenerator(this.maxInMemorySize, this.maxDiskUsagePerPart, this.maxParts, getTempDirectory()); + new MultipartParser(this.maxHeadersSize, 2 * 1024).parse(message.getBody(), boundary, + headersCharset, partListener); + return partListener.getParts(); + } + + + private static byte @Nullable [] boundary(HttpInputMessage message, Charset headersCharset) { + MediaType contentType = message.getHeaders().getContentType(); + if (contentType != null) { + String boundary = contentType.getParameter("boundary"); + if (boundary != null) { + int len = boundary.length(); + if (len > 2 && boundary.charAt(0) == '"' && boundary.charAt(len - 1) == '"') { + boundary = boundary.substring(1, len - 1); + } + return boundary.getBytes(headersCharset); + } + } + return null; + } + + private Path getTempDirectory() throws IOException { + if (this.tempDirectory == null || !this.tempDirectory.toFile().exists()) { + this.tempDirectory = Files.createTempDirectory("spring-multipart-"); + } + return this.tempDirectory; + } + + @Override + public boolean canWrite(ResolvableType targetType, Class valueClass, @Nullable MediaType mediaType) { + if (!MultiValueMap.class.isAssignableFrom(targetType.toClass())) { + return false; + } + if (mediaType == null || MediaType.ALL.equals(mediaType)) { + return true; + } + for (MediaType supportedMediaType : getSupportedMediaTypes()) { + if (supportedMediaType.isCompatibleWith(mediaType)) { + return true; + } + } + return false; + } + + @Override + @SuppressWarnings("unchecked") + public void write(MultiValueMap map, ResolvableType type, @Nullable MediaType contentType, HttpOutputMessage outputMessage, @Nullable Map hints) throws IOException, HttpMessageNotWritableException { + MultiValueMap parts = (MultiValueMap) map; + + // If the supplied content type is null, fall back to multipart/form-data. + // Otherwise, rely on the fact that isMultipart() already verified the + // supplied content type is multipart. + if (contentType == null) { + contentType = MediaType.MULTIPART_FORM_DATA; + } + + Map parameters = new LinkedHashMap<>(contentType.getParameters().size() + 2); + parameters.putAll(contentType.getParameters()); + + byte[] boundary = MimeTypeUtils.generateMultipartBoundary(); + if (!isFilenameCharsetSet()) { + if (!this.charset.equals(StandardCharsets.UTF_8) && + !this.charset.equals(StandardCharsets.US_ASCII)) { + parameters.put("charset", this.charset.name()); + } + } + parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII)); + + // Add parameters to output content type + contentType = new MediaType(contentType, parameters); + outputMessage.getHeaders().setContentType(contentType); + + if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) { + boolean repeatable = checkPartsRepeatable(parts, contentType); + streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + MultipartHttpMessageConverter.this.writeParts(outputStream, parts, boundary); + writeEnd(outputStream, boundary); + } + + @Override + public boolean repeatable() { + return repeatable; + } + }); + } + else { + writeParts(outputMessage.getBody(), parts, boundary); + writeEnd(outputMessage.getBody(), boundary); + } + } + + + @SuppressWarnings({"unchecked", "ConstantValue"}) + private boolean checkPartsRepeatable(MultiValueMap map, MediaType contentType) { + return map.entrySet().stream().allMatch(e -> e.getValue().stream().filter(Objects::nonNull).allMatch(part -> { + HttpHeaders headers = null; + Object body = part; + if (part instanceof HttpEntity entity) { + headers = entity.getHeaders(); + body = entity.getBody(); + Assert.state(body != null, "Empty body for part '" + e.getKey() + "': " + part); + } + HttpMessageConverter converter = (HttpMessageConverter) findConverterFor(e.getKey(), headers, body); + return converter != null && converter.canWriteRepeatedly((T) body, contentType); + })); + } + + private @Nullable HttpMessageConverter findConverterFor( + String name, @Nullable HttpHeaders headers, Object body) { + + Class partType = body.getClass(); + MediaType contentType = (headers != null ? headers.getContentType() : null); + for (HttpMessageConverter converter : this.partConverters) { + if (converter.canWrite(partType, contentType)) { + return converter; + } + } + return null; + } + + /** + * When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047, + * {@code encoded-word} syntax) we need to use ASCII for part headers, or + * otherwise we encode directly using the configured {@link #setCharset(Charset)}. + */ + private boolean isFilenameCharsetSet() { + return (this.multipartCharset != null); + } + + private void writeParts(OutputStream os, MultiValueMap parts, byte[] boundary) throws IOException { + for (Map.Entry> entry : parts.entrySet()) { + String name = entry.getKey(); + for (Object part : entry.getValue()) { + if (part != null) { + writeBoundary(os, boundary); + writePart(name, getHttpEntity(part), os); + writeNewLine(os); + } + } + } + } + + @SuppressWarnings("unchecked") + private void writePart(String name, HttpEntity partEntity, OutputStream os) throws IOException { + Object partBody = partEntity.getBody(); + Assert.state(partBody != null, "Empty body for part '" + name + "': " + partEntity); + HttpHeaders partHeaders = partEntity.getHeaders(); + MediaType partContentType = partHeaders.getContentType(); + HttpMessageConverter converter = findConverterFor(name, partHeaders, partBody); + if (converter != null) { + Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset; + HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset); + String filename = getFilename(partBody); + ContentDisposition.Builder cd = ContentDisposition.formData().name(name); + if (filename != null) { + cd.filename(filename, this.multipartCharset); + } + multipartMessage.getHeaders().setContentDisposition(cd.build()); + if (!partHeaders.isEmpty()) { + multipartMessage.getHeaders().putAll(partHeaders); + } + ((HttpMessageConverter) converter).write(partBody, partContentType, multipartMessage); + return; + } + throw new HttpMessageNotWritableException("Could not write request: " + + "no suitable HttpMessageConverter found for request type [" + partBody.getClass().getName() + "]"); + } + + /** + * Return an {@link HttpEntity} for the given part Object. + * @param part the part to return an {@link HttpEntity} for + * @return the part Object itself it is an {@link HttpEntity}, + * or a newly built {@link HttpEntity} wrapper for that part + */ + protected HttpEntity getHttpEntity(Object part) { + return (part instanceof HttpEntity httpEntity ? httpEntity : new HttpEntity<>(part)); + } + + /** + * Return the filename of the given multipart part. This value will be used for the + * {@code Content-Disposition} header. + *

The default implementation returns {@link Resource#getFilename()} if the part is a + * {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses. + * @param part the part to determine the file name for + * @return the filename, or {@code null} if not known + */ + protected @Nullable String getFilename(Object part) { + if (part instanceof Resource resource) { + return resource.getFilename(); + } + else { + return null; + } + } + + + private void writeBoundary(OutputStream os, byte[] boundary) throws IOException { + os.write('-'); + os.write('-'); + os.write(boundary); + writeNewLine(os); + } + + private static void writeEnd(OutputStream os, byte[] boundary) throws IOException { + os.write('-'); + os.write('-'); + os.write(boundary); + os.write('-'); + os.write('-'); + writeNewLine(os); + } + + private static void writeNewLine(OutputStream os) throws IOException { + os.write('\r'); + os.write('\n'); + } + + + /** + * Implementation of {@link org.springframework.http.HttpOutputMessage} used + * to write a MIME multipart. + */ + private static class MultipartHttpOutputMessage implements HttpOutputMessage { + + private final OutputStream outputStream; + + private final Charset charset; + + private final HttpHeaders headers = new HttpHeaders(); + + private boolean headersWritten = false; + + public MultipartHttpOutputMessage(OutputStream outputStream, Charset charset) { + this.outputStream = new MultipartOutputStream(outputStream); + this.charset = charset; + } + + @Override + public HttpHeaders getHeaders() { + return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers); + } + + @Override + public OutputStream getBody() throws IOException { + writeHeaders(); + return this.outputStream; + } + + private void writeHeaders() throws IOException { + if (!this.headersWritten) { + for (Map.Entry> entry : this.headers.headerSet()) { + byte[] headerName = getBytes(entry.getKey()); + for (String headerValueString : entry.getValue()) { + byte[] headerValue = getBytes(headerValueString); + this.outputStream.write(headerName); + this.outputStream.write(':'); + this.outputStream.write(' '); + this.outputStream.write(headerValue); + writeNewLine(this.outputStream); + } + } + writeNewLine(this.outputStream); + this.headersWritten = true; + } + } + + private byte[] getBytes(String name) { + return name.getBytes(this.charset); + } + + } + + + /** + * OutputStream that neither flushes nor closes. + */ + private static class MultipartOutputStream extends FilterOutputStream { + + public MultipartOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(byte[] b, int off, int let) throws IOException { + this.out.write(b, off, let); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java new file mode 100644 index 00000000000..194ea1631c4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java @@ -0,0 +1,553 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.converter.HttpMessageConversionException; + +/** + * Read a Multipart message as a byte stream and parse its content + * and signals them to the {@link PartListener}. + * + * @author Brian Clozel + * @author Arjen Poutsma + */ +final class MultipartParser { + + private static final Log logger = LogFactory.getLog(MultipartParser.class); + + private final int maxHeadersSize; + + private final int bufferSize; + + /** + * Create a new multipart parser instance. + * + * @param maxHeadersSize the maximum buffered header size + * @param bufferSize the size of the reading buffer + */ + MultipartParser(int maxHeadersSize, int bufferSize) { + this.maxHeadersSize = maxHeadersSize; + this.bufferSize = bufferSize; + } + + /** + * Parses the given stream of bytes into events published to the {@link PartListener}. + * @param input the input stream + * @param boundary the multipart boundary, as found in the {@code Content-Type} header + * @param headersCharset the charset to use for decoding headers + * @param listener a listener for parsed tokens + */ + public void parse(InputStream input, byte[] boundary, Charset headersCharset, PartListener listener) { + + InternalParser internalParser = new InternalParser(boundary, headersCharset, listener); + try { + while (true) { + byte[] read = input.readNBytes(this.bufferSize); + if (read.length == 0) { + break; + } + internalParser.state.data(DefaultDataBufferFactory.sharedInstance.wrap(read)); + } + internalParser.state.complete(); + } + catch (IOException ex) { + internalParser.state.dispose(); + listener.onError(new HttpMessageConversionException("Could not decode multipart message", ex)); + } + } + + private final class InternalParser { + + private final byte[] boundary; + + private final Charset headersCharset; + + private final PartListener listener; + + private State state; + + InternalParser(byte[] boundary, Charset headersCharset, PartListener listener) { + this.boundary = boundary; + this.headersCharset = headersCharset; + this.listener = listener; + this.state = new PreambleState(); + } + + void changeState(State newState, @Nullable DataBuffer remainder) { + if (logger.isTraceEnabled()) { + logger.trace("Changed state: " + this.state + " -> " + newState); + } + this.state.dispose(); + this.state = newState; + if (remainder != null) { + if (remainder.readableByteCount() > 0) { + newState.data(remainder); + } + else { + DataBufferUtils.release(remainder); + } + } + } + + /** + * Concatenates the given array of byte arrays. + */ + private static byte[] concat(byte[]... byteArrays) { + int len = 0; + for (byte[] byteArray : byteArrays) { + len += byteArray.length; + } + byte[] result = new byte[len]; + len = 0; + for (byte[] byteArray : byteArrays) { + System.arraycopy(byteArray, 0, result, len, byteArray.length); + len += byteArray.length; + } + return result; + } + + /** + * Represents the internal state of the {@link MultipartParser}. + * The flow for well-formed multipart messages is shown below: + *

+		 *     PREAMBLE
+		 *         |
+		 *         v
+		 *  +-->HEADERS--->DISPOSED
+		 *  |      |
+		 *  |      v
+		 *  +----BODY
+		 *  
+ * For malformed messages the flow ends in DISPOSED. + */ + private interface State { + + byte[] CR_LF = {'\r', '\n'}; + + byte HYPHEN = '-'; + + byte[] TWO_HYPHENS = {HYPHEN, HYPHEN}; + + String HEADER_ENTRY_SEPARATOR = "\\r\\n"; + + void data(DataBuffer buf); + + void complete(); + + default void dispose() { + } + } + + /** + * The initial state of the parser. Looks for the first boundary of the + * multipart message. Note that the first boundary is not necessarily + * prefixed with {@code CR LF}; only the prefix {@code --} is required. + */ + private final class PreambleState implements State { + + private final DataBufferUtils.Matcher firstBoundary; + + + PreambleState() { + this.firstBoundary = DataBufferUtils.matcher(concat(TWO_HYPHENS, InternalParser.this.boundary)); + } + + /** + * Looks for the first boundary in the given buffer. If found, changes + * state to {@link HeadersState}, and passes on the remainder of the + * buffer. + */ + @Override + public void data(DataBuffer buf) { + int endIdx = this.firstBoundary.match(buf); + if (endIdx != -1) { + if (logger.isTraceEnabled()) { + logger.trace("First boundary found @" + endIdx + " in " + buf); + } + DataBuffer preambleBuffer = buf.split(endIdx + 1); + DataBufferUtils.release(preambleBuffer); + changeState(new HeadersState(), buf); + } + else { + DataBufferUtils.release(buf); + } + } + + @Override + public void complete() { + changeState(DisposedState.INSTANCE, null); + InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find first boundary")); + } + + @Override + public String toString() { + return "PREAMBLE"; + } + + } + + /** + * The state of the parser dealing with part headers. Parses header + * buffers into a {@link HttpHeaders} instance, making sure that + * the amount does not exceed {@link #maxHeadersSize}. + */ + private final class HeadersState implements State { + + private final DataBufferUtils.Matcher endHeaders = DataBufferUtils.matcher(concat(CR_LF, CR_LF)); + + private final List buffers = new ArrayList<>(); + + private int byteCount; + + + /** + * First checks whether the multipart boundary leading to this state + * was the final boundary. Then looks for the header-body boundary + * ({@code CR LF CR LF}) in the given buffer. If found, checks whether + * the size of all header buffers does not exceed {@link #maxHeadersSize}, + * converts all buffers collected so far into a {@link HttpHeaders} object + * and changes to {@link BodyState}, passing the remainder of the + * buffer. If the boundary is not found, the buffer is collected if + * its size does not exceed {@link #maxHeadersSize}. + */ + @Override + public void data(DataBuffer buf) { + if (isLastBoundary(buf)) { + if (logger.isTraceEnabled()) { + logger.trace("Last boundary found in " + buf); + } + changeState(DisposedState.INSTANCE, buf); + InternalParser.this.listener.onComplete(); + return; + } + int endIdx = this.endHeaders.match(buf); + if (endIdx != -1) { + if (logger.isTraceEnabled()) { + logger.trace("End of headers found @" + endIdx + " in " + buf); + } + this.byteCount += endIdx; + if (belowMaxHeaderSize(this.byteCount)) { + DataBuffer headerBuf = buf.split(endIdx + 1); + this.buffers.add(headerBuf); + emitHeaders(); + changeState(new BodyState(), buf); + } + } + else { + this.byteCount += buf.readableByteCount(); + if (belowMaxHeaderSize(this.byteCount)) { + this.buffers.add(buf); + } + } + } + + private void emitHeaders() { + HttpHeaders headers = parseHeaders(); + if (logger.isTraceEnabled()) { + logger.trace("Emitting headers: " + headers); + } + InternalParser.this.listener.onHeaders(headers); + } + + /** + * If the given buffer is the first buffer, check whether it starts with {@code --}. + * If it is the second buffer, check whether it makes up {@code --} together with the first buffer. + */ + private boolean isLastBoundary(DataBuffer buf) { + return (this.buffers.isEmpty() && + buf.readableByteCount() >= 2 && + buf.getByte(0) == HYPHEN && buf.getByte(1) == HYPHEN) || + (this.buffers.size() == 1 && + this.buffers.get(0).readableByteCount() == 1 && + this.buffers.get(0).getByte(0) == HYPHEN && + buf.readableByteCount() >= 1 && + buf.getByte(0) == HYPHEN); + } + + /** + * Checks whether the given {@code count} is below or equal to {@link #maxHeadersSize} + * and throws a {@link DataBufferLimitException} if not. + */ + private boolean belowMaxHeaderSize(long count) { + if (count <= MultipartParser.this.maxHeadersSize) { + return true; + } + else { + InternalParser.this.listener.onError( + new HttpMessageConversionException("Part headers exceeded the memory usage limit of " + + MultipartParser.this.maxHeadersSize + " bytes")); + return false; + } + } + + /** + * Parses the list of buffers into a {@link HttpHeaders} instance. + * Converts the joined buffers into a string using ISO=8859-1, and parses + * that string into key and values. + */ + private HttpHeaders parseHeaders() { + if (this.buffers.isEmpty()) { + return HttpHeaders.EMPTY; + } + DataBuffer joined = this.buffers.get(0).factory().join(this.buffers); + this.buffers.clear(); + String string = joined.toString(InternalParser.this.headersCharset); + DataBufferUtils.release(joined); + String[] lines = string.split(HEADER_ENTRY_SEPARATOR); + HttpHeaders result = new HttpHeaders(); + for (String line : lines) { + int idx = line.indexOf(':'); + if (idx != -1) { + String name = line.substring(0, idx); + String value = line.substring(idx + 1); + while (value.startsWith(" ")) { + value = value.substring(1); + } + result.add(name, value); + } + } + return result; + } + + @Override + public void complete() { + changeState(DisposedState.INSTANCE, null); + InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find end of headers")); + } + + @Override + public void dispose() { + this.buffers.forEach(DataBufferUtils::release); + } + + @Override + public String toString() { + return "HEADERS"; + } + + } + + /** + * The state of the parser dealing with multipart bodies. Relays + * data buffers as {@link PartListener#onBody(DataBuffer, boolean)} + * until the boundary is found (or rather: {@code CR LF - - boundary}). + */ + private final class BodyState implements State { + + private final DataBufferUtils.Matcher boundaryMatcher; + + private final int boundaryLength; + + private final Deque queue = new ArrayDeque<>(); + + public BodyState() { + byte[] delimiter = concat(CR_LF, TWO_HYPHENS, InternalParser.this.boundary); + this.boundaryMatcher = DataBufferUtils.matcher(delimiter); + this.boundaryLength = delimiter.length; + } + + /** + * Checks whether the (end of the) needle {@code CR LF - - boundary} + * can be found in {@code buffer}. If found, the needle can overflow into the + * previous buffer, so we calculate the length and slice the current + * and previous buffers accordingly. We then change to {@link HeadersState} + * and pass on the remainder of {@code buffer}. If the needle is not found, we + * enqueue {@code buffer}. + */ + @Override + public void data(DataBuffer buffer) { + int endIdx = this.boundaryMatcher.match(buffer); + if (endIdx != -1) { + DataBuffer boundaryBuffer = buffer.split(endIdx + 1); + if (logger.isTraceEnabled()) { + logger.trace("Boundary found @" + endIdx + " in " + buffer); + } + int len = endIdx - this.boundaryLength + 1 - boundaryBuffer.readPosition(); + if (len > 0) { + // whole boundary in buffer. + // slice off the body part, and flush + DataBuffer body = boundaryBuffer.split(len); + DataBufferUtils.release(boundaryBuffer); + enqueue(body); + flush(); + } + else if (len < 0) { + // boundary spans multiple buffers, and we've just found the end + // iterate over buffers in reverse order + DataBufferUtils.release(boundaryBuffer); + DataBuffer prev; + while ((prev = this.queue.pollLast()) != null) { + int prevByteCount = prev.readableByteCount(); + int prevLen = prevByteCount + len; + if (prevLen >= 0) { + // slice body part of previous buffer, and flush it + DataBuffer body = prev.split(prevLen + prev.readPosition()); + DataBufferUtils.release(prev); + enqueue(body); + flush(); + break; + } + else { + // previous buffer only contains boundary bytes + DataBufferUtils.release(prev); + len += prevByteCount; + } + } + } + else /* if (len == 0) */ { + // buffer starts with complete delimiter, flush out the previous buffers + DataBufferUtils.release(boundaryBuffer); + flush(); + } + + changeState(new HeadersState(), buffer); + } + else { + enqueue(buffer); + } + } + + /** + * Store the given buffer. Emit buffers that cannot contain boundary bytes, + * by iterating over the queue in reverse order, and summing buffer sizes. + * The first buffer that passes the boundary length and subsequent buffers + * are emitted (in the correct, non-reverse order). + */ + private void enqueue(DataBuffer buf) { + this.queue.add(buf); + + int len = 0; + Deque emit = new ArrayDeque<>(); + for (Iterator iterator = this.queue.descendingIterator(); iterator.hasNext(); ) { + DataBuffer previous = iterator.next(); + if (len > this.boundaryLength) { + // addFirst to negate iterating in reverse order + emit.addFirst(previous); + iterator.remove(); + } + len += previous.readableByteCount(); + } + emit.forEach(buffer -> InternalParser.this.listener.onBody(buffer, false)); + } + + private void flush() { + for (Iterator iterator = this.queue.iterator(); iterator.hasNext(); ) { + DataBuffer buffer = iterator.next(); + boolean last = !iterator.hasNext(); + InternalParser.this.listener.onBody(buffer, last); + } + this.queue.clear(); + } + + @Override + public void complete() { + changeState(DisposedState.INSTANCE, null); + String msg = "Could not find end of body (␍␊--" + + new String(InternalParser.this.boundary, StandardCharsets.UTF_8) + + ")"; + InternalParser.this.listener.onError(new HttpMessageConversionException(msg)); + } + + @Override + public void dispose() { + this.queue.forEach(DataBufferUtils::release); + this.queue.clear(); + } + + @Override + public String toString() { + return "BODY"; + } + } + + /** + * The state of the parser when finished, either due to seeing the final + * boundary or to a malformed message. Releases all incoming buffers. + */ + private static final class DisposedState implements State { + + public static final DisposedState INSTANCE = new DisposedState(); + + private DisposedState() { + } + + @Override + public void data(DataBuffer buf) { + DataBufferUtils.release(buf); + } + + @Override + public void complete() { + } + + @Override + public String toString() { + return "DISPOSED"; + } + } + + } + + + /** + * Listen for part events while parsing the inbound stream of data. + */ + interface PartListener { + + /** + * Handle {@link HttpHeaders} for a part. + */ + void onHeaders(HttpHeaders headers); + + /** + * Handle a piece of data for a body part. + * @param buffer a chunk of body + * @param last whether this is the last chunk for the part + */ + void onBody(DataBuffer buffer, boolean last); + + /** + * Handle the completion event for the Multipart message. + */ + void onComplete(); + + /** + * Handle any error thrown during the parsing phase. + */ + void onError(Throwable error); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java new file mode 100644 index 00000000000..37a45a77862 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * Various static utility methods for dealing with multipart parsing. + * @author Arjen Poutsma + * @author Brian Clozel + */ +abstract class MultipartUtils { + + /** + * Return the character set of the given headers, as defined in the + * {@link HttpHeaders#getContentType()} header. + */ + static Charset charset(HttpHeaders headers) { + MediaType contentType = headers.getContentType(); + if (contentType != null) { + Charset charset = contentType.getCharset(); + if (charset != null) { + return charset; + } + } + return StandardCharsets.UTF_8; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java new file mode 100644 index 00000000000..a0a8f7fe83c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.http.HttpHeaders; + + +/** + * Representation for a part in a "multipart/form-data" request. + * + *

The origin of a multipart request may be a browser form in which case each + * part is either a {@link FormFieldPart} or a {@link FilePart}. + * + *

Multipart requests may also be used outside a browser for data of any + * content type (for example, JSON, PDF, etc). + * + * @author Brian Clozel + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + * @since 7.1 + * @see RFC 7578 (multipart/form-data) + * @see RFC 2183 (Content-Disposition) + * @see HTML5 (multipart forms) + */ +public interface Part { + + /** + * Return the name of the part in the multipart form. + * @return the name of the part, never {@code null} or empty + */ + String name(); + + /** + * Return the headers associated with the part. + */ + HttpHeaders headers(); + + /** + * Return the content for this part. + *

Note that for a {@link FormFieldPart} the content may be accessed + * more easily via {@link FormFieldPart#value()}. + */ + InputStream content() throws IOException; + + /** + * Delete the underlying storage for this part. + */ + default void delete() throws IOException { + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java new file mode 100644 index 00000000000..9eb43d05ca5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java @@ -0,0 +1,394 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Queue; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.util.FastByteArrayOutputStream; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * {@link MultipartParser.PartListener Listen} to a stream of part tokens + * and return a {@code MultiValueMap} as a result. + * + * @author Brian Clozel + * @author Arjen Poutsma + */ +final class PartGenerator implements MultipartParser.PartListener { + + private static final Log logger = LogFactory.getLog(PartGenerator.class); + + private final MultiValueMap parts = new LinkedMultiValueMap<>(); + + private final int maxInMemorySize; + + private final long maxDiskUsagePerPart; + + private final int maxParts; + + private final Path fileStorageDirectory; + + private int partCount; + + private State state; + + + PartGenerator(int maxInMemorySize, long maxDiskUsagePerPart, int maxParts, Path fileStorageDirectory) { + this.maxInMemorySize = maxInMemorySize; + this.maxDiskUsagePerPart = maxDiskUsagePerPart; + this.maxParts = maxParts; + this.fileStorageDirectory = fileStorageDirectory; + this.state = new InitialState(); + } + + /** + * Return the collected parts. + */ + public MultiValueMap getParts() { + return this.parts; + } + + @Override + public void onHeaders(HttpHeaders headers) { + if (isFormField(headers)) { + this.state = new FormFieldState(headers); + } + else { + this.state = new InMemoryState(headers); + } + } + + private static boolean isFormField(HttpHeaders headers) { + MediaType contentType = headers.getContentType(); + return (contentType == null || MediaType.TEXT_PLAIN.equalsTypeAndSubtype(contentType)) && + headers.getContentDisposition().getFilename() == null; + } + + @Override + public void onBody(DataBuffer buffer, boolean last) { + try { + this.state.onBody(buffer, last); + } + catch (Throwable ex) { + deleteParts(); + throw ex; + } + } + + void deleteParts() { + try { + for (List partList : this.parts.values()) { + for (Part part : partList) { + part.delete(); + } + } + } + catch (IOException ex) { + // ignored + } + } + + @Override + public void onComplete() { + if (logger.isTraceEnabled()) { + logger.trace("Finished reading " + this.partCount + " part(s)"); + } + } + + @Override + public void onError(Throwable error) { + deleteParts(); + throw new HttpMessageConversionException("Cannot decode multipart body", error); + } + + void addPart(Part part) { + if (this.maxParts != -1 && this.partCount == this.maxParts) { + throw new HttpMessageConversionException("Maximum number of parts exceeded: " + this.maxParts); + } + try { + this.partCount++; + this.parts.add(part.name(), part); + } + catch (Exception exc) { + throw new HttpMessageConversionException("Part #" + this.partCount + " is unnamed", exc); + } + } + + /** + * Represents the internal state of the {@link PartGenerator} for creating a single {@link Part}. + * {@link State} instances are stateful, and created when a new + * {@link MultipartParser.PartListener#onHeaders(HttpHeaders) headers instance} is accepted. + * The following rules determine which state the creator will have: + *

    + *
  1. If the part is a {@linkplain #isFormField(HttpHeaders) form field}, + * the creator will be in the {@link FormFieldState}.
  2. + *
  3. Otherwise, the creator will initially be in the + * {@link InMemoryState}, but will switch over to {@link FileState} + * when the part byte count exceeds {@link #maxInMemorySize}
  4. + *
+ */ + private interface State { + + /** + * Invoked when a {@link MultipartParser.PartListener#onBody(DataBuffer, boolean)} is received. + */ + void onBody(DataBuffer dataBuffer, boolean last); + + } + + /** + * The initial state of the creator. Throws an exception for {@link #onBody(DataBuffer, boolean)}. + */ + private static final class InitialState implements State { + + private InitialState() { + } + + @Override + public void onBody(DataBuffer dataBuffer, boolean last) { + DataBufferUtils.release(dataBuffer); + throw new HttpMessageConversionException("Body token not expected"); + } + + @Override + public String toString() { + return "INITIAL"; + } + } + + /** + * The creator state when a form field is received. + * Stores all body buffers in memory (up until {@link #maxInMemorySize}). + */ + private final class FormFieldState implements State { + + private final FastByteArrayOutputStream value = new FastByteArrayOutputStream(); + + private final HttpHeaders headers; + + public FormFieldState(HttpHeaders headers) { + this.headers = headers; + } + + @Override + public void onBody(DataBuffer dataBuffer, boolean last) { + int size = this.value.size() + dataBuffer.readableByteCount(); + if (PartGenerator.this.maxInMemorySize == -1 || + size < PartGenerator.this.maxInMemorySize) { + store(dataBuffer); + } + else { + DataBufferUtils.release(dataBuffer); + throw new HttpMessageConversionException("Form field value exceeded the memory usage limit of " + + PartGenerator.this.maxInMemorySize + " bytes"); + } + if (last) { + byte[] bytes = this.value.toByteArrayUnsafe(); + String value = new String(bytes, MultipartUtils.charset(this.headers)); + FormFieldPart formFieldPart = DefaultParts.formFieldPart(this.headers, value); + PartGenerator.this.addPart(formFieldPart); + } + } + + private void store(DataBuffer dataBuffer) { + try { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + this.value.write(bytes); + } + catch (IOException ex) { + throw new HttpMessageConversionException("Cannot store multipart body", ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + + @Override + public String toString() { + return "FORM-FIELD"; + } + } + + /** + * The creator state when not handling a form field. + * Stores all received buffers in a queue. + * If the byte count exceeds {@link #maxInMemorySize}, the creator state + * is changed to {@link FileState}. + */ + private final class InMemoryState implements State { + + private final Queue content = new ArrayDeque<>(); + + private long byteCount; + + private final HttpHeaders headers; + + + public InMemoryState(HttpHeaders headers) { + this.headers = headers; + } + + @Override + public void onBody(DataBuffer dataBuffer, boolean last) { + this.byteCount += dataBuffer.readableByteCount(); + if (PartGenerator.this.maxInMemorySize == -1 || + this.byteCount <= PartGenerator.this.maxInMemorySize) { + this.content.add(dataBuffer); + if (last) { + emitMemoryPart(); + } + } + else { + switchToFile(dataBuffer, last); + } + } + + private void switchToFile(DataBuffer current, boolean last) { + FileState newState = new FileState(this.headers, PartGenerator.this.fileStorageDirectory); + this.content.forEach(newState::writeBuffer); + newState.onBody(current, last); + PartGenerator.this.state = newState; + } + + private void emitMemoryPart() { + byte[] bytes = new byte[(int) this.byteCount]; + int idx = 0; + for (DataBuffer buffer : this.content) { + int len = buffer.readableByteCount(); + buffer.read(bytes, idx, len); + idx += len; + DataBufferUtils.release(buffer); + } + this.content.clear(); + DefaultDataBuffer content = DefaultDataBufferFactory.sharedInstance.wrap(bytes); + Part part = DefaultParts.part(this.headers, content); + PartGenerator.this.addPart(part); + } + + @Override + public String toString() { + return "IN-MEMORY"; + } + } + + /** + * The creator state when writing for a temporary file. + * {@link InMemoryState} initially switches to this state when the byte + * count exceeds {@link #maxInMemorySize}. + */ + private final class FileState implements State { + + private final HttpHeaders headers; + + private final Path file; + + private final OutputStream outputStream; + + private long byteCount; + + + public FileState(HttpHeaders headers, Path folder) { + this.headers = headers; + this.file = createFile(folder); + this.outputStream = createOutputStream(this.file); + } + + @Override + public void onBody(DataBuffer dataBuffer, boolean last) { + this.byteCount += dataBuffer.readableByteCount(); + if (PartGenerator.this.maxDiskUsagePerPart == -1 || this.byteCount <= PartGenerator.this.maxDiskUsagePerPart) { + writeBuffer(dataBuffer); + if (last) { + Part part = DefaultParts.part(this.headers, this.file); + PartGenerator.this.addPart(part); + } + } + else { + try { + this.outputStream.close(); + } + catch (IOException exc) { + // ignored + } + throw new HttpMessageConversionException("Part exceeded the disk usage limit of " + + PartGenerator.this.maxDiskUsagePerPart + " bytes"); + } + } + + private Path createFile(Path directory) { + try { + Path tempFile = Files.createTempFile(directory, null, ".multipart"); + if (logger.isTraceEnabled()) { + logger.trace("Storing multipart data in file " + tempFile); + } + return tempFile; + } + catch (IOException ex) { + throw new UncheckedIOException("Could not create temp file in " + directory, ex); + } + } + + private OutputStream createOutputStream(Path file) { + try { + return Files.newOutputStream(file, StandardOpenOption.WRITE); + } + catch (IOException ex) { + throw new UncheckedIOException("Could not write to temp file " + file, ex); + } + } + + private void writeBuffer(DataBuffer dataBuffer) { + try (InputStream in = dataBuffer.asInputStream()) { + in.transferTo(this.outputStream); + this.outputStream.flush(); + } + catch (IOException exc) { + throw new UncheckedIOException("Could not write to temp file ", exc); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + + @Override + public String toString() { + return "WRITE-FILE"; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java new file mode 100644 index 00000000000..158445f2c3e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java @@ -0,0 +1,7 @@ +/** + * Provides an HttpMessageConverter for Multipart support. + */ +@NullMarked +package org.springframework.http.converter.multipart; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java new file mode 100644 index 00000000000..f29024d6c78 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java @@ -0,0 +1,554 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; + +import org.apache.tomcat.util.http.fileupload.FileItem; +import org.apache.tomcat.util.http.fileupload.FileUpload; +import org.apache.tomcat.util.http.fileupload.RequestContext; +import org.apache.tomcat.util.http.fileupload.UploadContext; +import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.StreamingHttpOutputMessage; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.http.converter.ResourceHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.http.converter.xml.SourceHttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.testfixture.http.MockHttpInputMessage; +import org.springframework.web.testfixture.http.MockHttpOutputMessage; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; +import static org.springframework.http.MediaType.MULTIPART_MIXED; +import static org.springframework.http.MediaType.MULTIPART_RELATED; +import static org.springframework.http.MediaType.TEXT_XML; + + +/** + * Tests for {@link MultipartHttpMessageConverter}. + * + * @author Brian Clozel + * @author Arjen Poutsma + * @author Rossen Stoyanchev + * @author Sam Brannen + * @author Sebastien Deleuze + */ +class MultipartHttpMessageConverterTests { + + private MultipartHttpMessageConverter converter = new MultipartHttpMessageConverter( + List.of(new StringHttpMessageConverter(), new ByteArrayHttpMessageConverter(), + new ResourceHttpMessageConverter(), new JacksonJsonHttpMessageConverter()) + ); + + + @Test + void canRead() { + assertCanRead(MULTIPART_FORM_DATA); + assertCanRead(MULTIPART_MIXED); + assertCanRead(MULTIPART_RELATED); + assertCanRead(ResolvableType.forClass(LinkedMultiValueMap.class), MULTIPART_FORM_DATA); + assertCanRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Part.class), MULTIPART_FORM_DATA); + + assertCannotRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA); + } + + @Test + void canWrite() { + assertCanWrite(MULTIPART_FORM_DATA); + assertCanWrite(MULTIPART_MIXED); + assertCanWrite(MULTIPART_RELATED); + assertCanWrite(new MediaType("multipart", "form-data", UTF_8)); + assertCanWrite(MediaType.ALL); + assertCanWrite(null); + assertCanWrite(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA); + } + + @Test + void setSupportedMediaTypes() { + this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA)); + assertCannotWrite(MULTIPART_MIXED); + + this.converter.setSupportedMediaTypes(List.of(MULTIPART_MIXED)); + assertCanWrite(MULTIPART_MIXED); + } + + @Test + void addSupportedMediaTypes() { + this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA)); + assertCannotWrite(MULTIPART_MIXED); + + this.converter.addSupportedMediaTypes(MULTIPART_RELATED); + assertCanWrite(MULTIPART_RELATED); + } + + + private void assertCanRead(MediaType mediaType) { + assertCanRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), mediaType); + } + + private void assertCanRead(ResolvableType type, MediaType mediaType) { + assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isTrue(); + } + + private void assertCannotRead(ResolvableType type, MediaType mediaType) { + assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isFalse(); + } + + private void assertCanWrite(ResolvableType type, MediaType mediaType) { + assertThat(this.converter.canWrite(type, MultiValueMap.class, mediaType)).as(type + " : " + mediaType).isTrue(); + } + + private void assertCanWrite(MediaType mediaType) { + assertCanWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), mediaType); + } + + private void assertCannotWrite(MediaType mediaType) { + Class clazz = MultiValueMap.class; + assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse(); + } + + + @Nested + class ReadingTests { + + @Test + void readMultipartFiles() throws Exception { + MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); + MultiValueMap result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); + + assertThat(result).containsOnlyKeys("file2"); + assertThat(result.get("file2")).anyMatch(isFilePart("a.txt")) + .anyMatch(isFilePart("b.txt")); + } + + @Test + void readMultipartBrowser() throws Exception { + MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); + MultiValueMap result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); + + assertThat(result).containsOnlyKeys("file1", "file2", "text1", "text2"); + assertThat(result.get("file1")).anyMatch(isFilePart("a.txt")); + assertThat(result.get("file2")).anyMatch(isFilePart("a.txt")) + .anyMatch(isFilePart("b.txt")); + assertThat(result.get("text1")).anyMatch(isFormData("text1", "a")); + assertThat(result.get("text2")).anyMatch(isFormData("text2", "b")); + } + + @Test + void readMultipartInvalid() throws Exception { + MockHttpInputMessage response = createMultipartResponse("garbage-1.multipart", "boundary"); + assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) + .isInstanceOf(HttpMessageConversionException.class).hasMessage("Cannot decode multipart body"); + } + + @Test + void readMultipartMaxPartsExceeded() throws Exception { + MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); + converter.setMaxParts(1); + assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) + .isInstanceOf(HttpMessageConversionException.class).hasMessage("Maximum number of parts exceeded: 1"); + } + + @Test + void readMultipartToFiles() throws Exception { + MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); + converter.setMaxInMemorySize(1); + MultiValueMap result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); + assertThat(result).containsOnlyKeys("file2"); + } + + @Test + void readMultipartMaxInMemoryExceeded() throws Exception { + MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); + converter.setMaxInMemorySize(1); + assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) + .isInstanceOf(HttpMessageConversionException.class).hasMessage("Form field value exceeded the memory usage limit of 1 bytes"); + } + + @Test + void readMultipartMaxDiskUsageExceeded() throws Exception { + MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); + converter.setMaxInMemorySize(30); + converter.setMaxDiskUsagePerPart(35); + assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) + .isInstanceOf(HttpMessageConversionException.class).hasMessage("Part exceeded the disk usage limit of 35 bytes"); + } + + @Test + void readMultipartUnnamedPart() throws Exception { + MockHttpInputMessage response = createMultipartResponse("simple.multipart", "simple-boundary"); + assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) + .isInstanceOf(HttpMessageConversionException.class).hasMessage("Part #1 is unnamed"); + } + + + private MockHttpInputMessage createMultipartResponse(String fileName, String boundary) throws Exception { + InputStream stream = createStream(fileName); + MockHttpInputMessage response = new MockHttpInputMessage(stream); + response.getHeaders().setContentType( + new MediaType("multipart", "form-data", singletonMap("boundary", boundary))); + return response; + } + + private InputStream createStream(String fileName) throws IOException { + Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName); + return resource.getInputStream(); + } + + private Predicate isFilePart(String fileName) { + return part -> part instanceof FilePart filePart && + filePart.filename().equals(fileName); + } + + private Predicate isFormData(String name, String value) { + return part -> part instanceof FormFieldPart formFieldPart && + formFieldPart.name().equals(name) && + formFieldPart.value().equals(value); + } + + } + + @Nested + class WritingTests { + + @Test + void writeMultipart() throws Exception { + MultiValueMap parts = new LinkedMultiValueMap<>(); + parts.add("name 1", "value 1"); + parts.add("name 2", "value 2+1"); + parts.add("name 2", "value 2+2"); + parts.add("name 3", null); + + Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); + parts.add("logo", logo); + + // SPR-12108 + Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { + @Override + public String getFilename() { + return "Hall\u00F6le.jpg"; + } + }; + parts.add("utf8", utf8); + + MyBean myBean = new MyBean(); + myBean.setString("foo"); + HttpHeaders entityHeaders = new HttpHeaders(); + entityHeaders.setContentType(APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(myBean, entityHeaders); + parts.add("json", entity); + + Map parameters = new LinkedHashMap<>(2); + parameters.put("charset", UTF_8.name()); + parameters.put("foo", "bar"); + + StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage(); + converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage); + + final MediaType contentType = outputMessage.getHeaders().getContentType(); + assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839 + + // see if Commons FileUpload can read what we wrote + FileUpload fileUpload = new FileUpload(); + fileUpload.setFileItemFactory(new DiskFileItemFactory()); + RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); + List items = fileUpload.parseRequest(requestContext); + assertThat(items).hasSize(6); + FileItem item = items.get(0); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 1"); + assertThat(item.getString()).isEqualTo("value 1"); + + item = items.get(1); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 2"); + assertThat(item.getString()).isEqualTo("value 2+1"); + + item = items.get(2); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 2"); + assertThat(item.getString()).isEqualTo("value 2+2"); + + item = items.get(3); + assertThat(item.isFormField()).isFalse(); + assertThat(item.getFieldName()).isEqualTo("logo"); + assertThat(item.getName()).isEqualTo("logo.jpg"); + assertThat(item.getContentType()).isEqualTo("image/jpeg"); + assertThat(item.getSize()).isEqualTo(logo.getFile().length()); + + item = items.get(4); + assertThat(item.isFormField()).isFalse(); + assertThat(item.getFieldName()).isEqualTo("utf8"); + assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg"); + assertThat(item.getContentType()).isEqualTo("image/jpeg"); + assertThat(item.getSize()).isEqualTo(logo.getFile().length()); + + item = items.get(5); + assertThat(item.getFieldName()).isEqualTo("json"); + assertThat(item.getContentType()).isEqualTo("application/json"); + + assertThat(outputMessage.wasRepeatable()).isTrue(); + } + + @Test + void writeMultipartWithSourceHttpMessageConverter() throws Exception { + + converter = new MultipartHttpMessageConverter(List.of( + new StringHttpMessageConverter(), + new ResourceHttpMessageConverter(), + new SourceHttpMessageConverter<>())); + + MultiValueMap parts = new LinkedMultiValueMap<>(); + parts.add("name 1", "value 1"); + parts.add("name 2", "value 2+1"); + parts.add("name 2", "value 2+2"); + parts.add("name 3", null); + + Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); + parts.add("logo", logo); + + // SPR-12108 + Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { + @Override + public String getFilename() { + return "Hall\u00F6le.jpg"; + } + }; + parts.add("utf8", utf8); + + Source xml = new StreamSource(new StringReader("")); + HttpHeaders entityHeaders = new HttpHeaders(); + entityHeaders.setContentType(TEXT_XML); + HttpEntity entity = new HttpEntity<>(xml, entityHeaders); + parts.add("xml", entity); + + Map parameters = new LinkedHashMap<>(2); + parameters.put("charset", UTF_8.name()); + parameters.put("foo", "bar"); + + StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage(); + converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage); + + final MediaType contentType = outputMessage.getHeaders().getContentType(); + assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839 + + // see if Commons FileUpload can read what we wrote + FileUpload fileUpload = new FileUpload(); + fileUpload.setFileItemFactory(new DiskFileItemFactory()); + RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); + List items = fileUpload.parseRequest(requestContext); + assertThat(items).hasSize(6); + FileItem item = items.get(0); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 1"); + assertThat(item.getString()).isEqualTo("value 1"); + + item = items.get(1); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 2"); + assertThat(item.getString()).isEqualTo("value 2+1"); + + item = items.get(2); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("name 2"); + assertThat(item.getString()).isEqualTo("value 2+2"); + + item = items.get(3); + assertThat(item.isFormField()).isFalse(); + assertThat(item.getFieldName()).isEqualTo("logo"); + assertThat(item.getName()).isEqualTo("logo.jpg"); + assertThat(item.getContentType()).isEqualTo("image/jpeg"); + assertThat(item.getSize()).isEqualTo(logo.getFile().length()); + + item = items.get(4); + assertThat(item.isFormField()).isFalse(); + assertThat(item.getFieldName()).isEqualTo("utf8"); + assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg"); + assertThat(item.getContentType()).isEqualTo("image/jpeg"); + assertThat(item.getSize()).isEqualTo(logo.getFile().length()); + + item = items.get(5); + assertThat(item.getFieldName()).isEqualTo("xml"); + assertThat(item.getContentType()).isEqualTo("text/xml"); + + assertThat(outputMessage.wasRepeatable()).isFalse(); + } + + @Test // SPR-13309 + void writeMultipartOrder() throws Exception { + MyBean myBean = new MyBean(); + myBean.setString("foo"); + + MultiValueMap parts = new LinkedMultiValueMap<>(); + parts.add("part1", myBean); + + HttpHeaders entityHeaders = new HttpHeaders(); + entityHeaders.setContentType(APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(myBean, entityHeaders); + parts.add("part2", entity); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.setMultipartCharset(UTF_8); + converter.write(parts, new MediaType("multipart", "form-data", UTF_8), outputMessage); + + final MediaType contentType = outputMessage.getHeaders().getContentType(); + assertThat(contentType.getParameter("boundary")).as("No boundary found").isNotNull(); + + // see if Commons FileUpload can read what we wrote + FileUpload fileUpload = new FileUpload(); + fileUpload.setFileItemFactory(new DiskFileItemFactory()); + RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); + List items = fileUpload.parseRequest(requestContext); + assertThat(items).hasSize(2); + + FileItem item = items.get(0); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("part1"); + assertThat(item.getString()).isEqualTo("{\"string\":\"foo\"}"); + + item = items.get(1); + assertThat(item.isFormField()).isTrue(); + assertThat(item.getFieldName()).isEqualTo("part2"); + + assertThat(item.getString()) + .contains("{\"string\":\"foo\"}"); + } + + @Test + void writeMultipartCharset() throws Exception { + MultiValueMap parts = new LinkedMultiValueMap<>(); + Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); + parts.add("logo", logo); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + converter.write(parts, MULTIPART_FORM_DATA, outputMessage); + + MediaType contentType = outputMessage.getHeaders().getContentType(); + Map parameters = contentType.getParameters(); + assertThat(parameters).containsOnlyKeys("boundary"); + + converter.setCharset(StandardCharsets.ISO_8859_1); + + outputMessage = new MockHttpOutputMessage(); + converter.write(parts, MULTIPART_FORM_DATA, outputMessage); + + parameters = outputMessage.getHeaders().getContentType().getParameters(); + assertThat(parameters).containsOnlyKeys("boundary", "charset"); + assertThat(parameters).containsEntry("charset", "ISO-8859-1"); + } + + } + + + private static class StreamingMockHttpOutputMessage extends MockHttpOutputMessage implements StreamingHttpOutputMessage { + + private boolean repeatable; + + public boolean wasRepeatable() { + return this.repeatable; + } + + @Override + public void setBody(Body body) { + try { + this.repeatable = body.repeatable(); + body.writeTo(getBody()); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + } + + + private static class MockHttpOutputMessageRequestContext implements UploadContext { + + private final MockHttpOutputMessage outputMessage; + + private final byte[] body; + + private MockHttpOutputMessageRequestContext(MockHttpOutputMessage outputMessage) { + this.outputMessage = outputMessage; + this.body = this.outputMessage.getBodyAsBytes(); + } + + @Override + public String getCharacterEncoding() { + MediaType type = this.outputMessage.getHeaders().getContentType(); + return (type != null && type.getCharset() != null ? type.getCharset().name() : null); + } + + @Override + public String getContentType() { + MediaType type = this.outputMessage.getHeaders().getContentType(); + return (type != null ? type.toString() : null); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(body); + } + + @Override + public long contentLength() { + return body.length; + } + } + + + public static class MyBean { + + private String string; + + public String getString() { + return this.string; + } + + public void setString(String string) { + this.string = string; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java new file mode 100644 index 00000000000..2110e9ba39b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java @@ -0,0 +1,282 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.multipart; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.function.Consumer; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConversionException; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MultipartParser}. + * + * @author Brian Clozel + * @author Arjen Poutsma + */ +class MultipartParserTests { + + private static final MediaType TEXT_PLAIN_ASCII = new MediaType("text", "plain", StandardCharsets.US_ASCII); + + @Test + void simple() throws Exception { + TestListener listener = new TestListener(); + parse("simple.multipart", "simple-boundary", listener); + + listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue()) + .assertBodyChunk("This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.") + .assertHeader(headers -> assertThat(headers.getContentType()).isEqualTo(TEXT_PLAIN_ASCII)) + .assertBodyChunk("This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n") + .assertComplete(); + } + + @Test + void noHeaders() throws Exception { + TestListener listener = new TestListener(); + parse("no-header.multipart", "boundary", listener); + + listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue()) + .assertBodyChunk("a") + .assertComplete(); + } + + @Test + void noEndBoundary() throws Exception { + TestListener listener = new TestListener(); + parse("no-end-boundary.multipart", "boundary", listener); + + assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); + } + + @Test + void garbage() throws Exception { + TestListener listener = new TestListener(); + parse("garbage-1.multipart", "boundary", listener); + + assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); + } + + @Test + void noEndHeader() throws Exception { + TestListener listener = new TestListener(); + parse("no-end-header.multipart", "boundary", listener); + + assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); + } + + @Test + void noEndBody() throws Exception { + TestListener listener = new TestListener(); + parse("no-end-body.multipart", "boundary", listener); + + assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); + } + + @Test + void noBody() throws Exception { + TestListener listener = new TestListener(); + parse("no-body.multipart", "boundary", listener); + + listener.assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("1"))).isTrue()) + .assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("2"))).isTrue()) + .assertBodyChunk("a") + .assertComplete(); + } + + @Test + void firefox() throws Exception { + TestListener listener = new TestListener(); + parse("firefox.multipart", + "---------------------------18399284482060392383840973206", listener); + + listener.assertHeadersFormField("text1") + .assertBodyChunk("a") + .assertHeadersFormField("text2") + .assertBodyChunk("b") + .assertHeadersFile("file1", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "b.txt") + .assertBodyChunk() + .assertComplete(); + } + + @Test + void chrome() throws Exception { + TestListener listener = new TestListener(); + parse("chrome.multipart", + "----WebKitFormBoundaryEveBLvRT65n21fwU", listener); + + listener.assertHeadersFormField("text1") + .assertBodyChunk("a") + .assertHeadersFormField("text2") + .assertBodyChunk("b") + .assertHeadersFile("file1", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "b.txt") + .assertBodyChunk() + .assertComplete(); + } + + @Test + void safari() throws Exception { + TestListener listener = new TestListener(); + parse("safari.multipart", + "----WebKitFormBoundaryG8fJ50opQOML0oGD", listener); + + listener.assertHeadersFormField("text1") + .assertBodyChunk("a") + .assertHeadersFormField("text2") + .assertBodyChunk("b") + .assertHeadersFile("file1", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "a.txt") + .assertBodyChunk() + .assertHeadersFile("file2", "b.txt") + .assertBodyChunk() + .assertComplete(); + } + + @Test + void utf8Headers() throws Exception { + TestListener listener = new TestListener(); + parse("utf8.multipart", "simple-boundary", listener); + + listener.assertHeader(headers -> + assertThat(headers.hasHeaderValues("Føø", List.of("Bår"))).isTrue()) + .assertBodyChunk("This is plain ASCII text.") + .assertComplete(); + } + + private InputStream createStream(String fileName) throws IOException { + Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName); + return resource.getInputStream(); + } + + private void parse(String fileName, String boundary, MultipartParser.PartListener listener) throws Exception { + try (InputStream input = createStream(fileName)) { + MultipartParser multipartParser = new MultipartParser(10 * 1024, 4 * 1024); + multipartParser.parse(input, boundary.getBytes(UTF_8), StandardCharsets.UTF_8, listener); + } + } + + + static class TestListener implements MultipartParser.PartListener { + + Deque received = new ArrayDeque<>(); + + boolean complete; + + Throwable error; + + @Override + public void onHeaders(@NonNull HttpHeaders headers) { + this.received.add(headers); + } + + @Override + public void onBody(@NonNull DataBuffer buffer, boolean last) { + this.received.add(buffer); + } + + @Override + public void onComplete() { + this.complete = true; + } + + @Override + public void onError(@NonNull Throwable error) { + this.error = error; + } + + TestListener assertHeader(Consumer headersConsumer) { + Object value = received.pollFirst(); + assertThat(value).isInstanceOf(HttpHeaders.class); + headersConsumer.accept((HttpHeaders) value); + return this; + } + + TestListener assertHeadersFormField(String expectedName) { + return assertHeader(headers -> { + ContentDisposition cd = headers.getContentDisposition(); + assertThat(cd.isFormData()).isTrue(); + assertThat(cd.getName()).isEqualTo(expectedName); + }); + } + + TestListener assertHeadersFile(String expectedName, String expectedFilename) { + return assertHeader(headers -> { + ContentDisposition cd = headers.getContentDisposition(); + assertThat(cd.isFormData()).isTrue(); + assertThat(cd.getName()).isEqualTo(expectedName); + assertThat(cd.getFilename()).isEqualTo(expectedFilename); + }); + } + + TestListener assertBodyChunk(Consumer bodyConsumer) { + Object value = received.pollFirst(); + assertThat(value).isInstanceOf(DataBuffer.class); + bodyConsumer.accept((DataBuffer) value); + DataBufferUtils.release((DataBuffer) value); + return this; + } + + TestListener assertBodyChunk(String bodyContent) { + return assertBodyChunk(buffer -> { + String actual = buffer.toString(UTF_8); + assertThat(actual).isEqualTo(bodyContent); + }); + } + + TestListener assertBodyChunk() { + return assertBodyChunk(buffer -> { + }); + } + + TestListener assertLastBodyChunk() { + if (!received.isEmpty()) { + assertThat(received.peek()).isNotInstanceOf(DataBuffer.class); + } + return this; + } + + void assertComplete() { + assertThat(this.complete).isTrue(); + } + } + +}