Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 60 additions & 26 deletions ministack/services/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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), [])
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading