blob: a74c632c21854b0fa2e2904c7b9ae5b465f5ce0a [file]
/*
* Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package jdk.internal.net.http;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.ResponseInfo;
import java.net.http.HttpResponse.BodySubscriber;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jdk.internal.net.http.ResponseSubscribers.PathSubscriber;
import jdk.internal.util.Exceptions;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
public final class ResponseBodyHandlers {
private ResponseBodyHandlers() { }
/**
* A Path body handler.
*/
public static class PathBodyHandler implements BodyHandler<Path>{
private final Path file;
private final List<OpenOption> openOptions; // immutable list
/**
* Factory for creating PathBodyHandler.
*/
public static PathBodyHandler create(Path file,
List<OpenOption> openOptions) {
return new PathBodyHandler(file, openOptions);
}
private PathBodyHandler(Path file,
List<OpenOption> openOptions) {
this.file = file;
this.openOptions = openOptions;
}
@Override
public BodySubscriber<Path> apply(ResponseInfo responseInfo) {
return new PathSubscriber(file, openOptions);
}
}
/** With push promise Map implementation */
public static class PushPromisesHandlerWithMap<T>
implements HttpResponse.PushPromiseHandler<T>
{
private final ConcurrentMap<HttpRequest,CompletableFuture<HttpResponse<T>>> pushPromisesMap;
private final Function<HttpRequest,BodyHandler<T>> pushPromiseHandler;
public PushPromisesHandlerWithMap(Function<HttpRequest,BodyHandler<T>> pushPromiseHandler,
ConcurrentMap<HttpRequest,CompletableFuture<HttpResponse<T>>> pushPromisesMap) {
this.pushPromiseHandler = pushPromiseHandler;
this.pushPromisesMap = pushPromisesMap;
}
@Override
public void applyPushPromise(
HttpRequest initiatingRequest, HttpRequest pushRequest,
Function<BodyHandler<T>,CompletableFuture<HttpResponse<T>>> acceptor)
{
URI initiatingURI = initiatingRequest.uri();
URI pushRequestURI = pushRequest.uri();
if (!initiatingURI.getHost().equalsIgnoreCase(pushRequestURI.getHost()))
return;
String initiatingScheme = initiatingURI.getScheme();
String pushRequestScheme = pushRequestURI.getScheme();
if (!initiatingScheme.equalsIgnoreCase(pushRequestScheme)) return;
int initiatingPort = initiatingURI.getPort();
if (initiatingPort == -1 ) {
if ("https".equalsIgnoreCase(initiatingScheme))
initiatingPort = 443;
else
initiatingPort = 80;
}
int pushPort = pushRequestURI.getPort();
if (pushPort == -1 ) {
if ("https".equalsIgnoreCase(pushRequestScheme))
pushPort = 443;
else
pushPort = 80;
}
if (initiatingPort != pushPort)
return;
CompletableFuture<HttpResponse<T>> cf =
acceptor.apply(pushPromiseHandler.apply(pushRequest));
pushPromisesMap.put(pushRequest, cf);
}
}
// Similar to Path body handler, but for file download.
public static class FileDownloadBodyHandler implements BodyHandler<Path> {
private final Path directory;
private final List<OpenOption> openOptions;
/**
* Factory for creating FileDownloadBodyHandler.
*/
public static FileDownloadBodyHandler create(Path directory,
List<OpenOption> openOptions) {
try {
directory.toFile().getPath();
} catch (UnsupportedOperationException uoe) {
// directory not associated with the default file system provider
throw new IllegalArgumentException("invalid path: " + directory, uoe);
}
// existence, etc, checks must be after FS checks
if (Files.notExists(directory))
throw new IllegalArgumentException("non-existent directory: " + directory);
if (!Files.isDirectory(directory))
throw new IllegalArgumentException("not a directory: " + directory);
if (!Files.isWritable(directory))
throw new IllegalArgumentException("non-writable directory: " + directory);
return new FileDownloadBodyHandler(directory, openOptions);
}
private FileDownloadBodyHandler(Path directory,
List<OpenOption> openOptions) {
this.directory = directory;
this.openOptions = openOptions;
}
/** The "attachment" disposition-type and separator. */
static final String DISPOSITION_TYPE = "attachment;";
/** The "filename" parameter. */
static final Pattern FILENAME = Pattern.compile("filename\\s*=\\s*", CASE_INSENSITIVE);
static final List<String> PROHIBITED = List.of(".", "..", "", "~" , "|");
// Characters disallowed in token values
static final Set<Character> NOT_ALLOWED_IN_TOKEN = Set.of(
'(', ')', '<', '>', '@',
',', ';', ':', '\\', '"',
'/', '[', ']', '?', '=',
'{', '}', ' ', '\t');
static boolean allowedInToken(char c) {
if (NOT_ALLOWED_IN_TOKEN.contains(c))
return false;
// exclude CTL chars <= 31, == 127, or anything >= 128
return isTokenText(c);
}
static final UncheckedIOException unchecked(ResponseInfo rinfo,
String msg) {
String s;
if (Exceptions.enhancedNonSocketExceptions()) {
s = String.format("%s in response [%d, %s]", msg, rinfo.statusCode(), rinfo.headers());
} else {
s = String.format("%s in response [%d]", msg, rinfo.statusCode());
}
return new UncheckedIOException(new IOException(s));
}
static final UncheckedIOException unchecked(String msg) {
return new UncheckedIOException(new IOException(msg));
}
// Process a "filename=" parameter, which is either a "token"
// or a "quoted string". If a token, it is terminated by a
// semicolon or the end of the string.
// If a quoted string (surrounded by "" chars then the closing "
// terminates the name.
// quoted strings may contain quoted-pairs (eg embedded " chars)
static String processFilename(String src) throws UncheckedIOException {
if ("".equals(src))
return src;
if (src.charAt(0) == '\"') {
return processQuotedString(src.substring(1));
} else {
return processToken(src);
}
}
static boolean isTokenText(char c) throws UncheckedIOException {
return c > 31 && c < 127;
}
static boolean isQuotedStringText(char c) throws UncheckedIOException {
return c > 31;
}
static String processQuotedString(String src) throws UncheckedIOException {
boolean inqpair = false;
int len = src.length();
StringBuilder sb = new StringBuilder();
for (int i=0; i<len; i++) {
char c = src.charAt(i);
if (!isQuotedStringText(c)) {
throw unchecked("Illegal character");
}
if (c == '\"') {
if (!inqpair) {
return sb.toString();
} else {
sb.append(c);
}
} else if (c == '\\') {
if (!inqpair) {
inqpair = true;
continue;
} else {
// the quoted char is '\'
sb.append(c);
}
} else {
sb.append(c);
}
if (inqpair) {
inqpair = false;
}
}
// not terminated by "
throw unchecked("Invalid quoted string");
}
static String processToken(String src) throws UncheckedIOException {
int end = 0;
int len = src.length();
boolean whitespace = false;
for (int i=0; i<len; i++) {
char c = src.charAt(i);
if (c == ';') {
break;
}
if (c == ' ' || c == '\t') {
// WS only until ; or end of string
whitespace = true;
continue;
}
end++;
if (whitespace || !allowedInToken(c)) {
String msg = whitespace ? "whitespace must be followed by a semicolon"
: c + " is not allowed in a token";
throw unchecked(msg);
}
}
return src.substring(0, end);
}
@Override
public BodySubscriber<Path> apply(ResponseInfo responseInfo) {
String dispoHeader = responseInfo.headers().firstValue("Content-Disposition")
.orElseThrow(() -> unchecked(responseInfo, "No Content-Disposition header"));
if (!dispoHeader.regionMatches(true, // ignoreCase
0, DISPOSITION_TYPE,
0, DISPOSITION_TYPE.length())) {
throw unchecked(responseInfo, "Unknown Content-Disposition type");
}
Matcher matcher = FILENAME.matcher(dispoHeader);
if (!matcher.find()) {
throw unchecked(responseInfo, "Bad Content-Disposition filename parameter");
}
int n = matcher.end();
String filenameParam = processFilename(dispoHeader.substring(n));
// strip all but the last path segment
int x = filenameParam.lastIndexOf("/");
if (x != -1) {
filenameParam = filenameParam.substring(x+1);
}
x = filenameParam.lastIndexOf("\\");
if (x != -1) {
filenameParam = filenameParam.substring(x+1);
}
filenameParam = filenameParam.trim();
if (PROHIBITED.contains(filenameParam)) {
throw unchecked(responseInfo,
"Prohibited Content-Disposition filename parameter:"
+ filenameParam);
}
Path file = Paths.get(directory.toString(), filenameParam);
if (!file.startsWith(directory)) {
throw unchecked(responseInfo,
"Resulting file, " + file.toString() + ", outside of given directory");
}
return new PathSubscriber(file, openOptions);
}
}
}