Skip to content

Commit a5a5166

Browse files
committed
Fix: Properly escape URL construction for XML MPU API
1 parent 3d29c6f commit a5a5166

File tree

3 files changed

+110
-1
lines changed

3 files changed

+110
-1
lines changed

‎google/cloud/storage/transfer_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from google.cloud.storage import Client
3131
from google.cloud.storage import Blob
3232
from google.cloud.storage.blob import _get_host_name
33+
from google.cloud.storage.blob import _quote
3334
from google.cloud.storage.constants import _DEFAULT_TIMEOUT
3435
from google.cloud.storage._helpers import _api_core_retry_to_resumable_media_retry
3536
from google.cloud.storage.retry import DEFAULT_RETRY
@@ -1083,7 +1084,7 @@ def upload_chunks_concurrently(
10831084

10841085
hostname = _get_host_name(client._connection)
10851086
url = "{hostname}/{bucket}/{blob}".format(
1086-
hostname=hostname, bucket=bucket.name, blob=blob.name
1087+
hostname=hostname, bucket=bucket.name, blob=_quote(blob.name)
10871088
)
10881089

10891090
base_headers, object_metadata, content_type = blob._get_upload_arguments(

‎tests/system/test_transfer_manager.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ def test_upload_chunks_concurrently(shared_bucket, file_data, blobs_to_delete):
245245
chunk_size = 5 * 1024 * 1024 # Minimum supported by XML MPU API
246246
assert os.path.getsize(filename) > chunk_size # Won't make a good test otherwise
247247

248+
blobs_to_delete.append(upload_blob)
249+
248250
transfer_manager.upload_chunks_concurrently(
249251
filename,
250252
upload_blob,
@@ -418,3 +420,58 @@ def test_upload_chunks_concurrently_with_kms(
418420
source_contents = sf.read()
419421
temp_contents = tmp.read()
420422
assert source_contents == temp_contents
423+
424+
425+
def test_upload_chunks_concurrently_with_quoted_blob_names(
426+
shared_bucket, file_data, blobs_to_delete
427+
):
428+
source_file = file_data["big"]
429+
filename = source_file["path"]
430+
blob_name = "../example_bucket/mpu_file"
431+
upload_blob = shared_bucket.blob(blob_name)
432+
chunk_size = 5 * 1024 * 1024 # Minimum supported by XML MPU API
433+
assert os.path.getsize(filename) > chunk_size # Won't make a good test otherwise
434+
435+
blobs_to_delete.append(upload_blob)
436+
437+
# If the blob name is not quoted/encoded at all, this will result in a 403.
438+
transfer_manager.upload_chunks_concurrently(
439+
filename, upload_blob, chunk_size=chunk_size, deadline=DEADLINE
440+
)
441+
442+
with tempfile.NamedTemporaryFile() as tmp:
443+
# If the blob name is not quoted correctly, this will result in a 404.
444+
download_blob = shared_bucket.blob(blob_name)
445+
download_blob.download_to_file(tmp)
446+
tmp.seek(0)
447+
448+
with open(source_file["path"], "rb") as sf:
449+
source_contents = sf.read()
450+
temp_contents = tmp.read()
451+
assert source_contents == temp_contents
452+
453+
# Test emoji names are not mangled.
454+
blob_name = "\U0001f681" # Helicopter emoji
455+
upload_blob = shared_bucket.blob(blob_name)
456+
chunk_size = 5 * 1024 * 1024 # Minimum supported by XML MPU API
457+
assert os.path.getsize(filename) > chunk_size # Won't make a good test otherwise
458+
459+
blobs_to_delete.append(upload_blob)
460+
461+
transfer_manager.upload_chunks_concurrently(
462+
filename,
463+
upload_blob,
464+
chunk_size=chunk_size,
465+
deadline=DEADLINE,
466+
worker_type=transfer_manager.THREAD,
467+
)
468+
469+
with tempfile.NamedTemporaryFile() as tmp:
470+
download_blob = shared_bucket.blob(blob_name)
471+
download_blob.download_to_file(tmp)
472+
tmp.seek(0)
473+
474+
with open(source_file["path"], "rb") as sf:
475+
source_contents = sf.read()
476+
temp_contents = tmp.read()
477+
assert source_contents == temp_contents

‎tests/unit/test_transfer_manager.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,57 @@ def test_upload_chunks_concurrently():
794794
part_mock.upload.assert_called_with(transport)
795795

796796

797+
def test_upload_chunks_concurrently_quotes_urls():
798+
bucket = mock.Mock()
799+
bucket.name = "bucket"
800+
bucket.client = _PickleableMockClient(identify_as_client=True)
801+
transport = bucket.client._http
802+
bucket.user_project = None
803+
804+
blob = Blob(b"../wrongbucket/blob", bucket)
805+
blob.content_type = FAKE_CONTENT_TYPE
806+
quoted_url = "https://example.com/bucket/..%2Fwrongbucket%2Fblob"
807+
808+
FILENAME = "file_a.txt"
809+
SIZE = 2048
810+
811+
container_mock = mock.Mock()
812+
container_mock.upload_id = "abcd"
813+
part_mock = mock.Mock()
814+
ETAG = "efgh"
815+
part_mock.etag = ETAG
816+
container_cls_mock = mock.Mock(return_value=container_mock)
817+
818+
with mock.patch("os.path.getsize", return_value=SIZE), mock.patch(
819+
"google.cloud.storage.transfer_manager.XMLMPUContainer", new=container_cls_mock
820+
), mock.patch(
821+
"google.cloud.storage.transfer_manager.XMLMPUPart", return_value=part_mock
822+
):
823+
transfer_manager.upload_chunks_concurrently(
824+
FILENAME,
825+
blob,
826+
chunk_size=SIZE // 2,
827+
worker_type=transfer_manager.THREAD,
828+
)
829+
830+
container_mock.initiate.assert_called_once_with(
831+
transport=transport, content_type=blob.content_type
832+
)
833+
container_mock.register_part.assert_any_call(1, ETAG)
834+
container_mock.register_part.assert_any_call(2, ETAG)
835+
container_mock.finalize.assert_called_once_with(bucket.client._http)
836+
837+
assert container_mock._retry_strategy.max_sleep == 60.0
838+
assert container_mock._retry_strategy.max_cumulative_retry == 120.0
839+
assert container_mock._retry_strategy.max_retries is None
840+
841+
container_cls_mock.assert_called_once_with(
842+
quoted_url, FILENAME, headers=mock.ANY
843+
)
844+
845+
part_mock.upload.assert_called_with(transport)
846+
847+
797848
def test_upload_chunks_concurrently_passes_concurrency_options():
798849
bucket = mock.Mock()
799850
bucket.name = "bucket"

0 commit comments

Comments
 (0)