Repository navigation
feat(layer2): decode MPLS label stacks (RFC 3032 §2.1) - #252
Conversation
The registry's own examples, README.md, ARCHITECTURE.md, docs/CLAIMS.md and tests/test_registry.py all used EtherType 0x8847 (MPLS) as the "protocol this library does not implement" a third party would register. MPLS is about to ship as a built-in decoder (#187), after which that registration raises RegistryConflictError. Every stand-in now uses 0x88B5, which IANA's ieee-802-numbers registry (fetched 2026-10-25) lists as "IEEE Std 802 - Local Experimental Ethertype", with a hypothetical Telemetry class, as the SCTP work moved the ip.proto stand-ins to RFC 3692's 253. ARCHITECTURE no longer names MPLS as unimplemented. tests/test_docs.py now executes the example in each of the three documents and checks it registers on a free key, so the next built-in to take a stand-in's number fails a test instead of leaving a broken example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
EtherTypes 0x8847 ("MPLS") and 0x8848 ("MPLS with upstream-assigned
label", RFC 5332 §4; IANA's names) now dispatch to netprotocols.MPLS,
one layer holding the whole label stack as a tuple of MPLSLabel
entries: label, traffic_class (RFC 5462), bottom_of_stack and ttl.
Decode reads entries in place until the first with the S bit set, so
the stack bounds itself and bytes(MPLS.decode(x)) == x[:header_len];
a stack whose S bit never comes raises TruncatedHeaderError with
field="labels" and the offset of the missing entry, in time linear in
the buffer. Construction requires S on the last entry alone, which is
all decode produces, so every buildable stack round-trips.
MPLS names no payload protocol, so next_protocol() works by
convention: a bottom label of 0 or 2 (IPv4/IPv6 Explicit NULL) names
IPv4 or IPv6, 13 (GAL) and 14 (OAM Alert) stop the walk, and otherwise
the first nibble after the stack is read as an IP version (RFC 4928),
so a pseudowire control word stops it. next_protocol() is handed no
bytes, so decode() records the nibble in a compare=False field, as BGP
records whether another message follows; a stack built rather than
decoded answers from its bottom label alone. The class comes from the
ethertype table at 0x0800/0x86DD, so registry and decode_as overrides
reach the labelled packet, and GRE's Protocol Type 0x8847 reaches MPLS
with no extra code (RFC 4023). MPLSLabel.label_name names the base
special-purpose labels as IANA's registry (updated 2026-06-30, fetched
2026-10-25) does.
_base.py and ARCHITECTURE.md no longer call BGP the one header that
answers from decode context. MPLS joins the fuzz suite (ALL_PROTOCOLS,
a round-trip strategy, accessor-safety and trailer properties), the
truncation and memoryview contract tests, the layout map, README's
coverage table and CONTRIBUTING's layout list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
The capture host's kernel has CONFIG_MPLS_ROUTING off, so nothing here can push a label. scripts/capture_fixtures_mpls.sh has the kernel send two ICMP echo requests and two UDP datagrams between two netns over a veth pair, offload off, captured inbound; scripts/mpls_fixture_packets.py (stdlib only) then wraps them in script-built Ethernet headers and label stacks and sends them through an AF_PACKET socket, and reads the capture back to confirm every byte arrived as built. The capture script fails unless it holds exactly the eight planned frames: one, two and three labels; an Entropy Label Indicator and entropy label; IPv4 and IPv6 Explicit NULL bottoms (the IPv6 datagram script-built, the IPv4 frame zero padded to 60 bytes); EtherType 0x8848 to a multicast MAC; and an Ethernet pseudowire with a control word, where the walk stops. MANIFEST.md says which bytes the script and the kernel built. scripts/check_fixtures.py walks each stack to its bottom entry in its own code and verifies the labelled packet's checksums, choosing IPv4 or IPv6 by an Explicit NULL label or the first nibble, and reads the Ethernet frame after the pseudowire's control word. ARP moved into a _check_arp helper so _check_l3 dispatches both through a table and stays at radon B. tests/test_mpls.py checks the captured chains and fields, recomputes every checksum through the stack, and holds the checker to failing a flipped byte in each frame's labelled packet. The corpus is 273 frames across 28 scenarios; README's "Why" sentence now names the 265 frames its figures were measured on at 2.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
The issue asks for a fixture with truncation, and every truncation test so far cut a hand-built stack. capture_fixtures_mpls.sh now runs a second tcpdump with a 20-byte snap length beside the full capture and checks that each record is the first 20 bytes of its full frame. The file, tests/fixtures/mpls/mpls_snaplen.pcap, sits outside the corpus's non-recursive globs, as bgp/bgp_session.pcap does, because a cut frame does not walk cleanly in strict mode. tests/test_mpls.py holds its six multi-entry stacks to TruncatedHeaderError from MPLS (field "labels", offset 4, frame offset 18) and its two one-entry stacks to a whole MPLS layer that hands a 2-byte IPv4 header to IPv4. MPLS's constructor now keeps any iterable of entries as a tuple, so a stack built from a list compares equal to its decode and stays hashable, as the class docstring claims. The checker's new helpers get PEP 257 docstrings, SECURITY.md says the payload convention is documented behaviour, and MANIFEST's row sizes the padded datagram by its payload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
An independent review of the MPLS branch (#187) found that scripts/check_fixtures.py's bottom-label branch was never exercised: in every corpus frame and test the bottom label and the payload's first nibble agree, so dropping the Explicit NULL rules, dropping OAM Alert (14) or shifting the label out by 11 bits instead of 12 all survived. tests/test_mpls.py now feeds the checker frames where the two disagree: IPv4 Explicit NULL (0, at traffic class 0 and 7) over an IPv6 packet fails the IPv4 header check, IPv6 Explicit NULL (2) over an IPv4 packet is read as IPv6, GAL (13) and OAM Alert (14) over an IPv4 packet stay unread, and Router Alert (1) defers to the nibble. Each of those mutants, and dropping 0, 2 or 13 alone, now fails the suite. tests/test_docs.py also runs the three register() examples in registry.py's docstrings, in a copy of the default registry, so they are held to free keys as the README, ARCHITECTURE and CLAIMS examples are. Documentation nits from the same review: - MANIFEST: the snap-length capture is cut at byte 20, 2 bytes past each stack's first entry (not "4 bytes into each label stack"), and the script built every outer Ethernet header (the pseudowire's inner one is the kernel's). - mpls.py: labels 0-15 are special-purpose per RFC 7274 §1, not §2; is_special_purpose and label_name note that they judge one entry alone, so they cannot see an Extended Special-Purpose Label after an Extension Label (RFC 7274 §3.1). - registry.py: the module docstring's table list and best-effort paragraph say MPLS looks up the ethertype table by convention, not by a wire field, as ARCHITECTURE.md does. - CHANGELOG: the MPLS entry tells users who registered their own class at 0x8847/0x8848 that it now raises RegistryConflictError and how to override it; the Development entry names the registry.py examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
…nits The independent checker reads a payload with first nibble 0 as an Ethernet pseudowire only when the bottom label is not GAL (13) or OAM Alert (14). Dropping that guard survived tests/test_mpls.py; the unread-unless-strict cases now include a control word under each label, which the guardless checker would walk as Ethernet. The IPv6 Explicit NULL test asserted the checker's exact stats, which pinned its silent pass of an IPv4 packet read as IPv6 (_check_ipv6 checks neither version nor payload length). It now asserts only the route: mpls-stack, ipv6 present, ipv4 absent. The >>11, >>13 and dropped-label-2 mutants still fail it. The README/ARCHITECTURE/CLAIMS register test now runs each example in a derived registry patched in as DEFAULT, like the registry.py examples test, instead of popping the flattened table and leaving the entry in DEFAULT's own. Docs: the CHANGELOG entry says a registry from from_defaults() or derive() raises too, adds ARCHITECTURE.md and docs/CLAIMS.md to the 2.5.0 documents whose register() example used 0x8847, and shows decode_as with both keys; registry.py no longer calls MPLS the only next_protocol() that is not a table lookup (VXLAN, DNSOverTCP and BGP return fixed successors); a MANIFEST line is re-wrapped to its neighbours' width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
CLAIMS 5.1: 273 frames across 28 scenarios, and the script-built MPLS stacks join the list of captures (the over-long line master carried there is re-wrapped). 5.2: MPLS joins the accessors that never raise (TestMPLSAccessorSafety), ALL_PROTOCOLS is 31 and TestGeneralizedRoundTrips 27. 5.3 and the pyproject comment: 4,304 statements, 1 missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEQCZGyuvpMyZwUbDucF3G
|
Caution Review failedThe pull request is closed. Walkthrough
Merge Risk:
|
Summary
Adds
netprotocols.MPLS, which decodes an MPLS label stack. EtherType 0x8847 (MPLS) and 0x8848 (MPLS with upstream-assigned label) both dispatch to it. The header has no next-protocol field, so the labelled packet is resolved by documented convention. A script-built fixture around kernel-built datagrams joins the corpus. Part of Tier 3 (#173).Closes #187
What's included
src/netprotocols/layer2/mpls.py:MPLSLabelholds one entry: label, traffic class (RFC 5462), S, TTL.MPLS.labelsholds the whole stack down to the entry with S set, as a single layer.Dispatch by convention. The reserved bottom labels decide first:
Otherwise the payload's first nibble decides: 4 is IPv4, 6 is IPv6, and anything else ends the walk. That covers a pseudowire control word (nibble 0) and an associated channel header (nibble 1).
next_protocol()is handed no bytes, sodecode()records the nibble in acompare=Falsefield, as BGP does for "another message follows"._base.py, ARCHITECTURE andregistry.pynow describe two decode-context exceptions instead of one.MPLS in GRE (RFC 4023) decodes with no extra code, since GRE reads the ethertype table. A test pins it.
Registry examples move off 0x8847. The README, ARCHITECTURE, CLAIMS and
registry.pyused@register("ethertype", 0x8847) class MPLSas the "add a protocol yourself" example. That would now raiseRegistryConflictError, so the examples move to 0x88B5 (IEEE 802 Local Experimental EtherType 1).tests/test_docs.pynow executes every one of those examples on a derived registry. The CHANGELOG tells users who registered at 0x8847/0x8848 to passoverride=Trueor usedecode_as.Fixture:
scripts/capture_fixtures_mpls.shandscripts/mpls_fixture_packets.pyproducetests/fixtures/mpls.pcap, 8 frames. They cover:CONFIG_MPLS_ROUTINGoff. So the kernel built the inner ICMP/UDP datagrams, and the script added the label stacks and outer Ethernet headers and sent them through AF_PACKET. MANIFEST says which bytes each one built.tests/fixtures/mpls/mpls_snaplen.pcapholds the same frames cut by a 20-byte snap length, so truncation is tested on captured bytes. It sits outside the corpus counts.scripts/check_fixtures.pywalks the stack independently with the same convention and verifies the inner checksums. Tests cover each bottom-label rule, including cases where the label and the nibble disagree.Test suites:
MPLSjoins the fuzz, round-trip, accessor-safety, padding/trailer, contract and corpus-coverage suites. CLAIMS §5.1–5.3 and the coverage comment are re-derived: 31 protocols, 27 round-trip strategies, 4,304 statements with 1 missed, and MPLS added to the accessors that never raise.Verification
uv run ruff checkanduv run ruff format --checkare cleanuv run mypyis clean (strict)uv run pytestpasses locally: 4938 passedCHANGELOG.mdhas an entry under## [Unreleased]python3 scripts/check_fixtures.py tests/fixturesreports all files consistent, anduvx bandit -q -r -t B324 src scripts testsis clean. Nothing new is at radon C.New protocol or dispatch change — also:
src/netprotocols/_base.pyEtherTypenumbers carry display names in locksteptests/test_fuzz.py::ALL_PROTOCOLS)Notes
checksum.pyand_tlv.pyare untouched, so no mutation dispatch is needed.Generated by Claude Code
Summary by CodeRabbit
New Features
Documentation