-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(storage): add checksum validation on json read paths #13269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
6c3a696
feat(storage): add checksum validation on read paths
nidhiii-27 b610455
remove debug statements
nidhiii-27 109008a
Use HashingOutputStream instead of custom Crc32cHashingOutputStream
nidhiii-27 1251c19
Merge branch 'main' into default-checksum-json-read
nidhiii-27 90053ca
fix: skip crc32c validation if decompressed locally
nidhiii-27 5a9d9c5
Merge branch 'main' into default-checksum-json-read
nidhiii-27 549739d
fix(storage): validate checksum on range request spanning full object
nidhiii-27 e1524bd
Merge branch 'main' into default-checksum-json-read
nidhiii-27 2ac63c7
Merge branch 'main' into default-checksum-json-read
Dhriti07 8670c91
Merge branch 'main' into default-checksum-json-read
Dhriti07 44b5567
Merge branch 'main' into default-checksum-json-read
nidhiii-27 f5c9773
fix(storage): implement missing validate method in CumulativeHasher
nidhiii-27 8efa1b9
Merge branch 'main' into default-checksum-json-read
Dhriti07 d6c8295
Merge branch 'main' into default-checksum-json-read
nidhiii-27 3fe84bf
feat: implement response-based checksum validation on JSON read paths
nidhiii-27 a9ab73a
test(storage): add unit tests for suffix-range full-object validation…
nidhiii-27 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
...ogle-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageRpcHasherHelper.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License 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 com.google.cloud.storage; | ||
|
|
||
| import com.google.api.client.http.HttpResponse; | ||
| import com.google.api.core.InternalApi; | ||
| import com.google.common.io.BaseEncoding; | ||
| import com.google.common.primitives.Ints; | ||
| import java.io.IOException; | ||
| import java.io.OutputStream; | ||
| import java.nio.ByteBuffer; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.function.Supplier; | ||
|
|
||
| /** | ||
| * Internal utility class to perform client-side CRC32C checksum validation on downloaded data | ||
| * specifically for the {@code HttpStorageRpc} transport layer. | ||
| * | ||
| * <p>Since this class resides in the {@code com.google.cloud.storage} package, it has full, | ||
| * package-private compile-time access to internal components (like {@link Hasher} and {@link | ||
| * Crc32cValue}) without leaking GCS internal types into public client API surfaces. | ||
| */ | ||
| @InternalApi | ||
| public final class HttpStorageRpcHasherHelper { | ||
|
|
||
| public static final HttpStorageRpcHasherHelper INSTANCE = new HttpStorageRpcHasherHelper(); | ||
|
|
||
| private final Hasher hasher; | ||
|
|
||
| private HttpStorageRpcHasherHelper() { | ||
| hasher = Hasher.defaultHasher(); | ||
| } | ||
|
|
||
| /** | ||
| * Returns a wrapping output stream that hashes the written content if validation is enabled, or | ||
| * the original output stream otherwise. | ||
| */ | ||
| public OutputStream wrap(OutputStream out, boolean isChecksumValidationEnabled) { | ||
| boolean isHasherEnabled = !(hasher instanceof Hasher.NoOpHasher); | ||
| return (isChecksumValidationEnabled && isHasherEnabled) | ||
| ? new Crc32cHashingOutputStream(out) | ||
| : out; | ||
| } | ||
|
|
||
| /** | ||
| * Validates a raw byte array against GCS's expected base64-encoded value in response headers. | ||
| * | ||
| * @throws IOException if the checksums do not match. | ||
| */ | ||
| public void validate(HttpResponse response, byte[] content) throws IOException { | ||
| Map<String, String> hashes = extractHashesFromHeader(response); | ||
| String expectedCrc32cBase64 = hashes.get("crc32c"); | ||
| if (expectedCrc32cBase64 != null) { | ||
| validateCrc32c(expectedCrc32cBase64, content); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validates the downloaded output stream against GCS's expected base64-encoded value in response | ||
| * headers. | ||
| * | ||
| * @throws IOException if the checksums do not match. | ||
| */ | ||
| public void validate(HttpResponse response, OutputStream activeStream) throws IOException { | ||
| if (activeStream instanceof Crc32cHashingOutputStream) { | ||
| Crc32cHashingOutputStream targetStream = (Crc32cHashingOutputStream) activeStream; | ||
| Map<String, String> hashes = extractHashesFromHeader(response); | ||
| String expectedCrc32cBase64 = hashes.get("crc32c"); | ||
| if (expectedCrc32cBase64 != null) { | ||
| validateCrc32c(expectedCrc32cBase64, targetStream.hash()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validates a calculated CRC32C value against GCS's expected base64-encoded value. | ||
| * | ||
| * @throws IOException if the checksums do not match. | ||
| */ | ||
| public void validateCrc32c(String expectedCrc32cBase64, int calculatedCrc32c) throws IOException { | ||
| if (expectedCrc32cBase64 == null) { | ||
| return; | ||
| } | ||
| byte[] decoded = BaseEncoding.base64().decode(expectedCrc32cBase64); | ||
| int expectedVal = Ints.fromByteArray(decoded); | ||
|
|
||
| Crc32cValue<?> expected = Crc32cValue.of(expectedVal, 0); | ||
| Crc32cValue.Crc32cLengthKnown actual = Crc32cValue.of(calculatedCrc32c, 0); | ||
|
|
||
| // Invoke standard package-private validate path natively | ||
| System.out.println("validating checksum"); | ||
| hasher.validate(expected, actual); | ||
| } | ||
|
|
||
| /** | ||
| * Validates a downloaded raw byte array against GCS's expected base64-encoded value. | ||
| * | ||
| * @throws IOException if the checksums do not match. | ||
| */ | ||
| public void validateCrc32c(String expectedCrc32cBase64, byte[] content) throws IOException { | ||
| if (expectedCrc32cBase64 == null) { | ||
| return; | ||
| } | ||
| byte[] decoded = BaseEncoding.base64().decode(expectedCrc32cBase64); | ||
| int expectedVal = Ints.fromByteArray(decoded); | ||
|
|
||
| Crc32cValue<?> expected = Crc32cValue.of(expectedVal, 0); | ||
| System.out.println("validating checksum"); | ||
| hasher.validate( | ||
| expected, | ||
| new Supplier<ByteBuffer>() { | ||
| @Override | ||
| public ByteBuffer get() { | ||
| return ByteBuffer.wrap(content); | ||
| } | ||
| }); | ||
|
nidhiii-27 marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| @SuppressWarnings("UnstableApiUsage") | ||
| private static class Crc32cHashingOutputStream extends java.io.FilterOutputStream { | ||
| private final com.google.common.hash.Hasher hasher; | ||
|
|
||
| Crc32cHashingOutputStream(OutputStream out) { | ||
| super(out); | ||
| this.hasher = com.google.common.hash.Hashing.crc32c().newHasher(); | ||
| } | ||
|
|
||
| @Override | ||
| public void write(int b) throws IOException { | ||
| out.write(b); | ||
| hasher.putByte((byte) b); | ||
| } | ||
|
|
||
| @Override | ||
| public void write(byte[] b, int off, int len) throws IOException { | ||
| out.write(b, off, len); | ||
| hasher.putBytes(b, off, len); | ||
| } | ||
|
|
||
| int hash() { | ||
| return hasher.hash().asInt(); | ||
| } | ||
| } | ||
|
|
||
| private static Map<String, String> extractHashesFromHeader(HttpResponse response) { | ||
| List<String> hashHeaders = response.getHeaders().getHeaderStringValues("x-goog-hash"); | ||
| if (hashHeaders == null || hashHeaders.isEmpty()) { | ||
| return java.util.Collections.emptyMap(); | ||
| } | ||
|
|
||
| return hashHeaders.stream() | ||
| .flatMap(h -> java.util.Arrays.stream(h.split(","))) | ||
| .map(String::trim) | ||
| .filter(s -> !s.isEmpty()) | ||
| .map(s -> s.split("=", 2)) | ||
| .filter(a -> a.length == 2) | ||
| .filter(a -> "crc32c".equalsIgnoreCase(a[0]) || "md5".equalsIgnoreCase(a[0])) | ||
| .collect( | ||
| java.util.stream.Collectors.toMap(a -> a[0].toLowerCase(), a -> a[1], (v1, v2) -> v1)); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
...-cloud-storage/src/test/java/com/google/cloud/storage/HttpStorageRpcHasherHelperTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /* | ||
| * Copyright 2026 Google LLC | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License 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 com.google.cloud.storage; | ||
|
|
||
| import static org.junit.Assert.assertArrayEquals; | ||
| import static org.junit.Assert.assertNotEquals; | ||
| import static org.junit.Assert.assertSame; | ||
| import static org.junit.Assert.assertThrows; | ||
| import static org.junit.Assert.assertTrue; | ||
|
|
||
| import com.google.common.hash.Hashing; | ||
| import java.io.ByteArrayOutputStream; | ||
| import java.io.IOException; | ||
| import java.io.OutputStream; | ||
| import org.junit.Test; | ||
|
|
||
| public class HttpStorageRpcHasherHelperTest { | ||
|
|
||
| private static final byte[] CONTENT_BYTES = "Hello, World!".getBytes(); | ||
| private static final String CONTENT_CRC32C_BASE64 = | ||
| "TVUQaA=="; // expected CRC32C of "Hello, World!" | ||
|
|
||
| @Test | ||
| public void testWrap_disabled_returnsOriginalStream() { | ||
| ByteArrayOutputStream original = new ByteArrayOutputStream(); | ||
| OutputStream wrapped = HttpStorageRpcHasherHelper.INSTANCE.wrap(original, false); | ||
| assertSame(original, wrapped); | ||
| } | ||
|
|
||
| @Test | ||
| public void testWrap_enabled_returnsHashingStream() throws IOException { | ||
| ByteArrayOutputStream original = new ByteArrayOutputStream(); | ||
| OutputStream wrapped = HttpStorageRpcHasherHelper.INSTANCE.wrap(original, true); | ||
| assertNotEquals(original, wrapped); | ||
|
|
||
| wrapped.write(CONTENT_BYTES); | ||
| wrapped.flush(); | ||
| wrapped.close(); | ||
|
|
||
| byte[] writtenBytes = original.toByteArray(); | ||
| assertArrayEquals(CONTENT_BYTES, writtenBytes); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateCrc32c_int_expectSuccess() throws IOException { | ||
| int calculatedCrc32c = Hashing.crc32c().hashBytes(CONTENT_BYTES).asInt(); | ||
| // Should complete cleanly without throwing | ||
| HttpStorageRpcHasherHelper.INSTANCE.validateCrc32c(CONTENT_CRC32C_BASE64, calculatedCrc32c); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateCrc32c_int_expectMismatchFailure() { | ||
| int calculatedCrc32c = 12345; // Incorrect hash | ||
| Hasher.ChecksumMismatchException ex = | ||
| assertThrows( | ||
| Hasher.ChecksumMismatchException.class, | ||
| () -> | ||
| HttpStorageRpcHasherHelper.INSTANCE.validateCrc32c( | ||
| CONTENT_CRC32C_BASE64, calculatedCrc32c)); | ||
| assertTrue(ex.getMessage().contains("Mismatch checksum value")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateCrc32c_byteArray_expectSuccess() throws IOException { | ||
| // Should complete cleanly without throwing | ||
| HttpStorageRpcHasherHelper.INSTANCE.validateCrc32c(CONTENT_CRC32C_BASE64, CONTENT_BYTES); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateCrc32c_byteArray_expectMismatchFailure() { | ||
| byte[] wrongBytes = "Wrong bytes!".getBytes(); | ||
| Hasher.ChecksumMismatchException ex = | ||
| assertThrows( | ||
| Hasher.ChecksumMismatchException.class, | ||
| () -> | ||
| HttpStorageRpcHasherHelper.INSTANCE.validateCrc32c( | ||
| CONTENT_CRC32C_BASE64, wrongBytes)); | ||
| assertTrue(ex.getMessage().contains("Mismatch checksum value")); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.