Skip to content

Commit 9d6af5b

Browse files
tcoratgerclaude
andauthored
refactor(varint): parametrize the canonical LEB128 codec with max_bytes (leanEthereum#798)
The networking and snappy modules each carried a self-contained LEB128 implementation. The two algorithms were identical, differing only in byte cap (10 vs 5). One implementation was a slow drift away from the other waiting to happen. Keep the networking varint as the single source of truth and give both encode and decode a max_bytes parameter (default 10 = uint64 cap). Snappy now imports the canonical codec and passes 5 via a new SNAPPY_VARINT_MAX_BYTES constant. Encode now also enforces the cap, so per-cap semantics are symmetric. The snappy length-prefix tests run against the canonical codec, plus an integration test that asserts an oversize prefix surfaces as a SnappyDecompressionError. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a588bf3 commit 9d6af5b

8 files changed

Lines changed: 126 additions & 201 deletions

File tree

src/lean_spec/node/networking/varint.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,30 +114,40 @@ class VarintError(Exception):
114114
"""Raised when varint encoding or decoding fails."""
115115

116116

117-
def encode_varint(value: int) -> bytes:
117+
def encode_varint(value: int, max_bytes: int = 10) -> bytes:
118118
"""
119119
Encode an unsigned integer as LEB128 varint.
120120
121121
Splits the integer into 7-bit groups, emitting each as one byte.
122122
All bytes except the last have the continuation bit (0x80) set.
123123
124124
Args:
125-
value: Non-negative integer to encode. Maximum: 2^64 - 1.
125+
value: Non-negative integer to encode.
126+
max_bytes: Upper bound on the encoded byte count.
127+
Defaults to 10, which is the cap for a 64-bit value.
128+
Pass 5 for a 32-bit cap, matching the snappy length prefix.
126129
127130
Returns:
128131
Varint-encoded bytes. Length depends on value:
129132
130133
- 0-127: 1 byte
131134
- 128-16383: 2 bytes
132135
- 16384-2097151: 3 bytes
133-
- Up to 10 bytes for 64-bit values
136+
- Up to max_bytes for the largest representable value
134137
135138
Raises:
136-
ValueError: If value is negative.
139+
ValueError: If value is negative or does not fit in max_bytes.
137140
"""
138141
if value < 0:
139142
raise ValueError("Varint must be non-negative")
140143

144+
# Reject values that would need more than max_bytes to encode.
145+
#
146+
# Each output byte carries 7 data bits.
147+
# Anything that does not fit in max_bytes * 7 bits is rejected here.
148+
if value >> (7 * max_bytes):
149+
raise ValueError(f"Varint value does not fit in {max_bytes} bytes")
150+
141151
result = bytearray()
142152

143153
# Process 7 bits at a time until the value fits in 7 bits.
@@ -160,7 +170,7 @@ def encode_varint(value: int) -> bytes:
160170
return bytes(result)
161171

162172

163-
def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
173+
def decode_varint(data: bytes, offset: int = 0, max_bytes: int = 10) -> tuple[int, int]:
164174
"""
165175
Decode a varint from bytes at the given offset.
166176
@@ -170,6 +180,9 @@ def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
170180
Args:
171181
data: Input bytes containing the varint.
172182
offset: Starting position in data. Defaults to 0.
183+
max_bytes: Upper bound on the encoded byte count.
184+
Defaults to 10, which is the cap for a 64-bit value.
185+
Pass 5 for a 32-bit cap, matching the snappy length prefix.
173186
174187
Returns:
175188
Tuple of (decoded_value, bytes_consumed).
@@ -179,8 +192,8 @@ def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
179192
180193
Raises:
181194
VarintError: If the input is truncated (runs out of bytes
182-
before finding the final byte) or exceeds 10 bytes
183-
(would overflow 64 bits).
195+
before finding the final byte) or exceeds max_bytes
196+
(would overflow the declared range).
184197
"""
185198
result = 0
186199
shift = 0
@@ -212,10 +225,10 @@ def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
212225

213226
# Guard against malformed input that never terminates.
214227
#
215-
# A 64-bit value needs at most 10 bytes (70 bits, with 6 unused).
216-
# If we've shifted 70+ bits and still see continuation, the input
217-
# is invalid or represents a value larger than we can handle.
218-
if shift >= 70:
219-
raise VarintError("Varint too long")
228+
# A varint capped at max_bytes carries at most max_bytes * 7 bits.
229+
# Once we have already consumed that many bytes and still see
230+
# a continuation bit, the input is invalid or out of range.
231+
if pos - offset >= max_bytes:
232+
raise VarintError(f"Varint exceeds {max_bytes} bytes")
220233

221234
return result, pos - offset

src/lean_spec/node/snappy/compress.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,17 @@
6363

6464
from __future__ import annotations
6565

66+
from lean_spec.node.networking.varint import encode_varint
67+
6668
from .constants import (
6769
BLOCK_SIZE,
6870
HASH_MULTIPLIER,
6971
INPUT_MARGIN_BYTES,
7072
MAX_HASH_TABLE_BITS,
7173
MIN_HASH_TABLE_BITS,
74+
SNAPPY_VARINT_MAX_BYTES,
7275
)
73-
from .encoding import encode_copy_tag, encode_literal_tag, encode_varint32
76+
from .encoding import encode_copy_tag, encode_literal_tag
7477

7578

7679
def compress(data: bytes) -> bytes:
@@ -91,13 +94,13 @@ def compress(data: bytes) -> bytes:
9194
#
9295
# Even empty data needs a length prefix (varint 0).
9396
if not data:
94-
return encode_varint32(0)
97+
return encode_varint(0, max_bytes=SNAPPY_VARINT_MAX_BYTES)
9598

9699
# Build output buffer.
97100
#
98101
# Start with the uncompressed length as a varint.
99102
# The decompressor reads this first to allocate the output buffer.
100-
output = bytearray(encode_varint32(len(data)))
103+
output = bytearray(encode_varint(len(data), max_bytes=SNAPPY_VARINT_MAX_BYTES))
101104

102105
# Process input in blocks.
103106
#

src/lean_spec/node/snappy/constants.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,13 @@
146146

147147
#
148148
# The uncompressed length is encoded as a varint at the start of the
149-
# compressed data. Varints use 7 bits per byte, with the high bit
150-
# indicating continuation.
149+
# compressed data. The shared LEB128 codec from the networking layer
150+
# handles encoding and decoding. The cap below bounds the prefix length
151+
# to the 32-bit range defined by the Snappy format.
151152

152-
VARINT_CONTINUATION_BIT: Final = 0x80
153-
"""High bit set in varint bytes to indicate more bytes follow."""
153+
SNAPPY_VARINT_MAX_BYTES: Final = 5
154+
"""Maximum byte count for the uncompressed length prefix.
154155
155-
VARINT_DATA_MASK: Final = 0x7F
156-
"""Mask to extract the 7 data bits from a varint byte."""
156+
Five bytes carry thirty-five data bits.
157+
This is the smallest LEB128 length that covers the full 32-bit range
158+
used by the Snappy format for the uncompressed payload size."""

src/lean_spec/node/snappy/decompress.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@
7171

7272
from __future__ import annotations
7373

74-
from .encoding import decode_tag, decode_varint32
74+
from lean_spec.node.networking.varint import VarintError, decode_varint
75+
76+
from .constants import SNAPPY_VARINT_MAX_BYTES
77+
from .encoding import decode_tag
7578

7679

7780
class SnappyDecompressionError(Exception):
@@ -100,8 +103,10 @@ def decompress(data: bytes) -> bytes:
100103
#
101104
# Example: data = [0x08, ...] -> length = 8
102105
try:
103-
uncompressed_length, varint_bytes = decode_varint32(data, 0)
104-
except ValueError as e:
106+
uncompressed_length, varint_bytes = decode_varint(
107+
data, 0, max_bytes=SNAPPY_VARINT_MAX_BYTES
108+
)
109+
except VarintError as e:
105110
raise SnappyDecompressionError(f"Invalid length varint: {e}") from e
106111

107112
# Length = 0 is valid: the original data was empty.

src/lean_spec/node/snappy/encoding.py

Lines changed: 5 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
This module provides the low-level encoding and decoding primitives used by
55
both the compressor and decompressor:
66
7-
1. **Varint encoding**: Variable-length integer encoding for the uncompressed
8-
length prefix. Small values use fewer bytes, saving space.
9-
10-
2. **Tag byte encoding**: Compact representation of literal and copy operations.
7+
1. **Tag byte encoding**: Compact representation of literal and copy operations.
118
The tag byte format packs operation type and length into minimal space.
129
10+
The uncompressed length prefix uses the shared LEB128 varint codec from the
11+
networking layer with a five-byte cap, matching the 32-bit range used by the
12+
Snappy format specification.
13+
1314
Reference: https://github.com/google/snappy/blob/main/format_description.txt
1415
"""
1516

@@ -31,161 +32,11 @@
3132
MAX_COPY_2_OFFSET,
3233
MAX_INLINE_LITERAL_LENGTH,
3334
MIN_COPY_1_LENGTH,
34-
VARINT_CONTINUATION_BIT,
35-
VARINT_DATA_MASK,
3635
)
3736

3837
type TagType = Literal["literal", "copy"]
3938
"""Snappy tag type: either a literal (raw bytes) or a copy (back-reference)."""
4039

41-
# Varint Encoding
42-
#
43-
# Varints encode integers using as few bytes as possible.
44-
# - Small values use fewer bytes.
45-
# - Large values use more.
46-
#
47-
# Each byte has 8 bits:
48-
# - Bit 7 (high): continuation flag.
49-
# - 1 = more bytes follow,
50-
# - 0 = this is the last byte.
51-
# - Bits 0-6 (low): 7 bits of the integer value.
52-
#
53-
# Bytes are emitted least-significant chunk first.
54-
#
55-
# Byte count by value:
56-
# 0 .. 127 -> 1 byte
57-
# 128 .. 16,383 -> 2 bytes
58-
# 16,384 .. 2,097,151 -> 3 bytes
59-
# 2,097,152 .. 268,435,455 -> 4 bytes
60-
# 268,435,456 .. 2^32 - 1 -> 5 bytes
61-
#
62-
# Example: encoding 300
63-
#
64-
# 300 in binary: 100101100 (9 bits, needs 2 chunks of 7 bits)
65-
#
66-
# Chunk 1 (bits 0-6): 0101100 = 44. More bits remain, so continuation = 1.
67-
# Byte 1 = 0x80 | 44 = 0xAC
68-
#
69-
# Chunk 2 (bits 7+): 0000010 = 2. No more bits, so continuation = 0.
70-
# Byte 2 = 0x00 | 2 = 0x02
71-
#
72-
# Encoded: [0xAC, 0x02]
73-
#
74-
# Example: decoding [0xAC, 0x02]
75-
#
76-
# For each byte: check bit 7 for continuation, mask with 0x7F to get data.
77-
#
78-
# Byte 1 = 0xAC = 10101100:
79-
# bit 7 = 1 -> more bytes coming
80-
# data = 0xAC & 0x7F = 0101100 = 44
81-
# result = 44
82-
#
83-
# Byte 2 = 0x02 = 00000010:
84-
# bit 7 = 0 -> done
85-
# data = 0x02 & 0x7F = 0000010 = 2 (mask has no effect here)
86-
# result = 44 | (2 << 7) = 44 + 256 = 300
87-
88-
89-
def encode_varint32(value: int) -> bytes:
90-
"""Encode a 32-bit integer as a variable-length byte sequence.
91-
92-
The varint format uses 7 bits per byte for data, with the high bit
93-
indicating whether more bytes follow. This efficiently encodes small
94-
values in fewer bytes.
95-
96-
Algorithm:
97-
1. Take the lowest 7 bits of the value.
98-
2. If more bits remain, set the continuation bit (0x80).
99-
3. Repeat until all bits are encoded.
100-
101-
Args:
102-
value: Non-negative integer to encode (must fit in 32 bits).
103-
104-
Returns:
105-
Variable-length bytes encoding the integer (1-5 bytes).
106-
107-
Raises:
108-
ValueError: If value is negative or exceeds 32 bits.
109-
"""
110-
# Validate input range.
111-
# Varints in Snappy are unsigned 32-bit integers.
112-
if value < 0:
113-
raise ValueError(f"Varint value must be non-negative, got {value}")
114-
if value > 0xFFFFFFFF:
115-
raise ValueError(f"Varint value exceeds 32 bits: {value}")
116-
117-
# Build the encoding byte by byte.
118-
# We accumulate bytes in a list for efficiency.
119-
result: list[int] = []
120-
121-
while True:
122-
# Extract the lowest 7 bits.
123-
byte = value & VARINT_DATA_MASK
124-
125-
# Shift out the bits we just encoded.
126-
value >>= 7
127-
128-
if value != 0:
129-
# More bits remain: set continuation bit.
130-
byte |= VARINT_CONTINUATION_BIT
131-
132-
result.append(byte)
133-
134-
if value == 0:
135-
# All bits encoded.
136-
break
137-
138-
return bytes(result)
139-
140-
141-
def decode_varint32(data: bytes, offset: int = 0) -> tuple[int, int]:
142-
"""Decode a varint from a byte sequence at the given offset.
143-
144-
Reads bytes starting at offset, accumulating 7 bits per byte into
145-
the result. Stops when a byte without the continuation bit is found.
146-
147-
Args:
148-
data: Byte sequence containing the varint.
149-
offset: Position in data where the varint starts.
150-
151-
Returns:
152-
Tuple of (decoded_value, bytes_consumed).
153-
154-
Raises:
155-
ValueError: If the varint is malformed (too long or truncated).
156-
"""
157-
result = 0
158-
shift = 0
159-
bytes_read = 0
160-
161-
while True:
162-
# Check bounds.
163-
if offset + bytes_read >= len(data):
164-
raise ValueError("Truncated varint: unexpected end of data")
165-
166-
# Read next byte.
167-
byte = data[offset + bytes_read]
168-
bytes_read += 1
169-
170-
# Accumulate the 7 data bits at the current shift position.
171-
result |= (byte & VARINT_DATA_MASK) << shift
172-
shift += 7
173-
174-
# Check if this is the last byte (no continuation bit).
175-
if (byte & VARINT_CONTINUATION_BIT) == 0:
176-
break
177-
178-
# Safety check: varints should not exceed 5 bytes for 32-bit values.
179-
# (5 bytes * 7 bits = 35 bits, which covers 32-bit range)
180-
if bytes_read >= 5:
181-
raise ValueError("Varint too long: exceeds 5 bytes")
182-
183-
# Verify the result fits in 32 bits.
184-
if result > 0xFFFFFFFF:
185-
raise ValueError(f"Varint overflow: {result} exceeds 32 bits")
186-
187-
return result, bytes_read
188-
18940

19041
# Tag Byte Encoding - Literals
19142
#

tests/lean_spec/node/networking/test_reqresp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def test_varint_11_bytes_rejected(self) -> None:
204204
malformed = bytes([0x80] * 10 + [0x01])
205205
assert len(malformed) == 11
206206

207-
with pytest.raises(VarintError, match="too long"):
207+
with pytest.raises(VarintError, match="exceeds 10 bytes"):
208208
decode_varint(malformed)
209209

210210
def test_payload_at_max_size(self) -> None:

0 commit comments

Comments
 (0)