diff --git a/moto/s3/models.py b/moto/s3/models.py
index fdf78c273..7cd3e1255 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -14,6 +14,7 @@ import pytz
import sys
import time
import uuid
+import urllib.parse
from bisect import insort
from importlib import reload
@@ -153,6 +154,10 @@ class FakeKey(BaseModel):
self.s3_backend = s3_backend
+ @property
+ def safe_name(self):
+ return urllib.parse.quote(self.name, safe="")
+
@property
def version_id(self):
return self._version_id
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 03a0e6150..6f4addeee 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -590,6 +590,7 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
result_keys, result_folders = self.backend.list_objects(
bucket, prefix, delimiter
)
+ encoding_type = querystring.get("encoding-type", [None])[0]
if marker:
result_keys = self._get_results_from_token(result_keys, marker)
@@ -611,6 +612,7 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
is_truncated=is_truncated,
next_marker=next_marker,
max_keys=max_keys,
+ encoding_type=encoding_type,
),
)
@@ -642,6 +644,7 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
fetch_owner = querystring.get("fetch-owner", [False])[0]
max_keys = int(querystring.get("max-keys", [1000])[0])
start_after = querystring.get("start-after", [None])[0]
+ encoding_type = querystring.get("encoding-type", [None])[0]
if continuation_token or start_after:
limit = continuation_token or start_after
@@ -666,6 +669,7 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
is_truncated=is_truncated,
next_continuation_token=next_continuation_token,
start_after=None if continuation_token else start_after,
+ encoding_type=encoding_type,
)
@staticmethod
@@ -2051,13 +2055,16 @@ S3_BUCKET_GET_RESPONSE = """
{% if delimiter %}
{{ delimiter }}
{% endif %}
+ {% if encoding_type %}
+ {{ encoding_type }}
+ {% endif %}
{{ is_truncated }}
{% if next_marker %}
{{ next_marker }}
{% endif %}
{% for key in result_keys %}
- {{ key.name }}
+ {{ key.safe_name }}
{{ key.last_modified_ISO8601 }}
{{ key.etag }}
{{ key.size }}
@@ -2087,6 +2094,9 @@ S3_BUCKET_GET_RESPONSE_V2 = """
{{ key_count }}
{% if delimiter %}
{{ delimiter }}
+{% endif %}
+{% if encoding_type %}
+ {{ encoding_type }}
{% endif %}
{{ is_truncated }}
{% if next_continuation_token %}
@@ -2097,7 +2107,7 @@ S3_BUCKET_GET_RESPONSE_V2 = """
{% endif %}
{% for key in result_keys %}
- {{ key.name }}
+ {{ key.safe_name }}
{{ key.last_modified_ISO8601 }}
{{ key.etag }}
{{ key.size }}
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
index 79b0aa2ea..d5a864eb3 100644
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -134,6 +134,24 @@ def test_empty_key():
resp["Body"].read().should.equal(b"")
+@mock_s3
+def test_key_name_encoding_in_listing():
+ s3 = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)
+ client = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+ s3.create_bucket(Bucket="foobar")
+
+ name = "6T7\x159\x12\r\x08.txt"
+
+ key = s3.Object("foobar", name)
+ key.put(Body=b"")
+
+ key_received = client.list_objects(Bucket="foobar")["Contents"][0]["Key"]
+ key_received.should.equal(name)
+
+ key_received = client.list_objects_v2(Bucket="foobar")["Contents"][0]["Key"]
+ key_received.should.equal(name)
+
+
@mock_s3
def test_empty_key_set_on_existing_key():
s3 = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)