diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py index 3997b0afe..17da1236d 100644 --- a/moto/s3/exceptions.py +++ b/moto/s3/exceptions.py @@ -38,3 +38,36 @@ class MissingBucket(BucketError): "NoSuchBucket", "The specified bucket does not exist", *args, **kwargs) + + +class InvalidPartOrder(S3ClientError): + code = 400 + + def __init__(self, *args, **kwargs): + super(InvalidPartOrder, self).__init__( + "InvalidPartOrder", + ("The list of parts was not in ascending order. The parts " + "list must be specified in order by part number."), + *args, **kwargs) + + +class InvalidPart(S3ClientError): + code = 400 + + def __init__(self, *args, **kwargs): + super(InvalidPart, self).__init__( + "InvalidPart", + ("One or more of the specified parts could not be found. " + "The part might not have been uploaded, or the specified " + "entity tag might not have matched the part's entity tag."), + *args, **kwargs) + + +class EntityTooSmall(S3ClientError): + code = 400 + + def __init__(self, *args, **kwargs): + super(EntityTooSmall, self).__init__( + "EntityTooSmall", + "Your proposed upload is smaller than the minimum allowed object size.", + *args, **kwargs) diff --git a/moto/s3/models.py b/moto/s3/models.py index af67315e1..fd41125ed 100644 --- a/moto/s3/models.py +++ b/moto/s3/models.py @@ -11,7 +11,7 @@ import six from bisect import insort from moto.core import BaseBackend from moto.core.utils import iso_8601_datetime_with_milliseconds, rfc_1123_datetime -from .exceptions import BucketAlreadyExists, MissingBucket +from .exceptions import BucketAlreadyExists, MissingBucket, InvalidPart, EntityTooSmall from .utils import clean_key_name, _VersionedKeyStore UPLOAD_ID_BYTES = 43 @@ -123,23 +123,28 @@ class FakeMultipart(object): rand_b64 = base64.b64encode(os.urandom(UPLOAD_ID_BYTES)) self.id = rand_b64.decode('utf-8').replace('=', '').replace('+', '') - def complete(self): + def complete(self, body): decode_hex = codecs.getdecoder("hex_codec") total = bytearray() md5s = bytearray() last = None - for index, part in enumerate(self.list_parts(), start=1): + count = 0 + for pn, etag in body: + part = self.parts.get(pn) + if part is None or part.etag != etag: + raise InvalidPart() if last is not None and len(last.value) < UPLOAD_PART_MIN_SIZE: - return None, None + raise EntityTooSmall() part_etag = part.etag.replace('"', '') md5s.extend(decode_hex(part_etag)[0]) total.extend(part.value) last = part + count += 1 etag = hashlib.md5() etag.update(bytes(md5s)) - return total, "{0}-{1}".format(etag.hexdigest(), index) + return total, "{0}-{1}".format(etag.hexdigest(), count) def set_part(self, part_id, value): if part_id < 1: @@ -276,10 +281,10 @@ class S3Backend(BaseBackend): return new_multipart - def complete_multipart(self, bucket_name, multipart_id): + def complete_multipart(self, bucket_name, multipart_id, body): bucket = self.get_bucket(bucket_name) multipart = bucket.multiparts[multipart_id] - value, etag = multipart.complete() + value, etag = multipart.complete(body) if value is None: return del bucket.multiparts[multipart_id] diff --git a/moto/s3/responses.py b/moto/s3/responses.py index d9ef63012..1b19ef154 100644 --- a/moto/s3/responses.py +++ b/moto/s3/responses.py @@ -7,7 +7,7 @@ from six.moves.urllib.parse import parse_qs, urlparse from moto.core.responses import _TemplateEnvironmentMixin -from .exceptions import BucketAlreadyExists, S3ClientError +from .exceptions import BucketAlreadyExists, S3ClientError, InvalidPartOrder from .models import s3_backend from .utils import bucket_name_from_url, metadata_from_headers from xml.dom import minidom @@ -351,6 +351,15 @@ class ResponseObject(_TemplateEnvironmentMixin): template = self.response_template(S3_DELETE_OBJECT_SUCCESS) return 204, headers, template.render(bucket=removed_key) + def _complete_multipart_body(self, body): + ps = minidom.parseString(body).getElementsByTagName('Part') + prev = 0 + for p in ps: + pn = int(p.getElementsByTagName('PartNumber')[0].firstChild.wholeText) + if pn <= prev: + raise InvalidPartOrder() + yield (pn, p.getElementsByTagName('ETag')[0].firstChild.wholeText) + def _key_response_post(self, request, body, parsed_url, bucket_name, query, key_name, headers): if body == b'' and parsed_url.query == 'uploads': metadata = metadata_from_headers(request.headers) @@ -365,18 +374,15 @@ class ResponseObject(_TemplateEnvironmentMixin): return 200, headers, response if 'uploadId' in query: + body = self._complete_multipart_body(body) upload_id = query['uploadId'][0] - key = self.backend.complete_multipart(bucket_name, upload_id) - - if key is not None: - template = self.response_template(S3_MULTIPART_COMPLETE_RESPONSE) - return template.render( - bucket_name=bucket_name, - key_name=key.name, - etag=key.etag, - ) - template = self.response_template(S3_MULTIPART_COMPLETE_TOO_SMALL_ERROR) - return 400, headers, template.render() + key = self.backend.complete_multipart(bucket_name, upload_id, body) + template = self.response_template(S3_MULTIPART_COMPLETE_RESPONSE) + return template.render( + bucket_name=bucket_name, + key_name=key.name, + etag=key.etag, + ) elif parsed_url.query == 'restore': es = minidom.parseString(body).getElementsByTagName('Days') days = es[0].childNodes[0].wholeText @@ -588,14 +594,6 @@ S3_MULTIPART_COMPLETE_RESPONSE = """ """ -S3_MULTIPART_COMPLETE_TOO_SMALL_ERROR = """ - - EntityTooSmall - Your proposed upload is smaller than the minimum allowed object size. - asdfasdfsdafds - sdfgdsfgdsfgdfsdsfgdfs -""" - S3_ALL_MULTIPARTS = """ {{ bucket_name }}