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):