diff --git a/VERSION.txt b/VERSION.txt index 259865a698a..0c63546b804 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -414,8 +414,6 @@ jetty-10.0.18 - 26 October 2023 + 10537 HTTP/3: Incomplete Data Transfer When Used with Spring Boot WebFlux + 10696 jetty.sh doesn't work with JETTY_USER in Jetty 10.0.17 thru Jetty 12.0.2 - + 10669 Provide ability to defer initial deployment of webapps until after - Server has started + 10705 Creating a `HTTP3ServerConnector` with a `SslContextFactory` that has a non-null `SSLContext` makes the server fail to start with an unclear error message diff --git a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpDestination.java b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpDestination.java index 8ba6035f2e5..3758bd2c597 100644 --- a/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpDestination.java +++ b/jetty-core/jetty-client/src/main/java/org/eclipse/jetty/client/transport/HttpDestination.java @@ -188,7 +188,7 @@ public class HttpDestination extends ContainerLifeCycle implements Destination, protected void doStop() throws Exception { requestTimeouts.destroy(); - abort(new AsynchronousCloseException()); + abortExchanges(new AsynchronousCloseException()); Sweeper connectionPoolSweeper = client.getBean(Sweeper.class); if (connectionPoolSweeper != null && connectionPool instanceof Sweeper.Sweepable) connectionPoolSweeper.remove((Sweeper.Sweepable)connectionPool); @@ -294,7 +294,7 @@ public class HttpDestination extends ContainerLifeCycle implements Destination, @Override public void failed(Throwable x) { - abort(x); + abortExchanges(x); } @Override @@ -513,7 +513,7 @@ public class HttpDestination extends ContainerLifeCycle implements Destination, * * @param cause the abort cause */ - public void abort(Throwable cause) + private void abortExchanges(Throwable cause) { // Copy the queue of exchanges and fail only those that are queued at this moment. // The application may queue another request from the failure/complete listener diff --git a/jetty-core/jetty-deploy/src/test/java/org/eclipse/jetty/deploy/providers/ContextProviderStartupTest.java b/jetty-core/jetty-deploy/src/test/java/org/eclipse/jetty/deploy/providers/ContextProviderStartupTest.java index c9072312e46..aa8acefca23 100644 --- a/jetty-core/jetty-deploy/src/test/java/org/eclipse/jetty/deploy/providers/ContextProviderStartupTest.java +++ b/jetty-core/jetty-deploy/src/test/java/org/eclipse/jetty/deploy/providers/ContextProviderStartupTest.java @@ -32,10 +32,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import static org.awaitility.Awaitility.await; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/EtagUtils.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/EtagUtils.java index 2effc40960c..9221cef3999 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/EtagUtils.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/EtagUtils.java @@ -66,13 +66,9 @@ public final class EtagUtils */ public static HttpField createWeakEtagField(Resource resource, String etagSuffix) { - Path path = resource.getPath(); - if (path == null) - return null; - - String etagValue = EtagUtils.computeWeakEtag(path, etagSuffix); - if (etagValue != null) - return new PreEncodedHttpField(HttpHeader.ETAG, etagValue); + String etag = EtagUtils.computeWeakEtag(resource, etagSuffix); + if (etag != null) + return new PreEncodedHttpField(HttpHeader.ETAG, etag); return null; } @@ -213,6 +209,9 @@ public final class EtagUtils */ public static String rewriteWithSuffix(String etag, String newSuffix) { + if (etag == null) + return null; + StringBuilder ret = new StringBuilder(); boolean weak = etag.startsWith("W/"); int start = 0; diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java new file mode 100644 index 00000000000..7373b3fba0e --- /dev/null +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartConfig.java @@ -0,0 +1,220 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.http; + +import java.nio.file.Path; + +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.util.Attributes; + +import static org.eclipse.jetty.http.ComplianceViolation.Listener.NOOP; + +/** + * The Configuration needed to parse multipart/form-data. + * @see MultiPartFormData#from(Content.Source, Attributes, String, MultiPartConfig) + */ +public class MultiPartConfig +{ + private static final int DEFAULT_MAX_PARTS = 100; + private static final int DEFAULT_MAX_SIZE = 50 * 1024 * 1024; + private static final int DEFAULT_MAX_PART_SIZE = 10 * 1024 * 1024; + private static final int DEFAULT_MAX_MEMORY_PART_SIZE = 1024; + private static final int DEFAULT_MAX_HEADERS_SIZE = 8 * 1024; + private static final boolean DEFAULT_USE_FILES_FOR_PARTS_WITHOUT_FILE_NAME = true; + + public static class Builder + { + private Path _location; + private Integer _maxParts; + private Long _maxSize; + private Long _maxPartSize; + private Long _maxMemoryPartSize; + private Integer _maxHeadersSize; + private Boolean _useFilesForPartsWithoutFileName; + private MultiPartCompliance _complianceMode; + private ComplianceViolation.Listener _violationListener; + + public Builder() + { + } + + /** + * @param location the directory where parts will be saved as files. + */ + public Builder location(Path location) + { + _location = location; + return this; + } + + /** + * @param maxParts the maximum number of parts that can be parsed from the multipart content, or -1 for unlimited. + */ + public Builder maxParts(int maxParts) + { + _maxParts = maxParts; + return this; + } + + /** + * @return the maximum size in bytes of the whole multipart content, or -1 for unlimited. + */ + public Builder maxSize(long maxSize) + { + _maxSize = maxSize; + return this; + } + + /** + * @return the maximum part size in bytes, or -1 for unlimited. + */ + public Builder maxPartSize(long maxPartSize) + { + _maxPartSize = maxPartSize; + return this; + } + + /** + *

Sets the maximum size of a part in memory, after which it will be written as a file.

+ *

Use value {@code 0} to always write the part to disk.

+ *

Use value {@code -1} to never write the part to disk.

+ * + * @param maxMemoryPartSize the maximum part size which can be held in memory. + */ + public Builder maxMemoryPartSize(long maxMemoryPartSize) + { + _maxMemoryPartSize = maxMemoryPartSize; + return this; + } + + /** + * @param maxHeadersSize the max length of a {@link MultiPart.Part} headers, in bytes, or -1 for unlimited length. + */ + public Builder maxHeadersSize(int maxHeadersSize) + { + _maxHeadersSize = maxHeadersSize; + return this; + } + + /** + * @param useFilesForPartsWithoutFileName whether parts without a fileName may be stored as files. + */ + public Builder useFilesForPartsWithoutFileName(Boolean useFilesForPartsWithoutFileName) + { + _useFilesForPartsWithoutFileName = useFilesForPartsWithoutFileName; + return this; + } + + /** + * @param complianceMode the compliance mode. + */ + public Builder complianceMode(MultiPartCompliance complianceMode) + { + _complianceMode = complianceMode; + return this; + } + + /** + * @param violationListener the compliance violation listener. + */ + public Builder violationListener(ComplianceViolation.Listener violationListener) + { + _violationListener = violationListener; + return this; + } + + public MultiPartConfig build() + { + return new MultiPartConfig(_location, + _maxParts == null ? DEFAULT_MAX_PARTS : _maxParts, + _maxSize == null ? DEFAULT_MAX_SIZE : _maxSize, + _maxPartSize == null ? DEFAULT_MAX_PART_SIZE : _maxPartSize, + _maxMemoryPartSize == null ? DEFAULT_MAX_MEMORY_PART_SIZE : _maxMemoryPartSize, + _maxHeadersSize == null ? DEFAULT_MAX_HEADERS_SIZE : _maxHeadersSize, + _useFilesForPartsWithoutFileName == null ? DEFAULT_USE_FILES_FOR_PARTS_WITHOUT_FILE_NAME : _useFilesForPartsWithoutFileName, + _complianceMode == null ? MultiPartCompliance.RFC7578 : _complianceMode, + _violationListener == null ? NOOP : _violationListener); + } + } + + private final Path _location; + private final long _maxMemoryPartSize; + private final long _maxPartSize; + private final long _maxSize; + private final int _maxParts; + private final int _maxHeadersSize; + private final boolean _useFilesForPartsWithoutFileName; + private final MultiPartCompliance _compliance; + private final ComplianceViolation.Listener _listener; + + private MultiPartConfig(Path location, int maxParts, long maxSize, long maxPartSize, long maxMemoryPartSize, + int maxHeadersSize, boolean useFilesForPartsWithoutFileName, + MultiPartCompliance compliance, ComplianceViolation.Listener listener) + { + this._location = location; + this._maxParts = maxParts; + this._maxSize = maxSize; + this._maxPartSize = maxPartSize; + this._maxMemoryPartSize = maxMemoryPartSize; + this._maxHeadersSize = maxHeadersSize; + this._useFilesForPartsWithoutFileName = useFilesForPartsWithoutFileName; + this._compliance = compliance; + this._listener = listener; + } + + public Path getLocation() + { + return _location; + } + + public int getMaxParts() + { + return _maxParts; + } + + public long getMaxSize() + { + return _maxSize; + } + + public long getMaxPartSize() + { + return _maxPartSize; + } + + public long getMaxMemoryPartSize() + { + return _maxMemoryPartSize; + } + + public int getMaxHeadersSize() + { + return _maxHeadersSize; + } + + public boolean isUseFilesForPartsWithoutFileName() + { + return _useFilesForPartsWithoutFileName; + } + + public MultiPartCompliance getMultiPartCompliance() + { + return _compliance; + } + + public ComplianceViolation.Listener getViolationListener() + { + return _listener; + } +} diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java index e1cae2531fa..69293958022 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java @@ -79,8 +79,45 @@ public class MultiPartFormData } /** - * Returns {@code multipart/form-data} parts using {@link MultiPartCompliance#RFC7578}. + * Returns {@code multipart/form-data} parts using the given {@link Content.Source} and {@link MultiPartConfig}. + * + * @param content the source of the multipart content. + * @param attributes the attributes where the futureParts are tracked. + * @param contentType the value of the {@link HttpHeader#CONTENT_TYPE} header. + * @param config the multipart configuration. + * @return the future parts */ + public static CompletableFuture from(Content.Source content, Attributes attributes, String contentType, MultiPartConfig config) + { + // Look for an existing future (we use the future here rather than the parts as it can remember any failure). + CompletableFuture futureParts = MultiPartFormData.get(attributes); + if (futureParts == null) + { + // No existing parts, so we need to try to read them ourselves + + // Are we the right content type to produce our own parts? + if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.getValueParameters(contentType, null))) + return CompletableFuture.failedFuture(new IllegalStateException("Not multipart Content-Type")); + + // Do we have a boundary? + String boundary = MultiPart.extractBoundary(contentType); + if (boundary == null) + return CompletableFuture.failedFuture(new IllegalStateException("No multipart boundary parameter in Content-Type")); + + Parser parser = new Parser(boundary); + parser.configure(config); + futureParts = parser.parse(content); + attributes.setAttribute(MultiPartFormData.class.getName(), futureParts); + return futureParts; + } + return futureParts; + } + + /** + * Returns {@code multipart/form-data} parts using {@link MultiPartCompliance#RFC7578}. + * @deprecated use {@link #from(Content.Source, Attributes, String, MultiPartConfig)}. + */ + @Deprecated public static CompletableFuture from(Attributes attributes, String boundary, Function> parse) { return from(attributes, MultiPartCompliance.RFC7578, ComplianceViolation.Listener.NOOP, boundary, parse); @@ -95,11 +132,12 @@ public class MultiPartFormData * @param boundary the boundary for the {@code multipart/form-data} parts * @param parse the parser completable future * @return the future parts + * @deprecated use {@link #from(Content.Source, Attributes, String, MultiPartConfig)}. */ + @Deprecated public static CompletableFuture from(Attributes attributes, MultiPartCompliance compliance, ComplianceViolation.Listener listener, String boundary, Function> parse) { - @SuppressWarnings("unchecked") - CompletableFuture futureParts = (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + CompletableFuture futureParts = get(attributes); if (futureParts == null) { futureParts = parse.apply(new Parser(boundary, compliance, listener)); @@ -108,6 +146,18 @@ public class MultiPartFormData return futureParts; } + /** + * Returns {@code multipart/form-data} parts if they have already been created. + * + * @param attributes the attributes where the futureParts are tracked + * @return the future parts + */ + @SuppressWarnings("unchecked") + public static CompletableFuture get(Attributes attributes) + { + return (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + } + /** *

An ordered list of {@link MultiPart.Part}s that can * be accessed by index or by name, or iterated over.

@@ -219,9 +269,9 @@ public class MultiPartFormData { private final PartsListener listener = new PartsListener(); private final MultiPart.Parser parser; - private final MultiPartCompliance compliance; - private final ComplianceViolation.Listener complianceListener; - private boolean useFilesForPartsWithoutFileName; + private MultiPartCompliance compliance; + private ComplianceViolation.Listener complianceListener; + private boolean useFilesForPartsWithoutFileName = true; private Path filesDirectory; private long maxFileSize = -1; private long maxMemoryFileSize; @@ -234,6 +284,10 @@ public class MultiPartFormData this(boundary, MultiPartCompliance.RFC7578, ComplianceViolation.Listener.NOOP); } + /** + * @deprecated use {@link Parser#Parser(String)} with {@link #configure(MultiPartConfig)}. + */ + @Deprecated public Parser(String boundary, MultiPartCompliance multiPartCompliance, ComplianceViolation.Listener complianceViolationListener) { compliance = Objects.requireNonNull(multiPartCompliance); @@ -429,6 +483,23 @@ public class MultiPartFormData parser.setMaxParts(maxParts); } + /** + * Configure the Parser given a {@link MultiPartConfig} instance. + * @param config the configuration. + */ + public void configure(MultiPartConfig config) + { + parser.setMaxParts(config.getMaxParts()); + maxMemoryFileSize = config.getMaxMemoryPartSize(); + maxFileSize = config.getMaxPartSize(); + maxLength = config.getMaxSize(); + parser.setPartHeadersMaxLength(config.getMaxHeadersSize()); + useFilesForPartsWithoutFileName = config.isUseFilesForPartsWithoutFileName(); + filesDirectory = config.getLocation(); + complianceListener = config.getViolationListener(); + compliance = config.getMultiPartCompliance(); + } + // Only used for testing. int getPartsSize() { @@ -440,8 +511,7 @@ public class MultiPartFormData private final AutoLock lock = new AutoLock(); private final List parts = new ArrayList<>(); private final List partChunks = new ArrayList<>(); - private long fileSize; - private long memoryFileSize; + private long size; private Path filePath; private SeekableByteChannel fileChannel; private Throwable failure; @@ -450,22 +520,21 @@ public class MultiPartFormData public void onPartContent(Content.Chunk chunk) { ByteBuffer buffer = chunk.getByteBuffer(); + long maxPartSize = getMaxFileSize(); + size += buffer.remaining(); + if (maxPartSize >= 0 && size > maxPartSize) + { + onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxPartSize))); + return; + } + String fileName = getFileName(); if (fileName != null || isUseFilesForPartsWithoutFileName()) { - long maxFileSize = getMaxFileSize(); - fileSize += buffer.remaining(); - if (maxFileSize >= 0 && fileSize > maxFileSize) + long maxMemoryPartSize = getMaxMemoryFileSize(); + if (maxMemoryPartSize >= 0) { - onFailure(new IllegalStateException("max file size exceeded: %d".formatted(maxFileSize))); - return; - } - - long maxMemoryFileSize = getMaxMemoryFileSize(); - if (maxMemoryFileSize >= 0) - { - memoryFileSize += buffer.remaining(); - if (memoryFileSize > maxMemoryFileSize) + if (size > maxMemoryPartSize) { try { @@ -482,6 +551,11 @@ public class MultiPartFormData { write(c.getByteBuffer()); } + try (AutoLock ignored = lock.lock()) + { + this.partChunks.forEach(Content.Chunk::release); + this.partChunks.clear(); + } } write(buffer); if (chunk.isLast()) @@ -491,16 +565,23 @@ public class MultiPartFormData { onFailure(x); } - - try (AutoLock ignored = lock.lock()) - { - partChunks.forEach(Content.Chunk::release); - partChunks.clear(); - } return; } } } + else + { + long maxMemoryPartSize = getMaxMemoryFileSize(); + if (maxMemoryPartSize >= 0) + { + if (size > maxMemoryPartSize) + { + onFailure(new IllegalStateException("max memory file size exceeded: %d".formatted(maxMemoryPartSize))); + return; + } + } + } + // Retain the chunk because it is stored for later use. chunk.retain(); try (AutoLock ignored = lock.lock()) @@ -541,8 +622,7 @@ public class MultiPartFormData @Override public void onPart(String name, String fileName, HttpFields headers) { - fileSize = 0; - memoryFileSize = 0; + size = 0; try (AutoLock ignored = lock.lock()) { // Content-Transfer-Encoding is not a multi-valued field. diff --git a/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/MultiPartCaptureTest.java b/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/MultiPartCaptureTest.java index 7469cb2af51..171b9831261 100644 --- a/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/MultiPartCaptureTest.java +++ b/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/MultiPartCaptureTest.java @@ -58,7 +58,7 @@ public class MultiPartCaptureTest FS.ensureDirExists(tempDir); MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); - parser.setUseFilesForPartsWithoutFileName(false); + parser.setUseFilesForPartsWithoutFileName(true); parser.setFilesDirectory(tempDir); ByteBufferContentSource contentSource = new ByteBufferContentSource(formRequest.asByteBuffer()); MultiPartFormData.Parts parts = parser.parse(contentSource).get(); diff --git a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/internal/HTTP2Flusher.java b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/internal/HTTP2Flusher.java index ddff64b2cec..73e95969371 100644 --- a/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/internal/HTTP2Flusher.java +++ b/jetty-core/jetty-http2/jetty-http2-common/src/main/java/org/eclipse/jetty/http2/internal/HTTP2Flusher.java @@ -113,7 +113,7 @@ public class HTTP2Flusher extends IteratingCallback implements Dumpable { entries.offer(entry); if (LOG.isDebugEnabled()) - LOG.debug("Appended {}, entries={}", entry, entries.size()); + LOG.debug("Appended {}, entries={}, {}", entry, entries.size(), this); } } if (closed == null) @@ -132,7 +132,7 @@ public class HTTP2Flusher extends IteratingCallback implements Dumpable { list.forEach(entries::offer); if (LOG.isDebugEnabled()) - LOG.debug("Appended {}, entries={}", list, entries.size()); + LOG.debug("Appended {}, entries={} {}", list, entries.size(), this); } } if (closed == null) @@ -161,7 +161,7 @@ public class HTTP2Flusher extends IteratingCallback implements Dumpable protected Action process() throws Throwable { if (LOG.isDebugEnabled()) - LOG.debug("Flushing {}", session); + LOG.debug("process {} {}", session, this); try (AutoLock ignored = lock.lock()) { @@ -184,7 +184,7 @@ public class HTTP2Flusher extends IteratingCallback implements Dumpable if (pendingEntries.isEmpty()) { if (LOG.isDebugEnabled()) - LOG.debug("Flushed {}", session); + LOG.debug("Flushed {} {}", session, this); return Action.IDLE; } @@ -257,7 +257,7 @@ public class HTTP2Flusher extends IteratingCallback implements Dumpable if (LOG.isDebugEnabled()) LOG.debug("Failure generating {}", entry, failure); failed(failure); - return Action.SUCCEEDED; + return Action.SCHEDULED; } } diff --git a/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/MavenResource.java b/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/MavenResource.java index 42475de50fd..aefe9e1d134 100644 --- a/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/MavenResource.java +++ b/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/MavenResource.java @@ -217,4 +217,10 @@ public class MavenResource extends Resource return null; return _resource.resolve(subUriPath); } + + @Override + public String toString() + { + return "(Maven) " + _resource.toString(); + } } diff --git a/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/SelectiveJarResource.java b/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/SelectiveJarResource.java index 8e8c44f983e..22079cc47b1 100644 --- a/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/SelectiveJarResource.java +++ b/jetty-core/jetty-maven/src/main/java/org/eclipse/jetty/maven/SelectiveJarResource.java @@ -277,4 +277,10 @@ public class SelectiveJarResource extends Resource } } } + + @Override + public String toString() + { + return "(Selective Jar/Maven) " + _delegate.toString(); + } } diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java index c3ee715df20..00904781c0f 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java @@ -21,6 +21,7 @@ import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.Path; import java.security.Principal; import java.util.ArrayList; import java.util.List; @@ -34,6 +35,7 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; +import org.eclipse.jetty.http.ComplianceViolation; import org.eclipse.jetty.http.HttpCookie; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; @@ -41,6 +43,8 @@ import org.eclipse.jetty.http.HttpScheme; import org.eclipse.jetty.http.HttpURI; import org.eclipse.jetty.http.MetaData; import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPartCompliance; +import org.eclipse.jetty.http.MultiPartConfig; import org.eclipse.jetty.http.Trailers; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.internal.CompletionStreamWrapper; @@ -581,6 +585,36 @@ public interface Request extends Attributes, Content.Source return CookieCache.getCookies(request); } + /** + *

Get a {@link MultiPartConfig.Builder} given a {@link Request} and a location.

+ * + *

If the location is null this will extract the {@link Context} temp directory from the request. + * The {@code maxHeaderSize}, {@link MultiPartCompliance}, {@link ComplianceViolation.Listener} + * are also extracted from the request. Additional settings can be configured through the + * {@link MultiPartConfig.Builder} which is returned.

+ * + * @param request the request. + * @param location the temp directory location, or null to use the context default. + * @return a {@link MultiPartConfig} with settings extracted from the request. + */ + static MultiPartConfig.Builder getMultiPartConfig(Request request, Path location) + { + HttpChannel httpChannel = HttpChannel.from(request); + HttpConfiguration httpConfiguration = request.getConnectionMetaData().getHttpConfiguration(); + MultiPartCompliance multiPartCompliance = httpConfiguration.getMultiPartCompliance(); + ComplianceViolation.Listener complianceViolationListener = httpChannel.getComplianceViolationListener(); + int maxHeaderSize = httpConfiguration.getRequestHeaderSize(); + + if (location == null) + location = request.getContext().getTempDirectory().toPath(); + + return new MultiPartConfig.Builder() + .location(location) + .maxHeadersSize(maxHeaderSize) + .complianceMode(multiPartCompliance) + .violationListener(complianceViolationListener); + } + /** * Generate a proper "Location" header for redirects. * diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java index 84ef89a15e3..1a237c236ad 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java @@ -715,7 +715,14 @@ public class ContextHandler extends Handler.Wrapper implements Attributes, Alias if (!Resources.isReadable(baseResource)) throw new IllegalArgumentException("Base Resource is not valid: " + baseResource); if (baseResource.isAlias()) - LOG.warn("Base Resource should not be an alias"); + { + URI realUri = baseResource.getRealURI(); + if (realUri == null) + LOG.warn("Base Resource should not be an alias (100% of requests to context are subject to Security/Alias Checks): {}", baseResource); + else + LOG.warn("Base Resource should not be an alias (100% of requests to context are subject to Security/Alias Checks): {} points to {}", + baseResource, realUri.toASCIIString()); + } } _availability.set(Availability.STARTING); diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java index d10405241fd..f5650926ef6 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/ReadWriteFailuresTest.java @@ -202,7 +202,7 @@ public class ReadWriteFailuresTest POST / HTTP/1.1 Host: localhost Content-Length: 1 - + """; try (LocalConnector.LocalEndPoint endPoint = connector.executeRequest(request)) { @@ -234,7 +234,7 @@ public class ReadWriteFailuresTest POST / HTTP/1.1 Host: localhost Content-Length: %d - + %s """.formatted(content.length(), content); HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request, 5, TimeUnit.SECONDS)); diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EventsHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EventsHandlerTest.java index f61ef281838..0ecb573c398 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EventsHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/EventsHandlerTest.java @@ -17,6 +17,7 @@ import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -39,6 +40,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; public class EventsHandlerTest { @@ -117,6 +119,7 @@ public class EventsHandlerTest { AtomicReference beginNanoTime = new AtomicReference<>(); AtomicReference readyNanoTime = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); EventsHandler eventsHandler = new EventsHandler(new EchoHandler()) { @Override @@ -124,6 +127,7 @@ public class EventsHandlerTest { beginNanoTime.set(request.getBeginNanoTime()); readyNanoTime.set(request.getHeadersNanoTime()); + latch.countDown(); } }; startServer(eventsHandler); @@ -148,6 +152,7 @@ public class EventsHandlerTest String response = endPoint.getResponse(); assertThat(response, containsString("HTTP/1.1 200 OK")); + assertTrue(latch.await(5, TimeUnit.SECONDS)); assertThat(NanoTime.millisSince(beginNanoTime.get()), greaterThan(900L)); assertThat(NanoTime.millisSince(readyNanoTime.get()), greaterThan(450L)); } diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/MultiPartFormDataHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/MultiPartFormDataHandlerTest.java index 375642169d8..7233ff0ff2f 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/MultiPartFormDataHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/MultiPartFormDataHandlerTest.java @@ -78,8 +78,9 @@ public class MultiPartFormDataHandlerTest public boolean handle(Request request, Response response, Callback callback) { String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); - new MultiPartFormData.Parser(boundary) - .parse(request) + MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); + parser.setMaxMemoryFileSize(-1); + parser.parse(request) .whenComplete((parts, failure) -> { if (parts != null) @@ -128,8 +129,9 @@ public class MultiPartFormDataHandlerTest { String boundary = MultiPart.extractBoundary(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); - new MultiPartFormData.Parser(boundary) - .parse(request) + MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); + parser.setMaxMemoryFileSize(-1); + parser.parse(request) .whenComplete((parts, failure) -> { if (parts != null) diff --git a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/ExceptionUtil.java b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/ExceptionUtil.java index e677da1c6d2..6de5827e71c 100644 --- a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/ExceptionUtil.java +++ b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/ExceptionUtil.java @@ -303,58 +303,28 @@ public class ExceptionUtil return t1; } - public static void callAndThen(Throwable cause, Consumer first, Consumer second) + /** Call a method that handles a {@link Throwable}, catching and associating any exception that it throws. + * @param cause The {@link Throwable} to pass to the consumer + * @param consumer The handler of a {@link Throwable} + */ + public static void call(Throwable cause, Consumer consumer) { try { - first.accept(cause); + consumer.accept(cause); } catch (Throwable t) { - addSuppressedIfNotAssociated(cause, t); - } - finally - { - second.accept(cause); - } - } - - public static void callAndThen(Throwable cause, Consumer first, Runnable second) - { - try - { - first.accept(cause); - } - catch (Throwable t) - { - addSuppressedIfNotAssociated(cause, t); - } - finally - { - second.run(); - } - } - - public static void callAndThen(Runnable first, Runnable second) - { - try - { - first.run(); - } - catch (Throwable t) - { - // ignored - } - finally - { - second.run(); + ExceptionUtil.addSuppressedIfNotAssociated(t, cause); + throw t; } } /** - * Call a {@link Invocable.Callable} and handle failures - * @param callable The runnable to call - * @param failure The handling of failures + * Call a {@link Invocable.Callable} and handle any resulting failures + * @param callable The {@link org.eclipse.jetty.util.thread.Invocable.Callable} to call + * @param failure A handler of failures from the call + * @see #run(Runnable, Consumer) */ public static void call(Invocable.Callable callable, Consumer failure) { @@ -380,6 +350,7 @@ public class ExceptionUtil * Call a {@link Runnable} and handle failures * @param runnable The runnable to call * @param failure The handling of failures + * @see #call(Throwable, Consumer) */ public static void run(Runnable runnable, Consumer failure) { @@ -401,6 +372,71 @@ public class ExceptionUtil } } + /** + * Call a handler of {@link Throwable} and then always call another, suppressing any exceptions thrown. + * @param cause The {@link Throwable} to be passed to both consumers. + * @param call The first {@link Consumer} of {@link Throwable} to call. + * @param then The second {@link Consumer} of {@link Throwable} to call. + */ + public static void callAndThen(Throwable cause, Consumer call, Consumer then) + { + try + { + call.accept(cause); + } + catch (Throwable t) + { + addSuppressedIfNotAssociated(cause, t); + } + finally + { + then.accept(cause); + } + } + + /** + * Call a handler of {@link Throwable} and then always call a {@link Runnable}, suppressing any exceptions thrown. + * @param cause The {@link Throwable} to be passed to both consumers. + * @param call The {@link Consumer} of {@link Throwable} to call. + * @param then The {@link Runnable} to call. + */ + public static void callAndThen(Throwable cause, Consumer call, Runnable then) + { + try + { + call.accept(cause); + } + catch (Throwable t) + { + addSuppressedIfNotAssociated(cause, t); + } + finally + { + then.run(); + } + } + + /** + * Call a {@link Runnable} and then always call another, ignoring any exceptions thrown. + * @param call The first {@link Runnable} to call. + * @param then The second {@link Runnable} to call. + */ + public static void callAndThen(Runnable call, Runnable then) + { + try + { + call.run(); + } + catch (Throwable t) + { + // ignored + } + finally + { + then.run(); + } + } + /** *

Get from a {@link CompletableFuture} and convert any uncheck exceptions to {@link RuntimeException}.

* @param completableFuture The future to get from. diff --git a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/MemoryResource.java b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/MemoryResource.java index 31970e17cf1..5215218021c 100644 --- a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/MemoryResource.java +++ b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/MemoryResource.java @@ -155,6 +155,6 @@ public class MemoryResource extends Resource @Override public String toString() { - return getName(); + return "(Memory) " + _uri.toASCIIString(); } } diff --git a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java index d88930e1d6c..b5fc9657b26 100644 --- a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java +++ b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java @@ -460,4 +460,13 @@ public abstract class Resource implements Iterable } return false; } + + public String toString() + { + String str = getName(); + URI uri = getURI(); + if (uri != null) + str = getURI().toASCIIString(); + return "(" + this.getClass().getSimpleName() + ") " + str; + } } diff --git a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResourceFactory.java b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResourceFactory.java index f186bb23c72..61db423d822 100644 --- a/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResourceFactory.java +++ b/jetty-core/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResourceFactory.java @@ -308,7 +308,7 @@ public class URLResourceFactory implements ResourceFactory @Override public String toString() { - return String.format("URLResource@%X(%s)", this.uri.hashCode(), this.uri.toASCIIString()); + return "(URL) " + this.uri.toASCIIString(); } private static class InputStreamReference extends AtomicReference implements Runnable diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletMultiPartFormData.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletMultiPartFormData.java index 31ef0e5f1b8..b27b355b2cb 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletMultiPartFormData.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletMultiPartFormData.java @@ -26,12 +26,11 @@ import java.util.concurrent.CompletableFuture; import jakarta.servlet.MultipartConfigElement; import jakarta.servlet.ServletRequest; import jakarta.servlet.http.Part; -import org.eclipse.jetty.http.ComplianceViolation; import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.MimeTypes; import org.eclipse.jetty.http.MultiPart; -import org.eclipse.jetty.http.MultiPartCompliance; +import org.eclipse.jetty.http.MultiPartConfig; import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.io.AbstractConnection; import org.eclipse.jetty.io.ByteBufferPool; @@ -39,7 +38,7 @@ import org.eclipse.jetty.io.Connection; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.content.InputStreamContentSource; import org.eclipse.jetty.server.ConnectionMetaData; -import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.Request; import org.eclipse.jetty.util.StringUtil; /** @@ -99,16 +98,13 @@ public class ServletMultiPartFormData return CompletableFuture.failedFuture(new IllegalStateException("No core request")); // Get a temporary directory for larger parts. - File filesDirectory = StringUtil.isBlank(config.getLocation()) - ? servletContextRequest.getContext().getTempDirectory() - : new File(config.getLocation()); - - HttpChannel httpChannel = HttpChannel.from(servletContextRequest); - ComplianceViolation.Listener complianceViolationListener = httpChannel.getComplianceViolationListener(); - MultiPartCompliance compliance = servletContextRequest.getConnectionMetaData().getHttpConfiguration().getMultiPartCompliance(); + Path filesDirectory = StringUtil.isBlank(config.getLocation()) + ? servletContextRequest.getContext().getTempDirectory().toPath() + : new File(config.getLocation()).toPath(); // Look for an existing future MultiPartFormData.Parts - CompletableFuture futureFormData = MultiPartFormData.from(servletContextRequest, compliance, complianceViolationListener, boundary, parser -> + CompletableFuture futureFormData = MultiPartFormData.get(servletContextRequest); + if (futureFormData == null) { try { @@ -131,24 +127,24 @@ public class ServletMultiPartFormData source = iscs; } - parser.setMaxParts(contextHandler.getMaxFormKeys()); - parser.setFilesDirectory(filesDirectory.toPath()); - parser.setMaxMemoryFileSize(config.getFileSizeThreshold()); - parser.setMaxFileSize(config.getMaxFileSize()); - parser.setMaxLength(config.getMaxRequestSize()); - parser.setPartHeadersMaxLength(connectionMetaData.getHttpConfiguration().getRequestHeaderSize()); + MultiPartConfig multiPartConfig = Request.getMultiPartConfig(servletContextRequest, filesDirectory) + .location(filesDirectory) + .maxParts(contextHandler.getMaxFormKeys()) + .maxMemoryPartSize(config.getFileSizeThreshold()) + .maxPartSize(config.getMaxFileSize()) + .maxSize(config.getMaxRequestSize()) + .build(); - // parse the core parts. - return parser.parse(source); + futureFormData = MultiPartFormData.from(source, servletContextRequest, contentType, multiPartConfig); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } - }); + } // When available, convert the core parts to servlet parts - futureServletParts = futureFormData.thenApply(formDataParts -> new Parts(filesDirectory.toPath(), formDataParts)); + futureServletParts = futureFormData.thenApply(formDataParts -> new Parts(filesDirectory, formDataParts)); // cache the result in attributes. servletRequest.setAttribute(ServletMultiPartFormData.class.getName(), futureServletParts); diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java index c47a171e89a..0e712e87ed2 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/MultiPartServletTest.java @@ -472,6 +472,7 @@ public class MultiPartServletTest InputStream inputStream = new GZIPInputStream(responseStream.getInputStream()); MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary); formData.setMaxParts(1); + formData.setMaxMemoryFileSize(-1); MultiPartFormData.Parts parts = formData.parse(new InputStreamContentSource(inputStream)).join(); assertThat(parts.size(), is(1)); diff --git a/jetty-ee8/jetty-ee8-servlet/pom.xml b/jetty-ee8/jetty-ee8-servlet/pom.xml index 7e2da26e363..c18edff7624 100644 --- a/jetty-ee8/jetty-ee8-servlet/pom.xml +++ b/jetty-ee8/jetty-ee8-servlet/pom.xml @@ -74,6 +74,8 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} ${jetty.surefire.argLine} --add-modules org.eclipse.jetty.util.ajax --add-reads org.eclipse.jetty.ee8.servlet=org.eclipse.jetty.logging diff --git a/jetty-ee9/jetty-ee9-nested/src/main/java/org/eclipse/jetty/ee9/nested/SessionHandler.java b/jetty-ee9/jetty-ee9-nested/src/main/java/org/eclipse/jetty/ee9/nested/SessionHandler.java index 5a1e9872a42..ef1f7579261 100644 --- a/jetty-ee9/jetty-ee9-nested/src/main/java/org/eclipse/jetty/ee9/nested/SessionHandler.java +++ b/jetty-ee9/jetty-ee9-nested/src/main/java/org/eclipse/jetty/ee9/nested/SessionHandler.java @@ -47,7 +47,6 @@ import org.eclipse.jetty.server.HttpStream; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Session; import org.eclipse.jetty.session.AbstractSessionManager; -import org.eclipse.jetty.session.DefaultSessionIdManager; import org.eclipse.jetty.session.ManagedSession; import org.eclipse.jetty.session.SessionCache; import org.eclipse.jetty.session.SessionConfig; diff --git a/jetty-ee9/jetty-ee9-servlet/pom.xml b/jetty-ee9/jetty-ee9-servlet/pom.xml index 66bd2f795f8..3f00330ba1e 100644 --- a/jetty-ee9/jetty-ee9-servlet/pom.xml +++ b/jetty-ee9/jetty-ee9-servlet/pom.xml @@ -72,6 +72,8 @@ maven-surefire-plugin + + false @{argLine} ${jetty.surefire.argLine} --add-modules org.eclipse.jetty.util.ajax --add-reads org.eclipse.jetty.ee9.servlet=org.eclipse.jetty.logging diff --git a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/CrossContextDispatcherTest.java b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/CrossContextDispatcherTest.java index ad527a24364..50379d42850 100644 --- a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/CrossContextDispatcherTest.java +++ b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/CrossContextDispatcherTest.java @@ -18,7 +18,6 @@ import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -70,10 +69,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; diff --git a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DefaultServletTest.java b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DefaultServletTest.java index 7c157f58f26..f6eb2bbb39e 100644 --- a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DefaultServletTest.java +++ b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DefaultServletTest.java @@ -13,6 +13,8 @@ package org.eclipse.jetty.ee9.servlet; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.net.URL; @@ -28,6 +30,8 @@ import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; @@ -59,10 +63,12 @@ import org.eclipse.jetty.toolchain.test.MavenPaths; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension; +import org.eclipse.jetty.util.IO; import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.resource.FileSystemPool; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.resource.URLResourceFactory; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -2560,6 +2566,49 @@ public class DefaultServletTest } } + @Test + public void testGetPrecompressedSuffixMapping() throws Exception + { + Path docRoot = workDir.getEmptyPathDir().resolve("docroot"); + FS.ensureDirExists(docRoot); + + startServer((context) -> + { + ResourceFactory.registerResourceFactory("file", new URLResourceFactory()); + Resource resource = ResourceFactory.of(context).newResource(docRoot); + assertThat("Expecting URLResource", resource.getClass().getName(), endsWith("URLResource")); + context.setBaseResource(resource); + + ServletHolder defholder = context.addServlet(DefaultServlet.class, "*.js"); + defholder.setInitParameter("cacheControl", "no-store"); + defholder.setInitParameter("dirAllowed", "false"); + defholder.setInitParameter("gzip", "false"); + defholder.setInitParameter("precompressed", "gzip=.gz"); + }); + + + FS.ensureDirExists(docRoot.resolve("scripts")); + + String scriptText = "This is a script"; + Files.writeString(docRoot.resolve("scripts/script.js"), scriptText, UTF_8); + + byte[] compressedBytes = compressGzip(scriptText); + Files.write(docRoot.resolve("scripts/script.js.gz"), compressedBytes); + + String rawResponse = connector.getResponse(""" + GET /context/scripts/script.js HTTP/1.1 + Host: test + Accept-Encoding: gzip + Connection: close + + """); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + assertThat("Suffix url-pattern mapping not used", response.get(HttpHeader.CACHE_CONTROL), is("no-store")); + String responseDecompressed = decompressGzip(response.getContentBytes()); + assertThat(responseDecompressed, is("This is a script")); + } + @Test public void testHead() throws Exception { @@ -2950,6 +2999,31 @@ public class DefaultServletTest return ret; } + private static byte[] compressGzip(String textToCompress) throws IOException + { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + GZIPOutputStream gzipOut = new GZIPOutputStream(baos); + ByteArrayInputStream input = new ByteArrayInputStream(textToCompress.getBytes(UTF_8))) + { + IO.copy(input, gzipOut); + gzipOut.flush(); + gzipOut.finish(); + return baos.toByteArray(); + } + } + + private static String decompressGzip(byte[] compressedContent) throws IOException + { + try (ByteArrayInputStream input = new ByteArrayInputStream(compressedContent); + GZIPInputStream gzipInput = new GZIPInputStream(input); + ByteArrayOutputStream output = new ByteArrayOutputStream()) + { + IO.copy(gzipInput, output); + output.flush(); + return output.toString(UTF_8); + } + } + public static class Scenarios extends ArrayList { public void addScenario(String description, String rawRequest, int expectedStatus) diff --git a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DispatcherTest.java b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DispatcherTest.java index 2b1abfdc82a..03bccebb041 100644 --- a/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DispatcherTest.java +++ b/jetty-ee9/jetty-ee9-servlet/src/test/java/org/eclipse/jetty/ee9/servlet/DispatcherTest.java @@ -66,8 +66,10 @@ import org.eclipse.jetty.util.MultiMap; import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.UrlEncoded; import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.resource.FileSystemPool; import org.hamcrest.Matcher; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -75,6 +77,7 @@ import org.slf4j.LoggerFactory; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -124,6 +127,12 @@ public class DispatcherTest return _contextCollection; } + @BeforeEach + public void ensureFileSystemPoolIsSane() + { + assertThat(FileSystemPool.INSTANCE.mounts(), empty()); + } + @AfterEach public void destroy() throws Exception { diff --git a/pom.xml b/pom.xml index 8f6561c5a2f..bfe81f3b3fe 100644 --- a/pom.xml +++ b/pom.xml @@ -201,7 +201,7 @@ 4.0.2 4.0.1 3.1.5 - 10.1.16 + 10.1.25 2.0.1 5.1.2.Final @@ -226,7 +226,7 @@ 4.0.2 4.0.1 4.0.0-M1 - 11.0.0-M20 + 11.0.0-M21 2.0.1 6.0.0.Beta1 @@ -240,7 +240,7 @@ 1.0.0.v201108011116 1.2.5 4.0.6 - 9.0.83.1 + 9.0.90 3.1.9.Final 2.0.1 @@ -285,9 +285,9 @@ 5.4.0 7.1.1.Final infinispan/server - 15.0.1.Final-1 - 5.0.2.Final - 15.0.1.Final + 15.0.5.Final + 5.0.4.Final + 15.0.5.Final 1.2 false false @@ -367,7 +367,7 @@ 2.2.3 3.2.20 3.12.14 - 4.1.107.Final + 4.1.109.Final 0.9.1 8.1.0 8.0.0 @@ -407,7 +407,7 @@ 3.0.0 2.16.2 1.7.0.Final - 2.3.1.Final + 2.4.2.Final 2.4.8 @@ -1305,37 +1305,37 @@ org.wildfly.security wildfly-elytron-sasl-digest - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-external - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-gs2 - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-gssapi - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-oauth2 - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-plain - 2.3.1.Final + ${wildfly.elytron.version} org.wildfly.security wildfly-elytron-sasl-scram - 2.3.1.Final + ${wildfly.elytron.version} diff --git a/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java b/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java new file mode 100644 index 00000000000..844425ff5b9 --- /dev/null +++ b/tests/test-integration/src/test/java/org/eclipse/jetty/test/CoreMultiPartTest.java @@ -0,0 +1,559 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.zip.GZIPInputStream; + +import org.eclipse.jetty.client.BytesRequestContent; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.InputStreamResponseListener; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.OutputStreamRequestContent; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.EofException; +import org.eclipse.jetty.io.content.InputStreamContentSource; +import org.eclipse.jetty.logging.StacklessLogging; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.gzip.GzipHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +public class CoreMultiPartTest +{ + private static final int MAX_FILE_SIZE = 512 * 1024; + + private Server server; + private ServerConnector connector; + private HttpClient client; + private Path tmpDir; + private MultiPartConfig config; + + @BeforeEach + public void before() throws Exception + { + tmpDir = Files.createTempDirectory(CoreMultiPartTest.class.getSimpleName()); + } + + private MultiPartConfig multiPartConfig(Path location, int maxFormKeys, long maxRequestSize, long maxFileSize, long fileSizeThreshold) + { + return new MultiPartConfig.Builder() + .location(location) + .maxParts(maxFormKeys) + .maxSize(maxRequestSize) + .maxPartSize(maxFileSize) + .maxMemoryPartSize(fileSizeThreshold) + .useFilesForPartsWithoutFileName(true) + .build(); + } + + private void start(Handler handler, MultiPartConfig config) throws Exception + { + this.config = config == null ? multiPartConfig(tmpDir, -1, -1, MAX_FILE_SIZE, 0) : config; + server = new Server(null, null, null); + connector = new ServerConnector(server); + server.addConnector(connector); + server.setHandler(handler); + + GzipHandler gzipHandler = new GzipHandler(); + gzipHandler.addIncludedMimeTypes("multipart/form-data"); + gzipHandler.setMinGzipSize(32); + server.insertHandler(gzipHandler); + + server.start(); + client = new HttpClient(); + client.start(); + } + + @AfterEach + public void stop() throws Exception + { + LifeCycle.stop(client); + LifeCycle.stop(server); + IO.delete(tmpDir.toFile()); + } + + private MultiPartFormData.Parts getParts(Request request, MultiPartConfig config) + { + try + { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + return MultiPartFormData.from(request, request, contentType, config).get(); + } + catch (Throwable t) + { + throw new BadMessageException("bad multipart", t.getCause()); + } + } + + @Test + public void testLargePart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, multiPartConfig(null, -1, -1, 1024 * 1024, -1)); + + OutputStreamRequestContent content = new OutputStreamRequestContent(); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("param", null, null, content)); + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/defaultConfig") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + // The write side will eventually throw because connection is closed. + assertThrows(Throwable.class, () -> + { + // Write large amount of content to the part. + byte[] byteArray = new byte[1024 * 1024]; + Arrays.fill(byteArray, (byte)1); + for (int i = 0; i < 1024 * 2; i++) + { + content.getOutputStream().write(byteArray); + } + content.close(); + }); + + assert400orEof(listener, responseContent -> assertThat(responseContent, containsString("400"))); + } + + @Test + public void testManyParts() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, multiPartConfig(null, 1024, -1, -1, -1)); + + byte[] byteArray = new byte[1024]; + Arrays.fill(byteArray, (byte)1); + + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + for (int i = 0; i < 1024 * 1024; i++) + { + BytesRequestContent content = new BytesRequestContent(byteArray); + multiPart.addPart(new MultiPart.ContentSourcePart("part" + i, null, null, content)); + } + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/defaultConfig") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + assert400orEof(listener, responseContent -> assertThat(responseContent, containsString("400"))); + } + + @Test + public void testMaxRequestSize() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, multiPartConfig(tmpDir, -1, 1024, -1, 1024 * 1024 * 8)); + + OutputStreamRequestContent content = new OutputStreamRequestContent(); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("param", null, null, content)); + multiPart.close(); + + InputStreamResponseListener listener = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/requestSizeLimit") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(listener); + + Throwable writeError = null; + try + { + // Write large amount of content to the part. + byte[] byteArray = new byte[1024 * 1024]; + Arrays.fill(byteArray, (byte)1); + for (int i = 0; i < 1024 * 1024; i++) + { + content.getOutputStream().write(byteArray); + } + fail("We should never be able to write all the content."); + } + catch (Exception e) + { + writeError = e; + } + + assertThat(writeError, instanceOf(EofException.class)); + + assert400orEof(listener, null); + } + + private static void assert400orEof(InputStreamResponseListener listener, Consumer checkbody) throws InterruptedException, TimeoutException + { + // There is a race here, either we fail trying to write some more content OR + // we get 400 response, for some reason reading the content throws EofException. + String responseContent = null; + try + { + org.eclipse.jetty.client.Response response = listener.get(60, TimeUnit.SECONDS); + assertThat(response.getStatus(), equalTo(HttpStatus.BAD_REQUEST_400)); + responseContent = IO.toString(listener.getInputStream()); + } + catch (ExecutionException | IOException e) + { + Throwable cause = e.getCause(); + assertThat(cause, instanceOf(EofException.class)); + return; + } + + if (checkbody != null) + checkbody.accept(responseContent); + } + + @Test + public void testSimpleMultiPart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + MultiPartFormData.Parts parts = getParts(request, config); + assertNotNull(parts); + assertEquals(1, parts.size()); + MultiPart.Part part = parts.iterator().next(); + assertEquals("part1", part.getName()); + HttpFields fields = part.getHeaders(); + assertNotNull(fields); + assertEquals(2, fields.size()); + InputStream inputStream = Content.Source.asInputStream(part.getContentSource()); + String content1 = IO.toString(inputStream, UTF_8); + assertEquals("content1", content1); + + callback.succeeded(); + return true; + } + }, null); + + try (Socket socket = new Socket("localhost", connector.getLocalPort())) + { + OutputStream output = socket.getOutputStream(); + + String content = """ + --A1B2C3 + Content-Disposition: form-data; name="part1" + Content-Type: text/plain; charset="UTF-8" + + content1 + --A1B2C3-- + """; + String header = """ + POST / HTTP/1.1 + Host: localhost + Content-Type: multipart/form-data; boundary="A1B2C3" + Content-Length: $L + + """.replace("$L", String.valueOf(content.length())); + + output.write(header.getBytes(UTF_8)); + output.write(content.getBytes(UTF_8)); + output.flush(); + + HttpTester.Response response = HttpTester.parseResponse(socket.getInputStream()); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + + @Test + public void testTempFilesDeletedOnError() throws Exception + { + byte[] bytes = new byte[2 * MAX_FILE_SIZE]; + Arrays.fill(bytes, (byte)1); + + // Should throw as the max file size is exceeded. + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + getParts(request, config); + callback.succeeded(); + return true; + } + }, null); + + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("largePart", "largeFile.bin", HttpFields.EMPTY, new BytesRequestContent(bytes))); + multiPart.close(); + + try (StacklessLogging ignored = new StacklessLogging(HttpChannel.class)) + { + ContentResponse response = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(); + + assertEquals(400, response.getStatus()); + } + + String[] fileList = tmpDir.toFile().list(); + assertNotNull(fileList); + assertThat(fileList.length, is(0)); + } + + @Test + public void testDefaultTempDirectory() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + MultiPartConfig conf = Request.getMultiPartConfig(request, config.getLocation()) + .maxParts(config.getMaxParts()) + .maxSize(config.getMaxSize()) + .maxPartSize(config.getMaxPartSize()) + .maxMemoryPartSize(config.getMaxMemoryPartSize()) + .useFilesForPartsWithoutFileName(config.isUseFilesForPartsWithoutFileName()) + .build(); + MultiPartFormData.Parts parts = getParts(request, conf); + assertNotNull(parts); + assertEquals(1, parts.size()); + MultiPart.Part part = parts.iterator().next(); + assertEquals("part1", part.getName()); + HttpFields headers = part.getHeaders(); + assertNotNull(headers); + assertEquals(2, headers.size()); + InputStream inputStream = Content.Source.asInputStream(part.getContentSource()); + String content1 = IO.toString(inputStream, UTF_8); + assertEquals("content1", content1); + + callback.succeeded(); + return true; + } + }, multiPartConfig(null, -1, MAX_FILE_SIZE, -1, 0)); + + try (Socket socket = new Socket("localhost", connector.getLocalPort())) + { + OutputStream output = socket.getOutputStream(); + + String content = """ + --A1B2C3 + Content-Disposition: form-data; name="part1" + Content-Type: text/plain; charset="UTF-8" + + content1 + --A1B2C3-- + """; + String header = """ + POST / HTTP/1.1 + Host: localhost + Content-Type: multipart/form-data; boundary="A1B2C3" + Content-Length: $L + + """.replace("$L", String.valueOf(content.length())); + + output.write(header.getBytes(UTF_8)); + output.write(content.getBytes(UTF_8)); + output.flush(); + + HttpTester.Response response = HttpTester.parseResponse(socket.getInputStream()); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + + @Test + public void testMultiPartGzip() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, contentType); + + MultiPartRequestContent echoParts = new MultiPartRequestContent(MultiPart.extractBoundary(contentType)); + MultiPartFormData.Parts servletParts = getParts(request, config); + for (MultiPart.Part part : servletParts) + { + HttpFields.Mutable partHeaders = HttpFields.build(); + for (HttpField field : part.getHeaders()) + partHeaders.add(field); + + echoParts.addPart(new MultiPart.ContentSourcePart(part.getName(), part.getFileName(), partHeaders, part.getContentSource())); + } + echoParts.close(); + IO.copy(Content.Source.asInputStream(echoParts), Content.Sink.asOutputStream(response)); + + callback.succeeded(); + return true; + } + }, null); + + // Do not automatically handle gzip. + client.getContentDecoderFactories().clear(); + + String contentString = "the quick brown fox jumps over the lazy dog, " + + "the quick brown fox jumps over the lazy dog"; + StringRequestContent content = new StringRequestContent(contentString); + + MultiPartRequestContent multiPartContent = new MultiPartRequestContent(); + multiPartContent.addPart(new MultiPart.ContentSourcePart("stringPart", null, HttpFields.EMPTY, content)); + multiPartContent.close(); + + InputStreamResponseListener responseStream = new InputStreamResponseListener(); + client.newRequest("localhost", connector.getLocalPort()) + .path("/echo") + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .headers(h -> h.add(HttpHeader.ACCEPT_ENCODING, "gzip")) + .body(multiPartContent) + .send(responseStream); + + org.eclipse.jetty.client.Response response = responseStream.get(5, TimeUnit.SECONDS); + HttpFields headers = response.getHeaders(); + assertThat(headers.get(HttpHeader.CONTENT_TYPE), startsWith("multipart/form-data")); + assertThat(headers.get(HttpHeader.CONTENT_ENCODING), is("gzip")); + + String contentType = headers.get(HttpHeader.CONTENT_TYPE); + String boundary = MultiPart.extractBoundary(contentType); + InputStream inputStream = new GZIPInputStream(responseStream.getInputStream()); + MultiPartFormData.Parser formData = new MultiPartFormData.Parser(boundary); + formData.setMaxParts(1); + formData.setMaxMemoryFileSize(-1); + MultiPartFormData.Parts parts = formData.parse(new InputStreamContentSource(inputStream)).join(); + + assertThat(parts.size(), is(1)); + assertThat(parts.get(0).getContentAsString(UTF_8), is(contentString)); + } + + @Test + public void testDoubleReadFromPart() throws Exception + { + start(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain"); + PrintWriter writer = new PrintWriter(Content.Sink.asOutputStream(response)); + for (MultiPart.Part part : getParts(request, config)) + { + String partContent = IO.toString(Content.Source.asInputStream(part.getContentSource())); + writer.println("Part: name=" + part.getName() + ", size=" + part.getLength() + ", content=" + partContent); + + // We can only consume the getContentSource() once so we must use newContentSource(). + partContent = IO.toString(Content.Source.asInputStream(part.newContentSource())); + writer.println("Part: name=" + part.getName() + ", size=" + part.getLength() + ", content=" + partContent); + } + + writer.close(); + callback.succeeded(); + return true; + } + }, null); + + String contentString = "the quick brown fox jumps over the lazy dog, " + + "the quick brown fox jumps over the lazy dog"; + StringRequestContent content = new StringRequestContent(contentString); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + multiPart.addPart(new MultiPart.ContentSourcePart("myPart", null, HttpFields.EMPTY, content)); + multiPart.close(); + + ContentResponse response = client.newRequest("localhost", connector.getLocalPort()) + .scheme(HttpScheme.HTTP.asString()) + .method(HttpMethod.POST) + .body(multiPart) + .send(); + + assertEquals(200, response.getStatus()); + assertThat(response.getContentAsString(), containsString("Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog\n" + + "Part: name=myPart, size=88, content=the quick brown fox jumps over the lazy dog, the quick brown fox jumps over the lazy dog")); + } +}