Skip to content

Commit e0008c8

Browse files
Automatically add content hashes to uploads (#538)
Compute and send Dropbox content hashes for byte upload payloads by default. Preserve caller-provided hashes and support opting out. Add unit and live integration coverage for hashing and request verification. Run the complete unit suite in CI and coverage jobs.
1 parent 620b8be commit e0008c8

7 files changed

Lines changed: 418 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
5353
- name: Run unit tests
5454
run: |
55-
pytest -v test/unit/test_dropbox_unit.py
55+
pytest -v test/unit/
5656
Docs:
5757
runs-on: ubuntu-latest
5858
steps:

.github/workflows/coverage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
pip install .
3131
- name: Generate Unit Test Coverage
3232
run: |
33-
coverage run --rcfile=.coveragerc -m pytest test/unit/test_dropbox_unit.py
33+
coverage run --rcfile=.coveragerc -m pytest test/unit/
3434
coverage xml
3535
- name: Publish Coverage
3636
uses: codecov/codecov-action@v5

dropbox/content_hash.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Dropbox "content_hash" hashers, from
3+
https://github.com/dropbox/dropbox-api-content-hasher (adapted for Python 3).
4+
See https://www.dropbox.com/developers/reference/content-hash.
5+
"""
6+
7+
import hashlib
8+
9+
10+
class DropboxContentHasher(object):
11+
"""
12+
Computes a hash using the same algorithm that the Dropbox API uses for the
13+
the "content_hash" metadata field.
14+
15+
The digest() method returns a raw binary representation of the hash. The
16+
hexdigest() convenience method returns a hexadecimal-encoded version, which
17+
is what the "content_hash" metadata field uses.
18+
19+
This class has the same interface as the hashers in the standard 'hashlib'
20+
package.
21+
22+
Example:
23+
24+
hasher = DropboxContentHasher()
25+
with open('some-file', 'rb') as f:
26+
while True:
27+
chunk = f.read(1024) # or whatever chunk size you want
28+
if len(chunk) == 0:
29+
break
30+
hasher.update(chunk)
31+
print(hasher.hexdigest())
32+
"""
33+
34+
BLOCK_SIZE = 4 * 1024 * 1024
35+
36+
def __init__(self):
37+
self._overall_hasher = hashlib.sha256()
38+
self._block_hasher = hashlib.sha256()
39+
self._block_pos = 0
40+
41+
self.digest_size = self._overall_hasher.digest_size
42+
# hashlib classes also define 'block_size', but I don't know how people use that value
43+
44+
def update(self, new_data):
45+
if self._overall_hasher is None:
46+
raise AssertionError(
47+
"can't use this object anymore; you already called digest()")
48+
49+
assert isinstance(new_data, bytes), (
50+
"Expecting a byte string, got {!r}".format(new_data))
51+
52+
new_data_pos = 0
53+
while new_data_pos < len(new_data):
54+
if self._block_pos == self.BLOCK_SIZE:
55+
self._overall_hasher.update(self._block_hasher.digest())
56+
self._block_hasher = hashlib.sha256()
57+
self._block_pos = 0
58+
59+
space_in_block = self.BLOCK_SIZE - self._block_pos
60+
part = new_data[new_data_pos:(new_data_pos + space_in_block)]
61+
self._block_hasher.update(part)
62+
63+
self._block_pos += len(part)
64+
new_data_pos += len(part)
65+
66+
def _finish(self):
67+
if self._overall_hasher is None:
68+
raise AssertionError(
69+
"can't use this object anymore; you already called digest() or hexdigest()")
70+
71+
if self._block_pos > 0:
72+
self._overall_hasher.update(self._block_hasher.digest())
73+
self._block_hasher = None
74+
h = self._overall_hasher
75+
self._overall_hasher = None # Make sure we can't use this object anymore.
76+
return h
77+
78+
def digest(self):
79+
return self._finish().digest()
80+
81+
def hexdigest(self):
82+
return self._finish().hexdigest()
83+
84+
def copy(self):
85+
c = DropboxContentHasher.__new__(DropboxContentHasher)
86+
c._overall_hasher = self._overall_hasher.copy()
87+
c._block_hasher = self._block_hasher.copy()
88+
c._block_pos = self._block_pos
89+
c.digest_size = self.digest_size
90+
return c
91+
92+
93+
class StreamHasher(object):
94+
"""
95+
A wrapper around a file-like object (either for reading or writing)
96+
that hashes everything that passes through it. Can be used with
97+
DropboxContentHasher or any 'hashlib' hasher.
98+
99+
Example:
100+
101+
hasher = DropboxContentHasher()
102+
with open('some-file', 'rb') as f:
103+
wrapped_f = StreamHasher(f, hasher)
104+
response = some_api_client.upload(wrapped_f)
105+
106+
locally_computed = hasher.hexdigest()
107+
assert response.content_hash == locally_computed
108+
"""
109+
110+
def __init__(self, f, hasher):
111+
self._f = f
112+
self._hasher = hasher
113+
114+
def close(self):
115+
return self._f.close()
116+
117+
def flush(self):
118+
return self._f.flush()
119+
120+
def fileno(self):
121+
return self._f.fileno()
122+
123+
def tell(self):
124+
return self._f.tell()
125+
126+
def read(self, *args):
127+
b = self._f.read(*args)
128+
self._hasher.update(b)
129+
return b
130+
131+
def write(self, b):
132+
self._hasher.update(b)
133+
return self._f.write(b)
134+
135+
def __iter__(self):
136+
return self
137+
138+
def __next__(self):
139+
b = next(self._f)
140+
self._hasher.update(b)
141+
return b
142+
143+
def next(self):
144+
return self.__next__()
145+
146+
def readline(self, *args):
147+
b = self._f.readline(*args)
148+
self._hasher.update(b)
149+
return b
150+
151+
def readlines(self, *args):
152+
bs = self._f.readlines(*args)
153+
for b in bs:
154+
self._hasher.update(b)
155+
return bs
156+
157+
158+
def content_hash(content):
159+
"""Return the Dropbox content hash of a ``bytes`` payload as a hex string."""
160+
hasher = DropboxContentHasher()
161+
hasher.update(content)
162+
return hasher.hexdigest()

dropbox/dropbox_client.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import base64
1212
import contextlib
13+
import copy
1314
import json
1415
import logging
1516
import random
@@ -23,6 +24,7 @@
2324
RateLimitError_validator,
2425
)
2526
from dropbox import files
27+
from dropbox.content_hash import content_hash as _content_hash
2628
from dropbox.common import (
2729
PathRoot,
2830
PathRoot_validator,
@@ -151,7 +153,8 @@ def __init__(self,
151153
app_key=None,
152154
app_secret=None,
153155
scope=None,
154-
ca_certs=None):
156+
ca_certs=None,
157+
auto_content_hash=True):
155158
"""
156159
:param str oauth2_access_token: OAuth2 access token for making client
157160
requests.
@@ -182,6 +185,8 @@ def __init__(self,
182185
refresh will request all available scopes for application
183186
:param str ca_certs: a path to a file of concatenated CA certificates in PEM format.
184187
Has the same meaning as when using :func:`ssl.wrap_socket`.
188+
:param bool auto_content_hash: If True (default), send a computed
189+
content_hash with uploads so the server verifies integrity.
185190
"""
186191

187192
if not (oauth2_access_token or oauth2_refresh_token or (app_key and app_secret)):
@@ -205,6 +210,7 @@ def __init__(self,
205210
self._app_key = app_key
206211
self._app_secret = app_secret
207212
self._scope = scope
213+
self._auto_content_hash = auto_content_hash
208214

209215
self._max_retries_on_error = max_retries_on_error
210216
self._max_retries_on_rate_limit = max_retries_on_rate_limit
@@ -246,7 +252,8 @@ def clone(
246252
oauth2_access_token_expiration=None,
247253
app_key=None,
248254
app_secret=None,
249-
scope=None):
255+
scope=None,
256+
auto_content_hash=None):
250257
"""
251258
Creates a new copy of the Dropbox client with the same defaults unless modified by
252259
arguments to clone()
@@ -269,7 +276,10 @@ def clone(
269276
oauth2_access_token_expiration or self._oauth2_access_token_expiration,
270277
app_key or self._app_key,
271278
app_secret or self._app_secret,
272-
scope or self._scope
279+
scope or self._scope,
280+
auto_content_hash=(self._auto_content_hash
281+
if auto_content_hash is None
282+
else auto_content_hash),
273283
)
274284

275285
def request(self,
@@ -308,6 +318,9 @@ def request(self,
308318
if route.version > 1:
309319
route_name += '_v{}'.format(route.version)
310320
route_style = route.attrs['style'] or 'rpc'
321+
322+
request_arg = self._maybe_add_content_hash(request_arg, request_binary)
323+
311324
serialized_arg = stone_serializers.json_encode(route.arg_type,
312325
request_arg)
313326

@@ -356,6 +369,23 @@ def request(self,
356369
else:
357370
return deserialized_result
358371

372+
def _maybe_add_content_hash(self, request_arg, request_binary):
373+
"""Set content_hash on upload args when the caller didn't. Returns a
374+
copy; the caller's arg is not mutated."""
375+
if not self._auto_content_hash:
376+
return request_arg
377+
if not isinstance(request_binary, bytes):
378+
return request_arg
379+
if request_arg is None or \
380+
'content_hash' not in getattr(request_arg, '_all_field_names_', ()):
381+
return request_arg
382+
if request_arg.content_hash is not None:
383+
return request_arg
384+
385+
request_arg = copy.copy(request_arg)
386+
request_arg.content_hash = _content_hash(request_binary)
387+
return request_arg
388+
359389
def check_and_refresh_access_token(self):
360390
"""
361391
Checks if access token needs to be refreshed and refreshes if possible
@@ -804,6 +834,7 @@ def _get_dropbox_client_with_select_header(self, select_header_name, team_member
804834
app_key=self._app_key,
805835
app_secret=self._app_secret,
806836
scope=self._scope,
837+
auto_content_hash=self._auto_content_hash,
807838
)
808839

809840
class BadInputException(Exception):

test/integration/test_dropbox.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import sys
1111
import pytest
1212

13+
import dropbox.dropbox_client as dropbox_client
14+
1315
try:
1416
from io import BytesIO
1517
except ImportError:
@@ -194,6 +196,17 @@ def test_upload_download(self, dbx_from_env):
194196
# Cleanup folder
195197
dbx_from_env.files_delete('/Test/%s' % TIMESTAMP)
196198

199+
def test_upload_auto_content_hash(self, dbx_from_env, monkeypatch):
200+
# A deliberately incorrect auto-computed hash must be rejected by the
201+
# server, proving the default hash is included in the request.
202+
monkeypatch.setattr(dropbox_client, '_content_hash',
203+
lambda _: '0' * 64)
204+
random_filename = ''.join(RANDOM_FOLDER)
205+
random_path = '/Test/%s/%s' % (TIMESTAMP, random_filename)
206+
with pytest.raises(ApiError) as cm:
207+
dbx_from_env.files_upload(DUMMY_PAYLOAD, random_path)
208+
assert cm.value.error.is_content_hash_mismatch()
209+
197210
def test_bad_upload_types(self, dbx_from_env):
198211
with pytest.raises(TypeError):
199212
dbx_from_env.files_upload(BytesIO(b'test'), '/Test')

0 commit comments

Comments
 (0)