Files
minio/cmd/replication-trust_test.go
T
Feng Ruohang e12e739a53 Merge pull request #128 from pgsty/fix/issue-114-115-object-attributes-parts
fix: report logical part sizes and end pagination correctly in GetObjectAttributes
2026-09-06 09:25:28 +08:00

1434 lines
59 KiB
Go

// Copyright (c) 2015-2026 MinIO, Inc.
// Copyright (c) 2026 PGSTY
//
// 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.
package cmd
import (
"archive/tar"
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/crypto"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/kms"
"github.com/pgsty/silo-pkg/v3/policy"
)
func TestAPIReplicationTrustProtectsSSECReads(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPIReplicationTrustProtectsSSECReads,
})
}
func testAPIReplicationTrustProtectsSSECReads(_ ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
key := bytes.Repeat([]byte{0x31}, 32)
keyMD5 := md5.Sum(key)
data := bytes.Repeat([]byte("replication-trust-ssec-"), 256)
sseHeaders := map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
object := "replication-trust/ssec-read"
putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data, sseHeaders)
readerOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:GetObject"`)
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:GetObject","s3:ReplicateObject"`)
marker := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}
wrongCaseMarker := map[string]string{xhttp.MinIOSourceReplicationRequest: "TRUE"}
conditionalMarker := map[string]string{
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.IfNoneMatch: "*",
}
for _, test := range []struct {
name string
method string
creds auth.Credentials
headers map[string]string
wantStatus int
wantPlain bool
wantCipher bool
}{
{name: "get/reader/fake-marker", method: http.MethodGet, creds: readerOnly, headers: marker, wantStatus: http.StatusBadRequest},
{name: "get/replicator/trusted", method: http.MethodGet, creds: replicator, headers: marker, wantStatus: http.StatusOK, wantCipher: true},
{name: "get/root/trusted", method: http.MethodGet, creds: credentials, headers: marker, wantStatus: http.StatusOK, wantCipher: true},
{name: "get/replicator/wrong-case", method: http.MethodGet, creds: replicator, headers: wrongCaseMarker, wantStatus: http.StatusBadRequest},
{name: "get/reader/key", method: http.MethodGet, creds: readerOnly, headers: sseHeaders, wantStatus: http.StatusOK, wantPlain: true},
{name: "head/reader/fake-marker", method: http.MethodHead, creds: readerOnly, headers: marker, wantStatus: http.StatusBadRequest},
{name: "head/reader/conditional-oracle", method: http.MethodHead, creds: readerOnly, headers: conditionalMarker, wantStatus: http.StatusBadRequest},
{name: "head/replicator/trusted", method: http.MethodHead, creds: replicator, headers: marker, wantStatus: http.StatusOK},
{name: "head/replicator/wrong-case", method: http.MethodHead, creds: replicator, headers: wrongCaseMarker, wantStatus: http.StatusBadRequest},
} {
t.Run(test.name, func(t *testing.T) {
req, err := newTestSignedRequestV4(test.method, getGetObjectURL("", bucketName, object), 0, nil,
test.creds.AccessKey, test.creds.SecretKey, test.headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != test.wantStatus {
t.Fatalf("%s: status %d, want %d: %s", instanceType, rec.Code, test.wantStatus, rec.Body.String())
}
if test.wantPlain && !bytes.Equal(rec.Body.Bytes(), data) {
t.Fatal("ordinary SSE-C GET did not return plaintext")
}
if test.wantCipher && (len(rec.Body.Bytes()) == 0 || bytes.Equal(rec.Body.Bytes(), data)) {
t.Fatal("trusted replication GET did not return ciphertext")
}
})
}
}
func TestReplicationTrustControlsInternalOptionsAndEvents(t *testing.T) {
mtime := time.Date(2026, 8, 31, 12, 34, 56, 123, time.UTC)
headers := make(http.Header)
headers.Set(xhttp.MinIOSourceReplicationRequest, "true")
headers.Set(xhttp.MinIOSourceETag, "source-etag")
headers.Set(xhttp.MinIOSourceMTime, mtime.Format(time.RFC3339Nano))
headers.Set(xhttp.MinIOReplicationActualObjectSize, "123")
headers.Set(ReplicationSsecChecksumHeader, "checksum")
ordinary, err := putOptsFromHeaders(t.Context(), headers, nil, false)
if err != nil {
t.Fatal(err)
}
if ordinary.ReplicationRequest || ordinary.PreserveETag != "" || !ordinary.MTime.IsZero() {
t.Fatalf("ordinary options trusted internal headers: %#v", ordinary)
}
trusted, err := putOptsFromHeaders(t.Context(), headers, nil, true)
if err != nil {
t.Fatal(err)
}
if !trusted.ReplicationRequest || trusted.PreserveETag != "source-etag" || !trusted.MTime.Equal(mtime) {
t.Fatalf("trusted options lost source state: %#v", trusted)
}
completeReq := &http.Request{Header: headers.Clone(), Form: make(url.Values)}
ordinaryComplete, err := completeMultipartOpts(t.Context(), completeReq, "bucket", "object")
if err != nil {
t.Fatal(err)
}
if ordinaryComplete.ReplicationRequest || len(ordinaryComplete.UserDefined) != 0 {
t.Fatalf("ordinary completion trusted internal metadata: %#v", ordinaryComplete)
}
trustedCompleteCtx := withReplicationTrust(t.Context(), true, false)
trustedComplete, err := completeMultipartOpts(trustedCompleteCtx, completeReq.WithContext(trustedCompleteCtx), "bucket", "object")
if err != nil {
t.Fatal(err)
}
if !trustedComplete.ReplicationRequest || trustedComplete.UserDefined[ReservedMetadataPrefix+"Actual-Object-Size"] != "123" ||
trustedComplete.UserDefined[ReplicationSsecChecksumHeader] != "checksum" {
t.Fatalf("trusted completion lost internal metadata: %#v", trustedComplete)
}
req := &http.Request{Header: headers.Clone(), Form: make(url.Values)}
req = req.WithContext(context.Background())
if _, ok := extractReqParams(req)[xhttp.MinIOSourceReplicationRequest]; ok {
t.Fatal("untrusted marker suppressed events")
}
trustedCtx := withReplicationTrust(req.Context(), true, false)
req = req.WithContext(trustedCtx)
if _, ok := extractReqParams(req)[xhttp.MinIOSourceReplicationRequest]; !ok {
t.Fatal("trusted replication marker was not propagated to events")
}
}
func TestAPIPutObjectReplicationTrust(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPIPutObjectReplicationTrust,
})
}
func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, _ auth.Credentials, t *testing.T,
) {
putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject"`)
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:ReplicateObject"`)
payload := []byte("replication trust put payload")
sourceMTime := time.Date(2024, 1, 2, 3, 4, 5, 6, time.UTC)
request := func(t *testing.T, object string, creds auth.Credentials, status string, check bool) *httptest.ResponseRecorder {
t.Helper()
headers := map[string]string{
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.MinIOSourceETag: "source-etag",
xhttp.MinIOSourceMTime: sourceMTime.Format(time.RFC3339Nano),
}
if status != "" {
headers[xhttp.AmzBucketReplicationStatus] = status
}
if check {
headers[xhttp.MinIOSourceReplicationCheck] = "true"
}
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
int64(len(payload)), bytes.NewReader(payload), creds.AccessKey, creds.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
return rec
}
t.Run("untrusted marker is ordinary", func(t *testing.T) {
object := "replication-trust/put-ordinary"
if rec := request(t, object, putOnly, "PENDING", false); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if info.ETag == "source-etag" || info.ModTime.Equal(sourceMTime) {
t.Fatalf("untrusted source state was preserved: ETag=%q MTime=%v", info.ETag, info.ModTime)
}
assertObjectMetadataKeysAbsent(t, info.UserDefined, xhttp.AmzBucketReplicationStatus)
})
t.Run("unauthorized replica is denied", func(t *testing.T) {
object := "replication-trust/put-denied-replica"
if rec := request(t, object, putOnly, "REPLICA", false); rec.Code != http.StatusForbidden {
t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String())
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err == nil {
t.Fatal("unauthorized replica write created an object")
}
})
t.Run("trusted batch preserves source state", func(t *testing.T) {
object := "replication-trust/put-batch"
if rec := request(t, object, replicator, "", false); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if info.ETag != "source-etag" || !info.ModTime.Equal(sourceMTime) {
t.Fatalf("trusted source state lost: ETag=%q MTime=%v", info.ETag, info.ModTime)
}
assertObjectMetadataKeysAbsent(t, info.UserDefined, xhttp.AmzBucketReplicationStatus)
})
t.Run("trusted replica persists replica state", func(t *testing.T) {
object := "replication-trust/put-replica"
if rec := request(t, object, replicator, "REPLICA", false); rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if info.UserDefined[xhttp.AmzBucketReplicationStatus] != "REPLICA" {
t.Fatalf("replica status not persisted: %#v", info.UserDefined)
}
})
for _, test := range []struct {
name string
creds auth.Credentials
wantStatus int
}{
{name: "validity check requires ReplicateObject", creds: putOnly, wantStatus: http.StatusForbidden},
{name: "validity check succeeds for replicator", creds: replicator, wantStatus: http.StatusBadRequest},
} {
t.Run(test.name, func(t *testing.T) {
object := "replication-trust/put-check-" + strconv.Itoa(test.wantStatus)
if rec := request(t, object, test.creds, "REPLICA", true); rec.Code != test.wantStatus {
t.Fatalf("status %d, want %d: %s", rec.Code, test.wantStatus, rec.Body.String())
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err == nil {
t.Fatal("replication validity check created an object")
}
})
}
}
func TestAPISnowballReplicationTrustIsPerEntry(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPISnowballReplicationTrustIsPerEntry,
})
}
func testAPISnowballReplicationTrustIsPerEntry(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, _ auth.Credentials, t *testing.T,
) {
const (
allowedPrefix = "snowball/allowed/"
deniedPrefix = "snowball/denied/"
sourceETag = "0123456789abcdef0123456789abcdef"
)
creds := newSnowballReplicationTrustUser(t, instanceType, bucketName, allowedPrefix)
var body bytes.Buffer
tw := tar.NewWriter(&body)
objects := make([]struct {
name string
trusted bool
}, 0, 32)
for i := 0; i < 16; i++ {
for _, entry := range []struct {
prefix string
trusted bool
}{
{prefix: allowedPrefix, trusted: true},
{prefix: deniedPrefix},
} {
name := entry.prefix + strconv.Itoa(i)
data := []byte("snowball replication trust " + name)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(data); err != nil {
t.Fatal(err)
}
objects = append(objects, struct {
name string
trusted bool
}{name: name, trusted: entry.trusted})
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
headers := map[string]string{
xhttp.AmzSnowballExtract: "true",
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.MinIOSourceETag: sourceETag,
}
for _, test := range []struct {
name string
trailer bool
}{
{name: "signed-v4"},
{name: "streaming-unsigned-trailer", trailer: true},
} {
t.Run(test.name, func(t *testing.T) {
var req *http.Request
var err error
if test.trailer {
req, err = newStreamingUnsignedTrailerRequest(http.MethodPut,
getPutObjectURL("", bucketName, "snowball.tar"), body.Bytes(), UTCNow())
if err == nil {
for name, value := range headers {
req.Header.Set(name, value)
}
err = signRequestV4(req, creds.AccessKey, creds.SecretKey)
}
} else {
req, err = newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, "snowball.tar"),
int64(body.Len()), bytes.NewReader(body.Bytes()), creds.AccessKey, creds.SecretKey, headers)
}
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: Snowball PUT status %d: %s", instanceType, rec.Code, rec.Body.String())
}
for _, object := range objects {
info, err := obj.GetObjectInfo(t.Context(), bucketName, object.name, ObjectOptions{})
if err != nil {
t.Fatalf("%s: get %s: %v", instanceType, object.name, err)
}
if object.trusted && info.ETag != sourceETag {
t.Errorf("%s: trusted entry %s ETag = %q, want source ETag", instanceType, object.name, info.ETag)
}
if !object.trusted && info.ETag == sourceETag {
t.Errorf("%s: untrusted entry %s preserved source ETag", instanceType, object.name)
}
}
})
}
}
func TestAPISnowballInheritsBucketEncryption(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPISnowballInheritsBucketEncryption,
})
}
func testAPISnowballInheritsBucketEncryption(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousKMS := GlobalKMS
GlobalKMS = kms.NewStub("snowball-default-encryption")
defer func() { GlobalKMS = previousKMS }()
sseXML := []byte(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketSSEConfig, sseXML); err != nil {
t.Fatalf("%s: configure bucket encryption: %v", instanceType, err)
}
var body bytes.Buffer
tw := tar.NewWriter(&body)
objects := []string{"encrypted/one", "encrypted/two"}
for _, object := range objects {
data := []byte("snowball bucket encryption " + object)
if err := tw.WriteHeader(&tar.Header{Name: object, Mode: 0o600, Size: int64(len(data))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(data); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, "encrypted-snowball.tar"),
int64(body.Len()), bytes.NewReader(body.Bytes()), credentials.AccessKey, credentials.SecretKey,
map[string]string{xhttp.AmzSnowballExtract: "true"})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: Snowball PUT status %d: %s", instanceType, rec.Code, rec.Body.String())
}
for _, object := range objects {
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatalf("%s: get %s: %v", instanceType, object, err)
}
if _, encrypted := crypto.IsEncrypted(info.UserDefined); !encrypted {
t.Errorf("%s: extracted entry %s did not inherit bucket encryption", instanceType, object)
}
}
}
func newSnowballReplicationTrustUser(t *testing.T, instanceType, bucketName, allowedPrefix string) auth.Credentials {
t.Helper()
ctx := t.Context()
accessKey, secretKey, err := auth.GenerateCredentials()
if err != nil {
t.Fatalf("%s: generate credentials: %v", instanceType, err)
}
creds := auth.Credentials{AccessKey: accessKey, SecretKey: secretKey}
if _, err = globalIAMSys.CreateUser(ctx, creds.AccessKey, madmin.AddOrUpdateUserReq{
SecretKey: creds.SecretKey,
Status: madmin.AccountEnabled,
}); err != nil {
t.Fatalf("%s: create Snowball user: %v", instanceType, err)
}
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": ["arn:aws:s3:::` + bucketName + `/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ReplicateObject"],
"Resource": ["arn:aws:s3:::` + bucketName + `/` + allowedPrefix + `*"]
}
]
}`
parsed, err := policy.ParseConfig(strings.NewReader(policyJSON))
if err != nil {
t.Fatalf("%s: parse Snowball policy: %v", instanceType, err)
}
policyName := "snowball-replication-trust-" + mustGetUUID()
if _, err = globalIAMSys.SetPolicy(ctx, policyName, *parsed); err != nil {
t.Fatalf("%s: install Snowball policy: %v", instanceType, err)
}
if _, err = globalIAMSys.PolicyDBSet(ctx, creds.AccessKey, policyName, regUser, false); err != nil {
t.Fatalf("%s: attach Snowball policy: %v", instanceType, err)
}
return creds
}
func TestAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectMarkerOnlyDoesNotCopyCiphertext,
})
}
func testAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
key := bytes.Repeat([]byte{0x57}, 32)
keyMD5 := md5.Sum(key)
data := bytes.Repeat([]byte("copy marker-only plaintext "), 256)
sseHeaders := map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
srcObject := "replication-trust/copy-ssec-source"
dstObject := "replication-trust/copy-marker-only"
putCopyChecksumSource(t, apiRouter, credentials, bucketName, srcObject, data, sseHeaders)
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName,
`"s3:GetObject","s3:PutObject","s3:ReplicateObject"`)
headers := map[string]string{
xhttp.AmzCopySource: url.QueryEscape(SlashSeparator + bucketName + SlashSeparator + srcObject),
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, dstObject), 0, nil,
replicator.AccessKey, replicator.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: CopyObject status %d: %s", instanceType, rec.Code, rec.Body.String())
}
assertObjectContents(t, obj, bucketName, dstObject, data)
}
func TestAPIDeleteObjectReplicationTrust(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPIDeleteObjectReplicationTrust,
})
}
func testAPIDeleteObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, _ auth.Credentials, t *testing.T,
) {
deleteOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:DeleteObject"`)
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:DeleteObject","s3:ReplicateDelete"`)
payload := []byte("delete replication trust")
put := func(t *testing.T, object string) {
t.Helper()
if _, err := obj.PutObject(t.Context(), bucketName, object,
mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), ObjectOptions{}); err != nil {
t.Fatal(err)
}
}
remove := func(t *testing.T, object string, creds auth.Credentials, versionID string, deleteMarker, check bool) *httptest.ResponseRecorder {
t.Helper()
headers := map[string]string{
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.AmzBucketReplicationStatus: "REPLICA",
}
if deleteMarker {
headers[xhttp.MinIOSourceDeleteMarker] = "true"
}
if check {
headers[xhttp.MinIOSourceReplicationCheck] = "true"
}
target := getDeleteObjectURL("", bucketName, object)
if versionID != "" {
target += "?" + url.Values{xhttp.VersionID: {versionID}}.Encode()
}
req, err := newTestSignedRequestV4(http.MethodDelete, target,
0, nil, creds.AccessKey, creds.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
return rec
}
t.Run("replica status without ReplicateDelete is denied", func(t *testing.T) {
object := "replication-trust/delete-denied"
put(t, object)
if rec := remove(t, object, deleteOnly, "", false, false); rec.Code != http.StatusForbidden {
t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String())
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err != nil {
t.Fatalf("denied delete removed object: %v", err)
}
})
t.Run("trusted replica delete remains supported", func(t *testing.T) {
object := "replication-trust/delete-allowed"
put(t, object)
if rec := remove(t, object, replicator, "", false, false); rec.Code != http.StatusNoContent {
t.Fatalf("status %d, want 204: %s", rec.Code, rec.Body.String())
}
})
for _, shape := range []struct {
name string
deleteMarker bool
}{
{name: "delete-marker", deleteMarker: true},
{name: "version-purge"},
} {
t.Run("validity check/"+shape.name, func(t *testing.T) {
for _, test := range []struct {
name string
creds auth.Credentials
wantStatus int
}{
{name: "requires ReplicateDelete", creds: deleteOnly, wantStatus: http.StatusForbidden},
{name: "succeeds for replicator", creds: replicator, wantStatus: http.StatusBadRequest},
} {
t.Run(test.name, func(t *testing.T) {
object := "replication-trust/delete-check-" + shape.name + "-" + strconv.Itoa(test.wantStatus)
put(t, object)
if rec := remove(t, object, test.creds, mustGetUUID(), shape.deleteMarker, true); rec.Code != test.wantStatus {
t.Fatalf("status %d, want %d: %s", rec.Code, test.wantStatus, rec.Body.String())
}
if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err != nil {
t.Fatalf("replication validity check removed object: %v", err)
}
})
}
})
}
}
func TestAPISSECMultipartReplicationTrust(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPISSECMultipartReplicationTrust,
})
}
func testAPISSECMultipartReplicationTrust(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject"`)
key := bytes.Repeat([]byte{0x42}, 32)
keyMD5 := md5.Sum(key)
data := bytes.Repeat([]byte("trusted multipart replication "), 4096)
sseHeaders := map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
// A marker alone must not let an ordinary writer upload raw bytes into an
// SSE-C multipart upload without presenting the customer key.
fakeObject := "replication-trust/ssec-multipart-fake"
fakeNewReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, fakeObject),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
fakeNewRec := httptest.NewRecorder()
apiRouter.ServeHTTP(fakeNewRec, fakeNewReq)
if fakeNewRec.Code != http.StatusOK {
t.Fatalf("fake-path NewMultipart status %d: %s", fakeNewRec.Code, fakeNewRec.Body.String())
}
var fakeInit InitiateMultipartUploadResponse
if err = xmlDecoder(fakeNewRec.Body, &fakeInit, int64(fakeNewRec.Body.Len())); err != nil {
t.Fatal(err)
}
fakePartReq, err := newTestSignedRequestV4(http.MethodPut,
getPutObjectPartURL("", bucketName, fakeObject, fakeInit.UploadID, "1"), int64(len(data)), bytes.NewReader(data),
putOnly.AccessKey, putOnly.SecretKey, map[string]string{xhttp.MinIOSourceReplicationRequest: "true"})
if err != nil {
t.Fatal(err)
}
fakePartRec := httptest.NewRecorder()
apiRouter.ServeHTTP(fakePartRec, fakePartReq)
if fakePartRec.Code != http.StatusBadRequest {
t.Fatalf("fake marker PutPart status %d, want 400: %s", fakePartRec.Code, fakePartRec.Body.String())
}
object := "replication-trust/ssec-multipart"
// Create the source as a real SSE-C multipart object so the encrypted part
// layout and metadata match what the replication worker reads.
newReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
newRec := httptest.NewRecorder()
apiRouter.ServeHTTP(newRec, newReq)
if newRec.Code != http.StatusOK {
t.Fatalf("source NewMultipart status %d: %s", newRec.Code, newRec.Body.String())
}
var sourceInit InitiateMultipartUploadResponse
if err = xmlDecoder(newRec.Body, &sourceInit, int64(newRec.Body.Len())); err != nil {
t.Fatal(err)
}
partReq, err := newTestSignedRequestV4(http.MethodPut,
getPutObjectPartURL("", bucketName, object, sourceInit.UploadID, "1"), int64(len(data)), bytes.NewReader(data),
credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
partRec := httptest.NewRecorder()
apiRouter.ServeHTTP(partRec, partReq)
if partRec.Code != http.StatusOK {
t.Fatalf("source PutPart status %d: %s", partRec.Code, partRec.Body.String())
}
sourcePartETag := canonicalizeETag(partRec.Header()[xhttp.ETag][0])
sourceCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: sourcePartETag}}})
if err != nil {
t.Fatal(err)
}
completeReq, err := newTestSignedRequestV4(http.MethodPost,
getCompleteMultipartUploadURL("", bucketName, object, sourceInit.UploadID), int64(len(sourceCompleteBody)),
bytes.NewReader(sourceCompleteBody), credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
completeRec := httptest.NewRecorder()
apiRouter.ServeHTTP(completeRec, completeReq)
if completeRec.Code != http.StatusOK {
t.Fatalf("source Complete status %d: %s", completeRec.Code, completeRec.Body.String())
}
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{}, ObjectOptions{ReplicationRequest: true})
if err != nil {
t.Fatal(err)
}
sourceInfo := gr.ObjInfo
rawPart, err := io.ReadAll(gr)
gr.Close()
if err != nil {
t.Fatal(err)
}
if len(rawPart) == 0 || bytes.Equal(rawPart, data) {
t.Fatal("source replication read did not return encrypted bytes")
}
replicationOpts, isMP, err := putReplicationOpts(t.Context(), "", sourceInfo)
if err != nil {
t.Fatal(err)
}
if !isMP {
t.Fatal("SSE-C multipart source was not recognized as multipart")
}
replicationOpts.Internal.SourceMTime = time.Time{}
replicationHeaders := make(map[string]string)
for name, values := range replicationOpts.Header() {
if len(values) > 0 {
replicationHeaders[name] = values[0]
}
}
// Start the destination upload over the same key. The existing object stays
// readable until Complete, so buffering rawPart above mirrors a remote peer.
replNewReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
0, nil, replicator.AccessKey, replicator.SecretKey, replicationHeaders)
if err != nil {
t.Fatal(err)
}
replNewRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replNewRec, replNewReq)
if replNewRec.Code != http.StatusOK {
t.Fatalf("replica NewMultipart status %d: %s", replNewRec.Code, replNewRec.Body.String())
}
var replicaInit InitiateMultipartUploadResponse
if err = xmlDecoder(replNewRec.Body, &replicaInit, int64(replNewRec.Body.Len())); err != nil {
t.Fatal(err)
}
replPartHeaders := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}
replPartReq, err := newTestSignedRequestV4(http.MethodPut,
getPutObjectPartURL("", bucketName, object, replicaInit.UploadID, "1"), int64(len(rawPart)), bytes.NewReader(rawPart),
replicator.AccessKey, replicator.SecretKey, replPartHeaders)
if err != nil {
t.Fatal(err)
}
replPartRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replPartRec, replPartReq)
if replPartRec.Code != http.StatusOK {
t.Fatalf("replica PutPart status %d: %s", replPartRec.Code, replPartRec.Body.String())
}
replPartETag := canonicalizeETag(replPartRec.Header()[xhttp.ETag][0])
replCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: replPartETag}}})
if err != nil {
t.Fatal(err)
}
actualSize, err := sourceInfo.GetActualSize()
if err != nil {
t.Fatal(err)
}
replCompleteHeaders := map[string]string{
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.MinIOSourceMTime: sourceInfo.ModTime.Format(time.RFC3339Nano),
xhttp.MinIOSourceETag: sourceInfo.ETag,
xhttp.MinIOReplicationActualObjectSize: strconv.FormatInt(actualSize, 10),
}
replCompleteReq, err := newTestSignedRequestV4(http.MethodPost,
getCompleteMultipartUploadURL("", bucketName, object, replicaInit.UploadID), int64(len(replCompleteBody)),
bytes.NewReader(replCompleteBody), replicator.AccessKey, replicator.SecretKey, replCompleteHeaders)
if err != nil {
t.Fatal(err)
}
replCompleteRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replCompleteRec, replCompleteReq)
if replCompleteRec.Code != http.StatusOK {
t.Fatalf("replica Complete status %d: %s", replCompleteRec.Code, replCompleteRec.Body.String())
}
getReq, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
getRec := httptest.NewRecorder()
apiRouter.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET replicated object status %d: %s", getRec.Code, getRec.Body.String())
}
if !bytes.Equal(getRec.Body.Bytes(), data) {
t.Fatal("replicated SSE-C multipart object did not decrypt to source plaintext")
}
// A replicated SSE-C part is written as raw ciphertext, so its stored
// ActualSize is the ciphertext length. GetObjectAttributes must still
// report the part's plaintext length, which for this single-part object
// is the whole object size.
attributes := attributesPartsFetch(t, apiRouter, credentials, bucketName, object, sseHeaders)
if len(attributes.ObjectParts.Parts) != 1 {
t.Fatalf("replica attributes part count %d, want 1", len(attributes.ObjectParts.Parts))
}
if got := attributes.ObjectParts.Parts[0].Size; got != int64(len(data)) {
t.Errorf("replica attributes part 1 size=%d, want plaintext size %d", got, len(data))
}
if attributes.ObjectSize != int64(len(data)) {
t.Errorf("replica attributes ObjectSize=%d, want %d", attributes.ObjectSize, len(data))
}
if attributes.ObjectParts.Parts[0].Size != attributes.ObjectSize {
t.Errorf("replica attributes part 1 size=%d does not match ObjectSize=%d",
attributes.ObjectParts.Parts[0].Size, attributes.ObjectSize)
}
}
// TestAPIStreamingTrailerWithUntrustedReplicationHeaders verifies that a
// request which does not earn replication trust is still processed as an
// ordinary upload. The streaming body reader fills the original request's
// trailer while the handler continues with a header-stripped clone, so the
// trailing checksum must remain visible through that clone.
func TestAPIStreamingTrailerWithUntrustedReplicationHeaders(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIStreamingTrailerWithUntrustedReplicationHeaders})
}
func testAPIStreamingTrailerWithUntrustedReplicationHeaders(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, _ auth.Credentials, t *testing.T) {
putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject"`)
payload := bytes.Repeat([]byte("trailer probe "), 4096)
send := func(targetURL string) *httptest.ResponseRecorder {
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, targetURL, payload, UTCNow())
if err != nil {
t.Fatal(err)
}
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
req.Header.Set(xhttp.MinIOSourceETag, "forged-etag")
if err := signRequestV4(req, putOnly.AccessKey, putOnly.SecretKey); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
return rec
}
if rec := send(getPutObjectURL("", bucketName, "trailer-object")); rec.Code != http.StatusOK {
t.Fatalf("%s: PutObject status %d: %s", instanceType, rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, "trailer-object", ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if info.Size != int64(len(payload)) || info.ETag == "forged-etag" {
t.Fatalf("%s: stored size %d etag %q, want %d bytes with a computed etag", instanceType, info.Size, info.ETag, len(payload))
}
upload, err := obj.NewMultipartUpload(t.Context(), bucketName, "trailer-multipart", ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if rec := send(getPutObjectPartURL("", bucketName, "trailer-multipart", upload.UploadID, "1")); rec.Code != http.StatusOK {
t.Fatalf("%s: PutObjectPart status %d: %s", instanceType, rec.Code, rec.Body.String())
}
parts, err := obj.ListObjectParts(t.Context(), bucketName, "trailer-multipart", upload.UploadID, 0, 10, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if len(parts.Parts) != 1 || parts.Parts[0].Size != int64(len(payload)) {
t.Fatalf("%s: uploaded parts %+v, want one part of %d bytes", instanceType, parts.Parts, len(payload))
}
}
// TestAPICopyObjectReplicaLegalHoldTimestamp verifies that a replicated legal
// hold update records its own timestamp under the legal-hold key: a replica
// that arrives later with an older timestamp must not change the hold, the
// retention timestamp must stay untouched, and a newer replica still applies.
func TestAPICopyObjectReplicaLegalHoldTimestamp(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaLegalHoldTimestamp,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaLegalHoldTimestamp(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, _ auth.Credentials, t *testing.T,
) {
object := "replication-trust/legal-hold"
if _, err := obj.PutObject(t.Context(), bucketName, object, mustGetPutObjReader(t, bytes.NewReader([]byte("held")), 4, "", ""), ObjectOptions{}); err != nil {
t.Fatal(err)
}
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName,
`"s3:GetObject","s3:PutObject","s3:ReplicateObject","s3:PutObjectLegalHold","s3:GetObjectLegalHold","s3:GetObjectRetention"`)
apply := func(status, stamp string) {
t.Helper()
headers := map[string]string{
xhttp.AmzCopySource: url.QueryEscape(SlashSeparator + bucketName + SlashSeparator + object),
xhttp.AmzMetadataDirective: replaceDirective,
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.AmzBucketReplicationStatus: "REPLICA",
xhttp.AmzObjectLockLegalHold: status,
xhttp.MinIOSourceObjectLegalHoldTimestamp: stamp,
}
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, object), 0, nil,
replicator.AccessKey, replicator.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: replica CopyObject legal hold %s @ %s: status %d: %s", instanceType, status, stamp, rec.Code, rec.Body.String())
}
}
state := func() (hold, holdStamp string, hasRetentionStamp bool) {
t.Helper()
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
_, hasRetentionStamp = info.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp]
return info.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)], info.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp], hasRetentionStamp
}
apply("ON", "2026-09-03T10:00:00Z")
apply("OFF", "2026-09-03T09:00:00Z") // stale replica: must be ignored
if hold, stamp, retention := state(); hold != "ON" || stamp != "2026-09-03T10:00:00Z" || retention {
t.Fatalf("%s: after stale OFF: hold=%q legal-hold timestamp=%q retention timestamp present=%v", instanceType, hold, stamp, retention)
}
apply("OFF", "2026-09-03T11:00:00Z") // newer replica: applies
if hold, stamp, retention := state(); hold != "OFF" || stamp != "2026-09-03T11:00:00Z" || retention {
t.Fatalf("%s: after newer OFF: hold=%q legal-hold timestamp=%q retention timestamp present=%v", instanceType, hold, stamp, retention)
}
}
// Object Lock replication ordering fixtures. A replicated lock update carries
// the source value plus the reserved timestamp that orders it; a removal is an
// update that carries the ordering timestamp and no value.
const (
objectLockTestRetainUntil = "2030-01-01T00:00:00Z"
objectLockTestStamp0900 = "2026-09-03T09:00:00Z"
objectLockTestStamp0930 = "2026-09-03T09:30:00Z"
objectLockTestStamp1000 = "2026-09-03T10:00:00Z"
objectLockTestStamp1100 = "2026-09-03T11:00:00Z"
)
// objectLockFields is the Object Lock state stored on an object version,
// including the reserved timestamps that order replicated updates.
type objectLockFields struct {
mode, retainUntil, retentionStamp string
legalHold, legalHoldStamp string
}
// putObjectLockVersion seeds a versioned object carrying meta and returns its
// version id.
func putObjectLockVersion(t *testing.T, obj ObjectLayer, bucket, object string, meta map[string]string) string {
t.Helper()
info, err := obj.PutObject(t.Context(), bucket, object,
mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""),
ObjectOptions{Versioned: true, UserDefined: meta})
if err != nil {
t.Fatal(err)
}
return info.VersionID
}
// readObjectLockFields returns the Object Lock state stored on a version.
func readObjectLockFields(t *testing.T, obj ObjectLayer, bucket, object, versionID string) objectLockFields {
t.Helper()
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: versionID})
if err != nil {
t.Fatal(err)
}
return objectLockFields{
mode: info.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)],
retainUntil: info.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)],
retentionStamp: info.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp],
legalHold: info.UserDefined[strings.ToLower(xhttp.AmzObjectLockLegalHold)],
legalHoldStamp: info.UserDefined[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp],
}
}
// sendReplicaLockCopy issues the signed CopyObject a replication sender emits
// for a metadata update: the version copied onto itself with the REPLACE
// tagging directive, the trusted replication headers, and extra on top. It
// fails the test unless every empty-valued header survived onto the wire and
// the handler accepted the request.
func sendReplicaLockCopy(t *testing.T, apiRouter http.Handler, cred auth.Credentials, bucket, object, versionID string, extra map[string]string) {
t.Helper()
headers := map[string]string{
xhttp.AmzCopySource: url.QueryEscape(SlashSeparator+bucket+SlashSeparator+object) + "?versionId=" + versionID,
xhttp.AmzTagDirective: replaceDirective,
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.AmzBucketReplicationStatus: "REPLICA",
}
for key, value := range extra {
headers[key] = value
}
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucket, object)+"?versionId="+versionID, 0, nil,
cred.AccessKey, cred.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
for key, value := range headers {
if _, ok := req.Header[http.CanonicalHeaderKey(key)]; value == "" && !ok {
t.Fatalf("empty %s header was dropped before the handler", key)
}
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("replica CopyObject: status %d: %s", rec.Code, rec.Body.String())
}
}
// TestAPICopyObjectReplicaAbsentLockFieldsPreserveNewerState verifies that a
// replica CopyObject carrying no Object Lock values leaves the stored retention
// and legal hold alone when it does not win: its source retention timestamp is
// older than the stored one, and it carries no legal-hold update at all. A
// missing value is not on its own an instruction to erase.
func TestAPICopyObjectReplicaAbsentLockFieldsPreserveNewerState(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaAbsentLockFieldsPreserveNewerState,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaAbsentLockFieldsPreserveNewerState(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
object := "replication-trust/lock-absent-fields"
versionID := putObjectLockVersion(t, obj, bucketName, object, map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "GOVERNANCE",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): objectLockTestRetainUntil,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: objectLockTestStamp1000,
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
})
// A tag-only replica update: stale retention ordering, and no legal-hold
// update at all.
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, map[string]string{
xhttp.AmzObjectTagging: "application=independent-tag-update",
xhttp.MinIOSourceTaggingTimestamp: objectLockTestStamp1100,
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp0930,
})
want := objectLockFields{
mode: "GOVERNANCE", retainUntil: objectLockTestRetainUntil, retentionStamp: objectLockTestStamp1000,
legalHold: "ON", legalHoldStamp: objectLockTestStamp1000,
}
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: replica update without Object Lock values changed stored state: got %+v, want %+v", instanceType, got, want)
}
}
// TestAPICopyObjectReplicaRetentionRemovalKeepsOrderingTimestamp verifies that
// a replicated retention removal both applies and records its own ordering
// timestamp, so a retained update that arrives later with an older timestamp
// cannot resurrect the retention it removed.
func TestAPICopyObjectReplicaRetentionRemovalKeepsOrderingTimestamp(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaRetentionRemovalKeepsOrderingTimestamp,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaRetentionRemovalKeepsOrderingTimestamp(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
object := "replication-trust/lock-retention-removal"
versionID := putObjectLockVersion(t, obj, bucketName, object, map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "GOVERNANCE",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): objectLockTestRetainUntil,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: objectLockTestStamp0900,
})
// The removal is newer than the stored retention, so it applies.
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, map[string]string{
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp1000,
})
want := objectLockFields{retentionStamp: objectLockTestStamp1000}
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: after replica retention removal: got %+v, want %+v", instanceType, got, want)
}
// A retained update that arrives afterwards with an older source timestamp
// must lose, and the removal timestamp must survive the rejection.
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, map[string]string{
xhttp.AmzObjectLockMode: "GOVERNANCE",
xhttp.AmzObjectLockRetainUntilDate: objectLockTestRetainUntil,
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp0930,
})
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: after stale retained replica update: got %+v, want %+v", instanceType, got, want)
}
}
// TestAPICopyObjectReplicaObjectLockOrdering covers the replica lock shapes the
// two regression tests above do not reach: the present-but-empty retention pair
// a sender emits after a retention removal, the REPLACE metadata directive
// under which only the restore helpers can carry stored timestamps forward, and
// an orphaned legal-hold timestamp arriving without a status.
func TestAPICopyObjectReplicaObjectLockOrdering(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaObjectLockOrdering,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaObjectLockOrdering(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
retained := func(stamp string) map[string]string {
return map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "GOVERNANCE",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): objectLockTestRetainUntil,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: stamp,
}
}
// The shape PutObjectRetention leaves behind after a removal: the public
// keys present but empty, with the ordering timestamp of the removal.
removedUnderHold := map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): "",
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: objectLockTestStamp1000,
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
}
// A stale retained update carrying an orphaned newer legal-hold timestamp.
staleRetentionOrphanedHold := map[string]string{
xhttp.AmzObjectLockMode: "GOVERNANCE",
xhttp.AmzObjectLockRetainUntilDate: objectLockTestRetainUntil,
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp0930,
xhttp.MinIOSourceObjectLegalHoldTimestamp: objectLockTestStamp1100,
}
testCases := []struct {
name string
stored map[string]string
headers map[string]string
replaceMetadata bool
want objectLockFields
}{
{
// A newer present-empty removal applies and keeps its timestamp,
// exactly as the absent-header shape does.
name: "present-empty-retention-newer",
stored: retained(objectLockTestStamp0900),
headers: map[string]string{
xhttp.AmzObjectLockMode: "",
xhttp.AmzObjectLockRetainUntilDate: "",
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp1000,
},
want: objectLockFields{retentionStamp: objectLockTestStamp1000},
},
{
// The same removal arriving late must not erase newer retention.
name: "present-empty-retention-stale",
stored: retained(objectLockTestStamp1000),
headers: map[string]string{
xhttp.AmzObjectLockMode: "",
xhttp.AmzObjectLockRetainUntilDate: "",
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp0930,
},
want: objectLockFields{
mode: "GOVERNANCE", retainUntil: objectLockTestRetainUntil, retentionStamp: objectLockTestStamp1000,
},
},
{
// REPLACE rebuilds the metadata from the request headers, which
// never carry the reserved timestamps, so the restore helpers are
// the only thing that can keep the removal timestamp and the hold.
name: "replace-directive-restores-stored-state",
stored: removedUnderHold,
headers: staleRetentionOrphanedHold,
replaceMetadata: true,
want: objectLockFields{
retentionStamp: objectLockTestStamp1000,
legalHold: "ON", legalHoldStamp: objectLockTestStamp1000,
},
},
{
// Under COPY the reserved timestamps ride along on their own, but
// an orphaned legal-hold timestamp still carries no status and must
// not clear the stored hold.
name: "copy-directive-orphaned-legal-hold-timestamp",
stored: removedUnderHold,
headers: staleRetentionOrphanedHold,
want: objectLockFields{
retentionStamp: objectLockTestStamp1000,
legalHold: "ON", legalHoldStamp: objectLockTestStamp1000,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
object := "replication-trust/lock-ordering/" + testCase.name
versionID := putObjectLockVersion(t, obj, bucketName, object, testCase.stored)
headers := testCase.headers
if testCase.replaceMetadata {
headers = make(map[string]string, len(testCase.headers)+1)
for key, value := range testCase.headers {
headers[key] = value
}
headers[xhttp.AmzMetadataDirective] = replaceDirective
}
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, headers)
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != testCase.want {
t.Fatalf("%s: got %+v, want %+v", instanceType, got, testCase.want)
}
})
}
}
// TestAPICopyObjectReplicaRetentionRemovalUnderBucketKMS verifies that the
// replication ordering timestamps reach the Object Lock decision when the
// destination bucket applies default SSE-KMS. That path builds its own
// ObjectOptions, and dropping the timestamps there would leave every
// replicated lock update unordered and silently restore the stored value.
func TestAPICopyObjectReplicaRetentionRemovalUnderBucketKMS(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaRetentionRemovalUnderBucketKMS,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaRetentionRemovalUnderBucketKMS(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
previousKMS := GlobalKMS
GlobalKMS = kms.NewStub("object-lock-replication")
defer func() { GlobalKMS = previousKMS }()
sseXML := []byte(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>aws:kms</SSEAlgorithm><KMSMasterKeyID>object-lock-replication</KMSMasterKeyID></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketSSEConfig, sseXML); err != nil {
t.Fatalf("%s: configure bucket encryption: %v", instanceType, err)
}
object := "replication-trust/lock-kms-removal"
versionID := putObjectLockVersion(t, obj, bucketName, object, map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "GOVERNANCE",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): objectLockTestRetainUntil,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: objectLockTestStamp0900,
})
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, map[string]string{
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp1000,
})
want := objectLockFields{retentionStamp: objectLockTestStamp1000}
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: replica retention removal into an SSE-KMS bucket: got %+v, want %+v", instanceType, got, want)
}
}
// ssecKeyHeaders returns the SSE-C request headers for key, either as the
// destination key or as the copy-source key.
func ssecKeyHeaders(key []byte, copySource bool) map[string]string {
sum := md5.Sum(key)
encoded, digest := base64.StdEncoding.EncodeToString(key), base64.StdEncoding.EncodeToString(sum[:])
if copySource {
return map[string]string{
xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCopyCustomerKey: encoded,
xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: digest,
}
}
return map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: encoded,
xhttp.AmzServerSideEncryptionCustomerKeyMD5: digest,
}
}
// TestAPICopyObjectReplicaLockTimestampSurvivesSSECKeyRotation verifies that a
// replicated Object Lock decision survives an in-place SSE-C key rotation. That
// path snapshots the stored reserved metadata before the decision is made and
// merges it back afterwards to preserve the encryption headers, which would
// otherwise reinstate the ordering timestamp the decision replaced and let a
// stale retained update resurrect a removed retention.
func TestAPICopyObjectReplicaLockTimestampSurvivesSSECKeyRotation(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectReplicaLockTimestampSurvivesSSECKeyRotation,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectReplicaLockTimestampSurvivesSSECKeyRotation(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
keyA, keyB := bytes.Repeat([]byte{0x11}, 32), bytes.Repeat([]byte{0x22}, 32)
keyC, keyD := bytes.Repeat([]byte{0x33}, 32), bytes.Repeat([]byte{0x44}, 32)
object := "replication-trust/lock-ssec-rotation"
putCopyChecksumSource(t, apiRouter, cred, bucketName, object,
bytes.Repeat([]byte("object lock ssec rotation "), 16), ssecKeyHeaders(keyA, false))
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
versionID := info.VersionID
rotate := func(oldKey, newKey []byte, extra map[string]string) {
t.Helper()
headers := ssecKeyHeaders(oldKey, true)
for key, value := range ssecKeyHeaders(newKey, false) {
headers[key] = value
}
for key, value := range extra {
headers[key] = value
}
sendReplicaLockCopy(t, apiRouter, cred, bucketName, object, versionID, headers)
}
// Replicate a retention, then its removal, each during a key rotation.
rotate(keyA, keyB, map[string]string{
xhttp.AmzObjectLockMode: "GOVERNANCE",
xhttp.AmzObjectLockRetainUntilDate: objectLockTestRetainUntil,
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp1000,
})
rotate(keyB, keyC, map[string]string{
xhttp.MinIOSourceObjectRetentionTimestamp: objectLockTestStamp1100,
})
want := objectLockFields{retentionStamp: objectLockTestStamp1100}
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: after replicated removal during key rotation: got %+v, want %+v", instanceType, got, want)
}
// The stale retained update that follows must still lose the comparison.
rotate(keyC, keyD, map[string]string{
xhttp.AmzObjectLockMode: "GOVERNANCE",
xhttp.AmzObjectLockRetainUntilDate: objectLockTestRetainUntil,
xhttp.MinIOSourceObjectRetentionTimestamp: "2026-09-03T10:30:00Z",
})
if got := readObjectLockFields(t, obj, bucketName, object, versionID); got != want {
t.Errorf("%s: after stale retained replay during key rotation: got %+v, want %+v", instanceType, got, want)
}
}
// TestAPICopyObjectMarkerOnlyLeavesObjectLockUnchanged verifies that the
// the new value-less handling reaches only an actual replica. A trusted peer that
// sends the replication marker without REPLICA status is not replicating lock
// state, so a REPLACE copy carrying no lock headers must write a version with
// no retention and no legal hold, exactly as it did before.
func TestAPICopyObjectMarkerOnlyLeavesObjectLockUnchanged(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPICopyObjectMarkerOnlyLeavesObjectLockUnchanged,
makeBucketOptions: MakeBucketOptions{LockEnabled: true},
})
}
func testAPICopyObjectMarkerOnlyLeavesObjectLockUnchanged(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, cred auth.Credentials, t *testing.T,
) {
object := "replication-trust/lock-marker-only"
putObjectLockVersion(t, obj, bucketName, object, map[string]string{
strings.ToLower(xhttp.AmzObjectLockMode): "GOVERNANCE",
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): objectLockTestRetainUntil,
ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: objectLockTestStamp1000,
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
})
// The replication marker without REPLICA status: trusted, but not a replica.
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, object), 0, nil,
cred.AccessKey, cred.SecretKey, map[string]string{
xhttp.AmzCopySource: url.QueryEscape(SlashSeparator + bucketName + SlashSeparator + object),
xhttp.AmzMetadataDirective: replaceDirective,
xhttp.MinIOSourceReplicationRequest: "true",
})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: marker-only CopyObject: status %d: %s", instanceType, rec.Code, rec.Body.String())
}
if got := readObjectLockFields(t, obj, bucketName, object, ""); got != (objectLockFields{}) {
t.Errorf("%s: marker-only copy inherited Object Lock state: got %+v, want none", instanceType, got)
}
}