This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 833220346 WW-5474 Count files only for maxFiles, add maxParameterCount
(#1806)
833220346 is described below
commit 833220346ceb32ee44533da715ab3b59af05c16f
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 26 04:57:19 2026 +0200
WW-5474 Count files only for maxFiles, add maxParameterCount (#1806)
* WW-5474 docs(multipart): design for files-only maxFiles +
maxParameterCount
Spec for correcting struts.multipart.maxFiles to count file parts only
(consistently across the jakarta and jakarta-stream parsers) and adding
struts.multipart.maxParameterCount to cap non-file form fields, restoring
the DoS guard the old accidental total-part cap provided. Fail-closed on
breach.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 docs(multipart): implementation plan for
maxFiles/maxParameterCount
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): count files only for maxFiles, add
maxParameterCount (jakarta)
The jakarta parser passed maxFiles to commons-fileupload2 setMaxFileCount,
which counts every part (fields + files), so maxFiles wrongly limited total
parameters. Enforce a files-only count and non-file field count in Struts,
failing closed on breach; keep a total-parts commons backstop.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): honor -1 unlimited sentinel in total-parts
backstop
prepareServletFileUpload applied the total-parts backstop whenever both
maxFiles and maxParameterCount were non-null, without checking for the
-1 "unlimited" sentinel already honored by
enforceMaxFiles/enforceMaxParameterCount.
With maxFiles=-1 and maxParameterCount=256, maxParts computed to 255 and
was passed to commons-fileupload2's setMaxFileCount (which counts ALL
parts), wrongly rejecting large file-only uploads. Only apply the
backstop when both limits are finite (non-null and >= 0).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): apply files-only maxFiles + maxParameterCount to
stream parser
Replace the field-name-based exceedsMaxFiles with the shared files-only
enforcement and add parameter-count enforcement, matching the jakarta parser
and failing closed on breach.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): track all parsed items to avoid temp-file leak on
fail-closed breach
servletFileUpload.parseRequest() fully materializes every part - spilling
large ones to disk - before processUpload() iterates over the result. The
loop only added each DiskFileItem to diskFileItems as it was reached, so
when enforceMaxFiles/enforceMaxParameterCount threw mid-loop on a breach,
every item positioned after the breaching one was never registered for
cleanup. With no FileCleaningTracker on the factory, cleanUp() had no way
to reclaim those temp files, leaking disk space on the hardening path.
Materialize the parsed list once and register all items for cleanup
before processing so cleanUp() reclaims every temp file regardless of
where enforcement aborts.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): guard debug logging in enforce helpers (Sonar
S2629)
Wrap the LOG.debug calls in enforceMaxFiles/enforceMaxParameterCount with
isDebugEnabled() so normalizeSpace() is not evaluated when debug is
disabled,
matching the exceedsMaxStringLength pattern in the same class.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): address Copilot review - parser parity + overflow
guard
- JakartaMultiPartRequest: only count/enforce a file part toward maxFiles
when it
has a non-null field name, matching JakartaStreamMultiPartRequest's
accept criteria
(defensive: commons-fileupload2 already drops parts without a name
attribute before
parseRequest returns, so the two parsers stay consistent regardless).
- AbstractMultiPartRequest: compute the total-parts backstop with
Math.addExact and
clamp to Long.MAX_VALUE on overflow, so extremely large configured limits
cannot
wrap negative and silently disable the commons backstop.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* WW-5474 fix(multipart): log "processing a form field" only for form fields
Move the debug log into the isFormField branch so file parts are not
mislabelled; the file branch already logs "Processing a file".
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../java/org/apache/struts2/StrutsConstants.java | 5 +
.../multipart/AbstractMultiPartRequest.java | 86 ++-
.../FileUploadParameterCountLimitException.java | 45 ++
.../multipart/JakartaMultiPartRequest.java | 27 +-
.../multipart/JakartaStreamMultiPartRequest.java | 34 +-
.../org/apache/struts2/default.properties | 3 +
.../org/apache/struts2/struts-messages.properties | 4 +
.../multipart/AbstractMultiPartRequestTest.java | 6 +
.../multipart/JakartaMultiPartRequestTest.java | 138 +++++
.../JakartaStreamMultiPartRequestTest.java | 63 ++-
...6-07-22-WW-5474-multipart-maxfiles-semantics.md | 594 +++++++++++++++++++++
...-WW-5474-multipart-maxfiles-semantics-design.md | 100 ++++
12 files changed, 1064 insertions(+), 41 deletions(-)
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index e76fac4e5..72f04c859 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -226,6 +226,11 @@ public final class StrutsConstants {
*/
public static final String STRUTS_MULTIPART_MAX_FILES =
"struts.multipart.maxFiles";
+ /**
+ * The maximum number of non-file form fields (parameters) allowed in a
multipart request.
+ */
+ public static final String STRUTS_MULTIPART_MAX_PARAMETER_COUNT =
"struts.multipart.maxParameterCount";
+
/**
* The maximum length of a string parameter in a multipart request.
*/
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
index cb5c6b9ec..5bd008593 100644
---
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
@@ -93,6 +93,11 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
*/
protected Long maxFiles;
+ /**
+ * Specifies the maximum number of non-file form fields (parameters) in
one request.
+ */
+ protected Long maxParameterCount;
+
/**
* Specifies the maximum length of a string parameter in a multipart
request.
*/
@@ -160,6 +165,14 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
this.maxFiles = Long.parseLong(maxFiles);
}
+ /**
+ * @param maxParameterCount Injects the Struts maximum number of non-file
form fields.
+ */
+ @Inject(StrutsConstants.STRUTS_MULTIPART_MAX_PARAMETER_COUNT)
+ public void setMaxParameterCount(String maxParameterCount) {
+ this.maxParameterCount = Long.parseLong(maxParameterCount);
+ }
+
/**
* @param maxFileSize Injects the Struts maximum number of files, which
can be uploaded.
*/
@@ -226,9 +239,17 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
LOG.debug("Applies max size: {} to file upload request", maxSize);
servletFileUpload.setMaxSize(maxSize);
}
- if (maxFiles != null) {
- LOG.debug("Applies max files number: {} to file upload request",
maxFiles);
- servletFileUpload.setMaxFileCount(maxFiles);
+ if (maxFiles != null && maxFiles >= 0 && maxParameterCount != null &&
maxParameterCount >= 0) {
+ // Clamp on overflow: a wrapped-negative sum would silently
disable the backstop
+ // (commons-fileupload2 treats a negative count as "unlimited").
+ long maxParts;
+ try {
+ maxParts = Math.addExact(maxFiles, maxParameterCount);
+ } catch (ArithmeticException overflow) {
+ maxParts = Long.MAX_VALUE;
+ }
+ LOG.debug("Applies total parts backstop: {} to file upload
request", maxParts);
+ servletFileUpload.setMaxFileCount(maxParts);
}
if (maxFileSize != null) {
LOG.debug("Applies max size of single file: {} to file upload
request", maxFileSize);
@@ -298,6 +319,44 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
return false;
}
+ /**
+ * Fail-closed guard: throws when accepting another file would exceed
{@link #maxFiles}.
+ * A negative {@link #maxFiles} means "no limit", matching
commons-fileupload2's own
+ * {@code fileCountMax = -1} convention (see {@code
AbstractFileUpload.setFileCountMax}).
+ *
+ * @param currentFileCount number of files already accepted in this request
+ * @param fileName name of the file being considered (for logging)
+ */
+ protected void enforceMaxFiles(int currentFileCount, String fileName)
throws FileUploadFileCountLimitException {
+ if (maxFiles != null && maxFiles >= 0 && currentFileCount >= maxFiles)
{
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Cannot accept another file: {} as it would exceed
max files: {}", normalizeSpace(fileName), maxFiles);
+ }
+ throw new FileUploadFileCountLimitException(
+ String.format("Request exceeds allowed number of files,
permitted: %s", maxFiles),
+ maxFiles, currentFileCount + 1L);
+ }
+ }
+
+ /**
+ * Fail-closed guard: throws when accepting another form field would
exceed {@link #maxParameterCount}.
+ * A negative {@link #maxParameterCount} means "no limit", matching the
same convention as
+ * {@link #maxFiles}.
+ *
+ * @param currentParameterCount number of form fields already accepted in
this request
+ * @param fieldName name of the field being considered (for
logging)
+ */
+ protected void enforceMaxParameterCount(int currentParameterCount, String
fieldName) throws FileUploadParameterCountLimitException {
+ if (maxParameterCount != null && maxParameterCount >= 0 &&
currentParameterCount >= maxParameterCount) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Cannot accept another parameter: {} as it would
exceed max parameter count: {}", normalizeSpace(fieldName), maxParameterCount);
+ }
+ throw new FileUploadParameterCountLimitException(
+ String.format("Request exceeds allowed number of
parameters, permitted: %s", maxParameterCount),
+ maxParameterCount, currentParameterCount + 1L);
+ }
+ }
+
/**
* Processes the upload.
*
@@ -324,10 +383,14 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
} else if (e instanceof FileUploadContentTypeException ex) {
exClass = ex.getClass();
args = new Object[]{ex.getContentType()};
+ } else if (e instanceof FileUploadParameterCountLimitException ex)
{
+ exClass = ex.getClass();
+ args = new Object[]{ex.getPermitted(), ex.getActual()};
}
LocalizedMessage errorMessage = buildErrorMessage(exClass,
e.getMessage(), args);
addErrorIfAbsent(errorMessage);
+ clearCollectedData();
} catch (IOException e) {
LOG.warn("Unable to parse request", e);
LocalizedMessage errorMessage = buildErrorMessage(e.getClass(),
e.getMessage(), new Object[]{});
@@ -341,6 +404,23 @@ public abstract class AbstractMultiPartRequest implements
MultiPartRequest {
}
}
+ /**
+ * Fail-closed: discards everything collected so far so a rejected request
exposes
+ * no partial parameters or files to the action. Deletes partial upload
files first
+ * to avoid leaking temporary files.
+ */
+ private void clearCollectedData() {
+ for (List<UploadedFile> files : uploadedFiles.values()) {
+ for (UploadedFile file : files) {
+ if (file.isFile() && !file.delete()) {
+ LOG.warn("Could not delete partial upload file: {}",
file.getName());
+ }
+ }
+ }
+ uploadedFiles.clear();
+ parameters.clear();
+ }
+
/**
* Build error message.
*
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java
new file mode 100644
index 000000000..f6f87b996
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.struts2.dispatcher.multipart;
+
+import org.apache.commons.fileupload2.core.FileUploadException;
+
+/**
+ * Thrown when a multipart request contains more non-file form fields
(parameters)
+ * than allowed by {@code struts.multipart.maxParameterCount}.
+ */
+public class FileUploadParameterCountLimitException extends
FileUploadException {
+
+ private final long permitted;
+ private final long actual;
+
+ public FileUploadParameterCountLimitException(final String message, final
long permitted, final long actual) {
+ super(message);
+ this.permitted = permitted;
+ this.actual = actual;
+ }
+
+ public long getPermitted() {
+ return permitted;
+ }
+
+ public long getActual() {
+ return actual;
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
index 7177aaaea..d5d29cb99 100644
---
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
@@ -108,18 +108,31 @@ public class JakartaMultiPartRequest extends
AbstractMultiPartRequest {
RequestContext requestContext = createRequestContext(request);
- for (DiskFileItem item :
servletFileUpload.parseRequest(requestContext)) {
- // Track all DiskFileItem instances for cleanup - this is critical
for security
- // as it ensures temporary files are properly cleaned up even if
processing fails
- diskFileItems.add(item);
-
- LOG.debug(() -> "Processing a form field: " +
normalizeSpace(item.getFieldName()));
+ int fileCount = 0;
+ int parameterCount = 0;
+ // parseRequest() fully materializes every part (spilling large ones
to disk) before we
+ // iterate, so register them all for cleanup up front - otherwise a
fail-closed breach
+ // mid-loop would leak the temp files of every part after the
breaching one.
+ List<DiskFileItem> items =
servletFileUpload.parseRequest(requestContext);
+ diskFileItems.addAll(items);
+ for (DiskFileItem item : items) {
if (item.isFormField()) {
+ LOG.debug(() -> "Processing a form field: " +
normalizeSpace(item.getFieldName()));
// Process regular form fields (text inputs, checkboxes, etc.)
+ if (item.getFieldName() != null) {
+ enforceMaxParameterCount(parameterCount,
item.getFieldName());
+ parameterCount++;
+ }
processNormalFormField(item, charset);
} else {
- // Process file upload fields
+ // Process file upload fields (only count parts that would
actually be accepted:
+ // a real filename AND a non-null field name, matching
JakartaStreamMultiPartRequest
+ // so both parsers enforce maxFiles identically on malformed
parts).
LOG.debug(() -> "Processing a file: " +
normalizeSpace(item.getFieldName()));
+ if (item.getName() != null && !item.getName().trim().isEmpty()
&& item.getFieldName() != null) {
+ enforceMaxFiles(fileCount, item.getName());
+ fileCount++;
+ }
processFileField(item, saveDir);
}
}
diff --git
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java
index a318f0511..62ab294d8 100644
---
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java
@@ -20,7 +20,6 @@ package org.apache.struts2.dispatcher.multipart;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.fileupload2.core.FileItemInput;
-import org.apache.commons.fileupload2.core.FileUploadFileCountLimitException;
import org.apache.commons.fileupload2.core.FileUploadSizeException;
import
org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload;
import org.apache.logging.log4j.LogManager;
@@ -54,6 +53,9 @@ public class JakartaStreamMultiPartRequest extends
AbstractMultiPartRequest {
private static final Logger LOG =
LogManager.getLogger(JakartaStreamMultiPartRequest.class);
+ private int fileCount;
+ private int parameterCount;
+
/**
* Processes the upload.
*
@@ -64,6 +66,8 @@ public class JakartaStreamMultiPartRequest extends
AbstractMultiPartRequest {
protected void processUpload(HttpServletRequest request, String saveDir)
throws IOException {
Charset charset = readCharsetEncoding(request);
Path location = Path.of(saveDir);
+ fileCount = 0;
+ parameterCount = 0;
JakartaServletDiskFileUpload servletFileUpload =
prepareServletFileUpload(charset, location);
@@ -130,6 +134,9 @@ public class JakartaStreamMultiPartRequest extends
AbstractMultiPartRequest {
return;
}
+ enforceMaxParameterCount(parameterCount, fieldName);
+ parameterCount++;
+
String fieldValue = readStream(fileItemInput.getInputStream());
if (exceedsMaxStringLength(fieldName, fieldValue)) {
return;
@@ -148,26 +155,6 @@ public class JakartaStreamMultiPartRequest extends
AbstractMultiPartRequest {
.reduce(0L, Long::sum);
}
- private boolean exceedsMaxFiles(FileItemInput fileItemInput) {
- if (maxFiles != null && maxFiles == uploadedFiles.size()) {
- if (LOG.isDebugEnabled()) {
- LOG.debug("Cannot accept another file: {} as it will exceed
max files: {}",
- normalizeSpace(fileItemInput.getName()), maxFiles);
- }
- LocalizedMessage errorMessage = buildErrorMessage(
- FileUploadFileCountLimitException.class,
- String.format("File %s exceeds allowed maximum number of
files %s",
- fileItemInput.getName(), maxFiles),
- new Object[]{maxFiles, uploadedFiles.size()}
- );
- if (!errors.contains(errorMessage)) {
- errors.add(errorMessage);
- }
- return true;
- }
- return false;
- }
-
private void exceedsMaxSizeOfFiles(FileItemInput fileItemInput, File file,
Long currentFilesSize) {
if (LOG.isDebugEnabled()) {
LOG.debug("File: {} of size: {} exceeds allowed max size: {},
actual size of already uploaded files: {}",
@@ -226,9 +213,8 @@ public class JakartaStreamMultiPartRequest extends
AbstractMultiPartRequest {
return;
}
- if (exceedsMaxFiles(fileItemInput)) {
- return;
- }
+ enforceMaxFiles(fileCount, fileItemInput.getName());
+ fileCount++;
File file = createTemporaryFile(fileItemInput.getName(), location);
streamFileToDisk(fileItemInput, file);
diff --git a/core/src/main/resources/org/apache/struts2/default.properties
b/core/src/main/resources/org/apache/struts2/default.properties
index 1c501cb33..f742c0798 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -67,7 +67,10 @@ struts.multipart.parser=jakarta
### Uses jakarta.servlet.context.tempdir by default
struts.multipart.saveDir=
struts.multipart.maxSize=2097152
+# Maximum number of uploaded files (files only, not form fields)
struts.multipart.maxFiles=256
+# Maximum number of non-file form fields (parameters)
+struts.multipart.maxParameterCount=256
struts.multipart.maxStringLength=4096
# struts.multipart.maxFileSize=
diff --git
a/core/src/main/resources/org/apache/struts2/struts-messages.properties
b/core/src/main/resources/org/apache/struts2/struts-messages.properties
index 2e2eb30f9..63514b000 100644
--- a/core/src/main/resources/org/apache/struts2/struts-messages.properties
+++ b/core/src/main/resources/org/apache/struts2/struts-messages.properties
@@ -63,6 +63,10 @@
struts.messages.upload.error.FileUploadByteCountLimitException=File {1} assigned
# 0 - limit
struts.messages.upload.error.FileUploadFileCountLimitException=Request
exceeded allowed number of files! Permitted number of files is: {0}!
+# FileUploadParameterCountLimitException
+# 0 - limit
+struts.messages.upload.error.FileUploadParameterCountLimitException=Request
exceeded allowed number of parameters! Permitted number of parameters is: {0}!
+
# FileUploadSizeException
# 1 - permitted size
# 2 - actual size
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java
index c20b72b6d..3ef3550be 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java
@@ -389,6 +389,12 @@ abstract class AbstractMultiPartRequestTest {
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
}
+ @Test
+ public void maxParameterCountSetterStoresValue() {
+ multiPart.setMaxParameterCount("42");
+ assertThat(multiPart.maxParameterCount).isEqualTo(42L);
+ }
+
@Test
public void maxStringLength() throws IOException {
String content = formFile("file1", "test1.csv", "1,2,3,4") +
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
index 05af17a70..99fe60931 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
@@ -27,6 +27,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import static org.apache.commons.lang3.StringUtils.normalizeSpace;
import static org.assertj.core.api.Assertions.assertThat;
@@ -246,6 +247,74 @@ public class JakartaMultiPartRequestTest extends
AbstractMultiPartRequestTest {
assertThat(multiPartRequest.getErrors()).hasSize(1);
}
+ @Test
+ public void manyFormFieldsWithFewFilesAreAccepted() throws IOException {
+ // Regression for WW-5474: maxFiles must not count form fields.
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < 10; i++) {
+ content.append(formField("field" + i, "value" + i));
+ }
+ content.append(formFile("file1", "test1.csv", "1,2,3,4"));
+ content.append(formFile("file2", "test2.csv", "5,6,7,8"));
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2"); // only 2 files, but 10 fields present
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).isEmpty();
+ assertThat(multiPart.getFileParameterNames().asIterator()).toIterable()
+
.asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly("file1", "file2");
+ }
+
+ @Test
+ public void exceedsMaxFilesIsFailClosed() throws IOException {
+ String content = formField("param1", "value1") +
+ formFile("file1", "test1.csv", "1,2,3,4") +
+ formFile("file2", "test2.csv", "5,6,7,8") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("1");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+
assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().isEmpty();
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
+ }
+
+ @Test
+ public void exceedsMaxParameterCountIsFailClosed() throws IOException {
+ String content = formField("field1", "a") +
+ formField("field2", "b") +
+ formField("field3", "c") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxParameterCount("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadParameterCountLimitException");
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
+ }
+
+ @Test
+ public void multipleFilesUnderOneFieldNameAreCounted() throws IOException {
+ String content = formFile("file", "a.csv", "1") +
+ formFile("file", "b.csv", "2") +
+ formFile("file", "c.csv", "3") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2"); // 3 files share one field name -> still 3
files
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+ }
+
@Test
public void processFileFieldHandlesEmptyFileName() throws IOException {
String content =
@@ -277,6 +346,75 @@ public class JakartaMultiPartRequestTest extends
AbstractMultiPartRequestTest {
.isEqualTo("valid file content");
}
+ @Test
+ public void unlimitedMaxFilesIsNotClampedByTotalPartsBackstop() throws
IOException {
+ // Regression for WW-5474: maxFiles=-1 (unlimited) combined with a
finite
+ // maxParameterCount must not compute a finite total-parts backstop
+ // (maxFiles + maxParameterCount) and pass it to commons-fileupload2's
+ // setMaxFileCount, which counts ALL parts. Before the fix, -1 + 256 =
255
+ // wrongly rejected a 300-file/0-field upload at part 256.
+ int fileCount = 300;
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < fileCount; i++) {
+ content.append(formFile("file" + i, "test" + i + ".csv",
"1,2,3,4"));
+ }
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxSize("-1"); // isolate: don't let the size backstop
interfere
+ multiPart.setMaxFiles("-1"); // unlimited files
+ multiPart.setMaxParameterCount("256"); // finite, but must not clamp
file count
+
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).isEmpty();
+
assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().hasSize(fileCount);
+ }
+
+ @Test
+ public void trailingDiskFileItemsAreCleanedUpAfterFailClosedBreach()
throws IOException {
+ // Regression for WW-5474: parseRequest() fully materializes every
part - spilling
+ // large ones to disk - before processUpload() ever starts iterating.
If items were
+ // only registered for cleanup as the loop reached them, a fail-closed
breach mid-loop
+ // (e.g. enforceMaxFiles throwing) would leak the temp files of every
part positioned
+ // after the breaching one, since cleanUp()/cleanUpDiskFileItems()
never learns about them.
+ // Use a fresh, empty saveDir so the leftover-file assertion isn't
polluted by other tests.
+ File freshSaveDir =
Files.createTempDirectory("struts-ww5474-leak-test").toFile();
+ try {
+ StringBuilder content = new StringBuilder();
+ for (int i = 1; i <= 5; i++) {
+ content.append(formFile("file" + i, "test" + i + ".csv",
"1,2,3,4"));
+ }
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ // force every part to spill to disk instead of staying in-memory
+ multiPart.setBufferSize("1");
+ multiPart.setMaxFiles("2"); // breach occurs at file3, leaving
file4/file5 as trailing parts
+ multiPart.parse(mockRequest, freshSaveDir.getAbsolutePath());
+
+ // then - fail-closed: breach reported, no files exposed
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+
assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().isEmpty();
+
+ // when - cleanup runs
+ multiPart.cleanUp();
+
+ // then - no leftover temp files remain, including the trailing
parts after the breach
+ File[] leftover = freshSaveDir.listFiles((dir, name) ->
name.startsWith("upload_") && name.endsWith(".tmp"));
+ assertThat(leftover).isNullOrEmpty();
+ } finally {
+ File[] remaining = freshSaveDir.listFiles();
+ if (remaining != null) {
+ for (File f : remaining) {
+ f.delete();
+ }
+ }
+ freshSaveDir.delete();
+ }
+ }
+
@Test
public void inMemoryUploadIsNotWrittenToDiskUntilContentRequested() throws
IOException {
// given - a small file that Commons FileUpload keeps in memory
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java
index fc78021f5..988be53eb 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java
@@ -216,14 +216,63 @@ public class JakartaStreamMultiPartRequestTest extends
AbstractMultiPartRequestT
// when - set max files to 1
multiPart.setMaxFiles("1");
multiPart.parse(mockRequest, tempDir);
-
- // then - should have only 1 file and errors for others
- assertThat(multiPart.uploadedFiles).hasSize(1);
+
+ // then - fail-closed: no partial files, one error
+ assertThat(multiPart.uploadedFiles).isEmpty();
assertThat(multiPart.getErrors())
- .isNotEmpty()
- .allSatisfy(error ->
-
assertThat(error.getTextKey()).isEqualTo("struts.messages.upload.error.FileUploadFileCountLimitException")
- );
+ .map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+ }
+
+ @Test
+ public void streamManyFormFieldsWithFewFilesAreAccepted() throws
IOException {
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < 10; i++) {
+ content.append(formField("field" + i, "value" + i));
+ }
+ content.append(formFile("file1", "test1.csv", "1,2,3,4"));
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("1");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).isEmpty();
+ assertThat(multiPart.getFileParameterNames().asIterator()).toIterable()
+
.asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly("file1");
+ }
+
+ @Test
+ public void streamMultipleFilesUnderOneFieldNameAreCounted() throws
IOException {
+ String content = formFile("file", "a.csv", "1") +
+ formFile("file", "b.csv", "2") +
+ formFile("file", "c.csv", "3") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ // Field-name counting bug would keep all 3 under one key; files-only
counting rejects.
+ assertThat(multiPart.uploadedFiles).isEmpty();
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+ }
+
+ @Test
+ public void streamExceedsMaxParameterCountIsFailClosed() throws
IOException {
+ String content = formField("field1", "a") +
+ formField("field2", "b") +
+ formField("field3", "c") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxParameterCount("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadParameterCountLimitException");
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
}
@Test
diff --git
a/docs/superpowers/plans/2026-07-22-WW-5474-multipart-maxfiles-semantics.md
b/docs/superpowers/plans/2026-07-22-WW-5474-multipart-maxfiles-semantics.md
new file mode 100644
index 000000000..11dd4385d
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-22-WW-5474-multipart-maxfiles-semantics.md
@@ -0,0 +1,594 @@
+# WW-5474 — Files-only `maxFiles` + new `maxParameterCount` Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development (recommended) or
superpowers:executing-plans to implement this plan task-by-task. Steps use
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `struts.multipart.maxFiles` count uploaded *files only*
(identically in the `jakarta` and `jakarta-stream` parsers) and add
`struts.multipart.maxParameterCount` to cap non-file form fields, failing
closed on breach.
+
+**Architecture:** Shared enforcement lives in `AbstractMultiPartRequest` as
two throwing helpers (`enforceMaxFiles`, `enforceMaxParameterCount`) plus
fail-closed cleanup in `parse()`. Each parser counts file parts and form-field
parts separately and calls the helpers before accepting an item. The `jakarta`
parser keeps a coarse commons-fileupload2 total-parts backstop (`maxFiles +
maxParameterCount`) for early abort; the `jakarta-stream` parser aborts
naturally during iteration.
+
+**Tech Stack:** Java 17, commons-fileupload2 `2.0.0-M5` (core +
jakarta-servlet6), JUnit 4 + AssertJ (multipart unit tests), JUnit 3 /
XWorkTestCase (interceptor tests), Maven.
+
+## Global Constraints
+
+- Ticket prefix on every commit: `WW-5474 <type>(<scope>): <desc>`; end commit
body with `Co-Authored-By: Claude Opus 4.8 <[email protected]>`.
+- New `.java` files MUST carry the ASF license header (copy verbatim from
`JakartaMultiPartRequest.java` lines 1–18).
+- New config constant value: `struts.multipart.maxParameterCount`; default
`256`.
+- New message key:
`struts.messages.upload.error.FileUploadParameterCountLimitException`.
+- Build/test command: `mvn test -DskipAssembly -pl core
-Dtest=<ClassName>[#<method>]`.
+- Semantics: `maxFiles` = number of file parts (parts with a non-empty
filename); `maxParameterCount` = number of non-file form-field parts (each
value counts). A limit that is `null` is not enforced.
+- Fail-closed: on any limit breach, the request exposes **no** parameters and
**no** files; only the recorded upload error remains.
+
+---
+
+### Task 1: `jakarta` parser — files-only `maxFiles` + new `maxParameterCount`
+
+Adds all shared infrastructure and wires the default (`jakarta`) parser.
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (near
line 227, after `STRUTS_MULTIPART_MAX_FILES`)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties:70-71`
+- Modify:
`core/src/main/resources/org/apache/struts2/struts-messages.properties` (after
the `FileUploadFileCountLimitException` entry, ~line 65)
+- Create:
`core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java`
+- Modify:
`core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java`
+- Modify:
`core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java`
+- Test:
`core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java`
+- Test:
`core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java`
+
+**Interfaces:**
+- Produces (consumed by Task 2):
+ - `protected void AbstractMultiPartRequest.enforceMaxFiles(int
currentFileCount, String fileName) throws FileUploadFileCountLimitException`
+ - `protected void AbstractMultiPartRequest.enforceMaxParameterCount(int
currentParameterCount, String fieldName) throws
FileUploadParameterCountLimitException`
+ - `public void AbstractMultiPartRequest.setMaxParameterCount(String)`
injected from `struts.multipart.maxParameterCount`
+ - `FileUploadParameterCountLimitException(String message, long permitted,
long actual)` with `long getPermitted()` / `long getActual()`
+
+- [ ] **Step 1: Add the constant**
+
+In `StrutsConstants.java`, immediately after the `STRUTS_MULTIPART_MAX_FILES`
declaration (line 227):
+
+```java
+ /**
+ * The maximum number of non-file form fields (parameters) allowed in a
multipart request.
+ */
+ public static final String STRUTS_MULTIPART_MAX_PARAMETER_COUNT =
"struts.multipart.maxParameterCount";
+```
+
+- [ ] **Step 2: Add the default property + fix the maxFiles comment**
+
+In `default.properties`, replace lines 69-71:
+
+```properties
+struts.multipart.maxSize=2097152
+struts.multipart.maxFiles=256
+struts.multipart.maxStringLength=4096
+```
+
+with:
+
+```properties
+struts.multipart.maxSize=2097152
+# Maximum number of uploaded files (files only, not form fields)
+struts.multipart.maxFiles=256
+# Maximum number of non-file form fields (parameters)
+struts.multipart.maxParameterCount=256
+struts.multipart.maxStringLength=4096
+```
+
+- [ ] **Step 3: Add the message key**
+
+In `struts-messages.properties`, after the `FileUploadFileCountLimitException`
line (~line 64) add:
+
+```properties
+# FileUploadParameterCountLimitException
+# 0 - limit
+struts.messages.upload.error.FileUploadParameterCountLimitException=Request
exceeded allowed number of parameters! Permitted number of parameters is: {0}!
+```
+
+- [ ] **Step 4: Create the new exception**
+
+Create `FileUploadParameterCountLimitException.java` (with the ASF header
copied from `JakartaMultiPartRequest.java` lines 1–18):
+
+```java
+package org.apache.struts2.dispatcher.multipart;
+
+import org.apache.commons.fileupload2.core.FileUploadException;
+
+/**
+ * Thrown when a multipart request contains more non-file form fields
(parameters)
+ * than allowed by {@code struts.multipart.maxParameterCount}.
+ */
+public class FileUploadParameterCountLimitException extends
FileUploadException {
+
+ private final long permitted;
+ private final long actual;
+
+ public FileUploadParameterCountLimitException(final String message, final
long permitted, final long actual) {
+ super(message);
+ this.permitted = permitted;
+ this.actual = actual;
+ }
+
+ public long getPermitted() {
+ return permitted;
+ }
+
+ public long getActual() {
+ return actual;
+ }
+}
+```
+
+- [ ] **Step 5: Add the field, setter, and enforcement helpers to
`AbstractMultiPartRequest`**
+
+Add the import (with the other `fileupload2.core` imports near line 27):
+
+```java
+import org.apache.commons.fileupload2.core.FileUploadFileCountLimitException;
+```
+
+(`FileUploadFileCountLimitException` is already imported at line 27 — verify;
if present, skip.)
+
+Add a field after `maxFiles` (line 94):
+
+```java
+ /**
+ * Specifies the maximum number of non-file form fields (parameters) in
one request.
+ */
+ protected Long maxParameterCount;
+```
+
+Add a setter after `setMaxFiles` (after line 161):
+
+```java
+ /**
+ * @param maxParameterCount Injects the Struts maximum number of non-file
form fields.
+ */
+ @Inject(StrutsConstants.STRUTS_MULTIPART_MAX_PARAMETER_COUNT)
+ public void setMaxParameterCount(String maxParameterCount) {
+ this.maxParameterCount = Long.parseLong(maxParameterCount);
+ }
+```
+
+Add the two helpers after `exceedsMaxStringLength` (after line 299):
+
+```java
+ /**
+ * Fail-closed guard: throws when accepting another file would exceed
{@link #maxFiles}.
+ *
+ * @param currentFileCount number of files already accepted in this request
+ * @param fileName name of the file being considered (for logging)
+ */
+ protected void enforceMaxFiles(int currentFileCount, String fileName)
throws FileUploadFileCountLimitException {
+ if (maxFiles != null && currentFileCount >= maxFiles) {
+ LOG.debug("Cannot accept another file: {} as it would exceed max
files: {}", normalizeSpace(fileName), maxFiles);
+ throw new FileUploadFileCountLimitException(
+ String.format("Request exceeds allowed number of files,
permitted: %s", maxFiles),
+ maxFiles, currentFileCount + 1L);
+ }
+ }
+
+ /**
+ * Fail-closed guard: throws when accepting another form field would
exceed {@link #maxParameterCount}.
+ *
+ * @param currentParameterCount number of form fields already accepted in
this request
+ * @param fieldName name of the field being considered (for
logging)
+ */
+ protected void enforceMaxParameterCount(int currentParameterCount, String
fieldName) throws FileUploadParameterCountLimitException {
+ if (maxParameterCount != null && currentParameterCount >=
maxParameterCount) {
+ LOG.debug("Cannot accept another parameter: {} as it would exceed
max parameter count: {}", normalizeSpace(fieldName), maxParameterCount);
+ throw new FileUploadParameterCountLimitException(
+ String.format("Request exceeds allowed number of
parameters, permitted: %s", maxParameterCount),
+ maxParameterCount, currentParameterCount + 1L);
+ }
+ }
+```
+
+- [ ] **Step 6: Fail-closed cleanup + message mapping in `parse()`**
+
+In `AbstractMultiPartRequest.parse()` (lines 307-336), add a branch for the
new exception and clear collected data on abort. Replace the
`FileUploadContentTypeException` else-if block and the trailing
message-building lines (lines 324-330) with:
+
+```java
+ } else if (e instanceof FileUploadContentTypeException ex) {
+ exClass = ex.getClass();
+ args = new Object[]{ex.getContentType()};
+ } else if (e instanceof FileUploadParameterCountLimitException ex)
{
+ exClass = ex.getClass();
+ args = new Object[]{ex.getPermitted(), ex.getActual()};
+ }
+
+ LocalizedMessage errorMessage = buildErrorMessage(exClass,
e.getMessage(), args);
+ addErrorIfAbsent(errorMessage);
+ clearCollectedData();
+```
+
+Add the private helper after `addErrorIfAbsent` (after line 342). It deletes
any partial upload files before clearing so the stream parser does not leak
temp files:
+
+```java
+ /**
+ * Fail-closed: discards everything collected so far so a rejected request
exposes
+ * no partial parameters or files to the action. Deletes partial upload
files first
+ * to avoid leaking temporary files.
+ */
+ private void clearCollectedData() {
+ for (List<UploadedFile> files : uploadedFiles.values()) {
+ for (UploadedFile file : files) {
+ if (file.isFile() && !file.delete()) {
+ LOG.warn("Could not delete partial upload file: {}",
file.getName());
+ }
+ }
+ }
+ uploadedFiles.clear();
+ parameters.clear();
+ }
+```
+
+- [ ] **Step 7: `jakarta` backstop in `prepareServletFileUpload`**
+
+In `AbstractMultiPartRequest.prepareServletFileUpload` (lines 221-238),
replace the `if (maxFiles != null) { ... setMaxFileCount(maxFiles); }` block
(lines 229-232) with a coarse total-parts backstop that only constrains when
both limits are set (otherwise leaving commons unlimited so it cannot
re-introduce the all-parts bug):
+
+```java
+ if (maxFiles != null && maxParameterCount != null) {
+ long maxParts = maxFiles + maxParameterCount;
+ LOG.debug("Applies total parts backstop: {} to file upload
request", maxParts);
+ servletFileUpload.setMaxFileCount(maxParts);
+ }
+```
+
+- [ ] **Step 8: Write the failing `jakarta` tests**
+
+Add to `JakartaMultiPartRequestTest` (uses `formFile`, `formField`,
`boundary`, `endline`, `tempDir`, `mockRequest`, `multiPart` from the base
class):
+
+```java
+ @Test
+ public void manyFormFieldsWithFewFilesAreAccepted() throws IOException {
+ // Regression for WW-5474: maxFiles must not count form fields.
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < 10; i++) {
+ content.append(formField("field" + i, "value" + i));
+ }
+ content.append(formFile("file1", "test1.csv", "1,2,3,4"));
+ content.append(formFile("file2", "test2.csv", "5,6,7,8"));
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2"); // only 2 files, but 10 fields present
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).isEmpty();
+ assertThat(multiPart.getFileParameterNames().asIterator()).toIterable()
+
.asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly("file1", "file2");
+ }
+
+ @Test
+ public void exceedsMaxFilesIsFailClosed() throws IOException {
+ String content = formField("param1", "value1") +
+ formFile("file1", "test1.csv", "1,2,3,4") +
+ formFile("file2", "test2.csv", "5,6,7,8") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("1");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+
assertThat(multiPart.getFileParameterNames().asIterator()).toIterable().isEmpty();
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
+ }
+
+ @Test
+ public void exceedsMaxParameterCountIsFailClosed() throws IOException {
+ String content = formField("field1", "a") +
+ formField("field2", "b") +
+ formField("field3", "c") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxParameterCount("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadParameterCountLimitException");
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
+ }
+
+ @Test
+ public void multipleFilesUnderOneFieldNameAreCounted() throws IOException {
+ String content = formFile("file", "a.csv", "1") +
+ formFile("file", "b.csv", "2") +
+ formFile("file", "c.csv", "3") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2"); // 3 files share one field name -> still 3
files
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+ }
+```
+
+Ensure these imports exist in `JakartaMultiPartRequestTest` (add any missing):
`org.assertj.core.api.InstanceOfAssertFactories`,
`org.apache.struts2.dispatcher.LocalizedMessage`,
`java.nio.charset.StandardCharsets`, `java.io.IOException`, `static
org.assertj.core.api.Assertions.assertThat`.
+
+Add to `AbstractMultiPartRequestTest` (runs for both parsers; verifies only
the setter, safe before Task 2):
+
+```java
+ @Test
+ public void maxParameterCountSetterStoresValue() {
+ multiPart.setMaxParameterCount("42");
+ assertThat(multiPart.maxParameterCount).isEqualTo(42L);
+ }
+```
+
+- [ ] **Step 9: Run the new tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=JakartaMultiPartRequestTest#manyFormFieldsWithFewFilesAreAccepted+exceedsMaxParameterCountIsFailClosed`
+Expected: compile error / FAIL (methods `setMaxParameterCount`,
`FileUploadParameterCountLimitException` key not yet wired into the parser). If
Steps 1–7 are already applied, `manyFormFieldsWithFewFilesAreAccepted` fails
because the parser is not yet wired (Step 10).
+
+- [ ] **Step 10: Wire `JakartaMultiPartRequest.processUpload`**
+
+Replace the `for` loop body in `processUpload` (lines 118-132) with
per-category counting:
+
+```java
+ int fileCount = 0;
+ int parameterCount = 0;
+ for (DiskFileItem item :
servletFileUpload.parseRequest(requestContext)) {
+ // Track all DiskFileItem instances for cleanup - this is critical
for security
+ // as it ensures temporary files are properly cleaned up even if
processing fails
+ diskFileItems.add(item);
+
+ LOG.debug(() -> "Processing a form field: " +
normalizeSpace(item.getFieldName()));
+ if (item.isFormField()) {
+ // Process regular form fields (text inputs, checkboxes, etc.)
+ if (item.getFieldName() != null) {
+ enforceMaxParameterCount(parameterCount,
item.getFieldName());
+ parameterCount++;
+ }
+ processNormalFormField(item, charset);
+ } else {
+ // Process file upload fields (only count parts that carry an
actual file)
+ LOG.debug(() -> "Processing a file: " +
normalizeSpace(item.getFieldName()));
+ if (item.getName() != null &&
!item.getName().trim().isEmpty()) {
+ enforceMaxFiles(fileCount, item.getName());
+ fileCount++;
+ }
+ processFileField(item, saveDir);
+ }
+ }
+```
+
+`processUpload` already declares `throws IOException`; the enforcement
exceptions extend `FileUploadException extends IOException`, so no signature
change is needed.
+
+- [ ] **Step 11: Run the full jakarta + regression suites**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=JakartaMultiPartRequestTest`
+Expected: PASS (all four new tests + existing).
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=AbstractMultiPartRequestTest`
+Expected: PASS (shared `maxFiles()` still yields exactly one
`FileUploadFileCountLimitException`; new setter test passes for both
subclasses).
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=ActionFileUploadInterceptorTest`
+Expected: PASS. In particular `testUnacceptedNumberOfFiles` (4 files,
`maxFiles=3`) still reports null files + one action error `Request exceeded
allowed number of files! Permitted number of files is: 3!`.
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java \
+ core/src/main/resources/org/apache/struts2/default.properties \
+ core/src/main/resources/org/apache/struts2/struts-messages.properties \
+
core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java
\
+
core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
\
+
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
\
+
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
\
+
core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java
+git commit -m "$(cat <<'EOF'
+WW-5474 fix(multipart): count files only for maxFiles, add maxParameterCount
(jakarta)
+
+The jakarta parser passed maxFiles to commons-fileupload2 setMaxFileCount,
+which counts every part (fields + files), so maxFiles wrongly limited total
+parameters. Enforce a files-only count and non-file field count in Struts,
+failing closed on breach; keep a total-parts commons backstop.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>
+EOF
+)"
+```
+
+---
+
+### Task 2: `jakarta-stream` parser — same enforcement
+
+**Files:**
+- Modify:
`core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java`
+- Test:
`core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java`
+
+**Interfaces:**
+- Consumes (from Task 1): `enforceMaxFiles(int, String)`,
`enforceMaxParameterCount(int, String)`, fail-closed `parse()` behavior,
`FileUploadParameterCountLimitException`.
+
+- [ ] **Step 1: Update the existing `exceedsMaxFilesPath` test to fail-closed**
+
+In `JakartaStreamMultiPartRequestTest`, the current test asserts
`uploadedFiles` retains 1 file after breach. Under fail-closed it retains none.
Replace the assertions block (the `// then` section, currently
`assertThat(multiPart.uploadedFiles).hasSize(1);` and the errors assertion)
with:
+
+```java
+ // then - fail-closed: no partial files, one error
+ assertThat(multiPart.uploadedFiles).isEmpty();
+ assertThat(multiPart.getErrors())
+ .map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+```
+
+Add the import if missing: `import
org.apache.struts2.dispatcher.LocalizedMessage;`
+
+- [ ] **Step 2: Add the failing stream-specific tests**
+
+Add to `JakartaStreamMultiPartRequestTest`:
+
+```java
+ @Test
+ public void streamManyFormFieldsWithFewFilesAreAccepted() throws
IOException {
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < 10; i++) {
+ content.append(formField("field" + i, "value" + i));
+ }
+ content.append(formFile("file1", "test1.csv", "1,2,3,4"));
+ content.append(endline).append("--").append(boundary).append("--");
+
mockRequest.setContent(content.toString().getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("1");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).isEmpty();
+ assertThat(multiPart.getFileParameterNames().asIterator()).toIterable()
+
.asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly("file1");
+ }
+
+ @Test
+ public void streamMultipleFilesUnderOneFieldNameAreCounted() throws
IOException {
+ String content = formFile("file", "a.csv", "1") +
+ formFile("file", "b.csv", "2") +
+ formFile("file", "c.csv", "3") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxFiles("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ // Field-name counting bug would keep all 3 under one key; files-only
counting rejects.
+ assertThat(multiPart.uploadedFiles).isEmpty();
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadFileCountLimitException");
+ }
+
+ @Test
+ public void streamExceedsMaxParameterCountIsFailClosed() throws
IOException {
+ String content = formField("field1", "a") +
+ formField("field2", "b") +
+ formField("field3", "c") +
+ endline + "--" + boundary + "--";
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ multiPart.setMaxParameterCount("2");
+ multiPart.parse(mockRequest, tempDir);
+
+ assertThat(multiPart.getErrors()).map(LocalizedMessage::getTextKey)
+
.containsExactly("struts.messages.upload.error.FileUploadParameterCountLimitException");
+
assertThat(multiPart.getParameterNames().asIterator()).toIterable().isEmpty();
+ }
+```
+
+Ensure imports: `org.assertj.core.api.InstanceOfAssertFactories`,
`java.nio.charset.StandardCharsets`, `java.io.IOException`, `static
org.assertj.core.api.Assertions.assertThat`.
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=JakartaStreamMultiPartRequestTest#streamMultipleFilesUnderOneFieldNameAreCounted+streamExceedsMaxParameterCountIsFailClosed`
+Expected: FAIL — the old `exceedsMaxFiles` counts field names (so `file`×3
passes) and there is no parameter-count enforcement yet.
+
+- [ ] **Step 4: Rewire the stream parser**
+
+In `JakartaStreamMultiPartRequest`, remove the entire
`exceedsMaxFiles(FileItemInput)` method (lines 151-169) and the unused
`FileUploadFileCountLimitException` import if it becomes unused (leave it if
still referenced).
+
+Replace `processUpload` (lines 63-81) so counters are tracked across items
(instance fields, reset per parse):
+
+```java
+ private int fileCount;
+ private int parameterCount;
+
+ @Override
+ protected void processUpload(HttpServletRequest request, String saveDir)
throws IOException {
+ Charset charset = readCharsetEncoding(request);
+ Path location = Path.of(saveDir);
+ fileCount = 0;
+ parameterCount = 0;
+
+ JakartaServletDiskFileUpload servletFileUpload =
+ prepareServletFileUpload(charset, location);
+
+ LOG.debug("Using Jakarta Stream API to process request");
+ servletFileUpload.getItemIterator(request).forEachRemaining(item -> {
+ if (item.isFormField()) {
+ LOG.debug(() -> "Processing a form field: " +
normalizeSpace(item.getFieldName()));
+ processFileItemAsFormField(item);
+ } else {
+ LOG.debug(() -> "Processing a file: " +
normalizeSpace(item.getFieldName()));
+ processFileItemAsFileField(item, location);
+ }
+ });
+ }
+```
+
+In `processFileItemAsFormField` (lines 126-140), after the `fieldName == null`
guard, enforce and count before reading:
+
+```java
+ protected void processFileItemAsFormField(FileItemInput fileItemInput)
throws IOException {
+ String fieldName = fileItemInput.getFieldName();
+ if (fieldName == null) {
+ LOG.warn("Form field has null fieldName, skipping");
+ return;
+ }
+
+ enforceMaxParameterCount(parameterCount, fieldName);
+ parameterCount++;
+
+ String fieldValue = readStream(fileItemInput.getInputStream());
+ if (exceedsMaxStringLength(fieldName, fieldValue)) {
+ return;
+ }
+
+ List<String> values = parameters.computeIfAbsent(fieldName, k -> new
ArrayList<>());
+ values.add(fieldValue);
+ }
+```
+
+In `processFileItemAsFileField` (lines 216-249), replace the `if
(exceedsMaxFiles(fileItemInput)) { return; }` block (lines 229-231) with the
shared guard, counting after the empty-name / null-fieldName guards already
above it:
+
+```java
+ enforceMaxFiles(fileCount, fileItemInput.getName());
+ fileCount++;
+```
+
+Leave the rest of `processFileItemAsFileField` (temp file creation, empty-file
rejection, size checks, `createUploadedFile`) unchanged.
+
+- [ ] **Step 5: Run the stream suite**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=JakartaStreamMultiPartRequestTest`
+Expected: PASS (updated `exceedsMaxFilesPath` + three new tests + existing).
+
+- [ ] **Step 6: Run the full multipart + interceptor regression**
+
+Run: `mvn test -DskipAssembly -pl core
-Dtest=AbstractMultiPartRequestTest+JakartaMultiPartRequestTest+JakartaStreamMultiPartRequestTest+ActionFileUploadInterceptorTest`
+Expected: PASS for all.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java
\
+
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java
+git commit -m "$(cat <<'EOF'
+WW-5474 fix(multipart): apply files-only maxFiles + maxParameterCount to
stream parser
+
+Replace the field-name-based exceedsMaxFiles with the shared files-only
+enforcement and add parameter-count enforcement, matching the jakarta parser
+and failing closed on breach.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>
+EOF
+)"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- §2 files-only `maxFiles` both parsers → Task 1 Step 10, Task 2 Step 4. ✓
+- §3 new constant/default/injection → Task 1 Steps 1-2, 5. ✓
+- §4 shared `enforce*` helpers + new exception → Task 1 Steps 4-5. ✓
+- §5 fail-closed clearing + message wiring → Task 1 Steps 3, 6. ✓
+- §6 jakarta backstop + counters; stream rewire → Task 1 Steps 7, 10; Task 2
Step 4. ✓
+- §7 tests (both parsers, many-fields, over-limit files, over-limit params,
multi-file-one-field, fail-closed, setter) → Task 1 Step 8, Task 2 Steps 1-2. ✓
+- §7 note about JUnit 4 multipart tests honored; interceptor JUnit 3 suite run
for regression (Task 1 Step 11). ✓
+
+**Placeholder scan:** No TBD/TODO; all steps carry concrete code and exact
commands. ✓
+
+**Type consistency:** `enforceMaxFiles(int, String)` /
`enforceMaxParameterCount(int, String)` and
`FileUploadParameterCountLimitException(String, long, long)` with
`getPermitted()`/`getActual()` are defined in Task 1 and consumed identically
in Task 2 and in `parse()`. Message key string matches across property file,
tests, and exception mapping. ✓
+
+**Known wrinkles (from the spec, intentional):** a gross flood over `maxFiles
+ maxParameterCount` on the jakarta parser surfaces the generic
`FileUploadFileCountLimitException` via the commons backstop rather than a
per-category message; `clearCollectedData()` tightens all `FileUploadException`
abort paths (safe — no test asserts partial retention).
diff --git
a/docs/superpowers/specs/2026-07-22-WW-5474-multipart-maxfiles-semantics-design.md
b/docs/superpowers/specs/2026-07-22-WW-5474-multipart-maxfiles-semantics-design.md
new file mode 100644
index 000000000..d6a7b29e3
--- /dev/null
+++
b/docs/superpowers/specs/2026-07-22-WW-5474-multipart-maxfiles-semantics-design.md
@@ -0,0 +1,100 @@
+# WW-5474 — `struts.multipart.maxFiles` counts files only, plus new
`maxParameterCount`
+
+- **Ticket:** [WW-5474](https://issues.apache.org/jira/browse/WW-5474) —
*struts.multipart.maxFiles does not work as described/expected*
+- **Type:** Bug
+- **Fix version:** 7.3.0
+- **Date:** 2026-07-22
+
+## 1. Problem
+
+`struts.multipart.maxFiles` (default `256`) is documented as a cap on the
**number of uploaded files**. In practice it does not behave that way, and the
two Jakarta parsers disagree:
+
+- **`jakarta` parser (`JakartaMultiPartRequest`, the default)** passes the
value to commons-fileupload2 `setMaxFileCount(maxFiles)`. In
commons-fileupload2 `2.0.0-M5`,
`AbstractFileUpload.parseRequest(RequestContext)` throws
`FileUploadFileCountLimitException` when `itemList.size() == maxFileCount`, and
`itemList` holds **every** part — form fields *and* files. So `maxFiles`
actually caps the **total number of parameters**, firing spuriously on forms
with many normal fields and few (or z [...]
+
+- **`jakarta-stream` parser (`JakartaStreamMultiPartRequest`)** calls
`getItemIterator()`. In `2.0.0-M5` the streaming iterator
(`FileItemInputIteratorImpl`) enforces only `sizeMax` and `fileSizeMax` — it
**ignores `maxFileCount` entirely**. The class instead has its own
`exceedsMaxFiles()` which compares `maxFiles` against `uploadedFiles.size()` —
the number of **distinct field names**, not the number of files. Multiple files
sharing one field name collapse to one, so this both under-co [...]
+
+Net result: neither parser matches the documented "maximum number of files,"
and the two parsers behave differently from each other.
+
+### Side effect being preserved
+
+Today the `jakarta` parser's total-part cap of 256 *accidentally* guards
against a parameter-count flooding DoS. Making `maxFiles` count files only
would remove that incidental guard. `maxSize` (default 2 MB) bounds total bytes
but not the number of tiny parts, so we replace the incidental guard with an
explicit one (see §3).
+
+## 2. Goal
+
+1. `struts.multipart.maxFiles` counts **file parts only**, identically in both
parsers, matching the documentation.
+2. Add `struts.multipart.maxParameterCount` (default `256`) counting
**non-file form fields only**, restoring explicit DoS protection.
+3. The two limits are orthogonal: a request may carry up to `maxFiles` files
**and** up to `maxParameterCount` form fields.
+4. Exceeding **either** limit is **fail-closed**: the request is rejected with
a recorded upload error and the action receives **no partial data**.
+
+Out of scope: the deprecated `cos` parser (untouched); the Struts website
documentation (lives in the separate `struts-site` repo — only in-repo config
comments, JavaDoc, and message bundles are updated here).
+
+## 3. New configuration
+
+- **Constant:** `StrutsConstants.STRUTS_MULTIPART_MAX_PARAMETER_COUNT =
"struts.multipart.maxParameterCount"`.
+- **Default:** `default.properties` →
`struts.multipart.maxParameterCount=256`. Update the existing
`struts.multipart.maxFiles` comment to state it limits *files only*.
+- **Injection:** new field `protected Long maxParameterCount` on
`AbstractMultiPartRequest` with an
`@Inject(StrutsConstants.STRUTS_MULTIPART_MAX_PARAMETER_COUNT)` setter,
following the existing `maxFiles` setter pattern.
+
+| Setting | Counts | Default |
+|---|---|---|
+| `struts.multipart.maxFiles` | file parts only | 256 |
+| `struts.multipart.maxParameterCount` (new) | non-file form fields only | 256
|
+
+## 4. Shared enforcement (`AbstractMultiPartRequest`)
+
+Two helpers, called **before** accepting each item, used by both parsers so
behavior is identical:
+
+- `enforceMaxFiles(int currentFileCount, String fileName)` — throws
`FileUploadFileCountLimitException` (commons) when accepting one more file
would exceed `maxFiles`.
+- `enforceMaxParameterCount(int currentParameterCount, String fieldName)` —
throws a **new** `FileUploadParameterCountLimitException` when accepting one
more field would exceed `maxParameterCount`.
+
+Both exceptions extend `FileUploadException` (which `extends IOException`), so
a breach unwinds the parse loop and is caught by the existing `parse()`
handler. Each helper is a no-op when its limit is unset (`null`).
+
+### New exception
+
+`org.apache.struts2.dispatcher.multipart.FileUploadParameterCountLimitException
extends FileUploadException`, carrying `permitted` and `actual` counts
(mirroring `FileUploadFileCountLimitException`'s shape) for the localized
message args.
+
+## 5. Fail-closed handling in `parse()`
+
+`AbstractMultiPartRequest.parse()` already catches `FileUploadException` and
records a `LocalizedMessage`. Changes:
+
+1. **Discard partial data on any abort:** clear `parameters` and
`uploadedFiles` in the `FileUploadException` path so the action sees only the
upload error, never a partially-populated request. Temp files are reclaimed by
the existing `cleanUp()` (items are tracked *before* the limit check — see §6).
This tightens *all* abort paths (`maxSize`, `maxFileSize`, the new limits);
today those maps happen to be empty on abort, so it is a safe hardening rather
than a behavior change for existing [...]
+2. **Message wiring:** add a branch mapping
`FileUploadParameterCountLimitException` to args `{permitted, actual}`, and add
message key
`struts.messages.upload.error.FileUploadParameterCountLimitException` to
`struts-messages.properties` (matching the existing
`FileUploadFileCountLimitException` entry).
+
+## 6. Per-parser wiring
+
+### `jakarta` (`JakartaMultiPartRequest`, non-streaming)
+
+- Stop using `setMaxFileCount` as the *file* cap.
+- Keep a cheap early-abort **backstop** against gross floods by setting
commons `setMaxFileCount = maxFiles + maxParameterCount` (a total-parts ceiling
— commons cannot distinguish categories). This aborts pathological requests
before the full item list is materialized.
+- In the `processUpload` loop, track each `DiskFileItem` for cleanup
**first**, then maintain separate file and field counters and call
`enforceMaxFiles` / `enforceMaxParameterCount` before processing the item.
Precise, category-correct errors fire before the coarse backstop in all normal
over-limit cases.
+
+**Wrinkle (accepted):** a *gross* flood exceeding `maxFiles +
maxParameterCount` combined surfaces the generic
`FileUploadFileCountLimitException` with the combined total rather than a
precise per-category message. Normal over-limit cases (e.g. the 257th file, or
257th field) still produce precise messages. This is an acceptable
defense-in-depth tradeoff given the non-streaming parser cannot count per
category before `parseRequest` returns.
+
+### `jakarta-stream` (`JakartaStreamMultiPartRequest`)
+
+- Remove the buggy `exceedsMaxFiles` (field-name counting).
+- Maintain true file and field counters during iteration and call the shared
`enforceMaxFiles` / `enforceMaxParameterCount` helpers. The streaming iterator
aborts naturally and early on the throw.
+
+## 7. Testing
+
+JUnit 4 (`org.junit.Test`) — the multipart tests are plain JUnit 4, not
`XWorkTestCase`, so new `@Test` methods run.
+
+Add to **both** `JakartaMultiPartRequestTest` and
`JakartaStreamMultiPartRequestTest`:
+
+- Many form fields + few files (e.g. 300 fields, 2 files) with `maxFiles=256`
now **passes** (regression for the reported bug).
+- More than `maxFiles` files → fails with key
`struts.messages.upload.error.FileUploadFileCountLimitException`.
+- More than `maxParameterCount` fields → fails with key
`struts.messages.upload.error.FileUploadParameterCountLimitException`.
+- Multiple files under a single field name are counted individually (guards
the stream-parser regression).
+- On breach the request exposes no parameters and no files (fail-closed):
`getParameterNames()` / `getFileParameterNames()` empty, error present.
+
+Add to `AbstractMultiPartRequestTest`: the new setter parses and stores
`maxParameterCount`; default resolves to 256.
+
+## 8. Files touched
+
+- `core/.../StrutsConstants.java` — new constant.
+- `core/.../default.properties` — new default + corrected `maxFiles` comment.
+- `core/.../struts-messages.properties` — new message key.
+- `core/.../multipart/AbstractMultiPartRequest.java` — new field/setter, two
`enforce…` helpers, fail-closed clearing, message arg mapping.
+- `core/.../multipart/FileUploadParameterCountLimitException.java` — new
exception.
+- `core/.../multipart/JakartaMultiPartRequest.java` — counters + backstop
wiring.
+- `core/.../multipart/JakartaStreamMultiPartRequest.java` — replace
`exceedsMaxFiles`, add counters.
+- Tests: `JakartaMultiPartRequestTest`, `JakartaStreamMultiPartRequestTest`,
`AbstractMultiPartRequestTest`.