From 0509344f869d4481dfbbc07bf3204911a6c2042a Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:32 +0000 Subject: [PATCH 1/8] Verify envelope checksums Every RNTuple envelope ends with an XXH3-64 checksum. rootfilespec read it and compared stored checksums with each other, but never hashed the bytes, so a corrupted header, footer or page list parsed without complaint. REnvelope.read now checks the checksum over [0, length - 8) of the uncompressed envelope: the length includes the checksum, and the checksum covers everything before it (root-io-spec ERRATA 5, checked against ROOT's VerifyXxHash3, RNTupleSerialize.cxx:934). Unknown trailing bytes are covered too, as the spec's compatibility notes require. REnvelopeLocator.read_from also refuses a stored size larger than the uncompressed length. RNTuple decompression branches on equality (root-io-spec NOTES 2, RNTupleZip.hxx:106-113), so that case is an error, where the code used to try to decompress it. Fixes #117 Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/rntuple/envelope.py | 24 +++++++- tests/test_rntuple_envelopes.py | 91 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/test_rntuple_envelopes.py diff --git a/src/rootfilespec/rntuple/envelope.py b/src/rootfilespec/rntuple/envelope.py index eee162c..0029add 100644 --- a/src/rootfilespec/rntuple/envelope.py +++ b/src/rootfilespec/rntuple/envelope.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from typing import Annotated, Generic, TypeVar, cast +import xxhash from typing_extensions import Self from rootfilespec.bootstrap.compression import decompress @@ -76,6 +77,8 @@ def read(cls, buffer: ReadBuffer) -> tuple[Self, ReadBuffer]: """Reads an REnvelope from the given buffer.""" #### Save initial buffer position (for checking unknown bytes) payload_start_pos = buffer.relpos + # The whole envelope, for the checksum, which covers everything before it + envelope_bytes = buffer.data #### Get the first 64bit integer (lengthType) which contains the length and type of the envelope # lengthType, buffer = buffer.consume(8) @@ -108,6 +111,16 @@ def read(cls, buffer: ReadBuffer) -> tuple[Self, ReadBuffer]: #### Get the checksum (appended to envelope when writing to disk) (checksum,), buffer = buffer.unpack(" EnvType: Envelopes are compressed, so this decompresses and deserializes. """ #### Decompress the buffer if necessary - if len(buffer) != self.length: + # RNTuple decompression tests equality: a stored size equal to the length + # means stored raw, a smaller one compressed, and a larger one is an error + # (root-io-spec NOTES 2; RNTupleZip.hxx:106-113) + if len(buffer) > self.length: + msg = ( + f"{self.envtype.__name__} at offset {self.offset}: stored size " + f"{len(buffer)} is larger than its uncompressed length {self.length}" + ) + raise ValueError(msg) + if len(buffer) < self.length: buffer = decompress(buffer, self.length) #### Now read the envelope diff --git a/tests/test_rntuple_envelopes.py b/tests/test_rntuple_envelopes.py new file mode 100644 index 0000000..771bd88 --- /dev/null +++ b/tests/test_rntuple_envelopes.py @@ -0,0 +1,91 @@ +from pathlib import Path + +import pytest + +from rootfilespec.bootstrap import BOOTSTRAP_CONTEXT, ROOT3a3aRNTuple +from rootfilespec.reader import open_path +from rootfilespec.rntuple.envelope import REnvelopeLocator +from rootfilespec.rntuple.header import HeaderEnvelope +from rootfilespec.rntuple.RLocator import StandardLocator +from rootfilespec.serializable import BufferContext, ReadBuffer + +DATA = Path(__file__).parent.parent / "reference" / "root-io-spec" / "data" / "rntuple" +pytestmark = pytest.mark.skipif( + not DATA.exists(), reason="reference/root-io-spec not checked out" +) + + +def _anchors(path: Path) -> list[ROOT3a3aRNTuple]: + with open_path(path) as reader: + keylist = reader.keylist() + return [ + reader.fetch(keylist[name]) + for name in keylist + if keylist[name].fClassName.fString == b"ROOT::RNTuple" + ] + + +def _buffer(raw: bytes, offset: int, size: int) -> ReadBuffer: + return ReadBuffer( + memoryview(raw[offset : offset + size]), + 0, + BOOTSTRAP_CONTEXT, + BufferContext(abspos=offset), + ) + + +def _read(raw: bytes, loc): + return loc.read_from(_buffer(raw, loc.offset, loc.size)) + + +@pytest.mark.parametrize("name", sorted(p.name for p in DATA.glob("*.root"))) +def test_every_envelope_verifies(name: str): + """Issue #117: every envelope of every root-io-spec RNTuple fixture is checked + + Including rntuple/compressed.root, whose envelopes are compressed: the + checksum is over the uncompressed envelope. + """ + path = DATA / name + raw = path.read_bytes() + for anchor in _anchors(path): + header = _read(raw, anchor.header_locator) + footer = _read(raw, anchor.footer_locator) + assert footer.headerChecksum == header.checksum + for loc in footer.pagelist_locators: + assert _read(raw, loc).headerChecksum == header.checksum + + +def test_corrupted_header_envelope_raises(): + """rntuple/anchor.root's header envelope is 268..508 (length 240, checksum at + 500..508). Changing any byte before the checksum must be caught.""" + path = DATA / "anchor.root" + raw = bytearray(path.read_bytes()) + (anchor,) = _anchors(path) + loc = anchor.header_locator + assert (loc.offset, loc.size, loc.length) == (268, 240, 240) + # The "n" of the ntuple's name "ntpl" at 288: flipping its case keeps the + # envelope parseable, so only the checksum can catch it + assert raw[288:292] == b"ntpl" + raw[288] ^= 0x20 + with pytest.raises(ValueError, match="HeaderEnvelope checksum mismatch"): + _read(bytes(raw), loc) + + +def test_corrupted_checksum_raises(): + path = DATA / "anchor.root" + raw = bytearray(path.read_bytes()) + (anchor,) = _anchors(path) + loc = anchor.header_locator + raw[loc.offset + loc.length - 1] ^= 0x01 + with pytest.raises(ValueError, match="HeaderEnvelope checksum mismatch"): + _read(bytes(raw), loc) + + +def test_stored_size_larger_than_length_raises(): + """root-io-spec NOTES 2: RNTuple decompression tests equality; a stored size + larger than the uncompressed length is an error, not a raw payload""" + loc = REnvelopeLocator( + length=16, locator=StandardLocator(size=24, offset=0), envtype=HeaderEnvelope + ) + with pytest.raises(ValueError, match="larger than its uncompressed length"): + loc.read_from(_buffer(bytes(24), 0, 24)) From 7d230193713650fb8bfdeabeb8ed67bc8d323718 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:35:06 +0000 Subject: [PATCH 2/8] Ignore the missing xxhash stubs in the pre-commit mypy environment The pre-commit mypy hook installs only pytest, numpy and tomli, so it cannot find xxhash. bootstrap/compression.py already imports it the same way. Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/rntuple/envelope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rootfilespec/rntuple/envelope.py b/src/rootfilespec/rntuple/envelope.py index 0029add..4ac4a40 100644 --- a/src/rootfilespec/rntuple/envelope.py +++ b/src/rootfilespec/rntuple/envelope.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from typing import Annotated, Generic, TypeVar, cast -import xxhash +import xxhash # type: ignore[import-not-found] from typing_extensions import Self from rootfilespec.bootstrap.compression import decompress From ef17cbb3ee0c489ab94f0fec6c8167776a24bb37 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:19:28 +0000 Subject: [PATCH 3/8] Rename tests/test_rntuple_envelopes.py to tests/test_envelope_checksums.py Name the module after what it tests, so it does not read as the home of a broad set of tests (review on #125). Assisted-by: claude-code:claude-opus-5-5 --- tests/{test_rntuple_envelopes.py => test_envelope_checksums.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_rntuple_envelopes.py => test_envelope_checksums.py} (100%) diff --git a/tests/test_rntuple_envelopes.py b/tests/test_envelope_checksums.py similarity index 100% rename from tests/test_rntuple_envelopes.py rename to tests/test_envelope_checksums.py From 2a1cd26c03e90289f72ebf103d4219bfa0b371ac Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:37:49 -0500 Subject: [PATCH 4/8] Verify envelope checksums before parsing, and check the fetched size Two fixes from review: - REnvelope.read now verifies the XXH3-64 right after the type and length checks, before the payload is parsed, as ROOT's DeserializeEnvelope does (RNTupleSerialize.cxx:909-939). Before, the payload was parsed first, so 136 of the 224 single-byte corruptions of rntuple/anchor.root's header envelope failed with an unrelated parse error instead of the checksum mismatch. Envelopes shorter than 16 bytes are refused, as in ROOT. - REnvelopeLocator.read_from checks that it was given the locator's size, then applies NOTES 2's raw/compressed rule to the locator's stored size rather than to the buffer's length. A short read (a truncated file) said "Unknown compression algorithm". Tests: every corrupted byte of that envelope reports the checksum mismatch; short reads of 0, 200 and 239 of 240 bytes raise. Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/rntuple/envelope.py | 54 ++++++++++++++++++---------- tests/test_envelope_checksums.py | 30 ++++++++++++++++ 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/rootfilespec/rntuple/envelope.py b/src/rootfilespec/rntuple/envelope.py index 4ac4a40..61029cb 100644 --- a/src/rootfilespec/rntuple/envelope.py +++ b/src/rootfilespec/rntuple/envelope.py @@ -1,3 +1,4 @@ +import struct from collections.abc import Callable from dataclasses import dataclass, field from typing import Annotated, Generic, TypeVar, cast @@ -93,11 +94,29 @@ def read(cls, buffer: ReadBuffer) -> tuple[Self, ReadBuffer]: # Envelope size (uncompressed), encoded in the 48 most significant bits length = lengthType >> 16 + # The preamble and the checksum alone are 16 bytes + if length < 16: + msg = f"Length of envelope ({length}) of type {typeID} is shorter than 16 bytes" + raise ValueError(msg) # Ensure that the length of the envelope matches the buffer length if length - 8 != len(buffer): msg = f"Length of envelope ({length} minus 8) of type {typeID} does not match buffer length ({len(buffer)})" raise ValueError(msg) + #### Verify the checksum before trusting the payload, as ROOT does + # (RNTupleSerialize.cxx:909-939). The length includes the checksum, which + # covers [0, length - 8) of the uncompressed envelope (root-io-spec + # ERRATA 5), unknown trailing bytes too: "Checksum verification ... must + # include both known and unknown contents" + (checksum,) = struct.unpack(" tuple[Self, ReadBuffer]: # Unknown Bytes = Envelope Size - Envelope Bytes Read - Checksum (8 bytes) # Envelope Bytes Read = buffer.relpos - payload_start_pos - #### Get the checksum (appended to envelope when writing to disk) - (checksum,), buffer = buffer.unpack(" EnvType: Envelopes are compressed, so this decompresses and deserializes. """ + if len(buffer) != self.size: + msg = ( + f"{self.envtype.__name__} at {self.locator}: expected {self.size} " + f"bytes, got {len(buffer)}" + ) + raise ValueError(msg) + #### Decompress the buffer if necessary - # RNTuple decompression tests equality: a stored size equal to the length - # means stored raw, a smaller one compressed, and a larger one is an error - # (root-io-spec NOTES 2; RNTupleZip.hxx:106-113) - if len(buffer) > self.length: + # RNTuple decompression tests equality of the stored size (the locator's) + # and the length: equal means stored raw, smaller compressed, and larger + # is an error (root-io-spec NOTES 2; RNTupleZip.hxx:106-113) + if self.size > self.length: msg = ( - f"{self.envtype.__name__} at offset {self.offset}: stored size " - f"{len(buffer)} is larger than its uncompressed length {self.length}" + f"{self.envtype.__name__} at {self.locator}: stored size " + f"{self.size} is larger than its uncompressed length {self.length}" ) raise ValueError(msg) - if len(buffer) < self.length: + if self.size < self.length: buffer = decompress(buffer, self.length) #### Now read the envelope diff --git a/tests/test_envelope_checksums.py b/tests/test_envelope_checksums.py index 771bd88..b495ba5 100644 --- a/tests/test_envelope_checksums.py +++ b/tests/test_envelope_checksums.py @@ -89,3 +89,33 @@ def test_stored_size_larger_than_length_raises(): ) with pytest.raises(ValueError, match="larger than its uncompressed length"): loc.read_from(_buffer(bytes(24), 0, 24)) + + +def test_every_corrupted_byte_is_a_checksum_mismatch(): + """The checksum is verified before the payload is parsed, as ROOT does, so + corrupting any byte the checksum covers reports the checksum, not whatever + the parser trips over first. Before, 136 of these 224 bytes failed with an + unrelated parse error (an out-of-range slice, unknown feature flags, ...).""" + path = DATA / "anchor.root" + raw = path.read_bytes() + (anchor,) = _anchors(path) + loc = anchor.header_locator + # The 8-byte preamble is checked first (type, length); everything after it, + # up to the checksum, is covered only by the checksum + for pos in range(loc.offset + 8, loc.offset + loc.length - 8): + corrupted = bytearray(raw) + corrupted[pos] ^= 0x80 + with pytest.raises(ValueError, match="HeaderEnvelope checksum mismatch"): + _read(bytes(corrupted), loc) + + +@pytest.mark.parametrize("size", [0, 200, 239]) +def test_short_read_raises(size: int): + """Fewer bytes than the locator's size (a truncated file) are reported as + such, not taken for a compressed envelope""" + path = DATA / "anchor.root" + raw = path.read_bytes() + (anchor,) = _anchors(path) + loc = anchor.header_locator + with pytest.raises(ValueError, match=f"expected 240 bytes, got {size}"): + loc.read_from(_buffer(raw, loc.offset, size)) From 40f42ec3f7a35af5f787445bf2a695aa457f0da3 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:43:38 -0500 Subject: [PATCH 5/8] Read the envelope checksum through ReadBuffer, not struct Slice the buffer to its last 8 bytes and unpack there, as the rest of the library does, instead of importing struct into envelope.py. Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/rntuple/envelope.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rootfilespec/rntuple/envelope.py b/src/rootfilespec/rntuple/envelope.py index 61029cb..2a54589 100644 --- a/src/rootfilespec/rntuple/envelope.py +++ b/src/rootfilespec/rntuple/envelope.py @@ -1,4 +1,3 @@ -import struct from collections.abc import Callable from dataclasses import dataclass, field from typing import Annotated, Generic, TypeVar, cast @@ -108,7 +107,8 @@ def read(cls, buffer: ReadBuffer) -> tuple[Self, ReadBuffer]: # covers [0, length - 8) of the uncompressed envelope (root-io-spec # ERRATA 5), unknown trailing bytes too: "Checksum verification ... must # include both known and unknown contents" - (checksum,) = struct.unpack(" Date: Thu, 1 Oct 2026 11:40:19 -0500 Subject: [PATCH 6/8] Compare key class names as bytes in the envelope tests, after #127 #127 made key class names plain bytes. Assisted-by: claude-code:claude-opus-5-5 --- tests/test_envelope_checksums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_envelope_checksums.py b/tests/test_envelope_checksums.py index b495ba5..391e329 100644 --- a/tests/test_envelope_checksums.py +++ b/tests/test_envelope_checksums.py @@ -21,7 +21,7 @@ def _anchors(path: Path) -> list[ROOT3a3aRNTuple]: return [ reader.fetch(keylist[name]) for name in keylist - if keylist[name].fClassName.fString == b"ROOT::RNTuple" + if keylist[name].fClassName == b"ROOT::RNTuple" ] From 2c3e4b0ee42b06e093368cd5c629c747ed614c43 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:43:44 -0500 Subject: [PATCH 7/8] Split the envelope once into the checksummed bytes and the checksum REnvelope.read mixed three views of the envelope: the checksum was read from the buffer after the preamble at length - 16, hashed against a separate envelope_bytes slice at length - 8, and the payload was parsed from the buffer. It now splits the envelope once, into the bytes the checksum covers and the 8-byte checksum, verifies one against the other, and parses the payload from the covered bytes after the preamble, so nothing reads into the checksum (review on #129). The preamble is still read from the whole buffer first, because the split needs its length. Every envelope that read before reads the same. Two errors change: the length mismatch now reports the full envelope length and buffer length (before, both minus the 8-byte preamble), and a frame that runs past the payload raises IndexError ("Cannot get slice") where it raised ValueError ("Cannot consume a negative number of bytes"). Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/rntuple/envelope.py | 55 +++++++++++----------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/rootfilespec/rntuple/envelope.py b/src/rootfilespec/rntuple/envelope.py index 2a54589..b5bdcb0 100644 --- a/src/rootfilespec/rntuple/envelope.py +++ b/src/rootfilespec/rntuple/envelope.py @@ -75,14 +75,8 @@ class REnvelope(ROOTSerializable): @classmethod def read(cls, buffer: ReadBuffer) -> tuple[Self, ReadBuffer]: """Reads an REnvelope from the given buffer.""" - #### Save initial buffer position (for checking unknown bytes) - payload_start_pos = buffer.relpos - # The whole envelope, for the checksum, which covers everything before it - envelope_bytes = buffer.data - #### Get the first 64bit integer (lengthType) which contains the length and type of the envelope - # lengthType, buffer = buffer.consume(8) - (lengthType,), buffer = buffer.unpack(" tuple[Self, ReadBuffer]: msg = f"Envelope type {typeID} read does not match passed class {cls.__name__}" raise ValueError(msg) - # Envelope size (uncompressed), encoded in the 48 most significant bits + # Envelope size (uncompressed), encoded in the 48 most significant bits. + # It includes the preamble and the checksum, so it is at least 16 bytes length = lengthType >> 16 - # The preamble and the checksum alone are 16 bytes if length < 16: msg = f"Length of envelope ({length}) of type {typeID} is shorter than 16 bytes" raise ValueError(msg) - # Ensure that the length of the envelope matches the buffer length - if length - 8 != len(buffer): - msg = f"Length of envelope ({length} minus 8) of type {typeID} does not match buffer length ({len(buffer)})" + if length != len(buffer): + msg = f"Length of envelope ({length}) of type {typeID} does not match buffer length ({len(buffer)})" raise ValueError(msg) - #### Verify the checksum before trusting the payload, as ROOT does - # (RNTupleSerialize.cxx:909-939). The length includes the checksum, which - # covers [0, length - 8) of the uncompressed envelope (root-io-spec - # ERRATA 5), unknown trailing bytes too: "Checksum verification ... must - # include both known and unknown contents" - # The last 8 bytes of what follows the preamble (length - 8 bytes in all) - (checksum,), _ = buffer[length - 16 :].unpack(" tuple[Self, ReadBuffer]: ) raise ValueError(msg) - members = {"typeID": typeID, "length": length} - #### Get the payload - members, buffer = cls.update_members(members, buffer) + #### Get the payload, after the 8-byte preamble + _, payload = covered.consume(8) + members = {"typeID": typeID, "length": length, "checksum": checksum} + members, payload = cls.update_members(members, payload) - #### Consume any unknown trailing information in the envelope - _unknown, buffer = buffer.consume( - length - (buffer.relpos - payload_start_pos) - 8 - ) - # Unknown Bytes = Envelope Size - Envelope Bytes Read - Checksum (8 bytes) - # Envelope Bytes Read = buffer.relpos - payload_start_pos + #### Keep any unknown trailing information in the envelope + _unknown, _ = payload.consume(len(payload)) - #### The checksum, verified above (appended to envelope when writing to disk) - _, buffer = buffer.consume(8) - members["checksum"] = checksum envelope = cls(**members) envelope._unknown = _unknown - return envelope, buffer + return envelope, rest EnvType = TypeVar("EnvType", bound=REnvelope) From 3e43d5ce63bd976e73ba560e02df2704c74f6a40 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:02:37 -0500 Subject: [PATCH 8/8] Test the envelope length checks and the pinned header checksum - rntuple/anchor.root's case.toml pins the header envelope's checksum at 500 and the footer's copy at 854; assert the parsed values equal them. - An envelope whose length is under 16 bytes (the preamble and checksum alone) raises, as in ROOT's DeserializeEnvelope. - anchor.root's header with the preamble's length changed from 240 to 248 raises the length mismatch. Also drop the count of failures before the reordering from a test docstring; the commit that reordered the checks records it. Assisted-by: claude-code:claude-opus-5-5 --- tests/test_envelope_checksums.py | 44 ++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/test_envelope_checksums.py b/tests/test_envelope_checksums.py index 391e329..5179881 100644 --- a/tests/test_envelope_checksums.py +++ b/tests/test_envelope_checksums.py @@ -55,6 +55,19 @@ def test_every_envelope_verifies(name: str): assert _read(raw, loc).headerChecksum == header.checksum +def test_header_checksum_is_the_pinned_value(): + """rntuple/anchor.root's case.toml pins the header envelope's checksum at 500 + and the footer's copy of it at 854""" + path = DATA / "anchor.root" + raw = path.read_bytes() + (anchor,) = _anchors(path) + pinned = bytes([0x2A, 0x13, 0xA2, 0x84, 0xB3, 0x59, 0xD3, 0xDD]) + assert raw[500:508] == raw[854:862] == pinned + header = _read(raw, anchor.header_locator) + footer = _read(raw, anchor.footer_locator) + assert header.checksum == footer.headerChecksum == int.from_bytes(pinned, "little") + + def test_corrupted_header_envelope_raises(): """rntuple/anchor.root's header envelope is 268..508 (length 240, checksum at 500..508). Changing any byte before the checksum must be caught.""" @@ -91,11 +104,38 @@ def test_stored_size_larger_than_length_raises(): loc.read_from(_buffer(bytes(24), 0, 24)) +def test_envelope_shorter_than_16_bytes_raises(): + """The length counts the 8-byte preamble and the 8-byte checksum, so it is + at least 16, as ROOT's DeserializeEnvelope requires""" + loc = REnvelopeLocator( + length=8, locator=StandardLocator(size=8, offset=0), envtype=HeaderEnvelope + ) + # The preamble alone: type 1 in the low 16 bits, length 8 in the upper 48 + preamble = (8 << 16 | 0x01).to_bytes(8, "little") + with pytest.raises(ValueError, match="shorter than 16 bytes"): + loc.read_from(_buffer(preamble, 0, 8)) + + +def test_envelope_length_not_matching_the_locator_raises(): + """rntuple/anchor.root's header envelope with the length in its preamble + (bytes 270..276) changed from 240 to 248: the anchor says 240 were stored""" + path = DATA / "anchor.root" + raw = bytearray(path.read_bytes()) + (anchor,) = _anchors(path) + loc = anchor.header_locator + assert raw[268:276] == (240 << 16 | 0x01).to_bytes(8, "little") + raw[268:276] = (248 << 16 | 0x01).to_bytes(8, "little") + with pytest.raises( + ValueError, match=r"Length of envelope \(248\) .* buffer length \(240\)" + ): + _read(bytes(raw), loc) + + def test_every_corrupted_byte_is_a_checksum_mismatch(): """The checksum is verified before the payload is parsed, as ROOT does, so corrupting any byte the checksum covers reports the checksum, not whatever - the parser trips over first. Before, 136 of these 224 bytes failed with an - unrelated parse error (an out-of-range slice, unknown feature flags, ...).""" + the parser trips over first (an out-of-range slice, unknown feature flags, + ...).""" path = DATA / "anchor.root" raw = path.read_bytes() (anchor,) = _anchors(path)