From 7c0a99284f312a683ad6810c5dccd459232471ef Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Thu, 12 Nov 2026 07:49:47 +0800 Subject: [PATCH] fix(read): handle indexed ranges within the first encrypted block Signed-off-by: Feng Ruohang --- cmd/object-api-utils.go | 2 +- cmd/object-compressed-range_test.go | 103 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 cmd/object-compressed-range_test.go diff --git a/cmd/object-api-utils.go b/cmd/object-api-utils.go index dbe202f50..6ee1f45be 100644 --- a/cmd/object-api-utils.go +++ b/cmd/object-api-utils.go @@ -916,7 +916,7 @@ func NewGetObjectReader(rs *HTTPRangeSpec, oi ObjectInfo, opts ObjectOptions, h } // Decompression reader. var dopts []s2.ReaderOption - if off > 0 || decOff > 0 { + if off > 0 || decOff > 0 || decryptSkip > 0 { // We are not starting at the beginning, so ignore stream identifiers. dopts = append(dopts, s2.ReaderIgnoreStreamIdentifier()) } diff --git a/cmd/object-compressed-range_test.go b/cmd/object-compressed-range_test.go new file mode 100644 index 000000000..0470d6c81 --- /dev/null +++ b/cmd/object-compressed-range_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Feng Ruohang +// +// 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 . + +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/crypto" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/kms" +) + +// Indexed reads may skip into the first decrypted block while both the disk +// offset and the remaining decompressed skip are zero. The reader must still +// accept a compression block without the original stream identifier. +func TestAPICompressedEncryptedRange(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { + previousKMS, previousTLS := GlobalKMS, globalIsTLS + var err error + GlobalKMS, err = kms.NewBuiltin("range-key", bytes.Repeat([]byte{0x42}, 32)) + if err != nil { + t.Fatal(err) + } + globalIsTLS = true + defer func() { GlobalKMS, globalIsTLS = previousKMS, previousTLS }() + restore := setCopyChecksumCompression(true) + defer restore() + part := bytes.Repeat([]byte("compressible-A"), 1<<20) + for _, mode := range []string{"compressed", "sse-s3", "sse-kms"} { + for _, multipart := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/multipart=%t", backend, mode, multipart), func(t *testing.T) { + key := fmt.Sprintf("range-%s-%t.txt", mode, multipart) + headers := map[string]string{} + switch mode { + case "sse-s3": + headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES + case "sse-kms": + headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS + headers[xhttp.AmzServerSideEncryptionKmsID] = "range-key" + } + data := part + if multipart { + data = attributesPartsUpload(t, router, cred, bucket, key, headers, + [][]byte{part, bytes.Repeat([]byte("compressible-B"), 1<<20)}, []int{1, 2}) + } else { + putCopyChecksumSource(t, router, cred, bucket, key, data, headers) + } + oi, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + _, encrypted := crypto.IsEncrypted(oi.UserDefined) + if !oi.IsCompressed() || encrypted != (mode != "compressed") || len(oi.Parts) == 0 || len(oi.Parts[0].Index) == 0 { + t.Fatal("fixture must have indexed compression and the requested encryption") + } + if encrypted { + off, decOff, _, skip, _ := getCompressedOffsets(oi, 1<<20, func(b []byte) ([]byte, error) { return oi.compressionIndexDecrypt(b, nil) }) + if off != 0 || decOff != 0 || skip <= 0 { + t.Fatalf("fixture misses boundary: off=%d decOff=%d decryptSkip=%d", off, decOff, skip) + } + } + offsets := []int{0, 100, 1 << 20, 1<<20 + 1, len(data) - 1000} + if multipart { + offsets = append(offsets, len(part)-500, len(part), len(part)+1<<20) + } + for _, start := range offsets { + rec := attributesPartsSignedRequest(t, router, cred, http.MethodGet, getGetObjectURL("", bucket, key), nil, + map[string]string{"Range": fmt.Sprintf("bytes=%d-%d", start, start+999)}) + if rec.Code != http.StatusPartialContent || !bytes.Equal(rec.Body.Bytes(), data[start:start+1000]) { + t.Errorf("range at %d: status=%d bytes=%d, want 206 and exact slice", start, rec.Code, rec.Body.Len()) + } + } + rec := attributesPartsSignedRequest(t, router, cred, http.MethodGet, getGetObjectURL("", bucket, key), nil, nil) + if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), data) { + t.Fatal("full read changed after ranged reads") + } + }) + } + } + }, + }) +}