From 38ce95d960485ae5aa2c6cac2816271e1a046eba Mon Sep 17 00:00:00 2001 From: Alex Hu Date: Wed, 30 Sep 2026 02:22:31 -0600 Subject: [PATCH] s3api: always write XML timestamps with three fractional digits (#11520) CopyObject responses carried LastModified values such as "2026-09-29T20:30:04.56Z": trailing zeros of the fractional seconds were trimmed, and a whole-second value had no fraction at all. AWS S3 always writes exactly three digits ("...04.560Z"), and clients that parse with a fixed-width pattern reject anything else. minio-java 8.6.0 (yyyy-MM-dd'T'HH:mm:ss.SSS'Z') throws DateTimeParseException, so roughly one CopyObject in ten fails on the client even though the copy succeeded. Two causes: - xsdDateTime marshalled with "2006-01-02T15:04:05.999999999", which drops trailing zeros. It now writes UTC with ".000Z". - CopyObjectResult.MarshalXML had a pointer receiver, but the handlers pass the result by value, so encoding/xml never called it and fell back to time.Time's RFC 3339 encoding. It now has a value receiver. CopyPartResult had no custom marshaller at all; it now uses xsdDateTime. Follow-up to #8394 / #8398, which truncated these timestamps to milliseconds but kept the trimmed format. --- weed/s3api/s3api_object_handlers_copy.go | 14 +++++ weed/s3api/s3api_object_handlers_list_test.go | 2 +- weed/s3api/s3api_xml_timestamp_test.go | 56 +++++++++++++++++++ weed/s3api/s3api_xsd_generated.go | 16 +++++- 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 weed/s3api/s3api_xml_timestamp_test.go diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 84951348e..222b8c6a4 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/xml" "errors" "fmt" "io" @@ -792,6 +793,19 @@ type CopyPartResult struct { ChecksumResult } +// MarshalXML writes LastModified in the S3 timestamp format (see xsdDateTime) +// instead of encoding/xml's RFC 3339 with trimmed fractional seconds. +func (r CopyPartResult) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type T CopyPartResult + var layout struct { + *T + LastModified xsdDateTime `xml:"LastModified"` + } + layout.T = (*T)(&r) + layout.LastModified = xsdDateTime(r.LastModified) + return e.EncodeElement(layout, start) +} + func buildCopyPartResult(etag string, lastModified time.Time, metadata SSEResponseMetadata) CopyPartResult { result := CopyPartResult{ ETag: etag, diff --git a/weed/s3api/s3api_object_handlers_list_test.go b/weed/s3api/s3api_object_handlers_list_test.go index 1d87c6b8a..6c6fb95d8 100644 --- a/weed/s3api/s3api_object_handlers_list_test.go +++ b/weed/s3api/s3api_object_handlers_list_test.go @@ -119,7 +119,7 @@ func TestListObjectsHandler(t *testing.T) { // https://docs.aws.amazon.com/AmazonS3/latest/API/v2-RESTBucketGET.html expected := ` -test_container1000false1.zip"4397da7a7649e8085de9916c240e8166"123456765a011niqo39cdf8ec533ec3d1ccaafsa932STANDARD2011-04-09T12:34:49Z` +test_container1000false1.zip"4397da7a7649e8085de9916c240e8166"123456765a011niqo39cdf8ec533ec3d1ccaafsa932STANDARD2011-04-09T12:34:49.000Z` response := ListBucketResult{ Name: "test_container", diff --git a/weed/s3api/s3api_xml_timestamp_test.go b/weed/s3api/s3api_xml_timestamp_test.go new file mode 100644 index 000000000..3fda18e9d --- /dev/null +++ b/weed/s3api/s3api_xml_timestamp_test.go @@ -0,0 +1,56 @@ +package s3api + +import ( + "encoding/xml" + "strings" + "testing" + "time" +) + +// S3 clients such as minio-java parse LastModified with a fixed-width +// "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" pattern, so trailing zeros in the +// fractional seconds must not be trimmed. +func TestXMLTimestampsHaveFixedMilliseconds(t *testing.T) { + cases := []struct { + in time.Time + want string + }{ + {time.Date(2026, 9, 29, 20, 30, 4, 560_000_000, time.UTC), "2026-09-29T20:30:04.560Z"}, + {time.Date(2026, 9, 29, 20, 30, 4, 500_000_000, time.UTC), "2026-09-29T20:30:04.500Z"}, + {time.Date(2026, 9, 29, 20, 30, 4, 0, time.UTC), "2026-09-29T20:30:04.000Z"}, + {time.Date(2026, 9, 29, 20, 30, 4, 123_456_789, time.UTC), "2026-09-29T20:30:04.123Z"}, + {time.Date(2026, 9, 29, 14, 30, 4, 560_000_000, time.FixedZone("MDT", -6*3600)), "2026-09-29T20:30:04.560Z"}, + } + for _, c := range cases { + for name, v := range map[string]any{ + // by value, the way the handlers pass them to writeSuccessResponseXML + "CopyObjectResult": CopyObjectResult{ETag: "e", LastModified: c.in}, + "CopyPartResult": CopyPartResult{ETag: "e", LastModified: c.in}, + "CopyObjectResult (ptr)": &CopyObjectResult{ETag: "e", LastModified: c.in}, + "CopyPartResult (ptr)": &CopyPartResult{ETag: "e", LastModified: c.in}, + } { + out, err := xml.Marshal(v) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !strings.Contains(string(out), ""+c.want+"") { + t.Errorf("%s(%v): got %s, want LastModified %s", name, c.in, out, c.want) + } + } + } +} + +func TestXMLTimestampRoundTrip(t *testing.T) { + in := CopyObjectResult{ETag: "e", LastModified: time.Date(2026, 9, 29, 20, 30, 4, 560_000_000, time.UTC)} + out, err := xml.Marshal(&in) + if err != nil { + t.Fatal(err) + } + var back CopyObjectResult + if err := xml.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + if !back.LastModified.Equal(in.LastModified) { + t.Fatalf("round trip: got %v, want %v", back.LastModified, in.LastModified) + } +} diff --git a/weed/s3api/s3api_xsd_generated.go b/weed/s3api/s3api_xsd_generated.go index acc8d78e7..aecf95f10 100644 --- a/weed/s3api/s3api_xsd_generated.go +++ b/weed/s3api/s3api_xsd_generated.go @@ -623,13 +623,16 @@ type CopyObjectResult struct { ETag string `xml:"ETag"` } -func (t *CopyObjectResult) MarshalXML(e *xml.Encoder, start xml.StartElement) error { +// MarshalXML has a value receiver: handlers pass CopyObjectResult by value, and +// encoding/xml does not call a pointer-receiver MarshalXML on a non-addressable +// value, which silently fell back to time.Time's RFC 3339 encoding. +func (t CopyObjectResult) MarshalXML(e *xml.Encoder, start xml.StartElement) error { type T CopyObjectResult var layout struct { *T LastModified *xsdDateTime `xml:"LastModified"` } - layout.T = (*T)(t) + layout.T = (*T)(&t) layout.LastModified = (*xsdDateTime)(&layout.T.LastModified) return e.EncodeElement(layout, start) } @@ -1512,8 +1515,15 @@ type xsdDateTime time.Time func (t *xsdDateTime) UnmarshalText(text []byte) error { return _unmarshalTime(text, (*time.Time)(t), "2006-01-02T15:04:05.999999999") } + +// s3TimestampFormat is the timestamp layout AWS S3 uses in XML responses: +// UTC with exactly three fractional digits. Trimming trailing zeros (".56Z", +// or no fraction at all) breaks clients that parse with a fixed-width pattern, +// e.g. minio-java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'". +const s3TimestampFormat = "2006-01-02T15:04:05.000Z" + func (t xsdDateTime) MarshalText() ([]byte, error) { - return _marshalTime((time.Time)(t), "2006-01-02T15:04:05.999999999") + return []byte((time.Time)(t).UTC().Format(s3TimestampFormat)), nil } func (t xsdDateTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error { if (time.Time)(t).IsZero() {