fix: align multipart completion checksum errors

Return AWS-compatible errors for CompleteMultipartUpload checksum failures without changing the global streaming checksum mapping. Compare explicit multipart checksum types symmetrically, distinguish missing composite part checksums, and preserve the CRC64NVME canonicalization pending a direct AWS probe.

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-08-27 08:50:45 +08:00
parent edc8be6ed1
commit 5d152416de
7 changed files with 261 additions and 32 deletions
+12
View File
@@ -2169,6 +2169,10 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) {
err = unwrapAll(err)
switch err {
case errCompleteMultipartChecksumMismatch, errCompleteMultipartChecksumTypeMismatch:
apiErr = ErrBadDigest
case errMissingPartChecksum:
apiErr = ErrInvalidRequest
case errInvalidArgument:
apiErr = ErrAdminInvalidArgument
case errNoSuchPolicy:
@@ -2465,6 +2469,14 @@ func toAPIError(ctx context.Context, err error) APIError {
}
apiErr := errorCodes.ToAPIErr(toAPIErrorCode(ctx, err))
switch {
case errors.Is(err, errCompleteMultipartChecksumMismatch):
apiErr.Description = strings.TrimPrefix(err.Error(), errCompleteMultipartChecksumMismatch.Error()+": ")
case errors.Is(err, errCompleteMultipartChecksumTypeMismatch):
apiErr.Description = strings.TrimPrefix(err.Error(), errCompleteMultipartChecksumTypeMismatch.Error()+": ")
case errors.Is(err, errMissingPartChecksum):
apiErr.Description = strings.TrimPrefix(err.Error(), errMissingPartChecksum.Error()+": ")
}
switch apiErr.Code {
case "NotImplemented":
apiErr = APIError{
+4
View File
@@ -39,6 +39,10 @@ var toAPIErrorTests = []struct {
{err: ObjectNameInvalid{}, errCode: ErrInvalidObjectName},
{err: InvalidUploadID{}, errCode: ErrNoSuchUpload},
{err: InvalidPart{}, errCode: ErrInvalidPart},
{err: errCompleteMultipartChecksumMismatch, errCode: ErrBadDigest},
{err: errCompleteMultipartChecksumTypeMismatch, errCode: ErrBadDigest},
{err: errMissingPartChecksum, errCode: ErrInvalidRequest},
{err: hash.ChecksumMismatch{}, errCode: ErrContentChecksumMismatch},
{err: InsufficientReadQuorum{}, errCode: ErrSlowDownRead},
{err: InsufficientWriteQuorum{}, errCode: ErrSlowDownWrite},
{err: InvalidUploadIDKeyCombination{}, errCode: ErrNotImplemented},
+180 -10
View File
@@ -24,6 +24,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/dustin/go-humanize"
@@ -139,13 +140,18 @@ func completeMultipartUploadHTTP(t *testing.T, apiRouter http.Handler, creds aut
return rec
}
func apiErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
func apiError(t *testing.T, rec *httptest.ResponseRecorder) APIErrorResponse {
t.Helper()
var e APIErrorResponse
if err := xml.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("unable to decode error response %q: %v", rec.Body.String(), err)
}
return e.Code
return e
}
func apiErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
return apiError(t, rec).Code
}
// TestAPICompleteMultipartFullObjectChecksum covers pgsty/silo#31.
@@ -243,12 +249,12 @@ func testAPICompleteMultipartFullObjectChecksumMismatch(obj ObjectLayer, instanc
t.Fatalf("%s: CompleteMultipartUpload with a bad full object checksum returned %d, want 400",
instanceType, rec.Code)
}
// NOTE: AWS S3 documents BadDigest for a full object checksum mismatch on
// CompleteMultipartUpload. MinIO reports XAmzContentChecksumMismatch. That
// deviation is tracked separately; assert the current code so a future
// change to it is a deliberate one.
if got := apiErrorCode(t, rec); got != "XAmzContentChecksumMismatch" {
t.Fatalf("%s: expected XAmzContentChecksumMismatch, got %q", instanceType, got)
apiErr := apiError(t, rec)
if apiErr.Code != "BadDigest" {
t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
}
if want := "The CRC32 checksum you specified did not match the calculated checksum."; apiErr.Message != want {
t.Fatalf("%s: expected message %q, got %q", instanceType, want, apiErr.Message)
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
@@ -288,8 +294,33 @@ func testAPICompleteMultipartCompositeStillRequiresPartChecksums(obj ObjectLayer
t.Fatalf("%s/%s: composite CompleteMultipartUpload without part checksums returned %d, want 400",
instanceType, typ.String(), rec.Code)
}
if got := apiErrorCode(t, rec); got != "InvalidPart" {
t.Fatalf("%s/%s: expected InvalidPart, got %q", instanceType, typ.String(), got)
apiErr := apiError(t, rec)
if apiErr.Code != "InvalidRequest" {
t.Fatalf("%s/%s: expected InvalidRequest, got %q", instanceType, typ.String(), apiErr.Code)
}
wantMessage := fmt.Sprintf("The upload was created using a %s checksum. The complete request must include the checksum for each part. It was missing for part 1 in the request.", strings.ToLower(typ.String()))
if apiErr.Message != wantMessage {
t.Fatalf("%s/%s: expected message %q, got %q", instanceType, typ.String(), wantMessage, apiErr.Message)
}
// A retry that supplies part 1 but omits part 2 must name the actual
// missing part, not merely the first part in the upload.
completedParts := []CompletePart{
completePartWithChecksum(typ, 1, etags[0], mustChecksum(t, typ, partData[0])),
{PartNumber: 2, ETag: etags[1]},
}
rec = completePartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, completedParts, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s/%s: composite completion missing part 2 checksum returned %d, want 400",
instanceType, typ.String(), rec.Code)
}
apiErr = apiError(t, rec)
if apiErr.Code != "InvalidRequest" {
t.Fatalf("%s/%s: expected InvalidRequest, got %q", instanceType, typ.String(), apiErr.Code)
}
wantMessage = fmt.Sprintf("The upload was created using a %s checksum. The complete request must include the checksum for each part. It was missing for part 2 in the request.", strings.ToLower(typ.String()))
if apiErr.Message != wantMessage {
t.Fatalf("%s/%s: expected message %q, got %q", instanceType, typ.String(), wantMessage, apiErr.Message)
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
t.Fatalf("%s/%s: object was created despite a rejected completion", instanceType, typ.String())
@@ -297,6 +328,145 @@ func testAPICompleteMultipartCompositeStillRequiresPartChecksums(obj ObjectLayer
}
}
// TestAPICompleteMultipartCompositeChecksumMismatch covers the composite
// object-checksum path independently from full object checksum merging.
func TestAPICompleteMultipartCompositeChecksumMismatch(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICompleteMultipartCompositeChecksumMismatch,
endpoints: []string{"NewMultipart", "PutObjectPart", "CompleteMultipart"},
})
}
func testAPICompleteMultipartCompositeChecksumMismatch(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler,
credentials auth.Credentials, t *testing.T,
) {
typ := hash.ChecksumCRC32
partData, _ := multipartChecksumTestData()
objectName := "uploads/composite-object-mismatch"
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
typ.String(), xhttp.AmzChecksumTypeComposite)
etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
partCS := []string{mustChecksum(t, typ, partData[0]), mustChecksum(t, typ, partData[1])}
rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS,
map[string]string{
typ.Key(): mustChecksum(t, typ, []byte("wrong composite checksum")) + "-2",
xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: composite checksum mismatch returned %d, want 400", instanceType, rec.Code)
}
apiErr := apiError(t, rec)
if apiErr.Code != "BadDigest" {
t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
}
if want := "The CRC32 checksum you specified did not match the calculated checksum."; apiErr.Message != want {
t.Fatalf("%s: expected message %q, got %q", instanceType, want, apiErr.Message)
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
t.Fatalf("%s: object was created despite a failed composite checksum validation", instanceType)
}
}
// TestAPICompleteMultipartChecksumTypeMismatch verifies the type comparison in
// both directions. ChecksumType is a bitmask, so a containment check alone
// incorrectly accepts COMPOSITE uploads completed as FULL_OBJECT.
func TestAPICompleteMultipartChecksumTypeMismatch(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICompleteMultipartChecksumTypeMismatch,
endpoints: []string{"NewMultipart", "PutObjectPart", "CompleteMultipart"},
})
}
func testAPICompleteMultipartChecksumTypeMismatch(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler,
credentials auth.Credentials, t *testing.T,
) {
typ := hash.ChecksumCRC32
partData, full := multipartChecksumTestData()
for _, test := range []struct {
name string
createdType string
providedType string
}{
{name: "full-to-composite", createdType: xhttp.AmzChecksumTypeFullObject, providedType: xhttp.AmzChecksumTypeComposite},
{name: "composite-to-full", createdType: xhttp.AmzChecksumTypeComposite, providedType: xhttp.AmzChecksumTypeFullObject},
} {
t.Run(test.name, func(t *testing.T) {
objectName := "type-mismatch/" + test.name
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
typ.String(), test.createdType)
etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
partCS := []string{mustChecksum(t, typ, partData[0]), mustChecksum(t, typ, partData[1])}
rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS,
map[string]string{
typ.Key(): mustChecksum(t, typ, full),
xhttp.AmzChecksumType: test.providedType,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: checksum type mismatch returned %d, want 400", instanceType, rec.Code)
}
apiErr := apiError(t, rec)
if apiErr.Code != "BadDigest" {
t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
}
wantMessage := fmt.Sprintf("The checksum type %s does not match the multipart upload checksum type %s.", test.providedType, test.createdType)
if apiErr.Message != wantMessage {
t.Fatalf("%s: expected message %q, got %q", instanceType, wantMessage, apiErr.Message)
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
t.Fatalf("%s: object was created despite a rejected checksum type", instanceType)
}
})
}
t.Run("omitted-type-is-not-composite", func(t *testing.T) {
objectName := "type-mismatch/omitted-type"
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
typ.String(), xhttp.AmzChecksumTypeFullObject)
etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, nil,
map[string]string{typ.Key(): mustChecksum(t, typ, full)})
if rec.Code != http.StatusOK {
t.Fatalf("%s: completion without an explicit checksum type returned %d %s",
instanceType, rec.Code, rec.Body.String())
}
})
t.Run("algorithm-mismatch-remains-invalid-argument", func(t *testing.T) {
objectName := "type-mismatch/algorithm"
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
typ.String(), xhttp.AmzChecksumTypeFullObject)
etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, nil,
map[string]string{
hash.ChecksumCRC32C.Key(): mustChecksum(t, hash.ChecksumCRC32C, full),
xhttp.AmzChecksumType: xhttp.AmzChecksumTypeFullObject,
})
if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "InvalidArgument" {
t.Fatalf("%s: algorithm mismatch returned %d %s", instanceType, rec.Code, rec.Body.String())
}
})
t.Run("crc64nvme-composite-remains-canonicalized", func(t *testing.T) {
crc64Type := hash.ChecksumCRC64NVME
objectName := "type-mismatch/crc64nvme-composite"
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
crc64Type.String(), xhttp.AmzChecksumTypeComposite)
etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, crc64Type, partData)
rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, nil,
map[string]string{
crc64Type.Key(): mustChecksum(t, crc64Type, full),
xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
})
if rec.Code != http.StatusOK {
t.Fatalf("%s: CRC64NVME canonicalization changed: %d %s", instanceType, rec.Code, rec.Body.String())
}
})
}
// TestAPICompleteMultipartFullObjectVariants pins down the surrounding
// behavior of the relaxation: what may be omitted, what must still match, and
// that a zero length object is handled like any other.
+26 -21
View File
@@ -1173,11 +1173,18 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
var checksumType hash.ChecksumType
if cs := fi.Metadata[hash.MinIOMultipartChecksum]; cs != "" {
checksumType = hash.NewChecksumType(cs, fi.Metadata[hash.MinIOMultipartChecksumType])
if opts.WantChecksum != nil && !opts.WantChecksum.Type.Is(checksumType) {
return oi, InvalidArgument{
Bucket: bucket,
Object: fi.Name,
Err: fmt.Errorf("checksum type mismatch. got %q (%s) expected %q (%s)", checksumType.String(), checksumType.ObjType(), opts.WantChecksum.Type.String(), opts.WantChecksum.Type.ObjType()),
if opts.WantChecksum != nil {
providedType := opts.WantChecksum.Type | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
expectedType := checksumType | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
if providedType.Base() != expectedType.Base() {
return oi, InvalidArgument{
Bucket: bucket,
Object: fi.Name,
Err: fmt.Errorf("checksum algorithm mismatch. got %q expected %q", providedType.String(), expectedType.String()),
}
}
if opts.wantChecksumTypeSet && providedType.ObjType() != expectedType.ObjType() {
return oi, completeMultipartChecksumTypeMismatch(providedType.ObjType(), expectedType.ObjType())
}
}
checksumType |= hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
@@ -1308,19 +1315,18 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
break
}
}
// Part checksums are optional in the CompleteMultipartUpload body when
// the upload was created with a full object checksum type: clients send
// the object level checksum instead and do not retain part checksums.
// A part that carries any checksum at all is still validated against
// what we stored - including one sent under the wrong algorithm, which
// cannot match and is rejected. The object level checksum, if supplied,
// is verified against the merged part checksums below.
allowMissingPartCS := checksumType.FullObjectRequested() && !suppliedAnyCS
if !allowMissingPartCS && gotCS != crc {
// Full object completions may omit part checksums. Composite
// completions may not. Any checksum that is supplied is still
// validated, including one sent under the wrong algorithm.
if !suppliedAnyCS {
if !checksumType.FullObjectRequested() {
return oi, missingPartChecksum(checksumType.String(), part.PartNumber)
}
} else if gotCS != crc {
return oi, InvalidPart{
PartNumber: part.PartNumber,
ExpETag: gotCS,
GotETag: crc,
ExpETag: crc,
GotETag: gotCS,
}
}
cs := hash.NewChecksumString(checksumType.String(), crc)
@@ -1370,15 +1376,14 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
if opts.WantChecksum != nil {
if checksumType.FullObjectRequested() {
if opts.WantChecksum.Encoded != checksum.Encoded {
err := hash.ChecksumMismatch{
Want: opts.WantChecksum.Encoded,
Got: checksum.Encoded,
}
return oi, err
return oi, completeMultipartChecksumMismatch(checksumType.String())
}
} else {
err := opts.WantChecksum.Matches(checksumCombined, len(parts))
if err != nil {
if hash.IsChecksumMismatch(err) {
return oi, completeMultipartChecksumMismatch(checksumType.String())
}
return oi, err
}
}
+36
View File
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"io"
"strings"
)
// Converts underlying storage error. Convenience function written to
@@ -653,6 +654,41 @@ func (e InvalidPart) Error() string {
e.PartNumber, e.ExpETag, e.GotETag)
}
var (
errCompleteMultipartChecksumMismatch = errors.New("complete multipart checksum mismatch")
errCompleteMultipartChecksumTypeMismatch = errors.New("complete multipart checksum type mismatch")
errMissingPartChecksum = errors.New("missing multipart part checksum")
)
// completeMultipartChecksumMismatch reports an object checksum mismatch
// detected while completing a multipart upload. It is distinct from
// hash.ChecksumMismatch because AWS maps completion failures to BadDigest,
// while streaming PutObject and UploadPart failures keep using
// XAmzContentChecksumMismatch.
func completeMultipartChecksumMismatch(algorithm string) error {
description := "The checksum you specified did not match the calculated checksum."
if algorithm != "" {
description = fmt.Sprintf("The %s checksum you specified did not match the calculated checksum.", algorithm)
}
return fmt.Errorf("%w: %s", errCompleteMultipartChecksumMismatch, description)
}
// completeMultipartChecksumTypeMismatch reports an explicit checksum type that
// differs from the type selected when the multipart upload was initiated.
func completeMultipartChecksumTypeMismatch(providedType, expectedType string) error {
description := fmt.Sprintf("The checksum type %s does not match the multipart upload checksum type %s.",
providedType, expectedType)
return fmt.Errorf("%w: %s", errCompleteMultipartChecksumTypeMismatch, description)
}
// missingPartChecksum reports a part whose checksum is absent from a
// composite CompleteMultipartUpload request.
func missingPartChecksum(algorithm string, partNumber int) error {
description := fmt.Sprintf("The upload was created using a %s checksum. The complete request must include the checksum for each part. It was missing for part %d in the request.",
strings.ToLower(algorithm), partNumber)
return fmt.Errorf("%w: %s", errMissingPartChecksum, description)
}
// PartTooSmall - error if part size is less than 5MB.
type PartTooSmall struct {
PartSize int64
+2 -1
View File
@@ -84,7 +84,8 @@ type ObjectOptions struct {
Expiration ExpirationOptions
LifecycleAuditEvent lcAuditEvent
WantChecksum *hash.Checksum // x-amz-checksum-XXX checksum sent to PutObject/ CompleteMultipartUpload.
WantChecksum *hash.Checksum // x-amz-checksum-XXX checksum sent to PutObject/ CompleteMultipartUpload.
wantChecksumTypeSet bool // x-amz-checksum-type was explicitly set on CompleteMultipartUpload.
WantServerSideChecksumType hash.ChecksumType // if set, we compute a server-side checksum of this type
+1
View File
@@ -473,6 +473,7 @@ func completeMultipartOpts(ctx context.Context, r *http.Request, bucket, object
if err != nil {
return opts, err
}
opts.wantChecksumTypeSet = r.Header.Get(xhttp.AmzChecksumType) != ""
opts.MTime = mtime
opts.UserDefined = make(map[string]string)
// Transfer SSEC key in opts.EncryptFn