Skip to content

Commit a4b4c67

Browse files
committed
Validate deletion vector blob framing on read
Check the length prefix bounds, the DV magic bytes, and the CRC-32 checksum when stripping the deletion-vector-v1 framing, so corrupted or truncated blobs fail loudly instead of silently producing wrong delete positions. The writer added the CRC in this change; the reader now verifies it.
1 parent 5fdd51f commit a4b4c67

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

pyiceberg/table/deletion_vector.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,23 @@ def to_blob(self) -> PuffinBlob:
145145

146146

147147
def _extract_vector_payload(blob_payload: bytes) -> bytes:
148-
"""Strip deletion-vector-v1 blob framing: length(4 big-endian) + DV magic(4) ... CRC(4 big-endian)."""
148+
"""Strip and validate deletion-vector-v1 blob framing: length(4 big-endian) + DV magic(4) ... CRC(4 big-endian)."""
149+
if len(blob_payload) < 12:
150+
raise ValueError(f"Invalid deletion vector blob: too short ({len(blob_payload)} bytes)")
151+
149152
length_prefix = int.from_bytes(blob_payload[0:4], "big")
150-
return blob_payload[8 : 4 + length_prefix]
153+
if length_prefix < len(DELETION_VECTOR_MAGIC) or 4 + length_prefix + 4 > len(blob_payload):
154+
raise ValueError(f"Invalid deletion vector blob: inconsistent length prefix {length_prefix}")
155+
156+
blob_content = blob_payload[4 : 4 + length_prefix]
157+
if blob_content[: len(DELETION_VECTOR_MAGIC)] != DELETION_VECTOR_MAGIC:
158+
raise ValueError("Invalid deletion vector blob: magic bytes mismatch")
159+
160+
expected_crc = int.from_bytes(blob_payload[4 + length_prefix : 8 + length_prefix], "big")
161+
if zlib.crc32(blob_content) != expected_crc:
162+
raise ValueError("Invalid deletion vector blob: CRC-32 mismatch")
163+
164+
return blob_content[len(DELETION_VECTOR_MAGIC) :]
151165

152166

153167
def deletion_vectors_from_puffin_file(puffin_file: PuffinFile) -> list[DeletionVector]:

tests/table/test_deletion_vector.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,40 @@ def test_to_blob_payload_layout() -> None:
146146
assert length_prefix == len(DELETION_VECTOR_MAGIC) + len(vector)
147147
assert len(blob.payload) == 4 + length_prefix + 4
148148
assert DeletionVector._deserialize_bitmap(vector) == DeletionVector.from_positions("file.parquet", [1, 2, 3])._bitmaps
149+
150+
151+
def test_extract_vector_payload_rejects_corrupted_vector() -> None:
152+
from pyiceberg.table.deletion_vector import _extract_vector_payload
153+
154+
payload = bytearray(DeletionVector.from_positions("file.parquet", [1, 2, 3]).to_blob().payload)
155+
payload[10] ^= 0xFF # flip a byte inside the serialized vector
156+
157+
with pytest.raises(ValueError, match="CRC-32 mismatch"):
158+
_extract_vector_payload(bytes(payload))
159+
160+
161+
def test_extract_vector_payload_rejects_bad_length_prefix() -> None:
162+
from pyiceberg.table.deletion_vector import _extract_vector_payload
163+
164+
payload = bytearray(DeletionVector.from_positions("file.parquet", [1]).to_blob().payload)
165+
payload[0:4] = (len(payload) + 100).to_bytes(4, "big")
166+
167+
with pytest.raises(ValueError, match="inconsistent length prefix"):
168+
_extract_vector_payload(bytes(payload))
169+
170+
171+
def test_extract_vector_payload_rejects_wrong_magic() -> None:
172+
from pyiceberg.table.deletion_vector import _extract_vector_payload
173+
174+
payload = bytearray(DeletionVector.from_positions("file.parquet", [1]).to_blob().payload)
175+
payload[4:8] = b"\x00\x00\x00\x00"
176+
177+
with pytest.raises(ValueError, match="magic bytes mismatch"):
178+
_extract_vector_payload(bytes(payload))
179+
180+
181+
def test_extract_vector_payload_rejects_truncated_blob() -> None:
182+
from pyiceberg.table.deletion_vector import _extract_vector_payload
183+
184+
with pytest.raises(ValueError, match="too short"):
185+
_extract_vector_payload(b"\x00\x00\x00")

0 commit comments

Comments
 (0)