mirror of
https://github.com/pgsty/minio.git
synced 2026-10-15 22:26:00 +03:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4c97e607 | |||
| 721dbfd1b7 | |||
| 2926474688 | |||
| a7370aa9ac | |||
| a592d69f45 | |||
| bfc7906064 | |||
| eeff112491 | |||
| a70156e45b | |||
| 99245e3ef8 | |||
| 7a3ca0f821 | |||
| d94b3b3c92 | |||
| 86c5a92eef | |||
| cdc3b8cb10 | |||
| 0d3ba5d4cb | |||
| 71a1631f13 | |||
| 2d57b975d1 | |||
| 14042b5aab | |||
| cb85461c42 | |||
| ba6e8c8d69 | |||
| a54d0c4354 | |||
| 00d4a206f7 | |||
| f480086b6b | |||
| 968c30238c |
@@ -102,6 +102,11 @@ jobs:
|
||||
- name: Run tag replication tests under race detector
|
||||
run: go test -race ./cmd -run '^TestAPITagging' -count=1 -timeout=5m
|
||||
|
||||
- name: Run Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
crosscompile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -70,6 +70,12 @@ jobs:
|
||||
buildscripts/verify-rebrand.sh
|
||||
dockerscripts/docker-entrypoint_test.sh
|
||||
|
||||
# Keep this gate aligned with the Object Lock race regression in go.yml.
|
||||
- name: Gate release on Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
- name: Compute release variables
|
||||
env:
|
||||
# Passed through the environment, never interpolated into the script
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of SILO Object Storage.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/hash"
|
||||
)
|
||||
|
||||
func TestChecksumMismatchAPIMessage(t *testing.T) {
|
||||
for _, algorithm := range []string{"CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME", ""} {
|
||||
t.Run(algorithm, func(t *testing.T) {
|
||||
mismatch := hash.ChecksumMismatch{Algorithm: algorithm, Want: "expected", Got: "actual"}
|
||||
want := errorCodes.ToAPIErr(ErrContentChecksumMismatch).Description
|
||||
if algorithm != "" {
|
||||
want = fmt.Sprintf("The %s you specified did not match the calculated checksum.", algorithm)
|
||||
}
|
||||
for _, err := range []error{mismatch, fmt.Errorf("upload: %w", mismatch)} {
|
||||
got := toAPIError(t.Context(), err)
|
||||
if got.HTTPStatusCode != http.StatusBadRequest || got.Code != "XAmzContentChecksumMismatch" || got.Description != want {
|
||||
t.Fatalf("toAPIError(%v) = %+v, want 400 XAmzContentChecksumMismatch: %s", err, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2505,6 +2505,14 @@ func toAPIError(ctx context.Context, err error) APIError {
|
||||
case errors.Is(err, errMissingPartChecksum):
|
||||
apiErr.Description = strings.TrimPrefix(err.Error(), errMissingPartChecksum.Error()+": ")
|
||||
}
|
||||
// PutObject/UploadPart checksum mismatches name the algorithm in the
|
||||
// message, matching S3 (e.g. "The CRC32 you specified did not match the
|
||||
// calculated checksum."). Falls through to the generic table description
|
||||
// when the algorithm is unknown.
|
||||
var checksumMismatch hash.ChecksumMismatch
|
||||
if errors.As(err, &checksumMismatch) && checksumMismatch.Algorithm != "" {
|
||||
apiErr.Description = fmt.Sprintf("The %s you specified did not match the calculated checksum.", checksumMismatch.Algorithm)
|
||||
}
|
||||
switch apiErr.Code {
|
||||
case "NotImplemented":
|
||||
apiErr = APIError{
|
||||
|
||||
@@ -332,7 +332,12 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob
|
||||
}
|
||||
|
||||
if opts.VersionID != "" && !replicaTrusted {
|
||||
if objInfo, err := getObjectInfoFn(ctx, bucket, object, opts); err == nil {
|
||||
objInfo, err := getObjectInfoFn(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !errors.As(err, &MethodNotAllowed{}) {
|
||||
return mode, retainDate, legalHold, toAPIErrorCode(ctx, err)
|
||||
}
|
||||
} else {
|
||||
r := objectlock.GetObjectRetentionMeta(objInfo.UserDefined)
|
||||
t, err := objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
@@ -110,6 +111,96 @@ func TestReduceErrs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceWriteQuorumErrsCommitMetaMissing verifies that an in-quorum
|
||||
// errCommitMetaMissing reduces to the sentinel, which renameData() converts
|
||||
// to errErasureWriteQuorum, while below-quorum reduces directly to
|
||||
// errErasureWriteQuorum.
|
||||
func TestReduceWriteQuorumErrsCommitMetaMissing(t *testing.T) {
|
||||
errs := make([]error, 16)
|
||||
for i := range 9 {
|
||||
errs[i] = errCommitMetaMissing
|
||||
}
|
||||
if err := reduceWriteQuorumErrs(t.Context(), errs, objectOpIgnoredErrs, 9); !errors.Is(err, errCommitMetaMissing) {
|
||||
t.Errorf("expected %s, got %s", errCommitMetaMissing, err)
|
||||
}
|
||||
// The sentinel must be distinguishable from errFileNotFound so that
|
||||
// commit failures are never mapped to ObjectNotFound (404).
|
||||
if errors.Is(errCommitMetaMissing, errFileNotFound) {
|
||||
t.Error("errCommitMetaMissing must not match errFileNotFound")
|
||||
}
|
||||
|
||||
// Below quorum the reduction must yield a write-quorum failure (503);
|
||||
// eight sentinels against eight committed drives is not a quorum.
|
||||
belowQuorum := make([]error, 16)
|
||||
for i := range 8 {
|
||||
belowQuorum[i] = errCommitMetaMissing
|
||||
}
|
||||
if err := reduceWriteQuorumErrs(t.Context(), belowQuorum, objectOpIgnoredErrs, 9); !errors.Is(err, errErasureWriteQuorum) {
|
||||
t.Errorf("expected %s, got %s", errErasureWriteQuorum, err)
|
||||
}
|
||||
}
|
||||
|
||||
// renameDataFailingDisk is a StorageAPI stub returning a fixed error
|
||||
// from RenameData, enough to drive renameData() in unit tests.
|
||||
type renameDataFailingDisk struct {
|
||||
StorageAPI
|
||||
err error
|
||||
undoWrite bool
|
||||
}
|
||||
|
||||
func (d *renameDataFailingDisk) RenameData(context.Context, string, string, FileInfo, string, string, RenameOptions) (RenameDataResp, error) {
|
||||
return RenameDataResp{}, d.err
|
||||
}
|
||||
|
||||
func (d *renameDataFailingDisk) DeleteVersion(_ context.Context, _, _ string, _ FileInfo, _ bool, opts DeleteOptions) error {
|
||||
d.undoWrite = opts.UndoWrite
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRenameDataCommitMetaMissing verifies that renameData() converts an
|
||||
// in-quorum errCommitMetaMissing into errErasureWriteQuorum (HTTP 503),
|
||||
// while an in-quorum errFileNotFound keeps its meaning so that
|
||||
// PutObject() still reports IncompleteBody for client disconnects.
|
||||
func TestRenameDataCommitMetaMissing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
failures int
|
||||
diskErr error
|
||||
wantErr error
|
||||
}{
|
||||
{"all_commit_metadata_missing", 16, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"quorum_commit_metadata_missing", 9, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"neither_side_has_quorum", 8, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"successful_write_quorum", 7, errCommitMetaMissing, nil},
|
||||
{"staging_failure", 16, errFileNotFound, errFileNotFound},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
metadata := make([]FileInfo, 16)
|
||||
disks := make([]StorageAPI, 16)
|
||||
for i := range disks {
|
||||
metadata[i] = FileInfo{Deleted: true} // valid without erasure info
|
||||
disk := &renameDataFailingDisk{}
|
||||
if i < tc.failures {
|
||||
disk.err = tc.diskErr
|
||||
}
|
||||
disks[i] = disk
|
||||
}
|
||||
_, _, _, err := renameData(t.Context(), disks, minioMetaTmpBucket, "tmp", metadata, "bucket", "object", 9)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("expected %v, got %v", tc.wantErr, err)
|
||||
}
|
||||
for i, disk := range disks {
|
||||
// Failed writes undo only the drives that committed. A
|
||||
// successful quorum must never be rolled back.
|
||||
wantUndo := tc.wantErr != nil && i >= tc.failures
|
||||
if got := disk.(*renameDataFailingDisk).undoWrite; got != wantUndo {
|
||||
t.Errorf("disk %d: UndoWrite = %v, want %v", i, got, wantUndo)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashOrder - test order of ints in array
|
||||
func TestHashOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 clown15
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractModTimeFromUploadIDDir(t *testing.T) {
|
||||
now := time.Unix(1792056747, 123456789).UTC()
|
||||
nano := now.UnixNano()
|
||||
uuid := "d16ad42a-6fe5-4a3b-8092-69983142238f"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
dirName string
|
||||
expectedMod time.Time
|
||||
wantZero bool
|
||||
}{
|
||||
{
|
||||
name: "Modern Plaintext Format (<UUID>x<UnixNano>)",
|
||||
dirName: fmt.Sprintf("%sx%d", uuid, nano),
|
||||
expectedMod: time.Unix(0, nano),
|
||||
wantZero: false,
|
||||
},
|
||||
{
|
||||
// This valid UUID decodes to bytes containing 'x'. Checking
|
||||
// for that byte before parsing plaintext loses the timestamp.
|
||||
name: "Plaintext Also Decodes To Base64 Containing x",
|
||||
dirName: fmt.Sprintf("4eacd477-e733-4851-b821-6fd42785ff87x%d", nano),
|
||||
expectedMod: now,
|
||||
},
|
||||
{
|
||||
name: "Legacy Format Without Timestamp (<UUID>)",
|
||||
dirName: uuid,
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Base64 Encoded Format",
|
||||
dirName: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%sx%d", uuid, nano))),
|
||||
expectedMod: time.Unix(0, nano),
|
||||
wantZero: false,
|
||||
},
|
||||
{
|
||||
name: "Encoded Format With Deployment Prefix",
|
||||
dirName: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s.%sx%d", uuid, uuid, nano))),
|
||||
expectedMod: now,
|
||||
},
|
||||
{
|
||||
name: "Malformed Suffix (<UUID>xinvalid)",
|
||||
dirName: fmt.Sprintf("%sxnotanumber", uuid),
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Timestamp Overflow",
|
||||
dirName: uuid + "x9223372036854775808",
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed Plaintext UUID",
|
||||
dirName: fmt.Sprintf("not-a-uuidx%d", nano),
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Empty Directory Name",
|
||||
dirName: "",
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractModTimeFromUploadIDDir(tc.dirName)
|
||||
if tc.wantZero {
|
||||
if !got.IsZero() {
|
||||
t.Fatalf("expected zero time for %q, got %v", tc.dirName, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !got.Equal(tc.expectedMod) {
|
||||
t.Fatalf("extractModTimeFromUploadIDDir(%q) = %v; want %v", tc.dirName, got, tc.expectedMod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type multipartCleanupDisk struct {
|
||||
StorageAPI
|
||||
root string
|
||||
modTime time.Time
|
||||
readErr error
|
||||
reads int
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) Endpoint() Endpoint {
|
||||
return Endpoint{URL: &url.URL{Path: d.root}}
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||
d.reads++
|
||||
return FileInfo{ModTime: d.modTime}, d.readErr
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) StatVol(context.Context, string) (VolInfo, error) {
|
||||
return VolInfo{Created: time.Now()}, nil
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
||||
const uuid = "d16ad42a-6fe5-4a3b-8092-69983142238f"
|
||||
expiry := globalAPIConfig.getStaleUploadsExpiry()
|
||||
for _, tc := range []struct {
|
||||
name, prefix string
|
||||
age time.Duration
|
||||
legacy bool
|
||||
readErr error
|
||||
wantReads int
|
||||
wantDeleted bool
|
||||
}{
|
||||
{name: "recent", prefix: uuid, age: expiry / 2, readErr: errFileNotFound},
|
||||
{name: "plaintext_base64_collision", prefix: "4eacd477-e733-4851-b821-6fd42785ff87", age: expiry / 2, readErr: errFileNotFound},
|
||||
{name: "expired", prefix: uuid, age: 2 * expiry, readErr: errFileNotFound, wantDeleted: true},
|
||||
{name: "legacy_recent", prefix: uuid, age: expiry / 2, legacy: true, wantReads: 1},
|
||||
{name: "legacy_expired", prefix: uuid, age: 2 * expiry, legacy: true, wantReads: 1, wantDeleted: true},
|
||||
{name: "legacy_metadata_missing", prefix: uuid, age: 2 * expiry, legacy: true, readErr: errFileNotFound, wantReads: 1},
|
||||
{name: "malformed_metadata_missing", prefix: "not-a-uuid", age: 2 * expiry, readErr: errFileNotFound, wantReads: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
modTime := time.Now().Add(-tc.age)
|
||||
id := tc.prefix
|
||||
if !tc.legacy {
|
||||
id = fmt.Sprintf("%sx%d", tc.prefix, modTime.UnixNano())
|
||||
}
|
||||
disk := &multipartCleanupDisk{root: t.TempDir(), modTime: modTime, readErr: tc.readErr}
|
||||
uploadDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha", id)
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if disk.reads != tc.wantReads {
|
||||
t.Errorf("ReadVersion calls = %d, want %d", disk.reads, tc.wantReads)
|
||||
}
|
||||
_, err := os.Stat(uploadDir)
|
||||
if tc.wantDeleted {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expired upload must be removed: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("upload must be preserved: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-10
@@ -174,6 +174,24 @@ func (er erasureObjects) deleteAll(ctx context.Context, bucket, prefix string) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// extractModTimeFromUploadIDDir extracts creation time from the upload ID directory on disk.
|
||||
// On-disk directory names are of the form <UUID>x<UnixNano>.
|
||||
// Parse the native format first: plaintext UUIDs can also be valid Base64.
|
||||
// Preserve the previous encoded-format parser for compatibility.
|
||||
func extractModTimeFromUploadIDDir(uploadIDDir string) time.Time {
|
||||
if modTime, ok := multipartUploadTime(uploadIDDir); ok {
|
||||
return modTime
|
||||
}
|
||||
if b64, err := base64.RawURLEncoding.DecodeString(uploadIDDir); err == nil {
|
||||
if split := strings.Split(string(b64), "x"); len(split) == 2 {
|
||||
if t, err := strconv.ParseInt(split[1], 10, 64); err == nil {
|
||||
return time.Unix(0, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Remove the old multipart uploads on the given disk.
|
||||
func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk StorageAPI) {
|
||||
drivePath := disk.Endpoint().Path
|
||||
@@ -181,16 +199,7 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket), func(shaDir string, typ os.FileMode) error {
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), func(uploadIDDir string, typ os.FileMode) error {
|
||||
uploadIDPath := pathJoin(shaDir, uploadIDDir)
|
||||
var modTime time.Time
|
||||
// Upload IDs are of the form base64_url(<UUID>x<UnixNano>), we can extract the time from the UUID.
|
||||
if b64, err := base64.RawURLEncoding.DecodeString(uploadIDDir); err == nil {
|
||||
if split := strings.Split(string(b64), "x"); len(split) == 2 {
|
||||
t, err := strconv.ParseInt(split[1], 10, 64)
|
||||
if err == nil {
|
||||
modTime = time.Unix(0, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
modTime := extractModTimeFromUploadIDDir(uploadIDDir)
|
||||
// Fallback for older uploads without time in the ID.
|
||||
if modTime.IsZero() {
|
||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||
|
||||
@@ -1083,6 +1083,12 @@ func renameData(ctx context.Context, disks []StorageAPI, srcBucket, srcEntry str
|
||||
errs := g.Wait()
|
||||
|
||||
err := reduceWriteQuorumErrs(ctx, errs, objectOpIgnoredErrs, writeQuorum)
|
||||
if errors.Is(err, errCommitMetaMissing) {
|
||||
// Commit metadata went missing on a quorum of drives; the write
|
||||
// did not commit. Return a 503 class error so clients retry
|
||||
// instead of treating the object as absent (404).
|
||||
err = errErasureWriteQuorum
|
||||
}
|
||||
if err != nil {
|
||||
dg := errgroup.WithNErrs(len(disks))
|
||||
for index, nerr := range errs {
|
||||
|
||||
@@ -162,7 +162,7 @@ func toObjectErr(oerr error, params ...string) error {
|
||||
apiErr.Type = v.Type
|
||||
}
|
||||
return apiErr
|
||||
case errErasureWriteQuorum.Error():
|
||||
case errErasureWriteQuorum.Error(), errCommitMetaMissing.Error():
|
||||
apiErr := InsufficientWriteQuorum{}
|
||||
if len(params) >= 1 {
|
||||
apiErr.Bucket = params[0]
|
||||
|
||||
@@ -62,12 +62,13 @@ func testAPIPutObjectChunkedChecksum(obj ObjectLayer, instanceType, bucketName s
|
||||
// A well-formed 4-byte value that does not match the content.
|
||||
wrongCRC := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04})
|
||||
|
||||
apiCode := func(rec *httptest.ResponseRecorder) string {
|
||||
apiErrOf := func(rec *httptest.ResponseRecorder) APIErrorResponse {
|
||||
var apiErr APIErrorResponse
|
||||
b, _ := io.ReadAll(rec.Body)
|
||||
_ = xml.Unmarshal(b, &apiErr)
|
||||
return apiErr.Code
|
||||
return apiErr
|
||||
}
|
||||
apiCode := func(rec *httptest.ResponseRecorder) string { return apiErrOf(rec).Code }
|
||||
|
||||
// newChunkedJavaForm builds a non-trailer signed chunked PutObject request that
|
||||
// mirrors the AWS Java SDK v2 wire form: the checksum value sits in the header
|
||||
@@ -131,8 +132,12 @@ func testAPIPutObjectChunkedChecksum(obj ObjectLayer, instanceType, bucketName s
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: chunked+wrong CRC32: expected 400, got %d", instanceType, rec.Code)
|
||||
}
|
||||
if code := apiCode(rec); code != "XAmzContentChecksumMismatch" {
|
||||
t.Fatalf("%s: chunked+wrong CRC32: want XAmzContentChecksumMismatch, got %q", instanceType, code)
|
||||
gotErr := apiErrOf(rec)
|
||||
if gotErr.Code != "XAmzContentChecksumMismatch" {
|
||||
t.Fatalf("%s: chunked+wrong CRC32: want XAmzContentChecksumMismatch, got %q", instanceType, gotErr.Code)
|
||||
}
|
||||
if gotErr.Message != "The CRC32 you specified did not match the calculated checksum." {
|
||||
t.Fatalf("%s: chunked+wrong CRC32: message = %q, want S3 algorithm-specific wording", instanceType, gotErr.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -300,3 +303,244 @@ func TestPoolsObjectLockOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type planReviewGatedBody struct {
|
||||
io.ReadCloser
|
||||
first sync.Once
|
||||
ready chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (g *planReviewGatedBody) Read(p []byte) (int, error) {
|
||||
g.first.Do(func() { close(g.ready); <-g.release })
|
||||
return g.ReadCloser.Read(p)
|
||||
}
|
||||
|
||||
func TestPlanReviewSlowDirectoryPut(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
makeBucketOptions: MakeBucketOptions{VersioningEnabled: true, LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
|
||||
writer := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject"`)
|
||||
t.Run(instance, func(t *testing.T) {
|
||||
key := "slow-body-directory/"
|
||||
before, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewBufferString("original"), 8, "", ""), ObjectOptions{Versioned: true, VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte("replacement-body")
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, makeTestTargetURL("", bucket, key, nil), int64(len(payload)), bytes.NewReader(payload), writer.AccessKey, writer.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := &planReviewGatedBody{ReadCloser: req.Body, ready: make(chan struct{}), release: make(chan struct{})}
|
||||
req.Body = gate
|
||||
var once sync.Once
|
||||
release := func() { once.Do(func() { close(gate.release) }) }
|
||||
defer release()
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() { router.ServeHTTP(rec, req); close(done) }()
|
||||
select {
|
||||
case <-gate.ready:
|
||||
case <-done:
|
||||
t.Fatalf("PUT finished before body: %d %s", rec.Code, rec.Body.String())
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("PUT did not reach body read")
|
||||
}
|
||||
locked := make(chan int, 1)
|
||||
go func() {
|
||||
locked <- putRetention(t, router, root, bucket, key, nullVersionID, "COMPLIANCE", UTCNow().Add(365*24*time.Hour), false)
|
||||
}()
|
||||
select {
|
||||
case status := <-locked:
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("set lock while body paused: %d", status)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("retention update blocked behind paused body")
|
||||
}
|
||||
release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("PUT did not finish")
|
||||
}
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest || after.ETag != before.ETag || objectlock.GetObjectRetentionMeta(after.UserDefined).Mode != objectlock.RetCompliance {
|
||||
t.Fatal("paused ordinary PUT overwrote the COMPLIANCE null version after the lock was acknowledged")
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type planReviewReadFault struct {
|
||||
ObjectLayer
|
||||
key string
|
||||
fired bool
|
||||
putCalled bool
|
||||
}
|
||||
|
||||
func (p *planReviewReadFault) GetObjectInfo(ctx context.Context, bucket, key string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if key == p.key && opts.VersionID == nullVersionID && !p.fired {
|
||||
p.fired = true
|
||||
return ObjectInfo{}, InsufficientReadQuorum{}
|
||||
}
|
||||
return p.ObjectLayer.GetObjectInfo(ctx, bucket, key, opts)
|
||||
}
|
||||
|
||||
func (p *planReviewReadFault) PutObject(ctx context.Context, bucket, key string, data *PutObjReader, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if key == p.key {
|
||||
p.putCalled = true
|
||||
}
|
||||
return p.ObjectLayer.PutObject(ctx, bucket, key, data, opts)
|
||||
}
|
||||
|
||||
func TestPlanReviewPreflightReadFailure(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
makeBucketOptions: MakeBucketOptions{VersioningEnabled: true, LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
writer := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject"`)
|
||||
t.Run(instance, func(t *testing.T) {
|
||||
key := "locked-directory-read-fault/"
|
||||
_, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewBufferString("original"), 8, "", ""), ObjectOptions{
|
||||
Versioned: true, VersionID: nullVersionID, ReplicationRequest: true,
|
||||
UserDefined: map[string]string{
|
||||
strings.ToLower(xhttp.AmzObjectLockMode): "COMPLIANCE",
|
||||
strings.ToLower(xhttp.AmzObjectLockRetainUntilDate): amztime.ISO8601Format(UTCNow().Add(365 * 24 * time.Hour)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fault := &planReviewReadFault{ObjectLayer: obj, key: key}
|
||||
setObjectLayer(fault)
|
||||
defer setObjectLayer(obj)
|
||||
rec := retentionAuthzRequest(t, router, writer, http.MethodPut, makeTestTargetURL("", bucket, key, nil), []byte("replacement"), nil)
|
||||
if !fault.fired {
|
||||
t.Fatal("preflight fault was not injected")
|
||||
}
|
||||
if fault.putCalled {
|
||||
t.Fatal("preflight read error allowed PutObject to proceed to storage layer")
|
||||
}
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code < 400 {
|
||||
t.Errorf("metadata read quorum error was treated as absence: status=%d", rec.Code)
|
||||
}
|
||||
retentionOverwriteUnchanged(t, initial, after)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanReviewLegacyMPUCompliance(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
makeBucketOptions: MakeBucketOptions{VersioningEnabled: true, LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
|
||||
writer := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject"`)
|
||||
t.Run(instance, func(t *testing.T) {
|
||||
key := "legacy-mpu-compliance"
|
||||
initial, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewBufferString("original"), 8, "", ""), ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up, err := obj.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{Versioned: true, VersionID: initial.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte("replacement-part")
|
||||
part, err := obj.PutObjectPart(t.Context(), bucket, key, up.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := putRetention(t, router, root, bucket, key, initial.VersionID, "COMPLIANCE", UTCNow().Add(365*24*time.Hour), false); status != http.StatusOK {
|
||||
t.Fatalf("set compliance: %d", status)
|
||||
}
|
||||
before, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: initial.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := fmt.Appendf(nil, `<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"%s"</ETag></Part></CompleteMultipartUpload>`, part.ETag)
|
||||
rec := retentionAuthzRequest(t, router, writer, http.MethodPost, makeTestTargetURL("", bucket, key, url.Values{"uploadId": {up.UploadID}}), body, nil)
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: initial.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("legacy upload replaced locked UUID: status=%d", rec.Code)
|
||||
}
|
||||
retentionOverwriteUnchanged(t, before, after)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// A recovered upload retains an authentic server-generated version ID while a
|
||||
// surviving replica reintroduces its already-completed version and lock.
|
||||
// Only the fixture uses replication privileges; completion uses PutObject only.
|
||||
func TestRecoveredMultipartUploadAfterReplica(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
makeBucketOptions: MakeBucketOptions{VersioningEnabled: true, LockEnabled: true},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
|
||||
writer := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject"`)
|
||||
t.Run(instance, func(t *testing.T) {
|
||||
z := obj.(*erasureServerPools)
|
||||
key := "recovered-generated-upload"
|
||||
up, err := obj.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
er := z.serverPools[0].getHashedSet(key)
|
||||
fi, _, err := er.checkUploadIDExists(t.Context(), bucket, key, up.UploadID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = obj.PutObject(t.Context(), bucket, key,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("protected-original"), 18, "", ""),
|
||||
ObjectOptions{Versioned: true, VersionID: fi.VersionID, ReplicationRequest: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := putRetention(t, router, root, bucket, key, fi.VersionID, "COMPLIANCE", UTCNow().Add(365*24*time.Hour), false); status != http.StatusOK {
|
||||
t.Fatalf("setting compliance lock: %d", status)
|
||||
}
|
||||
before, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: fi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partRec := retentionAuthzRequest(t, router, writer, http.MethodPut,
|
||||
makeTestTargetURL("", bucket, key, url.Values{"uploadId": {up.UploadID}, "partNumber": {"1"}}),
|
||||
[]byte("attacker-replacement"), nil)
|
||||
if partRec.Code != http.StatusOK {
|
||||
t.Fatalf("upload part: %d %s", partRec.Code, partRec.Body.String())
|
||||
}
|
||||
etag := multipartConditionResponseETag(partRec)
|
||||
body := []byte(multipartConditionCompleteBody([]CompletePart{{PartNumber: 1, ETag: etag}}))
|
||||
rec := retentionAuthzRequest(t, router, writer, http.MethodPost,
|
||||
makeTestTargetURL("", bucket, key, url.Values{"uploadId": {up.UploadID}}), body, nil)
|
||||
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: fi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("recovered upload bypassed existing UUID guard: status=%d", rec.Code)
|
||||
}
|
||||
retentionOverwriteUnchanged(t, before, after)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ var errDiskAccessDenied = StorageErr("drive access denied")
|
||||
// errFileNotFound - cannot find the file.
|
||||
var errFileNotFound = StorageErr("file not found")
|
||||
|
||||
// errCommitMetaMissing - the staged xl.meta disappeared between the
|
||||
// data rename and the meta-file commit; a write-quorum failure, not
|
||||
// a missing object.
|
||||
var errCommitMetaMissing = StorageErr("commit metadata is missing")
|
||||
|
||||
// errFileNotFound - cannot find requested file version.
|
||||
var errFileVersionNotFound = StorageErr("file version not found")
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ func toStorageErr(err error) error {
|
||||
return errVolumeExists
|
||||
case errFileNotFound.Error():
|
||||
return errFileNotFound
|
||||
case errCommitMetaMissing.Error():
|
||||
return errCommitMetaMissing
|
||||
case errFileVersionNotFound.Error():
|
||||
return errFileVersionNotFound
|
||||
case errFileNameTooLong.Error():
|
||||
|
||||
@@ -290,6 +290,9 @@ func (s *storageRESTServer) NSScannerHandler(ctx context.Context, params *nsScan
|
||||
for update := range updates {
|
||||
resp := storageNSScannerRPC.NewResponse()
|
||||
resp.Update = &update
|
||||
// Pooled responses may carry fields from a previous
|
||||
// scan, always clear the fields not being set.
|
||||
resp.Final = nil
|
||||
out <- resp
|
||||
}
|
||||
}()
|
||||
@@ -301,6 +304,7 @@ func (s *storageRESTServer) NSScannerHandler(ctx context.Context, params *nsScan
|
||||
// Send final response.
|
||||
resp := storageNSScannerRPC.NewResponse()
|
||||
resp.Final = &ui
|
||||
resp.Update = nil
|
||||
out <- resp
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Julien Laurenceau and Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
)
|
||||
|
||||
type scannerResponseTestDisk struct {
|
||||
StorageAPI
|
||||
}
|
||||
|
||||
func (scannerResponseTestDisk) NSScanner(_ context.Context, cache dataUsageCache, updates chan<- dataUsageEntry, _ madmin.HealScanMode, _ func() bool) (dataUsageCache, error) {
|
||||
defer close(updates)
|
||||
updates <- dataUsageEntry{Size: int64(cache.Info.NextCycle)}
|
||||
cache.Info.NextCycle++
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func TestNSScannerHandlerClearsStaleResponses(t *testing.T) {
|
||||
endpoint, err := NewEndpoint(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalLocalDrivesMu.Lock()
|
||||
oldDrives, oldSets := globalLocalDrivesMap, globalLocalSetDrives
|
||||
globalLocalDrivesMap = map[string]StorageAPI{endpoint.String(): scannerResponseTestDisk{}}
|
||||
globalLocalSetDrives = nil
|
||||
globalLocalDrivesMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalLocalDrivesMu.Lock()
|
||||
globalLocalDrivesMap, globalLocalSetDrives = oldDrives, oldSets
|
||||
globalLocalDrivesMu.Unlock()
|
||||
})
|
||||
|
||||
oldRPC := storageNSScannerRPC
|
||||
t.Cleanup(func() { storageNSScannerRPC = oldRPC })
|
||||
// Every new allocation is dirty too: the check must not depend on
|
||||
// sync.Pool returning a previously used object.
|
||||
storageNSScannerRPC = grid.NewStream[*nsScannerOptions, grid.NoPayload, *nsScannerResp](grid.HandlerNSScanner,
|
||||
func() *nsScannerOptions { return &nsScannerOptions{} }, nil,
|
||||
func() *nsScannerResp {
|
||||
return &nsScannerResp{Update: &dataUsageEntry{Size: -1}, Final: &dataUsageCache{}}
|
||||
})
|
||||
server := storageRESTServer{endpoint: endpoint}
|
||||
for cycle := uint32(1); cycle <= 3; cycle++ {
|
||||
out := make(chan *nsScannerResp, 2)
|
||||
params := &nsScannerOptions{Cache: &dataUsageCache{Info: dataUsageCacheInfo{Name: "bucket", NextCycle: cycle}}}
|
||||
if err := server.NSScannerHandler(t.Context(), params, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("cycle %d: got %d responses, want an update and a final cache", cycle, len(out))
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
resp := <-out
|
||||
wire, err := resp.MarshalMsg(nil)
|
||||
storageNSScannerRPC.PutResponse(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded nsScannerResp
|
||||
if _, err := decoded.UnmarshalMsg(wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i == 0 {
|
||||
if decoded.Update == nil || decoded.Final != nil || decoded.Update.Size != int64(cycle) {
|
||||
t.Errorf("cycle %d: invalid update response: %+v", cycle, decoded)
|
||||
}
|
||||
} else if decoded.Update != nil || decoded.Final == nil || decoded.Final.Info.NextCycle != cycle+1 {
|
||||
t.Errorf("cycle %d: invalid final response: %+v", cycle, decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,16 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSysErrors(t *testing.T) {
|
||||
@@ -56,3 +62,120 @@ func TestSysErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommitMetaMissingMapping verifies the RPC and object-layer mapping
|
||||
// of errCommitMetaMissing, returned when the staged xl.meta disappears
|
||||
// between the data rename and the meta-file commit in RenameData().
|
||||
func TestCommitMetaMissingMapping(t *testing.T) {
|
||||
// The sentinel must survive a storage REST/grid round-trip; errors
|
||||
// cross the wire as strings and are remapped by toStorageErr().
|
||||
if got := toStorageErr(errors.New(errCommitMetaMissing.Error())); !errors.Is(got, errCommitMetaMissing) {
|
||||
t.Fatalf("expected %s, got %s", errCommitMetaMissing, got)
|
||||
}
|
||||
if got := toStorageErr(errCommitMetaMissing); got != errCommitMetaMissing {
|
||||
t.Fatalf("expected %s, got %s", errCommitMetaMissing, got)
|
||||
}
|
||||
|
||||
// toObjectErr() must map the sentinel to InsufficientWriteQuorum
|
||||
// (503), never to ObjectNotFound (404).
|
||||
err := toObjectErr(errCommitMetaMissing, "bucket", "object")
|
||||
qerr, ok := err.(InsufficientWriteQuorum)
|
||||
if !ok {
|
||||
t.Fatalf("expected InsufficientWriteQuorum, got %s (%T)", err, err)
|
||||
}
|
||||
if qerr.Bucket != "bucket" || qerr.Object != "object" {
|
||||
t.Fatalf("unexpected bucket/object %s/%s", qerr.Bucket, qerr.Object)
|
||||
}
|
||||
apiErr := toAPIError(t.Context(), err)
|
||||
if apiErr.Code != "SlowDownWrite" || apiErr.HTTPStatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected SlowDownWrite/503, got %+v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
// missingCommitMetaContext removes staged metadata at an existing cancellation
|
||||
// check, after WriteAll and (for non-inline objects) the data rename succeeded.
|
||||
// This deterministically models concurrent cleanup without changing production
|
||||
// code or relying on a filesystem race. The context itself remains live.
|
||||
type missingCommitMetaContext struct {
|
||||
context.Context
|
||||
mu sync.Mutex
|
||||
meta, dataDest string
|
||||
removed bool
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (c *missingCommitMetaContext) Done() <-chan struct{} {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.removed {
|
||||
if _, err := os.Stat(c.meta); err == nil {
|
||||
dataMoved := c.dataDest == ""
|
||||
if !dataMoved {
|
||||
_, err = os.Stat(c.dataDest)
|
||||
dataMoved = err == nil
|
||||
}
|
||||
if dataMoved {
|
||||
c.removeErr = os.Remove(c.meta)
|
||||
c.removed = c.removeErr == nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Context.Done()
|
||||
}
|
||||
|
||||
func TestXLStorageRenameDataCommitMetaMissing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dataFile bool
|
||||
}{
|
||||
{"empty_object", false},
|
||||
{"data_directory", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
disk, root, err := newXLStorageTestSetup(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer disk.Close()
|
||||
s := disk.storage
|
||||
if err := s.MakeVol(t.Context(), "bucket"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi := newFileInfo("object", 2, 2)
|
||||
fi.DataDir, fi.VersionID = mustGetUUID(), mustGetUUID()
|
||||
fi.Erasure.Index = 1
|
||||
fi.ModTime = time.Now().UTC()
|
||||
fi.Fresh = true
|
||||
ctx := &missingCommitMetaContext{
|
||||
Context: t.Context(),
|
||||
meta: filepath.Join(root, minioMetaTmpBucket, "staged", xlStorageFormatFile),
|
||||
}
|
||||
if tc.dataFile {
|
||||
fi.Size = 1
|
||||
fi.AddObjectPart(1, "", 1, 1, fi.ModTime, nil, nil)
|
||||
part := filepath.Join(root, minioMetaTmpBucket, "staged", fi.DataDir, "part.1")
|
||||
if err := os.MkdirAll(filepath.Dir(part), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(part, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx.dataDest = filepath.Join(root, "bucket", "object", fi.DataDir)
|
||||
}
|
||||
_, err = s.RenameData(ctx, minioMetaTmpBucket, "staged", fi, "bucket", "object", RenameOptions{})
|
||||
if !ctx.removed {
|
||||
t.Fatalf("metadata removal did not execute: removal error = %v, RenameData error = %v", ctx.removeErr, err)
|
||||
}
|
||||
if !errors.Is(err, errCommitMetaMissing) {
|
||||
t.Fatalf("expected commit metadata missing, got %v", err)
|
||||
}
|
||||
apiErr := toAPIError(t.Context(), toObjectErr(err, "bucket", "object"))
|
||||
if apiErr.Code != "SlowDownWrite" || apiErr.HTTPStatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected SlowDownWrite/503, got %+v", apiErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bucket", "object", xlStorageFormatFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed commit must not produce destination metadata: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func (x *xlMetaV2VersionHeader) unmarshalV(v uint8, bts []byte) (o []byte, err e
|
||||
|
||||
// unmarshalV1 decodes version 1, never released.
|
||||
func (x *xlMetaV2VersionHeader) unmarshalV1(bts []byte) (o []byte, err error) {
|
||||
// Fields absent from the V1 wire format must be reset since the
|
||||
// header may be reused across decodes.
|
||||
x.Signature = [4]byte{}
|
||||
x.EcN, x.EcM = 0, 0
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
@@ -134,7 +138,7 @@ type xlMetaV2VersionHeaderV2 struct {
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *xlMetaV2VersionHeaderV2) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
z.EcN, z.EcN = 0, 0
|
||||
z.EcN, z.EcM = 0, 0
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
@@ -184,7 +188,7 @@ func (z *xlMetaV2VersionHeaderV2) UnmarshalMsg(bts []byte) (o []byte, err error)
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *xlMetaV2VersionHeaderV2) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
z.EcN, z.EcN = 0, 0
|
||||
z.EcN, z.EcM = 0, 0
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadArrayHeader()
|
||||
if err != nil {
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func TestReadXLMetaNoData(t *testing.T) {
|
||||
@@ -1190,3 +1191,92 @@ func BenchmarkToFileInfoNoParts(b *testing.B) {
|
||||
func BenchmarkToFileInfoWithParts(b *testing.B) {
|
||||
benchmarkManyPartsOptionally(b, true)
|
||||
}
|
||||
|
||||
// TestXLMetaV2HeaderLegacyUnmarshalResetsEc checks that decoding legacy
|
||||
// version headers, which carry no erasure metadata fields, cannot leak
|
||||
// stale EcN/EcM values from a previously decoded object into the same
|
||||
// structure.
|
||||
func TestXLMetaV2HeaderLegacyUnmarshalResetsEc(t *testing.T) {
|
||||
base := xlMetaV2VersionHeader{
|
||||
VersionID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
|
||||
ModTime: time.Now().Unix(),
|
||||
Signature: [4]byte{1, 2, 3, 4},
|
||||
Type: ObjectType,
|
||||
Flags: xlFlagInlineData,
|
||||
}
|
||||
|
||||
// Legacy V2 header wire format: 5 fields, no EcN/EcM.
|
||||
var legacy []byte
|
||||
legacy = msgp.AppendArrayHeader(legacy, 5)
|
||||
legacy = msgp.AppendBytes(legacy, base.VersionID[:])
|
||||
legacy = msgp.AppendInt64(legacy, base.ModTime)
|
||||
legacy = msgp.AppendBytes(legacy, base.Signature[:])
|
||||
legacy = msgp.AppendUint8(legacy, uint8(base.Type))
|
||||
legacy = msgp.AppendUint8(legacy, uint8(base.Flags))
|
||||
|
||||
for _, decoder := range []string{"UnmarshalMsg", "DecodeMsg", "loadIndexed"} {
|
||||
t.Run(decoder, func(t *testing.T) {
|
||||
header := base
|
||||
header.EcN, header.EcM = 4, 8
|
||||
x2 := xlMetaV2VersionHeaderV2{xlMetaV2VersionHeader: &header}
|
||||
var err error
|
||||
switch decoder {
|
||||
case "UnmarshalMsg":
|
||||
_, err = x2.UnmarshalMsg(legacy)
|
||||
case "DecodeMsg":
|
||||
err = x2.DecodeMsg(msgp.NewReader(bytes.NewReader(legacy)))
|
||||
case "loadIndexed":
|
||||
indexed := msgp.AppendUint(nil, 2)
|
||||
indexed = msgp.AppendUint(indexed, xlMetaVersion)
|
||||
indexed = msgp.AppendInt(indexed, 1)
|
||||
indexed = msgp.AppendBytes(indexed, legacy)
|
||||
indexed = msgp.AppendBytes(indexed, nil)
|
||||
xl := xlMetaV2{versions: []xlMetaV2ShallowVersion{{header: header}}}
|
||||
err = xl.loadIndexed(indexed, nil)
|
||||
header = xl.versions[0].header
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if header != base {
|
||||
t.Fatalf("legacy header: got %+v, want %+v", header, base)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// V1 headers (4 fields, never released) must zero the fields too.
|
||||
var v1 []byte
|
||||
v1 = msgp.AppendArrayHeader(v1, 4)
|
||||
v1 = msgp.AppendBytes(v1, base.VersionID[:])
|
||||
v1 = msgp.AppendInt64(v1, base.ModTime)
|
||||
v1 = msgp.AppendUint8(v1, uint8(base.Type))
|
||||
v1 = msgp.AppendUint8(v1, uint8(base.Flags))
|
||||
|
||||
header := base
|
||||
header.EcN, header.EcM = 4, 8
|
||||
header.Signature = [4]byte{9, 9, 9, 9}
|
||||
if _, err := header.unmarshalV1(v1); err != nil {
|
||||
t.Fatalf("legacy V1 header unmarshal failed: %v", err)
|
||||
}
|
||||
if header.EcN != 0 || header.EcM != 0 {
|
||||
t.Fatalf("stale erasure metadata leaked on V1 decode: EcN %d, EcM %d", header.EcN, header.EcM)
|
||||
}
|
||||
if header.Signature != ([4]byte{}) {
|
||||
t.Fatalf("stale signature leaked on V1 decode: %v", header.Signature)
|
||||
}
|
||||
|
||||
// Current headers must keep their erasure metadata.
|
||||
current := base
|
||||
current.EcN, current.EcM = 4, 8
|
||||
wire, err := current.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("header marshal failed: %v", err)
|
||||
}
|
||||
decoded := base
|
||||
if _, err := decoded.UnmarshalMsg(wire); err != nil {
|
||||
t.Fatalf("header unmarshal failed: %v", err)
|
||||
}
|
||||
if decoded.EcN != 4 || decoded.EcM != 8 {
|
||||
t.Fatalf("erasure metadata lost on current decode: EcN %d, EcM %d", decoded.EcN, decoded.EcM)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -2925,7 +2925,15 @@ func (s *xlStorage) RenameData(ctx context.Context, srcVolume, srcPath string, f
|
||||
// if its a partial rename() do not attempt to delete recursively.
|
||||
// this can be healed since all parts are available.
|
||||
s.deleteFile(dstVolumeDir, dstDataPath, false, false)
|
||||
return res, osErrToFileErr(err)
|
||||
err = osErrToFileErr(err)
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
// The staged xl.meta disappeared between staging and commit.
|
||||
// This is a write-quorum failure, not a missing object; use a
|
||||
// dedicated error so the object layer returns 503 instead of
|
||||
// mapping it to ObjectNotFound (404).
|
||||
err = errCommitMetaMissing
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
if srcVolume != minioMetaMultipartBucket {
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.27.2
|
||||
|
||||
// Console and MC retain their historical module paths for best-effort upstream
|
||||
// compatibility. Pin the maintained PGSTY implementations used by SILO.
|
||||
replace github.com/minio/console => github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d
|
||||
replace github.com/minio/console => github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee
|
||||
|
||||
replace github.com/minio/mc => github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a
|
||||
|
||||
|
||||
@@ -547,8 +547,8 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a h1:FY1vcTQT67HGBe23ozlhOiQnWCzUCGmeSN5x6DT/kFs=
|
||||
github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a/go.mod h1:6z9lX8kOvaWz2vLlP7BjjF3JWHnIH86XDA2HwcmpZH8=
|
||||
github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d h1:A9ou1GkUckGZd3BOgEXeZ393nbYST967SKcI4Lgr/Vs=
|
||||
github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d/go.mod h1:JS165CF2yoTBIx1X8k1IQ6vunJcZFG3pRZlWHJ8UT8o=
|
||||
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee h1:e51AnrGe0Zq+nHTYaGfv/GkehQMO9Mekuw/ew6zYwOM=
|
||||
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee/go.mod h1:jpt39bx4wjhXoFB+BTOa/bx/pmU3pXzp2cT7PnT21Lo=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.1 h1:8MONT3Hky9EOnsuPk29590pEBE6MILaOnJtaejPPi7M=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.1/go.mod h1:jxKWxi52jSsDaDhXXPqjOyPDYdxx2iqdBXyLfM/xSIw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of SILO Object Storage.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChecksumMismatchAlgorithm(t *testing.T) {
|
||||
content := []byte("actual content")
|
||||
for _, typ := range []ChecksumType{ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ChecksumCRC64NVME} {
|
||||
t.Run(typ.String(), func(t *testing.T) {
|
||||
wrong := NewChecksumFromData(typ, []byte("wrong content"))
|
||||
check := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var mismatch ChecksumMismatch
|
||||
if !errors.As(err, &mismatch) || mismatch.Algorithm != typ.String() {
|
||||
t.Fatalf("error=%+v, want %s checksum mismatch", err, typ)
|
||||
}
|
||||
}
|
||||
check(t, wrong.Matches(content, 0))
|
||||
parts := NewChecksumFromData(typ, content)
|
||||
parts.WantParts = 2
|
||||
check(t, parts.Matches(content, 1))
|
||||
for _, mode := range []string{"header", "trailer", "malformed-trailer"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
reader, err := NewReader(t.Context(), bytes.NewReader(content), int64(len(content)), "", "", int64(len(content)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "http://localhost/bucket/object", nil)
|
||||
if mode == "header" {
|
||||
req.Header.Set(typ.Key(), wrong.Encoded)
|
||||
} else {
|
||||
req.Header.Set("x-amz-trailer", typ.Key())
|
||||
req.Trailer = make(http.Header)
|
||||
value := wrong.Encoded
|
||||
if mode == "malformed-trailer" {
|
||||
value = "!invalid-base64!"
|
||||
}
|
||||
req.Trailer.Set(typ.Key(), value)
|
||||
}
|
||||
if err = reader.AddChecksum(req, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = io.ReadAll(reader)
|
||||
check(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -584,15 +584,17 @@ func (c Checksum) Matches(content []byte, parts int) error {
|
||||
sum := hasher.Sum(nil)
|
||||
if c.WantParts > 0 && c.WantParts != parts {
|
||||
return ChecksumMismatch{
|
||||
Want: fmt.Sprintf("%s-%d", c.Encoded, c.WantParts),
|
||||
Got: fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(sum), parts),
|
||||
Algorithm: c.Type.String(),
|
||||
Want: fmt.Sprintf("%s-%d", c.Encoded, c.WantParts),
|
||||
Got: fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(sum), parts),
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(sum, c.Raw) {
|
||||
return ChecksumMismatch{
|
||||
Want: c.Encoded,
|
||||
Got: base64.StdEncoding.EncodeToString(sum),
|
||||
Algorithm: c.Type.String(),
|
||||
Want: c.Encoded,
|
||||
Got: base64.StdEncoding.EncodeToString(sum),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -74,8 +74,11 @@ func (e SizeMismatch) Error() string {
|
||||
|
||||
// ChecksumMismatch - when content checksum does not match with what was sent from client.
|
||||
type ChecksumMismatch struct {
|
||||
Want string
|
||||
Got string
|
||||
// Algorithm carries the requested checksum type (e.g. "CRC32") so API
|
||||
// error rendering can name it, matching S3's wording.
|
||||
Algorithm string
|
||||
Want string
|
||||
Got string
|
||||
}
|
||||
|
||||
func (e ChecksumMismatch) Error() string {
|
||||
|
||||
@@ -302,13 +302,14 @@ func (r *Reader) Read(p []byte) (int, error) {
|
||||
r.contentHash.Encoded = r.trailer.Get(r.contentHash.Type.Key())
|
||||
r.contentHash.Raw, err = base64.StdEncoding.DecodeString(r.contentHash.Encoded)
|
||||
if err != nil || len(r.contentHash.Raw) == 0 {
|
||||
return 0, ChecksumMismatch{Got: r.contentHash.Encoded}
|
||||
return 0, ChecksumMismatch{Algorithm: r.contentHash.Type.String(), Got: r.contentHash.Encoded}
|
||||
}
|
||||
}
|
||||
if sum := r.contentHasher.Sum(nil); !bytes.Equal(r.contentHash.Raw, sum) {
|
||||
err := ChecksumMismatch{
|
||||
Want: r.contentHash.Encoded,
|
||||
Got: base64.StdEncoding.EncodeToString(sum),
|
||||
Algorithm: r.contentHash.Type.String(),
|
||||
Want: r.contentHash.Encoded,
|
||||
Got: base64.StdEncoding.EncodeToString(sum),
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user