This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch WW-5474-multipart-maxfiles-semantics in repository https://gitbox.apache.org/repos/asf/struts.git
commit c9fe00321132a803e305487dcba09842bd42fbbb Author: Lukasz Lenart <[email protected]> AuthorDate: Wed Jul 22 15:04:09 2026 +0200 WW-5474 docs(multipart): implementation plan for maxFiles/maxParameterCount Co-Authored-By: Claude Opus 4.8 <[email protected]> --- ...6-07-22-WW-5474-multipart-maxfiles-semantics.md | 594 +++++++++++++++++++++ 1 file changed, 594 insertions(+) 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).
