Files
go-kv/wal/record_test.go
T
dailz 57e7525ddf fix: validate physical record length > 0 and type (H1+H2)
DecodePhysicalRecord only checked length upper bound, not lower bound
(length > 0 per design §3.2 line 389). It also didn't validate the
fragment type field (RecInvalid=0 and types > RecLast are corruption
indicators per design §3.2 line 366-372).

Corrupt data with length=0 could pass CRC (payload is empty, CRC only
covers length+type bytes) and inject empty records into the fragment
collector. Invalid type values would only be caught at the collector
level, wrapped as TailCorruptionError, rather than rejected at the
parser level.

Changes:
- wal/record.go: DecodePhysicalRecord now rejects length=0 and
  type ∉ {RecFull..RecLast} before payload copy and CRC check.
  Checks are ordered to reject invalid records ASAP.
- wal/record_test.go: 5 tests:
  - TestDecodePhysicalRecord_RejectZeroLength (H1)
  - TestDecodePhysicalRecord_RejectInvalidType (H2, type=0)
  - TestDecodePhysicalRecord_RejectUnknownType (H2, type>RecLast)
  - TestDecodePhysicalRecord_ValidRecordsUnaffected (regression for
    all 4 valid types)
  - TestParseBlockWrapsInvalidRecordAsTailCorruption (integration:
    ParseBlock wraps H1/H2 errors as TailCorruptionError)

Verified: all existing tests pass. Full suite green including
go test -race ./... .

Audit context: docs/audit-3.2.md H1+H2.
2026-06-18 13:59:04 +08:00

241 lines
7.0 KiB
Go

package wal
import (
"bytes"
"encoding/binary"
"hash/crc32"
"strings"
"testing"
)
func TestRecordRoundtrip(t *testing.T) {
payload := []byte("hello world")
encoded := EncodePhysicalRecord(RecFull, payload)
rec, consumed, err := DecodePhysicalRecord(encoded)
if err != nil {
t.Fatalf("DecodePhysicalRecord failed: %v", err)
}
if rec.Type != RecFull {
t.Errorf("expected type RecFull(%d), got %d", RecFull, rec.Type)
}
if string(rec.Payload) != "hello world" {
t.Errorf("expected payload 'hello world', got %q", string(rec.Payload))
}
if consumed != 7+len(payload) {
t.Errorf("expected consumed %d, got %d", 7+len(payload), consumed)
}
}
func TestSplitSmallPayload(t *testing.T) {
payload := make([]byte, 100)
for i := range payload {
payload[i] = byte(i)
}
records := SplitIntoRecords(payload)
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
rec, _, err := DecodePhysicalRecord(records[0])
if err != nil {
t.Fatalf("DecodePhysicalRecord failed: %v", err)
}
if rec.Type != RecFull {
t.Errorf("expected RecFull, got %d", rec.Type)
}
if !bytes.Equal(rec.Payload, payload) {
t.Error("payload mismatch")
}
}
func TestSplitIntoRecords(t *testing.T) {
// 40 KB payload → needs to split across blocks.
payload := make([]byte, 40*1024)
for i := range payload {
payload[i] = byte(i % 256)
}
records := SplitIntoRecords(payload)
if len(records) < 2 {
t.Fatalf("expected at least 2 records, got %d", len(records))
}
// Verify fragment sequence.
types := make([]uint8, len(records))
var concatenated []byte
for i, enc := range records {
rec, _, err := DecodePhysicalRecord(enc)
if err != nil {
t.Fatalf("DecodePhysicalRecord record %d failed: %v", i, err)
}
types[i] = rec.Type
concatenated = append(concatenated, rec.Payload...)
}
// First record must be RecFirst.
if types[0] != RecFirst {
t.Errorf("first record type: expected RecFirst(%d), got %d", RecFirst, types[0])
}
// Last record must be RecLast.
if types[len(types)-1] != RecLast {
t.Errorf("last record type: expected RecLast(%d), got %d", RecLast, types[len(types)-1])
}
// Middle records must be RecMiddle.
for i := 1; i < len(types)-1; i++ {
if types[i] != RecMiddle {
t.Errorf("record %d type: expected RecMiddle(%d), got %d", i, RecMiddle, types[i])
}
}
// Concatenated payloads must equal original.
if !bytes.Equal(concatenated, payload) {
t.Error("concatenated payloads do not match original")
}
}
func TestBlockPadding(t *testing.T) {
// blockOffset = WalBlockSize - 5 → remaining = 5, which is <= 7 → padding needed = 5.
blockOffset := uint32(WalBlockSize - 5)
padding := PaddingNeeded(blockOffset)
if padding != 5 {
t.Errorf("PaddingNeeded(%d): expected 5, got %d", blockOffset, padding)
}
// Cannot fit a record.
if CanFitRecord(blockOffset, 1) {
t.Error("CanFitRecord should return false when remaining <= 7")
}
// blockOffset = WalBlockSize - 8 → remaining = 8, which is > 7 → no padding needed.
blockOffset2 := uint32(WalBlockSize - 8)
padding2 := PaddingNeeded(blockOffset2)
if padding2 != 0 {
t.Errorf("PaddingNeeded(%d): expected 0, got %d", blockOffset2, padding2)
}
// Can fit a 1-byte payload: remaining=8, header=7, payload=1 → 8 >= 8.
if !CanFitRecord(blockOffset2, 1) {
t.Error("CanFitRecord should return true when remaining=8 and payloadLen=1")
}
// Cannot fit a 2-byte payload: remaining=8, header=7, payload=2 → 8 < 9.
if CanFitRecord(blockOffset2, 2) {
t.Error("CanFitRecord should return false when remaining=8 and payloadLen=2")
}
}
func TestCRCMismatch(t *testing.T) {
encoded := EncodePhysicalRecord(RecFull, []byte("test"))
// Corrupt a payload byte.
encoded[8] ^= 0xFF
_, _, err := DecodePhysicalRecord(encoded)
if err == nil {
t.Error("expected CRC mismatch error")
}
}
func TestDataTooShort(t *testing.T) {
_, _, err := DecodePhysicalRecord([]byte{1, 2, 3})
if err == nil {
t.Error("expected error for data too short")
}
}
// -------- H1+H2 regression guards --------
func TestDecodePhysicalRecord_RejectZeroLength(t *testing.T) {
buf := make([]byte, PhysicalRecordHeaderSize)
binary.LittleEndian.PutUint16(buf[4:6], 0)
buf[6] = RecFull
crc := crc32.Checksum(buf[4:7], crc32cTable)
binary.LittleEndian.PutUint32(buf[0:4], crc)
_, _, err := DecodePhysicalRecord(buf)
if err == nil {
t.Fatal("expected error for length=0")
}
if !strings.Contains(err.Error(), "length") {
t.Errorf("error should mention length, got: %v", err)
}
}
func TestDecodePhysicalRecord_RejectInvalidType(t *testing.T) {
payload := []byte("test")
buf := make([]byte, PhysicalRecordHeaderSize+len(payload))
binary.LittleEndian.PutUint16(buf[4:6], uint16(len(payload)))
buf[6] = RecInvalid
copy(buf[7:], payload)
crc := crc32.Checksum(buf[4:], crc32cTable)
binary.LittleEndian.PutUint32(buf[0:4], crc)
_, _, err := DecodePhysicalRecord(buf)
if err == nil {
t.Fatal("expected error for type=Invalid(0)")
}
if !strings.Contains(err.Error(), "type") {
t.Errorf("error should mention type, got: %v", err)
}
}
func TestDecodePhysicalRecord_RejectUnknownType(t *testing.T) {
payload := []byte("test")
buf := make([]byte, PhysicalRecordHeaderSize+len(payload))
binary.LittleEndian.PutUint16(buf[4:6], uint16(len(payload)))
buf[6] = RecLast + 1
copy(buf[7:], payload)
crc := crc32.Checksum(buf[4:], crc32cTable)
binary.LittleEndian.PutUint32(buf[0:4], crc)
_, _, err := DecodePhysicalRecord(buf)
if err == nil {
t.Fatal("expected error for type > RecLast")
}
if !strings.Contains(err.Error(), "type") {
t.Errorf("error should mention type, got: %v", err)
}
}
func TestDecodePhysicalRecord_ValidRecordsUnaffected(t *testing.T) {
for _, recType := range []uint8{RecFull, RecFirst, RecMiddle, RecLast} {
payload := []byte("test-payload")
encoded := EncodePhysicalRecord(recType, payload)
rec, consumed, err := DecodePhysicalRecord(encoded)
if err != nil {
t.Errorf("type %d: %v", recType, err)
}
if rec.Type != recType {
t.Errorf("Type = %d, want %d", rec.Type, recType)
}
if consumed != len(encoded) {
t.Errorf("consumed = %d, want %d", consumed, len(encoded))
}
}
}
// Oracle nice-to-have: integration test verifying ParseBlock wraps H1/H2
// errors as TailCorruptionError.
func TestParseBlockWrapsInvalidRecordAsTailCorruption(t *testing.T) {
// Build a block containing a valid record followed by a length=0 record
// with non-zero header (so ParseBlock doesn't treat it as zero padding).
validRec := EncodePhysicalRecord(RecFull, []byte("valid"))
invalidRec := make([]byte, PhysicalRecordHeaderSize)
binary.LittleEndian.PutUint16(invalidRec[4:6], 0) // length=0
invalidRec[6] = RecFull // non-zero type
crc := crc32.Checksum(invalidRec[4:7], crc32cTable)
binary.LittleEndian.PutUint32(invalidRec[0:4], crc)
block := append(validRec, invalidRec...)
_, err := ParseBlock(block)
if err == nil {
t.Fatal("expected TailCorruptionError for invalid record in block")
}
if !IsTailCorruption(err) {
t.Errorf("expected TailCorruptionError, got: %v", err)
}
}