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 c3e9db1b2 WW-5413 Avoid writing small in-memory multipart uploads to 
disk (#1805)
c3e9db1b2 is described below

commit c3e9db1b21fc9e8453c654e2f7a18e13bf3cf8d3
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 26 04:29:05 2026 +0200

    WW-5413 Avoid writing small in-memory multipart uploads to disk (#1805)
    
    * WW-5413 docs(core): design for in-memory multipart upload optimization
    
    Lazy-materializing UploadedFile plus a new getInputStream() accessor so
    small (in-memory) uploads no longer eagerly write a temp file, while
    getContent() keeps returning a File for backward compatibility.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 docs(core): implementation plan for in-memory upload optimization
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 feat(core): add UploadedFile.getInputStream() streaming accessor
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 feat(core): add lazily-materializing StrutsInMemoryUploadedFile
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 fix(core): make StrutsInMemoryUploadedFile serializable and 
thread-safe
    
    * WW-5413 refactor(core): drop eager temp-file write for in-memory uploads
    
    * WW-5413 test(core): cover deferred-write behavior for in-memory uploads
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 perf(core): avoid materializing in-memory uploads during 
interceptor validation
    
    * WW-5413 chore(core): clean up partial materialization and cover 
isMissing()
    
    * WW-5413 docs(core): sync design/plan with interceptor fix and deviations
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 chore(core): deprecate now-unused 
STRUTS_MESSAGES_INVALID_CONTENT_TYPE_KEY
    
    Mark the orphaned constant @Deprecated(forRemoval = true) instead of 
leaving it
    silently unused. The message key it referenced was only emitted from an 
unreachable
    block in acceptFile() that was removed with the in-memory upload 
optimization.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 test(core): cover materialization failure and getInputStream 
default branches
    
    Address review follow-ups on PR #1805:
    - document that processFileField's retained 'throws IOException' is 
intentional
      (subclass source compatibility), not an oversight
    - add a negative test: getContent() on an unwritable save dir throws 
StrutsException,
      stays unmaterialized, and leaves no partial file behind
    - cover the UploadedFile.getInputStream() default File branch and the 
no-content
      IOException branch
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5413 fix(core): address SonarCloud and Copilot review findings
    
    - materialize() now writes with StandardOpenOption.CREATE_NEW and fails 
closed if the
      target already exists, so a pre-planted file/symlink is never overwritten 
or followed
      (Copilot security note) + regression test
    - defensively copy the content byte array on construction and reject null 
content, so the
      instance owns its bytes and cannot observe caller mutation (Copilot / 
review)
    - delete() uses Files.deleteIfExists and logs the real cause on failure 
instead of a silent
      File.delete() boolean (Sonar MAJOR)
    - reorder field modifiers to JLS order 'transient volatile' (Sonar)
    - tests: assertThat(dir).isEmptyDirectory() instead of 
listFiles().isEmpty() (Sonar);
      drop unused DiskFileItem import (Sonar)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../multipart/JakartaMultiPartRequest.java         | 123 +--
 .../multipart/StrutsInMemoryUploadedFile.java      | 213 ++++++
 .../dispatcher/multipart/StrutsUploadedFile.java   |   8 +
 .../struts2/dispatcher/multipart/UploadedFile.java |  38 +
 .../interceptor/AbstractFileUploadInterceptor.java |  16 +-
 .../multipart/AbstractMultiPartRequestTest.java    |  20 +-
 .../multipart/JakartaMultiPartRequestTest.java     | 217 +-----
 .../multipart/StrutsInMemoryUploadedFileTest.java  | 217 ++++++
 .../multipart/StrutsUploadedFileTest.java          |  49 ++
 .../dispatcher/multipart/UploadedFileTest.java     |  91 +++
 .../ActionFileUploadInterceptorTest.java           |  40 +
 ...6-07-22-WW-5413-inmemory-upload-optimization.md | 827 +++++++++++++++++++++
 ...-WW-5413-inmemory-upload-optimization-design.md | 136 ++++
 13 files changed, 1702 insertions(+), 293 deletions(-)

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 6b19962eb..7177aaaea 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
@@ -25,12 +25,9 @@ import 
org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletDiskFileUpl
 import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
-import org.apache.struts2.dispatcher.LocalizedMessage;
 
-import java.io.File;
 import java.io.IOException;
 import java.nio.charset.Charset;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
@@ -41,13 +38,14 @@ import static 
org.apache.commons.lang3.StringUtils.normalizeSpace;
  * Multipart form data request adapter for Jakarta Commons FileUpload package.
  * 
  * <p>This implementation provides secure handling of multipart requests with 
proper
- * resource management and cleanup. It tracks all temporary files created 
during
- * the upload process and ensures they are properly cleaned up to prevent
- * resource leaks and security vulnerabilities.</p>
- * 
+ * resource management and cleanup. It tracks all {@link DiskFileItem} 
instances
+ * created during the upload process and ensures they are properly cleaned up 
to
+ * prevent resource leaks and security vulnerabilities. In-memory uploads are 
kept
+ * as byte arrays and only materialized to a temporary file lazily, on 
demand.</p>
+ *
  * <p>Key features:</p>
  * <ul>
- *   <li>Automatic tracking and cleanup of temporary files</li>
+ *   <li>Automatic tracking and cleanup of underlying disk file items</li>
  *   <li>Proper error handling with user-friendly error messages</li>
  *   <li>Support for both in-memory and disk-based file uploads</li>
  *   <li>Extensible cleanup mechanisms for customization</li>
@@ -79,11 +77,6 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
      * List to track all DiskFileItem instances for proper cleanup
      */
     private final List<DiskFileItem> diskFileItems = new ArrayList<>();
-    
-    /**
-     * List to track temporary files created for in-memory uploads
-     */
-    private final List<File> temporaryFiles = new ArrayList<>();
 
     /**
      * Processes the multipart upload request using Jakarta Commons FileUpload.
@@ -181,20 +174,21 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
      * <ol>
      *   <li>Validating the file name and field name are not null/empty</li>
      *   <li>Determining if the file is stored in memory or on disk</li>
-     *   <li>For in-memory files: creating a temporary file and copying 
content</li>
+     *   <li>For in-memory files: wrapping the content in a {@link 
StrutsInMemoryUploadedFile}
+     *       that only writes to disk lazily, on demand</li>
      *   <li>For disk files: using the existing file directly</li>
      *   <li>Creating an {@link UploadedFile} abstraction</li>
      *   <li>Adding the file to the uploaded files collection</li>
      * </ol>
-     * 
-     * <p>Temporary files created for in-memory uploads are automatically
-     * tracked for cleanup. Any errors during temporary file creation are
-     * logged and added to the error list for user feedback.</p>
-     * 
+     *
      * @param item the disk file item representing the uploaded file
-     * @see #cleanUpTemporaryFiles()
+     * @throws IOException never thrown by this implementation (in-memory 
content is materialized
+     *                     lazily elsewhere); the clause is retained on the 
signature deliberately for
+     *                     source compatibility with subclasses that override 
this method or catch it
+     *                     from {@code super.processFileField(...)}
      */
-    protected void processFileField(DiskFileItem item, String saveDir) {
+    // NOTE: `throws IOException` is intentionally retained for subclass 
source compatibility - do not remove.
+    protected void processFileField(DiskFileItem item, String saveDir) throws 
IOException {
         // Skip file uploads that don't have a file name - meaning that no 
file was selected.
         if (item.getName() == null || item.getName().trim().isEmpty()) {
             LOG.debug(() -> "No file has been uploaded for the field: " + 
normalizeSpace(item.getFieldName()));
@@ -215,40 +209,14 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
         List<UploadedFile> values = uploadedFiles.computeIfAbsent(fieldName, k 
-> new ArrayList<>());
 
         if (item.isInMemory()) {
-            LOG.debug(() -> "Creating temporary file representing in-memory 
uploaded item: " + normalizeSpace(item.getFieldName()));
-            try {
-                File tempFile = createTemporaryFile(item.getName(), 
Path.of(saveDir));
-                
-                // Track the temporary file for explicit cleanup
-                temporaryFiles.add(tempFile);
-
-                // Write the in-memory content to the temporary file
-                try (java.io.FileOutputStream fos = new 
java.io.FileOutputStream(tempFile)) {
-                    fos.write(item.get());
-                }
-
-                UploadedFile uploadedFile = StrutsUploadedFile.Builder
-                        .create(tempFile)
-                        .withOriginalName(item.getName())
-                        .withContentType(item.getContentType())
-                        .withInputName(item.getFieldName())
-                        .build();
-                values.add(uploadedFile);
-
-                if (LOG.isDebugEnabled()) {
-                    LOG.debug("Created temporary file for in-memory uploaded 
item: {} at {}",
-                             normalizeSpace(item.getName()), 
tempFile.getAbsolutePath());
-                }
-            } catch (IOException e) {
-                LOG.warn("Failed to create temporary file for in-memory 
uploaded item: {}",
-                        normalizeSpace(item.getName()), e);
-                
-                // Add the error to the errors list for proper user feedback
-                LocalizedMessage errorMessage = 
buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{item.getName()});
-                if (!errors.contains(errorMessage)) {
-                    errors.add(errorMessage);
-                }
-            }
+            LOG.debug(() -> "Keeping in-memory uploaded item without writing 
to disk: " + normalizeSpace(item.getFieldName()));
+            UploadedFile uploadedFile = StrutsInMemoryUploadedFile.Builder
+                    .create(item.get(), Path.of(saveDir))
+                    .withOriginalName(item.getName())
+                    .withContentType(item.getContentType())
+                    .withInputName(item.getFieldName())
+                    .build();
+            values.add(uploadedFile);
         } else {
             UploadedFile uploadedFile = StrutsUploadedFile.Builder
                     .create(item.getPath().toFile())
@@ -276,9 +244,8 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
      * be overridden by subclasses to customize cleanup behavior. All 
exceptions
      * are caught and logged to prevent cleanup failures from affecting the
      * overall cleanup process.</p>
-     * 
+     *
      * @see #cleanUp()
-     * @see #cleanUpTemporaryFiles()
      */
     protected void cleanUpDiskFileItems() {
         LOG.debug("Clean up all DiskFileItem instances (both form fields and 
file uploads");
@@ -299,58 +266,26 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
         }
     }
 
-    /**
-     * Cleans up temporary files created for in-memory uploads.
-     * 
-     * <p>This method deletes all temporary files that were created when
-     * processing in-memory uploads. These files are created in
-     * {@link #processFileField(DiskFileItem, String)} when an uploaded file is
-     * stored in memory and needs to be written to disk.</p>
-     * 
-     * <p>The cleanup process:</p>
-     * <ol>
-     *   <li>Iterates through all tracked temporary files</li>
-     *   <li>Checks if each file still exists</li>
-     *   <li>Attempts to delete existing files</li>
-     *   <li>Logs warnings for files that cannot be deleted</li>
-     * </ol>
-     * 
-     * <p>This method can be overridden by subclasses to customize cleanup
-     * behavior. All exceptions are caught and logged to ensure cleanup
-     * continues even if individual file deletions fail.</p>
-     * 
-     * @see #cleanUp()
-     * @see #cleanUpDiskFileItems()
-     */
-    protected void cleanUpTemporaryFiles() {
-        LOG.debug("Cleaning up {} temporary files created for in-memory 
uploads", temporaryFiles.size());
-        for (File tempFile : temporaryFiles) {
-            deleteFile(tempFile.toPath());
-        }
-    }
-
     /**
      * Performs complete cleanup of all resources associated with this request.
-     * 
+     *
      * <p>This method extends the parent cleanup functionality to ensure proper
      * cleanup of Jakarta-specific resources:</p>
      * <ol>
      *   <li>Calls parent cleanup to handle base class resources</li>
      *   <li>Cleans up all tracked {@link DiskFileItem} instances</li>
-     *   <li>Cleans up all temporary files created for in-memory uploads</li>
      *   <li>Clears internal tracking collections</li>
      * </ol>
-     * 
+     *
      * <p>This method is designed to be safe to call multiple times and will
      * not throw exceptions even if cleanup operations fail. All errors are
      * logged for debugging purposes.</p>
-     * 
+     *
      * <p><strong>Important:</strong> This method should always be called in a
      * finally block to ensure resources are properly released, even if
      * exceptions occur during request processing.</p>
-     * 
+     *
      * @see #cleanUpDiskFileItems()
-     * @see #cleanUpTemporaryFiles()
      * @see AbstractMultiPartRequest#cleanUp()
      */
     @Override
@@ -358,10 +293,8 @@ public class JakartaMultiPartRequest extends 
AbstractMultiPartRequest {
         super.cleanUp();
         try {
             cleanUpDiskFileItems();
-            cleanUpTemporaryFiles();
         } finally {
             diskFileItems.clear();
-            temporaryFiles.clear();
         }
     }
 
diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java
 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java
new file mode 100644
index 000000000..5ce01cf88
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java
@@ -0,0 +1,213 @@
+/*
+ * 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.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.StrutsException;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * In-memory backed {@link UploadedFile} for small multipart uploads that 
Commons FileUpload kept
+ * in memory ({@code DiskFileItem.isInMemory() == true}).
+ *
+ * <p>The content is held as a byte array and is written to a temporary file 
only the first time a
+ * caller demands a {@link File} through {@link #getContent()} or {@link 
#getAbsolutePath()} (lazy
+ * materialization). Callers reading through {@link #getInputStream()} never 
touch the disk. The
+ * temporary file uses the secure {@code upload_<uuid>.tmp} naming and ignores 
the user-supplied
+ * original filename.</p>
+ *
+ * <p><strong>Clustered deployments:</strong> the target temporary path is 
resolved on the node that
+ * created this instance. If an un-materialized instance is serialized (for 
example, session
+ * replication) and deserialized on another node, a later {@link 
#getContent()} materializes to that
+ * originating node's path, which may not exist on the new node. Read via 
{@link #getInputStream()},
+ * which never touches disk, when content must survive cross-node 
replication.</p>
+ *
+ * @since 7.3.0
+ */
+public class StrutsInMemoryUploadedFile implements UploadedFile {
+
+    private static final long serialVersionUID = 1L;
+
+    private static final Logger LOG = 
LogManager.getLogger(StrutsInMemoryUploadedFile.class);
+
+    private final byte[] content;
+    private final File targetFile;
+    private final String contentType;
+    private final String originalName;
+    private final String inputName;
+
+    private transient volatile File materializedFile;
+
+    private StrutsInMemoryUploadedFile(byte[] content, Path saveDir, String 
contentType,
+                                       String originalName, String inputName) {
+        // Defensive copy: the instance owns its bytes so callers cannot 
mutate content after construction.
+        this.content = Objects.requireNonNull(content, "content").clone();
+        String name = "upload_" + UUID.randomUUID().toString().replace("-", 
"_") + ".tmp";
+        this.targetFile = saveDir.resolve(name).toFile();
+        this.contentType = contentType;
+        this.originalName = originalName;
+        this.inputName = inputName;
+    }
+
+    private synchronized File materialize() {
+        if (materializedFile == null) {
+            try {
+                // CREATE_NEW fails closed if the target already exists, so we 
never overwrite or
+                // follow a pre-planted file/symlink in the upload directory.
+                Files.write(targetFile.toPath(), content, 
StandardOpenOption.CREATE_NEW);
+            } catch (FileAlreadyExistsException e) {
+                // A file already occupies the target path; do not touch it 
(possible planted file/symlink).
+                throw new StrutsException("Refusing to overwrite existing file 
while materializing in-memory uploaded file: " + targetFile.getName(), e);
+            } catch (IOException e) {
+                // Remove a partial file this call may have created before 
rethrowing.
+                try {
+                    Files.deleteIfExists(targetFile.toPath());
+                } catch (IOException suppressed) {
+                    e.addSuppressed(suppressed);
+                }
+                throw new StrutsException("Could not materialize in-memory 
uploaded file: " + targetFile.getName(), e);
+            }
+            materializedFile = targetFile;
+            LOG.debug("Materialized in-memory uploaded item to {}", 
targetFile.getAbsolutePath());
+        }
+        return materializedFile;
+    }
+
+    @Override
+    public InputStream getInputStream() {
+        return new ByteArrayInputStream(content);
+    }
+
+    @Override
+    public boolean isMissing() {
+        return false;
+    }
+
+    @Override
+    public Long length() {
+        return (long) content.length;
+    }
+
+    @Override
+    public String getName() {
+        return targetFile.getName();
+    }
+
+    @Override
+    public boolean isFile() {
+        File f = materializedFile;
+        return f != null && f.isFile();
+    }
+
+    @Override
+    public boolean delete() {
+        File f = materializedFile;
+        if (f == null) {
+            return true;
+        }
+        try {
+            return Files.deleteIfExists(f.toPath());
+        } catch (IOException e) {
+            LOG.warn("Could not delete materialized in-memory uploaded file: 
{}", f.getAbsolutePath(), e);
+            return false;
+        }
+    }
+
+    @Override
+    public String getAbsolutePath() {
+        return materialize().getAbsolutePath();
+    }
+
+    @Override
+    public File getContent() {
+        return materialize();
+    }
+
+    @Override
+    public String getContentType() {
+        return contentType;
+    }
+
+    @Override
+    public String getOriginalName() {
+        return originalName;
+    }
+
+    @Override
+    public String getInputName() {
+        return inputName;
+    }
+
+    @Override
+    public String toString() {
+        return "StrutsInMemoryUploadedFile{" +
+            "contentType='" + contentType + '\'' +
+            ", originalName='" + originalName + '\'' +
+            ", inputName='" + inputName + '\'' +
+            ", size=" + content.length +
+            '}';
+    }
+
+    public static class Builder {
+        private final byte[] content;
+        private final Path saveDir;
+        private String contentType;
+        private String originalName;
+        private String inputName;
+
+        private Builder(byte[] content, Path saveDir) {
+            this.content = content;
+            this.saveDir = saveDir;
+        }
+
+        public static Builder create(byte[] content, Path saveDir) {
+            return new Builder(content, saveDir);
+        }
+
+        public Builder withContentType(String contentType) {
+            this.contentType = contentType;
+            return this;
+        }
+
+        public Builder withOriginalName(String originalName) {
+            this.originalName = originalName;
+            return this;
+        }
+
+        public Builder withInputName(String inputName) {
+            this.inputName = inputName;
+            return this;
+        }
+
+        public UploadedFile build() {
+            return new StrutsInMemoryUploadedFile(content, saveDir, 
contentType, originalName, inputName);
+        }
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java
 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java
index 492b46fb0..418808945 100644
--- 
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java
+++ 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java
@@ -19,6 +19,9 @@
 package org.apache.struts2.dispatcher.multipart;
 
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
 
 public class StrutsUploadedFile implements UploadedFile {
 
@@ -64,6 +67,11 @@ public class StrutsUploadedFile implements UploadedFile {
         return file;
     }
 
+    @Override
+    public InputStream getInputStream() throws IOException {
+        return new FileInputStream(file);
+    }
+
     @Override
     public String getContentType() {
         return this.contentType;
diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java
index c0af59742..590d1e0c0 100644
--- 
a/core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java
+++ 
b/core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java
@@ -18,6 +18,11 @@
  */
 package org.apache.struts2.dispatcher.multipart;
 
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
 import java.io.Serializable;
 
 /**
@@ -74,4 +79,37 @@ public interface UploadedFile extends Serializable {
      */
     String getInputName();
 
+    /**
+     * Streams the uploaded content without forcing it to disk. 
Implementations backed by
+     * in-memory bytes can return the bytes directly; file-backed 
implementations stream the
+     * file. The default reads whatever {@link #getContent()} exposes.
+     *
+     * @return an input stream over the uploaded content
+     * @throws IOException if the content cannot be read
+     * @since 7.3.0
+     */
+    default InputStream getInputStream() throws IOException {
+        Object content = getContent();
+        if (content instanceof File file) {
+            return new FileInputStream(file);
+        }
+        if (content instanceof byte[] bytes) {
+            return new ByteArrayInputStream(bytes);
+        }
+        throw new IOException("No content stream available for " + getName());
+    }
+
+    /**
+     * Indicates whether this upload has no content available (for example, 
the upload failed).
+     * Implementations that hold their content in memory should override this 
to answer WITHOUT
+     * materializing the content to disk. The default reports missing when 
{@link #getContent()}
+     * is {@code null}.
+     *
+     * @return true if there is no content backing this upload
+     * @since 7.3.0
+     */
+    default boolean isMissing() {
+        return getContent() == null;
+    }
+
 }
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java
 
b/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java
index ee698721b..c7cbb65c8 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java
@@ -48,6 +48,13 @@ public abstract class AbstractFileUploadInterceptor extends 
AbstractInterceptor
     public static final String STRUTS_MESSAGES_ERROR_UPLOADING_KEY = 
"struts.messages.error.uploading";
     public static final String STRUTS_MESSAGES_ERROR_FILE_TOO_LARGE_KEY = 
"struts.messages.error.file.too.large";
     public static final String STRUTS_MESSAGES_INVALID_FILE_KEY = 
"struts.messages.invalid.file";
+    /**
+     * @deprecated since 7.3.0, no longer used. The unreachable content-type 
null-check in
+     * {@code acceptFile()} that referenced this key was removed as part of 
the in-memory upload
+     * optimization (WW-5413); there is no replacement. This constant will be 
removed in a future
+     * version.
+     */
+    @Deprecated(since = "7.3.0", forRemoval = true)
     public static final String STRUTS_MESSAGES_INVALID_CONTENT_TYPE_KEY = 
"struts.messages.invalid.content.type";
     public static final String 
STRUTS_MESSAGES_ERROR_CONTENT_TYPE_NOT_ALLOWED_KEY = 
"struts.messages.error.content.type.not.allowed";
     public static final String 
STRUTS_MESSAGES_ERROR_FILE_EXTENSION_NOT_ALLOWED_KEY = 
"struts.messages.error.file.extension.not.allowed";
@@ -114,8 +121,8 @@ public abstract class AbstractFileUploadInterceptor extends 
AbstractInterceptor
             validation = validationAware;
         }
 
-        // If it's null the upload failed
-        if (file == null || file.getContent() == null) {
+        // If it's missing the upload failed
+        if (file == null || file.isMissing()) {
             String errMsg = getTextMessage(action, 
STRUTS_MESSAGES_ERROR_UPLOADING_KEY, new String[]{inputName});
             if (validation != null) {
                 validation.addFieldError(inputName, errMsg);
@@ -124,11 +131,6 @@ public abstract class AbstractFileUploadInterceptor 
extends AbstractInterceptor
             return false;
         }
 
-        if (file.getContent() == null) {
-            String errMsg = getTextMessage(action, 
STRUTS_MESSAGES_INVALID_CONTENT_TYPE_KEY, new String[]{originalFilename});
-            errorMessages.add(errMsg);
-            LOG.warn(errMsg);
-        }
         if (maximumSize != null && maximumSize < file.length()) {
             String errMsg = getTextMessage(action, 
STRUTS_MESSAGES_ERROR_FILE_TOO_LARGE_KEY, new String[]{
                 inputName, originalFilename, file.getName(), "" + 
file.length(), getMaximumSizeStr(action)
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 6a2450201..c20b72b6d 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
@@ -207,8 +207,6 @@ abstract class AbstractMultiPartRequestTest {
                 .asInstanceOf(InstanceOfAssertFactories.LIST)
                 .containsOnly("file1", "file2");
         assertThat(multiPart.getFile("file1")).allSatisfy(file -> {
-            assertThat(file.isFile())
-                    .isTrue();
             assertThat(file.getOriginalName())
                     .isEqualTo("test1.csv");
             assertThat(file.getContentType())
@@ -220,10 +218,10 @@ abstract class AbstractMultiPartRequestTest {
                     .exists()
                     .content()
                     .isEqualTo("1,2,3,4");
-        });
-        assertThat(multiPart.getFile("file2")).allSatisfy(file -> {
             assertThat(file.isFile())
                     .isTrue();
+        });
+        assertThat(multiPart.getFile("file2")).allSatisfy(file -> {
             assertThat(file.getOriginalName())
                     .isEqualTo("test2.csv");
             assertThat(file.getInputName())
@@ -233,6 +231,8 @@ abstract class AbstractMultiPartRequestTest {
                     .exists()
                     .content()
                     .isEqualTo("5,6,7,8");
+            assertThat(file.isFile())
+                    .isTrue();
         });
     }
 
@@ -258,8 +258,6 @@ abstract class AbstractMultiPartRequestTest {
                 .asInstanceOf(InstanceOfAssertFactories.LIST)
                 .containsOnly("file1", "file2");
         assertThat(multiPart.getFile("file1")).allSatisfy(file -> {
-            assertThat(file.isFile())
-                    .isTrue();
             assertThat(file.getOriginalName())
                     .isEqualTo("test1.csv");
             assertThat(file.getContentType())
@@ -270,10 +268,10 @@ abstract class AbstractMultiPartRequestTest {
                     .exists()
                     .content()
                     .isEqualTo("1,2,3,4");
-        });
-        assertThat(multiPart.getFile("file2")).allSatisfy(file -> {
             assertThat(file.isFile())
                     .isTrue();
+        });
+        assertThat(multiPart.getFile("file2")).allSatisfy(file -> {
             assertThat(file.getOriginalName())
                     .isEqualTo("test2.csv");
             assertThat(file.getContentType())
@@ -285,6 +283,8 @@ abstract class AbstractMultiPartRequestTest {
                     .exists()
                     .content()
                     .isEqualTo("5,6,7,8");
+            assertThat(file.isFile())
+                    .isTrue();
         });
 
         List<UploadedFile> uploadedFiles = new ArrayList<>();
@@ -431,8 +431,6 @@ abstract class AbstractMultiPartRequestTest {
                 .asInstanceOf(InstanceOfAssertFactories.LIST)
                 .containsOnly("file1");
         assertThat(multiPart.getFile("file1")).allSatisfy(file -> {
-            assertThat(file.isFile())
-                    .isTrue();
             assertThat(file.getOriginalName())
                     .isEqualTo("test1.csv");
             assertThat(file.getContentType())
@@ -442,6 +440,8 @@ abstract class AbstractMultiPartRequestTest {
                     .exists()
                     .content()
                     .isEqualTo("Ł,Ś,Ż,Ó");
+            assertThat(file.isFile())
+                    .isTrue();
         });
     }
 
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 15b59f5dd..05af17a70 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
@@ -18,18 +18,15 @@
  */
 package org.apache.struts2.dispatcher.multipart;
 
-import org.apache.commons.fileupload2.core.DiskFileItem;
 import org.apache.struts2.dispatcher.LocalizedMessage;
 import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.Test;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.lang.reflect.Field;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
 
 import static org.apache.commons.lang3.StringUtils.normalizeSpace;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -41,119 +38,6 @@ public class JakartaMultiPartRequestTest extends 
AbstractMultiPartRequestTest {
         return new JakartaMultiPartRequest();
     }
 
-    @Test
-    public void temporaryFileCleanupForInMemoryUploads() throws IOException, 
NoSuchFieldException, IllegalAccessException {
-        // given - small files that will be in-memory
-        String content = formFile("file1", "test1.csv", "a,b,c,d") + 
-                        formFile("file2", "test2.csv", "1,2,3,4") +
-                        endline + "--" + boundary + "--";
-        
-        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
-        
-        // when
-        multiPart.parse(mockRequest, tempDir);
-        
-        // Access private field to verify temporary files are tracked
-        Field tempFilesField = 
JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles");
-        tempFilesField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        List<File> temporaryFiles = (List<File>) tempFilesField.get(multiPart);
-        
-        // Store file paths before cleanup for verification
-        List<String> tempFilePaths = temporaryFiles.stream()
-                .map(File::getAbsolutePath)
-                .toList();
-        
-        // Verify temporary files exist before cleanup
-        assertThat(temporaryFiles).isNotEmpty();
-        for (File tempFile : temporaryFiles) {
-            assertThat(tempFile).exists();
-        }
-        
-        // when - cleanup
-        multiPart.cleanUp();
-        
-        // then - verify files are deleted and tracking list is cleared
-        for (String tempFilePath : tempFilePaths) {
-            assertThat(new File(tempFilePath)).doesNotExist();
-        }
-        assertThat(temporaryFiles).isEmpty();
-    }
-
-    @Test
-    public void cleanupMethodsCanBeOverridden() {
-        // Create a custom implementation to test extensibility
-        class CustomJakartaMultiPartRequest extends JakartaMultiPartRequest {
-            boolean diskFileItemsCleanedUp = false;
-            boolean temporaryFilesCleanedUp = false;
-            
-            @Override
-            protected void cleanUpDiskFileItems() {
-                diskFileItemsCleanedUp = true;
-                super.cleanUpDiskFileItems();
-            }
-            
-            @Override
-            protected void cleanUpTemporaryFiles() {
-                temporaryFilesCleanedUp = true;
-                super.cleanUpTemporaryFiles();
-            }
-        }
-        
-        CustomJakartaMultiPartRequest customMultiPart = new 
CustomJakartaMultiPartRequest();
-        
-        // when
-        customMultiPart.cleanUp();
-        
-        // then
-        assertThat(customMultiPart.diskFileItemsCleanedUp).isTrue();
-        assertThat(customMultiPart.temporaryFilesCleanedUp).isTrue();
-    }
-
-    @Test
-    public void temporaryFileCreationFailureAddsError() throws IOException {
-        // Create a custom implementation that simulates temp file creation 
failure
-        class FaultyJakartaMultiPartRequest extends JakartaMultiPartRequest {
-            @Override
-            protected void processFileField(DiskFileItem item, String saveDir) 
{
-                // Simulate in-memory upload that fails to create temp file
-                if (item.isInMemory()) {
-                    try {
-                        // Simulate IOException during temp file creation
-                        throw new IOException("Simulated temp file creation 
failure");
-                    } catch (IOException e) {
-                        // Add the error to the errors list for proper user 
feedback
-                        LocalizedMessage errorMessage = 
buildErrorMessage(e.getClass(), e.getMessage(), 
-                                                                        new 
Object[]{item.getName()});
-                        if (!errors.contains(errorMessage)) {
-                            errors.add(errorMessage);
-                        }
-                    }
-                } else {
-                    super.processFileField(item, saveDir);
-                }
-            }
-        }
-        
-        FaultyJakartaMultiPartRequest faultyMultiPart = new 
FaultyJakartaMultiPartRequest();
-        
-        // given - small file that would normally be in-memory
-        String content = formFile("file1", "test1.csv", "a,b") + 
-                        endline + "--" + boundary + "--";
-        
-        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
-        
-        // when
-        faultyMultiPart.parse(mockRequest, tempDir);
-        
-        // then - verify error is properly captured
-        assertThat(faultyMultiPart.getErrors())
-                .hasSize(1)
-                .first()
-                .extracting(LocalizedMessage::getTextKey)
-                .isEqualTo("struts.messages.upload.error.IOException");
-    }
-
     @Test
     public void temporaryFileCreationErrorsAreNotDuplicated() throws 
IOException {
         // Test that duplicate errors are not added to the errors list
@@ -222,66 +106,6 @@ public class JakartaMultiPartRequestTest extends 
AbstractMultiPartRequestTest {
         assertThat(multiPart.parameters).isEmpty();
     }
 
-    @Test
-    public void temporaryFilesCreatedInSaveDirectory() throws IOException, 
NoSuchFieldException, IllegalAccessException {
-        // Test that temporary files for in-memory uploads are created in the 
saveDir, not system temp
-        String content = formFile("file1", "test1.csv", "small,content") +
-                        endline + "--" + boundary + "--";
-        
-        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
-        
-        // when
-        multiPart.parse(mockRequest, tempDir);
-        
-        // Access private field to get temporary files
-        Field tempFilesField = 
JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles");
-        tempFilesField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        List<File> temporaryFiles = (List<File>) tempFilesField.get(multiPart);
-        
-        // then - verify temporary files are created in saveDir
-        assertThat(temporaryFiles).isNotEmpty();
-        for (File tempFile : temporaryFiles) {
-            // Verify the temporary file is in the saveDir, not system temp
-            assertThat(tempFile.getParent()).isEqualTo(tempDir);
-            assertThat(tempFile.getName()).startsWith("upload_");
-            assertThat(tempFile.getName()).endsWith(".tmp");
-            assertThat(tempFile).exists();
-        }
-    }
-
-    @Test
-    public void secureTemporaryFileNaming() throws IOException, 
NoSuchFieldException, IllegalAccessException {
-        // Test that temporary files use UUID-based naming for security
-        String content = formFile("file1", "malicious../../../etc/passwd", 
"content") +
-                        endline + "--" + boundary + "--";
-        
-        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
-        
-        // when
-        multiPart.parse(mockRequest, tempDir);
-        
-        // Access private field to get temporary files
-        Field tempFilesField = 
JakartaMultiPartRequest.class.getDeclaredField("temporaryFiles");
-        tempFilesField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        List<File> temporaryFiles = (List<File>) tempFilesField.get(multiPart);
-        
-        // then - verify secure naming prevents directory traversal
-        assertThat(temporaryFiles).isNotEmpty();
-        for (File tempFile : temporaryFiles) {
-            // Verify the temporary file uses secure UUID naming
-            assertThat(tempFile.getName()).startsWith("upload_");
-            assertThat(tempFile.getName()).endsWith(".tmp");
-            // Verify it doesn't contain malicious path elements
-            assertThat(tempFile.getName()).doesNotContain("..");
-            assertThat(tempFile.getName()).doesNotContain("/");
-            assertThat(tempFile.getName()).doesNotContain("\\");
-            // Verify it's in the correct directory
-            assertThat(tempFile.getParent()).isEqualTo(tempDir);
-        }
-    }
-
     @Test
     public void processNormalFormFieldHandlesNullFieldName() throws 
IOException {
         // Test null field name handling in processNormalFormField
@@ -424,7 +248,7 @@ public class JakartaMultiPartRequestTest extends 
AbstractMultiPartRequestTest {
 
     @Test
     public void processFileFieldHandlesEmptyFileName() throws IOException {
-        String content = 
+        String content =
             endline + "--" + boundary + endline +
             "Content-Disposition: form-data; name=\"emptyfile\"; 
filename=\"\"" + endline +
             "Content-Type: text/plain" + endline +
@@ -436,12 +260,12 @@ public class JakartaMultiPartRequestTest extends 
AbstractMultiPartRequestTest {
             endline +
             "valid file content" +
             endline + "--" + boundary + "--";
-        
+
         mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
-        
+
         // when
         multiPart.parse(mockRequest, tempDir);
-        
+
         // then - should only process the file with valid filename
         assertThat(multiPart.getErrors()).isEmpty();
         assertThat(multiPart.uploadedFiles).hasSize(1);
@@ -453,4 +277,35 @@ public class JakartaMultiPartRequestTest extends 
AbstractMultiPartRequestTest {
                 .isEqualTo("valid file content");
     }
 
+    @Test
+    public void inMemoryUploadIsNotWrittenToDiskUntilContentRequested() throws 
IOException {
+        // given - a small file that Commons FileUpload keeps in memory
+        String content = formFile("file1", "test1.csv", "a,b,c,d") +
+                endline + "--" + boundary + "--";
+        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+        // when
+        multiPart.parse(mockRequest, tempDir);
+
+        UploadedFile file = multiPart.getFile("file1")[0];
+
+        // then - nothing written to disk right after parse
+        assertThat(file.isFile()).isFalse();
+
+        // and - content is readable via the stream path without materializing
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
StandardCharsets.UTF_8)).isEqualTo("a,b,c,d");
+        }
+        assertThat(file.isFile()).isFalse();
+
+        // and - getContent() materializes a real file on demand
+        File materialized = (File) file.getContent();
+        assertThat(materialized).exists().hasContent("a,b,c,d");
+        assertThat(file.isFile()).isTrue();
+
+        // and - cleanUp removes the materialized file
+        multiPart.cleanUp();
+        assertThat(materialized).doesNotExist();
+    }
+
 }
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java
new file mode 100644
index 000000000..8b220963a
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.nio.file.Path;
+
+import org.apache.struts2.StrutsException;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class StrutsInMemoryUploadedFileTest {
+
+    @Rule
+    public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    private Path saveDir;
+
+    @Before
+    public void setUp() {
+        saveDir = tempFolder.getRoot().toPath();
+    }
+
+    private UploadedFile build(byte[] content) {
+        return StrutsInMemoryUploadedFile.Builder
+                .create(content, saveDir)
+                .withOriginalName("orig.txt")
+                .withContentType("text/plain")
+                .withInputName("file")
+                .build();
+    }
+
+    @Test
+    public void getInputStreamReturnsBytesWithoutWritingFile() throws 
IOException {
+        UploadedFile file = build("hello".getBytes(UTF_8));
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("hello");
+        }
+
+        assertThat(file.isFile()).isFalse();
+        assertThat(tempFolder.getRoot()).isEmptyDirectory();
+    }
+
+    @Test
+    public void getContentThrowsStrutsExceptionAndLeavesNoFileWhenWriteFails() 
{
+        // save directory does not exist -> Files.write in materialize() fails
+        Path missingDir = tempFolder.getRoot().toPath().resolve("no-such-dir");
+        UploadedFile file = StrutsInMemoryUploadedFile.Builder
+                .create("x".getBytes(UTF_8), missingDir)
+                .withOriginalName("orig.txt")
+                .withContentType("text/plain")
+                .withInputName("file")
+                .build();
+
+        
assertThatThrownBy(file::getContent).isInstanceOf(StrutsException.class);
+
+        assertThat(file.isFile()).isFalse();                      // not 
marked materialized
+        assertThat(missingDir.toFile()).doesNotExist();           // no 
partial file left behind
+        assertThat(tempFolder.getRoot()).isEmptyDirectory();   // nothing 
leaked into the save root
+    }
+
+    @Test
+    public void getContentFailsClosedWhenTargetAlreadyExists() throws 
IOException {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        // Pre-plant a file at the exact target name (simulates a collision or 
planted file/symlink).
+        File planted = new File(tempFolder.getRoot(), file.getName());
+        java.nio.file.Files.writeString(planted.toPath(), "pre-existing");
+
+        
assertThatThrownBy(file::getContent).isInstanceOf(StrutsException.class);
+
+        // The pre-existing file must be neither overwritten nor deleted.
+        assertThat(planted).exists();
+        
assertThat(java.nio.file.Files.readString(planted.toPath())).isEqualTo("pre-existing");
+        assertThat(file.isFile()).isFalse();
+    }
+
+    @Test
+    public void getContentMaterializesFileExactlyOnce() {
+        UploadedFile file = build("data".getBytes(UTF_8));
+
+        File first = (File) file.getContent();
+        File second = (File) file.getContent();
+
+        assertThat(first).exists().hasContent("data");
+        assertThat(second).isSameAs(first);
+        assertThat(tempFolder.getRoot().listFiles()).hasSize(1);
+    }
+
+    @Test
+    public void getAbsolutePathMaterializesFile() {
+        UploadedFile file = build("data".getBytes(UTF_8));
+
+        String path = file.getAbsolutePath();
+
+        assertThat(new File(path)).exists().hasContent("data");
+        assertThat(file.isFile()).isTrue();
+    }
+
+    @Test
+    public void isFileFalseBeforeMaterializationTrueAfter() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        assertThat(file.isFile()).isFalse();
+        file.getContent();
+        assertThat(file.isFile()).isTrue();
+    }
+
+    @Test
+    public void lengthAndMetadataDoNotMaterialize() {
+        UploadedFile file = build("abcd".getBytes(UTF_8));
+
+        assertThat(file.length()).isEqualTo(4L);
+        assertThat(file.getContentType()).isEqualTo("text/plain");
+        assertThat(file.getOriginalName()).isEqualTo("orig.txt");
+        assertThat(file.getInputName()).isEqualTo("file");
+        assertThat(file.getName()).startsWith("upload_").endsWith(".tmp");
+
+        assertThat(file.isFile()).isFalse();
+        assertThat(tempFolder.getRoot()).isEmptyDirectory();
+    }
+
+    @Test
+    public void deleteRemovesMaterializedFile() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+        File materialized = (File) file.getContent();
+        assertThat(materialized).exists();
+
+        assertThat(file.delete()).isTrue();
+        assertThat(materialized).doesNotExist();
+    }
+
+    @Test
+    public void deleteIsNoOpWhenNotMaterialized() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        assertThat(file.delete()).isTrue();
+        assertThat(tempFolder.getRoot()).isEmptyDirectory();
+    }
+
+    @Test
+    public void maliciousOriginalNameDoesNotLeakIntoTempName() {
+        UploadedFile file = StrutsInMemoryUploadedFile.Builder
+                .create("x".getBytes(UTF_8), saveDir)
+                .withOriginalName("../../etc/passwd")
+                .build();
+
+        file.getContent(); // materialize
+
+        assertThat(file.getName()).startsWith("upload_").endsWith(".tmp");
+        
assertThat(file.getName()).doesNotContain("..").doesNotContain("/").doesNotContain("\\");
+        assertThat(new File(saveDir.toFile(), file.getName())).exists();
+    }
+
+    @Test
+    public void isMissingIsFalseWithoutMaterializing() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        assertThat(file.isMissing()).isFalse();
+        assertThat(file.isFile()).isFalse();                 // did not 
materialize
+        assertThat(tempFolder.getRoot()).isEmptyDirectory();
+    }
+
+    @Test
+    public void isSerializableWhenNotMaterialized() throws IOException, 
ClassNotFoundException {
+        UploadedFile file = build("payload".getBytes(UTF_8));
+
+        byte[] bytes;
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
+             ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+            oos.writeObject(file);
+            bytes = bos.toByteArray();
+        }
+
+        UploadedFile restored;
+        try (ObjectInputStream ois = new ObjectInputStream(new 
ByteArrayInputStream(bytes))) {
+            restored = (UploadedFile) ois.readObject();
+        }
+
+        assertThat(restored.length()).isEqualTo(7L);
+        assertThat(restored.getContentType()).isEqualTo("text/plain");
+        assertThat(restored.getName()).startsWith("upload_").endsWith(".tmp");
+        try (InputStream in = restored.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("payload");
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java
new file mode 100644
index 000000000..23df88dbc
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class StrutsUploadedFileTest {
+
+    @Rule
+    public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    @Test
+    public void getInputStreamReadsFileContent() throws IOException {
+        File backing = tempFolder.newFile("upload_test.tmp");
+        Files.writeString(backing.toPath(), "hello");
+
+        UploadedFile file = StrutsUploadedFile.Builder.create(backing).build();
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("hello");
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java
new file mode 100644
index 000000000..49282cffa
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class UploadedFileTest {
+
+    @Test
+    public void defaultGetInputStreamReadsByteArrayContent() throws 
IOException {
+        UploadedFile file = new UploadedFile() {
+            public Long length() { return 3L; }
+            public String getName() { return "x"; }
+            public String getOriginalName() { return "x"; }
+            public boolean isFile() { return false; }
+            public boolean delete() { return true; }
+            public String getAbsolutePath() { return null; }
+            public Object getContent() { return "abc".getBytes(UTF_8); }
+            public String getContentType() { return "text/plain"; }
+            public String getInputName() { return "file"; }
+        };
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), UTF_8)).isEqualTo("abc");
+        }
+    }
+
+    @Test
+    public void defaultGetInputStreamReadsFileContent() throws IOException {
+        File f = File.createTempFile("upload_", ".tmp");
+        try {
+            Files.writeString(f.toPath(), "hi");
+            try (InputStream in = uploadedFileReturning(f).getInputStream()) {
+                assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("hi");
+            }
+        } finally {
+            assertThat(f.delete()).isTrue();
+        }
+    }
+
+    @Test
+    public void defaultGetInputStreamThrowsWhenNoContent() {
+        assertThatThrownBy(uploadedFileReturning(null)::getInputStream)
+                .isInstanceOf(IOException.class);
+    }
+
+    @Test
+    public void defaultIsMissingReflectsContent() {
+        
assertThat(uploadedFileReturning("abc".getBytes(UTF_8)).isMissing()).isFalse();
+        assertThat(uploadedFileReturning(null).isMissing()).isTrue();
+    }
+
+    private static UploadedFile uploadedFileReturning(Object content) {
+        return new UploadedFile() {
+            public Long length() { return 0L; }
+            public String getName() { return "x"; }
+            public String getOriginalName() { return "x"; }
+            public boolean isFile() { return false; }
+            public boolean delete() { return true; }
+            public String getAbsolutePath() { return null; }
+            public Object getContent() { return content; }
+            public String getContentType() { return "text/plain"; }
+            public String getInputName() { return "file"; }
+        };
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java
index 61339327d..058dabe11 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java
@@ -27,6 +27,7 @@ import org.apache.struts2.ValidationAwareSupport;
 import org.apache.struts2.action.UploadedFilesAware;
 import org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest;
 import org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper;
+import org.apache.struts2.dispatcher.multipart.StrutsInMemoryUploadedFile;
 import org.apache.struts2.dispatcher.multipart.StrutsUploadedFile;
 import org.apache.struts2.dispatcher.multipart.UploadedFile;
 import org.apache.struts2.locale.DefaultLocaleProvider;
@@ -171,6 +172,45 @@ public class ActionFileUploadInterceptorTest extends 
StrutsInternalTestCase {
                 .contains("inputName");
     }
 
+    public void testAcceptFileDoesNotMaterializeInMemoryUpload() {
+        interceptor.setAllowedTypes("text/plain");
+
+        ValidationAwareSupport validation = new ValidationAwareSupport();
+        UploadedFile file = StrutsInMemoryUploadedFile.Builder
+                .create("hello".getBytes(StandardCharsets.UTF_8), 
tempDir.toPath())
+                .withContentType("text/plain")
+                .withOriginalName("f.txt")
+                .withInputName("inputName")
+                .build();
+
+        boolean ok = interceptor.acceptFile(validation, file, "f.txt", 
"text/plain", "inputName");
+
+        assertThat(ok).isTrue();
+        assertThat(validation.hasErrors()).isFalse();
+        // The optimization: validation must NOT have written the in-memory 
upload to disk.
+        assertThat(file.isFile()).isFalse();
+    }
+
+    public void testRejectedInMemoryUploadIsStillNotMaterialized() {
+        interceptor.setAllowedTypes("text/plain");
+
+        ValidationAwareSupport validation = new ValidationAwareSupport();
+        UploadedFile file = StrutsInMemoryUploadedFile.Builder
+                .create("hello".getBytes(StandardCharsets.UTF_8), 
tempDir.toPath())
+                .withContentType("text/html")
+                .withOriginalName("f.html")
+                .withInputName("inputName")
+                .build();
+
+        // wrong content type -> rejected
+        boolean ok = interceptor.acceptFile(validation, file, "f.html", 
"text/html", "inputName");
+
+        assertThat(ok).isFalse();
+        assertThat(validation.hasErrors()).isTrue();
+        // Even on rejection, no disk write happened.
+        assertThat(file.isFile()).isFalse();
+    }
+
     public void testAcceptFileWithMaxSize() throws Exception {
         interceptor.setMaximumSize(10L);
 
diff --git 
a/docs/superpowers/plans/2026-07-22-WW-5413-inmemory-upload-optimization.md 
b/docs/superpowers/plans/2026-07-22-WW-5413-inmemory-upload-optimization.md
new file mode 100644
index 000000000..1d7810597
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-22-WW-5413-inmemory-upload-optimization.md
@@ -0,0 +1,827 @@
+# WW-5413 In-memory Multipart Upload Optimization — 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:** Stop eagerly writing small (in-memory) multipart uploads to a 
temporary file; keep them in memory and materialize a file only when a caller 
demands one, while `getContent()` still returns a `java.io.File` for full 
backward compatibility.
+
+**Architecture:** Add a `default InputStream getInputStream()` to the 
`UploadedFile` interface (the type-safe, no-disk read path). Introduce 
`StrutsInMemoryUploadedFile`, a byte-array-backed `UploadedFile` that lazily 
writes a temp file only on `getContent()`/`getAbsolutePath()`. Rewire 
`JakartaMultiPartRequest.processFileField()` to build it for 
`item.isInMemory()`, and drop the now-obsolete eager-write / `temporaryFiles` 
bookkeeping (cleanup rides the existing `AbstractMultiPartRequest [...]
+
+**Tech Stack:** Java (Struts core module), Apache Commons FileUpload2 
2.0.0-M5, JUnit 4 + AssertJ, Maven.
+
+## Global Constraints
+
+- **Commit prefix:** every commit message MUST start with `WW-5413` followed 
by a conventional type, e.g. `WW-5413 feat(core): ...`.
+- **`getContent()` runtime type MUST remain `java.io.File`** for every 
`UploadedFile` implementation — never return `byte[]` from it. The 
bytes-without-a-file path is `getInputStream()` only.
+- **Core tests are JUnit 4** (`org.junit.Test`, AssertJ). Do NOT use JUnit 5. 
New standalone test classes must NOT extend `XWorkTestCase` (irrelevant here) — 
plain JUnit 4 classes run fine under Surefire.
+- **Secure temp-file naming:** materialized files MUST use the pattern 
`upload_<uuid>.tmp` in the provided save directory; the user-supplied original 
filename MUST NOT influence the on-disk name.
+- **Backward compatibility:** `UploadedFile.getInputStream()` MUST be a 
`default` method so third-party implementations keep compiling.
+- **Build/test command:** `mvn test -DskipAssembly -pl core 
-Dtest=ClassName#methodName` (single test) or `-Dtest=ClassName` (whole class).
+
+---
+
+## File Structure
+
+- 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java` 
— **modify**: add `default InputStream getInputStream()`.
+- 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java`
 — **modify**: override `getInputStream()` to stream the backing file.
+- 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java`
 — **create**: byte-array-backed, lazily-materializing `UploadedFile`.
+- 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java`
 — **modify**: build `StrutsInMemoryUploadedFile` for in-memory items; remove 
eager write, `temporaryFiles`, `cleanUpTemporaryFiles()`.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java`
 — **create**: interface default-method test.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java`
 — **create**: `getInputStream()` override test.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java`
 — **create**: full lazy-materialization unit tests.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java`
 — **modify**: remove 5 tests that reference removed internals; add integration 
tests for the new behavior.
+
+---
+
+## Task 1: Streaming accessor on the File-backed path
+
+Adds `getInputStream()` to the interface (default) and overrides it in the 
existing `File`-backed implementation.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java`
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java`
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java`
 (create)
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java`
 (create)
+
+**Interfaces:**
+- Produces: `UploadedFile.getInputStream() throws IOException` returning an 
`InputStream` over the file's bytes. Default impl: `File` content → 
`FileInputStream`; `byte[]` content → `ByteArrayInputStream`; otherwise 
`IOException`.
+
+- [ ] **Step 1: Write the failing interface default-method test**
+
+Create 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.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.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class UploadedFileTest {
+
+    @Test
+    public void defaultGetInputStreamReadsByteArrayContent() throws 
IOException {
+        UploadedFile file = new UploadedFile() {
+            public Long length() { return 3L; }
+            public String getName() { return "x"; }
+            public String getOriginalName() { return "x"; }
+            public boolean isFile() { return false; }
+            public boolean delete() { return true; }
+            public String getAbsolutePath() { return null; }
+            public Object getContent() { return "abc".getBytes(UTF_8); }
+            public String getContentType() { return "text/plain"; }
+            public String getInputName() { return "file"; }
+        };
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), UTF_8)).isEqualTo("abc");
+        }
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=UploadedFileTest`
+Expected: COMPILE FAILURE — `getInputStream()` is not defined on 
`UploadedFile`.
+
+- [ ] **Step 3: Add the default method to the interface**
+
+In `UploadedFile.java`, add these imports below `import java.io.Serializable;`:
+
+```java
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+```
+
+Add this method inside the interface (e.g. after `getInputName()`):
+
+```java
+    /**
+     * Streams the uploaded content without forcing it to disk. 
Implementations backed by
+     * in-memory bytes can return the bytes directly; file-backed 
implementations stream the
+     * file. The default reads whatever {@link #getContent()} exposes.
+     *
+     * @return an input stream over the uploaded content
+     * @throws IOException if the content cannot be read
+     * @since 7.3.0
+     */
+    default InputStream getInputStream() throws IOException {
+        Object content = getContent();
+        if (content instanceof File file) {
+            return new FileInputStream(file);
+        }
+        if (content instanceof byte[] bytes) {
+            return new ByteArrayInputStream(bytes);
+        }
+        throw new IOException("No content stream available for " + getName());
+    }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=UploadedFileTest`
+Expected: PASS.
+
+- [ ] **Step 5: Write the failing StrutsUploadedFile override test**
+
+Create 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.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.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class StrutsUploadedFileTest {
+
+    @Rule
+    public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    @Test
+    public void getInputStreamReadsFileContent() throws IOException {
+        File backing = tempFolder.newFile("upload_test.tmp");
+        Files.writeString(backing.toPath(), "hello");
+
+        UploadedFile file = StrutsUploadedFile.Builder.create(backing).build();
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("hello");
+        }
+    }
+}
+```
+
+- [ ] **Step 6: Run test to verify it passes via the interface default**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsUploadedFileTest`
+Expected: PASS (the default method already handles the `File` branch).
+
+- [ ] **Step 7: Add an explicit override in StrutsUploadedFile**
+
+In `StrutsUploadedFile.java`, replace the import block:
+
+```java
+import java.io.File;
+```
+
+with:
+
+```java
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+```
+
+Add this method (e.g. after `getContent()`):
+
+```java
+    @Override
+    public InputStream getInputStream() throws IOException {
+        return new FileInputStream(file);
+    }
+```
+
+- [ ] **Step 8: Run both tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=UploadedFileTest,StrutsUploadedFileTest`
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/multipart/UploadedFile.java \
+        
core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFile.java
 \
+        
core/src/test/java/org/apache/struts2/dispatcher/multipart/UploadedFileTest.java
 \
+        
core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsUploadedFileTest.java
+git commit -m "WW-5413 feat(core): add UploadedFile.getInputStream() streaming 
accessor"
+```
+
+---
+
+## Task 2: `StrutsInMemoryUploadedFile` (lazy materialization)
+
+The byte-array-backed implementation. This is the core of the optimization.
+
+**Files:**
+- Create: 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java`
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java`
 (create)
+
+**Interfaces:**
+- Consumes: `UploadedFile` (incl. `getInputStream()` from Task 1), 
`org.apache.struts2.StrutsException`.
+- Produces:
+  - `StrutsInMemoryUploadedFile.Builder.create(byte[] content, 
java.nio.file.Path saveDir)` → `Builder`
+  - 
`Builder.withContentType(String).withOriginalName(String).withInputName(String).build()`
 → `UploadedFile`
+  - Behavior: `getInputStream()` → `ByteArrayInputStream` (no disk); 
`getContent()`/`getAbsolutePath()` write once to `saveDir/upload_<uuid>.tmp` 
and cache; `isFile()` false until materialized; `getName()` returns the 
pre-chosen `upload_<uuid>.tmp`; `delete()` removes the materialized file 
(no-op, returns `true`, if never materialized).
+
+- [ ] **Step 1: Write the failing unit tests**
+
+Create 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.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.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class StrutsInMemoryUploadedFileTest {
+
+    @Rule
+    public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    private Path saveDir;
+
+    @Before
+    public void setUp() {
+        saveDir = tempFolder.getRoot().toPath();
+    }
+
+    private UploadedFile build(byte[] content) {
+        return StrutsInMemoryUploadedFile.Builder
+                .create(content, saveDir)
+                .withOriginalName("orig.txt")
+                .withContentType("text/plain")
+                .withInputName("file")
+                .build();
+    }
+
+    @Test
+    public void getInputStreamReturnsBytesWithoutWritingFile() throws 
IOException {
+        UploadedFile file = build("hello".getBytes(UTF_8));
+
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
UTF_8)).isEqualTo("hello");
+        }
+
+        assertThat(file.isFile()).isFalse();
+        assertThat(tempFolder.getRoot().listFiles()).isEmpty();
+    }
+
+    @Test
+    public void getContentMaterializesFileExactlyOnce() {
+        UploadedFile file = build("data".getBytes(UTF_8));
+
+        File first = (File) file.getContent();
+        File second = (File) file.getContent();
+
+        assertThat(first).exists().hasContent("data");
+        assertThat(second).isSameAs(first);
+        assertThat(tempFolder.getRoot().listFiles()).hasSize(1);
+    }
+
+    @Test
+    public void getAbsolutePathMaterializesFile() {
+        UploadedFile file = build("data".getBytes(UTF_8));
+
+        String path = file.getAbsolutePath();
+
+        assertThat(new File(path)).exists().hasContent("data");
+        assertThat(file.isFile()).isTrue();
+    }
+
+    @Test
+    public void isFileFalseBeforeMaterializationTrueAfter() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        assertThat(file.isFile()).isFalse();
+        file.getContent();
+        assertThat(file.isFile()).isTrue();
+    }
+
+    @Test
+    public void lengthAndMetadataDoNotMaterialize() {
+        UploadedFile file = build("abcd".getBytes(UTF_8));
+
+        assertThat(file.length()).isEqualTo(4L);
+        assertThat(file.getContentType()).isEqualTo("text/plain");
+        assertThat(file.getOriginalName()).isEqualTo("orig.txt");
+        assertThat(file.getInputName()).isEqualTo("file");
+        assertThat(file.getName()).startsWith("upload_").endsWith(".tmp");
+
+        assertThat(file.isFile()).isFalse();
+        assertThat(tempFolder.getRoot().listFiles()).isEmpty();
+    }
+
+    @Test
+    public void deleteRemovesMaterializedFile() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+        File materialized = (File) file.getContent();
+        assertThat(materialized).exists();
+
+        assertThat(file.delete()).isTrue();
+        assertThat(materialized).doesNotExist();
+    }
+
+    @Test
+    public void deleteIsNoOpWhenNotMaterialized() {
+        UploadedFile file = build("x".getBytes(UTF_8));
+
+        assertThat(file.delete()).isTrue();
+        assertThat(tempFolder.getRoot().listFiles()).isEmpty();
+    }
+
+    @Test
+    public void maliciousOriginalNameDoesNotLeakIntoTempName() {
+        UploadedFile file = StrutsInMemoryUploadedFile.Builder
+                .create("x".getBytes(UTF_8), saveDir)
+                .withOriginalName("../../etc/passwd")
+                .build();
+
+        file.getContent(); // materialize
+
+        assertThat(file.getName()).startsWith("upload_").endsWith(".tmp");
+        
assertThat(file.getName()).doesNotContain("..").doesNotContain("/").doesNotContain("\\");
+        assertThat(new File(saveDir.toFile(), file.getName())).exists();
+    }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsInMemoryUploadedFileTest`
+Expected: COMPILE FAILURE — `StrutsInMemoryUploadedFile` does not exist.
+
+- [ ] **Step 3: Implement `StrutsInMemoryUploadedFile`**
+
+Create 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.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.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.StrutsException;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+
+/**
+ * In-memory backed {@link UploadedFile} for small multipart uploads that 
Commons FileUpload kept
+ * in memory ({@code DiskFileItem.isInMemory() == true}).
+ *
+ * <p>The content is held as a byte array and is written to a temporary file 
only the first time a
+ * caller demands a {@link File} through {@link #getContent()} or {@link 
#getAbsolutePath()} (lazy
+ * materialization). Callers reading through {@link #getInputStream()} never 
touch the disk. The
+ * temporary file uses the secure {@code upload_<uuid>.tmp} naming and ignores 
the user-supplied
+ * original filename.</p>
+ *
+ * @since 7.3.0
+ */
+public class StrutsInMemoryUploadedFile implements UploadedFile {
+
+    private static final Logger LOG = 
LogManager.getLogger(StrutsInMemoryUploadedFile.class);
+
+    private final byte[] content;
+    private final Path saveDir;
+    private final String name;
+    private final String contentType;
+    private final String originalName;
+    private final String inputName;
+
+    private transient File materializedFile;
+
+    private StrutsInMemoryUploadedFile(byte[] content, Path saveDir, String 
contentType,
+                                       String originalName, String inputName) {
+        this.content = content;
+        this.saveDir = saveDir;
+        this.contentType = contentType;
+        this.originalName = originalName;
+        this.inputName = inputName;
+        this.name = "upload_" + UUID.randomUUID().toString().replace("-", "_") 
+ ".tmp";
+    }
+
+    private synchronized File materialize() {
+        if (materializedFile == null) {
+            File target = saveDir.resolve(name).toFile();
+            try {
+                Files.write(target.toPath(), content);
+            } catch (IOException e) {
+                throw new StrutsException("Could not materialize in-memory 
uploaded file: " + name, e);
+            }
+            materializedFile = target;
+            LOG.debug("Materialized in-memory uploaded item to {}", 
target.getAbsolutePath());
+        }
+        return materializedFile;
+    }
+
+    @Override
+    public InputStream getInputStream() {
+        return new ByteArrayInputStream(content);
+    }
+
+    @Override
+    public Long length() {
+        return (long) content.length;
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public boolean isFile() {
+        return materializedFile != null && materializedFile.isFile();
+    }
+
+    @Override
+    public boolean delete() {
+        if (materializedFile != null) {
+            return materializedFile.delete();
+        }
+        return true;
+    }
+
+    @Override
+    public String getAbsolutePath() {
+        return materialize().getAbsolutePath();
+    }
+
+    @Override
+    public File getContent() {
+        return materialize();
+    }
+
+    @Override
+    public String getContentType() {
+        return contentType;
+    }
+
+    @Override
+    public String getOriginalName() {
+        return originalName;
+    }
+
+    @Override
+    public String getInputName() {
+        return inputName;
+    }
+
+    @Override
+    public String toString() {
+        return "StrutsInMemoryUploadedFile{" +
+            "contentType='" + contentType + '\'' +
+            ", originalName='" + originalName + '\'' +
+            ", inputName='" + inputName + '\'' +
+            ", size=" + content.length +
+            '}';
+    }
+
+    public static class Builder {
+        private final byte[] content;
+        private final Path saveDir;
+        private String contentType;
+        private String originalName;
+        private String inputName;
+
+        private Builder(byte[] content, Path saveDir) {
+            this.content = content;
+            this.saveDir = saveDir;
+        }
+
+        public static Builder create(byte[] content, Path saveDir) {
+            return new Builder(content, saveDir);
+        }
+
+        public Builder withContentType(String contentType) {
+            this.contentType = contentType;
+            return this;
+        }
+
+        public Builder withOriginalName(String originalName) {
+            this.originalName = originalName;
+            return this;
+        }
+
+        public Builder withInputName(String inputName) {
+            this.inputName = inputName;
+            return this;
+        }
+
+        public UploadedFile build() {
+            return new StrutsInMemoryUploadedFile(content, saveDir, 
contentType, originalName, inputName);
+        }
+    }
+}
+```
+
+> Note: `org.apache.struts2.StrutsException` is the same exception class used 
in `AbstractMultiPartRequest`. Confirm the import resolves; it is an unchecked 
`RuntimeException`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsInMemoryUploadedFileTest`
+Expected: PASS (all 8 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFile.java
 \
+        
core/src/test/java/org/apache/struts2/dispatcher/multipart/StrutsInMemoryUploadedFileTest.java
+git commit -m "WW-5413 feat(core): add lazily-materializing 
StrutsInMemoryUploadedFile"
+```
+
+---
+
+## Task 3: Rewire `JakartaMultiPartRequest` and clean up obsolete internals
+
+Switch the in-memory branch to `StrutsInMemoryUploadedFile`, remove the eager 
write and `temporaryFiles` bookkeeping, and update the existing tests that 
reference the removed internals so the suite compiles and passes.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java`
+- Modify: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java`
+
+**Interfaces:**
+- Consumes: `StrutsInMemoryUploadedFile.Builder` (Task 2), existing 
`StrutsUploadedFile.Builder`.
+- Produces: `JakartaMultiPartRequest.processFileField(DiskFileItem, String)` 
no longer creates temp files eagerly; the `temporaryFiles` field and 
`cleanUpTemporaryFiles()` method are removed.
+
+- [ ] **Step 1: Replace the in-memory branch in `processFileField`**
+
+In `JakartaMultiPartRequest.java`, replace the entire `if (item.isInMemory()) 
{ ... } else { ... }` block (the eager `FileOutputStream` write path) with:
+
+```java
+        if (item.isInMemory()) {
+            LOG.debug(() -> "Keeping in-memory uploaded item without writing 
to disk: " + normalizeSpace(item.getFieldName()));
+            UploadedFile uploadedFile = StrutsInMemoryUploadedFile.Builder
+                    .create(item.get(), Path.of(saveDir))
+                    .withOriginalName(item.getName())
+                    .withContentType(item.getContentType())
+                    .withInputName(item.getFieldName())
+                    .build();
+            values.add(uploadedFile);
+        } else {
+            UploadedFile uploadedFile = StrutsUploadedFile.Builder
+                    .create(item.getPath().toFile())
+                    .withOriginalName(item.getName())
+                    .withContentType(item.getContentType())
+                    .withInputName(item.getFieldName())
+                    .build();
+            values.add(uploadedFile);
+        }
+```
+
+- [ ] **Step 2: Remove the `temporaryFiles` field**
+
+Delete this field and its Javadoc (the block around lines 83–86):
+
+```java
+    /**
+     * List to track temporary files created for in-memory uploads
+     */
+    private final List<File> temporaryFiles = new ArrayList<>();
+```
+
+- [ ] **Step 3: Remove `cleanUpTemporaryFiles()` and its call**
+
+Delete the entire `cleanUpTemporaryFiles()` method (its Javadoc + body). In 
`cleanUp()`, remove the `cleanUpTemporaryFiles();` line and the 
`temporaryFiles.clear();` line, leaving:
+
+```java
+    @Override
+    public void cleanUp() {
+        super.cleanUp();
+        try {
+            cleanUpDiskFileItems();
+        } finally {
+            diskFileItems.clear();
+        }
+    }
+```
+
+- [ ] **Step 4: Fix imports**
+
+Remove `import java.io.File;` (no longer used in this class). Leave 
`java.nio.file.Path` (used by `Path.of(saveDir)`). If the compiler reports any 
other now-unused import (e.g. `java.nio.file.Files`), remove it too. Keep 
`StringUtils` (still used by `processNormalFormField`).
+
+- [ ] **Step 5: Remove the 5 obsolete tests**
+
+In `JakartaMultiPartRequestTest.java`, delete these test methods entirely — 
they reference the removed `temporaryFiles` field / `cleanUpTemporaryFiles()` 
method, or assert the removed parse-time eager-write behavior. Their coverage 
is replaced by Task 2 (secure naming, materialization) and Task 4 (no eager 
write, cleanup):
+  - `temporaryFileCleanupForInMemoryUploads`
+  - `cleanupMethodsCanBeOverridden`
+  - `temporaryFileCreationFailureAddsError`
+  - `temporaryFilesCreatedInSaveDirectory`
+  - `secureTemporaryFileNaming`
+
+Leave all other tests untouched 
(`temporaryFileCreationErrorsAreNotDuplicated`, `cleanupIsIdempotent`, 
`endToEndMultipartProcessingWithCleanup`, `inMemoryVsDiskFileHandling`, 
`processNormalFormFieldHandlesNullFieldName`, 
`processFileFieldHandlesNullFieldName`, `diskFileItemCleanupCoverage`, 
`errorDuplicationPrevention`, `processFileFieldHandlesEmptyFileName`). After 
deleting, remove any imports left unused by the deletions (e.g. 
`java.lang.reflect.Field` if no longer referenced).
+
+- [ ] **Step 6: Run the whole class to verify it compiles and passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=JakartaMultiPartRequestTest`
+Expected: PASS. In particular `inMemoryVsDiskFileHandling` still passes — its 
small-file assertion goes through `getContent()`, which now materializes the 
file on demand and returns a `File`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java
 \
+        
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
+git commit -m "WW-5413 refactor(core): drop eager temp-file write for 
in-memory uploads"
+```
+
+---
+
+## Task 4: Integration tests for the deferred-write behavior
+
+Prove end-to-end that in-memory uploads are not written to disk until content 
is demanded, are readable via the stream path without a write, and are cleaned 
up after materialization.
+
+**Files:**
+- Modify: 
`core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java`
+
+**Interfaces:**
+- Consumes: the parse pipeline from Task 3, `UploadedFile.getInputStream()` 
from Task 1.
+
+- [ ] **Step 1: Add the imports the new test needs**
+
+In `JakartaMultiPartRequestTest.java`, ensure these imports are present (add 
any missing):
+
+```java
+import java.io.InputStream;
+```
+
+(`java.io.File`, `java.nio.charset.StandardCharsets`, and `static 
org.assertj.core.api.Assertions.assertThat` are already imported.)
+
+- [ ] **Step 2: Write the failing integration test**
+
+Add this test method to `JakartaMultiPartRequestTest`:
+
+```java
+    @Test
+    public void inMemoryUploadIsNotWrittenToDiskUntilContentRequested() throws 
IOException {
+        // given - a small file that Commons FileUpload keeps in memory
+        String content = formFile("file1", "test1.csv", "a,b,c,d") +
+                endline + "--" + boundary + "--";
+        mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+        // when
+        multiPart.parse(mockRequest, tempDir);
+
+        UploadedFile file = multiPart.getFile("file1")[0];
+
+        // then - nothing written to disk right after parse
+        assertThat(file.isFile()).isFalse();
+
+        // and - content is readable via the stream path without materializing
+        try (InputStream in = file.getInputStream()) {
+            assertThat(new String(in.readAllBytes(), 
StandardCharsets.UTF_8)).isEqualTo("a,b,c,d");
+        }
+        assertThat(file.isFile()).isFalse();
+
+        // and - getContent() materializes a real file on demand
+        File materialized = (File) file.getContent();
+        assertThat(materialized).exists().hasContent("a,b,c,d");
+        assertThat(file.isFile()).isTrue();
+
+        // and - cleanUp removes the materialized file
+        multiPart.cleanUp();
+        assertThat(materialized).doesNotExist();
+    }
+```
+
+- [ ] **Step 3: Run it to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=JakartaMultiPartRequestTest#inMemoryUploadIsNotWrittenToDiskUntilContentRequested`
+Expected: PASS.
+
+- [ ] **Step 4: Run the full multipart test package as a regression check**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest='org.apache.struts2.dispatcher.multipart.*'`
+Expected: PASS (all multipart tests, including 
`JakartaStreamMultiPartRequestTest` and `AbstractMultiPartRequestApiCheckTest`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java
+git commit -m "WW-5413 test(core): cover deferred-write behavior for in-memory 
uploads"
+```
+
+---
+
+## Final verification
+
+- [ ] **Run the core module's dispatcher tests** to catch any 
converter/interceptor fallout from the interface change:
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest='org.apache.struts2.dispatcher.**,org.apache.struts2.interceptor.**,org.apache.struts2.conversion.**'`
+Expected: PASS. Pay attention to `UploadedFileConverter`-related tests (legacy 
`File`-typed action property path) — a small upload now reaches the converter 
as a `StrutsInMemoryUploadedFile` whose `getContent()` returns a materialized 
`File`, so conversion must still succeed.
+
+---
+
+## Notes on the spec's error-handling trade-off (§4)
+
+The spec deliberately accepts that a materialization **write failure** now 
surfaces as an unchecked `StrutsException` during consumption (via 
`getContent()`/`getAbsolutePath()`), rather than as a parse-time 
`LocalizedMessage`. This is why the old `temporaryFileCreationFailureAddsError` 
test is removed rather than rewritten: the parse-time graceful-degradation path 
for in-memory items no longer exists. No new test asserts the exception, since 
it only fires on genuine filesystem failures i [...]
+
+---
+
+## Task 5: Non-materializing interceptor validation (added after whole-branch 
review)
+
+The whole-branch review found `AbstractFileUploadInterceptor.acceptFile()` 
calls `file.getContent() == null` first, materializing every in-memory upload 
during validation on the standard `fileUpload`/`actionFileUpload` path — 
defeating the optimization for the common consumer.
+
+**Files:** `UploadedFile.java`, `StrutsInMemoryUploadedFile.java`, 
`AbstractFileUploadInterceptor.java`, `ActionFileUploadInterceptorTest.java`.
+
+Steps: add `default boolean isMissing()` (= `getContent() == null`) to 
`UploadedFile`; override in `StrutsInMemoryUploadedFile` to `return false` 
(non-materializing); change the `acceptFile()` guard to `file == null || 
file.isMissing()` and delete the dead `getContent() == null` block; add 
JUnit3-style (`public void testXxx`) interceptor tests asserting 
`isFile()==false` after accept and after reject. Preserve 
`testAcceptFileWithNoContent` (null-content test-double rejected via the inter 
[...]
+
+## Task 6: Polish (from whole-branch review)
+
+**Files:** `StrutsInMemoryUploadedFile.java`, 
`StrutsInMemoryUploadedFileTest.java`, `UploadedFileTest.java`.
+
+Steps: in `materialize()`, `Files.deleteIfExists(targetFile.toPath())` (with 
`addSuppressed`) before rethrowing `StrutsException`, so a partial write on 
failure isn't leaked; add a class Javadoc note on the clustered-deployment 
serialization limitation; add direct unit tests for `isMissing()` (the override 
is non-materializing; the interface default reflects `getContent()`). Commit: 
`WW-5413 chore(core): clean up partial materialization and cover isMissing()`.
+
+> Note left out of scope deliberately: the now-unused 
`STRUTS_MESSAGES_INVALID_CONTENT_TYPE_KEY` constant in 
`AbstractFileUploadInterceptor` is a `public static final` and was left in 
place to avoid an API break. The vestigial `throws IOException` on 
`JakartaMultiPartRequest.processFileField` was also left in place — removing it 
could break a subclass that catches `IOException` from 
`super.processFileField(...)`.
diff --git 
a/docs/superpowers/specs/2026-07-22-WW-5413-inmemory-upload-optimization-design.md
 
b/docs/superpowers/specs/2026-07-22-WW-5413-inmemory-upload-optimization-design.md
new file mode 100644
index 000000000..e84237d12
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-07-22-WW-5413-inmemory-upload-optimization-design.md
@@ -0,0 +1,136 @@
+# WW-5413 — In-memory multipart upload optimization
+
+**Jira:** [WW-5413](https://issues.apache.org/jira/browse/WW-5413) · **Fix 
version:** 7.3.0 · **Component:** Core
+**Date:** 2026-07-22
+
+## Background
+
+WW-5413 was originally filed against 6.3.0: commons-io 2.16.0 broke 
`DeferredFileOutputStream`/`ThresholdingOutputStream`, cascading through 
commons-fileupload's `DiskFileItem` so multipart uploads were read as empty. A 
prior Struts workaround forced the disk-spill threshold to `-1`, spilling 
*every* field to disk. The ticket proposed dropping the `-1` threshold, 
handling the `isInMemory()` case properly, and avoiding unnecessary filesystem 
writes.
+
+**The root-cause bug is already resolved on `main`.** Struts has since 
migrated to **commons-fileupload2 2.0.0-M5** + **commons-io 2.22.0**. 
`AbstractMultiPartRequest.createJakartaFileUpload()` sets no threshold, so it 
uses `DiskFileItemFactory`'s default (~8 KB); small uploads legitimately stay 
in memory (`isInMemory() == true`).
+
+**What remains** is the performance half of the ticket. 
`JakartaMultiPartRequest.processFileField()` still takes every in-memory item 
and eagerly writes it to a temp file via `FileOutputStream`, then wraps that 
`File` in `StrutsUploadedFile`. The redundant filesystem write the ticket 
complained about is still present — it just moved from the fileupload layer 
into Struts' own code, because `UploadedFile`/`StrutsUploadedFile` are 
`File`-backed with no in-memory representation.
+
+## Goal
+
+Eliminate the redundant temp-file write for small (in-memory) uploads, while 
keeping 100% backward compatibility for existing `UploadedFile` consumers — 
including the legacy `File`-typed action property path and third-party 
`UploadedFile` implementations.
+
+## Constraints & compatibility facts
+
+- `UploadedFile` was designed for this: `isFile()` documents "real file or 
maybe just in-memory stream", `getContent()` returns `Object`, 
`getAbsolutePath()` is "if possible". `UploadedFile extends Serializable`.
+- `getContent()` de-facto returns a `java.io.File` everywhere today: 
`UploadedFileConverter` (legacy `File`-typed action properties), 
`apps/showcase` actions (`FileUploadAction.getContent()`), and user actions in 
the wild. **The runtime type of `getContent()` must remain `File`** — a 
size-dependent `byte[]`/`File` return would break these consumers 
non-deterministically. This is why we do **not** expose bytes through 
`getContent()`.
+- `AbstractMultiPartRequest.cleanUp()` deletes an uploaded file by calling 
`UploadedFile.delete()` **only when `isFile()` is true**. The lazy design plugs 
into this existing hook.
+- The in-memory `DiskFileItem` buffer stays valid until `cleanUp()` runs 
(after action processing), so reading `item.get()` at parse time is safe.
+
+## Approach: lazy materialization + a streaming accessor
+
+Keep `getContent()`/`getAbsolutePath()` returning a `File` (materialized on 
demand), and add a new, correctly-typed door for reading bytes without forcing 
a disk write.
+
+### 1. `UploadedFile` interface — new `getInputStream()`
+
+Add one method as a **`default`** so existing third-party implementations keep 
compiling:
+
+```java
+default InputStream getInputStream() throws IOException {
+    Object c = getContent();
+    if (c instanceof File f)   return new FileInputStream(f);
+    if (c instanceof byte[] b) return new ByteArrayInputStream(b);
+    throw new IOException("No content stream available for " + getName());
+}
+```
+
+This is the type-safe "give me the bytes without forcing a file" path. 
`getContent()` still returns a `File` for every implementation, so no existing 
consumer changes.
+
+### 2. New `StrutsInMemoryUploadedFile`
+
+A second `UploadedFile` implementation beside `StrutsUploadedFile` — each 
class stays single-purpose; `Struts*` naming per project convention. It holds:
+
+- `byte[] content` — the small upload's bytes, from `item.get()`
+- `Path saveDir` — where a temp file will be written if ever demanded
+- a **stable temp-file name chosen at construction** (`upload_<uuid>.tmp`) — 
only the *write* is deferred, so `getName()` is stable and matches the eventual 
file
+- metadata: `contentType`, `originalName`, `inputName`
+- `transient File materializedFile` — populated lazily, cached
+
+The object must stay `Serializable` (a `DiskFileItem` reference would not be). 
**Implementation note:** rather than a `Path saveDir` + `String name` (a 
concrete `Path` such as `sun.nio.fs.UnixPath` is *not* guaranteed 
`Serializable`), the shipped code stores a single pre-computed `java.io.File 
targetFile` (which *is* `Serializable`) plus a `serialVersionUID`; 
`materializedFile` is `volatile transient`.
+
+| Method | Behavior | Touches disk? |
+|---|---|---|
+| `getInputStream()` | `new ByteArrayInputStream(content)` | **No** |
+| `length()` | `content.length` | No |
+| `getName()` | the pre-chosen `upload_<uuid>.tmp` | No |
+| `getContent()` | `materialize()` → returns the `File` | **Yes, once** 
(cached) |
+| `getAbsolutePath()` | `materialize()` → path string | **Yes, once** (cached) 
|
+| `isFile()` | true only *after* materialization | No |
+| `getContentType()` / `getOriginalName()` / `getInputName()` | metadata | No |
+| `delete()` | deletes the materialized file if it exists; no-op otherwise | — 
|
+
+`materialize()` is `synchronized`, writes the bytes once to the pre-chosen 
path in `saveDir`, and caches the resulting `File`. The temp file is created 
with the project's secure UUID-named pattern (`upload_<uuid>.tmp`); the naming 
logic is shared with / extracted alongside 
`AbstractMultiPartRequest.createTemporaryFile` to avoid divergence.
+
+**Net effect:** rejected uploads, size/type checks (`length()`), and 
`getInputStream()` consumers **never write**; legacy `File`/`getContent()` 
consumers write exactly once — same as today, but deferred.
+
+### 3a. Non-materializing interceptor validation (added after whole-branch 
review)
+
+The net-effect claim above is only real if the framework's own consumers don't 
force materialization during validation. They did: 
`AbstractFileUploadInterceptor.acceptFile()` — run for every uploaded file on 
the standard `fileUpload`/`actionFileUpload` path — called `file.getContent() 
== null` as its first (failed-upload) guard, which materialized every small 
in-memory upload before any size/type check. `acceptFile()` only validates 
metadata; it never needs the bytes.
+
+Fix: add `default boolean isMissing()` to `UploadedFile` (default = 
`getContent() == null`, so third-party impls and the "no content = failed 
upload" contract are preserved), override it in `StrutsInMemoryUploadedFile` to 
return `false` (answered from the in-memory byte array, no materialization), 
change the `acceptFile()` guard to `file == null || file.isMissing()`, and 
delete the now-dead second `getContent() == null` block. Result: the 
`UploadedFilesAware` flow validates and hands fil [...]
+
+### 3. `JakartaMultiPartRequest.processFileField` + cleanup simplification
+
+The `item.isInMemory()` branch stops writing a temp file eagerly:
+
+```java
+if (item.isInMemory()) {
+    values.add(StrutsInMemoryUploadedFile.Builder
+        .create(item.get(), Path.of(saveDir))
+        .withOriginalName(item.getName())
+        .withContentType(item.getContentType())
+        .withInputName(item.getFieldName())
+        .build());
+} else {
+    // unchanged File-based path via item.getPath()
+}
+```
+
+Because in-memory files no longer create temp files eagerly:
+
+- the `temporaryFiles` field, `cleanUpTemporaryFiles()`, and the 
`FileOutputStream` write block in `JakartaMultiPartRequest` are removed;
+- cleanup of any *materialized* file happens through the existing 
`AbstractMultiPartRequest.cleanUp()` loop, which already calls `delete()` when 
`isFile()` is true — no new cleanup path is added;
+- `createTemporaryFile` remains in `AbstractMultiPartRequest` 
(`JakartaStreamMultiPartRequest` still uses it).
+
+`StrutsUploadedFile` keeps its current `File`-backed behavior; it may override 
`getInputStream()` to return `new FileInputStream(file)` directly rather than 
relying on the interface default.
+
+`JakartaStreamMultiPartRequest` is unchanged: it always streams to disk (no 
in-memory threshold) and stays `File`-backed via `StrutsUploadedFile`, 
inheriting `getInputStream()` for free.
+
+### 4. Error-handling behavior change (explicit)
+
+`getContent()`/`getAbsolutePath()` have no `throws` clause, so a 
materialization **write failure** surfaces as an unchecked `StrutsException` 
**during consumption**, rather than as a gracefully-collected 
`LocalizedMessage` at parse time (today's behavior). This affects only the rare 
"cannot write to `saveDir`" case, and only on the legacy `File`/`getContent()` 
path — the `getInputStream()` fast path never writes. Accepted as a reasonable 
trade for the optimization.
+
+## Testing
+
+**`StrutsInMemoryUploadedFile` unit tests**
+
+- `getInputStream()` returns the exact bytes and creates **no** file on disk
+- `getContent()` and `getAbsolutePath()` create the temp file **exactly once** 
and cache it (second call reuses the same `File`)
+- `isFile()` is false before materialization, true after
+- `length()` and metadata accessors do **not** materialize
+- `delete()` removes a materialized file and is a safe no-op when nothing was 
materialized
+
+**`JakartaMultiPartRequest` tests**
+
+- a small (in-memory) upload leaves **no** temp file on disk after `parse()`, 
until `getContent()`/`getAbsolutePath()` is called
+- content is readable via both `getInputStream()` and `getContent()`
+- a large (on-disk) upload keeps the existing `File`-backed path unchanged
+- legacy `File`-typed action-property conversion (`UploadedFileConverter`) 
still works for a small upload
+- `cleanUp()` removes any materialized file and leaves nothing behind
+
+Tests follow the existing multipart test base (core tests are JUnit4 / 
`XWorkTestCase`-style — an `@Test` added to a `TestCase` subclass silently 
never runs).
+
+## Out of scope
+
+- Migrating showcase actions / `UploadedFileConverter` onto `getInputStream()` 
— they continue using `getContent()`. (The upload **interceptor** validation 
path *was* brought in scope and made non-materializing — see §3a — because it 
defeated the optimization for the common consumer.)
+- Changing the disk-spill threshold or `JakartaStreamMultiPartRequest` 
streaming strategy.
+- Any change to `getContent()`'s runtime type (must remain `File`).
+
+## Known limitation
+
+`StrutsInMemoryUploadedFile.targetFile` is an absolute path resolved on the 
originating node. If an un-materialized instance is serialized (e.g. session 
replication) and deserialized on another node, a later `getContent()` 
materializes to that originating node's path, which may not exist there. 
Consumers that need content to survive cross-node replication should read via 
`getInputStream()` (never touches disk). Documented on the class Javadoc.

Reply via email to