You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2025/02/28 10:48:08 UTC

(httpcomponents-client) branch master updated: HTTPCLIENT-2360 - Enhance filename encoding in multipart/form-data per RFC 6266/5987 (#618)

This is an automated email from the ASF dual-hosted git repository.

abernal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


The following commit(s) were added to refs/heads/master by this push:
     new fc3b11e98 HTTPCLIENT-2360 - Enhance filename encoding in multipart/form-data per RFC 6266/5987 (#618)
fc3b11e98 is described below

commit fc3b11e98bed28a47fe000bf308c3c027d34df83
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Fri Feb 28 11:48:03 2025 +0100

    HTTPCLIENT-2360 - Enhance filename encoding in multipart/form-data per RFC 6266/5987 (#618)
    
    - Modified FormBodyPartBuilder to support HttpMultipartMode, adding filename* with UTF-8 encoding for non-ISO-8859-1 filenames in STRICT/EXTENDED modes, skipping it in LEGACY mode.
    - Updated HttpRFC7578Multipart to use mode for filename encoding: percent-encode in EXTENDED, ISO-8859-1 in STRICT/LEGACY, and always encode filename* per RFC 5987.
    - Adjusted MultipartEntityBuilder to propagate mode to FormBodyPartBuilder, ensuring consistent behavior across the pipeline.
    - Fixed tests to align with mode-specific expectations, maintaining LEGACY mode’s raw UTF-8 filename behavior.
---
 .../http/entity/mime/FormBodyPartBuilder.java      | 76 ++++++++++++++++++++--
 .../http/entity/mime/HttpRFC7578Multipart.java     | 29 ++++++---
 .../http/entity/mime/MultipartEntityBuilder.java   |  2 +-
 .../http/entity/mime/TestMultipartForm.java        |  4 +-
 4 files changed, 94 insertions(+), 17 deletions(-)

diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/FormBodyPartBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/FormBodyPartBuilder.java
index 72405cfc8..31d73cccb 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/FormBodyPartBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/FormBodyPartBuilder.java
@@ -27,12 +27,15 @@
 
 package org.apache.hc.client5.http.entity.mime;
 
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 
 import org.apache.hc.core5.http.ContentType;
 import org.apache.hc.core5.http.NameValuePair;
 import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.net.PercentCodec;
 import org.apache.hc.core5.util.Args;
 import org.apache.hc.core5.util.Asserts;
 
@@ -47,22 +50,52 @@ public class FormBodyPartBuilder {
     private ContentBody body;
     private final Header header;
 
+    /**
+     * The multipart mode determining how filenames are encoded in the {@code Content-Disposition}
+     * header, defaults to {@link HttpMultipartMode#STRICT}.
+     *
+     * @since 5.5
+     */
+    private HttpMultipartMode mode;
+
+    /**
+     * Encoder used to check if strings can be encoded in ISO-8859-1, supporting filename
+     * compatibility determinations in multipart form data.
+     */
+    private CharsetEncoder iso8859_1Encoder;
+
+    /**
+     * Creates a new builder instance with the specified name, content body, and multipart mode.
+     *
+     * @param name the name of the form field
+     * @param body the content body of the part
+     * @param mode the {@link HttpMultipartMode} to use, determining filename encoding behavior;
+     *
+     * @return a new {@code FormBodyPartBuilder} instance
+     * @since 5.5
+     */
+    public static FormBodyPartBuilder create(final String name, final ContentBody body, final HttpMultipartMode mode) {
+        return new FormBodyPartBuilder(name, body, mode);
+    }
+
     public static FormBodyPartBuilder create(final String name, final ContentBody body) {
-        return new FormBodyPartBuilder(name, body);
+        return new FormBodyPartBuilder(name, body, HttpMultipartMode.STRICT);
     }
 
     public static FormBodyPartBuilder create() {
         return new FormBodyPartBuilder();
     }
 
-    FormBodyPartBuilder(final String name, final ContentBody body) {
+    FormBodyPartBuilder(final String name, final ContentBody body, final HttpMultipartMode mode) {
         this();
         this.name = name;
         this.body = body;
+        this.mode = mode != null ? mode : HttpMultipartMode.STRICT;
     }
 
     FormBodyPartBuilder() {
         this.header = new Header();
+        this.mode = HttpMultipartMode.STRICT;
     }
 
     public FormBodyPartBuilder setName(final String name) {
@@ -102,6 +135,35 @@ public FormBodyPartBuilder removeFields(final String name) {
         return this;
     }
 
+    /**
+     * Determines whether the given string can be encoded in ISO-8859-1 without loss of data.
+     * This is used to decide whether the {@code filename} parameter can be used as-is or if
+     * the {@code filename*} parameter is needed for non-ISO-8859-1 characters.
+     *
+     * @param input the string to check, must not be {@code null}
+     * @return {@code true} if the string can be encoded in ISO-8859-1, {@code false} otherwise
+     * @since 5.5
+     */
+    private boolean canEncodeToISO8859_1(final String input) {
+        if (iso8859_1Encoder == null) {
+            iso8859_1Encoder = StandardCharsets.ISO_8859_1.newEncoder();
+        }
+        return iso8859_1Encoder.canEncode(input);
+    }
+
+    /**
+     * Encodes the given filename according to RFC 5987, prefixing it with {@code UTF-8''} and
+     * applying percent-encoding to non-ASCII characters. This is used for the {@code filename*}
+     * parameter in the {@code Content-Disposition} header when non-ISO-8859-1 characters are present.
+     *
+     * @param filename the filename to encode, must not be {@code null}
+     * @return the RFC 5987-encoded string, e.g., {@code UTF-8''example%20text}
+     * @since 5.5
+     */
+    private static String encodeRFC5987(final String filename) {
+        return "UTF-8''" + PercentCodec.RFC5987.encode(filename);
+    }
+
     public FormBodyPart build() {
         Asserts.notBlank(this.name, "Name");
         Asserts.notNull(this.body, "Content body");
@@ -114,7 +176,12 @@ public FormBodyPart build() {
             final List<NameValuePair> fieldParameters = new ArrayList<>();
             fieldParameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_NAME, this.name));
             if (this.body.getFilename() != null) {
-                fieldParameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_FILENAME, this.body.getFilename()));
+                final String filename = this.body.getFilename();
+                fieldParameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_FILENAME, filename));
+                // Add filename* only if non-ISO-8859-1 and not in LEGACY mode
+                if (mode != HttpMultipartMode.LEGACY && !canEncodeToISO8859_1(filename)) {
+                    fieldParameters.add(new BasicNameValuePair(MimeConsts.FIELD_PARAM_FILENAME_START, encodeRFC5987(filename)));
+                }
             }
             headerCopy.addField(new MimeField(MimeConsts.CONTENT_DISPOSITION, "form-data", fieldParameters));
         }
@@ -139,5 +206,4 @@ public FormBodyPart build() {
         }
         return new FormBodyPart(this.name, this.body, headerCopy);
     }
-
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java
index ff42b7746..3e5fd010d 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/HttpRFC7578Multipart.java
@@ -40,6 +40,8 @@ class HttpRFC7578Multipart extends AbstractMultipartFormat {
 
     private final List<MultipartPart> parts;
 
+    private final HttpMultipartMode mode;
+
     /**
      * Constructs a new instance of {@code HttpRFC7578Multipart} with the given charset, boundary, parts, preamble, and epilogue.
      *
@@ -54,9 +56,11 @@ public HttpRFC7578Multipart(
         final String boundary,
         final List<MultipartPart> parts,
         final String preamble,
-        final String epilogue) {
+        final String epilogue,
+        final HttpMultipartMode mode) {
         super(charset, boundary, preamble, epilogue);
         this.parts = parts;
+        this.mode = mode != null ? mode : HttpMultipartMode.STRICT; // Default to STRICT
     }
 
     /**
@@ -69,10 +73,12 @@ public HttpRFC7578Multipart(
     public HttpRFC7578Multipart(
             final Charset charset,
             final String boundary,
-            final List<MultipartPart> parts) {
-        this(charset,boundary,parts,null, null);
+            final List<MultipartPart> parts,
+            final HttpMultipartMode mode) {
+        this(charset,boundary,parts,null, null, mode);
     }
 
+
     @Override
     public List<MultipartPart> getParts() {
         return parts;
@@ -94,12 +100,17 @@ protected void formatMultipartHeader(final MultipartPart part, final OutputStrea
                     writeBytes(name, out);
                     writeBytes("=\"", out);
                     if (value != null) {
-                        if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME) ||
-                                name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START)) {
-                            final String encodedValue = name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START) ?
-                                    "UTF-8''" + PercentCodec.RFC5987.encode(value) : PercentCodec.RFC5987.encode(value);
-                            final byte[] encodedBytes = encodedValue.getBytes(StandardCharsets.US_ASCII);
-                            out.write(encodedBytes);
+                        if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME_START)) {
+                            final String encodedValue = "UTF-8''" + PercentCodec.RFC5987.encode(value);
+                            writeBytes(encodedValue, StandardCharsets.US_ASCII, out);
+                        } else if (name.equalsIgnoreCase(MimeConsts.FIELD_PARAM_FILENAME)) {
+                            if (mode == HttpMultipartMode.EXTENDED) {
+                                final String encodedValue = PercentCodec.RFC5987.encode(value);
+                                writeBytes(encodedValue, StandardCharsets.US_ASCII, out);
+                            } else {
+                                // Default to ISO-8859-1 for RFC 7578 compliance in STRICT/LEGACY
+                                writeBytes(value, StandardCharsets.ISO_8859_1, out);
+                            }
                         } else {
                             writeBytes(value, out);
                         }
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MultipartEntityBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MultipartEntityBuilder.java
index d9574361b..d86ec95ca 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MultipartEntityBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/entity/mime/MultipartEntityBuilder.java
@@ -289,7 +289,7 @@ MultipartFormEntity buildEntity() {
                     if (charsetCopy == null) {
                         charsetCopy = StandardCharsets.UTF_8;
                     }
-                    form = new HttpRFC7578Multipart(charsetCopy, boundaryCopy, multipartPartsCopy, preamble, epilogue);
+                    form = new HttpRFC7578Multipart(charsetCopy, boundaryCopy, multipartPartsCopy, preamble, epilogue, modeCopy);
                 } else {
                     form = new HttpRFC6532Multipart(charsetCopy, boundaryCopy, multipartPartsCopy, preamble, epilogue);
                 }
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartForm.java b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartForm.java
index 054b3ade5..617092ea1 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartForm.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/entity/mime/TestMultipartForm.java
@@ -295,11 +295,11 @@ void testMultipartFormBrowserCompatibleNonASCIIHeaders() throws Exception {
         @SuppressWarnings("resource")
         final FormBodyPart p1 = FormBodyPartBuilder.create(
                 "field1",
-                new InputStreamBody(new FileInputStream(tmpfile), s1 + ".tmp")).build();
+                new InputStreamBody(new FileInputStream(tmpfile), s1 + ".tmp"), HttpMultipartMode.LEGACY).build();
         @SuppressWarnings("resource")
         final FormBodyPart p2 = FormBodyPartBuilder.create(
                 "field2",
-                new InputStreamBody(new FileInputStream(tmpfile), s2 + ".tmp")).build();
+                new InputStreamBody(new FileInputStream(tmpfile), s2 + ".tmp"), HttpMultipartMode.LEGACY).build();
         final LegacyMultipart multipart = new LegacyMultipart(
                 StandardCharsets.UTF_8, "foo",
                 Arrays.asList(p1, p2));