Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 64 additions & 3 deletions src/netprotocols/layer3/ipv6_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
}


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
117 changes: 117 additions & 0 deletions tests/test_ipv6_ext.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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""
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading