From 2ee75a604d2d7cad4a683e431b49986fc4ef7c72 Mon Sep 17 00:00:00 2001 From: Oleg Kalnichevski Date: Thu, 14 Sep 2017 13:27:00 +0200 Subject: [PATCH 1/4] Upgraded HttpCore dependency to version 4.4.7 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fb250e2642..75c4c3d104 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ 1.6 1.6 - 4.4.6 + 4.4.7 1.2 1.10 2.6.11 From 4fbaa720ab2ee35f3281d193ce735d1f689dd175 Mon Sep 17 00:00:00 2001 From: Ioannis Sermetziadis Date: Thu, 28 Sep 2017 23:56:49 +0300 Subject: [PATCH 2/4] HTTPCLIENT-293 Refactored code in order to support multipart header field parameters in the data model and postpone the formatting and encoding of the parameters until the moment written into a stream, which is essential in order to avoid multiple encodings of the same value. Also provided a test case that fails due to incorrectly handling non US-ASCII characters in the filename field of the Content-Disposition header. --- .../http/entity/mime/FormBodyPartBuilder.java | 35 +++++++++------- .../org/apache/http/entity/mime/MIME.java | 15 +++++++ .../apache/http/entity/mime/MinimalField.java | 42 +++++++++++++++++-- .../mime/TestMultipartEntityBuilder.java | 34 +++++++++++++-- 4 files changed, 104 insertions(+), 22 deletions(-) diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java b/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java index 31c203a173..16c3dd8e6e 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java @@ -27,14 +27,15 @@ package org.apache.http.entity.mime; -import java.util.List; - import org.apache.http.entity.ContentType; import org.apache.http.entity.mime.content.AbstractContentBody; import org.apache.http.entity.mime.content.ContentBody; import org.apache.http.util.Args; import org.apache.http.util.Asserts; +import java.util.List; +import java.util.Map; + /** * Builder for individual {@link org.apache.http.entity.mime.FormBodyPart}s. * @@ -74,12 +75,22 @@ public FormBodyPartBuilder setBody(final ContentBody body) { return this; } - public FormBodyPartBuilder addField(final String name, final String value) { + public FormBodyPartBuilder addField(final String name, final String value, final Map parameters) { Args.notNull(name, "Field name"); - this.header.addField(new MinimalField(name, value)); + MinimalField minimalField = new MinimalField(name, value); + if(parameters != null) { + minimalField.setParameters(parameters); + } + this.header.addField(minimalField); return this; } + public FormBodyPartBuilder addField(final String name, final String value) { + Args.notNull(name, "Field name"); + this.header.addField(new MinimalField(name, value)); + return this; + } + public FormBodyPartBuilder setField(final String name, final String value) { Args.notNull(name, "Field name"); this.header.setField(new MinimalField(name, value)); @@ -101,16 +112,12 @@ public FormBodyPart build() { headerCopy.addField(field); } if (headerCopy.getField(MIME.CONTENT_DISPOSITION) == null) { - final StringBuilder buffer = new StringBuilder(); - buffer.append("form-data; name=\""); - buffer.append(encodeForHeader(this.name)); - buffer.append("\""); - if (this.body.getFilename() != null) { - buffer.append("; filename=\""); - buffer.append(encodeForHeader(this.body.getFilename())); - buffer.append("\""); - } - headerCopy.addField(new MinimalField(MIME.CONTENT_DISPOSITION, buffer.toString())); + MinimalField cp = new MinimalField(MIME.CONTENT_DISPOSITION, "form-data"); + cp.addParameter(MIME.HeaderFieldParam.NAME, this.name); + if(this.body.getFilename() != null) { + cp.addParameter(MIME.HeaderFieldParam.FILENAME, this.body.getFilename()); + } + headerCopy.addField(cp); } if (headerCopy.getField(MIME.CONTENT_TYPE) == null) { final ContentType contentType; diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java b/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java index 945bd1536a..82fddd8e73 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java @@ -41,6 +41,21 @@ public final class MIME { public static final String CONTENT_TRANSFER_ENC = "Content-Transfer-Encoding"; public static final String CONTENT_DISPOSITION = "Content-Disposition"; + public enum HeaderFieldParam { + NAME("name"), + FILENAME("filename"); + + private String name; + + public String getName() { + return name; + } + + HeaderFieldParam(String name) { + this.name = name; + } + } + public static final String ENC_8BIT = "8bit"; public static final String ENC_BINARY = "binary"; diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java b/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java index c6da877a99..20f0fe2d69 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java @@ -27,6 +27,10 @@ package org.apache.http.entity.mime; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; + /** * Minimal MIME field. * @@ -35,28 +39,58 @@ public class MinimalField { private final String name; - private final String value; + private final String value; + private Map parameters; public MinimalField(final String name, final String value) { super(); this.name = name; this.value = value; + this.parameters = new TreeMap(); } - public String getName() { + public MinimalField(MinimalField from) { + this.name = from.name; + this.value = from.value; + this.parameters = new TreeMap(from.parameters); + } + + public String getName() { return this.name; } public String getBody() { - return this.value; + StringBuilder sb = new StringBuilder(); + sb.append(this.value); + for (Iterator> it = this.parameters.entrySet().iterator(); it.hasNext(); ) { + Map.Entry next = it.next(); + sb.append("; "); + sb.append(next.getKey().getName()); + sb.append("=\""); + sb.append(next.getValue()); + sb.append("\""); + } + return sb.toString(); } + public Map getParameters() { + return parameters; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + public void addParameter(MIME.HeaderFieldParam paramName, String paramValue) { + parameters.put(paramName, paramValue); + } + @Override public String toString() { final StringBuilder buffer = new StringBuilder(); buffer.append(this.name); buffer.append(": "); - buffer.append(this.value); + buffer.append(this.getBody()); return buffer.toString(); } diff --git a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java index 68a1c172e8..f8a6204113 100644 --- a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java +++ b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java @@ -27,17 +27,21 @@ package org.apache.http.entity.mime; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.util.List; - import org.apache.http.Consts; import org.apache.http.Header; import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.content.StringBody; import org.apache.http.message.BasicNameValuePair; import org.junit.Assert; import org.junit.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + public class TestMultipartEntityBuilder { @Test @@ -123,4 +127,26 @@ public void testMultipartCustomContentTypeParameterOverrides() throws Exception contentType.getValue()); } + @Test + public void testMultipartContentDispositionFollowingRFC7578() throws Exception { + Map cpParams = new TreeMap(); + cpParams.put(MIME.HeaderFieldParam.NAME, "test"); + cpParams.put(MIME.HeaderFieldParam.FILENAME, "hello κόσμε!%"); + + final MultipartFormEntity entity = MultipartEntityBuilder.create() + .addPart(new FormBodyPartBuilder() + .setName("test") + .setBody(new StringBody("hello world", ContentType.TEXT_PLAIN)) + .addField("Content-Disposition", "multipart/form-data", cpParams) + .build()) + .buildEntity(); + + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + entity.getMultipart().writeTo(out); + out.close(); + String result = out.toString(Consts.ASCII.name()); + Assert.assertTrue(result, result.contains("filename=hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25")); + } + } From 1882a011ea49ea9c824fbea22a2905eb09bbe9ef Mon Sep 17 00:00:00 2001 From: Ioannis Sermetziadis Date: Fri, 29 Sep 2017 00:12:13 +0300 Subject: [PATCH 3/4] HTTPCLIENT-293 Implemented the percent encoding of the filename parameter of the Content-Disposition header based on RFC7578 sections 2 and 4.2. Unit test is updated to use the new HttpMultipartMode successfully. In the new MultipartForm implementation I included a PercentCodec that performs encoding/decoding to/from the percent encoding as described in RFC7578 and RFC3986. The PercentCodec class as well as some inner classes should be proposed to the commons-codec project, which apparently does not provide a generic (URLCodec is not). --- .../http/entity/mime/HttpMultipartMode.java | 4 +- .../entity/mime/HttpRFC7578Multipart.java | 158 ++++++++++++++++++ .../entity/mime/MultipartEntityBuilder.java | 3 + .../mime/TestMultipartEntityBuilder.java | 3 +- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java b/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java index 18923d8586..9a07576e62 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java @@ -38,6 +38,8 @@ public enum HttpMultipartMode { /** browser-compatible mode, i.e. only write Content-Disposition; use content charset */ BROWSER_COMPATIBLE, /** RFC 6532 compliant */ - RFC6532 + RFC6532, + /** RFC 7578 compliant */ + RFC7578 } diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java b/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java new file mode 100644 index 0000000000..e287e10d14 --- /dev/null +++ b/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java @@ -0,0 +1,158 @@ +package org.apache.http.entity.mime; + +import org.apache.commons.codec.DecoderException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.util.BitSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class HttpRFC7578Multipart extends AbstractMultipartForm { + + private final List parts; + + public HttpRFC7578Multipart( + final Charset charset, + final String boundary, + final List parts) { + super(Charset.forName("UTF-8"), boundary); + this.parts = parts; + } + + @Override + public List getBodyParts() { + return parts; + } + + @Override + protected void formatMultipartHeader(FormBodyPart part, OutputStream out) throws IOException { + for (final MinimalField field: part.getHeader()) { + if(MIME.CONTENT_DISPOSITION.equals(field.getName()) && field.getParameters() != null) { + //need to create a copy of field to perform encoding on, because this might happen multiple times + MinimalField fieldCopy = new MinimalField(field); + for (Iterator> it = fieldCopy.getParameters().entrySet().iterator(); it.hasNext(); ) { + Map.Entry next = it.next(); + if(MIME.HeaderFieldParam.FILENAME.equals(next.getKey())) { + String encodedFilenameValue = encodeWithPercentEncoding(next.getValue()); + fieldCopy.getParameters().put(next.getKey(), encodedFilenameValue); + } + } + writeField(fieldCopy, charset, out); + } else { + writeField(field, charset, out); + } + } + } + + private String encodeWithPercentEncoding(String str) { + PercentCodec percentCodec = new PercentCodec(); + byte[] percentEncodeResult = percentCodec.encode(str.getBytes(charset)); + return new String(percentEncodeResult, charset); + } + + static class PercentCodec { + + protected static final byte ESCAPE_CHAR = '%'; + + private static BitSet alwaysEncodeChars = new BitSet(); + + static { + alwaysEncodeChars.set(' '); + alwaysEncodeChars.set('%'); + } + + /** + * Percent-Encoding implementation based on RFC 3986 + */ + public byte[] encode(final byte[] bytes) { + if (bytes == null) { + return null; + } + + CharsetEncoder characterSetEncoder = Charset.forName("US-ASCII").newEncoder(); + + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (final byte c : bytes) { + int b = c; + if (b < 0) { + b = 256 + b; + } + if (characterSetEncoder.canEncode((char) b) && !alwaysEncodeChars.get(c)) { + buffer.write(b); + } else { + buffer.write(ESCAPE_CHAR); + final char hex1 = Utils.hexDigit(b >> 4); + final char hex2 = Utils.hexDigit(b); + buffer.write(hex1); + buffer.write(hex2); + } + } + return buffer.toByteArray(); + } + + public byte[] decode(final byte[] bytes) throws DecoderException { + if (bytes == null) { + return null; + } + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + final int b = bytes[i]; + if (b == ESCAPE_CHAR) { + try { + final int u = Utils.digit16(bytes[++i]); + final int l = Utils.digit16(bytes[++i]); + buffer.write((char) ((u << 4) + l)); + } catch (final ArrayIndexOutOfBoundsException e) { + throw new DecoderException("Invalid URL encoding: ", e); + } + } else { + buffer.write(b); + } + } + return buffer.toByteArray(); + } + } + + static class Utils { + + /** + * Radix used in encoding and decoding. + */ + private static final int RADIX = 16; + + /** + * Returns the numeric value of the character b in radix 16. + * + * @param b + * The byte to be converted. + * @return The numeric value represented by the character in radix 16. + * + * @throws DecoderException + * Thrown when the byte is not valid per {@link Character#digit(char,int)} + */ + static int digit16(final byte b) throws DecoderException { + final int i = Character.digit((char) b, RADIX); + if (i == -1) { + throw new DecoderException("Invalid URL encoding: not a valid digit (radix " + RADIX + "): " + b); + } + return i; + } + + /** + * Returns the upper case hex digit of the lower 4 bits of the int. + * + * @param b the input int + * @return the upper case hex digit of the lower 4 bits of the int. + */ + static char hexDigit(int b) { + return Character.toUpperCase(Character.forDigit(b & 0xF, RADIX)); + } + + } + +} diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java b/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java index 59e4ccc7ac..8435883991 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java @@ -230,6 +230,9 @@ MultipartFormEntity buildEntity() { case RFC6532: form = new HttpRFC6532Multipart(charsetCopy, boundaryCopy, bodyPartsCopy); break; + case RFC7578: + form = new HttpRFC7578Multipart(charsetCopy, boundaryCopy, bodyPartsCopy); + break; default: form = new HttpStrictMultipart(charsetCopy, boundaryCopy, bodyPartsCopy); } diff --git a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java index f8a6204113..1fe04cbc34 100644 --- a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java +++ b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java @@ -134,6 +134,7 @@ public void testMultipartContentDispositionFollowingRFC7578() throws Exception { cpParams.put(MIME.HeaderFieldParam.FILENAME, "hello κόσμε!%"); final MultipartFormEntity entity = MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.RFC7578) .addPart(new FormBodyPartBuilder() .setName("test") .setBody(new StringBody("hello world", ContentType.TEXT_PLAIN)) @@ -146,7 +147,7 @@ public void testMultipartContentDispositionFollowingRFC7578() throws Exception { entity.getMultipart().writeTo(out); out.close(); String result = out.toString(Consts.ASCII.name()); - Assert.assertTrue(result, result.contains("filename=hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25")); + Assert.assertTrue(result, result.contains("filename=\"hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25\"")); } } From cae7427a6b6a3905617405873f927b0c252122e8 Mon Sep 17 00:00:00 2001 From: Ioannis Sermetziadis Date: Fri, 29 Sep 2017 21:38:03 +0300 Subject: [PATCH 4/4] HTTPCLIENT-293 Structure and style improvements. Removed the FormBodyPartBuilder.encodeForHeader method and corresponding test case I did not find this encoding rule anywhere in the specification (RFC 7578) and this was conflicting with my modification. Fixed all checkstyle and unit test issues. Modified MinimalField in order to be immutable (like before). --- .../http/entity/mime/FormBodyPartBuilder.java | 50 +-- .../http/entity/mime/HttpMultipartMode.java | 4 +- .../entity/mime/HttpRFC7578Multipart.java | 303 ++++++++++-------- .../org/apache/http/entity/mime/MIME.java | 22 +- .../apache/http/entity/mime/MinimalField.java | 54 ++-- .../entity/mime/MultipartEntityBuilder.java | 6 +- .../entity/mime/TestFormBodyPartBuilder.java | 26 -- .../mime/TestMultipartEntityBuilder.java | 50 +-- 8 files changed, 244 insertions(+), 271 deletions(-) diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java b/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java index 16c3dd8e6e..6e6b094827 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/FormBodyPartBuilder.java @@ -27,15 +27,16 @@ package org.apache.http.entity.mime; +import java.util.List; +import java.util.Map; + +import java.util.TreeMap; import org.apache.http.entity.ContentType; import org.apache.http.entity.mime.content.AbstractContentBody; import org.apache.http.entity.mime.content.ContentBody; import org.apache.http.util.Args; import org.apache.http.util.Asserts; -import java.util.List; -import java.util.Map; - /** * Builder for individual {@link org.apache.http.entity.mime.FormBodyPart}s. * @@ -77,19 +78,15 @@ public FormBodyPartBuilder setBody(final ContentBody body) { public FormBodyPartBuilder addField(final String name, final String value, final Map parameters) { Args.notNull(name, "Field name"); - MinimalField minimalField = new MinimalField(name, value); - if(parameters != null) { - minimalField.setParameters(parameters); - } - this.header.addField(minimalField); + this.header.addField(new MinimalField(name, value, parameters)); return this; } - public FormBodyPartBuilder addField(final String name, final String value) { - Args.notNull(name, "Field name"); - this.header.addField(new MinimalField(name, value)); - return this; - } + public FormBodyPartBuilder addField(final String name, final String value) { + Args.notNull(name, "Field name"); + this.header.addField(new MinimalField(name, value)); + return this; + } public FormBodyPartBuilder setField(final String name, final String value) { Args.notNull(name, "Field name"); @@ -112,12 +109,12 @@ public FormBodyPart build() { headerCopy.addField(field); } if (headerCopy.getField(MIME.CONTENT_DISPOSITION) == null) { - MinimalField cp = new MinimalField(MIME.CONTENT_DISPOSITION, "form-data"); - cp.addParameter(MIME.HeaderFieldParam.NAME, this.name); - if(this.body.getFilename() != null) { - cp.addParameter(MIME.HeaderFieldParam.FILENAME, this.body.getFilename()); - } - headerCopy.addField(cp); + final Map fieldParameters = new TreeMap(); + fieldParameters.put(MIME.HeaderFieldParam.NAME, this.name); + if (this.body.getFilename() != null) { + fieldParameters.put(MIME.HeaderFieldParam.FILENAME, this.body.getFilename()); + } + headerCopy.addField(new MinimalField(MIME.CONTENT_DISPOSITION, "form-data", fieldParameters)); } if (headerCopy.getField(MIME.CONTENT_TYPE) == null) { final ContentType contentType; @@ -145,19 +142,4 @@ public FormBodyPart build() { return new FormBodyPart(this.name, this.body, headerCopy); } - private static String encodeForHeader(final String headerName) { - if (headerName == null) { - return null; - } - final StringBuilder sb = new StringBuilder(); - for (int i = 0; i < headerName.length(); i++) { - final char x = headerName.charAt(i); - if (x == '"' || x == '\\' || x == '\r') { - sb.append("\\"); - } - sb.append(x); - } - return sb.toString(); - } - } diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java b/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java index 9a07576e62..2a75f6fa95 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/HttpMultipartMode.java @@ -39,7 +39,7 @@ public enum HttpMultipartMode { BROWSER_COMPATIBLE, /** RFC 6532 compliant */ RFC6532, - /** RFC 7578 compliant */ - RFC7578 + /** RFC 7578 compliant */ + RFC7578 } diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java b/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java index e287e10d14..22c91c92e0 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/HttpRFC7578Multipart.java @@ -1,3 +1,30 @@ +/* + * ==================================================================== + * 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + package org.apache.http.entity.mime; import org.apache.commons.codec.DecoderException; @@ -14,145 +41,141 @@ public class HttpRFC7578Multipart extends AbstractMultipartForm { - private final List parts; - - public HttpRFC7578Multipart( - final Charset charset, - final String boundary, - final List parts) { - super(Charset.forName("UTF-8"), boundary); - this.parts = parts; - } - - @Override - public List getBodyParts() { - return parts; - } - - @Override - protected void formatMultipartHeader(FormBodyPart part, OutputStream out) throws IOException { - for (final MinimalField field: part.getHeader()) { - if(MIME.CONTENT_DISPOSITION.equals(field.getName()) && field.getParameters() != null) { - //need to create a copy of field to perform encoding on, because this might happen multiple times - MinimalField fieldCopy = new MinimalField(field); - for (Iterator> it = fieldCopy.getParameters().entrySet().iterator(); it.hasNext(); ) { - Map.Entry next = it.next(); - if(MIME.HeaderFieldParam.FILENAME.equals(next.getKey())) { - String encodedFilenameValue = encodeWithPercentEncoding(next.getValue()); - fieldCopy.getParameters().put(next.getKey(), encodedFilenameValue); - } - } - writeField(fieldCopy, charset, out); - } else { - writeField(field, charset, out); - } - } - } - - private String encodeWithPercentEncoding(String str) { - PercentCodec percentCodec = new PercentCodec(); - byte[] percentEncodeResult = percentCodec.encode(str.getBytes(charset)); - return new String(percentEncodeResult, charset); - } - - static class PercentCodec { - - protected static final byte ESCAPE_CHAR = '%'; - - private static BitSet alwaysEncodeChars = new BitSet(); - - static { - alwaysEncodeChars.set(' '); - alwaysEncodeChars.set('%'); - } - - /** - * Percent-Encoding implementation based on RFC 3986 - */ - public byte[] encode(final byte[] bytes) { - if (bytes == null) { - return null; - } - - CharsetEncoder characterSetEncoder = Charset.forName("US-ASCII").newEncoder(); - - final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - for (final byte c : bytes) { - int b = c; - if (b < 0) { - b = 256 + b; - } - if (characterSetEncoder.canEncode((char) b) && !alwaysEncodeChars.get(c)) { - buffer.write(b); - } else { - buffer.write(ESCAPE_CHAR); - final char hex1 = Utils.hexDigit(b >> 4); - final char hex2 = Utils.hexDigit(b); - buffer.write(hex1); - buffer.write(hex2); - } - } - return buffer.toByteArray(); - } - - public byte[] decode(final byte[] bytes) throws DecoderException { - if (bytes == null) { - return null; - } - final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - for (int i = 0; i < bytes.length; i++) { - final int b = bytes[i]; - if (b == ESCAPE_CHAR) { - try { - final int u = Utils.digit16(bytes[++i]); - final int l = Utils.digit16(bytes[++i]); - buffer.write((char) ((u << 4) + l)); - } catch (final ArrayIndexOutOfBoundsException e) { - throw new DecoderException("Invalid URL encoding: ", e); - } - } else { - buffer.write(b); - } - } - return buffer.toByteArray(); - } - } - - static class Utils { - - /** - * Radix used in encoding and decoding. - */ - private static final int RADIX = 16; - - /** - * Returns the numeric value of the character b in radix 16. - * - * @param b - * The byte to be converted. - * @return The numeric value represented by the character in radix 16. - * - * @throws DecoderException - * Thrown when the byte is not valid per {@link Character#digit(char,int)} - */ - static int digit16(final byte b) throws DecoderException { - final int i = Character.digit((char) b, RADIX); - if (i == -1) { - throw new DecoderException("Invalid URL encoding: not a valid digit (radix " + RADIX + "): " + b); - } - return i; - } - - /** - * Returns the upper case hex digit of the lower 4 bits of the int. - * - * @param b the input int - * @return the upper case hex digit of the lower 4 bits of the int. - */ - static char hexDigit(int b) { - return Character.toUpperCase(Character.forDigit(b & 0xF, RADIX)); - } - - } + private final List parts; + + public HttpRFC7578Multipart( + final String boundary, + final List parts) { + super(Charset.forName("UTF-8"), boundary); + this.parts = parts; + } + + @Override + public List getBodyParts() { + return parts; + } + + @Override + protected void formatMultipartHeader(final FormBodyPart part, final OutputStream out) throws IOException { + for (final MinimalField field : part.getHeader()) { + if (MIME.CONTENT_DISPOSITION.equals(field.getName()) && field.getParameters() != null) { + //need to create a copy of field to perform encoding on, because this might happen multiple times + final MinimalField fieldCopy = new MinimalField(field); + for (final Iterator> it = fieldCopy.getParameters().entrySet().iterator(); it.hasNext(); ) { + final Map.Entry next = it.next(); + if (MIME.HeaderFieldParam.FILENAME.equals(next.getKey())) { + final String encodedFilenameValue = encodeWithPercentEncoding(next.getValue()); + fieldCopy.getParameters().put(next.getKey(), encodedFilenameValue); + } + } + writeField(fieldCopy, charset, out); + } else { + writeField(field, charset, out); + } + } + } + + private String encodeWithPercentEncoding(final String str) { + final PercentCodec percentCodec = new PercentCodec(); + final byte[] percentEncodeResult = percentCodec.encode(str.getBytes(charset)); + return new String(percentEncodeResult, charset); + } + + static class PercentCodec { + + protected static final byte ESCAPE_CHAR = '%'; + + private static BitSet alwaysEncodeChars = new BitSet(); + + static { + alwaysEncodeChars.set(' '); + alwaysEncodeChars.set('%'); + } + + /** + * Percent-Encoding implementation based on RFC 3986 + */ + public byte[] encode(final byte[] bytes) { + if (bytes == null) { + return null; + } + + final CharsetEncoder characterSetEncoder = Charset.forName("US-ASCII").newEncoder(); + + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (final byte c : bytes) { + int b = c; + if (b < 0) { + b = 256 + b; + } + if (characterSetEncoder.canEncode((char) b) && !alwaysEncodeChars.get(c)) { + buffer.write(b); + } else { + buffer.write(ESCAPE_CHAR); + final char hex1 = Utils.hexDigit(b >> 4); + final char hex2 = Utils.hexDigit(b); + buffer.write(hex1); + buffer.write(hex2); + } + } + return buffer.toByteArray(); + } + + public byte[] decode(final byte[] bytes) throws DecoderException { + if (bytes == null) { + return null; + } + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + final int b = bytes[i]; + if (b == ESCAPE_CHAR) { + try { + final int u = Utils.digit16(bytes[++i]); + final int l = Utils.digit16(bytes[++i]); + buffer.write((char) ((u << 4) + l)); + } catch (final ArrayIndexOutOfBoundsException e) { + throw new DecoderException("Invalid URL encoding: ", e); + } + } else { + buffer.write(b); + } + } + return buffer.toByteArray(); + } + } + + static class Utils { + + /** + * Radix used in encoding and decoding. + */ + private static final int RADIX = 16; + + /** + * Returns the numeric value of the character b in radix 16. + * + * @param b The byte to be converted. + * @return The numeric value represented by the character in radix 16. + * @throws DecoderException Thrown when the byte is not valid per {@link Character#digit(char, int)} + */ + static int digit16(final byte b) throws DecoderException { + final int i = Character.digit((char) b, RADIX); + if (i == -1) { + throw new DecoderException("Invalid URL encoding: not a valid digit (radix " + RADIX + "): " + b); + } + return i; + } + + /** + * Returns the upper case hex digit of the lower 4 bits of the int. + * + * @param b the input int + * @return the upper case hex digit of the lower 4 bits of the int. + */ + static char hexDigit(final int b) { + return Character.toUpperCase(Character.forDigit(b & 0xF, RADIX)); + } + + } } diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java b/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java index 82fddd8e73..8b1d79a261 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MIME.java @@ -40,21 +40,19 @@ public final class MIME { public static final String CONTENT_TYPE = "Content-Type"; public static final String CONTENT_TRANSFER_ENC = "Content-Transfer-Encoding"; public static final String CONTENT_DISPOSITION = "Content-Disposition"; - public enum HeaderFieldParam { - NAME("name"), - FILENAME("filename"); - - private String name; + NAME("name"), + FILENAME("filename"); - public String getName() { - return name; - } + private String name; - HeaderFieldParam(String name) { - this.name = name; - } - } + public String getName() { + return name; + } + HeaderFieldParam(final String name) { + this.name = name; + } + } public static final String ENC_8BIT = "8bit"; public static final String ENC_BINARY = "binary"; diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java b/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java index 20f0fe2d69..547d5c8d70 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MinimalField.java @@ -39,7 +39,7 @@ public class MinimalField { private final String name; - private final String value; + private final String value; private Map parameters; public MinimalField(final String name, final String value) { @@ -49,41 +49,37 @@ public MinimalField(final String name, final String value) { this.parameters = new TreeMap(); } - public MinimalField(MinimalField from) { - this.name = from.name; - this.value = from.value; - this.parameters = new TreeMap(from.parameters); - } + public MinimalField(final String name, final String value, final Map parameters) { + this.name = name; + this.value = value; + this.parameters = new TreeMap(parameters); + } + + public MinimalField(final MinimalField from) { + this(from.name, from.value, from.parameters); + } - public String getName() { + public String getName() { return this.name; } public String getBody() { - StringBuilder sb = new StringBuilder(); - sb.append(this.value); - for (Iterator> it = this.parameters.entrySet().iterator(); it.hasNext(); ) { - Map.Entry next = it.next(); - sb.append("; "); - sb.append(next.getKey().getName()); - sb.append("=\""); - sb.append(next.getValue()); - sb.append("\""); - } - return sb.toString(); + final StringBuilder sb = new StringBuilder(); + sb.append(this.value); + for (final Iterator> it = this.parameters.entrySet().iterator(); it.hasNext(); ) { + final Map.Entry next = it.next(); + sb.append("; "); + sb.append(next.getKey().getName()); + sb.append("=\""); + sb.append(next.getValue()); + sb.append("\""); + } + return sb.toString(); } - public Map getParameters() { - return parameters; - } - - public void setParameters(Map parameters) { - this.parameters = parameters; - } - - public void addParameter(MIME.HeaderFieldParam paramName, String paramValue) { - parameters.put(paramName, paramValue); - } + public Map getParameters() { + return parameters; + } @Override public String toString() { diff --git a/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java b/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java index 8435883991..d0ed441f58 100644 --- a/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java +++ b/httpmime/src/main/java/org/apache/http/entity/mime/MultipartEntityBuilder.java @@ -230,9 +230,9 @@ MultipartFormEntity buildEntity() { case RFC6532: form = new HttpRFC6532Multipart(charsetCopy, boundaryCopy, bodyPartsCopy); break; - case RFC7578: - form = new HttpRFC7578Multipart(charsetCopy, boundaryCopy, bodyPartsCopy); - break; + case RFC7578: + form = new HttpRFC7578Multipart(boundaryCopy, bodyPartsCopy); + break; default: form = new HttpStrictMultipart(charsetCopy, boundaryCopy, bodyPartsCopy); } diff --git a/httpmime/src/test/java/org/apache/http/entity/mime/TestFormBodyPartBuilder.java b/httpmime/src/test/java/org/apache/http/entity/mime/TestFormBodyPartBuilder.java index 56dd4f9b8d..81b7e1fd8d 100644 --- a/httpmime/src/test/java/org/apache/http/entity/mime/TestFormBodyPartBuilder.java +++ b/httpmime/src/test/java/org/apache/http/entity/mime/TestFormBodyPartBuilder.java @@ -27,14 +27,11 @@ package org.apache.http.entity.mime; -import java.io.ByteArrayInputStream; import java.io.File; import java.util.Arrays; import java.util.List; - import org.apache.http.entity.ContentType; import org.apache.http.entity.mime.content.FileBody; -import org.apache.http.entity.mime.content.InputStreamBody; import org.apache.http.entity.mime.content.StringBody; import org.junit.Assert; import org.junit.Test; @@ -60,29 +57,6 @@ public void testBuildBodyPartBasics() throws Exception { header.getFields()); } - @Test - public void testCharacterStuffing() throws Exception { - final FormBodyPartBuilder builder = FormBodyPartBuilder.create(); - final InputStreamBody fileBody = new InputStreamBody(new ByteArrayInputStream( - "hello world".getBytes("UTF-8")), "stuff_with \"quotes\" and \\slashes\\.bin"); - final FormBodyPart bodyPart2 = builder - .setName("yada_with \"quotes\" and \\slashes\\") - .setBody(fileBody) - .build(); - - Assert.assertNotNull(bodyPart2); - Assert.assertEquals("yada_with \"quotes\" and \\slashes\\", bodyPart2.getName()); - Assert.assertEquals(fileBody, bodyPart2.getBody()); - final Header header2 = bodyPart2.getHeader(); - Assert.assertNotNull(header2); - assertFields(Arrays.asList( - new MinimalField("Content-Disposition", "form-data; name=\"yada_with \\\"quotes\\\" " + - "and \\\\slashes\\\\\"; filename=\"stuff_with \\\"quotes\\\" and \\\\slashes\\\\.bin\""), - new MinimalField("Content-Type", "application/octet-stream"), - new MinimalField("Content-Transfer-Encoding", "binary")), - header2.getFields()); - } - @Test public void testBuildBodyPartMultipleBuilds() throws Exception { final StringBody stringBody = new StringBody("stuff", ContentType.TEXT_PLAIN); diff --git a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java index 1fe04cbc34..3eb56e81e0 100644 --- a/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java +++ b/httpmime/src/test/java/org/apache/http/entity/mime/TestMultipartEntityBuilder.java @@ -27,6 +27,13 @@ package org.apache.http.entity.mime; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.List; + +import java.util.Map; +import java.util.TreeMap; import org.apache.http.Consts; import org.apache.http.Header; import org.apache.http.entity.ContentType; @@ -35,13 +42,6 @@ import org.junit.Assert; import org.junit.Test; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - public class TestMultipartEntityBuilder { @Test @@ -128,26 +128,26 @@ public void testMultipartCustomContentTypeParameterOverrides() throws Exception } @Test - public void testMultipartContentDispositionFollowingRFC7578() throws Exception { - Map cpParams = new TreeMap(); - cpParams.put(MIME.HeaderFieldParam.NAME, "test"); - cpParams.put(MIME.HeaderFieldParam.FILENAME, "hello κόσμε!%"); + public void testMultipartContentDispositionFollowingRFC7578() throws Exception { + final Map cpParams = new TreeMap(); + cpParams.put(MIME.HeaderFieldParam.NAME, "test"); + cpParams.put(MIME.HeaderFieldParam.FILENAME, "hello κόσμε!%"); - final MultipartFormEntity entity = MultipartEntityBuilder.create() - .setMode(HttpMultipartMode.RFC7578) - .addPart(new FormBodyPartBuilder() - .setName("test") - .setBody(new StringBody("hello world", ContentType.TEXT_PLAIN)) - .addField("Content-Disposition", "multipart/form-data", cpParams) - .build()) - .buildEntity(); + final MultipartFormEntity entity = MultipartEntityBuilder.create() + .setMode(HttpMultipartMode.RFC7578) + .addPart(new FormBodyPartBuilder() + .setName("test") + .setBody(new StringBody("hello world", ContentType.TEXT_PLAIN)) + .addField("Content-Disposition", "multipart/form-data", cpParams) + .build()) + .buildEntity(); - final ByteArrayOutputStream out = new ByteArrayOutputStream(); - entity.getMultipart().writeTo(out); - out.close(); - String result = out.toString(Consts.ASCII.name()); - Assert.assertTrue(result, result.contains("filename=\"hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25\"")); - } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + entity.getMultipart().writeTo(out); + out.close(); + final String result = out.toString(Consts.ASCII.name()); + Assert.assertTrue(result, result.contains("filename=\"hello%20%CE%BA%CF%8C%CF%83%CE%BC%CE%B5!%25\"")); + } }