Copilot commented on code in PR #1283:
URL: https://github.com/apache/struts/pull/1283#discussion_r2217901425
##########
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java:
##########
@@ -19,7 +19,6 @@
package org.apache.struts2.dispatcher.multipart;
import jakarta.servlet.http.HttpServletRequest;
-import org.apache.commons.fileupload2.core.DiskFileItemFactory;
import org.apache.commons.fileupload2.core.FileItemInput;
Review Comment:
The removed import for DiskFileItemFactory appears to be unused after the
refactoring, but verify that createJakartaFileUpload method removal is
intentional and doesn't break inheritance contract.
```suggestion
```
##########
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java:
##########
@@ -82,51 +81,62 @@ protected void processUpload(HttpServletRequest request,
String saveDir) throws
});
}
- protected JakartaServletDiskFileUpload createJakartaFileUpload(Charset
charset, Path location) {
- DiskFileItemFactory.Builder builder = DiskFileItemFactory.builder();
-
- LOG.debug("Using file save directory: {}", location);
- builder.setPath(location);
-
- LOG.debug("Sets buffer size: {}", bufferSize);
- builder.setBufferSize(bufferSize);
-
- LOG.debug("Using charset: {}", charset);
- builder.setCharset(charset);
-
- DiskFileItemFactory factory = builder.get();
- return new JakartaServletDiskFileUpload(factory);
- }
-
+ /**
+ * Reads the entire contents of an input stream into a string.
+ *
+ * <p>This method uses a buffered approach to efficiently read the stream
+ * content without loading the entire stream into memory at once. It uses
+ * try-with-resources to ensure proper cleanup of resources.</p>
+ *
+ * @param inputStream the input stream to read from
+ * @return the stream contents as a UTF-8 string
+ * @throws IOException if an error occurs reading the stream
+ */
private String readStream(InputStream inputStream) throws IOException {
- ByteArrayOutputStream result = new ByteArrayOutputStream();
- byte[] buffer = new byte[1024];
- for (int length; (length = inputStream.read(buffer)) != -1; ) {
- result.write(buffer, 0, length);
+ // Use try-with-resources to ensure ByteArrayOutputStream is properly
closed
+ try (ByteArrayOutputStream result = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[1024]; // 1KB buffer for efficient reading
+ // Read the stream in chunks to avoid loading everything into
memory at once
+ for (int length; (length = inputStream.read(buffer)) != -1; ) {
+ result.write(buffer, 0, length);
+ }
+ // Convert to string using UTF-8 encoding
+ return result.toString(StandardCharsets.UTF_8);
}
- return result.toString(StandardCharsets.UTF_8);
}
/**
- * Processes the FileItem as a normal form field.
- *
- * @param fileItemInput a form field item input
+ * Processes a normal form field (non-file) from the multipart request
using streaming API.
+ *
+ * <p>This method handles text form fields by:</p>
+ * <ol>
+ * <li>Validating the field name is not null</li>
+ * <li>Reading the field value from the input stream</li>
+ * <li>Checking if the field value exceeds maximum string length</li>
+ * <li>Adding the value to the parameters collection</li>
+ * </ol>
+ *
+ * <p>Fields with null names are skipped with a warning log message.</p>
+ * <p>The streaming approach is more memory-efficient for large form
data.</p>
+ *
+ * @param fileItemInput a form field item input from the streaming API
+ * @throws IOException if an error occurs reading the input stream
+ * @see #readStream(InputStream)
+ * @see #exceedsMaxStringLength(String, String)
*/
protected void processFileItemAsFormField(FileItemInput fileItemInput)
throws IOException {
String fieldName = fileItemInput.getFieldName();
+ if (fieldName == null) {
+ LOG.warn("Form field has null fieldName, skipping");
+ return;
+ }
+
String fieldValue = readStream(fileItemInput.getInputStream());
-
if (exceedsMaxStringLength(fieldName, fieldValue)) {
return;
}
- List<String> values;
- if (parameters.containsKey(fieldName)) {
- values = parameters.get(fieldName);
- } else {
- values = new ArrayList<>();
- parameters.put(fieldName, values);
- }
+ List<String> values = parameters.computeIfAbsent(fieldName, k -> new
ArrayList<>());
Review Comment:
[nitpick] Using computeIfAbsent with ArrayList::new lambda would be more
efficient than creating a new ArrayList inside the lambda:
`parameters.computeIfAbsent(fieldName, k -> new ArrayList<>())` should be
`parameters.computeIfAbsent(fieldName, k -> new ArrayList<>())`.
```suggestion
List<String> values = parameters.computeIfAbsent(fieldName,
ArrayList::new);
```
##########
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java:
##########
@@ -20,12 +20,14 @@
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.fileupload2.core.DiskFileItem;
-import org.apache.commons.fileupload2.core.DiskFileItemFactory;
+import org.apache.commons.fileupload2.core.RequestContext;
Review Comment:
The removed import for DiskFileItemFactory suggests the
createJakartaFileUpload method was moved to the parent class, but this should
be verified for consistency across implementations.
```suggestion
import org.apache.commons.fileupload2.core.RequestContext;
import org.apache.commons.fileupload2.core.DiskFileItemFactory;
```
##########
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java:
##########
@@ -71,4 +79,316 @@ public void maxSizeOfFiles() throws IOException {
.containsExactly("struts.messages.upload.error.FileUploadSizeException");
}
+ @Test
+ public void readStreamProperlyHandlesResources() throws Exception {
+ // Create a test input stream with known data
+ byte[] testData = "test data for stream
reading".getBytes(StandardCharsets.UTF_8);
+ InputStream testStream = new java.io.ByteArrayInputStream(testData);
+
+ JakartaStreamMultiPartRequest streamMultiPart = new
JakartaStreamMultiPartRequest();
+
+ // Use reflection to access private readStream method
+ Method readStreamMethod =
JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream",
InputStream.class);
+ readStreamMethod.setAccessible(true);
+
+ // when
+ String result = (String) readStreamMethod.invoke(streamMultiPart,
testStream);
+
+ // then
+ assertThat(result).isEqualTo("test data for stream reading");
+ }
+
+ @Test
+ public void readStreamHandlesExceptionsProperly() throws Exception {
+ // Create a stream that throws an exception
+ InputStream faultyStream = new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("Simulated stream failure");
+ }
+ };
+
+ JakartaStreamMultiPartRequest streamMultiPart = new
JakartaStreamMultiPartRequest();
+
+ // Use reflection to access private readStream method
+ Method readStreamMethod =
JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream",
InputStream.class);
+ readStreamMethod.setAccessible(true);
+
+ // when/then - should propagate the exception
+ assertThatThrownBy(() -> readStreamMethod.invoke(streamMultiPart,
faultyStream))
+ .isInstanceOf(InvocationTargetException.class)
+ .cause()
+ .isInstanceOf(IOException.class)
+ .hasMessage("Simulated stream failure");
+ }
+
+ @Test
+ public void readStreamHandlesEmptyStream() throws Exception {
+ // Create an empty stream
+ InputStream emptyStream = new java.io.ByteArrayInputStream(new
byte[0]);
+
+ JakartaStreamMultiPartRequest streamMultiPart = new
JakartaStreamMultiPartRequest();
+
+ // Use reflection to access private readStream method
+ Method readStreamMethod =
JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream",
InputStream.class);
+ readStreamMethod.setAccessible(true);
+
+ // when
+ String result = (String) readStreamMethod.invoke(streamMultiPart,
emptyStream);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void readStreamHandlesLargeData() throws Exception {
+ // Create a large data stream to test buffer handling
+ StringBuilder largeData = new StringBuilder();
+ for (int i = 0; i < 2000; i++) {
+ largeData.append("line").append(i).append("\n");
+ }
+
+ byte[] testData =
largeData.toString().getBytes(StandardCharsets.UTF_8);
+ InputStream largeStream = new java.io.ByteArrayInputStream(testData);
+
+ JakartaStreamMultiPartRequest streamMultiPart = new
JakartaStreamMultiPartRequest();
+
+ // Use reflection to access private readStream method
+ Method readStreamMethod =
JakartaStreamMultiPartRequest.class.getDeclaredMethod("readStream",
InputStream.class);
+ readStreamMethod.setAccessible(true);
+
+ // when
+ String result = (String) readStreamMethod.invoke(streamMultiPart,
largeStream);
+
+ // then
+ assertThat(result).isEqualTo(largeData.toString());
+ assertThat(result.length()).isGreaterThan(1024); // Verify it's larger
than internal buffer
+ }
+
+ @Test
+ public void processFileItemAsFormFieldHandlesNullFieldName() throws
IOException {
+ // Test the null field name path in processFileItemAsFormField
+ String content = formFile("", "test.csv", "data") + // Field name will
be empty/null-like
+ endline + "--" + boundary + "--";
+
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ // when
+ multiPart.parse(mockRequest, tempDir);
+
+ // then - should complete without error, but no parameters should be
added
+ assertThat(multiPart.getErrors()).isEmpty();
+ }
+
+ @Test
+ public void processFileItemAsFileFieldHandlesNullFieldName() throws
IOException {
+ // This test covers the null field name path in
processFileItemAsFileField
+ JakartaStreamMultiPartRequest streamMultiPart = new
JakartaStreamMultiPartRequest();
+
+ // Create a mock file item with null field name
+ String content = "--" + boundary + endline +
+ "Content-Disposition: form-data;
filename=\"test.csv\"" + endline +
+ "Content-Type: text/csv" + endline +
+ endline +
+ "test data" +
+ endline + "--" + boundary + "--";
+
+ mockRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
+
+ // when
+ streamMultiPart.parse(mockRequest, tempDir);
+
+ // then - should complete without error but no files should be uploaded
+ assertThat(streamMultiPart.getErrors()).isEmpty();
+ assertThat(streamMultiPart.uploadedFiles).isEmpty();
Review Comment:
Accessing the package-private field 'uploadedFiles' directly in tests
creates tight coupling. Consider using public getter methods or exposing a
test-friendly method instead.
##########
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java:
##########
@@ -18,11 +18,439 @@
*/
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.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;
+
public class JakartaMultiPartRequestTest extends AbstractMultiPartRequestTest {
@Override
protected AbstractMultiPartRequest createMultipartRequest() {
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
+ JakartaMultiPartRequest multiPartWithDuplicateErrors = new
JakartaMultiPartRequest();
+
+ // Simulate adding the same error twice
+ IOException testException = new IOException("Test exception");
+ LocalizedMessage errorMessage =
multiPartWithDuplicateErrors.buildErrorMessage(
+ testException.getClass(), testException.getMessage(), new
Object[]{"test.csv"});
+
+ // when - add same error twice
+ multiPartWithDuplicateErrors.errors.add(errorMessage);
Review Comment:
Directly accessing the package-private 'errors' field in tests creates tight
coupling. Consider using public methods or a test builder pattern to manipulate
error states.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]