diff --git a/CHANGELOG.md b/CHANGELOG.md index 8013a37..6eb7c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (51), the raw byte for Message Type (53). `None` — never raises — for codes this library does not decode and malformed option data (#96). +- **`IPv4Option.value` decodes Record Route, Timestamp and Router + Alert.** `IPv4Option` had `kind_name` and no `.value` — the three + common kinds (RFC 791 §3.1, RFC 2113) were named but their contents + left raw, unlike `TCPOption`, which already decodes its values. + Record Route (7) decodes to a `tuple[ipaddress.IPv4Address, ...]` of + the addresses recorded so far — the option's own pointer byte says + how many of the address slots are actually filled, not the option's + total length. Timestamp (68) decodes to `tuple[int, ...]` of plain + millisecond timestamps when its flag selects that shape, or + `tuple[tuple[ipaddress.IPv4Address, int], ...]` of (address, + timestamp) pairs when the flag says each entry carries one — the + overflow counter and pointer are not decoded, read `data` raw for + those. Router Alert (148) decodes to the 2-byte value as `int`. + `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). ## [2.0.0] - 2026-09-04 diff --git a/src/netprotocols/layer3/ip.py b/src/netprotocols/layer3/ip.py index f2f6f59..a41c627 100644 --- a/src/netprotocols/layer3/ip.py +++ b/src/netprotocols/layer3/ip.py @@ -4,8 +4,9 @@ ``bytes(IPv4.decode(x)) == x`` holds by construction; the ``parsed_options`` accessor walks the option TLV list on demand and never re-encodes, mirroring TCP's. The common kinds — Record Route, -Timestamp, Router Alert (RFC 791 §3.1, RFC 2113) — are named, and -unknown kinds keep their raw data. +Timestamp, Router Alert (RFC 791 §3.1, RFC 2113) — are named via +``kind_name`` and decoded into a typed ``value``; unknown kinds keep +their raw data. """ from __future__ import annotations @@ -85,14 +86,20 @@ def _ip_protocol_name(number: int) -> str: _OPT_EOL = 0 _OPT_NOP = 1 +#: Option kinds this library decodes a typed :attr:`IPv4Option.value` +#: for, beyond naming (RFC 791 §3.1; RFC 2113 for Router Alert). +_OPT_RECORD_ROUTE = 7 +_OPT_TIMESTAMP = 68 +_OPT_ROUTER_ALERT = 148 + #: IPv4 option kinds this library names (RFC 791 §3.1; RFC 2113 for #: Router Alert); unknown kinds fall back to their numeric value. _OPTION_KIND_NAMES: dict[int, str] = { _OPT_EOL: "End of Option List", _OPT_NOP: "No-Operation", - 7: "Record Route", - 68: "Timestamp", - 148: "Router Alert", + _OPT_RECORD_ROUTE: "Record Route", + _OPT_TIMESTAMP: "Timestamp", + _OPT_ROUTER_ALERT: "Router Alert", } @@ -117,6 +124,79 @@ def kind_name(self) -> str: name.""" return _OPTION_KIND_NAMES.get(self.kind, f"unknown ({self.kind})") + @property + def value( + self, + ) -> ( + int + | tuple[IPv4Address, ...] + | tuple[int, ...] + | tuple[tuple[IPv4Address, int], ...] + | None + ): + """The decoded value, for the kinds this library understands: + + - Record Route (7): the addresses recorded so far, as a tuple + of :class:`~ipaddress.IPv4Address` — ``data[0]`` is the + pointer to the next free slot (RFC 791 §3.1: the smallest + legal value is 4, meaning none recorded yet), so the number + of addresses already filled in is ``(pointer - 4) // 4``. + - Timestamp (68): the low nibble of ``data[1]`` is the flag + (RFC 791 §3.1) selecting the entry shape — ``0`` a tuple of + plain millisecond timestamps (``tuple[int, ...]``), ``1`` or + ``3`` a tuple of ``(address, timestamp)`` pairs + (``tuple[tuple[IPv4Address, int], ...]``) since each entry + also carries the IP module's address. The overflow counter + (high nibble of ``data[1]``) and an unrecognized flag value + are not decoded here — read :attr:`data` raw for those. + - Router Alert (148): the 2-byte value as an ``int`` (RFC 2113 + §2; ``0`` is the only value currently defined — "router + shall examine packet"). + + ``None`` — read :attr:`data` raw instead — for kinds this + library does not decode, an unrecognized Timestamp flag, and + data whose length or pointer is inconsistent with its kind + (degrades, never raises).""" + if self.kind == _OPT_RECORD_ROUTE and len(self.data) >= 1: + return self._record_route_addresses() + if self.kind == _OPT_TIMESTAMP and len(self.data) >= 2: + return self._timestamp_entries() + if self.kind == _OPT_ROUTER_ALERT and len(self.data) == 2: + return int.from_bytes(self.data, "big") + return None + + def _record_route_addresses(self) -> tuple[IPv4Address, ...] | None: + pointer = self.data[0] + route_area = self.data[1:] + filled_bytes = pointer - 4 + if pointer < 4 or filled_bytes % 4 or filled_bytes > len(route_area): + return None + filled = route_area[:filled_bytes] + return tuple( + IPv4Address(bytes_to_ipv4(filled[i : i + 4])) + for i in range(0, len(filled), 4) + ) + + def _timestamp_entries( + self, + ) -> tuple[int, ...] | tuple[tuple[IPv4Address, int], ...] | None: + flag = self.data[1] & 0x0F + entries = self.data[2:] + if flag == 0 and entries and len(entries) % 4 == 0: + return tuple( + int.from_bytes(entries[i : i + 4], "big") + for i in range(0, len(entries), 4) + ) + if flag in (1, 3) and entries and len(entries) % 8 == 0: + return tuple( + ( + IPv4Address(bytes_to_ipv4(entries[i : i + 4])), + int.from_bytes(entries[i + 4 : i + 8], "big"), + ) + for i in range(0, len(entries), 8) + ) + return None + @dataclass(frozen=True, slots=True) class IPv4(Protocol): diff --git a/tests/test_ip.py b/tests/test_ip.py index 52c4e72..0d2e085 100644 --- a/tests/test_ip.py +++ b/tests/test_ip.py @@ -177,6 +177,7 @@ def test_router_alert_from_a_decoded_header(self, raw_ipv4_header): assert alert.kind == 148 assert alert.kind_name == "Router Alert" assert alert.data == b"\x00\x00" + assert alert.value == 0 # "router shall examine packet" assert bytes(ip) == raw # parsing never re-encodes def test_record_route(self): @@ -236,3 +237,87 @@ def test_direct_construction(self): option = IPv4Option(kind=148, data=b"\x00\x00") assert option.kind_name == "Router Alert" assert IPv4Option(kind=0).data == b"" + + +class TestIPv4OptionValue: + """#96: `.value` decodes Record Route, Timestamp and Router Alert + (RFC 791 §3.1, RFC 2113); every other kind, and malformed data on + one of these three, degrades to `None` rather than raising.""" + + def test_record_route_decodes_the_addresses_recorded_so_far(self): + # pointer=12: two 4-byte addresses already recorded (RFC 791 + # §3.1 -- the smallest legal pointer, 4, means none yet). + route_area = ( + IPv4Address("192.0.2.1").packed + IPv4Address("192.0.2.2").packed + ) + data = bytes([12]) + route_area + ip = ipv4_with_options(bytes([7, 2 + len(data)]) + data) + record = ip.parsed_options[0] + assert record.value == ( + IPv4Address("192.0.2.1"), + IPv4Address("192.0.2.2"), + ) + + def test_record_route_pointer_at_minimum_is_no_addresses_yet(self): + data = bytes([4]) # pointer=4, nothing recorded yet + ip = ipv4_with_options(bytes([7, 2 + len(data)]) + data) + record = ip.parsed_options[0] + assert record.value == () + + def test_record_route_pointer_below_minimum_is_none(self): + data = bytes([2]) # 2 < 4: not a legal pointer value + ip = ipv4_with_options(bytes([7, 2 + len(data)]) + data) + record = ip.parsed_options[0] + assert record.value is None + + def test_record_route_pointer_past_the_route_area_is_none(self): + # pointer=12 claims two filled addresses; only one is present. + data = bytes([12]) + IPv4Address("10.0.0.1").packed + ip = ipv4_with_options(bytes([7, 2 + len(data)]) + data) + record = ip.parsed_options[0] + assert record.value is None + + def test_timestamp_flag_0_is_plain_milliseconds(self): + entries = (100_000).to_bytes(4, "big") + (100_004).to_bytes(4, "big") + data = bytes([4, 0]) + entries # pointer=4, overflow=0, flag=0 + ip = ipv4_with_options(bytes([68, 2 + len(data)]) + data) + timestamp = ip.parsed_options[0] + assert timestamp.value == (100_000, 100_004) + + def test_timestamp_flag_1_pairs_address_with_timestamp(self): + entry = IPv4Address("192.0.2.9").packed + (12_345).to_bytes(4, "big") + data = bytes([4, 1]) + entry # flag=1: address precedes timestamp + ip = ipv4_with_options(bytes([68, 2 + len(data)]) + data) + timestamp = ip.parsed_options[0] + assert timestamp.value == ((IPv4Address("192.0.2.9"), 12_345),) + + def test_timestamp_flag_3_also_pairs_address_with_timestamp(self): + entry = IPv4Address("192.0.2.10").packed + (5).to_bytes(4, "big") + data = bytes([4, 3]) + entry # flag=3: prespecified addresses + ip = ipv4_with_options(bytes([68, 2 + len(data)]) + data) + timestamp = ip.parsed_options[0] + assert timestamp.value == ((IPv4Address("192.0.2.10"), 5),) + + def test_timestamp_unrecognized_flag_is_none(self): + data = bytes([4, 2]) + b"\x00\x00\x00\x00" # flag=2 is not defined + ip = ipv4_with_options(bytes([68, 2 + len(data)]) + data) + timestamp = ip.parsed_options[0] + assert timestamp.value is None + + def test_router_alert_malformed_length_is_none(self): + data = b"\x00" # Router Alert is always exactly 2 bytes + ip = ipv4_with_options(bytes([148, 2 + len(data)]) + data) + alert = ip.parsed_options[0] + assert alert.value is None + + def test_unnamed_kind_value_is_none(self): + ip = ipv4_with_options(b"\x83\x03\x01") # Loose Source Route + option = ip.parsed_options[0] + assert option.value is None + + def test_direct_construction(self): + route_area = IPv4Address("198.51.100.1").packed + option = IPv4Option(kind=7, data=bytes([8]) + route_area) + assert option.value == (IPv4Address("198.51.100.1"),) + alert = IPv4Option(kind=148, data=b"\x00\x00") + assert alert.value == 0