Skip to content

Commit 2dea33c

Browse files
committed
fix(avro): bound Cython decoder input
1 parent 7dd8ee3 commit 2dea33c

3 files changed

Lines changed: 156 additions & 51 deletions

File tree

‎pyiceberg/avro/decoder_basic.c‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,43 +23,54 @@
2323
Decode an an array of zig-zag encoded integers from a buffer.
2424
2525
The buffer is advanced to the end of the integers.
26+
`end` is the first byte after the buffer.
2627
`count` is the number of integers to decode.
2728
`result` is where the decoded integers are stored.
2829
2930
The result is guaranteed to be 64 bits wide.
3031
3132
*/
32-
static inline void decode_zigzag_ints(const unsigned char **buffer, const uint64_t count, uint64_t *result) {
33+
static inline int decode_zigzag_ints(
34+
const unsigned char **buffer, const unsigned char *end, const uint64_t count, uint64_t *result) {
3335
uint64_t current_index;
3436
const unsigned char *current_position = *buffer;
3537
uint64_t temp;
36-
// The largest shift will always be < 64
3738
unsigned char shift;
39+
unsigned char byte;
3840

3941
for (current_index = 0; current_index < count; current_index++) {
40-
shift = 7;
41-
temp = *current_position & 0x7F;
42-
while(*current_position & 0x80) {
43-
current_position += 1;
44-
temp |= (uint64_t)(*current_position & 0x7F) << shift;
45-
shift += 7;
42+
temp = 0;
43+
shift = 0;
44+
while (1) {
45+
if (current_position >= end || shift >= 64) {
46+
return 0;
47+
}
48+
49+
byte = *current_position;
50+
current_position += 1;
51+
52+
if (shift == 63 && (byte & 0x7E)) {
53+
return 0;
54+
}
55+
temp |= (uint64_t)(byte & 0x7F) << shift;
56+
57+
if (!(byte & 0x80)) {
58+
break;
59+
}
60+
shift += 7;
4661
}
4762
result[current_index] = (temp >> 1) ^ (~(temp & 1) + 1);
48-
current_position += 1;
4963
}
5064
*buffer = current_position;
65+
return 1;
5166
}
5267

53-
54-
5568
/*
5669
Skip a zig-zag encoded integer in a buffer.
5770
5871
The buffer is advanced to the end of the integer.
5972
*/
60-
static inline void skip_zigzag_int(const unsigned char **buffer) {
61-
while(**buffer & 0x80) {
62-
*buffer += 1;
63-
}
64-
*buffer += 1;
73+
static inline int skip_zigzag_int(const unsigned char **buffer, const unsigned char *end) {
74+
uint64_t ignored;
75+
return decode_zigzag_ints(buffer, end, 1, &ignored);
6576
}

‎pyiceberg/avro/decoder_fast.pyx‎

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import array
2525

2626

2727
cdef extern from "decoder_basic.c":
28-
void decode_zigzag_ints(const unsigned char **buffer, const uint64_t count, uint64_t *result);
29-
void skip_zigzag_int(const unsigned char **buffer);
28+
int decode_zigzag_ints(const unsigned char **buffer, const unsigned char *end, const uint64_t count, uint64_t *result);
29+
int skip_zigzag_int(const unsigned char **buffer, const unsigned char *end);
3030

3131
unsigned_long_long_array_template = cython.declare(array.array, array.array('Q', []))
3232

@@ -61,6 +61,14 @@ cdef class CythonBinaryDecoder:
6161
def __dealloc__(self):
6262
PyMem_Free(self._data)
6363

64+
cdef inline void _ensure_available(self, uint64_t length):
65+
if length > <uint64_t>(self._end - self._current):
66+
raise EOFError(f"EOF: read {length} bytes")
67+
68+
cdef inline void _decode_zigzag_ints(self, uint64_t count, uint64_t *result):
69+
if not decode_zigzag_ints(&self._current, self._end, count, result):
70+
raise EOFError("EOF: read 1 bytes")
71+
6472
cpdef unsigned int tell(self):
6573
"""Return the current stream position."""
6674
return self._current - self._data
@@ -69,16 +77,19 @@ cdef class CythonBinaryDecoder:
6977
"""Read n bytes."""
7078
if n < 0:
7179
raise ValueError(f"Requested {n} bytes to read, expected positive integer.")
80+
cdef uint64_t length = n
81+
self._ensure_available(length)
7282
cdef const unsigned char *r = self._current
73-
self._current += n
74-
return r[0:n]
83+
self._current += length
84+
return r[0:length]
7585

7686
def read_boolean(self) -> bool:
7787
"""Reads a value from the stream as a boolean.
7888

7989
A boolean is written as a single byte
8090
whose value is either 0 (false) or 1 (true).
8191
"""
92+
self._ensure_available(1)
8293
self._current += 1;
8394
return self._current[-1] != 0
8495

@@ -88,46 +99,43 @@ cdef class CythonBinaryDecoder:
8899
int/long values are written using variable-length, zigzag coding.
89100
"""
90101
cdef uint64_t result;
91-
if self._current >= self._end:
92-
raise EOFError(f"EOF: read 1 bytes")
93-
decode_zigzag_ints(&self._current, 1, &result)
102+
self._decode_zigzag_ints(1, &result)
94103
return result
95104

96105
def read_ints(self, count: int) -> array.array[int]:
97106
"""Reads a list of integers."""
98107
newarray = array.clone(unsigned_long_long_array_template, count, zero=False)
99-
if self._current >= self._end:
100-
raise EOFError(f"EOF: read 1 bytes")
101-
decode_zigzag_ints(&self._current, count, <uint64_t *>newarray.data.as_ulonglongs)
108+
self._decode_zigzag_ints(count, <uint64_t *>newarray.data.as_ulonglongs)
102109
return newarray
103110

104111
cpdef void read_int_bytes_dict(self, count: int, dest: Dict[int, bytes]):
105112
"""Reads a dictionary of integers for keys and bytes for values into a destination dict."""
106-
cdef uint64_t result[2];
107-
if self._current >= self._end:
108-
raise EOFError(f"EOF: read 1 bytes")
113+
cdef uint64_t raw_result[2];
114+
cdef int64_t key
115+
cdef int64_t length
109116

110117
for _ in range(count):
111-
decode_zigzag_ints(&self._current, 2, <uint64_t *>&result)
112-
if result[1] <= 0:
113-
dest[result[0]] = b""
118+
self._decode_zigzag_ints(2, raw_result)
119+
key = <int64_t>raw_result[0]
120+
length = <int64_t>raw_result[1]
121+
if length <= 0:
122+
dest[key] = b""
114123
else:
115-
dest[result[0]] = self._current[0:result[1]]
116-
self._current += result[1]
124+
self._ensure_available(<uint64_t>length)
125+
dest[key] = self._current[0:length]
126+
self._current += length
117127

118128
cpdef inline bytes read_bytes(self):
119129
"""Bytes are encoded as a long followed by that many bytes of data."""
120-
cdef uint64_t length;
121-
if self._current >= self._end:
122-
raise EOFError(f"EOF: read 1 bytes")
123-
124-
decode_zigzag_ints(&self._current, 1, &length)
130+
cdef uint64_t raw_length;
131+
self._decode_zigzag_ints(1, &raw_length)
125132

126-
if length <= 0:
133+
if <int64_t>raw_length <= 0:
127134
return b""
135+
self._ensure_available(raw_length)
128136
cdef const unsigned char *r = self._current
129-
self._current += length
130-
return r[0:length]
137+
self._current += raw_length
138+
return r[0:raw_length]
131139

132140
cpdef float read_float(self):
133141
"""Reads a value from the stream as a float.
@@ -156,25 +164,32 @@ cdef class CythonBinaryDecoder:
156164
return self.read_bytes().decode("utf-8")
157165

158166
def skip_int(self) -> None:
159-
skip_zigzag_int(&self._current)
160-
return
167+
if not skip_zigzag_int(&self._current, self._end):
168+
raise EOFError("EOF: read 1 bytes")
161169

162170
def skip(self, n: int) -> None:
163-
self._current += n
171+
if n < 0:
172+
raise ValueError(f"Requested {n} bytes to skip, expected positive integer.")
173+
cdef uint64_t length = n
174+
self._ensure_available(length)
175+
self._current += length
164176

165177
def skip_boolean(self) -> None:
166-
self._current += 1
178+
self.skip(1)
167179

168180
def skip_float(self) -> None:
169-
self._current += 4
181+
self.skip(4)
170182

171183
def skip_double(self) -> None:
172-
self._current += 8
184+
self.skip(8)
173185

174186
def skip_bytes(self) -> None:
175-
cdef uint64_t result;
176-
decode_zigzag_ints(&self._current, 1, &result)
177-
self._current += result
187+
cdef uint64_t raw_length;
188+
self._decode_zigzag_ints(1, &raw_length)
189+
if <int64_t>raw_length <= 0:
190+
return
191+
self._ensure_available(raw_length)
192+
self._current += raw_length
178193

179194
def skip_utf8(self) -> None:
180195
self.skip_bytes()

‎tests/avro/test_decoder.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,42 @@ def test_read_int_longer(decoder_class: Callable[[bytes], BinaryDecoder]) -> Non
6666
assert decoder.read_int() == 1111111
6767

6868

69+
def test_cython_decoder_rejects_truncated_varint() -> None:
70+
decoder = CythonBinaryDecoder(b"\x80")
71+
72+
with pytest.raises(EOFError, match="EOF: read 1 bytes"):
73+
decoder.read_int()
74+
75+
assert decoder.tell() == 0
76+
77+
78+
def test_cython_decoder_rejects_overlong_varint() -> None:
79+
decoder = CythonBinaryDecoder(b"\x80" * 10 + b"\x00")
80+
81+
with pytest.raises(EOFError, match="EOF: read 1 bytes"):
82+
decoder.read_int()
83+
84+
assert decoder.tell() == 0
85+
86+
87+
def test_cython_decoder_rejects_truncated_skipped_varint() -> None:
88+
decoder = CythonBinaryDecoder(b"\x80")
89+
90+
with pytest.raises(EOFError, match="EOF: read 1 bytes"):
91+
decoder.skip_int()
92+
93+
assert decoder.tell() == 0
94+
95+
96+
def test_cython_decoder_rejects_truncated_ints() -> None:
97+
decoder = CythonBinaryDecoder(b"\x00")
98+
99+
with pytest.raises(EOFError, match="EOF: read 1 bytes"):
100+
decoder.read_ints(2)
101+
102+
assert decoder.tell() == 0
103+
104+
69105
def zigzag_encode(datum: int) -> bytes:
70106
result = []
71107
datum = (datum << 1) ^ (datum >> 63)
@@ -192,6 +228,49 @@ def test_read_bytes(decoder_class: Callable[[bytes], BinaryDecoder]) -> None:
192228
assert actual == b"\x01\x02\x03\x04"
193229

194230

231+
def test_cython_decoder_rejects_truncated_bytes() -> None:
232+
decoder = CythonBinaryDecoder(b"\x04\x01")
233+
234+
with pytest.raises(EOFError, match="EOF: read 2 bytes"):
235+
decoder.read_bytes()
236+
237+
assert decoder.tell() == 1
238+
239+
240+
def test_cython_decoder_rejects_truncated_skipped_bytes() -> None:
241+
decoder = CythonBinaryDecoder(b"\x04\x01")
242+
243+
with pytest.raises(EOFError, match="EOF: read 2 bytes"):
244+
decoder.skip_bytes()
245+
246+
assert decoder.tell() == 1
247+
248+
249+
@pytest.mark.parametrize("decoder_class", AVAILABLE_DECODERS)
250+
def test_read_negative_length_bytes(decoder_class: Callable[[bytes], BinaryDecoder]) -> None:
251+
decoder = decoder_class(b"\x01")
252+
assert decoder.read_bytes() == b""
253+
254+
255+
@pytest.mark.parametrize("decoder_class", AVAILABLE_DECODERS)
256+
def test_read_int_bytes_dict_negative_length(decoder_class: Callable[[bytes], BinaryDecoder]) -> None:
257+
decoder = decoder_class(b"\x00\x01")
258+
dest: dict[int, bytes] = {}
259+
260+
decoder.read_int_bytes_dict(1, dest)
261+
262+
assert dest == {0: b""}
263+
264+
265+
@pytest.mark.parametrize("decoder_class", AVAILABLE_DECODERS)
266+
def test_read_int_bytes_dict_rejects_truncated_bytes(decoder_class: Callable[[bytes], BinaryDecoder]) -> None:
267+
decoder = decoder_class(b"\x00\x04\x01")
268+
dest: dict[int, bytes] = {}
269+
270+
with pytest.raises(EOFError):
271+
decoder.read_int_bytes_dict(1, dest)
272+
273+
195274
@pytest.mark.parametrize("decoder_class", AVAILABLE_DECODERS)
196275
def test_read_utf8(decoder_class: Callable[[bytes], BinaryDecoder]) -> None:
197276
decoder = decoder_class(b"\x04\x76\x6f")

0 commit comments

Comments
 (0)