From a37b21bf2e69790194247525d913404529e017da Mon Sep 17 00:00:00 2001 From: Dhriti Chopra Date: Wed, 28 Oct 2026 09:38:09 +0000 Subject: [PATCH 1/4] fix(storage): validate CRC32C on HTTP downloads that were not decompressed GCS returns x-goog-stored-content-encoding and x-goog-stored-content-length on every object download, but HttpStorageRpcHasherHelper treated the presence of either header as transcoding, so CRC32C validation was skipped for all HTTP reads (readAllBytes, downloadTo, reader). Only skip validation when the object is stored gzip-compressed and was served decompressed, or when the HTTP client decompressed it. Fixes #14773 --- .../storage/HttpStorageRpcHasherHelper.java | 14 +++++-- ...iaryUnbufferedReadableByteChannelTest.java | 40 +++++++++++++++++++ .../HttpStorageRpcHasherHelperTest.java | 6 ++- .../spi/v1/HttpStorageRpcChecksumTest.java | 19 +++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java index 7d1bf13ff629..778173fb893e 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; +import java.util.Locale; import java.util.Map; import java.util.function.Supplier; @@ -106,12 +107,19 @@ private static boolean isFullObjectResponse(HttpResponse response) { } private boolean isTranscoded(HttpResponse response) { + return isDecompressedByServer(response) || isDecompressedByClient(response); + } + + /** GCS stored the object gzip-compressed but served it decompressed. */ + private static boolean isDecompressedByServer(HttpResponse response) { com.google.api.client.http.HttpHeaders headers = response.getHeaders(); String storedEncoding = HttpClientContext.firstHeaderValue(headers, "x-goog-stored-content-encoding"); - String storedLength = - HttpClientContext.firstHeaderValue(headers, "x-goog-stored-content-length"); - return storedEncoding != null || storedLength != null || isDecompressedByClient(response); + return isGzip(storedEncoding) && !isGzip(headers.getContentEncoding()); + } + + private static boolean isGzip(String encoding) { + return encoding != null && encoding.toLowerCase(Locale.ROOT).contains("gzip"); } private boolean isDecompressedByClient(HttpResponse response) { diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java index ac0a7c70fd73..d130b3859595 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java @@ -228,6 +228,46 @@ public void testRead_partialRangeNoCrc32cValidation() throws IOException { } } + @Test + public void testRead_gcsHeaders_mismatchedCrc32c_throwsChecksumMismatch() throws IOException { + // GCS returns x-goog-stored-content-* on every download, not only for transcoded objects. + Map extraHeaders = + ImmutableMap.of( + "x-goog-stored-content-encoding", + "identity", + "x-goog-stored-content-length", + String.valueOf(CONTENT_BYTES.length)); + Storage storageClient = + createMockStorageClient(200, "crc32c=" + WRONG_CRC32C_BASE64, extraHeaders); + + StorageObject from = new StorageObject().setBucket("bucket").setName("blob"); + ApiaryReadRequest apiaryReadRequest = + new ApiaryReadRequest(from, ImmutableMap.of(), ByteRangeSpec.nullRange()); + + SettableApiFuture resultFuture = SettableApiFuture.create(); + try (ApiaryUnbufferedReadableByteChannel channel = + new ApiaryUnbufferedReadableByteChannel( + apiaryReadRequest, + storageClient, + resultFuture, + RetrierWithAlg.attemptOnce(), + Retrying.neverRetry(), + Hasher.defaultHasher()); ) { + + ByteBuffer buf = ByteBuffer.allocate(4096); + Hasher.ChecksumMismatchException expected = + assertThrows( + Hasher.ChecksumMismatchException.class, + () -> { + while (channel.read(new ByteBuffer[] {buf}, 0, 1) != -1) { + buf.clear(); + } + }); + + assertTrue(expected.getMessage().contains("Mismatch checksum value")); + } + } + @Test public void logsWarning_whenReceivingMoreBytesThanRequested() throws IOException { byte[] content = "0123456789extra_bytes".getBytes(); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java index 6a8777d5ba92..7ac4cc0c684b 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java @@ -153,12 +153,14 @@ public void testShouldValidate_transcoded_storedEncoding() throws IOException { } @Test - public void testShouldValidate_transcoded_storedLength() throws IOException { + public void testShouldValidate_normalDownload_realGcsHeaders() throws IOException { + // GCS sends these headers on every download, including uncompressed objects (#14773). Map headers = new HashMap<>(); + headers.put("x-goog-stored-content-encoding", "identity"); headers.put("x-goog-stored-content-length", "123"); HttpResponse response = createHttpResponse(200, headers, true); - assertFalse(HttpStorageRpcHasherHelper.INSTANCE.shouldValidate(response)); + assertTrue(HttpStorageRpcHasherHelper.INSTANCE.shouldValidate(response)); } @Test diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java index 540e14eb045e..c656de9b631b 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java @@ -299,6 +299,25 @@ public void testRead_rangeSuffixFullLength_failedCrc32cValidation() throws Excep assertThat(ex.getCause().getMessage()).contains("Mismatch checksum value"); } + @Test + public void testReadAllBytes_gcsHeaders_failedCrc32cValidation() throws Exception { + // GCS returns x-goog-stored-content-* on every download, not only for transcoded objects. + MockLowLevelHttpResponse response = + new MockLowLevelHttpResponse() + .setContentType("application/octet-stream") + .addHeader("x-goog-hash", "crc32c=" + BAD_CRC32C_BASE64) + .addHeader("x-goog-stored-content-encoding", "identity") + .addHeader("x-goog-stored-content-length", String.valueOf(CONTENT.length)) + .setContent(new String(CONTENT, StandardCharsets.UTF_8)) + .setStatusCode(200); + + try (Storage storage = createMockStorage(response)) { + StorageException ex = + assertThrows(StorageException.class, () -> storage.readAllBytes(BlobId.of(BUCKET, BLOB))); + assertThat(ex.getMessage()).contains("Mismatch checksum value"); + } + } + private HttpStorageRpc createMockRpc(MockLowLevelHttpResponse response) { AuditingHttpTransport transport = new AuditingHttpTransport(response); TransportOptions transportOptions = From 3af785f187e43c1c70dc1ea1eb8c0fc0df8d559a Mon Sep 17 00:00:00 2001 From: Dhriti Chopra Date: Wed, 28 Oct 2026 09:49:26 +0000 Subject: [PATCH 2/4] fix(storage): don't validate a partial CRC32C after a resumed reader() download Now that CRC32C validation runs on HTTP downloads, a reader() download that is resumed after a broken stream kept the hashing stream from the first (full object) response. The resumed response is partial and is not validated, so at EOF the hash of only the first part was compared with the full object checksum, reporting a false ChecksumMismatchException. Clear the hashing stream when the current response is not validated. --- .../ApiaryUnbufferedReadableByteChannel.java | 3 + ...iaryUnbufferedReadableByteChannelTest.java | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannel.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannel.java index 25757921ba54..a0bbadf67076 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannel.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannel.java @@ -258,6 +258,9 @@ private ScatteringByteChannel open() { if (shouldValidate && expectedCrc32cBase64 != null) { this.hashingInputStream = new HashingInputStream(Hashing.crc32c(), content); content = this.hashingInputStream; + } else { + // e.g. a resumed (partial) read: don't validate a partial hash against the full object + this.hashingInputStream = null; } ReadableByteChannel rbc = Channels.newChannel(content); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java index d130b3859595..2b4304f6234e 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannelTest.java @@ -22,9 +22,13 @@ import static org.junit.Assert.assertTrue; import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.core.SettableApiFuture; +import com.google.api.gax.retrying.BasicResultRetryAlgorithm; import com.google.api.services.storage.Storage; import com.google.api.services.storage.model.StorageObject; import com.google.cloud.storage.ApiaryUnbufferedReadableByteChannel.ApiaryReadRequest; @@ -33,14 +37,20 @@ import com.google.cloud.storage.UnbufferedReadableByteChannelSession.UnbufferedReadableByteChannel; import com.google.cloud.storage.spi.v1.AuditingHttpTransport; import com.google.common.collect.ImmutableMap; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.WritableByteChannel; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; @@ -268,6 +278,87 @@ public void testRead_gcsHeaders_mismatchedCrc32c_throwsChecksumMismatch() throws } } + @Test + public void testRead_resumedAfterBrokenStream_doesNotReportFalseChecksumMismatch() + throws IOException { + int splitAt = 6; + int length = CONTENT_BYTES.length; + AtomicInteger requestCount = new AtomicInteger(); + HttpTransport transport = + new HttpTransport() { + @Override + protected LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + MockLowLevelHttpResponse response = + new MockLowLevelHttpResponse() + .addHeader("x-goog-hash", "crc32c=" + CORRECT_CRC32C_BASE64) + .addHeader("x-goog-stored-content-encoding", "identity") + .addHeader("x-goog-stored-content-length", String.valueOf(length)) + .addHeader("x-goog-generation", "12345"); + if (requestCount.getAndIncrement() == 0) { + // Full object response whose stream breaks part way through. + InputStream broken = + new SequenceInputStream( + new ByteArrayInputStream(Arrays.copyOf(CONTENT_BYTES, splitAt)), + new InputStream() { + @Override + public int read() throws IOException { + throw new BrokenStreamException(); + } + }); + return response.setStatusCode(200).setContent(broken); + } + // Resumed read of the rest of the object. + return response + .setStatusCode(206) + .addHeader( + "Content-Range", + String.format(Locale.US, "bytes %d-%d/%d", splitAt, length - 1, length)) + .setContent(Arrays.copyOfRange(CONTENT_BYTES, splitAt, length)); + } + }; + } + }; + Storage storageClient = + new Storage.Builder(transport, GsonFactory.getDefaultInstance(), null) + .setApplicationName("test") + .build(); + BasicResultRetryAlgorithm retryBrokenStream = + new BasicResultRetryAlgorithm() { + @Override + public boolean shouldRetry(Throwable previousThrowable, Object previousResponse) { + return previousThrowable instanceof BrokenStreamException; + } + }; + + StorageObject from = new StorageObject().setBucket("bucket").setName("blob"); + ApiaryReadRequest apiaryReadRequest = + new ApiaryReadRequest(from, ImmutableMap.of(), ByteRangeSpec.nullRange()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ApiaryUnbufferedReadableByteChannel channel = + new ApiaryUnbufferedReadableByteChannel( + apiaryReadRequest, + storageClient, + SettableApiFuture.create(), + RetrierWithAlg.attemptOnce(), + retryBrokenStream, + Hasher.defaultHasher())) { + ByteBuffer buf = ByteBuffer.allocate(4096); + while (channel.read(new ByteBuffer[] {buf}, 0, 1) != -1) { + buf.flip(); + out.write(buf.array(), 0, buf.limit()); + buf.clear(); + } + } + + assertThat(requestCount.get()).isEqualTo(2); + assertArrayEquals(CONTENT_BYTES, out.toByteArray()); + } + + private static final class BrokenStreamException extends IOException {} + @Test public void logsWarning_whenReceivingMoreBytesThanRequested() throws IOException { byte[] content = "0123456789extra_bytes".getBytes(); From baf366487da11349fa49027090e16b12c066a817 Mon Sep 17 00:00:00 2001 From: Dhriti Chopra Date: Thu, 29 Oct 2026 06:10:27 +0000 Subject: [PATCH 3/4] test(storage): verify CRC32C validation is skipped for objects served decompressed --- .../spi/v1/HttpStorageRpcChecksumTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java index c656de9b631b..5b70391a7412 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcChecksumTest.java @@ -318,6 +318,25 @@ public void testReadAllBytes_gcsHeaders_failedCrc32cValidation() throws Exceptio } } + @Test + public void testReadAllBytes_storedGzipServedDecompressed_skipsCrc32cValidation() + throws Exception { + // The object is stored gzip-compressed but GCS served it decompressed, so the served bytes + // cannot match the stored object's checksum and validation must be skipped. + MockLowLevelHttpResponse response = + new MockLowLevelHttpResponse() + .setContentType("application/octet-stream") + .addHeader("x-goog-hash", "crc32c=" + BAD_CRC32C_BASE64) + .addHeader("x-goog-stored-content-encoding", "gzip") + .addHeader("x-goog-stored-content-length", "42") + .setContent(new String(CONTENT, StandardCharsets.UTF_8)) + .setStatusCode(200); + + try (Storage storage = createMockStorage(response)) { + assertThat(storage.readAllBytes(BlobId.of(BUCKET, BLOB))).isEqualTo(CONTENT); + } + } + private HttpStorageRpc createMockRpc(MockLowLevelHttpResponse response) { AuditingHttpTransport transport = new AuditingHttpTransport(response); TransportOptions transportOptions = From 2c13579d40cd07d1df9017c6ef0fae25e50df412 Mon Sep 17 00:00:00 2001 From: Dhriti Chopra Date: Thu, 29 Oct 2026 06:37:05 +0000 Subject: [PATCH 4/4] fix(storage): match Content-Encoding case-insensitively when detecting client decompression GCS echoes an object's Content-Encoding metadata as-is (e.g. "GZIP"), and the HTTP client decompresses when the value matches gzip case-insensitively. isDecompressedByClient used a case-sensitive check, so such responses were validated against the stored object's checksum after being decompressed, reporting a false ChecksumMismatchException. --- .../cloud/storage/HttpStorageRpcHasherHelper.java | 5 ++--- .../storage/HttpStorageRpcHasherHelperTest.java | 13 +++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java index 778173fb893e..062564d103ac 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java @@ -119,14 +119,13 @@ private static boolean isDecompressedByServer(HttpResponse response) { } private static boolean isGzip(String encoding) { - return encoding != null && encoding.toLowerCase(Locale.ROOT).contains("gzip"); + return encoding != null && encoding.trim().toLowerCase(Locale.ROOT).contains("gzip"); } private boolean isDecompressedByClient(HttpResponse response) { boolean returnRaw = response.getRequest().getResponseReturnRawInputStream(); if (!returnRaw) { - String encoding = response.getHeaders().getContentEncoding(); - return encoding != null && encoding.contains("gzip"); + return isGzip(response.getHeaders().getContentEncoding()); } return false; } diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java index 7ac4cc0c684b..a2bddfa9bced 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java @@ -171,4 +171,17 @@ public void testShouldValidate_decompressedByClient() throws IOException { assertFalse(HttpStorageRpcHasherHelper.INSTANCE.shouldValidate(response)); } + + @Test + public void testShouldValidate_decompressedByClient_uppercaseContentEncoding() + throws IOException { + // GCS echoes the object's Content-Encoding metadata as-is, and the HTTP client matches it + // case-insensitively when deciding to decompress. + Map headers = new HashMap<>(); + headers.put("x-goog-stored-content-encoding", "GZIP"); + headers.put("Content-Encoding", "GZIP"); + HttpResponse response = createHttpResponse(200, headers, false); + + assertFalse(HttpStorageRpcHasherHelper.INSTANCE.shouldValidate(response)); + } }