From 9b73fc52a73b59eea347f327a716932639d89e2b Mon Sep 17 00:00:00 2001 From: jencymaryjoseph <35571282+jencymaryjoseph@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:43:17 -0700 Subject: [PATCH 1/3] Add signed headers Javadoc to S3Presigner --- .../amazon/awssdk/services/s3/presigner/S3Presigner.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presigner/S3Presigner.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presigner/S3Presigner.java index b5fd634255f..1db1c72efec 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presigner/S3Presigner.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presigner/S3Presigner.java @@ -300,6 +300,11 @@ static Builder builder() { * {@code x-amz-checksum-mode} header at download time, which the SDK sends automatically). *

* + * Signed headers note: If the {@code GetObjectRequest} includes fields that are marshalled as + * HTTP headers (as defined by the service model), these will be signed into the URL's signature but their + * values will not be stored in the URL — only their names appear in {@code X-Amz-SignedHeaders}. + * If any signed headers are missing from the download request, S3 will return a signature mismatch error. + * * Example Usage *

* From a00e4d357ad64ea60d02ba0ff1a2b58350b65444 Mon Sep 17 00:00:00 2001 From: jencymaryjoseph <35571282+jencymaryjoseph@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:43:43 -0700 Subject: [PATCH 2/3] Replace range/ifMatch with generic putHeader map --- .../AsyncPresignedUrlExtensionTestSuite.java | 2 +- ...ignedUrlMultipartDownloaderSubscriber.java | 6 +- .../multipart/PresignedUrlDownloadHelper.java | 25 +- ...ignedUrlMultipartDownloaderSubscriber.java | 3 +- .../DefaultAsyncPresignedUrlExtension.java | 3 +- ...PresignedUrlDownloadRequestMarshaller.java | 17 ++ .../PresignedUrlDownloadRequestWrapper.java | 87 ++---- .../model/PresignedUrlDownloadRequest.java | 129 +++++---- .../multipart/MultipartS3AsyncClientTest.java | 2 +- .../PresignedUrlDownloadHelperTest.java | 12 +- ...ipartDownloaderSubscriberWiremockTest.java | 4 +- ...DefaultAsyncPresignedUrlExtensionTest.java | 2 +- ...ignedUrlDownloadRequestMarshallerTest.java | 5 +- ...PresignedUrlSignedHeadersWiremockTest.java | 251 ++++++++++++++++++ ...resignedUrlDownloadRequestWrapperTest.java | 69 ++--- .../AsyncPresignedUrlExtensionTest.java | 2 +- .../PresignedUrlDownloadRequestTest.java | 26 +- 17 files changed, 457 insertions(+), 188 deletions(-) create mode 100644 services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java diff --git a/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java index 1ff737b5285..3a8465d2014 100644 --- a/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java +++ b/services/s3/src/it/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTestSuite.java @@ -373,7 +373,7 @@ private PresignedUrlDownloadRequest createRequestForKey(String key) { private PresignedUrlDownloadRequest createRequestForKey(String key, String range) { return PresignedUrlDownloadRequest.builder() .presignedUrl(createPresignedUrl(key)) - .range(range) + .putHeader("Range", range) .build(); } diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java index 88031de5c73..fb6a1d6fd5c 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriber.java @@ -130,7 +130,8 @@ public void onNext(AsyncResponseTransformer transformer) { PresignedUrlDownloadRequest partRequest = createRangedGetRequest(0L); - log.debug(() -> "Sending first range request with range=" + partRequest.range()); + log.debug(() -> "Sending first range request with range=" + + partRequest.headers().get(PresignedUrlDownloadHelper.RANGE_HEADER)); if (!inFlightPermits.tryAcquire()) { throw new IllegalStateException("Failed to acquire permit for first request"); @@ -231,7 +232,8 @@ private void sendPartRequest(AsyncResponseTransformer "Sending range request for part " + partIndex + " with range=" + partRequest.range()); + log.debug(() -> "Sending range request for part " + partIndex + " with range=" + + partRequest.headers().get(PresignedUrlDownloadHelper.RANGE_HEADER)); CompletableFuture response = s3AsyncClient.presignedUrlExtension().getObject(partRequest, transformer); diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java index 77beddae994..de6d161e86a 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelper.java @@ -32,6 +32,18 @@ @SdkInternalApi public class PresignedUrlDownloadHelper { + /** + * The {@code Range} HTTP header name. Used internally to detect a caller-supplied range (which forces a + * single-part download) and to build the SDK's own per-part range requests. Package-private so the + * multipart subscribers in this package can share it without exposing it on the public request type. + */ + static final String RANGE_HEADER = "Range"; + + /** + * The {@code If-Match} HTTP header name, used internally for per-part consistency on multipart downloads. + */ + static final String IF_MATCH_HEADER = "If-Match"; + private static final Logger log = Logger.loggerFor(PresignedUrlDownloadHelper.class); private static final int DEFAULT_MAX_IN_FLIGHT_PARTS = 10; @@ -57,9 +69,9 @@ public CompletableFuture downloadObject( Validate.paramNotNull(presignedRequest, "presignedRequest"); Validate.paramNotNull(asyncResponseTransformer, "asyncResponseTransformer"); - if (presignedRequest.range() != null) { - log.debug(() -> "Using single part download because presigned URL request range is included in the request. range = " - + presignedRequest.range()); + if (presignedRequest.headers().containsKey(RANGE_HEADER)) { + log.debug(() -> "Using single part download because presigned URL request includes a Range header. range = " + + presignedRequest.headers().get(RANGE_HEADER)); return asyncPresignedUrlExtension.getObject(presignedRequest, asyncResponseTransformer); } @@ -222,10 +234,11 @@ static PresignedUrlDownloadRequest createRangedGetRequest(PresignedUrlDownloadRe long endByte = totalContentLength != null ? Math.min(startByte + partSizeInBytes - 1, totalContentLength - 1) : startByte + partSizeInBytes - 1; - PresignedUrlDownloadRequest.Builder builder = originalRequest.toBuilder() - .range("bytes=" + startByte + "-" + endByte); + PresignedUrlDownloadRequest.Builder builder = + originalRequest.toBuilder() + .putHeader(RANGE_HEADER, "bytes=" + startByte + "-" + endByte); if (partIndex > 0 && eTag != null) { - builder.ifMatch(eTag); + builder.putHeader(IF_MATCH_HEADER, eTag); } return builder.build(); } diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java index 86c4c76308c..1690e70f571 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriber.java @@ -127,7 +127,8 @@ private void makeRangeRequest(long partIndex, AsyncResponseTransformer asyncResponseTransformer) { PresignedUrlDownloadRequest partRequest = createRangedGetRequest(partIndex); - log.debug(() -> "Sending range request for part " + partIndex + " with range=" + partRequest.range()); + log.debug(() -> "Sending range request for part " + partIndex + " with range=" + + partRequest.headers().get(PresignedUrlDownloadHelper.RANGE_HEADER)); requestsSent.incrementAndGet(); CompletableFuture responseFuture = s3AsyncClient.presignedUrlExtension() diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtension.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtension.java index d2a7088aa86..f67b5b2218b 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtension.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtension.java @@ -104,8 +104,7 @@ public CompletableFuture getObject( PresignedUrlDownloadRequestWrapper internalRequest = PresignedUrlDownloadRequestWrapper.builder() .url(presignedUrlDownloadRequest.presignedUrl()) - .range(presignedUrlDownloadRequest.range()) - .ifMatch(presignedUrlDownloadRequest.ifMatch()) + .headers(presignedUrlDownloadRequest.headers()) .build(); MetricCollector apiCallMetricCollector = metricPublishers.isEmpty() ? diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshaller.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshaller.java index b37aec33518..a05c851d07d 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshaller.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshaller.java @@ -16,6 +16,8 @@ package software.amazon.awssdk.services.s3.internal.presignedurl; import java.net.URI; +import java.util.List; +import java.util.Map; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.runtime.transform.Marshaller; @@ -68,6 +70,7 @@ public SdkHttpFullRequest marshall(PresignedUrlDownloadRequestWrapper presignedU .toBuilder() .uri(presignedUri); + addCustomHeaders(requestBuilder, presignedUrlDownloadRequestWrapper); addChecksumModeHeaderIfSignedInUrl(requestBuilder, presignedUri); return requestBuilder.build(); @@ -78,6 +81,20 @@ public SdkHttpFullRequest marshall(PresignedUrlDownloadRequestWrapper presignedU } } + /** + * Adds the request's headers to the HTTP request builder. These are the signed header values that must be + * replayed at download time. + */ + private void addCustomHeaders(SdkHttpFullRequest.Builder requestBuilder, + PresignedUrlDownloadRequestWrapper wrapper) { + if (wrapper.headers() == null || wrapper.headers().isEmpty()) { + return; + } + for (Map.Entry> entry : wrapper.headers().entrySet()) { + requestBuilder.putHeader(entry.getKey(), entry.getValue()); + } + } + /** * If the presigned URL's X-Amz-SignedHeaders contains "x-amz-checksum-mode", automatically add * the header with value "ENABLED" so S3 returns checksum headers in the response. diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapper.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapper.java index 5d556952396..2ceb91c462b 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapper.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapper.java @@ -16,71 +16,51 @@ package software.amazon.awssdk.services.s3.internal.presignedurl.model; import java.net.URL; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Function; +import java.util.TreeMap; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.SdkField; -import software.amazon.awssdk.core.protocol.MarshallLocation; -import software.amazon.awssdk.core.protocol.MarshallingType; -import software.amazon.awssdk.core.traits.LocationTrait; import software.amazon.awssdk.services.s3.model.S3Request; /** * Internal request object for presigned URL GetObject operations. *

- * This class is used internally by the AWS SDK to process presigned URL requests for S3 GetObject operations. It contains minimal - * SdkField definitions needed for custom marshalling and is not intended for direct use by SDK users. + * This class is used internally by the AWS SDK to process presigned URL requests for S3 GetObject operations. It carries the + * presigned URL and the headers that must be replayed at download time (the values that were signed when the URL was + * generated). It is not intended for direct use by SDK users. *

* Note: This is an internal implementation class and should not be used * directly. Use {@code PresignedUrlDownloadRequest} for public API interactions. */ @SdkInternalApi public final class PresignedUrlDownloadRequestWrapper extends S3Request { - private static final SdkField RANGE_FIELD = SdkField - .builder(MarshallingType.STRING) - .memberName("Range") - .getter(getter(PresignedUrlDownloadRequestWrapper::range)) - .traits(LocationTrait.builder().location(MarshallLocation.HEADER).locationName("Range") - .unmarshallLocationName("Range").build()).build(); - private static final SdkField IF_MATCH_FIELD = SdkField - .builder(MarshallingType.STRING) - .memberName("IfMatch") - .getter(getter(PresignedUrlDownloadRequestWrapper::ifMatch)) - .traits(LocationTrait.builder().location(MarshallLocation.HEADER).locationName("If-Match") - .unmarshallLocationName("If-Match").build()).build(); + private static final List> SDK_FIELDS = Collections.emptyList(); - private static final List> SDK_FIELDS = Collections.unmodifiableList( - Arrays.asList(RANGE_FIELD, IF_MATCH_FIELD)); - - private static final Map> SDK_NAME_TO_FIELD = memberNameToFieldInitializer(); + private static final Map> SDK_NAME_TO_FIELD = Collections.emptyMap(); private final URL url; - private final String range; - private final String ifMatch; + private final Map> headers; private PresignedUrlDownloadRequestWrapper(Builder builder) { super(builder); this.url = builder.url; - this.range = builder.range; - this.ifMatch = builder.ifMatch; + this.headers = Collections.unmodifiableMap(builder.headers); } public URL url() { return url; } - public String range() { - return range; - } - - public String ifMatch() { - return ifMatch; + /** + * Returns the headers to be sent with the download request, using case-insensitive header-name comparison. + */ + public Map> headers() { + return headers; } @Override @@ -93,17 +73,6 @@ public Map> sdkFieldNameToField() { return SDK_NAME_TO_FIELD; } - private static Function getter(Function g) { - return obj -> g.apply((PresignedUrlDownloadRequestWrapper) obj); - } - - private static Map> memberNameToFieldInitializer() { - Map> map = new HashMap<>(); - map.put("Range", RANGE_FIELD); - map.put("IfMatch", IF_MATCH_FIELD); - return Collections.unmodifiableMap(map); - } - @Override public Builder toBuilder() { return new Builder(this); @@ -125,22 +94,20 @@ public boolean equals(Object obj) { return false; } PresignedUrlDownloadRequestWrapper that = (PresignedUrlDownloadRequestWrapper) obj; - return Objects.equals(url, that.url) && Objects.equals(range, that.range) && Objects.equals(ifMatch, that.ifMatch); + return Objects.equals(url, that.url) && Objects.equals(headers, that.headers); } @Override public int hashCode() { int result = Objects.hashCode(super.hashCode()); result = 31 * result + Objects.hashCode(url); - result = 31 * result + Objects.hashCode(range); - result = 31 * result + Objects.hashCode(ifMatch); + result = 31 * result + Objects.hashCode(headers); return result; } public static final class Builder extends S3Request.BuilderImpl { private URL url; - private String range; - private String ifMatch; + private Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); public Builder() { } @@ -148,8 +115,9 @@ public Builder() { Builder(PresignedUrlDownloadRequestWrapper request) { super(request); this.url = request.url(); - this.range = request.range(); - this.ifMatch = request.ifMatch(); + if (request.headers() != null) { + headers(request.headers()); + } } public Builder url(URL url) { @@ -157,13 +125,14 @@ public Builder url(URL url) { return this; } - public Builder range(String range) { - this.range = range; - return this; - } - - public Builder ifMatch(String ifMatch) { - this.ifMatch = ifMatch; + public Builder headers(Map> headers) { + Map> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (headers != null) { + for (Map.Entry> entry : headers.entrySet()) { + copy.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + } + this.headers = copy; return this; } diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java index 6c19159251f..cae8337fdac 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java @@ -16,8 +16,14 @@ package software.amazon.awssdk.services.s3.presignedurl.model; import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.ToString; import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; @@ -25,24 +31,40 @@ /** * Request object for performing download operations using a presigned URL. + * + *

If any fields from the {@code GetObjectRequest} that are marshalled as HTTP headers were signed when + * generating the presigned URL using {@link software.amazon.awssdk.services.s3.presigner.S3Presigner#presignGetObject}, + * their values are not stored in the URL — only their names appear in {@code X-Amz-SignedHeaders}. If any + * signed headers are missing from the download request, S3 will return a signature mismatch error. + * + *

Use {@link Builder#putHeader(String, String)} (or {@link Builder#headers(Map)}) to supply the signed + * header values at download time, for example:

+ *
{@code
+ * PresignedUrlDownloadRequest.builder()
+ *     .presignedUrl(url)
+ *     .putHeader("Range", "bytes=0-1023")
+ *     .putHeader("If-Match", eTag)
+ *     .build();
+ * }
+ * */ @SdkPublicApi public final class PresignedUrlDownloadRequest implements ToCopyableBuilder { + private final URL presignedUrl; - private final String range; - private final String ifMatch; + private final Map> headers; private PresignedUrlDownloadRequest(BuilderImpl builder) { this.presignedUrl = builder.presignedUrl; - this.range = builder.range; - this.ifMatch = builder.ifMatch; + this.headers = CollectionUtils.deepUnmodifiableMap(builder.headers, + () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER)); } /** *

- * The presigned URL for the S3 object. This URL contains all necessary authentication information and can be used to download - * the object without additional credentials. + * The presigned URL for the S3 object. This URL contains all necessary authentication information and can be used + * to download the object without additional credentials. *

* Note: Presigned URLs have a limited lifetime and will expire after the * specified duration. Ensure the URL is used before expiration. @@ -54,29 +76,16 @@ public URL presignedUrl() { } /** - *

- * Specifies the byte range of an object. For more information about the HTTP Range header, see - * - * https://www.rfc-editor.org/rfc/rfc9110.html#name-range. - *

- * Note: Amazon S3 doesn't support retrieving multiple ranges of data per GET request. + * Returns the headers to be sent with the download request. These are the headers that were signed when + * generating the presigned URL and must be included at download time for the signature to match (for example + * {@code Range}, {@code If-Match}, {@code If-None-Match}, or the SSE-C headers). * - * @return The HTTP Range header value, or null if not specified. - */ - public String range() { - return range; - } - - /** - *

- * Return the object only if its entity tag (ETag) is the same as the one specified in this header, - * otherwise return a 412 (precondition failed) error. - *

+ *

The returned map uses case-insensitive header-name comparison.

* - * @return The If-Match header value, or null if not specified. + * @return An unmodifiable map of header name to values, or an empty map if none set. */ - public String ifMatch() { - return ifMatch; + public Map> headers() { + return headers; } @Override @@ -96,8 +105,7 @@ public static Class serializableBuilderClass() { public int hashCode() { int hashCode = 1; hashCode = 31 * hashCode + Objects.hashCode(presignedUrl()); - hashCode = 31 * hashCode + Objects.hashCode(range()); - hashCode = 31 * hashCode + Objects.hashCode(ifMatch()); + hashCode = 31 * hashCode + Objects.hashCode(headers()); return hashCode; } @@ -111,54 +119,72 @@ public boolean equals(Object obj) { } PresignedUrlDownloadRequest other = (PresignedUrlDownloadRequest) obj; return Objects.equals(presignedUrl(), other.presignedUrl()) && - Objects.equals(range(), other.range()) && - Objects.equals(ifMatch(), other.ifMatch()); + Objects.equals(headers(), other.headers()); } @Override public String toString() { return ToString.builder("PresignedUrlDownloadRequest") .add("PresignedUrl", presignedUrl()) - .add("Range", range()) - .add("IfMatch", ifMatch()) + .add("Headers", headers()) .build(); } public interface Builder extends CopyableBuilder { /** * Sets the presigned URL for the S3 object. - * @param presignedUrl + * @param presignedUrl the presigned URL * @return Returns a reference to this object so that method calls can be chained together. */ Builder presignedUrl(URL presignedUrl); /** - * Specifies the byte range of an object. - * @param range The HTTP Range header value (e.g., "bytes=0-1023") + * Adds a single header to be sent with the download request. Use this to supply any header value that was + * signed when generating the presigned URL (for example {@code Range}, {@code If-Match}, + * {@code If-None-Match}, or the SSE-C headers) and therefore must be present at download time. + * + *

Header names are treated case-insensitively. This overrides any value already configured for the same + * header name.

+ * + * @param name The header name (e.g., {@code "Range"}, {@code "If-Match"}) + * @param value The header value (e.g., {@code "bytes=0-1023"}) + * @return Returns a reference to this object so that method calls can be chained together. + */ + default Builder putHeader(String name, String value) { + return putHeader(name, Collections.singletonList(value)); + } + + /** + * Adds a single header with multiple values to be sent with the download request. + * + *

Header names are treated case-insensitively. This overrides any values already configured for the same + * header name.

+ * + * @param name The header name + * @param values The header values * @return Returns a reference to this object so that method calls can be chained together. */ - Builder range(String range); + Builder putHeader(String name, List values); /** - * Return the object only if its entity tag (ETag) is the same as the one specified in this header. - * @param ifMatch The If-Match header value (ETag) + * Sets all headers to be sent with the download request, replacing any previously configured headers. + * + * @param headers A map of header name to values * @return Returns a reference to this object so that method calls can be chained together. */ - Builder ifMatch(String ifMatch); + Builder headers(Map> headers); } static final class BuilderImpl implements Builder { private URL presignedUrl; - private String range; - private String ifMatch; + private Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private BuilderImpl() { } - private BuilderImpl(PresignedUrlDownloadRequest presignedUrlDownloadRequest) { - presignedUrl(presignedUrlDownloadRequest.presignedUrl()); - range(presignedUrlDownloadRequest.range()); - ifMatch(presignedUrlDownloadRequest.ifMatch()); + private BuilderImpl(PresignedUrlDownloadRequest request) { + presignedUrl(request.presignedUrl()); + headers(request.headers()); } @Override @@ -168,14 +194,19 @@ public Builder presignedUrl(URL presignedUrl) { } @Override - public Builder range(String range) { - this.range = range; + public Builder putHeader(String name, List values) { + Validate.paramNotNull(name, "name"); + Validate.paramNotNull(values, "values"); + this.headers.put(name, new ArrayList<>(values)); return this; } @Override - public Builder ifMatch(String ifMatch) { - this.ifMatch = ifMatch; + public Builder headers(Map> headers) { + Validate.paramNotNull(headers, "headers"); + Map> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + copy.putAll(CollectionUtils.deepCopyMap(headers)); + this.headers = copy; return this; } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java index d92fd69ac3c..bd3dfe874ac 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java @@ -78,7 +78,7 @@ void presignedUrlExtension_rangeSpecified_shouldBypassMultipart() throws Malform AsyncResponseTransformer mockTransformer = mock(AsyncResponseTransformer.class); PresignedUrlDownloadRequest req = PresignedUrlDownloadRequest.builder() .presignedUrl(new URL("https://s3.amazonaws.com/bucket/key?signature=abc")) - .range("bytes=0-1023") + .putHeader("Range", "bytes=0-1023") .build(); when(mockDelegate.presignedUrlExtension()).thenReturn(mockDelegateExtension); S3AsyncClient s3AsyncClient = MultipartS3AsyncClient.create(mockDelegate, MultipartConfiguration.builder().build(), true); diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java index 7902916abf9..cc78f6e92df 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java @@ -133,8 +133,8 @@ void createRangedGetRequest_firstPart_shouldNotIncludeIfMatch() throws Malformed PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( original, 0, 16L, 32L, "\"etag\""); - assertThat(result.range()).isEqualTo("bytes=0-15"); - assertThat(result.ifMatch()).isNull(); + assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=0-15")); + assertThat(result.headers().containsKey("If-Match")).isFalse(); assertThat(result.presignedUrl()).isEqualTo(url); } @@ -148,8 +148,8 @@ void createRangedGetRequest_secondPart_shouldIncludeIfMatch() throws MalformedUR PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( original, 1, 16L, 32L, "\"etag\""); - assertThat(result.range()).isEqualTo("bytes=16-31"); - assertThat(result.ifMatch()).isEqualTo("\"etag\""); + assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=16-31")); + assertThat(result.headers().get("If-Match")).isEqualTo(java.util.Collections.singletonList("\"etag\"")); } @Test @@ -163,7 +163,7 @@ void createRangedGetRequest_lastPartClamped_shouldNotExceedTotalSize() throws Ma PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( original, 1, 16L, 30L, "\"etag\""); - assertThat(result.range()).isEqualTo("bytes=16-29"); + assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=16-29")); } @Test @@ -177,6 +177,6 @@ void createRangedGetRequest_nullTotalContentLength_shouldUseFullPartSize() throw PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( original, 0, 16L, null, null); - assertThat(result.range()).isEqualTo("bytes=0-15"); + assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=0-15")); } } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java index c71534842bb..d73c998a98d 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java @@ -149,7 +149,7 @@ void presignedUrlDownload_withRangeHeader_shouldReceivePartialContent(String tra stubSuccessfulRangeResponse(); PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() .presignedUrl(presignedUrl) - .range("bytes=0-10") + .putHeader("Range", "bytes=0-10") .build(); Object result = executeDownload(request, transformerType).join(); byte[] expectedPartial = Arrays.copyOfRange(TEST_DATA, 0, 11); @@ -333,7 +333,7 @@ void presignedUrlDownload_withRangeHeader_emptyObject_shouldThrow416(String tran PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() .presignedUrl(presignedUrl) - .range("bytes=0-1024") + .putHeader("Range", "bytes=0-1024") .build(); assertThatThrownBy(() -> executeDownload(request, transformerType).join()) .hasRootCauseInstanceOf(S3Exception.class); diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java index 9fed4335aa5..0ccd2fd2696 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java @@ -152,7 +152,7 @@ private static Stream requestConfigurationTestCases() { Arguments.of( "Request with range header", (Consumer) builder -> - builder.presignedUrl(createTestUrl()).range("bytes=0-1024"), + builder.presignedUrl(createTestUrl()).putHeader("Range", "bytes=0-1024"), "bytes=0-1024" ) ); diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java index 04c047cb4cf..2b6d188bb2b 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java @@ -119,7 +119,9 @@ void marshall_withValidRangeFormats_shouldAddRangeHeader(String rangeValue) thro PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(testUrl) - .range(rangeValue) + .headers(java.util.Collections.singletonMap( + "Range", + java.util.Collections.singletonList(rangeValue))) .build(); SdkHttpFullRequest result = marshaller.marshall(request); @@ -144,7 +146,6 @@ void marshall_withNullOrEmptyRange_shouldNotAddRangeHeader(String rangeValue) th PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(testUrl) - .range(rangeValue) .build(); SdkHttpFullRequest result = marshaller.marshall(request); diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java new file mode 100644 index 00000000000..7c165bb814b --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java @@ -0,0 +1,251 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.services.s3.internal.presignedurl; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URL; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest; + +/** + * WireMock tests verifying that signed headers supplied via {@link PresignedUrlDownloadRequest#putHeader} + * are sent in the HTTP request and that missing signed headers result in a signature mismatch (403). + */ +@WireMockTest +class PresignedUrlSignedHeadersWiremockTest { + + private static final String BODY = "hello-presigned"; + private static final String KEY_PATH = "/test-key"; + + private S3AsyncClient s3Client; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmInfo) { + s3Client = S3AsyncClient.builder() + .endpointOverride(java.net.URI.create(wmInfo.getHttpBaseUrl())) + .region(Region.US_EAST_1) + .credentialsProvider(AnonymousCredentialsProvider.create()) + .forcePathStyle(true) + .build(); + } + + @AfterEach + void tearDown() { + if (s3Client != null) { + s3Client.close(); + } + } + + @Test + void getObject_withRangeHeader_shouldSendRangeInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception { + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", equalTo("bytes=0-1023")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", String.valueOf(BODY.length())) + .withHeader("Content-Range", "bytes 0-1023/2048") + .withBody(BODY))); + + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange"); + + ResponseBytes result = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .putHeader("Range", "bytes=0-1023") + .build(), + AsyncResponseTransformer.toBytes()) + .join(); + + assertThat(result.asUtf8String()).isEqualTo(BODY); + verify(getRequestedFor(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", equalTo("bytes=0-1023"))); + } + + @Test + void getObject_withIfMatchHeader_shouldSendIfMatchInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception { + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("If-Match", equalTo("\"abc123\"")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Length", String.valueOf(BODY.length())) + .withBody(BODY))); + + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Bif-match"); + + ResponseBytes result = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .putHeader("If-Match", "\"abc123\"") + .build(), + AsyncResponseTransformer.toBytes()) + .join(); + + assertThat(result.asUtf8String()).isEqualTo(BODY); + verify(getRequestedFor(urlPathEqualTo(KEY_PATH)) + .withHeader("If-Match", equalTo("\"abc123\""))); + } + + @Test + void getObject_withMultipleSignedHeaders_shouldSendAllInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception { + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", equalTo("bytes=0-99")) + .withHeader("If-None-Match", equalTo("\"old-etag\"")) + .withHeader("x-amz-server-side-encryption-customer-algorithm", equalTo("AES256")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", String.valueOf(BODY.length())) + .withHeader("Content-Range", "bytes 0-99/200") + .withBody(BODY))); + + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, + "host%3Bif-none-match%3Brange%3Bx-amz-server-side-encryption-customer-algorithm"); + + ResponseBytes result = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .putHeader("Range", "bytes=0-99") + .putHeader("If-None-Match", "\"old-etag\"") + .putHeader("x-amz-server-side-encryption-customer-algorithm", "AES256") + .build(), + AsyncResponseTransformer.toBytes()) + .join(); + + assertThat(result.asUtf8String()).isEqualTo(BODY); + verify(getRequestedFor(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", equalTo("bytes=0-99")) + .withHeader("If-None-Match", equalTo("\"old-etag\"")) + .withHeader("x-amz-server-side-encryption-customer-algorithm", equalTo("AES256"))); + } + + @Test + void getObject_withMissingSignedHeader_serverReturns403(WireMockRuntimeInfo wmInfo) throws Exception { + // Simulate S3 returning 403 SignatureDoesNotMatch when a signed header is missing + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", WireMock.absent()) + .willReturn(aResponse() + .withStatus(403) + .withHeader("Content-Type", "application/xml") + .withBody("\n" + + "" + + "SignatureDoesNotMatch" + + "The request signature we calculated does not match " + + "the signature you provided." + + ""))); + + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange"); + + // Caller omits the Range header that was signed into the URL + CompletableFuture> future = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build(), + AsyncResponseTransformer.toBytes()); + + assertThatThrownBy(future::join) + .hasCauseInstanceOf(S3Exception.class) + .hasMessageContaining("signature"); + } + + @Test + void getObject_withWrongHeaderValue_serverReturns403(WireMockRuntimeInfo wmInfo) throws Exception { + // S3 rejects if the header value doesn't match what was signed + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", equalTo("bytes=0-1023")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", "1024") + .withHeader("Content-Range", "bytes 0-1023/2048") + .withBody(BODY))); + + // Any request with a different Range gets 403 + stubFor(get(urlPathEqualTo(KEY_PATH)) + .withHeader("Range", WireMock.notMatching("bytes=0-1023")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("Content-Type", "application/xml") + .withBody("\n" + + "" + + "SignatureDoesNotMatch" + + "The request signature we calculated does not match " + + "the signature you provided." + + ""))); + + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange"); + + // Caller sends a different Range value than what was signed + CompletableFuture> future = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .putHeader("Range", "bytes=500-999") + .build(), + AsyncResponseTransformer.toBytes()); + + assertThatThrownBy(future::join) + .hasCauseInstanceOf(S3Exception.class) + .hasMessageContaining("signature"); + } + + @Test + void getObject_withNoSignedHeaders_shouldSucceedWithoutExtraHeaders(WireMockRuntimeInfo wmInfo) throws Exception { + stubFor(get(urlPathEqualTo(KEY_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Length", String.valueOf(BODY.length())) + .withBody(BODY))); + + // URL signed with only "host" — no additional headers needed + URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host"); + + ResponseBytes result = s3Client.presignedUrlExtension() + .getObject(PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build(), + AsyncResponseTransformer.toBytes()) + .join(); + + assertThat(result.asUtf8String()).isEqualTo(BODY); + } + + private static URL presignedUrlWithSignedHeaders(WireMockRuntimeInfo wmInfo, String signedHeaders) throws Exception { + return new URL(wmInfo.getHttpBaseUrl() + KEY_PATH + "?" + + "X-Amz-Algorithm=AWS4-HMAC-SHA256&" + + "X-Amz-SignedHeaders=" + signedHeaders + "&" + + "X-Amz-Signature=fakesig&" + + "X-Amz-Expires=600"); + } +} diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java index 8b301a33219..bdd6fecbe21 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java @@ -18,12 +18,13 @@ import static org.assertj.core.api.Assertions.assertThat; import java.net.URL; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TreeMap; import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.jupiter.api.Test; import software.amazon.awssdk.core.SdkField; -import software.amazon.awssdk.core.protocol.MarshallLocation; class PresignedUrlDownloadRequestWrapperTest { @@ -36,83 +37,69 @@ void equalsAndHashCode_shouldFollowContract() { @Test void basicProperties_shouldWork() throws Exception { + Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.put("Range", Collections.singletonList("bytes=0-100")); + headers.put("If-Match", Collections.singletonList("\"etag-123\"")); + PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(new URL("https://example.com")) - .range("bytes=0-100") - .ifMatch("\"etag-123\"") + .headers(headers) .build(); assertThat(request.url()).isEqualTo(new URL("https://example.com")); - assertThat(request.range()).isEqualTo("bytes=0-100"); - assertThat(request.ifMatch()).isEqualTo("\"etag-123\""); + assertThat(request.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100")); + assertThat(request.headers().get("If-Match")).isEqualTo(Collections.singletonList("\"etag-123\"")); } @Test - void sdkFields_shouldReturnExpectedFields() throws Exception { + void sdkFields_shouldReturnEmptyList() throws Exception { PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(new URL("https://example.com")) - .range("bytes=0-100") + .headers(Collections.emptyMap()) .build(); List> fields = request.sdkFields(); - assertThat(fields).hasSize(2); - assertThat(fields).extracting(SdkField::memberName) - .containsExactlyInAnyOrder("Range", "IfMatch"); - assertThat(fields).allMatch(field -> field.location() == MarshallLocation.HEADER); - SdkField rangeField = fields.stream() - .filter(f -> "Range".equals(f.memberName())) - .findFirst() - .orElseThrow(() -> new AssertionError("Range field not found")); - Object rangeValue = rangeField.getValueOrDefault(request); - assertThat(rangeValue).isNotNull(); - assertThat(rangeValue).isEqualTo("bytes=0-100"); + assertThat(fields).isEmpty(); } @Test - void sdkFieldNameToField_shouldReturnExpectedMapping() throws Exception { + void sdkFieldNameToField_shouldReturnEmptyMapping() throws Exception { PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(new URL("https://example.com")) .build(); Map> fieldMap = request.sdkFieldNameToField(); - assertThat(fieldMap) - .hasSize(2) - .containsKeys("Range", "IfMatch"); - assertThat(fieldMap.get("Range").memberName()).isEqualTo("Range"); - assertThat(fieldMap.get("IfMatch").memberName()).isEqualTo("IfMatch"); + assertThat(fieldMap).isEmpty(); } @Test - void rangeField_shouldMarshalCorrectly() throws Exception { + void headers_shouldBeCaseInsensitive() throws Exception { + Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.put("Range", Collections.singletonList("bytes=0-1023")); + PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(new URL("https://example.com")) - .range("bytes=0-1023") + .headers(headers) .build(); - SdkField rangeField = request.sdkFields().stream() - .filter(f -> "Range".equals(f.memberName())) - .findFirst() - .orElseThrow(() -> new AssertionError("Range field not found")); - Object extractedValue = rangeField.getValueOrDefault(request); - - assertThat(extractedValue).isEqualTo("bytes=0-1023"); + assertThat(request.headers().get("range")).isEqualTo(Collections.singletonList("bytes=0-1023")); + assertThat(request.headers().get("RANGE")).isEqualTo(Collections.singletonList("bytes=0-1023")); } @Test - void ifMatchField_shouldMarshalCorrectly() throws Exception { + void toBuilder_shouldPreserveHeaders() throws Exception { + Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.put("If-Match", Collections.singletonList("\"etag-value\"")); + PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder() .url(new URL("https://example.com")) - .ifMatch("\"etag-value\"") + .headers(headers) .build(); - SdkField ifMatchField = request.sdkFields().stream() - .filter(f -> "IfMatch".equals(f.memberName())) - .findFirst() - .orElseThrow(() -> new AssertionError("IfMatch field not found")); - Object extractedValue = ifMatchField.getValueOrDefault(request); + PresignedUrlDownloadRequestWrapper copy = request.toBuilder().build(); - assertThat(extractedValue).isEqualTo("\"etag-value\""); + assertThat(copy.headers().get("If-Match")).isEqualTo(Collections.singletonList("\"etag-value\"")); } } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java index 1b99c9bde49..49c07119e65 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java @@ -168,7 +168,7 @@ void givenRangeRequest_whenGetObjectCalled_thenReturnsPartialContent(WireMockRun URL presignedUrl = createPresignedUrl(wireMockRuntimeInfo, presignedUrlPath); PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() .presignedUrl(presignedUrl) - .range(range) + .putHeader("Range", range) .build(); CompletableFuture> result = diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java index 1cc7b34364f..06e7f20cab8 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.net.URL; +import java.util.Collections; import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.jupiter.api.Test; @@ -34,11 +35,11 @@ void builder_shouldCreateRequestWithAllFields() throws Exception { URL url = new URL("https://example.com"); PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() .presignedUrl(url) - .range("bytes=0-100") + .putHeader("Range", "bytes=0-100") .build(); assertThat(request.presignedUrl()).isEqualTo(url); - assertThat(request.range()).isEqualTo("bytes=0-100"); + assertThat(request.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100")); } @Test @@ -49,7 +50,7 @@ void builder_shouldCreateRequestWithOnlyRequiredFields() throws Exception { .build(); assertThat(request.presignedUrl()).isEqualTo(url); - assertThat(request.range()).isNull(); + assertThat(request.headers()).isEmpty(); } @Test @@ -57,13 +58,13 @@ void toBuilder_shouldCreateBuilderFromExistingRequest() throws Exception { URL url = new URL("https://example.com"); PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() .presignedUrl(url) - .range("bytes=0-100") + .putHeader("Range", "bytes=0-100") .build(); PresignedUrlDownloadRequest copy = original.toBuilder().build(); assertThat(copy.presignedUrl()).isEqualTo(original.presignedUrl()); - assertThat(copy.range()).isEqualTo(original.range()); + assertThat(copy.headers()).isEqualTo(original.headers()); } @Test @@ -72,29 +73,28 @@ void toBuilder_shouldAllowModification() throws Exception { URL url2 = new URL("https://other.com"); PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() .presignedUrl(url1) - .range("bytes=0-100") + .putHeader("Range", "bytes=0-100") .build(); PresignedUrlDownloadRequest modified = original.toBuilder() .presignedUrl(url2) - .range("bytes=200-300") + .putHeader("Range", "bytes=200-300") .build(); assertThat(modified.presignedUrl()).isEqualTo(url2); - assertThat(modified.range()).isEqualTo("bytes=200-300"); + assertThat(modified.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=200-300")); // Original unchanged assertThat(original.presignedUrl()).isEqualTo(url1); - assertThat(original.range()).isEqualTo("bytes=0-100"); + assertThat(original.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100")); } @Test void toString_shouldContainActualFieldValues() throws Exception { URL url = new URL("https://example.com"); - String range = "bytes=0-100"; PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() .presignedUrl(url) - .range(range) + .putHeader("Range", "bytes=0-100") .build(); String result = request.toString(); @@ -102,9 +102,7 @@ void toString_shouldContainActualFieldValues() throws Exception { assertThat(result) .isNotNull() .isNotEmpty() - .contains(request.presignedUrl().toString()) - .contains(request.range()); - + .contains(request.presignedUrl().toString()); } @Test From 9d9144073e62456de92cc69c9dbb18634729bb3a Mon Sep 17 00:00:00 2001 From: jencymaryjoseph <35571282+jencymaryjoseph@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:52:17 -0700 Subject: [PATCH 3/3] Fix in transfer manager --- .../S3TransferManagerPresignedUrlDownloadIntegrationTest.java | 4 ++-- .../awssdk/transfer/s3/internal/GenericS3TransferManager.java | 4 ++-- .../S3TransferManagerPresignedUrlListenerWiremockTest.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java index 003fddb5d5b..dc0e1bf0284 100644 --- a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java +++ b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java @@ -128,7 +128,7 @@ void downloadFileWithPresignedUrl_progressTracking(String tmType, S3TransferMana PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder() .presignedUrl(createPresignedRequest(key).url()); if (range != null) { - requestBuilder.range(range); + requestBuilder.putHeader("Range", range); } PresignedFileDownload download = tm.downloadFileWithPresignedUrl( @@ -159,7 +159,7 @@ void downloadWithPresignedUrl_toBytes_progressTracking(String tmType, S3Transfer PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder() .presignedUrl(createPresignedRequest(key).url()); if (range != null) { - requestBuilder.range(range); + requestBuilder.putHeader("Range", range); } Download> download = tm.downloadWithPresignedUrl( diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java index c86a3fea12f..f913f1f2606 100644 --- a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java +++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java @@ -615,7 +615,7 @@ public final PresignedFileDownload downloadFileWithPresignedUrl(PresignedDownloa progressUpdater.transferInitiated(); responseTransformer = isS3ClientMultipartEnabled() - && presignedDownloadFileRequest.presignedUrlDownloadRequest().range() == null + && !presignedDownloadFileRequest.presignedUrlDownloadRequest().headers().containsKey("Range") ? progressUpdater.wrapForNonSerialFileDownload( responseTransformer, GetObjectRequest.builder().build()) : progressUpdater.wrapResponseTransformer(responseTransformer); @@ -651,7 +651,7 @@ public final Download downloadWithPresignedUrl( progressUpdater.transferInitiated(); responseTransformer = isS3ClientMultipartEnabled() - && presignedDownloadRequest.presignedUrlDownloadRequest().range() == null + && !presignedDownloadRequest.presignedUrlDownloadRequest().headers().containsKey("Range") ? progressUpdater.wrapForNonSerialFileDownload( responseTransformer, GetObjectRequest.builder().build()) : progressUpdater.wrapResponseTransformer(responseTransformer); diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java index 2e9e5dbecca..27a6d95c54c 100644 --- a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java +++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java @@ -119,7 +119,7 @@ void presignedUrlDownload_shouldInvokeListener(boolean multipartEnabled, String PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder() .presignedUrl(presignedUrl); if (range != null) { - requestBuilder.range(range); + requestBuilder.putHeader("Range", range); } if ("toFile".equals(type)) {