Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions s3proxy/handlers/buckets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import uuid
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from urllib.parse import parse_qs

import structlog
Expand All @@ -24,6 +25,19 @@
LIST_HEAD_CONCURRENCY = 50


def _s3_timestamp(value: object) -> str:
"""Format a listing timestamp as S3 does: RFC3339 in UTC with a 'Z' suffix.

datetime.isoformat() emits a '+00:00' offset, which strict S3 clients reject
— scylla-manager's rclone 1.51.0 fails the whole listing with "cannot parse
'+00:00' as 'Z'". Emit millisecond precision + 'Z' to match real S3.
"""
if not isinstance(value, datetime):
return str(value)
d = value.astimezone(UTC)
return f"{d:%Y-%m-%dT%H:%M:%S}.{d.microsecond // 1000:03d}Z"


def _strip_minio_cache_suffix(value: str | None) -> str | None:
"""Strip MinIO cache metadata suffix from marker/token values.

Expand Down Expand Up @@ -207,7 +221,7 @@ async def resolve(obj: dict) -> dict:
size, etag = obj.get("Size", 0), obj.get("ETag", "").strip('"')
return {
"key": obj["Key"],
"last_modified": obj["LastModified"].isoformat(),
"last_modified": _s3_timestamp(obj["LastModified"]),
"etag": etag,
"size": size,
"storage_class": obj.get("StorageClass", "STANDARD"),
Expand Down Expand Up @@ -297,9 +311,7 @@ async def handle_list_multipart_uploads(
{
"Key": key,
"UploadId": upload.get("UploadId", ""),
"Initiated": upload.get("Initiated", "").isoformat()
if hasattr(upload.get("Initiated"), "isoformat")
else str(upload.get("Initiated", "")),
"Initiated": _s3_timestamp(upload.get("Initiated", "")),
"StorageClass": upload.get("StorageClass", "STANDARD"),
}
)
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/test_s3_timestamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Self-check: listing timestamps use S3's 'Z' UTC suffix, not '+00:00'.

datetime.isoformat() emits '+00:00', which scylla-manager's rclone 1.51.0
rejects ("cannot parse '+00:00' as 'Z'"), failing the whole ListObjects. S3
itself returns millisecond-precision RFC3339 with a 'Z' suffix.
"""

from datetime import UTC, datetime, timezone

from s3proxy.handlers.buckets import _s3_timestamp


def test_utc_datetime_uses_z_suffix_millis():
d = datetime(2026, 6, 30, 10, 24, 43, 399000, tzinfo=UTC)
assert _s3_timestamp(d) == "2026-06-30T10:24:43.399Z"


def test_non_utc_is_converted_to_utc_z():
from datetime import timedelta

d = datetime(2026, 6, 30, 12, 24, 43, 0, tzinfo=timezone(timedelta(hours=2)))
assert _s3_timestamp(d) == "2026-06-30T10:24:43.000Z"
assert "+" not in _s3_timestamp(d)


def test_non_datetime_passthrough():
assert _s3_timestamp("") == ""
assert _s3_timestamp("already-a-string") == "already-a-string"


if __name__ == "__main__":
test_utc_datetime_uses_z_suffix_millis()
test_non_utc_is_converted_to_utc_z()
test_non_datetime_passthrough()
print("ok")