diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java index 8be5bb8ee5b4..cf1568b7a432 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtils.java @@ -78,20 +78,23 @@ public static Optional multipartDownloadResumeCo } /** - * Parses the start byte from a Content-Range header. - * + * Parses start byte and end byte from a Content-Range header. + * * @param contentRange the Content-Range header value (e.g., "bytes 0-1023/2048") - * @return the start byte position, or -1 if parsing fails + * @return array of [startByte, endByte], or null if parsing fails */ - public static long parseStartByteFromContentRange(String contentRange) { + public static long[] parseContentRange(String contentRange) { if (contentRange == null) { - return -1; + return null; } Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange); if (!matcher.matches()) { - return -1; + return null; } - return Long.parseLong(matcher.group(1)); + return new long[] { + Long.parseLong(matcher.group(1)), + Long.parseLong(matcher.group(2)) + }; } /** @@ -111,4 +114,15 @@ public static Optional parseContentRangeForTotalSize(String contentRange) return Optional.of(Long.parseLong(matcher.group(3))); } + /** + * Calculates the total number of parts needed to download an object of the given size. + * + * @param contentLength total object size in bytes + * @param partSize size of each part in bytes + * @return the number of parts + */ + public static int calculateTotalParts(long contentLength, long partSize) { + return (int) Math.ceil((double) contentLength / partSize); + } + } 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 8df801f96d34..5c62778d85ee 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 @@ -50,7 +50,6 @@ public class ParallelPresignedUrlMultipartDownloaderSubscriber implements Subscriber> { private static final Logger log = Logger.loggerFor(ParallelPresignedUrlMultipartDownloaderSubscriber.class); - private static final String BYTES_RANGE_PREFIX = "bytes="; private final S3AsyncClient s3AsyncClient; private final PresignedUrlDownloadRequest presignedUrlDownloadRequest; @@ -66,7 +65,10 @@ public class ParallelPresignedUrlMultipartDownloaderSubscriber private final AtomicInteger partNumber = new AtomicInteger(0); private final AtomicInteger completedParts = new AtomicInteger(0); private final Semaphore inFlightPermits; - private final AtomicBoolean isCompletedExceptionally = new AtomicBoolean(false); + /** + * CAS gate ensuring only the first part failure triggers error handling and cancellation. + */ + private final AtomicBoolean downloadFailed = new AtomicBoolean(false); private final AtomicBoolean processingPending = new AtomicBoolean(false); private final Map> inFlightRequests = new ConcurrentHashMap<>(); private final Queue>> pendingTransformers = @@ -138,6 +140,7 @@ private void sendFirstRequest(AsyncResponseTransformer { inFlightRequests.remove(0); @@ -156,8 +159,7 @@ private void sendFirstRequest(AsyncResponseTransformer String.format("Total content length: %d, Total parts: %d", totalContentLength, totalParts)); + Optional validationError = validatePartResponse(res, 0); + if (validationError.isPresent()) { + handlePartError(validationError.get(), 0); + return; + } + if (totalParts <= 1) { resultFuture.complete(firstResponse); synchronized (subscriptionLock) { @@ -217,7 +225,7 @@ private void processRequest(AsyncResponseTransformer transformer, int partIndex) { - if (isCompletedExceptionally.get()) { + if (downloadFailed.get()) { inFlightPermits.release(); return; } @@ -235,10 +243,14 @@ private void sendPartRequest(AsyncResponseTransformer "Ignoring late completion for part " + partIndex + ", download already failed"); + return; + } Optional validationError = validatePartResponse(res, partIndex); if (validationError.isPresent()) { @@ -288,35 +300,12 @@ private void processPendingTransformers() { } private Optional validatePartResponse(GetObjectResponse response, int partIndex) { - String contentRange = response.contentRange(); - if (contentRange == null) { - return Optional.of(PresignedUrlDownloadHelper.missingContentRangeHeader()); - } - Long contentLength = response.contentLength(); - if (contentLength == null || contentLength < 0) { - return Optional.of(PresignedUrlDownloadHelper.invalidContentLength()); - } - long expectedStartByte = partIndex * configuredPartSizeInBytes; - long expectedEndByte = Math.min(expectedStartByte + configuredPartSizeInBytes - 1, totalContentLength - 1); - String expectedRange = "bytes " + expectedStartByte + "-" + expectedEndByte + "/"; - if (!contentRange.startsWith(expectedRange)) { - return Optional.of(SdkClientException.create( - "Content-Range mismatch. Expected range starting with: " + expectedRange + - ", but got: " + contentRange)); - } - long expectedPartSize = (partIndex == totalParts - 1) - ? totalContentLength - (partIndex * configuredPartSizeInBytes) - : configuredPartSizeInBytes; - if (!contentLength.equals(expectedPartSize)) { - return Optional.of(SdkClientException.create( - String.format("Part content length validation failed for part %d. Expected: %d, but got: %d", - partIndex, expectedPartSize, contentLength))); - } - return Optional.empty(); + return PresignedUrlDownloadHelper.validatePartResponse( + response, partIndex, configuredPartSizeInBytes, totalContentLength, totalParts); } private void handlePartError(Throwable error, int partIndex) { - if (isCompletedExceptionally.compareAndSet(false, true)) { + if (downloadFailed.compareAndSet(false, true)) { log.debug(() -> "Error on part " + partIndex, error); resultFuture.completeExceptionally(error); inFlightRequests.values().forEach(future -> future.cancel(true)); @@ -329,24 +318,8 @@ private void handlePartError(Throwable error, int partIndex) { } private PresignedUrlDownloadRequest createRangedGetRequest(int partIndex) { - long startByte = partIndex * configuredPartSizeInBytes; - long endByte; - if (totalContentLength != null) { - endByte = Math.min(startByte + configuredPartSizeInBytes - 1, totalContentLength - 1); - } else { - endByte = startByte + configuredPartSizeInBytes - 1; - } - String rangeHeader = BYTES_RANGE_PREFIX + startByte + "-" + endByte; - PresignedUrlDownloadRequest.Builder builder = presignedUrlDownloadRequest.toBuilder() - .range(rangeHeader); - if (partIndex > 0 && eTag != null) { - builder.ifMatch(eTag); - } - return builder.build(); - } - - private int calculateTotalParts(long contentLength, long partSize) { - return (int) Math.ceil((double) contentLength / partSize); + return PresignedUrlDownloadHelper.createRangedGetRequest( + presignedUrlDownloadRequest, partIndex, configuredPartSizeInBytes, totalContentLength, eTag); } @Override 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 3f5c955abad7..b6d070d23bc2 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 @@ -15,6 +15,7 @@ package software.amazon.awssdk.services.s3.internal.multipart; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import software.amazon.awssdk.annotations.SdkInternalApi; @@ -142,6 +143,93 @@ static SdkClientException invalidContentLength() { return SdkClientException.create("Invalid or missing Content-Length in response"); } + /** + * Validates a part response for data integrity. Checks that Content-Range and Content-Length + * match the expected values based on part index, part size, and total object size. + * + * @param response the GetObject response to validate + * @param partIndex zero-based index of this part + * @param partSizeInBytes configured part size + * @param totalContentLength total object size (from Content-Range), or null if not yet known + * @param totalParts total number of parts, or null if not yet known + * @return empty if valid, or an SdkClientException describing the mismatch + */ + static Optional validatePartResponse(GetObjectResponse response, + int partIndex, + long partSizeInBytes, + Long totalContentLength, + Integer totalParts) { + String contentRange = response.contentRange(); + if (contentRange == null) { + return Optional.of(missingContentRangeHeader()); + } + + Long contentLength = response.contentLength(); + if (contentLength == null || contentLength < 0) { + return Optional.of(invalidContentLength()); + } + + long expectedStartByte = partIndex * partSizeInBytes; + long[] parsedRange = MultipartDownloadUtils.parseContentRange(contentRange); + if (parsedRange == null) { + return Optional.of(invalidContentRangeHeader(contentRange)); + } + long actualStartByte = parsedRange[0]; + long actualEndByte = parsedRange[1]; + if (actualStartByte != expectedStartByte) { + return Optional.of(SdkClientException.create( + "Content-Range mismatch for part " + partIndex + ". Expected start byte: " + expectedStartByte + + ", but got: bytes " + actualStartByte + "-" + actualEndByte)); + } + if (totalContentLength != null) { + long expectedEndByte = Math.min(expectedStartByte + partSizeInBytes - 1, totalContentLength - 1); + if (actualEndByte != expectedEndByte) { + return Optional.of(SdkClientException.create( + "Content-Range mismatch for part " + partIndex + ". Expected: bytes " + expectedStartByte + "-" + + expectedEndByte + ", but got: bytes " + actualStartByte + "-" + actualEndByte)); + } + } + + if (totalContentLength != null && totalParts != null) { + long expectedPartSize = (partIndex == totalParts - 1) + ? totalContentLength - (partIndex * partSizeInBytes) + : partSizeInBytes; + if (!contentLength.equals(expectedPartSize)) { + return Optional.of(SdkClientException.create( + String.format("Part content length validation failed for part %d. Expected: %d, but got: %d", + partIndex, expectedPartSize, contentLength))); + } + } + return Optional.empty(); + } + + /** + * Creates a range-based GET request for a specific part of a presigned URL download. + * + * @param originalRequest the original presigned URL request + * @param partIndex zero-based index of this part + * @param partSizeInBytes configured part size + * @param totalContentLength total object size, or null if not yet known (first part) + * @param eTag ETag from first response, used for If-Match on parts 1+ + * @return a new PresignedUrlDownloadRequest with the appropriate Range and If-Match headers + */ + static PresignedUrlDownloadRequest createRangedGetRequest(PresignedUrlDownloadRequest originalRequest, + int partIndex, + long partSizeInBytes, + Long totalContentLength, + String eTag) { + long startByte = partIndex * partSizeInBytes; + long endByte = totalContentLength != null + ? Math.min(startByte + partSizeInBytes - 1, totalContentLength - 1) + : startByte + partSizeInBytes - 1; + PresignedUrlDownloadRequest.Builder builder = originalRequest.toBuilder() + .range("bytes=" + startByte + "-" + endByte); + if (partIndex > 0 && eTag != null) { + builder.ifMatch(eTag); + } + return builder.build(); + } + /** * Returns true if the error is a 416 Range Not Satisfiable response from S3. * Used by subscribers to detect empty object responses on the first range request. 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 2f698eea58f2..bb65bb4847db 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 @@ -22,7 +22,6 @@ import java.util.concurrent.atomic.AtomicInteger; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; -import software.amazon.awssdk.annotations.Immutable; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.ThreadSafe; import software.amazon.awssdk.core.async.AsyncResponseTransformer; @@ -44,13 +43,11 @@ * ordering and validation of responses.

*/ @ThreadSafe -@Immutable @SdkInternalApi public class PresignedUrlMultipartDownloaderSubscriber implements Subscriber> { private static final Logger log = Logger.loggerFor(PresignedUrlMultipartDownloaderSubscriber.class); - private static final String BYTES_RANGE_PREFIX = "bytes="; private final S3AsyncClient s3AsyncClient; private final PresignedUrlDownloadRequest presignedUrlDownloadRequest; @@ -70,7 +67,7 @@ public class PresignedUrlMultipartDownloaderSubscriber */ private final CompletableFuture resultFuture; private final Object lock = new Object(); - private final AtomicInteger completedParts; + private final AtomicInteger nextPartIndex; private final AtomicInteger requestsSent; /** @@ -91,7 +88,7 @@ public PresignedUrlMultipartDownloaderSubscriber( this.s3AsyncClient = s3AsyncClient; this.presignedUrlDownloadRequest = presignedUrlDownloadRequest; this.configuredPartSizeInBytes = configuredPartSizeInBytes; - this.completedParts = new AtomicInteger(0); + this.nextPartIndex = new AtomicInteger(0); this.requestsSent = new AtomicInteger(0); this.future = new CompletableFuture<>(); this.resultFuture = resultFuture; @@ -113,17 +110,17 @@ public void onNext(AsyncResponseTransformer= totalParts) { + currentPartIndex = nextPartIndex.get(); + if (totalParts != null && currentPartIndex >= totalParts) { log.debug(() -> String.format("Completing multipart download after a total of %d parts downloaded.", totalParts)); subscription.cancel(); return; } - completedParts.incrementAndGet(); + nextPartIndex.incrementAndGet(); } - makeRangeRequest(nextPartIndex, asyncResponseTransformer); + makeRangeRequest(currentPartIndex, asyncResponseTransformer); } private void makeRangeRequest(int partIndex, @@ -151,15 +148,15 @@ private void makeRangeRequest(int partIndex, return; } if (validatePart(response, partIndex, asyncResponseTransformer)) { - requestMoreIfNeeded(completedParts.get()); + requestMoreIfNeeded(nextPartIndex.get()); } }); } private boolean validatePart(GetObjectResponse response, int partIndex, AsyncResponseTransformer asyncResponseTransformer) { - int totalComplete = completedParts.get(); - log.debug(() -> String.format("Completed part %d", totalComplete)); + int dispatched = nextPartIndex.get(); + log.debug(() -> String.format("Dispatched %d parts so far", dispatched)); String responseETag = response.eTag(); String responseContentRange = response.contentRange(); @@ -179,7 +176,7 @@ private boolean validatePart(GetObjectResponse response, int partIndex, } this.totalContentLength = parsedContentLength.get(); - this.totalParts = calculateTotalParts(totalContentLength, configuredPartSizeInBytes); + this.totalParts = MultipartDownloadUtils.calculateTotalParts(totalContentLength, configuredPartSizeInBytes); log.debug(() -> String.format("Total content length: %d, Total parts: %d", totalContentLength, totalParts)); } @@ -194,9 +191,9 @@ private boolean validatePart(GetObjectResponse response, int partIndex, return true; } - private void requestMoreIfNeeded(int totalComplete) { + private void requestMoreIfNeeded(int dispatched) { synchronized (lock) { - if (hasMoreParts(totalComplete)) { + if (hasMoreParts(dispatched)) { subscription.request(1); } else { if (totalParts != null && requestsSent.get() != totalParts) { @@ -211,78 +208,17 @@ private void requestMoreIfNeeded(int totalComplete) { } private Optional validateResponse(GetObjectResponse response, int partIndex) { - if (response == null) { - return Optional.of(SdkClientException.create("Response cannot be null")); - } - String contentRange = response.contentRange(); - if (contentRange == null) { - return Optional.of(PresignedUrlDownloadHelper.missingContentRangeHeader()); - } - - Long contentLength = response.contentLength(); - if (contentLength == null || contentLength < 0) { - return Optional.of(PresignedUrlDownloadHelper.invalidContentLength()); - } - - long expectedStartByte = partIndex * configuredPartSizeInBytes; - long expectedEndByte; - if (totalContentLength != null) { - expectedEndByte = Math.min(expectedStartByte + configuredPartSizeInBytes - 1, totalContentLength - 1); - } else { - expectedEndByte = expectedStartByte + configuredPartSizeInBytes - 1; - } - String expectedRange = "bytes " + expectedStartByte + "-" + expectedEndByte + "/"; - if (!contentRange.startsWith(expectedRange)) { - return Optional.of(SdkClientException.create( - "Content-Range mismatch. Expected range starting with: " + expectedRange + - ", but got: " + contentRange)); - } - - long expectedPartSize; - if (totalContentLength != null && partIndex == totalParts - 1) { - expectedPartSize = totalContentLength - (partIndex * configuredPartSizeInBytes); - } else { - expectedPartSize = configuredPartSizeInBytes; - } - if (!contentLength.equals(expectedPartSize)) { - return Optional.of(SdkClientException.create( - String.format("Part content length validation failed for part %d. Expected: %d, but got: %d", - partIndex, expectedPartSize, contentLength))); - } - - long actualStartByte = MultipartDownloadUtils.parseStartByteFromContentRange(contentRange); - if (actualStartByte != expectedStartByte) { - return Optional.of(SdkClientException.create( - "Content range offset mismatch for part " + partIndex + - ". Expected start: " + expectedStartByte + ", but got: " + actualStartByte)); - } - - return Optional.empty(); + return PresignedUrlDownloadHelper.validatePartResponse( + response, partIndex, configuredPartSizeInBytes, totalContentLength, totalParts); } - private int calculateTotalParts(long contentLength, long partSize) { - return (int) Math.ceil((double) contentLength / partSize); - } - - private boolean hasMoreParts(int completedPartsCount) { - return totalParts != null && completedPartsCount < totalParts; + private boolean hasMoreParts(int dispatched) { + return totalParts != null && dispatched < totalParts; } private PresignedUrlDownloadRequest createRangedGetRequest(int partIndex) { - long startByte = partIndex * configuredPartSizeInBytes; - long endByte; - if (totalContentLength != null) { - endByte = Math.min(startByte + configuredPartSizeInBytes - 1, totalContentLength - 1); - } else { - endByte = startByte + configuredPartSizeInBytes - 1; - } - String rangeHeader = BYTES_RANGE_PREFIX + startByte + "-" + endByte; - PresignedUrlDownloadRequest.Builder builder = presignedUrlDownloadRequest.toBuilder() - .range(rangeHeader); - if (partIndex > 0 && eTag != null) { - builder.ifMatch(eTag); - } - return builder.build(); + return PresignedUrlDownloadHelper.createRangedGetRequest( + presignedUrlDownloadRequest, partIndex, configuredPartSizeInBytes, totalContentLength, eTag); } private void handleError(Throwable t) { diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtilsTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtilsTest.java index ef44f1d5566e..e4f04f486aa0 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtilsTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartDownloadUtilsTest.java @@ -61,12 +61,18 @@ void contextWithParts_contextShouldBePresent() { } @Test - void parseStartByteFromContentRange_shouldParseValidAndInvalidRanges() { - assertThat(MultipartDownloadUtils.parseStartByteFromContentRange("bytes 0-1023/2048")).isEqualTo(0); - assertThat(MultipartDownloadUtils.parseStartByteFromContentRange("bytes 1024-2047/2048")).isEqualTo(1024); + void parseContentRange_shouldParseValidAndInvalidRanges() { + long[] result = MultipartDownloadUtils.parseContentRange("bytes 0-1023/2048"); + assertThat(result).isNotNull(); + assertThat(result[0]).isEqualTo(0); + assertThat(result[1]).isEqualTo(1023); - assertThat(MultipartDownloadUtils.parseStartByteFromContentRange("invalid")).isEqualTo(-1); - assertThat(MultipartDownloadUtils.parseStartByteFromContentRange(null)).isEqualTo(-1); + result = MultipartDownloadUtils.parseContentRange("bytes 1024-2047/2048"); + assertThat(result[0]).isEqualTo(1024); + assertThat(result[1]).isEqualTo(2047); + + assertThat(MultipartDownloadUtils.parseContentRange("invalid")).isNull(); + assertThat(MultipartDownloadUtils.parseContentRange(null)).isNull(); } @Test @@ -77,4 +83,13 @@ void parseContentRangeForTotalSize_shouldParseValidAndInvalidRanges() { assertThat(MultipartDownloadUtils.parseContentRangeForTotalSize("invalid")).isEmpty(); assertThat(MultipartDownloadUtils.parseContentRangeForTotalSize(null)).isEmpty(); } + + @Test + void calculateTotalParts_shouldCalculateCorrectly() { + assertThat(MultipartDownloadUtils.calculateTotalParts(32, 16)).isEqualTo(2); // exact fit + assertThat(MultipartDownloadUtils.calculateTotalParts(33, 16)).isEqualTo(3); // remainder rounds up + assertThat(MultipartDownloadUtils.calculateTotalParts(1, 16)).isEqualTo(1); // smaller than part size + assertThat(MultipartDownloadUtils.calculateTotalParts(16, 16)).isEqualTo(1); // exactly one part + assertThat(MultipartDownloadUtils.calculateTotalParts(0, 16)).isEqualTo(0); // empty object + } } \ No newline at end of file diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriberTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriberTest.java index e29558531418..57ace09759f0 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriberTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/ParallelPresignedUrlMultipartDownloaderSubscriberTest.java @@ -336,6 +336,84 @@ void multipartObject_416OnSecondRequest_shouldFailWithError() { .isInstanceOf(CompletionException.class); } + @Test + void getObject_firstPartContentLengthMismatch_shouldFailWithValidationError() throws IOException { + stubFor(get(urlEqualTo(PRESIGNED_URL_PATH)) + .withHeader("Range", matching("bytes=0-15")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", "10") + .withHeader("Content-Range", "bytes 0-15/32") + .withHeader("ETag", "\"test-etag\"") + .withBody(Arrays.copyOfRange(TEST_DATA, 0, 10)))); + + tempFile = createTempFileUnchecked(); + PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build(); + + assertThatThrownBy(() -> s3AsyncClient.presignedUrlExtension() + .getObject(request, AsyncResponseTransformer.toFile(tempFile)) + .join()) + .isInstanceOf(CompletionException.class) + .hasRootCauseInstanceOf(SdkClientException.class) + .hasMessageContaining("content length validation failed"); + } + + @Test + void getObject_firstPartContentRangeStartByteMismatch_shouldFailWithValidationError() throws IOException { + stubFor(get(urlEqualTo(PRESIGNED_URL_PATH)) + .withHeader("Range", matching("bytes=0-15")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", "16") + .withHeader("Content-Range", "bytes 5-20/32") + .withHeader("ETag", "\"test-etag\"") + .withBody(Arrays.copyOfRange(TEST_DATA, 0, 16)))); + + tempFile = createTempFileUnchecked(); + PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build(); + + assertThatThrownBy(() -> s3AsyncClient.presignedUrlExtension() + .getObject(request, AsyncResponseTransformer.toFile(tempFile)) + .join()) + .isInstanceOf(CompletionException.class) + .hasRootCauseInstanceOf(SdkClientException.class) + .hasMessageContaining("Content-Range mismatch"); + } + + @Test + void getObject_objectMutatedBetweenParts_shouldFailWith412() throws IOException { + stubFor(get(urlEqualTo(PRESIGNED_URL_PATH)) + .withHeader("Range", matching("bytes=0-15")) + .willReturn(aResponse() + .withStatus(206) + .withHeader("Content-Length", "16") + .withHeader("Content-Range", "bytes 0-15/32") + .withHeader("ETag", "\"original-etag\"") + .withBody(Arrays.copyOfRange(TEST_DATA, 0, 16)))); + + stubFor(get(urlEqualTo(PRESIGNED_URL_PATH)) + .withHeader("Range", matching("bytes=16-31")) + .withHeader("If-Match", matching("\"original-etag\"")) + .willReturn(aResponse() + .withStatus(412) + .withBody("PreconditionFailed" + + "Object changed"))); + + tempFile = createTempFileUnchecked(); + PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder() + .presignedUrl(presignedUrl) + .build(); + + assertThatThrownBy(() -> s3AsyncClient.presignedUrlExtension() + .getObject(request, AsyncResponseTransformer.toFile(tempFile)) + .join()) + .isInstanceOf(CompletionException.class); + } + private static Path createTempFile() throws IOException { Path path = Files.createTempFile("parallel-test-" + UUID.randomUUID(), ".tmp"); Files.deleteIfExists(path); 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 new file mode 100644 index 000000000000..ed58af0aae71 --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java @@ -0,0 +1,182 @@ +/* + * 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.multipart; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest; + +class PresignedUrlDownloadHelperTest { + + @Test + void validatePartResponse_validResponse_shouldReturnEmpty() { + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 0-15/32") + .contentLength(16L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, 32L, 2); + + assertThat(result).isEmpty(); + } + + @Test + void validatePartResponse_missingContentRange_shouldReturnError() { + GetObjectResponse response = GetObjectResponse.builder() + .contentLength(16L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, 32L, 2); + + assertThat(result).isPresent(); + assertThat(result.get().getMessage()).contains("No Content-Range header"); + } + + @Test + void validatePartResponse_invalidContentLength_shouldReturnError() { + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 0-15/32") + .contentLength(-1L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, 32L, 2); + + assertThat(result).isPresent(); + assertThat(result.get().getMessage()).contains("Invalid or missing Content-Length"); + } + + @Test + void validatePartResponse_contentRangeMismatch_shouldReturnError() { + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 5-20/32") + .contentLength(16L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, 32L, 2); + + assertThat(result).isPresent(); + assertThat(result.get().getMessage()).contains("Content-Range mismatch for part 0"); + } + + @Test + void validatePartResponse_contentLengthMismatch_shouldReturnError() { + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 0-15/32") + .contentLength(10L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, 32L, 2); + + assertThat(result).isPresent(); + assertThat(result.get().getMessage()).contains("content length validation failed"); + } + + @Test + void validatePartResponse_lastPartSmallerSize_shouldPass() { + // 30-byte object, 16-byte parts → part 1 is bytes 16-29 (14 bytes) + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 16-29/30") + .contentLength(14L) + .build(); + + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 1, 16L, 30L, 2); + + assertThat(result).isEmpty(); + } + + @Test + void validatePartResponse_nullTotalContentLength_shouldStillValidateRange() { + GetObjectResponse response = GetObjectResponse.builder() + .contentRange("bytes 0-15/32") + .contentLength(16L) + .build(); + + // When totalContentLength is null, content-length check is skipped but range check still works + Optional result = PresignedUrlDownloadHelper.validatePartResponse( + response, 0, 16L, null, null); + + assertThat(result).isEmpty(); + } + + @Test + void createRangedGetRequest_firstPart_shouldNotIncludeIfMatch() throws MalformedURLException { + URL url = new URL("https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc"); + PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() + .presignedUrl(url) + .build(); + + PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( + original, 0, 16L, 32L, "\"etag\""); + + assertThat(result.range()).isEqualTo("bytes=0-15"); + assertThat(result.ifMatch()).isNull(); + assertThat(result.presignedUrl()).isEqualTo(url); + } + + @Test + void createRangedGetRequest_secondPart_shouldIncludeIfMatch() throws MalformedURLException { + URL url = new URL("https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc"); + PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() + .presignedUrl(url) + .build(); + + PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( + original, 1, 16L, 32L, "\"etag\""); + + assertThat(result.range()).isEqualTo("bytes=16-31"); + assertThat(result.ifMatch()).isEqualTo("\"etag\""); + } + + @Test + void createRangedGetRequest_lastPartClamped_shouldNotExceedTotalSize() throws MalformedURLException { + URL url = new URL("https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc"); + PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() + .presignedUrl(url) + .build(); + + // 30-byte object, 16-byte parts → part 1 should be bytes=16-29 (not 16-31) + PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( + original, 1, 16L, 30L, "\"etag\""); + + assertThat(result.range()).isEqualTo("bytes=16-29"); + } + + @Test + void createRangedGetRequest_nullTotalContentLength_shouldUseFullPartSize() throws MalformedURLException { + URL url = new URL("https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc"); + PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder() + .presignedUrl(url) + .build(); + + // First part when total size unknown — uses full part size without clamping + PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest( + original, 0, 16L, null, null); + + assertThat(result.range()).isEqualTo("bytes=0-15"); + } +}