mirror of
https://github.com/pgsty/minio.git
synced 2026-09-15 23:14:04 +03:00
4fcdf37ce6
Restore only the six replication-specific metadata fields after trust validation, so streaming uploads retain their actual content encoding and Snowball entries do not inherit ordinary metadata from the outer archive. Include helper, authenticated PUT/COPY/multipart and Snowball regressions, plus the R7 investigation, actual Opus 5 consensus and local verification. The production change is based on PR #187 by Mikhail Khadarenka. Co-authored-by: Mikhail Khadarenka <chodorenko@gmail.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
277 lines
11 KiB
Go
277 lines
11 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/xml"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/minio/minio/internal/auth"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
)
|
|
|
|
// Exercise authenticated handlers and actual disk metadata, including the
|
|
// response headers consumers see after replication has completed.
|
|
func TestAPIReplicaContentEncoding(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIReplicaContentEncoding})
|
|
}
|
|
|
|
func testAPIReplicaContentEncoding(obj ObjectLayer, instance, bucket string, router http.Handler, owner auth.Credentials, t *testing.T) {
|
|
ordinary := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject","s3:GetObject"`)
|
|
replicator := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
|
|
for _, mode := range []string{"ordinary", "untrusted-marker", "replica"} {
|
|
for _, tc := range []struct{ name, wire, want string }{
|
|
{"bare", "aws-chunked", ""}, {"mixed", "aws-chunked,gzip", "gzip"}, {"gzip", "gzip", "gzip"},
|
|
} {
|
|
for _, operation := range []string{"put", "copy-replace", "multipart"} {
|
|
t.Run(instance+"/"+mode+"/"+tc.name+"/"+operation, func(t *testing.T) {
|
|
object := mode + "/" + tc.name + "/" + operation
|
|
payload := replicaEncodingPayload(t, tc.want)
|
|
creds := ordinary
|
|
headers := map[string]string{xhttp.ContentEncoding: tc.wire, xhttp.ContentType: "application/octet-stream", "X-Amz-Meta-Source": "encoding-test"}
|
|
if mode != "ordinary" {
|
|
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
|
}
|
|
if mode == "replica" {
|
|
creds = replicator
|
|
headers[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
|
}
|
|
send := func(method, target string, data []byte, hdrs map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req, err := newTestSignedRequestV4(method, target, int64(len(data)), bytes.NewReader(data), creds.AccessKey, creds.SecretKey, hdrs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return replicaEncodingServe(t, router, req, http.StatusOK)
|
|
}
|
|
switch operation {
|
|
case "put":
|
|
if strings.Contains(tc.wire, "aws-chunked") {
|
|
req := replicaEncodingStream(t, getPutObjectURL("", bucket, object), payload, creds, headers)
|
|
replicaEncodingServe(t, router, req, http.StatusOK)
|
|
} else {
|
|
send(http.MethodPut, getPutObjectURL("", bucket, object), payload, headers)
|
|
}
|
|
case "copy-replace":
|
|
source := object + "-source"
|
|
if _, err := obj.PutObject(t.Context(), bucket, source, mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), ObjectOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
headers[xhttp.AmzCopySource] = url.QueryEscape("/" + bucket + "/" + source)
|
|
headers[xhttp.AmzMetadataDirective] = replaceDirective
|
|
send(http.MethodPut, getCopyObjectURL("", bucket, object), nil, headers)
|
|
case "multipart":
|
|
rec := send(http.MethodPost, getNewMultipartURL("", bucket, object), nil, headers)
|
|
var init InitiateMultipartUploadResponse
|
|
if err := xml.Unmarshal(rec.Body.Bytes(), &init); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Part/completion metadata must not replace the encoding saved at initiation.
|
|
partHeaders := map[string]string{xhttp.ContentEncoding: "br"}
|
|
if mode == "replica" {
|
|
partHeaders[xhttp.MinIOSourceReplicationRequest] = "true"
|
|
partHeaders[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
|
}
|
|
part := send(http.MethodPut, getPutObjectPartURL("", bucket, object, init.UploadID, "1"), payload, partHeaders)
|
|
partETags := part.Header()[xhttp.ETag]
|
|
if len(partETags) != 1 {
|
|
t.Fatalf("missing part ETag: %#v", part.Header())
|
|
}
|
|
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(partETags[0])}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
send(http.MethodPost, getCompleteMultipartUploadURL("", bucket, object, init.UploadID), complete, partHeaders)
|
|
}
|
|
assertReplicaEncodingObject(t, obj, router, owner, bucket, object, tc.want, payload)
|
|
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := info.UserDefined[xhttp.AmzBucketReplicationStatus]; (got == "REPLICA") != (mode == "replica") {
|
|
t.Errorf("replica status %q for mode %s", got, mode)
|
|
}
|
|
if info.ContentType != "application/octet-stream" {
|
|
t.Errorf("content-type=%q", info.ContentType)
|
|
}
|
|
if value, ok := caseInsensitiveMap(info.UserDefined).Lookup("x-amz-meta-source"); !ok || value != "encoding-test" {
|
|
t.Errorf("user metadata lost: %#v", info.UserDefined)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
t.Run(instance+"/unauthorized-replica", func(t *testing.T) {
|
|
object := "denied-replica"
|
|
req := replicaEncodingStream(t, getPutObjectURL("", bucket, object), []byte("denied"), ordinary, map[string]string{xhttp.ContentEncoding: "aws-chunked", xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA"})
|
|
rec := replicaEncodingServe(t, router, req, http.StatusForbidden)
|
|
var response APIErrorResponse
|
|
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Code != "AccessDenied" {
|
|
t.Fatalf("expected permission denial, got %s", response.Code)
|
|
}
|
|
if _, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err == nil {
|
|
t.Error("denied replica created an object")
|
|
}
|
|
})
|
|
}
|
|
|
|
func replicaEncodingPayload(t *testing.T, encoding string) []byte {
|
|
t.Helper()
|
|
data := bytes.Repeat([]byte("replica encoding payload\n"), 128)
|
|
if encoding != "gzip" {
|
|
return data
|
|
}
|
|
var b bytes.Buffer
|
|
w := gzip.NewWriter(&b)
|
|
if _, err := w.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
func replicaEncodingStream(t *testing.T, target string, data []byte, creds auth.Credentials, headers map[string]string) *http.Request {
|
|
t.Helper()
|
|
const chunkSize = 64
|
|
body := bytes.NewReader(data)
|
|
req, err := newTestStreamingRequest(http.MethodPut, target, int64(len(data)), chunkSize, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
now := UTCNow()
|
|
signature, err := signStreamingRequest(req, creds.AccessKey, creds.SecretKey, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req, err = assembleStreamingChunks(req, body, chunkSize, creds.SecretKey, signature, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func replicaEncodingServe(t *testing.T, router http.Handler, req *http.Request, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != want {
|
|
t.Fatalf("%s %s: status=%d want=%d body=%s", req.Method, req.URL, rec.Code, want, rec.Body.String())
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func assertReplicaEncodingObject(t *testing.T, obj ObjectLayer, router http.Handler, creds auth.Credentials, bucket, object, encoding string, data []byte) {
|
|
t.Helper()
|
|
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.ContentEncoding != encoding {
|
|
t.Errorf("persisted content-encoding=%q want=%q", info.ContentEncoding, encoding)
|
|
}
|
|
if encoding == "" {
|
|
if _, present := info.UserDefined["content-encoding"]; present {
|
|
t.Error("transport-only content-encoding key persisted")
|
|
}
|
|
}
|
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
|
req, err := newTestSignedRequestV4(method, getPutObjectURL("", bucket, object), 0, nil, creds.AccessKey, creds.SecretKey, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := replicaEncodingServe(t, router, req, http.StatusOK)
|
|
if got := rec.Header().Get(xhttp.ContentEncoding); got != encoding {
|
|
t.Errorf("%s content-encoding=%q want=%q", method, got, encoding)
|
|
}
|
|
if encoding == "" {
|
|
if _, present := rec.Header()[xhttp.ContentEncoding]; present {
|
|
t.Errorf("%s sent an empty/transport encoding header", method)
|
|
}
|
|
}
|
|
if method == http.MethodGet && !bytes.Equal(rec.Body.Bytes(), data) {
|
|
t.Errorf("GET body differs: got %d bytes want %d", rec.Body.Len(), len(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPISnowballReplicaContentEncoding(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
pax map[string]string
|
|
want string
|
|
}{
|
|
{name: "no-pax"},
|
|
{name: "pax-without-encoding", pax: map[string]string{"minio.metadata.Content-Type": "application/octet-stream"}},
|
|
{name: "pax-bare", pax: map[string]string{"minio.metadata.Content-Encoding": "aws-chunked"}},
|
|
{name: "pax-mixed", pax: map[string]string{"minio.metadata.Content-Encoding": "aws-chunked,gzip"}, want: "gzip"},
|
|
} {
|
|
t.Run(instance+"/"+tc.name, func(t *testing.T) {
|
|
object := "snowball/" + tc.name
|
|
data := replicaEncodingPayload(t, tc.want)
|
|
var archive bytes.Buffer
|
|
tw := tar.NewWriter(&archive)
|
|
if err := tw.WriteHeader(&tar.Header{Name: object, Mode: 0o600, Size: int64(len(data)), PAXRecords: tc.pax}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ordinaryMetadata map[string]string
|
|
// An unauthorized entry in a REPLICA request is rejected. Compare the
|
|
// same archive across ordinary and authorized replica requests instead.
|
|
for _, replica := range []bool{false, true} {
|
|
headers := map[string]string{
|
|
xhttp.ContentEncoding: "aws-chunked", xhttp.AmzSnowballExtract: "true",
|
|
xhttp.ContentType: "application/x-tar", xhttp.CacheControl: "max-age=123",
|
|
"X-Amz-Meta-Archive": "outer-request",
|
|
}
|
|
if replica {
|
|
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
|
headers[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
|
}
|
|
req := replicaEncodingStream(t, getPutObjectURL("", bucket, "archive.tar"), archive.Bytes(), creds, headers)
|
|
replicaEncodingServe(t, router, req, http.StatusOK)
|
|
assertReplicaEncodingObject(t, obj, router, creds, bucket, object, tc.want, data)
|
|
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
metadata := maps.Clone(info.UserDefined)
|
|
for _, key := range []string{xhttp.AmzBucketReplicationStatus, ReservedMetadataPrefixLower + ReplicaStatus, ReservedMetadataPrefixLower + ReplicaTimestamp, "etag"} {
|
|
delete(metadata, key)
|
|
}
|
|
if !replica {
|
|
ordinaryMetadata = metadata
|
|
} else if !reflect.DeepEqual(metadata, ordinaryMetadata) {
|
|
t.Errorf("replica inherited ordinary archive metadata: got %#v want %#v", metadata, ordinaryMetadata)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}})
|
|
}
|