From c2d1ce2c1451a3785b3842f4c0f031443999ce9e Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Fri, 11 Sep 2020 05:17:39 -0400 Subject: [PATCH] Add If-Match, If-None-Match and If-Unmodified-Since to S3 GET/HEAD (#3021) fixes #2705 --- moto/s3/exceptions.py | 20 ++++++- moto/s3/responses.py | 40 +++++++++++--- tests/test_s3/test_s3.py | 114 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 7 deletions(-) diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py index 3ed385f1c..7ea21b096 100644 --- a/moto/s3/exceptions.py +++ b/moto/s3/exceptions.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from moto.core.exceptions import RESTError +from moto.core.exceptions import RESTError ERROR_WITH_BUCKET_NAME = """{% extends 'single_error' %} {% block extra %}{{ bucket }}{% endblock %} @@ -10,6 +10,10 @@ ERROR_WITH_KEY_NAME = """{% extends 'single_error' %} {% block extra %}{{ key_name }}{% endblock %} """ +ERROR_WITH_CONDITION_NAME = """{% extends 'single_error' %} +{% block extra %}{{ condition }}{% endblock %} +""" + class S3ClientError(RESTError): def __init__(self, *args, **kwargs): @@ -386,3 +390,17 @@ class NoSuchUpload(S3ClientError): super(NoSuchUpload, self).__init__( "NoSuchUpload", "The specified multipart upload does not exist." ) + + +class PreconditionFailed(S3ClientError): + code = 412 + + def __init__(self, failed_condition, **kwargs): + kwargs.setdefault("template", "condition_error") + self.templates["condition_error"] = ERROR_WITH_CONDITION_NAME + super(PreconditionFailed, self).__init__( + "PreconditionFailed", + "At least one of the pre-conditions you specified did not hold", + condition=failed_condition, + **kwargs + ) diff --git a/moto/s3/responses.py b/moto/s3/responses.py index 395cb5736..fa3e536a7 100644 --- a/moto/s3/responses.py +++ b/moto/s3/responses.py @@ -36,6 +36,7 @@ from .exceptions import ( InvalidNotificationEvent, ObjectNotInActiveTierError, NoSystemTags, + PreconditionFailed, ) from .models import ( s3_backend, @@ -1149,13 +1150,28 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): ) version_id = query.get("versionId", [None])[0] if_modified_since = headers.get("If-Modified-Since", None) + if_match = headers.get("If-Match", None) + if_none_match = headers.get("If-None-Match", None) + if_unmodified_since = headers.get("If-Unmodified-Since", None) + key = self.backend.get_object(bucket_name, key_name, version_id=version_id) if key is None: raise MissingKey(key_name) + + if if_unmodified_since: + if_unmodified_since = str_to_rfc_1123_datetime(if_unmodified_since) + if key.last_modified > if_unmodified_since: + raise PreconditionFailed("If-Unmodified-Since") + if if_match and key.etag != if_match: + raise PreconditionFailed("If-Match") + if if_modified_since: if_modified_since = str_to_rfc_1123_datetime(if_modified_since) - if if_modified_since and key.last_modified < if_modified_since: + if key.last_modified < if_modified_since: + return 304, response_headers, "Not Modified" + if if_none_match and key.etag == if_none_match: return 304, response_headers, "Not Modified" + if "acl" in query: template = self.response_template(S3_OBJECT_ACL_RESPONSE) return 200, response_headers, template.render(obj=key) @@ -1319,8 +1335,9 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): part_number = int(part_number) if_modified_since = headers.get("If-Modified-Since", None) - if if_modified_since: - if_modified_since = str_to_rfc_1123_datetime(if_modified_since) + if_match = headers.get("If-Match", None) + if_none_match = headers.get("If-None-Match", None) + if_unmodified_since = headers.get("If-Unmodified-Since", None) key = self.backend.get_object( bucket_name, key_name, version_id=version_id, part_number=part_number @@ -1329,10 +1346,21 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): response_headers.update(key.metadata) response_headers.update(key.response_dict) - if if_modified_since and key.last_modified < if_modified_since: + if if_unmodified_since: + if_unmodified_since = str_to_rfc_1123_datetime(if_unmodified_since) + if key.last_modified > if_unmodified_since: + return 412, response_headers, "" + if if_match and key.etag != if_match: + return 412, response_headers, "" + + if if_modified_since: + if_modified_since = str_to_rfc_1123_datetime(if_modified_since) + if key.last_modified < if_modified_since: + return 304, response_headers, "Not Modified" + if if_none_match and key.etag == if_none_match: return 304, response_headers, "Not Modified" - else: - return 200, response_headers, "" + + return 200, response_headers, "" else: return 404, response_headers, "" diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py index 960594801..4139cf055 100644 --- a/tests/test_s3/test_s3.py +++ b/tests/test_s3/test_s3.py @@ -2335,6 +2335,64 @@ def test_boto3_get_object_if_modified_since(): e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"}) +@mock_s3 +def test_boto3_get_object_if_unmodified_since(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + s3.put_object(Bucket=bucket_name, Key=key, Body="test") + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.get_object( + Bucket=bucket_name, + Key=key, + IfUnmodifiedSince=datetime.datetime.utcnow() - datetime.timedelta(hours=1), + ) + e = err.exception + e.response["Error"]["Code"].should.equal("PreconditionFailed") + e.response["Error"]["Condition"].should.equal("If-Unmodified-Since") + + +@mock_s3 +def test_boto3_get_object_if_match(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + s3.put_object(Bucket=bucket_name, Key=key, Body="test") + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.get_object( + Bucket=bucket_name, Key=key, IfMatch='"hello"', + ) + e = err.exception + e.response["Error"]["Code"].should.equal("PreconditionFailed") + e.response["Error"]["Condition"].should.equal("If-Match") + + +@mock_s3 +def test_boto3_get_object_if_none_match(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + etag = s3.put_object(Bucket=bucket_name, Key=key, Body="test")["ETag"] + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.get_object( + Bucket=bucket_name, Key=key, IfNoneMatch=etag, + ) + e = err.exception + e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"}) + + @mock_s3 def test_boto3_head_object_if_modified_since(): s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) @@ -2355,6 +2413,62 @@ def test_boto3_head_object_if_modified_since(): e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"}) +@mock_s3 +def test_boto3_head_object_if_unmodified_since(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + s3.put_object(Bucket=bucket_name, Key=key, Body="test") + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.head_object( + Bucket=bucket_name, + Key=key, + IfUnmodifiedSince=datetime.datetime.utcnow() - datetime.timedelta(hours=1), + ) + e = err.exception + e.response["Error"].should.equal({"Code": "412", "Message": "Precondition Failed"}) + + +@mock_s3 +def test_boto3_head_object_if_match(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + s3.put_object(Bucket=bucket_name, Key=key, Body="test") + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.head_object( + Bucket=bucket_name, Key=key, IfMatch='"hello"', + ) + e = err.exception + e.response["Error"].should.equal({"Code": "412", "Message": "Precondition Failed"}) + + +@mock_s3 +def test_boto3_head_object_if_none_match(): + s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME) + bucket_name = "blah" + s3.create_bucket(Bucket=bucket_name) + + key = "hello.txt" + + etag = s3.put_object(Bucket=bucket_name, Key=key, Body="test")["ETag"] + + with assert_raises(botocore.exceptions.ClientError) as err: + s3.head_object( + Bucket=bucket_name, Key=key, IfNoneMatch=etag, + ) + e = err.exception + e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"}) + + @mock_s3 @reduced_min_part_size def test_boto3_multipart_etag():