feat(levin): portable storage varint encode/decode
Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
7089e0990c
commit
abc88f5c7a
2 changed files with 233 additions and 0 deletions
93
node/levin/varint.go
Normal file
93
node/levin/varint.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright (c) 2024-2026 Lethean Contributors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package levin
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Size-mark bits occupying the two lowest bits of the first byte.
|
||||
const (
|
||||
varintMask = 0x03
|
||||
varintMark1 = 0x00 // 1 byte, max 63
|
||||
varintMark2 = 0x01 // 2 bytes, max 16,383
|
||||
varintMark4 = 0x02 // 4 bytes, max 1,073,741,823
|
||||
varintMark8 = 0x03 // 8 bytes, max 4,611,686,018,427,387,903
|
||||
varintMax1 = 63
|
||||
varintMax2 = 16_383
|
||||
varintMax4 = 1_073_741_823
|
||||
varintMax8 = 4_611_686_018_427_387_903
|
||||
)
|
||||
|
||||
// ErrVarintTruncated is returned when the buffer is too short.
|
||||
var ErrVarintTruncated = errors.New("levin: truncated varint")
|
||||
|
||||
// ErrVarintOverflow is returned when the value is too large to encode.
|
||||
var ErrVarintOverflow = errors.New("levin: varint overflow")
|
||||
|
||||
// PackVarint encodes v using the epee portable-storage varint scheme.
|
||||
// The low two bits of the first byte indicate the total encoded width;
|
||||
// the remaining bits carry the value in little-endian order.
|
||||
func PackVarint(v uint64) []byte {
|
||||
switch {
|
||||
case v <= varintMax1:
|
||||
return []byte{byte((v << 2) | varintMark1)}
|
||||
case v <= varintMax2:
|
||||
raw := uint16((v << 2) | varintMark2)
|
||||
buf := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(buf, raw)
|
||||
return buf
|
||||
case v <= varintMax4:
|
||||
raw := uint32((v << 2) | varintMark4)
|
||||
buf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(buf, raw)
|
||||
return buf
|
||||
default:
|
||||
raw := (v << 2) | varintMark8
|
||||
buf := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(buf, raw)
|
||||
return buf
|
||||
}
|
||||
}
|
||||
|
||||
// UnpackVarint decodes one epee portable-storage varint from buf.
|
||||
// It returns the decoded value, the number of bytes consumed, and any error.
|
||||
func UnpackVarint(buf []byte) (value uint64, bytesConsumed int, err error) {
|
||||
if len(buf) == 0 {
|
||||
return 0, 0, ErrVarintTruncated
|
||||
}
|
||||
|
||||
mark := buf[0] & varintMask
|
||||
|
||||
switch mark {
|
||||
case varintMark1:
|
||||
value = uint64(buf[0]) >> 2
|
||||
return value, 1, nil
|
||||
case varintMark2:
|
||||
if len(buf) < 2 {
|
||||
return 0, 0, ErrVarintTruncated
|
||||
}
|
||||
raw := binary.LittleEndian.Uint16(buf[:2])
|
||||
value = uint64(raw) >> 2
|
||||
return value, 2, nil
|
||||
case varintMark4:
|
||||
if len(buf) < 4 {
|
||||
return 0, 0, ErrVarintTruncated
|
||||
}
|
||||
raw := binary.LittleEndian.Uint32(buf[:4])
|
||||
value = uint64(raw) >> 2
|
||||
return value, 4, nil
|
||||
case varintMark8:
|
||||
if len(buf) < 8 {
|
||||
return 0, 0, ErrVarintTruncated
|
||||
}
|
||||
raw := binary.LittleEndian.Uint64(buf[:8])
|
||||
value = raw >> 2
|
||||
return value, 8, nil
|
||||
default:
|
||||
// Unreachable — mark is masked to 2 bits.
|
||||
return 0, 0, ErrVarintTruncated
|
||||
}
|
||||
}
|
||||
140
node/levin/varint_test.go
Normal file
140
node/levin/varint_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Copyright (c) 2024-2026 Lethean Contributors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package levin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPackVarint_Value5(t *testing.T) {
|
||||
// 5 << 2 | 0x00 = 20 = 0x14
|
||||
got := PackVarint(5)
|
||||
assert.Equal(t, []byte{0x14}, got)
|
||||
}
|
||||
|
||||
func TestPackVarint_Value100(t *testing.T) {
|
||||
// 100 << 2 | 0x01 = 401 = 0x0191 → LE [0x91, 0x01]
|
||||
got := PackVarint(100)
|
||||
assert.Equal(t, []byte{0x91, 0x01}, got)
|
||||
}
|
||||
|
||||
func TestPackVarint_Value65536(t *testing.T) {
|
||||
// 65536 << 2 | 0x02 = 262146 = 0x00040002 → LE [0x02, 0x00, 0x04, 0x00]
|
||||
got := PackVarint(65536)
|
||||
assert.Equal(t, []byte{0x02, 0x00, 0x04, 0x00}, got)
|
||||
}
|
||||
|
||||
func TestPackVarint_Value2Billion(t *testing.T) {
|
||||
got := PackVarint(2_000_000_000)
|
||||
require.Len(t, got, 8)
|
||||
// Low 2 bits must be 0x03 (8-byte mark).
|
||||
assert.Equal(t, byte(0x03), got[0]&0x03)
|
||||
}
|
||||
|
||||
func TestPackVarint_Zero(t *testing.T) {
|
||||
got := PackVarint(0)
|
||||
assert.Equal(t, []byte{0x00}, got)
|
||||
}
|
||||
|
||||
func TestPackVarint_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint64
|
||||
wantLen int
|
||||
}{
|
||||
{"1-byte max (63)", 63, 1},
|
||||
{"2-byte min (64)", 64, 2},
|
||||
{"2-byte max (16383)", 16_383, 2},
|
||||
{"4-byte min (16384)", 16_384, 4},
|
||||
{"4-byte max (1073741823)", 1_073_741_823, 4},
|
||||
{"8-byte min (1073741824)", 1_073_741_824, 8},
|
||||
{"8-byte max", 4_611_686_018_427_387_903, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := PackVarint(tc.value)
|
||||
assert.Len(t, got, tc.wantLen, "wrong length for value %d", tc.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVarint_RoundTrip(t *testing.T) {
|
||||
values := []uint64{
|
||||
0, 1, 63, 64, 100, 16_383, 16_384,
|
||||
1_073_741_823, 1_073_741_824,
|
||||
4_611_686_018_427_387_903,
|
||||
}
|
||||
|
||||
for _, v := range values {
|
||||
buf := PackVarint(v)
|
||||
decoded, consumed, err := UnpackVarint(buf)
|
||||
require.NoError(t, err, "value %d", v)
|
||||
assert.Equal(t, v, decoded, "mismatch for value %d", v)
|
||||
assert.Equal(t, len(buf), consumed, "wrong bytes consumed for value %d", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackVarint_EmptyInput(t *testing.T) {
|
||||
_, _, err := UnpackVarint([]byte{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrVarintTruncated)
|
||||
}
|
||||
|
||||
func TestUnpackVarint_Truncated2Byte(t *testing.T) {
|
||||
// Encode 64 (needs 2 bytes), then only pass 1 byte.
|
||||
buf := PackVarint(64)
|
||||
require.Len(t, buf, 2)
|
||||
_, _, err := UnpackVarint(buf[:1])
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrVarintTruncated)
|
||||
}
|
||||
|
||||
func TestUnpackVarint_Truncated4Byte(t *testing.T) {
|
||||
buf := PackVarint(16_384)
|
||||
require.Len(t, buf, 4)
|
||||
_, _, err := UnpackVarint(buf[:2])
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrVarintTruncated)
|
||||
}
|
||||
|
||||
func TestUnpackVarint_Truncated8Byte(t *testing.T) {
|
||||
buf := PackVarint(1_073_741_824)
|
||||
require.Len(t, buf, 8)
|
||||
_, _, err := UnpackVarint(buf[:4])
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrVarintTruncated)
|
||||
}
|
||||
|
||||
func TestUnpackVarint_ExtraBytes(t *testing.T) {
|
||||
// Ensure that extra trailing bytes are not consumed.
|
||||
buf := append(PackVarint(42), 0xFF, 0xFF)
|
||||
decoded, consumed, err := UnpackVarint(buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(42), decoded)
|
||||
assert.Equal(t, 1, consumed)
|
||||
}
|
||||
|
||||
func TestPackVarint_SizeMarkBits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint64
|
||||
wantMark byte
|
||||
}{
|
||||
{"1-byte", 0, 0x00},
|
||||
{"2-byte", 64, 0x01},
|
||||
{"4-byte", 16_384, 0x02},
|
||||
{"8-byte", 1_073_741_824, 0x03},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := PackVarint(tc.value)
|
||||
assert.Equal(t, tc.wantMark, got[0]&0x03)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue