Skip to content

Commit 3cc79e1

Browse files
Add automatic content hash for uploads (#385)
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.
1 parent 0e0c773 commit 3cc79e1

7 files changed

Lines changed: 768 additions & 4 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ We provide [Examples][examples] to help get you started with a lot of the basic
3737
- [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
3838
- [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
3939

40+
## Upload integrity
41+
42+
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.
43+
44+
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.
45+
46+
To compute a Dropbox content hash manually:
47+
48+
```csharp
49+
using (var stream = File.OpenRead("local-file.txt"))
50+
{
51+
var contentHash = ContentHasher.ComputeHash(stream);
52+
}
53+
```
54+
55+
To disable automatic hashing for one upload, wrap the stream:
56+
57+
```csharp
58+
await client.Files.UploadAsync(
59+
new UploadArg("/remote-file.txt"),
60+
ContentHasher.WithoutAutoContentHash(stream));
61+
```
62+
63+
To disable automatic hashing for a client:
64+
65+
```csharp
66+
var config = new DropboxClientConfig
67+
{
68+
AutoContentHash = false,
69+
};
70+
```
71+
4072
## Getting Help
4173

4274
If you find a bug, please see [CONTRIBUTING.md][contributing] for information on how to report it.

dropbox-sdk-dotnet/Dropbox.Api.Integration.Tests/DropboxApiTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,60 @@ public async Task TestUploadKeepsStreamOpenWhenConfigured()
285285
Assert.AreEqual(3, stream.Length);
286286
}
287287

288+
/// <summary>
289+
/// Tests that an auto-hashed upload is accepted by the server and round-trips.
290+
/// </summary>
291+
/// <returns>The <see cref="Task" />.</returns>
292+
[TestMethod]
293+
public async Task TestAutoContentHashUploadRoundTrip()
294+
{
295+
var content = "auto-content-hash-roundtrip";
296+
var expectedHash = ContentHasher.ComputeHash(GetStream(content));
297+
298+
// Upload with auto content hash enabled (the default). The server
299+
// rejects the upload if the hash the SDK sent does not match.
300+
var uploaded = await client.Files.UploadAsync(testingPath + "/Hash.txt", body: GetStream(content));
301+
Assert.AreEqual(expectedHash, uploaded.ContentHash);
302+
303+
// Metadata and downloaded content should match too.
304+
var metadata = await client.Files.GetMetadataAsync(testingPath + "/Hash.txt");
305+
Assert.AreEqual(expectedHash, metadata.AsFile.ContentHash);
306+
307+
var download = await client.Files.DownloadAsync(testingPath + "/Hash.txt");
308+
var downloaded = await download.GetContentAsStringAsync();
309+
Assert.AreEqual(content, downloaded);
310+
}
311+
312+
/// <summary>
313+
/// Tests that a chunked upload session with auto content hashes round-trips.
314+
/// </summary>
315+
/// <returns>The <see cref="Task" />.</returns>
316+
[TestMethod]
317+
public async Task TestAutoContentHashUploadSession()
318+
{
319+
var chunk1 = "chunk-one-";
320+
var chunk2 = "chunk-two-";
321+
var chunk3 = "chunk-three";
322+
var full = chunk1 + chunk2 + chunk3;
323+
var path = testingPath + "/Session.txt";
324+
325+
// Each session call hashes its own chunk; the server rejects a mismatch.
326+
var start = await client.Files.UploadSessionStartAsync(body: GetStream(chunk1));
327+
var appendCursor = new UploadSessionCursor(start.SessionId, (ulong)chunk1.Length);
328+
329+
await client.Files.UploadSessionAppendV2Async(appendCursor, body: GetStream(chunk2));
330+
var finishCursor = new UploadSessionCursor(start.SessionId, (ulong)(chunk1.Length + chunk2.Length));
331+
332+
var uploaded = await client.Files.UploadSessionFinishAsync(
333+
finishCursor, new CommitInfo(path), body: GetStream(chunk3));
334+
335+
Assert.AreEqual(ContentHasher.ComputeHash(GetStream(full)), uploaded.ContentHash);
336+
337+
var download = await client.Files.DownloadAsync(path);
338+
var downloaded = await download.GetContentAsStringAsync();
339+
Assert.AreEqual(full, downloaded);
340+
}
341+
288342
/// <summary>
289343
/// Test get metadata.
290344
/// </summary>
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
//-----------------------------------------------------------------------------
2+
// <copyright file="AutoContentHashUploadTests.cs" company="Dropbox Inc">
3+
// Copyright (c) Dropbox Inc. All rights reserved.
4+
// </copyright>
5+
//-----------------------------------------------------------------------------
6+
7+
namespace Dropbox.Api.Unit.Tests
8+
{
9+
using System.Collections.Generic;
10+
using System.IO;
11+
using System.Net;
12+
using System.Net.Http;
13+
using System.Text;
14+
using System.Threading;
15+
using System.Threading.Tasks;
16+
using Dropbox.Api.Files;
17+
using Microsoft.VisualStudio.TestTools.UnitTesting;
18+
using Newtonsoft.Json.Linq;
19+
20+
/// <summary>
21+
/// Tests for automatic upload content hash injection.
22+
/// </summary>
23+
[TestClass]
24+
public class AutoContentHashUploadTests
25+
{
26+
/// <summary>
27+
/// Verifies seekable upload streams get a computed content hash.
28+
/// </summary>
29+
/// <returns>The asynchronous test task.</returns>
30+
[TestMethod]
31+
public async Task TestAutoContentHashUpload()
32+
{
33+
var handler = new CaptureUploadHandler();
34+
using (var client = CreateClient(handler))
35+
{
36+
var uploadArg = new UploadArg("/auto.txt");
37+
var content = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
38+
39+
await client.Files.UploadAsync(uploadArg, content).ConfigureAwait(false);
40+
41+
var request = handler.Requests[0];
42+
var arg = JObject.Parse(request.Arg);
43+
Assert.AreEqual(ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("payload"))), (string)arg["content_hash"]);
44+
Assert.AreEqual("payload", request.Body);
45+
Assert.IsNull(uploadArg.ContentHash);
46+
Assert.AreEqual(Encoding.UTF8.GetByteCount("payload"), content.Position);
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Verifies manual content hash values are preserved.
52+
/// </summary>
53+
/// <returns>The asynchronous test task.</returns>
54+
[TestMethod]
55+
public async Task TestManualContentHashWins()
56+
{
57+
const string ManualHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
58+
var handler = new CaptureUploadHandler();
59+
using (var client = CreateClient(handler))
60+
{
61+
await client.Files.UploadAsync(
62+
new UploadArg("/manual.txt", contentHash: ManualHash),
63+
new MemoryStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false);
64+
65+
var arg = JObject.Parse(handler.Requests[0].Arg);
66+
Assert.AreEqual(ManualHash, (string)arg["content_hash"]);
67+
}
68+
}
69+
70+
/// <summary>
71+
/// Verifies the per-stream opt-out wrapper suppresses auto hash population.
72+
/// </summary>
73+
/// <returns>The asynchronous test task.</returns>
74+
[TestMethod]
75+
public async Task TestWithoutAutoContentHash()
76+
{
77+
var handler = new CaptureUploadHandler();
78+
using (var client = CreateClient(handler))
79+
{
80+
await client.Files.UploadAsync(
81+
new UploadArg("/opt-out.txt"),
82+
ContentHasher.WithoutAutoContentHash(new MemoryStream(Encoding.UTF8.GetBytes("payload")))).ConfigureAwait(false);
83+
84+
var arg = JObject.Parse(handler.Requests[0].Arg);
85+
Assert.IsNull(arg["content_hash"]);
86+
Assert.AreEqual("payload", handler.Requests[0].Body);
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Verifies non-seekable streams are uploaded without automatic content hashes.
92+
/// </summary>
93+
/// <returns>The asynchronous test task.</returns>
94+
[TestMethod]
95+
public async Task TestNonSeekableStreamSkipsAutoContentHash()
96+
{
97+
var handler = new CaptureUploadHandler();
98+
using (var client = CreateClient(handler))
99+
{
100+
await client.Files.UploadAsync(
101+
new UploadArg("/non-seekable.txt"),
102+
new NonSeekableStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false);
103+
104+
var arg = JObject.Parse(handler.Requests[0].Arg);
105+
Assert.IsNull(arg["content_hash"]);
106+
Assert.AreEqual("payload", handler.Requests[0].Body);
107+
}
108+
}
109+
110+
/// <summary>
111+
/// Verifies auto hashing uses the stream's current position as the upload start.
112+
/// </summary>
113+
/// <returns>The asynchronous test task.</returns>
114+
[TestMethod]
115+
public async Task TestAutoContentHashPreservesCurrentPosition()
116+
{
117+
var handler = new CaptureUploadHandler();
118+
using (var client = CreateClient(handler))
119+
{
120+
var content = new MemoryStream(Encoding.UTF8.GetBytes("prefix:payload"));
121+
content.Position = Encoding.UTF8.GetByteCount("prefix:");
122+
123+
await client.Files.UploadAsync(new UploadArg("/offset.txt"), content).ConfigureAwait(false);
124+
125+
var arg = JObject.Parse(handler.Requests[0].Arg);
126+
Assert.AreEqual(ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("payload"))), (string)arg["content_hash"]);
127+
Assert.AreEqual("payload", handler.Requests[0].Body);
128+
}
129+
}
130+
131+
/// <summary>
132+
/// Verifies the client-level opt-out suppresses auto hash population.
133+
/// </summary>
134+
/// <returns>The asynchronous test task.</returns>
135+
[TestMethod]
136+
public async Task TestConfigDisablesAutoContentHash()
137+
{
138+
var handler = new CaptureUploadHandler();
139+
using (var client = CreateClient(handler, autoContentHash: false))
140+
{
141+
await client.Files.UploadAsync(
142+
new UploadArg("/disabled.txt"),
143+
new MemoryStream(Encoding.UTF8.GetBytes("payload"))).ConfigureAwait(false);
144+
145+
var arg = JObject.Parse(handler.Requests[0].Arg);
146+
Assert.IsNull(arg["content_hash"]);
147+
}
148+
}
149+
150+
private static DropboxClient CreateClient(CaptureUploadHandler handler, bool autoContentHash = true)
151+
{
152+
return new DropboxClient(
153+
"access-token",
154+
new DropboxClientConfig
155+
{
156+
HttpClient = new HttpClient(handler),
157+
DisposeUploadStream = false,
158+
AutoContentHash = autoContentHash,
159+
});
160+
}
161+
162+
private sealed class CaptureUploadHandler : HttpMessageHandler
163+
{
164+
public IList<CapturedUploadRequest> Requests { get; } = new List<CapturedUploadRequest>();
165+
166+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
167+
{
168+
var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
169+
this.Requests.Add(new CapturedUploadRequest
170+
{
171+
Arg = string.Join(string.Empty, request.Headers.GetValues("Dropbox-API-Arg")),
172+
Body = body,
173+
});
174+
175+
return new HttpResponseMessage(HttpStatusCode.OK)
176+
{
177+
Content = new StringContent(
178+
"{\"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}",
179+
Encoding.UTF8,
180+
"application/json"),
181+
};
182+
}
183+
}
184+
185+
private sealed class CapturedUploadRequest
186+
{
187+
public string Arg { get; set; }
188+
189+
public string Body { get; set; }
190+
}
191+
192+
private sealed class NonSeekableStream : MemoryStream
193+
{
194+
public NonSeekableStream(byte[] buffer)
195+
: base(buffer)
196+
{
197+
}
198+
199+
public override bool CanSeek => false;
200+
}
201+
}
202+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//-----------------------------------------------------------------------------
2+
// <copyright file="ContentHasherTests.cs" company="Dropbox Inc">
3+
// Copyright (c) Dropbox Inc. All rights reserved.
4+
// </copyright>
5+
//-----------------------------------------------------------------------------
6+
7+
namespace Dropbox.Api.Unit.Tests
8+
{
9+
using System.IO;
10+
using System.Text;
11+
using System.Threading.Tasks;
12+
using Dropbox.Api.Files;
13+
using Microsoft.VisualStudio.TestTools.UnitTesting;
14+
15+
/// <summary>
16+
/// Tests for Dropbox content hash computation.
17+
/// </summary>
18+
[TestClass]
19+
public class ContentHasherTests
20+
{
21+
/// <summary>
22+
/// Verifies known Dropbox content hash vectors.
23+
/// </summary>
24+
[TestMethod]
25+
public void TestComputeHashKnownVectors()
26+
{
27+
Assert.AreEqual(
28+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
29+
ContentHasher.ComputeHash(new MemoryStream()));
30+
31+
Assert.AreEqual(
32+
"bf5d3affb73efd2ec6c36ad3112dd933efed63c4e1cbffcfa88e2759c144f2d8",
33+
ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("a"))));
34+
35+
Assert.AreEqual(
36+
"4f8b42c22dd3729b519ba6f68d2da7cc5b2d606d05daed5ad5128cc03e6c6358",
37+
ContentHasher.ComputeHash(new MemoryStream(Encoding.UTF8.GetBytes("abc"))));
38+
}
39+
40+
/// <summary>
41+
/// Verifies block boundary handling.
42+
/// </summary>
43+
[TestMethod]
44+
public void TestComputeHashBlockBoundary()
45+
{
46+
var content = new byte[ContentHasher.BlockSize + 1];
47+
for (var i = 0; i < content.Length; i++)
48+
{
49+
content[i] = (byte)'a';
50+
}
51+
52+
Assert.AreEqual(
53+
"5f858b62ccd88447586305aec6fd53c96747cfebf527cbba129a6dfed47d9624",
54+
ContentHasher.ComputeHash(new MemoryStream(content)));
55+
}
56+
57+
/// <summary>
58+
/// Verifies asynchronous hash computation.
59+
/// </summary>
60+
/// <returns>The asynchronous test task.</returns>
61+
[TestMethod]
62+
public async Task TestComputeHashAsync()
63+
{
64+
var content = new MemoryStream(Encoding.UTF8.GetBytes("abc"));
65+
var contentHash = await ContentHasher.ComputeHashAsync(content).ConfigureAwait(false);
66+
67+
Assert.AreEqual("4f8b42c22dd3729b519ba6f68d2da7cc5b2d606d05daed5ad5128cc03e6c6358", contentHash);
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)