// 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 . 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()) } }