Skip to content

Commit a371f67

Browse files
feat(Storage): Enable full object checksum validation for resumable uploads
1 parent a513063 commit a371f67

4 files changed

Lines changed: 208 additions & 80 deletions

File tree

apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.IntegrationTests/UploadObjectTest.cs

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,8 @@ public void UploadObject_InvalidHash_ThrowOnly()
280280
var name = IdGenerator.FromGuid();
281281
var bucket = _fixture.MultiVersionBucket;
282282
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.ThrowOnly };
283-
Assert.Throws<UploadValidationException>(() => client.UploadObject(bucket, name, null, stream, options));
284-
// We don't delete the object, so it's still present.
285-
ValidateData(bucket, name, new MemoryStream(interceptor.UploadedBytes));
283+
var exception = Assert.Throws<GoogleApiException>(() => client.UploadObject(bucket, name, null, stream, options));
284+
Assert.Equal(HttpStatusCode.BadRequest, exception.HttpStatusCode);
286285
}
287286

288287
[Fact]
@@ -295,28 +294,12 @@ public void UploadObject_InvalidHash_DeleteAndThrow()
295294
var name = IdGenerator.FromGuid();
296295
var bucket = _fixture.MultiVersionBucket;
297296
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.DeleteAndThrow };
298-
Assert.Throws<UploadValidationException>(() => client.UploadObject(bucket, name, null, stream, options));
297+
var exception = Assert.Throws<GoogleApiException>(() => client.UploadObject(bucket, name, null, stream, options));
298+
Assert.Equal(HttpStatusCode.BadRequest, exception.HttpStatusCode);
299299
var notFound = Assert.Throws<GoogleApiException>(() => _fixture.Client.GetObject(bucket, name));
300300
Assert.Equal(HttpStatusCode.NotFound, notFound.HttpStatusCode);
301301
}
302302

303-
[Fact]
304-
public void UploadObject_InvalidHash_DeleteAndThrow_DeleteFails()
305-
{
306-
var client = StorageClient.Create();
307-
var interceptor = new BreakUploadInterceptor();
308-
client.Service.HttpClient.MessageHandler.AddExecuteInterceptor(interceptor);
309-
client.Service.HttpClient.MessageHandler.AddExecuteInterceptor(new BreakDeleteInterceptor());
310-
var stream = GenerateData(50);
311-
var name = IdGenerator.FromGuid();
312-
var bucket = _fixture.MultiVersionBucket;
313-
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.DeleteAndThrow };
314-
var ex = Assert.Throws<UploadValidationException>(() => client.UploadObject(bucket, name, null, stream, options));
315-
Assert.NotNull(ex.AdditionalFailures);
316-
// The deletion failed, so the uploaded object still exists.
317-
ValidateData(bucket, name, new MemoryStream(interceptor.UploadedBytes));
318-
}
319-
320303
[Fact]
321304
public async Task UploadObjectAsync_InvalidHash_None()
322305
{
@@ -343,9 +326,8 @@ public async Task UploadObjectAsync_InvalidHash_ThrowOnly()
343326
var name = IdGenerator.FromGuid();
344327
var bucket = _fixture.MultiVersionBucket;
345328
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.ThrowOnly };
346-
await Assert.ThrowsAsync<UploadValidationException>(() => client.UploadObjectAsync(bucket, name, null, stream, options));
347-
// We don't delete the object, so it's still present.
348-
ValidateData(bucket, name, new MemoryStream(interceptor.UploadedBytes));
329+
var exception = await Assert.ThrowsAsync<GoogleApiException>(() => client.UploadObjectAsync(bucket, name, null, stream, options));
330+
Assert.Equal(HttpStatusCode.BadRequest, exception.HttpStatusCode);
349331
}
350332

351333
[Fact]
@@ -359,28 +341,12 @@ public async Task UploadObjectAsync_InvalidHash_DeleteAndThrow()
359341
var name = IdGenerator.FromGuid();
360342
var bucket = _fixture.MultiVersionBucket;
361343
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.DeleteAndThrow };
362-
await Assert.ThrowsAsync<UploadValidationException>(() => client.UploadObjectAsync(bucket, name, null, stream, options));
344+
var exception = await Assert.ThrowsAsync<GoogleApiException>(() => client.UploadObjectAsync(bucket, name, null, stream, options));
345+
Assert.Equal(HttpStatusCode.BadRequest, exception.HttpStatusCode);
363346
var notFound = await Assert.ThrowsAsync<GoogleApiException>(() => _fixture.Client.GetObjectAsync(bucket, name));
364347
Assert.Equal(HttpStatusCode.NotFound, notFound.HttpStatusCode);
365348
}
366349

367-
[Fact]
368-
public async Task UploadObjectAsync_InvalidHash_DeleteAndThrow_DeleteFails()
369-
{
370-
var client = StorageClient.Create();
371-
var interceptor = new BreakUploadInterceptor();
372-
client.Service.HttpClient.MessageHandler.AddExecuteInterceptor(interceptor);
373-
client.Service.HttpClient.MessageHandler.AddExecuteInterceptor(new BreakDeleteInterceptor());
374-
var stream = GenerateData(50);
375-
var name = IdGenerator.FromGuid();
376-
var bucket = _fixture.MultiVersionBucket;
377-
var options = new UploadObjectOptions { UploadValidationMode = UploadValidationMode.DeleteAndThrow };
378-
var ex = await Assert.ThrowsAsync<UploadValidationException>(() => client.UploadObjectAsync(bucket, name, null, stream, options));
379-
Assert.NotNull(ex.AdditionalFailures);
380-
// The deletion failed, so the uploaded object still exists.
381-
ValidateData(bucket, name, new MemoryStream(interceptor.UploadedBytes));
382-
}
383-
384350
[Fact]
385351
public async Task InitiateUploadSessionAsync_NegativeLength()
386352
{
@@ -460,21 +426,6 @@ public async Task InterceptAsync(HttpRequestMessage request, CancellationToken c
460426
}
461427
}
462428

463-
private class BreakDeleteInterceptor : IHttpExecuteInterceptor
464-
{
465-
public Task InterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken)
466-
{
467-
// We only care about Delete requests
468-
if (request.Method == HttpMethod.Delete)
469-
{
470-
// Ugly but effective hack: replace the generation URL parameter so that we add a leading 9,
471-
// so the generation we try to delete is the wrong one.
472-
request.RequestUri = new Uri(request.RequestUri.ToString().Replace("generation=", "generation=9"));
473-
}
474-
return Task.FromResult(0);
475-
}
476-
}
477-
478429
private Object GetExistingObject()
479430
{
480431
var obj = _fixture.Client.UploadObject(_fixture.MultiVersionBucket, IdGenerator.FromGuid(), "application/octet-stream", GenerateData(100));

apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1.Tests/UploadObjectOptionsTest.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public void InvalidChunkSize(int chunkSize)
5454
[Fact]
5555
public void ModifyMediaUpload_DefaultOptions()
5656
{
57-
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null);
57+
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null, null);
5858
var options = new UploadObjectOptions();
5959
options.ModifyMediaUpload(upload);
6060
Assert.Equal(ResumableUpload<InsertMediaUpload>.DefaultChunkSize, upload.ChunkSize);
@@ -71,7 +71,7 @@ public void ModifyMediaUpload_DefaultOptions()
7171
[Fact]
7272
public void ModifyMediaUpload_AllOptions_PositiveMatch()
7373
{
74-
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null);
74+
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null, null);
7575
var options = new UploadObjectOptions
7676
{
7777
ChunkSize = UploadObjectOptions.MinimumChunkSize * 3,
@@ -96,7 +96,7 @@ public void ModifyMediaUpload_AllOptions_PositiveMatch()
9696
[Fact]
9797
public void ModifyMediaUpload_AllOptions_NegativeMatch()
9898
{
99-
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null);
99+
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null, null);
100100
var options = new UploadObjectOptions
101101
{
102102
ChunkSize = UploadObjectOptions.MinimumChunkSize * 3,
@@ -117,7 +117,7 @@ public void ModifyMediaUpload_AllOptions_NegativeMatch()
117117
[Fact]
118118
public void ModifyMediaUpload_MatchNotMatchConflicts()
119119
{
120-
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null);
120+
var upload = new CustomMediaUpload(new DummyService(), null, "bucket", new MemoryStream(), null, null);
121121
Assert.Throws<ArgumentException>(() =>
122122
{
123123
var options = new UploadObjectOptions { IfGenerationMatch = 1L, IfGenerationNotMatch = 2L };

apis/Google.Cloud.Storage.V1/Google.Cloud.Storage.V1/CustomMediaUpload.cs

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2017 Google Inc. All Rights Reserved.
1+
// Copyright 2017 Google Inc. All Rights Reserved.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -12,12 +12,16 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
using Google.Apis.Http;
1516
using Google.Apis.Services;
17+
using Google.Apis.Upload;
1618
using System;
1719
using System.IO;
20+
using System.Linq;
1821
using System.Net.Http;
22+
using System.Threading;
23+
using System.Threading.Tasks;
1924
using static Google.Apis.Storage.v1.ObjectsResource;
20-
using Google.Apis.Upload;
2125

2226
namespace Google.Cloud.Storage.V1
2327
{
@@ -26,12 +30,157 @@ namespace Google.Cloud.Storage.V1
2630
/// </summary>
2731
internal sealed class CustomMediaUpload : InsertMediaUpload
2832
{
33+
private readonly Stream _stream;
34+
private readonly Crc32cHashInterceptor _interceptor;
35+
private readonly IClientService _service;
36+
private readonly HashingStream _hashingStream;
37+
2938
public CustomMediaUpload(IClientService service, Apis.Storage.v1.Data.Object body, string bucket,
30-
Stream stream, string contentType)
31-
: base(service, body, bucket, stream, contentType)
39+
Stream stream, string contentType, UploadObjectOptions options)
40+
: base(service, body, bucket, new HashingStream(stream), contentType)
3241
{
42+
_stream = stream;
43+
_service = service;
44+
var validationMode = options?.UploadValidationMode ?? UploadObjectOptions.DefaultValidationMode;
45+
_hashingStream = (HashingStream) ContentStream;
46+
_interceptor = new Crc32cHashInterceptor(this, _hashingStream, _service, validationMode);
47+
_service?.HttpClient?.MessageHandler?.AddExecuteInterceptor(_interceptor);
3348
}
3449

3550
internal new ResumableUploadOptions Options => base.Options;
51+
52+
private sealed class Crc32cHashInterceptor : IHttpExecuteInterceptor
53+
{
54+
private const string GoogleHashHeader = "x-goog-hash";
55+
private const int ReadBufferSize = 81920;
56+
private readonly IClientService _service;
57+
private readonly CustomMediaUpload _owner;
58+
private Uri _uploadUri;
59+
private readonly UploadValidationMode _validationMode;
60+
private readonly HashingStream _hashingStream;
61+
62+
public Crc32cHashInterceptor(CustomMediaUpload owner, HashingStream hashingStream, IClientService service, UploadValidationMode validationMode)
63+
{
64+
_hashingStream = hashingStream;
65+
_service = service;
66+
_owner = owner;
67+
_validationMode = validationMode;
68+
_owner.UploadSessionData += OnSessionData;
69+
_owner.ProgressChanged += OnProgressChanged;
70+
}
71+
72+
public Task InterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken)
73+
{
74+
if (_uploadUri != null && !_uploadUri.Equals(request.RequestUri))
75+
{
76+
return Task.CompletedTask;
77+
}
78+
79+
if (request.Method == System.Net.Http.HttpMethod.Put && request.Content?.Headers.Contains("Content-Range") is true)
80+
{
81+
var rangeHeader = request.Content.Headers.GetValues("Content-Range").First();
82+
83+
if (IsFinalChunk(rangeHeader))
84+
{
85+
if (_validationMode != UploadValidationMode.None)
86+
{
87+
var calculatedHash = _hashingStream.GetBase64Hash();
88+
request.Headers.TryAddWithoutValidation(GoogleHashHeader, $"crc32c={calculatedHash}");
89+
}
90+
}
91+
}
92+
return Task.CompletedTask;
93+
}
94+
95+
private void OnSessionData(IUploadSessionData data)
96+
{
97+
_uploadUri = data.UploadUri;
98+
_owner.UploadSessionData -= OnSessionData;
99+
}
100+
101+
private void OnProgressChanged(IUploadProgress progress)
102+
{
103+
if (progress.Status == UploadStatus.Completed || progress.Status == UploadStatus.Failed)
104+
{
105+
// Clean up when upload is finished.
106+
_service?.HttpClient?.MessageHandler?.RemoveExecuteInterceptor(this);
107+
_owner.ProgressChanged -= OnProgressChanged;
108+
}
109+
}
110+
111+
private bool IsFinalChunk(string rangeHeader)
112+
{
113+
// Expected format: "bytes {start}-{end}/{total}" or "bytes */{total}" for the final request.
114+
// We are interested in the final chunk of a known-size upload.
115+
const string prefix = "bytes ";
116+
if (!rangeHeader.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
117+
{
118+
return false;
119+
}
120+
121+
ReadOnlySpan<char> span = rangeHeader.AsSpan(prefix.Length);
122+
int slashIndex = span.IndexOf('/');
123+
if (slashIndex == -1)
124+
{
125+
return false;
126+
}
127+
128+
var totalSpan = span.Slice(slashIndex + 1);
129+
if (totalSpan.IsEmpty || totalSpan[0] == '*')
130+
{
131+
return false;
132+
}
133+
134+
if (!long.TryParse(totalSpan.ToString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out long totalSize))
135+
{
136+
return false;
137+
}
138+
139+
var rangeSpan = span.Slice(0, slashIndex);
140+
int dashIndex = rangeSpan.IndexOf('-');
141+
if (dashIndex == -1)
142+
{
143+
return false;
144+
}
145+
146+
var endByteSpan = rangeSpan.Slice(dashIndex + 1);
147+
if (!long.TryParse(endByteSpan.ToString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out long endByte))
148+
{
149+
return false;
150+
}
151+
152+
// If endByte is the last byte of the file, it's the final chunk.
153+
return (endByte + 1) == totalSize;
154+
}
155+
}
156+
157+
private sealed class HashingStream : Stream
158+
{
159+
private readonly Stream _inner;
160+
private readonly Crc32c _hasher = new Crc32c();
161+
162+
public HashingStream(Stream inner) => _inner = inner;
163+
164+
public override int Read(byte[] buffer, int offset, int count)
165+
{
166+
int bytesRead = _inner.Read(buffer, offset, count);
167+
if (bytesRead > 0)
168+
{
169+
_hasher.UpdateHash(buffer, offset, bytesRead);
170+
}
171+
return bytesRead;
172+
}
173+
174+
public string GetBase64Hash() => Convert.ToBase64String(_hasher.GetHash());
175+
public override bool CanRead => _inner.CanRead;
176+
public override bool CanSeek => _inner.CanSeek;
177+
public override bool CanWrite => _inner.CanWrite;
178+
public override long Length => _inner.Length;
179+
public override long Position { get => _inner.Position; set => _inner.Position = value; }
180+
public override void Flush() => _inner.Flush();
181+
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
182+
public override void SetLength(long value) => _inner.SetLength(value);
183+
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
184+
}
36185
}
37186
}

0 commit comments

Comments
 (0)