package s3api import ( "bytes" "crypto/rand" "fmt" "io" "sync/atomic" "testing" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" ) // TestMultipartSSES3RealisticEndToEnd reproduces the production multipart SSE-S3 // flow where ALL parts share the same DEK and baseIV (the upload-init key/IV), // and each part is encrypted with partOffset=0. Each part is then chunked at 8MB // boundaries the way UploadReaderInChunks does. After completion, the chunks // have global offsets but per-chunk stored IVs derived from part-local offsets. // // buildMultipartSSES3Reader is then run on the assembled chunks; the // concatenated decrypted output must equal the concatenation of the part // plaintexts. This is the round trip that fails in #8908 if anything in the // encrypt or decrypt path is inconsistent. func TestMultipartSSES3RealisticEndToEnd(t *testing.T) { keyManager := initSSES3KeyManagerForTest(t) // One DEK and one baseIV, shared by all parts (the upload-init values). key, err := GenerateSSES3Key() if err != nil { t.Fatalf("GenerateSSES3Key: %v", err) } baseIV := make([]byte, s3_constants.AESBlockSize) if _, err := rand.Read(baseIV); err != nil { t.Fatalf("rand.Read baseIV: %v", err) } const chunkSize = int64(8 * 1024 * 1024) // Realistic mix of part sizes: small (one chunk), exact 8MB, >8MB (two // chunks), much larger (multiple chunks). partSizes := []int{ 5 * 1024 * 1024, // 5MB (single chunk) 8 * 1024 * 1024, // 8MB exactly (single chunk, full) 8*1024*1024 + 123, // crosses chunk boundary (two chunks) 17 * 1024 * 1024, // three chunks 1234, // tiny } parts := make([][]byte, len(partSizes)) for i, n := range partSizes { parts[i] = makeRandomPlaintext(t, n) } // Build the chunks list the way completion would produce it: encrypt each // part with partOffset=0, slice the ciphertext at chunkSize boundaries, // store per-chunk metadata IV = calculateIVWithOffset(baseIV, partLocalOff), // then assign GLOBAL offsets to the FileChunk. type chunkBlob struct { fid string ciphertext []byte } var chunks []*filer_pb.FileChunk chunkData := map[string][]byte{} var globalOffset int64 for partIdx, partPlaintext := range parts { encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(partPlaintext), key, baseIV, 0) if err != nil { t.Fatalf("CreateSSES3EncryptedReaderWithBaseIV(part %d): %v", partIdx, err) } ciphertext, err := io.ReadAll(encReader) if err != nil { t.Fatalf("read encrypted part %d: %v", partIdx, err) } for partLocalOff := int64(0); partLocalOff < int64(len(ciphertext)); partLocalOff += chunkSize { end := partLocalOff + chunkSize if end > int64(len(ciphertext)) { end = int64(len(ciphertext)) } cipherSlice := ciphertext[partLocalOff:end] chunkIV, _ := calculateIVWithOffset(baseIV, partLocalOff) chunkKey := &SSES3Key{ Key: key.Key, KeyID: key.KeyID, Algorithm: key.Algorithm, IV: chunkIV, } meta, err := SerializeSSES3Metadata(chunkKey) if err != nil { t.Fatalf("SerializeSSES3Metadata(part %d off %d): %v", partIdx, partLocalOff, err) } fid := fmt.Sprintf("%d,%d", partIdx+1, partLocalOff) chunks = append(chunks, &filer_pb.FileChunk{ FileId: fid, Offset: globalOffset, // global offset assigned at completion Size: uint64(end - partLocalOff), SseType: filer_pb.SSEType_SSE_S3, SseMetadata: meta, }) chunkData[fid] = cipherSlice globalOffset += end - partLocalOff } } fetch := func(c *filer_pb.FileChunk) (io.ReadCloser, error) { data, ok := chunkData[c.GetFileIdString()] if !ok { return nil, fmt.Errorf("unexpected chunk %s", c.GetFileIdString()) } return io.NopCloser(bytes.NewReader(data)), nil } reader, err := buildMultipartSSES3Reader(chunks, keyManager, fetch) if err != nil { t.Fatalf("buildMultipartSSES3Reader: %v", err) } got, err := io.ReadAll(reader) if err != nil { t.Fatalf("ReadAll decrypted output: %v", err) } want := bytes.Join(parts, nil) if !bytes.Equal(got, want) { idx := firstMismatch(got, want) end := idx + 32 if end > len(got) { end = len(got) } if end > len(want) { end = len(want) } t.Fatalf("decrypted output mismatch at byte %d (total len got=%d want=%d)\n got: %x\nwant: %x", idx, len(got), len(want), got[idx:end], want[idx:end]) } } // TestBuildMultipartSSES3Reader_LazyChunkFetch pins the lazy behavior of // buildMultipartSSES3Reader: chunk N's HTTP fetch only happens after chunk // N-1 has been fully consumed. The original eager loop opened every chunk's // HTTP response upfront and held them open while io.MultiReader walked // through readers[0]; for objects with many chunks (e.g. a 200MB Docker image // blob), this could trip volume-server idle/keepalive limits and produce // truncated reads at the client (issue #8908). // // The test installs a fetch hook that tracks how many chunks have been // opened and when each one is closed, and verifies: // - At any point during streaming, at most one chunk's reader is open. // - The number of opened chunks grows as bytes are read out, not upfront. // - All chunks are closed when the outer reader is fully drained. func TestBuildMultipartSSES3Reader_LazyChunkFetch(t *testing.T) { keyManager := initSSES3KeyManagerForTest(t) key, err := GenerateSSES3Key() if err != nil { t.Fatalf("GenerateSSES3Key: %v", err) } baseIV := make([]byte, s3_constants.AESBlockSize) if _, err := rand.Read(baseIV); err != nil { t.Fatalf("rand.Read baseIV: %v", err) } // Many small chunks (mirrors many-part Docker Registry uploads). const numChunks = 8 const chunkPayload = 1024 plaintexts := make([][]byte, numChunks) chunkData := map[string][]byte{} chunks := make([]*filer_pb.FileChunk, 0, numChunks) for i := 0; i < numChunks; i++ { plaintexts[i] = makeRandomPlaintext(t, chunkPayload) // Encrypt as a fresh "part" with partOffset=0 (matching putToFiler). encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(plaintexts[i]), key, baseIV, 0) if err != nil { t.Fatalf("encrypt chunk %d: %v", i, err) } ciphertext, err := io.ReadAll(encReader) if err != nil { t.Fatalf("read ciphertext %d: %v", i, err) } chunkIV, _ := calculateIVWithOffset(baseIV, 0) chunkKey := &SSES3Key{ Key: key.Key, KeyID: key.KeyID, Algorithm: key.Algorithm, IV: chunkIV, } meta, err := SerializeSSES3Metadata(chunkKey) if err != nil { t.Fatalf("serialize meta %d: %v", i, err) } fid := fmt.Sprintf("vol,c%d", i) chunks = append(chunks, &filer_pb.FileChunk{ FileId: fid, Offset: int64(i) * chunkPayload, Size: uint64(chunkPayload), SseType: filer_pb.SSEType_SSE_S3, SseMetadata: meta, }) chunkData[fid] = ciphertext } var openCount int64 // total opens var liveCount int64 // currently open var maxLive int64 fetch := func(c *filer_pb.FileChunk) (io.ReadCloser, error) { atomic.AddInt64(&openCount, 1) if live := atomic.AddInt64(&liveCount, 1); live > atomic.LoadInt64(&maxLive) { atomic.StoreInt64(&maxLive, live) } data, ok := chunkData[c.GetFileIdString()] if !ok { return nil, fmt.Errorf("unexpected chunk %s", c.GetFileIdString()) } return &liveTrackingReadCloser{Reader: bytes.NewReader(data), live: &liveCount}, nil } reader, err := buildMultipartSSES3Reader(chunks, keyManager, fetch) if err != nil { t.Fatalf("buildMultipartSSES3Reader: %v", err) } // Construction alone must not have opened any chunk reader. if got := atomic.LoadInt64(&openCount); got != 0 { t.Fatalf("expected no chunks opened before any Read, got %d", got) } // Read first byte: should open chunk 0 only. one := make([]byte, 1) if n, err := reader.Read(one); n != 1 || err != nil { t.Fatalf("first Read: n=%d err=%v", n, err) } if got := atomic.LoadInt64(&openCount); got != 1 { t.Errorf("after first byte, expected 1 chunk opened, got %d", got) } if got := atomic.LoadInt64(&liveCount); got != 1 { t.Errorf("after first byte, expected 1 chunk live, got %d", got) } // Drain the rest. rest, err := io.ReadAll(reader) if err != nil { t.Fatalf("drain: %v", err) } got := append(one, rest...) want := bytes.Join(plaintexts, nil) if !bytes.Equal(got, want) { idx := firstMismatch(got, want) t.Fatalf("decrypted output mismatch at byte %d (got len %d, want len %d)", idx, len(got), len(want)) } if got := atomic.LoadInt64(&openCount); got != int64(numChunks) { t.Errorf("expected exactly %d chunk opens after drain, got %d", numChunks, got) } if got := atomic.LoadInt64(&maxLive); got > 1 { t.Errorf("expected at most 1 chunk reader live at a time (lazy), saw peak of %d", got) } if got := atomic.LoadInt64(&liveCount); got != 0 { t.Errorf("expected all chunks closed after drain, %d still live", got) } } type liveTrackingReadCloser struct { io.Reader live *int64 once bool } func (r *liveTrackingReadCloser) Close() error { if r.once { return nil } r.once = true atomic.AddInt64(r.live, -1) return nil }