diff --git a/cmd/bucket-object-lock.go b/cmd/bucket-object-lock.go index e5828e2dd..36211c45d 100644 --- a/cmd/bucket-object-lock.go +++ b/cmd/bucket-object-lock.go @@ -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 } diff --git a/cmd/object-copy-federation-legalhold_test.go b/cmd/object-copy-federation-legalhold_test.go index 44e3e5dc5..941719699 100644 --- a/cmd/object-copy-federation-legalhold_test.go +++ b/cmd/object-copy-federation-legalhold_test.go @@ -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 diff --git a/cmd/object-copy-federation-result_test.go b/cmd/object-copy-federation-result_test.go new file mode 100644 index 000000000..20d70f10b --- /dev/null +++ b/cmd/object-copy-federation-result_test.go @@ -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(`aws:kmsdestination-bucket-key`)) + 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) + } + }, + }) +} diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 315047035..a4dfab00b 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -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 diff --git a/cmd/object-lock-default-retention_test.go b/cmd/object-lock-default-retention_test.go new file mode 100644 index 000000000..325657fc6 --- /dev/null +++ b/cmd/object-lock-default-retention_test.go @@ -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, `Enabled%s2`, 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) + } + }) + } + }, + }) +}