From 68338aa81891eb43afff7d6da9ad9b23c4db747e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 22 May 2026 21:33:40 +0200 Subject: [PATCH] Use ASCII chars in Content-Disposition filename parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, gh-36328 avoided using RFC 2047 encoding for the "filename" parameter and use ISO-8859-1 only. This change unfortunately caused issues because some implementations might try and detect the encoding automatically. This commit restricts the filename parameter to ASCII encoding only by: * transliterating characters to the closes ASCII character ("é"->"e", "ä"->"ae"...) * falling back to "_" for other chacacters with non latin alphabet or emojis Fixes gh-36805 --- .../http/ContentDisposition.java | 98 ++- .../http/ContentDispositionTests.java | 589 +++++++++--------- 2 files changed, 382 insertions(+), 305 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ContentDisposition.java b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java index 5498133bbcd..6e2e5f2eb1d 100644 --- a/spring-web/src/main/java/org/springframework/http/ContentDisposition.java +++ b/spring-web/src/main/java/org/springframework/http/ContentDisposition.java @@ -17,12 +17,9 @@ package org.springframework.http; import java.io.ByteArrayOutputStream; -import java.nio.CharBuffer; -import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; -import java.nio.charset.CharsetEncoder; -import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.util.ArrayList; import java.util.Base64; import java.util.HexFormat; @@ -47,6 +44,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; * @author Sebastien Deleuze * @author Juergen Hoeller * @author Rossen Stoyanchev + * @author Brian Clozel * @author Sergey Tsypanov * @since 5.0 * @see RFC 6266 @@ -174,19 +172,19 @@ public final class ContentDisposition { sb.append(this.type); } if (this.name != null) { - sb.append("; name=\""); - sb.append(this.name).append('\"'); + sb.append("; name=\"").append(this.name).append('\"'); } if (this.filename != null) { if (this.charset == null || StandardCharsets.US_ASCII.equals(this.charset)) { - sb.append("; filename=\""); - sb.append(encodeQuotedPairs(this.filename)).append('\"'); + sb.append("; filename=\"") + .append(encodeQuotedPairs(this.filename)) + .append('\"'); } else { - sb.append("; filename=\""); - sb.append(toIso88591(encodeQuotedPairs(this.filename))).append('\"'); - sb.append("; filename*="); - sb.append(encodeRfc5987Filename(this.filename, this.charset)); + sb.append("; filename=\"") + .append(transliterateToAscii(encodeQuotedPairs(this.filename))) + .append("\"; filename*=") + .append(encodeRfc5987Filename(this.filename, this.charset)); } } return sb.toString(); @@ -435,16 +433,74 @@ public final class ContentDisposition { return StreamUtils.copyToString(baos, charset); } - private static String toIso88591(String input) { - CharsetEncoder encoder = ISO_8859_1.newEncoder() - .onUnmappableCharacter(CodingErrorAction.REPLACE) - .replaceWith(new byte[] { (byte) '_' }); - try { - return ISO_8859_1.decode(encoder.encode(CharBuffer.wrap(input))).toString(); - } - catch (CharacterCodingException exc) { - throw new IllegalArgumentException("Failed to convert to ISO 8859-1", exc); + private static String transliterateToAscii(String input) { + StringBuilder sb = new StringBuilder(input.length() + 16); + for (int i = 0; i < input.length();) { + int codePoint = input.codePointAt(i); + i += Character.charCount(codePoint); + if (codePoint <= 127) { + sb.append((char) codePoint); + } + else { + switch (codePoint) { + case 'ä': + sb.append("ae"); + break; + case 'ö': + sb.append("oe"); + break; + case 'ü': + sb.append("ue"); + break; + case 'Ä': + sb.append("Ae"); + break; + case 'Ö': + sb.append("Oe"); + break; + case 'Ü': + sb.append("Ue"); + break; + case 'ß': + sb.append("ss"); + break; + case 'æ': + sb.append("ae"); + break; + case 'Æ': + sb.append("AE"); + break; + case 'œ': + sb.append("oe"); + break; + case 'Œ': + sb.append("OE"); + break; + default: + String cpStr = new String(Character.toChars(codePoint)); + // decompose accented characters into two separate parts + String normalized = Normalizer.normalize(cpStr, Normalizer.Form.NFD); + for (int j = 0; j < normalized.length(); ) { + int ncp = normalized.codePointAt(j); + j += Character.charCount(ncp); + if (ncp <= 127) { + sb.append((char) ncp); + } + else { + int type = Character.getType(ncp); + // do not write fallback character for accents + if (type != Character.NON_SPACING_MARK && + type != Character.COMBINING_SPACING_MARK && + type != Character.ENCLOSING_MARK) { + sb.append('_'); + } + } + } + break; + } + } } + return sb.toString(); } private static String encodeQuotedPairs(String filename) { diff --git a/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java index fc8e6985715..2928b836763 100644 --- a/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java +++ b/spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java @@ -19,6 +19,7 @@ package org.springframework.http; import java.nio.charset.StandardCharsets; import java.util.function.BiConsumer; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -30,293 +31,10 @@ import static org.springframework.http.ContentDisposition.parse; * * @author Sebastien Deleuze * @author Rossen Stoyanchev + * @author Brian Clozel */ class ContentDispositionTests { - - @Test - void parseFilenameQuoted() { - assertThat(parse("form-data; name=\"foo\"; filename=\"foo.txt\"")) - .isEqualTo(ContentDisposition.formData() - .name("foo") - .filename("foo.txt") - .build()); - } - - @Test - void parseFilenameUnquoted() { - assertThat(parse("form-data; filename=unquoted")) - .isEqualTo(ContentDisposition.formData() - .filename("unquoted") - .build()); - } - - @Test // SPR-16091 - void parseFilenameWithSemicolon() { - assertThat(parse("attachment; filename=\"filename with ; semicolon.txt\"")) - .isEqualTo(ContentDisposition.attachment() - .filename("filename with ; semicolon.txt") - .build()); - } - - @Test - void parseEncodedFilename() { - assertThat(parse("form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt")) - .isEqualTo(ContentDisposition.formData() - .name("name") - .filename("中文.txt", StandardCharsets.UTF_8) - .build()); - } - - @Test // gh-24112 - void parseEncodedFilenameWithPaddedCharset() { - assertThat(parse("attachment; filename*= UTF-8''some-file.zip")) - .isEqualTo(ContentDisposition.attachment() - .filename("some-file.zip", StandardCharsets.UTF_8) - .build()); - } - - @Test // gh-26463 - void parseBase64EncodedFilename() { - String input = "attachment; filename=\"=?UTF-8?B?5pel5pys6KqeLmNzdg==?=\""; - assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); - } - - @Test - void parseBase64EncodedFilenameMultipleSegments() { - String input = - "attachment; filename=\"=?utf-8?B?U3ByaW5n5qGG5p625Li65Z+65LqOSmF2YeeahOeOsOS7o+S8geS4muW6lA==?= " + - "=?utf-8?B?55So56iL5bqP5o+Q5L6b5LqG5YWo6Z2i55qE57yW56iL5ZKM6YWN572u5qih?= " + - "=?utf-8?B?5Z6LLnR4dA==?=\""; - assertThat(parse(input).getFilename()).isEqualTo("Spring框架为基于Java的现代企业应用程序提供了全面的编程和配置模型.txt"); - } - - @Test // gh-26463 - void parseBase64EncodedShiftJISFilename() { - String input = "attachment; filename=\"=?SHIFT_JIS?B?k/qWe4zqLmNzdg==?=\""; - assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); - } - - @Test - void parseQuotedPrintableFilename() { - String input = "attachment; filename=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E.csv?=\""; - assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); - } - - @Test - void parseQuotedPrintableFilenameMultipleSegments() { - String input = - "attachment; filename=\"=?utf-8?Q?Spring=E6=A1=86=E6=9E=B6=E4=B8=BA=E5=9F=BA=E4=BA=8E?=" + - "=?utf-8?Q?Java=E7=9A=84=E7=8E=B0=E4=BB=A3=E4=BC=81=E4=B8=9A=E5=BA=94?=" + - "=?utf-8?Q?=E7=94=A8=E7=A8=8B=E5=BA=8F=E6=8F=90=E4=BE=9B=E4=BA=86=E5=85=A8?=" + - "=?utf-8?Q?=E9=9D=A2=E7=9A=84=E7=BC=96=E7=A8=8B=E5=92=8C=E9=85=8D=E7=BD=AE?=" + - "=?utf-8?Q?=E6=A8=A1=E5=9E=8B.txt?=\""; - assertThat(parse(input).getFilename()).isEqualTo("Spring框架为基于Java的现代企业应用程序提供了全面的编程和配置模型.txt"); - - } - - @Test - void parseQuotedPrintableShiftJISFilename() { - String input = "attachment; filename=\"=?SHIFT_JIS?Q?=93=FA=96{=8C=EA.csv?=\""; - assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); - } - - @Test - void parseEncodedFilenameWithoutCharset() { - assertThat(parse("form-data; name=\"name\"; filename*=test.txt")) - .isEqualTo(ContentDisposition.formData() - .name("name") - .filename("test.txt") - .build()); - } - - @Test - void parseEncodedFilenameWithInvalidCharset() { - assertThatIllegalArgumentException() - .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-16''test.txt")); - } - - @Test - void parseEncodedFilenameWithInvalidName() { - assertThatIllegalArgumentException() - .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-8''%A")); - - assertThatIllegalArgumentException() - .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-8''%A.txt")); - } - - @Test - void parseBackslash() { - String s = "form-data; name=\"foo\"; filename=\"foo\\\\bar \\\"baz\\\" qux \\\\\\\" quux.txt\""; - ContentDisposition cd = ContentDisposition.parse( - s); - assertThat(cd.getName()).isEqualTo("foo"); - assertThat(cd.getFilename()).isEqualTo("foo\\bar \"baz\" qux \\\" quux.txt"); - assertThat(cd.toString()).isEqualTo(s); - } - - @Test - void parseBackslashInLastPosition() { - ContentDisposition cd = ContentDisposition.parse("form-data; name=\"foo\"; filename=\"bar\\\""); - assertThat(cd.getName()).isEqualTo("foo"); - assertThat(cd.getFilename()).isEqualTo("bar\\"); - assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"bar\\\\\""); - } - - @Test - void parseWindowsPath() { - ContentDisposition cd = ContentDisposition.parse("form-data; name=\"foo\"; filename=\"D:\\foo\\bar.txt\""); - assertThat(cd.getName()).isEqualTo("foo"); - assertThat(cd.getFilename()).isEqualTo("D:\\foo\\bar.txt"); - assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"D:\\\\foo\\\\bar.txt\""); - } - - @Test - void parseWithExtraSemicolons() { - assertThat(parse("form-data; name=\"foo\";; ; filename=\"foo.txt\";")) - .isEqualTo(ContentDisposition.formData() - .name("foo") - .filename("foo.txt") - .build()); - } - - @Test - void parseAttributesCaseInsensitively() { - ContentDisposition cd = ContentDisposition.parse("form-data; Name=\"foo\"; FileName=\"bar.txt\""); - assertThat(cd.getName()).isEqualTo("foo"); - assertThat(cd.getFilename()).isEqualTo("bar.txt"); - assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"bar.txt\""); - } - - @Test - void parseEmpty() { - assertThatIllegalArgumentException().isThrownBy(() -> parse("")); - } - - @Test - void parseNoType() { - assertThatIllegalArgumentException().isThrownBy(() -> parse(";")); - } - - @Test - void parseInvalidParameter() { - assertThatIllegalArgumentException().isThrownBy(() -> parse("foo;bar")); - } - - @Test - void format() { - assertThat( - ContentDisposition.formData() - .name("foo") - .filename("foo.txt") - .build().toString()) - .isEqualTo("form-data; name=\"foo\"; filename=\"foo.txt\""); - } - - @Test - void formatWithEncodedFilename() { - assertThat( - ContentDisposition.formData() - .name("name") - .filename("中文.txt", StandardCharsets.UTF_8) - .build().toString()) - .isEqualTo("form-data; name=\"name\"; " + - "filename=\"__.txt\"; " + - "filename*=UTF-8''%E4%B8%AD%E6%96%87.txt"); - } - - @Test - void formatWithEncodedFilenameUsingUsAscii() { - assertThat( - ContentDisposition.formData() - .name("name") - .filename("test.txt", StandardCharsets.US_ASCII) - .build() - .toString()) - .isEqualTo("form-data; name=\"name\"; filename=\"test.txt\""); - } - - @Test // gh-24220 - void formatWithFilenameWithQuotes() { - BiConsumer tester = (input, output) -> { - assertThat(ContentDisposition.formData().filename(input).build().toString()) - .isEqualTo("form-data; filename=\"" + output + "\""); - assertThat(ContentDisposition.formData().filename(input, StandardCharsets.US_ASCII).build().toString()) - .isEqualTo("form-data; filename=\"" + output + "\""); - }; - - String filename = "\"foo.txt"; - tester.accept(filename, "\\\"foo.txt"); - - filename = "\\\"foo.txt"; - tester.accept(filename, "\\\\\\\"foo.txt"); - - filename = "\\\\\"foo.txt"; - tester.accept(filename, "\\\\\\\\\\\"foo.txt"); - - filename = "\\\\\\\"foo.txt"; - tester.accept(filename, "\\\\\\\\\\\\\\\"foo.txt"); - - filename = "\\\\\\\\\"foo.txt"; - tester.accept(filename, "\\\\\\\\\\\\\\\\\\\"foo.txt"); - - tester.accept("\"\"foo.txt", "\\\"\\\"foo.txt"); - tester.accept("\"\"\"foo.txt", "\\\"\\\"\\\"foo.txt"); - - tester.accept("foo.txt\\", "foo.txt\\\\"); - tester.accept("foo.txt\\\\", "foo.txt\\\\\\\\"); - tester.accept("foo.txt\\\\\\", "foo.txt\\\\\\\\\\\\"); - } - - @Test - void formatWithUtf8FilenameWithQuotes() { - String filename = "\"中文.txt"; - assertThat(ContentDisposition.formData().filename(filename, StandardCharsets.UTF_8).build().toString()) - .isEqualTo("form-data; filename=\"\\\"__.txt\"; filename*=UTF-8''%22%E4%B8%AD%E6%96%87.txt"); - } - - @Test - void formatWithEncodedFilenameUsingInvalidCharset() { - assertThatIllegalArgumentException().isThrownBy(() -> - ContentDisposition.formData() - .name("name") - .filename("test.txt", StandardCharsets.UTF_16) - .build() - .toString()); - } - - @Test - void parseFormatted() { - ContentDisposition cd = ContentDisposition.builder("form-data") - .name("foo") - .filename("foo\\bar \"baz\" qux \\\" quux.txt").build(); - ContentDisposition parsed = ContentDisposition.parse(cd.toString()); - assertThat(parsed).isEqualTo(cd); - assertThat(parsed.toString()).isEqualTo(cd.toString()); - } - - @Test // gh-30252 - void parseFormattedWithQuestionMark() { - String filename = "filename with ?问号.txt"; - ContentDisposition cd = ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build(); - String result = cd.toString(); - assertThat(result).isEqualTo("attachment; " + - "filename=\"filename with ?__.txt\"; " + - "filename*=UTF-8''filename%20with%20%3F%E9%97%AE%E5%8F%B7.txt"); - - String[] parts = result.split("; "); - - String quotedPrintableFilename = parts[0] + "; " + parts[1]; - assertThat(ContentDisposition.parse(quotedPrintableFilename).getFilename()) - .isEqualTo("filename with ?__.txt"); - - String rfc5987Filename = parts[0] + "; " + parts[2]; - assertThat(ContentDisposition.parse(rfc5987Filename).getFilename()) - .isEqualTo(filename); - } - @Test void attachmentType(){ ContentDisposition attachment = ContentDisposition.attachment().build(); @@ -341,4 +59,307 @@ class ContentDispositionTests { assertThat(inline.isInline()).isTrue(); } + @Nested + class ParsingTests { + + @Test + void parseFilenameQuoted() { + assertThat(parse("form-data; name=\"foo\"; filename=\"foo.txt\"")) + .isEqualTo(ContentDisposition.formData() + .name("foo") + .filename("foo.txt") + .build()); + } + + @Test + void parseFilenameUnquoted() { + assertThat(parse("form-data; filename=unquoted")) + .isEqualTo(ContentDisposition.formData() + .filename("unquoted") + .build()); + } + + @Test // SPR-16091 + void parseFilenameWithSemicolon() { + assertThat(parse("attachment; filename=\"filename with ; semicolon.txt\"")) + .isEqualTo(ContentDisposition.attachment() + .filename("filename with ; semicolon.txt") + .build()); + } + + @Test + void parseEncodedFilename() { + assertThat(parse("form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt")) + .isEqualTo(ContentDisposition.formData() + .name("name") + .filename("中文.txt", StandardCharsets.UTF_8) + .build()); + } + + @Test // gh-24112 + void parseEncodedFilenameWithPaddedCharset() { + assertThat(parse("attachment; filename*= UTF-8''some-file.zip")) + .isEqualTo(ContentDisposition.attachment() + .filename("some-file.zip", StandardCharsets.UTF_8) + .build()); + } + + @Test // gh-26463 + void parseBase64EncodedFilename() { + String input = "attachment; filename=\"=?UTF-8?B?5pel5pys6KqeLmNzdg==?=\""; + assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); + } + + @Test + void parseBase64EncodedFilenameMultipleSegments() { + String input = + "attachment; filename=\"=?utf-8?B?U3ByaW5n5qGG5p625Li65Z+65LqOSmF2YeeahOeOsOS7o+S8geS4muW6lA==?= " + + "=?utf-8?B?55So56iL5bqP5o+Q5L6b5LqG5YWo6Z2i55qE57yW56iL5ZKM6YWN572u5qih?= " + + "=?utf-8?B?5Z6LLnR4dA==?=\""; + assertThat(parse(input).getFilename()).isEqualTo("Spring框架为基于Java的现代企业应用程序提供了全面的编程和配置模型.txt"); + } + + @Test // gh-26463 + void parseBase64EncodedShiftJISFilename() { + String input = "attachment; filename=\"=?SHIFT_JIS?B?k/qWe4zqLmNzdg==?=\""; + assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); + } + + @Test + void parseQuotedPrintableFilename() { + String input = "attachment; filename=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E.csv?=\""; + assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); + } + + @Test + void parseQuotedPrintableFilenameMultipleSegments() { + String input = + "attachment; filename=\"=?utf-8?Q?Spring=E6=A1=86=E6=9E=B6=E4=B8=BA=E5=9F=BA=E4=BA=8E?=" + + "=?utf-8?Q?Java=E7=9A=84=E7=8E=B0=E4=BB=A3=E4=BC=81=E4=B8=9A=E5=BA=94?=" + + "=?utf-8?Q?=E7=94=A8=E7=A8=8B=E5=BA=8F=E6=8F=90=E4=BE=9B=E4=BA=86=E5=85=A8?=" + + "=?utf-8?Q?=E9=9D=A2=E7=9A=84=E7=BC=96=E7=A8=8B=E5=92=8C=E9=85=8D=E7=BD=AE?=" + + "=?utf-8?Q?=E6=A8=A1=E5=9E=8B.txt?=\""; + assertThat(parse(input).getFilename()).isEqualTo("Spring框架为基于Java的现代企业应用程序提供了全面的编程和配置模型.txt"); + + } + + @Test + void parseQuotedPrintableShiftJISFilename() { + String input = "attachment; filename=\"=?SHIFT_JIS?Q?=93=FA=96{=8C=EA.csv?=\""; + assertThat(parse(input).getFilename()).isEqualTo("日本語.csv"); + } + + @Test + void parseEncodedFilenameWithoutCharset() { + assertThat(parse("form-data; name=\"name\"; filename*=test.txt")) + .isEqualTo(ContentDisposition.formData() + .name("name") + .filename("test.txt") + .build()); + } + + @Test + void parseEncodedFilenameWithInvalidCharset() { + assertThatIllegalArgumentException() + .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-16''test.txt")); + } + + @Test + void parseEncodedFilenameWithInvalidName() { + assertThatIllegalArgumentException() + .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-8''%A")); + + assertThatIllegalArgumentException() + .isThrownBy(() -> parse("form-data; name=\"name\"; filename*=UTF-8''%A.txt")); + } + + @Test + void parseBackslash() { + String s = "form-data; name=\"foo\"; filename=\"foo\\\\bar \\\"baz\\\" qux \\\\\\\" quux.txt\""; + ContentDisposition cd = ContentDisposition.parse(s); + assertThat(cd.getName()).isEqualTo("foo"); + assertThat(cd.getFilename()).isEqualTo("foo\\bar \"baz\" qux \\\" quux.txt"); + } + + @Test + void parseBackslashInLastPosition() { + ContentDisposition cd = ContentDisposition.parse("form-data; name=\"foo\"; filename=\"bar\\\""); + assertThat(cd.getName()).isEqualTo("foo"); + assertThat(cd.getFilename()).isEqualTo("bar\\"); + assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"bar\\\\\""); + } + + @Test + void parseWindowsPath() { + ContentDisposition cd = ContentDisposition.parse("form-data; name=\"foo\"; filename=\"D:\\foo\\bar.txt\""); + assertThat(cd.getName()).isEqualTo("foo"); + assertThat(cd.getFilename()).isEqualTo("D:\\foo\\bar.txt"); + assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"D:\\\\foo\\\\bar.txt\""); + } + + @Test + void parseWithExtraSemicolons() { + assertThat(parse("form-data; name=\"foo\";; ; filename=\"foo.txt\";")) + .isEqualTo(ContentDisposition.formData() + .name("foo") + .filename("foo.txt") + .build()); + } + + @Test + void parseAttributesCaseInsensitively() { + ContentDisposition cd = ContentDisposition.parse("form-data; Name=\"foo\"; FileName=\"bar.txt\""); + assertThat(cd.getName()).isEqualTo("foo"); + assertThat(cd.getFilename()).isEqualTo("bar.txt"); + assertThat(cd.toString()).isEqualTo("form-data; name=\"foo\"; filename=\"bar.txt\""); + } + + @Test + void parseEmpty() { + assertThatIllegalArgumentException().isThrownBy(() -> parse("")); + } + + @Test + void parseNoType() { + assertThatIllegalArgumentException().isThrownBy(() -> parse(";")); + } + + @Test + void parseInvalidParameter() { + assertThatIllegalArgumentException().isThrownBy(() -> parse("foo;bar")); + } + + + @Test + void parseFormatted() { + ContentDisposition cd = ContentDisposition.builder("form-data") + .name("foo") + .filename("foo\\bar \"baz\" qux \\\" quux.txt").build(); + ContentDisposition parsed = ContentDisposition.parse(cd.toString()); + assertThat(parsed).isEqualTo(cd); + assertThat(parsed.toString()).isEqualTo(cd.toString()); + } + + @Test // gh-30252 + void parseFormattedWithQuestionMark() { + String filename = "filename with ?问号.txt"; + ContentDisposition cd = ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build(); + String result = cd.toString(); + assertThat(result).isEqualTo("attachment; " + + "filename=\"filename with ?__.txt\"; " + + "filename*=UTF-8''filename%20with%20%3F%E9%97%AE%E5%8F%B7.txt"); + + String[] parts = result.split("; "); + + String quotedPrintableFilename = parts[0] + "; " + parts[1]; + assertThat(ContentDisposition.parse(quotedPrintableFilename).getFilename()) + .isEqualTo("filename with ?__.txt"); + + String rfc5987Filename = parts[0] + "; " + parts[2]; + assertThat(ContentDisposition.parse(rfc5987Filename).getFilename()) + .isEqualTo(filename); + } + } + + @Nested + class FormattingTests { + + @Test + void format() { + assertThat( + ContentDisposition.formData() + .name("foo") + .filename("foo.txt") + .build().toString()) + .isEqualTo("form-data; name=\"foo\"; filename=\"foo.txt\""); + } + + @Test + void formatWithEncodedFilename() { + assertThat( + ContentDisposition.formData() + .name("name") + .filename("Äsprïngö.txt", StandardCharsets.UTF_8) + .build().toString()) + .isEqualTo("form-data; name=\"name\"; " + + "filename=\"Aespringoe.txt\"; " + + "filename*=UTF-8''%C3%84spr%C3%AFng%C3%B6.txt"); + } + + @Test + void formatWithUnmappableCharacters() { + assertThat( + ContentDisposition.formData() + .name("name") + .filename("中文.txt", StandardCharsets.UTF_8) + .build().toString()) + .isEqualTo("form-data; name=\"name\"; " + + "filename=\"__.txt\"; " + + "filename*=UTF-8''%E4%B8%AD%E6%96%87.txt"); + } + + + @Test + void formatWithEncodedFilenameUsingUsAscii() { + assertThat( + ContentDisposition.formData() + .name("name") + .filename("test.txt", StandardCharsets.US_ASCII) + .build() + .toString()) + .isEqualTo("form-data; name=\"name\"; filename=\"test.txt\""); + } + + @Test // gh-24220 + void formatWithFilenameWithQuotes() { + BiConsumer tester = (input, output) -> { + assertThat(ContentDisposition.formData().filename(input).build().toString()) + .isEqualTo("form-data; filename=\"" + output + "\""); + assertThat(ContentDisposition.formData().filename(input, StandardCharsets.US_ASCII).build().toString()) + .isEqualTo("form-data; filename=\"" + output + "\""); + }; + + String filename = "\"foo.txt"; + tester.accept(filename, "\\\"foo.txt"); + + filename = "\\\"foo.txt"; + tester.accept(filename, "\\\\\\\"foo.txt"); + + filename = "\\\\\"foo.txt"; + tester.accept(filename, "\\\\\\\\\\\"foo.txt"); + + filename = "\\\\\\\"foo.txt"; + tester.accept(filename, "\\\\\\\\\\\\\\\"foo.txt"); + + filename = "\\\\\\\\\"foo.txt"; + tester.accept(filename, "\\\\\\\\\\\\\\\\\\\"foo.txt"); + + tester.accept("\"\"foo.txt", "\\\"\\\"foo.txt"); + tester.accept("\"\"\"foo.txt", "\\\"\\\"\\\"foo.txt"); + + tester.accept("foo.txt\\", "foo.txt\\\\"); + tester.accept("foo.txt\\\\", "foo.txt\\\\\\\\"); + tester.accept("foo.txt\\\\\\", "foo.txt\\\\\\\\\\\\"); + } + + @Test + void formatWithUtf8FilenameWithQuotes() { + String filename = "\"中文.txt"; + assertThat(ContentDisposition.formData().filename(filename, StandardCharsets.UTF_8).build().toString()) + .isEqualTo("form-data; filename=\"\\\"__.txt\"; filename*=UTF-8''%22%E4%B8%AD%E6%96%87.txt"); + } + + @Test + void formatWithEncodedFilenameUsingInvalidCharset() { + assertThatIllegalArgumentException().isThrownBy(() -> + ContentDisposition.formData() + .name("name") + .filename("test.txt", StandardCharsets.UTF_16) + .build() + .toString()); + } + } + }