Skip to content

Commit 3971fc1

Browse files
authored
[v2] Validate multipart downloads using object ETags (#9493)
1 parent 184aa26 commit 3971fc1

20 files changed

Lines changed: 257 additions & 20 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "bugfix",
3+
"category": "s3transfer",
4+
"description": "Validate ETag of stored object during multipart downloads"
5+
}

awscli/customizations/s3/filegenerator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def __init__(
108108
dest_type=None,
109109
operation_name=None,
110110
response_data=None,
111+
etag=None,
111112
):
112113
self.src = src
113114
self.dest = dest
@@ -118,6 +119,7 @@ def __init__(
118119
self.dest_type = dest_type
119120
self.operation_name = operation_name
120121
self.response_data = response_data
122+
self.etag = etag
121123

122124

123125
class FileGenerator:
@@ -177,6 +179,7 @@ def _inject_extra_information(self, file_stat_kwargs, extra_information):
177179
src_type = file_stat_kwargs['src_type']
178180
file_stat_kwargs['size'] = extra_information['Size']
179181
file_stat_kwargs['last_update'] = extra_information['LastModified']
182+
file_stat_kwargs['etag'] = extra_information.get('ETag')
180183

181184
# S3 objects require the response data retrieved from HeadObject
182185
# and ListObject
@@ -401,4 +404,5 @@ def _list_single_object(self, s3_path):
401404
response['Size'] = int(response.pop('ContentLength'))
402405
last_update = parse(response['LastModified'])
403406
response['LastModified'] = last_update.astimezone(tzlocal())
407+
response['ETag'] = response.pop('ETag', None)
404408
return s3_path, response

awscli/customizations/s3/fileinfo.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(
5454
source_client=None,
5555
is_stream=False,
5656
associated_response_data=None,
57+
etag=None,
5758
):
5859
self.src = src
5960
self.src_type = src_type
@@ -71,6 +72,7 @@ def __init__(
7172
self.source_client = source_client
7273
self.is_stream = is_stream
7374
self.associated_response_data = associated_response_data
75+
self.etag = etag
7476

7577
def is_glacier_compatible(self):
7678
"""Determines if a file info object is glacier compatible

awscli/customizations/s3/fileinfobuilder.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def _inject_info(self, file_base):
4747
file_info_attr['parameters'] = self._parameters
4848
file_info_attr['is_stream'] = self._is_stream
4949
file_info_attr['associated_response_data'] = file_base.response_data
50+
file_info_attr['etag'] = file_base.etag
5051

5152
# This is a bit quirky. The below conditional hinges on the --delete
5253
# flag being set, which only occurs during a sync command. The source

awscli/customizations/s3/s3handler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
DeleteSourceFileSubscriber,
3838
DeleteSourceObjectSubscriber,
3939
DirectoryCreatorSubscriber,
40+
ProvideETagSubscriber,
4041
ProvideLastModifiedTimeSubscriber,
4142
ProvideSizeSubscriber,
4243
ProvideUploadContentTypeSubscriber,
@@ -243,6 +244,7 @@ def _get_subscribers(self, fileinfo):
243244
subscribers = []
244245
result_subscriber_kwargs = self._get_result_subscriber_kwargs(fileinfo)
245246
self._add_provide_size_subscriber(subscribers, fileinfo)
247+
subscribers.append(ProvideETagSubscriber(fileinfo.etag))
246248
subscribers.append(QueuedResultSubscriber(**result_subscriber_kwargs))
247249
self._add_additional_subscribers(subscribers, fileinfo)
248250
subscribers.extend(

awscli/customizations/s3/subscribers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,27 @@ def on_queued(self, future, **kwargs):
7878
)
7979

8080

81+
class ProvideETagSubscriber(BaseSubscriber):
82+
"""
83+
A subscriber which provides the object ETag before it's queued.
84+
"""
85+
86+
def __init__(self, etag):
87+
self.etag = etag
88+
89+
def on_queued(self, future, **kwargs):
90+
# The CRT transfer client does not support providing an expected
91+
# ETag of an object. So only provide it if it is available.
92+
if hasattr(future.meta, 'provide_object_etag'):
93+
future.meta.provide_object_etag(self.etag)
94+
else:
95+
LOGGER.debug(
96+
'Not providing object ETag. Future: %s does not offer'
97+
'the capability to notify the ETag of an object',
98+
future,
99+
)
100+
101+
81102
class DeleteSourceSubscriber(OnDoneFilteredSubscriber):
82103
"""A subscriber which deletes the source of the transfer."""
83104

awscli/s3transfer/download.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
import logging
1515
import threading
1616

17+
from botocore.exceptions import ClientError
1718
from s3transfer.compat import seekable
18-
from s3transfer.exceptions import RetriesExceededError
19+
from s3transfer.exceptions import RetriesExceededError, S3DownloadFailedError
1920
from s3transfer.futures import IN_MEMORY_DOWNLOAD_TAG
2021
from s3transfer.tasks import SubmissionTask, Task
2122
from s3transfer.utils import (
@@ -346,17 +347,23 @@ def _submit(
346347
:param bandwidth_limiter: The bandwidth limiter to use when
347348
downloading streams
348349
"""
349-
if transfer_future.meta.size is None:
350-
# If a size was not provided figure out the size for the
351-
# user.
350+
if (
351+
transfer_future.meta.size is None
352+
or transfer_future.meta.etag is None
353+
):
352354
response = client.head_object(
353355
Bucket=transfer_future.meta.call_args.bucket,
354356
Key=transfer_future.meta.call_args.key,
355357
**transfer_future.meta.call_args.extra_args,
356358
)
359+
# If a size was not provided figure out the size for the
360+
# user.
357361
transfer_future.meta.provide_transfer_size(
358362
response['ContentLength']
359363
)
364+
# Provide an etag to ensure a stored object is not modified
365+
# during a multipart download.
366+
transfer_future.meta.provide_object_etag(response.get('ETag'))
360367

361368
download_output_manager = self._get_download_output_manager_cls(
362369
transfer_future, osutil
@@ -479,9 +486,12 @@ def _submit_ranged_download_request(
479486
part_size, i, num_parts
480487
)
481488

482-
# Inject the Range parameter to the parameters to be passed in
483-
# as extra args
484-
extra_args = {'Range': range_parameter}
489+
# Inject extra parameters to be passed in as extra args
490+
extra_args = {
491+
'Range': range_parameter,
492+
}
493+
if transfer_future.meta.etag is not None:
494+
extra_args['IfMatch'] = transfer_future.meta.etag
485495
extra_args.update(call_args.extra_args)
486496
finalize_download_invoker.increment()
487497
# Submit the ranged downloads
@@ -593,6 +603,15 @@ def _main(
593603
else:
594604
return
595605
return
606+
except ClientError as e:
607+
error_code = e.response.get('Error', {}).get('Code')
608+
if error_code == "PreconditionFailed":
609+
raise S3DownloadFailedError(
610+
f'Contents of stored object "{key}" in bucket '
611+
f'"{bucket}" did not match expected ETag.'
612+
)
613+
else:
614+
raise
596615
except S3_RETRYABLE_DOWNLOAD_ERRORS as e:
597616
logger.debug(
598617
"Retrying exception caught (%s), "

awscli/s3transfer/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ class S3UploadFailedError(Exception):
2323
pass
2424

2525

26+
class S3DownloadFailedError(Exception):
27+
pass
28+
29+
2630
class InvalidSubscriberMethodError(Exception):
2731
pass
2832

awscli/s3transfer/futures.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def __init__(self, call_args=None, transfer_id=None):
127127
self._transfer_id = transfer_id
128128
self._size = None
129129
self._user_context = {}
130+
self._etag = None
130131

131132
@property
132133
def call_args(self):
@@ -148,6 +149,11 @@ def user_context(self):
148149
"""A dictionary that requesters can store data in"""
149150
return self._user_context
150151

152+
@property
153+
def etag(self):
154+
"""The etag of the stored object for validating multipart downloads"""
155+
return self._etag
156+
151157
def provide_transfer_size(self, size):
152158
"""A method to provide the size of a transfer request
153159
@@ -157,6 +163,15 @@ def provide_transfer_size(self, size):
157163
"""
158164
self._size = size
159165

166+
def provide_object_etag(self, etag):
167+
"""A method to provide the etag of a transfer request
168+
169+
By providing this value, the TransferManager will validate
170+
multipart downloads by supplying an IfMatch parameter with
171+
the etag as the value to GetObject requests.
172+
"""
173+
self._etag = etag
174+
160175

161176
class TransferCoordinator:
162177
"""A helper class for managing TransferFuture"""

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
StreamWithError,
9393
RecordingSubscriber,
9494
FileSizeProvider,
95+
ETagProvider,
9596
RecordingOSUtils,
9697
RecordingExecutor,
9798
TransferCoordinatorWithInterrupt,

0 commit comments

Comments
 (0)