Skip to content

fix(protocols): let from_data rebuild a parsed packet - #536

Merged
JarryShaw merged 2 commits into
mainfrom
fix/506-from-data-parsed-payload
Sep 20, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix/506-from-data-parsed-payload

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Closes #506.

IPv4.from_data() could not rebuild a datagram that had been parsed — only one built by hand. Two independent faults, both downstream of the _make_data that #494 fixed, so #499 moved the wall rather than building it.

Root cause 1 — Schema.pack named the wrong class

pcapkit/protocols/schema/schema.py:669, the PayloadField branch:

from pcapkit.protocols.protocol import Protocol   # <- meant ProtocolBase
...
elif isinstance(data, Protocol):

The metaclass revision renamed the base to ProtocolBase and kept Protocol as a thin subclass that adds auto-registration for externally defined engines. Every other site in the tree imports it as ProtocolBase as Protocol; this one was missed, because its import is a runtime import inside a method rather than a module-level TYPE_CHECKING one. I grepped the whole package to check that claim — schema/schema.py was the sole outlier, and even the other two function-local imports (corekit/protochain.py, internet/esp.py:1337) get it right.

Measured descendant counts, by importing every module under pcapkit.protocols and walking __subclasses__:

class descendants
Protocol 0
ProtocolBase 43

So the branch that packs a protocol payload had been unreachable for about three years, and every protocol instance fell through to ProtocolUnbound — including the Raw that parsing yields for an unrecognised payload, which is exactly what _make_payload hands back. This is strictly a widening: Protocol is itself a ProtocolBase (asserted in the test), so nothing that satisfied the old check stops satisfying the new one.

Because the fault is in the shared base, it was never IPv4-specific — it affected every protocol whose parse yields a protocol-instance payload. That answers the "scope worth checking" question in #506.

Root cause 2 — IPv4 option padding was a wire code, not an option

pcapkit/protocols/internet/ipv4.py:1263 and :1294. Both branches of _make_ipv4_options padded to the 32-bit boundary with NOP option schemas but terminated with a bare Enum_OptionNumber.EOOL — the wire code rather than an option. The enclosing options field takes only schemas and bytes, so packing failed with FieldValueError: Field options has invalid value for any option whose length is not already a multiple of four.

Now an EOOL option schema, as the adjacent NOP padding always was.

Evidence

Fails without the fix, one half at a time

Each fix reverted in isolation against the same tests (exit codes read directly; pytest-subtests is absent and pytest 9.1.1 reports a failing subtest's parent as PASSED).

tree result
both fixes 24 passed, 377 subtests, exit 0
schema fix reverted 7 failed, 20 passed, exit 1
ipv4 fix reverted 8 failed, 20 passed, exit 1

Reverting the schema fix fails all four new IPv4 tests (three as subtests of the round-trip test) with ProtocolUnbound: unsupported type ... Raw. Reverting the ipv4 fix fails the padding test, two pre-existing option-constructor tests, and exactly the four EXPECTED_FAILURES cases below — which is what makes the table edit load-bearing rather than cosmetic:

SUBFAILED(case='ipv4-option/RR')   SUBFAILED(case='ipv4-option/LSR')
SUBFAILED(case='ipv4-option/SID')  SUBFAILED(case='ipv4-option/SSR')

The two halves are independent: neither revert reproduces the other's failures.

Test counts

file before after
tests/protocols/internet/test_ipv4_unit.py 13 passed, 16 subtests 17 passed, 19 subtests
tests/protocols/test_option_roundtrip_unit.py 6 passed, 358 subtests 7 passed, 358 subtests

EXPECTED_FAILURES — imported, not grepped, since it is built with ** unpacking — goes from 59 entries to 55, dropping exactly ipv4-option/{RR, LSR, SSR, SID}. RR, LSR and SSR could not have been routed around: their length is 3 + counts * 4, never a multiple of four.

Real-packet sweep

Every protocol layer instance in all 24 sample captures pushed through type(layer).from_data(layer.info) and compared against bytes(layer). Both trees swept over the same capture set (the one today's main generates), so the rows line up one-for-one — 8086 layer instances each.

transition count
RAISED → DIFFERENT 3503
RAISED → IDENTICAL 300
RAISED → RAISED 2394
IDENTICAL → IDENTICAL 1889

3803 previously-raising layer instances now reconstruct (RAISED 6197 → 2394), 300 are newly byte-identical (1889 → 2189), and 0 regressions — no row moved backwards in the transition matrix.

The 3503 that reconstruct but do not match are pre-existing reconstruction gaps elsewhere in the library, now reachable where they used to be masked by the exception. They are not introduced here.

An honest note on which half the sweep credits

The sweep above credits the schema half only. I swept a third tree carrying the schema fix alone, and it came back row-for-row identical to the tree with both fixes — 0 differing rows of 8086. No capture in today's fixture set contains a misaligned IPv4 option, so the padding fix changes nothing there.

The IPv4 half does have real-packet evidence, of a different kind: it is why those captures contain no such option. With the fix, examples/generators/make_samples.py emits four frames it previously could not write at all, and options-ipv4.pcap grows from 8 frames / 456 octets to 12 frames / 784:

before:  MTUP  MTUR  TR  SEC  E_SEC  RTRALT
after:   RR  MTUP  MTUR  TR  SEC  LSR  E_SEC  SID  SSR  RTRALT      (+ EOOL/NOP padding)

Those four new frames are exactly the four EXPECTED_FAILURES entries dropped. This also renumbers the capture, which invalidated a docstring in this branch's own tests that cited "frame 8 of options-ipv4.pcap" — the inlined literal is now frame 12. It is named by its option rather than its index now, with a note saying why.

No regressions beyond the targeted files

schema.py is shared by every protocol, so the whole blast radius was run on both trees: tests/protocols/ tests/corekit/ tests/foundation/.

tree result
main at c8fd97bcd 756 passed, 11 skipped, 1731 subtests, 0 failed (18m35s)
this branch 761 passed, 11 skipped, 1734 subtests, 0 failed (11m55s)

Exactly +5 tests and +3 subtests — the five added here, and the three subtests of the round-trip test. The same 11 skips, and nothing that passed before stops passing. Per the host-care constraint I did not run coverage or the full suite.

ipv4-option/SID: dropped from the table, but not dropped

SID starts passing here only because padding is no longer fatal. The underlying defect survives untouched: SIDOption.sid is a UInt32Field at pcapkit/protocols/schema/internet/ipv4.py:368, where RFC 791 §3.1 gives the Stream ID two octets inside a four-octet option — which is also what _make_opt_sid itself writes into length=4 at ipv4.py:1637. Only the schema field disagrees.

Its generator cycle closes because both halves go through the same _make_opt_sid and emit the same wrong six octets, matching each other. It is only against the wire that the width shows:

octets 20.. length ihl
read off the wire 88040037 24 6
from_data re-emits 8804000000370100 28 7

It cannot stay an EXPECTED_FAILURES entry, because the status such an entry would have to record is 'OK' — the one value test_round_trip_is_identity_or_a_recorded_gap reads as "needs no entry". So rather than deleting it with no trace, it is recorded twice:

Tests added

  • test_ipv4_from_data_rebuilds_a_parsed_datagram — three parsed datagrams (no options; a RTRALT option; options with no payload at all), from wire literals rather than constructed, so the info carries what parsing really produces.
  • test_ipv4_make_accepts_a_protocol_instance_as_payload — the documented bytes | Protocol | Schema construction API, asserted on shape as well as bytes so that a _make_payload changed to return bytes cannot make the round trip pass while leaving the API broken.
  • test_schema_pack_packs_a_protocol_base_payload_on_its_own — Schema.pack directly, with no make in front of it, so the test says which layer was at fault. Also asserts issubclass(Protocol, ProtocolBase) (the no-regression claim) and that a non-payload still raises ProtocolUnbound (widened, not removed).
  • test_ipv4_make_options_pads_with_an_eool_option_not_its_wire_code — both padding branches, the list one reached through make and the container one reached through from_data.
  • test_a_parsed_sid_option_re_emits_two_octets_too_wide — the IPv4 SID option: SIDOption.sid is UInt32Field where RFC 791 gives a 16-bit Stream ID, so a parsed option re-emits two octets too wide #534 pin.

Each new test carries assertNotIsInstance(raw_payload, Protocol). That negative matters: the defective branch was not uncovered before this fix, it was covered by DummyProtocol at tests/protocols/schema/test_schema_unit.py:167 — the only class in the tree subclassing the engine Protocol — which packed happily while every real protocol raised. Without the negative assertion the same hole reopens if someone makes a protocol subclass Protocol instead of fixing the check.

One disambiguation for whoever greps this

grep 'class .*(Protocol)' returns 28 hits under pcapkit/protocols/data/, which look like counter-examples to the "0 descendants" claim above and are not. There are two unrelated classes named Protocol:

descendants role
pcapkit.protocols.protocol.Protocol 0 engine base — the one in the defect
pcapkit.protocols.data.protocol.Protocol 28 data-model base — unrelated

Verified: issubclass(data.internet.ipv4.IPv4, protocols.protocol.Protocol) is False, and so is issubclass(..., ProtocolBase). The descendant counts above were measured by walking __subclasses__ after importing every module under pcapkit.protocols, so they already account for these.

Closes #506.

`IPv4.from_data()` raised `ProtocolUnbound: unsupported type ... Raw` on any
datagram read off the wire. Two independent faults, both downstream of the
`_make_data` that #494 fixed:

- schema/schema.py: the `PayloadField` branch of `Schema.pack` imported
  `Protocol` where every other site in the tree imports `ProtocolBase as
  Protocol`, so it tested `isinstance(data, Protocol)`. `Protocol` has 0
  descendants and `ProtocolBase` has 43 (measured), so the branch that packs a
  protocol payload was unreachable and every protocol instance fell through to
  `ProtocolUnbound` -- including the `Raw` that parsing yields. Strictly a
  widening: `Protocol` is itself a `ProtocolBase`.
- internet/ipv4.py: both branches of `_make_ipv4_options` appended a bare
  `Enum_OptionNumber.EOOL` as end-of-list padding where the option field takes
  only schemas and bytes. Now an `EOOL` option schema, as the adjacent `NOP`
  padding always was.

- tests: round-trip three *parsed* datagrams, pack a `ProtocolBase` payload
  through `Schema.pack` directly, and assert both padding branches emit an
  option. `EXPECTED_FAILURES` drops from 59 entries to 55 as `ipv4-option/RR`,
  `LSR`, `SSR` and `SID` now round-trip.

`SID` closes only because padding is no longer fatal; its 6-octet pack
asymmetry survives and is pinned by a new test and filed as #534.
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — the shipped fix genuinely checks isinstance(data, ProtocolBase) (confirmed by reading the diff and by re-verifying the descendant counts, Protocol: 0, ProtocolBase: 43), fails-before/passes-after reproduces exactly for both root causes, #534 exists and matches the SID-asymmetry claim word for word, and the EXPECTED_FAILURES reconciliation (59→55, exactly {RR, LSR, SID, SSR} removed) is exact — with two non-blocking notes in the detailed write-up: a real test-suite robustness gap found by falsification, and an unresolved discrepancy in the repo-wide sweep's absolute totals (the fix's measured effect corroborates closely; the denominator does not).

@JarryShaw

Copy link
Copy Markdown
Owner Author

Detailed review (independent verification, falsify-not-bless)

Reviewed at head ca2568dabee09c047be567202be6bb284d14da28 in an isolated worktree, with a clean tree reconfirmed after every revert/restore experiment.

1. Test design — both required properties present, confirmed by reading the diff.

  • Negative assertion self.assertNotIsInstance(raw_payload, Protocol) present in both test_ipv4_make_accepts_a_protocol_instance_as_payload and test_schema_pack_packs_a_protocol_base_payload_on_its_own — guards specifically against "re-satisfy the old Protocol check."
  • Direct Schema.pack test (test_schema_pack_packs_a_protocol_base_payload_on_its_own) builds a Schema_IPv4 directly and calls .pack() with no IPv4.make in the call chain, genuinely isolating the schema layer. Also asserts issubclass(Protocol, ProtocolBase) (no-regression) and that object() still raises ProtocolUnbound (widened, not removed).
  • Fails-without-fix, reproduced exactly (exit codes read from a file, not through a pipe — and the reviewer hit the pytest-9.1.1 mislabeling trap directly: a parent test printed PASSED in the summary while all 3 of its subtests were SUBFAILED; only the exit code told the truth): both fixes present → 24 passed/377 subtests, exit 0. Schema fix reverted → 7 failed/20 passed, exit 1. IPv4 fix reverted → 8 failed/20 passed, exit 1, SUBFAILED exactly RR, LSR, SID, SSR. All three states match the PR's claims exactly.

2. Falsification attempt — a genuine, worth-noting finding. Two "wrong fixes" tried against the shipped tests:

  • An aliasing trick (from pcapkit.protocols.protocol import Protocol as ProtocolBase) — correctly caught, same failures as a real revert.
  • Special-casing exactly the classes the tests use (elif isinstance(data, (Protocol, Raw, NoPayload)):) — this passes all 4 new tests. Independently confirmed the general IPv4.from_data() cannot rebuild a parsed packet: ProtocolUnbound on a Raw payload #506 defect survives under this fake fix: a real parsed UDP instance used as an IPv4 payload still raises ProtocolUnbound for every one of the ~41 other real ProtocolBase subclasses, because every shipped test's "real protocol" payload is only Raw(...) or NoPayload() — none uses a second real protocol class. The shipped fix itself is correct (verified by reading the diff: it genuinely checks isinstance(data, ProtocolBase), not a special case) — this finding is about test-suite robustness, not a defect in the shipped code. Worth a follow-up suggestion (add a test using a second real protocol, e.g. UDP-in-IPv4, as payload) but not a blocker.

3. Issue #534 — exists and matches precisely. Title, body, the exact file/line (pcapkit/protocols/schema/internet/ipv4.py:368), and the same 88040037→8804000000370100 wire evidence all confirmed via gh issue view 534, including the explicit statement "Split out of #506... Nothing here is fixed by #506." RR/LSR/SSR's removal is independently confirmed clean — reverting just the padding fix reproduces exactly and only {RR, LSR, SID, SSR} as failing, no other surviving defect for RR/LSR/SSR specifically.

4. EXPECTED_FAILURES reconciliation — exact match. Imported the dict directly on main (59 entries) and PR head (55 entries): removed exactly {ipv4-option/LSR, RR, SID, SSR}, nothing added.

5. Repo-wide sweep — re-derived independently; the fix's effect corroborates, the absolute total does not. No sweep script is checked into the repo (searched tests/, examples/, util/) — it was run ad hoc by the PR author, so an independent implementation was written and run against the canonical capture set (python examples/generators/make_samples.py, the only documented generation path):

transition independently measured PR's claim
RAISED → DIFFERENT 3507 3503
RAISED → IDENTICAL 304 300
RAISED → RAISED (unchanged) 814 2394
IDENTICAL → IDENTICAL (unchanged) 309 1889
total 4934 8086

Zero regressions confirmed independently either way. The two "moved" categories — the actual measurable effect of the fix — are within single digits of the PR's claim, strongly corroborating the fix's impact. The two "unchanged" categories differ by exactly +1580 each in the PR's favor — too symmetric to be noise, suggesting roughly 3160 additional layer instances exist in whatever capture set the PR author actually used, which could not be reproduced via the documented make samples process. No additional generation step could be found. This is reported plainly as an open discrepancy, not resolved — the coordinator's instruction was specifically to re-derive rather than quote these figures, and re-derivation surfaced a real gap between what's reproducible from the repo as checked out and what the PR describes.

6. Real-packet evidence for root cause 2 (IPv4 EOOL padding) — none exists, and the PR discloses this itself. Confirmed: examples/generators/pcap.py builds synthetic captures "from protocol semantics" (its own docstring); examples/generators/options.py is explicitly documented as schema-only, round-tripped through the library's own construction output, not independent ground truth. Every new/modified test for root cause 2 uses inlined hex literals, hand-built or copied from the synthetic generator. The regenerated options-ipv4.pcap growing 456→784 bytes is the only "real" evidence offered, and it is itself schema-only. The PR's own "An honest note on which half the sweep credits" section discloses exactly this — confirmed accurate, not performative.

7. Descendant counts — confirmed exact. Protocol: 0 descendants, ProtocolBase: 43 descendants (via __subclasses__() walk). DummyProtocol confirmed the only class subclassing Protocol anywhere in the tree.

Verdict

The shipped code fix is correct and its targeted fails-before/passes-after proofs are exact. Two things are flagged for visibility rather than as blockers: a test-suite coverage gap surfaced by falsification (not a code defect), and an unresolved sweep-total discrepancy (the fix's effect corroborates; the denominator doesn't, and no alternate generation path could explain it). Recommend merge.

@JarryShaw
JarryShaw merged commit 122d327 into main Sep 20, 2026
12 of 24 checks passed
@JarryShaw
JarryShaw deleted the fix/506-from-data-parsed-payload branch September 20, 2026 05:25
JarryShaw added a commit that referenced this pull request Sep 20, 2026
, #537)

* `_make_opt_sec` never set RFC 1108's field termination indicator, so every
  SEC option the library wrote with an authority was one its own reader warned
  about -- including in the project's own `options-ipv4.pcap`. Bit 0 of the
  final octet is now set.
* `_make_opt_sec` sized the bitmap from the highest authority *index* rather
  than the bit count, so a lone `GENSER` (value 0) built a zero-octet bitmap
  and raised a bare `IndexError`, and every multiple of eight under-sized by an
  octet. It sizes from the count and rejects a non-authority index with
  `ProtocolError`.
* `Field_Termination_Indicator` (index 7) was accepted as an authority, writing
  an option that read as terminated while carrying none. Index 7, 15 and 23 are
  termination bits by `_read_opt_sec`'s own numbering and are now rejected; the
  reader's `range(7)` is left alone, being the correct half.
* `SIDOption.sid` was a `UInt32Field` where RFC 791 gives a 2-octet Stream ID,
  so a well-formed option over-read by two octets (`packet length < 0: -2`) and
  re-emitted six octets wide, dragging NOP/EOOL padding in behind it. Narrowed
  to `UInt16Field`; a parsed datagram now rebuilds byte-identically.
* Adds a UDP-in-IPv4 payload test, closing the gap that let a fix
  special-casing `(Protocol, Raw, NoPayload)` pass all four of #536's tests.

Full pytest suite green: 1181 passed, 17 skipped, 2661 subtests.
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

IPv4.from_data() cannot rebuild a parsed packet: ProtocolUnbound on a Raw payload

1 participant