1.5.0 – 2026-10-29

The largest release since 1.0, and the first recorded here as it happened rather than reconstructed. New extraction engines, ESP with payload decryption, SCTP and NGAP over SCTP, a library logger that no longer hijacks the consumer’s, and a defect programme run through the issue tracker, citing issues and pull requests from #340 to #2257.

Preceded by 1.5.0a1 (2026-09-15), 1.5.0b1 to 1.5.0b3 (all 2026-09-19) and 1.5.0b4 to 1.5.0b14 (2026-09-20 to 2026-10-22), dated in UTC and all published as prereleases and so resolved only by pip install --pre. 1.5.0b1 half-shipped: the tag, the GitHub release and the Conda deployments landed, but PyPI rejected the wheel because twine check found a Sphinx-only :mod: role in README.rst, which pyproject.toml declares as the dynamic long description. 1.5.0b2 reshipped it – an existing version cannot republish – and 1.5.0b3 followed the CI change that stops a TestPyPI outage from costing a release its wheels (#497, #498).

pcapkit.const

Changed

  • a breaking change to pcapkit.const.reg.apptype. AppType becomes a memberless base over one registry per transport protocol – TCP, UDP, SCTP and DCCP – because aenum refuses to subclass an enumeration that has members. isinstance(TCP.http, AppType) still holds and AppType.get(port, proto=...) proxies to the registry, so nothing under pcapkit.protocols changes. The rows IANA assigns no port, and those it assigns no transport protocol, stop being members; they become list-table docstrings on the registry that owns them. __members_proto__ is now a per-registry MultiDict keyed on port, so every service IANA registers on port 80 survives. get() answers with the canonical service, curated from /etc/services because IANA names no precedence, and get_all() reaches the aliases (#754).

  • the %-formatted raise ValueError(...) calls in the generated pcapkit.const.reg.apptype.apptype, left by #759, are f-strings (#792). The remainder followed: the _missing_ guards in pcapkit.vendor.tcp.flags, .ftp.command and .http.method, AppType’s __new__/__repr__/__str__, and the extend_enum fallbacks in get() and in the span-handling tail. The consider-using-f-string disable drops from tcp.flags and reg.apptype.apptype and stays on ftp.command and http.method, whose __repr__ still used %. Edited in the vendor templates and applied to the generated const files by hand, since a crawl would fetch IANA’s live registry and rewrite unrelated rows (#798).

  • the __repr__ methods #798 left on % (in pcapkit/const/ftp/command.py and pcapkit/const/http/method.py) are f-strings, so the disable drops from both files and both pcapkit/vendor/ templates. The other bespoke templates ({const,vendor}/ftp/return_code.py, {const,vendor}/http/status_code.py, vendor/http/frame.py) and vendor/default.py still carry it, so #804’s claim that nothing in pcapkit/{const,vendor}/{ftp,http}/ needs it holds for this pair only; repr() output is unchanged (#804).

  • test_const_enum_no_mint.py’s is_manufactured() exemption for the hex-suffixed names #878 (below) preserves moves from a per-file to a per-name-shape one. The per-file exemption was shape-blind: rewriting a branch to 'Xyplex_%d' % value – exactly the value-derived shape the predicate exists to flag – left the sweep green. The check now exempts only a '..._0x%s' % hex(value)[2:].upper().zfill(4) expression, and the exact exempt_hits == 53 bound stays. Test-only. Closes #879 (#881).

  • a breaking change to AppType.proto: it is the single transport of the registry the member lives in, not every transport IANA assigned the service, and register_apptype takes its transports as varargs (#806, #815). Aliases show in repr() and str(), and can be registered per transport (#807, #837).

  • a breaking change to TransportProtocol: its base moves from aenum.IntFlag to aenum.IntEnum, so | between two members gives a plain int rather than a composite member (#808, #836).

  • a breaking change to TLSKeyLabel: the hand-written class in pcapkit.protocols.misc.pcapng.protocol becomes the generated pcapkit.const.pcapng.tls_key_label.TLSKeyLabel (PCAPNG_TLSKeyLabel in pcapkit.const.pcapng), crawled from the IANA registry RFC 9850 creates (#886, #890), and the old path is removed (#1744, #1754).

  • a breaking change to Method.get(): the method token is case-sensitive, per RFC 9110 Section 9.1, so Method.get('get') no longer resolves to GET (#896, #907). It also looks a key up by value, so BASELINE-CONTROL and VERSION-CONTROL resolve (#908, #915).

  • a breaking change to ExtensionHeader: it is generated from IANA’s IPv6 Extension Header Types registry rather than the Protocol Numbers registry’s flag, and the two disagree on 147 (BIT-EMU) (#925, #926).

  • the TCP and UDP AppType registries load on first use rather than at import (#1538, #1560).

  • a breaking change to LLACode: Wilcard is spelled Wildcard and MH is MN, the names RFC 5568 gives codes 0 and 2, with no alias for either old name (#1848, #1872).

  • a breaking change to tcp.flags.Flags: each member is its wire mask in the flags field, FIN 0x001 up to AE 0x100, rather than 1 << offset in IANA’s most-significant-first bit numbering, which put FIN at 0x8000. int(tcp.connection) of a SYN/ACK is 0x012, as the header carries it, not 0x4800, and Flags(0x12) is SYN | ACK. The range guard narrows to the 12-bit flags field, 0 <= value <= 0xFFF. Code that stored or compared the old integers has to change; membership tests and names are unaffected (#2310).

Fixed

  • constant lookups that rejected a value the registry defines. RouterAlert(0) is the only value RFC 2113 defines and the one IGMP, RSVP and MLD actually send, and it was discarded because the vendor crawler skipped a header row IANA’s CSV does not have; IPX Socket(0) is that protocol’s own default, so bytes(IPX(...)) crashed on its own defaults; and the FTP _missing_ overrides were plain methods rather than classmethods, so every unregistered value raised TypeError instead of extending the enumeration (#492, #503).

  • the string-keyed get() in pcapkit.const.ftp.command and pcapkit.const.http.method tested membership with the raw key but registered key.upper(), so the first lowercase or mixed-case token raised TypeError: 'RETR' already in use. Reachable from wire data: pcapkit.protocols.application.ftp.protocol matches with re.I and passes the match verbatim, and RFC 959 Section 5 makes FTP commands case-insensitive, so retr file.txt was a valid request the library could not parse. get() and _missing_ now look the key up under the canonical upper-case name, so every casing resolves to the existing member; a duplicate GET would have lacked the real one’s safe and idempotent attributes (#582, #583). http.method no longer folds case: RFC 9110 Section 9.1 makes the method token case-sensitive, so Method.get() matches it exactly (#896, above).

  • get()’s documented default was ignored on the integer path across the generated pcapkit.const tree, because _missing_ cannot see the caller’s default – Hardware.get(99999, 0) raised ValueError instead of returning 0. The integer path now consults default before letting the error escape. -1, the placeholder the generated signature already carried, still separates “no default” from “a default”; #859 replaced it with an identity sentinel once the shared base #858 (below) put get() behind one call site. The sweep covers the integer registries generated from the shared template, not only those #584 named; pcapng OptionType and reg AppType carry a bespoke integer fallback and are deliberately left alone, since neither drops a default by raising. Not reachable from wire data, so a contract fix rather than a parse fix (#584).

  • _missing_ in the Mobility Header flag enumerations (BindingACKFlag, BindingUpdateFlag, HandoverACKFlag, HandoverInitiateFlag) ended in return cls(value), the constructor that had just failed, so every in-range value that is not already a member re-entered _missing_ unbounded and raised RecursionError – F(0), no flags set, and every composite such as BindingACKFlag(0x06). Defining _missing_ at all caused it, by shadowing the aenum Flag machinery that resolves exactly those values; pcapkit/const/tcp/flags.py had none and so never had the defect (it has one now, from #647 below, with the same tail). Each now ends in return super()._missing_(value), as pcapkit/vendor/default.py emits and most const modules (#647’s new ones included) already did, so F(0) is an empty flag and BindingACKFlag(0x06) is S|D. Minting through extend_enum was rejected for a flag type: naming 0x06 Unassigned_0x06 would hide the composite and add a member per bit pattern. The range guard is untouched (#623).

  • constant registries rejected an invalid value differently from the built-in enum, or did not reject it at all. Flags, ftp.command.CommandType and reg.apptype.TransportProtocol are IntFlag types with no _missing_, so aenum composed a pseudo-member for any integer: Flags(-1) returned 65520 (every TCP flag set, from a value no 16-bit field can hold) and Flags(-65536) none. ftp.command.Command, FEATCode and http.method.Method are StrEnum types whose _missing_ called value.upper() before checking the type, so an integer raised AttributeError. These now raise a bare ValueError, as enum.IntEnum does and the other modules already did. #647’s proposal to replace those with pcapkit.utilities.exceptions.EnumError was deliberately rejected: EnumError is (BaseError, TypeError), not a ValueError, so it would diverge from the built-in, walk past the except ValueError in every generated get() body (silently undoing #584), and log CRITICAL from BaseError.__init__ once per discarded default. The one deliberate divergence is kept and pinned: mutable registries look up, miss, and extend_enum rather than raising, so Method('FROBNICATE'), ProtectionAuthority(1 << 70) and AppType.get(65000, proto=tcp) still register; the #775 and #860 entries below record how each came to return an unregistered member instead. The flag guards end in return super()._missing_(value), so in-range composites still decompose and #623 stays fixed; Flags accepts 0 <= value <= 0xFFFF. A new tests/const/test_const_enum_builtin_parity.py asserts that every registry raises the same exception type as a stdlib enum.IntEnum; it keys on aenum.Enum because an IntFlag is not an IntEnum subclass, which is how the IntFlag ones escaped the existing sweeps (#647).

  • a breaking change to AppType.get(): an out-of-range port is refused rather than minted. Its except ValueError caught the range rejection from _missing_ and minted regardless, so AppType.get(-1, proto='tcp') returned an unreachable PORT_-1_tcp while TCP(-1) raised. It now tests _missing_ for None (“no row for this port”) and lets the rejection through; a valid but unassigned port still minted, until #874 (below) made it an unregistered member. Transport._make_port() passes an unvalidated int, so TCP.make(srcport=99999) now raises where it minted junk; in 1.4.1, TCP(srcport=N) raised AttributeError for any bare int. Separately, the crawler rendered one _missing_ branch per registry row, so the spans IANA registers on more than one transport emitted the same condition twice and every registry answered with the first: AppType.get(6010, proto='udp') gave a member whose proto read tcp, 6666/udp answered ircu rather than reserved, and proto='sctp' minted x11 where IANA assigns nothing. A branch naming a transport now tests cls.__transport__; the branches naming none are unchanged (#764).

  • a breaking change to AppType._dispatch(): it resolved a proto naming several transports by its lowest set bit, and tcp is lowest, so every composite containing it dispatched into the TCP registry – silently, and undetectably to a caller checking equality, since AppType.__eq__ compares on port alone. TransportProtocol was still an aenum.IntFlag and a member’s proto carried the whole set IANA assigned the service, so tcp | udp was an ordinary value to pass back in; the AppType.proto and TransportProtocol entries above record how #806 and #808 later retired both. Over every multi-transport member this misrouted port 888 (accessbuilder resolved to TCP’s cddbp) and port 999 (puprouter resolved to TCP’s garcon). It now raises ProtocolError, a ValueError subclass, so the documented contract holds. No library caller passes a composite (#759).

  • get()’s string-key miss and every generated registry’s _missing_ bounded-range branch called aenum.extend_enum() on an unrecognised value, so e.g. Hardware(40) minted a permanent member. Fixed at the shared sites in pcapkit/vendor/default.py, plus a new register(value, name) base classmethod for the caller-named path that still mints. Every generated registry is updated; the non-minting get() miss applies to all of them, but the _missing_ fix changes behaviour only for those with a bounded-range extend_enum branch. pcapkit/toolkit/scapy.py and pcapkit/toolkit/pyshark.py fed get() a string key with no default and now raise MissingKeyError instead of receiving a placeholder; the reader sites in hopopt.py/ipv6_opts.py convert the KeyError into ProtocolError. Riding along: pcapkit/toolkit/pyshark.py gains a FILTER_NAME_TO_LINKTYPE map (eth to ETHERNET, tr to IEEE802_5), consulted ahead of LinkType.get since pyshark reports a filter name, not a DLT name. The other minting call sites in _missing_, get and the hand-written mh.py and ngap.py were deferred; the register/register_alias sites are not deferred but the by-design caller-named path (#838).

  • a breaking change to pcapkit.const.ipx.socket.Socket’s _missing_: archived range branches that were strict subsets of a range tested earlier – (0x0020, 0x003F) after (0x0001, 0x0BB8), and (0x4000, 0x4FFF) and (0x8000, 0xFFFF) after (0x0BB9, 0xFFFF) – so they never fired. The generator pcapkit/vendor/ipx/socket.py now lists each subset range ahead of the range containing it. 36891 of the 65521 values that _missing_ resolves (56.3%) change name: 32 from Registered by Xerox to Experimental, 4095 from Dynamically Assigned to Dynamically Assigned Socket Numbers and 32764 from Dynamically Assigned to Statically Assigned Socket Numbers. No defined member changes and every value still resolves (#847).

  • LinkType(209).name returned 'IPMB_LINUX', which tcpdump’s own table marks “Legacy names (do not use)”, because a 2024-05-04 regeneration emitted that row above the current I2C_LINUX = 209 and aenum gives a value to the member defined first. pcapkit/vendor/reg/linktype.py sinks a row whose note mentions “legacy” after every current row, so the current name is defined first; in the registry crawled for this release that affects value 209 alone. IPMB_LINUX stays a reachable alias (LinkType['IPMB_LINUX'] still resolves to 209). Not breaking: only the canonical name for the shared value moves (#848). The rule sinks only a row whose value another row also holds, counting values inside ranges, so a current row that merely mentions a legacy name stays first (#852, #854).

  • a breaking change to more const registries’ _missing_: applies the owner’s mint/unmint ruling (settled on #847, confirmed on #775: a final, concrete assigned name mints, and a notation for readers does not) across the _missing_ files still calling extend_enum. Most convert wholly, each minting a bare status word (Unassigned, Reserved [...], Reserved for Private/Experimental Use, Unspecified in the IANA registry, Deprecated), which the ruling treats as a notation rather than an assignment. Some are mixed: EtherType’s DEC Unassigned and “Old Xerox Experimental values. Invalid as an Ethertype since 1983.” branches convert, while vendor-company names (Xyplex, Motorola, Walker Richer & Quinn and others) keep minting, since a proprietary protocol has no public name beyond the company’s; IEEE802_3_Length_Field and Berkeley_Trailer_encap_IP also keep minting but are not company names. Socket (ipx)’s allocation-policy names (Experimental, Dynamically Assigned Socket Numbers, Statically Assigned Socket Numbers, Dynamically Assigned) convert, while Registered by Xerox keeps minting, a real ownership fact. Left alone are CGAType, whose Tag_<hex> mint is not an IANA-style range, and the files holding the classes #860 moves onto EnumRegistry (the AppType, StatusCode, ftp.return_code, ftp.command, Method and OptionType modules; #869 and #874, below). Each conversion changes both the vendor crawler and the generated file, per #838’s and #858’s precedent. The worked examples in docs/source/contributing/conventions/mint-criterion.rst keep Registered by Xerox under Mint (#861).

  • a breaking change to EtherType’s _missing_ ordering: values 0x0101-0x01FF now resolve to their Old Xerox Experimental name instead of an IEEE802_3_Length_Field_0x0101-style one. The generated method tested 0x0000 <= value <= 0x05DC (IEEE802.3 Length Field) first, and the Old Xerox range sits wholly inside it, so that branch was unreachable. Unlike #841’s IPX fix, these bounds come from the live IANA CSV, so the fix is general: pcapkit/vendor/reg/ethertype.py’s process() collects range rows and a new EtherType._insert_range places each ahead of the first already-placed range that fully contains it, leaving non-overlapping pairs in CSV order; this pair is the only containment among the CSV’s ranges. Closes #862 (#865).

  • a breaking change to Method: __new__ called str.__new__(cls) with no argument, so every registered member’s str payload was empty regardless of value – str(Method.GET) == '' and Method.GET == 'GET' was False; .value was always correct. #869 (above) had fixed the unregistered path only, leaving str(Method('frob')) == 'frob' beside str(Method.GET) == ''. Fixed to str.__new__(cls, value), mirroring Command.__new__, in the generator (pcapkit/vendor/http/method.py) and the generated module. No sibling hand-rolled __new__ has the defect (OptionType/AppType deliberately store a display string). Consequences, all corrections: Method.GET == 'GET' is True; bool(Method.GET) flips False to True; json.dumps(Method.GET) emits "GET" not ""; sorted() is lexicographic rather than input-order-stable; and {m: ... for m in Method} gains an entry per member where it had one, because every member previously hashed as '' and compared equal. Nothing under pcapkit/protocols relied on the old behaviour. Closes #870 (#871).

  • a breaking change to TransportProtocol and AppType: step 2 of #860, the AppType half (the other bespoke registries were #869, above). AppType and its transport subclasses move onto EnumRegistry and both mint sites – _missing_’s range branches and get()’s own mint (PORT_{port}_{transport} -> 'unknown') – now build through a new _unregistered_member override that reconstructs svc/port/proto, per the owner’s ruling that only IANA-registered values are legitimate and that get() lacks the information to create new ones. Every branch converts, including those naming a real service assigned to a whole port span (x11, active-net, satvid-datalnk, vrml-multi-use, ircu, swx, flex-lm); unlike FEATCode’s fix (#869, above) there is no import-time self-mutation to cure by declaring members statically, so these stay deliberately unregistered lookups. AppType gains a working register(); the base’s would have built a member with svc='<null>'. The breaking part: TransportProtocol moves from power-of-two values (tcp=1, udp=2, sctp=4, dccp=8) to sequential auto() (sctp=3, dccp=4), per the owner’s follow-up ruling once |-joined composites stopped being parsed. AppType.get() treats whatever int it is given as a whole, so the renumbering changes what specific integers mean: on AppType.get(80, proto=...), a bare 4 resolved to SCTP.http on main and now silently resolves to DCCP.unknown, because a real member sits at 4 under both numberings; a bare 8 used to mint DCCP.PORT_80_dccp and now raises ValueError. The composed-value case is deliberately untested, per the owner’s ruling that a breaking change already retired that path (#874).

  • closes the remaining scope of #775: EtherType and Socket, the last registries whose _missing_ minted a permanent member, now return a non-registering cls._unregistered_member(value, name). The issue’s AST census of extend_enum in _missing_/get is down to the single CGAType carve-out (its mint is not an IANA-style range). EtherType(0x0888) used to add a member to __members__ and return the identical object on a repeat; it now returns <EtherType.Xyplex_0x0888: 2184> with __members__ unchanged, absent from _value2member_map_, equal but not identical across lookups – likewise Socket(0x0010). The regenerated EtherType was not checked against the live IANA CSV, only against the committed fixture. Keeping each branch’s hex-suffixed name reintroduces the value-derived shape test_const_enum_no_mint.py’s is_manufactured() sweep forbids; rather than weaken the predicate, a narrow exemption covers exactly these files, since the collision the check guards against cannot occur once _unregistered_member never registers (#878).

  • OptionType.register() and Command.register() built members that get() could not find again, and OptionType.register_alias() raised not a registered OptionType for every argument. Both registries now round-trip through get(), in the generated files and the vendor templates (#1311, #1330, #1369).

  • the Quick-Start function registries rejected 9 to 15, though RFC 4782 gives the Function field four bits; the whole 4-bit range now resolves (#1350, #1353).

  • every three-digit FTP reply and HTTP status code resolves, including those no registry row names (#1443, #1444).

  • the AppType registry’s dict union is defined only on interpreters whose dict has one (#1694, #1703).

  • NGAP’s ProcedureCode and ProtocolIE are generated into pcapkit.const.ngap from pycrate’s compiled NGAP specification, with no network fetch. An unseen value gets a non-registering member, where every unknown key used to mint a member at the shared value -1 and so aliased every other (#880, #899).

  • FEATCode.get() matches case-insensitively, as RFC 5797 Section 2 defines FEAT codes; the registry-wide case-sensitivity audit found no other registry that needed to fold (#903, #913).

  • register refuses what get refuses. TCP.register(70000, 'x') and Hardware.register(70000, 'x') minted a member that get then returned, because no registry overrode the _validate_value hook register calls. Each generated registry, AppType included, now overrides it with the check its _missing_ makes, raising EnumValueError. get(value, default) on a value the hook refuses still returns default and logs nothing (#1792, #1818, #1816, #1823; the quiet miss is #1845, #1873, under pcapkit.corekit). AppType.register() and OptionType.register() keep that rejection loud inside a quiet get, as EnumRegistry.register() does (#1916, #1927).

  • Command.get() refuses a key that is not a str with EnumValueError, as Method.get() does. get(b'\x01') minted a member keyed by bytes, and get(-1) raised AttributeError whatever its default (#1824, #1826).

  • IANA’s port spans that name no transport answer AppType.get() with the asking registry’s transport rather than TransportProtocol.undefined, as its unknown fallback for a port in no span already did: TCP.get(8012) is unassigned [tcp]. A port IANA lists on other transports only stays unknown: 8011 is reserved [tcp] and bier-echo [udp], and unknown [dccp] and unknown [sctp]. Lookups still mint nothing (#860) and an out-of-range port still raises (#2048, #2052).

  • Method._missing_ resolves an HTTP method whose registered name differs from its own value, so Method('baseline-control') and Method('version-control') give BASELINE_CONTROL and VERSION_CONTROL rather than minting an unregistered member: the lookup falls back to the value map on the upper-cased value before it mints. Method.get() stays case-sensitive (#2284, #2351).

pcapkit.corekit

Added

  • tests/corekit/test_fields_numbers_width_repair.py, covering each octet boundary in its own method, since the defect is a pattern and a single case would pass against a fix that special-cased the reported width (#599).

  • FieldBaseShortReadPaddingSideTests, covering both byte orders at 2, 4 and 8 octets and every truncation point, including the figures #604 reports, plus guard rails for a full read and for a read against an empty buffer, the #431 behaviour the option and list loops depend on (#604).

  • EnumLookup, the base carrying get and get_all that EnumRegistry and the non-registry enumerations share (#877, #906, #921, #932). Its get raises pcapkit exceptions in the shape of the stdlib Enum: a name miss raises EnumKeyError, a KeyError (#923, #927).

  • MultiInfo and OrderedMultiInfo, immutable Info subclasses that are also a MultiDict and an OrderedMultiDict, so a repeated key keeps every value. The data models’ option lists use them (#1484, #1523).

  • pcapkit.corekit.fields.byteorder, holding packet_byteorder and byteorder_callback, the byte-order helpers the PCAP and PCAP-NG schemas share (#1528, #1711, #1715).

  • can_seek(), whether a stream really seeks: on Windows a pipe answers seekable() with True while its seek does nothing. It trusts seekable() only for a regular file or a stream with no descriptor. Extractor wraps any other stream in SeekableReader, so pcapkit - reads a Windows pipe (#2097, #2104), and advance() skips a record on one by reading (#2108, #2118, 93a6173ae).

Changed

  • Probe, CipherSuite and IntegritySuite are Info subclasses rather than typing.NamedTuple. They are Mappings now, so len() and iteration yield field names rather than values. TCPSegment in pcapkit.toolkit.pcap stays a typing.NamedTuple, since it is built for every TCP frame and an Info costs more to construct.

  • ModuleDescriptor.klass reads an already-imported module out of sys.modules rather than re-entering importlib.import_module, since next layer dispatch resolves a descriptor there on a per-frame path. Neither a registry hit holding a ModuleDescriptor nor a miss writes the resolved class back: a memoised hit went stale on importlib.reload (#1559), and recording a miss in a class-level collections.defaultdict is the defect #421/#426 fixed at this layer, #425/#428 for the option, chunk and block registries, and #555/#560 at the schema layer. A resolution now costs ~117 ns rather than ~436 ns, which is not measurable in extract() wall clock. The change is taken for its shape: sys.modules stays the only module cache, whereas a memo of the class would serve the pre-reload class after an importlib.reload forever and fail isinstance against the live one. Proposed by @Ts-Boom in #563; the implementation differs because that one added a second, never-invalidated cache of resolved classes (#574).

  • SeekableReader.truncate() raises instead of returning a size, and the misspelled writeable() is now writable(). Both break external callers; nothing inside the package calls either. io.IOBase documents one gate over two methods – “If False, write() and truncate() will raise OSError” – and this reader’s writable() answers False, yet truncate returned its new size, where open(path, 'rb').truncate() raises. The refusal is UnsupportedOperation('truncate'), the same one write raises; pcapkit’s subclasses io.UnsupportedOperation. The old resizing is kept as the private _truncate_buffer: it resizes a private lookback window, not the stream, and it is the only route to the “position sits before the window” state that seek and the buffered read paths must refuse, for which #643 and #644 landed tests. writeable() was never an override of anything, so io, shutil and third parties never saw it, and the value reported is unchanged (#645).

  • a breaking change to @final, now enforced at runtime on Info and Schema. info_final and schema_final both end return final(cls), so every finalised class carried typing.final’s __final__ marker and nothing read it. @final alone on an Info or Schema subclass – without @info_final or @schema_final – now raises InfoError or SchemaError at first construction, the class being marked final but never finalised and so carrying no generated __init__. __init_subclass__ cannot catch that, since final applies after class creation, so the check sits in the existing one-shot FinalisedState.NONE branch and a finalised class pays nothing. Deriving from a finalised class raises too, from new Info.__init_subclass__() and Schema.__init_subclass__() hooks (before, only EnumSchema had one). SchemaError is a ValueError, so code catching TypeError around a subclass declaration will not see it. @info_final @final and @final @info_final both finalise silently; the re-entry check keys on __finalised__ rather than __final__, because only the former records the decorator having run. @info_final twice warns and returns the class unchanged. pcapkit.utilities.compat takes final from typing_extensions below 3.11 rather than 3.8, because typing.final only records __final__ from 3.11. The guard cannot fire on the library’s own classes (#778).

  • a breaking change to Info.to_dict(): it returns an OrderedMultiDict at every level rather than a dict, so a key that repeats, such as a second IPv6 extension header of one type, survives a from_dict(to_dict()) rebuild. type(d) is dict no longer holds (#1453, #1466, #1484, #1523).

  • a breaking change to pcapkit.utilities.decorators: it moves to pcapkit.corekit.decorators, with no re-export. seekset, beholder and prepare are no longer importable from pcapkit.utilities, and the module logs as pcapkit.corekit.decorators (#1735, #1737).

  • a breaking change to the sentinels: all live in pcapkit.corekit.sentinels, which exports the objects NULL, NO_VALUE and NO_DEFAULT and their common base Sentinel, but not their types, and NoValue is spelled NO_VALUE (#916, #911, #922, #937, #939). No moved name keeps a re-export, so NoValueType has only that home, not its 1.4.x one in pcapkit.corekit.fields.field (#1744, #1754).

Fixed

  • stdlib exceptions leaking out where the library’s own were promised: a malformed IP field value raised a bare ValueError instead of FieldValueError (#465); a bool address was silently packed as 0.0.0.1 or 0.0.0.0, bool being an int subclass (#491, #500); and @prepare discarded extra arguments silently and treated a declared zero length as end of stream, which is now distinguished from a genuinely exhausted one and raises StreamEOFError (#454, #458).

  • FieldBase.unpack() zero-padded up to a field’s declared length with rjust() however little data buffer held; a ~40-octet PCAP-NG Decryption Secrets Block with a bogus inner length forced a multi-gigabyte allocation, since length is often wire-derived. A declared length past 262144 octets – libpcap’s MAXIMUM_SNAPLEN and this package’s default snaplen – that the buffer cannot back now raises FieldValueError; the option and list loops’ tolerance for a short read past a truncated area (#431) is far under that ceiling and untouched (#554).

  • that ceiling bounds one field, and a packet holds many, so the sum of a parse’s zero padding was still unbounded: 200 minimal PCAP-NG Decryption Secrets Blocks (4,800 wire octets, each declaring secrets_length 262,142 against two supplied octets) retained 50.0 MiB, 10,922x per block, with the per-field guard never firing. FieldBase.unpack() now keeps a per-context ledger of octets supplied against octets synthesised, and raises FieldValueError once the total of shortfalls past 65,536 octets passes 262144 + 16 * supplied. The same 200 blocks retain 0.2 MiB (54.6x). A shortfall of 65,536 octets or fewer, the span of a 16-bit wire length, is padded unconditionally and charged to nothing, so no snapshot-truncated capture is subject to the budget; without that band the same legitimate frame parsed differently depending on what had been parsed before. What this does not close: a length declared by a 16-bit field can still be repeated without limit, and at this layer a crafted file is indistinguishable from a legitimate snapshot-truncated capture of offload-sized segments; separating them needs the frame’s own incl_len/orig_len. Nothing resets the ledger, so it bounds everything a context has parsed rather than one file (#573).

  • which exception a malformed TCP SACK option raised depended on unrelated process state: a clean interpreter raised ProtocolError as documented, but a process that had popped pcapkit.corekit.fields.misc from sys.modules (as the #439 ABC-cache regression tests do in every setUp/tearDown) raised FieldValueError from a different layer, before the documented check (#525). ListField.unpack() resolved SchemaField through a function-local import re-run on every call; a module reimported mid-process is a second, distinct class, so isinstance misclassified the field and billed each item by its declared length instead of what it consumed. Any caller relying on the previously-observed FieldValueError now gets ProtocolError, deterministically, matching the docstring.

  • a NumberField whose length was a callable could not pack or parse at any width struct has a native integer code for. length is -1 until the callable resolves, -1 has no native code, and the template builder raised _need_process for it and never cleared it – a latch. Resolving the real width rebuilt the template but left the latch set, so pre_process handed bytes to a template that had become >Q (struct.error: required argument is not an integer), and parsing failed in mirror by calling int.from_bytes on an integer struct.unpack had produced. The flag is now recomputed from the width in force, which also keeps a callable resolving to a width with no native code (3 octets) byte-packed. Every native width (1, 2, 4 and 8 octets) was affected, not only the 8 reported, on NumberField and EnumField alike. This made every extended 8-octet MPTCP DSS form unbuildable, those widths being chosen at runtime from the DSS flags; #585 worked around it in the TCP schema alone (#591).

  • NumberField.pre_process() sized a value with floor division dressed as a ceiling. When a field is packed while length is still the -1 placeholder, the width came from math.ceil(value.bit_length() // 8); the // had already floored, so math.ceil did nothing and the width was one octet short: 256 at one octet, 65536 at two, 16777216 at three. The reach is wider than “just past a boundary”: every bit length that is not a multiple of eight is wrong, so 1 (bit length 1, floored to zero octets) and every value from 1 to 127 failed too. It is now the ceiling it was meant to be. The repair is reached only by packing a field the caller never resolved (a schema resolves every field first), which is why #591’s suite, whose values all had byte-aligned bit lengths, missed it; #591’s fix neither caused nor masked it. Deliberately left alone: a signed field is sized without room for its sign bit, so an unresolved signed field still cannot pack 128. The same typo in the ILNP nonce option builders is a separate change (#599).

  • FieldBase.unpack() padded a short read on the wrong side. When a buffer falls short of the declared length – the accommodation that lets a snapshot-truncated capture parse (#431) – the shortfall was zero-filled with rjust(), placing the zeros at the front, as if the octets never read were the leading ones; a short read has lost the trailing ones. It is now ljust(), correct for both byte orders: one octet of a four-octet little-endian 120 read as 2013265920, and three octets of a four-octet big-endian 0x01020304 as 0x10203. A full read is untouched (#604).

  • SeekableReader.truncate() put its padding where the reader’s bookkeeping says the content is, and read then returned one octet fewer than the buffer held. Same family as #604, but separate defects. The buffer keeps content at [0:_buffer_cur] with padding behind it, so a reduction must keep the octets it has and an extension must append at the tail; temp[-size:] sliced the buffer rather than the content, keeping trailing padding and discarding octets read, and temp.rjust(size) prefixed the zeros. Separately, read capped at min(size, self._buffer_cur - 1), one short at the start and reaching into padding further in, and made up the shortfall from the stream past the skipped octet. The reported symptom needed both: read(4), truncate(8), seek(0), read(8) over b'abcde' returned b'\x00\x00\x00e' and now returns b'abcde'. Further contract faults are fixed with it: the position was reset to the buffer start rather than left alone; an omitted size resized to 0 rather than the current position; and _buffer_cur was left addressing octets a reduced buffer no longer had, so the next read raised ValueError: memoryview assignment: lvalue and rvalue have different structures from _write_buffer. truncate(0) raised the same by a second route (buf[-self._buffer_size:] is buf[-0:], the whole buffer), so _write_buffer now counts from the front; that state is reachable only through truncate. A reduction also advances _buffer_set past the dropped octets, keeping _buffer_set + _buffer_cur equal to how far the stream has been consumed, which seek reads as its licence to fetch more; clamping _buffer_cur alone made that sum under-report, and the next forward seek silently spliced in octets from the wrong offset (read(8), truncate(3), seek(6), read(1) over b'abcdefghijklmnop' returned b'l' where b'g' sits at offset 6). Latent rather than live: nothing here calls truncate, though it is public (#622).

  • the 16-bit band the #573 entry above records as deliberately left open: a length declared by a 16-bit wire field could still be repeated without limit. The crafted PCAP-NG of 2,000 Enhanced Packet Blocks, each with an option declaring 65,535 octets against four real ones, synthesised 1,637x its size in zeros and now synthesises none, with every frame and option still parsed. The bound is taken one layer up, where the missing information exists: a PCAP-NG block’s Block Total Length is authoritative and cross-checked against its trailing copy, so the option area is that length less the fixed fields, captured_len and its padding, and an option declaring more payload than the area has left is malformed. A snapshot-truncated capture says so through captured_len and leaves its options whole, so it never trips this – which the #571 len(buffer) < length rejection could not distinguish, and why it was declined. A new bounded_option clamps the payload to the octets the area has left and warns with SchemaWarning; it is applied to every variable-width option and record payload in the PCAP-NG schema, and deliberately not to the block-level payload fields, the 32-bit band the running ledger already budgets. A companion bounded_area clamps a packet block’s option area to the octets the block itself holds less the trailing Block Total Length: the area was otherwise sized from a declared length nothing checks against the file, so a block declaring 1,000,000 octets while holding 36 synthesised 65,535 zeros with no warning. Both are no-ops on a well-formed block. The non-packet blocks’ option areas keep the framing assumption; the general fix for a declared length reaching a read is #678. Clamping rather than refusing keeps the #431 accommodation, since a PCAP-NG block read has no catch point above FieldBase.unpack() and one refusal would abort a whole extraction rather than one block; and the clamp reads only the block’s own declared framing, never a running total, so identical input answers identically whatever preceded it. The skip for a remainder already past zero is load-bearing on the unpacking path, where clamping would give the field a negative template such as '-2s'. No clamp fires on the sample captures, and #678’s uncaught ValueError on truncated input is unchanged (#594).

  • a breaking change to FieldBase.length: it called struct.calcsize on a template built from a negative resolved length (e.g. '-5s', from a length= callback whose running counter had been overdrawn), raising a bare struct.error that ordinary caller code does not catch. The chokepoint every affected schema shares now raises ProtocolError, covering application/{ftp,httpv1,httpv2,ngap}.py, internet/{hip,ipv6_route,mh}.py, link/ethernet.py, misc/pcapng.py and transport/sctp.py with no other file changed; a non-negative length is unaffected. This closes the follow-on the httpv2 length-guard entry under pcapkit.protocols filed as out of scope (#805).

  • a breaking change to EnumRegistry.register(): it now raises ValueError naming the existing member and pointing at register_alias(), where it used to silently alias an already-registered value under the caller’s new name – aenum treats a taken value as an alias request, so register(existing_value, 'TOTALLY_NEW_NAME') returned the existing member, minted nothing and raised nothing, contradicting its docstring. pcapkit.corekit.enum.EnumRegistry is a new mixin implementing get, get_all, register, register_alias, register_aliases and _unregistered_member once, per the owner’s ruling on #842; it first converts registries with no bespoke __new__ (ipv6.extension_header.ExtensionHeader, tcp.flags.Flags and the mh.*_flag modules), and serves IntEnum, IntFlag and StrEnum alike. The guard reaches only those: the other const registries still carry the old generator’s bare extend_enum(cls, name, value), so pcapkit.const ships contradictory register contracts until #858 (below). These also gain the generated template’s KeyError catching on a missing name, so Flags.get('NOPE', 2048) returns Flags.ACK rather than raising; and a key that is neither int nor str, which raised TypeError on the IntFlag registries (Flags.get(3.5)), now raises ValueError, tried as a value. On ExtensionHeader it raised KeyError, the shift #858’s entry attributes to the other registries (#855).

  • a breaking change to EnumRegistry.register(), extended: the const registries still on the generated template’s bare extend_enum now inherit the guard #855 (above) added, so TransType.register(6, 'TOTALLY_NEW_NAME') raises ValueError naming the value and the member that holds it and pointing at register_alias(), where it used to mint and raise nothing. pcapkit/vendor/default.py’s template now emits class {NAME}(EnumRegistry, IntEnum) and drops its own get/register/_unregistered_member. The modules with a bespoke __new__ (ftp/command, ftp/return_code, http/method, http/status_code, pcapng/option_type and reg/apptype among them) are left alone, so every other class inherits EnumRegistry (#855). get()’s dispatch shifts only for a key that is neither int nor str (KeyError, tried as a name; now ValueError, tried as a value), a key type nothing in the library passes. pcapkit/corekit/enums.py is renamed enum.py, the owner’s ruling (#858).

  • EnumRegistry.get()’s str-key branch tried only a name lookup (cls._member_map_[key]) and on a miss raised or returned cls(default), never the value path, so on a StrEnum registry a key that resolved through the constructor (_Str('known-value')) raised KeyError through get(), and a supplied default overrode a good value match the code never attempted. It now falls back to a plain _value2member_map_ lookup once the name lookup misses and before consulting default. Deliberately not cls(key): FEATCode._missing_() (pcapkit/const/ftp/command.py) mints via extend_enum for any unrecognised string, so routing through the constructor would let a failed name lookup mint a permanent member once a registry like it converts; a dict lookup never reaches _missing_. A name still wins when a string is both a name and a different member’s value. Step 1 of #860; step 2, converting the bespoke registries, follows (#869, #874, below). The non-str path is unchanged. Widens rather than breaks: a call that raised KeyError now resolves, and no caller that got a correct result gets a different one (#863).

  • a breaking change to EnumRegistry.get(): its docstring claimed “It never mints”, but each cls(default) call site reached _missing_ for a default inside a still-minting registry’s range, growing the registry as a side effect of resolving default rather than key. Per the owner’s ruling that only register() mints, and get() only through the ranges _missing_ mints, each site now resolves default through a plain _value2member_map_ lookup; key resolution is unchanged, so EtherType.get(0x0888) still mints Xyplex_0x0888 through its own _missing_, as #775’s deliberate exception permits. A default naming no registered member now falls through to the error key itself would have raised, rather than a fresh error about the default – the accepted cost of the ruling. The docstring’s “It never mints” is qualified to default only. So Hardware.get('Definitely-Not-A-Member', 40), with 40 in Hardware’s declared-but-unassigned range, now raises KeyError naming the original key. Closes #864 (#868).

  • ProtoChain.__add__() returned the left operand’s cached protocols and aliases: once a.protocols had been read, a + ProtoChain(IPv4) reported chain == 'Ethernet:IPv4' but protocols == (Ethernet,). copy.copy(self) carried the cache across; the result is now built fresh, as from_list builds one, and += follows, since it falls back to __add__ (#1099).

  • a SchemaField with no declared length handed its -1 placeholder to the nested schema as that schema’s length, so every item of such a list warned packet length < 0: a HIP LOCATOR_SET and an MH CGA Parameters item among them. SchemaField.unpack() now passes what is left in the buffer as a declared length – not None, which would turn a truncated item’s FieldValueError into prepare()’s quiet StreamEOFError. Fixed-length items are unchanged; the HTTP/2 SETTINGS pair, whose size is fixed, is given it in its own schema instead (#1140, below) (#1148).

  • a signed NumberField decoded as unsigned, a zero-length PayloadField misread, and an empty Raw did not rebuild (#1331).

  • from_dict(to_dict()) lost nested values, and len() of an Info counted its hidden keys (#1334).

  • StringField packs back exactly the octets it unpacked, where a guessed charset, a U+FFFD substitution or a character-count length used to change them; the decoded text is unchanged (#1293, #1336). A charset that rejects 'replace' falls back to UTF-8 (#1706, #1728), and Python 3.8 unquotes through pcapkit.utilities.compat.unquote (#1693, #1707).

  • IPv4InterfaceField keeps a mask that is not a netmask, and a deep copy of an OrderedMultiDict keeps its order (#1346).

  • a breaking change to padding: non-zero padding octets are kept on rebuild rather than zeroed (#1223, #1436).

  • fields keep the bits and octets they captured on rebuild: a BitField’s unnamed bits (#1487, #1495), a NumberField’s bits outside bit_length (#1488, #1497), and the bytes an untyped ListField unpacked (#1489, #1494). An unnamed ListField, PayloadField or SchemaField gets a default name (#1491, #1493).

  • each non-final Info subclass is finalised on its own state (#1490, #1496), and its constructor reads only the class’s own annotations (#1692, #1702).

  • copying or pickling a stream proxy no longer recurses (#1524, #1544).

  • @prepare reads length and packet by keyword as well as by position, so Schema.unpack(data) and Schema.unpack(data, length=2) work where they raised IndexError (#444, #450).

  • ListField unpacks a schema item from the per-item copy its callback and length_callback configured, not from the unconfigured original (#433, #453).

  • an *IntField given a signed= that contradicts its fixed sign, such as UInt8Field(signed=True), raises FieldValueError; the argument used to be discarded silently (#545, #549).

  • negative lengths raise ProtocolError rather than a bare stdlib error: FieldBase.length tells a malformed template from a negative resolved length (#825, #827), and NumberField checks a negative resolved length and a negative bit_length (#828, #829, #831, #834).

  • ModuleDescriptor.klass raises ProtocolError for a class name that resolves to nothing, or one never given, rather than a bare AttributeError; the registry modules share one NULL sentinel compared by identity, where each had its own string (#832, #833, #835).

  • PortEnumField given a proto with no .name – a string such as 'tcp', or a composite such as TransportProtocol.tcp | TransportProtocol.udp, which is a bare int – raised AttributeError on a port the registry does not hold. The miss takes its transport from the registry’s __transport__, as AppType.get() does (#1774, #1781).

  • EnumLookup.get() answers a value miss quietly, whether default answers it or the miss is raised: a registry’s _validate_value rejection logs nothing and installs no exception hook there, while EnumRegistry.register() still reports the same rejection loudly (#1845, #1873).

  • each sentinel is a singleton, as None is: calling its type, copy, deepcopy and pickle at every protocol give back the one object, and NO_VALUE reprs as its own name rather than as an object address. Their new common base, Sentinel, is public; a sentinel’s type cannot be subclassed, and neither it nor the sentinel takes an attribute write. A pickle written before this change at protocol 0 or 1 still loads, though not as the singleton (#1841, #1842, #1876).

  • defects off CPython on Linux. On GraalPy, SeekableReader’s reads aliased a buffer a later slide rewrote, so they copy out of it, and a MultiDict nested each value list, so it is built from a dict (#1963, #1974); and a sentinel type refused the cache writes _py_abc makes, so isinstance against it raised TypeError (#1960, #1966). Field.pack hands struct a bytearray as bytes for the s, p and c formats, which PyPy refuses for s and p and GraalPy accepts for c where CPython does not, so it packs alike on every interpreter (#2004, #2007, #2015, #2018). On Windows, SeekableReader(buffer_save=True) with no buffer_path could not reopen its temporary file; it creates one it can, and deletes it on close() or collection (#2098, #2105).

  • PortEnumField and SwitchField are exported from pcapkit.corekit, pcapkit.corekit.fields and pcapkit.all, and SchemaField, nested_packet_context and BytesField from the modules defining them, so import * binds them (#2174, #2191).

  • a ListField callback is handed the per-item copy it configures, not the shared field, and a SchemaField with a callable length sizes its template from the resolved length rather than 1024s. No parse in the library reached either (#2173, #2190).

  • PortEnumField range-checks the port it is handed, so a directly built TCP, UDP or SCTP schema raises FieldValueError for a port outside the field’s width, where srcport=70000 was masked to 0x1170 and -1 to 0xffff. NumberField keeps masking, which is its documented contract; the NumberField and Schema docstrings now say that direct construction range-checks nothing, and name where the checks live (#2314, #2335).

pcapkit.dumpkit

Fixed

  • a flag value with no declared bits no longer dumps as Type::None [0]. This changes user-visible output in every textual dump format. make_dumper’s object_hook renders each enumeration member as Type::name [value] and interpolated the name unguarded, but a Flag value composed entirely of undeclared bits has name is None, so the literal None landed in the name half: Flags(0) rendered as Flags::None [0] in json, tree, text, txt, plist and xml, out of both Extractor and TraceFlow. A nameless member now renders with the value’s decimal spelling, so Flags(0) gives Flags::0 [0] and Flags(8) gives Flags::8 [8]. That is the spelling the enumeration libraries use for an undeclared residue (Flags(2057).name is ACK|9), and it cannot be mistaken for a member name, since an identifier may not begin with a digit, whereas NONE is a real declared name elsewhere. The guard is on name is None and not on the value, because the defect was never about zero (Flags(1) and Flags(9) are equally nameless). Surfaced by #634, which made a flagless TCP segment reach this path (#648).

  • tests/dumpkit/test_nameless_enum_rendering_unit.py (added by #670) carried probes assuming every flag-enum registry is 16 bits wide: test_scalar_return_renders_a_nameless_member_as_its_value used one fixed tuple ending in 65536, and test_no_flag_registry_renders_the_literal_none swept every registry but only against registry(0). Widths differ: ftp.command.CommandType 3 bits, reg.apptype.TransportProtocol 4 (computed at runtime), the mh ack/initiate flags 8, and mh.binding_update_flag.BindingUpdateFlag and tcp.flags.Flags 16. tcp.flags.Flags(65536) correctly raises (0 <= value <= 0xFFFF), so the sweep was failing on correct behaviour. The probes now derive each registry’s bound from its declared members, and a new test pins that one past the widest in-field value is refused. Test-only (#702).

  • the plist writer emitted mapping keys unescaped, so a key carrying &, < or > produced a report that is not well-formed XML. dictdumper’s _append_dict interpolates the key into '<key>{item}</key>' and calls _encode_value on the value only, so both branches of pcapkit.dumpkit.common that build a mapping now escape their keys through one escape_key helper – a MultiDict, where #575 escaped an enum-derived key inline and nothing else, and a plain dict, where nothing was escaped. The json, tree and text writers are untouched. A non-str key is escaped too: a PCAP-NG decryption secrets block keys TLS key log entries by a raw bytes client random whose repr can carry any of those characters. The upstream half is JarryShaw/DictDumper#125; this does not wait on it. The json writer’s quoting defect stands, upstream as JarryShaw/DictDumper#121 (#772).

  • the JSON writer emitted mapping keys unescaped, so the report of test.pcapng did not parse (Expecting ':' delimiter): its TLS key log is keyed by a bytes client random whose repr holds " and \. escape_key in pcapkit.dumpkit.common now JSON-escapes keys for JSON output, as #772 made it XML-escape them for plist, and the JSON report of every examples/captures/ file now parses. The upstream defect, JarryShaw/DictDumper#121, stands (#1152). The PCAP-NG end-to-end test of that report no longer skips; test-only (#1795, #1809).

  • a breaking change to the dump schema: the output writers dropped or corrupted what the packets carried. An OrderedMultiDict, which every option list is, now dumps as a list of single-key entries in wire order (#1305). Inside an object the writers fall back on, a class, module or function is written as its qualified name rather than expanded, and an object met again on its own expansion path as <circular reference: T> (#1372, #1381).

  • tree output raised on a complex value, and is now documented as lossy (#1266, #1366).

  • plist dates are written to whole seconds, so plistlib reads them back (#1448, #1450).

  • a PCAP trace record scales its fraction to the trace’s resolution (#1500, #1529), and writes the octets it has rather than a truncated record’s claimed length (#1541, #1543).

  • a subclass of a dumper make_dumper builds recursed without end in its object_hook fallback, which called super(type(self), self) (#2144, #2146).

pcapkit.foundation

Added

  • the engine='pypcap' and engine='pcap_ct' extraction engines, independent distributions of the same libpcap interface, and engine='pypcapfile' (#386, #405). They buy speed by doing less – neither pypcap nor pcap_ct dissects at all, so they offer no reassembly or flow tracing, and pypcapfile has no IPv6 decoder. Install only one of pypcap and pcap-ct: both own the top-level pcap module, and with both present pcap-ct wins the import and the other becomes unselectable. The missing interface constants PyPCAP, PCAP_CT and PyPCAPFile are now exported alongside DPKT, Scapy, PyShark and PCAPKit (#412). Python 3.11 is the last interpreter on which every built-in engine can run, and even there pypcap and pcap-ct cannot coexist.

  • EngineBase.unsupported_reason(), a preflight every engine answers and Extractor.run() consults before anything is imported. An engine that cannot run in the current environment now gives one warning naming the real cause (a Python version, a missing tshark or libpcap, the wrong pcap distribution) and a clean fall back to pcapkit’s own parser, rather than an error from inside the third-party package (#396, #405). The same holds for an engine whose package is not installed at all (#1045).

  • conflict on the reassembly data models: absolute, inclusive ranges where two fragments claimed the same span with different bytes, previously lost silently on both the IP (#482) and TCP (#443, #478) paths.

  • the declarations that pair a __proto__ dispatch registry with the enumeration its codes come from, replacing the module-level tables that held the pairing apart from the __proto__ they describe. __proto_key__, on each class owning such a registry (Link, Internet, Frame, PCAPNG, TCP, UDP and SCTP), names that enumeration and whether a bare int is also valid (#2392); __proto_dest__, on the LinkType, EtherType, TransType and PayloadProtocolIdentifier registries, names the destination each enumeration keys, which is how code=<member> infers one without an explicit destination. Both are rendered attributes, and the vendor crawlers generate the latter from a new PROTO_DEST (#2403).

Changed

  • layer= and protocol= are honoured rather than inert. Both were read under the wrong names, so every value a caller passed was dropped into **kwargs; the CLI’s -L also now validates its argument. The packet context reaches the schema layer for the first time, so a field the wire elides can be resolved from its enclosing packet (#404). follow_tcp_stream dispatches on the engine type, where both branches of the old test were dead and the native adapter ran against every engine’s frames (#402).

  • reassembly and flow-tracing defaults moved (#435), visible to a caller. Datagram.completed widened from bool to a Completion enumeration (COMPLETE, PARTIAL, TIMEOUT); only COMPLETE is truthy, so if datagram.completed: is unaffected but datagram.completed == True no longer holds. TCP flow tracing is bidirectional by default, which merges each flow’s two halves and closes one only once both have FINed – 331 flows become 111 on the sample HTTP capture, the difference being single-frame stray tails; pass trace_bidirectional=False for the old behaviour. IP reassembly also gained the 60-second timeout [RFC 1122, RFC 8200], clocked off the capture’s own timestamps, tunable with reasm_timeout=; TCP reassembly gets no timeout by default. trace_analyse= is new, and reassembles each traced flow’s application layer.

  • conflicting TCP overlaps resolve first-write-wins, per RFC 9293 section 3.10, where they had silently resolved last-write-wins (#443, #478). A deliberate, narrow behaviour break: a conforming retransmission carries identical bytes. IP fragment reassembly keeps last-write-wins, because RFC 791 specifies the opposite resolution, and records the disagreement instead (#482).

  • renames with no compatibility alias left behind: HoleDiscriptor is spelled HoleDescriptor and its package alias TCP_HoleDiscriptor is TCP_HoleDescriptor (#350).

  • subclass registration is opt-in for Engine, Reassembly, TraceFlow and dumpkit’s Dumper (#514). Each registers if and only if its registry keyword is given – engine= for Engine, protocol= for Reassembly and TraceFlow, fmt= for Dumper. Previously an absent keyword fell back to the class’ own name, so every subclass of the public class was registered, and declining meant subclassing the parallel *Base class under an alias, which every built-in does (hence no public class had a subclass, while every built-in derives from its *Base class). This breaks out-of-tree code that subclasses one of these classes and relies on the derived key; pass the keyword, or call the matching register_* function. Nothing the library ships is affected, and the *Base classes remain importable. Silent failures are now loud: an unrecognised class keyword raises UnsupportedCall instead of being swallowed by **kwargs (passing name= to a Reassembly subclass silently ignored the key, since protocol= is the real one), and so does Dumper’s ext= without fmt=. A class attribute is not an opt-in: __engine_name__ and __protocol_name__ still set the name a class reports, registered or not. Each metaclass gained a class-level registry property mirroring EnumSchema.registry, and a Dumper subclass no longer touches the filesystem while its class statement runs. Engine’s keyword is engine= rather than the name= first shipped, because mcls, name, bases and namespace collide with abc.ABCMeta.__new__’s parameters on Python 3.10 and raise TypeError before the hook runs. There is no name= alias: a keyword that works on some interpreters and not others is the trap being removed.

  • a breaking change to Extractor.register_engine(), register_reassembly and register_traceflow, and the register_extractor_* wrappers over them: each now accepts only a subclass of the public Engine, Reassembly or TraceFlow (#1016). They had accepted a subclass of the matching *Base class since #513, which was a tolerance for pcapkit’s own built-ins (every built-in derives from the base, none from the public class) rather than a contract: *Base is documented as internal, and the public class is the one carrying the registration hook. Third-party code that subclasses EngineBase, ReassemblyBase or TraceFlowBase directly and registers through these functions now gets RegistryError naming the public class; subclass the public class instead. The error messages are unchanged. The built-ins never pass through a registrar; Extractor._register_internal_engine and its sibling internal registrars check the base for internal registration, and are what the #513 regression test now exercises. register_protocol is untouched (#1020).

  • extraction is around 46% faster on a 1,117-frame HTTP capture, with byte-identical output (#420). A reassembled datagram’s payload is analysed on first read rather than eagerly, cutting IP reassembly’s own cost by 90.7% and TCP’s by 23.7% (IP reassembly submits a datagram for every frame, fragmented or not) (#424). Flow tracing over the same capture went from 1416.6 ms to 744.0 ms, because the flow dumper handed each record to a Frame constructor that re-dissected the whole stack to return the bytes it had just been given; options are no longer parsed twice either (#427).

  • a breaking change to Extractor.engine and Extractor.record_header(): both are now declared to return EngineBase[_P] rather than Engine, and the private _exeng attribute follows. The built-in engines subclass EngineBase directly, so none was ever an Engine, which adds only the __init_subclass__ registration hook. A type checker sees the change: code passing the result to an Engine-typed parameter must now accept EngineBase; and because the old declarations were unparameterised, engine.read_frame() and record_header().read_frame() used to be Any and are now the extractor’s own frame type, so an assignment that relied on Any there no longer type-checks (#1022).

  • the documentation of the engine base classes: the member stubs (name, module, registry, extractor, unsupported_reason, run, read_frame, close and the rest) moved from Engine to EngineBase, where they are defined, and Engine now documents only the __init_subclass__ registration hook it adds, so the EngineBase[_P] that Extractor.engine returns leads to an API. Cross-references to Engine.run, Engine.read_frame, Engine.close and Engine.unsupported_reason now point at EngineBase, which stays out of __all__, as #1016 settled (#1024).

  • a breaking change to register_mh_extension: schema is keyword-only, as in the other schema-taking registrars (#516, #526).

  • the pypcap and pcap_ct engines probe the pcap backend once per extraction rather than three times, and find its distribution without parsing each candidate’s METADATA, which importlib.metadata does on Python 3.15 and which made pcap_ct extraction about 2.4x slower there (#2110, #2112, #2113, #2121).

Fixed

  • TCP reassembly mixed absolute sequence numbers with buffer-relative slicing, so on any capture carrying a SYN with a realistic initial sequence number incomplete datagrams were dropped silently, and the completed=False branch of the public API was unreachable (#349, #376).

  • format='text' raised AttributeError before writing anything, naming a dictdumper.Text that has never existed. It now points at Tree, as the 'txt' alias beside it already did.

  • Extractor had the ownership of its input stream inverted: a handle it opened itself, from fin given as a path, was never closed, while a stream the caller supplied and still needed was. _cleanup closed under not self._flag_s (the flag that gated the open()), and the SeekableReader wrapping a non-seekable input asked for stream_closing=not self._flag_s in the same wrong direction. The leak was one descriptor per extraction, which surfaced as a ResourceWarning failing the test suite (#577, #596, #600; #606 only made the test immune). Both conditions now follow ownership via a single Extractor._owns_input, and a finaliser releases the handle of an extraction abandoned before end of file (auto=False, iterated part way and dropped); the files #610 names emit no such warning. Leaving a with block follows the same rule, so a caller’s stream stays open after it (#1999, #2000). The finaliser is a weakref.finalize, so it also runs where __del__ never does, as on GraalPy (#1997, #1998).

  • extract(..., no_eof=True) never returned. End of stream was detected and ExtractionWarning: EOF reached fired, but every loop that handles it (record_frames, __next__, __call__) read if self._flag_n: continue, so the flag suppressed the error that ended the loop without supplying another ending and an exhausted input was retried forever. The flag is not pointless – __main__ sets no_eof for fin='-', and the end of what has arrived on a live capture is not the end of the capture – so this is a termination condition rather than a deletion. The input’s own position tells a paused capture from a finished one: prepare raises end of stream when zero bytes remain and restores the position first, so two consecutive ends of stream at the same position mean nothing arrived between them (Extractor._note_eof_progress). A pipe is unaffected, since a read there blocks while the writer is open but idle. This also fixes pcapkit -, which hung on any finished stdin. One deliberate narrowing: a seekable input does not block, so a file still being appended to ends at the data present when the extraction reached it; a timed grace period would make the cut-off intermittent rather than absent, so following a growing file is left to a policy of its own. Pre-existing and untouched: an append landing mid-record raises ValueError: read length must be non-negative or -1. The end-to-end tests are bounded by a child process rather than tests._support.time_limit, because the in-process deadline proved intermittent on this loop, and an intermittent guard against a hang leaves a wedged suite rather than a red test. Guesses at the cause are ruled out: the parse path’s except Exception handlers are never entered during the spin, and the memory is the retry loop’s EOF reached records, which the runner retains (#620).

  • register_protocol displaced one protocol class with another and said nothing. The registry behind pcapkit.protocols.__proto__ is keyed on cls.__name__.upper(), and several dispatchable classes are named HTTP (the base pcapkit.protocols.application.http.protocol.HTTP and the httpv1 and httpv2 implementations), so a bare assignment decided the winner: register_protocol(httpv2.HTTP) moved __proto__['HTTP'] to the httpv2 class with no exception, log line or RegistryWarning, and ProtocolBase.expand_comp('HTTP') then resolved to whichever registered last. The overwrite now raises a RegistryWarning naming the displaced and the replacing class. The sharpest path is not a call to the registrar: Protocol.__init_subclass__() registers unconditionally, so merely defining a subclass of the public Protocol under a name a built-in holds displaced that built-in. A plain import pcapkit emits zero new warnings: the built-in HTTP classes derive from ProtocolBase, so __init_subclass__ never fires for them, and import-time seeding keys off the distinct names HTTP, HTTPv1 and HTTPv2. The guard deliberately reads “key present and the incumbent is a different class”, not mere presence (#726 later gave its sibling registrars the same identity guard): this function is the funnel every wrapper registrar ends in, so registering one class under two codes (register_tcp then register_udp, a documented path) reaches it twice with nothing displaced. The collision is reported, not resolved: re-keying is a registry-format change that none of the bare-name readers can absorb (each degrades silently to a string or Raw on a miss), so it belongs to the registry redesign in #514. The issue called this the one registrar with no warning; in fact none warned by itself, the siblings only by delegating to a guarded classmethod (#675).

  • the #: autodoc comments in pcapkit/foundation/traceflow/traceflow.py that named a bare Type, which Sphinx resolves against every class named Type in the project rather than typing.Type, and silently linked to pcapkit.const.l2tp.type.Type, an L2TP field-type enum; once #684 rendered TraceFlow._foutio, the built docs pointed at the wrong class with no warning. These sites now spell it ~typing.Type[Dumper], among the sites #709 lists. The other bare Type sites, in hand-written :type: fields under docs/source/pcapkit/foundation/, are fixed separately by #714 (#709).

  • register_protocol’s overwrite warning could claim a protocol was replaced with itself. The guard was correct (incumbent is not protocol, an identity check from #681), but the message built both operands with a bare repr(). A factory defining a same-named closure-local class on every call (as tests/protocols/test_construction_keyword_check_unit.py’s _protocol_class does) produces two distinct classes sharing one __module__ and __qualname__, so a real overwrite read “overwriting X with X.” The message now compares the two reprs and, only when they coincide, appends each object’s id(); __module__/__qualname__ would not disambiguate, being what the repr already renders (#710).

  • more registrars warned on mere key presence rather than an actual overwrite, outside the wording of #718’s identity guard (incumbent is not None and incumbent is not new, landed by #726), whose issue named only code-keyed registrars: register_engine, register_reassembly and register_traceflow on Extractor, and register_dumper on both Extractor and TraceFlow. Each now compares the incumbent by identity before warning; both register_dumper sites compare only the stored dumper, so re-registering with just a new file extension stays silent too. Non-breaking: a correct caller sees strictly fewer warnings (#739).

  • the class registrars listed below now raise RegistryError for a non-class argument, where a bare TypeError (“issubclass() arg 1 must be a class”) used to escape – from the guard itself for the register_dumper methods, and from inside abc for the others. They are register_dumper, register_engine, register_reassembly and register_traceflow on Extractor, TraceFlow.register_dumper() (reached through register_traceflow_dumper), and pcapkit.foundation.registry.protocols.register_protocol. Each guard gains an explicit isinstance(x, type) test ahead of its issubclass; in those that accept a ModuleDescriptor it runs after the descriptor is unwrapped, so a descriptor naming a non-class attribute is rejected too, while register_protocol takes no descriptor. No guard’s target class changes, and a wrong class still raises RegistryError as before. RegistryError subclasses TypeError, so except TypeError still catches it; a caller matching the exact type, or the old message, does not. The same guard in the protocol register classmethods is fixed separately by #1026 (#1021).

  • a missing extraction engine package now gives a single EngineWarning rather than a duplicate (#1045, #1046). Extractor.import_test() warned that the package was absent, then Extractor.run() warned again when it fell back to the default engine; run now stays silent there, and import_test says what run used to: engine <name> (<module>) is not installed; using default engine instead, the module name in backticks. Only code that matched import_test’s old wording, or that expects the duplicate warning, breaks.

  • Extractor defects: Extractor.output’s UnsupportedCall named format rather than output; make_name looked an unknown format up in the __output__ defaultdict and so inserted it; and the Scapy engine never set _offmt, so Extractor.format raised AttributeError under engine='scapy' (#1095).

  • the DPKT, Scapy and PyShark engines call Extractor.record_header() at setup again, as they did until 2023, so the global-header step is shared with the default engine and sets _offmt for each of them. Their dumped output changes: JSON, plist and tree reports gain the leading Global Header record (Section Header 1 for PCAP-NG) the default engine already writes, identical to its, and files=True writes that record’s file too; frame records are unchanged. Extractor.format now also answers on a capture with no frames. format='pcap' failing on every engine is a separate defect, #1127 (below) (#1104).

  • format='pcap' failed on every engine with a TypeError from PCAPIO.__init__(), missing protocol: Extractor.__init__() created the writer before any global header was read, so PCAPIO had no link type. The header step now creates it through Extractor._open_output(), and run() opens it for engines with no such step; a PCAP writer takes the frame itself and skips the Global Header record, so the default engine writes in.pcap back byte for byte. The third-party engines hand the writer no frame octets, so they fall back to JSON with a FormatWarning, as their flow tracing already does, and a PCAP-NG input raises FormatError (#1127). The written global header also dropped the input’s thiszone, sigfigs and snaplen, so most sample PCAP files did not come back byte for byte; the PCAP engine now passes those fields through _open_output to PCAPIO, and every one does (#1165).

  • IP reassembly reports only the octets it holds: a truncated fragment, a strict run and a zero-length datagram no longer report octets from the declared Total Length (#1368). Only the final fragment’s partial block counts, and a datagram too long for its length field never completes (#1582, #1585, #1598).

  • TCP reassembly judges completion from each buffer’s own gaps, compares sequence numbers modulo 2**32, and holds a FIN until reordered data arrives (#1375). A TSO fragment’s length is its captured length, and RCVBT covers the last block (#1564).

  • a second TCP flow with the same label truncated the first flow’s file; a reused label now gets the next free suffix (#1360, #1371).

  • the engine, reassembly, traceflow and dumper registrars accept a built-in back under the name it shipped with, restoring the shipped entry after an override; the engine, reassembly and traceflow registrars still refuse any other built-in (#1363, #1376).

  • PCAP-NG blocks are numbered across the whole file, not per section (#1256, #1426).

  • format='pcap' is kept when an unavailable engine falls back to the default one (#1264, #1430).

  • extract() accepts a file object with no name as fin (#1506, #1509), and reads every frame from a pipe or other non-seekable stream (#1586, #1595).

  • the third-party engines read what the capture holds. The dpkt engine keeps a frame dpkt cannot parse rather than leaking AttributeError (#1351, #1377), applies each PCAP-NG interface’s link type and timestamp resolution rather than the first one’s (#1379, #1396), and reads float timestamps, Simple Packet Blocks and raw-IP frames (#1550). The scapy engine resets its interfaces at each PCAP-NG section (#1522, #1546), takes a packet’s link type and clock from its interface (#1563), and reads each record whole (#1717, #1733). The pyshark engine skips non-packet PCAP-NG blocks (#1515, #1531), and the pypcapfile engine reads the PCAP records pypcapfile drops (#1512, #1569). dpkt and scapy write real PCAP traces (#1507, #1511).

  • a callable passed as verbose= is called on every engine. The scapy, dpkt, pyshark, pypcap, pcap-ct and pypcapfile engines replaced it with their own printer, which now runs only for verbose=True, its output unchanged (#1874, #1880).

  • an Extractor whose constructor raises closes what it owns, the input it opened and then the engine it chose, so a PyShark capture’s tshark does not outlive it; the original exception still propagates, and a caller’s stream stays open (#2100, #2106, #2107, #2119).

  • engine='pypcap' warns and falls back to the default engine when the pcap module is installed but cannot load, as engine='pcap_ct' already did, where it raised the loader’s OSError (#2132, #2133).

  • TCP reassembly bounded its holes with sys.maxsize, which on GraalPy and 32-bit builds is 2**31 - 1, inside TCP’s sequence space, so a stream past it never completed. The bound is HOLE_OPEN_BOUND, 2**63 - 1, the value 64-bit builds already used (#1959, #1965).

  • the method-name registrars, such as register_ipv4_option, and register_linktype, register_pcap, register_pcapng, register_ethertype and register_transtype refuse a code that is not an enum member with RegistryError before touching a registry, where they registered it and then raised AttributeError, leaving the entry behind (#2186, #2193).

  • pcapkit.foundation.registry.protocols.register_protocol_code, and the code= class keyword, check every destination’s key before registering any of them. A key belonging to another enum, such as {Frame: TransType.ICMP}, is refused with RegistryError where it replaced the shipped LinkType.ETHERNET entry, and a bad target late in the list no longer leaves the earlier ones registered. A destination keeping a registry of its own, such as Loopback and IPX, is not checked against another enum (#2268, #2343).

  • the number registrars register_tcp, register_udp, register_sctp and register_apptype refuse a bool code with RegistryError, where register_tcp(True, ...) registered port 1, since bool is an int subclass (#2269, #2344).

  • IP reassembly reports a datagram that is not itself fragmented but whose BUFID is already buffered: it submits that buffer as incomplete and then reassembles the datagram in a buffer of its own, per RFC 791 Section 3.2, where the datagram was never reported at all. On IPv6 an atomic fragment is reassembled in a buffer of its own and submitted without flushing the datagram already being reassembled, so that one’s final fragment still completes it, per RFC 6946 Section 4 (#2270, #2345).

  • the PyPCAP, pcap-ct and pypcapfile engines warn with AttributeWarning, naming the engine, that they ignore a parsing context supplied through Extractor’s context=, as the dpkt, scapy and pyshark engines already did, where they dropped it silently (#2313, #2332).

  • Deferred could not be pickled under protocol 0 or 1 – a slotted class with no __getstate__ raises TypeError there, independent of the analyze/proto regression #2417 fixed. It now defines __getstate__ and __setstate__, mirroring the (dict_or_None, slots) pair Python’s own default slotted-class reduction already used for every released build, so a Deferred pickled by one of those still unpickles. The slot names come from copyreg._slotnames rather than a hand-rolled walk of type(self).__mro__, which would otherwise shadow a base class’s slots under a subclass declaring its own, miscount a bare-string __slots__ = 'name', or read a private, name-mangled slot under its unmangled spelling (#2423, #2425, #2424).

  • that same Deferred still failed to pickle under protocol 0 or 1 on GraalPy 25.4, after #2423’s fix: copyreg._reduce_ex, which handles protocol 0 and 1 before __getstate__ is ever consulted, walks cls.__mro__ for a base to compute a base-object state from, and on GraalPy that walk stops at Generic itself rather than at object, raising TypeError: typing.Generic() takes no arguments. Deferred.__reduce_ex__ now hard-codes the base=object, state=None outcome the walk always reached on CPython for protocol 0 and 1, so the walk – and Generic – is no longer part of the route pickling takes. The same override also checks for, and defers to, a subclass’s own __reduce__ before building that hard-coded tuple – without that check a Deferred subclass overriding __reduce__ had it silently ignored at protocol 0 and 1 while protocol 2 and above (via object.__reduce_ex__ unchanged) kept honouring it (#2433, #2435).

pcapkit.interface

Added

Fixed

  • the messages the interface functions and the helpers behind them raise and warn read in the house <prefix>: <lowercase description>: ... {value!r} form. reassemble and trace lowercase their unsupported-protocol error and quote the protocol; follow_tcp_stream warns using default engine instead, the wording Extractor already used, and quotes an engine that has no reassembly adapter; TraceFlowBase’s output-format warning is lowercased; and Extractor.make_name quotes the format it does not know, where an empty one ended the message at the colon with nothing after it (#2303, #2374).

pcapkit.protocols

Added

  • ESP parsing and construction [RFC 4303], with optional payload decryption and ICV verification through cryptography (pip install pypcapkit[crypto]) (#378). Keys reach the dissector through a new caller-state channel, pcapkit.corekit.context, surfaced as the context= keyword on extract() and Extractor, carrying an esp.protocol.SecurityAssociation. Nothing here raises: with no association the SPI and sequence number are still reported and the ciphertext is left opaque with status=NO_SA, and a failed decryption or ICV check is recorded on the parsed result the same way. Extended Sequence Numbers, TFC padding and anti-replay are not implemented, and an unsupported cipher or MAC is refused with a clear error rather than half-processed.

  • SCTP as a transport protocol [RFC 9260]: every chunk type, chunk parameter and error cause it defines, with the CRC32c both recorded and verifiable (it covers the SCTP packet alone, with no IP pseudo-header). Upper layers register on the DATA chunk’s Payload Protocol Identifier through register_sctp, not on a port (#379).

  • NGAP over SCTP (3GPP TS 38.413), decoding aligned PER through pycrate (pip install pypcapkit[NGAP]) (#251, #417). Decoding is generic by ASN.1 shape rather than per-procedure, so every elementary procedure and protocol IE works and a new 3GPP release needs no code change. Registered as a default on PPID 60 and 66, but only 60 decodes: PPID 66 is an NGAP PDU inside a DTLS record, and there is no DTLS dissector. pycrate was at first left out of the all extra – it is LGPL-2.1+ where this package is BSD-3-Clause, and lands some 238 MB to obtain one module – until #910 counted it among all’s core addons, so all installs it (the all entry under Project infrastructure).

  • the Mobility Header registry, completed (#383, #437): the RFC 5568 fast-handover messages and options, then every registered message data type, every registered option (with nested sub-option registries for the flow identification, access network identifier, quality-of-service and LMA-controlled MAG parameter families) and every CGA extension. The CGA Parameters option, the only option that can carry a CGA extension on the wire, was the last to come off the generic handler, which is what let the CGA extensions round-trip end to end.

  • dispatch entries for dissectors that existed but were reachable from no registry (#436): FTP-DATA on TCP 20, HTTP/1 on TCP 8080, HTTP on UDP 8080, L2TPv2 on UDP 1701 and OSPF at TransType 89. VLAN became an abstract base with C_Tag (802.1Q) and S_Tag (802.1ad) as concrete subclasses, so a Q-in-Q frame no longer collapses into one opaque Raw; L2TP likewise became a base, with L2TPv2 carrying the RFC 2661 implementation.

  • Protocol/ProtocolBase gain a code= class keyword for the next-layer dispatch registries (Link.__proto__, Internet.__proto__, TCP.__proto__, UDP.__proto__, SCTP.__proto__, Frame.__proto__, PCAPNG.__proto__), the opt-in treatment #514 gave Engine, Reassembly, TraceFlow and Dumper above, extended to the one family that needed a registration key invented. Omitting code leaves a subclass unregistered, as before; the built-in tables are still filled by literal assignment in each layer module, so nothing the library ships moves. code accepts a bare enum member, whose type infers the destination (EtherType means Link, TransType means Internet, PayloadProtocolIdentifier means SCTP, and LinkType means both Frame and PCAPNG, mirroring register_linktype), or a {destination: key} mapping, required for a raw int such as a port number, which cannot say which transport it belongs to. Either form may appear in an iterable, so one declaration can register a class into several registries (e.g. a L2TP subclass reachable by IP protocol number and by a UDP port), and the mapping form is accepted even where inference would work. Inference refuses rather than guesses: an enum member whose type names no known destination raises RegistryError, and an unrecognised class keyword raises UnsupportedCall, matching the other registrar families. Backed by pcapkit.foundation.registry.protocols.register_protocol_code, also callable directly for a class that declined at definition time. It was written up as the mechanism #548 needs; #548 turned out to need a missing class instead (115 is RFC 3931 L2TPv3 over IP, not RFC 2661; see the corresponding Fixed entry below), so its worked example names L2TPv3 (#570).

  • tests/protocols/test_dispatch_reachability_unit.py, the coverage #548 asked for: every ProtocolBase descendant whose __index__ returns an enum member must be reachable under that code in the registry its enum type designates, read from the same __proto_dest__ declaration backing code= so the two cannot drift. It walks the classes rather than the registry entries, so it catches a class nothing registered, the shape in which OSPF once shipped (#548).

  • tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py, covering all three MP_JOIN layouts through the public constructor, the construct-pack-parse cycle for each, the stale-flags case that rules out a zero-valued default, the statement order, and controls for the parse path and the flag-independent options. The old cases bypassed the public path, so full coverage coexisted with a broken one. The stale tcp-mptcp/MP_JOIN entry is deleted from EXPECTED_FAILURES (#587).

  • tests/protocols/test_option_generator_tcp_base_unit.py, asserting that every TCP_BASE key is a parameter TCP.make() declares (derived from inspect.signature) and that the built segment carries the stated header in both data model and packed octets (#602).

  • ILNP nonce sizing coverage in tests/protocols/internet/test_ipv6_extension_unit.py, a test per protocol, asserting the declared length, the exact packed octets and the construct-pack-parse cycle over a spread of nonces, most with a bit length that is not a multiple of eight, where floor division and the ceiling disagree (#601).

  • SOLUTION parameter width coverage in tests/protocols/internet/test_hip_unit.py: a spread of widths, each asserting the declared length, the reader’s acceptance and the construct-pack-parse cycle, plus a 57-bit case pinning the 20-octet length RFC 5201 Section 5.2.5 requires of HIPv1. Some widths are deliberately not multiples of eight, where the defective ceil(bits / 4) differs from the correct width (#608).

  • the BSD loopback layer for LINKTYPE_NULL and LINKTYPE_LOOP, and dissection of LINKTYPE_RAW by the version nibble (#1594).

  • one home for each RFC- or format-fixed constant spelled out in more than one module – the IPv4, IPv6, AH and TCP header lengths and offsets, IP_MAX_LENGTH, and the PCAP and PCAP-NG framing constants – in the module that owns the protocol (#1739, #1752).

Changed

  • a breaking change to OSPF, RARP and DRARP: they move from pcapkit.protocols.link to pcapkit.protocols.application, with no deprecated re-export at the old path (#719). Layer is decided by designed function, with the IETF as the single source of truth – RFC 1812 Section 7 places routing protocols in the application layer and RFC 1122 Section 1.1.3 lists RARP there – and the rule is recorded in docs/source/contributing/conventions/protocol-layer-placement.rst.

    Import paths, before to after:

    from pcapkit import OSPF and from pcapkit.protocols import OSPF (likewise RARP and DRARP) are unaffected; from pcapkit.protocols.link import OSPF and the deep paths above break. RARP has no data or schema module of its own, as it reuses ARP’s.

    layer changes from 'Link' to 'Application' for OSPF, RARP and DRARP. OSPF now takes Application as its only base, and RARP becomes class RARP(Application, ARP) – layer base first, because ARP’s chain reaches Link, which owns __layer__. ARP and InARP stay 'Link'. Dispatch keys are unchanged: OSPF is still reached from Internet.__proto__ at TransType.OSPFIGP and RARP from Link.__proto__ by EtherType. Leaving Link repoints OSPF.__proto__ at ProtocolBase.__proto__, so OSPF no longer sees Link’s EtherType entries – which nothing used, since it is reached by protocol number, and a -1 lookup falls back to Raw in either registry without inserting. register and _read_protos still resolve, on ProtocolBase rather than Link. RARP keeps Link’s through ARP.

    Layer-limited extraction: layer='link' no longer sets the termination flag on OSPF and layer='application' now does – the same inversion applies to RARP – but the extracted protochain is unchanged at every layer= value, including 'internet' (IPv4:OSPFIGP before and after, as IPv4 ends the chain itself).

  • renames with no compatibility alias left behind: PCAP-NG Option subclasses spell the namespace class keyword ns= instead of namespace= (#439).

  • tests/protocols/transport/test_tcp_udp_unit.py now reaches the MP_JOIN dispatchers through TCP() itself, instead of assigning a Python set to _flags on a bare TCP.__new__(TCP). A set answers the same membership tests, so every branch ran while the attribute had neither the type production assigns nor the ordering that governs when it exists – how #587 stayed invisible. Reverting #587’s hoist, or restoring the cast('Enum_Flags', 0) seed, now fails the file. Test-only (#603).

  • building a protocol through its constructor with a keyword that names nothing now raises UnsupportedCall instead of discarding it. This is a behaviour change to a public API: every make in the tree ends its signature with **kwargs and reads nothing out of it, so a misspelled keyword was accepted, dropped, and the field it named kept its default – wrong octets, nothing said, as in #602, #541 and #556. The check sits in ProtocolBase.__init__() rather than make, because __post_init__ passes one **kwargs to the construction and to the parse of what it has just constructed, so a keyword declared only by read legitimately travels through make (HIP.read() declares extension). The accepted set is the union of every keyword-taking parameter of make, read, pack, unpack, __post_init__ and __init__ across the MRO. Parsing is deliberately untouched: a protocol cannot know which keywords its parent passed on. A new __keywords__ opts a class out: a set, for a keyword read out of **kwargs by name (ESP.read() with packet); and None, for a dispatcher whose real signature belongs to a class chosen at call time (only HTTP.make). The message names the near neighbour (seq reports did you mean ‘seq_no’?). from_data warns UnknownFieldWarning rather than raising for a key its own _make_data returned, which the caller cannot fix; a keyword passed to from_data itself raises, as for a direct construction (#1147). The leftover copies of #602’s misspelled TCP base, in examples/generators/dispatch.py and tests/protocols/transport/, are fixed. Some _make_data keys that make does not declare are reported, not fixed: Frame._make_data() returns ts_src where make declares ts_sec, L2TPv2._make_data() returns prio where it declares priority, and Header._make_data() returns a magic_number that Header.make() does not take. A direct SomeProtocol.make(...) call is not checked and still discards silently (#617).

  • a breaking change to Application: it no longer forbids payload dispatch outright. It encodes “no further protocol layer above this one”, which undissected trailer bytes are not, so _decode_next_layer and _import_next_layer accept the -1 sentinel – the rest of the packet, resolving to Raw, or to NoPayload when nothing remains – and __post_init__ keeps a payload that read set instead of overwriting it with NoPayload. A real protocol number (any proto other than -1) is still refused with UnsupportedCall. A subclass that calls either method with -1 changes from raising to succeeding; no subclass in the package does (#719).

  • the Protocol and ProtocolMeta docstrings. Protocol claimed to be the base of every protocol family, though nothing in the package subclasses it, and ProtocolMeta claimed to generate attributes its empty body does not. Protocol is now documented as the base for third-party protocols, and ProtocolBase as the one the families derive from (#1101).

  • a breaking change to OSPF_CrytographicAuthentication: the misspelling is corrected to OSPF_CryptographicAuthentication, and CrytographicAuthentication in pcapkit.protocols.application.ospf.schema and pcapkit.protocols.application.ospf.data to CryptographicAuthentication, with no alias left for the old spelling. Both OSPF names also move to the application group of schema.__all__ and data.__all__, after #719’s move (#1090).

  • a breaking change to a declared length that runs past the data: it is refused with ProtocolError rather than accepted and zero-filled on rebuild, so a malformed option looks different in info (#1325, #1398).

  • dissection is bounded: a frame at 64 layers (#1610, #1620) and an IPv6 extension-header chain at 32 headers (#1604, #1608). Both limits are PCAPKIT_FRAME_LAYER_LIMIT and PCAPKIT_EXTENSION_HEADER_LIMIT in pcapkit.utilities.config (#1743). Rebuilding a frame cut at the chain bound warns once (#1619, #1630).

  • a breaking change to IPv6 extension headers: an unrecognised or unparsable one is parsed generically, per RFC 6564, rather than collapsing the packet to Raw (#891, #904). IPv6_Ext does it, and is also the shared base of every extension header (#917, #924).

  • a breaking change to pack(): it no longer keeps the header it builds on the instance it is called on, which in 1.4.1 too left schema disagreeing with info: UDP(srcport=1234).pack() reset schema.srcport to 0 while info.srcport kept 1234. Once an instance has been parsed or constructed, pack() builds on a shallow copy, so schema, info and what make() sets, such as TCP’s connection, stay as they were. The Frame, PCAPNG and HTTPv2 overrides do the same, and re-packing a PCAP-NG block that carries an option allowed only once no longer raises. Code that relied on pack() to re-sync schema calls make() or constructs a new instance (#1769, #1791).

  • a breaking change to the HOPOPT, IPv6-Opts and Mobility Header padding: two octets are one PadN with no data (01 00) rather than two Pad1 options, as RFC 8200 Section 4.2 and RFC 6275 Section 6.2.2 ask. Only a freshly built header with exactly two octets of padding changes; a parsed one that carried two Pad1 options still rebuilds byte for byte (#1793, #1812).

  • a breaking change to NTPTimestamp: it and PMIPv6Timestamp are Info classes in pcapkit.protocols.internet.mh.data rather than tuples, and NTPTimestamp keeps no alias at its 1.4.x home in pcapkit.protocols.internet.mh.protocol. As Mappings they no longer index, unpack, compare or hash as a tuple does; .seconds and .fraction still work, and the dumpers write both as keys (#1886, #1887).

  • a breaking change to rebuilding from to_dict(): the payload is dispatched only from the __next_name__ and __next_type__ keys, and the IPv6 extension-header chain only from __exthdr__, never from key order, which on PyPy follows an earlier instance’s attribute layout and so picked the wrong layer. to_dict(), iteration and len() carry the dunder keys a new Info.__included__ names, and Info.__excluded__ only keeps a key out of repr, str and the dumps. A hand-written dict needs those keys, __short_read__ among them, to rebuild, and a custom Info lists its own dunder keys in __included__ (#2003, #2014).

  • a breaking change to PCAPNG.context: the block’s section Context, which it returned from 1.0.0 through 1.4.x, is PCAPNG.section_context, with no alias, and context is the caller-supplied ContextRegistry every protocol inherits from ProtocolBase, on a Section Header Block too. PCAPNG.section_context raises UnsupportedCall where context did: on a block that holds no section, such as a Section Header Block the PCAPNG engine has not assigned the section it opens. Code reading block.context for the section reads block.section_context (#2172, #2205).

  • a breaking change to FTP_DATA: it derives from Application as well as Raw, so its __layer__ names the application layer where it was None – a layer='application' extraction stops at it, and extract(layer=FTP_DATA) resolves to that layer rather than to 'none'. Raw still does the parsing and the rebuild, and __index__ is the application layer’s, raising IntError where it raised UnsupportedCall. The class moves to pcapkit.protocols.application.ftp_data.protocol with nothing left behind on the old path in pcapkit.protocols.application.ftp.protocol, where it shipped from 1.0.0 through 1.4.1 (#2285, #2357).

  • a breaking change to HIP’s REG_FAILED parameter: its first octet is the Failure Type, and it carries no lifetime (RFC 8003 Section 4.5), so pcapkit.protocols.internet.hip.data.RegFailedParameter replaces lifetime with failure_type, a RegistrationFailure, with no alias, and its reg_type holds Registration values rather than failure types. HIP._make_param_reg_failed() takes failure_type in place of lifetime, and reg_failed takes registration types. The octets are unchanged (#2309).

  • a breaking change to the HTTP/2 RST_STREAM and GOAWAY builders: a frame constructed without error carries NO_ERROR (0x0), the code RFC 9113 Section 7 gives a condition that is not the result of an error, rather than HTTP_1_1_REQUIRED (0xd), which asks the peer to use HTTP/1.1 instead. A make that omits error emits different octets; one that passes it is unchanged (#2311).

Fixed

  • next-layer, option, chunk, block and parameter dispatch all read defaultdict registries, so a lookup miss inserted the key into class-level state shared by every later instance, after which a legitimate register_* call warned that the code was already registered. Every read now goes through a lookup that does not grow the table, and IPv4.__option__ and HIP.__parameter__ became inspectable class attributes rather than names assembled at call time (#426, #428, #429, #434). What breaks: a tuple-registered handler pair written to the documented OptionParser/OptionConstructor signature now works where it could never be called, and a pair written with an explicit leading self – the only shape that used to work – now does not.

  • the identical defect one layer up, in the schema layer’s EnumSchema.registry: Option.registry[code] for an unregistered code inserted the default schema under that code, so a single lookup made an unassigned TCP option number such as 156 read back as registered for the rest of the process. EnumSchema.__enum__ is now built (or, when a subclass seeds it manually, as PCAPNG.Option’s namespaced mapping and TCP.MPTCP’s plain one do) as a retention-safe mapping that still returns the registered default on a miss but stops recording it; .registry returns the same object as before, so nothing holding a reference is affected (#555).

  • on Python 3.10 and older, no Schema subclass got its own _abc_impl: all fell through to collections.abc.Mapping’s, so a single isinstance or issubclass answer poisoned every later question about that class for the rest of the process. A terminating PCAP-NG EndRecord tested True as an IPv4Record (#439).

  • construction, broken in several places at once: the generated typed __init__ was never installed, so __post_init__ did not run and a schema built from a subset of its fields could not be packed – UDP(srcport=53, dstport=5353) now packs (#430); IPv6 and Mobility Header option padding was wrong, leaving construction wholly broken (#398); HTTP.make called the versioned make unbound, so every real call raised TypeError (#452, #462); and IPv4._make_data() returned the fragment offset in octets where the wire wants 8-octet units, and read data.options on a packet that has none (#494, #499).

  • a truncated or under-declared area no longer parses “successfully”, and no longer wedges the process. The option and list loops could spin forever with no exception on a truncated area, reachable from untrusted input through HOPOPT, IPv6-Opts, MH, HIP and SCTP; each iteration must now advance the stream by at least one octet, and the error names the option, the offset and the octets remaining (#431, #432). Separately, wire-derived lengths in ipv6_opts, CALIPSO, MPL, REG_INFO and HIP list callbacks underflowed below zero, which ListField’s own while length > 0 turned into a silent empty list; they are floored and raise instead (#449, #456, #460, #463).

  • field widths and units, each measured against the specification: HIP’s TRANSPORT_FORMAT_LIST, NAT_TRAVERSAL_MODE and ESP_TRANSFORM list entries are two octets, not one [RFC 7401, RFC 5770, RFC 7402] (#463, #472); the MN-ID option sizes from its subtype, not from identifier’s Python type (#448, #464, #467); IPv6-Route’s Hdr Ext Len is computed in 8-octet units on both sides [RFC 8200] (#487, #489); and the Fast Binding Update and Acknowledgment Lifetimes are plain seconds [RFC 5568], not RFC 6275’s four-second units (#502).

  • the remaining call sites where a _make_* method converts an address itself, ahead of the schema, so the same bool-as-int mistake reached them, unguarded by the schema-layer fix above: ARP._make_proto_resolve, IPv6_Route._make_data_type_rpl() (which also derived its RPL compression lengths from the laundered value, corrupting the address list), the dst parameter of IPv6_Route.make(), and the latent OSPF._make_id_numbers. Each now goes through parse_ip_address, the helper #539 and #552 route their own sites through, completing the programme #481 and #508 started. Pinning version=6 on the IPv6_Route sites is a further, smaller behaviour change: both converted a plain integer through the family-inferring ipaddress.ip_address, so ip=[258] packed a 4-octet IPv4 address (0.0.1.2) inside an IPv6-only header; it now packs the 16-octet ::102 (#540).

  • PCAP and PCAP-NG output and parsing: bytes(frame) returned the next frame’s octets, files=True wrote names like Frame 1..json, and a PCAP-NG timestamp_epoch was shifted by the reading host’s timezone (#403); further parser defects (#341–#347, #371) and, on the write path, more block-parsing ones (#388); BitField packed every named bit as set (#359, #374); the extension-header walk failed to advance past the last IPv6 extension header (#348, #373); and IPv6 fragment offsets went unscaled, with reassembly keyed on the flow label – optional, and routinely zero, so distinct datagrams collapsed together – rather than on the fragment identification (#389).

  • TCP._make_mptcp_addaddr() could not build an ADD_ADDR option end to end: its kind=/length= arguments were rejected with UnknownFieldWarning and dropped, and .pack() then raised KeyError: 'length' from port’s own condition, pkt['length'] in (10, 22). The cause was one layer up: MPTCP, the base of every Multipath TCP subtype schema, declared kind and length only under typing.TYPE_CHECKING rather than as real fields, unlike Option. That dropped kind=/length= for every _make_mptcp_* constructor, so MPTCP now declares both for real (#541). The missing fields broke parsing too: a subtype schema’s leading field read the kind octet itself, an off-by-two in field alignment rather than a wire-format change (a correct sender’s octets were always right). Spec-correct ADD_ADDR and MP_PRIO options failed with FieldError: TCP: [OptNo 30] 3 invalid IP version and KeyError: 'length'; both parse now.

  • dropped-keyword/wrong-cast defects flagged in review during this release and filed only as #556: HIP’s _make_param_encrypted passed cipher= to a schema with no such field, so an AES-cipher ENCRYPTED parameter built through make packed without its IV; and IPv6-Route’s RPL.post_process(), which runs on every Schema.pack(), assumed self.addresses was still the concatenated bytes a parse leaves, and raised slicing the list[bytes] a make-built multi-address header holds (#556).

  • further defects #541 exposed by letting construction reach code that had never run. MPTCP.subtype was still typing.TYPE_CHECKING-only, so TCP(options=[(Enum_Option.Multipath_TCP, ...)]) raised AttributeError: ... has no attribute 'subtype' for every subtype but MP_JOIN: the convenience constructor reads the schema straight back through _read_mptcp_* with no byte round trip, so _MPTCP.post_process(), the only code that set subtype, never ran. Fixed on the construction path (TCP._make_mode_mp()) rather than by making subtype a real field too, the way kind/length became in #541: subtype is already packed as 4 bits of each subtype’s own test bitfield, and a second field for the same bits would double-encode them or need a “derive, don’t pack” field kind this library lacks (#566). Separately, _make_mptcp_capable wrote length=20 if rkey is None else 32 where RFC 8684 section 3.1 gives 12 and 20, and MPTCPCapable.rkey’s condition (pkt['length'] != 32) dropped the receiver’s key for exactly the length the maker used for “key present”: a key-absent MP_CAPABLE could not be built, and a key-present one lost its key on the wire. Both, and the guard in _read_mptcp_capable, now agree on 12/20. This changes MP_CAPABLE’s packed output: a 20-octet key-present option built or parsed under the old code becomes 12 octets with no key, or 20 octets with the key present, depending on which the caller meant (#567).

  • TCP’s MP_CAPABLE now parses and builds all five lengths of RFC 8684 section 3.1 – 4 (SYN, no key), 12, 20, 22 (adds the Data-Level Length) and 24 (adds the Checksum) – where _read_mptcp_capable raised ProtocolError: TCP: [OptNo 30] invalid format for anything but 12 and 20. In the schema skey is now present only for length > 4 and rkey for length > 12, and the fields dl_len (16 bits, length > 20) and checksum (2 octets, length > 22) are new. _make_mptcp_capable takes dl_len and checksum too, where it used to drop them silently through **kwargs, and picks the length from how many of skey, rkey, dl_len and checksum are not None. A field given without the one before it raises ProtocolError. The data model MPTCPCapable gains dl_len and checksum, and skey becomes Optional[int]: code that constructs it directly must now pass both new fields, and to_dict() carries the new keys (#1042).

  • IPv6-Route’s RPL routing data was five octets wide at the front where RFC 6554 section 3 gives four, with further defects behind it. CmprI, CmprE and Pad are each 4 bits sharing a 32-bit word with a 20-bit Reserved, but the schema declared cmpr_i and cmpr_e as whole octets, so a constructed two-address header packed to 41 octets while its Hdr Ext Len of 5 declared 48. Correcting the width is a wire-format change and reshapes the schema: RPL(cmpr_i=..., cmpr_e=...) is now RPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...}), beside the existing pad={'pad_len': ...}. Behind it, the reader’s header.length % 16 guard read Hdr Ext Len as an octet count and assumed 16-octet addresses, which an SRH carries only when CmprI and CmprE are both 0 – the unit confusion #487 fixed for Source Route and Type 2, left by #489 for want of a working RPL round trip to validate against. It is replaced by section 4.2’s address-count arithmetic, n = (((Hdr Ext Len * 8) - Pad - (16 - CmprE)) / (16 - CmprI)) + 1, which the reader now requires to be non-negative and whole. RPL.post_process() subtracted pad_len a second time from a buffer whose length callback had already taken it off, losing one address per 16 - CmprI octets of padding, and set ip only when it had parsed octets, so IPv6_Route(type=..., data={'ip': [...]}) raised a bare AttributeError: 'RPL' object has no attribute 'ip'. And _make_data_type_rpl computed Pad as 8 - length % 8 without the outer % 8, so an aligned address vector got a full 8 octets of padding where section 3 requires Pad to be 0 when CmprI and CmprE are both 0. The narrower cmpr_i also removes a latent divide-by-zero (a whole octet could hold 16, making 16 - CmprI zero). ipv6-route-type/RPL_Source_Route_Header round-trips and its EXPECTED_FAILURES entry is deleted; none of this has been checked against a real RPL capture (#564).

  • L2TPv2 parsed any version nibble, so an L2TPv3 datagram was reported as v2 with a tunnel and session ID read out of v3’s Control Connection ID. RFC 2661 §3.1 fixes Ver at 2 and reserves 1 for L2F, and L2TPv2.version documented that any other value “is a different protocol reached through a different class”, but nothing enforced it, so the hard-coded Literal[2] property and info.version answered 2 and 3 on the same octets. read now raises ProtocolError, degrading the payload to Raw through the existing beholder path with the reason recorded. This affects real captures: RFC 3931 §4.1.2 puts L2TPv3 on port 1701 too, the port UDP.__proto__ binds, so Ethernet:IPv4:UDP:L2TPv2:Raw with invented field values becomes Ethernet:IPv4:UDP:Raw with the octets preserved.

    This resolves #548, which reported TransType.L2TP (115) as registered nowhere and proposed binding L2TPv2 there. That binding is wrong rather than awkward: RFC 3931 §4.1.1 gives 115 to L2TPv3 over IP, whose session header carries no version nibble at all, so a v2 parser cannot detect that the datagram is not its own. 115 is a missing class, not a missing registration, and stays unbound until an L2TPv3 class exists; no dissector was invented to fill it. The reasoning is recorded in pcapkit.protocols.link.l2tp.protocol, and register_protocol_code’s worked example, which named L2TPv2 at 115, names L2TPv3 instead (#548).

  • building any MP_JOIN option raised AttributeError: 'TCP' object has no attribute '_flags'. TCP.make() constructed the options before assigning the self._flags that the option makers read, and _make_mptcp_join branches on it to choose among the three layouts of RFC 8684 section 3.2 (figure 5, SYN, 12 octets; figure 6, SYN/ACK, 16; figure 7, ACK, 24). The parse path was never affected, since read assigns the flags first. Fixed by hoisting the flag resolution above the _make_tcp_options call, leaving only the data-offset computation, which needs the options’ total length, after it. Not fixed by a zero default for _flags, which would have been worse: Protocol.pack() is public and calls make, so an instance that had parsed a segment had the attribute set and silently built the option for the segment it had read. A parsed MP_JOIN-SYN instance asked to pack an MP_JOIN-ACK segment emitted an ACK header carrying figure 5’s 12-octet SYN option, with the caller’s 20-octet HMAC replaced by an all-zero token and nonce, and nothing raised. Hoisting also made a branch reachable for the first time, an MP_JOIN on a segment with neither SYN nor ACK: the accumulator was seeded with cast('Enum_Flags', 0), and typing.cast being a runtime no-op, self._flags stayed a plain int and the first membership test raised TypeError rather than the documented ProtocolError. It is now seeded with Enum_Flags(0), a real flagless member that equals 0 and ORs identically. The read path’s seed is unchanged: a flagless MP_JOIN is rejected by mptcp_data_selector before _read_mptcp_join runs (#587).

  • the ILNP Nonce option builders in HOPOPT and IPv6_Opts sized the option with math.ceil(nonce.bit_length() // 8), floor division dressed as a ceiling (// floors and math.ceil of an int is a no-op). The nonce is packed by a NumberField whose width is the declared len, so an under-declared length silently truncated the nonce on the wire. Every nonce whose bit length was not a multiple of eight was affected, and small values were the worst case rather than boundary values: any nonce below 256 was declared as zero octets and dropped (nonce=9 packed to b'\x8b\x00' and parsed back as 0). Fixed to max(1, math.ceil(nonce.bit_length() / 8)), the form used at the other sizing sites in hip.py and mh.py. The one-octet floor is the mh.py convention and is load-bearing because nonce defaults to 0, whose bit length is 0: without it the default builds an option with no Nonce Value field at all, collapsing “the nonce is 0” into “there is no nonce” when RFC 6744 gives the option that field. The read path takes its width from the len octet on the wire and was never affected (#601).

  • httpv1’s _RE_METHOD was unanchored and re.match anchors only at the start, so it prefix-matched, and the request-line reader passed the whole para1 to Method.get() rather than the captured method group. So b'Get' matched on the single character G, satisfied the guard that decides a start-line is a request, and handed the mixed-case token to a lookup that raised on it. Fixing either half alone still gives a wrong answer (normalising the lookup would parse b'Get' as GET off a one-character match; passing the group would parse it as a method named G). The pattern is anchored at both ends and the captured group is looked up, so a non-method token is a malformed request line rather than a mis-parsed one. Method tokens are case-sensitive per RFC 9110 Section 9.1, so no re.I was added: GET parses, Get and get are rejected (#583).

  • _RE_STATUS in the same reader had the same unanchored prefix defect, found by auditing _RE_METHOD’s siblings, and escaped as the wrong exception type. The pattern is only a guard (the value comes from int(para2) on the raw token), so a prefix match let a malformed status past the guard and out of int() uncaught, where _read_http_header documents ProtocolError: 200x raised ValueError: invalid literal for int() and 2000 raised ValueError: 2000 is not a valid StatusCode; both are now ProtocolError. RFC 9112 Section 4 gives status-code = 3DIGIT, exactly three, so the anchor is what the grammar said (the production lives in HTTP/1.1 because status-code is part of its status-line; RFC 9110 Section 15 covers code semantics and the registry). _RE_VERSION was audited and is safe, since every call site reads the captured group (#583).

  • as a consequence of the above, the unhandled MemoryError in pcapkit/protocols/protocol.py’s self._file.read() call on a truncated PCAP-NG capture. dhcp_little_endian.pcapng cut to 161, 641 or 1389 octets left a one-octet read of a little-endian 32-bit block length, which rjust() turned into 0x78000000 or 0x84000000 (1.88 to 2.06 GiB) and passed straight to self._file.read() as an allocation size, from a 1772-octet file. Under a 1 GiB address-space cap all three raise MemoryError; with more space the parse fails anyway. With ljust() the same reads report 120 and 132, no large allocation is attempted, and all three end in the ordinary handled parse failure (#604).

  • reading a big-endian classic PCAP byte-swapped every record header field, and then crashed. Frame.unpack() seeded the file’s byte order under the key bytesorder where the schema’s byteorder_callback reads byteorder, so the lookup never found it and fell back to sys.byteorder, the host’s order rather than the file’s. On a little-endian host reading a little-endian capture that is right by coincidence, and every capture in this repository was little-endian. Frame 1 of big_endian.pcap read incl_len=1241513984 for a record whose real value is 74. incl_len is the payload length, so that record consumed the whole file and the second was read with a negative length, raising ValueError: read length must be non-negative or -1 – the reported crash, and the second symptom. The sibling Frame.pack() spelled the key correctly, marking this a slip rather than a second key, and the fallback made a misspelled key indistinguishable from an absent one; byteorder_callback now records that it defines the key and why the fallback hides a typo (#605).

  • documentation. mptcp_dss_ack_selector’s note said a corrected field-width lambda “would not have worked” and that fixing it belonged to pcapkit.corekit.fields.numbers, which is where #598 then fixed it; the same paragraph sat in test_tcp_mptcp_length_arithmetic_unit.py’s docstring, whose other stale claim was that MP_JOIN “cannot be built through the public TCP() constructor at all”, true only until #587. A callable-length NumberField packs and unpacks both DSS widths now, and wire absence was never the obstacle: MPTCPDSS.ssn, dl_len and checksum have always been ConditionalField on the sibling M flag. The SwitchField form is kept for the narrower reason the note now gives: ConditionalField’s length forwards to the wrapped field without consulting the condition, whereas a SwitchField always resolves to a concrete field (#603).

  • the assertion #604 left pinning the old padding side, which fails once #621 pads with ljust(). TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length expected a truncated TCP option’s data as the synthesised zero octets followed by the real ones, as rjust() produced: Reserved_79 declaring length=12 over 6 real octets reports aabbccddeeff00000000 where the test expected 00000000aabbccddeeff. The real octets now come first, and the docstring is corrected. Test-only; the sibling case in tests/protocols/internet/test_ipv4_unit.py was retargeted in #621 (#604).

  • the HIP SOLUTION builder sized the parameter with 4 + math.ceil(max(random.bit_length(), solution.bit_length()) / 4), an invalid shorthand for two fields. RFC 7401 Section 5.2.5 gives Length as 4 + RHASH_len / 4 over a Random #I and a Puzzle solution #J of RHASH_len / 8 octets each, and / 4 equals twice / 8 only because RHASH_len is a whole number of octets. For an arbitrary bit_length() the builder emitted an odd contents width, which its own reader refused: _read_param_solution’s (schema.len - 4) % 2 guard exists because SolutionParameter splits that width into two equal halves. random=0x1 with solution=0xfff declared len=7 and raised ProtocolError: HIPv2: [ParamNo 321] invalid format on input the library had produced. The undersized length was worse: both fields take their width from that len, so solution=0xfff was packed into one octet and came back as 0xff, silently. Fixed to 4 + 2 * math.ceil(max(...) / 8), the form the sibling PUZZLE builder uses, which is even by construction. Loosening the reader was rejected: an odd contents width has no meaning in the wire format (the RFC makes the fields equal-width and does not say which takes an extra octet), and a laxer guard would still have truncated the value (#608).

  • TCP.read() seeded its connection-flag accumulator with cast('Enum_Flags', 0). typing.cast is a runtime no-op, so the accumulator began as the plain int 0, and only the |= promoting it to a pcapkit.const.tcp.flags.Flags member ever changed that. A segment with an all-zero flags octet (an nmap NULL scan) left self._flags an int, so a membership test raised TypeError: argument of type 'int' is not a container or iterable, and TCP.connection returned an int where it annotates Flags. Any flag masked it, which is how it survived 100% statement and branch coverage. The seed is Flags(0), as #597 already did for the sibling accumulator in make; Flags declares no _missing_, so Flags(0) is an ordinary aenum.IntFlag pseudo-member and does not meet the RecursionError the same construction hits on the pcapkit.const.mh flag enumerations (#623). The MP_JOIN path was latent: mptcp_data_selector rejects a flagless MP_JOIN before RFC 8684 section 3.2’s three layouts are chosen. A dump-format change is observable: a flagless segment’s connection renders as a string where it rendered as the number 0, so it no longer switches JSON type with the flags. That string read Flags::None [0] until the pcapkit.dumpkit fix above (#616).

  • a breaking change to a public attribute. Frame.len and Frame.cap_len were filled from opposite wire fields depending on the container format, so frame.len meant the captured length out of a .pcap and the on-wire length out of a .pcapng. The PCAP reader moved to match PCAP-NG: len is the on-wire length (the record header’s orig_len) and cap_len the octets stored (incl_len). Code reading frame.len or frame.cap_len from a .pcap gets the other field’s value than before, and for a truncated frame that is a different number rather than a relabelling. Seniority did not settle it: the PCAP spelling is the older (c43892af, 2022-01-11) and PCAP-NG’s arrived in 25f216f4 (2023-04-27), both internally consistent. The names settle it, being Wireshark’s: frame.len is “Frame length on the wire” and frame.cap_len “Frame length stored into the capture file”, and frame.len_lt_caplen expert info flags frame_len < cap_len as malformed, which it could not be if len were the smaller, captured one. Frame.read() hands _decode_next_layer the octets present, now frame.cap_len; handing over the on-wire length would be the declared-length-exceeds-available-octets fault of #554, #573 and #594 on every snapped frame. The dumpers are untouched. The two lengths differ only for a snapshot-cut frame, and no such fixture existed until #614 (#618).

  • the HIP PUZZLE and SOLUTION builders derived wire-format values from the payload value instead of the data model, and each was wrong. This changes the PuzzleParameter and SolutionParameter data models and the octets both parameters emit. The width of Random #I and Puzzle solution #J came from int.bit_length() alone, so every leading zero octet was dropped on re-serialisation: a SOLUTION read with Length = 20 rebuilt as Length = 6, a PUZZLE with Length = 12 as Length = 5, silently. That width is RHASH_len / 8 octets [RFC 7401 Section 5.2.4, RFC 7401 Section 5.2.5], a property of the Responder’s HIT Suite, so both data models now carry it as rhash_len and both builders prefer it; a new rhash_len= keyword states it for a build from scratch, which under HIPv2 is the only place it can come from, since RSA,DSA/SHA-256 is the REQUIRED HIT Suite [RFC 7401 Section 5.2.10] and makes the field 32 octets rather than 8. On the mandatory path about one parameter in 256 has a zero top octet and lost it (#653). SOLUTION’s second contents octet is Reserved, “zero when sent, ignored when received” [RFC 7401 Section 5.2.5, and RFC 5201 Section 5.2.5 identically], not the Lifetime that only RFC 7401 Section 5.2.4 defines, which pcapkit encoded there as 2^(value - 32) seconds. It wrote 0x20, 0x21, 0x25 or 0x2b into a field the RFC requires to be zero, and could not write that zero: a conformant SOLUTION with Reserved 0x00 parsed to timedelta(0) and could not be re-serialised, escaping a bare ValueError from math.log2(0.0) that was not a BaseError. The field is renamed reserved, defaults to zero, and is carried verbatim across a round trip (#654). And neither builder read its own version keyword, so version=1 and version=2 computed identical lengths, where RFC 5201 Section 5.2.4 and RFC 5201 Section 5.2.5 state both fields as literally 8 bytes and Length as literally 12 and 20. Under HIPv1 each builder accepted only a bit_length() of 57..64 and built, for anything narrower, a parameter this library’s reader rejects – the shape of #608, in the PUZZLE builder that #629 never opened (#655). The remaining math.log2 sites, in PUZZLE where a lifetime is real, raise ProtocolError ((BaseError, ValueError), so a caller written around the bare exception still catches it). Migration: SolutionParameter.lifetime is now reserved and an int rather than a timedelta; PuzzleParameter and SolutionParameter gained a required rhash_len; and _make_param_solution no longer takes lifetime=. Each was reachable end to end only once #608 was fixed in #629, which removed the parity guard that had failed these rebuilds loudly; the round trip then stopped raising and started quietly emitting a different parameter, which is why they were fixed together (#653, #654, #655).

  • every HIP parameter pcapkit emitted was 4 (mod 8) octets long, because the padding aligned the contents rather than the record. Corrected for every HIP parameter but LOCATOR_SET, excluded below. This changes the octets those parameters write, and the length their data models report. RFC 7401 Section 5.2.1 requires every TLV parameter to have “a length (that includes the Type and Length fields), which is a multiple of 8 bytes” and gives Total Length = 11 + Length - (Length + 3) % 8; every padding site computed (8 - (Length % 8)) % 8, which aligns the contents alone. For every Length from 0 to 63 the result was never a multiple of eight and never the RFC’s value, erring both ways: at Length = 4 (a whole SEQ, which RFC 7401 Section 5.3.5 puts on every UPDATE) pcapkit appended four octets that must not be there, and at Length = 8 it appended none and went out four short. A conformant peer lands mid-parameter; pcapkit did not, because its reader consumed the same wrong count its writer wrote, which is why no round-trip test could see it and why the fix is asserted against the RFC’s arithmetic. Every site but LOCATOR_SET’s (the PaddingField callbacks in pcapkit/protocols/schema/internet/hip.py and the record lengths in pcapkit/protocols/internet/hip.py) now goes through one parameter_total_len and one parameter_padding_len (#651). LOCATOR_SET kept the old expression because defects there cancelled exactly: its padding callback never received the parameter’s len (the nested Locator schemas share a packet context whose own len shadowed it, so it saw 4 for an IPv6 locator), and the parameter’s len was written in 4-octet units where the RFC’s Length is a byte count; the result, 4 + 24n + 4, equalled the RFC total 24n + 8. #679 later fixed both sites. EncryptedParameter’s data length callback is fixed in the same change: it subtracted the sixteen iv octets but not the four reserved ones, which cancelled the padding’s four at Length % 8 in {0, 5, 6, 7}, so correcting the padding alone would have made ENCRYPTED four octets too long at all eight residues. HIP.make()’s len = total_length // 8 + 4 is not part of the defect: RFC 7401 Section 5.1.3 defines Header Length in 8-byte units excluding the first 8 bytes, so the floor division is exact once each parameter is a multiple of eight. Migration: a SEQ parameter’s length is now 8 where it was 12, and the other parameters move likewise, so code comparing stored output byte for byte or asserting on Data_*Parameter.length sees the RFC’s values. LOCATOR_SET is unchanged in both respects until #679 (below). examples/generators/options.py’s HIP_COPIES stayed at two for R1_COUNTER’s four-octet counter where RFC 7401 Section 5.2.3 requires eight, which this defect had masked, until #672 widened counter and #679 fixed LOCATOR_SET’s Length unit, after which #689 dropped HIP_COPIES to one (#651).

  • HTTP/2 flag defects on each side of a round trip. This changes the octets a reconstructed DATA frame emits, and the dumped value of a flagless frame’s flags. _make_http_data was the only _make_http_* method that never read frame.flags, so a DATA frame parsed with END_STREAM set rebuilt with the bit clear (flags octet 0x01 to 0x00): a well-formed frame saying the stream continues where the capture said it ended [RFC 9113 Section 6.1]. PADDED was re-derived from pad_len, and the sibling builders already restored theirs, so this was a missing read-back (#652). Separately, FrameType.post_process() seeded its flag accumulator with a bare 0, which |= promotes only as a side effect, so a frame with no bit set left __flags__ a plain int where the schema and data model declare a Flags, and END_STREAM in __value__ raised TypeError: argument of type 'int' is not a container or iterable rather than answering False. That is the common case: a 0x00 flags octet is every opening SETTINGS, every non-final DATA and every non-ACK PING. It now seeds self.Flags(0), as the construct path already did at each of its sites. In the dump, __value__ was a JSON number for a flagless frame and a string for every other frame in the same capture; it is consistently a string now, "Flags::0 [0]" (the spelling the pcapkit.dumpkit fix above gives a nameless member) where it read 0, the same change #616 made to TCP’s connection. The seed is guarded because FrameType.Flags declares no members and, from Python 3.11, a memberless enum.Flag subclass refuses Flags(0), so the frame schemas that inherit it (UnassignedFrame, PriorityFrame, RSTStreamFrame, GoawayFrame, WindowUpdateFrame) keep the plain int, which nothing observes since each passes flags=None. A DATA round trip is still lossy for an unrelated reason found while measuring: payload length callbacks are mis-parenthesised, so an unpadded DATA, HEADERS or PUSH_PROMISE frame parses with its whole payload dropped (#668, below) (#652, #650).

  • MPTCP error messages carried a doubled separator, rendering as TCP: : [OptNo 30] 1: invalid flags combination. Cosmetic: the exception type, option number and subtype were right and nothing parses these strings, but an empty field reads as a value that failed to interpolate. The sites were _read_mptcp_join and _make_mptcp_join for the invalid flag combination, and both guards of _make_mptcp_dss for missing required fields; every other message in the file was already spelled correctly, and none is doubled now. Pre-existing since 2023-04-10. The new test asserts each message in full, since the existing assertIn on the body passes either side of the change (#649).

  • names appeared in string annotations their own module never imported, so a type checker or documentation build could not resolve them while every test passed: Any in pcapkit/utilities/logging.py, and in pcapkit/protocols/schema/internet/ipv6_route.py both Protocol in a payload: stub and Optional inside a typing.cast. Each is now in its module’s TYPE_CHECKING block, Protocol spelled ProtocolBase as Protocol as the sibling schema modules already do. The cast case is the one worth naming: typing.cast never evaluates its first argument, so no test that runs the line can see a bad name in it. Nothing resolves at runtime that did not before (TYPE_CHECKING is False); making these annotations resolve at runtime is a separate decision about the idiom. A new tests/project/test_annotation_names.py pins the invariant without a type checker: it resolves every string annotation in the package against the names its own module binds, following nested forward references such as 'list["Nested"]' and treating Literal members and Annotated metadata as values; without the fix, it reports exactly those findings. The same change corrects the MANIFEST.in comment asserting that include README.md was the only thing putting the README in a source distribution and that an sdist without it could not be installed; both halves are false, and the #619 entry above carried the same claim and is corrected with it (#642).

  • every PCAP-NG packet block now carries the captured octets it declares. Every Enhanced Packet Block of the committed examples/captures/dhcp.pcapng reported len(packet) == 0 against a captured_len of 314, 342, 314 and 342, as did the Simple Packet Block and obsolete Packet Block (synthesised; no committed fixture has either). Those block types, exactly PCAPNG.PACKET_TYPES, are the whole blast radius. The octets were read correctly and thrown away: PCAPNG.unpack() extracted them from the block schema, and ProtocolBase.__init__() overwrote the result with self.packet.payload. The inherited packet splits a protocol at self.length octets of header and takes the rest as payload, which holds only for a header followed by its payload; PCAPNG.length is the Block Total Length, so the split consumed the whole block as header (len(frame.packet.header) == 348 on a 348-octet block), and a block’s trailer (option list, repeated total length) means no length could make it right. The fix is at the computation rather than the injection: PCAPNG.packet is overridden to take the payload from the block schema’s __payload__ field and the header from the octets ahead of it, summed out of the schema buffers so the packet types’ payload offsets (28, 12, 28) are not hard-coded. Teaching ProtocolBase.__init__() to skip a protocol that had set packet was rejected: it heals frame.info.packet while frame.packet still reports the whole block as header. The override is a plain property, not the cached_property it replaces: the inherited one caches because it reads the stream, where this one only walks buffers already filled, and caching it would let a second unpack on one instance return the first call’s octets. This moves the parse output of every PCAP-NG capture on the success path, so it ships labelled breaking: frame.info.packet goes from b'' to hundreds of octets, frame.packet.header from the whole block to the pre-payload prefix. It was also a wire-format defect: PCAPIO writes value.packet after each record header, so a dump declared hundreds of octets per record and delivered none, desynchronising any reader that walks by incl_len. It round-trips now. The uncaught ValueError on an EOF-truncated PCAP-NG (#678) is untouched, and #685 (below) later removed the stale examples/captures/pcapng.txt from the index (#646).

  • an unpadded HTTP/2 DATA, HEADERS or PUSH_PROMISE frame parsed with its whole payload silently discarded. The payload length callbacks for those frames in pcapkit/protocols/schema/application/httpv2.py put the conditional in the wrong place: a conditional binds looser than -, so pkt['__length__'] - pkt['pad_len'] if pkt['flags']['bit_3'] else 0 groups as (pkt['__length__'] - pkt['pad_len']) if ... else 0, and the else arm returned 0 (“read no octets”) where it meant to subtract 0. Padding is rare in HTTP/2, so the broken arm was the common one, and nothing raised or warned: info.data was b''. An unpadded DATA frame declaring b'{"ok":true}\n' now parses as those twelve octets, and unpadded HEADERS and PUSH_PROMISE keep their header block fragment. Padded frames are unaffected, since (A - B) if T else 0 and A - (B if T else 0) are both A - B for truthy T; the padded arm has no off-by-one (#668 asked that be checked), because Schema.unpack() decrements packet['__length__'] by each field’s width, so pad_len’s own octet is already out by the time the payload field runs. The same lambda also governs packing: an unpadded DATA frame carrying twelve octets packed to a nine-octet header whose length field declared 21, body absent, so pcapkit was emitting malformed HTTP/2; a make -> parse -> make cycle of a non-empty unpadded payload now closes byte-for-byte. The only other conditional of this shape, TSOption.remainder in pcapkit/protocols/schema/internet/ipv4.py, is correct: its else 0 is intentional because ts_data consumes the whole option data area in that arm. The new tests use non-zero padding rather than the zeros RFC 9113 Section 6.1 tells a sender to use, so failing to subtract it yields a different byte string. Ships labelled breaking: restoring a dropped payload moves the parse output of essentially every HTTP/2 capture, and constructed frames change byte-for-byte, from malformed to correct (#668).

  • the schema half of every option-like registrar overwrote silently while the parser half of the same call warned. EnumSchema.register() was a bare cls.__enum__[code] = schema, and is the schema half of the public option registrars, so one register_ipv4_option call replacing a built-in named the parser it displaced and said nothing about the schema. EnumSchema.__init_subclass__() reaches the same registry without calling register, so a subclass declared with a code= keyword (the documented way to add a schema) stayed silent too; both are guarded, and the declaration hook’s branches are folded into one loop. Presence is a faithful test only because _EnumRegistry.__missing__() returns a miss without recording it, so parsing an unknown code cannot make the next legitimate registration warn. The code-keyed parser registrars (on ProtocolBase, Link, Internet, Frame, PCAPNG, Transport and SCTP) now name the displaced entry and its replacement, where they reported only that something was overwritten. Their firing condition is harmonised onto the narrower guard #681 gave register_protocol (#718, #726) rather than presence alone: that key is derived from the value it stores, whereas these take a caller-supplied code. Where a table is pre-seeded with an unresolved ModuleDescriptor, the guard’s is comparison measures the resolved incoming class against the descriptor, so re-registering the same class under its own pre-seeded code (Internet.register(TransType.TCP, TCP)) still warns, a false positive the harmonised guard does not close. ContextRegistry.register() already raises on a duplicate, and the reassembly and ESP registrars append to lists with no key, so none of them takes a guard. import pcapkit emits no RegistryWarning: none of its registry writes (R1CounterParameter’s second code, from #690, among them) lands on a key already present (#692).

  • every EOF-truncated PCAP-NG file raised a bare ValueError out of Extractor, losing the whole extraction rather than the one truncated block. Block Total Length is cross-checked against its trailing copy and never against the file, so a last block declaring more than the file holds left PCAPNG.read() seeking past the end, and the next block read measured a negative remainder that io.RawIOBase.read refused. The seek is now clamped to the octets _read_fileng returned, with a ProtocolWarning naming the overrun, and a tail under the twelve octets a block needs is the quiet StreamEOFError that Extractor.record_frames() already catches (deliberately not a clamp, which would fabricate a frame out of zero padding). Another route to the same shape is closed by a new nonnegative() helper, composed into bounded_option and bounded_area and applied to the unwrapped length - N spans and the __option_padding__ fields: _TextField.__call__() builds f'{length}s' unconditionally, so a negative became '-8s' and struct.calcsize raised an exception caught nowhere a frame loop looks. Both of #678’s failure families are gone; others remain: ValueError: N is not a valid BlockType (filed as #701) and MemoryError from #593’s 32-bit band. Bare exceptions in SystemdJournalExportBlock.post_process(), reachable from valid input, are fixed with it: the body’s NUL alignment padding was read as a field name (struct.error; a NUL-only line now ends the entry); a binary field’s declared length was unbounded against its entry (OverflowError; now a ProtocolError that keeps the block’s journal data as captured, with a ProtocolWarning, #1406); and a non-UTF-8 name, key or value raised UnicodeDecodeError (now errors='replace' and reported). The silent loss of every field after a binary one is filed as #704. The unclamped non-packet option areas keep the per-block framing assumption #676’s note describes. Also here, the last registrar with no overwrite guard: Option.register() now reports a displaced option schema as a RegistryWarning naming every namespace the ns='opt' fan-out displaced something in, tested by membership (subscripting the per-namespace defaultdict would insert UnknownOption for a code nobody registered). The condition is identity-based like the parser registrars and #681’s guard for register_protocol (#718, #726); a namespace the call itself creates is exempt. Flooring the decryption-secrets payloads that read __length__ whole was tried and reverted, since Schema.pack() leaves that key at -1 for “unknown”. Ships labelled breaking: well-formed captures are byte-identical, but any truncated PCAP-NG now yields the frames before the cut where it raised, the last of which may carry zero-padded octets, and some short-file depths move from ProtocolError: PCAP-NG: unknown byte-order magic to StreamEOFError. The #683 drift #685 tracks is unchanged (#678).

  • HIP’s R1_COUNTER/R1_Counter parameter packed 12 octets where RFC 7401 Section 5.2.3 requires 16, and LOCATOR_SET declared its Length in 4-octet units where RFC 7401 Section 5.2.1’s Length is a byte count – independent defects, fixed together because the evidence for either needed the other out of the way. R1CounterParameter’s counter was a UInt32Field where the RFC states the width twice, “8 bytes” in the diagram and “a 64-bit unsigned integer” in prose; both versions whose packing reaches the class, R1_Counter (128) and R1_COUNTER (129), packed a 12-octet record at 4 (mod 8). It is a UInt64Field now and the record is 16 octets (#672). LocatorSetParameter’s Length was sum(Locator.len), in the 4-octet units RFC 8046 Section 4 gives Locator Length; a set of n plain IPv6 locators declared 4n octets against 24n present, and Schema.unpack() handed the nested ListField only the declared octets, so n = 2 and n = 5 parsed one truncated locator and left the rest unconsumed. HIP._make_param_locator_set() now sums 8 + Locator.len * 4 per locator (#679). Another defect in the same parameter’s padding, which #651 left alone because it cancelled this one, is fixed alongside: the nested Locator schemas share the parameter’s packet context and pack their own len over it, so padding, evaluated after the list, saw 4 rather than the real length. A new locator_set_len_callback snapshots Length under a private key before any locator packs, and locator_set_padding_len reads that snapshot. The counter defaulted to 0 and the width defect could not be told apart from it, so the fixture now overrides both R1_COUNTER codes with counter=0xaabbccdd; new tests are test_hip_r1_counter_width_unit.py and test_hip_locator_set_length_unit.py. The hip-parameter/R1_Counter EXPECTED_FAILURES entry (code 128 registered no schema of its own, since R1CounterParameter declared only code=129) was deleted once that registry defect was fixed as #690. HIP_COPIES dropped to one as #689, once #672 and #679 left it routing around nothing.

  • httpv1.HTTP raised a bare exception for any payload that is not an HTTP/1 message, at these sites: the header/body split in read, the header’s CRLF split and the start-line split in _read_http_header, and item[1] on a field line with no colon. HTTP._guess_version() falls through on ProtocolError alone, so the HTTP/1 attempt aborted the guess and the HTTP/2 arm below was dead code for each of those inputs. Each site now raises ProtocolError, which each method’s Raises: section documented, and a test pins the invariant (nothing escapes httpv1.HTTP that is not a ProtocolError), so any further bare-raising site would fail there. An obs-fold continuation line [RFC 9112 Section 5.2] is legal HTTP/1 and is now unfolded, the RFC’s remedy, rather than read as a field of its own (with no colon it raised that IndexError; with one it parsed into a spurious extra field). Reaching the HTTP/2 arm exposed a payload under nine octets to httpv2.HTTP, where it usually fails inside the schema with a bare struct.error (not a ValueError, ProtocolError or pcapkit StructError, so uncatchable); read converts it and _guess_version suppresses it on the last arm only, since an arm that is not last hands the payload onward when it swallows. Separately, PayloadField.protocol looked a str up in __proto__ as given while the registry is keyed on the upper-cased class name, so a name not already upper-case resolved to None and yielded Raw; it now folds case and warns with RegistryWarning for a genuinely unregistered name, and __init__ assigns through that property instead of writing _protocol past it (#787, #789).

  • a systemd journal entry whose last field lacks the format’s mandatory trailing newline let the PCAP-NG block’s 32-bit alignment padding be read as part of that field’s value, with no exception or warning: b'MESSAGE=hello' plus three NULs returned MESSAGE == 'hello\x00\x00\x00'. The padding-only-line guard from #678 catches padding only on a line of its own, which needs the preceding field to have ended with a real newline; without one readline() runs through the value into the padding. Reachable on text fields only (a binary field’s length-prefixed read is never in play, and a binary field name on such a line fails its own length-prefix read regardless). A field line with no terminating newline raises ProtocolError, so the block’s journal data is kept as captured, padding included, with a ProtocolWarning (#794, #1406). examples/generators/pcapng.py’s _journal_entry cited a bare line number for the defect its alignment workaround avoids and mis-attributed the fix; it now names #678’s not line.strip(b'\x00') guard and the mechanism (#791).

  • httpv2.HTTP.read() tested only the 24-bit declared length off the wire (schema.length < 9), never how many octets the buffer held, so a frame backed by far fewer real octets than declared parsed and reported the declared, attacker-controlled length. Sweeping the declared length against a four-octet buffer gave non-monotone accept/reject (9, 15, 65535 and 16777215 parsed, 10 and 100 did not), an artifact of _read_http_settings’s own (declared - 9) % 6 check. HTTP.unpack now rejects a non-empty buffer under nine octets before the schema layer runs, preserving StreamEOFError for a genuinely exhausted implicit-length stream, and read’s guard also requires the declared length not to exceed the buffer. A buffer that clears nine octets can still carry a frame type whose fixed fields exceed what is left (GOAWAY at 9-16 octets, PUSH_PROMISE at 9-12, padded DATA, HEADERS and PUSH_PROMISE) and crash the schema layer with a bare struct.error; that root cause is in the generic Schema.unpack()/ FieldBase.length machinery the schema modules share, out of scope and filed as #805, which is why _guess_version’s last arm keeps suppressing struct.error alongside ProtocolError (#802).

  • SystemdJournalExportBlock.post_process() skipped a binary field’s one trailing newline with a bare entry_data.read(), which reads to EOF: the next readline() returned b'' and ended the entry, discarding every field behind the first binary one. The skip is bound to entry_data.read(1), and a value not followed by its newline raises ProtocolError rather than resynchronising at the wrong offset, so the block’s journal data is kept as captured, as one bytes item, with a ProtocolWarning (#1406). Without the fix, test_journal_fields_following_a_binary_field_are_not_discarded fails with MissingKeyError: 'SECOND' (#704).

  • the same block’s entries were split on a bare b'\n\n', which shreds a binary field whose value contains that pair. The entries are walked instead, each binary value counted out by its length prefix; a bad terminator or length raises ProtocolError, so the block’s journal data is kept as captured, with a ProtocolWarning (#1406). Another gap closed in the same change: a trailing separator on the block’s last octet was indistinguishable from EOF and swallowed, so a rebuild wrote length one octet short (#723).

  • a breaking change to .get()-backed enum fields: TCP/UDP/SCTP’s PortEnumField and PCAP-NG’s OptionEnumField each minted a member through aenum.extend_enum for every value no row or documented span covers (111 calls on http.pcap, all ephemeral ports). Each now peeks at what .get() would consult and call it only once the value resolves; a genuine miss gets EnumField._unregistered_member(), a real member of the same registry built through its storage base’s __new__, so isinstance holds and the lookup table stops growing. Riding along: PCAP-NG option namespaces stop cross-contaminating: once a code was minted under opt, OptionType.get() merged opt over the requested namespace and most other namespaces answered opt_unknown for it; each now gets its own <ns>_unknown. Breaking because #575 asked for byte-identical output across the sample captures, as #420 and #427 did, and this deliberately misses that bar: some sample captures change, entirely from the new <unassigned> rendering plus the PLIST escaping above; normalising both leaves them byte-identical. So that a resolved unassigned member still pickles, _unregistered_member installs a __reduce_ex__ rebuilding an equivalent unregistered member (#575).

  • HTTP._guess_version() tried to parse a stream as HTTP/2 and read failure as “not HTTP/2”, rather than identifying the version first, so a genuine connection preface followed by a real SETTINGS frame raised ProtocolError: unknown HTTP version. It now identifies before it parses: a preface is recognised as HTTP/2 outright, skipped rather than fed to httpv2, and counted as header so info stays byte-identical to reading the following frame alone (otherwise the injected packet=self.packet.payload, sliced from octet 9 of a buffer whose frame starts at 24, reported preface remnants as payload). A preface with no frame behind it raises ProtocolError: HTTP/2: connection preface with no frame; a bare mid-stream SETTINGS frame, an Upgrade: h2c request and non-HTTP input are unchanged, the frame and the request left undecidable on purpose (Upgrade: h2c needs per-connection state a single payload cannot carry, and a mid-stream frame has no safe heuristic, since a type <= 9 guess misfires on binary HTTP/1 bodies). Protochain over the sample captures is unchanged (#800).

  • TCP.__proto__ bound httpv1.HTTP directly for ports 80 and 8080, so a segment on either port was HTTP/1 by assertion of the port alone; both versions share those ports, so the payload has to decide. Repointed to the generic pcapkit.protocols.application.http.protocol.HTTP proxy, whose identification became positive only once #800 and #814 landed, which is why this waited. udp.py already bound the proxy for both ports; this removes the asymmetry its docstring and docs/source/contributing/pep.rst documented as an open request. Over the sample captures every HTTP/1.1 frame keeps its chain, and the genuine HTTP/2 frames in options-transport.pcap change Ethernet:IPv4:TCP:Raw to Ethernet:IPv4:TCP:HTTP/2. Not labelled breaking, but not free: TCP:80/8080 traffic that is neither valid HTTP/1 nor preface-carrying is now exposed to the fall-through arm, where the direct httpv1 binding left it Raw regardless; a caller depending on that is the one who would notice (#682).

  • a breaking change to more bespoke registries: step 2 of #860, ahead of AppType (#874, above), bringing StatusCode, ReturnCode, ResponseKind, GroupingInformation, OptionType, FEATCode, Command and Method onto EnumRegistry and applying #775’s mint/unmint ruling to their get() as well as _missing_. StatusCode, ReturnCode, ResponseKind, GroupingInformation and OptionType convert their unambiguous placeholder branches (Unassigned, Unknown, opt_unknown) to _unregistered_member; those with a custom __new__ (StatusCode, ReturnCode, OptionType) get an override reconstructing the attributes the base’s generic helper would leave unset, and StatusCode/ReturnCode’s hand-written get(), still on the retired default == -1 convention, is replaced by the base’s (no caller relied on the old form). OptionType keeps its own get() for its multi-namespace dispatch, and both its int/namespace and str paths build an unregistered member, where each minted one; the live pcapng parse path (PCAPNG._make_pcapng_options()) calls it with wire bytes, so parsing an undeclared option code registered a permanent member. FEATCode, Command and Method mint the literal wire value as its own name rather than any placeholder; per the owner’s ruling that get() lacks the information to create new members (Command needs feat/desc/type/conf and Method needs safe/idempotent, which a bare wire string lacks), both _missing_ and each class’s own get() (an independent mint site of its own) now build an unregistered member. FEATCode’s crawler now declares every real FEAT-code value from the live IANA table instead of minting some of them as a side effect of evaluating Command’s rows at import time, the import-time-mutation shape #861 removed from FilterType; pinned count-agnostically so a future IANA update cannot fail a correct regeneration. Untouched, per rulings: CommandType stays IntFlag (real A|P composites in the generated data), and TransportProtocol’s auto() renumbering and AppType are #874’s. pcapkit/protocols/schema/misc/pcapng.py’s OptionEnumField.post_process docstring is corrected: it still bypasses OptionType.get() directly, now for pickle round-trip safety (#860 already stops the minting that used to be the reason), since neither of OptionType’s paths gets both pickling and correct rendering right at once (#869).

  • register on ProtocolBase, Frame, PCAPNG, SCTP, Link, Internet and the concrete Transport subclasses (TCP, UDP) now raises RegistryError for a non-class argument, where it raised a bare TypeError (“issubclass() arg 1 must be a class”) from inside abc. Each guard gains an explicit isinstance(protocol, type) test ahead of its issubclass, after the ModuleDescriptor is unwrapped, so a descriptor naming a non-class attribute is rejected too. Every such Raises: clause already promised RegistryError if protocol “is not a … subclass”, which a non-class satisfied in spirit but not in fact; they now read “is not a class, or not a … subclass”. No guard’s target class changes, and a wrong class still raises RegistryError as before. Transport.register() on Transport itself still raises UnsupportedCall first; only its subclasses reach the guard. Not breaking for exception handling: RegistryError subclasses TypeError through BaseError, so except TypeError still catches it, and the type and message text are the only things a handler can key on that change. Observable side effects do come with it, because BaseError is loud by default: the non-class path now emits pcapkit’s one CRITICAL log record, and outside devmode it installs sys.excepthook and threading.excepthook, neither of which the bare TypeError from abc did. That is the wrong-class path’s existing behaviour, so the fix makes the two consistent rather than introducing something new. Follows the same fix for the foundation registrars (#1021) (#1026).

  • PCAPNG’s ns_dnsname, ns_dnsIP4addr and ns_dnsIP6addr scope guards, on both the read and the make side, now say the option “must be in Name Resolution Block”, which is the block they check; they used to name the systemd(1) Journal Export Block, with a literal :manpage: role in the text. register_record’s RegistryWarning likewise now says “name resolution record already registered”. The make-side ns_dnsIP4addr/ns_dnsIP6addr messages also drop the lowercase ns_dnsip4addr/ns_dnsip6addr spelling for the OptionType member name. Other guards named the wrong option too: _make_option_if_rxspeed’s scope and duplicate guards now say [if_rxspeed] rather than [if_txspeed], and _read_option_epb_queue’s length guard says [epb_queue] rather than [epb_packetid]. Only message text changes; the exception and warning types are unchanged (#1038).

  • httpv1.HTTP refused every HTTP/1 message with no header fields, which [RFC 9112 Section 2.1] allows (*( field-line CRLF )): b'GET / HTTP/1.0\r\n\r\n' raised ProtocolError: HTTP: invalid format, because _read_http_header treated a header with no CRLF as malformed. So HTTPv1(method='GET', uri='/', http_version='1.1') failed to read back the bytes its own make built, and HTTP._guess_version() answered unknown HTTP version, since _test_start_line mirrored the same check. A CRLF-less header is now a start line with zero fields, in the parser and the predicate alike. That check was also what refused the HTTP/2 connection preface, whose header is exactly PRI * HTTP/2.0; it is now refused by name instead, to the same message (#1041).

  • httpv1.HTTP.from_data could not rebuild an HTTP/1 message whose body is empty: HTTP.from_data(info) for b'GET / HTTP/1.1\r\nHost: a\r\n\r\n' raised TypeError: can't concat NoneType to bytes. read records an empty body as None, _make_data passed it through, and make concatenated it. _make_data now hands make b'' for a None body; info.body is still None, so readers see no change. httpv2 is unaffected: its empty-payload frames already round-trip (#1050).

  • IPv6._read_ip_hextet sliced the traffic class from hex[0:2] and the flow label from hex[2:], both including the version nibble, so 6abcdef0 read as (6, 0x6a, 0xbcdef0); it now reads (6, 0xab, 0xcdef0). The parser reads these fields through the schema, so only a direct call was affected (#1094).

  • the Mobility Header Message ID option wrote a zero fraction for every timestamp, and MesgIDOption.post_process() ignored the fraction on read. _make_opt_mesg_id now writes round(frac * 2**32), clamped to 32 bits, and the reader adds fraction / 2**32 seconds, so timestamp keeps sub-second precision (#1093).

  • HIP parameter defects. HOST_ID packed DI-Type and DI-Length as a 4-octet BitField where RFC 7401 section 5.2.9 has one 16-bit word, so every built record carried two surplus octets and read back the wrong algorithm (#1118); with it, make_samples.py builds every HIP parameter. HIP_CIPHER warned at six Cipher IDs, which section 5.2.8 allows; it now warns only above six and keeps the first six, as a recipient must. The maker is unchanged (#1092). And the HIPv2-only DH_GROUP_LIST, HIP_CIPHER, HIT_SUITE_LIST and TRANSPORT_FORMAT_LIST were built and parsed in a HIPv1 packet; at version 1 the reader and the maker now raise ProtocolError, as R1_Counter and HIP_TRANSFORM already do on a version mismatch (#1133).

  • the PCAP Header could not rebuild itself. _make_magic’s explicit-endian branch ignored nanosecond (#1096), and _make_data returned a magic_number that make() does not take – one of the keys the #617 entry above reports – so from_data dropped it with an UnknownFieldWarning and rebuilt every header with the host-default d4c3b2a1 magic. _make_data now returns byteorder and nanosecond, and every byte order and timestamp resolution rebuilds unchanged. Header._read_protos also read four octets whatever size asked for (#1125), and read them little-endian in a big-endian file; it now follows the file’s byte order (#2312). And _make_magic honoured lilendian and bigendian only as a pair, so Header(bigendian=True) wrote d4c3b2a1 on a little-endian host; either flag alone now picks the byte order and overrides byteorder, and equal flags still raise EndianError (#1144).

  • TCP’s NS flag was written by make() but never parsed: it had been commented out of Flags, the reader and _make_data, with no reason recorded. Per the owner’s ruling it is exposed rather than removed, so info.flags.ns exists and from_data keeps the bit (#1098).

  • from_data round trips. Frame._make_data() returned ts_src and L2TPv2._make_data() prio, the other keys the #617 entry reports, so from_data discarded both with an UnknownFieldWarning and an L2TPv2 priority bit was lost; they are now ts_sec and priority (#1097). And ProtocolBase.from_data() took no keywords, so Frame.from_data and PCAPNG.from_data, which need num/header and num/sct/ctx, could not be called at all. Extra keywords now reach the construction, overriding _make_data’s, under the construction-keyword check, so a typo raises UnsupportedCall (#1126). HTTP.from_data always raised invalid HTTP version: 0, looking up a version key neither versioned data class has; it now dispatches on the data’s class. HTTP/2’s _make_data also returned the payload length, which ProtocolBase takes as the parse length, so even a direct call failed as shorter than the 9-octet frame header; make now computes it, and every HTTP frame in the sample captures rebuilds byte for byte (#1154).

  • Quick-Start options did not round-trip. The IPv4, HOPOPT and IPv6-Opts readers took the function from schema.func, which only a parsed schema carries, so a built option raised AttributeError; HOPOPT and IPv6-Opts also sized the 8-octet option as 5, misreading the nonce with a packet length < 0 warning (#1091). TCP’s Quick-Start Response reader read one octet past the option, and its maker produced a negative rate code below 40 kbps; such a rate now rounds down to zero, which RFC 4782 permits (#1119).

  • Ethernet, ARP and PCAPNG packed MAC addresses as ASCII hex text. Ethernet._make_mac_addr returned the separator-stripped hex string and BytesField(length=6) truncated it, so Ethernet() put 303030303030 on the wire for an all-zero address (#1123). ARP._make_addr_resolve, PCAPNG._make_mac_addr and _make_eui_addr did the same; all now decode the hex to octets, and from_data rebuilds each byte for byte (#1134).

  • TCP’s User Timeout maker wrote length=3 where RFC 5482 and the reader require 4, so every option it built failed to parse with ProtocolError (#1120).

  • a breaking change to the HTTP/2 frame length: it counted the 9-octet frame header, where RFC 9113 section 4.1 counts the payload alone, so make() wrote 00000e for a 5-octet PRIORITY frame and read rejected RFC-conformant frames – and, since _read_http_priority checked for 9, pcapkit’s own PRIORITY frame. The length and every per-type check now exclude the header. Captures written to the old convention no longer parse; the PRIORITY, RST_STREAM, SETTINGS (with and without ACK), PING, WINDOW_UPDATE, DATA, PUSH_PROMISE and GOAWAY frames that Scapy’s contrib.http2 packs now parse and rebuild byte for byte (#1121).

  • a breaking change to ARP.make(): hlen and plen are derived from the packed sha and spa when omitted, and an address that disagrees with its length field raises ProtocolError where it used to be truncated silently – ARP(ptype=IPv6, spa='::1') wrote plen=4 and four octets of the address. A non-IP str protocol address packs as hex octets rather than ASCII text, as hardware addresses do since #1134, and non-hex input raises ProtocolError (#1139).

  • HTTP/2 SETTINGS built each setting pair as a SchemaField with no length, so a well-formed frame warned packet length < 0 twice per pair. The item now has its fixed 6-octet size, as TCP SACK and SCTP gap-ack items already do (#1140).

  • HTTP._guess_version() took almost any binary for HTTP/2 on its fall-through: nine zero octets read as a DATA frame. The HTTP/2 trial now runs only on a header with a type RFC 9113 defines, no undefined flag, the reserved bit clear and a stream that type is sent on. Over 5,000 fuzzed zero-heavy buffers, HTTP/2 labels fell from 566 to 101, every one a legal DATA header. Explicit version=2 parsing is unchanged (#1141).

  • a breaking change to TCP.make()’s options: _make_tcp_options dropped the NOP and EOOL options it was given and padded each other option to 4 octets separately, so from_data rebuilt none of the segments in http.pcap with their original header octets. Options are now emitted as given and padded once at the end – zero octets after a trailing EOOL, otherwise NOP options and an EOOL – so a built header can be shorter than before; every TCP segment of the measured sample captures rebuilds its header octets, and options-*.pcap regenerate unchanged (#1151).

  • a breaking change to from_data on a capture truncated by snaplen: IPv4, IPv6 and UDP rebuilt their length field from the payload at hand, writing the recorded length, so big_endian.pcap frame 3 came back as 45000052 where 450004a2 was on the wire. _make_data now passes the parsed length through to make()’s new total_length (IPv4, UDP) or payload_length (IPv6) keyword, which computes the length as before when omitted, so IPv4(payload=...) is unchanged (#1155).

  • a breaking change to the layer chain of a capture cut inside a header: a next layer parsed from too few captured octets was zero-filled, and from_data rebuilt the fabricated octets. IPv4.read() handed UDP the declared payload length when nothing past the IPv4 header was captured, so test.pcapng frame 3 rebuilt with 8 invented UDP octets (#1167). Every _import_next_layer now goes through one guard: no captured octets gives NoPayload, and fewer than the next layer’s __length_hint__ gives Raw unless the parsed header fits anyway, as a 12-octet L2TPv2 ZLB does. A header-only IPv6 packet is IPv6 rather than IPv6:UDP:Raw, and frame 3 is Ethernet:Raw, rebuilt byte for byte (#1170). The guard needed honest hints, so IPv6_Route (4), MH (6), HOPOPT and IPv6_Opts (2) now hint their 8-octet minimum header (#1173), and so does the IPv6_Ext fallback, which hinted 2 (#1178).

  • a breaking change to L2TPv2, against RFC 2661 section 3.1. Type was inverted; it is now Data = 0 and Control = 1, regenerated in pcapkit.const from the fixed pcapkit.vendor table, so the same bytes parse to the other flags.type, and a default make() writes T=0 where it wrote T=1. A control message now gets L and a Length field automatically, and the offset pad, which from_data zero-filled, is kept as info.padding – a new key in info and in dumped output (#1175). from_data also cleared L and dropped the Length field: _make_data returned it as length, which ProtocolBase takes as the parse length. make()’s length keyword is now total_length, written as given; a call still passing length loses L silently (#1172). make() also gains length_flag and padding, and raises ProtocolError for a total_length with length_flag=False.

  • a breaking change to IPv6.from_data: it dropped the extension-header chain, so a packet carrying a Routing header rebuilt 48 octets as 40. The chain is now kept in wire order under a hidden __exthdr__ key, absent from dumped output (to_dict carries it since #2014, below), and re-emitted ahead of the upper layer. Sample frames that rebuilt wrong now rebuild exactly, though some that had rebuilt with wrong bytes raised AttributeError instead (#1177), from older defects fixed with it: HIP.alias read _info before read had set it, which also broke a standalone HIP parse and HIP.from_data (#1184), and the MPL option’s maker called bit_length() on the IPv6Address seed of an S=0 option. MPL in HOPOPT and IPv6_Opts now takes S from the parsed option or a new seed_type keyword, and a missing or oversized seed raises ProtocolError (#1185). With both fixed, every IPv6 frame of the options-*.pcap captures rebuilds byte for byte.

  • a breaking change to the SMF_DPD option’s maker in HOPOPT and IPv6_Opts: it overwrote the parsed DPD mode, so H-DPD rebuilt as I-DPD, and inferred the TaggerID type from the Tagger ID’s length, so a 4-octet DEFAULT Tagger ID rebuilt as IPv4. Both now come from the parsed option, or from mode and a new tid_type keyword. A Tagger ID that does not fit its type raises ProtocolError, so a 17-octet one does where it raised FieldValueError, and tid=b'' builds a NULL Tagger ID where it raised ValueError (#1190). An H-DPD option with an empty Hash Assist Value raises ProtocolError where it raised a bare IndexError (#1193).

  • a ForwardMatchField that read short at the end of the stream rewound by its declared width, not by the octets it read, so the fields after it re-read the preceding octet. An SMF_DPD option in the last two octets of a HOPOPT or IPv6_Opts header was re-read from the padding and raised a bare TypeError; it now raises FieldValueError, as CALIPSO and MPL do. A full read rewinds as before (#1195).

  • HIP() with no parameters wrote Header Length 0, below RFC 7401 section 5.1’s minimum of 4 for the two HITs, so it failed to read back with ProtocolError; it now writes 4. read names a Header Length below 4 in a new invalid header length message. Lengths 0 to 3 already raised ProtocolError, so only the message changes (#1189).

  • the RPL option, type 0x23, raised AttributeError in HOPOPT and IPv6_Opts (#1196, #1198). The test that an option of any type in the last two octets of the header fails in-library no longer skips it; test-only (#1771, #1776).

  • a breaking change to the RPL option: it keeps its reserved flag bits on rebuild and reads RFC 6553 sub-TLVs (#1199, #1201).

  • make() rejected an enum member name given as str unless a namespace was passed (#1210, #1227); raw items in a make() list build a packet, and str member names resolve per argument (#1352).

  • a breaking change to rebuilding a parsed packet, the round-trip programme: from_data(info) gives back the packet’s own octets. Most of these fixes add the make() keywords and info keys the rebuild needs, which is what breaks:

    • ARP, InARP and DRARP, any Ethernet hlen, and a ptype/plen mismatch (#1224); L2TPv2 reserved flag bits, and a lone ns or nr in make() (#1226); octets past the L2TPv2 and OSPF Length fields (#1455, #1460).

    • SCTP chunk flags, reserved fields and final padding (#1253), octets after the last whole chunk (#1468, #1472), a last chunk short of its padding (#1474, #1477), a final parameter or cause short of its padding (#1479, #1481), and a Host Name with no NUL terminator (#1482, #1483).

    • TCP: MPTCP options (#1254); reserved bits, octets after EOOL and the UTO granularity (#1348); and a header length declared past the data, with IPv4 (#1411).

    • UDP: octets past the Length field (#1461, #1467).

    • FTP and HTTP/1 lines, byte for byte (#1301), keeping the raw command case, HTTP/1 whitespace and field octets, with whole multi-line FTP replies parsed (#1347). HTTP/2 frames lose no octets (#1341).

    • OSPF keeps the parsed Packet Length, and NGAP refuses octets after the NGAP-PDU (#1304); OSPF cryptographic authentication rebuilds from its dict form (#1464, #1469).

    • IPv4 option areas, route slots, timestamps and reserved bits (#1344); the TS option’s slots beyond the pointer (#1349, #1390); TS and SEC options byte for byte (#1399).

    • IPv6 core headers (#1307); HOPOPT and IPv6_Opts option areas (#1310); a CALIPSO option’s trailing pad (#1403, #1407), and its make() writing a corrupt length on overflow (#1408, #1409); an extension header parsed with extension=True (#1446, #1449); RPL source routes (#1463); and a cut extension-header chain (#1476).

    • octets past IPv4 Total Length and IPv6 Payload Length (#1209, #1431), and past the IPX Length field (#1433, #1435). IPX from_data raised struct.error, and AH wrote its reserved field back as zero (#1338).

    • HIP: Header Length, an empty parameter list, the PUZZLE lifetime and reserved bits (#1342); Locator flags (#1365, #1367); BitField namespaces’ reserved bits (#1417, #1419); and the two fixed header bits (#1459, #1462).

    • MH: zero-length PadN, reserved fields and BU/BA flag bits (#1324, #1345); the reserved bits of the BitField flag namespaces (#1418, #1421); and the reserved octets PaddingField sites capture (#1442).

    • PCAP-NG: constructed and rebuilt blocks (#1339), lengths and padding (#1373), blocks and options as parsed (#1378), further follow-ups (#1412), malformed journal entries and short packet blocks (#1420), an SHB or IDB shorter than its fixed fields (#1422, #1425), and captured padding octets (#1441).

    • a cut PCAP header or PCAP-NG block (#1470, #1475).

  • Schema.pack() keeps option padding, SchemaField spans and iterated keys (#1374); a SchemaField’s unread octets survive from_data rather than rebuilding zero-filled (#1380, #1397); a header cut inside a fixed-width field rebuilds as captured (#1465); and each non-final Schema subclass is finalised on its own state (#1498, #1499).

  • from_data rebuilds the payload from info.to_dict() (#1447, #1452).

  • building a PCAP frame with snaplen 0 wrote a record with no packet data (#1385, #1410); an out-of-range if_tzone is kept as captured and every fromtimestamp failure is caught (#1340, #1429).

  • registration: the parser registrars warn only when the entry really changes (#1362, #1400); re-registering a stored ModuleDescriptor keeps it (#1364, #1415); restoring a registration puts back the shipped descriptor, including through PCAPNG.register() (#1533, #1573); and unregistering a custom protocol removes its name from __proto__ (#1505, #1530).

  • fragments: an IPv6 extension header behind a first fragment is dissected as without it (#1539, #1540), and a later IPv4 or IPv6 fragment’s data is raw, not an upper-layer header (#1545, #1552, #1553, #1562).

  • IPv4: a Total Length of 0 is read as TSO, and one inside the header as bogus (#1547, #1554).

  • IPv6: a jumbogram’s payload is dissected up to its Jumbo Payload Length (#1434, #1440), a jumbogram RFC 2675 calls an error is rejected (#1687, #1713), and so is a header whose Version is not 6 (#1680, #1723).

  • the warnings of a next layer replaced by Raw are dropped (#1580, #1588).

  • IPv6-Route’s data-type readers name the routing type in their ProtocolError, where each message read [TypeNo <class 'type'>] (#442), and HTTP(..., version=1) parses from the frame’s data rather than the drained file, where it raised ValueError (#447, #451).

  • Schema.pack() accepts a tuple for a ListField, as the data models declare, so HIP and MH options that hold one round-trip (#476, #480).

  • a Mobility Header CGA extension reports its length with its 4-octet header, where every parsed one read 2 octets short (#512, #517, #528).

  • TCP’s SACK option enforces the 8n + 2 length RFC 2018 gives it, as its docstring promised (#519, #535).

  • IPv4.from_data() rebuilds a parsed datagram: Schema.pack() checks a payload against ProtocolBase, and option padding ends in an EOOL option rather than a bare wire code (#506, #536).

  • IPv4’s SEC option sets the termination bit its own reader requires, sizes the bitmap from the bit count, and SID is 16 bits wide (#534, #537, #543).

  • the MPTCP option header reads length from bit 8, per RFC 8684 Section 3, rather than bit 1 (#553, #558).

  • Mobility Header enumerations defined inline from their RFCs (FastBindingAcknowledgmentStatus, IPv6AddressPrefixCode, LocalizedRoutingStatus, LMAAddressCode) raise EnumValueError for an unassigned value rather than minting Unassigned_N (#880, #889), and each inherits get and its default from EnumLookup rather than overriding it (#935, #940).

  • pcapkit.protocols.protocol.__all__ lists Protocol beside ProtocolBase, so from pcapkit.protocols.protocol import * binds the module’s public class (#1770, #1778).

  • the data and schema packages re-export what their submodules list in __all__, under the prefixes their siblings use: pcapkit.protocols.data.link exports the L2TP data model as L2TP and L2TP_Flags (#1797, #1806), and the Mobility Header models, the HOPOPT and IPv6-Opts SMFDPDOption and QuickStartOption, PCAP-NG’s PacketBlock and PACK_* options and HTTP/2’s Settings reach their packages and the aggregators (#1807, #1815).

  • the MPL option’s seed in the HOPOPT and IPv6-Opts schemas is annotated int | IPv6Address, since an S=0 option holds the source address (#1840, #1866).

  • a declared length above sys.maxsize, such as a PCAP incl_len of 2**31 - 16 on GraalPy or 32-bit CPython, raised OverflowError from a read or seek. Every length-driven read in Schema.unpack(), Frame.read() and PCAPNG.read() is bounded at sys.maxsize, so such a claim gets the overrun handling it gets on 64-bit CPython (#1975, #1979).

  • PCAP-NG blocks cut short by the file or by their own framing no longer abort an extraction. A Decryption Secrets Block whose secrets_length runs past the block keeps the declared length and warns, its secrets capped at what the block holds (#1980, #1983); an unknown, Custom or systemd journal export block the file truncates gets the “block truncated” warning the other block types give (#1984, #1988); and a malformed TLS or WireGuard key-log line warns with ProtocolWarning and is left out of entries, where it raised FieldValueError (#1985, #1989). A ZigBee NWK or APS key off its fixed width rebuilds as captured, where it was zero-padded or cut to that width (#1986, #2001).

  • capture timestamps convert without the platform’s fromtimestamp limits. Windows’ C runtime covers only 1970 to 3000, so a PCAP-NG timestamp outside that range fell back to 1970 with invalid timestamp, and the Mobility Header’s NTP and PMIPv6 timestamps had the same gap. PCAP, PCAP-NG and MH convert through pcapkit.utilities.datetime.fromtimestamp(), which gives the stdlib’s result on Linux (#2099, #2111), and encode through pcapkit.utilities.datetime.totimestamp(), since GraalPy’s timestamp() put a pre-1970 instant with a fraction one second late (#2134, #2136).

  • ARP’s address makers refuse what they cannot pack: a bool protocol address raises FieldValueError whatever ptype is; a hardware address that is none of str, bytes and bytearray (a bool included), or a protocol address not packed as IPv4 or IPv6 that is none of those and not an ipaddress address, raises ProtocolError, where both failed later with a bare TypeError. A bytearray packs as bytes (#2166, #2182, #2256, #2257).

  • Ethernet’s and PCAP-NG’s MAC address makers, and PCAP-NG’s EUI address maker, raise ProtocolError for an address that is none of str, bytes and bytearray (a bool included), where they raised a bare TypeError (#2222, #2232).

  • the Quick-Start rate parses as the int the IPv4, HOPOPT, IPv6-Opts and TCP data models declare, where it was a float such as 80.0, and a TCP Fast Open cookie request, the option of length 2, parses with cookie as b'' rather than None (#2167, #2185).

  • PCAP-NG’s ns_dnsIP6addr option and IPv6 name resolution record build with their defaults, 2001:4860:4860::8888 and ::1, where IPv4 defaults made them raise FieldValueError (#2168, #2179).

  • message text: the epb_flags and pack_flags length errors say expected 4, the length they check, rather than expected 8 (#2168, #2179), and the IPv4 TS option’s unknown-flag warning and the Mobility Header’s prefix error spell timestamp and mobile (#2171, #2189).

  • IPX dissects its payload from a registry of its own, IPX.__proto__, keyed by IPX Packet Type, where it looked the Packet Type up as an IP protocol number, so a type 17 (NCP) payload parsed as IPX:UDP and type 4 as IPX:IPv4. No IPX-native protocol ships, so the payload is Raw unless one is registered (#2169, #2187).

  • HIP’s ECHO_RESPONSE_UNSIGNED maker builds an EchoResponseUnsignedParameter schema, where it built the request’s; the octets were already right (#2170, #2188).

  • a breaking change to PCAP-NG’s Interface Description Block maker: snaplen defaults to 0, which the PCAP-NG specification reads as no limit, where its default of 2**64 - 1 was truncated to 0xFFFFFFFF by the 32-bit SnapLen field and the made info disagreed with the octets. A block made without snaplen writes 0 there, and a snaplen outside 0 to 0xFFFFFFFF raises ProtocolError where it was truncated silently (#2278).

  • HIP parameters read and rebuilt against the wrong definition. TRANSACTION_ID and OVERLAY_ID keep the identifier’s wire width on rebuild, through a new id_len field, where the length was sized from the integer’s own bit_length and the leading zero octets were lost (#2271); the HIP_TRANSFORM maker raises ProtocolError for the HIPv2 parameter its own reader already rejects (#2275); LOCATOR_SET’s maker derives the locator type from whether spi was given, where an spi= locator was left at type 0, a combination the locator selector itself rejects, and an explicit type that disagrees raises naming both values (#2276); REG_INFO, REG_REQUEST and REG_RESPONSE lifetimes decode as the 2**((v-64)/8) seconds RFC 8003 Section 4.1 defines, with a zero octet its own case, where the octet was read as raw seconds, and their makers take either a duration or the raw exponent (#2281); OVERLAY_TTL’s ttl is the hop count RFC 6079 defines rather than a timedelta (#2282); and CERT’s cert_group is a plain int, since RFC 8002 Section 2 defines no registry for it, where it was decoded through the DH Group registry (#2283). HOST_ID’s maker prefixes its errors in the house HIPv{version}: [ParamNo {code}] form (#2299). The data-model and signature changes are breaking (#2400).

  • TCP’s Multipath TCP options: an unknown subtype sizes its declared length from what the schema writes, so one built with data=b'' declares a Length of 3, the header octets it always packs, rather than 2 (#2272); and a parsed subtype schema no longer carries an option attribute it does not declare and no maker mirrors – the declared kind already holds Multipath_TCP, and the octets were identical either way (#2293, #2396).

  • PCAP-NG’s Decryption Secrets Block maker builds from a bytes secrets_data, its b'' default included, by reading those octets through the same secrets schema the parse side selects, where every secrets type raised because the readers expect a parsed schema (#2273, #2346).

  • HTTP/2’s make defaults frame to the frame type’s own constructor rather than to a b'' packed verbatim with Length 0, so PRIORITY, RST_STREAM, PUSH_PROMISE, PING, GOAWAY and WINDOW_UPDATE build a frame that reads back, where HTTP(type=..., sid=...) raised; an explicit b'' still packs raw, and the default is byte-identical to frame={}. settings=None builds the parameterless SETTINGS frame RFC 9113 Section 6.5 permits rather than raising (#2274, #2395).

  • IPv4’s Timestamp option builds from its own default: timestamp=None gives a Timestamp-only option with counts empty slots, where the default raised ProtocolError because the maker took only a list or a dict, and any other value is reported with its repr (#2277, #2347).

  • a short read raises StructError for every size, not only for the sizes with a struct format code: the int.from_bytes path checks the length it got, where it returned a smaller integer. quiet still returns the partial value (#2279, #2349).

  • the Mobility Header’s LMAA option carries the code its address family requires: the code defaults to that family’s own and an explicit one that disagrees raises ProtocolError, where address='10.0.0.1' packed the IPv6 code anyway (RFC 5949 Section 6.2.2). A captured mismatch still rebuilds byte for byte (#2280, #2350).

  • a base class’s make keywords stop reaching a subclass whose own make ignores them. A new ProtocolBase.__own_make_keywords__ marker, read per class like the __keywords__ opt-out, keeps a base’s parameters out of the allowlist once a subclass overrides make, and IPv6_Ext sets it, so IPv6_Route(len=99) is refused where it built the same octets as IPv6_Route() and a misspelling was steered to the dead keyword (#2286, #2358).

  • TCP’s, UDP’s and SCTP’s make raise ProtocolError for a bool port, where UDP(srcport=True) packed port 1 and TCP(dstport=False) port 0 (#2287, #2359).

  • SCTP’s Supported Address Types and Missing Mandatory Parameter makers raise ProtocolError for a parameter type that is neither a Parameter member nor an int, a bool included, where True became parameter type 1, False raised EnumValueError, and a str or a float was stored unchecked (#2288, #2363).

  • Loopback.make raises ProtocolError for an address family that is not an int, from the same check as its range test, where True packed family 1, '2' and None raised TypeError and 2.0 raised AttributeError (#2289, #2364).

  • FTP’s make raises ProtocolError for a command that is none of str, bytes and bytearray, a bool included, where its fallback read cmmd.value, so an int, a bool or a float raised a bare AttributeError while any object that happened to carry a .value was accepted unchecked. A bytearray builds as bytes (#2290, #2365).

  • _make_pack, and _make_index packing through it, raise StructError for a size with no struct format code, as the struct path does, where a value out of range, a non-integer or a bad size leaked OverflowError, AttributeError or ValueError (#2291, #2367).

  • a binary read at the end of the stream raises StructError, as an unpack does, where an exhausted or short stream raised TypeError out of ord() (#2292, #2368).

  • IPv4’s SEC option warns about the bit it observed: the field termination indicator RFC 1108 puts in the final protection authority octet is reported as set, with its value, where the message said the indicator was not set (#2295, #2372).

  • a maker that refuses a caller’s argument names the argument and quotes the value, rather than reusing the reader’s invalid format text, which says nothing about which argument was wrong: SCTP’s chunk-flag, empty-data, hostname-termination and reserved-length guards, the Mobility Header’s data-type, option-length, empty-data and Pad1 guards, TCP’s MP_CAPABLE optional-field guard, and HIP’s and PCAP-NG’s fallback branches (#2296, #2405), then HOPOPT’s and IPv6-Opts’ Pad1 warning and HTTP/2’s and PCAP-NG’s make dispatchers (#2406, #2407). The readers still say invalid format, which is what a parse found.

  • ESP’s errors read in the house ESP: <description>: ... {value!r} form: the messages raised by the cipher and integrity lookups, by esp.protocol.SecurityAssociation’s SPI, destination, AEAD, ICV, key-splitting, decryption and encryption checks, by the context registrar and by make all carry the prefix and quote the offending value, where none carried an ESP: prefix and most printed the value bare (#2297, #2366).

  • HTTP’s version, data, start-line and settings errors carry the HTTP: prefix and the rejected value, as in HTTP: invalid version: 3 and HTTP/2: [Type 4] invalid settings: 42, where the prefixes were missing or malformed, some were capitalised with a full stop, and some left the value out (#2298, #2373).

  • the capture guards read in that form too. The guards that reject a file’s magic number or byte order carry the PCAP: and PCAP-NG: prefixes, and PCAP’s magic-number guard says what it read (#2300); and PCAP-NG’s option guards drop their trailing full stop, with the block-placement guard naming the block type through the BlockType registry rather than printing the raw number _type holds while a block is being made (#2301, #2398).

  • IPv6_Ext’s version gate reads invalid version: must be 6, not {version!r}, in the house form and quoting the rejected value, where its bespoke tail read only valid for IPv6, got version=4 – naming the version it requires in prose rather than as the number it compares against (#2302, #2397).

  • a trial parse kept as Raw because it raised no longer replays its warnings: the holding context honours its discard on an exception exit too, and the next-layer reader discards a raising trial’s warnings under beholder, so an Ethernet frame cut inside its IPv4 header stops warning packet length < 0 for a layer it does not have. Outside beholder the error reaches the caller and the warnings are still reported, and the Raw’s error names the cut (#2308, #2339).

  • the declarations that described a value the code never produces now match it, so a type checker and the rendered API agree with what is stored: CALIPSO’s cmpt_bitmap is the tuple of ints its reader stores, the SMF hash-based DPD option declares its own schema rather than its sibling’s, an RR or SSR route that is always built is not Optional, NoPayload narrows protochain to None, ARP’s address maker admits the bytearray its body handles, and the PCAP-NG flags stub and an undeclared entry_raw follow. HIP’s OverlayTTLParameter and ViaRVSParameter join their modules’ __all__ and the package re-exports those lists require. Parsing and rebuilding are unchanged (#2317, #2377).

  • HIP’s parameters=[(code, data)] form rebuilds a parsed parameter through its maker positionally, so the parameters whose maker keyword is spelled differently from the data model’s own field name – suite_id against suites, group_id against group, reg_type against reg_request and its siblings, and the nested locator keys – keep their values, where they fell back to the maker’s default or raised AttributeError: HIP_TRANSFORM rebuilt as 0241000000000000, losing its suite_id. The dict form, as in parameters=[(code, {'suites': [...]})], is unchanged (#2352, #2383).

  • PCAP-NG settles the byte order before a block maker runs, the way pack does – the section’s, else the one the packet data declares, else the host’s – so an option or a record handed over as raw bytes, whose type code is read back out of those octets, builds in any block rather than raising AttributeError: 'PCAPNG' object has no attribute '_byte'. A Section Header Block still overrides it from its own Byte-Order Magic, and __packet__=None degrades to no packet data rather than raising TypeError, as Schema.pack() already treated it (#2353, #2369).

  • a PCAP-NG block made from raw bytes is read back off the octets pack has just written, as a reader of the file would, rather than off the schema that carries them, so a Decryption Secrets Block built that way parses and rebuilds byte for byte, where the read looked for the block’s fields on the schema holding the octets and raised AttributeError. Octets that are no block raise StreamEOFError or ProtocolError (#2354, #2370).

  • a str enumeration name resolves where the namespace could not be inferred from the argument’s own default. HOPOPT’s and IPv6-Opts’ seed_type and tid_type and HTTP/1’s status default to None, so a name such as 'SEEDID_16_BIT_UNSIGNED_INTEGER' raised ProtocolNotImplemented while the member and int forms of the same call worked (#2356, #2393); and HIP’s list-argument makers, where the name is an item of a list parameter rather than a bare argument, resolve too, with the enumerations they need imported at runtime rather than only under TYPE_CHECKING (#2394, #2401).

  • a PCAP-NG block made without an explicit timestamp reads its interface’s resolution from the same lookup the maker already makes for the scale, so the Enhanced Packet Block, the obsolete Packet Block and the Interface Statistics Block default their timestamp where they raised AttributeError: '_info', having no parsed data model to resolve the interface through yet (#2362, #2382).

pcapkit.toolkit

Added

  • util/pyshark_encap_map.py, a generator that regenerates ENCAP_TYPE_TO_LINKTYPE and FILTER_NAME_TO_LINKTYPE in place inside pcapkit/toolkit/pyshark.py, so the tables #850 hand-built stop being hand-maintained. It lives under util/ rather than pcapkit/vendor/ because Vendor’s base class is hardwired to an HTTP fetch and a generated enum-class file, where this generator’s source of truth is the local tshark/editcap binaries. It guards against its worst failure mode: a naive LinkType(dlt) cannot detect an unmapped DLT, because _missing_ mints a placeholder rather than raising, so the generator snapshots every known LinkType value first and calls LinkType(dlt) only once a value is confirmed a member. The real-tshark sweep is gated on a new HAS_WIRESHARK flag, registered in tests/_dependency_gates.py’s NON_DISTRIBUTION_FLAGS next to HAS_PROC_FD, for things pip cannot install. The sweep arithmetic is version-pinned (editcap -T accepts 226 encapsulations on Wireshark 4.6.9, 224 on 4.2.2, CI’s Ubuntu noble), so count assertions run only under exactly the measured version, with a separate VersionIndependentInvariantTests class for what holds regardless. The only change to pcapkit/toolkit/pyshark.py itself is the IPMB_LINUX entry becoming I2C_LINUX, the canonical name #848 gave value 209 – the same member either way (#853).

  • tcp_segment and its result type are public in pcapkit.toolkit.pcap (#1570, #1579).

Fixed

  • the engine adapters, which were quietly wrong rather than loud. The dpkt toolkit split TCP and IPv4 headers at their fixed struct size instead of their real length, so option octets overwrote payload in the sequence-indexed reassembly buffer; it also read an ipv6_frag.nh that dpkt does not have, and passed fragment offsets unscaled (#351, #370, #385, #395). scapy never loaded its layer registry, so that engine did not dissect at all (#409), and its IPv4 fragment offset reached reassembly unscaled (#483, #484). The IPv6 adapters disagreed about whether the 8-octet Fragment header belongs to ihl, header and tl; per RFC 8200 section 4.5 it belongs to none, and they now agree (#415, #424).

  • pypcapfile’s IP.src/.dst are dotted-decimal ASCII text in a ctypes.c_char_p (per pypcapfile 0.12.0’s ip.py), not packed 4-byte values, so ipaddress.IPv4Address(ipv4.src) raised AddressValueError and ipv4_header()’s struct.pack('...II', ipv4.src, ...) raised struct.error on every call. A single _parse_ipv4_address() chokepoint replaces the separate call sites, also accepting packed bytes or an int. The same engine’s IP.opt/.payload carry hex-ASCII rather than raw bytes once decoding stops short of the transport layer; a new _maybe_unhex() covers the sites that read them. Not breaking: nothing on this path worked on 3.10/3.11, so correct behaviour cannot regress a working caller (#743).

  • pypcapfile’s _read_a_packet(layers=0) hexlifies a whole frame into ASCII text and stops, but _decode() fed that into Ethernet(packet.packet, layers=1), whose __init__ unpacks the header with struct.unpack, so every field came out garbage without raising. Fixed with binascii.unhexlify(packet.packet) ahead of the decoder call, in preference to decoding eagerly at layers>0 inside the lazy generator, which would lose _decode()’s per-frame AttributeWarning fallback. Link-layer dispatch resolves from the header’s ll_type and is unaffected. Landed with #743: standalone, this turns a non-crashing extraction into a crashing one, since Extractor.run() catches only EOFError, StopIteration and KeyboardInterrupt and the AddressValueError/struct.error #743 fixes escape until both land (#746).

  • a breaking change to pyshark link-type resolution in tcp_traceflow: an unrecognised encapsulation or filter name now raises MissingKeyError instead of substituting a plausible DLT. pcapkit/toolkit/pyshark.py resolved a frame’s link type from packet.layers[0].layer_name, a Wireshark display-filter name, with a blanket Enum_LinkType.get(name.upper()) fallback. One filter name serves several encapsulations, so that fallback returned wrong DLTs silently (null for a DLT_LOOP capture, 101 (RAW) for rawip6). Re-keyed onto frame.encap_type, which is distinct per encapsulation, and the fallback is gone. Both tables were measured rather than transcribed (Wireshark’s source is not on the build host): each entry round-tripped through editcap -F pcap -T <encap> and read back with tshark. ENCAP_TYPE_TO_LINKTYPE grows to cover every measured encapsulation whose DLT has a LinkType member, and FILTER_NAME_TO_LINKTYPE grows from #838’s hand-built entries to every root filter name, pseudo-protocol roots aside, that resolved to a single LinkType member across the encapsulations this host’s Wireshark can write. pcapkit/toolkit/pyshark.py reaches full coverage under new tests, which fail without the fix; live pyshark parsing stays unexercised, since pyshark.FileCapture does not run on Python 3.14 (#850).

  • pypcapfile’s tcp_reassembly set last one past the segment. TCP_Packet.last is inclusive, and the other adapters set seqnum + len(payload) - 1, as this one now does too (#1100).

  • scapy’s packet2dict dumped nested packets as {} and mutated its input (#1287); the dpkt reassembly adapters build their data from the record’s own octets rather than dpkt’s re-serialised bytes, and leave the packet alone (#1297, #1335).

  • the TCP adapters: pyshark accepts tshark’s True/False TCP flags (#1514, #1527); a TCP segment whose header the snapshot cut is traced and reassembled (#1518, #1556); dpkt reads SYN, FIN and RST whatever the NS bit (#1618, #1621); and the scapy adapters stop at the IP datagram’s declared length (#1584, #1589).

  • timestamps: dpkt and pypcapfile hand over the correctly rounded timestamp (#1668, #1690), and a copied DecimalTimestamp keeps orig_len under _pydecimal (#1647, #1653).

  • the third-party engines are best effort: the code each adapter carried only to match the default engine is reverted (#1718, #1721, #1724, #1727, closing #1708 through #1743). What each engine leaves out, such as TCP behind a tunnel or an IPv6 extension header, is documented rather than warned about. The pypcapfile engine keeps its warning, once per capture, that it leaves TCP over IPv6 out (#1513, #1535).

  • the pcap-ct, PyPCAP, pypcapfile, pyshark and scapy adapters lead the messages below with their engine’s name, read off the engine class’s own __engine_name__ rather than hand-typed, so a rename under pcapkit/foundation/engines/ cannot leave an adapter spelling the old one: the pcap-ct and PyPCAP reassembly refusals, pypcapfile’s address errors, its IPv6 UnsupportedCall and its TCP-over-IPv6 warning, pyshark’s boolean-field, timestamp and missing-layer messages, and scapy’s import-time warning, which quotes the installable name, all read in the house <prefix>: <lowercase description>: ... {value!r} form. The dpkt adapter and the adapters’ ModuleNotFound raises keep the messages they had (#2304, #2399).

  • the IPv4 and IPv6 reassembly adapters take the first layer of that IP version, from the outside in, that is actually a fragment – IPv4 with MF set or a non-zero offset, IPv6 with a Fragment header – so a fragmented inner datagram in an IP-in-IP tunnel is reassembled on its own header, where it was reassembled whole under the tunnel’s header, or dropped when the tunnel set DF or carried no Fragment header of its own. With no fragmented layer they take the outermost, as before. Applied to the default engine’s PCAP and PCAP-NG adapters and to the scapy and dpkt ones, which picked the outermost too (#2307, #2338).

pcapkit.utilities

Added

Changed

  • pcapkit no longer configures logging at import. It installs a NullHandler and sets no level, so verbosity is inherited from the application instead of being seized by whichever library was imported second; the old stderr handler stays as the PCAPKIT_DEVMODE opt-in. Consequences: the previous behaviour is configure(logging.INFO, stream=sys.stderr); the registry and extractor info calls became debug, so those messages are invisible even at INFO; and the handler is no longer logger.handlers[0]. verbose= output stays on stdout and is not logging (#384).

  • each warning is reported once per channel, and pcapkit no longer inserts a simplefilter('ignore', ...) at the front of the process-global warnings.filters (#362–#364, #390). The application’s own filter therefore wins, which is the point and also the sharp edge: under -W error, or pytest’s filterwarnings = error, a pcapkit warning that used to be suppressed will raise. Suppress them deliberately with warnings.filterwarnings('ignore', category=BaseWarning). quiet=True now means no record at any level and no longer sets sys.tracebacklimit, and the pcapkit.utilities.warnings.DEVMODE re-export is gone; its home is pcapkit.utilities.logging.

  • pcapkit.utilities.compat exports pathlib, ModuleNotFoundError, collections.abc.Collection and functools.cached_property() as the standard library has them: their fallbacks for interpreters below 3.8 are removed with that floor (#2051, #2053).

Fixed

  • further gaps the #514 keyword audit turned up, none covered by a test: StreamEOFError’s docstring did not say that @prepare always raises it with quiet=True (the end-of-stream convention StructError follows via its own eof=True), so nothing pinned that silence against regression; and register_extractor_engine’s real keyword, name, was not itself under test, only its corrected docstring, so a rename could put the two out of step again (#577).

  • hold_warnings is in pcapkit.utilities.warnings.__all__ (#1602, #1603).

  • the default threading excepthook is the fallback before Python 3.10, which has no threading.__excepthook__ (#1691, #1701).

  • stacklevel() returns a level relative to its caller, as warnings.warn and logging count it, so a warning or a BaseError log record names the caller’s frame at any stack depth rather than a frame inside logging or pcapkit (#397).

  • re-executing pcapkit.utilities.logging under PCAPKIT_DEVMODE keeps one stderr handler, where each importlib.reload added another (#391).

  • a loud BaseError no longer sets the process-global sys.tracebacklimit, which truncated every later traceback, pcapkit’s or not; lazily installed sys.excepthook and threading.excepthook keep its output terse instead (#983).

  • an import-time warning names the importing module on GraalPy too, where the importer’s stack level counted the bootstrap frames GraalPy’s warnings.warn skips, attributing the warning to <sys>:0 (#1963, #1996).

pcapkit.vendor

Fixed

  • re.sub sites under pcapkit/vendor/ passed re.MULTILINE as the fourth positional argument, which is count, not flags: substitution capped at 8 matches, and a DeprecationWarning on Python 3.13+ (a future TypeError). Found by an AST sweep rather than grep, since most put the flag on a continuation line. The flag moves to flags= at every site, in default.py, hip/eddsa_curve.py, http/frame.py, http/method.py, http/status_code.py, ipv4/router_alert.py, ipv6/option.py, ipv6/router_alert.py, ipv6/tagger_id.py, reg/ethertype.py, tcp/flags.py, tcp/mp_tcp_option.py and tcp/option.py. Behaviour preserving: the shared pattern (r'\r*\n') has no MULTILINE-sensitive anchor, so no const/ regeneration was needed (#796).

  • main was red (#866) with RecursionError in tests/const/test_const_enum_lookup.py, tests/const/test_const_enum_no_mint.py and tests/protocols/misc/test_pcapng_unit.py. pcapkit/vendor/pcapng/record_type.py and secrets_type.py rendered a _missing_ body whose first line did not return: cls._unregistered_member(value, 'Unassigned') followed by return cls(value). That was harmless while the first line was extend_enum(...), which registers the member so cls(value) found it; #861 (above) replaced it with _unregistered_member, which deliberately does not register, so cls(value) missed and re-entered _missing_. #861 fixed the generated modules but not these crawlers, so a live regeneration reintroduced the defect. Both crawlers now collapse to the single returning line the other registries use, and their const files are regenerated. A new guard at the crawler-emission layer requires a hard-coded _missing_ body to end in a return and no emitted line to re-enter _missing_ for the same value – deliberately narrower than “every emitted line must return”, which Vendor.process()’s shape legitimately breaks. The guard sees only some crawlers; the rest are covered behaviourally. Closes #866 (#867).

  • python -m pcapkit.vendor <target> could not fail: run() swallowed every crawler exception as a filterable VendorRuntimeWarning and main() always returned 0, so a broken crawler regenerating nothing looked like success to a human and to the cron job. Per the owner’s ruling – discard only what a failing sub-vendor changed, and let a cron job warn about failed sub-vendors without failing as a whole – the pieces below land. Exit code (pcapkit/vendor/__main__.py): run() returns whether its target succeeded; main() attempts every target and returns 1 if any raised, writing the failing target’s qualified name and exception repr to stderr unconditionally via print() rather than the filterable warning. Per-target snapshot and restore: a new _snapshot_and_restore context manager backs up a target’s const file with tempfile.mkstemp before it runs (resolving the destination through Vendor._dest_path(), now a classmethod in pcapkit/vendor/default.py so it can be called before Vendor.__init__ fetches, renders or writes), restores the backup via os.replace on any exception before re-raising, and discards it on success. A target whose destination cannot be resolved (a crawler outside the real tree) runs unprotected rather than failing the snapshot step, since Vendor.__init__ hits the same resolution failure itself; a destination that does not exist yet is left alone. Workflow (.github/workflows/cron-vendor.yml): the “Update Vendor” step no longer aborts under bash -e when pcapkit-vendor exits non-zero. The exit code is captured around that one command, so targets that succeeded are still sorted by isort, diffed, committed and pushed, while a “Warning” block naming the failed target(s) is appended to $GITHUB_STEP_SUMMARY, a ::warning:: annotation is emitted per failure, and the step’s own exit code is always 0. A per-target git checkout -- <path> restore step was dropped: with snapshot/restore, a failed target’s const file is never touched. Closes #872 (#873).

  • _snapshot_and_restore (#873) carried an unpinned invariant and inaccurate prose and lint, none of it changing behaviour. 1. except BaseException: in _snapshot_and_restore is pinned: a test asserts that a KeyboardInterrupt still restores the file and propagates. 2. A false docstring clause is corrected: a crawler whose _dest_path is an instance-method-only override fails the class call _snapshot_and_restore makes but not the instance call inside __init__, so that target runs unprotected and succeeds silently. 3. The # pylint: disable=no-else-raise pragma is kept, because it is needed: under the Makefile’s flags, which CI runs, pylint reports R1720 on the else:. A --disable=all --enable=no-else-raise run reports nothing there, since enabling the one message does not re-enable the check on a try/except/else the way --enable=R does, so such a run cannot show the pragma unneeded. The else: is deliberate: it makes the backup cleanup unreachable from the except. Left out of scope: an untested os.close(fd) leak (harmless, since copy2 reopens the path) and the semantically identical unindented form of the same else:. Closes #875 (#876).

  • TLSKeyLabel tracked the Mozilla NSS key-log wiki, but draft-ietf-opsawg-pcapng-06 section 4.7 now points at [I-D.ietf-tls-keylogfile] (draft-05), published as RFC 9850, “The SSLKEYLOGFILE Format for TLS”, whose section 4.2 creates the IANA “TLS SSLKEYLOGFILE Labels” registry. Against it, TLSKeyLabel was missing ECH_SECRET and ECH_CONFIG (RFC 9850 sections 2.3 and 4.2), so TLSKeyLabel('ECH_SECRET') raised ValueError on a legitimate Decryption Secrets Block; both are added, ECH_SECRET keeping the file’s # nosec B105 bandit convention for *_SECRET members. RSA has no row in RFC 9850’s registry (it is an NSS-only label the NSS wiki records as removed in NSS 3.34) and is kept, since removing a public member is breaking, with a docstring comment marking it the historical exception. pcapkit/vendor/pcapng/secrets_type.py’s stale # NSS Key Log Format comment is repointed at RFC 9850, and TLSKeyLabel’s docstring cites it. A new test pins the member set against RFC 9850 plus the RSA exception. Closes #882 (#883).

  • both spellings of an IANA citation render as :rfc: roles in the generated pcapkit.const docstrings (#1664, #1673).

  • MAX_RETRY and CI_MODE stay in pcapkit.vendor.default, read from PCAPKIT_VENDOR_RETRY and PCAPKIT_CI_MODE; a bad value warns with ConfigWarning and falls back, where 1.4.x crashed on a non-integer and read 0 as 1 (#1745).

  • the IPX socket crawler no longer scrapes Wikipedia, whose page dropped the socket table, so a regeneration stops failing on it (#507, #511).

  • the crawlers send a descriptive User-Agent built from the package metadata, which Wikipedia requires, and the PCAP-NG crawlers read draft -03, the one revision that reproduces their constant files (#518, #524).

  • pcapkit.vendor and pcapkit.vendor.mh list MH_HandoverInitiateStatus in __all__, where each repeated MH_HandoverACKStatus in its place, so a bare pcapkit-vendor run reaches its crawler (#531).

  • with no terminal on stdin, a failed fetch re-raises rather than waiting for an operator to save the page, a wait that printed without end under a closed or piped stdin (#522, #542).

  • each crawler derives its const/ path from the pcapkit.vendor root, so a crawler at any depth, such as vendor/reg/apptype/tcp.py, writes to the right file (#741).

  • pcapkit.vendor.ipx names its sys.path temporary _path rather than path, which read as a public name of the package, and the public-API test covers pcapkit.vendor, which it used to skip (#1772, #1779).

  • the generated const comments render as written: a URL reference followed by another bracket no longer autolinks into it, a leading enumerator such as (S) is escaped, and a hyphenated word is no longer split across lines (#1846, #1847, #1871).

  • without the vendor extra, pcapkit-vendor and python -m pcapkit.vendor exit 1 with one line naming the missing packages and pypcapkit[vendor], where they died with a traceback, and --help and --version still work. Importing a crawler then raises ModuleNotFoundError (#1850, #1875).

  • the ESP cipher and integrity crawlers strip the brackets off IANA’s [UNSPECIFIED] “no document” cell before testing it against the unknown placeholder, so pcapkit.const.esp.integrity no longer reads #: AUTH_DES_MAC [UNSPECIFIED] (DEPRECATED ...) for AUTH_DES_MAC and AUTH_KPDK_MD5 (#2294, #2371).

  • some dict-based crawlers’ process(self, data) ignored the data argument and read the module-level DATA literal instead, in ipv4/{qs_function,tos_del,tos_ecn,tos_pre,tos_rel,tos_thr,ts_flag}.py, ipv6/{option_action,qs_function,smf_dpd_mode}.py, mh/{binding_error,lla_code}.py and pcapng/{filter_type,hash_algorithm,secrets_type,verdict_type}.py. Harmless today, since every real call site passes request()’s own DATA-returning value, so data is DATA always held in practice – but a caller passing different data, such as a test fixture or a future LINK-based fetch, got the module’s table back regardless, with no error. The dict-based crawlers are hand-written rather than generated, so the fix needs no const/ regeneration (#2429, #2430).

Project infrastructure

Added

  • an end-to-end test tier (#376), sample-capture generators so a fresh clone can rebuild every fixture (#340), a Dockerised engine benchmark covering every supported Python version (#410), and registry round-trip coverage that records the entries which cannot close the cycle rather than skipping them (#440, #504).

  • coverage for the untested half of the #431 accommodation: a TCP or IPv4 option whose declared length asks for more data than the capture holds, pinning that it still parses with the short read zero-padded rather than rejected. The test #431 left covers only an option area with no data behind it; a candidate fix for #554 turned the untested half into an unwrapped FieldValueError while the rest of the suite stayed green (#571, #572).

  • CITATION.cff, citation metadata in Citation File Format 1.2.0, which GitHub renders as the “Cite this repository” button and which citation managers and dependency inventories read directly. It is the machine-readable half of the attribution BSD-3-Clause already asks for. Validated with cffconvert --validate and against the published 1.2.0 schema; doi and orcid are omitted rather than invented, since neither exists for this project and both are checked formats, so a wrong value would still validate. The licence is unchanged (BSD-3-Clause, no NOTICE). Alongside it the copyright line moves from 2018-2023 to 2018-2026 (LICENSE was its only occurrence; docs/source/conf.py derives its own from the current year), and a stray s after the closing DAMAGE. of the licence text, present since the Mozilla-to-BSD relicence, is removed so the wording matches canonical BSD-3-Clause (#615).

  • examples/generators/endian.py, and the byte-order tests that read what it writes. There was no big-endian .pcap in the repository, which is why #605 survived its own code review: the fix to a misspelled key left the corrected path as untested as the broken one. The generator writes big_endian.pcap (magic a1 b2 c3 d4), big_endian_nanosecond.pcap (a1 b2 3c 4d, the first fixture to take that branch of the magic-number table) and little_endian.pcap (d4 c3 b2 a1), carrying the same records in each, so tests can assert that byte order makes no difference to what is read rather than only matching numbers written in a test. Frame 3 is captured short (1200 octets on the wire, 96-octet snaplen), so incl_len and orig_len differ and cannot both be satisfied by one byte-swapped value. test_frame_endian_runtime.py drives each file through extract() and derives expectations by walking its record chain with struct; a unit-tier case in test_header_frame_unit.py builds a big-endian capture in memory, so the fixture-free selection CI runs on every push catches it too. Without the fix, each fails (the fixture-backed ones by that ValueError, the in-memory one by AssertionError: 3106905 != 1500000000) while the little-endian twin passes with and without it, showing the records themselves are not the variable (#605).

  • local type stubs under typings/ for dpkt, pyshark, pcap and pypcapfile, declaring what pcapkit uses of each, so mypy checks the engines against them. mypy.ini reads them through mypy_path, and neither the sdist nor the wheel ships them (#1878, #1882).

  • a glossary page for the terms with several meanings – registry, schema, data model, info and default engine – each sense linked to the page that explains it (#1949, #1955).

  • the engine benchmark on GitHub Actions: benchmark.yml, weekly and on dispatch, measures every engine on the runner’s own interpreters through examples/benchmark/native.sh and posts the report to the CI reports discussions (#2125, #2128). A pcap engine whose module cannot load is an explained row rather than a fatal error (#2129, #2131), and the report is reST with footnotes, so it pastes unedited onto docs/source/engines.rst (#2142), the page the engine comparison and prerequisites move to from index.rst, carrying the first run’s figures (f9de043d6, d7aa41535). The tables had first been regenerated with Python 3.15 measured (#2109).

  • a win-64 Conda package (#2060). Conda Update builds and smoke-tests it (#2067), reading the environment without os.environb (#2120, #2122) and conda-forge’s CFEP-27 site-packages layout (#2126, #2127); then it publishes from Conda Update and Create Release, and a release is complete only once the platforms its tagged commit lists, win-64 among them, are on Anaconda (#2138). A manual Conda Update run builds only (c5081fbfe) unless dispatched on main with publish, which forces the bump that ships a release’s missing Anaconda files (#2150), and Create Release’s warning about an unfinished release names that remedy when Anaconda files are what is missing (#2140, #2151).

Changed

  • a breaking change to requires-python: it is >=3.8, so pip refuses an older interpreter at install, as “requires a different Python”. The sources use 3.8 syntax, and the bpc-* backport tools that converted it for 3.6 and 3.7 are no longer maintained, so the conversion goes: the bpc-* requirements, refactor() and the setup.py build commands that ran it. The 3.7 KTLO (keep the lights on) legs go with it, and coverage-comment.yml lists no install failure as by design any more, which closes #2040 (#2051, #2053).

  • setup.py is gone, and pyproject.toml is the only build configuration. With the 3.8 floor it was a Python 2 guard and a bare setup(): the wheel’s files are identical without it, pip install -e . goes through PEP 660, pip still refuses 3.7 as “requires a different Python”, and on 2.7 it fails on the setuptools build requirement before it would reach setup.py. examples/benchmark/Dockerfile stops copying it, and a test fails should setup.py return or a COPY there name a file the repository does not track. Dependabot now fetches pyproject.toml in setup.py’s place and passes it through unstubbed; the static readme stays, as it does not depend on what a stub would supply (#2061, #2066).

  • renames with no compatibility alias left behind: examples/sample and examples/samples – one letter apart, holding different things – are now examples/captures and examples/generators.

  • the README is a landing page now, and Markdown rather than reStructuredText. README.rst became a much shorter README.md, keeping what a reader arriving from PyPI or a search result needs (what the library is, why it exists rather than Scapy or DPKT, how to install it, a worked example, where the documentation lives). Module Structure, Engine Comparison, Engine support by Python version, Test Environment, Test Results and Installation Notes were already duplicated in fuller form in docs/source/index.rst, so they are linked rather than restated; the blocks that existed nowhere else moved: Testing is now docs/source/contributing/testing.rst, and the pipenv and make setup block joined the Installation section of docs/source/index.rst. Requested by the project owner, and a deliberate exception to the reStructuredText convention, for the README only, since its renderers are GitHub and PyPI rather than Sphinx. setup.py read README.md and declared text/markdown (pyproject.toml declares the readme statically since #1994), and MANIFEST.in gains include README.md, which is belt-and-braces rather than load-bearing: setuptools’ sdist ships a README.md regardless. Of the include lines only CHANGELOG.md is load-bearing, measured in the #631 entry below. examples/benchmark/Dockerfile copies README.md, while the README.md that examples/benchmark/benchmark.py names is the benchmark suite’s own (#619).

  • CODE_OF_CONDUCT.md moves from Contributor Covenant 1.4 to 3.0, at the owner’s request. The text is the canonical 3.0 Markdown fetched from https://www.contributor-covenant.org/version/3/0/code_of_conduct/code_of_conduct.md rather than transcribed. Some things needed deciding. 3.0’s [NOTE placeholders are filled or removed: the reporting channel names jarryshaw@icloud.com (the contact 1.4 carried and SECURITY.md uses as email fallback) plus GitHub’s report-abuse form for a report about the maintainer, which a single-maintainer project cannot otherwise cover; the enforcement placeholder is an instruction to the adopter and is removed. 3.0’s plural “Community Moderators” become the singular maintainer, this repository having no moderators. The enforcement ladder (Warning, Temporarily Limited Activities, Temporary Suspension, Permanent Ban) is a suggestion and is kept, each rung mapping to a lever one person holds on GitHub. 3.0 is CC BY-SA 4.0 where 1.4 carried no licence notice, so the attribution names version 3.0, links the permanent version/3/0/ URL, carries the CC BY-SA 4.0 notice, indicates changes were made as BY requires, and says the share-alike term covers this document only (the code remains BSD-3-Clause, LICENSE untouched). The ladder renders as nested list items, checked against GitHub’s Markdown API; #613 had to repair the 1.4 file when a stray list marker collapsed it into one nested item (#624).

  • reconciled which private (_xxx) attributes and methods the Sphinx build documents, replacing an ad hoc mix with one rule: document the contract, hide the recipe. A member every subclass must implement, or whose shape a caller depends on, stays documented despite its underscore; a private helper that only keeps one method short does not. Module-level directives for pure implementation detail (esp._resolve, esp._CRYPTO, ngap._convert, ngap._revert, ngap._PYCRATE, ngap._PDU_LOCK, pypcapfile._NamedStream) were dropped, while autoattribute directives were added for class-private state that is contract, among them Extractor._flag_f, the PyPCAP and PCAP_CT engines’ _backend (the only third-party engines with one), TraceFlow’s internal fields, FieldBase/Field internals, the built-in PCAP engine’s _gbhdr, _vinfo, _nnsec and _dlink (shared with PyPCAP, PCAP_CT and PyPCAPFile) and PCAPNG’s _ctx/_ctx_list. The runtime definitions of _missing_ (under pcapkit.const, and inline in pcapkit.protocols) gained one write-up in place of a directive per class, a new “Unrecognised Values” section in docs/source/pcapkit/const/index.rst cross-referenced from registry.rst (conf.py already excludes _missing_ from every autoclass). CONTRIBUTING.md gained the rule as a named section. The new Sphinx warning, a bare Type newly rendered by TraceFlow._foutio’s directive, is #709. No line under pcapkit/ changed (#684).

  • examples/captures/out.json, out.plist, out.txt and pcapng.txt are no longer tracked in git; they are build output, not fixtures, and a tracked rendering with no reader goes stale silently. pcapng.txt is exactly that: it still recorded packet -> NIL for the Enhanced Packet Blocks of dhcp.pcapng long after #683 gave those blocks their captured octets back. Rather than regenerate it again and leave the drift free to recur, these files are removed from the index and folded into examples/captures/’s blanket .gitignore rule; the genuine inputs in.pcap and dhcp.pcapng stay tracked. examples/legacy_smoke/Makefile and its README.md now describe regenerating these reports via make fixtures, and tests/project/test_capture_tracking.py pins the invariant (#685).

  • tests/project/test_isort_clean.py gated on isort with a try/except ImportError inside setUpClass, which tests/_dependency_gates.py cannot see (_gates_of() walks a decorator list, never a function body), and isort was in no pyproject.toml extra, so the file reported OK (skipped=1) on every CI leg and the dependency-gate guard could not tell. It now gates on a module-level HAS_ISORT and a class-level @unittest.skipUnless so gated_scopes() counts it, MODULE_PROVIDERS maps isort, and isort joins the test extra rather than being excluded (lint.yml installs none, so unlike mypy there is no existing check to defer to). No workflow changed, every pytest job already building on .[test]; gated_scopes() gains this scope and dependency_gate_gaps() is unchanged. Another instance of the hazard #745 exists to stop (#766).

  • tests/vendor/test_vendor_reg_apptype_generator_unit.py gated on mypy with no MODULE_PROVIDERS entry, so _top_level_providers() died on a bare KeyError: 'mypy' once the gate became visible – a further instance of the hazard #745 exists to stop, like #766 above. MODULE_PROVIDERS['mypy'] = ('mypy',) fixes the lookup, and the bare MODULE_PROVIDERS[...] subscripts route through that function, which raises an AssertionError naming the module, key and table on any future gap. mypy is deliberately not added to a pyproject.toml extra: no extra carries it, it is a Pipfile dev-dependency that lint.yml installs and already runs whole-package, and lint.yml runs no pytest job for pytest_jobs() to see, so the fix is a DEPENDENCY_GATE_EXCLUSIONS['HAS_MYPY'] entry on test, engine-tests and gate. dependency_gate_gaps() gains the new (flag, job) pairs, each expected (#779).

  • engine='pyshark' had no CI job driving it through a real tshark-parsed capture. pypcap-parity installs pyshark but its apt-get install named no tshark package, so PyShark.unsupported_reason() declined the engine, the extractor fell back to the built-in parser, and the only real-extraction assertion, assertGreater(extractor.length, 0), passed against the fallback’s output: a vacuous pass invisible in every skip count. pypcap-parity now installs tshark with the debconf pre-seed engine-tests carries, and the test proves which engine ran (no EngineWarning, extractor._exnam == 'pyshark', a PyShark instance), as the PyPCAP parity test does. Only tests/integration/test_engine_runtime.py and test_engine_parity.py change; the length assertion is kept (#846).

  • engine-tests ran a Python-version leg per interpreter regardless of which engines that interpreter could install, so pyproject.toml’s PyPCAPFile = ["pypcapfile; python_version < '3.12'"] marker resolved to nothing on 3.12–3.14, yet the Engines Python 3.12/3.13/3.14 legs passed and, once required, gated merges while exercising an engine that was never installed. Rebuilt as a Python x engine matrix in which every cell has an expected outcome: supported cells install through the real extra and must pass; unsupported cells (PyPCAPFile on 3.12–3.15, PyShark on 3.14/3.15) install by raw pip install to bypass the marker and assert the engine’s own PYTHON_CEILING decline; and not-installable cells (PyPCAP on 3.12–3.15) log a genuine C-extension build failure and skip the test step. The install step never passes silently in either direction. Python 3.15 is continue-on-error throughout. Job names change shape (Engines Python X becomes Engines Python X (engine)), so the branch-protection ruleset’s required-check list was updated in the same window, lest a required check that stopped existing block every merge (#849).

  • Ruleset 23497679’s required_status_checks names exact check contexts, and GitHub rulesets match literally with no wildcard, so the list needs hand-editing on every matrix change. The old Engines Python <version> names in it are dead since #849; the Compat Python 3.10-3.14 names stay live, emitted by python-compatibility.yml, and are untouched. Adds a job, required-checks (context Required checks passed), depending on [test, integration, engine-tests, pypcap-parity] with if: always() && inputs.gate-only != true and an explicit per-dependency check that fails loudly, naming the job, on anything but success: a bare needs: list would leave it skipped rather than failed when a dependency failed, and GitHub’s docs list a skipped required check as passing. That context is meant to replace the ruleset’s test, integration, Engines and pypcap-parity entries once the ruleset is updated separately, as a repository setting outside the change; the Compat names are excluded from the replacement. Only .github/workflows/unit-tests.yml changes; no library or test code (#856).

  • engine-tests runs one job per engine, Engines (<engine>), each looping over its interpreters in fresh virtual environments, in place of #849’s job per Python and engine. required-checks also needs changes and project-tests: project-tests must pass on every diff, and a skipped leg passes only where changes classifies the diff as docs-only (#1052, #1055), which may skip test, integration, engine-tests and pypcap-parity, or as test-only, which may skip engine-tests and pypcap-parity (#2243, #2244).

  • the prose the post-#719 defect wave added or touched, checked against the code; no code changes. MesgIDOption.seconds in the Mobility Header schema said seconds since 1970 where it holds an RFC 1305 NTP count from 1900; HTTP._guess_version() comments predated the HTTP/2 gate; and the _parse_next_layer note miscounted the captured octets. Extractor._open_output() now says only the PCAP engine passes header fields, Extractor.run() and the PCAP-NG engine’s run list the FormatError for PCAP output from a PCAP-NG input, and the extraction and engine pages document the PCAP-output path (#1179, #1180, #1186, #1103).

  • a breaking change to the all extra: it carries the core addons only – cli, crypto and NGAP. The third-party engines 1.4.x bundled in it (dpkt, scapy and pyshark) and the vendor crawler’s requests and beautifulsoup4 are no longer in it; the engines keep their own DPKT, Scapy and PyShark extras and the crawler its vendor extra, and pypcapfile has a new PyPCAPFile extra of its own. A new dev extra carries what CI needs (#910, #914).

  • the Scapy and dev extras require scapy>=2.6.0 (#1572, #1578), and the docs extra Sphinx>=7.3, the first release with versionremoved (#1743), and an exact sphinx-autodoc-typehints pin (#1856, #1877), 3.17.2 since the weekly dependency update (#1944). The test extra gains psutil, markdown-it-py and lxml, which the scripts under util/ use (#1894, #1896). The crypto and all extras cap cryptography below 46 on PyPy older than 3.11, for which no later release has a Linux wheel (#2040, #2046).

  • the READMEs of examples/benchmark and examples/legacy_smoke are Markdown, README.md rather than README.rst, and MANIFEST.in ships them through recursive-include examples *.md. Stale build leftovers go: setup.py’s bdist_wheel override, since build_py and install already convert the tree the wheel archives, and MANIFEST.in’s prune test, which named no directory; make profile runs in examples/legacy_smoke (#1773, #1780). MANIFEST.in also drops the lines that matched nothing – global-include *.rst, include pcapkit/_extern and exclude pcapkit/protocols/*/NotImplemented – and the source distribution and wheel are unchanged (#1794, #1813).

  • the contributor pages state no counts that a test or the Python version updater pinned, and each pin checks the fact it stood for instead (#1811, #1817).

  • the workflows’ supply chain (#1968): every third-party action is pinned to a commit SHA that Dependabot updates by pull request (#1981); no third-party step runs after the deploy key is loaded, and every workflow declares its top-level permissions, the GitHub Pages build read-only beside a separate publishing job (#1970); and the cron jobs name the environments that hold their secrets (#1993). pyproject.toml declares the readme statically, so Dependabot can build the editable install (#1994).

  • CodeQL skips tests/, where the recurring clear-text-logging alerts in pcapkit.foundation.extraction and pcapkit.corekit.io traced to a test fixture’s secrets names; pcapkit/ is still scanned by every query (#2130, #2139). The advisory linters drop their long-standing false positives with no behaviour change: vermin honours the # novermin markers and confirms the 3.8 floor, bandit reports nothing, and the unused type: ignore comments go, from the vendor templates too (#2145, #2147).

Fixed

  • places where a documentation page contradicted the code (#413), ambiguous cross-references and autodoc signature failures (#416), and Extractor’s documented exception plus phantom or stale Args: labels (#501).

  • both halves of what the Changelog drift gate told an author, in util/changelog_md.py. ResidualMarkupError named the entry file but numbered its lines against the converted body, which rule 6 joins onto one line per block, so a reported number could not reach most of the file, and roles on separate source lines were all reported as one line. The conversion now carries a source map, so every complaint cites a line of the file the message names, one per construct, in the entry’s order (#588). Rule 2 accepted only the bare-number spelling of Sphinx’s :rfc: role, so RFC 6554 Section 3 fell past it and was reported as a role the rules do not cover; both spellings now convert, the anchor carried into the link target and the link text taken from Sphinx’s own (#592). Regenerating CHANGELOG.md is byte-identical over every committed entry: the fix changes what the gate says, not what it emits.

  • examples/generators/options.py’s TCP_BASE spelled header fields with names TCP.make() does not declare, so every option fixture in examples/captures/options-tcp.pcap was built from values the generator had not asked for. make ends in **kwargs and reads nothing out of it, so an undeclared keyword is accepted and discarded with no warning. 'seq': 1 was seq_no, so every captured frame carried sequence number 0; 'urgent_pointer' was urgent (requested value and default both 0); and 'ack_flag': False was ack while 'ack': 0 bound to ack itself, the acknowledgement flag rather than the number (ack_no), so that pair was wrong both ways. Each is renamed to the parameter make declares, keeping the intended values. TCP(**TCP_BASE).info.seq was 0 against a declared 1 and is now 1, and options-tcp.pcap changes only in the low octet of each frame’s sequence number, with no case status or EXPECTED_FAILURES entry moving. The module’s other base mappings were audited: the rest pass nothing their make does not declare, and HIP’s extension is declared by HIP.read(), this library’s documented way of forwarding a read-path keyword, not another instance (#602).

  • util/bump_version.py left CITATION.cff naming the previous release. Nothing else maintains that file, so every bump since #615 would have stranded the version and date-released that GitHub’s “Cite this repository” button, citation managers, Zenodo and dependency inventories read. Both fields now move with __version__, together, since the file’s header says both describe the newest published release and moving only one would assert that 1.5.0b5 was released on the day 1.5.0b4 was. The date is UTC, because seven of the thirty most recent bumps were made late evening US-Eastern, where a local date is a day behind the publish; the bump triggers create-release.yml, the median gap to the PyPI upload is three minutes, and the UTC dates agree 30 times of 30. The rewrite is line-oriented, so the comment header, key ordering and each field’s quoting survive (cff-version and a references entry’s own version are anchored out at column zero), and the result is checked with cffconvert --validate. An absent file is reported on stderr and skipped rather than failing the vendor cron before its git commit, which would discard a whole registry crawl for a documentation file; a file with no version field raises before anything is written, since reporting success while rewriting nothing is the staleness being fixed. The script gains a main() guard (it ran the whole bump at import, hence no testable surface), and the import pcapkit fallback in its version reader, which returned "1.5.0b4'\n" (closing quote and newline, which packaging rejects), is fixed; it never worked and went unnoticed because the only caller installs the package first. A new gate asserts the committed file still names the packaged version, covering hand-made version changes that never run this script (#625).

  • LICENSE’s copyright notice began the term at 2018, a year after the work it covers. The repository’s first commit is c57f7d0b7, dated 2017-11-07, so the notice understated the term and contradicted docs/source/conf.py, whose Sphinx footer is f'2017-{datetime.date.today().year}, Jarry Shaw'. The wrong start year survived every maintenance pass because each looked only at the other end of the range: bc836cfa2 (2020-05-31) introduced 2018-2020 when the BSD-3-Clause text replaced MPL 2.0, and hand bumps to 2018-2022, 2018-2023 and 2018-2026 (#615) each copied 2018 forward. 2017 is restored rather than newly asserted: the initial MIT LICENSE read Copyright (c) 2017 Jarry Shaw, and the GPL v3 era carried Copyright (C) 2017 Jarry Shaw in its appendix; it was lost on 2018-12-08, when 4f3e00d5f relicensed to Apache 2.0 under Copyright 2018 Jarry Shaw and 1c69341dc replaced that with MPL 2.0 text with no author notice. The end year is dropped rather than automated: the notice ships inside the sdist and wheel, so its text is fixed at build time in every copy already downloaded and cannot be computed like the docs footer; copyright subsists from creation whether or not a notice names the current year; and BSD-3-Clause’s canonical form is a singular Copyright (c) <year> <owner>. A range was six years of hand maintenance buying nothing, and automation was considered and rejected, since automating a value that need not be current is worse than not carrying it. conf.py is untouched (a self-maintaining footer showing a range is conventional and correct). The BSD 3-Clause License title line and the comma in the notice follow choosealicense.com’s template, whose first line is that title and whose notice reads Copyright (c) [year], [fullname]. The licence body is unaltered: the change is confined to the copyright line, and the DAMAGE. that #615 repaired from DAMAGE.s is still clean (#630).

  • CITATION.cff shipped in no source distribution. MANIFEST.in had an include each for README.md, LICENSE and CHANGELOG.md but none for the citation file, and its global-include patterns (*.rst, *.py) could not match a .cff. Removing those include lines and rebuilding shows README.md and LICENSE shipping anyway (setuptools adds the latter from license_files, recorded as License-File: LICENSE in PKG-INFO) while CHANGELOG.md vanishes, so only CHANGELOG.md is load-bearing, and a citation file, which no packaging default covers, is in the same position. The gap is invisible from the web UI, because GitHub renders “Cite this repository” from the repository; citation managers, Zenodo and dependency inventories read the published artifact, the surface that was missing it. #615 flagged the omission and #619 did not address it. It matters because #625 taught util/bump_version.py to keep the file’s version and date-released in step, and a release exercising that path would publish an sdist omitting the artefact under test. It also lets RepositoryCitationTests in tests/project/test_bump_version.py (the gate #625 added for hand-authored bumps) run against an unpacked sdist, where it skipped itself with “CITATION.cff is not shipped in the source distribution”. python -m build --sdist now differs from before only by the archive entry pypcapkit-1.5.0b4/CITATION.cff, the shipped copy is byte-identical, and twine check --strict passes on both (#631).

  • the release workflow published to PyPI and to Anaconda with nothing a human had to approve. create-release.yml’s pypi job had environment: release commented out while the id-token: write from the same upstream snippet was re-added live below it, so the block read as disabled as a unit when only its gate still was; the conda job had no environment: at all. Neither needed a tag: the workflow also fires on completion of Vendor Update, so a scheduled registry crawl that bumped the version was enough to publish to both public indexes, a median three minutes from bump to upload. Every job that reaches outside the run is now gated: github on github-release (the GitHub Release and the v* tag), tag on conda-tag (the commit pushed to main), pypi on pypi, conda on anaconda. One environment per credential rather than a shared release, because approval is granted to an environment and not a job, so one release approved once would release both indexes together. PyPI has to be answerable alone: an accepted version number cannot be reused, and skip-existing: true makes the re-upload succeed having published nothing, so an unintended publish permanently consumes the number the intended release wanted. The workflow half is only half the fix: an environment: naming one that does not exist is created implicitly with no protection rules and the job proceeds unapproved, so this is inert until each of these environments exists in repository settings with a required reviewer, and each one’s “Deployment branches and tags” must stay unrestricted or the tags: v* trigger fails outright instead of pausing. github-pages is the standing example, carrying no protection rule since 2021. The new test asserts the general rule, that no job running a publishing action or a git push may omit an environment:, so a publishing job added later without a gate fails rather than ships (#641).

  • test comments in tests/_tiers.py, tests/test_tier_guard.py and tests/integration/_helpers.py stated an exact count of committed captures under examples/captures/, a number that drifts whenever the tracked set changes, and _helpers.py’s undercounted on the day it was written. Each now describes the invariant (_tiers.py: “the moment somebody commits another capture… or stops committing one”; _helpers.py: “…are fixtures – some of them committed –…”; test_tier_guard.py drops its ordinal). Prose-only, verified with coverage.parser.PythonParser that no executable statement moved (#700).

  • test_every_tracked_name_exists_and_matches_git in tests/test_tier_guard.py checked neither the git index nor the filesystem despite its name: it asserted only that the tracked-capture list was non-empty and no name contained a /, so a hardcoded list passed whether or not it matched reality. Sibling test_capture_suggestions_are_captures had the same gap. Fixed in #703; #703’s description names both #700 and #708. The first test now re-derives the expected set from git ls-files -z under examples/captures/, asserts equality against it, and checks each name with Path.is_file(); its sibling re-implements committed_capture_names()’s filter inline, which catches a wrong filter but still trusts committed_captures() for the tracked set. A hardcoded literal that happens to match the tracked names still passes; the test catches a set that is wrong when it runs (#708).

  • pcapkit.all.__all__ listed OSPF, RARP and DRARP on its Link Layer line after #719 moved them (#1124), and omitted names its packages export, among them ESP, HTTPv1, HTTPv2, PCAPNG and EnumRegistry, so from pcapkit.all import * did not bind them. pcapkit.utilities stays unlisted deliberately, so generic helpers such as warn do not arrive through import * (#1136).

  • examples/generators/options.py wrote every HTTP/2 sample frame on stream 0, which RFC 9113 forbids for DATA, HEADERS and the other stream-level types, so those frames in options-transport.pcap decoded as TCP:Raw. _httpv2_build now puts SETTINGS, PING and GOAWAY on stream 0 and every other type on stream 1, with PUSH_PROMISE promising stream 2, and every HTTP/2 sample frame decodes as TCP:HTTP/2; no other generated capture changes (#1156).

  • make_samples.py builds fixtures from the tree it runs in, not the shared checkout (#1343, #1388), and retries the Wireshark sample downloads on 429, 5xx and timeouts (#1658, #1659). examples/generators/options.py’s deadline discards a SIGALRM still pending as it puts the old handler back, which on GraalPy could kill the process (#2090, #2091).

  • the Conda Update workflow tags and uploads each bump exactly once (#1632, #1641), stays green when a merge races its push (#1649, #1669), and keeps the release’s name and notes when it uploads (#1657, #1678). Vendor Update commits the new modules a crawl adds (#1685, #1688), and runs tests/const and tests/vendor on the regenerated tree first, keeping only the modules they pass with (#1853, #1879).

  • package __all__ lists match what each package holds: several disagreed with it, and from pcapkit.protocols.data import * raised AttributeError on the unbound HOPOPT_QuickStartOption (#515, #527); the public attributes several packages left out are listed (#533, #544).

  • the documentation build’s “more than one target found” warnings, fixed where they arise: an implicit reference to dict, type or set no longer matches a registry member such as TCP.dict, an enum member’s docstring holding a URL no longer gains a spurious type field, and the hand-written TypeVar directives take the bare name under their .. module:: (#1775, #1790).

  • the Mermaid diagrams render at their natural size, shrunk only to the page width, where each was fitted into a fixed box, and their click targets are relative to the page, so they resolve under the site’s /PyPCAPKit/ prefix (#1103, #1819).

  • the API reference renders what the code declares. Extractor._vfunc keeps its annotation (#1839, #1867), and the HTTP/2 frame schemas’ and the engine classes’ annotations resolve (#1857, #1870). Under the pinned sphinx-autodoc-typehints (#1856, #1877), conf.py renders an aenum base as such, keeps a default as written (byteorder=sys.byteorder rather than the build host’s value), gives a class the parameters of the __init__ its __new__ hides and an enumeration (*values), keeps table ids unique, and stops the source index linking a missing page (#1858 to #1863, #1881). The generated const tables carry no version directives; each note is on the module’s hand-written page instead (#1884, #1895).

  • cross-references resolve or render as literals. A role into a package with no inventory is a literal or an rst_epilog link (#1919, #1928, #1936, #1939, #1920, #1945); a pcap module mention links the note on PyPCAP and pcap-ct (#1948, #1952); library-internal names link, and the classes and special methods they name are documented (#1940, #1946, #1947, #1953), the const registries’ get overrides among them (#1921, #1935); and a Warns: section’s warning classes link as Raises: entries do (#2094, #2096). conf.py resolves a decorated function’s overloads and qualifies a third-party class whose bare name collides (#1917, #1918, #1929). The versionchanged directives on Info iteration and Raw name 1.3.1.post8, the release that shipped their change (#1933, #1934).

  • the Conda release legs: Create Release’s Anaconda completeness check counts osx-arm64 (#1922, #1932); the macos-26-intel legs install Miniforge, upload with the anaconda CLI where the upload action’s download returned 404, and build again past upstream conda and conda-build breakage (#2044, #2045, #2049, #2123, #2124); and util/conda_vendor.py’s pin refresh warns when it cannot move a platform pin, or when a pinned release has lost a wheel (#2025, #2026, #2028, #2035).

  • pcapkit-cli’s help strings say what each option does: -o describes the output rather than the input, an extension is appended only under --auto-extension, and each format flag names the flags that take precedence over it (#1796, #1810).

  • the Conda package: its vendored dependencies are the package’s dependencies and its all extra, with their requirements, shipped with their data files rather than as .py files only (#1814, #1821); the osx-64 package is built on an Intel runner, where converting the arm64 build shipped arm64 binaries (#1822, #1825); and on Intel macOS it pins cryptography 48.0.1, which has a wheel for that platform where the newer pin has none (#1827, #1828). util/conda_vendor.py --check reports a package pinned differently under one marker (#1852, #1865); the platform pins move to conda/platform-pins.toml, each with a review-by date, and Conda Update moves them up (#1851, #1885); and the release’s conda leg installs none of pcapkit’s extras and smoke-tests the package before uploading it (#1854, #1855, #1868).

  • a wheel built with the oldest setuptools [build-system] requires allowed, without build isolation, left out pcapkit/py.typed, so type checkers read the installed package as untyped. Nothing declares the file, and setuptools ships it unasked only from 69.0.0, so that is the floor now, in pyproject.toml and the Conda recipe alike (#2062, #2064).

  • examples/legacy_smoke’s test_perf benchmarks every engine its support table lists, writing each result to ../captures/engines/<engine>.txt or skipping the engine with its reason, where it looked each benchmark up in a table that did not cover pypcap, pcap-ct or pypcapfile, so the first of those to pass preflight raised KeyError and ended the run (#2305, #2375).