mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* fix(s3api): re-encrypt UploadPartCopy bytes for the destination's SSE config (#8908) The remaining failure mode in #8908 was that Docker Registry's blob finalization (server-side Move via UploadPartCopy) silently corrupts SSE-S3 multipart objects. Reproduces with `aws s3api upload-part-copy` under bucket-default SSE-S3: the GET on the completed object returns deterministic wrong bytes (correct length, same wrong SHA-256 across runs). The metadata is mathematically self-consistent — every chunk's stored IV equals `calculateIVWithOffset(baseIV_dst, partLocalOffset)` — but the bytes on disk were encrypted with the SOURCE upload's key+baseIV. Root cause: - `copyChunksForRange` (and `createDestinationChunk`) constructs new chunks for UploadPartCopy without copying `SseType` / `SseMetadata`, so destination chunks are written with `SseType=NONE`. - At completion, `completedMultipartChunk` (PR #9224's NONE→SSE_S3 backfill, intended to recover from a different missing-metadata bug) sees those NONE chunks under an SSE-S3 multipart upload and backfills SSE-S3 metadata derived from the destination upload's baseIV. The chunk metadata is now internally consistent and the GET path applies decryption — but the bytes on disk are encrypted with the source upload's key, not the destination's. Decryption produces deterministic garbage. Docker Registry pulls then fail with "Digest did not match". Fix: when either the source object or the destination multipart upload has any SSE configured, take a slow-path UploadPartCopy that (1) opens a plaintext reader of the source range — decrypting the source's per-chunk SSE-S3 metadata if needed via a reused `buildMultipartSSES3Reader`, and (2) feeds that plaintext through `putToFiler`'s existing encryption pipeline by staging the destination upload entry's SSE-S3/SSE-KMS headers on a cloned request. Encryption then matches PutObjectPart's contract: every part starts a fresh CTR stream from counter 0 with `baseIV_dst`, and each internal chunk's metadata records `calculateIVWithOffset(baseIV_dst, chunk.partLocalOffset)`. The `non-SSE → non-SSE` case still takes the existing fast raw-byte copy path — bytes on disk are plaintext on both sides, so chunk-level metadata is irrelevant. Cross-encryption from SSE-KMS / SSE-C sources is left as TODO — the new path returns an explicit error rather than the previous silent corruption. SSE-S3 (the user-reported case) round-trips correctly. Tests: - test/s3/sse/s3_sse_uploadpartcopy_integration_test.go pins three UploadPartCopy shapes against bucket-default SSE-S3: * Docker-Registry-shape 32MB+tail (the user's exact 5-chunk / 2-part metadata layout) * single full-object UploadPartCopy * many small range copies Each round-trips SHA-256. - test/s3/sse/s3_sse_concurrent_repro_test.go covers the parallel multipart-upload shape from the user report (5 blobs in parallel, full GET and chunked range GET both hash-checked) — pre-existing coverage; added here as a regression sentinel. * test(s3-sse): rename UploadPartCopy regression test so CI matches it The CI workflow .github/workflows/s3-sse-tests.yml dispatches on the TEST_PATTERN ".*Multipart.*Integration" — i.e. the test name must contain both "Multipart" and "Integration" for CI to run it. The previous name TestSSES3UploadPartCopyIntegration had only "Integration"; "UploadPart" isn't "Multipart". Rename to TestSSES3MultipartUploadPartCopyIntegration so the regression test actually runs in CI rather than only locally. * fix(s3api): map unsupported UploadPartCopy SSE source to 501, not 500 (review feedback on #9280) openSourcePlaintextReader explicitly rejects SSE-KMS and SSE-C sources (SSE-S3 is the only one wired up in this slow path so far). Earlier the caller blanket-mapped that to ErrInternalError, which collapses "this shape isn't implemented yet" into the same 500 response a real server failure would produce. Clients can no longer tell whether they hit a feature gap or a bug. Introduce a sentinel errCopySourceSSEUnsupported and have copyObjectPartViaReencryption errors.Is-check it; on match, return ErrNotImplemented (501) instead of ErrInternalError (500). Other failures still map to 500. Found by coderabbitai review on PR #9280. * fix(s3api): UploadPartCopy must fail with NoSuchUpload when upload entry is missing (review feedback on #9280) CopyObjectPartHandler's earlier checkUploadId call only verifies that the uploadID's hash prefix matches dstObject; it does not prove the upload directory exists in the filer. The previous logic silently swallowed filer_pb.ErrNotFound from getEntry(uploadDir) and fell through with uploadEntry=nil, which then skipped the destination SSE check and could route a plain-source copy through the raw-byte fast path even though the destination's encryption state is unknown. Treat ErrNotFound as ErrNoSuchUpload so the client sees the right status, matching the AWS S3 contract for UploadPartCopy on a non-existent upload. Found by coderabbitai review on PR #9280. * feat(s3api): set SSE response headers on UploadPartCopy slow path (review feedback on #9280) PutObjectPartHandler writes x-amz-server-side-encryption (and the KMS key-id header for SSE-KMS) on every successful part response so clients can confirm the destination's encryption state. The new UploadPartCopy slow path was missing this — it returned only the ETag in the response body and no SSE response headers. Plumb putToFiler's SSEResponseMetadata back through copyObjectPartViaReencryption to the handler, then call setSSEResponseHeaders before writing the XML response, matching the PutObjectPart contract. Found by gemini-code-assist review on PR #9280. * fix(s3api): map transient filer errors on UploadPartCopy upload-entry fetch to 503 (review feedback on #9280) Earlier non-ErrNotFound errors from getEntry(uploadDir, uploadID) all returned 500 InternalError, which most SDKs treat as fatal — even though a transient filer outage (gRPC Unavailable, leader election in flight, deadline exceeded) is exactly the kind of failure SDK retry logic is supposed to recover from. Add an isTransientFilerError helper that recognises: - context.DeadlineExceeded / context.Canceled - gRPC codes.Unavailable, DeadlineExceeded, ResourceExhausted, Aborted When the upload-entry fetch fails for one of those reasons, return 503 ServiceUnavailable so the client retries; everything else still maps to 500. Log line now also carries dstObject (in addition to dstBucket and uploadID) to make incident triage easier. Found by gemini-code-assist review on PR #9280.
191 lines
6.8 KiB
Go
191 lines
6.8 KiB
Go
package sse_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestSSES3MultipartUploadPartCopyIntegration pins the fix for issue #8908.
|
|
//
|
|
// Docker Registry's S3 storage driver finalizes blob uploads via a server-side
|
|
// "Move" pattern: a streaming PUT/multipart upload to a temporary key, then
|
|
// CreateMultipartUpload + UploadPartCopy(s) + CompleteMultipartUpload to put
|
|
// the bytes at the final blob path. Under bucket-default SSE-S3, every push
|
|
// goes through this UploadPartCopy step.
|
|
//
|
|
// Before the fix, copyChunksForRange did a raw byte copy that left the
|
|
// destination's part chunks SseType=NONE. Then completedMultipartChunk
|
|
// (PR #9224) saw NONE chunks in an SSE-S3 multipart upload and "backfilled"
|
|
// SSE-S3 metadata with IVs derived from the destination upload's baseIV. But
|
|
// the bytes on disk had been encrypted with the SOURCE upload's key+baseIV,
|
|
// so the read path decrypted with the wrong IV — yielding deterministic byte
|
|
// corruption on GET (the "Digest did not match" symptom kubelet surfaces).
|
|
//
|
|
// This test reproduces the exact shape: a 39MB plaintext source (single
|
|
// PutObject — auto-chunked into multiple internal SSE-S3 chunks on disk
|
|
// because of bucket-default SSE-S3), then a fresh multipart upload at a new
|
|
// destination key with two UploadPartCopy parts (32MB + 7MB) and Complete.
|
|
// The full GET must SHA back to what was uploaded.
|
|
//
|
|
// The function name contains both "Multipart" and "Integration" so it is matched
|
|
// by the `.*Multipart.*Integration` pattern in .github/workflows/s3-sse-tests.yml
|
|
// and the `TestSSE.*Integration` pattern in test/s3/sse/Makefile, ensuring this
|
|
// regression coverage runs in CI.
|
|
func TestSSES3MultipartUploadPartCopyIntegration(t *testing.T) {
|
|
ctx := context.Background()
|
|
client, err := createS3Client(ctx, defaultConfig)
|
|
require.NoError(t, err, "Failed to create S3 client")
|
|
|
|
bucketName, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"sse-s3-uploadpartcopy-")
|
|
require.NoError(t, err, "Failed to create test bucket")
|
|
defer cleanupTestBucket(ctx, client, bucketName)
|
|
|
|
// Bucket-default SSE-S3 — same setup Docker Registry uses.
|
|
_, err = client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
|
|
Bucket: aws.String(bucketName),
|
|
ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{
|
|
Rules: []types.ServerSideEncryptionRule{
|
|
{
|
|
ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{
|
|
SSEAlgorithm: types.ServerSideEncryptionAes256,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err, "Failed to set bucket default SSE-S3")
|
|
|
|
// Source: 39MB single PutObject (auto-chunked internally into 5 SSE-S3 chunks).
|
|
const sourceSize = 39 * 1024 * 1024
|
|
sourceData := generateTestData(sourceSize)
|
|
expectedSHA := sha256.Sum256(sourceData)
|
|
|
|
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String("source-blob"),
|
|
Body: bytes.NewReader(sourceData),
|
|
})
|
|
require.NoError(t, err, "Failed to upload source object")
|
|
|
|
// Sanity check: the source itself must round-trip correctly.
|
|
{
|
|
resp, err := client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String("source-blob"),
|
|
})
|
|
require.NoError(t, err, "Failed to GET source")
|
|
got, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedSHA, sha256.Sum256(got), "source object must round-trip")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
// part definitions: each entry is (start, end) byte range to copy.
|
|
parts [][2]int64
|
|
}{
|
|
{
|
|
// Docker Registry's typical Move shape for blobs around 40MB:
|
|
// one 32MB part + one tail part. This is exactly the metadata
|
|
// shape the user reported (5 dst chunks across 2 multipart parts).
|
|
name: "DockerRegistry_32MB_Plus_Tail",
|
|
parts: [][2]int64{
|
|
{0, 32*1024*1024 - 1},
|
|
{32 * 1024 * 1024, sourceSize - 1},
|
|
},
|
|
},
|
|
{
|
|
// Single full-object UploadPartCopy.
|
|
name: "Single_Full_Object_Copy",
|
|
parts: [][2]int64{
|
|
{0, sourceSize - 1},
|
|
},
|
|
},
|
|
{
|
|
// Many small range-copies — exercises the per-part-local-offset
|
|
// IV math under varied chunk-overlap shapes.
|
|
name: "Many_5MB_Ranges",
|
|
parts: [][2]int64{
|
|
{0, 5*1024*1024 - 1},
|
|
{5 * 1024 * 1024, 10*1024*1024 - 1},
|
|
{10 * 1024 * 1024, 15*1024*1024 - 1},
|
|
{15 * 1024 * 1024, 20*1024*1024 - 1},
|
|
{20 * 1024 * 1024, sourceSize - 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dstKey := "dest-" + strings.ToLower(strings.ReplaceAll(tc.name, "_", "-"))
|
|
|
|
createResp, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(dstKey),
|
|
})
|
|
require.NoError(t, err, "CreateMultipartUpload")
|
|
uploadID := aws.ToString(createResp.UploadId)
|
|
|
|
completedParts := make([]types.CompletedPart, 0, len(tc.parts))
|
|
for i, rng := range tc.parts {
|
|
partNumber := int32(i + 1)
|
|
resp, err := client.UploadPartCopy(ctx, &s3.UploadPartCopyInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(dstKey),
|
|
PartNumber: aws.Int32(partNumber),
|
|
UploadId: aws.String(uploadID),
|
|
CopySource: aws.String(bucketName + "/source-blob"),
|
|
CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", rng[0], rng[1])),
|
|
})
|
|
require.NoErrorf(t, err, "UploadPartCopy part %d range=[%d,%d]", partNumber, rng[0], rng[1])
|
|
completedParts = append(completedParts, types.CompletedPart{
|
|
ETag: resp.CopyPartResult.ETag,
|
|
PartNumber: aws.Int32(partNumber),
|
|
})
|
|
}
|
|
|
|
_, err = client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(dstKey),
|
|
UploadId: aws.String(uploadID),
|
|
MultipartUpload: &types.CompletedMultipartUpload{Parts: completedParts},
|
|
})
|
|
require.NoError(t, err, "CompleteMultipartUpload")
|
|
|
|
// Two-pass verification: full GET (Docker Registry / Kubelet shape).
|
|
resp, err := client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(dstKey),
|
|
})
|
|
require.NoError(t, err, "GetObject")
|
|
defer resp.Body.Close()
|
|
|
|
h := sha256.New()
|
|
n, err := io.Copy(h, resp.Body)
|
|
require.NoError(t, err, "stream GET body")
|
|
require.Equal(t, int64(sourceSize), n, "GET length")
|
|
|
|
var actual [32]byte
|
|
copy(actual[:], h.Sum(nil))
|
|
|
|
require.Equalf(t, expectedSHA, actual,
|
|
"UploadPartCopy SHA mismatch (#8908):\n expected sha256:%s\n got sha256:%s\n parts=%d size=%d",
|
|
hex.EncodeToString(expectedSHA[:]),
|
|
hex.EncodeToString(actual[:]),
|
|
len(tc.parts), n)
|
|
})
|
|
}
|
|
}
|