Skip to content

protocols: bind FTP-DATA, HTTP-alt, OSPF, L2TP and the 802.1ad S-Tag, and fix four dispatch defects - #436

Merged
JarryShaw merged 7 commits into
mainfrom
feat/protocol-bindings-and-stag
Sep 17, 2026
Merged

JarryShaw merged 7 commits into
mainfrom
feat/protocol-bindings-and-stag

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes the port-binding, VLAN S-Tag and OSPF/L2TP items from the owner's list.

What is bound, and on whose authority

Port numbers and service names from IANA's Service Name and Transport Protocol Port Number Registry (CSV fetched today); protocol numbers from IANA's Protocol Numbers registry.

binding authority
TCP 20 → FTP_DATA ftp-data, "File Transfer [Default Data]"
TCP 8080 → httpv1.HTTP http-alt, "HTTP Alternate (see port 80)"
UDP 8080 → http.HTTP same
UDP 1701 → L2TP l2tp
TransType 89 → OSPF OSPFIGP (:rfc:2328)
EtherType 0x8100C_Tag, 0x88A8S_Tag IEEE 802.1Q / 802.1ad

Three bindings declined, with reasons rather than omission. Port 8443 is registered as pcsync-https, not an HTTP alternate; real traffic there is TLS, and application/NotImplemented/tls.py is empty, so binding it would feed a TLS record to an HTTP parser. TransType 115 references :rfc:3931 (L2TPv3 over IP), a different session header from the :rfc:2661 v2 framing this dissector implements — make() hardcodes version=2. And FTP on UDP 20/21 is IANA-registered but is not where FTP actually runs.

The implemented application set is only FTP, FTP_DATA, HTTPv1, HTTPv2 and NGAP; NGAP is already bound on SCTP PPID 60/66 and IANA registers it on sctp alone. Everything else under NotImplemented/ is a zero-byte file, so there was nothing further to bind.

VLAN: an abstract base, and the reason is QinQ

VLAN becomes an abstract base carrying the whole tag — read, make, _make_data, length — with C_Tag and S_Tag adding only name, alias, info_name and id(). It is abstract for the same mechanical reason IP is: it does not define name, which ProtocolBase declares abstract, so no new abstractmethod was needed.

The layouts really are identical — only the TPID differs — so the justification is QinQ, with one refinement worth recording: a single class bound at both EtherTypes would not collide, it would nest, giving ethernet.c_tag.c_tag with nothing to say which was the service tag. Distinct info_name is what fixes that.

Two gotchas found on the way. __init_subclass__ resolves an omitted schema=/data= by looking the subclass name up in pcapkit.protocols.schema and assigns unconditionally, so both tags must restate them or silently receive Schema_Raw. And pcapkit/protocols/__init__.py rebuilds __proto__ from its own __all__, overwriting auto-registration, so both names had to be added there too.

Four defects fixed, none of them in the brief — and OSPF had never worked

  • vlan.py:121 read dei=bool(tci['pcp']) instead of tci['dei'], so drop-eligibility was wrong whenever it disagreed with priority. The existing test passed only because it pinned pcp=EE, dei=True.
  • ospf.py:133 used schema = self.__schema__ — the schema class — where every sibling uses self.__header__. Any input raised TypeError: unsupported operand type(s) for -: 'UInt16Field' and 'int', verified on main. So OSPF has never parsed anything, which is why binding it needed fixes rather than a table entry.
  • ospf.py's alias/name read self._info, but _info is not assigned until read() returns, while _decode_next_layer reads alias during read(). Now held on _version, mirroring ARP._acnm.
  • ospf.py and l2tp.py dispatched the remaining payload length as the proto positional. That resolved to Raw only because lengths rarely collide with a registered EtherType — a 2048-byte body would have parsed as IPv4. Both now use the -1 sentinel, as arp.py:205 does.

The existing OSPF tests had masked two of these by assigning an instance to __schema__ and mocking a two-argument _decode_next_layer; both are corrected.

Verification

Suite 891 passed / 18 skipped / 869 subtests against a baseline of 876 / 18 / 852 — exactly +15 tests and +17 subtests, no regressions. The baseline was built with git clone, not git archive: an archive has no .git, so test_tier_guard.py skips ~19 there and inflates the count — which produced a wrong first measurement before it was caught.

Captures byte-identical, 45 files (15 × tree/json/reassembly), by both diff -r and an md5 manifest, with the inputs verified identical first. make_samples.py regenerates byte-identically. mypy identical at 128 errors across 41 files. pylint: zero new messages and one pre-existing long line removed. Sphinx: 85 warnings, identical sets, exit 0 both trees.

No capture output changed, and that was checked rather than assumed. A scan of all 1,339 frames found zero VLAN tags, zero OSPF or L2TP packets and no traffic on ports 20, 1701, 8080 or 8443 — every new code path is unreachable from this corpus. The deliberate changes are therefore demonstrable only on synthetic frames:

QinQ frame    before: Ethernet:IEEE_Std_802_1Q_Service_VLAN_tag_identifier   (all Raw)
              after:  Ethernet:802.1ad:802.1Q:IPv4:TCP:Raw
C-Tag dei=0   before: c_tag: pcp=3 dei=True  vid=200      <- wrong
              after:  c_tag: pcp=3 dei=False vid=200

All registry reads go through ProtocolBase._lookup_registry, and two tests assert no registry grows, including when parsing an unregistered port.

Left alone deliberately

OSPF and L2TP living under protocols/link/ is a misclassification — both report layer == 'Link' while being carried inside IP and UDP respectively. Moving them changes public import paths, so they stay put; it is inert for layer-limited extraction because IPv4/IPv6 terminate an internet extraction first. Documented in both modules and in pep.rst.

http.HTTP's explicit version= path is broken independently of this change: it passes an already-drained file object, so version=1 raises ValueError and version=2 raises KeyError. Only the auto-guess path works, which is why UDP 80 and 8080 function. UDP therefore still points at http.HTTP and TCP at httpv1.HTTP; repointing would change existing output and wants its own change. Recorded in pep.rst.

Also noted, all pre-existing: Extractor never closes its input handle, surfacing as a ResourceWarning attributed to the wrong capture; and AppType.get(80, proto='tcp') returns www_http rather than http because __members_proto__ is last-definition-wins. tcp.py's docstring claimed port 80 → http.HTTP while the code bound httpv1.HTTP; that one is fixed here.

…ables that reach them

Split VLAN into an abstract base plus concrete tags, and fill in the dispatch
entries whose dissectors already existed but were reachable from no registry.

* ``VLAN`` becomes an abstract base holding the tag layout, with ``C_Tag``
  (802.1Q, ``0x8100``) and ``S_Tag`` (802.1ad, ``0x88A8``) as concrete
  subclasses. The two layouts are identical -- the TPID that tells them apart
  belongs to the encapsulating header -- so the split is not about parsing but
  about Q-in-Q: both tags appear in one frame, and ``info_name`` is what keeps
  them distinct in the parsed output. A stacked frame previously collapsed into
  a single opaque ``Raw`` payload, losing the inner tag and everything under it.
* ``VLAN.read`` reported the DEI flag as ``bool(tci['pcp'])`` instead of reading
  its own bit, so it was wrong whenever priority and drop-eligibility
  disagreed. The existing test passed because it pinned a case where they did
  not.
* TCP 20 to ``FTP_DATA`` and 8080 to HTTP/1; UDP 8080 to HTTP and 1701 to
  ``L2TP``. Port numbers, service names and descriptions are IANA
  service-name-registry assignments. 8443 is deliberately left unbound: IANA
  registers it as ``pcsync-https``, and pcapkit implements no TLS.
* OSPF is bound at ``TransType`` 89. Three defects had kept it from parsing
  anything at all: ``read`` consulted the schema *class* rather than the parsed
  header, ``alias`` reached for an ``_info`` that does not exist until ``read``
  has returned, and the remaining payload length was dispatched as if it were a
  protocol code. L2TP shared the last of those; both now dispatch on the ``-1``
  sentinel, as ARP does.
* ``TransType`` 115 stays unbound: it references RFC 3931, i.e. L2TPv3 over IP,
  whose session header differs from the RFC 2661 v2 framing this dissector
  implements.

Every registry read goes through ``ProtocolBase._lookup_registry``, so a miss
does not grow the shared tables.

Suite 891 passed / 18 skipped, against 876 / 18 on main (+15 tests, +17
subtests). All 15 sample captures produce byte-identical tree and json output;
none of them contains a VLAN tag, an OSPF or L2TP packet, or traffic on any
newly bound port, so the new paths are covered by synthetic frames instead.
mypy unchanged at 128 errors in 41 files; pylint adds no message and drops one
over-long line.
Comment thread docs/source/pep.rst Outdated
@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewed at cf7f0f275 (head of feat/protocol-bindings-and-stag), replacing Copilot for this pass. Worked from a checked-out worktree at that sha plus a pristine git clone of main at 44aa38ae8 for before/after comparison, both on Python 3.14 via the project venv with PYTHONSAFEPATH=1 and explicit PYTHONPATH.

What I checked and ran:

  • Corpus scan for target traffic. Regenerated examples/captures/ on both trees (make_samples.py) and independently scanned all 15 capture files with dpkt (not pcapkit, to stay independent of the code under review): 1,338 frames by dpkt's count, 1,339 by pcapkit's own count (the one-frame gap is a deliberately malformed synthetic frame in test.pcapng that dpkt's stricter reader drops; pcapkit still counts it, and it decodes as RAW, not a target protocol). Zero VLAN tags, zero OSPF/L2TP, nothing on ports 20/1701/8080/8443 in either count. Confirms the "byte-identical, unreachable from this corpus" claim. Also diffed the two trees' generated captures with diff -r and md5sum — byte-identical.
  • Synthetic-frame probes, run against both trees: a QinQ frame (S-Tag containing C-Tag), OSPF-in-IPv4, L2TP-on-UDP/1701, and FTP-DATA-on-TCP/20. All four match the PR body's claimed before/after exactly — e.g. QinQ goes from Ethernet:802.1ad(all Raw) on main to Ethernet:802.1ad:802.1Q:IPv4:TCP:Raw on the branch, with s_tag/c_tag as distinct, correctly-nested keys.
  • Each of the four fixed defects, reproduced directly against main, not just read off the diff: VLAN's dei bug reads bool(pcp) and gives the wrong answer in both directions when pcp/dei disagree (confirmed both ways); constructing OSPF directly on main raises TypeError: unsupported operand type(s) for -: 'UInt16Field' and 'int' on any input, verbatim as claimed; isolating just that bug (monkeypatching only the schema access) surfaces the second bug underneath it, AttributeError: 'OSPF' object has no attribute '_info', from alias/name reading _info while read() is still running; and for the length-as-proto bug, constructing L2TP on main with a 2048-byte payload that itself parses as a plausible IPv4 header produces L2TP:IPv4:TCP — a genuine misparse, not just a latent risk — while the branch produces L2TP:Raw for the same input via the -1 sentinel.
  • Declined bindings: confirmed directly against the branch's dispatch tables that TransType.L2TP (115) is absent from Internet.__proto__, and 8443 is absent from both TCP.__proto__ and UDP.__proto__; TCP.__proto__/UDP.__proto__ keys are exactly {20,21,80,8080}/{80,1701,8080}, matching the claimed FTP/HTTP-alt/L2TP scope with nothing extra.
  • VLAN ABC traps: confirmed VLAN.__abstractmethods__ == {'name'}, that C_Tag/S_Tag each explicitly restate schema=/data= (and that ProtocolBase.__init_subclass__ really does resolve an omitted one by subclass-name lookup with unconditional assignment, so the restatement is load-bearing, not defensive), and that both are present in pcapkit/protocols/__init__.py, pcapkit/protocols/link/__init__.py, and pcapkit/all.py's __all__ lists (the registry-rebuild trap named in the body). The QinQ test yields s_tag and nested s_tag.c_tag with distinct info_names, not c_tag.c_tag.
  • Registry-growth: _lookup_registry/_lookup_next_layer are the only paths the new code uses to reach __proto__; no bare registry[code] in the touched files. Ran tests/protocols/test_dispatch_bindings_unit.py and the updated tests/protocols/link/test_link_unit.py directly (34 tests, all pass, including the two registry-non-growth tests and the OSPF/L2TP dispatch tests).
  • Full test suite, both trees: branch 892 passed / 17 skipped / 869 subtests (605s) vs. baseline 877 passed / 17 skipped / 852 subtests (590s) — zero failures on either side. My absolute counts run one higher on "passed" and one lower on "skipped" than the PR's stated 891/18 and 876/18 on both trees equally (almost certainly one environment-conditional skip that runs here), but the deltas match the PR body exactly: +15 passed, +17 subtests, identical skip count, no regressions.

One finding posted inline (cosmetic, non-blocking): the pep.rst port-count tally at line 227 says "6 port numbers" bound via AppType, but the actual union of TCP.__proto__/UDP.__proto__ port numbers is 5 ({20,21,80,1701,8080}), and 7 if counting (port, transport) pairs instead — 6 doesn't match either convention, including the one the same paragraph's "before" line uses correctly.

Nothing else turned up. I could not find a case where the new bindings, the declined bindings, or the four fixes disagreed with what the PR body claims for them.

Verdict: good to merge, once the one-line pep.rst count is fixed (or left as a known nit — it's documentation prose, not code).

…he L2TP versions

Follows the project's existing convention, which the ARP family already encodes:
a protocol with its own ``__index__`` gets its own module, and siblings may share
one only when they share an index. ``InARP`` shares :mod:`~pcapkit.protocols.link.arp`
because it inherits ``ARP``'s index; ``RARP`` declares a different index and so
has :mod:`~pcapkit.protocols.link.rarp`, which ``DRARP`` then shares.

* ``C_Tag`` and ``S_Tag`` move to ``link/c_tag.py`` and ``link/s_tag.py``, and
  each now **declares the EtherType it is reached by** -- ``0x8100`` and
  ``0x88A8``. That declaration was the missing piece: both were bound in
  ``Link.__proto__`` as distinct EtherTypes while inheriting a ``__index__``
  that raised. The ``VLAN`` base keeps raising, which is correct for an abstract
  protocol nothing dispatches to, and keeps the shared tag layout.
* ``L2TP`` becomes an abstract base and ``L2TPv2`` carries the RFC 2661
  implementation, in ``link/l2tpv2.py``. What existed was v2 only, presented as
  though it were L2TP in general. The base holds no header parsing at all, in
  the way ``internet.ip.IP`` holds none: the versions genuinely do not share a
  header, only the version nibble in the first 16-bit word. UDP 1701 now binds
  the concrete class.
* ``OSPF.__index__`` returns ``TransType.OSPFIGP`` instead of raising -- the same
  gap as the VLAN tags, since it is dispatched from ``Internet.__proto__`` at 89.

``TransType`` 115 stays unbound, and the reason is now structural rather than
incidental: it is L2TPv3 (RFC 3931), and there is no ``L2TPv3`` class for it to
point at. 115 is also the first index anything in the family would carry, so v3
gets its own module when written. Recorded in the module and in ``pep.rst``.

Neither ``L2TP`` nor ``L2TPv2`` declares an index: v2 is reached by a UDP *port*,
and a port is not an ``__index__`` value anywhere here -- every non-raising
``__index__`` returns a ``TransType``, ``EtherType`` or ``LinkType``, and
``Application.__index__`` raises for that reason.

``id()`` follows the HTTP family: canonical name first, then the version- or
variant-flavoured alias, since callers take element zero as canonical. ``L2TP``
and ``L2TPv2`` both return ``('L2TP', 'L2TPv2')``; the ``VLAN`` base claims
``('VLAN', 'C_Tag', 'S_Tag')`` while each tag keeps its own name canonical, as
the tags are distinct protocols rather than versions of one.
``info_name`` is declared on the ``L2TP`` base so a consumer finds the datagram
under ``l2tp`` whichever version was on the wire; the version is reported by
``alias`` instead.

The follow-up stream implementing L2TPv3 and L2F has what it needs written into
the base's docstring, including that ``Ver == 1`` selects **L2F** (RFC 2341), a
separate protocol, to be named ``L2F`` with ``L2TPv1`` only as an ``id()`` alias.

Suite 915 passed / 18 skipped, against 898 / 18 on main (+17 tests, +16
subtests). All 15 sample captures produce byte-identical tree, json and
reassembly output; ``make_samples.py`` regenerates byte-identically. mypy
unchanged at 128 errors in 41 files; pylint adds no message and still drops one
over-long line.
Comment thread docs/source/pcapkit/protocols/link/vlan.rst Outdated
Comment thread pcapkit/protocols/link/vlan.py Outdated
Comment thread pcapkit/protocols/link/ospf.py Outdated
…-in-Q illustration

Review feedback on #436, all cosmetic.

* The module-docstring heading rule now runs three characters past the end of the
  title in ``vlan.py``, ``c_tag.py``, ``s_tag.py`` and ``l2tpv2.py``. Since the
  title starts at source column 3, after the ``\"\"\"``, that leaves three rule
  characters either side of it -- the title centred on the rule, which is what
  172 of the package's 189 module headings already do, ``arp.py`` and ``rarp.py``
  among them. ``l2tp.py`` already matched, having kept its original rule.
* The Q-in-Q illustration in ``vlan.rst`` puts every ``=`` in one column, and the
  ``<-`` annotations in another.
* Drops the note on ``OSPF.__index__`` recording that it used to raise. The
  behaviour stays -- it returns ``TransType.OSPFIGP``, since OSPF is dispatched
  from ``Internet.__proto__`` at 89 -- and the docstring now has the same shape
  as ``ARP.__index__``. The two ``read`` comments explaining the ``-1`` sentinel
  are left alone: those are source comments where the history *is* the
  explanation, not published API documentation where it is noise.

No behaviour change. All 45 capture output files byte-identical to origin/main,
``make_samples.py`` regenerates byte-identically, suite 915 passed / 18 skipped
unchanged, mypy 128 errors in 41 files unchanged, pylint unchanged.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review at head c8b2f6822 (c8b2f68226672f273ed90d54faef4bb6341b156d)

Replacing Copilot for this pass. Reviewed the current head, not the delta from the earlier cf7f0f275 verdict — the module split and a cosmetic round landed since then.

What I checked

  • CI: gh pr view 436 --json statusCheckRollup. All substantive legs are green: Unit Tests and Integration for Python 3.10–3.15, Python Compatibility 3.10–3.15, CodeQL, pyup.io/safety-ci, and deploy-pages (i.e. the Sphinx build itself succeeded). Docs test gate and Gate (full suite, Python 3.14) show SKIPPED, not failing — no red checks anywhere.
  • Drift: git rev-list --count HEAD..origin/main = 0 in my worktree, so origin/main reads are current. Fetched refs/pull/436/head into a local ref and confirmed its sha matches c8b2f68226672f273ed90d54faef4bb6341b156d exactly before reading anything from it. All "on main" claims below are git show origin/main:<path>; all "on this PR" claims are git show <that ref>:<path> or a real checkout of it — never the ambient working tree.
  • Full diff via gh pr diff 436 (2916 lines).

Registry/dispatch bookkeeping, checked against the actual tables

  • Internet.__proto__ gains exactly one entry, Enum_TransType.OSPFIGP (=89, confirmed in pcapkit/const/reg/transtype.py), and no such entry existed before — 15 → 16 bindings, matching the "16 of the 151" in pep.rst.
  • Link.__proto__ goes from {ARP, RARP, VLAN, IPv4, IPv6, IPX} (6) to {ARP, RARP, C_Tag, S_Tag, IPv4, IPv6, IPX} (7), matching "7 of the 160" in pep.rst. EtherType.Customer_VLAN_Tag_Type = 0x8100 and IEEE_Std_802_1Q_Service_VLAN_tag_identifier = 0x88A8, matching what's bound.
  • TCP.__proto__ = {20, 21, 80, 8080}, UDP.__proto__ = {80, 1701, 8080} — 7 bindings over 5 distinct ports, matching pep.rst's "7 bindings over 5 port numbers." No duplicate/colliding keys in either dict literal.
  • The three declines hold up: TransType.L2TP = 115 in code and is RFC 3931 (v3) by IANA's own registry text, a different session header from the RFC 2661 v2 this PR implements — no L2TPv3 class exists anywhere in the tree, so the binding really does have nowhere to point yet. Port 8443 is IANA pcsync-https, not an HTTP alternate, and application/NotImplemented/tls.py is empty — binding it would hand a TLS record to an HTTP parser, so leaving it unbound is correct, not a gap.
  • __init_subclass__ trap (pcapkit/protocols/protocol.py): confirmed it resolves an omitted schema=/data= via getattr(schema_module, cls.__name__, Schema_Raw) — by the subclass name, unconditionally, no inheritance. C_Tag, S_Tag and L2TPv2 all correctly restate schema=Schema_VLAN, data=Data_VLAN / schema=Schema_L2TP, data=Data_L2TP; omitting them would have silently bound the Raw pair exactly as the inline comments warn.
  • pcapkit/protocols/__init__.py rebuild trap: confirmed register_protocol() (called from __init_subclass__) writes into the same module-level __proto__ dict that the bottom of pcapkit/protocols/__init__.py then unconditionally reassigns from its own __all__ list. C_Tag, L2TPv2, S_Tag are correctly added to __all__ in pcapkit/protocols/__init__.py, pcapkit/protocols/link/__init__.py, and pcapkit/all.py. pcapkit/__init__.py (the curated family-heads list) is untouched, and that's consistent — it already excluded InARP/DRARP/HTTPv1 before this PR, so VLAN/L2TP staying as the listed heads is the existing convention, not an oversight.
  • No dispatch code added by this PR reads a registry other than through _decode_next_layer_import_next_layer_lookup_next_layerProtocolBase._lookup_registry. The PR's own test_dispatch_lookups_do_not_grow_the_shared_registries and test_parsing_an_unregistered_port_does_not_grow_the_registry assert this and both pass (see below).
  • Backward compatibility of protocol='VLAN': ProtocolBase._check_term_threshold builds comp_test from self.id() on the instance. C_Tag.id() = ('C_Tag', 'VLAN'), S_Tag.id() = ('S_Tag', 'VLAN'), so extraction termination on protocol='VLAN' still matches either concrete tag. Grepped the whole tree (PR head) for direct vlan.VLAN(...) construction elsewhere — none exists, so nothing depends on VLAN being concrete.

The four defects — reproduced independently, not just read

  • OSPF TypeError: built a raw OSPFv2 Hello header and did OSPF(io.BytesIO(raw), len(raw)) directly against a worktree of origin/main (bypassing dispatch entirely, since 89 isn't even bound there). Got exactly:
    TypeError: unsupported operand type(s) for -: 'UInt16Field' and 'int'
    
    Root cause confirmed by reading ospf.py on main: schema = self.__schema__ reads the class, so schema.length is a UInt16Field descriptor, not a parsed value. This fully substantiates "OSPF has never parsed anything."
  • -1 sentinel bug: confirmed by diff that both ospf.py and (pre-split) l2tp.py called self._decode_next_layer(x, length - hdr_len) with only two positional arguments, against a base signature _decode_next_layer(self, dict_, proto, length=None, ...) where proto is mandatory position 2. The leftover byte count was therefore bound to proto, not length. Nice bit of confirmation: a 2048-byte remaining payload equals 0x0800, i.e. EtherType.Internet_Protocol_version_4 — exactly the PR body's illustrative claim about a 2048-byte body misparsing as IPv4, and not a coincidence. The fix (arp.py:205's -1 sentinel pattern) is pre-existing precedent, confirmed present on origin/main already.
  • DEI bug: confirmed origin/main:pcapkit/protocols/link/vlan.py:121 reads dei=bool(tci['pcp']). Fixed to tci['dei'] here, and the new test pins priority/DEI in disagreement so the fix can't hide behind a coincidentally-matching test fixture again.
  • alias/name read _info before read() assigns it: confirmed the ordering constraint (_decode_next_layer reads self.alias while read() is still running, and _info isn't set until read() returns) and that the fix (self._version, set first thing in read()) mirrors the pre-existing ARP._acnm pattern exactly, including the same limitation that name/alias are unavailable on a purely-make()'d instance that never went through read() — which is already true of ARP today, so not a new gap.

Ran the PR's own tests against the actual PR-head tree

Cloned via git worktree add --detach <path> pr436 (ref pinned to the exact head sha above), then:

PYTHONSAFEPATH=1 PYTHONPATH=<worktree> /local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/python -c "import pcapkit; print(pcapkit.__file__)"
# -> <worktree>/pcapkit/__init__.py, confirming the tree under test

Interpreter: /local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/python, 3.14.7.

  • tests/protocols/test_dispatch_bindings_unit.py — 11/11 pass, including the Q-in-Q stacked-tag acceptance test (Ethernet:802.1ad:802.1Q:IPv4:TCP:Raw with s_tag/c_tag both present and distinct), OSPF-over-protocol-89 (Ethernet:IPv4:OSPFv2:Raw, fields correctly parsed), and L2TPv2-over-UDP-1701 (Ethernet:IPv4:UDP:L2TPv2:Raw).
  • tests/protocols/link/test_link_unit.py — 25/25 pass.

Not independently verified (time/scope)

  • The claimed suite totals (915/18/949 vs. baseline 898/18/933), the 45-file byte-identical capture/md5-manifest claim, the mypy (128/41 files) and pylint (4572 vs 4573, 8.77/10) deltas, and the Sphinx byte-identical-warning-set claim. These need the gitignored examples/captures/ corpus and a baseline tree side-by-side, which is outside what I reproduced here. CI's own green run across every Python version (unit + integration + compat) and a successful deploy-pages build are consistent with these but don't themselves prove the byte-for-byte counts.
  • The full corpus scan for VLAN/OSPF/L2TP frames and ports 20/1701/8080/8443 — didn't regenerate or scan examples/captures/.
  • One thing for the record, not a PR finding since it's not in the PR body/diff: an independent count of __index__ on origin/main gives 7 raising and 14 (not 13) Enum_TransType-returning implementations (ah, esp, hip, hopopt, ipv4, ipv6, ipv6_frag, ipv6_opts, ipv6_route, ipx, mh, sctp, tcp, udp), plus 2 Enum_EtherType and 1 Enum_LinkType. Off by one from a count quoted to me out of band, but it doesn't change the conclusion: no protocol reached by a port has a non-raising __index__ anywhere in the tree, which is exactly why L2TPv2.__index__ raising (port 1701) and OSPF.__index__ now returning (protocol number 89, not a port) are both the right call.

Verdict

No defects found in this pass. Good to merge.

@JarryShaw
JarryShaw merged commit f50436a into main Sep 17, 2026
24 checks passed
@JarryShaw
JarryShaw deleted the feat/protocol-bindings-and-stag branch September 17, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant