From f5a43614d94fe7668c3c745b3c79b23344b6ab8e Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Fri, 26 Jun 2026 14:21:37 +0300 Subject: [PATCH] fstree: limit ranged payload stream from object start FSTree range reads returned the raw payload stream when the requested range started at offset zero and no payload bytes were buffered in the prefix. This could make ranged GET send more bytes than requested and trigger client-side payload size overflow. Signed-off-by: Andrey Butusov --- CHANGELOG.md | 1 + .../blobstor/fstree/fstree.go | 8 +-- .../blobstor/fstree/fstree_test.go | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d41d7d862..59cb3b3d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Changelog for NeoFS Node - Data race in RANGE response buffer (#4013) - Incorrect access denial during EACL rechecks for payload-only GET (#4024, #4029) - neofs-ir does not fill `neofs_ir_state_epoch` metrics on startup and does not keep it actual after FS chain connection loss (#4035) +- Ranged object reads from FSTree sometimes returning more payload bytes than requested (#4046) ### Changed - Optimized EC GET request execution (#3996) diff --git a/pkg/local_object_storage/blobstor/fstree/fstree.go b/pkg/local_object_storage/blobstor/fstree/fstree.go index 0220fe1ea7..62c6c9b0ad 100644 --- a/pkg/local_object_storage/blobstor/fstree/fstree.go +++ b/pkg/local_object_storage/blobstor/fstree/fstree.go @@ -645,14 +645,14 @@ func (t *FSTree) shiftPayloadRangeStream(prefix []byte, pldLen uint64, pldFldOff // stream is non-nil here according to conditions above - if len(prefix) == 0 { - return stream, nil - } - if err := checkTooBigRange(off, ln); err != nil { return nil, err } + if len(prefix) == 0 { + return &limitedFileReader{ReadSeekCloser: stream, limit: int64(ln)}, nil + } + return newPrefixedReadSeekCloser(prefix, &limitedFileReader{ReadSeekCloser: stream, limit: int64(ln) - int64(len(prefix))}), nil } diff --git a/pkg/local_object_storage/blobstor/fstree/fstree_test.go b/pkg/local_object_storage/blobstor/fstree/fstree_test.go index d9a6970a02..de742eab85 100644 --- a/pkg/local_object_storage/blobstor/fstree/fstree_test.go +++ b/pkg/local_object_storage/blobstor/fstree/fstree_test.go @@ -16,7 +16,9 @@ import ( oid "github.com/nspcc-dev/neofs-sdk-go/object/id" oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" objecttest "github.com/nspcc-dev/neofs-sdk-go/object/test" + protoobject "github.com/nspcc-dev/neofs-sdk-go/proto/object" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" ) func TestAddressToString(t *testing.T) { @@ -68,6 +70,52 @@ func testReadPayloadRange(t *testing.T, fst *FSTree) { }) } +func TestFSTree_ReadPayloadRangeLimitsEmptyPrefixStream(t *testing.T) { + fst := setupFSTree(t) + payload := []byte("payload") + + baseHeader := protowire.AppendTag(nil, protoobject.FieldHeaderPayloadLength, protowire.VarintType) + baseHeader = protowire.AppendVarint(baseHeader, uint64(len(payload))) + + // keep payload bytes out of the initially buffered prefix while preserving valid protobuf wire + const prefixLen = iobject.NonPayloadFieldsBufferLength + const readBufLen = 2 * iobject.NonPayloadFieldsBufferLength + payloadPrefixLen := protowire.SizeTag(protoobject.FieldObjectPayload) + protowire.SizeVarint(uint64(len(payload))) + headerPaddingFieldNum := protowire.Number(protoobject.FieldHeaderPayloadLength + 1) + headerPaddingLen := prefixLen + for { + headerPaddingFieldLen := protowire.SizeTag(headerPaddingFieldNum) + protowire.SizeVarint(uint64(headerPaddingLen)) + headerPaddingLen + headerLen := len(baseHeader) + headerPaddingFieldLen + n := prefixLen - protowire.SizeTag(protoobject.FieldObjectHeader) - protowire.SizeVarint(uint64(headerLen)) - len(baseHeader) - protowire.SizeTag(headerPaddingFieldNum) - protowire.SizeVarint(uint64(headerPaddingLen)) - payloadPrefixLen + require.GreaterOrEqual(t, n, 0) + if n == headerPaddingLen { + break + } + headerPaddingLen = n + } + + baseHeader = protowire.AppendTag(baseHeader, headerPaddingFieldNum, protowire.BytesType) + baseHeader = protowire.AppendBytes(baseHeader, make([]byte, headerPaddingLen)) + + objWire := protowire.AppendTag(nil, protoobject.FieldObjectHeader, protowire.BytesType) + objWire = protowire.AppendBytes(objWire, baseHeader) + objWire = protowire.AppendTag(objWire, protoobject.FieldObjectPayload, protowire.BytesType) + objWire = protowire.AppendVarint(objWire, uint64(len(payload))) + require.Len(t, objWire, prefixLen) + objWire = append(objWire, payload...) + + addr := oidtest.Address() + require.NoError(t, fst.Put(addr, objWire)) + + stream, err := fst.ReadPayloadRange(addr, 0, 3, make([]byte, readBufLen)) + require.NoError(t, err) + defer stream.Close() + + actual, err := io.ReadAll(stream) + require.NoError(t, err) + require.Equal(t, payload[:3], actual) +} + func testGetRangeStreamFunc(t *testing.T, fst *FSTree, fn func(fst *FSTree, addr oid.Address, off, ln uint64) (io.ReadCloser, error)) { const pldLen = 1024 pld := testutil.RandByteSlice(pldLen) @@ -90,6 +138,7 @@ func testGetRangeStreamFunc(t *testing.T, fst *FSTree, fn func(fst *FSTree, addr for _, tc := range []struct{ off, ln uint64 }{ {off: 0, ln: 0}, + {off: 0, ln: pldLen / 2}, {off: 0, ln: pldLen}, {off: 1, ln: pldLen - 1}, {off: pldLen - 1, ln: 1},