Skip to content

Commit b2343b0

Browse files
fix: emit listing LastModified as RFC3339 'Z' (rclone rejects +00:00) (#94)
1 parent 0cddafc commit b2343b0

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

s3proxy/handlers/buckets.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import contextlib
55
import uuid
66
import xml.etree.ElementTree as ET
7+
from datetime import UTC, datetime
78
from urllib.parse import parse_qs
89

910
import structlog
@@ -24,6 +25,19 @@
2425
LIST_HEAD_CONCURRENCY = 50
2526

2627

28+
def _s3_timestamp(value: object) -> str:
29+
"""Format a listing timestamp as S3 does: RFC3339 in UTC with a 'Z' suffix.
30+
31+
datetime.isoformat() emits a '+00:00' offset, which strict S3 clients reject
32+
— scylla-manager's rclone 1.51.0 fails the whole listing with "cannot parse
33+
'+00:00' as 'Z'". Emit millisecond precision + 'Z' to match real S3.
34+
"""
35+
if not isinstance(value, datetime):
36+
return str(value)
37+
d = value.astimezone(UTC)
38+
return f"{d:%Y-%m-%dT%H:%M:%S}.{d.microsecond // 1000:03d}Z"
39+
40+
2741
def _strip_minio_cache_suffix(value: str | None) -> str | None:
2842
"""Strip MinIO cache metadata suffix from marker/token values.
2943
@@ -207,7 +221,7 @@ async def resolve(obj: dict) -> dict:
207221
size, etag = obj.get("Size", 0), obj.get("ETag", "").strip('"')
208222
return {
209223
"key": obj["Key"],
210-
"last_modified": obj["LastModified"].isoformat(),
224+
"last_modified": _s3_timestamp(obj["LastModified"]),
211225
"etag": etag,
212226
"size": size,
213227
"storage_class": obj.get("StorageClass", "STANDARD"),
@@ -297,9 +311,7 @@ async def handle_list_multipart_uploads(
297311
{
298312
"Key": key,
299313
"UploadId": upload.get("UploadId", ""),
300-
"Initiated": upload.get("Initiated", "").isoformat()
301-
if hasattr(upload.get("Initiated"), "isoformat")
302-
else str(upload.get("Initiated", "")),
314+
"Initiated": _s3_timestamp(upload.get("Initiated", "")),
303315
"StorageClass": upload.get("StorageClass", "STANDARD"),
304316
}
305317
)

tests/unit/test_s3_timestamp.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Self-check: listing timestamps use S3's 'Z' UTC suffix, not '+00:00'.
2+
3+
datetime.isoformat() emits '+00:00', which scylla-manager's rclone 1.51.0
4+
rejects ("cannot parse '+00:00' as 'Z'"), failing the whole ListObjects. S3
5+
itself returns millisecond-precision RFC3339 with a 'Z' suffix.
6+
"""
7+
8+
from datetime import UTC, datetime, timezone
9+
10+
from s3proxy.handlers.buckets import _s3_timestamp
11+
12+
13+
def test_utc_datetime_uses_z_suffix_millis():
14+
d = datetime(2026, 6, 30, 10, 24, 43, 399000, tzinfo=UTC)
15+
assert _s3_timestamp(d) == "2026-06-30T10:24:43.399Z"
16+
17+
18+
def test_non_utc_is_converted_to_utc_z():
19+
from datetime import timedelta
20+
21+
d = datetime(2026, 6, 30, 12, 24, 43, 0, tzinfo=timezone(timedelta(hours=2)))
22+
assert _s3_timestamp(d) == "2026-06-30T10:24:43.000Z"
23+
assert "+" not in _s3_timestamp(d)
24+
25+
26+
def test_non_datetime_passthrough():
27+
assert _s3_timestamp("") == ""
28+
assert _s3_timestamp("already-a-string") == "already-a-string"
29+
30+
31+
if __name__ == "__main__":
32+
test_utc_datetime_uses_z_suffix_millis()
33+
test_non_utc_is_converted_to_utc_z()
34+
test_non_datetime_passthrough()
35+
print("ok")

0 commit comments

Comments
 (0)