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 4f3fd69aa WW-5632 Harden commons-fileupload2 dependency against 
milestone binary-incompatibility (#1735)
4f3fd69aa is described below

commit 4f3fd69aa60b7487c83bc478f1aa93bf04985c58
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Jun 13 08:28:47 2026 +0200

    WW-5632 Harden commons-fileupload2 dependency against milestone 
binary-incompatibility (#1735)
    
    * WW-5632 docs: add commons-fileupload2 milestone-hardening design spec
    
    Design for hardening the commons-fileupload2 dependency against
    milestone binary-incompatibility (manage -core, activate a scoped
    enforcer rule, add a runtime API guard in AbstractMultiPartRequest).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5632 docs: add implementation plan for fileupload2 milestone hardening
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5632 build(deps): manage commons-fileupload2-core alongside 
jakarta-servlet6
    
    Pin both commons-fileupload2 artifacts to a single
    commons-fileupload2.version property so the volatile -core API can no
    longer skew from -jakarta-servlet6 in the reactor.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5632 build: enforce a single commons-fileupload2 version
    
    Activate maven-enforcer-plugin (previously dormant in pluginManagement)
    with a fileupload-scoped bannedDependencies rule so any divergent
    commons-fileupload2 version fails the build early.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5632 fix(fileupload): fail fast on incompatible commons-fileupload2 API
    
    Verify once per JVM that the fileupload size-limit setters exist and
    throw a clear StrutsException reporting the core/jakarta version skew,
    replacing an opaque deep-stack NoSuchMethodError in downstream runtimes.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5632 fix(fileupload): make API-verification guard static
    
    Resolve Sonar java:S2696 (instance method writing a static field) by
    making ensureFileUploadApiVerified() static; verification is JVM-global.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../multipart/AbstractMultiPartRequest.java        |  51 +++
 .../AbstractMultiPartRequestApiCheckTest.java      |  47 +++
 ...6-10-WW-5632-fileupload2-milestone-hardening.md | 392 +++++++++++++++++++++
 ...06-10-fileupload2-milestone-hardening-design.md | 206 +++++++++++
 parent/pom.xml                                     |   7 +-
 pom.xml                                            |  17 +-
 6 files changed, 718 insertions(+), 2 deletions(-)

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 3bb0d17d2..cb5c6b9ec 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
@@ -19,6 +19,7 @@
 package org.apache.struts2.dispatcher.multipart;
 
 import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.fileupload2.core.AbstractFileUpload;
 import org.apache.commons.fileupload2.core.DiskFileItemFactory;
 import org.apache.commons.fileupload2.core.FileUploadByteCountLimitException;
 import org.apache.commons.fileupload2.core.FileUploadContentTypeException;
@@ -32,6 +33,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.StrutsException;
 import org.apache.struts2.dispatcher.LocalizedMessage;
 import org.apache.struts2.inject.Inject;
 
@@ -60,6 +62,12 @@ public abstract class AbstractMultiPartRequest implements 
MultiPartRequest {
 
     private static final Logger LOG = 
LogManager.getLogger(AbstractMultiPartRequest.class);
 
+    /**
+     * Verified once per JVM: whether the commons-fileupload2 API on the 
classpath matches what
+     * Struts compiled against. Guards against a mismatched milestone 
resolving at runtime.
+     */
+    private static volatile boolean fileUploadApiVerified;
+
     /**
      * Defines the internal buffer size used during streaming operations.
      */
@@ -211,6 +219,7 @@ public abstract class AbstractMultiPartRequest implements 
MultiPartRequest {
     }
 
     protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset 
charset, Path saveDir) {
+        ensureFileUploadApiVerified();
         JakartaServletDiskFileUpload servletFileUpload = 
createJakartaFileUpload(charset, saveDir);
 
         if (maxSize != null) {
@@ -228,6 +237,48 @@ public abstract class AbstractMultiPartRequest implements 
MultiPartRequest {
         return servletFileUpload;
     }
 
+    /**
+     * Verifies once per JVM that the commons-fileupload2 API on the classpath 
matches what Struts
+     * compiled against, failing fast with an actionable message instead of a 
deep-stack
+     * {@link NoSuchMethodError} when a mismatched milestone is resolved.
+     */
+    private static void ensureFileUploadApiVerified() {
+        if (!fileUploadApiVerified) {
+            verifyFileUploadApi(JakartaServletDiskFileUpload.class);
+            fileUploadApiVerified = true;
+        }
+    }
+
+    /**
+     * Probes {@code uploadClass} for the size-limit setters Struts invokes in
+     * {@link #prepareServletFileUpload}. Package-private for testing.
+     *
+     * @param uploadClass the file upload class to verify
+     * @throws StrutsException if any required method is absent, indicating a 
binary-incompatible
+     *                         commons-fileupload2 version on the classpath
+     */
+    static void verifyFileUploadApi(Class<?> uploadClass) {
+        for (String method : new String[]{"setMaxSize", "setMaxFileCount", 
"setMaxFileSize"}) {
+            try {
+                uploadClass.getMethod(method, long.class);
+            } catch (NoSuchMethodException e) {
+                throw new StrutsException(String.format(
+                        "Incompatible Apache Commons FileUpload on the 
classpath: %s.%s(long) is missing. " +
+                                "Detected commons-fileupload2-core version 
[%s] and commons-fileupload2-jakarta-servlet6 version [%s]. " +
+                                "Align commons-fileupload2-core with 
commons-fileupload2-jakarta-servlet6 (use the same release for both).",
+                        uploadClass.getName(), method,
+                        implementationVersion(AbstractFileUpload.class),
+                        implementationVersion(uploadClass)), e);
+            }
+        }
+    }
+
+    private static String implementationVersion(Class<?> clazz) {
+        Package pkg = clazz.getPackage();
+        String version = pkg != null ? pkg.getImplementationVersion() : null;
+        return version != null ? version : "unknown";
+    }
+
     protected RequestContext createRequestContext(HttpServletRequest request) {
         return new StrutsRequestContext(request);
     }
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java
new file mode 100644
index 000000000..3dba48ccf
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java
@@ -0,0 +1,47 @@
+/*
+ * 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.jakarta.servlet6.JakartaServletDiskFileUpload;
+import org.apache.struts2.StrutsException;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class AbstractMultiPartRequestApiCheckTest {
+
+    @Test
+    public void verifyFileUploadApiPassesForCompatibleClass() {
+        assertThatCode(() -> 
AbstractMultiPartRequest.verifyFileUploadApi(JakartaServletDiskFileUpload.class))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    public void verifyFileUploadApiThrowsForIncompatibleClass() {
+        assertThatThrownBy(() -> 
AbstractMultiPartRequest.verifyFileUploadApi(IncompatibleFileUpload.class))
+                .isInstanceOf(StrutsException.class)
+                .hasMessageContaining("setMaxSize")
+                .hasMessageContaining("Align commons-fileupload2-core");
+    }
+
+    /** Stub lacking the size-limit setters, simulating a binary-incompatible 
fileupload version. */
+    private static class IncompatibleFileUpload {
+    }
+}
diff --git 
a/docs/superpowers/plans/2026-06-10-WW-5632-fileupload2-milestone-hardening.md 
b/docs/superpowers/plans/2026-06-10-WW-5632-fileupload2-milestone-hardening.md
new file mode 100644
index 000000000..d52edb7f4
--- /dev/null
+++ 
b/docs/superpowers/plans/2026-06-10-WW-5632-fileupload2-milestone-hardening.md
@@ -0,0 +1,392 @@
+# commons-fileupload2 Milestone Hardening 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 a `commons-fileupload2-core` / `-jakarta-servlet6` version skew 
impossible in Struts's own build and turn a future runtime `NoSuchMethodError` 
into a clear, actionable `StrutsException`.
+
+**Architecture:** Three independent changes. (A1) Introduce a single 
`commons-fileupload2.version` property and manage *both* fileupload artifacts 
in `parent/pom.xml`. (A2) Activate the dormant `maven-enforcer-plugin` with a 
fileupload-scoped `bannedDependencies` rule. (B) Add a once-per-JVM reflective 
API guard in `AbstractMultiPartRequest`.
+
+**Tech Stack:** Maven (multi-module), `maven-enforcer-plugin` 3.6.3, Java 17, 
JUnit 4 + AssertJ (the `core` module's established test stack), Apache Commons 
FileUpload 2.0.0-M5.
+
+**Ticket:** [WW-5632](https://issues.apache.org/jira/browse/WW-5632)
+**Spec:** 
`docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md`
+**Branch:** `WW-5632-fileupload2-milestone-hardening` (already checked out)
+
+---
+
+## File Structure
+
+- `pom.xml` (root) — add the `commons-fileupload2.version` property; change 
the enforcer rule from `dependencyConvergence` to a scoped 
`bannedDependencies`; bind the enforcer into the active `<plugins>` section.
+- `parent/pom.xml` — reference the new property for 
`commons-fileupload2-jakarta-servlet6` and add a managed entry for 
`commons-fileupload2-core`.
+- 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java`
 — add the runtime API guard and call it from `prepareServletFileUpload`.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java`
 (new) — unit tests for the guard.
+
+---
+
+## Task 1: Manage both fileupload artifacts via a single version property (A1)
+
+**Files:**
+- Modify: `pom.xml:118-119` (properties block)
+- Modify: `parent/pom.xml:128-132` (dependencyManagement entry)
+
+- [ ] **Step 1: Add the version property to the root POM**
+
+In `pom.xml`, inside the `<properties>` block, add the property in 
alphabetical order between `byte-buddy.version` (line 118) and 
`freemarker.version` (line 119):
+
+```xml
+        <byte-buddy.version>1.18.8</byte-buddy.version>
+        <commons-fileupload2.version>2.0.0-M5</commons-fileupload2.version>
+        <freemarker.version>2.3.34</freemarker.version>
+```
+
+- [ ] **Step 2: Reference the property and add the `-core` managed entry**
+
+In `parent/pom.xml`, replace the existing single 
`commons-fileupload2-jakarta-servlet6` management entry (currently lines 
128-132):
+
+```xml
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
+                <version>2.0.0-M5</version>
+            </dependency>
+```
+
+with two entries, both referencing the property (the volatile API lives in 
`-core`, so it must be pinned too):
+
+```xml
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-fileupload2-core</artifactId>
+                <version>${commons-fileupload2.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
+                <version>${commons-fileupload2.version}</version>
+            </dependency>
+```
+
+- [ ] **Step 3: Verify both artifacts resolve to the pinned version**
+
+Run:
+```bash
+mvn -q -pl core dependency:list -DskipAssembly 
'-Dincludes=org.apache.commons:commons-fileupload2*'
+```
+Expected: both `commons-fileupload2-core` and 
`commons-fileupload2-jakarta-servlet6` listed at `2.0.0-M5`.
+
+- [ ] **Step 4: Verify the reactor still builds**
+
+Run:
+```bash
+mvn -q validate -DskipAssembly
+```
+Expected: `BUILD SUCCESS` (no errors from the new property / managed 
dependency).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pom.xml parent/pom.xml
+git commit -m "WW-5632 build(deps): manage commons-fileupload2-core alongside 
jakarta-servlet6
+
+Pin both commons-fileupload2 artifacts to a single
+commons-fileupload2.version property so the volatile -core API can no
+longer skew from -jakarta-servlet6 in the reactor.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Task 2: Activate a fileupload-scoped enforcer rule (A2)
+
+**Files:**
+- Modify: `pom.xml:349-353` (enforcer rule config in `<pluginManagement>`)
+- Modify: `pom.xml:373-378` (active `<plugins>` section)
+
+- [ ] **Step 1: Replace the dormant `dependencyConvergence` rule with a scoped 
`bannedDependencies` rule**
+
+In `pom.xml`, inside the `maven-enforcer-plugin` execution in 
`<pluginManagement>`, replace the current configuration (lines 349-353):
+
+```xml
+                            <configuration>
+                                <rules>
+                                    <dependencyConvergence />
+                                </rules>
+                            </configuration>
+```
+
+with a rule that bans all commons-fileupload2 versions except the pinned one 
(`<includes>` are exceptions to the `<excludes>` bans):
+
+```xml
+                            <configuration>
+                                <rules>
+                                    <bannedDependencies>
+                                        <excludes>
+                                            
<exclude>org.apache.commons:commons-fileupload2-core</exclude>
+                                            
<exclude>org.apache.commons:commons-fileupload2-jakarta-servlet6</exclude>
+                                        </excludes>
+                                        <includes>
+                                            
<include>org.apache.commons:commons-fileupload2-core:${commons-fileupload2.version}</include>
+                                            
<include>org.apache.commons:commons-fileupload2-jakarta-servlet6:${commons-fileupload2.version}</include>
+                                        </includes>
+                                    </bannedDependencies>
+                                </rules>
+                            </configuration>
+```
+
+- [ ] **Step 2: Bind the enforcer into the active `<plugins>` section**
+
+In `pom.xml`, inside the active `<build><plugins>` block, add the enforcer 
plugin entry immediately after the `maven-release-plugin` entry (after line 
378; version is inherited from `<pluginManagement>`):
+
+```xml
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-release-plugin</artifactId>
+                <version>3.3.1</version>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-enforcer-plugin</artifactId>
+            </plugin>
+```
+
+- [ ] **Step 3: Verify the enforcer now executes and passes on the clean tree**
+
+Run:
+```bash
+mvn -q validate -DskipAssembly
+```
+Expected: `BUILD SUCCESS`. To confirm the rule actually ran (not skipped), run:
+```bash
+mvn validate -DskipAssembly -pl core | grep -i "enforce"
+```
+Expected: a line showing `maven-enforcer-plugin:3.6.3:enforce (enforce)` 
executing.
+
+- [ ] **Step 4: Verify the rule catches a skew (manual negative check, then 
revert)**
+
+Temporarily edit `parent/pom.xml` to set the `commons-fileupload2-core` 
managed version to a different value (e.g. `2.0.0-M4` instead of 
`${commons-fileupload2.version}`), then run:
+```bash
+mvn validate -DskipAssembly -pl core
+```
+Expected: `BUILD FAILURE` with a `bannedDependencies` violation naming 
`commons-fileupload2-core`.
+Then revert the edit:
+```bash
+git checkout -- parent/pom.xml
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pom.xml
+git commit -m "WW-5632 build: enforce a single commons-fileupload2 version
+
+Activate maven-enforcer-plugin (previously dormant in pluginManagement)
+with a fileupload-scoped bannedDependencies rule so any divergent
+commons-fileupload2 version fails the build early.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Task 3: Runtime API guard in AbstractMultiPartRequest (B)
+
+**Files:**
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java`
 (create)
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java`
 (imports ~line 22 & 34; new method block near `prepareServletFileUpload` at 
line 213; call site at line 214)
+
+- [ ] **Step 1: Write the failing test**
+
+Create 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java`:
+
+```java
+/*
+ * 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.jakarta.servlet6.JakartaServletDiskFileUpload;
+import org.apache.struts2.StrutsException;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class AbstractMultiPartRequestApiCheckTest {
+
+    @Test
+    public void verifyFileUploadApiPassesForCompatibleClass() {
+        assertThatCode(() -> 
AbstractMultiPartRequest.verifyFileUploadApi(JakartaServletDiskFileUpload.class))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    public void verifyFileUploadApiThrowsForIncompatibleClass() {
+        assertThatThrownBy(() -> 
AbstractMultiPartRequest.verifyFileUploadApi(IncompatibleFileUpload.class))
+                .isInstanceOf(StrutsException.class)
+                .hasMessageContaining("setMaxSize")
+                .hasMessageContaining("Align commons-fileupload2-core");
+    }
+
+    /** Stub lacking the size-limit setters, simulating a binary-incompatible 
fileupload version. */
+    private static class IncompatibleFileUpload {
+    }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+mvn test -DskipAssembly -pl core -Dtest=AbstractMultiPartRequestApiCheckTest
+```
+Expected: FAIL — compilation error `cannot find symbol: method 
verifyFileUploadApi(java.lang.Class)` (the guard does not exist yet). This is 
the red state.
+
+- [ ] **Step 3: Add the two imports**
+
+In `AbstractMultiPartRequest.java`, add the `-core` `AbstractFileUpload` 
import alongside the existing `fileupload2.core` imports (after line 22, 
`import org.apache.commons.fileupload2.core.DiskFileItemFactory;` — keep 
alphabetical, so `AbstractFileUpload` goes *before* it):
+
+```java
+import org.apache.commons.fileupload2.core.AbstractFileUpload;
+import org.apache.commons.fileupload2.core.DiskFileItemFactory;
+```
+
+And add the `StrutsException` import after the existing `StrutsConstants` 
import (line 34):
+
+```java
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.StrutsException;
+```
+
+- [ ] **Step 4: Implement the guard and wire it into 
`prepareServletFileUpload`**
+
+In `AbstractMultiPartRequest.java`, add `ensureFileUploadApiVerified();` as 
the first statement of `prepareServletFileUpload` (currently line 213-214):
+
+```java
+    protected JakartaServletDiskFileUpload prepareServletFileUpload(Charset 
charset, Path saveDir) {
+        ensureFileUploadApiVerified();
+        JakartaServletDiskFileUpload servletFileUpload = 
createJakartaFileUpload(charset, saveDir);
+```
+
+Then add the following members. Place the field next to the other static 
members (e.g. directly after the `LOG` field at line 61), and the three methods 
directly after the `prepareServletFileUpload` method (after its closing brace 
at line 229):
+
+Field (after line 61):
+
+```java
+    /**
+     * Verified once per JVM: whether the commons-fileupload2 API on the 
classpath matches what
+     * Struts compiled against. Guards against a mismatched milestone 
resolving at runtime.
+     */
+    private static volatile boolean fileUploadApiVerified;
+```
+
+Methods (after `prepareServletFileUpload`):
+
+```java
+    /**
+     * Verifies once per JVM that the commons-fileupload2 API on the classpath 
matches what Struts
+     * compiled against, failing fast with an actionable message instead of a 
deep-stack
+     * {@link NoSuchMethodError} when a mismatched milestone is resolved.
+     */
+    private void ensureFileUploadApiVerified() {
+        if (!fileUploadApiVerified) {
+            verifyFileUploadApi(JakartaServletDiskFileUpload.class);
+            fileUploadApiVerified = true;
+        }
+    }
+
+    /**
+     * Probes {@code uploadClass} for the size-limit setters Struts invokes in
+     * {@link #prepareServletFileUpload}. Package-private for testing.
+     *
+     * @param uploadClass the file upload class to verify
+     * @throws StrutsException if any required method is absent, indicating a 
binary-incompatible
+     *                         commons-fileupload2 version on the classpath
+     */
+    static void verifyFileUploadApi(Class<?> uploadClass) {
+        for (String method : new String[]{"setMaxSize", "setMaxFileCount", 
"setMaxFileSize"}) {
+            try {
+                uploadClass.getMethod(method, long.class);
+            } catch (NoSuchMethodException e) {
+                throw new StrutsException(String.format(
+                        "Incompatible Apache Commons FileUpload on the 
classpath: %s.%s(long) is missing. " +
+                                "Detected commons-fileupload2-core version 
[%s] and commons-fileupload2-jakarta-servlet6 version [%s]. " +
+                                "Align commons-fileupload2-core with 
commons-fileupload2-jakarta-servlet6 (use the same release for both).",
+                        uploadClass.getName(), method,
+                        implementationVersion(AbstractFileUpload.class),
+                        implementationVersion(uploadClass)), e);
+            }
+        }
+    }
+
+    private static String implementationVersion(Class<?> clazz) {
+        Package pkg = clazz.getPackage();
+        String version = pkg != null ? pkg.getImplementationVersion() : null;
+        return version != null ? version : "unknown";
+    }
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run:
+```bash
+mvn test -DskipAssembly -pl core -Dtest=AbstractMultiPartRequestApiCheckTest
+```
+Expected: PASS — both tests green.
+
+- [ ] **Step 6: Run the multipart regression tests**
+
+Run:
+```bash
+mvn test -DskipAssembly -pl core -Dtest='*MultiPartRequest*'
+```
+Expected: PASS — `JakartaMultiPartRequestTest` and 
`JakartaStreamMultiPartRequestTest` still green (the guard runs once and does 
not change upload behavior).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java
 \
+        
core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestApiCheckTest.java
+git commit -m "WW-5632 fix(fileupload): fail fast on incompatible 
commons-fileupload2 API
+
+Verify once per JVM that the fileupload size-limit setters exist and
+throw a clear StrutsException reporting the core/jakarta version skew,
+replacing an opaque deep-stack NoSuchMethodError in downstream runtimes.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Final Verification
+
+- [ ] **Run the full core test suite**
+
+Run:
+```bash
+mvn test -DskipAssembly -pl core
+```
+Expected: `BUILD SUCCESS`, all tests pass, enforcer rule executed during 
`validate`.
+
+- [ ] **Confirm the working tree is clean and the branch holds three commits**
+
+Run:
+```bash
+git status --short && git log --oneline -3
+```
+Expected: no uncommitted changes; the three WW-5632 commits on top of the spec 
commit.
diff --git 
a/docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md 
b/docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md
new file mode 100644
index 000000000..551486c29
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md
@@ -0,0 +1,206 @@
+# Design: Harden commons-fileupload2 against milestone churn
+
+**Date:** 2026-06-10
+**Status:** Approved design — pending implementation plan
+**Ticket:** [WW-5632](https://issues.apache.org/jira/browse/WW-5632)
+**Origin:** [user@struts mailing list 
thread](https://lists.apache.org/thread/fcdls8xvd9tp9o6dcog65vkqozv4nq5x)
+(Tamás Barta, Struts 7.1.1 file upload `NoSuchMethodError`)
+**Related (closed):** [WW-5615](https://issues.apache.org/jira/browse/WW-5615) 
— "Adapt to renamed
+methods in Apache Commons FileUpload 2.0.0-M5", fixed in 7.2.0 via PR #1584 / 
commit `d2810d42f`.
+
+## Context
+
+A user on Struts 7.1.1 reported `java.lang.NoSuchMethodError:
+'void 
org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpload.setSizeMax(long)'`
+at upload time. Apache Commons FileUpload 2.0.0-M5 renamed several 
`AbstractFileUpload` methods
+(`setSizeMax`→`setMaxSize`, `setFileSizeMax`→`setMaxFileSize`, 
`setFileCountMax`→`setMaxFileCount`),
+breaking binary compatibility with M4. Struts declared M4 but the user's build 
resolved M5.
+
+WW-5615 (PR #1584) fixed the **symptom** for 7.2.0: it renamed the three call 
sites in
+`AbstractMultiPartRequest.java` and bumped 
`commons-fileupload2-jakarta-servlet6` M4 → M5 in
+`parent/pom.xml`. That commit did **nothing else**.
+
+This design addresses the **class of failure** that WW-5615 left open.
+
+## Root-cause chain (verified on current `main`, 7.2.0-SNAPSHOT)
+
+1. **Milestone dependency.** Struts depends on `-M` builds of 
commons-fileupload2, which break
+   binary compatibility between milestones. Until a 2.0.0 GA exists, Struts is 
committed to
+   milestone artifacts.
+2. **The volatile API lives in an unmanaged artifact.** `setMaxSize(long)` / 
`setMaxFileCount(long)`
+   / `setMaxFileSize(long)` are declared on 
`org.apache.commons.fileupload2.core.AbstractFileUpload`
+   in **`commons-fileupload2-core`** (verified via `javap`). 
`JakartaServletDiskFileUpload` merely
+   inherits them. `parent/pom.xml` `<dependencyManagement>` pins only
+   `commons-fileupload2-jakarta-servlet6` — **`commons-fileupload2-core` is 
unmanaged.** A transitive
+   dependency pulling a different `-core` milestone reproduces the exact 
`NoSuchMethodError` even
+   when `-jakarta-servlet6` is pinned correctly.
+3. **The build guardrail is dormant.** `maven-enforcer-plugin` is configured 
with a
+   `dependencyConvergence` rule, but **only inside `<pluginManagement>`** of 
the root `pom.xml`; it is
+   never bound to an active `<plugins>` section, so it never executes. 
Struts's own build would not
+   catch a fileupload version skew.
+4. **The BOM does not help consumers.** `struts2-bom` exports only Struts 
module versions, not the
+   fileupload version. Downstream apps importing the BOM get no convergence 
assistance.
+
+Net effect: a downstream/transitive dependency can select a mismatched 
`commons-fileupload2-core`
+milestone, and because milestones break binary compatibility, the user gets a 
runtime
+`NoSuchMethodError` deep in request handling, with no build-time warning.
+
+## Goals
+
+- Make a `commons-fileupload2-core` / `-jakarta-servlet6` version skew 
**impossible within Struts's
+  own build**, deterministically.
+- Fail the Struts build **early and clearly** if a future transitive 
dependency wants a
+  commons-fileupload2 version other than the tested one.
+- For downstream consumer runtimes (where Struts's build guards cannot reach), 
replace the opaque
+  deep-stack `NoSuchMethodError` with a **clear, actionable 
`StrutsException`**.
+
+## Non-goals
+
+- **Shading/relocating commons-fileupload2.** Rejected: the library is 
security-sensitive (CVE
+  history); shading would force Struts to re-release on every fileupload CVE, 
against Apache norms,
+  and bloats the artifact.
+- **Exporting the fileupload version through `struts2-bom`.** Considered and 
deferred — out of scope
+  for this change.
+- **Migrating off milestone versions.** Not actionable until a 
commons-fileupload2 2.0.0 GA exists.
+
+## Design
+
+### Part A — Build-time fail-fast (POM)
+
+**A1. Manage both artifacts at one version.**
+Introduce a single `commons-fileupload2.version` property (single source of 
truth) and add a
+`<dependencyManagement>` entry for 
`org.apache.commons:commons-fileupload2-core` alongside the
+existing `commons-fileupload2-jakarta-servlet6` entry in `parent/pom.xml`, 
both referencing the
+property. Because `<dependencyManagement>` wins Maven version mediation, this 
forces a single,
+matched `-core` version across the entire Struts reactor regardless of 
transitive requests —
+closing root-cause #2 deterministically for Struts's own build.
+
+**A2. Activate a narrowly-scoped enforcer (chosen over global 
`dependencyConvergence`).**
+Bind `maven-enforcer-plugin` into an active `<plugins>` section with a 
`bannedDependencies` rule
+scoped **only** to commons-fileupload2: ban all versions of
+`org.apache.commons:commons-fileupload2-core` and
+`org.apache.commons:commons-fileupload2-jakarta-servlet6` **except** the pinned
+`${commons-fileupload2.version}`. This fails the build immediately if any 
transitive dependency
+introduces a different fileupload version, with effectively zero blast radius 
on unrelated
+dependencies.
+
+> **Why not global `dependencyConvergence`?** It has never actually run; 
activating it may surface
+> many pre-existing, unrelated version conflicts across the multi-module 
build, ballooning scope
+> unpredictably. The fileupload-scoped `bannedDependencies` rule targets 
exactly the failure mode in
+> this report. (Global convergence remains a reasonable separate cleanup task, 
out of scope here.)
+
+The pinned version string lives once in the `commons-fileupload2.version` 
property and is referenced
+by both the `<dependencyManagement>` entries and the enforcer rule — no 
duplicated literals.
+
+### Part B — Runtime diagnostics guard
+
+Add a one-time, package-private static check in `AbstractMultiPartRequest`, 
invoked on first use
+(e.g. at the top of `prepareServletFileUpload`), guarded so the reflective 
probe runs **once** per
+JVM — no per-request cost.
+
+**Probe (testable, pure):**
+`static void verifyFileUploadApi(Class<?> uploadClass)` reflectively confirms 
that `uploadClass`
+declares (inherited included) `setMaxSize(long)`, `setMaxFileCount(long)`, and 
`setMaxFileSize(long)`.
+If any is absent it throws `org.apache.struts2.StrutsException`.
+
+**Self-maintaining message (no hardcoded "expected" version):** the exception 
reports the
+implementation versions read at runtime from both packages —
+`org.apache.commons.fileupload2.core.AbstractFileUpload.class.getPackage().getImplementationVersion()`
+(the `-core` version) and 
`JakartaServletDiskFileUpload.class.getPackage().getImplementationVersion()`
+(the `-jakarta-servlet6` version) — names the missing method, and instructs 
the user to align
+`commons-fileupload2-core` with `commons-fileupload2-jakarta-servlet6`. 
Versions fall back to
+`"unknown"` when no manifest implementation version is present. Surfacing the 
**skew** (core vs
+jakarta versions) is the actionable signal; no version constant is baked into 
Struts to drift.
+
+**One-time guard:** the caller wraps 
`verifyFileUploadApi(JakartaServletDiskFileUpload.class)` with a
+JVM-once gate (`static volatile boolean` or a holder). The probe method itself 
is stateless so tests
+can call it repeatedly.
+
+## Testing & verification
+
+**Part A:**
+- `mvn validate -DskipAssembly` runs the enforcer clean on the current tree 
(no fileupload skew
+  exists today).
+- Manual negative check: temporarily declare a conflicting 
`commons-fileupload2-core` version and
+  confirm the build fails with the banned-dependency message; revert.
+
+**Part B (unit tests in `AbstractMultiPartRequestTest`):**
+- `verifyFileUploadApi(JakartaServletDiskFileUpload.class)` does **not** throw 
(real classpath has the
+  M5 API).
+- `verifyFileUploadApi(<stub class lacking the setters>)` throws 
`StrutsException`; assert the message
+  names the missing method and the remediation (align `-core` with 
`-jakarta-servlet6`).
+
+Full suite: `mvn test -DskipAssembly -pl core`.
+
+## Risks
+
+- **Enforcer noise (mitigated).** Scoping `bannedDependencies` to 
commons-fileupload2 only avoids the
+  unbounded scope risk of global `dependencyConvergence`.
+- **Reflective probe drift.** If a future commons-fileupload2 release renames 
these setters again, the
+  probe's hardcoded method names become a deliberate tripwire to update 
alongside the dependency bump
+  — acceptable and intended.
+- **Null implementation version.** Handled via `"unknown"` fallback so the 
guard never NPEs while
+  building its diagnostic message.
+
+## Out of scope / follow-ups
+
+- Tracked under [WW-5632](https://issues.apache.org/jira/browse/WW-5632).
+- Global `dependencyConvergence` cleanup across the reactor.
+- Exporting third-party versions through `struts2-bom`.
+- Revisiting the dependency once commons-fileupload2 2.0.0 GA ships.
+
+## JIRA ticket
+
+**Summary (title):**
+
+```
+Harden commons-fileupload2 dependency against milestone binary-incompatibility
+```
+
+**Description (JIRA wiki markup — paste into the description field):**
+
+```
+h3. Background
+
+[WW-5615|https://issues.apache.org/jira/browse/WW-5615] fixed the 
{{NoSuchMethodError}}
+caused by Apache Commons FileUpload 2.0.0-M5 renaming {{setSizeMax}} -> 
{{setMaxSize}}
+(and friends), shipped in 7.2.0 via 
[#1584|https://github.com/apache/struts/pull/1584].
+That fix addressed the *symptom* for one milestone bump but not the underlying 
*class of
+failure*.
+
+h3. Problem
+
+Struts depends on _milestone_ ({{-M}}) builds of commons-fileupload2, which 
break binary
+compatibility between milestones. Three gaps remain on {{main}}:
+
+* The renamed setters ({{setMaxSize}}, {{setMaxFileCount}}, 
{{setMaxFileSize}}) live in
+  *{{commons-fileupload2-core}}* ({{AbstractFileUpload}}), but only
+  {{commons-fileupload2-jakarta-servlet6}} is pinned in 
{{dependencyManagement}} —
+  {{-core}} is unmanaged, so a transitive dependency can pull a mismatched 
{{-core}}
+  milestone and reproduce the {{NoSuchMethodError}}.
+* The {{maven-enforcer-plugin}} {{dependencyConvergence}} rule sits only in
+  {{<pluginManagement>}} and is never bound to an active {{<plugins>}} 
section, so it
+  never runs — the build cannot catch a fileupload version skew.
+* Downstream consumer runtimes get an opaque, deep-stack {{NoSuchMethodError}} 
with no
+  actionable guidance.
+
+h3. Proposed changes
+
+* *(A1)* Introduce a single {{commons-fileupload2.version}} property and 
manage *both*
+  {{commons-fileupload2-core}} and {{commons-fileupload2-jakarta-servlet6}} at 
that version
+  in {{parent/pom.xml}}, forcing a matched {{-core}} version across the 
reactor.
+* *(A2)* Activate {{maven-enforcer-plugin}} with a fileupload-scoped 
{{bannedDependencies}}
+  rule that fails the build on any commons-fileupload2 version other than the 
pinned one
+  (narrow scope, near-zero blast radius).
+* *(B)* Add a once-per-JVM reflective guard in {{AbstractMultiPartRequest}} 
that throws a
+  clear {{StrutsException}} reporting the {{-core}} vs {{-jakarta-servlet6}} 
version skew
+  instead of an opaque {{NoSuchMethodError}}.
+
+Full design: 
{{docs/superpowers/specs/2026-06-10-fileupload2-milestone-hardening-design.md}}
+
+h3. Affects / Fix version
+
+* Affects: 7.1.1+ (root cause present on 7.2.0-SNAPSHOT {{main}})
+* Component: File Upload
+```
+
diff --git a/parent/pom.xml b/parent/pom.xml
index 74325b8ba..b5f2e4a30 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -125,10 +125,15 @@
                 <artifactId>commons-collections4</artifactId>
                 <version>4.5.0</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-fileupload2-core</artifactId>
+                <version>${commons-fileupload2.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.commons</groupId>
                 <artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
-                <version>2.0.0-M5</version>
+                <version>${commons-fileupload2.version}</version>
             </dependency>
             <dependency>
                 <groupId>commons-io</groupId>
diff --git a/pom.xml b/pom.xml
index 5e954d13e..8dc1eb761 100644
--- a/pom.xml
+++ b/pom.xml
@@ -116,6 +116,7 @@
         <!-- dependency versions in alphanumeric order -->
         <asm.version>9.10.1</asm.version>
         <byte-buddy.version>1.18.8</byte-buddy.version>
+        <commons-fileupload2.version>2.0.0-M5</commons-fileupload2.version>
         <freemarker.version>2.3.34</freemarker.version>
         <hibernate-validator.version>8.0.2.Final</hibernate-validator.version>
         <jackson.version>2.21.3</jackson.version>
@@ -348,7 +349,17 @@
                             <id>enforce</id>
                             <configuration>
                                 <rules>
-                                    <dependencyConvergence />
+                                    <bannedDependencies>
+                                        <message>commons-fileupload2 version 
skew detected: only ${commons-fileupload2.version} is allowed. Align all 
commons-fileupload2 artifacts (core and jakarta-servlet6) to the version 
defined by the commons-fileupload2.version property in the root POM.</message>
+                                        <excludes>
+                                            
<exclude>org.apache.commons:commons-fileupload2-core</exclude>
+                                            
<exclude>org.apache.commons:commons-fileupload2-jakarta-servlet6</exclude>
+                                        </excludes>
+                                        <includes>
+                                            
<include>org.apache.commons:commons-fileupload2-core:${commons-fileupload2.version}</include>
+                                            
<include>org.apache.commons:commons-fileupload2-jakarta-servlet6:${commons-fileupload2.version}</include>
+                                        </includes>
+                                    </bannedDependencies>
                                 </rules>
                             </configuration>
                             <goals>
@@ -376,6 +387,10 @@
                 <artifactId>maven-release-plugin</artifactId>
                 <version>3.3.1</version>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-enforcer-plugin</artifactId>
+            </plugin>
             <plugin>
                 <artifactId>maven-jar-plugin</artifactId>
                 <configuration>


Reply via email to