From e1c7503d30ea47b81e03820334ffce6fb53ef637 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 8 Jul 2026 14:51:07 -0700 Subject: [PATCH] Add automatic content hash for uploads Compute and send a Dropbox content_hash for seekable upload streams so the server verifies upload integrity and rejects corrupted uploads. Applies to all upload routes whose request carries a ContentHash (direct upload and upload sessions). - ContentHasher: public helper computing the Dropbox block-SHA256 content hash (sync + async), plus WithoutAutoContentHash to opt a stream out. - Handler: hash the body before sending, restore the stream position, and set the hash on a cloned request; retry now rewinds to the original upload position rather than 0. - Property access is resolved once per request type and cached. - Config: DropboxClientConfig.AutoContentHash, on by default; opt out per-client or per-upload. Non-seekable streams and manually supplied hashes are left untouched. - Tests: unit tests for the hasher and upload wiring, and live e2e tests covering direct upload round-trip and a chunked upload session. - README: document the behavior, the 2x read cost, and opt-out. --- README.md | 32 +++ .../DropboxApiTests.cs | 54 ++++ .../AutoContentHashUploadTests.cs | 202 ++++++++++++++ .../ContentHasherTests.cs | 70 +++++ .../Dropbox.Api/ContentHasher.cs | 251 ++++++++++++++++++ .../Dropbox.Api/DropboxClientConfig.cs | 6 + .../Dropbox.Api/DropboxRequestHandler.cs | 157 ++++++++++- 7 files changed, 768 insertions(+), 4 deletions(-) create mode 100644 dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/AutoContentHashUploadTests.cs create mode 100644 dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/ContentHasherTests.cs create mode 100644 dropbox-sdk-dotnet/Dropbox.Api/ContentHasher.cs diff --git a/README.md b/README.md index 89770c716..d7385baf0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,38 @@ We provide [Examples][examples] to help get you started with a lot of the basic - [Simple Business Dashboard](https://github.com/dropbox/dropbox-sdk-dotnet/tree/main/dropbox-sdk-dotnet/Examples/SimpleBusinessDashboard) - This is a demo of a business dashboard - [Universal Demo](https://github.com/dropbox/dropbox-sdk-dotnet/tree/main/dropbox-sdk-dotnet/Examples/UniversalDemo/UniversalDemo) - This is an example of how to use the SDK across multiple platforms +## Upload integrity + +For file upload calls whose request type includes `ContentHash`, the SDK automatically computes and sends a Dropbox `content_hash` when the upload stream is seekable. The server rejects the upload if the hash does not match the bytes it receives. + +Because `content_hash` is part of the request header, this default-on validation reads the stream once to compute the hash, seeks back to the original position, and then reads it again for the upload body. That is 2x read I/O for seekable uploads. For local files the second read is often served from OS cache; for slow, network-backed, or encrypted streams it can materially increase upload time. Non-seekable streams are uploaded without automatic hashing. A manually supplied `contentHash` argument always wins. If a seekable stream has unusual read or rewind behavior, disable automatic hashing for that upload or client. + +To compute a Dropbox content hash manually: + +```csharp +using (var stream = File.OpenRead("local-file.txt")) +{ + var contentHash = ContentHasher.ComputeHash(stream); +} +``` + +To disable automatic hashing for one upload, wrap the stream: + +```csharp +await client.Files.UploadAsync( + new UploadArg("/remote-file.txt"), + ContentHasher.WithoutAutoContentHash(stream)); +``` + +To disable automatic hashing for a client: + +```csharp +var config = new DropboxClientConfig +{ + AutoContentHash = false, +}; +``` + ## Getting Help If you find a bug, please see [CONTRIBUTING.md][contributing] for information on how to report it. diff --git a/dropbox-sdk-dotnet/Dropbox.Api.Integration.Tests/DropboxApiTests.cs b/dropbox-sdk-dotnet/Dropbox.Api.Integration.Tests/DropboxApiTests.cs index 3c4cb93d2..990e42c10 100644 --- a/dropbox-sdk-dotnet/Dropbox.Api.Integration.Tests/DropboxApiTests.cs +++ b/dropbox-sdk-dotnet/Dropbox.Api.Integration.Tests/DropboxApiTests.cs @@ -285,6 +285,60 @@ public async Task TestUploadKeepsStreamOpenWhenConfigured() Assert.AreEqual(3, stream.Length); } + /// + /// Tests that an auto-hashed upload is accepted by the server and round-trips. + /// + /// The . + [TestMethod] + public async Task TestAutoContentHashUploadRoundTrip() + { + var content = "auto-content-hash-roundtrip"; + var expectedHash = ContentHasher.ComputeHash(GetStream(content)); + + // Upload with auto content hash enabled (the default). The server + // rejects the upload if the hash the SDK sent does not match. + var uploaded = await client.Files.UploadAsync(testingPath + "/Hash.txt", body: GetStream(content)); + Assert.AreEqual(expectedHash, uploaded.ContentHash); + + // Metadata and downloaded content should match too. + var metadata = await client.Files.GetMetadataAsync(testingPath + "/Hash.txt"); + Assert.AreEqual(expectedHash, metadata.AsFile.ContentHash); + + var download = await client.Files.DownloadAsync(testingPath + "/Hash.txt"); + var downloaded = await download.GetContentAsStringAsync(); + Assert.AreEqual(content, downloaded); + } + + /// + /// Tests that a chunked upload session with auto content hashes round-trips. + /// + /// The . + [TestMethod] + public async Task TestAutoContentHashUploadSession() + { + var chunk1 = "chunk-one-"; + var chunk2 = "chunk-two-"; + var chunk3 = "chunk-three"; + var full = chunk1 + chunk2 + chunk3; + var path = testingPath + "/Session.txt"; + + // Each session call hashes its own chunk; the server rejects a mismatch. + var start = await client.Files.UploadSessionStartAsync(body: GetStream(chunk1)); + var appendCursor = new UploadSessionCursor(start.SessionId, (ulong)chunk1.Length); + + await client.Files.UploadSessionAppendV2Async(appendCursor, body: GetStream(chunk2)); + var finishCursor = new UploadSessionCursor(start.SessionId, (ulong)(chunk1.Length + chunk2.Length)); + + var uploaded = await client.Files.UploadSessionFinishAsync( + finishCursor, new CommitInfo(path), body: GetStream(chunk3)); + + Assert.AreEqual(ContentHasher.ComputeHash(GetStream(full)), uploaded.ContentHash); + + var download = await client.Files.DownloadAsync(path); + var downloaded = await download.GetContentAsStringAsync(); + Assert.AreEqual(full, downloaded); + } + /// /// Test get metadata. /// diff --git a/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/AutoContentHashUploadTests.cs b/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/AutoContentHashUploadTests.cs new file mode 100644 index 000000000..912009a43 --- /dev/null +++ b/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/AutoContentHashUploadTests.cs @@ -0,0 +1,202 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) Dropbox Inc. All rights reserved. +// +//----------------------------------------------------------------------------- + +namespace Dropbox.Api.Unit.Tests +{ + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Dropbox.Api.Files; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + + /// + /// Tests for automatic upload content hash injection. + /// + [TestClass] + public class AutoContentHashUploadTests + { + /// + /// Verifies seekable upload streams get a computed content hash. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestAutoContentHashUpload() + { + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler)) + { + var uploadArg = new UploadArg("/auto.txt"); + var content = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + await client.Files.UploadAsync(uploadArg, content).ConfigureAwait(false); + + var request = handler.Requests[0]; + var arg = JObject.Parse(request.Arg); + Assert.AreEqual(ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("payload"))), (string)arg["content_hash"]); + Assert.AreEqual("payload", request.Body); + Assert.IsNull(uploadArg.ContentHash); + Assert.AreEqual(Encoding.UTF8.GetByteCount("payload"), content.Position); + } + } + + /// + /// Verifies manual content hash values are preserved. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestManualContentHashWins() + { + const string ManualHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler)) + { + await client.Files.UploadAsync( + new UploadArg("/manual.txt", contentHash: ManualHash), + new MemoryStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false); + + var arg = JObject.Parse(handler.Requests[0].Arg); + Assert.AreEqual(ManualHash, (string)arg["content_hash"]); + } + } + + /// + /// Verifies the per-stream opt-out wrapper suppresses auto hash population. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestWithoutAutoContentHash() + { + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler)) + { + await client.Files.UploadAsync( + new UploadArg("/opt-out.txt"), + ContentHasher.WithoutAutoContentHash(new MemoryStream(Encoding.UTF8.GetBytes("payload")))).ConfigureAwait(false); + + var arg = JObject.Parse(handler.Requests[0].Arg); + Assert.IsNull(arg["content_hash"]); + Assert.AreEqual("payload", handler.Requests[0].Body); + } + } + + /// + /// Verifies non-seekable streams are uploaded without automatic content hashes. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestNonSeekableStreamSkipsAutoContentHash() + { + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler)) + { + await client.Files.UploadAsync( + new UploadArg("/non-seekable.txt"), + new NonSeekableStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false); + + var arg = JObject.Parse(handler.Requests[0].Arg); + Assert.IsNull(arg["content_hash"]); + Assert.AreEqual("payload", handler.Requests[0].Body); + } + } + + /// + /// Verifies auto hashing uses the stream's current position as the upload start. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestAutoContentHashPreservesCurrentPosition() + { + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler)) + { + var content = new MemoryStream(Encoding.UTF8.GetBytes("prefix:payload")); + content.Position = Encoding.UTF8.GetByteCount("prefix:"); + + await client.Files.UploadAsync(new UploadArg("/offset.txt"), content).ConfigureAwait(false); + + var arg = JObject.Parse(handler.Requests[0].Arg); + Assert.AreEqual(ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("payload"))), (string)arg["content_hash"]); + Assert.AreEqual("payload", handler.Requests[0].Body); + } + } + + /// + /// Verifies the client-level opt-out suppresses auto hash population. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestConfigDisablesAutoContentHash() + { + var handler = new CaptureUploadHandler(); + using (var client = CreateClient(handler, autoContentHash: false)) + { + await client.Files.UploadAsync( + new UploadArg("/disabled.txt"), + new MemoryStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false); + + var arg = JObject.Parse(handler.Requests[0].Arg); + Assert.IsNull(arg["content_hash"]); + } + } + + private static DropboxClient CreateClient(CaptureUploadHandler handler, bool autoContentHash = true) + { + return new DropboxClient( + "access-token", + new DropboxClientConfig + { + HttpClient = new HttpClient(handler), + DisposeUploadStream = false, + AutoContentHash = autoContentHash, + }); + } + + private sealed class CaptureUploadHandler : HttpMessageHandler + { + public IList Requests { get; } = new List(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + this.Requests.Add(new CapturedUploadRequest + { + Arg = string.Join(string.Empty, request.Headers.GetValues("Dropbox-API-Arg")), + Body = body, + }); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"name\":\"file.txt\",\"id\":\"id:test\",\"client_modified\":\"2020-01-02T03:04:05Z\",\"server_modified\":\"2020-01-02T03:04:06Z\",\"rev\":\"abcdef123\",\"size\":1,\"is_downloadable\":true}", + Encoding.UTF8, + "application/json"), + }; + } + } + + private sealed class CapturedUploadRequest + { + public string Arg { get; set; } + + public string Body { get; set; } + } + + private sealed class NonSeekableStream : MemoryStream + { + public NonSeekableStream(byte[] buffer) + : base(buffer) + { + } + + public override bool CanSeek => false; + } + } +} diff --git a/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/ContentHasherTests.cs b/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/ContentHasherTests.cs new file mode 100644 index 000000000..ce91bef24 --- /dev/null +++ b/dropbox-sdk-dotnet/Dropbox.Api.Unit.Tests/ContentHasherTests.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) Dropbox Inc. All rights reserved. +// +//----------------------------------------------------------------------------- + +namespace Dropbox.Api.Unit.Tests +{ + using System.IO; + using System.Text; + using System.Threading.Tasks; + using Dropbox.Api.Files; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + /// + /// Tests for Dropbox content hash computation. + /// + [TestClass] + public class ContentHasherTests + { + /// + /// Verifies known Dropbox content hash vectors. + /// + [TestMethod] + public void TestComputeHashKnownVectors() + { + Assert.AreEqual( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ContentHasher.ComputeHash(new MemoryStream())); + + Assert.AreEqual( + "bf5d3affb73efd2ec6c36ad3112dd933efed63c4e1cbffcfa88e2759c144f2d8", + ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("a")))); + + Assert.AreEqual( + "4f8b42c22dd3729b519ba6f68d2da7cc5b2d606d05daed5ad5128cc03e6c6358", + ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("abc")))); + } + + /// + /// Verifies block boundary handling. + /// + [TestMethod] + public void TestComputeHashBlockBoundary() + { + var content = new byte[ContentHasher.BlockSize + 1]; + for (var i = 0; i < content.Length; i++) + { + content[i] = (byte)'a'; + } + + Assert.AreEqual( + "5f858b62ccd88447586305aec6fd53c96747cfebf527cbba129a6dfed47d9624", + ContentHasher.ComputeHash(new MemoryStream(content))); + } + + /// + /// Verifies asynchronous hash computation. + /// + /// The asynchronous test task. + [TestMethod] + public async Task TestComputeHashAsync() + { + var content = new MemoryStream(Encoding.UTF8.GetBytes("abc")); + var contentHash = await ContentHasher.ComputeHashAsync(content).ConfigureAwait(false); + + Assert.AreEqual("4f8b42c22dd3729b519ba6f68d2da7cc5b2d606d05daed5ad5128cc03e6c6358", contentHash); + } + } +} diff --git a/dropbox-sdk-dotnet/Dropbox.Api/ContentHasher.cs b/dropbox-sdk-dotnet/Dropbox.Api/ContentHasher.cs new file mode 100644 index 000000000..e89670173 --- /dev/null +++ b/dropbox-sdk-dotnet/Dropbox.Api/ContentHasher.cs @@ -0,0 +1,251 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) Dropbox Inc. All rights reserved. +// +//----------------------------------------------------------------------------- + +namespace Dropbox.Api.Files +{ + using System; + using System.Globalization; + using System.IO; + using System.Security.Cryptography; + using System.Threading.Tasks; + + /// + /// Computes Dropbox API content hashes for file contents. + /// + /// + /// Dropbox content hashes are computed by splitting content into 4 MiB blocks, hashing + /// each block with SHA-256, concatenating those block hashes, and SHA-256 hashing the + /// concatenation. + /// + public static class ContentHasher + { + /// + /// The number of bytes in each Dropbox content hash block. + /// + public const int BlockSize = 4 * 1024 * 1024; + + private static readonly byte[] EmptyBytes = new byte[0]; + + /// + /// Computes the Dropbox API content hash for the stream from its current position to + /// the end of the stream. + /// + /// The content stream to hash. + /// The lowercase hexadecimal Dropbox API content hash. + public static string ComputeHash(Stream content) + { + if (content == null) + { + throw new ArgumentNullException("content"); + } + + var buffer = new byte[BlockSize]; + using (var overallHasher = SHA256.Create()) + { + while (true) + { + var count = ReadBlock(content, buffer); + if (count == 0) + { + break; + } + + using (var blockHasher = SHA256.Create()) + { + var blockHash = blockHasher.ComputeHash(buffer, 0, count); + overallHasher.TransformBlock(blockHash, 0, blockHash.Length, null, 0); + } + } + + overallHasher.TransformFinalBlock(EmptyBytes, 0, 0); + return ToHex(overallHasher.Hash); + } + } + + /// + /// Computes the Dropbox API content hash for the stream from its current position to + /// the end of the stream. + /// + /// The content stream to hash. + /// The task that represents the asynchronous hash operation. The TResult + /// parameter contains the lowercase hexadecimal Dropbox API content hash. + public static async Task ComputeHashAsync(Stream content) + { + if (content == null) + { + throw new ArgumentNullException("content"); + } + + var buffer = new byte[BlockSize]; + using (var overallHasher = SHA256.Create()) + { + while (true) + { + var count = await ReadBlockAsync(content, buffer).ConfigureAwait(false); + if (count == 0) + { + break; + } + + using (var blockHasher = SHA256.Create()) + { + var blockHash = blockHasher.ComputeHash(buffer, 0, count); + overallHasher.TransformBlock(blockHash, 0, blockHash.Length, null, 0); + } + } + + overallHasher.TransformFinalBlock(EmptyBytes, 0, 0); + return ToHex(overallHasher.Hash); + } + } + + /// + /// Returns a stream wrapper that disables automatic SDK content hash population for + /// upload requests. + /// + /// The upload content stream. + /// A stream wrapper that disables automatic content hash population. + /// + /// A manually supplied ContentHash value on the upload argument is still sent. This + /// wrapper delegates reads, seeking, and disposal to the wrapped stream. + /// + public static Stream WithoutAutoContentHash(Stream content) + { + if (content == null) + { + return null; + } + + return new AutoContentHashDisabledStream(content); + } + + /// + /// Returns whether automatic SDK content hash population is disabled for the stream. + /// + /// The upload content stream. + /// true if automatic content hash population is disabled. + internal static bool IsAutoContentHashDisabled(Stream content) + { + return content is AutoContentHashDisabledStream; + } + + private static int ReadBlock(Stream content, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var count = content.Read(buffer, offset, buffer.Length - offset); + if (count == 0) + { + break; + } + + offset += count; + } + + return offset; + } + + private static async Task ReadBlockAsync(Stream content, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var count = await content.ReadAsync(buffer, offset, buffer.Length - offset).ConfigureAwait(false); + if (count == 0) + { + break; + } + + offset += count; + } + + return offset; + } + + private static string ToHex(byte[] bytes) + { + var chars = new char[bytes.Length * 2]; + for (var i = 0; i < bytes.Length; i++) + { + var b = bytes[i]; + chars[i * 2] = GetHexValue(b / 16); + chars[(i * 2) + 1] = GetHexValue(b % 16); + } + + return new string(chars); + } + + private static char GetHexValue(int value) + { + return value.ToString("x", CultureInfo.InvariantCulture)[0]; + } + + private sealed class AutoContentHashDisabledStream : Stream + { + private readonly Stream inner; + + public AutoContentHashDisabledStream(Stream inner) + { + this.inner = inner; + } + + public override bool CanRead => this.inner.CanRead; + + public override bool CanSeek => this.inner.CanSeek; + + public override bool CanWrite => this.inner.CanWrite; + + public override long Length => this.inner.Length; + + public override long Position + { + get => this.inner.Position; + set => this.inner.Position = value; + } + + public override void Flush() + { + this.inner.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return this.inner.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return this.inner.Seek(offset, origin); + } + + public override void SetLength(long value) + { + this.inner.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + this.inner.Write(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) + { + return this.inner.ReadAsync(buffer, offset, count, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this.inner.Dispose(); + } + + base.Dispose(disposing); + } + } + } +} diff --git a/dropbox-sdk-dotnet/Dropbox.Api/DropboxClientConfig.cs b/dropbox-sdk-dotnet/Dropbox.Api/DropboxClientConfig.cs index dc1b1edb3..00c877462 100644 --- a/dropbox-sdk-dotnet/Dropbox.Api/DropboxClientConfig.cs +++ b/dropbox-sdk-dotnet/Dropbox.Api/DropboxClientConfig.cs @@ -73,5 +73,11 @@ public DropboxClientConfig(string userAgent, int maxRetriesOnError) /// backwards compatibility; set to false to keep the stream open. /// public bool DisposeUploadStream { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SDK automatically computes and sends + /// Dropbox content hashes for upload requests when possible. Default is true. + /// + public bool AutoContentHash { get; set; } = true; } } diff --git a/dropbox-sdk-dotnet/Dropbox.Api/DropboxRequestHandler.cs b/dropbox-sdk-dotnet/Dropbox.Api/DropboxRequestHandler.cs index 522db7eaf..4aa32cded 100644 --- a/dropbox-sdk-dotnet/Dropbox.Api/DropboxRequestHandler.cs +++ b/dropbox-sdk-dotnet/Dropbox.Api/DropboxRequestHandler.cs @@ -7,6 +7,7 @@ namespace Dropbox.Api { using System; + using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -43,6 +44,12 @@ internal sealed class DropboxRequestHandler : ITransport /// private const string DropboxApiResultHeader = "Dropbox-API-Result"; + private static readonly ConcurrentDictionary ContentHashAccessorCache = + new ConcurrentDictionary(); + + private static readonly MethodInfo MemberwiseCloneMethod = + typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic); + /// /// The member id of the selected user. /// @@ -185,6 +192,7 @@ async Task ITransport.SendUploadRequestAsync responseDecoder, IDecoder errorDecoder) { + request = await this.AddAutoContentHashAsync(request, body).ConfigureAwait(false); var serializedArg = JsonWriter.Write(request, requestEncoder, true); var res = await this.RequestJsonStringWithRetry(host, route, auth, RouteStyle.Upload, serializedArg, body) .ConfigureAwait(false); @@ -329,6 +337,92 @@ internal DropboxRequestHandler WithPathRoot(PathRoot pathRoot) pathRoot: pathRoot); } + private static ContentHashAccessor CreateContentHashAccessor(Type requestType) + { + var contentHashProperty = requestType.GetProperty("ContentHash", BindingFlags.Instance | BindingFlags.Public); + if (contentHashProperty == null || contentHashProperty.PropertyType != typeof(string)) + { + return ContentHashAccessor.None; + } + + var getter = contentHashProperty.GetGetMethod(); + var setter = contentHashProperty.GetSetMethod(true); + if (getter == null || setter == null) + { + return ContentHashAccessor.None; + } + + return new ContentHashAccessor(getter, setter); + } + + private async Task AddAutoContentHashAsync(TRequest request, Stream body) + { + if (!this.options.AutoContentHash || + request == null || + body == null || + global::Dropbox.Api.Files.ContentHasher.IsAutoContentHashDisabled(body)) + { + return request; + } + + var contentHashAccessor = ContentHashAccessorCache.GetOrAdd(request.GetType(), CreateContentHashAccessor); + if (!contentHashAccessor.CanAccess) + { + return request; + } + + var existingHash = contentHashAccessor.GetValue(request); + if (!string.IsNullOrEmpty(existingHash) || !body.CanSeek) + { + return request; + } + + long originalPosition; + try + { + originalPosition = body.Position; + } + catch (Exception) + { + return request; + } + + string contentHash = null; + Exception computeException = null; + try + { + contentHash = await global::Dropbox.Api.Files.ContentHasher.ComputeHashAsync(body).ConfigureAwait(false); + } + catch (Exception e) + { + computeException = e; + } + + Exception restoreException = null; + try + { + body.Position = originalPosition; + } + catch (Exception e) + { + restoreException = e; + } + + if (computeException != null) + { + throw new IOException("Failed to compute Dropbox content hash for upload.", computeException); + } + + if (restoreException != null) + { + throw new IOException("Failed to restore upload stream position after computing Dropbox content hash.", restoreException); + } + + var requestCopy = (TRequest)MemberwiseCloneMethod.Invoke(request, null); + contentHashAccessor.SetValue(requestCopy, contentHash); + return requestCopy; + } + /// /// Requests the JSON string with retry. /// @@ -352,6 +446,7 @@ private async Task RequestJsonStringWithRetry( var hasRefreshed = false; var maxRetries = this.options.MaxClientRetries; var r = new Random(); + long? uploadStartPosition = null; if (routeStyle == RouteStyle.Upload) { @@ -366,6 +461,17 @@ private async Task RequestJsonStringWithRetry( { maxRetries = 0; } + else + { + try + { + uploadStartPosition = body.Position; + } + catch (Exception) + { + maxRetries = 0; + } + } } await this.CheckAndRefreshAccessToken(); @@ -423,9 +529,9 @@ private async Task RequestJsonStringWithRetry( #else await Task.Delay(backoff).ConfigureAwait(false); #endif - if (body != null) + if (uploadStartPosition.HasValue) { - body.Position = 0; + body.Position = uploadStartPosition.Value; } } } @@ -743,6 +849,39 @@ private void Dispose(bool disposing) } } + private sealed class ContentHashAccessor + { + public static readonly ContentHashAccessor None = new ContentHashAccessor(null, null); + + private readonly MethodInfo getter; + + private readonly MethodInfo setter; + + public ContentHashAccessor(MethodInfo getter, MethodInfo setter) + { + this.getter = getter; + this.setter = setter; + } + + public bool CanAccess + { + get + { + return this.getter != null && this.setter != null; + } + } + + public string GetValue(object request) + { + return (string)this.getter.Invoke(request, null); + } + + public void SetValue(object request, string contentHash) + { + this.setter.Invoke(request, new object[] { contentHash }); + } + } + /// /// Used to return un-typed result information to the layer that can interpret the /// object types. @@ -965,7 +1104,8 @@ public DropboxRequestHandlerOptions(DropboxClientConfig config, string oauth2Acc DefaultApiNotifyDomain, config.HttpClient, config.LongPollHttpClient, - config.DisposeUploadStream) + config.DisposeUploadStream, + config.AutoContentHash) { } @@ -990,6 +1130,7 @@ public DropboxRequestHandlerOptions(DropboxClientConfig config, string oauth2Acc /// The custom http client for long poll. If not provided, a default /// http client with longer timeout will be created. /// Whether to dispose the upload stream after the request. + /// Whether to automatically compute Dropbox content hashes for upload requests. public DropboxRequestHandlerOptions( string oauth2AccessToken, string oauth2RefreshToken, @@ -1003,7 +1144,8 @@ public DropboxRequestHandlerOptions( string apiNotifyHostname, HttpClient httpClient, HttpClient longPollHttpClient, - bool disposeUploadStream = true) + bool disposeUploadStream = true, + bool autoContentHash = true) { var type = typeof(DropboxRequestHandlerOptions); #if PORTABLE40 @@ -1021,6 +1163,7 @@ public DropboxRequestHandlerOptions( this.HttpClient = httpClient; this.LongPollHttpClient = longPollHttpClient; this.DisposeUploadStream = disposeUploadStream; + this.AutoContentHash = autoContentHash; this.OAuth2AccessToken = oauth2AccessToken; this.OAuth2RefreshToken = oauth2RefreshToken; this.OAuth2AccessTokenExpiresAt = oauth2AccessTokenExpiresAt; @@ -1080,6 +1223,12 @@ public DropboxRequestHandlerOptions( /// public bool DisposeUploadStream { get; private set; } + /// + /// Gets a value indicating whether to automatically compute Dropbox content hashes + /// for upload requests. + /// + public bool AutoContentHash { get; private set; } + /// /// Gets the user agent string. ///