fix(ipv6-route): size the RPL fixed area at 4 octets per RFC 6554 (#564) - #590
Conversation
- RPL's routing-data fixed area packed to 5 octets where RFC 6554
section 3 gives 4: CmprI, CmprE and Pad are each 4-bit fields sharing
one 32-bit word with a 20-bit Reserved, but cmpr_i and cmpr_e were
declared as whole octets. A two-address header built to 41 octets
against the 48 its own Hdr Ext Len of 5 declared. They are now one
1-octet BitField: RPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...}).
- Replace the reader's `header.length % 16` guard, which read Hdr Ext Len
as an octet count and assumed 16-octet addresses, with RFC 6554
section 4.2's own address-count arithmetic, required to close.
- 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.
- RPL.post_process now sets `ip` on the construction path too, where the
reader raised a bare AttributeError once the guard stopped masking it.
- _make_data_type_rpl computed Pad as `8 - len % 8`, handing an already
aligned vector 8 octets of padding where section 3 requires 0.
Wire-format change. ipv6-route-type/RPL_Source_Route_Header round-trips
now and its EXPECTED_FAILURES entry is deleted; tests/protocols and
tests/foundation pass (783 passed, 11 skipped).
a25ccff to
2947b80
Compare
|
✅ GOOD TO GO at head sha |
|
Reviewer: Sonnet; PR authored on Opus 5. Falsify-not-bless pass on PR #590 ( 1. RFC 6554 re-derivation, from
|
Fixes #564
Root cause
RFC 6554 Section 3, "Format of the RPL Routing Header", draws the routing header as two 32-bit words plus the address vector. The second word — the part
pcapkit'sRPLrouting-data schema owns, the first being the genericnext/Hdr Ext Len/Routing Type/Segments Left— is:and the field definitions give
CmprI,CmprEandPadeach as a "4-bit unsigned integer", withReservedtaking the remaining 20 bits. That is 32 bits — one 4-octet word.pcapkit/protocols/schema/internet/ipv6_route.pydeclared the fixed area asUInt8Field()+UInt8Field()+BitField(length=3)— 5 octets.IPv6_Route._make_hdr_ext_lenthen derivedHdr Ext Lenfrom that inflated data area, so a built header was narrower than its own declaration. Measured on the two addresses the round-trip table uses:_read_data_type_rpl's own docstring diagram had the layout right all along; only the schema disagreed with it.Three more defects behind it, taken off in the same pass
#564's scope note asks for the
Hdr Ext Lenarithmetic and the% 16guard to be reconciled together, "the guard cannot be validated against a header whose width is still wrong". Fixing the width made the next layer reachable, and so on; each of these is required for the one in front of it to be verifiable.The reader's length guard (
pcapkit/protocols/internet/ipv6_route.py:638-639). It readif header.length % 16 != 0, wrong twice over:header.lengthisHdr Ext Len, "the length of the Routing header in 8-octet units" (Section 3), not an octet count; and a fixed multiple-of-16 bound assumes 16-octet addresses, which an SRH only carries whenCmprIandCmprEare both 0. Together they admitted onlyHdr Ext Len∈ {0, 16, 32, …} — nothing under 136 octets. This is the unit confusion IPv6_Route Source-Route headers cannot round-trip: make() emits a wrong Hdr Ext Len and the parse path rejects even a correct wire form #487 fixed for Source Route and Type 2, which fix(ipv6-route): compute Hdr Ext Len in 8-octet units on both sides #489 flagged and deliberately left for want of a working RPL round trip. Replaced with Section 4.2's own arithmetic:The reader now requires that division to close — non-negative and whole. Nothing stricter is imposed: Section 4.2 specifies no malformed-header drop condition, only
Segments Left > n.RPL.post_processdouble-subtractedpad_len(schema/internet/ipv6_route.py:296).addresses' own length callback already takespad_lenoff — the trailing padding is read by the separatepaddingfield — so subtracting it again lost one address per16 - CmprIoctets of padding. Only reachable once the guard stopped rejecting every padded header.RPL.post_processnever setipon the construction path (schema/internet/ipv6_route.py:264).Protocol.__post_init__packs and then unpacks, andIPv6_Route.readhands_read_data_type_rplthe schemamakejust built, not a re-parsed one. On that pathaddressesis thelist[bytes]of Two dropped-keyword/wrong-cast defects flagged in review and never filed (hip.py:3533, ipv6_route.py:207) #556, sopost_processreturned early and the reader raised a bare, out-of-libraryAttributeError: 'RPL' object has no attribute 'ip'. Masked for as long as the% 16guard rejected constructed headers first — verified by neutralising only the guard on an otherwise unfixed tree (evidence below).Plus one independent RFC MUST violation in the same arithmetic:
_make_data_type_rplcomputedPadas8 - length % 8(protocols/internet/ipv6_route.py:807), missing the outer% 8, so an already-8-octet-aligned vector was handed a full 8 octets of padding —8 - 0is 8, not 0. Reachable through the public API wheneverdstshares no prefix with the addresses, which is exactly the case that forcesCmprIandCmprEto 0, and Section 3 says: "Note that when CmprI and CmprE are both 0, Pad MUST carry a value of 0."_make_data_type_nonein the same module already spelled the idiom with the outer modulo.Breaking changes
Hdr Ext Lenchanges accordingly (2 addresses: 41 octets/Hdr Ext Len5 → 40 octets/Hdr Ext Len4). Headers built by the old code were self-inconsistent, so nothing conforming is lost.RPL(cmpr_i=..., cmpr_e=...)is nowRPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...}), beside thepad={'pad_len': ...}that was already there.Data_RPLis unchanged —info.cmpr_iandinfo.cmpr_estill read as plain ints.dstequal to one of the addresses yieldsCmprIof 16, which no longer fits a 4-bit field and now raisesFieldValueErrorat pack time instead of silently packing a nonsense header. See "not fixed" below.Hardening side-effect
cmpr_iwas a whole octet, so a hostile packet could set it to 16 and make16 - CmprIzero — aZeroDivisionErrorinpost_process. A 4-bit field tops out at 15, so the divisor is now structurally at least 1. Pinned by a test parsing0xffinto thecmproctet.Test evidence — failing first, then passing
Four new tests in
tests/protocols/internet/test_ipv6_extension_unit.py. Proved against the unfixed tree via a throwawaygit worktreeat the pre-fix commit with only the new test file copied in (pcapkit/pristine; since removed). Interpreter and tree pinned:Exit codes read from files, never from the printed summary (this pytest has no
pytest-subtests, so a failing subtest's parent still printsPASSED).Before — all four fail,
EXIT=1each:The last one fails at the guard, which is what masked defect 3. Neutralising only the guard on the otherwise-unfixed tree exposes it, confirming the ordering claim rather than asserting it:
After —
EXIT=0:(7 = the 4 new tests plus the 3 pre-existing
-k rplones.)The round-trip gate proves it independently
ipv6-route-type/RPL_Source_Route_Headerhad anEXPECTED_FAILURESentry. With the fix applied and the entry still present, the harness failed because the case had started passing — its own words:Entry deleted, per the table's own "entries deleted rather than left behind" convention, with the four-defect stack recorded in the comment that replaces it:
Suite
coverage run -m pytest(neverpytest-cov).tests/protocols/+tests/foundation/— everything this change touches — 783 passed, 11 skipped, 1796 subtests passed,EXIT=0. Coverage, from a run scoped with--includeto the two changed source files: both at 100% statements and 100% branches --protocols/internet/ipv6_route.py179 statements / 54 branches andschema/internet/ipv6_route.py78 / 12, zero missing,EXIT=0. No tree-wide run was completed: one was started and terminated for host memory, so its result is unknown and nothing above rests on it. CI's own matrix is the full-suite gate.Found and deliberately not fixed
CmprIof 16 frommake. Ifdstequals one of the addresses,os.path.commonprefixreturns 16 octets and_make_data_type_rplsetscmpr_i/cmpr_eto 16, which a 4-bit field rejects withFieldValueError. Eliding all 16 octets is meaningless (a zero-length element), so refusing is right, but the message comes fromBitFieldrather than from a check that explains itself. Clamping or validating in_make_data_type_rplis a separate behaviour decision and belongs in its own change.ValueErrorduring parse.Hdr Ext Lenof 0 gives an empty address buffer;post_processthen callsipaddress.ip_address(b''), which raisesValueError, not an in-library exception. It happens before the guard, which runs in_read_data_type_rplafter the schema has been unpacked, so the new guard cannot catch it either. Pre-existing and orthogonal to the octet width; fixing it means restructuring where validation happens._make_data_type_rplwithdstset andip=[]raisesIndexErrorfromip[-1]. Pre-existing, unrelated.ipv6_route_header_lengthis undocumented —docs/source/pcapkit/protocols/internet/ipv6_route.rstlistsipv6_route_data_selectorandipv6_route_data_lengthbut not it. Left alone to keep this diff to the defect; onlyCmprInfowas added there, which this change requires.GH-556reference intest_ipv6_route_rpl_packs_a_multi_address_list's docstring, where house style is#556. Not touched, to avoid widening an already sizeable test diff.None of this has been checked against a real RPL capture — the same caveat #489 recorded against the guard it replaces, and #564 repeats. Every measurement here is against RFC 6554's text and hand-built wire forms.