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
97 changes: 91 additions & 6 deletions examples/generators/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
than a packet assembled by :mod:`scapy` from its own protocol model. That is the
point: the octets under test are the library's own output, so a capture is a
record of what this version of ``pcapkit`` builds, and re-parsing it exercises
``_read_*`` against ``_make_*``.
``_read_*`` against ``_make_*``. The one frame with more in it is HOPOPT's Jumbo
Payload case, a genuine jumbogram -- see :func:`_jumbogram_datagram`.

Only the codes that construct *successfully* can appear in a capture -- there
are no octets for the ones that do not. A failing case in a captured family
Expand Down Expand Up @@ -152,6 +153,18 @@
#: Destination address for every generated IPv6 envelope.
DST_IP6 = '2001:db8::2'

#: Jumbo Payload Length of the ``hopopt-option/Jumbo_Payload`` case: the least
#: a jumbogram may carry, one more than the 16-bit Payload Length holds
#: (:rfc:`2675#section-2`). See :func:`_jumbogram_datagram`.
JUMBO_LEN = 0x10000
#: Snapshot length of a capture whose frames all fit in it: scapy's default,
#: which keeps every capture but a jumbogram's as it always was.
SNAPLEN = 0xFFFF
#: Snapshot length of a capture holding a longer frame: libpcap's
#: ``MAXIMUM_SNAPLEN``. A libpcap reader cuts a record to the file's snaplen, so
#: a jumbogram under :data:`SNAPLEN` would reach it truncated.
JUMBO_SNAPLEN = 262144

#: Seconds a single case may take before :func:`roundtrip` gives up on it and
#: records ``'TIMEOUT'``, before :data:`TIMEOUT_SCALE_ENV` stretches it. A working
#: case takes low single-digit milliseconds, so this is headroom for a stalled
Expand Down Expand Up @@ -575,7 +588,8 @@ def _ipv6_option_overrides() -> 'dict[Any, dict[str, Any]]':
from pcapkit.const.ipv6.option import Option as Enum_Option

# HOPOPT and IPv6-Opts key on the same enumeration and behave identically
# on every code, so one table serves both.
# on every code, so one table serves both; ``_hopopt_overrides`` adds the
# one value that matters only in a Hop-by-Hop Options header.
return {
# The nonce is sized to its bit length rounded up to whole octets, at
# least one, so the default ``nonce=0`` is a one-octet zero. A
Expand All @@ -586,6 +600,17 @@ def _ipv6_option_overrides() -> 'dict[Any, dict[str, Any]]':
}


def _hopopt_overrides() -> 'dict[Any, dict[str, Any]]':
from pcapkit.const.ipv6.option import Option as Enum_Option

# A Jumbo Payload option in the Hop-by-Hop Options header after an IPv6
# header makes the packet a jumbogram, and one whose Jumbo Payload Length is
# not above 65,535 is an error (RFC 2675 section 3) the default engine
# rejects (#1687). So the option carries a legal length, and ``_frame``
# builds the jumbogram around it. In IPv6-Opts the option is just an option.
return {**_ipv6_option_overrides(), Enum_Option.Jumbo_Payload: {'len': JUMBO_LEN}}


def _hopopt_base() -> 'dict[str, Any]':
from pcapkit.const.reg.transtype import TransType
return {'next': TransType.UDP, 'payload': b''}
Expand Down Expand Up @@ -1588,7 +1613,7 @@ def _pcapng_secrets_build(code: 'Any', kwargs: 'dict[str, Any]') -> 'Any':
Family('ipv4-option', _ipv4_registry, _ipv4_overrides,
_ipv4_build, _ipv4_parse, _ipv4_extract, _ipv4_rebuild,
capture='options-ipv4.pcap', envelope='ethernet-ipv4'),
Family('hopopt-option', _hopopt_registry, _ipv6_option_overrides,
Family('hopopt-option', _hopopt_registry, _hopopt_overrides,
_hopopt_build, _hopopt_parse, _ipv6_extract, _hopopt_rebuild,
capture='options-ipv6.pcap', envelope='ipv6', proto=0),
Family('ipv6-opts-option', _ipv6_opts_registry, _ipv6_option_overrides,
Expand Down Expand Up @@ -1880,6 +1905,54 @@ def outcomes(families: 'Optional[tuple[Family, ...]]' = None,
yield roundtrip(case, deadline)


def _jumbogram_datagram(octets: 'bytes', jumbo: 'int') -> 'bytes':
"""The UDP datagram that makes a jumbogram up to its Jumbo Payload Length.

With a Payload Length of 0 it is the Jumbo Payload Length that says where
the packet ends, so the octets after the Hop-by-Hop Options header have to
fill it, or the frame is a jumbogram cut short. The header names UDP as its
next header, as :func:`_hopopt_base` has every HOPOPT case do, so they are a
datagram of zeros to the Discard port [:rfc:`863`], with the checksum
:rfc:`8200#section-8.1` has a receiver verify. Its length fits UDP's Length
field, which :rfc:`2675#section-4` therefore leaves as is. The checksum is
the one's complement sum of :rfc:`1071`, computed here rather than by
:mod:`scapy`, so that the unit-tier test can build the datagram on a leg
that installs no :mod:`scapy`.

Args:
octets: The Hop-by-Hop Options header, as :func:`roundtrip` built it.
jumbo: Its Jumbo Payload Length.

Returns:
The datagram, ``jumbo - len(octets)`` octets long.

Raises:
ValueError: If the header does not name UDP, or the octets it leaves
are not the length of a UDP datagram.

"""
import ipaddress

size = jumbo - len(octets)
if octets[0] != 17 or not 8 <= size <= 0xFFFF:
raise ValueError(f'cannot fill a {jumbo}-octet jumbogram after a {len(octets)}-octet '
f'header naming next header {octets[0]} with a UDP datagram')

head = (50000).to_bytes(2, 'big') + (9).to_bytes(2, 'big') + size.to_bytes(2, 'big')
data = bytes(size - 8)
pseudo = (ipaddress.IPv6Address(SRC_IP6).packed + ipaddress.IPv6Address(DST_IP6).packed
+ size.to_bytes(4, 'big') + bytes(3) + bytes([17]))
# The sum of the 16-bit words, an odd octet padded with a zero, with every
# carry folded back in; the checksum is its complement.
covered = pseudo + head + bytes(2) + data + bytes(size % 2)
total = sum(int.from_bytes(covered[i:i + 2], 'big') for i in range(0, len(covered), 2))
while total >> 16:
total = (total & 0xFFFF) + (total >> 16)
# A computed checksum of zero goes out as all ones, since zero means none.
value = (~total & 0xFFFF) or 0xFFFF
return head + value.to_bytes(2, 'big') + data


def _frame(family: 'Family', octets: 'bytes', index: 'int') -> 'Any':
"""Wrap constructed octets in the envelope that makes them decodable.

Expand Down Expand Up @@ -1911,8 +1984,18 @@ def _frame(family: 'Family', octets: 'bytes', index: 'int') -> 'Any':
frame = link / IP(src=SRC_IP, dst=DST_IP, proto=family.proto,
id=index, ttl=64) / Raw(octets)
elif family.envelope == 'ipv6':
frame = link / IPv6(src=SRC_IP6, dst=DST_IP6, nh=family.proto,
hlim=64) / Raw(octets)
from pcapkit.protocols.schema.internet.ipv6 import jumbo_payload_length

# A Jumbo Payload option where it makes the packet a jumbogram gets a
# genuine one: a Payload Length of 0 (RFC 2675 section 3, #1687), and
# octets to make up the Jumbo Payload Length.
jumbo = None if family.proto is None else jumbo_payload_length(family.proto, octets)
if jumbo is None:
frame = link / IPv6(src=SRC_IP6, dst=DST_IP6, nh=family.proto,
hlim=64) / Raw(octets)
else:
frame = link / IPv6(src=SRC_IP6, dst=DST_IP6, nh=family.proto, hlim=64,
plen=0) / Raw(octets + _jumbogram_datagram(octets, jumbo))
elif family.envelope == 'tcp':
frame = (link / IP(src=SRC_IP, dst=DST_IP, id=index, ttl=64)
/ TCP(sport=50000, dport=80, flags='A', seq=1, ack=1)
Expand Down Expand Up @@ -1973,7 +2056,9 @@ def generate(dest: 'pathlib.Path | None' = None) -> 'list[pathlib.Path]':
written = [] # type: list[pathlib.Path]
for name in sorted(buckets):
path = dest / name
wrpcap(str(path), buckets[name])
frames = buckets[name]
snaplen = SNAPLEN if max(len(frame) for frame in frames) <= SNAPLEN else JUMBO_SNAPLEN
wrpcap(str(path), frames, snaplen=snaplen)
written.append(path)

total = sum(len(frames) for frames in buckets.values())
Expand Down
59 changes: 38 additions & 21 deletions pcapkit/protocols/internet/ipv6.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@
from pcapkit.protocols.internet.ip import IP
from pcapkit.protocols.protocol import FRAME_LAYER_LIMIT, _frame_depth
from pcapkit.protocols.schema.internet.ipv6 import IPv6 as Schema_IPv6
from pcapkit.protocols.schema.internet.ipv6 import jumbo_payload_length
from pcapkit.protocols.schema.internet.ipv6 import jumbo_payload_length, jumbogram_error
from pcapkit.utilities.decorators import beholder
from pcapkit.utilities.exceptions import stacklevel
from pcapkit.utilities.warnings import ProtocolWarning, warn
from pcapkit.utilities.exceptions import ProtocolError

if TYPE_CHECKING:
from enum import IntEnum as StdlibEnum
Expand Down Expand Up @@ -197,11 +196,26 @@ def read(self, length: 'Optional[int]' = None, *,
Returns:
Parsed packet data.

Raises:
ProtocolError: If the packet is one of the errors
:rfc:`2675#section-3` has a receiver detect (see
:func:`~pcapkit.protocols.schema.internet.ipv6.jumbogram_error`).

"""
if length is None:
length = len(self)
schema = self.__header__

# NOTE: A receiver treats each of these as an error (:issue:`1687`), so
# the frame stops here, c.f. :meth:`IPv4.read
# <pcapkit.protocols.internet.ipv4.IPv4.read>`. A Fragment header beside
# the Jumbo Payload option is caught on the walk, see
# :meth:`_decode_next_layer`.
rest = schema.get_payload() + schema.trailer
error = jumbogram_error(schema.length, schema.next, rest)
if error is not None:
raise ProtocolError(f'{self.alias}: {error}')

ipv6 = Data_IPv6.from_dict({
'version': schema.hextet['version'],
'class': schema.hextet['class'],
Expand All @@ -224,19 +238,9 @@ def read(self, length: 'Optional[int]' = None, *,
# NOTE: A jumbogram's extent is its Jumbo Payload Length, not the zero
# Payload Length, c.f. :meth:`Schema_IPv6.post_process
# <pcapkit.protocols.schema.internet.ipv6.IPv6.post_process>`
# (:issue:`1434`). :rfc:`2675#section-2` makes a Jumbo Payload option
# beside a non-zero Payload Length an error; such a packet is still
# kept as captured, with the Payload Length deciding its extent, since
# that is what the receiver had to go by.
extent = schema.length
jumbo = jumbo_payload_length(schema.next, schema.get_payload() + schema.trailer)
if jumbo is not None:
if schema.length == 0:
extent = jumbo
else:
warn(f'{self.alias}: Jumbo Payload option with non-zero Payload Length '
f'{schema.length}; ignoring the Jumbo Payload Length {jumbo}',
ProtocolWarning, stacklevel=stacklevel())
# (:issue:`1434`); the check above leaves a zero one beside it.
jumbo = jumbo_payload_length(schema.next, rest)
extent = schema.length if jumbo is None else jumbo

# update packet info
if __packet__ is None:
Expand Down Expand Up @@ -276,7 +280,8 @@ def make(self,
dst: Destination IP address.
payload_length: Length of the payload, extension headers included;
computed from the payload when omitted, and zero (a jumbogram)
if that exceeds 65,535 octets.
if that exceeds 65,535 octets or the payload starts with a
Hop-by-Hop Options header carrying a Jumbo Payload option.
payload: Payload data.
trailer: Octets after the packet, outside the Payload Length, such
as link-layer padding; written as is.
Expand All @@ -294,12 +299,13 @@ def make(self,
# length the sender declared instead of the one that was recorded
# (:issue:`1155`). Computed from the octets at hand otherwise, which is
# what a plain ``IPv6(payload=...)`` wants -- or zero for a payload too
# long for the field, i.e. a jumbogram, whose length the caller carries
# in a Jumbo Payload option of ``payload``'s Hop-by-Hop Options header
# (:rfc:`2675#section-2`).
# long for the field, or one whose Hop-by-Hop Options header carries a
# Jumbo Payload option, i.e. a jumbogram, whose length the caller gives
# there (:rfc:`2675#section-2`); its Payload Length must be zero
# (:issue:`1687`).
if payload_length is None:
payload_length = len(payload)
if payload_length > 0xFFFF:
if payload_length > 0xFFFF or jumbo_payload_length(next_val, bytes(payload)) is not None:
payload_length = 0

return Schema_IPv6(
Expand Down Expand Up @@ -526,6 +532,11 @@ def _decode_next_layer(self, ipv6: 'Data_IPv6', proto: 'Optional[int]' = None,
Returns:
Current protocol with next layer extracted.

Raises:
ProtocolError: If the walk reaches a Fragment header in a packet
carrying a Jumbo Payload option, an error by
:rfc:`2675#section-3` (:issue:`1687`).

"""
#: Extension headers.
self._exthdr = OrderedMultiDict() # type: OrderedMultiDict[Enum_ExtensionHeader, ProtocolBase] # pylint: disable=attribute-defined-outside-init
Expand Down Expand Up @@ -634,6 +645,12 @@ def _decode_next_layer(self, ipv6: 'Data_IPv6', proto: 'Optional[int]' = None,
# reassembly, so ``fragment``, ``hdr_len`` and ``raw_len`` keep
# that split however far the walk goes on.
if split is None:
# NOTE: Nor may a jumbogram carry one (:issue:`1687`), so the
# frame stops here as for the errors :meth:`read` checks;
# past those, its Jumbo Payload option is in the payload.
error = jumbogram_error(ipv6.payload, ipv6.next, self.__header__.get_payload(), lambda: True)
if error is not None:
raise ProtocolError(f'{self.alias}: {error}')
split = (hdr_len, raw_len)
ipv6.__update__({
'fragment': self._read_packet(header=hdr_len, payload=raw_len),
Expand Down
43 changes: 42 additions & 1 deletion pcapkit/protocols/schema/internet/ipv6.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

if TYPE_CHECKING:
from ipaddress import IPv6Address
from typing import Any, Optional
from typing import Any, Callable, Optional

from pcapkit.protocols.protocol import ProtocolBase

Expand Down Expand Up @@ -143,3 +143,44 @@ def jumbo_payload_length(next: 'int', data: 'bytes') -> 'Optional[int]': # pyli
return int.from_bytes(data[ptr + 2:ptr + 6], 'big', signed=False)
ptr += 2 + size
return None


def jumbogram_error(length: 'int', next: 'int', data: 'bytes', # pylint: disable=redefined-builtin
fragment: 'Optional[Callable[[], bool]]' = None) -> 'Optional[str]':
"""Name the error :rfc:`2675#section-3` has a receiver detect in an IPv6 packet, if any.

The four errors that section lists for a node that understands the Jumbo
Payload option, each a packet the default engine rejects (:issue:`1687`):

* a Payload Length of zero, a Next Header of Hop-by-Hop Options, and no
Jumbo Payload option;
* a non-zero Payload Length and a Jumbo Payload option;
* a Jumbo Payload option whose Jumbo Payload Length is below 65,536;
* a Jumbo Payload option and a Fragment header.

The option counts only where :func:`jumbo_payload_length` finds it.

Args:
length: Payload Length of the IPv6 header.
next: Next Header of the IPv6 header.
data: Octets following the IPv6 header, as captured.
fragment: Tells whether the extension header chain, as the default
engine walks it, has a Fragment header; called only for a packet
carrying the option, so a caller walks the chain only for those.

Returns:
The error, or :data:`None` if the packet makes none of them.

"""
jumbo = jumbo_payload_length(next, data)
if jumbo is None:
if not length and next == Enum_TransType.HOPOPT:
return 'Payload Length 0 and a Hop-by-Hop Options header with no Jumbo Payload option'
return None
if length:
return f'Jumbo Payload option with a non-zero Payload Length {length}'
if jumbo <= 0xFFFF:
return f'Jumbo Payload Length {jumbo}, not greater than 65,535'
if fragment is not None and fragment():
return 'Jumbo Payload option with a Fragment header'
return None
3 changes: 3 additions & 0 deletions tests/protocols/internet/test_internet_edge_roundtrip_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ def hip_param(code: int, body: bytes) -> bytes:
REJECTED = {
'ipv4/version-6': Reject('ProtocolError', '[IPv4] invalid version: 6'),
'ipv4/ihl-4': Reject('ProtocolError', 'resolved to a negative length'),
# a Jumbo Payload option beside a non-zero Payload Length, an RFC 2675 error (#1687)
'hopopt/jumbo-payload-0/in-ipv6': Reject('ProtocolError', 'Jumbo Payload option with a non-zero Payload '
'Length 8'),
}

KNOWN_FAILURES = () # type: tuple[edge.Gap, ...]
Expand Down
Loading
Loading