Repository navigation
protocols: honour layer=/protocol= limits, and forward the packet context - #404
Conversation
…text **#356 -- `layer=` and `protocol=` were inert, as were the CLI's `-L`/`-P`.** `ProtocolBase.__init__` read the limits from `_layer`/`_protocol`, but no producer in the tree spells them that way: both engines, all four `_import_next_layer` implementations, and the public `extract(layer=…, protocol=…)` that the CLI feeds all pass them un-prefixed. They landed in `**kwargs`, were dropped, `_sigterm` stayed `False`, and the parse always ran to the top. Measured on frame 4 of `http6.cap`, whose full chain is `Ethernet:IPv6:TCP:HTTP/1.1`: layer='link' before Ethernet:IPv6:TCP:HTTP/1.1 after Ethernet:IPv6 layer='internet' before Ethernet:IPv6:TCP:HTTP/1.1 after Ethernet:IPv6:TCP protocol='TCP' before Ethernet:IPv6:TCP:HTTP/1.1 after Ethernet:IPv6:TCP:Raw Fixed at `ProtocolBase.__init__`, which accepts both spellings with the prefixed one winning. That is the single point every producer funnels through, so it repairs all six at once plus any third-party protocol. Guarded to the parse path (`file is not None`): on the construction path `protocol` is a real `make()` argument -- `IPv4.make` takes one -- so consuming it there would drop the value being built. Also normalised the `'none'`/`'null'` no-limit sentinels that `Extractor.__init__` substitutes, so they are not compared against real protocol names on every protocol of every packet. The CLI's forwarding was already correct; the core ignored it. But `-L` defaulted to the *string* `'None'` and `-P` to `'null'`, working only because `Extractor` happened to lowercase the former into its sentinel; both now default to `None`. `-L` gained `choices` + `type=str.lower`, so `-L bogus` exits with usage instead of being silently ignored. `-P` is deliberately left unvalidated -- protocols can be registered dynamically. **#382 -- the packet context never reached the schema.** `_import_next_layer` hands the next protocol its context as `packet=`, while the schema layer reads `__packet__`. Nothing bridged them, so every schema's `unpack`/`post_process` saw `{}`. `IPv4.read`/`IPv6.read` are the only reads declaring `__packet__` and both always got `None`. A second, chained bug had to be fixed for the value to survive. `Schema.unpack` cleared `__updated__` *before* `prepare` ran `post_process`, and every field `post_process` assigns sets the flag again -- so the schema was left dirty, the next `bytes()`/`len()` re-packed it, and `Schema.pack` called `post_process` a second time with a context rebuilt from the schema's own fields, overwriting exactly the context-derived values. `OptionField.unpack` measures each option with `len(data)`, which triggered it one option later. `Schema.pack` already orders these correctly; the unpack path now matches. Demonstrated with a HOPOPT carrying an MPL option whose Seed-ID :rfc:`7731` elides, so the seed *is* the outer source address: `seed_id` was `None`, and is now `IPv6Address('2001:db8::1')`. The context is passed as a **shallow copy**: `Schema.unpack` writes every field it reads plus `__length__`/`__option_padding__` into it, and the IPv6 extension-header walk hands one dict to each header in turn, so sharing it would leak a sibling's fields into the next header's `ConditionalField` tests. An explicitly supplied `__packet__` -- the PCAP-NG engine's snaplen -- is never overridden. Un-skipped `test_layer_and_protocol_limits_stop_the_parse`, the only parse-limit skip; it passes with its assertions unchanged. The two remaining skips are dictdumper blockers. Verified: unit tier 594 passed / 14 skipped (baseline 575/14); integration 85 passed / 2 skipped (baseline 70/3, and one of those skips is the test above now running). All 25 new assertions fail against the unmodified source, with a deliberate control that passes both ways. Parse benchmark on `http.pcap`, 1117 frames, best of 5: 1.0661s -> 1.0266s. Two notes on scope. `pcapkit/utilities/decorators.py` was outside the original brief but the `__updated__` ordering fix has to live there -- `post_process` is invoked by `prepare`, after `Schema.unpack` returns, so `schema.py` cannot reach it. And `tests/integration/test_cli_subprocess.py` ran `python -m pcapkit` in a temp cwd, so the subprocess imported the *installed* editable package rather than the tree under test; CLI assertions passed in-process and failed in subprocess until the collected tree went on the subprocess `PYTHONPATH`. Any prior CLI change made in a worktree was asserted against code it never ran. Left alone: `ipv6_route.py`'s RPL consumer of `__packet__` is still unreachable -- `_read_data_type_rpl` tests `header.length % 16` and `_read_data_type_src` tests `(header.length - 8) % 16`, comparing the raw `Hdr Ext Len` octet, which counts 8-octet units, against octet counts. Reported, not fixed.
There was a problem hiding this comment.
🟢 Approval recommended
The changes address well-scoped, demonstrated defects in core parsing/context propagation with strong unit + integration coverage and no issues found in review.
Pull request overview
This PR fixes two core parsing issues in PyPCAPKit’s protocol stack: (1) extraction limits (layer= / protocol=) were previously ignored due to keyword spelling mismatches, and (2) per-packet context was not reaching schema unpack/post-processing (packet= never bridged to __packet__). It also aligns schema “dirty” flag handling during unpack with the existing pack path to prevent unintended repacks and context-derived value clobbering.
Changes:
- Make
ProtocolBase.__init__honour both_layer/_protocolandlayer/protocol(prefixed wins) and normalize “no limit” sentinels. - Republish
packet=as a shallow-copied__packet__during parsing so schemaunpack/post_processreceives the enclosing-layer context. - Fix
preparedecorator to leave schemas clean afterpost_process, and harden/extend CLI + integration/unit test coverage for these behaviors.
File summaries
| File | Description |
|---|---|
| pcapkit/protocols/protocol.py | Accept unprefixed parse-limit kwargs during parsing; normalize sentinels; bridge packet → __packet__ with a copy. |
| pcapkit/utilities/decorators.py | Clear schema __updated__ after post_process in the unpack path to prevent unintended repacks and value overwrites. |
| pcapkit/main.py | CLI: default -L/-P to None, validate -L with choices + case-folding, keep -P free-form. |
| tests/utilities/test_decorators.py | Add targeted regression tests for prepare leaving schemas clean and tolerating non-schema returns. |
| tests/protocols/test_protocol_base_unit.py | Add unit tests for parse-limit keyword spelling, sentinel normalization, and packet→__packet__ forwarding (including a real MPL/IPv6 seed-id consumer). |
| tests/integration/test_frame_iteration.py | Unskip/expand end-to-end assertions proving limits stop parsing at the correct layer/protocol and preserve raw payload verbatim above the stop. |
| tests/integration/test_cli_subprocess.py | Ensure subprocess CLI tests execute against the checkout under test (PYTHONPATH), and add CLI assertions for -L/-P behavior and -L validation. |
| tests/cli/test_main.py | Pin CLI forwarding contract for layer/protocol, and verify defaults + layer case-insensitivity + invalid layer rejection. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Closes #356. Closes #382.
(Filing both explicitly — a comma list only auto-closes the first, which bit #403.)
#356 —
layer=andprotocol=were completely inertProtocolBase.__init__read the limits from_layer/_protocol, but no producer in the tree spells them that way: both engines, all four_import_next_layerimplementations, and the publicextract(layer=…, protocol=…)that the CLI feeds all pass them un-prefixed. They landed in**kwargs, were dropped,_sigtermstayedFalse, and the parse always ran to the top.Measured on frame 4 of
http6.cap, whose full chain isEthernet:IPv6:TCP:HTTP/1.1:layer='link'Ethernet:IPv6:TCP:HTTP/1.1Ethernet:Internet_Protocol_version_6layer='internet'Ethernet:IPv6:TCP:HTTP/1.1Ethernet:IPv6:TCPlayer='transport'Ethernet:IPv6:TCP:HTTP/1.1Ethernet:IPv6:TCP:Rawprotocol='TCP'Ethernet:IPv6:TCP:HTTP/1.1Ethernet:IPv6:TCP:RawPCAP-NG behaved the same way. CLI:
pcapkit-cli -v -L internetwent from the full chain toEthernet:IPv6:TCP.Fixed at
ProtocolBase.__init__, which now accepts both spellings with the prefixed one winning — the single point every producer funnels through, so it repairs all six at once plus any third-party protocol. Guarded to the parse path (file is not None), because on the construction pathprotocolis a realmake()argument (IPv4.maketakes one), so consuming it there would drop the value being built. Also normalised the'none'/'null'no-limit sentinels thatExtractor.__init__substitutes, so they are not compared against real protocol names on every protocol of every packet.The CLI's forwarding was already correct — the core ignored it. But
-Ldefaulted to the string'None'and-Pto'null', working only becauseExtractorhappened to lowercase the former into its sentinel; both now default toNone.-Lgainedchoices+type=str.lower, so-L bogusexits with usage rather than being silently ignored (-L Internetstill works).-Pis deliberately left unvalidated, since protocols can be registered dynamically.#382 — the packet context never reached the schema
_import_next_layerhands the next protocol its context aspacket=; the schema layer reads__packet__. Nothing bridged them, so every schema'sunpack/post_processsaw{}.IPv4.read/IPv6.readare the only reads declaring__packet__, and both always receivedNone.A second, chained bug had to be fixed for the value to survive.
Schema.unpackcleared__updated__beforeprepareranpost_process, and every fieldpost_processassigns sets the flag again — so the schema was left dirty, the nextbytes()/len()re-packed it, andSchema.packcalledpost_processa second time with a context rebuilt from the schema's own fields, overwriting exactly the context-derived values.OptionField.unpackmeasures each option withlen(data), which tripped it one option later.Schema.packalready orders these correctly; the unpack path now matches.Demonstrated with a hand-built IPv6 + HOPOPT carrying an MPL option whose Seed-ID RFC 7731 elides, so the seed is the outer source address:
[]['dst', 'src'][len, next, type]['dst','len','next','src','type']seed_idNoneIPv6Address('2001:db8::1')The context is passed as a shallow copy —
Schema.unpackwrites every field it reads plus__length__/__option_padding__into it, and the IPv6 extension-header walk hands one dict to each header in turn, so sharing it would leak a sibling's fields into the next header'sConditionalFieldtests. An explicitly supplied__packet__(the PCAP-NG engine's snaplen) is never overridden.Verification
tests/integration*_runtime.py/*_regression.pyZero failures. All 25 new assertions fail against the unmodified source — verified by reverting the three source files and re-running (25 failed / 42 passed), with a deliberate control (
test_no_limit_parses_to_the_top_of_the_stack) that passes both ways so the suite cannot pass vacuously.Un-skipped
test_layer_and_protocol_limits_stop_the_parse, the only parse-limit skip, which now passes with its assertions unchanged. The two remaining integration skips are unrelated dictdumper blockers.Parse benchmark (
http.pcap, 1117 frames, best of 5): 1.0661s → 1.0266s. mypy: identical 9 pre-existing errors, only line numbers shifted.Two scope notes
pcapkit/utilities/decorators.pyis touched, which was outside the original brief. The__updated__ordering fix has to live there:post_processis invoked byprepare, afterSchema.unpackreturns, soschema.pycannot reach it.tests/integration/test_cli_subprocess.pyhad a blind spot, now fixed. It ranpython -m pcapkitin a temp cwd, so the subprocess imported the installed editable package — the main checkout — rather than the tree under test. The new CLI assertions passed in-process and failed in subprocess until the collected tree went on the subprocessPYTHONPATH. Worth knowing generally: any prior CLI change made in a worktree was asserted against code it never ran.Left alone, reported not fixed
ipv6_route.py's RPL consumer of__packet__is still unreachable, confirming the caveat in #382:_read_data_type_rpltestsheader.length % 16and_read_data_type_srctests(header.length - 8) % 16, comparing the rawHdr Ext Lenoctet — which counts 8-octet units — against octet counts. So a 1–3-address source route, or any RPL header without a multiple of 8 addresses, is rejected before the context matters. Separate defect, separate change.