mirror of
https://github.com/pgsty/minio.git
synced 2026-09-08 03:26:07 +03:00
0b0ae2423a
CopyObjectHandler recorded the source compression metadata whenever the copy was metadata-only, on the assumption that the object layer would then leave the stored bytes alone. That assumption does not hold. Both erasureServerPools.CopyObject and erasureSets.CopyObject only skip a data rewrite in three cases, and otherwise fall back to a full PutObject. The reachable gap is a copy whose source is a null version on a bucket that gained versioning after the object was written. Neither version ID is set, so the self-referential version branch is skipped, the data is rewritten as plaintext, and the preserved compression metadata then described bytes that no longer exist. A subsequent GET failed with "s2: corrupt input". Mirror the object layer's decision in copyRewritesObjectData and record the compression metadata from it, so the metadata always describes whichever bytes are finally stored. The source version selection that lets a versioned metadata-only copy add a self-referential version moves next to the same decision, since both depend on the effective metadata-only value. Signed-off-by: Feng Ruohang <rh@vonng.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
13 KiB
Go
324 lines
13 KiB
Go
// 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 cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/minio/minio/internal/hash"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
)
|
|
|
|
func TestAPICopyObjectMetadataOnlyCompression(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
for _, versioned := range []bool{false, true} {
|
|
name := "unversioned"
|
|
if versioned {
|
|
name = "versioned"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPICopyObjectMetadataOnlyCompression,
|
|
endpoints: []string{"CopyObject", "PutObject", "GetObject"},
|
|
makeBucketOptions: MakeBucketOptions{VersioningEnabled: versioned},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func testAPICopyObjectMetadataOnlyCompression(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
data := bytes.Repeat([]byte("metadata-only-copy-plaintext-"), 64*1024)
|
|
want := mustChecksum(t, hash.ChecksumCRC32, data)
|
|
object := "copy-metadata/existing-checksum.txt"
|
|
putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data,
|
|
map[string]string{xhttp.AmzChecksumCRC32: want})
|
|
before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil || before.IsCompressed() {
|
|
t.Fatalf("%s: invalid metadata-copy precondition: compressed=%v size=%d err=%v",
|
|
instanceType, before.IsCompressed(), before.Size, err)
|
|
}
|
|
|
|
restoreCompression := setCopyChecksumCompression(true)
|
|
compressionRestored := false
|
|
defer func() {
|
|
if !compressionRestored {
|
|
restoreCompression()
|
|
}
|
|
}()
|
|
|
|
rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object,
|
|
map[string]string{xhttp.AmzMetadataDirective: "REPLACE"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: metadata-only CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
|
|
}
|
|
assertCopyChecksum(t, obj, bucketName, object, hash.ChecksumCRC32, data, false, nil)
|
|
if got := readCopyChecksumObject(t, obj, bucketName, object, ObjectOptions{}); !bytes.Equal(got, data) {
|
|
prefix := got
|
|
if len(prefix) > 100 {
|
|
prefix = prefix[:100]
|
|
}
|
|
t.Fatalf("%s: metadata-only CopyObject body differs: got %d bytes, want %d, prefix %q",
|
|
instanceType, len(got), len(data), prefix)
|
|
}
|
|
afterMetadataCopy, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before.VersionID != "" && afterMetadataCopy.VersionID == before.VersionID {
|
|
t.Fatalf("%s: versioned metadata-only copy did not create a new version", instanceType)
|
|
}
|
|
|
|
destination := "copy-metadata/rewritten.txt"
|
|
rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, object, destination, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: data-rewriting CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
|
|
}
|
|
assertCopyChecksum(t, obj, bucketName, destination, hash.ChecksumCRC32, data, true, nil)
|
|
|
|
compressedObject := "copy-metadata/preserve-compressed.txt"
|
|
putCopyChecksumSource(t, apiRouter, credentials, bucketName, compressedObject, data,
|
|
map[string]string{xhttp.AmzChecksumCRC32: want})
|
|
assertCopyChecksum(t, obj, bucketName, compressedObject, hash.ChecksumCRC32, data, true, nil)
|
|
|
|
restoreCompression()
|
|
compressionRestored = true
|
|
rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, compressedObject, compressedObject,
|
|
map[string]string{xhttp.AmzMetadataDirective: "REPLACE"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: compressed metadata-only CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
|
|
}
|
|
assertCopyChecksum(t, obj, bucketName, compressedObject, hash.ChecksumCRC32, data, true, nil)
|
|
if got := readCopyChecksumObject(t, obj, bucketName, compressedObject, ObjectOptions{}); !bytes.Equal(got, data) {
|
|
t.Fatalf("%s: compressed metadata-only CopyObject body differs", instanceType)
|
|
}
|
|
}
|
|
|
|
func TestAPICopyObjectSSECKeyRotationKeepsCompressionState(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
for _, versioned := range []bool{false, true} {
|
|
name := "unversioned"
|
|
if versioned {
|
|
name = "versioned"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPICopyObjectSSECKeyRotationKeepsCompressionState,
|
|
endpoints: []string{"CopyObject", "PutObject", "GetObject"},
|
|
makeBucketOptions: MakeBucketOptions{VersioningEnabled: versioned},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func testAPICopyObjectSSECKeyRotationKeepsCompressionState(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
data := bytes.Repeat([]byte("key-rotation-plaintext-"), 64*1024)
|
|
object := "copy-metadata/key-rotation.txt"
|
|
oldKey := bytes.Repeat([]byte{0x11}, 32)
|
|
oldMD5 := md5.Sum(oldKey)
|
|
newKey := bytes.Repeat([]byte{0x22}, 32)
|
|
newMD5 := md5.Sum(newKey)
|
|
|
|
putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data, map[string]string{
|
|
xhttp.AmzChecksumCRC32: mustChecksum(t, hash.ChecksumCRC32, data),
|
|
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
|
|
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
|
|
})
|
|
before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil || before.IsCompressed() {
|
|
t.Fatalf("%s: invalid key-rotation precondition: compressed=%v err=%v", instanceType, before.IsCompressed(), err)
|
|
}
|
|
|
|
restoreCompression := setCopyChecksumCompression(true)
|
|
defer restoreCompression()
|
|
rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{
|
|
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
|
|
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
|
|
xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
|
|
xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: key rotation failed: %d %s", instanceType, rec.Code, rec.Body.String())
|
|
}
|
|
assertCopyChecksumResponse(t, rec, hash.ChecksumCRC32, data)
|
|
after, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.IsCompressed() {
|
|
t.Fatalf("%s: metadata-only key rotation stamped compression metadata", instanceType)
|
|
}
|
|
if before.VersionID != "" && after.VersionID == before.VersionID {
|
|
t.Fatalf("%s: versioned key rotation did not create a new version", instanceType)
|
|
}
|
|
|
|
getHeaders := map[string]string{
|
|
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
|
|
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
|
|
}
|
|
req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, getHeaders)
|
|
if err != nil {
|
|
t.Fatalf("failed to build GetObject request: %v", err)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(response, req)
|
|
if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), data) {
|
|
t.Fatalf("%s: post-rotation GetObject returned %d with %d bytes, want 200 with %d bytes: %s",
|
|
instanceType, response.Code, response.Body.Len(), len(data), response.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestAPICopyObjectMetadataOnlyNullVersion covers the copy whose source is a
|
|
// null version on a bucket that gained versioning after the object was written.
|
|
// The object layer cannot reference such a version, so it rewrites the data and
|
|
// the recorded compression metadata has to describe the rewritten bytes.
|
|
func TestAPICopyObjectMetadataOnlyNullVersion(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPICopyObjectMetadataOnlyNullVersion,
|
|
endpoints: []string{"CopyObject", "PutObject", "GetObject"},
|
|
})
|
|
}
|
|
|
|
func testAPICopyObjectMetadataOnlyNullVersion(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
restoreCompression := setCopyChecksumCompression(true)
|
|
compressionRestored := false
|
|
defer func() {
|
|
if !compressionRestored {
|
|
restoreCompression()
|
|
}
|
|
}()
|
|
|
|
data := bytes.Repeat([]byte("null-version-metadata-copy-"), 64*1024)
|
|
want := mustChecksum(t, hash.ChecksumCRC32, data)
|
|
object := "copy-metadata/null-version.txt"
|
|
putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data,
|
|
map[string]string{xhttp.AmzChecksumCRC32: want})
|
|
|
|
before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !before.IsCompressed() || before.VersionID != "" {
|
|
t.Fatalf("%s: invalid null-version precondition: compressed=%v versionID=%q",
|
|
instanceType, before.IsCompressed(), before.VersionID)
|
|
}
|
|
|
|
// Versioning is enabled after the write, so the object keeps a null version.
|
|
if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName,
|
|
bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
|
t.Fatalf("%s: unable to enable versioning: %v", instanceType, err)
|
|
}
|
|
if !globalBucketVersioningSys.PrefixEnabled(bucketName, object) {
|
|
t.Fatalf("%s: versioning did not become enabled", instanceType)
|
|
}
|
|
|
|
// Without compression the rewritten destination stores plaintext.
|
|
restoreCompression()
|
|
compressionRestored = true
|
|
|
|
rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object,
|
|
map[string]string{xhttp.AmzMetadataDirective: "REPLACE"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: metadata-only CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
|
|
}
|
|
|
|
after := assertCopyChecksum(t, obj, bucketName, object, hash.ChecksumCRC32, data, false, nil)
|
|
if after.VersionID == "" {
|
|
t.Fatalf("%s: versioned copy did not create a new version", instanceType)
|
|
}
|
|
if got := readCopyChecksumObject(t, obj, bucketName, object, ObjectOptions{}); !bytes.Equal(got, data) {
|
|
t.Fatalf("%s: copied object body differs: got %d bytes, want %d", instanceType, len(got), len(data))
|
|
}
|
|
}
|
|
|
|
func TestCopyRewritesObjectData(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
metadataOnly bool
|
|
srcOpts ObjectOptions
|
|
dstOpts ObjectOptions
|
|
want bool
|
|
}{
|
|
{
|
|
name: "data copy always rewrites",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "unversioned in-place metadata update",
|
|
metadataOnly: true,
|
|
},
|
|
{
|
|
name: "addressed version updated in place",
|
|
metadataOnly: true,
|
|
srcOpts: ObjectOptions{VersionID: "v1"},
|
|
dstOpts: ObjectOptions{VersionID: "v1"},
|
|
},
|
|
{
|
|
name: "versioned self referential version",
|
|
metadataOnly: true,
|
|
srcOpts: ObjectOptions{VersionID: "v1"},
|
|
dstOpts: ObjectOptions{Versioned: true},
|
|
},
|
|
{
|
|
name: "versioned null source version cannot be referenced",
|
|
metadataOnly: true,
|
|
dstOpts: ObjectOptions{Versioned: true},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "suspended destination with an addressed source version",
|
|
metadataOnly: true,
|
|
srcOpts: ObjectOptions{VersionID: "v1"},
|
|
dstOpts: ObjectOptions{VersionSuspended: true, VersionID: nullVersionID},
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := copyRewritesObjectData(tt.metadataOnly, tt.srcOpts, tt.dstOpts); got != tt.want {
|
|
t.Fatalf("copyRewritesObjectData() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|