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");
+ }
+}