From f1944124b7053b215a3f83817810ec7066ba0861 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 16 Sep 2026 16:39:47 -0400 Subject: [PATCH 1/3] dumpkit: write PCAP records directly instead of rebuilding a Frame per packet `PCAPIO._append_value` handed the record fields and the packet octets it already held to a `Frame` constructor just to get bytes back. That constructor packs the record and then dissects it again through the whole protocol stack, so every frame the flow tracer wrote was parsed twice. - Pack the 16-octet record header here, with a `struct.Struct` chosen from the byte order of the global header the dumper wrote, and write it followed by `value.packet` verbatim. - Mask each field to 32 bits, because `UInt32Field` truncated rather than rejecting an out-of-range value and `struct.pack` raises instead. - Drop the now-unused `pcapkit.protocols.misc.pcap.frame` import. - Pin the record layout for both byte orders, the truncation, and the fact that appending a frame no longer parses it. The re-dissection was also warning about payloads the dumper only had to copy: writing a 3-octet payload raised `SchemaWarning: packet length < 0: -3`. Measured on `http.pcap` (1117 frames, best of 7, one interpreter per measurement): flow-traced extraction 2319 ms -> 1263 ms against an unchanged 1030 ms untraced baseline, so 82% of the tracing overhead is gone. `test.pcap` 62.1 -> 33.5 ms, `http6.cap` 48.4 -> 25.8 ms. Output is byte-identical: all 30 `tree`/`json` dumps and all 710 flow-trace files over the 15 fixtures with reassembly and tracing enabled, plus 1420 more flow-trace files over both `trace_byteorder` and both `trace_nanosecond` values. Suite 833 passed, 17 skipped; mypy message multiset unchanged at 124 errors. --- pcapkit/dumpkit/pcap.py | 72 ++++++++++++++++--- tests/dumpkit/test_common_unit.py | 113 ++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/pcapkit/dumpkit/pcap.py b/pcapkit/dumpkit/pcap.py index 546bac7037..6dd470cf93 100644 --- a/pcapkit/dumpkit/pcap.py +++ b/pcapkit/dumpkit/pcap.py @@ -9,12 +9,12 @@ :mod:`dictdumper`. """ +import struct import sys from typing import TYPE_CHECKING from pcapkit.dumpkit.common import DumperBase as Dumper from pcapkit.protocols.data.misc.pcap.header import Header as Data_Header -from pcapkit.protocols.misc.pcap.frame import Frame from pcapkit.protocols.misc.pcap.header import Header if TYPE_CHECKING: @@ -31,6 +31,23 @@ 'PCAPIO', ] +#: Per-byte-order record header packers for the four ``uint32`` fields of a PCAP +#: record header -- ``ts_sec``, ``ts_usec``, ``incl_len``, ``orig_len``. Keyed by +#: the byte order of the global header this dumper wrote, so that the records +#: agree with the magic number a reader will find in front of them. +_RECORD_HEADER = { + 'little': struct.Struct('IIII'), +} + +#: Truncation mask for those four fields. :class:`~pcapkit.corekit.fields.numbers.UInt32Field`, +#: which used to pack them, masks to the field width in +#: :meth:`~pcapkit.corekit.fields.numbers.NumberField.pre_process` rather than +#: rejecting an out-of-range value -- a ``ts_sec`` of ``2**32 + 5`` was written as +#: ``5``. :func:`struct.pack` raises instead, so the mask is applied here to keep +#: the two spellings writing the same octets for every input. +_UINT32_MASK = 0xFFFF_FFFF + class PCAPIO(Dumper): """PCAP file dumper. @@ -46,6 +63,8 @@ class PCAPIO(Dumper): if TYPE_CHECKING: #: PCAP file global header. _ghdr: 'Data_Header' + #: Record header packer, in the global header's byte order. + _rechdr: 'struct.Struct' ########################################################################## # Properties. @@ -75,6 +94,11 @@ def __init__(self, fname: 'str', *, protocol: 'Enum_LinkType | StdlibIntEnum | A """ #: int: Frame counter. self._fnum = 1 + # NOTE: Both of these now only record how the dumper was configured -- the + # values that shape the output reach it through :meth:`self._dump_header + # <_dump_header>`'s own arguments, and are readable afterwards from + # :attr:`self._ghdr <_ghdr>`. They are kept because they are part of the + # instance surface a subclass may already read. #: bool: Nanosecond-resolution file flag. self._nsec = nanosecond #: Enum_LinkType | StdlibIntEnum | AenumIntEnum | str | int: Data link type. @@ -123,6 +147,13 @@ def _dump_header(self, *, protocol: 'Enum_LinkType | StdlibIntEnum | AenumIntEnu file.write(packet) self._ghdr = header.info + #: struct.Struct: Packer for the record header preceding each frame, in the + #: byte order of the global header just written. Taken from + #: :attr:`self._ghdr <_ghdr>` rather than from the ``byteorder`` argument + #: because :class:`~pcapkit.protocols.misc.pcap.header.Header` is what + #: validates and normalises it. + self._rechdr = _RECORD_HEADER[self._ghdr.magic_number.byteorder] + def _append_value(self, value: 'Data_Frame', file: 'IO[bytes]', name: 'str') -> 'None': # pylint: disable=unused-argument """Call this function to write contents. @@ -131,14 +162,35 @@ def _append_value(self, value: 'Data_Frame', file: 'IO[bytes]', name: 'str') -> file: output file name: name of current content block + Notes: + A PCAP record is a 16-octet header followed by the packet octets, and + both are already in hand: the header fields are exactly + ``value.frame_info`` and the octets are exactly ``value.packet``. So + this writes them directly, rather than handing them to + :class:`~pcapkit.protocols.misc.pcap.frame.Frame`, whose constructor + packs the record and then **dissects it again** through the whole + protocol stack to arrive at bytes it was given. That round trip was + about 82% of the cost of a flow-traced extraction -- ``http.pcap``, + 1117 frames, best of 7: 2319 ms with the rebuild against 1263 ms + without, over a 1030 ms untraced baseline. + + Dropping it is not only cheaper. The re-dissection re-emitted every + parse warning the frame had already produced once, and warned about + payloads it had no business parsing at all -- writing a 3-octet + payload raised ``SchemaWarning: packet length < 0: -3`` from a dumper + that only had to copy it. + """ - packet = Frame( - nanosecond=self._nsec, - num=self._fnum, - proto=self._link, - packet=value.packet, - header=self._ghdr, - **value.frame_info, - ).data - file.write(packet) + # NOTE: The payload is read before the metadata so that a caller passing a + # mapping rather than a dissected frame -- which the flow-tracing adapters + # of several engines do -- still fails naming ``packet``, as the ``Frame`` + # construction did. :mod:`pcapkit.foundation.extraction` substitutes a + # dict-capable trace format on the strength of that error. + packet = value.packet + frame_info = value.frame_info + + file.write(self._rechdr.pack(frame_info.ts_sec & _UINT32_MASK, + frame_info.ts_usec & _UINT32_MASK, + frame_info.incl_len & _UINT32_MASK, + frame_info.orig_len & _UINT32_MASK) + packet) self._fnum += 1 diff --git a/tests/dumpkit/test_common_unit.py b/tests/dumpkit/test_common_unit.py index 89de1be39f..b1dc042033 100644 --- a/tests/dumpkit/test_common_unit.py +++ b/tests/dumpkit/test_common_unit.py @@ -7,10 +7,12 @@ import io from ipaddress import ip_address import pathlib +import struct import tempfile import types import unittest from unittest import mock +import warnings from tests._support import purge_modules @@ -154,6 +156,117 @@ def test_null_dumper_noops_and_pcap_dumper_writes_file(self) -> None: self.assertIs(dumper(frame), dumper) self.assertGreater(pcap_path.stat().st_size, header_size) + def test_pcap_dumper_writes_the_record_header_in_the_global_header_byte_order(self) -> None: + # The dumper writes the 16-octet record header itself instead of building a + # Frame to pack it, so the byte order of those four uint32 fields is now this + # module's responsibility rather than the schema's. It has to match the magic + # number in front of them, and 'big' has to keep working on a little-endian + # host -- which is exactly what a struct format hardcoded to one endianness + # would silently get wrong. + # + # Values are chosen to be byte-order-visible: every field is asymmetric, so a + # wrong endianness cannot coincide with a right one. + from pcapkit.const.reg.linktype import LinkType + from pcapkit.dumpkit.pcap import PCAPIO + from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo + + payload = bytes(range(6)) + for byteorder, endian in (('little', '<'), ('big', '>')): + for nanosecond in (False, True): + with self.subTest(byteorder=byteorder, nanosecond=nanosecond): + with tempfile.TemporaryDirectory() as tempdir: + path = pathlib.Path(tempdir) / 'sample.pcap' + dumper = PCAPIO(str(path), protocol=LinkType.ETHERNET, + byteorder=byteorder, nanosecond=nanosecond) + header_size = path.stat().st_size + + frame = Frame( + frame_info=FrameInfo(ts_sec=0x01020304, ts_usec=0x05060708, + incl_len=len(payload), orig_len=0x0A0B0C0D), + time='time', number=1, time_epoch=1.25, + len=len(payload), cap_len=0x0A0B0C0D, + ) + frame.__update__(packet=payload) + dumper(frame) + + self.assertEqual( + path.read_bytes()[header_size:], + struct.pack(f'{endian}IIII', 0x01020304, 0x05060708, + len(payload), 0x0A0B0C0D) + payload, + ) + + def test_pcap_dumper_truncates_an_out_of_range_record_field(self) -> None: + # UInt32Field, which used to pack these four fields, masks to the field width + # in NumberField.pre_process instead of rejecting an out-of-range value, so + # the dumper silently wrote a wrapped one. struct.pack raises on the same + # input, so writing the header directly would have turned a written -- if + # wrong -- record into a struct.error. Pinned so the mask is not mistaken for + # dead defensiveness and dropped: this is behaviour being preserved, not + # behaviour being chosen. + from pcapkit.const.reg.linktype import LinkType + from pcapkit.dumpkit.pcap import PCAPIO + from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo + + with tempfile.TemporaryDirectory() as tempdir: + path = pathlib.Path(tempdir) / 'sample.pcap' + dumper = PCAPIO(str(path), protocol=LinkType.ETHERNET, + byteorder='little', nanosecond=False) + header_size = path.stat().st_size + + frame = Frame( + frame_info=FrameInfo(ts_sec=2 ** 32 + 5, ts_usec=7, + incl_len=2, orig_len=2), + time='time', number=1, time_epoch=1.0, len=2, cap_len=2, + ) + frame.__update__(packet=b'ab') + dumper(frame) + + self.assertEqual(path.read_bytes()[header_size:], + struct.pack(' None: + # Successive frames have to accumulate, and each record has to be the caller's + # own octets verbatim -- the dumper no longer round-trips them through a Frame + # construction, which packed the record and then dissected it again through + # the whole protocol stack to reach bytes it had just been handed. + # + # The payload here is deliberately not a valid Ethernet frame: under the old + # rebuild it was dissected anyway, so a dumper that reparses is not merely + # slower but is doing work that can warn or raise on data it only had to copy. + from pcapkit.const.reg.linktype import LinkType + from pcapkit.dumpkit.pcap import PCAPIO + from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo + + def make(number: int, packet: bytes) -> Frame: + frame = Frame( + frame_info=FrameInfo(ts_sec=number, ts_usec=0, incl_len=len(packet), + orig_len=len(packet)), + time='time', number=number, time_epoch=float(number), + len=len(packet), cap_len=len(packet), + ) + frame.__update__(packet=packet) + return frame + + with tempfile.TemporaryDirectory() as tempdir: + path = pathlib.Path(tempdir) / 'sample.pcap' + dumper = PCAPIO(str(path), protocol=LinkType.ETHERNET, + byteorder='little', nanosecond=False) + header_size = path.stat().st_size + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + self.assertIs(dumper(make(1, b'\xff' * 3)), dumper) + self.assertIs(dumper(make(2, b'\xfe' * 5)), dumper) + + self.assertEqual( + path.read_bytes()[header_size:], + struct.pack(' None: # Why Extractor substitutes a dict-capable trace format for the DPKT, Scapy, # PyShark and PyPCAPFile engines: their flow-tracing adapters report each From 52402d292a3b7486a58c00b878d1252f8c4d8d72 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 16 Sep 2026 18:23:07 -0400 Subject: [PATCH 2/3] corekit: read only the option type field, not the whole base schema, to select an option schema `OptionField.unpack` unpacked the base schema in full to read one field from it, threw the result away, rewound, and unpacked the option again with the real schema -- so every option was parsed twice. Measured on `examples/captures/profile.pcapng`: 2274 schema unpacks for 1137 options. - Read the base schema's type field alone, exactly the way `Schema.unpack` reads one field, so `code` keeps the enumeration type that the `eool` comparison and the `OrderedMultiDict` key rely on. - Rewind by the octets actually read rather than by `len(meta)`, which a truncated stream could have over-rewound. - Pin that each option is unpacked exactly once, that options of differing widths decode in sequence, and that the area past the end-of-option-list marker still reaches the following padding field. Safe because the type field is field 0 of every base schema in the package, always a fixed 1- or 2-octet `EnumField`/`OptionEnumField` with no length callback -- checked over all 34 `OptionField` declarations and all 18 distinct base schemas. None of those base schemas overrides `pre_unpack`, and the five that override `post_process` touch only `len`/`length`, never the type field. The discarded pre-parse's writes into `packet` were not load-bearing either: the three option schemas that do not re-declare the base schema's fields (HOPOPT `_SMFDPDOption` and `_QuickStartOption`, IPv4 `_QSOption`) parse identically with and without them, both in isolation and driven through `OptionField`. Measured best of 15 (best of 7 for `http.pcap`), one interpreter per measurement: `profile.pcapng` 43.3 -> 37.6 ms (-13.3%), `test.pcapng` 7.87 -> 7.04 ms (-10.5%), `test.pcap` 28.9 -> 26.3 ms (-8.9%), `many_interfaces.pcapng` 48.1 -> 44.8 ms (-7.0%), `http.pcap` 1038 -> 980 ms (-5.6%). Output is byte-identical: all 771 files of the equivalence run -- 30 `tree`/`json` dumps over the 15 fixtures with reassembly and tracing enabled, 710 flow-trace files, and the recorded warning multisets -- match exactly, with no change even to the warnings. Suite 836 passed, 17 skipped; mypy message multiset unchanged at 124 errors. `len(data)` is deliberately left alone: returning the consumed count from `Schema.unpack` is the clean fix and that file belongs to #420, a `file.tell()` delta is not equivalent for the three wrapper schemas whose `post_process` returns a nested schema, and the provably-equivalent `__buffer__` sum is worth only ~0.5%. --- pcapkit/corekit/fields/collections.py | 28 +++++- tests/corekit/test_fields_collections.py | 117 +++++++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/corekit/test_fields_collections.py diff --git a/pcapkit/corekit/fields/collections.py b/pcapkit/corekit/fields/collections.py index ae0c7ec202..6243ed3d6a 100644 --- a/pcapkit/corekit/fields/collections.py +++ b/pcapkit/corekit/fields/collections.py @@ -256,15 +256,35 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'list new_packet = packet.copy() new_packet[self.name] = OrderedMultiDict() + # NOTE: Only the base schema's type field is read here, rather than the + # whole base schema. The option schema below re-reads the same octets + # from the rewound stream, and the base schema's result is used for + # nothing but the type code, so unpacking it in full parsed every option + # twice -- 2274 schema unpacks for 1137 options of + # ``examples/captures/profile.pcapng``. The type field is the first field + # of every base schema in this package, so reading and rewinding it alone + # lands the stream on the same octet the full unpack and its rewind did. + # + # The three lines below deliberately mirror what + # :meth:`Schema.unpack ` + # does for one field -- ``field(packet)``, read ``field.length`` octets, + # then ``field.unpack(byte, packet.copy())`` -- rather than reaching for + # :func:`struct.unpack`. That keeps ``code``'s enumeration type, which + # both the ``self._eool`` comparison and the ``OrderedMultiDict`` key + # depend on, and it is what makes the two spellings equivalent rather + # than merely similar. + type_field = self._base_schema.__fields__[self._type_name] + temp = [] # type: list[_TS] while length > 0: - # unpack option type using base schema - meta = self._base_schema.unpack(file, length, packet) # type: ignore[call-arg,misc,var-annotated] - code = cast('int', meta[self._type_name]) + # unpack option type using the base schema's type field + field = type_field(packet) + byte = file.read(field.length) + code = cast('int', field.unpack(byte, packet.copy())) schema = self._registry[code] # rewind to the beginning of the option - file.seek(-len(meta), io.SEEK_CUR) + file.seek(-len(byte), io.SEEK_CUR) # unpack option using option schema data = schema.unpack(file, length, packet) # type: ignore[call-arg,misc,var-annotated] diff --git a/tests/corekit/test_fields_collections.py b/tests/corekit/test_fields_collections.py new file mode 100644 index 0000000000..8a39ce50b2 --- /dev/null +++ b/tests/corekit/test_fields_collections.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import collections +import unittest +from typing import TYPE_CHECKING + +from tests._support import purge_modules + +if TYPE_CHECKING: + from typing import Any + + +class OptionFieldTests(unittest.TestCase): + """Option-list parsing by :class:`~pcapkit.corekit.fields.collections.OptionField`. + + The schemas below stand in for the real TLV protocols: a base schema holding + the option type and length, and two option schemas of *different* total + widths registered against it, so that a stream left in the wrong place by + the type lookup shows up as the second option decoding wrongly rather than + as a silent shift. + + """ + + def setUp(self) -> None: + purge_modules(['pcapkit']) + + from pcapkit.corekit.fields.collections import OptionField + from pcapkit.corekit.fields.numbers import UInt16Field + from pcapkit.corekit.fields.strings import BytesField, PaddingField + from pcapkit.protocols.schema.schema import Schema, schema_final + + #: Every schema unpacked while parsing an option list, in order. + self.unpacked = [] # type: list[str] + unpacked = self.unpacked + + class Base(Schema): + """Base schema: a two-octet type, then a two-octet body length.""" + + kind: 'int' = UInt16Field() + size: 'int' = UInt16Field() + + @classmethod + def pre_unpack(cls, packet: 'dict[str, Any]') -> 'None': + unpacked.append(cls.__name__) + + @schema_final + class Body(Base): + """An option carrying ``size`` octets of body after the header.""" + + body: 'bytes' = BytesField(length=lambda pkt: pkt['size']) + + @schema_final + class End(Base): + """The end-of-option-list marker: header only, no body.""" + + registry = collections.defaultdict( + lambda: Body, {1: Body, 0: End}, + ) # type: collections.defaultdict[int, type[Base]] + + @schema_final + class Options(Schema): + options: 'list[Base]' = OptionField( + length=13, base_schema=Base, type_name='kind', + registry=registry, eool=0, + ) + pad: 'bytes' = PaddingField( + length=lambda pkt: pkt.get('__option_padding__', 0), + ) + + self.Base = Base + self.Body = Body + self.End = End + self.Options = Options + + #: A 7-octet ``Body`` option, then a 4-octet ``End``, then 2 spare + #: octets inside the field's declared 13. + self.buffer = b'\x00\x01\x00\x03abc' b'\x00\x00\x00\x00' b'\xde\xad' + + def test_each_option_is_unpacked_exactly_once(self) -> None: + """Selecting an option schema must not parse the option first. + + Reading the option type by unpacking the whole base schema and throwing + the result away parsed every option twice -- two schema unpacks per + option, the first of which was used for nothing but its type field. The + base schema must therefore not appear here at all, and each option + schema exactly once. + + """ + self.Options.unpack(self.buffer, 13, {}) + + self.assertEqual(self.unpacked, ['Body', 'End']) + + def test_options_of_differing_widths_parse_in_sequence(self) -> None: + """The stream must be left exactly at each option's first octet.""" + options = self.Options.unpack(self.buffer, 13, {}).options + + self.assertEqual([type(opt).__name__ for opt in options], ['Body', 'End']) + self.assertEqual(options[0].kind, 1) + self.assertEqual(options[0].size, 3) + self.assertEqual(options[0].body, b'abc') + self.assertEqual(options[1].kind, 0) + + def test_the_area_left_unread_after_the_eool_is_reported_as_padding(self) -> None: + """The area past the end-of-option-list marker is handed back. + + ``OptionField`` reports it through ``__option_padding__``, which is what + the following field sizes itself from -- so the two spare octets landing + in ``pad`` is the whole rewind-and-truncate arithmetic being right. + + """ + schema = self.Options.unpack(self.buffer, 13, {}) + + self.assertEqual(schema.pad, b'\xde\xad') + + +if __name__ == '__main__': + unittest.main() From ed4442f58474c9cc9bff80138de353d52f6e0c78 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 17 Sep 2026 00:08:52 -0400 Subject: [PATCH 3/3] corekit: fall back to the full base-schema unpack when the type field is not first Reading only the base schema's type field to select an option schema assumes that field sits at the front of the option. Every `OptionField` in this package satisfies that -- 34 declarations over 18 base schemas, all with a fixed 1- or 2-octet enum first -- but `OptionField` is public and `pcapkit.foundation.registry` invites third-party protocols to register their own base schemas, which need not. Measured on a base schema with the type field second: the shortcut read `size` as the type code, hit the end-of-option-list value, and returned `['End']` where the full unpack returns `['Body', 'End']`, reclassifying four octets as padding. No exception -- a silently misparsed packet. - Decide once, in `__init__`, whether the shortcut fits: the type field must be the first field, a `NumberField` (so `Schema.unpack` reads it through its ordinary per-field branch), and of fixed rather than callable width. - Where it does not fit, unpack the base schema in full, exactly as before the shortcut existed. A foreign base schema is accommodated rather than rejected, so a registration that works today cannot become an import-time failure; each test is written to select the slow path rather than raise. - State the constraint in the class docstring, where someone registering a protocol will read it. - Drop a now-redundant `cast`: the `isinstance` check narrows the type field to `NumberField`, whose `unpack` is already typed `int`. Costs nothing measurable: `profile.pcapng` 30.4/30.8 ms before against 30.7/30.9 ms after over two alternating rounds of best-of-15, the direction flipping between them, against a 2.4-2.8 ms spread. All 34 declarations in the package still take the shortcut, asserted by a new test so that a future declaration cannot quietly lose it. Output unchanged: all 771 files of the equivalence run are byte-identical to the branch without this commit, and still differ from `main` only in the duplicate warnings the dumper commit removed. Suite 867 passed, 17 skipped; mypy message multiset unchanged at 128 errors; pylint unchanged. --- pcapkit/corekit/fields/collections.py | 98 +++++++++-- tests/corekit/test_fields_collections.py | 201 +++++++++++++++++++++++ 2 files changed, 282 insertions(+), 17 deletions(-) diff --git a/pcapkit/corekit/fields/collections.py b/pcapkit/corekit/fields/collections.py index 6243ed3d6a..cbe2e06def 100644 --- a/pcapkit/corekit/fields/collections.py +++ b/pcapkit/corekit/fields/collections.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Generic, TypeVar, cast from pcapkit.corekit.fields.field import FieldBase +from pcapkit.corekit.fields.numbers import NumberField from pcapkit.corekit.multidict import OrderedMultiDict from pcapkit.utilities.compat import List from pcapkit.utilities.exceptions import FieldValueError @@ -178,6 +179,29 @@ class OptionField(ListField, Generic[_TS]): This field is used to represent a list of fields, as in the case of lists of options and/or parameters in a protocol. + Note: + :meth:`self.unpack ` selects an option's schema by reading the + ``type_name`` field of ``base_schema`` **alone** off the front of the + option, instead of unpacking the whole base schema and keeping only that + one value. That is only the same read when three things hold of the type + field: + + 1. it is the base schema's **first** field, so that it is what sits at the + front of the option; + 2. it is a :class:`~pcapkit.corekit.fields.numbers.NumberField`, so that + :meth:`Schema.unpack ` + reads it through its ordinary per-field branch; + 3. its length is a fixed integer rather than a callable, so that its width + does not depend on packet data the shortcut has not read. + + All of that is true of every ``OptionField`` declared in this package. A + base schema registered from outside it -- c.f. + :mod:`pcapkit.foundation.registry` -- need not satisfy it, and is **not** + rejected: such a base schema is unpacked in full, exactly as it was before + the shortcut existed. It parses correctly and pays the cost of the second + parse. Only the shortcut is withheld, so a base schema whose type field + comes second cannot be silently misread. + """ @property @@ -228,6 +252,34 @@ def __init__(self, length: 'int | Callable[[dict[str, Any]], int]' = lambda _: - raise FieldValueError('Field