Skip to content

Commit 93ed5ce

Browse files
tcoratgerclaude
andauthored
refactor(types/ssz_base): reject trailing bytes on decode, polish docs (leanEthereum#768)
- decode_bytes now rejects bytes left over after the stream-based decoder finishes, closing a silent-acceptance path that a buggy or malicious subclass could otherwise hide. - Documentation rewritten per the project's documentation rules: drop filler ("This is the minimal interface..."), strip variable names from prose, remove type duplication from Args and Returns (the signature is already authoritative), split joined sentences onto one line each. - Replace the misleading Sequence[Any] | None annotation on the getattr return — getattr with a default returns Any | None, so no extra hint is needed. - Trim unused imports (Sequence, Any) and the no-op with-statements around BytesIO. No public API change, no behavior change beyond the trailing-bytes rejection. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3919faa commit 93ed5ce

1 file changed

Lines changed: 55 additions & 50 deletions

File tree

src/lean_spec/types/ssz_base.py

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,126 @@
1-
"""Base classes and interfaces for all SSZ types."""
1+
"""Abstract bases for the SSZ type system."""
22

33
import io
44
from abc import ABC, abstractmethod
5-
from collections.abc import Sequence
6-
from typing import IO, Any, Self
5+
from typing import IO, Self
76

87
from .base import StrictBaseModel
8+
from .exceptions import SSZSerializationError
99

1010

1111
class SSZType(ABC):
12-
"""
13-
Abstract base class for all SSZ types.
14-
15-
This is the minimal interface that all SSZ types must implement.
16-
Use SSZModel for Pydantic-based SSZ types.
17-
"""
12+
"""Abstract base for every SSZ-encodable type."""
1813

1914
@classmethod
2015
@abstractmethod
2116
def is_fixed_size(cls) -> bool:
22-
"""
23-
Check if the type has a fixed size in bytes.
17+
"""Whether every instance encodes to the same number of bytes.
2418
2519
Returns:
26-
bool: True if the size is fixed, False otherwise.
20+
True for fixed-size types, False for variable-size.
2721
"""
2822
...
2923

3024
@classmethod
3125
@abstractmethod
3226
def get_byte_length(cls) -> int:
33-
"""
34-
Get the byte length of the type if it is fixed-size.
35-
36-
Raises:
37-
TypeError: If the type is not fixed-size.
27+
"""Fixed encoded byte length of this type.
3828
3929
Returns:
40-
int: The number of bytes.
30+
The constant byte width every instance encodes to.
31+
32+
Raises:
33+
SSZTypeError: If the type is variable-size.
4134
"""
4235
...
4336

4437
@abstractmethod
4538
def serialize(self, stream: IO[bytes]) -> int:
46-
"""
47-
Serializes the object and writes it to a binary stream.
39+
"""Write the SSZ encoding to a binary stream.
4840
4941
Args:
50-
stream (IO[bytes]): The stream to write the serialized data to.
42+
stream: Output binary stream.
5143
5244
Returns:
53-
int: The number of bytes written.
45+
Number of bytes written.
5446
"""
5547
...
5648

5749
@classmethod
5850
@abstractmethod
5951
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
60-
"""
61-
Deserializes an object from a binary stream within a given scope.
52+
"""Read one value from a binary stream within a bounded byte budget.
6253
6354
Args:
64-
stream (IO[bytes]): The stream to read from.
65-
scope (int): The number of bytes available to read for this object.
55+
stream: Source binary stream.
56+
scope: Number of bytes belonging to this value.
6657
6758
Returns:
68-
Self: An instance of the class.
59+
A new instance reconstructed from the stream.
6960
"""
7061
...
7162

7263
def encode_bytes(self) -> bytes:
73-
"""
74-
Serializes the SSZ object to a byte string.
64+
"""Encode this value to its SSZ byte representation.
7565
7666
Returns:
77-
bytes: The serialized byte string.
67+
Serialized bytes.
7868
"""
79-
with io.BytesIO() as stream:
80-
self.serialize(stream)
81-
return stream.getvalue()
69+
stream = io.BytesIO()
70+
self.serialize(stream)
71+
return stream.getvalue()
8272

8373
@classmethod
8474
def decode_bytes(cls, data: bytes) -> Self:
85-
"""
86-
Deserializes a byte string into an SSZ object.
75+
"""Decode SSZ bytes into a new instance.
76+
77+
Rejects trailing bytes left over after the stream-based decoder finishes.
78+
A spec decoder must accept exactly one canonical encoding per value.
8779
8880
Args:
89-
data (bytes): The byte string to deserialize.
81+
data: SSZ-encoded bytes containing exactly one value.
9082
9183
Returns:
92-
Self: An instance of the class.
84+
A new instance reconstructed from the input.
85+
86+
Raises:
87+
SSZSerializationError: If the input carries bytes past the decoded value.
9388
"""
94-
with io.BytesIO(data) as stream:
95-
return cls.deserialize(stream, len(data))
89+
stream = io.BytesIO(data)
90+
instance = cls.deserialize(stream, len(data))
91+
92+
# Spec contract: each canonical encoding maps to exactly one value.
93+
#
94+
# Any unread bytes mean the input either over-allocated or carries noise.
95+
leftover = len(data) - stream.tell()
96+
if leftover:
97+
raise SSZSerializationError(f"{cls.__name__}: {leftover} trailing byte(s) after decode")
98+
return instance
9699

97100

98101
class SSZModel(StrictBaseModel, SSZType):
99-
"""
100-
Base class for SSZ types that use Pydantic validation.
102+
"""Pydantic-backed SSZ base used by containers, lists, vectors, and bitfields.
103+
104+
Two shapes share this base:
101105
102-
This combines StrictBaseModel (Pydantic validation + immutability) with SSZ serialization.
103-
Use this for containers and complex types that can benefit from Pydantic.
106+
- Collections wrap an inner sequence in one Pydantic field called data.
107+
- Containers expose multiple named Pydantic fields that map to a struct on the wire.
104108
105-
For simple types that need special inheritance (like int), use SSZType directly.
109+
The default length and string forms switch on which shape the subclass uses.
106110
"""
107111

108112
def __len__(self) -> int:
109-
"""Return the length of the collection's data or number of container fields."""
110-
data: Sequence[Any] | None = getattr(self, "data", None)
113+
"""Element count for collections, field count for containers."""
114+
data = getattr(self, "data", None)
111115
if data is not None:
112116
return len(data)
113117
return len(type(self).model_fields)
114118

115119
def __repr__(self) -> str:
116-
"""String representation showing the class name and data."""
117-
data: Sequence[Any] | None = getattr(self, "data", None)
120+
"""Show collection contents as data=[...] or container fields as name=value pairs."""
121+
cls_name = type(self).__name__
122+
data = getattr(self, "data", None)
118123
if data is not None:
119-
return f"{self.__class__.__name__}(data={list(data)!r})"
124+
return f"{cls_name}(data={list(data)!r})"
120125
field_strs = [f"{name}={getattr(self, name)!r}" for name in type(self).model_fields]
121-
return f"{self.__class__.__name__}({' '.join(field_strs)})"
126+
return f"{cls_name}({' '.join(field_strs)})"

0 commit comments

Comments
 (0)