From 67c7592e590ebd98166f2c320caaf505f69a5e70 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 24 Sep 2026 11:34:18 -0400 Subject: [PATCH] fix(toolkit): parse PyPCAPFile's dotted-decimal IPv4 text, not packed bytes PyPCAPFile's `IP.src`/`.dst` are dotted-decimal ASCII text in a `ctypes.c_char_p` (e.g. `b'10.1.1.2'`), not packed 4-byte values -- so every `ipaddress.IPv4Address(ipv4.src)` call raised `AddressValueError`, and `ipv4_header()`'s `struct.pack('...II', ipv4.src, ...)` raised `struct.error`. - `_parse_ipv4_address()` is the single chokepoint, used at every affected site. It also accepts a packed 4-byte `bytes` value for a different PyPCAPFile release/fork; an `int` branch was dropped after review -- it existed only to keep this module's own int-typed test stand-ins passing, which is exactly the fixture that hid this defect. The stand-ins now use dotted-decimal `bytes`. - `IP.opt`/`.payload` have the same defect, one class over: hex-ASCII, not raw, once decoding stops short of the transport layer. `_maybe_unhex()` fixes this at four sites, including one review caught that a first pass missed (`ipv4_reassembly`'s fragment payload). Its docstring now states the residual false-positive risk plainly instead of overclaiming safety. - Same defect again on `Ethernet.src`/`.dst`: PyPCAPFile pre-formats them as colon-ASCII text, which `_ethernet2dict` was passing through unexamined against a raw-bytes stand-in that no longer matched reality. `_parse_mac_address()` normalises both forms to the lowercase colon-hex string the default engine's own `_read_mac_addr` uses. - `_layer2dict` un-hexes an Ethernet/IP layer's raw nested payload before recursing, fixing an internal inconsistency the opt/payload fix on its own left behind: `opt` correctly decoded while the nested `'Raw'` payload stayed hex-encoded (and twice its true length) in the same dict. - IPv6: confirmed PyPCAPFile has no IPv6 decoder at all (no defect there; `ipv6_reassembly` already raises `UnsupportedCall` unconditionally). Verified on Python 3.10.21 with `pypcapfile` 0.12.0 in a throwaway venv: of the 10 gated methods, 7 failed before this change (4 in tests/toolkit/test_pypcapfile_unit.py, 3 in tests/foundation/engines/test_new_engine_parity_runtime.py), matching #743. This fix turns the 4 in test_pypcapfile_unit.py green. The 3 in test_new_engine_parity_runtime.py stay red, byte-identical before and after -- root-caused while verifying this fix to a separate, out-of-scope defect in the PyPCAPFile *engine* (not this toolkit module): it feeds hex-encoded frame bytes straight into PyPCAPFile's own decoders without un-hexlifying first. Filed as #746, fix open as #748. 28 unit tests (up from 14), each new one confirmed to fail without its fix; a mutation pass confirms every `_maybe_unhex`/`_parse_*` call site is asserted. `coverage run` on pcapkit/toolkit/pypcapfile.py: 100% (130 stmts/44 branches, up from 100/34 pre-change). Fixes #743. --- pcapkit/toolkit/pypcapfile.py | 224 +++++++++++++++++++++++--- tests/toolkit/test_pypcapfile_unit.py | 173 +++++++++++++++++++- 2 files changed, 368 insertions(+), 29 deletions(-) diff --git a/pcapkit/toolkit/pypcapfile.py b/pcapkit/toolkit/pypcapfile.py index 96facd555..64a5f64cd 100644 --- a/pcapkit/toolkit/pypcapfile.py +++ b/pcapkit/toolkit/pypcapfile.py @@ -28,15 +28,18 @@ reimplementation. """ +import binascii import ipaddress import struct +import sys +import textwrap from typing import TYPE_CHECKING from pcapkit.const.reg.transtype import TransType as Enum_TransType from pcapkit.foundation.reassembly.data.ip import Packet as IP_Packet from pcapkit.foundation.reassembly.data.tcp import Packet as TCP_Packet from pcapkit.foundation.traceflow.data.tcp import Packet as TF_TCP_Packet -from pcapkit.utilities.exceptions import UnsupportedCall +from pcapkit.utilities.exceptions import ProtocolError, UnsupportedCall if TYPE_CHECKING: from ipaddress import IPv4Address, IPv6Address @@ -52,6 +55,11 @@ 'ipv4_reassembly', 'ipv6_reassembly', 'tcp_reassembly', 'tcp_traceflow', ] +#: Whether :meth:`bytes.hex` supports the ``sep`` argument (Python 3.8+), as used +#: below by :func:`_parse_mac_address` -- mirrors the same guard in +#: :meth:`pcapkit.protocols.link.ethernet.Ethernet._read_mac_addr`. +py38 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 8) + #: Minimum length of a TCP header, i.e. the fixed part with no options. TCP_MIN_HEADER_LEN = 20 @@ -84,6 +92,130 @@ def packet2timestamp(packet: 'Packet') -> 'float': return packet.timestamp + packet.timestamp_us / divisor +def _parse_ipv4_address(value: 'Any') -> 'IPv4Address': + """Parse a raw PyPCAPFile IPv4 ``src``/``dst`` field. + + Args: + value: Raw value of an :attr:`IP.src ` + or :attr:`IP.dst ` field. + + Returns: + The parsed address. + + Note: + The released `PyPCAPFile`_ (0.12.0) stores ``src``/``dst`` as + dotted-decimal ASCII text in a :class:`ctypes.c_char_p` -- e.g. + ``b'10.1.1.2'`` -- rather than as a packed 4-byte value, which is all + :class:`ipaddress.IPv4Address` accepts from a :obj:`bytes` argument + (it raises :exc:`~ipaddress.AddressValueError` otherwise). This helper + also accepts a packed 4-byte :obj:`bytes` value directly, so a + differently-represented `PyPCAPFile`_ fork or release still works. + Anything that is not :obj:`bytes` is rejected outright: an earlier + revision also accepted a plain :obj:`int`, kept only because this + module's own unit test stand-ins modelled the field that way -- + which meant production had been widened specifically to keep a + fixture passing that was hiding this very defect, so the stand-ins + were changed to use dotted-decimal :obj:`bytes` instead and the + ``int`` case was dropped. + + Raises: + ProtocolError: If ``value`` cannot be parsed as an IPv4 address. + + .. _PyPCAPFile: https://github.com/kisom/pypcapfile + + """ + if not isinstance(value, bytes): + raise ProtocolError(f'invalid PyPCAPFile IPv4 address: {value!r}') + try: + if len(value) != 4: + # Dotted-decimal ASCII text, the actual PyPCAPFile 0.12.0 form -- + # a packed 4-byte address is never a valid dotted-quad string, + # since the shortest one (``0.0.0.0``) is already 7 bytes long. + return ipaddress.IPv4Address(value.decode('ascii')) + return ipaddress.IPv4Address(value) + except (ValueError, UnicodeDecodeError) as error: + raise ProtocolError(f'invalid PyPCAPFile IPv4 address: {value!r}') from error + + +def _parse_mac_address(value: 'Any') -> 'str': + """Parse a raw PyPCAPFile Ethernet ``src``/``dst`` field. + + Args: + value: Raw value of an :attr:`Ethernet.src + ` or + :attr:`Ethernet.dst ` + field. + + Returns: + Lowercase, colon-separated hex MAC address, e.g. ``'01:00:5e:01:03:03'`` + -- the same form :meth:`~pcapkit.protocols.link.ethernet.Ethernet._read_mac_addr` + uses for the default engine. + + Note: + The released `PyPCAPFile`_ (0.12.0) already stores ``src``/``dst`` pre-formatted + exactly this way, as ASCII text in a :class:`ctypes.c_char_p` -- not as a raw + 6-byte value, which :func:`bytes` alone passes straight through unexamined. This + helper also accepts a raw 6-byte value directly, so a differently-represented + `PyPCAPFile`_ fork or release, and the stand-ins used in unit tests, keep working. + + Raises: + ProtocolError: If ``value`` cannot be parsed as a MAC address. + + .. _PyPCAPFile: https://github.com/kisom/pypcapfile + + """ + if not isinstance(value, (bytes, bytearray)): + raise ProtocolError(f'invalid PyPCAPFile MAC address: {value!r}') + raw = bytes(value) + if len(raw) == 6: + return raw.hex(':') if py38 else ':'.join(textwrap.wrap(raw.hex(), 2)) + try: + return raw.decode('ascii') + except UnicodeDecodeError as error: + raise ProtocolError(f'invalid PyPCAPFile MAC address: {value!r}') from error + + +def _maybe_unhex(value: 'bytes') -> 'bytes': + """Undo :func:`binascii.hexlify`, if ``value`` looks hex-encoded. + + Args: + value: Raw bytes, possibly hex-encoded. + + Returns: + ``value`` hex-decoded, if it is a valid even-length hex string; + otherwise ``value`` itself, unchanged. + + Note: + The released `PyPCAPFile`_ (0.12.0) stores an IPv4 packet's options + (:attr:`IP.opt `) and payload + (:attr:`IP.payload `) as + :func:`binascii.hexlify`'d ASCII text once decoding stops short of the + transport layer -- which is exactly how + :class:`~pcapkit.foundation.engines.pypcapfile.PyPCAPFile` calls it -- + rather than as the raw bytes this module otherwise assumes. Both forms + are :obj:`bytes`, hex-encoded or not, which is why :func:`_is_raw` + cannot tell them apart, and falling back to the original value when + it does not decode as hex is what keeps this a no-op for the raw, + non-hex stand-ins used in unit tests. + + This is **not** a guarantee against every false positive, only a + cheap and, in practice, reliable heuristic: a genuinely raw payload + whose every byte happens to fall in the ASCII hex-digit range (e.g. a + 24-byte TCP header built entirely from digits and ``A``-``F``) is + indistinguishable from real hex text and gets silently halved. There + is no way to tell the two forms apart from the bytes alone; this is + acceptable here because that byte range is a small fraction of the + possible values a real header or payload can take. + + .. _PyPCAPFile: https://github.com/kisom/pypcapfile + + """ + try: + return binascii.unhexlify(value) + except binascii.Error: + return value + + def ipv4_header(ipv4: 'IP') -> 'bytes': """Rebuild the raw header bytes of a PyPCAPFile IPv4 packet. @@ -98,22 +230,26 @@ def ipv4_header(ipv4: 'IP') -> 'bytes': *every* IPv4 header field plus the options blob, so this reconstruction is byte-exact rather than approximate. + Raises: + ProtocolError: If ``ipv4.src`` or ``ipv4.dst`` cannot be parsed as an + IPv4 address. + .. _PyPCAPFile: https://github.com/kisom/pypcapfile """ return struct.pack( '!BBHHHBBHII', - (ipv4.v << 4) | ipv4.hl, # version and internet header length - ipv4.tos, # type of service - ipv4.len, # total length - ipv4.id, # identification - (ipv4.flags << 13) | ipv4.off, # flags and fragment offset - ipv4.ttl, # time to live - ipv4.p, # payload protocol type - ipv4.sum, # header checksum - ipv4.src, # source IP address - ipv4.dst, # destination IP address - ) + bytes(ipv4.opt) + (ipv4.v << 4) | ipv4.hl, # version and internet header length + ipv4.tos, # type of service + ipv4.len, # total length + ipv4.id, # identification + (ipv4.flags << 13) | ipv4.off, # flags and fragment offset + ipv4.ttl, # time to live + ipv4.p, # payload protocol type + ipv4.sum, # header checksum + int(_parse_ipv4_address(ipv4.src)), # source IP address + int(_parse_ipv4_address(ipv4.dst)), # destination IP address + ) + _maybe_unhex(bytes(ipv4.opt)) def _is_raw(layer: 'Any') -> 'bool': @@ -169,7 +305,7 @@ def _transport(ipv4: 'IP') -> 'Optional[bytes]': if not _is_raw(segment): return None - segment = bytes(segment) + segment = _maybe_unhex(bytes(segment)) if len(segment) < TCP_MIN_HEADER_LEN: return None return segment @@ -181,10 +317,14 @@ def _ethernet2dict(link: 'Any') -> 'dict[str, Any]': Args: link: PyPCAPFile Ethernet frame. + Raises: + ProtocolError: If ``link.src`` or ``link.dst`` cannot be parsed as a + MAC address. + """ return { - 'dst': bytes(link.dst), - 'src': bytes(link.src), + 'dst': _parse_mac_address(link.dst), + 'src': _parse_mac_address(link.src), 'type': link.type, } @@ -207,9 +347,9 @@ def _ipv4_2dict(ipv4: 'IP') -> 'dict[str, Any]': 'ttl': ipv4.ttl, 'p': ipv4.p, 'sum': ipv4.sum, - 'src': str(ipaddress.IPv4Address(ipv4.src)), - 'dst': str(ipaddress.IPv4Address(ipv4.dst)), - 'opt': bytes(ipv4.opt), + 'src': str(_parse_ipv4_address(ipv4.src)), + 'dst': str(_parse_ipv4_address(ipv4.dst)), + 'opt': _maybe_unhex(bytes(ipv4.opt)), } @@ -219,6 +359,21 @@ def _layer2dict(layer: 'Any') -> 'dict[str, Any]': Args: layer: Decoded PyPCAPFile layer, or raw bytes. + Note: + `PyPCAPFile`_'s own :class:`~pcapfile.protocols.linklayer.ethernet.Ethernet` + and :class:`~pcapfile.protocols.network.ip.IP` decoders hex-encode the + payload they retain rather than decoding it further (see + :func:`_maybe_unhex`), so a raw payload reached by recursing *from* one of + those two classes is un-hexed before being reported here. Without that, + an ``IP`` layer with options would report its own ``opt`` correctly + (:func:`_ipv4_2dict` already un-hexes it) while its nested ``'Raw'`` + payload stayed hex-encoded and twice the true length -- an inconsistency + within the very same :obj:`dict`. Any other, unrecognised layer type's + raw payload is left untouched, since nothing here establishes that it is + `PyPCAPFile`_-hexlified rather than genuinely raw. + + .. _PyPCAPFile: https://github.com/kisom/pypcapfile + """ if _is_raw(layer): raw = bytes(layer) @@ -235,6 +390,8 @@ def _layer2dict(layer: 'Any') -> 'dict[str, Any]': payload = getattr(layer, 'payload', None) if payload is not None: + if name in ('Ethernet', 'IP') and _is_raw(payload): + payload = _maybe_unhex(bytes(payload)) dict_['Raw' if _is_raw(payload) else type(payload).__name__] = _layer2dict(payload) return dict_ @@ -279,6 +436,11 @@ def packet2dict(packet: 'Packet', *, data_link: 'Enum_LinkType') -> 'dict[str, A Returns: Dict[str, Any]: A :obj:`dict` mapping of packet data. + Raises: + ProtocolError: If a decoded IPv4 layer's ``src``/``dst`` cannot be + parsed as an IPv4 address, or a decoded Ethernet layer's + ``src``/``dst`` cannot be parsed as a MAC address. + """ return { 'timestamp': packet2timestamp(packet), @@ -304,6 +466,10 @@ def ipv4_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'IP_Packet[IPv4Ad * If the ``packet`` can be reassembled, then the :obj:`dict` mapping of data for IPv4 reassembly (:term:`reasm.ipv4.packet`) will be returned; otherwise, returns :data:`None`. + Raises: + ProtocolError: If ``ipv4.src`` or ``ipv4.dst`` cannot be parsed as an + IPv4 address. + See Also: :class:`pcapkit.foundation.reassembly.ipv4.IPv4` @@ -317,8 +483,8 @@ def ipv4_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'IP_Packet[IPv4Ad header = ipv4_header(ipv4) return IP_Packet( bufid=( - ipaddress.IPv4Address(ipv4.src), # source IP address - ipaddress.IPv4Address(ipv4.dst), # destination IP address + _parse_ipv4_address(ipv4.src), # source IP address + _parse_ipv4_address(ipv4.dst), # destination IP address ipv4.id, # identification Enum_TransType.get(ipv4.p), # payload protocol type ), @@ -328,7 +494,7 @@ def ipv4_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'IP_Packet[IPv4Ad mf=bool(ipv4.flags & IPV4_FLAG_MF), # more fragment flag tl=ipv4.len, # total length, header includes header=header, # raw bytes type header - payload=bytearray(ipv4.payload), # raw bytearray type payload + payload=bytearray(_maybe_unhex(bytes(ipv4.payload))), # raw bytearray type payload timestamp=packet2timestamp(packet), # capture timestamp ) @@ -366,6 +532,10 @@ def tcp_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'TCP_Packet | None * If the ``packet`` can be reassembled, then the :obj:`dict` mapping of data for TCP reassembly (:term:`reasm.tcp.packet`) will be returned; otherwise, returns :data:`None`. + Raises: + ProtocolError: If ``ipv4.src`` or ``ipv4.dst`` cannot be parsed as an + IPv4 address. + See Also: :class:`pcapkit.foundation.reassembly.tcp.TCP` @@ -388,9 +558,9 @@ def tcp_reassembly(packet: 'Packet', *, count: 'int' = -1) -> 'TCP_Packet | None return TCP_Packet( bufid=( - ipaddress.IPv4Address(ipv4.src), # source IP address + _parse_ipv4_address(ipv4.src), # source IP address tcp.src_port, # source port - ipaddress.IPv4Address(ipv4.dst), # destination IP address + _parse_ipv4_address(ipv4.dst), # destination IP address tcp.dst_port, # destination port ), num=count, # original packet range number @@ -425,6 +595,10 @@ def tcp_traceflow(packet: 'Packet', *, data_link: 'Enum_LinkType', * If the ``packet`` can be traced, then the :obj:`dict` mapping of data for TCP flow tracing (:term:`trace.tcp.packet`) will be returned; otherwise, returns :data:`None`. + Raises: + ProtocolError: If ``ipv4.src`` or ``ipv4.dst`` cannot be parsed as an + IPv4 address. + See Also: :class:`pcapkit.foundation.traceflow.tcp.TCP` @@ -450,8 +624,8 @@ def tcp_traceflow(packet: 'Packet', *, data_link: 'Enum_LinkType', syn=bool(tcp.syn), # TCP synchronise (SYN) flag fin=bool(tcp.fin), # TCP finish (FIN) flag rst=bool(tcp.rst), # TCP reset (RST) flag - src=ipaddress.IPv4Address(ipv4.src), # source IP - dst=ipaddress.IPv4Address(ipv4.dst), # destination IP + src=_parse_ipv4_address(ipv4.src), # source IP + dst=_parse_ipv4_address(ipv4.dst), # destination IP srcport=tcp.src_port, # TCP source port dstport=tcp.dst_port, # TCP destination port timestamp=packet2timestamp(packet), # timestamp diff --git a/tests/toolkit/test_pypcapfile_unit.py b/tests/toolkit/test_pypcapfile_unit.py index b42b38146..2c995dad0 100644 --- a/tests/toolkit/test_pypcapfile_unit.py +++ b/tests/toolkit/test_pypcapfile_unit.py @@ -13,6 +13,7 @@ """ from __future__ import annotations +import binascii import importlib.util import ipaddress import struct @@ -93,8 +94,11 @@ def __init__(self, payload, **fields) -> None: self.ttl = 64 self.p = 6 self.sum = 0xBEEF - self.src = int(ipaddress.IPv4Address('10.1.1.2')) - self.dst = int(ipaddress.IPv4Address('10.1.1.3')) + # Dotted-decimal ASCII bytes, PyPCAPFile 0.12.0's actual ``IP.src``/``.dst`` + # form (see GH#743) -- not an ``int``, so this stand-in exercises the same + # parsing path the real decoder does instead of hiding behind a shortcut. + self.src = b'10.1.1.2' + self.dst = b'10.1.1.3' self.opt = b'' self.payload = payload self.__dict__.update(fields) @@ -179,8 +183,8 @@ def test_packet2dict_reports_the_decoded_tree(self) -> None: self.assertEqual(info['packet_len'], 86) ethernet = info['ETHERNET'] - self.assertEqual(ethernet['src'], b'\xa4\x5e\x60\xd9\x6b\x97') - self.assertEqual(ethernet['dst'], b'\x40\x33\x1a\xd1\x85\x1c') + self.assertEqual(ethernet['src'], 'a4:5e:60:d9:6b:97') + self.assertEqual(ethernet['dst'], '40:33:1a:d1:85:1c') self.assertEqual(ethernet['type'], 0x0800) network = ethernet['IP'] @@ -212,6 +216,103 @@ def test_packet2dict_and_chain_tolerate_an_undecoded_frame(self) -> None: self.assertEqual(packet2chain(make_packet(bytearray(b'\x01')), data_link=LinkType.ETHERNET), 'ETHERNET:Raw') + ########################################################################## + # Address and hex-text parsing helpers (see GH#743). + ########################################################################## + + def test_parse_ipv4_address_decodes_dotted_decimal_ascii_bytes(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_ipv4_address + + # PyPCAPFile 0.12.0's actual ``IP.src``/``IP.dst`` representation: a + # ``ctypes.c_char_p`` holding dotted-decimal ASCII text, not a packed + # 4-byte value. + self.assertEqual(_parse_ipv4_address(b'10.1.1.2'), ipaddress.IPv4Address('10.1.1.2')) + self.assertEqual(_parse_ipv4_address(b'255.255.255.255'), + ipaddress.IPv4Address('255.255.255.255')) + + def test_parse_ipv4_address_also_accepts_packed_bytes(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_ipv4_address + + # Defensive acceptance for a differently-represented PyPCAPFile release + # or fork -- a packed 4-byte value is never a valid dotted-quad string, + # so the two forms cannot be confused with each other. + self.assertEqual(_parse_ipv4_address(b'\n\x01\x01\x02'), + ipaddress.IPv4Address('10.1.1.2')) + + def test_parse_ipv4_address_wraps_invalid_input(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_ipv4_address + from pcapkit.utilities.exceptions import ProtocolError + + for label, value in ( + ('not an address', b'not-an-ip'), + ('not ASCII', b'\xff\xff\xff\xff\xff'), + ('none', None), + # ``int`` is deliberately rejected, not merely unparsed: an earlier + # revision accepted it only to keep ``FakeIP``'s int-typed stand-in + # passing, which is exactly the fixture that hid GH#743 in the + # first place. See ``_parse_ipv4_address``'s docstring. + ('int', int(ipaddress.IPv4Address('10.1.1.2'))), + ): + with self.subTest(case=label): + with self.assertRaises(ProtocolError): + _parse_ipv4_address(value) + + def test_maybe_unhex_decodes_hex_ascii_text(self) -> None: + from pcapkit.toolkit.pypcapfile import _maybe_unhex + + # PyPCAPFile 0.12.0 stores ``IP.opt``/``IP.payload`` as hex-ASCII text + # too, once decoding stops short of the transport layer -- which is + # exactly how the PyPCAPFile engine calls it. + self.assertEqual(_maybe_unhex(b'94040000'), b'\x94\x04\x00\x00') + self.assertEqual(_maybe_unhex(b''), b'') + + def test_maybe_unhex_leaves_already_raw_bytes_alone(self) -> None: + from pcapkit.toolkit.pypcapfile import _maybe_unhex + + self.assertEqual(_maybe_unhex(b'segment'), b'segment') # odd length + self.assertEqual(_maybe_unhex(b'\x01\x01\x01\x00'), b'\x01\x01\x01\x00') # non-hex bytes + + def test_maybe_unhex_has_a_false_positive_on_all_hex_digit_raw_bytes(self) -> None: + from pcapkit.toolkit.pypcapfile import _maybe_unhex + + # Documents the residual risk called out in ``_maybe_unhex``'s docstring + # rather than a desired behaviour: this 24-byte value is a plausible raw + # TCP header (data offset 6, NS+ACK+FIN, ports 12336/12593) whose every + # byte happens to fall in the ASCII hex-digit range, so it is + # indistinguishable from real hex text and gets halved. + raw_header = b'001122223333aA4455660000' + self.assertEqual(_maybe_unhex(raw_header), binascii.unhexlify(raw_header)) + self.assertNotEqual(_maybe_unhex(raw_header), raw_header) + + def test_parse_mac_address_decodes_colon_ascii_text(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_mac_address + + # PyPCAPFile 0.12.0's actual ``Ethernet.src``/``.dst`` representation: + # already colon-separated ASCII hex text, not a raw 6-byte value. + self.assertEqual(_parse_mac_address(b'01:00:5e:01:03:03'), '01:00:5e:01:03:03') + + def test_parse_mac_address_also_accepts_raw_six_bytes(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_mac_address + + # Defensive acceptance for a differently-represented PyPCAPFile release + # or fork, and for the raw-bytes stand-ins used in this module. + self.assertEqual(_parse_mac_address(b'\x01\x00\x5e\x01\x03\x03'), '01:00:5e:01:03:03') + self.assertEqual(_parse_mac_address(bytearray(b'\x01\x00\x5e\x01\x03\x03')), + '01:00:5e:01:03:03') + + def test_parse_mac_address_wraps_invalid_input(self) -> None: + from pcapkit.toolkit.pypcapfile import _parse_mac_address + from pcapkit.utilities.exceptions import ProtocolError + + for label, value in ( + ('not ASCII', b'\xff\xff\xff\xff\xff'), + ('none', None), + ('int', 42), + ): + with self.subTest(case=label): + with self.assertRaises(ProtocolError): + _parse_mac_address(value) + ########################################################################## # Reassembly and flow tracing. ########################################################################## @@ -359,6 +460,70 @@ def test_tcp_traceflow_reports_the_flow_endpoints(self) -> None: self.assertEqual(data.timestamp, 1511106545.471719) self.assertEqual(data.frame['ETHERNET']['IP']['src'], '10.1.1.2') + def test_ipv4_2dict_unhexes_the_options_field(self) -> None: + from pcapfile.protocols.network.ip import IP + + from pcapkit.toolkit.pypcapfile import _ipv4_2dict + + raw = make_ipv4(b'payload', options=b'\x94\x04\x00\x00') + info = _ipv4_2dict(IP(raw)) + self.assertEqual(info['opt'], b'\x94\x04\x00\x00') + + def test_ipv4_reassembly_unhexes_the_fragment_payload(self) -> None: + from pcapfile.protocols.network.ip import IP + + from pcapkit.toolkit.pypcapfile import ipv4_reassembly + + payload = b'fragment-body-bytes' + raw = make_ipv4(payload, flags=0b001) + packet = make_packet(FakeEthernet(IP(raw))) + + data = ipv4_reassembly(packet, count=1) + self.assertIsNotNone(data) + self.assertEqual(bytes(data.payload), payload) + + def test_layer2dict_keeps_opt_and_nested_payload_length_consistent(self) -> None: + from pcapfile.protocols.network.ip import IP + + from pcapkit.toolkit.pypcapfile import _layer2dict + + # Regression for the inconsistency a partial fix left behind: ``opt`` + # correctly un-hexed while the nested ``'Raw'`` payload stayed + # hex-encoded (and twice its true length) in the very same dict. + payload = b'x' * 32 + raw = make_ipv4(payload, options=b'\x94\x04\x00\x00') + info = _layer2dict(IP(raw)) + self.assertEqual(info['opt'], b'\x94\x04\x00\x00') + self.assertEqual(info['Raw'], {'raw_len': 32, 'raw': payload}) + + def test_layer2dict_unhexes_an_undecoded_ethernet_payload(self) -> None: + from pcapfile.protocols.linklayer.ethernet import Ethernet + + from pcapkit.toolkit.pypcapfile import _layer2dict + + # PyPCAPFile hex-encodes an Ethernet frame's payload too, whenever the + # ethertype has no decoder of its own (e.g. ARP, which PyPCAPFile does + # not decode) -- the same defect class as the IP layer's, one class over. + body = b'unknown-ethertype-body' + frame = struct.pack('!6s6sH', b'\x01\x00\x5e\x01\x03\x03', + b'\x00\x0c\x29\x3f\x1a\x07', 0x0806) + body + info = _layer2dict(Ethernet(frame)) + self.assertEqual(info['Raw'], {'raw_len': len(body), 'raw': body}) + + def test_ethernet2dict_reports_the_default_engines_mac_format(self) -> None: + from pcapfile.protocols.linklayer.ethernet import Ethernet + + from pcapkit.toolkit.pypcapfile import _ethernet2dict + + # Against PyPCAPFile's real decoder: confirms ``_ethernet2dict`` reports + # the same lowercase colon-separated form the default engine's own + # ``Ethernet._read_mac_addr`` does, not PyPCAPFile's ASCII bytes as-is. + frame = struct.pack('!6s6sH', b'\x01\x00\x5e\x01\x03\x03', + b'\x00\x0c\x29\x3f\x1a\x07', 0x0800) + b'\x00' * 20 + info = _ethernet2dict(Ethernet(frame)) + self.assertEqual(info['dst'], '01:00:5e:01:03:03') + self.assertEqual(info['src'], '00:0c:29:3f:1a:07') + if __name__ == '__main__': unittest.main()