package sse_test import ( "bytes" "context" "errors" "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" smithyhttp "github.com/aws/smithy-go/transport/http" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // internalChunkSize mirrors the constant in weed/s3api/s3api_object_handlers_put.go // (the size at which auto-chunking splits a single PUT or part body inside the // volume-server layer). Range-read coverage that's interesting for SSE has to // straddle this boundary, since each internal chunk is encrypted with its own // adjusted IV (SSE-S3 / SSE-KMS) or its own random IV + PartOffset (SSE-C), // and the read path has to stitch keystreams across chunks correctly. const internalChunkSize = 8 * 1024 * 1024 // TestSSERangeReadIntegration is the canonical end-to-end coverage matrix // for HTTP range GETs across SSE modes, object size classes, and range // patterns. It supplements the per-SSE-mode TestSSExxxRangeRequests tests // (which are scoped to small single-chunk objects, ≤1MB) by also exercising // MEDIUM single-PUT objects that cross one internal 8MB chunk boundary AND // LARGE multipart objects whose content spans many internal chunks. The // many-chunk case is the path that broke in #8908 for full-object GETs; // pinning range correctness here protects against any future regression in // per-chunk IV / PartOffset plumbing for partial reads. // // The function name ends in "Integration" so it is matched by the existing // `TestSSE.*Integration` pattern that the test/s3/sse Makefile and the // .github/workflows/s3-sse-tests.yml CI flow use to discover SSE integration // tests; both flows already start the server using s3-config-template.json, // which configures the embedded `local` KMS provider with on-demand DEK // creation, so the sse_kms subtests run end-to-end in CI. // // For ad-hoc local runs against a server without any KMS provider, the test // probes once with a 1-byte SSE-KMS PUT and t.Skip's the sse_kms subtree // with a clear message rather than producing a 5xx-storm in the logs. func TestSSERangeReadIntegration(t *testing.T) { ctx := context.Background() client, err := createS3Client(ctx, defaultConfig) require.NoError(t, err, "create S3 client") bucketName, err := createTestBucket(ctx, client, defaultConfig.BucketPrefix+"range-matrix-") require.NoError(t, err, "create test bucket") // MUST be t.Cleanup, not defer: the mode/size subtests below call // t.Parallel(), which pauses them and yields back to this function. If // we used defer, the for loop would finish scheduling, the function // would return, and defer would fire BEFORE any parallel subtest body // has run -- deleting the bucket out from under them. t.Cleanup waits // until the test AND all its subtests complete. t.Cleanup(func() { cleanupTestBucket(ctx, client, bucketName) }) modes := []sseRangeMode{ newRangeModeNone(), newRangeModeSSEC(), newRangeModeSSEKMS("test-range-coverage-key"), newRangeModeSSES3(), } // Size classes. Sizes are chosen to stress specific boundaries: // small : single internal chunk, no boundary // medium : one internal chunk boundary (8MB+arbitrary tail) // large : multipart with parts > 8MB, so each part itself spans // multiple internal chunks AND the object spans multiple // parts -- this is the shape the #8908 fix targets. sizes := []sseRangeSize{ { name: "small_256KB_single_chunk", singlePutBytes: 256 * 1024, }, { name: "medium_12MB_one_internal_boundary", singlePutBytes: internalChunkSize + 4*1024*1024, }, { name: "large_multipart_5x9MB_many_internal_boundaries", // 5 parts of 9MB each: 45MB total. Every part exceeds the 8MB // internal chunk size, so every part is split into 2 internal // chunks (8MB + 1MB), giving ~10 internal chunks across the // object. With AWS's 5MB minimum part size, this is the // smallest realistic shape that exercises both inter-part // stitching and intra-part chunk-boundary crossing. multipartParts: []int{ 9 * 1024 * 1024, 9 * 1024 * 1024, 9 * 1024 * 1024, 9 * 1024 * 1024, 9 * 1024 * 1024, }, }, } // Each (mode, size) pair uploads an independent object key under the // shared bucket and exercises range reads against it. The four modes // have no shared state (each one carries its own SSE-C key, KMS keyID, // or none); within a mode each size class also writes a unique key. // That makes both levels safe to t.Parallel(), which substantially cuts // CI wall time on the matrix (~45MB of data per mode). for _, mode := range modes { mode := mode t.Run(mode.name(), func(t *testing.T) { t.Parallel() if reason := mode.probe(t, ctx, client, bucketName); reason != "" { t.Skipf("%s unsupported in this test environment: %s", mode.name(), reason) } for _, sz := range sizes { sz := sz t.Run(sz.name, func(t *testing.T) { t.Parallel() objectKey := fmt.Sprintf("%s/%s", mode.name(), sz.name) var data []byte if len(sz.multipartParts) > 0 { parts := make([][]byte, len(sz.multipartParts)) for i, n := range sz.multipartParts { parts[i] = generateTestData(n) } mode.uploadMultipart(t, ctx, client, bucketName, objectKey, parts) data = bytes.Join(parts, nil) } else { data = generateTestData(sz.singlePutBytes) mode.uploadSingle(t, ctx, client, bucketName, objectKey, data) } for _, rc := range rangeCasesFor(int64(len(data))) { rc := rc t.Run(rc.name, func(t *testing.T) { verifyRangeRead(t, ctx, client, mode, bucketName, objectKey, data, rc) }) } }) } }) } } // rangeCasesFor returns the set of range patterns to exercise on an object of // the given total length. Some patterns are skipped automatically when the // object is too small for them to be meaningful (e.g. the many-chunk-spanning // case requires the object to actually span many internal chunks). func rangeCasesFor(totalLen int64) []sseRangeCase { cases := []sseRangeCase{ { name: "single_byte_at_zero", rangeHeader: "bytes=0-0", start: 0, end: 0, }, { name: "prefix_512_bytes", rangeHeader: "bytes=0-511", start: 0, end: 511, }, { name: "single_byte_at_last", rangeHeader: fmt.Sprintf("bytes=%d-%d", totalLen-1, totalLen-1), start: totalLen - 1, end: totalLen - 1, }, { name: "suffix_last_100_bytes", rangeHeader: "bytes=-100", start: totalLen - 100, end: totalLen - 1, }, { name: "open_ended_from_middle", rangeHeader: fmt.Sprintf("bytes=%d-", totalLen/2), start: totalLen / 2, end: totalLen - 1, }, { name: "whole_object_as_range", rangeHeader: fmt.Sprintf("bytes=0-%d", totalLen-1), start: 0, end: totalLen - 1, }, } if totalLen >= 64 { cases = append(cases, sseRangeCase{ // AES block-boundary stress: a 17-byte range starting at byte 15 // crosses the AES block boundary at byte 16. SSE-C historically // has the most fragile offset arithmetic here, so this is worth // pinning across all modes. name: "mid_chunk_crosses_aes_block_boundary", rangeHeader: "bytes=15-31", start: 15, end: 31, }) } if totalLen > internalChunkSize+128 { // Range straddles one internal 8MB chunk boundary by 64 bytes on // each side. Decryption has to fetch two distinct chunks and stitch // the keystreams together correctly -- the path that exposed #8908's // SSE-KMS double-IV bug (fixed in #9224 commit 4) and is the most // sensitive single test for chunk-boundary stitching. cases = append(cases, sseRangeCase{ name: "mid_straddles_one_internal_boundary", rangeHeader: fmt.Sprintf("bytes=%d-%d", internalChunkSize-64, internalChunkSize+63), start: internalChunkSize - 64, end: internalChunkSize + 63, }) } if totalLen > 3*internalChunkSize+128 { // Range that spans more than three internal 8MB chunks. This is // the regression path for #8908's read-side issue: the eager // multipart reader opened all chunks at once and could close // later ones via keepalive while earlier ones were still being // drained; range path uses the per-chunk view helpers (always // lazy) but a generous-size cross-many-chunks range is still the // best end-to-end pin that the per-chunk IV plumbing is correct // across part and chunk boundaries. start := int64(internalChunkSize/2) + 5 end := start + 3*internalChunkSize + 17 if end >= totalLen { end = totalLen - 1 } cases = append(cases, sseRangeCase{ name: "mid_spans_many_internal_boundaries", rangeHeader: fmt.Sprintf("bytes=%d-%d", start, end), start: start, end: end, }) } return cases } // sseRangeCase is one (start,end) range to GET, with the literal Range header // to send so we cover both `bytes=N-M`, `bytes=N-`, and `bytes=-N` forms. type sseRangeCase struct { name string rangeHeader string start, end int64 // inclusive byte offsets in the source data } type sseRangeSize struct { name string singlePutBytes int // if >0, upload via PutObject of this many random bytes multipartParts []int // if non-empty, multipart upload with these part sizes (in bytes) } // sseRangeMode is the per-SSE-type behavior plug for the matrix: how to // configure CreateBucket / PutObject / CreateMultipartUpload / UploadPart / // GetObject, and what to assert on GET responses. type sseRangeMode interface { name() string // probe attempts a 1-byte upload and returns "" on success or a short // reason string if the test environment doesn't support this mode (used // to t.Skip the SSE-KMS subtests when no KMS provider is configured). probe(t *testing.T, ctx context.Context, client *s3.Client, bucket string) string uploadSingle(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, data []byte) uploadMultipart(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte) configureGet(in *s3.GetObjectInput) verifyGet(t *testing.T, resp *s3.GetObjectOutput) } func newRangeModeNone() sseRangeMode { return &rangeModeNone{} } type rangeModeNone struct{} func (m *rangeModeNone) name() string { return "no_sse" } func (m *rangeModeNone) probe(t *testing.T, ctx context.Context, client *s3.Client, bucket string) string { return "" } func (m *rangeModeNone) uploadSingle(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, data []byte) { t.Helper() _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(data), }) require.NoError(t, err, "PutObject") } func (m *rangeModeNone) uploadMultipart(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte) { t.Helper() multipartUpload(t, ctx, client, bucket, key, parts, nil, nil) } func (m *rangeModeNone) configureGet(in *s3.GetObjectInput) {} func (m *rangeModeNone) verifyGet(t *testing.T, resp *s3.GetObjectOutput) { t.Helper() assert.Empty(t, string(resp.ServerSideEncryption), "no SSE response header expected for plaintext object") } func newRangeModeSSEC() sseRangeMode { return &rangeModeSSEC{key: generateSSECKey()} } type rangeModeSSEC struct { key *SSECKey } func (m *rangeModeSSEC) name() string { return "sse_c" } func (m *rangeModeSSEC) probe(t *testing.T, ctx context.Context, client *s3.Client, bucket string) string { return "" } func (m *rangeModeSSEC) uploadSingle(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, data []byte) { t.Helper() _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(data), SSECustomerAlgorithm: aws.String("AES256"), SSECustomerKey: aws.String(m.key.KeyB64), SSECustomerKeyMD5: aws.String(m.key.KeyMD5), }) require.NoError(t, err, "PutObject SSE-C") } func (m *rangeModeSSEC) uploadMultipart(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte) { t.Helper() multipartUpload(t, ctx, client, bucket, key, parts, func(in *s3.CreateMultipartUploadInput) { in.SSECustomerAlgorithm = aws.String("AES256") in.SSECustomerKey = aws.String(m.key.KeyB64) in.SSECustomerKeyMD5 = aws.String(m.key.KeyMD5) }, func(in *s3.UploadPartInput) { in.SSECustomerAlgorithm = aws.String("AES256") in.SSECustomerKey = aws.String(m.key.KeyB64) in.SSECustomerKeyMD5 = aws.String(m.key.KeyMD5) }, ) } func (m *rangeModeSSEC) configureGet(in *s3.GetObjectInput) { in.SSECustomerAlgorithm = aws.String("AES256") in.SSECustomerKey = aws.String(m.key.KeyB64) in.SSECustomerKeyMD5 = aws.String(m.key.KeyMD5) } func (m *rangeModeSSEC) verifyGet(t *testing.T, resp *s3.GetObjectOutput) { t.Helper() assert.Equal(t, "AES256", aws.ToString(resp.SSECustomerAlgorithm)) assert.Equal(t, m.key.KeyMD5, aws.ToString(resp.SSECustomerKeyMD5)) } func newRangeModeSSEKMS(keyID string) sseRangeMode { return &rangeModeSSEKMS{keyID: keyID} } type rangeModeSSEKMS struct { keyID string } func (m *rangeModeSSEKMS) name() string { return "sse_kms" } func (m *rangeModeSSEKMS) probe(t *testing.T, ctx context.Context, client *s3.Client, bucket string) string { t.Helper() probeKey := "__probe__" + m.name() _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(probeKey), Body: bytes.NewReader([]byte{0}), ServerSideEncryption: types.ServerSideEncryptionAwsKms, SSEKMSKeyId: aws.String(m.keyID), }) if err != nil { // "KMS provider not configured" is the friendly diagnostic that // causes the caller to t.Skip the SSE-KMS subtree. We use it for // two narrow categories: // 1. 5xx responses -- the s3api InternalError surface when no KMS // provider is wired up at server start. // 2. Specific error strings ("KMS.NotConfigured" / // "NotImplemented" / "not configured") regardless of status, // to catch other servers that may use a 4xx/501 to signal the // same condition. // We deliberately do NOT auto-skip on a generic 4xx: a real // SSE-KMS misconfiguration in the test request itself (bad keyID // format, missing header, etc.) also surfaces as a 400, and the // CI-meaningful path -- where the server IS configured for KMS -- // must fail loudly in that case rather than silently skip the // integration coverage. var apiErr *smithyhttp.ResponseError if errors.As(err, &apiErr) { if code := apiErr.HTTPStatusCode(); code >= 500 { return fmt.Sprintf("KMS provider not configured (PutObject returned %d)", code) } } errMsg := err.Error() if strings.Contains(errMsg, "KMS.NotConfigured") || strings.Contains(errMsg, "NotImplemented") || strings.Contains(errMsg, "not configured") { return fmt.Sprintf("KMS provider not configured: %v", err) } return fmt.Sprintf("KMS PutObject probe failed: %v", err) } // Best-effort cleanup of the probe object. _, _ = client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: aws.String(probeKey), }) return "" } func (m *rangeModeSSEKMS) uploadSingle(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, data []byte) { t.Helper() _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(data), ServerSideEncryption: types.ServerSideEncryptionAwsKms, SSEKMSKeyId: aws.String(m.keyID), }) require.NoError(t, err, "PutObject SSE-KMS") } func (m *rangeModeSSEKMS) uploadMultipart(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte) { t.Helper() multipartUpload(t, ctx, client, bucket, key, parts, func(in *s3.CreateMultipartUploadInput) { in.ServerSideEncryption = types.ServerSideEncryptionAwsKms in.SSEKMSKeyId = aws.String(m.keyID) }, // SSE-KMS does not require per-part headers (server reuses upload-init key). nil, ) } func (m *rangeModeSSEKMS) configureGet(in *s3.GetObjectInput) {} func (m *rangeModeSSEKMS) verifyGet(t *testing.T, resp *s3.GetObjectOutput) { t.Helper() assert.Equal(t, types.ServerSideEncryptionAwsKms, resp.ServerSideEncryption) assert.Equal(t, m.keyID, aws.ToString(resp.SSEKMSKeyId)) } func newRangeModeSSES3() sseRangeMode { return &rangeModeSSES3{} } type rangeModeSSES3 struct{} func (m *rangeModeSSES3) name() string { return "sse_s3" } func (m *rangeModeSSES3) probe(t *testing.T, ctx context.Context, client *s3.Client, bucket string) string { return "" } func (m *rangeModeSSES3) uploadSingle(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, data []byte) { t.Helper() _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(data), ServerSideEncryption: types.ServerSideEncryptionAes256, }) require.NoError(t, err, "PutObject SSE-S3") } func (m *rangeModeSSES3) uploadMultipart(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte) { t.Helper() multipartUpload(t, ctx, client, bucket, key, parts, func(in *s3.CreateMultipartUploadInput) { in.ServerSideEncryption = types.ServerSideEncryptionAes256 }, // SSE-S3 multipart parts inherit encryption from the upload init. nil, ) } func (m *rangeModeSSES3) configureGet(in *s3.GetObjectInput) {} func (m *rangeModeSSES3) verifyGet(t *testing.T, resp *s3.GetObjectOutput) { t.Helper() assert.Equal(t, types.ServerSideEncryptionAes256, resp.ServerSideEncryption) } // multipartUpload is a small helper shared across SSE modes that need to // assemble the test object via Create / UploadPart / Complete with optional // per-mode header injection. It registers a t.Cleanup that aborts the upload // if Complete didn't run successfully, so a test failure mid-way doesn't // leave an orphan upload behind. func multipartUpload(t *testing.T, ctx context.Context, client *s3.Client, bucket, key string, parts [][]byte, configCreate func(*s3.CreateMultipartUploadInput), configPart func(*s3.UploadPartInput)) { t.Helper() createIn := &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucket), Key: aws.String(key), } if configCreate != nil { configCreate(createIn) } createResp, err := client.CreateMultipartUpload(ctx, createIn) require.NoError(t, err, "CreateMultipartUpload") uploadID := aws.ToString(createResp.UploadId) completed := false t.Cleanup(func() { if completed { return } _, _ = client.AbortMultipartUpload(context.Background(), &s3.AbortMultipartUploadInput{ Bucket: aws.String(bucket), Key: aws.String(key), UploadId: aws.String(uploadID), }) }) completedParts := make([]types.CompletedPart, 0, len(parts)) for i, part := range parts { partNumber := int32(i + 1) in := &s3.UploadPartInput{ Bucket: aws.String(bucket), Key: aws.String(key), PartNumber: aws.Int32(partNumber), UploadId: aws.String(uploadID), Body: bytes.NewReader(part), } if configPart != nil { configPart(in) } resp, err := client.UploadPart(ctx, in) require.NoError(t, err, "UploadPart %d", partNumber) completedParts = append(completedParts, types.CompletedPart{ ETag: resp.ETag, PartNumber: aws.Int32(partNumber), }) } _, err = client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucket), Key: aws.String(key), UploadId: aws.String(uploadID), MultipartUpload: &types.CompletedMultipartUpload{Parts: completedParts}, }) require.NoError(t, err, "CompleteMultipartUpload") completed = true } // verifyRangeRead does the actual GET + assertions for one (mode, object, // range case). It checks: the body bytes match the source slice; the // Content-Length header matches the range length; the Content-Range header // matches the resolved byte range; the SSE response headers match the mode. func verifyRangeRead(t *testing.T, ctx context.Context, client *s3.Client, mode sseRangeMode, bucket, key string, source []byte, rc sseRangeCase) { t.Helper() totalLen := int64(len(source)) in := &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Range: aws.String(rc.rangeHeader), } mode.configureGet(in) resp, err := client.GetObject(ctx, in) require.NoError(t, err, "GetObject %s range=%s", key, rc.rangeHeader) defer resp.Body.Close() got, err := io.ReadAll(resp.Body) require.NoError(t, err, "read range body") expected := source[rc.start : rc.end+1] expectedLen := rc.end - rc.start + 1 // Body-length check is `require` rather than `assert` because the bug // fixed in #8908 surfaces as a fully-readable body that is shorter than // requested -- a "truncation regression". Comparing different-length // slices with assertDataEqual below would just produce a noisy byte-diff // on top of the underlying truncation; bailing here keeps the failure // log focused on the symptom that actually matters. require.Equal(t, len(expected), len(got), "body length mismatch for %s range=%s (source size=%d) — likely truncation regression", key, rc.rangeHeader, totalLen) assert.Equal(t, expectedLen, aws.ToInt64(resp.ContentLength), "Content-Length header mismatch for %s range=%s", key, rc.rangeHeader) // Content-Range: bytes start-end/total wantContentRange := fmt.Sprintf("bytes %d-%d/%d", rc.start, rc.end, totalLen) assert.Equal(t, wantContentRange, aws.ToString(resp.ContentRange), "Content-Range header mismatch for %s range=%s", key, rc.rangeHeader) // Compare bytes with a hash-only assertion to keep failure output small // (the actual byte content is random and unhelpful printed verbatim). assertDataEqual(t, expected, got, "Range body mismatch for %s range=%s", key, rc.rangeHeader) mode.verifyGet(t, resp) }