Encode Unicode download filenames in RFC 5987 response headers

This commit is contained in:
root
2026-10-21 15:43:35 +08:00
parent e125b07c13
commit 25b71ad7d1
2 changed files with 90 additions and 7 deletions
@@ -1,6 +1,8 @@
package lion.Http;
import java.io.File;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -11,9 +13,6 @@ import java.util.Map;
*/
public final class FileResponseFactory {
/** 不能作为 Content-Disposition 文件名的字符(引号、反斜杠与控制字符)统一替换。 */
private static final String UNSAFE_FILENAME_CHARS = "[\r\n\"\\\\]";
private FileResponseFactory() {
}
@@ -72,11 +71,22 @@ public final class FileResponseFactory {
}
/**
* 文件名在响应头里是 {@code attachment; filename="..."} 形式,必须剔除可能断开
* 头部行或提前闭合引号的字符。仅影响响应头,不改变磁盘上的文件名。
* HTTP 响应头不能直接包含中文等 Unicode 字符。保留 ASCII 兼容名,并用
* RFC 5987 的 UTF-8 filename* 传递完整文件名;控制字符、引号和反斜杠仍会清理。
*/
static String contentDisposition(String fileName) {
String safe = fileName == null ? "" : fileName.replaceAll(UNSAFE_FILENAME_CHARS, "_");
return "attachment; filename=\"" + safe + "\"";
StringBuilder safe = new StringBuilder();
StringBuilder ascii = new StringBuilder();
if (fileName != null) fileName.codePoints().forEach(codePoint -> {
int sanitized = Character.isISOControl(codePoint) || codePoint == '"' || codePoint == '\\'
? '_' : codePoint;
safe.appendCodePoint(sanitized);
ascii.append(sanitized <= 0x7e ? (char) sanitized : '_');
});
String disposition = "attachment; filename=\"" + ascii + "\"";
if (safe.toString().equals(ascii.toString())) return disposition;
String encoded = URLEncoder.encode(safe.toString(), StandardCharsets.UTF_8)
.replace("+", "%20").replace("*", "%2A");
return disposition + "; filename*=UTF-8''" + encoded;
}
}
@@ -0,0 +1,73 @@
package lion.Http;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
class UnicodeDownloadTest {
@Test void asciiNamesKeepTheExistingHeader() {
assertEquals("attachment; filename=\"archive + name.zip\"",
FileResponseFactory.contentDisposition("archive + name.zip"));
}
@Test void unicodeNamesHaveAsciiHeadersAndLosslessUtf8Filename() {
for (String filename : new String[]{"中文 日本語.zip", "café + 100%*.zip", "附件 📚.zip"}) {
String header = FileResponseFactory.contentDisposition(filename);
assertTrue(header.chars().allMatch(c -> c >= 32 && c < 127));
assertEquals(filename, decodeFilename(header));
assertFalse(header.contains("+100"));
}
}
@Test void allControlsAndQuotedFilenameEscapesAreRemoved() {
String header = FileResponseFactory.contentDisposition("标题\r\n\u0000\u007f\u0085\"\\.zip");
assertTrue(header.chars().allMatch(c -> c >= 32 && c < 127));
assertEquals("标题_______.zip", decodeFilename(header));
}
@Test void realHttpServerDownloadsUnicodeFileAndSupportsHeadAndResume(@TempDir Path directory) throws Exception {
String filename = "[中文 (日本語)] 下载 + 100% 📚.zip";
Path file = directory.resolve(filename);
byte[] content = new byte[128 * 1024 + 73];
new java.util.Random(73).nextBytes(content);
Files.write(file, content);
try (HttpFileServer server = HttpFileServer.create(0, "/", request ->
FileResponseFactory.forFile(request.rangeHeader(), file.toFile(), filename), 2);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()) {
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.port() + "/archive.zip");
var full = client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofByteArray());
assertEquals(200, full.statusCode());
assertArrayEquals(content, full.body());
assertEquals(filename, decodeFilename(full.headers().firstValue("Content-Disposition").orElseThrow()));
var head = client.send(HttpRequest.newBuilder(uri).method("HEAD", HttpRequest.BodyPublishers.noBody()).build(),
HttpResponse.BodyHandlers.ofByteArray());
assertEquals(200, head.statusCode());
assertEquals(Integer.toString(content.length), head.headers().firstValue("Content-Length").orElseThrow());
assertEquals(0, head.body().length);
var range = client.send(HttpRequest.newBuilder(uri).header("Range", "bytes=4096-8191").GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertEquals(206, range.statusCode());
assertArrayEquals(Arrays.copyOfRange(content, 4096, 8192), range.body());
assertEquals("bytes 4096-8191/" + content.length, range.headers().firstValue("Content-Range").orElseThrow());
assertEquals(filename, decodeFilename(range.headers().firstValue("Content-Disposition").orElseThrow()));
}
}
private static String decodeFilename(String header) {
return URLDecoder.decode(header.substring(header.indexOf("filename*=UTF-8''") + "filename*=UTF-8''".length()),
StandardCharsets.UTF_8);
}
}