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.
This commit is contained in:
Alex Hu
2026-09-30 16:22:31 +08:00
committed by GitHub
parent 9a454bbe2b
commit 38ce95d960
4 changed files with 84 additions and 4 deletions
+14
View File
@@ -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,
@@ -119,7 +119,7 @@ func TestListObjectsHandler(t *testing.T) {
// https://docs.aws.amazon.com/AmazonS3/latest/API/v2-RESTBucketGET.html
expected := `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult><Name>test_container</Name><Prefix></Prefix><Marker></Marker><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>1.zip</Key><ETag>&#34;4397da7a7649e8085de9916c240e8166&#34;</ETag><Size>1234567</Size><Owner><ID>65a011niqo39cdf8ec533ec3d1ccaafsa932</ID></Owner><StorageClass>STANDARD</StorageClass><LastModified>2011-04-09T12:34:49Z</LastModified></Contents><EncodingType></EncodingType></ListBucketResult>`
<ListBucketResult><Name>test_container</Name><Prefix></Prefix><Marker></Marker><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>1.zip</Key><ETag>&#34;4397da7a7649e8085de9916c240e8166&#34;</ETag><Size>1234567</Size><Owner><ID>65a011niqo39cdf8ec533ec3d1ccaafsa932</ID></Owner><StorageClass>STANDARD</StorageClass><LastModified>2011-04-09T12:34:49.000Z</LastModified></Contents><EncodingType></EncodingType></ListBucketResult>`
response := ListBucketResult{
Name: "test_container",
+56
View File
@@ -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), "<LastModified>"+c.want+"</LastModified>") {
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)
}
}
+13 -3
View File
@@ -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() {