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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 85 additions & 5 deletions src/netprotocols/layer3/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
}


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