fix(s3api): Fix multipart upload ETag compatibility with Hadoop S3A (#9772)

* s3api: use getEtagFromEntry for multipart part ETag to prefer Extended metadata

* s3api: add tests for getEtagFromEntry Extended ETag preference in multipart upload

* s3api: avoid double-quoting ETags in ListParts output

* s3api: add docstring for filer_multipart_etag_test.go
This commit is contained in:
Nguyễn Lộc Phúc
2026-06-01 13:03:46 -07:00
committed by GitHub
parent 5ea75dcc67
commit ed31271e28
2 changed files with 125 additions and 3 deletions
+4 -3
View File
@@ -467,7 +467,7 @@ func (s3a *S3ApiServer) prepareMultipartCompletionState(r *http.Request, input *
}
partStartChunk := len(finalParts)
partETag := filer.ETag(entry)
partETag := getEtagFromEntry(entry)
for _, chunk := range entry.GetChunks() {
finalChunk, chunkErr := completedMultipartChunk(chunk, offset, sses3Info)
@@ -1007,12 +1007,13 @@ func (s3a *S3ApiServer) listObjectParts(input *s3.ListPartsInput) (output *ListP
glog.Errorf("listObjectParts %s %s parse %s: %v", *input.Bucket, *input.UploadId, entry.Name, err)
continue
}
partETag := filer.ETag(entry)
partETag := getEtagFromEntry(entry)
part := &s3.Part{
PartNumber: aws.Int64(int64(partNumber)),
LastModified: aws.Time(time.Unix(entry.Attributes.Mtime, 0).UTC()),
Size: aws.Int64(int64(filer.FileSize(entry))),
ETag: aws.String("\"" + partETag + "\""),
ETag: aws.String(partETag),
}
output.Part = append(output.Part, part)
glog.V(3).Infof("listObjectParts: Added part %d, size=%d, etag=%s",
+121
View File
@@ -0,0 +1,121 @@
// Tests multipart upload ETag behavior when both stored S3 ETags and
// MD5-derived ETags are present. The tests verify that SeaweedFS uses
// the stored ETag for part validation and multipart ETag calculation,
// while still falling back to the MD5-derived value when necessary.
package s3api
import (
"crypto/md5"
"encoding/hex"
"fmt"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func TestGetEtagFromEntryPrefersStoredExtendedETag(t *testing.T) {
storedETag := "11111111111111111111111111111111"
computedMD5 := mustDecodeHexETagForTest(t, "22222222222222222222222222222222")
entry := &filer_pb.Entry{
Name: "0001.part",
Attributes: &filer_pb.FuseAttributes{
Md5: computedMD5,
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(storedETag),
},
}
if got, want := getEtagFromEntry(entry), `"`+storedETag+`"`; got != want {
t.Fatalf("getEtagFromEntry() = %q, want stored extended ETag %q", got, want)
}
match, invalid, normalizedPartETag, normalizedEntryETag := validateCompletePartETag(storedETag, entry)
if invalid || !match {
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want stored ETag match", match, invalid, normalizedPartETag, normalizedEntryETag)
}
computedETag := hex.EncodeToString(computedMD5)
match, invalid, normalizedPartETag, normalizedEntryETag = validateCompletePartETag(computedETag, entry)
if invalid || match {
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want computed filer ETag mismatch", match, invalid, normalizedPartETag, normalizedEntryETag)
}
}
func TestGetEtagFromEntryFallbacksToFilerETag(t *testing.T) {
computedETag := "33333333333333333333333333333333"
entry := &filer_pb.Entry{
Name: "0001.part",
Attributes: &filer_pb.FuseAttributes{
Md5: mustDecodeHexETagForTest(t, computedETag),
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(""),
},
}
if got, want := getEtagFromEntry(entry), `"`+computedETag+`"`; got != want {
t.Fatalf("getEtagFromEntry() = %q, want fallback filer ETag %q", got, want)
}
}
func TestCalculateMultipartETagUsesStoredPartETags(t *testing.T) {
storedPart1 := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
storedPart2 := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
computedPart1 := "cccccccccccccccccccccccccccccccc"
computedPart2 := "dddddddddddddddddddddddddddddddd"
partEntries := map[int][]*filer_pb.Entry{
1: {newMultipartETagTestEntry(t, "0001.part", storedPart1, computedPart1)},
2: {newMultipartETagTestEntry(t, "0002.part", storedPart2, computedPart2)},
}
got := calculateMultipartETag(partEntries, []int{1, 2})
want := expectedMultipartETagForTest(t, storedPart1, storedPart2)
if got != want {
t.Fatalf("calculateMultipartETag() = %q, want %q", got, want)
}
legacyComputedETag := expectedMultipartETagForTest(t, computedPart1, computedPart2)
if got == legacyComputedETag {
t.Fatalf("calculateMultipartETag() = %q, still used filer.ETag-derived part ETags", got)
}
}
func newMultipartETagTestEntry(t *testing.T, name, storedETag, computedETag string) *filer_pb.Entry {
t.Helper()
return &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Md5: mustDecodeHexETagForTest(t, computedETag),
},
Extended: map[string][]byte{
s3_constants.ExtETagKey: []byte(storedETag),
},
}
}
func expectedMultipartETagForTest(t *testing.T, partETags ...string) string {
t.Helper()
var combined []byte
for _, partETag := range partETags {
partETag = strings.Trim(partETag, `"`)
if before, _, found := strings.Cut(partETag, "-"); found {
partETag = before
}
combined = append(combined, mustDecodeHexETagForTest(t, partETag)...)
}
return fmt.Sprintf("%x-%d", md5.Sum(combined), len(partETags))
}
func mustDecodeHexETagForTest(t *testing.T, etag string) []byte {
t.Helper()
decoded, err := hex.DecodeString(etag)
if err != nil {
t.Fatalf("decode hex ETag %q: %v", etag, err)
}
return decoded
}