Skip to content

interface: add the three missing engine constants, and guard dpkt/scapy tracing - #412

Merged
JarryShaw merged 5 commits into
mainfrom
feat/engine-constants
Sep 16, 2026
Merged

JarryShaw merged 5 commits into
mainfrom
feat/engine-constants

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Two engine-plumbing defects.

Three of seven engines had no constant

interface/core.py exposed DPKT, Scapy, PCAPKit and PyShark, but not PyPCAP, PCAP_CT or PyPCAPFile — so three shipped engines could only be selected by a bare string while their siblings had a name.

All seven now resolve from pcapkit, pcapkit.interface and pcapkit.all:

pcapkit.DPKT = 'dpkt'          pcapkit.PyPCAP = 'pypcap'
pcapkit.Scapy = 'scapy'        pcapkit.PCAP_CT = 'pcap_ct'
pcapkit.PCAPKit = 'default'    pcapkit.PyPCAPFile = 'pypcapfile'
pcapkit.PyShark = 'pyshark'

That needed three files beyond interface/core.py, because the constants are re-exported by explicit name — pcapkit.PyPCAP would not have existed while pcapkit.DPKT did. Checked for shadowing: pcapkit.foundation does not re-export the engine classes, so the string macros collide with nothing.

docs/source/pcapkit/interface/core.rst claimed the newer engines were string-selected only, and never mentioned PCAP_CT. Now documents all three, and says only runtime-registered engines lack a constant.

The trace guard now covers dpkt and scapy

Its own NOTE said they were "deliberately not listed here… outside the scope of this change". That scope has passed.

Their flow-tracing adapters report each frame as a plain dict, which the PCAP trace dumper cannot re-serialise — it reaches for frame.packet:

engine='dpkt' trace_format=None
  File ".../pcapkit/dumpkit/pcap.py", line 139, in _append_value
      packet=value.packet,
  AttributeError: 'dict' object has no attribute 'packet'

None stays in the format tuple because TraceFlow.__init__ substitutes 'pcap' for it.

dpkt reproduced directly; scapy did not — and that is the interesting part. Its crash was masked by #406: the engine imported only scapy.sendrecv, so every frame dissected as Raw, no TCP layer was found, and the tracer was never fed. With the layer registry loaded (as #409 now does) it crashes identically for None, 'pcap' and 'cap'.

So the guard is written from what the adapters produce, not from which engines happen to crash today. Writing it the other way would have left scapy out and re-broken it the moment #409 landed.

follow_tcp_stream's workaround is kept, having measured both paths

trace files warning
follow_tcp_stream(engine='dpkt', format=None) 3 none
Extractor(engine='dpkt', trace_format=None) 3, byte-identical FormatWarning naming trace_format=None
follow_tcp_stream(engine='dpkt', format='pcap') — warns, naming the engine's limitation

Same replacement format, same files. They differ in when they complain: the core guard warns for every substitution including the unset default, while follow_tcp_stream upgrades an unset format silently and warns only on an explicitly unusable one — a distinction already pinned by test_dpkt_unset_trace_format_is_upgraded_quietly.

Removing it would make follow_tcp_stream(engine='dpkt') warn about a default the caller never chose, with a message naming a trace_format= argument the function does not expose. Kept, with its stale NOTE corrected and a test pinning the difference.

Negative controls

A constant that silently falls back is worse than a missing one, so the tests were checked against the bug:

  • PyPCAP = 'pypcap-typo' → registry-divergence test fails, and the selection test fails 'PCAP' != 'PyPCAP', catching exactly the silent-fallback case.
  • Reverting the guard tuple → 12 subtests fail '.pcap' != '.json', and the end-to-end test fails with the real AttributeError.

Five constants get a real extraction asserting type(extractor.engine).__engine_name__; the other two use stand-in modules, since pcap/pcapfile aren't installed here and pypcapfile is version-gated off on 3.14.

dumpkit/pcap.py is unchanged — the fix belongs in the guard — but a dumpkit test now pins why (PCAPIO raises AttributeError on a mapping), so the guard's justification is verified rather than asserted in a comment.

Verification

Fixture-free CI selection: 660 passed, 8 skipped against a 644/8 baseline measured on the unmodified worktree — the delta is the new tests, no new skips, no failures.

Rebased onto merged main, and I checked the thing most likely to break: this branch predates #409's rename of the scapy test in tests/interface/test_misc.py, and a clean apply could have silently reverted it. It didn't — the new name is present and the old one absent, matching main.

…py tracing

`interface/core.py` exposed constants for the engines it knew about -- `DPKT`,
`Scapy`, `PCAPKit`, `PyShark` -- but not for `PyPCAP`, `PCAP_CT` or `PyPCAPFile`,
so three of the seven shipped engines could only be selected by a bare string
while their siblings had a name. All seven now resolve from `pcapkit`,
`pcapkit.interface` and `pcapkit.all`.

That required three files beyond `interface/core.py`: the constants are
re-exported by explicit name, so `pcapkit.PyPCAP` would not have existed while
`pcapkit.DPKT` did. Checked for shadowing -- `pcapkit.foundation` does not
re-export the engine *classes*, so the string macros collide with nothing.

`docs/source/pcapkit/interface/core.rst` said the newer engines were
string-selected only and never mentioned `PCAP_CT` at all; it now documents all
three and says only runtime-registered engines lack a constant.

**The trace-format guard now covers dpkt and scapy.** Its own NOTE said they were
"deliberately *not* listed here… outside the scope of this change", and that scope
has passed. Their flow-tracing adapters report each frame as a plain `dict`, which
the PCAP trace dumper cannot re-serialise -- it reaches for `frame.packet` and
raises `AttributeError: 'dict' object has no attribute 'packet'` at
`dumpkit/pcap.py:139`. `None` stays in the format tuple because `TraceFlow.__init__`
substitutes `'pcap'` for it.

dpkt reproduced directly. **scapy did not, and that is worth recording**: its crash
was *masked* by #406 -- the engine imported only `scapy.sendrecv`, so every frame
dissected as `Raw`, no TCP layer was found, and the tracer was never fed. With the
layer registry loaded it crashes identically. So the guard is written from what the
adapters produce rather than from which engines happen to crash today.

`follow_tcp_stream`'s local workaround is **kept**, having measured both paths:
they pick the same replacement format and write byte-identical trace files, but
differ in when they complain. The core guard warns for every substitution including
the unset default, while `follow_tcp_stream` upgrades an unset `format` silently and
warns only on an explicitly unusable one -- a distinction already pinned by
`test_dpkt_unset_trace_format_is_upgraded_quietly`. Removing it would make
`follow_tcp_stream(engine='dpkt')` warn about a default the caller never chose,
using a message that names a `trace_format=` argument the function does not expose.
Its stale NOTE is corrected and a test pins the difference.

Negative controls, because a constant that silently falls back is worse than a
missing one: setting `PyPCAP = 'pypcap-typo'` fails both the registry-divergence
test and the selection test with `'PCAP' != 'PyPCAP'`, and reverting the guard tuple
fails 12 subtests plus the end-to-end test with the real `AttributeError`. Five
constants get a real extraction asserting `__engine_name__`.

`dumpkit/pcap.py` is unchanged -- the fix belongs in the guard -- but a dumpkit test
now pins *why* (`PCAPIO` raises `AttributeError` on a mapping), so the guard's
justification is checked rather than asserted in a comment.

Verified: fixture-free CI 660 passed / 8 skipped against a 644/8 baseline, the delta
being the new tests; all seven constants resolve to their registry keys.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new engine-constant tests use stub modules for pcap-ct/pcapfile that aren’t marked as packages (missing __path__), which can break importlib.import_module('pcap._pcap') / import pcapfile.linklayer during selection tests.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR addresses two engine-selection/tracing plumbing gaps in pcapkit: it adds missing public engine constants for shipped engines, and extends the constructor-level trace-format guard so dict-frame engines (including DPKT/Scapy) don’t route to the PCAP trace dumper path that can’t serialize mappings.

Changes:

  • Add engine constants for PyPCAP, PCAP_CT, and PyPCAPFile, and re-export them from pcapkit, pcapkit.interface, and pcapkit.all.
  • Guard Extractor(..., trace_format in {None,'pcap','cap'}) for dict-frame engines (dpkt, scapy, pyshark, pypcapfile) by substituting a dict-capable format (JSON) and warning.
  • Add/adjust tests and docs to pin constant reachability/selection and the trace-format substitution behavior (including an explicit unit pin for why follow_tcp_stream keeps its local upgrade behavior).
File summaries
File Description
tests/interface/test_misc.py Adds a regression test pinning why follow_tcp_stream keeps its local format upgrade semantics.
tests/interface/test_core.py Adds tests asserting engine-constant completeness, re-exports, and that constants select the intended engine.
tests/foundation/test_extraction.py Adds constructor-level and end-to-end tests for trace-format substitution on dict-frame engines.
tests/dumpkit/test_common_unit.py Adds a unit test pinning that the PCAP dumper can’t serialize mapping-shaped frames.
pcapkit/interface/misc.py Updates rationale/comments around the local trace-format upgrade behavior in follow_tcp_stream.
pcapkit/interface/core.py Adds missing engine constants and clarifies invariant that constant values must match registry keys.
pcapkit/interface/init.py Re-exports the new engine constants from pcapkit.interface.
pcapkit/foundation/extraction.py Extends the dict-frame tracing guard to include dpkt and scapy.
pcapkit/all.py Exposes new engine constants via pcapkit.all.__all__.
pcapkit/init.py Exposes new engine constants via pcapkit.__all__.
docs/source/pcapkit/interface/core.rst Documents the new engine constants and clarifies runtime-registered engines have no constant.
Review details

Suppressed comments (1)

tests/interface/test_core.py:350

  • The pcapfile stub module needs to be marked as a package (set __path__) or import pcapfile.linklayer/savefile/structs inside PyPCAPFile.__init__ can raise ModuleNotFoundError: 'pcapfile' is not a package, even with the submodules pre-seeded in sys.modules.
        package = types.ModuleType('pcapfile')
        savefile = types.ModuleType('pcapfile.savefile')
        savefile.load_savefile = lambda *a, **kw: FakeSaveFile()  # type: ignore[attr-defined]
        linklayer = types.ModuleType('pcapfile.linklayer')
        linklayer.clookup = lambda linktype: FakeDecoded          # type: ignore[attr-defined]
  • 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.

Comment thread tests/interface/test_core.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

Following up on the review summary, which raises a second stub beyond the one in the inline thread — import pcapfile.linklayer alongside importlib.import_module('pcap._pcap'). Both were checked by running them, and neither needs __path__. No change made.

The import system resolves sys.modules before it consults a parent package, so a preregistered submodule is returned without any path search. That holds for the statement form as well as importlib:

import sys, types
pkg = types.ModuleType('pcapfile')            # deliberately no __path__
sub = types.ModuleType('pcapfile.linklayer')
pkg.linklayer = sub
sys.modules['pcapfile'] = pkg
sys.modules['pcapfile.linklayer'] = sub
import pcapfile.linklayer                     # works

Measured on 3.10, 3.12 and 3.14: ok, statement form works without __path__ on each. The pcap._pcap case was checked the same way across 3.10 through 3.14 and behaves identically.

Both stubs do register every submodule they need — tests/interface/test_core.py:361-362 for pcapfile and its three submodules, and the is_pcap_ct branch for pcap._pcap.

Worth noting that the test already documents the requirement that does exist here, which is a different one — the parent needs the submodules bound as attributes, because the engine reaches them through the package object it stored in _expkg rather than by importing them again:

# Both bindings are needed: ``import pcapfile.savefile`` is satisfied by
# sys.modules, but the engine then reaches the submodules as *attributes*
# of the package it stored in ``_expkg``.

That is why package.savefile, package.linklayer and package.structs are assigned. Adding __path__ would address a failure mode that cannot occur while leaving that real one — already handled — unremarked.

tests/interface/test_core.py: 13 passed, 7 subtests passed.

@JarryShaw
JarryShaw merged commit b630a1b into main Sep 16, 2026
24 checks passed
JarryShaw added a commit that referenced this pull request Sep 16, 2026
…ted page (#419)

* docs: record the protocol, reassembly and crypto gaps on the Help Wanted page

Catalogues what PyPCAPKit does not yet implement, so the gaps are tracked
rather than rediscovered. Every entry below was verified against the code at
5e4378d; nothing here is inferred from documentation.

New sections in docs/source/pep.rst
-----------------------------------

* "DTLS" (under More Protocols, More!!!) -- no dissector and no stub anywhere
  in the tree; only TLS/SSL has a stub. The registries have moved ahead of it:
  SCTP DTLS chunk type 65 (pcapkit/const/sctp/chunk.py:77), DTLS Key Management
  chunk parameter 0x8006 (const/sctp/parameter.py:86), error causes 100-103
  (const/sctp/cause_code.py:62,66,70,74), and seven PPIDs -- 47, 66, 67, 68, 69
  and 4242 (const/sctp/payload_protocol_identifier.py:181,240,243,246,249,265).
  DTLS blocks NGAP over DTLS (PPID 66) independently of NGAP itself.

* "Registered, But Not Dissected" (under More Protocols, More!!!) -- registry
  values with no dissector behind them, all counts measured by importing the
  worktree copy:
    - LinkType 3 of 219 bound (ETHERNET 1, IPV4 228, IPV6 229), declared twice:
      pcapkit/protocols/misc/pcap/frame.py:89-96 and misc/pcapng.py:555-562
    - TransType 15 of 151 bound, pcapkit/protocols/internet/internet.py:89-107
    - EtherType 6 of 160 bound, pcapkit/protocols/link/link.py:74-87
    - AppType 2 port numbers of 8182 -- TCP 21, TCP 80 (transport/tcp.py:309-315)
      and UDP 80 (transport/udp.py:72-78)
    - SCTP PPID 0 of 75; SCTP.__proto__ ships empty, transport/sctp.py:344-347,
      and no in-tree caller of register_sctp exists
  Cheap wins named: OSPF (link/ospf.py:70) and L2TP (link/l2tp.py:76) are
  implemented but bound to nothing (TransType 89, 115 unbound; both make
  __index__ raise at ospf.py:229-237 / l2tp.py:231-239); FTP_DATA
  (application/ftp.py:195) exists but TCP 20 is unbound; HTTP is bound on port
  80 only; EtherType 0x88A8 unbound though VLAN parses that shape; LinkType
  NULL/LOOP/RAW carry bare IPv4/IPv6 which pcapkit dissects.

* "Reassembly Beyond IP and TCP" (new top-level section) -- SCTP has no
  reassembly; grep -rni sctp pcapkit/foundation/ returns nothing.
    - The DATA chunk fields a reassembler needs are all exposed already:
      tsn/stream_id/stream_seq/ppid/data and flags.B/flags.E at
      protocols/transport/sctp.py:1143-1157; the only readers of B/E are in
      _make_chunk_data (sctp.py:1725-1726), i.e. the construction path.
    - Only the first DATA chunk of a bundle is dispatched, sctp.py:517-524.
    - I-DATA (RFC 8260, chunk 64) is not dissected -- SCTP.__chunk__[Chunk(64)]
      is 'donone'; read() matches only Payload_Data (sctp.py:520), so an
      I-DATA-only packet yields no payload at all. FORWARD TSN 192 and
      I-FORWARD-TSN 194 likewise unhandled.
    - Where the work goes: pcapkit/foundation/reassembly/sctp.py, subclassing
      Reassembly and implementing reassembly() and submit() (the only two
      abstract methods; reassembly/reassembly.py:180-195). Registering it is
      inert today -- Extractor.register_reassembly accepts any name
      (extraction.py:396-416) but Extractor instantiates only three literal
      keys (extraction.py:840,848,856) and ReassemblyManager is a fixed-field
      Info container (reassembly/__init__.py:37-49). Also needs the read-side
      twin (reassembly/data/__init__.py:37-48), five toolkit adapters, five
      engine call sites, and interface/core.py:148-171.
    - Flow tracing is TCP only (Extractor.__traceflow__ has one key;
      TraceFlowManager one field). TCP tracer closes on FIN not RST -- rst is
      absent from traceflow/data/tcp.py:43-46 -- and flows are unidirectional
      (traceflow/tcp.py:99).
    - No reassembly timeout or eviction anywhere in pcapkit/foundation/; the
      buffer models carry no timestamp (reassembly/data/ip.py:29-51,
      data/tcp.py:29-61). 8191 + 65535 bytes preallocated per in-flight IP
      datagram id (reassembly/ip.py:82-88).
    - Engines: pyshark disables reassembly (engines/pyshark.py:207-213);
      pypcap (pypcap.py:295-306) and pcap_ct (pcap_ct.py:380-391) disable
      reassembly and traceflow; pypcapfile disables IPv6 (pypcapfile.py:225-229,
      toolkit/pypcapfile.py:348-350).

Corrections to text that was already on the page
------------------------------------------------

* ESP. "Only five encryption and five integrity algorithms" is right (verified:
  36 Cipher members, 15 Integrity, 5 entries each in CIPHER_SUITES at
  esp.py:405-426 and INTEGRITY_SUITES at esp.py:432-448) but two of the ten are
  the no-ops ENCR_NULL and NONE. The "not implemented" list was incomplete;
  added AES-CTR (RFC 3686), AES-CMAC-96 (RFC 4494), the AES-GMAC family
  (RFC 4543) with ENCR_NULL_AUTH_AES_GMAC, and the Camellia, RFC 8750
  implicit-IV and RFC 9227 MGM families. Added the two structural blockers:
  IntegritySuite records the digest as a hashlib constructor name
  (esp.py:360-362, 687-693) so a non-HMAC MAC needs the record widened, and
  load_cryptography imports only the ciphers submodules (esp.py:223-234).

* Mobility Header. The three counts are accurate (24 messages / 14 dispatched,
  71 options / 20, 4 CGA extensions / 1; tables at mh.py:527, 552, 583) but the
  claim that the "# TODO markers sit at the exact dispatch tables" was wrong --
  the six markers are at mh.py:1510, 2310, 2422, 2986, 3757, 3871, i.e. the end
  of each handler block. Both places need editing. Also noted that read and make
  are symmetric (37 each) so every gap is a gap in both directions, and that
  mh.py:32-78 imports 32 const/mh sub-registry enums it never uses -- the
  enumerations for the missing options are already generated.
  "NEMO" dropped from the option-group description: Mobile Network Prefix
  (option 6) is implemented, while DNS-UPDATE-TYPE (17), Vendor Specific (19)
  and Service Selection (20) are missing and belong to none of the named groups.

* Fixed one pre-existing rendering bug in the Logging Integration bullet list: a
  literal nested inside bold. RST has no nested inline markup and does not warn,
  so the backticks were rendering as visible characters.

Checked and confirmed still accurate, so deliberately left alone
----------------------------------------------------------------

* The NotImplemented stub list -- 33 files, all 0 bytes except
  link/NotImplemented/eapol.py which holds one comment line. Layer grouping in
  the page matches the tree exactly, including NDP being under link/ and QUIC
  having no stub.
* SCTP registered-but-unimplemented type codes: 30 chunk types / 13 in
  __chunk__, 32 parameters / 8, 23 cause codes / 13 -- so 17, 24 and 10 missing,
  exactly as the page says.
* Test suite: 91 modules matching tests/test_*.py.
* The warnings double-report item: pcapkit/utilities/warnings.py documents the
  two-channel emission as deliberate, and warn() does still log and then call
  warnings.warn, so an application routing warnings into logging sees each twice.
* ESP/SCTP/PCAPNG/logging/engines all genuinely done, as the page states.
* No read/make asymmetry anywhere: NotImplementedError appears only in
  utilities/exceptions.py definitions, and every UnsupportedCall in
  pcapkit/protocols/ is a deliberate design decision (extension headers with no
  payload of their own, container classes with no registry index, abstract
  bases).

Code defects found while auditing, not fixed here -- to be filed as issues
-------------------------------------------------------------------------

1. __proto__ registry pollution. protocol.py:1300 does self.__proto__[proto] on
   a defaultdict, so a lookup miss inserts the key; internet/internet.py:248 and
   internet/ipv6.py:409 repeat it. Reproduced: parsing one 24-byte IPv4 packet
   with proto=1 inserts TransType.ICMP -> Raw into the class-level (shared)
   Internet.__proto__, after which register_transtype(TransType.ICMP, ...) warns
   "protocol 1 already registered, overwriting". SCTP._import_next_layer
   overrides the base method precisely to avoid this and documents the bug at
   sctp.py:818-831 -- a ready-made template for Frame, PCAPNG, Link, Internet,
   IPv6, TCP and UDP.
2. beholder's Raw fallback breaks on payload-less schemas.
   utilities/decorators.py:137 calls self.__header__.get_payload(), which raises
   ProtocolUnbound (schema/schema.py:461) for SCTP, whose schema has no payload
   field. Any registered SCTP upper layer that throws takes the whole SCTP layer
   down to Raw. Already fixed on the open NGAP branch (PR #417), so main only.
3. register_apptype cannot register an SCTP-only service. It raises RegistryError
   (foundation/registry/protocols.py:633-634) for the ~41 AppType members whose
   proto is SCTP-only or DCCP-only, and register_sctp is PPID-keyed, so there is
   no alternative path. Intent unclear.
4. ESP AEAD dispatch falls through. esp.py:743-756 and 782-806 treat anything
   that is not ENCR_NULL or ENCR_AES_CBC as AES-GCM. Safe today because
   CIPHER_SUITES holds nothing else, but adding AES-CCM or ChaCha20-Poly1305 to
   the table without touching decrypt/encrypt would silently mis-process them.
5. Cipher.get() / Integrity.get() invent members for an unknown string via
   extend_enum(..., -1) (const/esp/cipher.py:229-233, integrity.py:118-122).
   ESP's own _resolve avoids them, so not reachable through ESP.
6. vendor/pcapng/filter_type.py:25 has DATA = {} with a TODO URL, so the
   generated enum has no members at all.
7. DPKT and Scapy trace output is affected by the mapping-vs-frame defect that
   extraction.py:879-881 documents and excludes. PR #412 is in flight over that
   area.

Docs build: 91 WARNING/ERROR lines before this change. The after-count is still
running at the time of this commit and is NOT yet confirmed; re-run
`PCAPKIT_SPHINX=1 PYTHONPATH=. python -m sphinx -b html docs/source /tmp/out`
and compare before treating this as verified.

Not done yet: the summary posts to discussion #106 (one reply under the "More
Protocols, More!!!" topic comment 2859991, one new top-level comment for the
reassembly theme, which no existing topic covers) and the DTLS note under #251.
Nothing has been posted to either discussion.

* docs: reconcile the performance section with the profiling pass that answered it

"Maybe Even Faster?" closed by asking for "a measured benchmark showing where the
time actually goes". That benchmark now exists (#420) and cut extraction on a
1117-frame HTTP capture by ~46% with byte-identical output, so the section asks
for something already delivered.

Rewritten to record what was found instead: the four hot-path defects that were
fixed, the three larger wins deliberately left for their own review (the flow
dumper reopening its output per frame, `analyze()` running eagerly on
unfragmented frames, and every option being parsed twice), and the two
measurement traps that make a naive benchmark of this library meaningless --
`reassembly=`/`trace=` are no-ops without `ip=`/`tcp=`, and timing several
capture shapes in one process inflates them by up to 73%.

Two predictions the profiling contradicted are recorded as such, since both are
the sort of thing a contributor would otherwise assume and spend a day on:
`Info` construction, the usual suspect, is 0.0% of its own cost and 4.4% of a
parse; and the logging integration is 0.03% even in the heaviest shape. The
IO-batching idea the original thread proposed was never the bottleneck.

Whether unfragmented frames should be emitted as datagrams at all is left as an
open design question rather than answered here.

Clean build: 91 warning/error lines, unchanged from this branch's baseline, none
naming pep.rst.
@JarryShaw
JarryShaw deleted the feat/engine-constants branch September 17, 2026 01:06
@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.

2 participants