diff --git a/make/CreateJmods.gmk b/make/CreateJmods.gmk index 6edc69397a5..d9e5f415fe7 100644 --- a/make/CreateJmods.gmk +++ b/make/CreateJmods.gmk @@ -1,5 +1,5 @@ # -# Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2014, 2021, 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 @@ -196,6 +196,11 @@ else # not java.base endif endif +# Set main class of jdk.httpserver module +ifeq ($(MODULE), jdk.httpserver) + JMOD_FLAGS += --main-class sun.net.httpserver.simpleserver.Main +endif + # Changes to the jmod tool itself should also trigger a rebuild of all jmods. # The variable JMOD_CMD could contain an environment variable assignment before # the actual command. Filter that out using wildcard before adding to DEPS. diff --git a/make/langtools/src/classes/build/tools/symbolgenerator/CreateSymbols.java b/make/langtools/src/classes/build/tools/symbolgenerator/CreateSymbols.java index 71ff01f94dd..1f439e1c29e 100644 --- a/make/langtools/src/classes/build/tools/symbolgenerator/CreateSymbols.java +++ b/make/langtools/src/classes/build/tools/symbolgenerator/CreateSymbols.java @@ -123,6 +123,7 @@ import com.sun.tools.classfile.InnerClasses_attribute; import com.sun.tools.classfile.InnerClasses_attribute.Info; import com.sun.tools.classfile.Method; import com.sun.tools.classfile.MethodParameters_attribute; +import com.sun.tools.classfile.ModuleMainClass_attribute; import com.sun.tools.classfile.ModuleResolution_attribute; import com.sun.tools.classfile.ModuleTarget_attribute; import com.sun.tools.classfile.Module_attribute; @@ -928,6 +929,12 @@ public class CreateSymbols { attributes.put(Attribute.ModuleTarget, new ModuleTarget_attribute(attrIdx, targetIdx)); } + if (header.moduleMainClass != null) { + int attrIdx = addString(cp, Attribute.ModuleMainClass); + int targetIdx = addString(cp, header.moduleMainClass); + attributes.put(Attribute.ModuleMainClass, + new ModuleMainClass_attribute(attrIdx, targetIdx)); + } int attrIdx = addString(cp, Attribute.Module); attributes.put(Attribute.Module, new Module_attribute(attrIdx, @@ -2294,6 +2301,13 @@ public class CreateSymbols { chd.isSealed = true; break; } + case Attribute.ModuleMainClass: { + ModuleMainClass_attribute moduleMainClass = (ModuleMainClass_attribute) attr; + assert feature instanceof ModuleHeaderDescription; + ModuleHeaderDescription mhd = (ModuleHeaderDescription) feature; + mhd.moduleMainClass = moduleMainClass.getMainClassName(cf.constant_pool); + break; + } default: throw new IllegalStateException("Unhandled attribute: " + attrName); @@ -2731,6 +2745,7 @@ public class CreateSymbols { List provides = new ArrayList<>(); Integer moduleResolution; String moduleTarget; + String moduleMainClass; @Override public int hashCode() { @@ -2743,6 +2758,7 @@ public class CreateSymbols { hash = 83 * hash + Objects.hashCode(this.provides); hash = 83 * hash + Objects.hashCode(this.moduleResolution); hash = 83 * hash + Objects.hashCode(this.moduleTarget); + hash = 83 * hash + Objects.hashCode(this.moduleMainClass); return hash; } @@ -2781,6 +2797,10 @@ public class CreateSymbols { other.moduleResolution)) { return false; } + if (!Objects.equals(this.moduleMainClass, + other.moduleMainClass)) { + return false; + } return true; } @@ -2818,6 +2838,8 @@ public class CreateSymbols { output.append(" resolution " + quote(Integer.toHexString(moduleResolution), true)); + if (moduleMainClass != null) + output.append(" moduleMainClass " + quote(moduleMainClass, true)); writeAttributes(output); output.append("\n"); writeInnerClasses(output, baselineVersion, version); @@ -2862,6 +2884,8 @@ public class CreateSymbols { moduleResolution = Integer.parseInt(resolutionFlags, 16); } + moduleMainClass = reader.attributes.get("moduleMainClass"); + readAttributes(reader); reader.moveNext(); readInnerClasses(reader); diff --git a/make/modules/jdk.httpserver/Gensrc.gmk b/make/modules/jdk.httpserver/Gensrc.gmk new file mode 100644 index 00000000000..6e90917db33 --- /dev/null +++ b/make/modules/jdk.httpserver/Gensrc.gmk @@ -0,0 +1,41 @@ +# +# Copyright (c) 2021, 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. +# + +include GensrcCommonJdk.gmk +include GensrcProperties.gmk +include Modules.gmk + +################################################################################ + +# Use wildcard so as to avoid getting non-existing directories back +SIMPLESERVER_RESOURCES_DIRS := $(wildcard $(addsuffix /sun/net/httpserver/simpleserver/resources, \ + $(call FindModuleSrcDirs, jdk.httpserver))) + +$(eval $(call SetupCompileProperties, SIMPLESERVER_PROPERTIES, \ + SRC_DIRS := $(SIMPLESERVER_RESOURCES_DIRS), \ + CLASS := ListResourceBundle, \ +)) + +TARGETS += $(SIMPLESERVER_PROPERTIES) diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Filter.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Filter.java index 2149357b97c..7dacefcb59b 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Filter.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Filter.java @@ -28,10 +28,13 @@ package com.sun.net.httpserver; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; import java.util.List; import java.util.ListIterator; import java.util.Objects; import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import sun.net.httpserver.DelegatingHttpExchange; /** * A filter used to pre- and post-process incoming requests. Pre-processing occurs @@ -134,7 +137,6 @@ public abstract class Filter { */ public abstract void doFilter (HttpExchange exchange, Chain chain) throws IOException; - /** * Returns a short description of this {@code Filter}. * @@ -252,4 +254,64 @@ public abstract class Filter { } }; } + + /** + * Returns a + * {@linkplain Filter#beforeHandler(String, Consumer) pre-processing Filter} + * that inspects and possibly adapts the request state. + * + * The {@code Request} returned by the {@link UnaryOperator requestOperator} + * will be the effective request state of the exchange. It is executed for + * each {@code HttpExchange} before invoking either the next filter in the + * chain or the exchange handler (if this is the final filter in the chain). + * Exceptions thrown by the {@code requestOperator} are not handled by the + * filter. + * + * @apiNote + * When the returned filter is invoked, it first invokes the + * {@code requestOperator} with the given exchange, {@code ex}, in order to + * retrieve the adapted request state. It then invokes the next + * filter in the chain or the exchange handler, passing an exchange + * equivalent to {@code ex} with the adapted request state set as the + * effective request state. + * + *

Example of adding the {@code "Foo"} request header to all requests: + *

{@code
+     *     var filter = Filter.adaptRequest("Add Foo header", r -> r.with("Foo", List.of("Bar")));
+     *     httpContext.getFilters().add(filter);
+     * }
+ * + * @param description the string to be returned from {@link #description()} + * @param requestOperator the request operator + * @return a filter that adapts the request state before the exchange is handled + * @throws NullPointerException if any argument is null + * @since 18 + */ + public static Filter adaptRequest(String description, + UnaryOperator requestOperator) { + Objects.requireNonNull(description); + Objects.requireNonNull(requestOperator); + + return new Filter() { + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + var request = requestOperator.apply(exchange); + var newExchange = new DelegatingHttpExchange(exchange) { + @Override + public URI getRequestURI() { return request.getRequestURI(); } + + @Override + public String getRequestMethod() { return request.getRequestMethod(); } + + @Override + public Headers getRequestHeaders() { return request.getRequestHeaders(); } + }; + chain.doFilter(newExchange); + } + @Override + public String description() { + return description; + } + }; + } } diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java index 2ba160f7216..84535027eb4 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java @@ -25,6 +25,7 @@ package com.sun.net.httpserver; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.LinkedList; @@ -33,6 +34,8 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.BiFunction; +import java.util.stream.Collectors; +import sun.net.httpserver.UnmodifiableHeaders; /** * HTTP request and response headers are represented by this class which @@ -65,6 +68,14 @@ import java.util.function.BiFunction; * value given overwriting any existing values in the value list. * * + *

An instance of {@code Headers} is either mutable or immutable. + * A mutable headers allows to add, remove, or modify header names and + * values, e.g. the instance returned by {@link HttpExchange#getResponseHeaders()}. + * An immutable headers disallows any modification to header names or + * values, e.g. the instance returned by {@link HttpExchange#getRequestHeaders()}. + * The mutator methods for an immutable headers instance unconditionally throw + * {@code UnsupportedOperationException}. + * *

All methods in this class reject {@code null} values for keys and values. * {@code null} keys will never be present in HTTP request or response headers. * @since 1.6 @@ -78,6 +89,25 @@ public class Headers implements Map> { */ public Headers() {map = new HashMap<>(32);} + /** + * Creates a mutable {@code Headers} from the given {@code headers} with + * the same header names and values. + * + * @param headers a map of header names and values + * @throws NullPointerException if {@code headers} or any of its names or + * values are null, or if any value contains + * null. + * @since 18 + */ + public Headers(Map> headers) { + Objects.requireNonNull(headers); + var h = headers.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Entry::getKey, e -> new LinkedList<>(e.getValue()))); + map = new HashMap<>(32); + this.putAll(h); + } + /** * Normalize the key by converting to following form. * First {@code char} upper case, rest lower case. @@ -254,4 +284,55 @@ public class Headers implements Map> { sb.append(" }"); return sb.toString(); } + + /** + * Returns an immutable {@code Headers} with the given name value pairs as + * its set of headers. + * + *

The supplied {@code String} instances must alternate as header names + * and header values. To add several values to the same name, the same name + * must be supplied with each new value. If the supplied {@code headers} is + * empty, then an empty {@code Headers} is returned. + * + * @param headers the list of name value pairs + * @return an immutable headers with the given name value pairs + * @throws NullPointerException if {@code headers} or any of its + * elements are null. + * @throws IllegalArgumentException if the number of supplied strings is odd. + * @since 18 + */ + public static Headers of(String... headers) { + Objects.requireNonNull(headers); + if (headers.length == 0) { + return new UnmodifiableHeaders(new Headers()); + } + if (headers.length % 2 != 0) { + throw new IllegalArgumentException("wrong number, %d, of elements" + .formatted(headers.length)); + } + Arrays.stream(headers).forEach(Objects::requireNonNull); + + var h = new Headers(); + for (int i = 0; i < headers.length; i += 2) { + String name = headers[i]; + String value = headers[i + 1]; + h.add(name, value); + } + return new UnmodifiableHeaders(h); + } + + /** + * Returns an immutable {@code Headers} from the given {@code headers} with + * the same header names and values. + * + * @param headers a map of header names and values + * @return an immutable headers + * @throws NullPointerException if {@code headers} or any of its names or + * values are null, or if any value contains + * null. + * @since 18 + */ + public static Headers of(Map> headers) { + return new UnmodifiableHeaders(new Headers(headers)); + } } diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpExchange.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpExchange.java index 5ad7cf5330b..ea2845f56f5 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpExchange.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpExchange.java @@ -69,7 +69,7 @@ import java.net.URI; * @since 1.6 */ -public abstract class HttpExchange implements AutoCloseable { +public abstract class HttpExchange implements AutoCloseable, Request { /** * Constructor for subclasses to call. @@ -78,19 +78,8 @@ public abstract class HttpExchange implements AutoCloseable { } /** - * Returns an immutable {@link Headers} containing the HTTP headers that - * were included with this request. - * - *

The keys in this {@code Headers} are the header names, while the - * values are a {@link java.util.List} of - * {@linkplain java.lang.String Strings} containing each value that was - * included in the request, in the order they were included. Header fields - * appearing multiple times are represented as multiple string values. - * - *

The keys in {@code Headers} are case-insensitive. - * - * @return a read-only {@code Headers} which can be used to access request - * headers. + * {@inheritDoc} + * @return {@inheritDoc} */ public abstract Headers getRequestHeaders(); @@ -111,16 +100,14 @@ public abstract class HttpExchange implements AutoCloseable { public abstract Headers getResponseHeaders(); /** - * Returns the request {@link URI}. - * - * @return the request {@code URI} + * {@inheritDoc} + * @return {@inheritDoc} */ public abstract URI getRequestURI(); /** - * Returns the request method. - * - * @return the request method + * {@inheritDoc} + * @return {@inheritDoc} */ public abstract String getRequestMethod(); diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpHandlers.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpHandlers.java new file mode 100644 index 00000000000..03642033914 --- /dev/null +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpHandlers.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2021, 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 com.sun.net.httpserver; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * Implementations of {@link com.sun.net.httpserver.HttpHandler HttpHandler} + * that implement various useful handlers, such as a static response handler, + * or a conditional handler that complements one handler with another. + * + *

The factory method {@link #of(int, Headers, String)} provides a + * means to create handlers with pre-set static response state. For example, a + * {@code jsonHandler} that always returns 200 with the same json: + *

{@code
+ *    HttpHandlers.of(200,
+ *                    Headers.of("Content-Type", "application/json"),
+ *                    Files.readString(Path.of("some.json")));
+ * }
+ * or a {@code notAllowedHandler} that always replies with 405 - + * Method Not Allowed, and indicates the set of methods that are allowed: + *
{@code
+ *    HttpHandlers.of(405, Headers.of("Allow", "GET"), "");
+ * }
+ * + *

The functionality of a handler can be extended or enhanced through the + * use of {@link #handleOrElse(Predicate, HttpHandler, HttpHandler) handleOrElse}, + * which allows to complement a given handler. For example, complementing a + * {@code jsonHandler} with notAllowedHandler: + * + *

{@code
+ *    Predicate IS_GET = r -> r.getRequestMethod().equals("GET");
+ *    var handler = HttpHandlers.handleOrElse(IS_GET, jsonHandler, notAllowedHandler);
+ * }
+ * + * The above handleOrElse {@code handler} offers an if-else like construct; + * if the request method is "GET" then handling of the exchange is delegated to + * the {@code jsonHandler}, otherwise handling of the exchange is delegated to + * the {@code notAllowedHandler}. + * + * @since 18 + */ +public final class HttpHandlers { + + private HttpHandlers() { } + + /** + * Complements a conditional {@code HttpHandler} with another handler. + * + *

This method creates a handleOrElse handler; an if-else like + * construct. Exchanges who's request matches the {@code handlerTest} + * predicate are handled by the {@code handler}. All remaining exchanges + * are handled by the {@code fallbackHandler}. + * + *

Example of a nested handleOrElse handler: + *

{@code
+     *    Predicate IS_GET = r -> r.getRequestMethod().equals("GET");
+     *    Predicate WANTS_DIGEST =  r -> r.getRequestHeaders().containsKey("Want-Digest");
+     *
+     *    var h1 = new SomeHandler();
+     *    var h2 = HttpHandlers.handleOrElse(IS_GET, new SomeGetHandler(), h1);
+     *    var h3 = HttpHandlers.handleOrElse(WANTS_DIGEST.and(IS_GET), new SomeDigestHandler(), h2);
+     * }
+ * The {@code h3} handleOrElse handler delegates handling of the exchange to + * {@code SomeDigestHandler} if the "Want-Digest" request header is present + * and the request method is {@code GET}, otherwise it delegates handling of + * the exchange to the {@code h2} handler. The {@code h2} handleOrElse + * handler, in turn, delegates handling of the exchange to {@code + * SomeGetHandler} if the request method is {@code GET}, otherwise it + * delegates handling of the exchange to the {@code h1} handler. The {@code + * h1} handler handles all exchanges that are not previously delegated to + * either {@code SomeGetHandler} or {@code SomeDigestHandler}. + * + * @param handlerTest a request predicate + * @param handler a conditional handler + * @param fallbackHandler a fallback handler + * @return a handler + * @throws NullPointerException if any argument is null + */ + public static HttpHandler handleOrElse(Predicate handlerTest, + HttpHandler handler, + HttpHandler fallbackHandler) { + Objects.requireNonNull(handlerTest); + Objects.requireNonNull(handler); + Objects.requireNonNull(fallbackHandler); + return exchange -> { + if (handlerTest.test(exchange)) + handler.handle(exchange); + else + fallbackHandler.handle(exchange); + }; + } + + /** + * Returns an {@code HttpHandler} that sends a response comprising the given + * {@code statusCode}, {@code headers}, and {@code body}. + * + *

This method creates a handler that reads and discards the request + * body before it sets the response state and sends the response. + * + *

{@code headers} are the effective headers of the response. The + * response body bytes are a {@code UTF-8} encoded byte sequence of + * {@code body}. The response headers + * {@linkplain HttpExchange#sendResponseHeaders(int, long) are sent} with + * the given {@code statusCode} and the body bytes' length (or {@code -1} + * if the body is empty). The body bytes are then sent as response body, + * unless the body is empty, in which case no response body is sent. + * + * @param statusCode a response status code + * @param headers a headers + * @param body a response body string + * @return a handler + * @throws IllegalArgumentException if statusCode is not a positive 3-digit + * integer, as per rfc2616, section 6.1.1 + * @throws NullPointerException if headers or body are null + */ + public static HttpHandler of(int statusCode, Headers headers, String body) { + if (statusCode < 100 || statusCode > 999) + throw new IllegalArgumentException("statusCode must be 3-digit: " + + statusCode); + Objects.requireNonNull(headers); + Objects.requireNonNull(body); + + final var headersCopy = Headers.of(headers); + final var bytes = body.getBytes(StandardCharsets.UTF_8); + + return exchange -> { + try (exchange) { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().putAll(headersCopy); + if (exchange.getRequestMethod().equals("HEAD")) { + exchange.getResponseHeaders().set("Content-Length", Integer.toString(bytes.length)); + exchange.sendResponseHeaders(statusCode, -1); + } + else if (bytes.length == 0) { + exchange.sendResponseHeaders(statusCode, -1); + } else { + exchange.sendResponseHeaders(statusCode, bytes.length); + exchange.getResponseBody().write(bytes); + } + } + }; + } +} diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java index 0067334f417..8ed0172690f 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2020, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2021, 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 @@ -30,6 +30,8 @@ import com.sun.net.httpserver.spi.HttpServerProvider; import java.io.IOException; import java.net.BindException; import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Objects; import java.util.concurrent.Executor; /** @@ -150,6 +152,58 @@ public abstract class HttpServer { return provider.createHttpServer (addr, backlog); } + /** + * Creates an {@code HttpServer} instance with an initial context. + * + *

The server is created with an initial context that maps the + * URI {@code path} to the exchange {@code handler}. The initial context is + * created as if by an invocation of + * {@link HttpServer#createContext(String) createContext(path)}. The + * {@code filters}, if any, are added to the initial context, in the order + * they are given. The returned server is not started so can be configured + * further if required. + * + *

The server instance will bind to the given + * {@link java.net.InetSocketAddress}. + * + *

A maximum backlog can also be specified. This is the maximum number + * of queued incoming connections to allow on the listening socket. + * Queued TCP connections exceeding this limit may be rejected by + * the TCP implementation. The HttpServer is acquired from the currently + * installed {@link HttpServerProvider}. + * + * @param addr the address to listen on, if {@code null} then + * {@link #bind bind} must be called to set the address + * @param backlog the socket backlog. If this value is less than or + * equal to zero, then a system default value is used + * @param path the root URI path of the context, must be absolute + * @param handler the HttpHandler for the context + * @param filters the Filters for the context, optional + * @return the HttpServer + * @throws BindException if the server cannot bind to the address + * @throws IOException if an I/O error occurs + * @throws IllegalArgumentException if path is invalid + * @throws NullPointerException if any of: {@code path}, {@code handler}, + * {@code filters}, or any element of {@code filters}, are {@code null} + * @since 18 + */ + public static HttpServer create(InetSocketAddress addr, + int backlog, + String path, + HttpHandler handler, + Filter... filters) throws IOException { + Objects.requireNonNull(path); + Objects.requireNonNull(handler); + Objects.requireNonNull(filters); + Arrays.stream(filters).forEach(Objects::requireNonNull); + + HttpServer server = HttpServer.create(addr, backlog); + HttpContext context = server.createContext(path); + context.setHandler(handler); + Arrays.stream(filters).forEach(f -> context.getFilters().add(f)); + return server; + } + /** * Binds a currently unbound {@code HttpServer} to the given address and * port number. A maximum backlog can also be specified. This is the maximum diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpsServer.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpsServer.java index aef47ed3bc4..7b3dafd5582 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpsServer.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpsServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2013, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2021, 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 @@ -28,6 +28,9 @@ package com.sun.net.httpserver; import java.io.IOException; import java.net.BindException; import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Objects; + import com.sun.net.httpserver.spi.HttpServerProvider; /** @@ -92,6 +95,61 @@ public abstract class HttpsServer extends HttpServer { return provider.createHttpsServer(addr, backlog); } + /** + * Creates an {@code HttpsServer} instance with an initial context. + * + *

The server is created with an initial context that maps the + * URI {@code path} to the exchange {@code handler}. The initial context is + * created as if by an invocation of + * {@link HttpsServer#createContext(String) createContext(path)}. The + * {@code filters}, if any, are added to the initial context, in the order + * they are given. The returned server is not started so can be configured + * further if required. + * + *

The server instance will bind to the given + * {@link java.net.InetSocketAddress}. + * + *

A maximum backlog can also be specified. This is the maximum number + * of queued incoming connections to allow on the listening socket. + * Queued TCP connections exceeding this limit may be rejected by + * the TCP implementation. The HttpsServer is acquired from the currently + * installed {@link HttpServerProvider}. + * + *

The server must have an HttpsConfigurator established with + * {@link #setHttpsConfigurator(HttpsConfigurator)}. + * + * @param addr the address to listen on, if {@code null} then + * {@link #bind bind} must be called to set the address + * @param backlog the socket backlog. If this value is less than or + * equal to zero, then a system default value is used + * @param path the root URI path of the context, must be absolute + * @param handler the HttpHandler for the context + * @param filters the Filters for the context, optional + * @return the HttpsServer + * @throws BindException if the server cannot bind to the address + * @throws IOException if an I/O error occurs + * @throws IllegalArgumentException if path is invalid + * @throws NullPointerException if any of: {@code path}, {@code handler}, + * {@code filters}, or any element of {@code filters}, are {@code null} + * @since 18 + */ + public static HttpsServer create(InetSocketAddress addr, + int backlog, + String path, + HttpHandler handler, + Filter... filters) throws IOException { + Objects.requireNonNull(path); + Objects.requireNonNull(handler); + Objects.requireNonNull(filters); + Arrays.stream(filters).forEach(Objects::requireNonNull); + + HttpsServer server = HttpsServer.create(addr, backlog); + HttpContext context = server.createContext(path); + context.setHandler(handler); + Arrays.stream(filters).forEach(f -> context.getFilters().add(f)); + return server; + } + /** * Sets this server's {@link HttpsConfigurator} object. * diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Request.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Request.java new file mode 100644 index 00000000000..8d9d8748e6c --- /dev/null +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Request.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2021, 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 com.sun.net.httpserver; + +import java.net.URI; +import java.util.List; +import java.util.Objects; + +/** + * A view of the immutable request state of an HTTP exchange. + * + * @since 18 + */ +public interface Request { + + /** + * Returns the request {@link URI}. + * + * @return the request {@code URI} + */ + URI getRequestURI(); + + /** + * Returns the request method. + * + * @return the request method string + */ + String getRequestMethod(); + + /** + * Returns an immutable {@link Headers} containing the HTTP headers that + * were included with this request. + * + *

The keys in this {@code Headers} are the header names, while the + * values are a {@link java.util.List} of + * {@linkplain java.lang.String Strings} containing each value that was + * included in the request, in the order they were included. Header fields + * appearing multiple times are represented as multiple string values. + * + *

The keys in {@code Headers} are case-insensitive. + * + * @return a read-only {@code Headers} which can be used to access request + * headers. + */ + Headers getRequestHeaders(); + + /** + * Returns an identical {@code Request} with an additional header. + * + *

The returned {@code Request} has the same set of + * {@link #getRequestHeaders() headers} as {@code this} request, but with + * the addition of the given header. All other request state remains + * unchanged. + * + *

If {@code this} request already contains a header with the same name + * as the given {@code headerName}, then its value is not replaced. + * + * @implSpec + * The default implementation first creates a new {@code Headers}, {@code h}, + * then adds all the request headers from {@code this} request to {@code h}, + * then adds the given name-values mapping if {@code headerName} is + * not present in {@code h}. Then an unmodifiable view, {@code h'}, of + * {@code h} and a new {@code Request}, {@code r}, are created. + * The {@code getRequestMethod} and {@code getRequestURI} methods of + * {@code r} simply invoke the equivalently named method of {@code this} + * request. The {@code getRequestHeaders} method returns {@code h'}. Lastly, + * {@code r} is returned. + * + * @param headerName the header name + * @param headerValues the list of header values + * @return a request + * @throws NullPointerException if any argument is null, or if any element + * of headerValues is null. + */ + default Request with(String headerName, List headerValues) { + Objects.requireNonNull(headerName); + Objects.requireNonNull(headerValues); + final Request r = this; + + var h = new Headers(); + h.putAll(r.getRequestHeaders()); + if (!h.containsKey(headerName)) { + h.put(headerName, headerValues); + } + var unmodifiableHeaders = Headers.of(h); + return new Request() { + @Override + public URI getRequestURI() { return r.getRequestURI(); } + + @Override + public String getRequestMethod() { return r.getRequestMethod(); } + + @Override + public Headers getRequestHeaders() { return unmodifiableHeaders; } + }; + } +} diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/SimpleFileServer.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/SimpleFileServer.java new file mode 100644 index 00000000000..bd81e8e5eac --- /dev/null +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/SimpleFileServer.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2021, 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. + * + * 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 com.sun.net.httpserver; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.URLConnection; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import sun.net.httpserver.simpleserver.FileServerHandler; +import sun.net.httpserver.simpleserver.OutputFilter; + +/** + * A simple HTTP file server and its components (intended for testing, + * development and debugging purposes only). + * + *

A simple file server is composed of three + * components: + *

    + *
  • an {@link HttpServer HttpServer} that is bound to a given address,
  • + *
  • an {@link HttpHandler HttpHandler} that serves files from a given + * directory path, and
  • + *
  • an optional {@link Filter Filter} that prints log messages relating to + * the exchanges handled by the server.
  • + *
+ * The individual server components can be retrieved for reuse and extension via + * the static methods provided. + * + *

Simple file server

+ * + *

The {@link #createFileServer(InetSocketAddress,Path,OutputLevel) createFileServer} + * static factory method returns an {@link HttpServer HttpServer} that is a + * simple out-of-the-box file server. The server comes with an initial handler + * that serves files from a given directory path (and its subdirectories). + * The output level determines what log messages are printed to + * {@code System.out}, if any. + * + *

Example of a simple file server: + *

{@code
+ *    var addr = new InetSocketAddress(8080);
+ *    var server = SimpleFileServer.createFileServer(addr, Path.of("/some/path"), OutputLevel.INFO);
+ *    server.start();
+ * }
+ * + *

File handler

+ * + *

The {@link #createFileHandler(Path) createFileHandler} static factory + * method returns an {@code HttpHandler} that serves files and directory + * listings. The handler supports only the HEAD and GET request + * methods; to handle other request methods, one can either add additional + * handlers to the server, or complement the file handler by composing a single + * handler via + * {@link HttpHandlers#handleOrElse(Predicate, HttpHandler, HttpHandler)}. + * + *

Example of composing a single handler: + *

{@code
+ *    var handler = HttpHandlers.handleOrElse(
+ *        (req) -> req.getRequestMethod().equals("PUT"),
+ *        (exchange) -> {
+ *            // validate and handle PUT request
+ *        },
+ *        SimpleFileServer.createFileHandler(Path.of("/some/path")))
+ *    );
+ * }
+ * + *

Output filter

+ * + *

The {@link #createOutputFilter(OutputStream, OutputLevel) createOutputFilter} + * static factory method returns a + * {@link Filter#afterHandler(String, Consumer) post-processing filter} that + * prints log messages relating to the exchanges handled by the server. The + * output format is specified by the {@link OutputLevel outputLevel}. + * + *

Example of an output filter: + *

{@code
+ *    var filter = SimpleFileServer.createOutputFilter(System.out, OutputLevel.VERBOSE);
+ *    var server = HttpServer.create(new InetSocketAddress(8080), 10, "/some/path/", new SomeHandler(), filter);
+ *    server.start();
+ * }
+ * + *

Main entry point

+ * + *

A simple HTTP file server implementation is + * provided via the + * main entry point + * of the {@code jdk.httpserver} module. + * + * @since 18 + */ +public final class SimpleFileServer { + + private static final UnaryOperator MIME_TABLE = + URLConnection.getFileNameMap()::getContentTypeFor; + + private SimpleFileServer() { } + + /** + * Describes the log message output level produced by the server when + * processing exchanges. + * + * @since 18 + */ + public enum OutputLevel { + /** + * Used to specify no log message output level. + */ + NONE, + + /** + * Used to specify the informative log message output level. + * + *

The log message format is based on the + * Common Logfile Format, + * that includes the following information about an {@code HttpExchange}: + * + *

{@code remotehost rfc931 authuser [date] "request" status bytes} + * + *

Example: + *

{@code
+         *    127.0.0.1 - - [22/Jun/2000:13:55:36 -0700] "GET /example.txt HTTP/1.1" 200 -
+         * }
+ * + * @implNote The fields {@code rfc931}, {@code authuser} and {@code bytes} + * are not captured in the implementation, so are always represented as + * {@code '-'}. + */ + INFO, + + /** + * Used to specify the verbose log message output level. + * + *

Additional to the information provided by the + * {@linkplain OutputLevel#INFO info} level, the verbose level + * includes the request and response headers of the {@code HttpExchange} + * and the absolute path of the resource served up. + */ + VERBOSE + } + + /** + * Creates a file server the serves files from a given path. + * + *

The server is configured with an initial context that maps the + * URI {@code path} to a file handler. The file handler is + * created as if by an invocation of + * {@link #createFileHandler(Path) createFileHandler(rootDirectory)}, and is + * associated to a context created as if by an invocation of + * {@link HttpServer#createContext(String) createContext("/")}. + * + *

An output level can be given to print log messages relating to the + * exchanges handled by the server. The log messages, if any, are printed to + * {@code System.out}. If {@link OutputLevel#NONE OutputLevel.NONE} is + * given, no log messages are printed. + * + * @param addr the address to listen on + * @param rootDirectory the root directory to be served, must be an absolute path + * @param outputLevel the log message output level + * @return an HttpServer + * @throws IllegalArgumentException if root does not exist, is not absolute, + * is not a directory, or is not readable + * @throws UncheckedIOException if an I/O error occurs + * @throws NullPointerException if any argument is null + * @throws SecurityException if a security manager is installed and a + * recursive {@link java.io.FilePermission} "{@code read}" of the + * rootDirectory is denied + */ + public static HttpServer createFileServer(InetSocketAddress addr, + Path rootDirectory, + OutputLevel outputLevel) { + Objects.requireNonNull(addr); + Objects.requireNonNull(rootDirectory); + Objects.requireNonNull(outputLevel); + try { + var handler = FileServerHandler.create(rootDirectory, MIME_TABLE); + if (outputLevel.equals(OutputLevel.NONE)) + return HttpServer.create(addr, 0, "/", handler); + else + return HttpServer.create(addr, 0, "/", handler, OutputFilter.create(System.out, outputLevel)); + } catch (IOException ioe) { + throw new UncheckedIOException(ioe); + } + } + + /** + * Creates a file handler that serves files from a given directory + * path (and its subdirectories). + * + *

The file handler resolves the request URI against the given + * {@code rootDirectory} path to determine the path {@code p} on the + * associated file system to serve the response. If the path {@code p} is + * a directory, then the response contains a directory listing, formatted in + * HTML, as the response body. If the path {@code p} is a file, then the + * response contains a "Content-Type" header based on the best-guess + * content type, as determined by an invocation of + * {@linkplain java.net.FileNameMap#getContentTypeFor(String) getContentTypeFor}, + * on the system-wide {@link URLConnection#getFileNameMap() mimeTable}, as + * well as the contents of the file as the response body. + * + *

The handler supports only requests with the HEAD or GET + * method, and will reply with a {@code 405} response code for requests with + * any other method. + * + * @param rootDirectory the root directory to be served, must be an absolute path + * @return a file handler + * @throws IllegalArgumentException if rootDirectory does not exist, + * is not absolute, is not a directory, or is not readable + * @throws NullPointerException if the argument is null + * @throws SecurityException if a security manager is installed and a + * recursive {@link java.io.FilePermission} "{@code read}" of the + * rootDirectory is denied + */ + public static HttpHandler createFileHandler(Path rootDirectory) { + Objects.requireNonNull(rootDirectory); + return FileServerHandler.create(rootDirectory, MIME_TABLE); + } + + /** + * Creates a {@linkplain Filter#afterHandler(String, Consumer) + * post-processing Filter} that prints log messages about + * {@linkplain HttpExchange exchanges}. The log messages are printed to + * the given {@code OutputStream} in {@code UTF-8} encoding. + * + * @apiNote + * To not output any log messages it is recommended to not use a filter. + * + * @param out the stream to print to + * @param outputLevel the output level + * @return a post-processing filter + * @throws IllegalArgumentException if {@link OutputLevel#NONE OutputLevel.NONE} + * is given + * @throws NullPointerException if any argument is null + */ + public static Filter createOutputFilter(OutputStream out, + OutputLevel outputLevel) { + Objects.requireNonNull(out); + Objects.requireNonNull(outputLevel); + return OutputFilter.create(out, outputLevel); + } +} diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/package-info.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/package-info.java index 0f5382444ec..ed3978e0eae 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/package-info.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2013, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2021, 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 @@ -120,6 +120,12 @@ } }); +

+ The {@link com.sun.net.httpserver.SimpleFileServer} class offers a simple + HTTP file server (intended for testing, development and debugging purposes + only). A default implementation is provided via the + main entry point + of the {@code jdk.httpserver} module. @since 1.6 */ diff --git a/src/jdk.httpserver/share/classes/module-info.java b/src/jdk.httpserver/share/classes/module-info.java index 015b9355d00..417854d2ee8 100644 --- a/src/jdk.httpserver/share/classes/module-info.java +++ b/src/jdk.httpserver/share/classes/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2014, 2021, 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 @@ -25,6 +25,40 @@ /** * Defines the JDK-specific HTTP server API. + *

+ * A basic high-level API for building embedded servers. Both HTTP and + * HTTPS are supported. + *

+ * The main components are: + *

    + *
  • the {@link com.sun.net.httpserver.HttpExchange} class that describes a + * request and response pair,
  • + *
  • the {@link com.sun.net.httpserver.HttpHandler} interface to handle + * incoming requests, plus the {@link com.sun.net.httpserver.HttpHandlers} class + * that provides useful handler implementations,
  • + *
  • the {@link com.sun.net.httpserver.HttpContext} class that maps a URI path + * to a {@code HttpHandler},
  • + *
  • the {@link com.sun.net.httpserver.HttpServer} class to listen for + * connections and dispatch requests to handlers,
  • + *
  • the {@link com.sun.net.httpserver.Filter} class that allows pre- and post- + * processing of requests.
+ *

+ * The {@link com.sun.net.httpserver.SimpleFileServer} class offers a simple + * HTTP file server (intended for testing, development and debugging purposes + * only). A default implementation is provided via the + * main entry point of the {@code jdk.httpserver} module, which can be used on + * the command line as such: + *

{@code
+ *    Usage: java -m jdk.httpserver [-b bind address] [-p port] [-d directory]
+ *                                  [-o none|info|verbose] [-h to show options]
+ *    Options:
+ *    -b, --bind-address    - Address to bind to. Default: 127.0.0.1 or ::1 (loopback).
+ *                            For all interfaces use "-b 0.0.0.0" or "-b ::".
+ *    -d, --directory       - Directory to serve. Default: current directory.
+ *    -o, --output          - Output format. none|info|verbose. Default: info.
+ *    -p, --port            - Port to listen on. Default: 8000.
+ *    -h, -?, --help        - Print this help message.
+ * }
* * @uses com.sun.net.httpserver.spi.HttpServerProvider * diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/DelegatingHttpExchange.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/DelegatingHttpExchange.java new file mode 100644 index 00000000000..88399471a7f --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/DelegatingHttpExchange.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021, 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 sun.net.httpserver; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; + +public abstract class DelegatingHttpExchange extends HttpExchange { + + private final HttpExchange exchange; + + public DelegatingHttpExchange(HttpExchange ex) { + this.exchange = ex; + } + + public abstract Headers getRequestHeaders(); + + public abstract String getRequestMethod(); + + public abstract URI getRequestURI(); + + public Headers getResponseHeaders() { + return exchange.getResponseHeaders(); + } + + public HttpContext getHttpContext() { + return exchange.getHttpContext(); + } + + public void close() { + exchange.close(); + } + + public InputStream getRequestBody() { + return exchange.getRequestBody(); + } + + public int getResponseCode() { + return exchange.getResponseCode(); + } + + public OutputStream getResponseBody() { + return exchange.getResponseBody(); + } + + public void sendResponseHeaders(int rCode, long contentLen) throws IOException { + exchange.sendResponseHeaders(rCode, contentLen); + } + + public InetSocketAddress getRemoteAddress() { + return exchange.getRemoteAddress(); + } + + public InetSocketAddress getLocalAddress() { + return exchange.getLocalAddress(); + } + + public String getProtocol() { + return exchange.getProtocol(); + } + + public Object getAttribute(String name) { + return exchange.getAttribute(name); + } + + public void setAttribute(String name, Object value) { + exchange.setAttribute(name, value); + } + + public void setStreams(InputStream i, OutputStream o) { + exchange.setStreams(i, o); + } + + public HttpPrincipal getPrincipal() { + return exchange.getPrincipal(); + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/ExchangeImpl.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/ExchangeImpl.java index 454b6cd435c..6a7398611ac 100644 --- a/src/jdk.httpserver/share/classes/sun/net/httpserver/ExchangeImpl.java +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/ExchangeImpl.java @@ -85,7 +85,7 @@ class ExchangeImpl { String m, URI u, Request req, long len, HttpConnection connection ) throws IOException { this.req = req; - this.reqHdrs = new UnmodifiableHeaders(req.headers()); + this.reqHdrs = Headers.of(req.headers()); this.rspHdrs = new Headers(); this.method = m; this.uri = u; diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/FileServerHandler.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/FileServerHandler.java new file mode 100644 index 00000000000..b1b28446b4d --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/FileServerHandler.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2005, 2021, 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. + * + * 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 sun.net.httpserver.simpleserver; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.lang.System.Logger; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpHandlers; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * A basic HTTP file server handler for static content. + * + *

Must be given an absolute pathname to the directory to be served. + * Supports only HEAD and GET requests. Directory listings and files can be + * served, content types are supported on a best-guess basis. + */ +public final class FileServerHandler implements HttpHandler { + + private static final List SUPPORTED_METHODS = List.of("HEAD", "GET"); + private static final List UNSUPPORTED_METHODS = + List.of("CONNECT", "DELETE", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"); + + private final Path root; + private final UnaryOperator mimeTable; + private final Logger logger; + + private FileServerHandler(Path root, UnaryOperator mimeTable) { + root = root.normalize(); + + @SuppressWarnings("removal") + var securityManager = System.getSecurityManager(); + if (securityManager != null) + securityManager.checkRead(pathForSecurityCheck(root.toString())); + + if (!Files.exists(root)) + throw new IllegalArgumentException("Path does not exist: " + root); + if (!root.isAbsolute()) + throw new IllegalArgumentException("Path is not absolute: " + root); + if (!Files.isDirectory(root)) + throw new IllegalArgumentException("Path is not a directory: " + root); + if (!Files.isReadable(root)) + throw new IllegalArgumentException("Path is not readable: " + root); + this.root = root; + this.mimeTable = mimeTable; + this.logger = System.getLogger("com.sun.net.httpserver"); + } + + private static String pathForSecurityCheck(String path) { + var separator = String.valueOf(File.separatorChar); + return path.endsWith(separator) ? (path + "-") : (path + separator + "-"); + } + + private static final HttpHandler NOT_IMPLEMENTED_HANDLER = + HttpHandlers.of(501, Headers.of(), ""); + + private static final HttpHandler METHOD_NOT_ALLOWED_HANDLER = + HttpHandlers.of(405, Headers.of("Allow", "HEAD, GET"), ""); + + public static HttpHandler create(Path root, UnaryOperator mimeTable) { + var fallbackHandler = HttpHandlers.handleOrElse( + r -> UNSUPPORTED_METHODS.contains(r.getRequestMethod()), + METHOD_NOT_ALLOWED_HANDLER, + NOT_IMPLEMENTED_HANDLER); + return HttpHandlers.handleOrElse( + r -> SUPPORTED_METHODS.contains(r.getRequestMethod()), + new FileServerHandler(root, mimeTable), fallbackHandler); + } + + private void handleHEAD(HttpExchange exchange, Path path) throws IOException { + handleSupportedMethod(exchange, path, false); + } + + private void handleGET(HttpExchange exchange, Path path) throws IOException { + handleSupportedMethod(exchange, path, true); + } + + private void handleSupportedMethod(HttpExchange exchange, Path path, boolean writeBody) + throws IOException { + if (Files.isDirectory(path)) { + if (missingSlash(exchange)) { + handleMovedPermanently(exchange); + return; + } + if (indexFile(path) != null) { + serveFile(exchange, indexFile(path), writeBody); + } else { + listFiles(exchange, path, writeBody); + } + } else { + serveFile(exchange, path, writeBody); + } + } + + private void handleMovedPermanently(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Location", getRedirectURI(exchange.getRequestURI())); + exchange.sendResponseHeaders(301, -1); + } + + private void handleForbidden(HttpExchange exchange) throws IOException { + exchange.sendResponseHeaders(403, -1); + } + + private void handleNotFound(HttpExchange exchange) throws IOException { + String fileNotFound = ResourceBundleHelper.getMessage("html.not.found"); + var bytes = (openHTML + + "

" + fileNotFound + "

\n" + + "

" + sanitize.apply(exchange.getRequestURI().getPath()) + "

\n" + + closeHTML).getBytes(UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + + if (exchange.getRequestMethod().equals("HEAD")) { + exchange.getResponseHeaders().set("Content-Length", Integer.toString(bytes.length)); + exchange.sendResponseHeaders(404, -1); + } else { + exchange.sendResponseHeaders(404, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + } + + private static void discardRequestBody(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody()) { + is.readAllBytes(); + } + } + + private String getRedirectURI(URI uri) { + String query = uri.getRawQuery(); + String redirectPath = uri.getRawPath() + "/"; + return query == null ? redirectPath : redirectPath + "?" + query; + } + + private static boolean missingSlash(HttpExchange exchange) { + return !exchange.getRequestURI().getPath().endsWith("/"); + } + + private static String contextPath(HttpExchange exchange) { + String context = exchange.getHttpContext().getPath(); + if (!context.startsWith("/")) { + throw new IllegalArgumentException("Context path invalid: " + context); + } + return context; + } + + private static String requestPath(HttpExchange exchange) { + String request = exchange.getRequestURI().getPath(); + if (!request.startsWith("/")) { + throw new IllegalArgumentException("Request path invalid: " + request); + } + return request; + } + + // Checks that the request does not escape context. + private static void checkRequestWithinContext(String requestPath, + String contextPath) { + if (requestPath.equals(contextPath)) { + return; // context path requested, e.g. context /foo, request /foo + } + String contextPathWithTrailingSlash = contextPath.endsWith("/") + ? contextPath : contextPath + "/"; + if (!requestPath.startsWith(contextPathWithTrailingSlash)) { + throw new IllegalArgumentException("Request not in context: " + contextPath); + } + } + + // Checks that path is, or is within, the root. + private static Path checkPathWithinRoot(Path path, Path root) { + if (!path.startsWith(root)) { + throw new IllegalArgumentException("Request not in root"); + } + return path; + } + + // Returns the request URI path relative to the context. + private static String relativeRequestPath(HttpExchange exchange) { + String context = contextPath(exchange); + String request = requestPath(exchange); + checkRequestWithinContext(request, context); + return request.substring(context.length()); + } + + private Path mapToPath(HttpExchange exchange, Path root) { + try { + assert root.isAbsolute() && Files.isDirectory(root); // checked during creation + String uriPath = relativeRequestPath(exchange); + String[] pathSegment = uriPath.split("/"); + + // resolve each path segment against the root + Path path = root; + for (var segment : pathSegment) { + path = path.resolve(segment); + if (!Files.isReadable(path) || isHiddenOrSymLink(path)) { + return null; // stop resolution, null results in 404 response + } + } + path = path.normalize(); + return checkPathWithinRoot(path, root); + } catch (Exception e) { + logger.log(System.Logger.Level.TRACE, + "FileServerHandler: request URI path resolution failed", e); + return null; // could not resolve request URI path + } + } + + private static Path indexFile(Path path) { + Path html = path.resolve("index.html"); + Path htm = path.resolve("index.htm"); + return Files.exists(html) ? html : Files.exists(htm) ? htm : null; + } + + private void serveFile(HttpExchange exchange, Path path, boolean writeBody) + throws IOException + { + var respHdrs = exchange.getResponseHeaders(); + respHdrs.set("Content-Type", mediaType(path.toString())); + respHdrs.set("Last-Modified", getLastModified(path)); + if (writeBody) { + exchange.sendResponseHeaders(200, Files.size(path)); + try (InputStream fis = Files.newInputStream(path); + OutputStream os = exchange.getResponseBody()) { + fis.transferTo(os); + } + } else { + respHdrs.set("Content-Length", Long.toString(Files.size(path))); + exchange.sendResponseHeaders(200, -1); + } + } + + private void listFiles(HttpExchange exchange, Path path, boolean writeBody) + throws IOException + { + var respHdrs = exchange.getResponseHeaders(); + respHdrs.set("Content-Type", "text/html; charset=UTF-8"); + respHdrs.set("Last-Modified", getLastModified(path)); + var bodyBytes = dirListing(exchange, path).getBytes(UTF_8); + if (writeBody) { + exchange.sendResponseHeaders(200, bodyBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bodyBytes); + } + } else { + respHdrs.set("Content-Length", Integer.toString(bodyBytes.length)); + exchange.sendResponseHeaders(200, -1); + } + } + + private static final String openHTML = """ + + + + + + + """; + + private static final String closeHTML = """ + + + """; + + private static final String hrefListItemTemplate = """ +
  • %s
  • + """; + + private static String hrefListItemFor(URI uri) { + return hrefListItemTemplate.formatted(uri.toASCIIString(), sanitize.apply(uri.getPath())); + } + + private static String dirListing(HttpExchange exchange, Path path) throws IOException { + String dirListing = ResourceBundleHelper.getMessage("html.dir.list"); + var sb = new StringBuilder(openHTML + + "

    " + dirListing + " " + + sanitize.apply(exchange.getRequestURI().getPath()) + + "

    \n" + + "
      \n"); + try (var paths = Files.list(path)) { + paths.filter(p -> Files.isReadable(p) && !isHiddenOrSymLink(p)) + .map(p -> path.toUri().relativize(p.toUri())) + .forEach(uri -> sb.append(hrefListItemFor(uri))); + } + sb.append("
    \n"); + sb.append(closeHTML); + + return sb.toString(); + } + + private static String getLastModified(Path path) throws IOException { + var fileTime = Files.getLastModifiedTime(path); + return fileTime.toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } + + private static boolean isHiddenOrSymLink(Path path) { + try { + return Files.isHidden(path) || Files.isSymbolicLink(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // Default for unknown content types, as per RFC 2046 + private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; + + private String mediaType(String file) { + String type = mimeTable.apply(file); + return type != null ? type : DEFAULT_CONTENT_TYPE; + } + + // A non-exhaustive map of reserved-HTML and special characters to their + // equivalent entity. + private static final Map RESERVED_CHARS = Map.of( + (int) '&' , "&" , + (int) '<' , "<" , + (int) '>' , ">" , + (int) '"' , """ , + (int) '\'' , "'" , + (int) '/' , "/" ); + + // A function that takes a string and returns a sanitized version of that + // string with the reserved-HTML and special characters replaced with their + // equivalent entity. + private static final UnaryOperator sanitize = + file -> file.chars().collect(StringBuilder::new, + (sb, c) -> sb.append(RESERVED_CHARS.getOrDefault(c, Character.toString(c))), + StringBuilder::append).toString(); + + @Override + public void handle(HttpExchange exchange) throws IOException { + assert List.of("GET", "HEAD").contains(exchange.getRequestMethod()); + try (exchange) { + discardRequestBody(exchange); + Path path = mapToPath(exchange, root); + if (path != null) { + exchange.setAttribute("request-path", path.toString()); // store for OutputFilter + if (!Files.exists(path) || !Files.isReadable(path) || isHiddenOrSymLink(path)) { + handleNotFound(exchange); + } else if (exchange.getRequestMethod().equals("HEAD")) { + handleHEAD(exchange, path); + } else { + handleGET(exchange, path); + } + } else { + exchange.setAttribute("request-path", "could not resolve request URI path"); + handleNotFound(exchange); + } + } + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/Main.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/Main.java new file mode 100644 index 00000000000..c51140bf5e9 --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/Main.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021, 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. + * + * 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 sun.net.httpserver.simpleserver; + +import java.io.PrintWriter; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Programmatic entry point to start the simpleserver tool. + * + *

    This is NOT part of any supported API. + * If you write code that depends on this, you do so at your own risk. + * This code and its internal interface are subject to change or deletion + * without notice. + */ +public class Main { + + /** + * This constructor should never be called. + */ + private Main() { throw new AssertionError(); } + + /** + * The main entry point. + * + *

    The command line arguments are parsed and the server is started. If + * started successfully, the server will run on a new non-daemon thread, + * and this method will return. Otherwise, if the server is not started + * successfully, e.g. an error is encountered while parsing the arguments + * or an I/O error occurs, the server is not started and this method invokes + * System::exit with an appropriate exit code. + * + * @param args the command-line options + * @throws NullPointerException if {@code args} is {@code null}, or if there + * are any {@code null} values in the {@code args} array + */ + public static void main(String... args) { + int ec = SimpleFileServerImpl.start(new PrintWriter(System.out, true, UTF_8), args); + if (ec != 0) + System.exit(ec); + // otherwise the server has been started successfully and runs in + // another non-daemon thread. + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/OutputFilter.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/OutputFilter.java new file mode 100644 index 00000000000..bdbcf2361ee --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/OutputFilter.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2005, 2021, 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. + * + * 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 sun.net.httpserver.simpleserver; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.function.Consumer; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * A Filter that outputs log messages about an HttpExchange. The implementation + * uses a {@link Filter#afterHandler(String, Consumer) post-processing filter}. + * + *

    If the outputLevel is INFO, the format is based on the + * Common Logfile Format. + * In this case the output includes the following information about an exchange: + * + *

    remotehost rfc931 authuser [date] "request line" status bytes + * + *

    Example: + * 127.0.0.1 - - [22/Jun/2000:13:55:36 -0700] "GET /example.txt HTTP/1.1" 200 - + * + *

    The fields rfc931, authuser and bytes are not captured in the implementation + * and are always represented as '-'. + * + *

    If the outputLevel is VERBOSE, the output additionally includes the + * absolute path of the resource requested, if it has been + * {@linkplain HttpExchange#setAttribute(String, Object) provided} via the + * attribute {@code "request-path"}, as well as the request and response headers + * of the exchange. + */ +public final class OutputFilter extends Filter { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MMM/yyyy:HH:mm:ss Z"); + private final PrintStream printStream; + private final OutputLevel outputLevel; + private final Filter filter; + + private OutputFilter(OutputStream os, OutputLevel outputLevel) { + printStream = new PrintStream(os, true, UTF_8); + this.outputLevel = outputLevel; + var description = "HttpExchange OutputFilter (outputLevel: " + outputLevel + ")"; + this.filter = Filter.afterHandler(description, operation()); + } + + public static OutputFilter create(OutputStream os, OutputLevel outputLevel) { + if (outputLevel.equals(OutputLevel.NONE)) { + throw new IllegalArgumentException("Not a valid outputLevel: " + outputLevel); + } + return new OutputFilter(os, outputLevel); + } + + private Consumer operation() { + return e -> { + String s = e.getRemoteAddress().getHostString() + " " + + "- - " // rfc931 and authuser + + "[" + OffsetDateTime.now().format(FORMATTER) + "] " + + "\"" + e.getRequestMethod() + " " + e.getRequestURI() + " " + e.getProtocol() + "\" " + + e.getResponseCode() + " -"; // bytes + printStream.println(s); + + if (outputLevel.equals(OutputLevel.VERBOSE)) { + if (e.getAttribute("request-path") instanceof String requestPath) { + printStream.println("Resource requested: " + requestPath); + } + logHeaders(">", e.getRequestHeaders()); + logHeaders("<", e.getResponseHeaders()); + } + }; + } + + private void logHeaders(String sign, Headers headers) { + headers.forEach((name, values) -> { + var sb = new StringBuilder(); + var it = values.iterator(); + while (it.hasNext()) { + sb.append(it.next()); + if (it.hasNext()) { + sb.append(", "); + } + } + printStream.println(sign + " " + name + ": " + sb); + }); + printStream.println(sign); + } + + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + try { + filter.doFilter(exchange, chain); + } catch (Throwable t) { + if (!outputLevel.equals(OutputLevel.NONE)) { + reportError(ResourceBundleHelper.getMessage("err.server.handle.failed", + t.getMessage())); + } + throw t; + } + } + + @Override + public String description() { return filter.description(); } + + private void reportError(String message) { + printStream.println(ResourceBundleHelper.getMessage("error.prefix") + " " + message); + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/ResourceBundleHelper.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/ResourceBundleHelper.java new file mode 100644 index 00000000000..99349fe3474 --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/ResourceBundleHelper.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021, 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. + * + * 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 sun.net.httpserver.simpleserver; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +class ResourceBundleHelper { + static final ResourceBundle bundle; + + static { + try { + bundle = ResourceBundle.getBundle("sun.net.httpserver.simpleserver.resources.simpleserver"); + } catch (MissingResourceException e) { + throw new InternalError("Cannot find simpleserver resource bundle for locale " + Locale.getDefault()); + } + } + + static String getMessage(String key, Object... args) { + try { + return MessageFormat.format(bundle.getString(key), args); + } catch (MissingResourceException e) { + throw new InternalError("Missing message: " + key); + } + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/SimpleFileServerImpl.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/SimpleFileServerImpl.java new file mode 100644 index 00000000000..d236879c6b5 --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/SimpleFileServerImpl.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2021, 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. + * + * 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 sun.net.httpserver.simpleserver; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; + +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.Objects; + +/** + * A class that provides a simple HTTP file server to serve the content of + * a given directory. + * + *

    The server is an HttpServer bound to a given address. It comes with an + * HttpHandler that serves files from a given directory path + * (and its subdirectories) on the default file system, and an optional Filter + * that prints log messages related to the exchanges handled by the server to + * a given output stream. + * + *

    Unless specified as arguments, the default values are:

      + *
    • bind address: 127.0.0.1 or ::1 (loopback)
    • + *
    • directory: current working directory
    • + *
    • outputLevel: info
    + *
  • port: 8000
  • + *

    + * The implementation is provided via the main entry point of the jdk.httpserver + * module. + */ +final class SimpleFileServerImpl { + private static final InetAddress LOOPBACK_ADDR = InetAddress.getLoopbackAddress(); + private static final int DEFAULT_PORT = 8000; + private static final Path DEFAULT_ROOT = Path.of("").toAbsolutePath(); + private static final OutputLevel DEFAULT_OUTPUT_LEVEL = OutputLevel.INFO; + private static boolean addrSpecified = false; + + private SimpleFileServerImpl() { throw new AssertionError(); } + + /** + * Starts a simple HTTP file server created on a directory. + * + * @param writer the writer to which output should be written + * @param args the command line options + * @throws NullPointerException if any of the arguments are {@code null}, + * or if there are any {@code null} values in the {@code args} array + * @return startup status code + */ + static int start(PrintWriter writer, String[] args) { + Objects.requireNonNull(args); + for (var arg : args) { + Objects.requireNonNull(arg); + } + Out out = new Out(writer); + + InetAddress addr = LOOPBACK_ADDR; + int port = DEFAULT_PORT; + Path root = DEFAULT_ROOT; + OutputLevel outputLevel = DEFAULT_OUTPUT_LEVEL; + + // parse options + Iterator options = Arrays.asList(args).iterator(); + String option = null; + String optionArg = null; + try { + while (options.hasNext()) { + option = options.next(); + switch (option) { + case "-h", "-?", "--help" -> { + out.showHelp(); + return Startup.OK.statusCode; + } + case "-b", "--bind-address" -> { + addr = InetAddress.getByName(optionArg = options.next()); + addrSpecified = true; + } + case "-d", "--directory" -> + root = Path.of(optionArg = options.next()); + case "-o", "--output" -> + outputLevel = Enum.valueOf(OutputLevel.class, + (optionArg = options.next()).toUpperCase(Locale.ROOT)); + case "-p", "--port" -> + port = Integer.parseInt(optionArg = options.next()); + default -> throw new AssertionError(); + } + } + } catch (AssertionError ae) { + out.reportError(ResourceBundleHelper.getMessage("err.unknown.option", option)); + out.showUsage(); + return Startup.CMDERR.statusCode; + } catch (NoSuchElementException nsee) { + out.reportError(ResourceBundleHelper.getMessage("err.missing.arg", option)); + out.showOption(option); + return Startup.CMDERR.statusCode; + } catch (Exception e) { + out.reportError(ResourceBundleHelper.getMessage("err.invalid.arg", option, optionArg)); + e.printStackTrace(out.writer); + return Startup.CMDERR.statusCode; + } finally { + out.flush(); + } + + // configure and start server + try { + var socketAddr = new InetSocketAddress(addr, port); + var server = SimpleFileServer.createFileServer(socketAddr, root, outputLevel); + server.start(); + out.printStartMessage(root, server); + } catch (Throwable t) { + out.reportError(ResourceBundleHelper.getMessage("err.server.config.failed", t.getMessage())); + return Startup.SYSERR.statusCode; + } finally { + out.flush(); + } + return Startup.OK.statusCode; + } + + private final static class Out { + private final PrintWriter writer; + private Out() { throw new AssertionError(); } + + Out(PrintWriter writer) { + this.writer = Objects.requireNonNull(writer); + } + + void printStartMessage(Path root, HttpServer server) + throws UnknownHostException + { + String port = Integer.toString(server.getAddress().getPort()); + var inetAddr = server.getAddress().getAddress(); + var isAnyLocal = inetAddr.isAnyLocalAddress(); + var addr = isAnyLocal ? InetAddress.getLocalHost().getHostAddress() : inetAddr.getHostAddress(); + if (!addrSpecified) { + writer.println(ResourceBundleHelper.getMessage("loopback.info")); + } + if (isAnyLocal) { + writer.println(ResourceBundleHelper.getMessage("msg.start.anylocal", root, addr, port)); + } else { + writer.println(ResourceBundleHelper.getMessage("msg.start.other", root, addr, port)); + } + } + + void showUsage() { + writer.println(ResourceBundleHelper.getMessage("usage")); + } + + void showHelp() { + writer.println(ResourceBundleHelper.getMessage("usage")); + writer.println(ResourceBundleHelper.getMessage("options", LOOPBACK_ADDR.getHostAddress())); + } + + void showOption(String option) { + switch (option) { + case "-b", "--bind-address" -> + writer.println(ResourceBundleHelper.getMessage("opt.bindaddress", LOOPBACK_ADDR.getHostAddress())); + case "-d", "--directory" -> + writer.println(ResourceBundleHelper.getMessage("opt.directory")); + case "-o", "--output" -> + writer.println(ResourceBundleHelper.getMessage("opt.output")); + case "-p", "--port" -> + writer.println(ResourceBundleHelper.getMessage("opt.port")); + } + } + + void reportError(String message) { + writer.println(ResourceBundleHelper.getMessage("error.prefix") + " " + message); + } + + void flush() { + writer.flush(); + } + } + + private enum Startup { + /** Started with no errors */ + OK(0), + /** Not started, bad command-line arguments */ + CMDERR(1), + /** Not started, system error or resource exhaustion */ + SYSERR(2); + + Startup(int statusCode) { + this.statusCode = statusCode; + } + public final int statusCode; + } +} diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/resources/simpleserver.properties b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/resources/simpleserver.properties new file mode 100644 index 00000000000..bf7572b02d6 --- /dev/null +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/simpleserver/resources/simpleserver.properties @@ -0,0 +1,70 @@ +# +# Copyright (c) 2021, 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. +# + +usage=\ +Usage: java -m jdk.httpserver [-b bind address] [-p port] [-d directory]\n\ +\ [-o none|info|verbose] [-h to show options] + +options=\ +Options:\n\ +-b, --bind-address - Address to bind to. Default: {0} (loopback).\n\ +\ For all interfaces use "-b 0.0.0.0" or "-b ::".\n\ +-d, --directory - Directory to serve. Default: current directory.\n\ +-o, --output - Output format. none|info|verbose. Default: info.\n\ +-p, --port - Port to listen on. Default: 8000.\n\ +-h, -?, --help - Print this help message.\n\ +To stop the server, press Ctrl + C. + +opt.bindaddress=\ +-b, --bind-address - Address to bind to. Default: {0} (loopback).\n\ +\ For all interfaces use "-b 0.0.0.0" or "-b ::". +opt.directory=\ +-d, --directory - Directory to serve. Default: current directory. +opt.output=\ +-o, --output - Output format. none|info|verbose. Default: info. +opt.port=\ +-p, --port - Port to listen on. Default: 8000. + +loopback.info=\ +Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::". + +msg.start.anylocal=\ +Serving {0} and subdirectories on 0.0.0.0 (all interfaces) port {2}\n\ +URL http://{1}:{2}/ + +msg.start.other=\ +Serving {0} and subdirectories on {1} port {2}\n\ +URL http://{1}:{2}/ + +error.prefix=Error: + +err.unknown.option=unknown option: {0} +err.missing.arg=no value given for {0} +err.invalid.arg=invalid value given for {0}: {1} +err.server.config.failed=server config failed: {0} +err.server.handle.failed=server exchange handling failed: {0} + +html.dir.list=Directory listing for +html.not.found=File not found diff --git a/test/jdk/TEST.ROOT b/test/jdk/TEST.ROOT index 9f8bcca9b91..0a6afd8e66a 100644 --- a/test/jdk/TEST.ROOT +++ b/test/jdk/TEST.ROOT @@ -27,8 +27,9 @@ exclusiveAccess.dirs=java/math/BigInteger/largeMemory \ java/rmi/Naming java/util/prefs sun/management/jmxremote \ sun/tools/jstatd sun/tools/jcmd \ sun/tools/jinfo sun/tools/jmap sun/tools/jps sun/tools/jstack sun/tools/jstat \ -com/sun/tools/attach sun/security/mscapi java/util/Arrays/largeMemory \ -java/util/BitSet/stream javax/rmi java/net/httpclient/websocket +com/sun/tools/attach sun/security/mscapi java/util/stream java/util/Arrays/largeMemory \ +java/util/BitSet/stream javax/rmi java/net/httpclient/websocket \ +com/sun/net/httpserver/simpleserver # Group definitions groups=TEST.groups diff --git a/test/jdk/com/sun/net/httpserver/FilterTest.java b/test/jdk/com/sun/net/httpserver/FilterTest.java index 8c4f734d96c..45c7ef21e8d 100644 --- a/test/jdk/com/sun/net/httpserver/FilterTest.java +++ b/test/jdk/com/sun/net/httpserver/FilterTest.java @@ -40,6 +40,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.ConsoleHandler; import java.util.logging.Level; import java.util.logging.Logger; @@ -47,10 +49,10 @@ import com.sun.net.httpserver.Filter; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; -import static java.net.http.HttpClient.Builder.NO_PROXY; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.testng.annotations.BeforeTest; +import static java.net.http.HttpClient.Builder.NO_PROXY; import static org.testng.Assert.*; public class FilterTest { @@ -79,6 +81,9 @@ public class FilterTest { expectThrows(NPE, () -> Filter.afterHandler("Some description", null)); expectThrows(NPE, () -> Filter.afterHandler(null, HttpExchange::getResponseCode)); + + expectThrows(NPE, () -> Filter.adaptRequest("Some description", null)); + expectThrows(NPE, () -> Filter.adaptRequest(null, r -> r.with("Foo", List.of("Bar")))); } @Test @@ -90,6 +95,9 @@ public class FilterTest { var afterFilter = Filter.afterHandler(desc, HttpExchange::getResponseCode); assertEquals(desc, afterFilter.description()); + + var adaptFilter = Filter.adaptRequest(desc, r -> r.with("Foo", List.of("Bar"))); + assertEquals(desc, adaptFilter.description()); } @DataProvider @@ -305,6 +313,64 @@ public class FilterTest { } } + @Test + public void testInspectRequest() throws Exception { + var handler = new EchoHandler(); + var inspectedURI = new AtomicReference(); + var filter = Filter.adaptRequest("Inspect request URI", + r -> {inspectedURI.set(r.getRequestURI()); return r;}); + var server = HttpServer.create(new InetSocketAddress(LOOPBACK_ADDR,0), 10); + server.createContext("/", handler).getFilters().add(filter); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "foo/bar")).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(inspectedURI.get(), URI.create("/foo/bar")); + } finally { + server.stop(0); + } + } + + private static HttpExchange originalExchange; + + /** + * Confirms that adaptRequest changes only the expected request state and + * all other exchange state remains unchanged. + */ + @Test + public void testAdaptRequest() throws Exception { + var handler = new CompareStateAndEchoHandler(); + var captureFilter = Filter.beforeHandler("capture exchange", e -> { + e.setAttribute("foo", "bar"); + originalExchange = e; + }); + var adaptFilter = Filter.adaptRequest("Add x-foo request header", r -> { + // Confirm request state is unchanged + assertEquals(r.getRequestHeaders(), originalExchange.getRequestHeaders()); + assertEquals(r.getRequestURI(), originalExchange.getRequestURI()); + assertEquals(r.getRequestMethod(), originalExchange.getRequestMethod()); + return r.with("x-foo", List.of("bar")); + }); + var server = HttpServer.create(new InetSocketAddress(LOOPBACK_ADDR,0), 10); + var context = server.createContext("/", handler); + context.getFilters().add(captureFilter); + context.getFilters().add(adaptFilter); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "bar"); + } finally { + server.stop(0); + } + } + + // --- infra --- + static URI uri(HttpServer server, String path) { return URI.create("http://localhost:%s/%s".formatted(server.getAddress().getPort(), path)); } @@ -325,6 +391,42 @@ public class FilterTest { } } + /** + * A handler that compares the adapted exchange with the original exchange, + * before discarding the request and returning the test request header value. + */ + static class CompareStateAndEchoHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + assertEquals(exchange.getLocalAddress(), originalExchange.getLocalAddress()); + assertEquals(exchange.getRemoteAddress(), originalExchange.getRemoteAddress()); + assertEquals(exchange.getProtocol(), originalExchange.getProtocol()); + assertEquals(exchange.getPrincipal(), originalExchange.getPrincipal()); + assertEquals(exchange.getHttpContext(), originalExchange.getHttpContext()); + assertEquals(exchange.getRequestMethod(), originalExchange.getRequestMethod()); + assertEquals(exchange.getRequestURI(), originalExchange.getRequestURI()); + assertEquals(exchange.getRequestBody(), originalExchange.getRequestBody()); + assertEquals(exchange.getResponseHeaders(), originalExchange.getResponseHeaders()); + assertEquals(exchange.getResponseCode(), originalExchange.getResponseCode()); + assertEquals(exchange.getResponseBody(), originalExchange.getResponseBody()); + assertEquals(exchange.getAttribute("foo"), originalExchange.getAttribute("foo")); + assertFalse(exchange.getRequestHeaders().equals(originalExchange.getRequestHeaders())); + + exchange.setAttribute("foo", "barbar"); + assertEquals(exchange.getAttribute("foo"), originalExchange.getAttribute("foo")); + + try (InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + is.readAllBytes(); + var resp = exchange.getRequestHeaders().get("x-foo") + .get(0) + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + os.write(resp); + } + } + } + /** * A test handler that does nothing */ diff --git a/test/jdk/com/sun/net/httpserver/HeadersTest.java b/test/jdk/com/sun/net/httpserver/HeadersTest.java index b62d2a6d79a..dffa4143c0f 100644 --- a/test/jdk/com/sun/net/httpserver/HeadersTest.java +++ b/test/jdk/com/sun/net/httpserver/HeadersTest.java @@ -26,6 +26,7 @@ * @bug 8251496 8268960 * @summary Tests for methods in Headers class * @modules jdk.httpserver/com.sun.net.httpserver:+open + * jdk.httpserver/sun.net.httpserver:+open * @library /test/lib * @build jdk.test.lib.net.URIBuilder * @run testng/othervm HeadersTest @@ -55,10 +56,12 @@ import com.sun.net.httpserver.HttpServer; import jdk.test.lib.net.URIBuilder; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import sun.net.httpserver.UnmodifiableHeaders; import static java.net.http.HttpClient.Builder.NO_PROXY; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotEquals; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -314,4 +317,131 @@ public class HeadersTest { h2.put("b", List.of("22")); assertTrue(h1.equals(h2)); } + + @Test + public static void test1ArgConstructorNull() { + assertThrows(NPE, () -> new Headers(null)); + { + final var m = new HashMap>(); + m.put(null, List.of("Bar")); + assertThrows(NPE, () -> new Headers(m)); + } + { + final var m = new HashMap>(); + m.put("Foo", null); + assertThrows(NPE, () -> new Headers(m)); + } + { + final var m = new HashMap>(); + final var list = new LinkedList(); + list.add(null); + m.put("Foo", list); + assertThrows(NPE, () -> new Headers(m)); + } + } + + @Test + public static void test1ArgConstructor() { + { + var h = new Headers(new Headers()); + assertTrue(h.isEmpty()); + } + { + var h = new Headers(Map.of("Foo", List.of("Bar"))); + assertEquals(h.get("Foo"), List.of("Bar")); + assertEquals(h.size(), 1); + } + { + var h1 = new Headers(new UnmodifiableHeaders(new Headers())); + assertTrue(h1.isEmpty()); + h1.put("Foo", List.of("Bar")); // modifiable + assertEquals(h1.get("Foo"), List.of("Bar")); + assertEquals(h1.size(), 1); + + var h2 = new Headers(h1); + assertEquals(h2.get("Foo"), List.of("Bar")); + assertEquals(h2.size(), 1); + + assertEquals(h1, h2); + h1.set("Foo", "Barbar"); + assertNotEquals(h1, h2); + } + } + + @Test + public static void testMutableHeaders() { + { + var h = new Headers(); + h.add("Foo", "Bar"); + h.add("Foo", "Bazz"); + h.get("Foo").remove(0); + h.remove("Foo"); + h.clear(); + } + { + var h = new Headers(Map.of("Foo", List.of("Bar"))); + h.get("Foo").add("Bazz"); + h.get("Foo").remove(0); + h.remove("Foo"); + h.clear(); + } + } + + @Test + public static void testOfNull() { + assertThrows(NPE, () -> Headers.of((String[])null)); + assertThrows(NPE, () -> Headers.of(null, "Bar")); + assertThrows(NPE, () -> Headers.of("Foo", null)); + + assertThrows(NPE, () -> Headers.of((Map>) null)); + { + final var m = new HashMap>(); + m.put(null, List.of("Bar")); + assertThrows(NPE, () -> Headers.of(m)); + } + { + final var m = new HashMap>(); + m.put("Foo", null); + assertThrows(NPE, () -> Headers.of(m)); + } + { + final var m = new HashMap>(); + final var list = new LinkedList(); + list.add(null); + m.put("Foo", list); + assertThrows(NPE, () -> Headers.of(m)); + } + } + + @Test + public static void testOf() { + final var h = Headers.of("a", "1", "b", "2"); + assertEquals(h.size(), 2); + List.of("a", "b").forEach(n -> assertTrue(h.containsKey(n))); + List.of("1", "2").forEach(v -> assertTrue(h.containsValue(List.of(v)))); + } + + @Test + public static void testOfEmpty() { + for (var h : List.of(Headers.of(), Headers.of(new String[] { }))) { + assertEquals(h.size(), 0); + assertTrue(h.isEmpty()); + } + } + + @Test + public static void testOfNumberOfElements() { + assertThrows(IAE, () -> Headers.of("a")); + assertThrows(IAE, () -> Headers.of("a", "1", "b")); + } + + @Test + public static void testOfMultipleValues() { + final var h = Headers.of("a", "1", "b", "1", "b", "2", "b", "3"); + assertEquals(h.size(), 2); + List.of("a", "b").forEach(n -> assertTrue(h.containsKey(n))); + List.of(List.of("1"), List.of("1", "2", "3")).forEach(v -> assertTrue(h.containsValue(v))); + } + + // Immutability tests in UnmodifiableHeadersTest.java } diff --git a/test/jdk/com/sun/net/httpserver/UnmodifiableHeadersTest.java b/test/jdk/com/sun/net/httpserver/UnmodifiableHeadersTest.java index bdcaa4a4683..8c70d65c966 100644 --- a/test/jdk/com/sun/net/httpserver/UnmodifiableHeadersTest.java +++ b/test/jdk/com/sun/net/httpserver/UnmodifiableHeadersTest.java @@ -40,6 +40,7 @@ import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpPrincipal; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import sun.net.httpserver.UnmodifiableHeaders; import static org.testng.Assert.assertEquals; @@ -62,14 +63,24 @@ public class UnmodifiableHeadersTest { assertEquals(unmodifiableHeaders2.get("Foo"), headers.get("Foo")); } - @Test - public static void testUnmodifiableHeaders() { + @DataProvider + public Object[][] headers() { var headers = new Headers(); headers.add("Foo", "Bar"); - HttpExchange exchange = new TestHttpExchange(headers); + var exchange = new TestHttpExchange(headers); - assertUnsupportedOperation(exchange.getRequestHeaders()); - assertUnmodifiableCollection(exchange.getRequestHeaders()); + return new Object[][] { + { exchange.getRequestHeaders() }, + { Headers.of("Foo", "Bar") }, + { Headers.of(Map.of("Foo", List.of("Bar"))) }, + }; + } + + @Test(dataProvider = "headers") + public static void testUnmodifiableHeaders(Headers headers) { + assertUnsupportedOperation(headers); + assertUnmodifiableCollection(headers); + assertUnmodifiableList(headers); } static final Class UOP = UnsupportedOperationException.class; diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/CommandLineNegativeTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/CommandLineNegativeTest.java new file mode 100644 index 00000000000..a494d5961a3 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/CommandLineNegativeTest.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Negative tests for simpleserver command-line tool + * @library /test/lib + * @modules jdk.httpserver + * @run testng/othervm CommandLineNegativeTest + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import jdk.test.lib.Platform; +import jdk.test.lib.process.OutputAnalyzer; +import jdk.test.lib.process.ProcessTools; +import jdk.test.lib.util.FileUtils; +import org.testng.SkipException; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.lang.System.out; +import static org.testng.Assert.assertFalse; + +public class CommandLineNegativeTest { + + static final Path JAVA_HOME = Path.of(System.getProperty("java.home")); + static final String JAVA = getJava(JAVA_HOME); + static final Path CWD = Path.of(".").toAbsolutePath().normalize(); + static final Path TEST_DIR = CWD.resolve("CommandLineNegativeTest"); + static final Path TEST_FILE = TEST_DIR.resolve("file.txt"); + static final String LOOPBACK_ADDR = InetAddress.getLoopbackAddress().getHostAddress(); + + @BeforeTest + public void setup() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + Files.createDirectories(TEST_DIR); + Files.createFile(TEST_FILE); + } + + @DataProvider + public Object[][] unknownOption() { + return new Object[][] { + {"--unknownOption"}, + {"null"} + }; + } + + @Test(dataProvider = "unknownOption") + public void testBadOption(String opt) throws Throwable { + out.println("\n--- testUnknownOption, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt) + .shouldNotHaveExitValue(0) + .shouldContain("Error: unknown option: " + opt); + } + + @DataProvider + public Object[][] tooManyOptionArgs() { + return new Object[][] { + {"-b", "localhost"}, + {"-d", "/some/path"}, + {"-o", "none"}, + {"-p", "0"}, + {"--bind-address", "localhost"}, + {"--directory", "/some/path"}, + {"--output", "none"}, + {"--port", "0"} + // doesn't fail for -h option + }; + } + + @Test(dataProvider = "tooManyOptionArgs") + public void testTooManyOptionArgs(String opt, String arg) throws Throwable { + out.println("\n--- testTooManyOptionArgs, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, arg, arg) + .shouldNotHaveExitValue(0) + .shouldContain("Error: unknown option: " + arg); + } + + @DataProvider + public Object[][] noArg() { + return new Object[][] { + {"-b", """ + -b, --bind-address - Address to bind to. Default: %s (loopback). + For all interfaces use "-b 0.0.0.0" or "-b ::".""".formatted(LOOPBACK_ADDR)}, + {"-d", "-d, --directory - Directory to serve. Default: current directory."}, + {"-o", "-o, --output - Output format. none|info|verbose. Default: info."}, + {"-p", "-p, --port - Port to listen on. Default: 8000."}, + {"--bind-address", """ + -b, --bind-address - Address to bind to. Default: %s (loopback). + For all interfaces use "-b 0.0.0.0" or "-b ::".""".formatted(LOOPBACK_ADDR)}, + {"--directory", "-d, --directory - Directory to serve. Default: current directory."}, + {"--output", "-o, --output - Output format. none|info|verbose. Default: info."}, + {"--port", "-p, --port - Port to listen on. Default: 8000."} + // doesn't fail for -h option + }; + } + + @Test(dataProvider = "noArg") + public void testNoArg(String opt, String msg) throws Throwable { + out.println("\n--- testNoArg, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt) + .shouldNotHaveExitValue(0) + .shouldContain("Error: no value given for " + opt) + .shouldContain(msg); + } + + @DataProvider + public Object[][] invalidValue() { + return new Object[][] { + {"-b", "[127.0.0.1]"}, + {"-b", "badhost"}, + {"--bind-address", "192.168.1.220..."}, + + {"-o", "bad-output-level"}, + {"--output", "bad-output-level"}, + + {"-p", "+-"}, + {"--port", "+-"} + }; + } + + @Test(dataProvider = "invalidValue") + public void testInvalidValue(String opt, String val) throws Throwable { + out.println("\n--- testInvalidValue, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, val) + .shouldNotHaveExitValue(0) + .shouldContain("Error: invalid value given for " + opt + ": " + val); + } + + @DataProvider + public Object[][] portOptions() { return new Object[][] {{"-p"}, {"--port"}}; } + + @Test(dataProvider = "portOptions") + public void testPortOutOfRange(String opt) throws Throwable { + out.println("\n--- testPortOutOfRange, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, "65536") // range 0 to 65535 + .shouldNotHaveExitValue(0) + .shouldContain("Error: server config failed: " + "port out of range:65536"); + } + + @DataProvider + public Object[][] directoryOptions() { return new Object[][] {{"-d"}, {"--directory"}}; } + + @Test(dataProvider = "directoryOptions") + public void testRootNotAbsolute(String opt) throws Throwable { + out.println("\n--- testRootNotAbsolute, opt=\"%s\" ".formatted(opt)); + var root = Path.of("."); + assertFalse(root.isAbsolute()); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, root.toString()) + .shouldNotHaveExitValue(0) + .shouldContain("Error: server config failed: " + "Path is not absolute:"); + } + + @Test(dataProvider = "directoryOptions") + public void testRootNotADirectory(String opt) throws Throwable { + out.println("\n--- testRootNotADirectory, opt=\"%s\" ".formatted(opt)); + var file = TEST_FILE.toString(); + assertFalse(Files.isDirectory(TEST_FILE)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, file) + .shouldNotHaveExitValue(0) + .shouldContain("Error: server config failed: " + "Path is not a directory: " + file); + } + + @Test(dataProvider = "directoryOptions") + public void testRootDoesNotExist(String opt) throws Throwable { + out.println("\n--- testRootDoesNotExist, opt=\"%s\" ".formatted(opt)); + Path root = TEST_DIR.resolve("not/existent/dir"); + assertFalse(Files.exists(root)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, root.toString()) + .shouldNotHaveExitValue(0) + .shouldContain("Error: server config failed: " + "Path does not exist: " + root.toString()); + } + + @Test(dataProvider = "directoryOptions") + public void testRootNotReadable(String opt) throws Throwable { + out.println("\n--- testRootNotReadable, opt=\"%s\" ".formatted(opt)); + if (Platform.isWindows()) { + // Not applicable to Windows. Reason: cannot revoke an owner's read + // access to a directory that was created by that owner + throw new SkipException("cannot run on Windows"); + } + Path root = Files.createDirectories(TEST_DIR.resolve("not/readable/dir")); + try { + root.toFile().setReadable(false, false); + assertFalse(Files.isReadable(root)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, root.toString()) + .shouldNotHaveExitValue(0) + .shouldContain("Error: server config failed: " + "Path is not readable: " + root.toString()); + } finally { + root.toFile().setReadable(true, false); + } + } + + @AfterTest + public void teardown() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + } + + // --- infra --- + + static String getJava(Path image) { + boolean isWindows = System.getProperty("os.name").startsWith("Windows"); + Path java = image.resolve("bin").resolve(isWindows ? "java.exe" : "java"); + if (Files.notExists(java)) + throw new RuntimeException(java + " not found"); + return java.toAbsolutePath().toString(); + } + + static OutputAnalyzer simpleserver(String... args) throws Throwable { + var pb = new ProcessBuilder(args) + .directory(TEST_DIR.toFile()); + var outputAnalyser = ProcessTools.executeCommand(pb) + .outputTo(System.out) + .errorTo(System.out); + return outputAnalyser; + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/CommandLinePositiveTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/CommandLinePositiveTest.java new file mode 100644 index 00000000000..a5e636c354d --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/CommandLinePositiveTest.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Positive tests for simpleserver command-line tool + * @library /test/lib + * @modules jdk.httpserver + * @run testng/othervm CommandLinePositiveTest + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import jdk.test.lib.Platform; +import jdk.test.lib.process.OutputAnalyzer; +import jdk.test.lib.process.ProcessTools; +import jdk.test.lib.util.FileUtils; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.lang.System.out; + +public class CommandLinePositiveTest { + + static final Path JAVA_HOME = Path.of(System.getProperty("java.home")); + static final String JAVA = getJava(JAVA_HOME); + static final Path CWD = Path.of(".").toAbsolutePath().normalize(); + static final Path TEST_DIR = CWD.resolve("CommandLinePositiveTest"); + static final Path TEST_FILE = TEST_DIR.resolve("file.txt"); + static final String TEST_DIR_STR = TEST_DIR.toString(); + static final String LOOPBACK_ADDR = InetAddress.getLoopbackAddress().getHostAddress(); + + @BeforeTest + public void setup() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + Files.createDirectories(TEST_DIR); + Files.createFile(TEST_FILE); + } + + static final int SIGTERM = 15; + static final int NORMAL_EXIT_CODE = normalExitCode(); + + static int normalExitCode() { + if (Platform.isWindows()) { + return 1; // expected process destroy exit code + } else { + // signal terminated exit code on Unix is 128 + signal value + return 128 + SIGTERM; + } + } + + @DataProvider + public Object[][] directoryOptions() { return new Object[][] {{"-d"}, {"--directory"}}; } + + @Test(dataProvider = "directoryOptions") + public void testDirectory(String opt) throws Throwable { + out.println("\n--- testDirectory, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", "-p", "0", opt, TEST_DIR_STR) + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Binding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".") + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + } + + @DataProvider + public Object[][] portOptions() { return new Object[][] {{"-p"}, {"--port"}}; } + + @Test(dataProvider = "portOptions") + public void testPort(String opt) throws Throwable { + out.println("\n--- testPort, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, "0") + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Binding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".") + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + } + + @DataProvider + public Object[][] helpOptions() { return new Object[][] {{"-h"}, {"-?"}, {"--help"}}; } + + static final String USAGE_TEXT = """ + Usage: java -m jdk.httpserver [-b bind address] [-p port] [-d directory] + [-o none|info|verbose] [-h to show options]"""; + + static final String OPTIONS_TEXT = """ + Options: + -b, --bind-address - Address to bind to. Default: %s (loopback). + For all interfaces use "-b 0.0.0.0" or "-b ::". + -d, --directory - Directory to serve. Default: current directory. + -o, --output - Output format. none|info|verbose. Default: info. + -p, --port - Port to listen on. Default: 8000. + -h, -?, --help - Print this help message. + To stop the server, press Ctrl + C.""".formatted(LOOPBACK_ADDR); + + @Test(dataProvider = "helpOptions") + public void testHelp(String opt) throws Throwable { + out.println("\n--- testHelp, opt=\"%s\" ".formatted(opt)); + simpleserver(WaitForLine.HELP_STARTUP_LINE, + false, // do not explicitly destroy the process + JAVA, "-m", "jdk.httpserver", opt) + .shouldHaveExitValue(0) + .shouldContain(USAGE_TEXT) + .shouldContain(OPTIONS_TEXT); + } + + @DataProvider + public Object[][] bindOptions() { return new Object[][] {{"-b"}, {"--bind-address"}}; } + + @Test(dataProvider = "bindOptions") + public void testBindAllInterfaces(String opt) throws Throwable { + out.println("\n--- testPort, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, "0.0.0.0") + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on 0.0.0.0 (all interfaces) port") + .shouldContain("URL http://" + InetAddress.getLocalHost().getHostAddress()); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, "::0") + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on 0.0.0.0 (all interfaces) port") + .shouldContain("URL http://" + InetAddress.getLocalHost().getHostAddress()); + } + + @Test(dataProvider = "bindOptions") + public void testLastOneWinsBindAddress(String opt) throws Throwable { + out.println("\n--- testLastOneWinsBindAddress, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", "-p", "0", opt, "123.4.5.6", opt, LOOPBACK_ADDR) + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + + } + + @Test(dataProvider = "directoryOptions") + public void testLastOneWinsDirectory(String opt) throws Throwable { + out.println("\n--- testLastOneWinsDirectory, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", "-p", "0", opt, TEST_DIR_STR, opt, TEST_DIR_STR) + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Binding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".") + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + } + + @DataProvider + public Object[][] outputOptions() { return new Object[][] {{"-o"}, {"--output"}}; } + + @Test(dataProvider = "outputOptions") + public void testLastOneWinsOutput(String opt) throws Throwable { + out.println("\n--- testLastOneWinsOutput, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", "-p", "0", opt, "none", opt, "verbose") + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Binding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".") + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + } + + @Test(dataProvider = "portOptions") + public void testLastOneWinsPort(String opt) throws Throwable { + out.println("\n--- testLastOneWinsPort, opt=\"%s\" ".formatted(opt)); + simpleserver(JAVA, "-m", "jdk.httpserver", opt, "-999", opt, "0") + .shouldHaveExitValue(NORMAL_EXIT_CODE) + .shouldContain("Binding to loopback by default. For all interfaces use \"-b 0.0.0.0\" or \"-b ::\".") + .shouldContain("Serving " + TEST_DIR_STR + " and subdirectories on " + LOOPBACK_ADDR + " port") + .shouldContain("URL http://" + LOOPBACK_ADDR); + } + + @AfterTest + public void teardown() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + } + + // --- infra --- + + static String getJava(Path image) { + boolean isWindows = System.getProperty("os.name").startsWith("Windows"); + Path java = image.resolve("bin").resolve(isWindows ? "java.exe" : "java"); + if (Files.notExists(java)) + throw new RuntimeException(java + " not found"); + return java.toAbsolutePath().toString(); + } + + static final String REGULAR_STARTUP_LINE1_STRING = "Serving"; + static final String REGULAR_STARTUP_LINE2_STRING = "URL http://"; + + // The stdout/stderr output line to wait for when starting the simpleserver + enum WaitForLine { + REGULAR_STARTUP_LINE (REGULAR_STARTUP_LINE2_STRING) , + HELP_STARTUP_LINE (OPTIONS_TEXT.lines().reduce((first, second) -> second).orElseThrow()); + + final String value; + WaitForLine(String value) { this.value = value; } + } + + static OutputAnalyzer simpleserver(String... args) throws Throwable { + return simpleserver(WaitForLine.REGULAR_STARTUP_LINE, true, args); + } + + static OutputAnalyzer simpleserver(WaitForLine waitForLine, boolean destroy, String... args) throws Throwable { + StringBuffer sb = new StringBuffer(); // stdout & stderr + // start the process and await the waitForLine before returning + var p = ProcessTools.startProcess("simpleserver", + new ProcessBuilder(args).directory(TEST_DIR.toFile()), + line -> sb.append(line + "\n"), + line -> line.startsWith(waitForLine.value), + 30, // suitably high default timeout, not expected to timeout + TimeUnit.SECONDS); + if (destroy) { + p.destroy(); // SIGTERM on Unix + } + int ec = p.waitFor(); + var outputAnalyser = new OutputAnalyzer(sb.toString(), "", ec); + return outputAnalyser; + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/CustomFileSystemTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/CustomFileSystemTest.java new file mode 100644 index 00000000000..0826ca259ae --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/CustomFileSystemTest.java @@ -0,0 +1,970 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for SimpleFileServer with a root that is not of the default + * file system + * @library /test/lib + * @build jdk.test.lib.Platform jdk.test.lib.net.URIBuilder + * @run testng/othervm CustomFileSystemTest + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.AccessMode; +import java.nio.file.CopyOption; +import java.nio.file.DirectoryStream; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.ProviderMismatchException; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.FileAttributeView; +import java.nio.file.attribute.UserPrincipalLookupService; +import java.nio.file.spi.FileSystemProvider; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.Platform; +import jdk.test.lib.net.URIBuilder; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class CustomFileSystemTest { + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() throws Exception { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + } + + @Test + public void testFileGET() throws Exception { + var root = createDirectoryInCustomFs("testFileGET"); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(file); + var expectedLength = Long.toString(Files.size(file)); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "some text"); + assertEquals(response.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + } finally { + server.stop(0); + } + } + + @Test + public void testDirectoryGET() throws Exception { + var expectedBody = openHTML + """ +

    Directory listing for /

    + + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testDirectoryGET"); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testFileHEAD() throws Exception { + var root = createDirectoryInCustomFs("testFileHEAD"); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(file); + var expectedLength = Long.toString(Files.size(file)); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @Test + public void testDirectoryHEAD() throws Exception { + var expectedLength = Integer.toString( + (openHTML + """ +

    Directory listing for /

    + + """ + closeHTML).getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testDirectoryHEAD"); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @DataProvider + public Object[][] indexFiles() { + var fileContent = openHTML + """ +

    This is an index file

    + """ + closeHTML; + var dirListing = openHTML + """ +

    Directory listing for /

    +
      +
    + """ + closeHTML; + return new Object[][] { + {"1", "index.html", "text/html", "116", fileContent, true}, + {"2", "index.htm", "text/html", "116", fileContent, true}, + {"3", "index.txt", "text/html; charset=UTF-8", "134", dirListing, false} + }; + } + + @Test(dataProvider = "indexFiles") + public void testDirectoryWithIndexGET(String id, + String filename, + String contentType, + String contentLength, + String expectedBody, + boolean serveIndexFile) throws Exception { + var root = createDirectoryInCustomFs("testDirectoryWithIndexGET"+id); + var lastModified = getLastModified(root); + if (serveIndexFile) { + var file = Files.writeString(root.resolve(filename), expectedBody, CREATE); + lastModified = getLastModified(file); + } + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), contentType); + assertEquals(response.headers().firstValue("content-length").get(), contentLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + if (serveIndexFile) { + Files.delete(root.resolve(filename)); + } + } + } + + @Test + public void testNotReadableFileGET() throws Exception { + if (!Platform.isWindows()) { // not applicable on Windows + var expectedBody = openHTML + """ +

    File not found

    +

    /aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testNotReadableFileGET"); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + + file.toFile().setReadable(false, false); + assert !Files.isReadable(file); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + file.toFile().setReadable(true, false); + } + } + } + + @Test + public void testNotReadableSegmentGET() throws Exception { + if (!Platform.isWindows()) { // not applicable on Windows + var expectedBody = openHTML + """ +

    File not found

    +

    /dir/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testNotReadableSegmentGET"); + var dir = Files.createDirectory(root.resolve("dir")); + var file = Files.writeString(dir.resolve("aFile.txt"), "some text", CREATE); + + dir.toFile().setReadable(false, false); + assert !Files.isReadable(dir); + assert Files.isReadable(file); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "dir/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + dir.toFile().setReadable(true, false); + } + } + } + + @Test + public void testInvalidRequestURIGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /aFile?#.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testInvalidRequestURIGET"); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile?#.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testNotFoundGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testNotFoundGET"); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testNotFoundHEAD() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testNotFoundHEAD"); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @Test + public void testSymlinkGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /symlink

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testSymlinkGET"); + var symlink = root.resolve("symlink"); + var target = Files.writeString(root.resolve("target.txt"), "some text", CREATE); + Files.createSymbolicLink(symlink, target); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "symlink")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testSymlinkSegmentGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /symlink/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createDirectoryInCustomFs("testSymlinkSegmentGET"); + var symlink = root.resolve("symlink"); + var target = Files.createDirectory(root.resolve("target")); + Files.writeString(target.resolve("aFile.txt"), "some text", CREATE); + Files.createSymbolicLink(symlink, target); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "symlink/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testHiddenFileGET() throws Exception { + var root = createDirectoryInCustomFs("testHiddenFileGET"); + var file = createHiddenFile(root); + var fileName = file.getFileName().toString(); + var expectedBody = openHTML + """ +

    File not found

    +

    /""" + fileName + + """ +

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, fileName)).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testHiddenSegmentGET() throws Exception { + var root = createDirectoryInCustomFs("testHiddenSegmentGET"); + var file = createFileInHiddenDirectory(root); + var expectedBody = openHTML + """ +

    File not found

    +

    /.hiddenDirectory/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, ".hiddenDirectory/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + private Path createHiddenFile(Path root) throws IOException { + Path file; + if (Platform.isWindows()) { + file = Files.createFile(root.resolve("aFile.txt")); + Files.setAttribute(file, "dos:hidden", true, LinkOption.NOFOLLOW_LINKS); + } else { + file = Files.writeString(root.resolve(".aFile.txt"), "some text", CREATE); + } + assertTrue(Files.isHidden(file)); + return file; + } + + private Path createFileInHiddenDirectory(Path root) throws IOException { + Path dir; + Path file; + if (Platform.isWindows()) { + dir = Files.createDirectory(root.resolve("hiddenDirectory")); + Files.setAttribute(dir, "dos:hidden", true, LinkOption.NOFOLLOW_LINKS); + } else { + dir = Files.createDirectory(root.resolve(".hiddenDirectory")); + } + file = Files.writeString(dir.resolve("aFile.txt"), "some text", CREATE); + assertTrue(Files.isHidden(dir)); + assertFalse(Files.isHidden(file)); + return file; + } + + @Test + public void testMovedPermanently() throws Exception { + var root = createDirectoryInCustomFs("testMovedPermanently"); + Files.createDirectory(root.resolve("aDirectory")); + var expectedBody = openHTML + """ +

    Directory listing for /aDirectory/

    +
      +
    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + { + var client = HttpClient.newBuilder().proxy(NO_PROXY) + .followRedirects(HttpClient.Redirect.NEVER).build(); + var uri = uri(server, "aDirectory"); + var request = HttpRequest.newBuilder(uri).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 301); + assertEquals(response.headers().firstValue("content-length").get(), "0"); + assertEquals(response.headers().firstValue("location").get(), "/aDirectory/"); + + // tests that query component is preserved during redirect + var uri2 = uri(server, "aDirectory", "query"); + var req2 = HttpRequest.newBuilder(uri2).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 301); + assertEquals(res2.headers().firstValue("content-length").get(), "0"); + assertEquals(res2.headers().firstValue("location").get(), "/aDirectory/?query"); + } + + { // tests that redirect to returned relative URI works + var client = HttpClient.newBuilder().proxy(NO_PROXY) + .followRedirects(HttpClient.Redirect.ALWAYS).build(); + var uri = uri(server, "aDirectory"); + var request = HttpRequest.newBuilder(uri).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), expectedBody); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + } + } finally { + server.stop(0); + } + } + + @Test + public void testXss() throws Exception { + var root = createDirectoryInCustomFs("testXss"); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "beginDelim%3C%3EEndDelim")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertTrue(response.body().contains("beginDelim%3C%3EEndDelim")); + assertTrue(response.body().contains("File not found")); + } finally { + server.stop(0); + } + } + + static Path createDirectoryInCustomFs(String name) throws Exception { + var defaultFs = FileSystems.getDefault(); + var fs = new CustomProvider(defaultFs.provider()).newFileSystem(defaultFs); + var dir = fs.getPath(name); + if (Files.notExists(dir)) { + Files.createDirectory(dir); + } + return dir.toAbsolutePath(); + } + + static final String openHTML = """ + + + + + + + """; + + static final String closeHTML = """ + + + """; + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + static URI uri(HttpServer server, String path, String query) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .query(query) + .buildUnchecked(); + } + + static String getLastModified(Path path) throws IOException { + return Files.getLastModifiedTime(path).toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } + + // --- Custom File System --- + + static class CustomProvider extends FileSystemProvider { + private final ConcurrentHashMap map = + new ConcurrentHashMap<>(); + private final FileSystemProvider defaultProvider; + + public CustomProvider(FileSystemProvider provider) { + defaultProvider = provider; + } + + @Override + public String getScheme() { + return defaultProvider.getScheme(); + } + + public FileSystem newFileSystem(FileSystem fs) { + return map.computeIfAbsent(fs, (sfs) -> + new CustomFileSystem(this, fs)); + } + + @Override + public FileSystem newFileSystem(URI uri, Map env) throws IOException { + FileSystem fs = defaultProvider.newFileSystem(uri, env); + return map.computeIfAbsent(fs, (sfs) -> + new CustomFileSystem(this, fs) + ); + } + + @Override + public FileSystem getFileSystem(URI uri) { + return map.get(defaultProvider.getFileSystem(uri)); + } + + @Override + public Path getPath(URI uri) { + Path p = defaultProvider.getPath(uri); + return map.get(defaultProvider.getFileSystem(uri)).wrap(p); + } + + @Override + public SeekableByteChannel newByteChannel(Path path, Set options, FileAttribute... attrs) throws IOException { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.newByteChannel(p, options, attrs); + } + + @Override + public DirectoryStream newDirectoryStream(Path dir, DirectoryStream.Filter filter) throws IOException { + throw new RuntimeException("not implemented"); + } + + @Override + public void createDirectory(Path dir, FileAttribute... attrs) throws IOException { + Path p = toCustomPath(dir).unwrap(); + defaultProvider.createDirectory(p, attrs); + } + + @Override + public void delete(Path path) throws IOException { + Path p = toCustomPath(path).unwrap(); + defaultProvider.delete(p); + } + + @Override + public void copy(Path source, Path target, CopyOption... options) throws IOException { + Path sp = toCustomPath(source).unwrap(); + Path tp = toCustomPath(target).unwrap(); + defaultProvider.copy(sp, tp, options); + } + + @Override + public void move(Path source, Path target, CopyOption... options) + throws IOException { + Path sp = toCustomPath(source).unwrap(); + Path tp = toCustomPath(target).unwrap(); + defaultProvider.move(sp, tp, options); + } + + @Override + public boolean isSameFile(Path path, Path path2) + throws IOException { + Path p = toCustomPath(path).unwrap(); + Path p2 = toCustomPath(path2).unwrap(); + return defaultProvider.isSameFile(p, p2); + } + + @Override + public boolean isHidden(Path path) throws IOException { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.isHidden(p); + } + + @Override + public FileStore getFileStore(Path path) throws IOException { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.getFileStore(p); + } + + @Override + public void checkAccess(Path path, AccessMode... modes) throws IOException { + Path p = toCustomPath(path).unwrap(); + defaultProvider.checkAccess(p, modes); + } + + @Override + public V getFileAttributeView(Path path, + Class type, + LinkOption... options) { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.getFileAttributeView(p, type, options); + } + + @Override + public A readAttributes(Path path, + Class type, + LinkOption... options) + throws IOException { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.readAttributes(p, type, options); + } + + @Override + public Map readAttributes(Path path, + String attributes, + LinkOption... options) + throws IOException { + Path p = toCustomPath(path).unwrap(); + return defaultProvider.readAttributes(p, attributes, options); + } + + @Override + public void setAttribute(Path path, String attribute, + Object value, LinkOption... options) + throws IOException { + Path p = toCustomPath(path).unwrap(); + defaultProvider.setAttribute(p, attribute, options); + } + + // Checks that the given file is a CustomPath + static CustomPath toCustomPath(Path obj) { + if (obj == null) + throw new NullPointerException(); + if (!(obj instanceof CustomPath cp)) + throw new ProviderMismatchException(); + return cp; + } + } + + static class CustomFileSystem extends FileSystem { + + private final CustomProvider provider; + private final FileSystem delegate; + + public CustomFileSystem(CustomProvider provider, FileSystem delegate) { + this.provider = provider; + this.delegate = delegate; + } + + @Override + public FileSystemProvider provider() { + return provider; + } + + @Override + public void close() throws IOException { delegate.close(); } + + @Override + public boolean isOpen() { + return true; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String getSeparator() { return delegate.getSeparator(); } + + @Override + public Iterable getRootDirectories() { + return null; + } + + @Override + public Iterable getFileStores() { + return null; + } + + @Override + public Set supportedFileAttributeViews() { + return null; + } + + @Override + public Path getPath(String first, String... more) { + return delegate.getPath(first, more); + } + + @Override + public PathMatcher getPathMatcher(String syntaxAndPattern) { + return null; + } + + @Override + public UserPrincipalLookupService getUserPrincipalLookupService() { + return null; + } + + @Override + public WatchService newWatchService() throws IOException { + return null; + } + + @Override + public String toString() { + return delegate.toString(); + } + + Path wrap(Path path) { + return (path != null) ? new CustomPath(this, path) : null; + } + + Path unwrap(Path wrapper) { + if (wrapper == null) + throw new NullPointerException(); + if (!(wrapper instanceof CustomPath cp)) + throw new ProviderMismatchException(); + return cp.unwrap(); + } + } + + static class CustomPath implements Path { + + private final CustomFileSystem fs; + private final Path delegate; + + CustomPath(CustomFileSystem fs, Path delegate) { + this.fs = fs; + this.delegate = delegate; + } + + @Override + public FileSystem getFileSystem() { + return fs; + } + + @Override + public boolean isAbsolute() { + return delegate.isAbsolute(); + } + + @Override + public Path getRoot() { + return fs.wrap(delegate.getRoot()); + } + + @Override + public Path getFileName() { + return null; + } + + @Override + public Path getParent() { + return null; + } + + @Override + public int getNameCount() { + return 0; + } + + @Override + public Path getName(int index) { + return null; + } + + @Override + public Path subpath(int beginIndex, int endIndex) { + return null; + } + + @Override + public boolean startsWith(Path other) { + return delegate.startsWith(other); + } + + @Override + public boolean endsWith(Path other) { + return false; + } + + @Override + public Path normalize() { + return fs.wrap(delegate.normalize()); + } + + @Override + public Path resolve(Path other) { + return fs.wrap(delegate.resolve(fs.unwrap(other))); + } + + @Override + public Path relativize(Path other) { + return null; + } + + @Override + public URI toUri() { + return delegate.toUri(); + } + + @Override + public Path toAbsolutePath() { + return fs.wrap(delegate.toAbsolutePath()); + } + + @Override + public Path toRealPath(LinkOption... options) throws IOException { + return null; + } + + @Override + public WatchKey register(WatchService watcher, WatchEvent.Kind[] events, WatchEvent.Modifier... modifiers) throws IOException { + return null; + } + + @Override + public int compareTo(Path other) { + return 0; + } + + Path unwrap() { + return delegate; + } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/FileServerHandlerTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/FileServerHandlerTest.java new file mode 100644 index 00000000000..a5179b03f16 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/FileServerHandlerTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for FileServerHandler + * @run testng FileServerHandlerTest + */ + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import com.sun.net.httpserver.Authenticator; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpPrincipal; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static org.testng.Assert.*; + +public class FileServerHandlerTest { + + static final Path CWD = Path.of(".").toAbsolutePath(); + static final Class RE = RuntimeException.class; + + @DataProvider + public Object[][] notAllowedMethods() { + var l = List.of("POST", "PUT", "DELETE", "TRACE", "OPTIONS"); + return l.stream().map(s -> new Object[] { s }).toArray(Object[][]::new); + } + + @Test(dataProvider = "notAllowedMethods") + public void testNotAllowedRequestMethod(String requestMethod) throws Exception { + var handler = SimpleFileServer.createFileHandler(CWD); + var exchange = new MethodHttpExchange(requestMethod); + handler.handle(exchange); + assertEquals(exchange.rCode, 405); + assertEquals(exchange.getResponseHeaders().getFirst("allow"), "HEAD, GET"); + } + + @DataProvider + public Object[][] notImplementedMethods() { + var l = List.of("GARBAGE", "RUBBISH", "TRASH", "FOO", "BAR"); + return l.stream().map(s -> new Object[] { s }).toArray(Object[][]::new); + } + + @Test(dataProvider = "notImplementedMethods") + public void testNotImplementedRequestMethod(String requestMethod) throws Exception { + var handler = SimpleFileServer.createFileHandler(CWD); + var exchange = new MethodHttpExchange(requestMethod); + handler.handle(exchange); + assertEquals(exchange.rCode, 501); + } + + // 301 and 404 response codes tested in SimpleFileServerTest + + @Test + public void testThrowingExchange() { + var h = SimpleFileServer.createFileHandler(CWD); + { + var exchange = new ThrowingHttpExchange("GET") { + public InputStream getRequestBody() { + throw new RuntimeException("getRequestBody"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getRequestBody"); + } + { + var exchange = new ThrowingHttpExchange("GET") { + public Headers getResponseHeaders() { + throw new RuntimeException("getResponseHeaders"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getResponseHeaders"); + } + { + var exchange = new ThrowingHttpExchange("GET") { + public void sendResponseHeaders(int rCode, long responseLength) { + throw new RuntimeException("sendResponseHeaders"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "sendResponseHeaders"); + } + { + var exchange = new ThrowingHttpExchange("GET") { + public OutputStream getResponseBody() { + throw new RuntimeException("getResponseBody"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getResponseBody"); + } + { + var exchange = new ThrowingHttpExchange("GET") { + public void close() { + throw new RuntimeException("close"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "close"); + } + } + + static class ThrowingHttpExchange extends StubHttpExchange { + private final String method; + volatile int rCode; + volatile long responseLength; + volatile Headers responseHeaders; + volatile Headers requestHeaders; + volatile InputStream requestBody; + + ThrowingHttpExchange(String method) { + this.method = method; + responseHeaders = new Headers(); + requestHeaders = new Headers(); + requestBody = new ByteArrayInputStream(new byte[]{}); + } + + @Override public String getRequestMethod() { return method; } + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public Headers getRequestHeaders() { return requestHeaders; } + @Override public InputStream getRequestBody() { return requestBody; } + @Override public URI getRequestURI() { return URI.create("/"); } + @Override public OutputStream getResponseBody() { + return OutputStream.nullOutputStream(); + } + @Override public void sendResponseHeaders(int rCode, long responseLength) { + this.rCode = rCode; + this.responseLength = responseLength; + } + @Override public HttpContext getHttpContext() { + return new HttpContext() { + @Override public HttpHandler getHandler() { return null; } + @Override public void setHandler(HttpHandler handler) { } + @Override public String getPath() { + return "/"; + } + @Override public HttpServer getServer() { + return null; + } + @Override public Map getAttributes() { + return null; + } + @Override public List getFilters() { + return null; + } + @Override public Authenticator setAuthenticator(Authenticator auth) { + return null; + } + @Override public Authenticator getAuthenticator() { + return null; + } + }; + } + } + + static class MethodHttpExchange extends StubHttpExchange { + private final String method; + volatile int rCode; + volatile long responseLength; + volatile Headers responseHeaders; + volatile InputStream requestBody; + + MethodHttpExchange(String method) { + this.method = method; + responseHeaders = new Headers(); + requestBody = InputStream.nullInputStream(); + } + + @Override public String getRequestMethod() { return method; } + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public InputStream getRequestBody() { return requestBody; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { + this.rCode = rCode; + this.responseLength = responseLength; + } + } + + static class StubHttpExchange extends HttpExchange { + @Override public Headers getRequestHeaders() { return null; } + @Override public Headers getResponseHeaders() { return null; } + @Override public URI getRequestURI() { return null; } + @Override public String getRequestMethod() { return null; } + @Override public void close() { } + @Override public InputStream getRequestBody() { return null; } + @Override public OutputStream getResponseBody() { return null; } + @Override public HttpContext getHttpContext() { return null; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { } + @Override public InetSocketAddress getRemoteAddress() { return null; } + @Override public int getResponseCode() { return 0; } + @Override public InetSocketAddress getLocalAddress() { return null; } + @Override public String getProtocol() { return null; } + @Override public Object getAttribute(String name) { return null; } + @Override public void setAttribute(String name, Object value) { } + @Override public void setStreams(InputStream i, OutputStream o) { } + @Override public HttpPrincipal getPrincipal() { return null; } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/HttpHandlersTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/HttpHandlersTest.java new file mode 100644 index 00000000000..85d271e44fa --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/HttpHandlersTest.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for HttpHandlers + * @library /test/lib + * @build jdk.test.lib.net.URIBuilder + * @run testng/othervm HttpHandlersTest + */ + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import jdk.test.lib.net.URIBuilder; +import com.sun.net.httpserver.*; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.testng.Assert.*; + +public class HttpHandlersTest { + + static final Class NPE = NullPointerException.class; + static final Class IAE = IllegalArgumentException.class; + static final Class RE = RuntimeException.class; + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + } + + @Test + public void testNull() { + final var handler = new TestHandler(); + assertThrows(NPE, () -> HttpHandlers.handleOrElse(null, handler, new TestHandler())); + assertThrows(NPE, () -> HttpHandlers.handleOrElse(p -> true, null, handler)); + assertThrows(NPE, () -> HttpHandlers.handleOrElse(p -> true, handler, null)); + + final var headers = new Headers(); + final var body = ""; + assertThrows(NPE, () -> HttpHandlers.of(200, null, body)); + assertThrows(NPE, () -> HttpHandlers.of(200, headers, null)); + } + + @Test + public void testOfStatusCode() { + final var headers = new Headers(); + final var body = ""; + assertThrows(IAE, () -> HttpHandlers.of(99, headers, body)); + assertThrows(IAE, () -> HttpHandlers.of(1000, headers, body)); + } + + @Test + public void testOfNoBody() throws Exception { + var handler = HttpHandlers.of(200, Headers.of("foo", "bar"), ""); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertTrue(response.headers().map().containsKey("date")); + assertEquals(response.headers().firstValue("foo").get(), "bar"); + assertEquals(response.headers().firstValue("content-length").get(), "0"); + assertEquals(response.headers().map().size(), 3); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @Test + public void testOfWithBody() throws Exception { + var handler = HttpHandlers.of(200, Headers.of("foo", "bar"), "hello world"); + var expectedLength = Integer.toString("hello world".getBytes(UTF_8).length); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertTrue(response.headers().map().containsKey("date")); + assertEquals(response.headers().firstValue("foo").get(), "bar"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().map().size(), 3); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "hello world"); + } finally { + server.stop(0); + } + } + + @Test + public void testOfHeadRequest() throws Exception { + var handler = HttpHandlers.of(200, Headers.of("content-length", "999"), "hello world"); + var expectedLength = Integer.toString("hello world".getBytes(UTF_8).length); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertTrue(response.headers().map().containsKey("date")); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().map().size(), 2); + assertEquals(response.statusCode(), 200); + } finally { + server.stop(0); + } + } + + @Test + public void testOfOverwriteHeaders() throws Exception { + var headers = Headers.of("content-length", "1000", "date", "12345"); + var handler = HttpHandlers.of(200, headers, "hello world"); + var expectedLength = Integer.toString("hello world".getBytes(UTF_8).length); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertNotEquals(response.headers().firstValue("date").get(), "12345"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().map().size(), 2); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "hello world"); + } finally { + server.stop(0); + } + } + + @DataProvider + public Object[][] responseBodies() { + return new Object[][] { {"hello world"}, {""} }; + } + + @Test(dataProvider = "responseBodies") + public void testOfThrowingExchange(String body) { + var h = HttpHandlers.of(200, Headers.of(), body); + { + var exchange = new ThrowingHttpExchange() { + public InputStream getRequestBody() { + throw new RuntimeException("getRequestBody"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getRequestBody"); + } + { + var exchange = new ThrowingHttpExchange() { + public Headers getResponseHeaders() { + throw new RuntimeException("getResponseHeaders"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getResponseHeaders"); + } + { + var exchange = new ThrowingHttpExchange() { + public void sendResponseHeaders(int rCode, long responseLength) { + throw new RuntimeException("sendResponseHeaders"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "sendResponseHeaders"); + } + { + var exchange = new ThrowingHttpExchange() { + public OutputStream getResponseBody() { + throw new RuntimeException("getResponseBody"); + } + }; + if (!body.isEmpty()) { // getResponseBody not called if no responseBody + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "getResponseBody"); + } + } + { + var exchange = new ThrowingHttpExchange() { + public void close() { + throw new RuntimeException("close"); + } + }; + var t = expectThrows(RE, () -> h.handle(exchange)); + assertEquals(t.getMessage(), "close"); + } + } + + @Test + public void testHandleOrElseTrue() throws Exception { + var h1 = new TestHandler("TestHandler-1"); + var h2 = new TestHandler("TestHandler-2"); + var handler = HttpHandlers.handleOrElse(p -> true, h1, h2); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "TestHandler-1"); + } finally { + server.stop(0); + } + } + + @Test + public void testHandleOrElseFalse() throws Exception { + var h1 = new TestHandler("TestHandler-1"); + var h2 = new TestHandler("TestHandler-2"); + var handler = HttpHandlers.handleOrElse(p -> false, h1, h2); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "TestHandler-2"); + } finally { + server.stop(0); + } + } + + @Test + public void testHandleOrElseNested() throws Exception { + var h1 = new TestHandler("TestHandler-1"); + var h2 = new TestHandler("TestHandler-2"); + var h3 = new TestHandler("TestHandler-3"); + var h4 = HttpHandlers.handleOrElse(p -> false, h1, h2); + var handler = HttpHandlers.handleOrElse(p -> false, h3, h4); + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", handler); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "TestHandler-2"); + } finally { + server.stop(0); + } + } + + /** + * A test handler that discards the request and returns its name + */ + static class TestHandler implements HttpHandler { + final String name; + TestHandler(String name) { this.name = name; } + TestHandler() { this("no name"); } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + is.readAllBytes(); + var resp = name.getBytes(UTF_8); + exchange.sendResponseHeaders(200, resp.length); + os.write(resp); + } + } + } + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + static class ThrowingHttpExchange extends HttpExchange { + static final String requestMethod = "GET"; + volatile int responseCode; + volatile long responseLength; + volatile Headers responseHeaders; + volatile InputStream requestBody; + + ThrowingHttpExchange() { + responseHeaders = new Headers(); + this.requestBody = InputStream.nullInputStream(); + } + + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public InputStream getRequestBody() { return requestBody; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { + this.responseCode = rCode; + this.responseLength = responseLength; + } + @Override public OutputStream getResponseBody() { + return OutputStream.nullOutputStream(); + } + @Override public String getRequestMethod() { return requestMethod; } + + @Override public Headers getRequestHeaders() { return null; } + @Override public URI getRequestURI() { return null; } + @Override public HttpContext getHttpContext() { return null; } + @Override public void close() { } + @Override public InetSocketAddress getRemoteAddress() { return null; } + @Override public int getResponseCode() { return 0; } + @Override public InetSocketAddress getLocalAddress() { return null; } + @Override public String getProtocol() { return null; } + @Override public Object getAttribute(String name) { return null; } + @Override public void setAttribute(String name, Object value) { } + @Override public void setStreams(InputStream i, OutputStream o) { } + @Override public HttpPrincipal getPrincipal() { return null; } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/HttpsServerTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/HttpsServerTest.java new file mode 100644 index 00000000000..2227c4cd437 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/HttpsServerTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Test for HttpsServer::create + * @library /test/lib + * @build jdk.test.lib.Platform jdk.test.lib.net.URIBuilder + * @run testng/othervm HttpsServerTest + */ + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import javax.net.ssl.SSLContext; +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertThrows; + +public class HttpsServerTest { + + static final Class NPE = NullPointerException.class; + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + SSLContext sslContext; + + @BeforeTest + public void setup() throws IOException { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + sslContext = new SimpleSSLContext().get(); + SSLContext.setDefault(sslContext); + } + + @Test + public void testNull() { + assertThrows(NPE, () -> HttpsServer.create(null, 0, null, new Handler())); + assertThrows(NPE, () -> HttpsServer.create(null, 0, "/", null)); + assertThrows(NPE, () -> HttpsServer.create(null, 0, "/", new Handler(), (Filter)null)); + assertThrows(NPE, () -> HttpsServer.create(null, 0, "/", new Handler(), new Filter[]{null})); + } + + @Test + public void testCreate() throws IOException { + assertNull(HttpsServer.create().getAddress()); + + final var s1 = HttpsServer.create(null, 0); + assertNull(s1.getAddress()); + s1.bind((LOOPBACK_ADDR), 0); + assertEquals(s1.getAddress().getAddress(), LOOPBACK_ADDR.getAddress()); + + final var s2 = HttpsServer.create(null, 0, "/foo/", new Handler()); + assertNull(s2.getAddress()); + s2.bind(LOOPBACK_ADDR, 0); + assertEquals(s2.getAddress().getAddress(), LOOPBACK_ADDR.getAddress()); + s2.removeContext("/foo/"); // throws if context doesn't exist + } + + @Test + public void testExchange() throws Exception { + var filter = new Filter(); + var server = HttpsServer.create(LOOPBACK_ADDR, 0, "/test", new Handler(), filter); + server.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + server.start(); + try { + var client = HttpClient.newBuilder() + .proxy(NO_PROXY) + .sslContext(sslContext) + .build(); + var request = HttpRequest.newBuilder(uri(server, "/test")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "hello world"); + assertEquals(response.headers().firstValue("content-length").get(), + Integer.toString("hello world".length())); + assertEquals(response.statusCode(), filter.responseCode.get().intValue()); + } finally { + server.stop(0); + } + } + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .scheme("https") + .host("localhost") + .port(server.getAddress().getPort()) + .path(path) + .buildUnchecked(); + } + + /** + * A test handler that discards the request and sends a response + */ + static class Handler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + is.readAllBytes(); + var resp = "hello world".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + os.write(resp); + } + } + } + + /** + * A test post-processing filter that captures the response code + */ + static class Filter extends com.sun.net.httpserver.Filter { + final CompletableFuture responseCode = new CompletableFuture<>(); + + @Override + public void doFilter(HttpExchange exchange, Chain chain) throws IOException { + chain.doFilter(exchange); + responseCode.complete(exchange.getResponseCode()); + } + + @Override + public String description() { + return "HttpsServerTest Filter"; + } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/IdempotencyAndCommutativityTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/IdempotencyAndCommutativityTest.java new file mode 100644 index 00000000000..d98944077dc --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/IdempotencyAndCommutativityTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import jdk.test.lib.net.URIBuilder; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.file.StandardOpenOption.CREATE; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +/* + * @test + * @summary Test idempotency and commutativity of responses with an exhaustive + * set of binary request sequences + * @library /test/lib + * @build jdk.test.lib.net.URIBuilder + * @run testng/othervm IdempotencyAndCommutativityTest + */ +public class IdempotencyAndCommutativityTest { + + static final Path CWD = Path.of(".").toAbsolutePath().normalize(); + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final String FILE_NAME = "file.txt"; + static final String DIR_NAME = ""; + static final String MISSING_FILE_NAME = "doesNotExist"; + + static HttpServer server; + static HttpClient client; + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() throws IOException { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + Path root = Files.createDirectories(CWD.resolve("testDirectory")); + Files.writeString(root.resolve(FILE_NAME), "some text", CREATE); + + client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, SimpleFileServer.OutputLevel.VERBOSE); + server.start(); + } + + // Container of expected response state for a given request + record ExchangeValues(String method, String resource, int respCode, String contentType) {} + + // Creates an exhaustive set of binary exchange sequences + @DataProvider + public Object[][] allBinarySequences() { + final List sequences = List.of( + new ExchangeValues("GET", FILE_NAME, 200, "text/plain"), + new ExchangeValues("GET", DIR_NAME, 200, "text/html; charset=UTF-8"), + new ExchangeValues("GET", MISSING_FILE_NAME, 404, "text/html; charset=UTF-8"), + new ExchangeValues("HEAD", FILE_NAME, 200, "text/plain"), + new ExchangeValues("HEAD", DIR_NAME, 200, "text/html; charset=UTF-8"), + new ExchangeValues("HEAD", MISSING_FILE_NAME, 404, "text/html; charset=UTF-8"), + new ExchangeValues("UNKNOWN", FILE_NAME, 501, null), + new ExchangeValues("UNKNOWN", DIR_NAME, 501, null), + new ExchangeValues("UNKNOWN", MISSING_FILE_NAME, 501, null) + ); + + return sequences.stream() // cartesian product + .flatMap(s1 -> sequences.stream().map(s2 -> new ExchangeValues[] { s1, s2 })) + .toArray(Object[][]::new); + } + + @Test(dataProvider = "allBinarySequences") + public void testBinarySequences(ExchangeValues e1, ExchangeValues e2) throws Exception { + System.out.println("---"); + System.out.println(e1); + executeExchange(e1); + System.out.println(e2); + executeExchange(e2); + } + + private static void executeExchange(ExchangeValues e) throws Exception { + var request = HttpRequest.newBuilder(uri(server, e.resource())) + .method(e.method(), HttpRequest.BodyPublishers.noBody()) + .build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), e.respCode()); + if (e.contentType != null) { + assertEquals(response.headers().firstValue("content-type").get(), e.contentType()); + } else { + assertTrue(response.headers().firstValue("content-type").isEmpty()); + } + } + + @AfterTest + public static void teardown() { + server.stop(0); + } + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/MapToPathTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/MapToPathTest.java new file mode 100644 index 00000000000..d6e61da4073 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/MapToPathTest.java @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests the FileServerHandler's mapping of request URI path to file + * system path + * @library /test/lib + * @build jdk.test.lib.Platform jdk.test.lib.net.URIBuilder + * @run testng/othervm MapToPathTest + */ + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpHandlers; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.net.URIBuilder; +import jdk.test.lib.util.FileUtils; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; +import static java.lang.System.out; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.file.StandardOpenOption.CREATE; +import static org.testng.Assert.assertEquals; + +public class MapToPathTest { + + static final Path CWD = Path.of(".").toAbsolutePath(); + static final Path TEST_DIR = CWD.resolve("MapToPathTest").normalize(); + + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + static final Filter OUTPUT_FILTER = SimpleFileServer.createOutputFilter(out, OutputLevel.VERBOSE); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() throws IOException { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + createDirectories(TEST_DIR); + } + + private void createDirectories(Path testDir) throws IOException { + // Create directory tree: + // + // |-- TEST_DIR + // |-- foo + // |-- bar + // |-- baz + // |-- file.txt + // |-- file.txt + // |-- foobar + // |-- file.txt + // |-- file.txt + + Files.createDirectories(TEST_DIR); + Stream.of("foo", "foobar", "foo/bar/baz").forEach(s -> { + try { + Path p = testDir.resolve(s); + Files.createDirectories(p); + Files.writeString(p.resolve("file.txt"), s, CREATE); + } catch (IOException ioe) { + throw new UncheckedIOException(ioe); + } + }); + Files.writeString(testDir.resolve("file.txt"), "testdir", CREATE); + } + + @Test + public void test() throws Exception { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + { + var handler = SimpleFileServer.createFileHandler(TEST_DIR); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler, OUTPUT_FILTER); + server.start(); + try { + var req1 = HttpRequest.newBuilder(uri(server, "/")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(res1.headers().firstValue("content-length").get(), Long.toString(257L)); + assertEquals(res1.headers().firstValue("last-modified").get(), getLastModified(TEST_DIR)); + + var req2 = HttpRequest.newBuilder(uri(server, "/../")).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 404); // cannot escape root + + var req3 = HttpRequest.newBuilder(uri(server, "/foo/bar/baz/c://")).build(); + var res3 = client.send(req3, BodyHandlers.ofString()); + assertEquals(res3.statusCode(), 404); // not found + + var req4 = HttpRequest.newBuilder(uri(server, "/foo/file:" + TEST_DIR.getParent())).build(); + var res4 = client.send(req4, BodyHandlers.ofString()); + assertEquals(res4.statusCode(), 404); // not found + + var req5 = HttpRequest.newBuilder(uri(server, "/foo/bar/\\..\\../")).build(); + var res5 = client.send(req5, BodyHandlers.ofString()); + assertEquals(res5.statusCode(), 404); // not found + + var req6 = HttpRequest.newBuilder(uri(server, "/foo")).build(); + var res6 = client.send(req6, BodyHandlers.ofString()); + assertEquals(res6.statusCode(), 301); // redirect + assertEquals(res6.headers().firstValue("content-length").get(), "0"); + assertEquals(res6.headers().firstValue("location").get(), "/foo/"); + } finally { + server.stop(0); + } + } + { + var handler = SimpleFileServer.createFileHandler(TEST_DIR); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/browse/", handler, OUTPUT_FILTER); + server.start(); + try { + var req1 = HttpRequest.newBuilder(uri(server, "/browse/file.txt")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "testdir"); + assertEquals(res1.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(res1.headers().firstValue("content-length").get(), Long.toString(7L)); + assertEquals(res1.headers().firstValue("last-modified").get(), getLastModified(TEST_DIR.resolve("file.txt"))); + + var req2 = HttpRequest.newBuilder(uri(server, "/store/file.txt")).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 404); // no context found + } finally { + server.stop(0); + } + } + { + // Test "/foo/" context (with trailing slash) + var handler = SimpleFileServer.createFileHandler(TEST_DIR.resolve("foo")); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/foo/", handler, OUTPUT_FILTER); + server.start(); + try { + var req1 = HttpRequest.newBuilder(uri(server, "/foo/file.txt")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "foo"); + assertEquals(res1.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(res1.headers().firstValue("content-length").get(), Long.toString(3L)); + assertEquals(res1.headers().firstValue("last-modified").get(), getLastModified(TEST_DIR.resolve("foo").resolve("file.txt"))); + + var req2 = HttpRequest.newBuilder(uri(server, "/foobar/file.txt")).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 404); // no context found + + var req3 = HttpRequest.newBuilder(uri(server, "/foo/../foobar/file.txt")).build(); + var res3 = client.send(req3, BodyHandlers.ofString()); + assertEquals(res3.statusCode(), 404); // cannot escape context + + var req4 = HttpRequest.newBuilder(uri(server, "/foo/../..")).build(); + var res4 = client.send(req4, BodyHandlers.ofString()); + assertEquals(res4.statusCode(), 404); // cannot escape root + + var req5 = HttpRequest.newBuilder(uri(server, "/foo/bar")).build(); + var res5 = client.send(req5, BodyHandlers.ofString()); + assertEquals(res5.statusCode(), 301); // redirect + assertEquals(res5.headers().firstValue("content-length").get(), "0"); + assertEquals(res5.headers().firstValue("location").get(), "/foo/bar/"); + } finally { + server.stop(0); + } + } + { + // Test "/foo" context (without trailing slash) + var handler = SimpleFileServer.createFileHandler(TEST_DIR.resolve("foo")); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/foo", handler, OUTPUT_FILTER); + server.start(); + try { + var req1 = HttpRequest.newBuilder(uri(server, "/foo/file.txt")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "foo"); + assertEquals(res1.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(res1.headers().firstValue("content-length").get(), Long.toString(3L)); + assertEquals(res1.headers().firstValue("last-modified").get(), getLastModified(TEST_DIR.resolve("foo").resolve("file.txt"))); + + var req2 = HttpRequest.newBuilder(uri(server, "/foobar/")).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 404); // handler prevents mapping to /foo/bar + + var req3 = HttpRequest.newBuilder(uri(server, "/foobar/file.txt")).build(); + var res3 = client.send(req3, BodyHandlers.ofString()); + assertEquals(res3.statusCode(), 404); // handler prevents mapping to /foo/bar/file.txt + + var req4 = HttpRequest.newBuilder(uri(server, "/file.txt")).build(); + var res4 = client.send(req4, BodyHandlers.ofString()); + assertEquals(res4.statusCode(), 404); + + var req5 = HttpRequest.newBuilder(uri(server, "/foo/bar")).build(); + var res5 = client.send(req5, BodyHandlers.ofString()); + assertEquals(res5.statusCode(), 301); // redirect + assertEquals(res5.headers().firstValue("content-length").get(), "0"); + assertEquals(res5.headers().firstValue("location").get(), "/foo/bar/"); + + var req6 = HttpRequest.newBuilder(uri(server, "/foo")).build(); + var res6 = client.send(req6, BodyHandlers.ofString()); + assertEquals(res6.statusCode(), 301); // redirect + assertEquals(res6.headers().firstValue("content-length").get(), "0"); + assertEquals(res6.headers().firstValue("location").get(), "/foo/"); + } finally { + server.stop(0); + } + } + } + + // Tests with a mixture of in-memory and file handlers. + @Test + public void multipleContexts() throws Exception { + var rootHandler = HttpHandlers.of(200, Headers.of(), "root response body"); + var fooHandler = SimpleFileServer.createFileHandler(TEST_DIR.resolve("foo")); + var foobarHandler = SimpleFileServer.createFileHandler(TEST_DIR.resolve("foobar")); + var barHandler = HttpHandlers.of(200, Headers.of(), "bar response body"); + + var server = HttpServer.create(LOOPBACK_ADDR, 0); + server.createContext("/", rootHandler); + server.createContext("/foo/", fooHandler); + server.createContext("/bar/", barHandler); + server.createContext("/foobar/", foobarHandler); + server.start(); + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + try { + for (String uriPath : List.of("/", "/blah", "/xyz/t/z", "/txt") ) { + out.println("uri.Path=" + uriPath); + var req1 = HttpRequest.newBuilder(uri(server, uriPath)).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "root response body"); + } + { + var req1 = HttpRequest.newBuilder(uri(server, "/foo/file.txt")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "foo"); + + var req2 = HttpRequest.newBuilder(uri(server, "/foo/bar/baz/file.txt")).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 200); + assertEquals(res2.body(), "foo/bar/baz"); + } + { + var req1 = HttpRequest.newBuilder(uri(server, "/foobar/file.txt")).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "foobar"); + } + for (String uriPath : List.of("/bar/", "/bar/t", "/bar/t/z", "/bar/index.html") ) { + out.println("uri.Path=" + uriPath); + var req1 = HttpRequest.newBuilder(uri(server, uriPath)).build(); + var res1 = client.send(req1, BodyHandlers.ofString()); + assertEquals(res1.statusCode(), 200); + assertEquals(res1.body(), "bar response body"); + } + } finally { + server.stop(0); + } + } + + // Tests requests with queries, which are simply ignored by the handler + @Test + public void requestWithQuery() throws Exception { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var handler = SimpleFileServer.createFileHandler(TEST_DIR); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler, OUTPUT_FILTER); + server.start(); + try { + for (String query : List.of("x=y", "x=", "xxx", "#:?") ) { + out.println("uri.Query=" + query); + var req = HttpRequest.newBuilder(uri(server, "", query)).build(); + var res = client.send(req, BodyHandlers.ofString()); + assertEquals(res.statusCode(), 200); + assertEquals(res.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(res.headers().firstValue("content-length").get(), Long.toString(257L)); + assertEquals(res.headers().firstValue("last-modified").get(), getLastModified(TEST_DIR)); + } + } finally { + server.stop(0); + } + } + + @AfterTest + public void teardown() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + } + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path(path) + .buildUnchecked(); + } + + static URI uri(HttpServer server, String path, String query) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path(path) + .query(query) + .buildUnchecked(); + } + + static String getLastModified(Path path) throws IOException { + return Files.getLastModifiedTime(path).toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/OutputFilterTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/OutputFilterTest.java new file mode 100644 index 00000000000..b7583ea901b --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/OutputFilterTest.java @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for OutputFilter + * @modules java.base/sun.net.www:+open + * @library /test/lib + * @build jdk.test.lib.net.URIBuilder + * @run testng/othervm -Djdk.httpclient.redirects.retrylimit=1 OutputFilterTest + */ + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.net.URIBuilder; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.charset.StandardCharsets.*; +import static com.sun.net.httpserver.SimpleFileServer.OutputLevel.*; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +public class OutputFilterTest { + static final Class NPE = NullPointerException.class; + static final Class IAE = IllegalArgumentException.class; + static final Class IOE = IOException.class; + + static final OutputStream OUT = new ByteArrayOutputStream(); + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger logger = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + logger.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + logger.addHandler(ch); + } + } + + @Test + public void testNull() { + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(null, null)); + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(null, VERBOSE)); + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(OUT, null)); + } + + @Test + public void testDescription() { + var filter = SimpleFileServer.createOutputFilter(OUT, VERBOSE); + assertEquals(filter.description(), "HttpExchange OutputFilter (outputLevel: VERBOSE)"); + + filter = SimpleFileServer.createOutputFilter(OUT, INFO); + assertEquals(filter.description(), "HttpExchange OutputFilter (outputLevel: INFO)"); + } + + @Test + public void testNONE() { + assertThrows(IAE, () -> SimpleFileServer.createOutputFilter(OUT, NONE)); + } + + /** + * Confirms that the output filter produces the expected output for a + * successful exchange (with the request-path attribute set.) + */ + @Test + public void testExchange() throws Exception { + var baos = new ByteArrayOutputStream(); + var handler = new RequestPathHandler(); + var filter = SimpleFileServer.createOutputFilter(baos, VERBOSE); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler, filter); + server.start(); + try (baos) { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().map().size(), 3); + assertEquals(response.body(), "hello world"); + } finally { + server.stop(0); + baos.flush(); + var filterOutput = baos.toString(UTF_8); + var pattern = Pattern.compile(""" + 127\\.0\\.0\\.1 - - \\[[\\s\\S]+] "GET / HTTP/1\\.1" 200 - + Resource requested: /foo/bar + (>[\\s\\S]+:[\\s\\S]+)+ + > + (<[\\s\\S]+:[\\s\\S]+)+ + < + """.replaceAll("\n", System.lineSeparator())); + assertTrue(pattern.matcher(filterOutput).matches()); + + /* + * Expected output format: + * """ + * 127.0.0.1 - - [06/Jul/2021:12:56:47 +0100] "GET / HTTP/1.1" 200 - + * Resource requested: /foo/bar + * > Connection: Upgrade, HTTP2-Settings + * > Http2-settings: AAEAAEAAAAIAAAABAAMAAABkAAQBAAAAAAUAAEAA + * > Host: localhost:59146 + * > Upgrade: h2c + * > User-agent: Java-http-client/18-internal + * > Content-length: 0 + * > + * < Date: Tue, 06 Jul 2021 11:56:47 GMT + * < Content-length: 11 + * < + * """; + */ + } + } + + /** + * Confirms that the output filter produces the expected output for + * a successful exchange (without the request-path attribute set.) + */ + @Test + public void testExchangeWithoutRequestPath() throws Exception { + var baos = new ByteArrayOutputStream(); + var handler = new NoRequestPathHandler(); + var filter = SimpleFileServer.createOutputFilter(baos, VERBOSE); + var server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler, filter); + server.start(); + try (baos) { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().map().size(), 2); + assertEquals(response.body(), "hello world"); + } finally { + server.stop(0); + baos.flush(); + var filterOutput = baos.toString(UTF_8); + var pattern = Pattern.compile(""" + 127\\.0\\.0\\.1 - - \\[[\\s\\S]+] "GET / HTTP/1\\.1" 200 - + (>[\\s\\S]+:[\\s\\S]+)+ + > + (<[\\s\\S]+:[\\s\\S]+)+ + < + """.replaceAll("\n", System.lineSeparator())); + assertTrue(pattern.matcher(filterOutput).matches()); + + /* + * Expected output: + * """ + * 127.0.0.1 - - [12/Jul/2021:10:05:10 +0000] "GET / HTTP/1.1" 200 - + * > Connection: Upgrade, HTTP2-Settings + * > Http2-settings: AAEAAEAAAAIAAAABAAMAAABkAAQBAAAAAAUAAEAA + * > Host: localhost:57931 + * > Upgrade: h2c + * > User-agent: Java-http-client/18-internal + * > Content-length: 0 + * > + * < Date: Mon, 12 Jul 2021 10:05:10 GMT + * < Content-length: 11 + * < + * """; + */ + } + } + + @DataProvider + public Object[][] throwingHandler() { + return new Object[][] { + {VERBOSE, "Error: server exchange handling failed: IOE ThrowingHandler" + System.lineSeparator()}, + {INFO, "Error: server exchange handling failed: IOE ThrowingHandler" + System.lineSeparator()}, + {NONE, ""} + }; + } + + /** + * Confirms that the output filter captures a throwable that is thrown + * during the exchange handling and prints the expected error message. + * The "httpclient.redirects.retrylimit" system property is set to 1 to + * prevent retries on the client side, which would result in more than one + * error message. + */ + @Test(dataProvider = "throwingHandler") + public void testExchangeThrowingHandler(OutputLevel level, + String expectedOutput) throws Exception { + var baos = new ByteArrayOutputStream(); + var handler = new ThrowingHandler(); + HttpServer server; + if (level.equals(NONE)) { + server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler); + } else { + var filter = SimpleFileServer.createOutputFilter(baos, level); + server = HttpServer.create(LOOPBACK_ADDR, 10, "/", handler, filter); + } + server.start(); + try (baos) { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + assertThrows(IOE, () -> client.send(request, HttpResponse.BodyHandlers.ofString())); + } finally { + server.stop(0); + baos.flush(); + assertEquals(baos.toString(UTF_8), expectedOutput); + } + } + + /** + * Confirms that the output filter prints the expected message if the request + * URI cannot be resolved. This only applies if the filter is used in + * combination with the SimpleFileServer file-handler, which sets the + * necessary request-path attribute. + */ + @Test + public void testCannotResolveRequestURI() throws Exception { + var baos = new ByteArrayOutputStream(); + var handler = SimpleFileServer.createFileHandler(Path.of(".").toAbsolutePath()); + var filter = SimpleFileServer.createOutputFilter(baos, VERBOSE); + var server = HttpServer.create(LOOPBACK_ADDR, 0, "/", handler, filter); + server.start(); + try (baos) { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile\u0000.txt")).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().map().size(), 3); + } finally { + server.stop(0); + baos.flush(); + var filterOutput = baos.toString(UTF_8); + var pattern = Pattern.compile(""" + 127\\.0\\.0\\.1 - - \\[[\\s\\S]+] "GET /aFile%00\\.txt HTTP/1\\.1" 404 - + Resource requested: could not resolve request URI path + (>[\\s\\S]+:[\\s\\S]+)+ + > + (<[\\s\\S]+:[\\s\\S]+)+ + < + """.replaceAll("\n", System.lineSeparator())); + assertTrue(pattern.matcher(filterOutput).matches()); + } + } + + // --- infra --- + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + /** + * A handler that sets the request-path attribute and a custom header + * and sends a response. + */ + static class RequestPathHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + is.readAllBytes(); + exchange.setAttribute("request-path", "/foo/bar"); + exchange.getResponseHeaders().put("Foo", List.of("bar", "bar")); + var resp = "hello world".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + os.write(resp); + } + } + } + + /** + * A handler that sets no request-path attribute and sends a response. + */ + static class NoRequestPathHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + is.readAllBytes(); + var resp = "hello world".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + os.write(resp); + } + } + } + + /** + * A handler that throws an IOException. + */ + static class ThrowingHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try (exchange) { + throw new IOException("IOE ThrowingHandler"); + } + } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/RequestTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/RequestTest.java new file mode 100644 index 00000000000..cb740f4dd2a --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/RequestTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for Request + * @run testng RequestTest + */ + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import com.sun.net.httpserver.*; +import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertThrows; + +public class RequestTest { + + @Test + public void testAddToEmpty() { + var headers = new Headers(); + Request request = new TestHttpExchange(headers); + request = request.with("Foo", List.of("Bar")); + assertEquals(request.getRequestHeaders().size(), 1); + assertEquals(request.getRequestHeaders().get("Foo"), List.of("Bar")); + assertReadOnly(request.getRequestHeaders()); + } + + @Test + public void testAddition() { + var headers = new Headers(); + headers.add("Foo", "Bar"); + Request request = new TestHttpExchange(headers); + request = request.with("X-Foo", List.of("Bar")); + assertEquals(request.getRequestHeaders().size(), 2); + assertEquals(request.getRequestHeaders().get("Foo"), List.of("Bar")); + assertEquals(request.getRequestHeaders().get("X-Foo"), List.of("Bar")); + assertReadOnly(request.getRequestHeaders()); + } + + @Test + public void testAddWithExisting() { + final String headerName = "Foo"; + var headers = new Headers(); + headers.add(headerName, "Bar"); + Request request = new TestHttpExchange(headers); + request = request.with(headerName, List.of("blahblahblah")); + assertEquals(request.getRequestHeaders().size(), 1); + assertEquals(request.getRequestHeaders().get(headerName), List.of("Bar")); + assertReadOnly(request.getRequestHeaders()); + } + + @Test + public void testAddSeveral() { + var headers = new Headers(); + headers.add("Foo", "Bar"); + Request request = new TestHttpExchange(headers); + request = request.with("Larry", List.of("a")) + .with("Curly", List.of("b")) + .with("Moe", List.of("c")); + assertEquals(request.getRequestHeaders().size(), 4); + assertEquals(request.getRequestHeaders().getFirst("Foo"), "Bar"); + assertEquals(request.getRequestHeaders().getFirst("Larry"), "a"); + assertEquals(request.getRequestHeaders().getFirst("Curly"), "b"); + assertEquals(request.getRequestHeaders().getFirst("Moe" ), "c"); + assertReadOnly(request.getRequestHeaders()); + } + + static final Class UOP = UnsupportedOperationException.class; + + static void assertReadOnly(Headers headers) { + assertUnsupportedOperation(headers); + assertUnmodifiableCollection(headers); + assertUnmodifiableList(headers); + } + + static void assertUnsupportedOperation(Headers headers) { + assertThrows(UOP, () -> headers.add("a", "b")); + assertThrows(UOP, () -> headers.compute("c", (k, v) -> List.of("c"))); + assertThrows(UOP, () -> headers.computeIfAbsent("d", k -> List.of("d"))); + assertThrows(UOP, () -> headers.computeIfPresent("Foo", (k, v) -> null)); + assertThrows(UOP, () -> headers.merge("e", List.of("e"), (k, v) -> List.of("e"))); + assertThrows(UOP, () -> headers.put("f", List.of("f"))); + assertThrows(UOP, () -> headers.putAll(Map.of())); + assertThrows(UOP, () -> headers.putIfAbsent("g", List.of("g"))); + assertThrows(UOP, () -> headers.remove("h")); + assertThrows(UOP, () -> headers.replace("i", List.of("i"))); + assertThrows(UOP, () -> headers.replace("j", List.of("j"), List.of("j"))); + assertThrows(UOP, () -> headers.replaceAll((k, v) -> List.of("k"))); + assertThrows(UOP, () -> headers.set("l", "m")); + assertThrows(UOP, () -> headers.clear()); + } + + static void assertUnmodifiableCollection(Headers headers) { + var entry = new AbstractMap.SimpleEntry<>("n", List.of("n")); + + assertThrows(UOP, () -> headers.values().remove(List.of("Bar"))); + assertThrows(UOP, () -> headers.values().removeAll(List.of("Bar"))); + assertThrows(UOP, () -> headers.keySet().remove("Foo")); + assertThrows(UOP, () -> headers.keySet().removeAll(List.of("Foo"))); + assertThrows(UOP, () -> headers.entrySet().remove(entry)); + assertThrows(UOP, () -> headers.entrySet().removeAll(List.of(entry))); + } + + static void assertUnmodifiableList(Headers headers) { + assertThrows(UOP, () -> headers.get("Foo").remove(0)); + assertThrows(UOP, () -> headers.get("foo").remove(0)); + assertThrows(UOP, () -> headers.values().stream().findFirst().orElseThrow().remove(0)); + assertThrows(UOP, () -> headers.entrySet().stream().findFirst().orElseThrow().getValue().remove(0)); + } + + static class TestHttpExchange extends StubHttpExchange { + final Headers headers; + TestHttpExchange(Headers headers) { + this.headers = headers; + } + @Override + public Headers getRequestHeaders() { + return headers; + } + } + + static class StubHttpExchange extends HttpExchange { + @Override public Headers getRequestHeaders() { return null; } + @Override public Headers getResponseHeaders() { return null; } + @Override public URI getRequestURI() { return null; } + @Override public String getRequestMethod() { return null; } + @Override public HttpContext getHttpContext() { return null; } + @Override public void close() { } + @Override public InputStream getRequestBody() { return null; } + @Override public OutputStream getResponseBody() { return null; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { } + @Override public InetSocketAddress getRemoteAddress() { return null; } + @Override public int getResponseCode() { return 0; } + @Override public InetSocketAddress getLocalAddress() { return null; } + @Override public String getProtocol() { return null; } + @Override public Object getAttribute(String name) { return null; } + @Override public void setAttribute(String name, Object value) { } + @Override public void setStreams(InputStream i, OutputStream o) { } + @Override public HttpPrincipal getPrincipal() { return null; } + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTest.java new file mode 100644 index 00000000000..72d0050b2af --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTest.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for FileServerHandler with SecurityManager + * @library /test/lib + * @build jdk.test.lib.net.URIBuilder + * @run main/othervm/java.security.policy=SecurityManagerTestRead.policy -ea SecurityManagerTest true + * @run main/othervm/java.security.policy=SecurityManagerTestNoRead.policy -ea SecurityManagerTest false + * @run main/othervm -ea SecurityManagerTest true + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.AccessControlException; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.net.URIBuilder; +import jdk.test.lib.util.FileUtils; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE; + +/** + * Tests the permission checks during the creation of a FileServerHandler. + * + * A FileServerHandler can only be created if a "read" FilePermission is + * granted for the root directory passed. The test consists of 3 runs: + * 1) security manager enabled and "read" FilePermission granted + * 2) security manager enabled and "read" FilePermission NOT granted + * 3) security manager NOT enabled + * 2) misses the required permissions to call many of the java.nio.file methods, + * the test works around this by reusing the test directory created in the + * previous run. +* */ +public class SecurityManagerTest { + + static final Path CWD = Path.of(".").toAbsolutePath().normalize(); + static final Path TEST_DIR = CWD.resolve("SecurityManagerTest"); + static final InetSocketAddress LOOPBACK_ADDR = + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + static boolean readPermitted; + static String lastModifiedDir; + static String lastModifiedFile; + + public static void main(String[] args) throws Exception { + setupLogging(); + readPermitted = Boolean.parseBoolean(args[0]); + if (readPermitted) { + createTestDir(); + testDirectoryGET(); + testFileGET(); + } else { // no FilePermission "read" for TEST_DIR granted, + // assert handler cannot be created + testCreateHandler(); + } + } + + private static void setupLogging() { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + } + + private static void createTestDir() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + Files.createDirectories(TEST_DIR); + var file = Files.writeString(TEST_DIR.resolve("aFile.txt"), "some text", CREATE); + lastModifiedDir = getLastModified(TEST_DIR); + lastModifiedFile = getLastModified(file); + } + + private static void testDirectoryGET() throws Exception { + var expectedBody = openHTML + """ +

    Directory listing for /

    +
    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, TEST_DIR, OutputLevel.VERBOSE); + + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assert response.statusCode() == 200; + assert response.body().equals(expectedBody); + assert response.headers().firstValue("content-type").get().equals("text/html; charset=UTF-8"); + assert response.headers().firstValue("content-length").get().equals(expectedLength); + assert response.headers().firstValue("last-modified").get().equals(lastModifiedDir); + } finally { + server.stop(0); + } + } + + private static void testFileGET() throws Exception { + var expectedBody = "some text"; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, TEST_DIR, OutputLevel.VERBOSE); + + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assert response.statusCode() == 200; + assert response.body().equals("some text"); + assert response.headers().firstValue("content-type").get().equals("text/plain"); + assert response.headers().firstValue("content-length").get().equals(expectedLength); + assert response.headers().firstValue("last-modified").get().equals(lastModifiedFile); + } finally { + server.stop(0); + } + } + + @SuppressWarnings("removal") + private static void testCreateHandler(){ + try { + SimpleFileServer.createFileServer(LOOPBACK_ADDR, TEST_DIR, OutputLevel.NONE); + throw new RuntimeException("Handler creation expected to fail"); + } catch (AccessControlException expected) { } + + try { + SimpleFileServer.createFileHandler(TEST_DIR); + throw new RuntimeException("Handler creation expected to fail"); + } catch (AccessControlException expected) { } + } + + static final String openHTML = """ + + + + + + + """; + + static final String closeHTML = """ + + + """; + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + static String getLastModified(Path path) throws IOException { + return Files.getLastModifiedTime(path).toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestNoRead.policy b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestNoRead.policy new file mode 100644 index 00000000000..b91171b4145 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestNoRead.policy @@ -0,0 +1,39 @@ +// +// Copyright (c) 2021, 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. +// +// 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. +// + +// for JTwork/classes/test/lib/jdk/test/lib/util/FileUtils.class +grant codeBase "file:${test.classes}/../../../../../../test/lib/-" { + permission java.util.PropertyPermission "*", "read"; +}; + +grant codeBase "file:${test.classes}/*" { + permission java.net.URLPermission "http://localhost:*/*", "GET"; + + // for HTTP server + permission java.net.SocketPermission "localhost:*", "accept,resolve"; + + // for HTTP/1.1 server logging + permission java.util.logging.LoggingPermission "control"; + + permission java.util.PropertyPermission "*", "read"; +}; diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestRead.policy b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestRead.policy new file mode 100644 index 00000000000..b8b6b3a5f37 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/SecurityManagerTestRead.policy @@ -0,0 +1,43 @@ +// +// Copyright (c) 2021, 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. +// +// 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. +// + +// for JTwork/classes/test/lib/jdk/test/lib/util/FileUtils.class +grant codeBase "file:${test.classes}/../../../../../../test/lib/-" { + permission java.util.PropertyPermission "*", "read"; +}; + +grant codeBase "file:${test.classes}/*" { + permission java.net.URLPermission "http://localhost:*/*", "GET"; + + // for test directory tree + permission java.io.FilePermission "${user.dir}${/}SecurityManagerTest", "read,write,delete"; + permission java.io.FilePermission "${user.dir}${/}SecurityManagerTest/-", "read,write,delete"; + + // for HTTP server + permission java.net.SocketPermission "localhost:*", "accept,resolve"; + + // for HTTP/1.1 server logging + permission java.util.logging.LoggingPermission "control"; + + permission java.util.PropertyPermission "*", "read"; +}; diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/ServerMimeTypesResolutionTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/ServerMimeTypesResolutionTest.java new file mode 100644 index 00000000000..baa7d17b505 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/ServerMimeTypesResolutionTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +import java.io.IOException; +import java.io.StringReader; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import jdk.test.lib.net.URIBuilder; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import sun.net.www.MimeTable; + +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +/* + * @test + * @summary Tests for MIME types in response headers + * @modules java.base/sun.net.www:+open + * @library /test/lib + * @build jdk.test.lib.net.URIBuilder + * @run testng/othervm ServerMimeTypesResolutionTest + */ +public class ServerMimeTypesResolutionTest { + + static final Path CWD = Path.of(".").toAbsolutePath(); + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + static final String FILE_NAME = "empty-file-of-type"; + static final String UNKNOWN_FILE_EXTENSION = ".unknown-file-extension"; + static final Properties SUPPORTED_MIME_TYPES = new Properties(); + static final Set UNSUPPORTED_FILE_EXTENSIONS = new HashSet<>(); + static List supportedFileExtensions; + static Path root; + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() throws Exception { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + getSupportedMimeTypes(SUPPORTED_MIME_TYPES); + supportedFileExtensions = getFileExtensions(SUPPORTED_MIME_TYPES); + root = createFileTreeFromMimeTypes(SUPPORTED_MIME_TYPES); + } + + public static Properties getSupportedMimeTypes(Properties properties) throws IOException { + properties.load(MimeTable.class.getResourceAsStream("content-types.properties")); + return properties; + } + + private static List getFileExtensions(Properties input) { + return new ArrayList<>(getMimeTypesPerFileExtension(input).keySet()); + } + + private static Map getMimeTypesPerFileExtension(Properties input) { + return input + .entrySet() + .stream() + .filter(entry -> ((String)entry.getValue()).contains("file_extensions")) + .flatMap(entry -> + Arrays.stream( + ((String)deserialize((String) entry.getValue(), ";") + .get("file_extensions")).split(",")) + .map(extension -> + Map.entry(extension, entry.getKey().toString()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static Path createFileTreeFromMimeTypes(Properties properties) + throws IOException { + final Path root = Files.createDirectory(CWD.resolve(ServerMimeTypesResolutionTest.class.getSimpleName())); + for (String extension : supportedFileExtensions) { + Files.createFile(root.resolve(toFileName(extension))); + } + Files.createFile(root.resolve(toFileName(UNKNOWN_FILE_EXTENSION))); + return root; + } + + private static String toFileName(String extension) { + return "%s%s".formatted(FILE_NAME, extension); + } + + protected static Properties deserialize(String serialized, String delimiter) { + try { + Properties properties = new Properties(); + properties.load( + new StringReader( + Optional.ofNullable(delimiter) + .map(d -> serialized.replaceAll(delimiter, System.lineSeparator())) + .orElse(serialized) + ) + ); + return properties; + } catch (IOException exception) { + exception.printStackTrace(); + throw new RuntimeException(("error while deserializing string %s " + + "to properties").formatted(serialized), exception); + } + } + + @Test + public static void testMimeTypeHeaders() throws Exception { + final var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, SimpleFileServer.OutputLevel.VERBOSE); + server.start(); + try { + final var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + final Map mimeTypesPerFileExtension = getMimeTypesPerFileExtension(SUPPORTED_MIME_TYPES); + for (String extension : supportedFileExtensions) { + final String expectedMimeType = mimeTypesPerFileExtension.get(extension); + execute(server, client, extension, expectedMimeType); + } + execute(server, client, UNKNOWN_FILE_EXTENSION,"application/octet-stream"); + } finally { + server.stop(0); + } + } + + private static void execute(HttpServer server, + HttpClient client, + String extension, + String expectedMimeType) + throws IOException, InterruptedException { + final var uri = uri(server, toFileName(extension)); + final var request = HttpRequest.newBuilder(uri).build(); + final var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(),expectedMimeType); + } + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + @DataProvider + public static Object[][] commonExtensions() { + Set extensions = Set.of(".aac", ".abw", ".arc", ".avi", ".azw", ".bin", ".bmp", ".bz", + ".bz2", ".csh", ".css", ".csv", ".doc", ".docx",".eot", ".epub", ".gz", ".gif", ".htm", ".html", ".ico", + ".ics", ".jar", ".jpeg", ".jpg", ".js", ".json", ".jsonld", ".mid", ".midi", ".mjs", ".mp3", ".cda", + ".mp4", ".mpeg", ".mpkg", ".odp", ".ods", ".odt", ".oga", ".ogv", ".ogx", ".opus", ".otf", ".png", + ".pdf", ".php", ".ppt", ".pptx", ".rar", ".rtf", ".sh", ".svg", ".swf", ".tar", ".tif", ".tiff", ".ts", + ".ttf", ".txt", ".vsd", ".wav", ".weba", ".webm", ".webp", ".woff", ".woff2", ".xhtml", ".xls", ".xlsx", + ".xml", ".xul", ".zip", ".3gp", ".3g2", ".7z"); + return extensions.stream().map(e -> new Object[]{e}).toArray(Object[][]::new); + } + + /** + * This is a one-off test to check which common file extensions are + * currently supported by the system-wide mime table returned by + * {@linkplain java.net.FileNameMap#getContentTypeFor(String) getContentTypeFor}. + * + * Source common mime types: + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + */ +// @Test(dataProvider = "commonExtensions") + public static void testCommonExtensions(String extension) { + var contains = supportedFileExtensions.contains(extension); + if (!contains) UNSUPPORTED_FILE_EXTENSIONS.add(extension); + assertTrue(contains, "expecting %s to be present".formatted(extension)); + } + +// @AfterTest + public static void printUnsupportedFileExtensions() { + System.out.println("Unsupported file extensions: " + UNSUPPORTED_FILE_EXTENSIONS.size()); + UNSUPPORTED_FILE_EXTENSIONS.forEach(System.out::println); + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/SimpleFileServerTest.java b/test/jdk/com/sun/net/httpserver/simpleserver/SimpleFileServerTest.java new file mode 100644 index 00000000000..440e106c682 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/SimpleFileServerTest.java @@ -0,0 +1,703 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Tests for SimpleFileServer + * @library /test/lib + * @build jdk.test.lib.Platform jdk.test.lib.net.URIBuilder + * @run testng/othervm SimpleFileServerTest + */ + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.Platform; +import jdk.test.lib.net.URIBuilder; +import jdk.test.lib.util.FileUtils; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE; +import static org.testng.Assert.*; + +public class SimpleFileServerTest { + + static final Class NPE = NullPointerException.class; + static final Class IAE = IllegalArgumentException.class; + static final Class UIOE = UncheckedIOException.class; + + static final Path CWD = Path.of(".").toAbsolutePath(); + static final Path TEST_DIR = CWD.resolve("SimpleFileServerTest"); + + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = true; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + @BeforeTest + public void setup() throws IOException { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + Files.createDirectories(TEST_DIR); + } + + @Test + public void testFileGET() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testFileGET")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(file); + var expectedLength = Long.toString(Files.size(file)); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), "some text"); + assertEquals(response.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + } finally { + server.stop(0); + } + } + + @Test + public void testDirectoryGET() throws Exception { + var expectedBody = openHTML + """ +

    Directory listing for /

    + + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testDirectoryGET")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testFileHEAD() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testFileHEAD")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(file); + var expectedLength = Long.toString(Files.size(file)); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @Test + public void testDirectoryHEAD() throws Exception { + var expectedLength = Integer.toString( + (openHTML + """ +

    Directory listing for /

    + + """ + closeHTML).getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testDirectoryHEAD")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @DataProvider + public Object[][] indexFiles() { + var fileContent = openHTML + """ +

    This is an index file

    + """ + closeHTML; + var dirListing = openHTML + """ +

    Directory listing for /

    +
      +
    + """ + closeHTML; + return new Object[][] { + {"1", "index.html", "text/html", "116", fileContent, true}, + {"2", "index.htm", "text/html", "116", fileContent, true}, + {"3", "index.txt", "text/html; charset=UTF-8", "134", dirListing, false} + }; + } + + @Test(dataProvider = "indexFiles") + public void testDirectoryWithIndexGET(String id, + String filename, + String contentType, + String contentLength, + String expectedBody, + boolean serveIndexFile) throws Exception { + var root = Files.createDirectories(TEST_DIR.resolve("testDirectoryWithIndexGET"+id)); + var lastModified = getLastModified(root); + if (serveIndexFile) { + var file = Files.writeString(root.resolve(filename), expectedBody, CREATE); + lastModified = getLastModified(file); + } + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), contentType); + assertEquals(response.headers().firstValue("content-length").get(), contentLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + if (serveIndexFile) { + Files.delete(root.resolve(filename)); + } + } + } + + @Test + public void testNotReadableFileGET() throws Exception { + if (!Platform.isWindows()) { // not applicable on Windows + var expectedBody = openHTML + """ +

    File not found

    +

    /aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testNotReadableFileGET")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + + file.toFile().setReadable(false, false); + assert !Files.isReadable(file); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + file.toFile().setReadable(true, false); + } + } + } + + @Test + public void testNotReadableSegmentGET() throws Exception { + if (!Platform.isWindows()) { // not applicable on Windows + var expectedBody = openHTML + """ +

    File not found

    +

    /dir/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testNotReadableSegmentGET")); + var dir = Files.createDirectory(root.resolve("dir")); + var file = Files.writeString(dir.resolve("aFile.txt"), "some text", CREATE); + + dir.toFile().setReadable(false, false); + assert !Files.isReadable(dir); + assert Files.isReadable(file); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "dir/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + dir.toFile().setReadable(true, false); + } + } + } + + @Test + public void testInvalidRequestURIGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /aFile?#.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testInvalidRequestURIGET")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile?#.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testNotFoundGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testNotFoundGET")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testNotFoundHEAD() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testNotFoundHEAD")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + } + } + + @Test + public void testSymlinkGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /symlink

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testSymlinkGET")); + var symlink = root.resolve("symlink"); + var target = Files.writeString(root.resolve("target.txt"), "some text", CREATE); + Files.createSymbolicLink(symlink, target); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "symlink")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testSymlinkSegmentGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /symlink/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = Files.createDirectory(TEST_DIR.resolve("testSymlinkSegmentGET")); + var symlink = root.resolve("symlink"); + var target = Files.createDirectory(root.resolve("target")); + Files.writeString(target.resolve("aFile.txt"), "some text", CREATE); + Files.createSymbolicLink(symlink, target); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "symlink/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testHiddenFileGET() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testHiddenFileGET")); + var file = createHiddenFile(root); + var fileName = file.getFileName().toString(); + var expectedBody = openHTML + """ +

    File not found

    +

    /""" + fileName + + """ +

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, fileName)).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + @Test + public void testHiddenSegmentGET() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testHiddenSegmentGET")); + var file = createFileInHiddenDirectory(root); + var expectedBody = openHTML + """ +

    File not found

    +

    /.hiddenDirectory/aFile.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, ".hiddenDirectory/aFile.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + } + } + + private Path createHiddenFile(Path root) throws IOException { + Path file; + if (Platform.isWindows()) { + file = Files.createFile(root.resolve("aFile.txt")); + Files.setAttribute(file, "dos:hidden", true, LinkOption.NOFOLLOW_LINKS); + } else { + file = Files.writeString(root.resolve(".aFile.txt"), "some text", CREATE); + } + assertTrue(Files.isHidden(file)); + return file; + } + + private Path createFileInHiddenDirectory(Path root) throws IOException { + Path dir; + Path file; + if (Platform.isWindows()) { + dir = Files.createDirectory(root.resolve("hiddenDirectory")); + Files.setAttribute(dir, "dos:hidden", true, LinkOption.NOFOLLOW_LINKS); + } else { + dir = Files.createDirectory(root.resolve(".hiddenDirectory")); + } + file = Files.writeString(dir.resolve("aFile.txt"), "some text", CREATE); + assertTrue(Files.isHidden(dir)); + assertFalse(Files.isHidden(file)); + return file; + } + + @Test + public void testMovedPermanently() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testMovedPermanently")); + Files.createDirectory(root.resolve("aDirectory")); + var expectedBody = openHTML + """ +

    Directory listing for /aDirectory/

    +
      +
    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + { + var client = HttpClient.newBuilder().proxy(NO_PROXY) + .followRedirects(HttpClient.Redirect.NEVER).build(); + var uri = uri(server, "aDirectory"); + var request = HttpRequest.newBuilder(uri).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 301); + assertEquals(response.headers().firstValue("content-length").get(), "0"); + assertEquals(response.headers().firstValue("location").get(), "/aDirectory/"); + + // tests that query component is preserved during redirect + var uri2 = uri(server, "aDirectory", "query"); + var req2 = HttpRequest.newBuilder(uri2).build(); + var res2 = client.send(req2, BodyHandlers.ofString()); + assertEquals(res2.statusCode(), 301); + assertEquals(res2.headers().firstValue("content-length").get(), "0"); + assertEquals(res2.headers().firstValue("location").get(), "/aDirectory/?query"); + } + + { // tests that redirect to returned relative URI works + var client = HttpClient.newBuilder().proxy(NO_PROXY) + .followRedirects(HttpClient.Redirect.ALWAYS).build(); + var uri = uri(server, "aDirectory"); + var request = HttpRequest.newBuilder(uri).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.body(), expectedBody); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + } + } finally { + server.stop(0); + } + } + + @Test + public void testNull() { + final var addr = InetSocketAddress.createUnresolved("foo", 8080); + final var path = Path.of("/tmp"); + final var levl = OutputLevel.INFO; + assertThrows(NPE, () -> SimpleFileServer.createFileServer(null, null, null)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(null, null, levl)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(null, path, null)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(null, path, levl)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(addr, null, null)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(addr, null, levl)); + assertThrows(NPE, () -> SimpleFileServer.createFileServer(addr, path, null)); + + assertThrows(NPE, () -> SimpleFileServer.createFileHandler(null)); + + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(null, null)); + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(null, levl)); + assertThrows(NPE, () -> SimpleFileServer.createOutputFilter(System.out, null)); + } + + @Test + public void testInitialSlashContext() { + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, TEST_DIR, OutputLevel.INFO); + server.removeContext("/"); // throws if no context + server.stop(0); + } + + @Test + public void testBound() { + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, TEST_DIR, OutputLevel.INFO); + var boundAddr = server.getAddress(); + server.stop(0); + assertTrue(boundAddr.getAddress() != null); + assertTrue(boundAddr.getPort() > 0); + } + + @Test + public void testIllegalPath() throws Exception { + var addr = LOOPBACK_ADDR; + { // not absolute + Path p = Path.of("."); + assert Files.isDirectory(p); + assert Files.exists(p); + assert !p.isAbsolute(); + var iae = expectThrows(IAE, () -> SimpleFileServer.createFileServer(addr, p, OutputLevel.INFO)); + assertTrue(iae.getMessage().contains("is not absolute")); + } + { // not a directory + Path p = Files.createFile(TEST_DIR.resolve("aFile")); + assert !Files.isDirectory(p); + var iae = expectThrows(IAE, () -> SimpleFileServer.createFileServer(addr, p, OutputLevel.INFO)); + assertTrue(iae.getMessage().contains("not a directory")); + } + { // does not exist + Path p = TEST_DIR.resolve("doesNotExist"); + assert !Files.exists(p); + var iae = expectThrows(IAE, () -> SimpleFileServer.createFileServer(addr, p, OutputLevel.INFO)); + assertTrue(iae.getMessage().contains("does not exist")); + } + { // not readable + if (!Platform.isWindows()) { // not applicable on Windows + Path p = Files.createDirectory(TEST_DIR.resolve("aDir")); + p.toFile().setReadable(false, false); + assert !Files.isReadable(p); + try { + var iae = expectThrows(IAE, () -> SimpleFileServer.createFileServer(addr, p, OutputLevel.INFO)); + assertTrue(iae.getMessage().contains("not readable")); + } finally { + p.toFile().setReadable(true, false); + } + } + } + } + + @Test + public void testUncheckedIOException() { + var addr = InetSocketAddress.createUnresolved("foo", 8080); + assertThrows(UIOE, () -> SimpleFileServer.createFileServer(addr, TEST_DIR, OutputLevel.INFO)); + assertThrows(UIOE, () -> SimpleFileServer.createFileServer(addr, TEST_DIR, OutputLevel.VERBOSE)); + } + + @Test + public void testXss() throws Exception { + var root = Files.createDirectory(TEST_DIR.resolve("testXss")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "beginDelim%3C%3EEndDelim")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertTrue(response.body().contains("beginDelim%3C%3EEndDelim")); + assertTrue(response.body().contains("File not found")); + } finally { + server.stop(0); + } + } + + @AfterTest + public void teardown() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + } + + static final String openHTML = """ + + + + + + + """; + + static final String closeHTML = """ + + + """; + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + static URI uri(HttpServer server, String path, String query) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .query(query) + .buildUnchecked(); + } + + static String getLastModified(Path path) throws IOException { + return Files.getLastModifiedTime(path).toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } +} diff --git a/test/jdk/com/sun/net/httpserver/simpleserver/StressDirListings.java b/test/jdk/com/sun/net/httpserver/simpleserver/StressDirListings.java new file mode 100644 index 00000000000..0ef6904e13d --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/simpleserver/StressDirListings.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2021, 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. + * + * 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. + */ + +/* + * @test + * @summary Test to stress directory listings + * @library /test/lib + * @run testng/othervm StressDirListings + */ + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Path; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.SimpleFileServer; +import com.sun.net.httpserver.SimpleFileServer.OutputLevel; +import jdk.test.lib.net.URIBuilder; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; +import static java.lang.System.out; +import static java.net.http.HttpClient.Builder.NO_PROXY; +import static org.testng.Assert.assertEquals; + +public class StressDirListings { + + static final Path CWD = Path.of(".").toAbsolutePath(); + static final InetSocketAddress LOOPBACK_ADDR = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + static final boolean ENABLE_LOGGING = false; + static final Logger LOGGER = Logger.getLogger("com.sun.net.httpserver"); + + HttpServer simpleFileServer; + + @BeforeTest + public void setup() throws IOException { + if (ENABLE_LOGGING) { + ConsoleHandler ch = new ConsoleHandler(); + LOGGER.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + LOGGER.addHandler(ch); + } + simpleFileServer = SimpleFileServer.createFileServer(LOOPBACK_ADDR, CWD, OutputLevel.VERBOSE); + simpleFileServer.start(); + } + + @AfterTest + public void teardown() { + simpleFileServer.stop(0); + } + + // Enough to trigger FileSystemException: Too many open files (machine dependent) + static final int TIMES = 11_000; + + /** + * Issues a large number of identical GET requests sequentially, each of + * which returns the root directory listing. An IOException will be thrown + * if the server does not issue a valid reply, e.g. the server logic that + * enumerates the directory files fails to close the stream from Files::list. + */ + @Test + public void testDirListings() throws Exception { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(simpleFileServer)).build(); + for (int i=0; iDirectory listing for / +
      +
    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createZipFs(TEST_DIR.resolve("testDirectoryGET.zip")); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testDirectoryGET.zip")); + } + } + + @Test + public void testFileHEAD() throws Exception { + var root = createZipFs(TEST_DIR.resolve("testFileHEAD.zip")); + var file = Files.writeString(root.resolve("aFile.txt"), "some text", CREATE); + var lastModified = getLastModified(file); + var expectedLength = Long.toString(Files.size(file)); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile.txt")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/plain"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testFileHEAD.zip")); + } + } + + @Test + public void testDirectoryHEAD() throws Exception { + var expectedLength = Integer.toString( + (openHTML + """ +

    Directory listing for /

    +
      +
    + """ + closeHTML).getBytes(UTF_8).length); + var root = createZipFs(TEST_DIR.resolve("testDirectoryHEAD.zip")); + var lastModified = getLastModified(root); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), "text/html; charset=UTF-8"); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testDirectoryHEAD.zip")); + } + } + + @DataProvider + public Object[][] indexFiles() { + var fileContent = openHTML + """ +

    This is an index file

    + """ + closeHTML; + var dirListing = openHTML + """ +

    Directory listing for /

    +
      +
    + """ + closeHTML; + return new Object[][] { + {"1", "index.html", "text/html", "116", fileContent, true}, + {"2", "index.htm", "text/html", "116", fileContent, true}, + {"3", "index.txt", "text/html; charset=UTF-8", "134", dirListing, false} + }; + } + + @Test(dataProvider = "indexFiles") + public void testDirectoryWithIndexGET(String id, + String filename, + String contentType, + String contentLength, + String expectedBody, + boolean serveIndexFile) throws Exception { + var root = createZipFs(TEST_DIR.resolve("testDirectoryWithIndexGET"+id+".zip")); + var lastModified = getLastModified(root); + if (serveIndexFile) { + var file = Files.writeString(root.resolve(filename), expectedBody, CREATE); + lastModified = getLastModified(file); + } + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 200); + assertEquals(response.headers().firstValue("content-type").get(), contentType); + assertEquals(response.headers().firstValue("content-length").get(), contentLength); + assertEquals(response.headers().firstValue("last-modified").get(), lastModified); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + if (serveIndexFile) { + Files.delete(root.resolve(filename)); + } + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testDirectoryWithIndexGET"+id+".zip")); + } + } + + // no testNotReadableGET() - Zip file system does not enforce access permissions + // no testSymlinkGET() - Zip file system does not support symlink creation + // no testHiddenFileGET() - Zip file system does not support hidden files + + @Test + public void testInvalidRequestURIGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /aFile?#.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createZipFs(TEST_DIR.resolve("testInvalidRequestURIGET.zip")); + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "aFile?#.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testInvalidRequestURIGET.zip")); + } + } + + @Test + public void testNotFoundGET() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createZipFs(TEST_DIR.resolve("testNotFoundGET.zip")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), expectedBody); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testNotFoundGET.zip")); + } + } + + @Test + public void testNotFoundHEAD() throws Exception { + var expectedBody = openHTML + """ +

    File not found

    +

    /doesNotExist.txt

    + """ + + closeHTML; + var expectedLength = Integer.toString(expectedBody.getBytes(UTF_8).length); + var root = createZipFs(TEST_DIR.resolve("testNotFoundHEAD.zip")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "doesNotExist.txt")) + .method("HEAD", BodyPublishers.noBody()).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertEquals(response.headers().firstValue("content-length").get(), expectedLength); + assertEquals(response.body(), ""); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testNotFoundHEAD.zip")); + } + } + + @Test + public void testMovedPermanently() throws Exception { + var root = createZipFs(TEST_DIR.resolve("testMovedPermanently.zip")); + Files.createDirectory(root.resolve("aDirectory")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var uri = uri(server, "aDirectory"); + var request = HttpRequest.newBuilder(uri).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 301); + assertEquals(response.headers().firstValue("content-length").get(), "0"); + assertEquals(response.headers().firstValue("location").get(), "/aDirectory/"); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testMovedPermanently.zip")); + } + } + + static Path createZipFs(Path zipFile) throws Exception { + var p = zipFile.toAbsolutePath().normalize(); + var fs = FileSystems.newFileSystem(p, Map.of("create", "true")); + assert fs != FileSystems.getDefault(); + return fs.getPath("/"); // root entry + } + + @Test + public void testXss() throws Exception { + var root = createZipFs(TEST_DIR.resolve("testXss.zip")); + + var server = SimpleFileServer.createFileServer(LOOPBACK_ADDR, root, OutputLevel.VERBOSE); + server.start(); + try { + var client = HttpClient.newBuilder().proxy(NO_PROXY).build(); + var request = HttpRequest.newBuilder(uri(server, "beginDelim%3C%3EEndDelim")).build(); + var response = client.send(request, BodyHandlers.ofString()); + assertEquals(response.statusCode(), 404); + assertTrue(response.body().contains("beginDelim%3C%3EEndDelim")); + assertTrue(response.body().contains("File not found")); + } finally { + server.stop(0); + root.getFileSystem().close(); + Files.deleteIfExists(TEST_DIR.resolve("testXss.zip")); + } + } + + @AfterTest + public void teardown() throws IOException { + if (Files.exists(TEST_DIR)) { + FileUtils.deleteFileTreeWithRetry(TEST_DIR); + } + } + + static final String openHTML = """ + + + + + + + """; + + static final String closeHTML = """ + + + """; + + static URI uri(HttpServer server, String path) { + return URIBuilder.newBuilder() + .host("localhost") + .port(server.getAddress().getPort()) + .scheme("http") + .path("/" + path) + .buildUnchecked(); + } + + static String getLastModified(Path path) throws IOException { + return Files.getLastModifiedTime(path).toInstant().atZone(ZoneId.of("GMT")) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } +}