Skip to content

Commit 20f416c

Browse files
committed
feat(storage): add checksum validation in the json read channel
1 parent 109008a commit 20f416c

3 files changed

Lines changed: 173 additions & 4 deletions

File tree

java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryUnbufferedReadableByteChannel.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
import com.google.common.collect.ImmutableMap;
3939
import com.google.common.hash.HashFunction;
4040
import com.google.common.hash.Hashing;
41+
import com.google.common.hash.HashingInputStream;
4142
import com.google.common.io.BaseEncoding;
43+
import com.google.common.primitives.Ints;
4244
import com.google.gson.Gson;
4345
import com.google.gson.stream.JsonReader;
4446
import java.io.IOException;
@@ -61,13 +63,15 @@
6163
import org.checkerframework.checker.nullness.qual.NonNull;
6264
import org.checkerframework.checker.nullness.qual.Nullable;
6365

66+
@SuppressWarnings("UnstableApiUsage")
6467
class ApiaryUnbufferedReadableByteChannel implements UnbufferedReadableByteChannel {
6568

6669
private final ApiaryReadRequest apiaryReadRequest;
6770
private final Storage storage;
6871
private final SettableApiFuture<StorageObject> result;
6972
private final ResultRetryAlgorithm<?> resultRetryAlgorithm;
7073
private final Retrier retrier;
74+
private final Hasher hasher;
7175

7276
private long position;
7377
private ScatteringByteChannel sbc;
@@ -77,16 +81,21 @@ class ApiaryUnbufferedReadableByteChannel implements UnbufferedReadableByteChann
7781
// returned X-Goog-Generation header value
7882
private Long xGoogGeneration;
7983

84+
private HashingInputStream hashingInputStream;
85+
private String expectedCrc32cBase64;
86+
8087
ApiaryUnbufferedReadableByteChannel(
8188
ApiaryReadRequest apiaryReadRequest,
8289
Storage storage,
8390
SettableApiFuture<StorageObject> result,
8491
Retrier retrier,
85-
ResultRetryAlgorithm<?> resultRetryAlgorithm) {
92+
ResultRetryAlgorithm<?> resultRetryAlgorithm,
93+
Hasher hasher) {
8694
this.apiaryReadRequest = apiaryReadRequest;
8795
this.storage = storage;
8896
this.result = result;
8997
this.retrier = retrier;
98+
this.hasher = hasher;
9099
this.resultRetryAlgorithm =
91100
new BasicResultRetryAlgorithm<Object>() {
92101
@Override
@@ -126,6 +135,16 @@ public long read(ByteBuffer[] dsts, int offset, int length) throws IOException {
126135
long read = sbc.read(dsts, offset, length);
127136
if (read == -1) {
128137
returnEOF = true;
138+
if (hashingInputStream != null && expectedCrc32cBase64 != null) {
139+
int calculatedCrc32c = hashingInputStream.hash().asInt();
140+
byte[] decoded = BaseEncoding.base64().decode(expectedCrc32cBase64);
141+
int expectedVal = Ints.fromByteArray(decoded);
142+
143+
Crc32cValue<?> expected = Crc32cValue.of(expectedVal, 0);
144+
Crc32cValue.Crc32cLengthKnown actual = Crc32cValue.of(calculatedCrc32c, 0);
145+
146+
Hasher.defaultHasher().validate(expected, actual);
147+
}
129148
} else {
130149
totalRead += read;
131150
}
@@ -211,9 +230,19 @@ private ScatteringByteChannel open() {
211230
if (!result.isDone()) {
212231
result.set(clone);
213232
}
233+
234+
Map<String, String> hashes = ChecksumResponseParser.extractHashesFromHeader(media);
235+
this.expectedCrc32cBase64 = hashes.get("crc32c");
214236
}
215237
}
216238

239+
boolean isHasherEnabled = !(hasher instanceof Hasher.NoOpHasher);
240+
boolean isFullObjectDownload = (request.getByteRangeSpec().getHttpRangeHeader() == null);
241+
if (isHasherEnabled && isFullObjectDownload) {
242+
this.hashingInputStream = new HashingInputStream(Hashing.crc32c(), content);
243+
content = this.hashingInputStream;
244+
}
245+
217246
ReadableByteChannel rbc = Channels.newChannel(content);
218247
return StorageByteChannels.readable().asScatteringByteChannel(rbc);
219248
} catch (HttpResponseException e) {

java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpDownloadSessionBuilder.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ public static final class ReadableByteChannelSessionBuilder {
5454
private final BlobReadChannelContext blobReadChannelContext;
5555
private boolean autoGzipDecompression;
5656

57-
// private Hasher hasher; // TODO: wire in Hasher
57+
private final Hasher hasher;
5858

5959
private ReadableByteChannelSessionBuilder(BlobReadChannelContext blobReadChannelContext) {
6060
this.blobReadChannelContext = blobReadChannelContext;
61+
this.hasher = Hasher.defaultHasher();
6162
this.autoGzipDecompression = false;
6263
}
6364

@@ -96,7 +97,8 @@ public UnbufferedReadableByteChannelSessionBuilder unbuffered() {
9697
blobReadChannelContext.getApiaryClient(),
9798
resultFuture,
9899
blobReadChannelContext.getRetrier(),
99-
blobReadChannelContext.getRetryAlgorithmManager().idempotent()),
100+
blobReadChannelContext.getRetryAlgorithmManager().idempotent(),
101+
hasher),
100102
ApiFutures.transform(
101103
resultFuture, StorageObject::getContentEncoding, MoreExecutors.directExecutor()));
102104
} else {
@@ -105,7 +107,8 @@ public UnbufferedReadableByteChannelSessionBuilder unbuffered() {
105107
blobReadChannelContext.getApiaryClient(),
106108
resultFuture,
107109
blobReadChannelContext.getRetrier(),
108-
blobReadChannelContext.getRetryAlgorithmManager().idempotent());
110+
blobReadChannelContext.getRetryAlgorithmManager().idempotent(),
111+
hasher);
109112
}
110113
};
111114
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage;
18+
19+
import static org.junit.Assert.assertArrayEquals;
20+
import static org.junit.Assert.assertThrows;
21+
import static org.junit.Assert.assertTrue;
22+
23+
import com.google.api.client.http.HttpTransport;
24+
import com.google.api.client.json.gson.GsonFactory;
25+
import com.google.api.client.testing.http.MockLowLevelHttpResponse;
26+
import com.google.api.core.SettableApiFuture;
27+
import com.google.api.services.storage.Storage;
28+
import com.google.api.services.storage.model.StorageObject;
29+
import com.google.cloud.storage.ApiaryUnbufferedReadableByteChannel.ApiaryReadRequest;
30+
import com.google.cloud.storage.Retrying.RetrierWithAlg;
31+
import com.google.common.collect.ImmutableMap;
32+
import java.io.ByteArrayOutputStream;
33+
import java.io.IOException;
34+
import java.nio.ByteBuffer;
35+
import java.nio.channels.Channels;
36+
import java.nio.channels.WritableByteChannel;
37+
import org.junit.Test;
38+
39+
public class ApiaryUnbufferedReadableByteChannelTest {
40+
41+
private static final byte[] CONTENT_BYTES = "Hello, World!".getBytes();
42+
private static final String CORRECT_CRC32C_BASE64 = "TVUQaA==";
43+
private static final String WRONG_CRC32C_BASE64 = "AAAAAA==";
44+
45+
private Storage createMockStorageClient(String googHashHeader) {
46+
HttpTransport transport =
47+
new HttpTransport() {
48+
@Override
49+
protected com.google.api.client.http.LowLevelHttpRequest buildRequest(
50+
String method, String url) throws IOException {
51+
return new com.google.api.client.testing.http.MockLowLevelHttpRequest() {
52+
@Override
53+
public com.google.api.client.http.LowLevelHttpResponse execute() throws IOException {
54+
MockLowLevelHttpResponse lowLevelResponse =
55+
new MockLowLevelHttpResponse()
56+
.setContent(CONTENT_BYTES)
57+
.setContentLength(CONTENT_BYTES.length)
58+
.addHeader("Content-Length", String.valueOf(CONTENT_BYTES.length))
59+
.addHeader("x-goog-generation", "12345");
60+
if (googHashHeader != null) {
61+
lowLevelResponse.addHeader("x-goog-hash", googHashHeader);
62+
}
63+
return lowLevelResponse;
64+
}
65+
};
66+
}
67+
};
68+
return new Storage.Builder(transport, GsonFactory.getDefaultInstance(), null)
69+
.setApplicationName("test")
70+
.build();
71+
}
72+
73+
@Test
74+
public void testRead_successfulCrc32cValidation() throws IOException {
75+
Storage storageClient = createMockStorageClient("crc32c=" + CORRECT_CRC32C_BASE64);
76+
77+
StorageObject from = new StorageObject().setBucket("bucket").setName("blob");
78+
ApiaryReadRequest apiaryReadRequest =
79+
new ApiaryReadRequest(from, ImmutableMap.of(), ByteRangeSpec.nullRange());
80+
81+
SettableApiFuture<StorageObject> resultFuture = SettableApiFuture.create();
82+
try (ApiaryUnbufferedReadableByteChannel channel =
83+
new ApiaryUnbufferedReadableByteChannel(
84+
apiaryReadRequest,
85+
storageClient,
86+
resultFuture,
87+
RetrierWithAlg.attemptOnce(),
88+
Retrying.neverRetry(),
89+
Hasher.defaultHasher()); ) {
90+
91+
ByteArrayOutputStream out = new ByteArrayOutputStream();
92+
try (WritableByteChannel w = Channels.newChannel(out)) {
93+
ByteBuffer buf = ByteBuffer.allocate(4096);
94+
while (channel.read(new ByteBuffer[] {buf}, 0, 1) != -1) {
95+
buf.flip();
96+
w.write(buf);
97+
buf.clear();
98+
}
99+
}
100+
101+
assertArrayEquals(CONTENT_BYTES, out.toByteArray());
102+
}
103+
}
104+
105+
@Test
106+
public void testRead_mismatchedCrc32cValidation_throwsChecksumMismatch() throws IOException {
107+
Storage storageClient = createMockStorageClient("crc32c=" + WRONG_CRC32C_BASE64);
108+
109+
StorageObject from = new StorageObject().setBucket("bucket").setName("blob");
110+
ApiaryReadRequest apiaryReadRequest =
111+
new ApiaryReadRequest(from, ImmutableMap.of(), ByteRangeSpec.nullRange());
112+
113+
SettableApiFuture<StorageObject> resultFuture = SettableApiFuture.create();
114+
try (ApiaryUnbufferedReadableByteChannel channel =
115+
new ApiaryUnbufferedReadableByteChannel(
116+
apiaryReadRequest,
117+
storageClient,
118+
resultFuture,
119+
RetrierWithAlg.attemptOnce(),
120+
Retrying.neverRetry(),
121+
Hasher.defaultHasher()); ) {
122+
123+
ByteBuffer buf = ByteBuffer.allocate(4096);
124+
IOException expected =
125+
assertThrows(
126+
IOException.class,
127+
() -> {
128+
while (channel.read(new ByteBuffer[] {buf}, 0, 1) != -1) {
129+
buf.clear();
130+
}
131+
});
132+
133+
assertTrue(expected instanceof Hasher.ChecksumMismatchException);
134+
assertTrue(expected.getMessage().contains("Mismatch checksum value"));
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)