diff --git a/ministack/services/s3.py b/ministack/services/s3.py index a662682e..7401fb5d 100644 --- a/ministack/services/s3.py +++ b/ministack/services/s3.py @@ -4286,6 +4286,44 @@ def _object_mtime_dt(obj: dict): return dt.replace(microsecond=0) +def _check_copy_source_preconditions(headers: dict, src_obj: dict): + """Judge the x-amz-copy-source-if-* headers against the source. + + Per the AWS CopyObject reference (RFC 7232 precedence): + x-amz-copy-source-if-match takes precedence over -if-unmodified-since, + and -if-none-match over -if-modified-since, so the date header only + applies when its ETag counterpart is absent. UploadPartCopy carries the + same four headers and judges them the same way, so both ask here. + + Returns an error response, or None when every condition holds.""" + msg = "At least one of the pre-conditions you specified did not hold" + src_mtime = _object_mtime_dt(src_obj) + src_etag = (src_obj.get("etag") or "").strip('"') + + if_match = headers.get("x-amz-copy-source-if-match", "") + if if_match: + if if_match.strip('"') != src_etag: + return _error("PreconditionFailed", msg, 412) + else: + unmod = _parse_http_date( + headers.get("x-amz-copy-source-if-unmodified-since", "")) + # "Unmodified since" fails when the source was modified after it. + if unmod and src_mtime and src_mtime > unmod: + return _error("PreconditionFailed", msg, 412) + + if_none_match = headers.get("x-amz-copy-source-if-none-match", "") + if if_none_match: + if if_none_match.strip('"') == src_etag: + return _error("PreconditionFailed", msg, 412) + else: + mod = _parse_http_date( + headers.get("x-amz-copy-source-if-modified-since", "")) + # "Modified since" fails when the source has NOT changed since it. + if mod and src_mtime and src_mtime <= mod: + return _error("PreconditionFailed", msg, 412) + return None + + def _copy_object(bucket_name: str, dest_key: str, headers: dict): # Split the raw header at "?" before percent-decoding: a key legitimately # containing "?versionId" arrives encoded (%3FversionId) and must stay @@ -4318,6 +4356,13 @@ def _copy_object(bucket_name: str, dest_key: str, headers: dict): if dest_bucket is None: return _no_such_bucket(bucket_name) + # A canned ACL on the copy applies to the destination, as it does on a + # put; an unknown value rejects the whole request rather than being + # dropped on the floor. + canned_acl = headers.get("x-amz-acl") + if canned_acl and canned_acl not in _CANNED_OBJECT_ACLS: + return _error("InvalidArgument", f"Invalid x-amz-acl value: {canned_acl}", 400) + if src_version_id: ventry = next( (v for v in _object_versions.get((src_bucket_name, src_key), []) @@ -4364,32 +4409,9 @@ def _copy_object(bucket_name: str, dest_key: str, headers: dict): # AWS echoes the copied source version on a versioned source. copy_src_vid = src_version_id or src_obj.get("version_id") - # Copy-source preconditions. Per the AWS CopyObject reference (RFC 7232 - # precedence): x-amz-copy-source-if-match takes precedence over - # -if-unmodified-since, and -if-none-match over -if-modified-since, so the - # date header only applies when its ETag counterpart is absent. - _precond_msg = "At least one of the pre-conditions you specified did not hold" - src_mtime = _object_mtime_dt(src_obj) - - if_match = headers.get("x-amz-copy-source-if-match", "") - if if_match: - if if_match.strip('"') != src_obj["etag"].strip('"'): - return _error("PreconditionFailed", _precond_msg, 412) - else: - unmod = _parse_http_date(headers.get("x-amz-copy-source-if-unmodified-since", "")) - # "Unmodified since" fails when the source was modified after the given time. - if unmod and src_mtime and src_mtime > unmod: - return _error("PreconditionFailed", _precond_msg, 412) - - if_none_match = headers.get("x-amz-copy-source-if-none-match", "") - if if_none_match: - if if_none_match.strip('"') == src_obj["etag"].strip('"'): - return _error("PreconditionFailed", _precond_msg, 412) - else: - mod = _parse_http_date(headers.get("x-amz-copy-source-if-modified-since", "")) - # "Modified since" fails when the source has NOT changed since the given time. - if mod and src_mtime and src_mtime <= mod: - return _error("PreconditionFailed", _precond_msg, 412) + precond_err = _check_copy_source_preconditions(headers, src_obj) + if precond_err is not None: + return precond_err directive = headers.get("x-amz-metadata-directive", "COPY").upper() if directive == "REPLACE": @@ -4515,6 +4537,14 @@ def _copy_object(bucket_name: str, dest_key: str, headers: dict): else: _object_tags.pop((bucket_name, dest_key, dest_version_id), None) + if canned_acl: + _object_acl[(bucket_name, dest_key, dest_version_id)] = ( + _canned_acl_policy_xml(canned_acl, _canonical_owner_id())) + else: + # The destination is a new object: it does not inherit whatever the + # key it replaced was permissioned with. + _object_acl.pop((bucket_name, dest_key, dest_version_id), None) + root = Element("CopyObjectResult", xmlns=S3_NS) SubElement(root, "LastModified").text = last_modified SubElement(root, "ETag").text = new_etag @@ -5653,6 +5683,10 @@ def _upload_part_copy(bucket_name: str, dest_key: str, query_params: dict, heade if sse_src_err is not None: return sse_src_err + precond_err = _check_copy_source_preconditions(headers, ventry) + if precond_err is not None: + return precond_err + # Handle x-amz-copy-source-range copy_range = headers.get("x-amz-copy-source-range", "") if copy_range: diff --git a/tests/test_s3.py b/tests/test_s3.py index d96c56c2..4cbda79f 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -5734,6 +5734,60 @@ def test_s3_restore_notifications_to_sqs(s3, sqs): assert red["lifecycleRestorationExpiryTime"].endswith("Z") +def test_s3_copy_object_applies_canned_acl(s3): + """x-amz-acl on a copy permissions the destination, as it does on a put.""" + bkt = "s3-copy-acl" + s3.create_bucket(Bucket=bkt) + s3.put_object(Bucket=bkt, Key="src.txt", Body=b"body") + + s3.copy_object(Bucket=bkt, Key="dst.txt", ACL="public-read", + CopySource={"Bucket": bkt, "Key": "src.txt"}) + grants = s3.get_object_acl(Bucket=bkt, Key="dst.txt")["Grants"] + assert any(g.get("Grantee", {}).get("URI", "").endswith("AllUsers") + for g in grants) + + # A copy without one leaves the destination private. + s3.copy_object(Bucket=bkt, Key="plain.txt", + CopySource={"Bucket": bkt, "Key": "src.txt"}) + grants = s3.get_object_acl(Bucket=bkt, Key="plain.txt")["Grants"] + assert not any(g.get("Grantee", {}).get("URI", "").endswith("AllUsers") + for g in grants) + + with pytest.raises(ClientError) as exc: + s3.copy_object(Bucket=bkt, Key="bad.txt", ACL="nonsense", + CopySource={"Bucket": bkt, "Key": "src.txt"}) + assert exc.value.response["Error"]["Code"] == "InvalidArgument" + + +def test_s3_upload_part_copy_honours_source_preconditions(s3): + """UploadPartCopy carries the same copy-source conditions CopyObject does.""" + bkt = "s3-upc-precond" + s3.create_bucket(Bucket=bkt) + body = b"x" * (5 * 1024 * 1024) + etag = s3.put_object(Bucket=bkt, Key="src.bin", Body=body)["ETag"] + + upload = s3.create_multipart_upload(Bucket=bkt, Key="dst.bin")["UploadId"] + with pytest.raises(ClientError) as exc: + s3.upload_part_copy(Bucket=bkt, Key="dst.bin", UploadId=upload, + PartNumber=1, + CopySource={"Bucket": bkt, "Key": "src.bin"}, + CopySourceIfMatch='"00000000000000000000000000000000"') + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + + with pytest.raises(ClientError) as exc: + s3.upload_part_copy(Bucket=bkt, Key="dst.bin", UploadId=upload, + PartNumber=1, + CopySource={"Bucket": bkt, "Key": "src.bin"}, + CopySourceIfNoneMatch=etag) + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + + # The condition that holds copies the range. + part = s3.upload_part_copy(Bucket=bkt, Key="dst.bin", UploadId=upload, + PartNumber=1, + CopySource={"Bucket": bkt, "Key": "src.bin"}, + CopySourceIfMatch=etag) + assert part["CopyPartResult"]["ETag"] + s3.abort_multipart_upload(Bucket=bkt, Key="dst.bin", UploadId=upload) def test_s3_put_object_rejects_a_mismatched_checksum(s3): """A supplied checksum is verified against the body, not just stored.