Skip to content

Commit b4ee0a2

Browse files
Merge branch 'release-0.14.0'
* release-0.14.0: Bumping version to 0.14.0 Add changelog entry Validate multipart download content ranges Update actions/checkout to 5.0.0 (#357)
2 parents 7ea7d92 + e3429b8 commit b4ee0a2

10 files changed

Lines changed: 86 additions & 9 deletions

File tree

.changes/0.14.0.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{
3+
"category": "download",
4+
"description": "Validate requested range matches content range in response for multipart downloads",
5+
"type": "feature"
6+
}
7+
]

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
security-events: write
2121
steps:
2222
- name: "Checkout repository"
23-
uses: "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3"
23+
uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8"
2424

2525
- name: "Run CodeQL init"
2626
uses: "github/codeql-action/init@cdcdbb579706841c47f7063dda365e292e5cad7a"

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
runs-on: ubuntu-latest
1414

1515
steps:
16-
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3
16+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
1717
- name: Set up Python 3.9
1818
uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1
1919
with:

.github/workflows/run-crt-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
os: [ubuntu-latest, macOS-latest, windows-latest]
1717

1818
steps:
19-
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3
19+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
2020
- name: Set up Python ${{ matrix.python-version }}
2121
uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1
2222
with:

.github/workflows/run-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
os: [ubuntu-latest, macOS-latest, windows-latest]
2020

2121
steps:
22-
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3
22+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
2323
- name: Set up Python ${{ matrix.python-version }}
2424
uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1
2525
with:

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
CHANGELOG
33
=========
44

5+
0.14.0
6+
======
7+
8+
* feature:download: Validate requested range matches content range in response for multipart downloads
9+
10+
511
0.13.1
612
======
713

s3transfer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def __call__(self, bytes_amount):
146146
from s3transfer.exceptions import RetriesExceededError, S3UploadFailedError
147147

148148
__author__ = 'Amazon Web Services'
149-
__version__ = '0.13.1'
149+
__version__ = '0.14.0'
150150

151151

152152
logger = logging.getLogger(__name__)

s3transfer/download.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
from botocore.exceptions import ClientError
1818

1919
from s3transfer.compat import seekable
20-
from s3transfer.exceptions import RetriesExceededError, S3DownloadFailedError
20+
from s3transfer.exceptions import (
21+
RetriesExceededError,
22+
S3DownloadFailedError,
23+
S3ValidationError,
24+
)
2125
from s3transfer.futures import IN_MEMORY_DOWNLOAD_TAG
2226
from s3transfer.tasks import SubmissionTask, Task
2327
from s3transfer.utils import (
@@ -578,6 +582,10 @@ def _main(
578582
response = client.get_object(
579583
Bucket=bucket, Key=key, **extra_args
580584
)
585+
self._validate_content_range(
586+
extra_args.get('Range'),
587+
response.get('ContentRange'),
588+
)
581589
streaming_body = StreamReaderProgress(
582590
response['Body'], callbacks
583591
)
@@ -635,6 +643,27 @@ def _main(
635643
def _handle_io(self, download_output_manager, fileobj, chunk, index):
636644
download_output_manager.queue_file_io_task(fileobj, chunk, index)
637645

646+
def _validate_content_range(self, requested_range, content_range):
647+
if not requested_range or not content_range:
648+
return
649+
# Unparsed `ContentRange` looks like `bytes 0-8388607/39542919`,
650+
# where `0-8388607` is the fetched range and `39542919` is
651+
# the total object size.
652+
response_range, total_size = content_range.split('/')
653+
# Subtract `1` because range is 0-indexed.
654+
final_byte = str(int(total_size) - 1)
655+
# If it's the last part, the requested range will not include
656+
# the final byte, eg `bytes=33554432-`.
657+
if requested_range.endswith('-'):
658+
requested_range += final_byte
659+
# Request looks like `bytes=0-8388607`.
660+
# Parsed response looks like `bytes 0-8388607`.
661+
if requested_range[6:] != response_range[6:]:
662+
raise S3ValidationError(
663+
f"Requested range: `{requested_range[6:]}` does not match "
664+
f"content range in response: `{response_range[6:]}`"
665+
)
666+
638667

639668
class ImmediatelyWriteIOGetObjectTask(GetObjectTask):
640669
"""GetObjectTask that immediately writes to the provided file object

s3transfer/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ class FatalError(CancelledError):
3939
"""A CancelledError raised from an error in the TransferManager"""
4040

4141
pass
42+
43+
44+
class S3ValidationError(Exception):
45+
pass

tests/functional/test_download.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from botocore.exceptions import ClientError
2222

2323
from s3transfer.compat import SOCKET_ERROR
24-
from s3transfer.exceptions import RetriesExceededError, S3DownloadFailedError
24+
from s3transfer.exceptions import (
25+
RetriesExceededError,
26+
S3DownloadFailedError,
27+
S3ValidationError,
28+
)
2529
from s3transfer.manager import TransferConfig, TransferManager
2630
from tests import (
2731
BaseGeneralInterfaceTest,
@@ -109,7 +113,7 @@ def add_head_object_response(self, expected_params=None):
109113
self.stubber.add_response(**head_response)
110114

111115
def add_successful_get_object_responses(
112-
self, expected_params=None, expected_ranges=None
116+
self, expected_params=None, expected_ranges=None, extras=None
113117
):
114118
# Add all get_object responses needed to complete the download.
115119
# Should account for both ranged and nonranged downloads.
@@ -124,6 +128,8 @@ def add_successful_get_object_responses(
124128
stubbed_response['expected_params']['Range'] = (
125129
expected_ranges[i]
126130
)
131+
if extras:
132+
stubbed_response['service_response'].update(extras[i])
127133
self.stubber.add_response(**stubbed_response)
128134

129135
def add_n_retryable_get_object_responses(self, n, num_reads=0):
@@ -511,9 +517,12 @@ def test_download(self):
511517
'RequestPayer': 'requester',
512518
}
513519
expected_ranges = ['bytes=0-3', 'bytes=4-7', 'bytes=8-']
520+
stubbed_ranges = ['bytes 0-3/10', 'bytes 4-7/10', 'bytes 8-9/10']
514521
self.add_head_object_response(expected_params)
515522
self.add_successful_get_object_responses(
516-
{**expected_params, 'IfMatch': self.etag}, expected_ranges
523+
{**expected_params, 'IfMatch': self.etag},
524+
expected_ranges,
525+
[{"ContentRange": r} for r in stubbed_ranges],
517526
)
518527

519528
future = self.manager.download(
@@ -547,6 +556,28 @@ def test_download_with_checksum_enabled(self):
547556
with open(self.filename, 'rb') as f:
548557
self.assertEqual(self.content, f.read())
549558

559+
def test_download_raises_if_content_range_mismatch(self):
560+
expected_params = {
561+
'Bucket': self.bucket,
562+
'Key': self.key,
563+
}
564+
expected_ranges = ['bytes=0-3', 'bytes=4-7', 'bytes=8-']
565+
# Note that the final retrieved range should be `bytes 8-9/10`.
566+
stubbed_ranges = ['bytes 0-3/10', 'bytes 4-7/10', 'bytes 7-8/10']
567+
self.add_head_object_response(expected_params)
568+
self.add_successful_get_object_responses(
569+
{**expected_params, 'IfMatch': self.etag},
570+
expected_ranges,
571+
[{"ContentRange": r} for r in stubbed_ranges],
572+
)
573+
574+
future = self.manager.download(
575+
self.bucket, self.key, self.filename, self.extra_args
576+
)
577+
with self.assertRaises(S3ValidationError) as e:
578+
future.result()
579+
self.assertIn('does not match content range', str(e.exception))
580+
550581
def test_download_raises_if_etag_validation_fails(self):
551582
expected_params = {
552583
'Bucket': self.bucket,

0 commit comments

Comments
 (0)