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() {