Repository navigation
ipv6/mh: fix option padding, and with it construction, which was wholly broken - #398
Conversation
…ly broken
The headline is not the Pad1 parse bug this started from: `HOPOPT(next=...)` and
`IPv6_Opts(next=...)` raised `ProtocolError: ... invalid format` for **any**
option set, including no options at all, because construction packs and then
re-parses its own bytes. Verified against main:
HOPOPT no options -> FAIL ProtocolError: HOPOPT: invalid format
HOPOPT empty list -> FAIL ProtocolError: HOPOPT: invalid format
Two compounding faults in `_make_hopopt_options` / `_make_ipv6_opts`:
1. `_make_opt_pad(PadN, length=pad_len)` emitted `pad_len + 2` octets while
accounting only `pad_len` -- a PadN spends two octets on its own type and
Opt Data Len, so filling `pad_len` needs `Opt Data Len = pad_len - 2`.
2. Each option was aligned to 8 octets *on its own*, leaving the options area a
multiple of 8 -- but `read` sizes it as `hdr_ext_len * 8 + 6`, six short of a
multiple of 8, because the 2-octet fixed header precedes it. `math.ceil` then
rounded the shortfall away and the header declared 6 octets more than it
carried.
Alignment now comes from a running length that includes the fixed part, and
`make` divides exactly instead of rounding up. `options is None` no longer
short-circuits to an empty area: the minimum header is 8 octets of which 2 are
fixed, so "no options" is 6 octets of padding.
MH had the same round-up, and its modulus is its own rather than 6 -- its fixed
part is 6 octets, so the message data is well formed at `== 2 (mod 8)` and an
option-less message is already aligned where an IPv6 options header needs 6
octets of padding. `MH._pad_mh_message` appends padding into the mobility
options, where :rfc:`6275#section-6.2.5` puts it. A message whose body is opaque
has nowhere to put it and is reported through a `ProtocolWarning` rather than
silently misdeclared.
The original Pad1 defect was also not where it was thought to be. Both schemas
already declared the length octet conditionally, so the documented
`ProtocolError` was unreachable; the break was one field along, in
`PadOption.pad`, which sized itself with `pkt.get('len', 0)`. A skipped
`ConditionalField` is recorded as `NoValue` rather than omitted, so `.get`
returned the singleton, `PaddingField` interpolated it into a struct template,
and the parse died with `struct.error: bad char in struct format`. Fixed in the
schema layer in all three modules -- hopopt, ipv6_opts and mh -- so a Pad1
consumes exactly one octet on the wire and nothing downstream compensates.
Also fixed a two-octet overshoot when re-making a parsed padding option: the data
model's `length` counts the whole option, the schema's counts only Opt Data Len.
MH copied one into the other unconverted, and HOPOPT and IPv6_Opts ignored the
argument entirely, collapsing any re-made padding option to a single Pad1. An
existing test carried a comment describing this as a pre-existing defect; that
comment now records the fix.
Swept every length callback in `pcapkit/protocols/schema/` with its conditional
fields set to `NoValue`. Only the three PadOption cases are genuinely reachable:
ipv4's EOOL/NOP have field-less schema classes, httpv2 and l2tp wrap the padding
field in a ConditionalField sharing the same predicate, and the
`__option_padding__` lookups come from OptionField rather than a ConditionalField.
Seven existing assertions encoded the bug and are corrected, each with the octet
count justifying it in a comment. Two test *inputs* changed as well, because with
the old inputs the branch under test no longer arises: a 6-octet option leaves
the header already aligned, so the "two Pad1" case emitted no padding at all
once the alignment was right. Every make_options case now also asserts
`len(emitted) == returned_length` and `(returned_length + 2) % 8 == 0`, so the
accounting cannot drift from the bytes again.
Round trips go through the real construction path rather than a hand-built
header, which is what the broken construction had been blocking. All new tests
were confirmed to fail against main's source. Fixture-free unit tier 553 passed,
17 skipped, 503 subtests -- baseline 539/17/455.
There was a problem hiding this comment.
🟡 Changes recommended
MH construction can still emit malformed headers for unaligned opaque bytes bodies because it warns but does not ensure emitted bytes match the declared Header Len.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes IPv6 extension header (HOPOPT/IPv6-Opts) and Mobility Header (MH) option padding so that (1) Pad1 parses correctly as a 1-octet option, and (2) constructed headers declare lengths that exactly match emitted bytes (including correct 8-octet alignment rules).
Changes:
- Fix
Pad1parsing in HOPOPT/IPv6-Opts/MH schemas by making padding-data length callbacks treat skippedConditionalFieldvalues (NoValue) as zero. - Fix header construction/padding accounting for HOPOPT/IPv6-Opts (2-byte fixed header) and MH (6-byte fixed header) so declared lengths match emitted bytes and “no options” produces the minimum valid header.
- Expand/adjust unit tests to assert byte-identical round trips and correct length/alignment behavior; update docs to include the new helper functions.
File summaries
| File | Description |
|---|---|
| tests/protocols/internet/test_mh_unit.py | Adds/updates MH padding parsing + construction round-trip tests and fixes prior assertions that encoded buggy behavior. |
| tests/protocols/internet/test_ipv6_extension_unit.py | Adds HOPOPT/IPv6-Opts wire-parse tests for Pad1/PadN and construction round-trip/alignment assertions. |
| pcapkit/protocols/schema/internet/mh.py | Introduces pad_opt_data_len and updates PadOption to size padding data safely for Pad1. |
| pcapkit/protocols/schema/internet/ipv6_opts.py | Same pad_opt_data_len fix pattern for IPv6-Opts padding option schema. |
| pcapkit/protocols/schema/internet/hopopt.py | Same pad_opt_data_len fix pattern for HOPOPT padding option schema. |
| pcapkit/protocols/internet/mh.py | Fixes MH construction length calculation and adds padding helpers to align MH per its 6-byte fixed header. |
| pcapkit/protocols/internet/ipv6_opts.py | Fixes IPv6-Opts construction so empty options still produce a valid padded minimum header and length math is exact. |
| pcapkit/protocols/internet/hopopt.py | Fixes HOPOPT construction so empty options still produce a valid padded minimum header and length math is exact. |
| docs/source/pcapkit/protocols/internet/mh.rst | Documents the new pad_opt_data_len helper. |
| docs/source/pcapkit/protocols/internet/ipv6_opts.rst | Documents the new pad_opt_data_len helper. |
| docs/source/pcapkit/protocols/internet/hopopt.rst | Documents the new pad_opt_data_len helper. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot was right on #398. For a message body that carries no mobility options, `_pad_mh_message` warned and returned the body unchanged -- but the warning did not stop the malformed header from shipping. `length` is `(len(data) + 6) // 8 - 1`, which floors, so an opaque body of 4, 6 or 8 octets emitted 10, 12 or 14 octets while declaring 8. Measured: len(body) emitted declared 4 10 8 MISMATCH 6 12 8 MISMATCH 8 14 8 MISMATCH A parser reads the 8 it was promised and misinterprets the rest. The padding now goes into the body itself, and the warning says so. I had first made this raise, on the grounds that appending octets to a body whose layout is unknown corrupts it -- but for a *construction* call that is the wrong trade: the caller asked for a packet and the shortfall is recoverable, so completing it beats refusing. The warning is there precisely because the emitted body is then not byte-for-byte what was handed in. Kept as a `ProtocolError`: a schema body with neither `options` nor a `bytes` payload, where there is genuinely nowhere to put the octets. No such body exists in-tree today. Two existing assertions changed, both of which pinned the old misdeclaring behaviour. Their constructors return raw `bytes`, so both now take the padding branch: make(type=custom, data={'value': b'dict'}).data b'dict' -> b'dict' + 6 nulls make(type=custom, data=unknown_message).data b'model' -> b'model' + 5 nulls and the test asserting a warning-and-untouched body now asserts the padding, that `6 + len(body)` is a multiple of 8, and that the declared length equals what is emitted. It also pins that an already-aligned body is returned untouched and *unremarked*, since the warning is only meaningful when the body actually changed. Fixture-free unit tier 556 passed, 17 skipped, 511 subtests.
… fix/ipv6-mh-option-padding
|
Copilot's finding was correct, and it is fixed in Quantifying it firstThe warning did not stop the malformed header from shipping. A parser reads the 8 octets it was promised and misinterprets the remainder. So this was a real wire-format defect, not a cosmetic one. What I did, and a course correctionMy first attempt made it raise, reasoning that appending octets to a body whose layout is unknown corrupts the body rather than the length field. Jarry overruled that, correctly: on a construction path the caller has asked for a packet and the shortfall is recoverable, so completing it beats refusing. That is Copilot's option (a). So the padding now goes into the body itself and the warning says so — the warning is meaningful precisely because the emitted body is no longer byte-for-byte what was handed in. One Two existing assertions changed — both pinned the bugWorth calling out rather than burying, since they change observable output. Both constructors return raw
The first emitted 10 octets while declaring 8; the second 11 while declaring 8. The test that asserted "warns and returns unchanged" now asserts the padding, that Fixture-free unit tier: 556 passed, 17 skipped, 511 subtests, 0 failed. |
… floored one (#601) (#607) The ILNP Nonce option builders sized the option with `math.ceil(nonce.bit_length() // 8)` at hopopt.py:1889 and ipv6_opts.py:1892. `//` floors, and `math.ceil` of an `int` is a no-op, so the ceiling was never taken. The nonce is packed by a `NumberField` whose width *is* that declared `len` (schema/internet/hopopt.py:722, ipv6_opts.py:727), so an under-declared length did not merely mis-state the option -- it silently truncated the nonce on the wire, with nothing raised. - Small nonces are the worst case, not boundary values. Any nonce below 256 was declared as *zero* octets and dropped from the packet: measured, `nonce=9` packed to `b'\x8b\x00'` and parsed back as `0`, and `nonce=256` and `nonce=65536` truncated to `0` as well. Only a bit length that is an exact multiple of eight gave the right answer. - Fixed to `max(1, math.ceil(nonce.bit_length() / 8))`, the form already used at five other sizing sites -- hip.py:3156, :3334, :4289, :4315 and mh.py:7947. - The one-octet floor is mh.py's convention and is load-bearing here because `nonce` defaults to `0`, whose bit length is `0`. Without it the default argument builds an ILNP Nonce option carrying no Nonce Value field at all, collapsing "the nonce is 0" into "there is no nonce" when RFC 6744 gives the option that field. Value round-trip is unaffected either way, so this is structural rather than a data fix, and it is called out as a deliberate addition rather than folded in silently. - The read path was never affected: it takes the width from the `len` octet on the wire rather than recomputing it. New cases in tests/protocols/internet/test_ipv6_extension_unit.py, one test per protocol, assert the declared length, the exact packed octets and the construct-pack-parse cycle over ten nonces. Reverting hopopt.py alone fails test_hopopt_... with 8 SUBFAILED subtests (`0 != 1`, `1 != 2`, `2 != 3`, `4 != 5`) while test_ipv6_opts_... passes, and reverting ipv6_opts.py alone mirrors it exactly -- so each site is pinned by its own test. The two byte-aligned controls, 255 and 0xFFFFFF, pass either way. The suite missed this because the only ILNP nonce it exercised was `0xFFFFFF` (examples/generators/options.py:537), bit length 24, precisely where floor division and the ceiling agree -- the same blind spot that hid the identical typo in numbers.py. There is no ILNP entry in EXPECTED_FAILURES (45 entries, confirmed by importing it), and none starts passing, so that file is untouched. The new table guards itself: it asserts at least six of its own values stay non-byte-aligned and one stays below 256. tests/protocols/internet/ 221 passed, 718 subtests, exit 0. Coverage of the two modules 99% statement and branch, the one uncovered line in each being a pre-existing gap in `_make_opt_pad` from #398. Statement coverage of the changed line was already 100% before this change, which is exactly why the defect survived; what the new tests add is assertions on the length it produces. `python util/changelog_md.py --check` exits 0. Fixes #601
This started as the Pad1 parse defect and turned out to be much larger. The Pad1 bug is real, but it is not the headline.
Construction was completely broken for HOPOPT, IPv6-Opts and MH
Not "broken for exotic option sets" — broken for no options at all. Verified against current
main:and where it did not raise, it emitted a malformed header:
Construction packs and then immediately re-parses its own bytes, so a length that disagrees with the octets is fatal.
After:
Every case re-parses and repacks byte-identically.
Two compounding faults
_make_opt_pad(PadN, length=pad_len)emitted two octets more than it accounted for. A PadN spends 2 octets on its own type andOpt Data Len, so fillingpad_lenoctets needsOpt Data Len = pad_len - 2.readsizes it ashdr_ext_len * 8 + 6, six short of a multiple of 8, because the 2-octet fixed header precedes it.math.ceil((total_length - 6) / 8)rounded that shortfall away, so the header declared 6 octets more than it carried.Alignment now derives from a running length that includes the fixed part, and
makedivides exactly instead of rounding up.options is Noneno longer short-circuits to an empty area: the minimum header is 8 octets of which 2 are fixed, so "no options" means 6 octets of padding, not nothing.MH's modulus is its own, not 6. Its fixed part is 6 octets, so the message data is well formed at
≡ 2 (mod 8)— an option-less MH message is already aligned where an IPv6 options header needs 6 octets of padding.MH._pad_mh_messageputs padding in the mobility options, where RFC 6275 §6.2.5 does. A message with an opaque body (UnknownMessage,ExperimentalMessage, rawbytes) has nowhere to put it and is reported viaProtocolWarningrather than silently misdeclared.The Pad1 defect was not where the note said
Both schemas already declared the length octet conditionally, and
Option.post_processclampslento 0 for Pad1 — so the documentedProtocolErrorpath was unreachable. The break was one field further along, inPadOption.pad:A skipped
ConditionalFieldis recorded asNoValue, not omitted (schema.py:644), so.get('len', 0)returned the singleton rather than the default,PaddingFieldinterpolated it into a struct template, and the parse died in the field layer:Fixed in the schema layer in all three modules, so a Pad1 reads one octet and nothing downstream compensates. Rejected: splitting
PadOptioninto Pad1/PadN classes (churns__all__, the type hints, the docs, and a test assertingregistry[Pad1] is registry[PadN]), and wrapping thePaddingFieldin aConditionalField(bypasses thePaddingFieldspecial-case in both pack and unpack loops).The sweep for the same defect class
I exercised every length callback in
pcapkit/protocols/schema/with its class's conditional fields set toNoValue— by script, not grep. Only the threePadOptioncases are genuinely reachable:hopopt,ipv6_opts,mhipv4EOOL/NOPhave dedicated field-less schema classes, so no lambda runs withlengthskipped (verified by parsing)httpv2(3),l2tpConditionalFieldwith the same predicate as the field its length readstcp,ipv4,pcapng(9 sites)__option_padding__comes fromOptionField, not aConditionalField, so the.getdefault is reachable and intendedAlso fixed a two-octet overshoot when re-making a parsed padding option: the data model's
lengthcounts the whole option, the schema's counts onlyOpt Data Len. MH copied one into the other unconverted; HOPOPT and IPv6-Opts ignored the argument entirely, silently collapsing a re-made padding option to a singlePad1. An existing test carried a comment calling this a known pre-existing defect — that comment now records the fix.Seven existing assertions changed, each because it encoded the bug
None turned out to be correct. Every change carries the octet count justifying it, in a comment:
total_lengthschema_lengthdict_lengthmade_empty.options[]['PadOption'], +len(bytes()) == 8_make_opt_pad(...).lengthlen(pack()) == 4Two test inputs also changed, and that is the part worth scrutinising.
pad1_lengthandno_pad_lengthhad 6- and 8-octet options. Once the alignment is correct, a 6-octet option leaves the header already aligned, so the "two Pad1" case would emit no padding at all and lose its coverage entirely. The inputs moved to 4 and 6 octets so each case still exercises the branch it was written for:pad1_length: 4 + 1 + 1 = 6; 2 + 6 = 8 — two Pad1, as intendedno_pad_length: 6 octets, nothing added; 2 + 6 = 8 — genuinely no paddingEvery
make_optionscase now also assertslen(emitted) == returned_lengthand(returned_length + 2) % 8 == 0, so the accounting cannot drift from the bytes again.Verification
Round trips now go through the real construction path (
protocol_cls(next=…, options=[…]),MH(next=…, data={…})), which is what the broken construction had been blocking — the hand-built-header round trip is gone, the wire-level tests are kept. Every new test was confirmed to fail againstmain's source with the tests held constant.mypyon all six changed modules: no new errors. The 5 failures intests/protocols/internetare*_runtime.pyonly (excluded from the CI selection) and are missing generated fixtures, identical before and after.Left alone
_make_hopopt_optionsstill discards a caller-supplied padding option and regenerates the canonical layout, so a byte-exact round trip of a hand-placed padding layout is not possible by design.schema/internet/mh.py'sOption.post_process(sayslength is always 1, code sets0; the code is right — that field isOpt Data Len, absent for Pad1).