Repository: localstack/localstack

Issue:
bug: [PERL] Net::Amazon::S3 module can't list buckets or bucket contents while Amazon::S3 can
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

Hi! 
I believe there is compatibility issue between localstack and Perl module wrapping S3 API [Net::Amazon::S3](https://metacpan.org/pod/Net::Amazon::S3). Uploading and downloading file works great, but listing bucket contents always gives empty list (i think also listing all buckets gives empty list but this is not an issue for me, since i'm always using one bucket). Listing files using AWS CLI, python's Boto3 or even old Perl alternative [Amazon::S3](https://metacpan.org/release/BIGFOOT/Amazon-S3-0.59/view/lib/Amazon/S3.pm) works OK for me.

### Expected Behavior

Both perl modules should be able to list bucket contents

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### Part of `docker-compose.yml`

```
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
    image: localstack/localstack:latest
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
    environment:
      # LocalStack configuration: https://docs.localstack.cloud/references/configuration/
      - DEBUG=${DEBUG- }
    networks:
      - default


```
#### Perl code that prints nothing (i.e. can't see uploaded file)
```
#!/usr/bin/perl
use warnings;
use Net::Amazon::S3;

use Config::IniFiles;

my $bucket_name = "myname";
my $s3_endpoint = "localstack:4566";
my $homedir = "/usr/share/httpd";
my $aws_cfg = Config::IniFiles->new(-file => "$homedir/.aws/config");



my $s3 = Net::Amazon::S3->new(
    {
        aws_access_key_id => $aws_cfg->val('default', 'aws_access_key_id'),
        aws_secret_access_key => $aws_cfg->val('default', 'aws_secret_access_key'),
        retry => 1,
        host => $s3_endpoint,
        secure => 0,
        use_virtual_host => 0,
    }
);

$s3->add_bucket( { bucket => $bucket_name } )
    or die $s3->err . ": " . $s3->errstr;

my $bucket_obj = $s3->bucket($bucket_name);

my $bucket_contents = $bucket_obj->list
        or die $s3->err . ": " . $s3->errstr;

for my $key ( @{ $bucket_contents->{keys} } ) {
        print "$key->{key}\n";
}
```

#### Perl code that WORKS OK (i.e. prints out uploaded file "key")
```
#!/usr/bin/perl
use warnings;
use CGI::Carp;
use Amazon::S3;

use Config::IniFiles;

my $bucket_name = "/mybucket";
my $s3_endpoint = "localstack:4566";
my $homedir = "/usr/share/httpd";
my $aws_cfg = Config::IniFiles->new(-file => "$homedir/.aws/config");


my $s3 = Amazon::S3->new(
    {
        aws_access_key_id => $aws_cfg->val('default', 'aws_access_key_id'),
        aws_secret_access_key => $aws_cfg->val('default', 'aws_secret_access_key'),
        retry => 1,
        host => $s3_endpoint,
        secure => 0,
    }
);

$s3->add_bucket( { bucket => $bucket_name } )
    or die $s3->err . ": " . $s3->errstr;

my $bucket = $s3->bucket( $bucket_name );
my $keyname = 'testing.txt';
my $value   = 'T';
$bucket->add_key(
    $keyname, $value,
        {   content_type        => 'text/plain',
            'x-amz-meta-colour' => 'orange',
        }
   );

my $response = $bucket->list
     or die $s3->err . ": " . $s3->errstr;
print $response->{bucket}."\n";
for my $key (@{ $response->{keys} }) {
      print "\t".$key->{key}."\n";
}
```


### Environment

```markdown
- host OS: windows + WSL
- container OS (where perl is run): CentOS 7.9.2009
- LocalStack: 
localstack-main | LocalStack version: 3.0.3.dev
localstack-main | LocalStack build date: 2023-12-20
localstack-main | LocalStack build git hash: 5c3ef157
```


### Anything else?

Net::Amazon::S3 way works OK with real AWS S3 (eu-west-1)



Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).

Resolving patch:
diff --git a/localstack/aws/protocol/serializer.py b/localstack/aws/protocol/serializer.py
--- a/localstack/aws/protocol/serializer.py
+++ b/localstack/aws/protocol/serializer.py
@@ -1465,6 +1465,8 @@ class S3ResponseSerializer(RestXMLResponseSerializer):
         "UploadPartCopyOutput": "CopyPartResult",
     }
 
+    XML_NAMESPACE = "http://s3.amazonaws.com/doc/2006-03-01/"
+
     def _serialize_response(
         self,
         parameters: dict,
@@ -1577,6 +1579,8 @@ def _prepare_additional_traits_in_xml(self, root: Optional[ETree.Element], reque
         if root and not root.tail:
             root.tail = "\n"
 
+        root.attrib["xmlns"] = self.XML_NAMESPACE
+
     @staticmethod
     def _timestamp_iso8601(value: datetime) -> str:
         """


Test patch:
diff --git a/tests/aws/services/s3/test_s3.py b/tests/aws/services/s3/test_s3.py
--- a/tests/aws/services/s3/test_s3.py
+++ b/tests/aws/services/s3/test_s3.py
@@ -4552,7 +4552,10 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         assert "ListAllMyBucketsResult" in resp_dict
         # validate that the Owner tag is first, before Buckets. This is because the Java SDK is counting on the order
         # to properly set the Owner value to the buckets.
-        resp_dict["ListAllMyBucketsResult"].pop("@xmlns", None)
+        assert (
+            resp_dict["ListAllMyBucketsResult"].pop("@xmlns")
+            == "http://s3.amazonaws.com/doc/2006-03-01/"
+        )
         list_buckets_tags = list(resp_dict["ListAllMyBucketsResult"].keys())
         assert list_buckets_tags[0] == "Owner"
         assert list_buckets_tags[1] == "Buckets"
@@ -4563,6 +4566,7 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         assert b'<?xml version="1.0" encoding="UTF-8"?>\n' in get_xml_content(resp.content)
         resp_dict = xmltodict.parse(resp.content)
         assert "ListBucketResult" in resp_dict
+        assert resp_dict["ListBucketResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
         # validate that the Contents tag is last, after BucketName. Again for the Java SDK to properly set the
         # BucketName value to the objects.
         list_objects_tags = list(resp_dict["ListBucketResult"].keys())
@@ -4575,6 +4579,7 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         assert b'<?xml version="1.0" encoding="UTF-8"?>\n' in get_xml_content(resp.content)
         resp_dict = xmltodict.parse(resp.content)
         assert "ListBucketResult" in resp_dict
+        assert resp_dict["ListBucketResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
         # same as ListObjects
         list_objects_v2_tags = list(resp_dict["ListBucketResult"].keys())
         assert list_objects_v2_tags.index("Name") < list_objects_v2_tags.index("Contents")
@@ -4586,11 +4591,17 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         assert b'<?xml version="1.0" encoding="UTF-8"?>\n' in get_xml_content(resp.content)
         resp_dict = xmltodict.parse(resp.content)
         assert "ListMultipartUploadsResult" in resp_dict
+        assert (
+            resp_dict["ListMultipartUploadsResult"]["@xmlns"]
+            == "http://s3.amazonaws.com/doc/2006-03-01/"
+        )
 
         # GetBucketLocation
         location_constraint_url = f"{bucket_url}?location"
         resp = s3_http_client.get(location_constraint_url, headers=headers)
-        assert b'<?xml version="1.0" encoding="UTF-8"?>\n' in get_xml_content(resp.content)
+        xml_content = get_xml_content(resp.content)
+        assert b'<?xml version="1.0" encoding="UTF-8"?>\n' in xml_content
+        assert b'<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/"' in xml_content
 
         tagging = {"TagSet": [{"Key": "tag1", "Value": "tag1"}]}
         # put some tags on the bucket
@@ -4601,6 +4612,7 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         resp = s3_http_client.get(get_bucket_tagging_url, headers=headers)
         resp_dict = xmltodict.parse(resp.content)
         assert resp_dict["Tagging"]["TagSet"] == {"Tag": {"Key": "tag1", "Value": "tag1"}}
+        assert resp_dict["Tagging"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
 
         # put an object to tests the next requests
         key_name = "test-key"
@@ -4611,18 +4623,21 @@ def get_xml_content(http_response_content: bytes) -> bytes:
         resp = s3_http_client.get(get_object_tagging_url, headers=headers)
         resp_dict = xmltodict.parse(resp.content)
         assert resp_dict["Tagging"]["TagSet"] == {"Tag": {"Key": "tag1", "Value": "tag1"}}
+        assert resp_dict["Tagging"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
 
         # CopyObject
         get_object_tagging_url = f"{bucket_url}/{key_name}?tagging"
         resp = s3_http_client.get(get_object_tagging_url, headers=headers)
         resp_dict = xmltodict.parse(resp.content)
         assert resp_dict["Tagging"]["TagSet"] == {"Tag": {"Key": "tag1", "Value": "tag1"}}
+        assert resp_dict["Tagging"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
 
         copy_object_url = f"{bucket_url}/copied-key"
         copy_object_headers = {**headers, "x-amz-copy-source": f"{bucket_url}/{key_name}"}
         resp = s3_http_client.put(copy_object_url, headers=copy_object_headers)
         resp_dict = xmltodict.parse(resp.content)
         assert "CopyObjectResult" in resp_dict
+        assert resp_dict["CopyObjectResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/"
 
         multipart_key = "multipart-key"
         create_multipart = aws_client.s3.create_multipart_upload(
diff --git a/tests/aws/services/s3/test_s3.validation.json b/tests/aws/services/s3/test_s3.validation.json
--- a/tests/aws/services/s3/test_s3.validation.json
+++ b/tests/aws/services/s3/test_s3.validation.json
@@ -230,6 +230,9 @@
   "tests/aws/services/s3/test_s3.py::TestS3::test_region_header_exists": {
     "last_validated_date": "2023-08-03T02:13:15+00:00"
   },
+  "tests/aws/services/s3/test_s3.py::TestS3::test_response_structure": {
+    "last_validated_date": "2024-01-03T16:46:18+00:00"
+  },
   "tests/aws/services/s3/test_s3.py::TestS3::test_s3_analytics_configurations": {
     "last_validated_date": "2023-08-03T02:25:40+00:00"
   },