-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Enable full object checksum validation for multi-chunk resumable uploads #1
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -500,6 +500,103 @@ public void TestUploadInPartialServer( | |
| Assert.Equal(UploadStatus.Completed, progress.Status); | ||
| } | ||
| } | ||
| #if NET6_0_OR_GREATER | ||
| /// <summary> | ||
| /// Verifies that a resumable upload completes successfully and validates crc32c checksum across different chunking configurations | ||
| /// and stream types of known size. | ||
| /// </summary> | ||
| [Theory, CombinatorialData] | ||
| public void TestChunkedUpload_CompletesAndVerifiesChecksumInPartialServer( | ||
| [CombinatorialValues(true, false)] bool knownSize, | ||
| [CombinatorialValues(80, 150)] int partialSize, | ||
| [CombinatorialValues(100, 200)] int chunkSize) | ||
| { | ||
| var actualChunkSize = Math.Min(partialSize, chunkSize); | ||
| var expectedCallCount = (UploadLength + actualChunkSize - 1) / actualChunkSize + 1; | ||
| using (var server = new MultiChunkPartialServer(_server, partialSize)) | ||
| using (var service = new MockClientService(server.HttpPrefix)) | ||
| { | ||
| var content = knownSize ? new MemoryStream(UploadTestBytes) : new UnknownSizeMemoryStream(UploadTestBytes); | ||
| var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", actualChunkSize); | ||
| var progress = uploader.Upload(); | ||
| Assert.Equal(expectedCallCount, server.Requests.Count); | ||
| var request = server.Requests.Last(); | ||
|
|
||
| if (knownSize) | ||
| { | ||
| Assert.Contains(ResumableUpload.GoogleHashHeader, request.Headers.AllKeys); | ||
| var headerValue = request.Headers[ResumableUpload.GoogleHashHeader]; | ||
| var expectedBase64Hash = CalculateCrc32c(UploadTestBytes); | ||
| Assert.Equal($"crc32c={expectedBase64Hash}", headerValue); | ||
| } | ||
| else | ||
| { | ||
| Assert.DoesNotContain(ResumableUpload.GoogleHashHeader, request.Headers.AllKeys); | ||
| } | ||
|
|
||
| Assert.Equal(UploadTestBytes, server.Bytes); | ||
| Assert.Equal(UploadStatus.Completed, progress.Status); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Verifies that the session initiation succeeds when a matching CRC32C checksum is provided. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task TestInitiateSessionWithMatchingCrc32c() | ||
| { | ||
| var content = new MemoryStream(UploadTestBytes); | ||
| using (var server = new MultiChunkServer(_server)) | ||
| using (var service = new MockClientService(server.HttpPrefix)) | ||
| { | ||
| var chunkSize = 1024; | ||
| var expectedBase64Hash = CalculateCrc32c(UploadTestBytes); | ||
| var body = new TestResumableUploadWithCrc32c { Crc32c = expectedBase64Hash }; | ||
| var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", chunkSize) | ||
| { | ||
| Body = body | ||
| }; | ||
|
|
||
| await uploader.InitiateSessionAsync(); | ||
| // Assert that the initiation request was sent | ||
| Assert.Single(server.Requests); | ||
| var initiationRequest = server.Requests.First(); | ||
|
|
||
| // Assert that the X-Goog-Hash header was sent with the correct CRC32C | ||
| Assert.Contains(ResumableUpload.GoogleHashHeader, initiationRequest.Headers.AllKeys); | ||
| Assert.Equal($"crc32c={expectedBase64Hash}", initiationRequest.Headers[ResumableUpload.GoogleHashHeader]); | ||
| Assert.True(uploader.Crc32cHeaderSentInInitiation); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Verifies that the session initiation fails when a mismatched CRC32C checksum is provided. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task TestInitiateSessionWithMismatchingCrc32c() | ||
| { | ||
| var content = new MemoryStream(UploadTestBytes); | ||
| using (var server = new MultiChunkServer(_server)) | ||
| using (var service = new MockClientService(server.HttpPrefix)) | ||
| { | ||
| var chunkSize = 1024; | ||
| var expectedBase64Hash = CalculateCrc32c(UploadTestBytes); | ||
| var body = new TestResumableUploadWithCrc32c { Crc32c = "incorrectHash" }; | ||
|
|
||
| var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", chunkSize) | ||
| { | ||
| Body = body | ||
| }; | ||
|
|
||
| var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => uploader.InitiateSessionAsync()); | ||
| Assert.Equal($"The calculated CRC32C of the stream ({expectedBase64Hash}) does not match the CRC32C provided in the object metadata ({body.Crc32c}).", exception.Message); | ||
|
|
||
| // Assert that no request was sent to the server | ||
| Assert.Empty(server.Requests); | ||
| Assert.False(uploader.Crc32cHeaderSentInInitiation); | ||
| } | ||
| } | ||
|
Comment on lines
+504
to
+598
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests for CRC32C checksum validation rely on #if NET6_0_OR_GREATER
/// <summary>
/// Verifies that a resumable upload completes successfully and validates crc32c checksum across different chunking configurations
/// and stream types of known size.
/// </summary>
[Theory, CombinatorialData]
public void TestChunkedUpload_CompletesAndVerifiesChecksumInPartialServer(
[CombinatorialValues(true)] bool knownSize,
[CombinatorialValues(80, 150)] int partialSize,
[CombinatorialValues(100, 200)] int chunkSize)
{
var actualChunkSize = Math.Min(partialSize, chunkSize);
var expectedCallCount = (UploadLength + actualChunkSize - 1) / actualChunkSize + 1;
using (var server = new MultiChunkPartialServer(_server, partialSize))
using (var service = new MockClientService(server.HttpPrefix))
{
var content = knownSize ? new MemoryStream(UploadTestBytes) : new UnknownSizeMemoryStream(UploadTestBytes);
var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", actualChunkSize);
var progress = uploader.Upload();
Assert.Equal(expectedCallCount, server.Requests.Count);
var request = server.Requests.Last();
Assert.Contains(ResumableUpload.GoogleHashHeader, request.Headers.AllKeys);
var headerValue = request.Headers[ResumableUpload.GoogleHashHeader];
var expectedBase64Hash = CalculateCrc32c(UploadTestBytes);
Assert.Equal($"crc32c={expectedBase64Hash}", headerValue);
Assert.Equal(UploadTestBytes, server.Bytes);
Assert.Equal(UploadStatus.Completed, progress.Status);
}
}
/// <summary>
/// Verifies that the session initiation succeeds when a matching CRC32C checksum is provided.
/// </summary>
[Fact]
public async Task TestInitiateSessionWithMatchingCrc32c()
{
int partialSize = 80;
var content = new MemoryStream(UploadTestBytes);
using (var server = new MultiChunkPartialServer(_server, partialSize))
using (var service = new MockClientService(server.HttpPrefix))
{
var chunkSize = 1024;
var expectedBase64Hash = CalculateCrc32c(UploadTestBytes);
var body = new TestResumableUploadWithCrc32c { Crc32c = expectedBase64Hash };
var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", chunkSize)
{
Body = body
};
await uploader.InitiateSessionAsync();
// Assert that the initiation request was sent
Assert.Single(server.Requests);
var initiationRequest = server.Requests.First();
// Assert that the X-Goog-Hash header was sent with the correct CRC32C
Assert.Contains(ResumableUpload.GoogleHashHeader, initiationRequest.Headers.AllKeys);
Assert.Equal($"crc32c={expectedBase64Hash}", initiationRequest.Headers[ResumableUpload.GoogleHashHeader]);
Assert.True(uploader.Crc32cHeaderSentInInitiation);
}
}
/// <summary>
/// Verifies that the session initiation fails when a mismatched CRC32C checksum is provided.
/// </summary>
[Fact]
public async Task TestInitiateSessionWithMismatchingCrc32c()
{
int partialSize = 80;
var content = new MemoryStream(UploadTestBytes);
using (var server = new MultiChunkPartialServer(_server, partialSize))
using (var service = new MockClientService(server.HttpPrefix))
{
var chunkSize = 1024;
var expectedBase64Hash = CalculateCrc32c(UploadTestBytes);
var body = new TestResumableUploadWithCrc32c { Crc32c = "incorrectHash" };
var uploader = new TestResumableUpload(service, "MultiChunk", "POST", content, "text/plain", chunkSize)
{
Body = body
};
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => uploader.InitiateSessionAsync());
Assert.Equal($"The calculated CRC32C of the stream ({expectedBase64Hash}) does not match the CRC32C provided in the object metadata ({body.Crc32c}).", exception.Message);
// Assert that no request was sent to the server
Assert.Empty(server.Requests);
Assert.False(uploader.Crc32cHeaderSentInInitiation);
}
}
#endif
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /gemini review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The conditional compilation directives have been correctly applied to the CRC32C checksum validation tests and related code, addressing the compatibility issue with older target frameworks. This resolves the critical issue previously identified. Thank you for the quick resolution! |
||
| #endif | ||
|
|
||
| /// <summary> | ||
| /// Server that expects an initial call with path and query parameters. | ||
|
|
@@ -697,5 +794,18 @@ public void TestUploadWithRequestAndResponseBody( | |
| Assert.Equal(1, reponseReceivedCount); | ||
| } | ||
| } | ||
|
|
||
| #if NET6_0_OR_GREATER | ||
| /// <summary> | ||
| /// Calculates the CRC32C checksum for the specified data. | ||
| /// </summary> | ||
| private string CalculateCrc32c(byte[] uploadTestBytes) | ||
| { | ||
| var crc32 = new System.IO.Hashing.Crc32(); | ||
| crc32.Append(uploadTestBytes); | ||
| byte[] hashBytes = crc32.GetCurrentHash(); | ||
| return Convert.ToBase64String(hashBytes); | ||
| } | ||
|
mahendra-google marked this conversation as resolved.
|
||
| #endif | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -92,5 +92,15 @@ internal TestLogger(ITestOutputHelper outputHelper) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal void WriteLine(string message, [CallerMemberName] string caller = null) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _outputHelper.WriteLine($"Test {_id:0000} at {DateTime.UtcNow:HH:mm:ss.fff}: {caller} - {message}"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #if NET6_0_OR_GREATER | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Resumable upload class that allows Crc32c to be set for testing. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private class TestResumableUploadWithCrc32c | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public string Crc32c { get; set; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+97
to
+103
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test helper class is only used for CRC32C checksum validation tests, which are only available on .NET 6.0 and newer. To prevent build failures on older target frameworks, this class should be conditionally compiled. #if NET6_0_OR_GREATER
/// <summary>
/// Resumable upload class that allows Crc32c to be set for testing.
/// </summary>
private class TestResumableUploadWithCrc32c
{
public string Crc32c { get; set; }
}
#endif
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /gemini review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the It appears the Please apply the conditional compilation as suggested previously.
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /gemini review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the The Please apply the conditional compilation as suggested previously.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -72,6 +72,19 @@ public abstract class ResumableUpload | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The x-goog-api-client header value used for resumable uploads initiated without any options or an HttpClient. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static readonly string DefaultGoogleApiClientHeader = new VersionHeaderBuilder().AppendDotNetEnvironment().AppendAssemblyVersion("gdcl", typeof(ResumableUpload)).ToString(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The header name used to specify the crc32c hash of the stream for validation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal const string GoogleHashHeader = "X-Goog-Hash"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #if NET6_0_OR_GREATER | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The size of the buffer (in bytes) used when reading the stream to calculate the CRC32C checksum. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Set to 80 KB (81920 bytes) to balance memory usage and I/O performance. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+83
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a minor inaccuracy in the comment. 81920 bytes is 80 KiB (kibibytes), not 80 KB (kilobytes). Using "KiB" is more precise for base-2 units. /// The size of the buffer (in bytes) used when reading the stream to calculate the CRC32C checksum.
/// Set to 80 KiB (81920 bytes) to balance memory usage and I/O performance.
/// </summary> |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private const int Crc32cCalculationBufferSize = 81920; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
mahendra-google marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endregion // Constants | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #region Construction | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -142,6 +155,12 @@ public InitiatedResumableUpload(Uri uploadUri, Stream contentStream, ResumableUp | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal ConfigurableHttpClient HttpClient { get; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Gets or sets a value indicating whether the CRC32C checksum header was included | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// during the initiation of the resumable upload session. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public bool Crc32cHeaderSentInInitiation { get; internal set; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary>Gets or sets the stream to upload.</summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Stream ContentStream { get; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -641,6 +660,24 @@ protected async Task<bool> SendNextChunkAsync(Stream stream, CancellationToken c | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| BytesClientSent = BytesServerReceived + contentLength; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Logger.Debug("MediaUpload[{0}] - Sending bytes={1}-{2}", UploadUri, BytesServerReceived, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| BytesClientSent - 1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bool isFinalChunk = BytesClientSent == StreamLength; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isFinalChunk && !Crc32cHeaderSentInInitiation) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| string trailingCrc32c = await CalculateCrc32cAsync(ContentStream, cancellationToken).ConfigureAwait(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (trailingCrc32c != null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request.Headers.TryAddWithoutValidation(GoogleHashHeader, $"crc32c={trailingCrc32c}"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+664
to
+671
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a significant performance issue here. Calling A more performant approach for seekable streams would be to calculate the hash once at the beginning of the upload process and store it for later use. For example, you could compute the hash in Example of what this could look like: // In ResumableUpload class
private string _fullStreamCrc32c;
private async Task<IUploadProgress> UploadCoreAsync(CancellationToken cancellationToken)
{
// Calculate hash at the start of the upload if stream is seekable.
_fullStreamCrc32c = await CalculateCrc32cAsync(ContentStream, cancellationToken).ConfigureAwait(false);
try
{
while (!await SendNextChunkAsync(ContentStream, cancellationToken).ConfigureAwait(false))
{
// ...
}
// ...
}
// ...
}
// In SendNextChunkAsync
if (isFinalChunk && !Crc32cHeaderSentInInitiation)
{
if (_fullStreamCrc32c != null)
{
request.Headers.TryAddWithoutValidation(GoogleHashHeader, $"crc32c={_fullStreamCrc32c}");
}
}This would move the performance cost of the extra read to the beginning of the upload, which is a much better user experience than a long pause before the upload completes. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Explicitly ensure the header is NOT present for non-final chunks | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (Just in case it was added by a default modifier elsewhere) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (request.Headers.Contains(GoogleHashHeader)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request.Headers.Remove(GoogleHashHeader); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // We can't assume that the server actually received all the data. It almost always does, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // but just occasionally it'll return a 308 that makes us resend a chunk. We need to | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -664,6 +701,54 @@ protected async Task<bool> SendNextChunkAsync(Stream stream, CancellationToken c | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return completed; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #if NET6_0_OR_GREATER | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Calculates the CRC32C hash of the entire stream and returns it as a Base64 string. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal async Task<string> CalculateCrc32cAsync(Stream stream, CancellationToken cancellationToken) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (stream == null || !stream.CanSeek) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| long originalPosition = stream.Position; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stream.Position = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var crc32c = new System.IO.Hashing.Crc32(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(Crc32cCalculationBufferSize); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int bytesRead; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| crc32c.Append(new ReadOnlySpan<byte>(buffer, 0, bytesRead)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| System.Buffers.ArrayPool<byte>.Shared.Return(buffer); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| byte[] hashBytes = crc32c.GetCurrentHash(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Convert.ToBase64String(hashBytes); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| finally | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Restore the stream position. Important so the upload continues correctly. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stream.Position = originalPosition; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
mahendra-google marked this conversation as resolved.
mahendra-google marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Calculates the CRC32C hash of the entire stream and returns it as a Base64 string. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal Task<string> CalculateCrc32cAsync(Stream stream, CancellationToken cancellationToken) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Task.FromResult<string>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary>Handles a media upload HTTP response.</summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <returns><c>True</c> if the entire media has been completely uploaded.</returns> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private async Task<bool> HandleResponse(HttpResponseMessage response) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1063,6 +1148,10 @@ public class ResumableUpload<TRequest> : ResumableUpload | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary>The uploadType parameter value for resumable uploads.</summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private const string Resumable = "resumable"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// The identifier string for the CRC32C hash.</summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private const string Crc32c = "Crc32c"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endregion // Constants | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #region Construction | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1133,6 +1222,29 @@ protected ResumableUpload(IClientService service, string path, string httpMethod | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public override async Task<Uri> InitiateSessionAsync(CancellationToken cancellationToken = default(CancellationToken)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HttpRequestMessage request = CreateInitializeRequest(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Crc32cHeaderSentInInitiation = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Body != null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var metadataHash = GetPropertyFromGenericBody(Body, Crc32c); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!string.IsNullOrEmpty(metadataHash)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| string calculatedHash = await CalculateCrc32cAsync(ContentStream, cancellationToken).ConfigureAwait(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This implementation has a significant performance implication for large files. A more efficient approach would be to calculate the CRC32C hash on-the-fly as the stream is read for uploading. This could be achieved by wrapping the While the current approach is simpler to implement, the performance cost is notable and this behavior should be clearly documented for users of this feature. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (calculatedHash != null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (metadataHash != calculatedHash) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new InvalidOperationException( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $"The calculated CRC32C of the stream ({calculatedHash}) does not match the CRC32C provided in the object metadata ({metadataHash})."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request.Headers.TryAddWithoutValidation(GoogleHashHeader, $"crc32c={calculatedHash}"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Crc32cHeaderSentInInitiation = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1232
to
+1241
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a CRC32C hash is provided for a non-seekable stream, it is silently ignored. This could be confusing for developers who expect the hash to be used. It would be better to log a warning to inform them that the hash cannot be validated on the client and will not be sent to the server because the stream is not seekable. if (calculatedHash != null)
{
if (metadataHash != calculatedHash)
{
throw new InvalidOperationException(
$"The calculated CRC32C of the stream ({calculatedHash}) does not match the CRC32C provided in the object metadata ({metadataHash}).");
}
request.Headers.TryAddWithoutValidation(GoogleHashHeader, $"crc32c={calculatedHash}");
Crc32cHeaderSentInInitiation = true;
}
else
{
Logger.Warning("A CRC32C hash was provided in the request body, but the stream is not seekable. The hash will not be validated on the client, and will not be sent to the server.");
}
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /gemini review There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for incorporating the warning! That addresses the core concern. For better integration with potential logging frameworks used by consumers of this library, it would be more idiomatic to use
Suggested change
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ApplicationContext.Logger.ForType<ResumableUpload<TRequest>>().Warning("A CRC32C hash was provided in the request body, but the stream is not seekable. The hash will not be validated on the client, and will not be sent to the server."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
mahendra-google marked this conversation as resolved.
mahendra-google marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Options?.ModifySessionInitiationRequest?.Invoke(request); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var response = await Service.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1211,6 +1323,12 @@ private void SetAllPropertyValues(RequestBuilder requestBuilder) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Retrieves the value of a specific property from a generic object using reflection. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// </summary> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal string GetPropertyFromGenericBody(TRequest body, string propertyName) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body?.GetType().GetProperty(propertyName)?.GetValue(body) as string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1329
to
+1330
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using reflection to get the
Consider defining an interface for bodies that support CRC32C validation: public interface IBodyWithCrc32c
{
string Crc32c { get; }
}Then, in if (Body is IBodyWithCrc32c bodyWithCrc32c)
{
var metadataHash = bodyWithCrc32c.Crc32c;
// ... rest of the logic
}This approach provides compile-time safety and is more performant. While this would require users to implement the interface, it makes the contract for this feature explicit. Given that reflection is used elsewhere in this library, the current approach may be consistent with existing design patterns, but an interface-based approach is generally preferable for new features.
Comment on lines
+1329
to
+1330
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using reflection to access the A more robust alternative would be to introduce an interface, for example Example: public interface IUploadWithCrc32c
{
string Crc32c { get; }
}
// In InitiateSessionAsync:
if (Body is IUploadWithCrc32c uploadWithCrc && !string.IsNullOrEmpty(uploadWithCrc.Crc32c))
{
var metadataHash = uploadWithCrc.Crc32c;
// ...
}This would provide compile-time safety and better performance. It's a non-breaking change, and types that need this functionality can opt-in by implementing the interface. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #endregion Upload Implementation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
knownSizeparameter is only evertruein this test, making it and the conditional logic for creating thecontentstream redundant. To improve clarity and simplify the test, you can remove theknownSizeparameter and directly initializecontentas aMemoryStream.