fix(schema): read the MPTCP option length from bit 8, not bit 1 (#553) - #558
Conversation
`_MPTCP.test` declared its 3-octet forward-match namespace as `'length': (1, 8)`, eight bits starting one bit into the option. - The window is anchored at the option's first octet: `_MPTCP` is what `Option.registry` dispatches to for `kind == Multipath_TCP` and, alone among the option schemas, does not inherit `Option`, so nothing has consumed `kind`/`length` before it runs. RFC 8684 section 3 therefore puts `kind` in bits 0-7, `length` in bits 8-15 and the subtype in bits 16-19 -- which the sibling entry `'subtype': (16, 4)` already assumes, and both are sliced from one big-endian bit string over the same window. - At bit 1 the field straddled the low seven bits of `kind` and the high bit of `length`, so a 12-octet MP_CAPABLE (`1e 0c 01`) decoded its length as 60. The entry is now `(8, 8)`. Nothing caught this because the decoded length only sizes the nested subtype schema, whose over-read the enclosing `OptionField` absorbs. Adds `tests/protocols/transport/test_tcp_mptcp_length_unit.py`: five cases over the schema's own declared field, including all 256 declarable lengths. Three of them fail at `(1, 8)` with `60 != 12`. Unit tier: 1104 passed, 8 skipped, unchanged from mainline.
|
✅ GOOD TO MERGE — independently derived the correct bit offset from RFC 8684 section 3's own layout (Kind at bits 0-7, Length at bits 8-15, Subtype at bits 16-19) and hand-decoded the worked example's raw bytes ( |
Detailed review (independent verification, falsify-not-bless)Head sha reviewed: Is
|
Closes #553.
The defect
pcapkit/protocols/schema/transport/tcp.pydeclared_MPTCP's option-headerforward match as:
BitField's namespace entries are(start_bit, bit_width), sliced out of onebig-endian bit string over the whole 3-octet window.
lengthread eight bitsstarting one bit in, straddling the low seven bits of
kindand the high bitof
length.The offset arithmetic, checked against RFC 8684
RFC 8684 section 3 lays every Multipath TCP option out as
Kind(8 bits),Length(8 bits),Subtype(4 bits), then subtype-specific content. Numberingfrom the option's first octet that puts
Lengthat bits 8-15.Three things confirm the window really is anchored at
kind, rather than afterthe option header:
The sibling entry
'subtype': (16, 4)is already written against thatnumbering, and decodes correctly today. Two entries in one namespace cannot be
anchored differently —
BitField.post_processslices both from the same bitstring — so
subtypeat bit 16 fixes the origin atkindand leaves bit 8 asthe only place
lengthcan begin._MPTCPis registered as the schema for the option itself(
Option.register(Enum_Option.Multipath_TCP, _MPTCP)) and, alone among theoption schemas, does not inherit
Option— so it is handed the option'sfirst octet and nothing has consumed
kind/lengthbefore it.Measured, on the 12-octet MP_CAPABLE of RFC 8684 figure 4, whose first three
octets are
1e 0c 01→ bits000111100000110000000001:[1:9]— old(1, 8)[8:16]— new(8, 8)[16:20]—subtype0= MP_CAPABLESo
(8, 8), and the issue's reported "60 instead of 12" reproduces exactly.Why nothing caught it
The decoded length is used for one thing —
SchemaField(length=pkt['test']['length'])in
mptcp_data_selector, sizing the nested subtype schema. Over-declaring thatsize fails silently: the enclosing
OptionFieldstops at the end-of-option-listmarker or the declared options length, so the over-read is absorbed rather than
reported.
Coverage
New
tests/protocols/transport/test_tcp_mptcp_length_unit.py, five cases read offthe schema's own declared field (
_MPTCP.__fields__['test']) rather than a copy,so a regression in the shipped declaration is what fails. Includes the exhaustive
case: all 256 lengths a single octet can declare must decode back to themselves.
Proven to fail without the fix — pytest exit code read from a file, since a
wrapper's exit code is not pytest's:
The two cases that pass either way are the
subtypeand anchor invariants, whichare correct before and after — they exist to rule out "fixing" the length by
moving the window instead of the entry.
Measured against
/local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/pythonwithPYTHONSAFEPATH=1andpcapkit.__file__asserted into this worktree.Scope: #541 and the other seven MPTCP makers
#553 asks whether the eight
tcp-mptcp/*entries inEXPECTED_FAILURESshare#541's root cause, so that #541 could close here. They share one structural
cause but need three different fixes, and one of them is unrelated — so this PR
deliberately does not attempt the family, and #541 should stay open. Measured, by
calling each of the eight makers and reading the exception:
The structural cause: every MPTCP subtype schema inherits
MPTCP(
schema/transport/tcp.py:608), notOption, andMPTCPdeclareskind,lengthandsubtypeonly underif TYPE_CHECKING:(lines 613-619) — pureannotations, no field descriptors. Their per-class
TYPE_CHECKING __init__stubsnonetheless advertise
kind/length, which is what leads every_make_mptcp_*helper to pass them; both are then dropped withUnknownFieldWarning.It surfaces three ways:
KeyError: 'length'inpack(), from each schema's ownpkt['length']predicateAttributeError: no attribute 'kind', deferred to_read_tcp_options—pack()succeedsAttributeError: 'TCP' object has no attribute '_flags'— never reaches schema construction at allGroup C is a genuinely separate defect:
_make_mptcp_joinbranches onself._flags, which only exists while parsing. It masks whether the kind/lengthcause even applies to MP_JOIN.
Fixing the family means giving the subtype schemas real
kind/lengthfields (oradding a construct-path wrapper mirroring
_MPTCP), which changes ten schemaclasses and both directions of the MPTCP path — a much larger change than a bit
offset, and one that would swamp this diff. Worth noting it is needed for parsing
too, not just construction: a real 12-octet MP_CAPABLE does not parse on
maineither, raisingKeyError: 'length'fromMPTCPCapable.rkey'spkt['length'] != 32predicate, independently of which bit offset the headerused. That is why this PR's assertion is on the decoded namespace rather than on
an end-to-end parse.
EXPECTED_FAILURESis untouched — all eight entries still fail in therecorded way, and
tests/protocols/test_option_roundtrip_unit.pypassesunchanged.
Verification
--ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py'), the selection CI runs: 1104 passed, 8 skipped, identical to the pre-change baseline onmain.make samplesand fails identically onmain(134 failed / 1165 passed both before and after).