Repository: boto/botocore
Issue: Can not PUT a 0 bytes object when Object Lock is enabled on s3 bucket

When uploading an empty file to an s3 bucket which has object lock enabled:

    An error occurred (InvalidRequest) when calling the PutObject operation: Content-MD5 HTTP header is required for Put Object requests with Object Lock parameters

The API requires this header, but in botocore it does not inject it, I think problem is probably here within the [handler for `before-call.s3.PutObject`](https://github.com/boto/botocore/blame/1aac4d7c1f311826a1a4686e54444f6ded72ea45/botocore/handlers.py#L951):

https://github.com/boto/botocore/blame/1aac4d7c1f311826a1a4686e54444f6ded72ea45/botocore/handlers.py#L187-L196

Look at the if-statement:

    if request_dict['body'] and 'Content-MD5' not in params['headers']:
        # ... the params['headers']['Content-MD5'] gets set ...

It neglects the possibility that `request_dict["body"]` is empty.

For >= 1 bytes files, `content-md5` gets automatically set and upload succeeds.

Changed files:
tests/unit/test_handlers.py, tests/functional/test_s3.py, botocore/utils.py

diff --git a/botocore/utils.py b/botocore/utils.py
index a643d888d..8eda6148b 100644
--- a/botocore/utils.py
+++ b/botocore/utils.py
@@ -2215,7 +2215,7 @@ def conditionally_calculate_md5(params, **kwargs):
     """Only add a Content-MD5 if the system supports it."""
     headers = params['headers']
     body = params['body']
-    if MD5_AVAILABLE and body and 'Content-MD5' not in headers:
+    if MD5_AVAILABLE and body is not None and 'Content-MD5' not in headers:
         md5_digest = calculate_md5(body, **kwargs)
         params['headers']['Content-MD5'] = md5_digest
 
diff --git a/tests/functional/test_s3.py b/tests/functional/test_s3.py
index 00917d93f..b7645125d 100644
--- a/tests/functional/test_s3.py
+++ b/tests/functional/test_s3.py
@@ -919,6 +919,18 @@ class TestS3SigV4(BaseS3OperationTest):
             self.client.put_object(Bucket='foo', Key='bar', Body='baz')
         self.assertIn('content-md5', self.get_sent_headers())
 
+    def test_content_md5_set_empty_body(self):
+        with self.http_stubber:
+            self.client.put_object(Bucket='foo', Key='bar', Body='')
+        self.assertIn('content-md5', self.get_sent_headers())
+
+    def test_content_md5_set_empty_file(self):
+        with self.http_stubber:
+            with temporary_file('rb') as f:
+                assert f.read() == b''
+                self.client.put_object(Bucket='foo', Key='bar', Body=f)
+        self.assertIn('content-md5', self.get_sent_headers())
+
     def test_content_sha256_set_if_config_value_is_true(self):
         config = Config(signature_version='s3v4', s3={
             'payload_signing_enabled': True
diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py
index 7857ef67a..fde22e34e 100644
--- a/tests/unit/test_handlers.py
+++ b/tests/unit/test_handlers.py
@@ -1217,6 +1217,17 @@ class TestAddMD5(BaseMD5Test):
             request_dict['headers']['Content-MD5'],
             'OFj2IjCsPJFfMAxmQxLGPw==')
 
+    def test_add_md5_with_empty_body(self):
+        request_dict = {
+            'body': b'',
+            'headers': {}
+        }
+        self.md5_digest.return_value = b'8X\\xf6"0\\xac<\\x91_0\\x0cfC\\x12\\xc6?'
+        conditionally_calculate_md5(request_dict)
+        self.assertEqual(
+            request_dict['headers']['Content-MD5'],
+            'OFj2IjCsPJFfMAxmQxLGPw==')
+
     def test_add_md5_with_bytearray_object(self):
         request_dict = {
             'body': bytearray(b'foobar'),