From 57e16a40f4c4d46c33cd9fec0da9c9d987842aac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:55:56 +0000 Subject: [PATCH] IPv6: decode Routing header segments and Router Alert/Jumbo Payload IPv6Routing.data was entirely unparsed -- no segment-list extraction for any routing type. RH0 and Mobile IPv6 both store a 4-byte reserved field followed by one address per segment, which segments decodes into a tuple[IPv6Address, ...]. RPL (routing_type 3) is deliberately not decoded. RFC 6554 elides a shared prefix from each intermediate address relative to the enclosing packet's destination address -- context a single extension header does not carry on its own, the same class of problem DNS compression posed for RDATA in an earlier PR of this tier, except here there's no message-wide buffer to fall back on: IPv6Routing only ever sees its own bytes. Attempting a partial or wrong decode would be worse than leaving it raw, so data stays the only way to read an RPL header, same as before this change. IPv6Option.value decodes Router Alert and Jumbo Payload, mirroring the IPv4 side of the same two RFCs (2711, 2675) landed two PRs ago in this tier. This is the fourth and last of #96's four independent pieces. Part of #96. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CP7X7H4k3pBWoiAkBdxATM --- CHANGELOG.md | 18 +++++ src/netprotocols/layer3/ipv6_ext.py | 67 +++++++++++++++- tests/test_ipv6_ext.py | 117 ++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb7c9c..e35dced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `None` — never raises — for every other kind and for malformed data on one of these three (a bad pointer, an unrecognized Timestamp flag, a short buffer) (#96). +- **`IPv6Routing.segments` and `IPv6Option.value` for Router Alert / + Jumbo Payload.** `IPv6Routing.data` was entirely unparsed — no + segment-list extraction for any routing type. RH0 (`routing_type` + ``0``, deprecated by RFC 5095 but still seen — RFC 2460 §4.4) and + Mobile IPv6 (``2``, RFC 6275 §6.4) both store a 4-byte reserved field + followed by one IPv6 address per segment; `segments` decodes that + into a `tuple[ipaddress.IPv6Address, ...]`. RPL Source Routing (``3``, + RFC 6554) is deliberately **not** decoded: RFC 6554 §3 elides a + shared prefix from each intermediate address relative to the + enclosing packet's *destination* address, context this + one-extension-header accessor does not have — `data` stays available + raw. `None` for every other routing type and for malformed address + data. Separately, `IPv6Option.value` (Hop-by-Hop / Destination + Options TLVs) now decodes Router Alert (5, RFC 2711 §2.1) and Jumbo + Payload (194, RFC 2675 §2) into typed `int`s, the last of #96's four + pieces (`IPv6Option` "likewise decode[d] no values"); `None` for + every other type and malformed data, same contract throughout this + tier (#96). ## [2.0.0] - 2026-09-04 diff --git a/src/netprotocols/layer3/ipv6_ext.py b/src/netprotocols/layer3/ipv6_ext.py index abd56fc..ed35e01 100644 --- a/src/netprotocols/layer3/ipv6_ext.py +++ b/src/netprotocols/layer3/ipv6_ext.py @@ -19,10 +19,11 @@ from __future__ import annotations from dataclasses import dataclass +from ipaddress import IPv6Address from struct import Struct from typing import ClassVar, Self -from netprotocols._base import Protocol +from netprotocols._base import Protocol, bytes_to_ipv6 from netprotocols._enums import IPProtocol from netprotocols.layer3.ip import _ip_protocol_class, _ip_protocol_name from netprotocols.registry import Registry @@ -43,14 +44,19 @@ #: (RFC 8200 §4.2): Pad1, one byte of padding. _OPT_PAD1 = 0 +#: Option types this library decodes a typed :attr:`IPv6Option.value` +#: for, beyond naming. +_OPT_ROUTER_ALERT = 5 +_OPT_JUMBO_PAYLOAD = 194 + #: Option types this library names (RFC 8200 §4.2; RFC 2711 for Router #: Alert; RFC 2675 for Jumbo Payload); unknown types fall back to their #: numeric value. _OPTION_TYPE_NAMES: dict[int, str] = { _OPT_PAD1: "Pad1", 1: "PadN", - 5: "Router Alert", - 194: "Jumbo Payload", + _OPT_ROUTER_ALERT: "Router Alert", + _OPT_JUMBO_PAYLOAD: "Jumbo Payload", } @@ -86,6 +92,22 @@ def unrecognized_action(self) -> int: only to a non-multicast destination.""" return self.type >> 6 + @property + def value(self) -> int | None: + """The decoded value, for the types this library understands: + the 2-byte value as an ``int`` for Router Alert (5, RFC 2711 + §2.1 — ``0`` MLD, ``1`` RSVP, ``2`` Active Networks); the + 4-byte jumbogram payload length as an ``int`` for Jumbo Payload + (194, RFC 2675 §2 — used when the enclosing IPv6 header's + ``payload_length`` is ``0``). ``None`` — read :attr:`data` raw + instead — for every other type and for data whose length does + not match its type (degrades, never raises).""" + if self.type == _OPT_ROUTER_ALERT and len(self.data) == 2: + return int.from_bytes(self.data, "big") + if self.type == _OPT_JUMBO_PAYLOAD and len(self.data) == 4: + return int.from_bytes(self.data, "big") + return None + def _next_in_ipv6_chain( number: int, registry: Registry | None = None @@ -238,6 +260,15 @@ class IPv6DestinationOptions(_IPv6OptionsHeader): """The Destination Options header (RFC 8200 §4.6), protocol 60.""" +#: Routing types whose type-specific data this library decodes into a +#: :attr:`IPv6Routing.segments` address list: RH0 (deprecated by RFC +#: 5095, but still seen — RFC 2460 §4.4, a 4-byte reserved field then +#: one 16-byte address per segment) and Mobile IPv6 (RFC 6275 §6.4, +#: the same 4-byte-reserved-then-addresses shape, always one address). +_ROUTING_TYPE_RH0 = 0 +_ROUTING_TYPE_MOBILE_IPV6 = 2 + + @dataclass(frozen=True, slots=True) class IPv6Routing(Protocol): """The Routing header (RFC 8200 §4.4), protocol 43. @@ -334,6 +365,36 @@ def next_header_enum(self) -> IPProtocol | None: except ValueError: return None + @property + def segments(self) -> tuple[IPv6Address, ...] | None: + """The segment list, for the routing types this library + decodes: RH0 (:data:`routing_type` ``0``) and Mobile IPv6 + (``2``) both store a 4-byte reserved field followed by one + :class:`~ipaddress.IPv6Address` per segment — decoded here + as-is. + + RPL Source Routing (``3``, RFC 6554) is deliberately **not** + decoded: RFC 6554 §3 elides a shared prefix from each + intermediate address, relative to the enclosing packet's + *destination* address — context this accessor, scoped to one + extension header, does not have. Read :attr:`data` raw for it. + + ``None`` for every other routing type and for data whose + length is not ``4 + 16 * N`` bytes for some ``N`` (degrades, + never raises).""" + if self.routing_type not in ( + _ROUTING_TYPE_RH0, + _ROUTING_TYPE_MOBILE_IPV6, + ): + return None + addresses = self.data[4:] + if len(self.data) < 4 or len(addresses) % 16: + return None + return tuple( + IPv6Address(bytes_to_ipv6(addresses[i : i + 16])) + for i in range(0, len(addresses), 16) + ) + @dataclass(frozen=True, slots=True) class IPv6Fragment(Protocol): diff --git a/tests/test_ipv6_ext.py b/tests/test_ipv6_ext.py index 75d43e5..9f3a2ea 100644 --- a/tests/test_ipv6_ext.py +++ b/tests/test_ipv6_ext.py @@ -1,5 +1,8 @@ """IPv6 extension headers (RFC 8200 §4.3-4.6), driven by corpus frames.""" +import socket +from ipaddress import IPv6Address + import pytest from conftest import FIXTURES, read_pcap @@ -189,6 +192,7 @@ def test_router_alert_and_padn(self): assert alert.type == 5 assert alert.type_name == "Router Alert" assert alert.data == b"\x00\x00" + assert alert.value == 0 # MLD assert padn.type == 1 assert padn.type_name == "PadN" assert padn.data == b"" @@ -205,6 +209,7 @@ def test_jumbo_payload(self): assert jumbo.type == 194 assert jumbo.type_name == "Jumbo Payload" assert jumbo.data == b"\x00\x10\x00\x00" + assert jumbo.value == 0x00100000 assert jumbo.unrecognized_action == 3 def test_destination_options_share_the_parser(self): @@ -242,6 +247,118 @@ def test_round_trip_unchanged_by_parsing(self): assert bytes(hbh) == raw +class TestIPv6OptionValue: + """#96: `.value` decodes Router Alert and Jumbo Payload (RFC 2711 + §2.1, RFC 2675 §2); every other type, and malformed data on one of + these two, degrades to `None` rather than raising.""" + + def test_unnamed_type_value_is_none(self): + assert IPv6Option(type=138, data=b"\xca\xfe").value is None + + def test_pad1_and_padn_value_is_none(self): + assert IPv6Option(type=0).value is None + assert IPv6Option(type=1, data=b"\x00\x00").value is None + + def test_router_alert_wrong_length_is_none(self): + assert IPv6Option(type=5, data=b"\x00").value is None + + def test_jumbo_payload_wrong_length_is_none(self): + assert IPv6Option(type=194, data=b"\x00\x10\x00").value is None + + def test_direct_construction(self): + assert IPv6Option(type=5, data=b"\x00\x01").value == 1 # RSVP + jumbo_len = 4_294_967_295 # max 32-bit value + option = IPv6Option(type=194, data=jumbo_len.to_bytes(4, "big")) + assert option.value == jumbo_len + + +class TestIPv6RoutingSegments: + """#96: `segments` decodes RH0 and Mobile IPv6's address list (RFC + 2460 §4.4, RFC 6275 §6.4); RPL (routing_type 3) is deliberately + left undecoded (its addresses are compressed relative to the + enclosing packet's destination, context this accessor lacks).""" + + def test_rh0_two_segments(self): + reserved = b"\x00\x00\x00\x00" + addr_a = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + addr_b = socket.inet_pton(socket.AF_INET6, "2001:db8::2") + data = reserved + addr_a + addr_b + routing = IPv6Routing( + next_header=6, + hdr_ext_len=len(data) // 8, + routing_type=0, + segments_left=2, + data=data, + ) + assert routing.segments == ( + IPv6Address("2001:db8::1"), + IPv6Address("2001:db8::2"), + ) + + def test_rh0_no_segments_is_an_empty_tuple(self): + data = b"\x00\x00\x00\x00" # reserved only, no addresses + routing = IPv6Routing( + next_header=6, + hdr_ext_len=0, + routing_type=0, + segments_left=0, + data=data, + ) + assert routing.segments == () + + def test_mobile_ipv6_single_home_address(self): + reserved = b"\x00\x00\x00\x00" + home = socket.inet_pton(socket.AF_INET6, "2001:db8::dead") + data = reserved + home + routing = IPv6Routing( + next_header=59, + hdr_ext_len=len(data) // 8, + routing_type=2, + segments_left=1, + data=data, + ) + assert routing.segments == (IPv6Address("2001:db8::dead"),) + + def test_rpl_is_deliberately_not_decoded(self): + # RFC 6554 compresses addresses relative to the destination + # address, so this library does not attempt it -- even data + # shaped exactly like a valid RH0/MIPv6 payload stays None. + reserved = b"\x00\x00\x00\x00" + addr = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + routing = IPv6Routing( + next_header=6, + hdr_ext_len=(len(reserved) + len(addr)) // 8, + routing_type=3, + segments_left=1, + data=reserved + addr, + ) + assert routing.segments is None + assert routing.data == reserved + addr # still available raw + + def test_other_routing_types_are_none(self): + routing = IPv6Routing( + next_header=59, + hdr_ext_len=0, + routing_type=253, # reserved for experimentation, RFC 3692 + segments_left=0, + data=b"\x00\x00\x00\x00", + ) + assert routing.segments is None + + def test_malformed_address_area_is_none(self): + # 4-byte reserved + 8 bytes: not a whole number of 16-byte + # addresses. + data = b"\x00\x00\x00\x00" + b"\xff" * 8 + routing = IPv6Routing( + next_header=6, + hdr_ext_len=len(data) // 8, + routing_type=0, + segments_left=1, + data=data, + ) + assert routing.segments is None + + class TestRegistryGating: def test_ipv4_never_dispatches_extension_headers(self, raw_ipv4_header): for number in (0, 43, 44, 60):