Repository: getmoto/moto

Issue:
Deleting whitespace-only S3 objects keys fails
S3 allows most unicode characters as parts of object keys. This includes whitespace characters. S3 also allows whitespace-only object keys. However when using whitespace-only object keys with `moto`, `moto` fails when trying to delete such a key. Here is a minimal example to reproduce this behavior:

```python
import boto3
from moto import mock_s3
 
with mock_s3():
    resource = boto3.resource("s3")
    bucket = resource.create_bucket(
        Bucket="test",
        CreateBucketConfiguration={"LocationConstraint": "eu-central-1"},
    )
    key_name = " "
    bucket.put_object(Key=key_name, Body=b"")
    bucket.delete_objects(Delete={"Objects": [{"Key": key_name}]})
```

```
Traceback (most recent call last):
  File "/some/dir/.config/JetBrains/PyCharmCE2021.1/scratches/scratch_1.py", line 12, in <module>
    bucket.delete_objects(Delete={"Objects": [{"Key": key_name}]})
  File "/some/dir/lib/python3.8/site-packages/boto3/resources/factory.py", line 520, in do_action
    response = action(self, *args, **kwargs)
  File "/some/dir/lib/python3.8/site-packages/boto3/resources/action.py", line 83, in __call__
    response = getattr(parent.meta.client, operation_name)(*args, **params)
  File "/some/dir/lib/python3.8/site-packages/botocore/client.py", line 386, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/client.py", line 691, in _make_api_call
    http, parsed_response = self._make_request(
  File "/some/dir/lib/python3.8/site-packages/botocore/client.py", line 711, in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
  File "/some/dir/lib/python3.8/site-packages/botocore/endpoint.py", line 102, in make_request
    return self._send_request(request_dict, operation_model)
  File "/some/dir/lib/python3.8/site-packages/botocore/endpoint.py", line 136, in _send_request
    while self._needs_retry(attempts, operation_model, request_dict,
  File "/some/dir/lib/python3.8/site-packages/botocore/endpoint.py", line 253, in _needs_retry
    responses = self._event_emitter.emit(
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 356, in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 228, in emit
    return self._emit(event_name, kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 211, in _emit
    response = handler(**kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 183, in __call__
    if self._checker(attempts, response, caught_exception):
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 250, in __call__
    should_retry = self._should_retry(attempt_number, response,
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 269, in _should_retry
    return self._checker(attempt_number, response, caught_exception)
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 316, in __call__
    checker_response = checker(attempt_number, response,
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 222, in __call__
    return self._check_caught_exception(
  File "/some/dir/lib/python3.8/site-packages/botocore/retryhandler.py", line 359, in _check_caught_exception
    raise caught_exception
  File "/some/dir/lib/python3.8/site-packages/botocore/endpoint.py", line 197, in _do_get_response
    responses = self._event_emitter.emit(event_name, request=request)
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 356, in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 228, in emit
    return self._emit(event_name, kwargs)
  File "/some/dir/lib/python3.8/site-packages/botocore/hooks.py", line 211, in _emit
    response = handler(**kwargs)
  File "/some/dir/lib/python3.8/site-packages/moto/core/models.py", line 368, in __call__
    status, headers, body = response_callback(
  File "/some/dir/lib/python3.8/site-packages/moto/s3/responses.py", line 265, in bucket_response
    response = self._bucket_response(request, full_url, headers)
  File "/some/dir/lib/python3.8/site-packages/moto/s3/responses.py", line 317, in _bucket_response
    return self._bucket_response_post(request, body, bucket_name)
  File "/some/dir/lib/python3.8/site-packages/moto/s3/responses.py", line 855, in _bucket_response_post
    return self._bucket_response_delete_keys(request, body, bucket_name)
  File "/some/dir/lib/python3.8/site-packages/moto/s3/responses.py", line 947, in _bucket_response_delete_keys
    bucket_name, undo_clean_key_name(key_name), version_id=version_id
  File "/some/dir/lib/python3.8/site-packages/moto/s3/utils.py", line 105, in undo_clean_key_name
    return quote(key_name)
  File "/some/dir/lib/python3.8/urllib/parse.py", line 839, in quote
    return quote_from_bytes(string, safe)
  File "/some/dir/lib/python3.8/urllib/parse.py", line 864, in quote_from_bytes
    raise TypeError("quote_from_bytes() expected bytes")
TypeError: quote_from_bytes() expected bytes
```

The problem is apparently [using `xmltodict.parse` with its default settings][1], which strips whitespaces, resulting in `None` as value if the value contained only whitespaces:

```python
>>> import xmltodict
>>> xmltodict.parse(b'<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Object><Key> </Key></Object></Delete>')
OrderedDict([('Delete', OrderedDict([('@xmlns', 'http://s3.amazonaws.com/doc/2006-03-01/'), ('Object', OrderedDict([('Key', None)]))]))])
```

A fix would be to disable stripping whitespaces when parsing XML and that also sounds reasonable to me, however I'm not sure if that'd break something else:

```python
>>> import xmltodict
>>> xmltodict.parse(b'<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Object><Key> </Key></Object></Delete>', strip_whitespace=False)
OrderedDict([('Delete', OrderedDict([('@xmlns', 'http://s3.amazonaws.com/doc/2006-03-01/'), ('Object', OrderedDict([('Key', ' ')]))]))])
```

Also as `xmltodict.parse` is used in various parts of `moto` it might make sense to revisit the default of stripping whitespace everywhere where it's used, as even if the value not only contains whitespaces, stripping them might result in a different behavior than how the AWS APIs behave.

[1]: https://github.com/spulec/moto/blob/d01bd59ea933e8b72d74e57af25086aed7bd74e9/moto/s3/responses.py#L929


Discussion:
Thanks for raising this @Dunedan, and for providing the cause/solution. 
Marking it as a bug.

Resolving patch:
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -964,7 +964,7 @@ def _get_path(request):
 
     def _bucket_response_delete_keys(self, request, body, bucket_name):
         template = self.response_template(S3_DELETE_KEYS_RESPONSE)
-        body_dict = xmltodict.parse(body)
+        body_dict = xmltodict.parse(body, strip_whitespace=False)
 
         objects = body_dict["Delete"].get("Object", [])
         if not isinstance(objects, list):


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -6587,3 +6587,22 @@ def test_create_bucket_duplicate():
         "Your previous request to create the named bucket succeeded and you already own it."
     )
     err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_delete_objects_with_empty_keyname():
+    client = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    resource = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "testbucket-4077"
+    bucket = resource.create_bucket(Bucket=bucket_name)
+    key_name = " "
+    bucket.put_object(Key=key_name, Body=b"")
+    client.list_objects(Bucket=bucket_name).should.have.key("Contents").length_of(1)
+
+    bucket.delete_objects(Delete={"Objects": [{"Key": key_name}]})
+    client.list_objects(Bucket=bucket_name).shouldnt.have.key("Contents")
+
+    bucket.put_object(Key=key_name, Body=b"")
+
+    client.delete_object(Bucket=bucket_name, Key=key_name)
+    client.list_objects(Bucket=bucket_name).shouldnt.have.key("Contents")