Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The largest release since 1.0, and the first recorded here as it happened rather
- **Fixed** -- 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 already popped `pcapkit.corekit.fields.misc` from `sys.modules` -- which the `#439` ABC-cache regression tests do in every case's `setUp`/`tearDown` -- raised `FieldValueError` instead, from a different layer entirely, before the documented check was even reached (#525). The cause was `ListField.unpack` resolving `SchemaField` through a function-local import re-run on every call; a module popped and reimported mid-process comes back as a second, distinct class, so `isinstance` against it silently misclassified the field and billed each item by its declared length instead of by what it actually consumed. **Any caller relying on the previously-observed** `FieldValueError` **for this case now gets** `ProtocolError` **instead, deterministically**, matching the method's own docstring. Fixed by importing at module level instead.
- **Fixed** -- two dropped-keyword/wrong-cast defects flagged in review during this release and never filed until now: HIP's `_make_param_encrypted` passed `cipher=` to a schema with no such field, so the value was silently dropped and 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` and not only after a parse, assumed `self.addresses` was still the concatenated `bytes` a parse leaves it as, and raised slicing the `list[bytes]` a `make`-built multi-address header actually holds there (#556).
- **Fixed** -- two more defects #541 exposed rather than caused, both since it let construction reach code that had never run before. `MPTCP.subtype` was still `typing.TYPE_CHECKING`-only, an annotation rather than a field, so `TCP(options=[(Enum_Option.Multipath_TCP, ...)])` raised `AttributeError: ... has no attribute 'subtype'` for every subtype but `MP_JOIN`: the convenience constructor builds a schema in memory and reads it straight back through `_read_mptcp_*` with no byte round trip, so `_MPTCP.post_process` -- the only code that ever set `subtype` -- never ran. Fixed on the construction path (`TCP._make_mode_mp`) rather than by adding a third real field the way `kind`/`length` got in #541: unlike those two, `subtype` is already packed as 4 bits of each subtype's own `test` bitfield, and a second, independent field for the same bits would either double-encode them or need a "derive, don't pack" field kind this library does not have (#566). Separately, `_make_mptcp_capable` wrote `length=20 if rkey is None else 32` where [RFC 8684](https://datatracker.ietf.org/doc/html/rfc8684) section 3.1 gives 12 and 20, and `MPTCPCapable.rkey`'s own condition (`pkt['length'] != 32`) dropped the receiver's key for exactly the length the maker used to mean "key present" -- so a spec-correct, key-absent MP_CAPABLE could not be built at all, and a key-present one silently lost its key on the wire. Both, and the matching 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 actually present, depending on which the caller meant (#567).
- **Fixed** -- IPv6-Route's RPL routing data was five octets wide at the front where [RFC 6554](https://datatracker.ietf.org/doc/html/rfc6554) section 3 gives four, and three further defects sat stacked behind it. `CmprI`, `CmprE` and `Pad` are each a 4-bit field, sharing one 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 the `Hdr Ext Len` of 5 derived from that inflated data area declared 48. Correcting the width is a **wire-format change**, and it reshapes the schema: `RPL(cmpr_i=..., cmpr_e=...)` is now `RPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...})`, beside the `pad={'pad_len': ...}` that was already there. 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 only carries when `CmprI` and `CmprE` are both 0 -- the unit confusion #487 fixed for Source Route and Type 2, flagged and deliberately left by #489 for want of a working RPL round trip to validate a replacement against. It is replaced by section 4.2's own address-count arithmetic, `n = (((Hdr Ext Len * 8) - Pad - (16 - CmprE)) / (16 - CmprI)) + 1`, which the reader now requires to close -- non-negative and whole. `RPL.post_process` subtracted `pad_len` a second time from a buffer whose own 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 once the guard stopped rejecting every constructed header, `IPv6_Route(type=..., data={'ip': [...]})` raised a bare `AttributeError: 'RPL' object has no attribute 'ip'` from the reader. And `_make_data_type_rpl` computed `Pad` as `8 - length % 8` without the outer `% 8`, so an already-aligned address vector was handed a full 8 octets of padding where section 3 requires that when `CmprI` and `CmprE` are both 0, `Pad` MUST carry a value of 0. The narrower `cmpr_i` also removes a latent divide-by-zero: a whole octet could hold 16, making `16 - CmprI` zero, where a 4-bit field tops out at 15. `ipv6-route-type/RPL_Source_Route_Header` round-trips now and its `EXPECTED_FAILURES` entry is deleted; as with the guard it replaces, none of this has been checked against a real RPL capture (#564).
- **Changed** -- `ModuleDescriptor.klass` reads an already-imported module out of `sys.modules` rather than re-entering `importlib.import_module`, which matters because next layer dispatch resolves a descriptor there on a per-frame path. A registry *hit* holding a `ModuleDescriptor` is resolved once and written back, but a *miss* deliberately is not -- recording a miss in a class-level `collections.defaultdict` is the defect #425/#428 fixed at this layer and #560 fixed at the schema layer -- so every unrecognised frame resolved the same fallback descriptor again: 48 of the 52 `ModuleDescriptor.klass` resolutions an extraction of `many_interfaces.pcapng` performs, and 4 of the 7 on `ipv4.pcap`. `import_module` keeps real per-call work for a module `sys.modules` already holds, so that resolution now costs ~117 ns rather than ~436 ns and the whole miss path ~526 ns rather than ~883 ns, on CPython 3.14.7. **The scale is worth stating plainly: this is not measurable in** `extract()` **wall clock.** 48 avoided calls is ~17 us against a ~37 ms extraction, two orders of magnitude inside this host's run-to-run variance, and the "~40% of cumulative time" reading that prompted the work was an artifact of `_import_next_layer` being a recursive-descent dispatcher -- its *self* time is 0.58%, while `aenum.extend_enum` is 16.7%. What the change is taken for is its shape rather than its speed: nothing memoises the resolved class, anywhere, so `sys.modules` stays the only module cache in play and its invalidation is the interpreter's. A memo of the class would serve the pre-reload class after an `importlib.reload` forever, and an instance of it fails `isinstance` against the live one. Proposed by `@Ts-Boom` in #563, whose profiling found the miss path; the implementation differs because that one added a second, never-invalidated cache of resolved classes (#574).

Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` and `1.5.0b2` (both 2026-09-18) and `1.5.0b3` (2026-09-19), 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` is what reshipped it -- the release workflow is version-driven, so 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).
Expand Down
33 changes: 33 additions & 0 deletions docs/source/changelog/1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,39 @@ pull requests between #326 and #509.
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 actually present,
depending on which the caller meant (#567).
* **Fixed** -- IPv6-Route's RPL routing data was five octets wide at the front
where :rfc:`6554` section 3 gives four, and three further defects sat
stacked behind it. ``CmprI``, ``CmprE`` and ``Pad`` are each a 4-bit field,
sharing one 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 the ``Hdr Ext Len`` of 5 derived from that
inflated data area declared 48. Correcting the width is a **wire-format
change**, and it reshapes the schema: ``RPL(cmpr_i=..., cmpr_e=...)`` is now
``RPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...})``, beside the
``pad={'pad_len': ...}`` that was already there. 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 only carries when ``CmprI`` and
``CmprE`` are both 0 -- the unit confusion #487 fixed for Source Route and
Type 2, flagged and deliberately left by #489 for want of a working RPL
round trip to validate a replacement against. It is replaced by section
4.2's own address-count arithmetic,
``n = (((Hdr Ext Len * 8) - Pad - (16 - CmprE)) / (16 - CmprI)) + 1``, which
the reader now requires to close -- non-negative and whole.
``RPL.post_process`` subtracted ``pad_len`` a second time from a buffer
whose own 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 once the guard stopped rejecting every constructed header,
``IPv6_Route(type=..., data={'ip': [...]})`` raised a bare
``AttributeError: 'RPL' object has no attribute 'ip'`` from the reader. And
``_make_data_type_rpl`` computed ``Pad`` as ``8 - length % 8`` without the
outer ``% 8``, so an already-aligned address vector was handed a full 8
octets of padding where section 3 requires that when ``CmprI`` and ``CmprE``
are both 0, ``Pad`` MUST carry a value of 0. The narrower ``cmpr_i`` also
removes a latent divide-by-zero: a whole octet could hold 16, making
``16 - CmprI`` zero, where a 4-bit field tops out at 15.
``ipv6-route-type/RPL_Source_Route_Header`` round-trips now and its
``EXPECTED_FAILURES`` entry is deleted; as with the guard it replaces, none
of this has been checked against a real RPL capture (#564).
* **Changed** -- ``ModuleDescriptor.klass`` reads an already-imported module out of
``sys.modules`` rather than re-entering ``importlib.import_module``, which
matters because next layer dispatch resolves a descriptor there on a per-frame
Expand Down
4 changes: 4 additions & 0 deletions docs/source/pcapkit/protocols/internet/ipv6_route.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ Header Schemas
Type Stubs
~~~~~~~~~~

.. autoclass:: pcapkit.protocols.schema.internet.ipv6_route.CmprInfo
:members:
:show-inheritance:

.. autoclass:: pcapkit.protocols.schema.internet.ipv6_route.PadInfo
:members:
:show-inheritance:
Expand Down
68 changes: 48 additions & 20 deletions pcapkit/protocols/internet/ipv6_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,30 +606,47 @@ def _read_data_type_rpl(self, schema: 'Schema_RPL', *, header: 'Schema_IPv6_Rout
Parsed route data.

"""
# NOTE: this guard has the same surface shape as the Source Route and
# Type 2 unit confusion #487 fixed above -- ``header.length`` is
# ``Hdr Ext Len`` in 8-octet units, not octets, and ``% 16`` reads
# like a leftover assumption that it was already a total octet
# count. It is left as-is here: RPL addresses are variable-length
# (compressed by ``cmpr_i``/``cmpr_e``), so a fixed ``% 16`` bound is
# not obviously the right invariant even under correct units, and
# nothing here has been checked against a real RPL capture. Flagged
# for follow-up rather than guessed at. (The round trip through
# ``RPL.post_process`` this note used to say was broken -- it
# treated a ``make``-built ``list[bytes]`` as ``bytes`` and raised
# on pack -- was fixed by #556; that no longer blocks validating a
# replacement here, but the replacement itself is still unwritten.)
if header.length % 16 != 0:
# NOTE: the ``% 16`` bound that stood here had the same surface shape
# as the Source Route and Type 2 unit confusion #487 fixed above --
# ``header.length`` is ``Hdr Ext Len``, in the 8-octet units
# :rfc:`6554#section-3` specifies, not octets -- and it additionally
# assumed 16-octet addresses, which an SRH only carries when ``CmprI``
# and ``CmprE`` are both 0. #489 called it out but deliberately left
# it alone, because ``RPL.post_process`` raised on every pack back
# then so there was no round trip to validate a replacement against.
# #556 removed that blocker and #564 the mis-sized fixed area behind
# it, so the replacement is written here rather than guessed at.
#
# :rfc:`6554#section-4.2` derives the address count from the same
# fields this reader has to hand:
#
# n = (((Hdr Ext Len * 8) - Pad - (16 - CmprE)) / (16 - CmprI)) + 1
#
# so the invariant actually available is that the division closes --
# non-negative, and whole. ``16 - cmpr_i`` cannot be zero: ``CmprI``
# is a *"4-bit unsigned integer"* per :rfc:`6554#section-3`, hence at
# most 15. Note this is a well-formedness check the library needs in
# order to walk ``Addresses[1..n]`` at all; :rfc:`6554#section-4.2`
# itself specifies no malformed-header drop condition, only
# ``Segments Left > n``, so nothing stricter is imposed here. Still
# not checked against a real RPL capture, which is the caveat the
# ``% 16`` bound carried too.
cmpr_i = schema.cmpr['cmpr_i']
cmpr_e = schema.cmpr['cmpr_e']
pad_len = schema.pad['pad_len']

remainder = header.length * 8 - pad_len - (16 - cmpr_e)
if remainder < 0 or remainder % (16 - cmpr_i) != 0:
raise ProtocolError(f'{self.alias}: [TypeNo {header.type}] invalid format')

ipv6_route = Data_RPL(
next=header.next,
length=ipv6_route_header_length(header.length),
type=header.type,
seg_left=header.seg_left,
cmpr_i=schema.cmpr_i,
cmpr_e=schema.cmpr_e,
pad=schema.pad['pad_len'],
cmpr_i=cmpr_i,
cmpr_e=cmpr_e,
pad=pad_len,
ip=tuple(schema.ip),
)
return ipv6_route
Expand Down Expand Up @@ -778,7 +795,16 @@ def _make_data_type_rpl(self, type: 'Enum_Routing', route: 'Optional[Data_RPL]'
prefix_e = os_path.commonprefix(test_list)
cmpr_e = len(prefix_e)

pad = 8 - ((len(ip) - 1) * (16 - cmpr_i) + (16 - cmpr_e)) % 8
# NOTE: the outer ``% 8`` is what keeps a vector that is
# already 8-octet aligned from being handed a *full* 8 octets
# of padding -- ``8 - 0`` is 8, not 0. That is reachable
# whenever ``dst`` shares no prefix with the addresses, which
# makes ``cmpr_i`` and ``cmpr_e`` both 0 and the vector a
# multiple of 16, and it contradicts :rfc:`6554#section-3`:
# *"Note that when CmprI and CmprE are both 0, Pad MUST carry
# a value of 0."* ``_make_data_type_none`` above already
# spells the idiom this way; this branch did not.
pad = (8 - ((len(ip) - 1) * (16 - cmpr_i) + (16 - cmpr_e)) % 8) % 8

ip_val = []
for item in ip[:-1]:
Expand All @@ -792,8 +818,10 @@ def _make_data_type_rpl(self, type: 'Enum_Routing', route: 'Optional[Data_RPL]'
ip_val.append(cast('IPv6Address', parse_ip_address(ip[-1], descr, version=6)).packed[cmpr_e:])

return Schema_RPL(
cmpr_i=cmpr_i,
cmpr_e=cmpr_e,
cmpr={
'cmpr_i': cmpr_i,
'cmpr_e': cmpr_e,
},
pad={
'pad_len': pad,
},
Expand Down
Loading
Loading