mirror of
https://github.com/pgsty/minio.git
synced 2026-09-16 15:34:06 +03:00
fix(object): preserve retention and federated copy destination state
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
@@ -334,11 +334,10 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob
|
||||
return mode, retainDate, legalHold, ErrObjectLocked
|
||||
}
|
||||
|
||||
if !legalHoldRequested && retentionCfg.LockEnabled {
|
||||
// inherit retention from bucket configuration
|
||||
return retentionCfg.Mode, objectlock.RetentionDate{Time: t.Add(retentionCfg.Validity)}, legalHold, ErrNone
|
||||
}
|
||||
return "", objectlock.RetentionDate{}, legalHold, ErrNone
|
||||
// Inherit retention from the bucket configuration. A legal-hold header
|
||||
// on the same request, ON or OFF, is independent of retention and must
|
||||
// not suppress the default (#165).
|
||||
return retentionCfg.Mode, objectlock.RetentionDate{Time: t.Add(retentionCfg.Validity)}, legalHold, ErrNone
|
||||
}
|
||||
return mode, retainDate, legalHold, ErrNone
|
||||
}
|
||||
|
||||
@@ -18,13 +18,16 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
// enableBucketObjectLock puts a lock-enabled configuration on an existing
|
||||
@@ -47,9 +50,108 @@ func enableBucketObjectLock(t *testing.T, bucket string) {
|
||||
globalBucketMetadataSys.Set(bucket, updated)
|
||||
}
|
||||
|
||||
// Exercise #165 and #166 together, including hold OFF, default retention and
|
||||
// explicit subsecond retention. Ordinary copies must not inherit source locks.
|
||||
func TestAPIFederatedCopyObjectLockParity(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
endpoints: []string{"CopyObject", "PutObject", "HeadObject", "GetObject"},
|
||||
objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
testKMS, err := kms.NewBuiltin(federationTestKMSKeyID, bytes.Repeat([]byte{0x58}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = testKMS
|
||||
defer func() { GlobalKMS = previousKMS }()
|
||||
remoteBucket, capture, cleanup := setupCopyObjectFederation(t, obj, router, instanceType, bucket)
|
||||
defer cleanup()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
until := UTCNow().Add(7 * 24 * time.Hour).Truncate(time.Second).Add(789 * time.Millisecond).Format(time.RFC3339Nano)
|
||||
for _, sourceType := range []string{"plain", "s3"} {
|
||||
source := sourceType + "-held-source"
|
||||
headers := federationSSEHeaders(sourceType, 0, false)
|
||||
headers[xhttp.AmzObjectLockLegalHold] = "ON"
|
||||
putCopyChecksumSource(t, router, cred, bucket, source, []byte("held source"), headers)
|
||||
for _, tc := range []struct {
|
||||
name, hold, defaultMode, explicitMode string
|
||||
replace bool
|
||||
}{
|
||||
{name: "on", hold: "ON"},
|
||||
{name: "off", hold: "OFF"},
|
||||
{name: "no source inheritance"},
|
||||
{name: "on with default", hold: "ON", defaultMode: "GOVERNANCE"},
|
||||
{name: "off with default", hold: "OFF", defaultMode: "COMPLIANCE"},
|
||||
{name: "explicit retention", explicitMode: "GOVERNANCE"},
|
||||
{name: "hold and explicit retention", hold: "ON", explicitMode: "COMPLIANCE"},
|
||||
{name: "replace metadata", hold: "ON", explicitMode: "GOVERNANCE", replace: true},
|
||||
} {
|
||||
t.Run(instanceType+"/"+sourceType+"/"+tc.name, func(t *testing.T) {
|
||||
setTestBucketDefaultRetention(t, bucket, tc.defaultMode)
|
||||
setTestBucketDefaultRetention(t, remoteBucket, tc.defaultMode)
|
||||
headers := map[string]string{}
|
||||
if tc.hold != "" {
|
||||
headers[xhttp.AmzObjectLockLegalHold] = tc.hold
|
||||
}
|
||||
if tc.explicitMode != "" {
|
||||
headers[xhttp.AmzObjectLockMode] = tc.explicitMode
|
||||
headers[xhttp.AmzObjectLockRetainUntilDate] = until
|
||||
}
|
||||
if tc.replace {
|
||||
headers[xhttp.AmzMetadataDirective] = "REPLACE"
|
||||
headers["X-Amz-Meta-Origin"] = "replacement"
|
||||
}
|
||||
capture.mu.Lock()
|
||||
capture.headers = nil
|
||||
capture.mu.Unlock()
|
||||
for _, destinationBucket := range []string{bucket, remoteBucket} {
|
||||
destination := sourceType + "-copy-" + strings.ReplaceAll(tc.name, " ", "-")
|
||||
rec := federatedCopyRequest(t, router, cred, bucket, source, destinationBucket, destination, headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("copy to %s: %d %s", destinationBucket, rec.Code, rec.Body.String())
|
||||
}
|
||||
info, err := obj.GetObjectInfo(t.Context(), destinationBucket, destination, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := objectlock.GetObjectLegalHoldMeta(info.UserDefined).Status; string(got) != tc.hold {
|
||||
t.Errorf("stored hold = %q, want %q", got, tc.hold)
|
||||
}
|
||||
retention := objectlock.GetObjectRetentionMeta(info.UserDefined)
|
||||
mode := tc.explicitMode
|
||||
if mode == "" {
|
||||
mode = tc.defaultMode
|
||||
}
|
||||
if string(retention.Mode) != mode {
|
||||
t.Errorf("stored retention = %q, want %q", retention.Mode, mode)
|
||||
}
|
||||
if tc.explicitMode != "" && retention.RetainUntilDate.Format(time.RFC3339Nano) != until {
|
||||
t.Errorf("retention date lost precision: %s, want %s", retention.RetainUntilDate, until)
|
||||
}
|
||||
for key := range info.UserDefined {
|
||||
if stringsHasPrefixFold(key, "X-Amz-Meta-X-Amz-Object-Lock-") {
|
||||
t.Errorf("lock state became user metadata: %s", key)
|
||||
}
|
||||
}
|
||||
if tc.replace && info.UserDefined["X-Amz-Meta-Origin"] != "replacement" {
|
||||
t.Errorf("replacement metadata was lost: %v", info.UserDefined)
|
||||
}
|
||||
}
|
||||
typed, asMetadata := capture.legalHoldHeaders()
|
||||
if len(asMetadata) != 0 || (tc.hold != "" && strings.Join(typed, "") != tc.hold) || (tc.hold == "" && len(typed) != 0) {
|
||||
t.Errorf("forwarded hold headers = %v, metadata = %v; want %q", typed, asMetadata, tc.hold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// legalHoldHeaders returns the forwarded legal-hold headers the remote saw,
|
||||
// separated into the real Object Lock header and the user-metadata spelling
|
||||
// minio-go produces for an unrecognised UserMetadata key.
|
||||
// minio-go produces for an unrecognized UserMetadata key.
|
||||
func (c *federationRemoteCapture) legalHoldHeaders() (typed, asMetadata []string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -72,7 +174,7 @@ func (c *federationRemoteCapture) legalHoldHeaders() (typed, asMetadata []string
|
||||
//
|
||||
// Before the fix the resolved hold was forwarded inside
|
||||
// PutObjectOptions.UserMetadata. minio-go's Header() prefixes every
|
||||
// UserMetadata key it does not recognise with "x-amz-meta-", and
|
||||
// UserMetadata key it does not recognize with "x-amz-meta-", and
|
||||
// x-amz-object-lock-legal-hold is in neither supportedHeaders nor isAmzHeader,
|
||||
// so the hold reached the remote as X-Amz-Meta-X-Amz-Object-Lock-Legal-Hold.
|
||||
// The destination stored no hold and the copy still answered 200 -- a silent
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
sse "github.com/minio/minio/internal/bucket/encryption"
|
||||
"github.com/minio/minio/internal/event"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/minio/minio/internal/pubsub"
|
||||
)
|
||||
|
||||
func TestAPIFederatedCopyObjectVersionAndEvent(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
endpoints: []string{"CopyObject", "PutObject", "GetObject", "HeadObject"},
|
||||
objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
testKMS, err := kms.NewBuiltin(federationTestKMSKeyID, bytes.Repeat([]byte{0x58}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = testKMS
|
||||
defer func() { GlobalKMS = previousKMS }()
|
||||
restore := setCopyChecksumCompression(true)
|
||||
defer restore()
|
||||
remoteBucket, _, cleanup := setupCopyObjectFederation(t, obj, router, instanceType, bucket)
|
||||
defer cleanup()
|
||||
enableBucketObjectLock(t, remoteBucket)
|
||||
events := make(chan event.Event, 8)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectCreatedCopy), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{"plain", "compressed", "encrypted"} {
|
||||
t.Run(instanceType+"/"+kind, func(t *testing.T) {
|
||||
data := []byte("logical object size")
|
||||
source := kind + "-source.bin"
|
||||
var headers map[string]string
|
||||
if kind == "compressed" {
|
||||
data = bytes.Repeat(data, 8192)
|
||||
source = kind + "-source.txt"
|
||||
}
|
||||
if kind == "encrypted" {
|
||||
headers = federationSSEHeaders("s3", 0, false)
|
||||
}
|
||||
putCopyChecksumSource(t, router, cred, bucket, source, data, headers)
|
||||
destination := "result/" + kind + " with space.bin"
|
||||
rec := federatedCopyRequest(t, router, cred, bucket, source, remoteBucket, destination, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("copy: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
versionID := strings.Join(rec.Header()[xhttp.AmzVersionID], "")
|
||||
if versionID == "" {
|
||||
t.Error("copy response omitted destination version ID")
|
||||
} else {
|
||||
info, err := obj.GetObjectInfo(t.Context(), remoteBucket, destination, ObjectOptions{VersionID: versionID})
|
||||
if err != nil || info.VersionID != versionID {
|
||||
t.Fatalf("response does not name the written version: %v, %q", err, info.VersionID)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case evt := <-events:
|
||||
key, err := url.QueryUnescape(evt.S3.Object.Key)
|
||||
if err != nil || key != destination || evt.S3.Bucket.Name != remoteBucket || evt.S3.Object.Size != int64(len(data)) || evt.S3.Object.VersionID == "" || evt.S3.Object.VersionID != versionID {
|
||||
t.Errorf("copy event does not describe the written object: %+v", evt.S3)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("copy event was not emitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIFederatedCopyObjectDestinationSSEDefaults(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
endpoints: []string{"CopyObject", "PutObject", "GetObject", "HeadObject"},
|
||||
objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
data := []byte("destination chooses default encryption")
|
||||
putCopyChecksumSource(t, router, cred, bucket, "source", data, nil)
|
||||
testKMS, err := kms.NewBuiltin(federationTestKMSKeyID, bytes.Repeat([]byte{0x58}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousKMS, previousAuto := GlobalKMS, globalAutoEncryption
|
||||
GlobalKMS, globalAutoEncryption = testKMS, true
|
||||
defer func() { GlobalKMS, globalAutoEncryption = previousKMS, previousAuto }()
|
||||
remoteBucket, capture, cleanup := setupCopyObjectFederationRemote(t, obj, router, instanceType, bucket, true)
|
||||
defer cleanup()
|
||||
destinationConfig, err := sse.ParseBucketSSEConfig(strings.NewReader(`<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>aws:kms</SSEAlgorithm><KMSMasterKeyID>destination-bucket-key</KMSMasterKeyID></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{"plain", "s3", "kms", "kms-default", "c"} {
|
||||
t.Run(instanceType+"/"+kind, func(t *testing.T) {
|
||||
var headers map[string]string
|
||||
if kind == "kms-default" {
|
||||
headers = map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS}
|
||||
} else {
|
||||
headers = federationSSEHeaders(kind, 0x22, false)
|
||||
}
|
||||
rec := federatedCopyRequest(t, router, cred, bucket, "source", remoteBucket, kind, headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("copy: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
capture.mu.Lock()
|
||||
forwarded := capture.headers[len(capture.headers)-1].Clone()
|
||||
capture.mu.Unlock()
|
||||
if kind == "plain" {
|
||||
if forwarded.Get(xhttp.AmzServerSideEncryption) != "" {
|
||||
t.Errorf("proxy injected SSE %q into a request with no client SSE", forwarded.Get(xhttp.AmzServerSideEncryption))
|
||||
}
|
||||
// With nothing injected, the KMS encryption the destination
|
||||
// stores can only be the remote applying its own defaults.
|
||||
info, err := obj.GetObjectInfo(t.Context(), remoteBucket, kind, ObjectOptions{})
|
||||
if err != nil || federationStoredSSE(info.UserDefined) != "kms" {
|
||||
t.Errorf("remote destination did not apply its own auto-encryption: %v, %v", err, info.UserDefined)
|
||||
}
|
||||
}
|
||||
// Replay the actual inbound headers against an independent bucket
|
||||
// configuration. No handler flips shared globals while serving.
|
||||
destinationConfig.Apply(forwarded, sse.ApplyOptions{})
|
||||
wantKey := headers[xhttp.AmzServerSideEncryptionKmsID]
|
||||
if kind == "plain" {
|
||||
wantKey = "destination-bucket-key"
|
||||
}
|
||||
if got := forwarded.Get(xhttp.AmzServerSideEncryptionKmsID); got != wantKey {
|
||||
t.Errorf("destination selected key %q, want %q", got, wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A local destination still inherits the server's auto-encryption.
|
||||
rec := federatedCopyRequest(t, router, cred, bucket, "source", bucket, "local-copy", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("local copy: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
info, err := obj.GetObjectInfo(t.Context(), bucket, "local-copy", ObjectOptions{})
|
||||
if err != nil || federationStoredSSE(info.UserDefined) != "kms" {
|
||||
t.Errorf("local copy lost auto-encryption: %v, %v", err, info.UserDefined)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
+44
-46
@@ -1404,11 +1404,27 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
allowReplicationMetadata := replicaTrusted
|
||||
|
||||
// Check if bucket encryption is enabled
|
||||
sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket)
|
||||
sseConfig.Apply(r.Header, sse.ApplyOptions{
|
||||
AutoEncrypt: globalAutoEncryption,
|
||||
})
|
||||
// Federation only: the destination bucket lives on another deployment and
|
||||
// the copy is forwarded to it as a PutObject. That remote write owns the
|
||||
// destination's storage transformations, so this handler hands it the
|
||||
// logical (decompressed, decrypted) bytes at their logical size and lets
|
||||
// the remote compress and encrypt once. Encrypting here as well would
|
||||
// forward ciphertext under the destination's own SSE option, which either
|
||||
// fails the length check or, for SSE to SSE, has the remote encrypt the
|
||||
// ciphertext a second time and store an unreadable object (#158).
|
||||
remoteCallRequired := isRemoteCopyRequired(ctx, srcBucket, dstBucket, objectAPI)
|
||||
|
||||
// Apply the destination bucket's default encryption only when this
|
||||
// deployment writes the destination. For a remote destination the proxy
|
||||
// has no authority over that bucket's defaults: applying its own here
|
||||
// would forward an explicit SSE header that the remote then honors in
|
||||
// place of the destination's configuration (#167).
|
||||
if !remoteCallRequired {
|
||||
sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket)
|
||||
sseConfig.Apply(r.Header, sse.ApplyOptions{
|
||||
AutoEncrypt: globalAutoEncryption,
|
||||
})
|
||||
}
|
||||
var srcOpts, dstOpts ObjectOptions
|
||||
srcOpts, err = copySrcOpts(ctx, r, srcBucket, srcObject)
|
||||
if err != nil {
|
||||
@@ -1518,16 +1534,6 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
// Federation only: the destination bucket lives on another deployment and
|
||||
// the copy is forwarded to it as a PutObject. That remote write owns the
|
||||
// destination's storage transformations, so this handler hands it the
|
||||
// logical (decompressed, decrypted) bytes at their logical size and lets
|
||||
// the remote compress and encrypt once. Encrypting here as well would
|
||||
// forward ciphertext under the destination's own SSE option, which either
|
||||
// fails the length check or, for SSE to SSE, has the remote encrypt the
|
||||
// ciphertext a second time and store an unreadable object (#158).
|
||||
remoteCallRequired := isRemoteCopyRequired(ctx, srcBucket, dstBucket, objectAPI)
|
||||
|
||||
var compressMetadata map[string]string
|
||||
// No need to compress for remote etcd calls
|
||||
// Pass the decompressed stream to such calls.
|
||||
@@ -1945,35 +1951,23 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
delete(srcInfo.UserDefined, k)
|
||||
}
|
||||
}
|
||||
// Legal hold does not survive the metadata map. minio-go's Header()
|
||||
// writes the typed lock fields first, then prefixes every UserMetadata
|
||||
// key it does not recognise with "x-amz-meta-": supportedHeaders lists
|
||||
// x-amz-object-lock-mode and x-amz-object-lock-retain-until-date but not
|
||||
// x-amz-object-lock-legal-hold, and isAmzHeader does not match it
|
||||
// either. Forwarded in the map the hold arrives as
|
||||
// X-Amz-Meta-X-Amz-Object-Lock-Legal-Hold, the destination stores no
|
||||
// hold, and the copy still answers 200 (#166).
|
||||
//
|
||||
// Carry only the hold on the typed field, and forward a clone without
|
||||
// the raw key: typed fields are written before the UserMetadata loop, so
|
||||
// a leftover raw key would add a bogus x-amz-meta- entry beside the
|
||||
// correct header. Retention stays in the map -- it already passes
|
||||
// through as a standard header, and moving it to the typed
|
||||
// RetainUntilDate field would truncate the date to whole seconds.
|
||||
// Forward a clone: request-only keys are removed or added below and
|
||||
// srcInfo.UserDefined stays the resolved record for the response.
|
||||
forwardedMeta := cloneMSS(srcInfo.UserDefined)
|
||||
// minio-go prefixes any UserMetadata key it does not recognize with
|
||||
// x-amz-meta-, and it recognizes the retention headers but not
|
||||
// x-amz-object-lock-legal-hold, so a hold left in the map reaches the
|
||||
// remote as user metadata and is silently dropped (#166). Carry it on
|
||||
// the typed option instead. Retention stays in the map: the typed
|
||||
// RetainUntilDate would truncate the date to whole seconds.
|
||||
legalHoldKey := strings.ToLower(xhttp.AmzObjectLockLegalHold)
|
||||
forwardedLegalHold := srcInfo.UserDefined[legalHoldKey]
|
||||
forwardedMeta := srcInfo.UserDefined
|
||||
if forwardedLegalHold != "" {
|
||||
forwardedMeta = cloneMSS(srcInfo.UserDefined)
|
||||
delete(forwardedMeta, legalHoldKey)
|
||||
}
|
||||
forwardedLegalHold := forwardedMeta[legalHoldKey]
|
||||
delete(forwardedMeta, legalHoldKey)
|
||||
opts := miniogo.PutObjectOptions{
|
||||
UserMetadata: forwardedMeta,
|
||||
ServerSideEncryption: dstOpts.ServerSideEncryption,
|
||||
UserTags: tag.ToMap(),
|
||||
}
|
||||
if forwardedLegalHold != "" {
|
||||
opts.LegalHold = miniogo.LegalHoldStatus(forwardedLegalHold)
|
||||
LegalHold: miniogo.LegalHoldStatus(forwardedLegalHold),
|
||||
}
|
||||
// The destination must carry the same checksum the local path would
|
||||
// produce; the federated path has the remote compute, validate, persist
|
||||
@@ -2021,14 +2015,18 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, rerr), r.URL)
|
||||
return
|
||||
}
|
||||
// Built from the resolved values rather than the forwarding map: the
|
||||
// legal hold was moved onto the typed option above, so opts.UserMetadata
|
||||
// no longer carries it and the response and event would under-report.
|
||||
// Keep the resolved legal hold in response and event metadata; the
|
||||
// request-only checksum header exists only in the forwarding map.
|
||||
objInfo.UserDefined = cloneMSS(srcInfo.UserDefined)
|
||||
// A forwarded checksum header is a request detail, not object metadata.
|
||||
if checksumHeaderValue != "" {
|
||||
delete(objInfo.UserDefined, wantChecksumType.Key())
|
||||
}
|
||||
// The response headers and the ObjectCreated:Copy event describe the
|
||||
// object this handler wrote, so name it: without these the response
|
||||
// carries no x-amz-version-id and the event has an empty key and zero
|
||||
// size (#170). Size is the logical size the remote was handed, which is
|
||||
// what it reports back.
|
||||
objInfo.Bucket = dstBucket
|
||||
objInfo.Name = dstObject
|
||||
objInfo.Size = actualSize
|
||||
objInfo.VersionID = remoteObjInfo.VersionID
|
||||
objInfo.ETag = remoteObjInfo.ETag
|
||||
objInfo.ModTime = remoteObjInfo.LastModified
|
||||
// Bind the checksum the remote computed for this exact write. A single
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func setTestBucketDefaultRetention(t *testing.T, bucket, mode string) {
|
||||
t.Helper()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.ObjectLockConfigXML = fmt.Appendf(nil, `<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>%s</Mode><Days>2</Days></DefaultRetention></Rule></ObjectLockConfiguration>`, mode)
|
||||
if err := meta.parseAllConfigs(t.Context(), newObjectLayerFn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
}
|
||||
|
||||
func TestAPIObjectLockDefaultRetentionWithLegalHold(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
endpoints: []string{"NewMultipart", "PutObjectPart", "CompleteMultipart", "CopyObject", "PutObject", "DeleteObject"},
|
||||
objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
data := []byte("default retention and legal hold are independent")
|
||||
putCopyChecksumSource(t, router, cred, bucket, "source", data, nil)
|
||||
for _, mode := range []string{"GOVERNANCE", "COMPLIANCE", ""} {
|
||||
setTestBucketDefaultRetention(t, bucket, mode)
|
||||
for _, hold := range []string{"ON", "OFF"} {
|
||||
for _, operation := range []string{"put", "copy", "multipart"} {
|
||||
t.Run(instanceType+"/"+mode+"/"+hold+"/"+operation, func(t *testing.T) {
|
||||
object := mode + "-" + hold + "-" + operation
|
||||
headers := map[string]string{xhttp.AmzObjectLockLegalHold: hold}
|
||||
// The stored retention date carries millisecond precision.
|
||||
before := UTCNow().Truncate(time.Millisecond)
|
||||
switch operation {
|
||||
case "put":
|
||||
putCopyChecksumSource(t, router, cred, bucket, object, data, headers)
|
||||
case "copy":
|
||||
rec := federatedCopyRequest(t, router, cred, bucket, "source", bucket, object, headers)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("copy: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
case "multipart":
|
||||
putFederationMultipartSource(t, router, cred, bucket, object, [][]byte{data}, headers)
|
||||
}
|
||||
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ret := objectlock.GetObjectRetentionMeta(info.UserDefined)
|
||||
if string(ret.Mode) != mode {
|
||||
t.Errorf("stored retention mode = %q, want %q", ret.Mode, mode)
|
||||
}
|
||||
if mode != "" && (ret.RetainUntilDate.Before(before.Add(48*time.Hour)) || ret.RetainUntilDate.After(UTCNow().Add(48*time.Hour))) {
|
||||
t.Errorf("default retention date = %s, want write time + 2 days", ret.RetainUntilDate)
|
||||
}
|
||||
if got := objectlock.GetObjectLegalHoldMeta(info.UserDefined).Status; string(got) != hold {
|
||||
t.Errorf("stored legal hold = %q, want %q", got, hold)
|
||||
}
|
||||
if mode == "COMPLIANCE" && hold == "OFF" {
|
||||
req, err := newTestSignedRequestV4(http.MethodDelete, getPutObjectURL("", bucket, object)+"?versionId="+info.VersionID, 0, nil, cred.AccessKey, cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "InvalidRequest") {
|
||||
t.Errorf("version DELETE must enforce retention: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectLockDefaultRetentionBoundaries(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
objAPITest: func(obj ObjectLayer, instanceType, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
setTestBucketDefaultRetention(t, bucket, "COMPLIANCE")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
replica, marker, explicit bool
|
||||
retentionErr, holdErr APIErrorCode
|
||||
wantMode objectlock.RetMode
|
||||
wantErr APIErrorCode
|
||||
}{
|
||||
{name: "trusted replica", replica: true},
|
||||
{name: "marker-only ordinary write", marker: true, wantMode: objectlock.RetCompliance},
|
||||
{name: "explicit retention", explicit: true, wantMode: objectlock.RetGovernance},
|
||||
{name: "retention permission denied", retentionErr: ErrAccessDenied, wantErr: ErrAccessDenied},
|
||||
{name: "legal hold permission denied", holdErr: ErrAccessDenied, wantErr: ErrAccessDenied},
|
||||
} {
|
||||
t.Run(instanceType+"/"+tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "http://minio.local/"+bucket+"/object", nil)
|
||||
r.Header.Set(xhttp.AmzObjectLockLegalHold, "OFF")
|
||||
if tc.marker {
|
||||
r.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||
}
|
||||
until := UTCNow().Add(24 * time.Hour).Truncate(time.Second).Add(789 * time.Millisecond)
|
||||
if tc.explicit {
|
||||
r.Header.Set(xhttp.AmzObjectLockMode, "GOVERNANCE")
|
||||
r.Header.Set(xhttp.AmzObjectLockRetainUntilDate, until.Format(time.RFC3339Nano))
|
||||
}
|
||||
mode, date, _, code := checkPutObjectLockAllowed(t.Context(), r, bucket, "object", obj.GetObjectInfo, tc.retentionErr, tc.holdErr, tc.replica)
|
||||
if code != tc.wantErr || mode != tc.wantMode {
|
||||
t.Fatalf("got mode %s error %s, want mode %s error %s", mode, niceError(code), tc.wantMode, niceError(tc.wantErr))
|
||||
}
|
||||
if tc.explicit && !date.Equal(until) {
|
||||
t.Errorf("explicit retention lost precision: %s != %s", date, until)
|
||||
}
|
||||
if tc.replica && !date.IsZero() {
|
||||
t.Errorf("replica acquired a destination default: %s", date)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user