fix: keep the checksum of a zero length multipart object

hash.Checksum.AddPart returned before seeding the accumulator when the
part was empty, so a multipart object with no content at all ended up
with no checksum instead of the checksum of zero bytes. Completing such
an upload failed with XAmzContentChecksumMismatch when the client
supplied the correct object checksum, and stored an empty checksum when
it did not.

Run the type check and the first checksum seeding before the zero size
early return. Appending zero bytes still leaves an existing accumulator
unchanged, so only the all empty case changes: a zero length part
followed by content already merged correctly, because prepending no bytes
does not alter a CRC.

AddPart has a single production caller, the multipart completion path, and
its part checksum type is derived from the upload's own checksum type, so
the type check now reached for zero sized parts cannot fire there.

Add a table test over CRC32, CRC32C and CRC64NVME covering every position
an empty part can take, and an API level zero length full object upload
that exercises the persisted AppendTo/ReadCheckSums round trip.

Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Feng Ruohang
2026-07-29 20:07:11 +08:00
parent c8590413fd
commit 3e14733f15
3 changed files with 148 additions and 3 deletions
+110
View File
@@ -0,0 +1,110 @@
// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package hash
import (
"bytes"
"testing"
)
// TestChecksumAddPart checks that merging part checksums reproduces the
// checksum of the concatenated content, including when parts are empty.
// A zero length first part used to leave the accumulator unseeded, which left
// multipart objects with an empty checksum.
func TestChecksumAddPart(t *testing.T) {
empty := []byte{}
a := bytes.Repeat([]byte("a"), 1024)
b := bytes.Repeat([]byte("b"), 7)
layouts := []struct {
name string
parts [][]byte
}{
{"single-empty", [][]byte{empty}},
{"empty-first", [][]byte{empty, a, b}},
{"empty-middle", [][]byte{a, empty, b}},
{"empty-last", [][]byte{a, b, empty}},
{"all-empty", [][]byte{empty, empty}},
{"no-empty", [][]byte{a, b}},
{"single", [][]byte{a}},
}
for _, typ := range []ChecksumType{ChecksumCRC32, ChecksumCRC32C, ChecksumCRC64NVME} {
for _, l := range layouts {
t.Run(typ.String()+"/"+l.name, func(t *testing.T) {
var merged Checksum
merged.Type = typ | ChecksumMultipart | ChecksumIncludesMultipart
var full []byte
for i, p := range l.parts {
part := NewChecksumFromData(typ, p)
if part == nil {
t.Fatalf("unable to compute part %d checksum", i+1)
}
if err := merged.AddPart(*part, int64(len(p))); err != nil {
t.Fatalf("AddPart(%d): %v", i+1, err)
}
full = append(full, p...)
}
want := NewChecksumFromData(typ, full)
if want == nil {
t.Fatal("unable to compute expected checksum")
}
if merged.Encoded != want.Encoded {
t.Fatalf("merged checksum = %q, want %q", merged.Encoded, want.Encoded)
}
if !merged.Valid() {
t.Fatalf("merged checksum is not valid: %+v", merged)
}
})
}
}
}
// TestChecksumAddPartTypeMismatch checks that a mismatched part type is
// reported even when the part is empty. The zero size shortcut used to hide it.
func TestChecksumAddPartTypeMismatch(t *testing.T) {
var merged Checksum
merged.Type = ChecksumCRC32 | ChecksumMultipart
other := NewChecksumFromData(ChecksumCRC32C, []byte("x"))
if other == nil {
t.Fatal("unable to compute part checksum")
}
if err := merged.AddPart(*other, 0); err == nil {
t.Fatal("expected a type mismatch error for a zero sized part of the wrong type")
}
if err := merged.AddPart(*other, 1); err == nil {
t.Fatal("expected a type mismatch error for a non-empty part of the wrong type")
}
}
// TestChecksumAddPartUnmergeable checks that non-CRC types are still refused.
func TestChecksumAddPartUnmergeable(t *testing.T) {
var merged Checksum
merged.Type = ChecksumSHA256 | ChecksumMultipart
other := NewChecksumFromData(ChecksumSHA256, []byte("x"))
if other == nil {
t.Fatal("unable to compute part checksum")
}
if err := merged.AddPart(*other, 1); err == nil {
t.Fatal("expected SHA256 to be refused as unmergeable")
}
}