Files
seaweedfs/weed/storage/needle/needle_read_test.go
T
06dda12e4b fix(volume): validate sizes in ReadNeedleBlob and WriteNeedleBlob (#11399)
* fix(volume): reject negative sizes in ReadNeedleBlob and WriteNeedleBlob

A ReadNeedleBlob RPC with a size of -44 or below (-36 on v2 volumes)
panics in makeslice inside needle.ReadNeedleBlob. The volume gRPC server
has no recovery interceptor, so one request kills the process. Smaller
negative sizes return bytes that are not a record.

WriteNeedleBlob accepted a negative size whenever the blob header
carried the same value: it appended the blob to .dat and indexed the
needle with that size, which reads as deleted.

Reject size < 0 in both Volume methods. Size 0 still passes, since
delete records carry it. The Rust volume server got the same storage
guards in #11345.

* fix(volume): reject needle blobs whose length does not match their size

WriteNeedleBlob appends the blob as is. A blob that is not the length
its size implies leaves .dat off the 8-byte grid, and every later
ordinary write to the volume is indexed at a truncated offset and reads
back as EOF. A blob off by 8 bytes keeps the grid but leaves bytes that
a .dat scan reads as the next record.

The in-tree callers already send exact lengths. The one case this newly
refuses is a copy between volumes of different needle versions, and
that case already writes a broken record: a v3 record lands on a v2
volume with 8 extra bytes, and a v2 record on a v3 volume either fails
the timestamp check or lands 8 bytes short.

This is separate from the negative-size guards, whose Rust counterpart
is #11345. The Rust server does not check the length yet.

* fix(volume): guard the blob buffer allocation in needle.ReadNeedleBlob

Volume.ReadNeedleBlob rejected negative sizes, but needle.ReadNeedleBlob
still sized its buffer from the size and is called directly by vacuum and
other paths. Reject a deletion marker before make() there too, and use
size.IsDeleted() in the volume-level checks.

* fix(volume): mirror the blob length check in the rust volume server

write_needle_blob_and_index checked the size against the blob header but
appended the blob verbatim, so a blob that is not the length its size
implies still leaves .dat off the record grid. Match the Go check.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-09-19 21:28:37 -07:00

106 lines
3.6 KiB
Go

package needle
import (
"bytes"
"errors"
"strings"
"testing"
. "github.com/seaweedfs/seaweedfs/weed/storage/types"
)
// readNeedleBodyBytes runs ReadNeedleBodyBytes and turns a panic into a test
// failure, so a regression reports which case broke instead of killing the run.
func readNeedleBodyBytes(t *testing.T, n *Needle, body []byte, version Version) (err error) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Fatalf("ReadNeedleBodyBytes panicked for size %d, body length %d: %v", n.Size, len(body), r)
}
}()
return n.ReadNeedleBodyBytes(body, version)
}
// The size feeds the read buffer's length, so a negative one never reaches make().
func TestReadNeedleBlobRejectsNegativeSize(t *testing.T) {
for _, version := range []Version{Version1, Version2, Version3} {
for _, size := range []Size{TombstoneFileSize, -100} {
if _, err := ReadNeedleBlob(nil, 0, size, version); !errors.Is(err, ErrorSizeInvalid) {
t.Fatalf("version %d size %d: expected ErrorSizeInvalid, got %v", version, size, err)
}
}
}
}
// A corrupted .dat header can carry a size that does not fit the body read for
// it. Vacuum used to panic on it with "slice bounds out of range [:-1]" (#6763).
func TestReadNeedleBodyBytesRejectsCorruptSize(t *testing.T) {
for version := Version1; IsSupportedVersion(version); version++ {
t.Run(versionString(version), func(t *testing.T) {
// A size of -1 is the case from #6763: its body length is still
// positive, so the scan reads a body and hands it over.
bodyLength := NeedleBodyLength(-1, version)
if bodyLength <= 0 {
t.Fatalf("expected a positive body length for size -1, got %d", bodyLength)
}
cases := []struct {
name string
size Size
body int
}{
{"size -1", -1, int(bodyLength)},
{"size -12", -12, 32},
{"size larger than body", 64, 32},
{"no room for the tail", 32, 32},
{"empty body", 0, 0},
{"empty body with data size", 1, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := &Needle{Size: c.size}
err := readNeedleBodyBytes(t, n, make([]byte, c.body), version)
if !errors.Is(err, ErrorCorrupted) {
t.Fatalf("expected an error wrapping ErrorCorrupted, got %v", err)
}
})
}
})
}
}
// The size guard must still accept every record the writer produces,
// including the size-0 record a delete appends.
func TestReadNeedleBodyBytesWrittenNeedles(t *testing.T) {
for version := Version1; IsSupportedVersion(version); version++ {
t.Run(versionString(version), func(t *testing.T) {
for _, data := range [][]byte{nil, []byte("hello seaweed")} {
written := &Needle{Id: 7, Cookie: 9, Data: data, Checksum: NewCRC(data), AppendAtNs: 42}
buf := new(bytes.Buffer)
if _, _, err := writeNeedleByVersion(version, written, 0, buf); err != nil {
// Some builds can read a version they cannot write; skip
// only that recognized case so a real writer regression
// still fails the test.
if strings.Contains(strings.ToLower(err.Error()), "unsupported version") {
t.Skipf("version %d is not writable in this build: %v", version, err)
}
t.Fatalf("write needle: %v", err)
}
n := new(Needle)
n.ParseNeedleHeader(buf.Bytes())
body := buf.Bytes()[NeedleHeaderSize:]
if int64(len(body)) != NeedleBodyLength(n.Size, version) {
t.Fatalf("body length %d, want %d", len(body), NeedleBodyLength(n.Size, version))
}
if err := readNeedleBodyBytes(t, n, body, version); err != nil {
t.Fatalf("read %d-byte needle: %v", len(data), err)
}
if !bytes.Equal(n.Data, data) {
t.Fatalf("data %q, want %q", n.Data, data)
}
}
})
}
}