diff --git a/docs/source/pcapkit/foundation/engines/3rdparty.rst b/docs/source/pcapkit/foundation/engines/3rdparty.rst index 10a712e211..0eb686f10b 100644 --- a/docs/source/pcapkit/foundation/engines/3rdparty.rst +++ b/docs/source/pcapkit/foundation/engines/3rdparty.rst @@ -400,7 +400,11 @@ support, as is used by :class:`pcapkit.foundation.extraction.Extractor`. disabled -- with an :class:`~pcapkit.utilities.warnings.AttributeWarning` -- when requested; IPv4 and TCP reassembly and TCP flow tracing remain available. PCAP-NG is rejected with a - :exc:`~pcapkit.utilities.exceptions.FormatError`. + :exc:`~pcapkit.utilities.exceptions.FormatError`. `PyPCAPFile`_ 0.12.0 drops + every record of a big-endian savefile, every record from a zero-length one + on, and a truncated final record, so the engine reads the records itself, + into `PyPCAPFile`_'s own packet objects -- see + :meth:`~pcapkit.foundation.engines.pypcapfile.PyPCAPFile._read_records`. .. autoclass:: pcapkit.foundation.engines.pypcapfile.PyPCAPFile :no-members: @@ -427,6 +431,7 @@ Internal Definitions -------------------- .. automethod:: pcapkit.foundation.engines.pypcapfile.PyPCAPFile._get_decoder +.. automethod:: pcapkit.foundation.engines.pypcapfile.PyPCAPFile._read_records .. automethod:: pcapkit.foundation.engines.pypcapfile.PyPCAPFile._decode Backend Detection diff --git a/pcapkit/foundation/engines/pypcapfile.py b/pcapkit/foundation/engines/pypcapfile.py index 404c56ba6f..6bb97b2f87 100644 --- a/pcapkit/foundation/engines/pypcapfile.py +++ b/pcapkit/foundation/engines/pypcapfile.py @@ -11,6 +11,7 @@ """ import binascii +import ctypes import struct import sys from typing import TYPE_CHECKING, cast @@ -20,13 +21,14 @@ from pcapkit.foundation.engines.engine import EngineBase from pcapkit.foundation.reassembly import ReassemblyManager from pcapkit.utilities.exceptions import FormatError, stacklevel -from pcapkit.utilities.warnings import AttributeWarning, warn +from pcapkit.utilities.warnings import AttributeWarning, ProtocolWarning, warn __all__ = ['PyPCAPFile'] if TYPE_CHECKING: from typing import Any, Callable, Iterator, Optional + from pcapfile.structs import __pcap_header__ as PCAPFileHeader from pcapfile.structs import pcap_packet as PCAPFilePacket from pcapkit.foundation.extraction import Extractor @@ -41,6 +43,9 @@ class PyPCAPFile(EngineBase['PCAPFilePacket']): * reads PCAP savefiles only, raising :exc:`~pcapkit.utilities.exceptions.FormatError` on PCAP-NG, + * reads the savefile's records itself, into `PyPCAPFile`_'s own packet + objects, as `PyPCAPFile`_ 0.12.0 drops records the ``default`` engine + reads -- see :meth:`_read_records`, * disables IPv6 reassembly, warning as it does so, while leaving IPv4 and TCP reassembly and TCP flow tracing in place, and * leaves TCP over IPv6 out of TCP reassembly and flow tracing, with one @@ -157,14 +162,17 @@ def run(self) -> 'None': as :mod:`pcapfile` and :attr:`self._extmp ` as an iterator over the lazily generated savefile packets. - The savefile is loaded with ``layers=0``. Per :func:`pcapfile.savefile - ._read_a_packet`, that does *not* hand back each frame's bytes verbatim -- - it hexlifies the whole frame into ASCII text and stops there, exactly as it - would for any layer at which it runs out of layers left to descend. It is - :meth:`_decode` that un-hexlifies and then decodes each frame to - :attr:`LAYERS` depth using :func:`pcapfile.linklayer.clookup` -- the very - call :mod:`pcapfile` makes internally. Doing it this way round is what - lets the link layer type be inspected (and reported on) *before* the + :func:`pcapfile.savefile.load_savefile` loads and validates the savefile + header. The records are then read by :meth:`_read_records`, not by the + packet generator it returns, which drops records (see there). Like + :func:`pcapfile.savefile._read_a_packet` with ``layers=0``, + :meth:`_read_records` does *not* hand back each frame's bytes verbatim -- + it hexlifies the whole frame into ASCII text and stops there, exactly as + :mod:`pcapfile` does for any layer at which it runs out of layers left to + descend. It is :meth:`_decode` that un-hexlifies and then decodes each + frame to :attr:`LAYERS` depth using :func:`pcapfile.linklayer.clookup` -- + the very call :mod:`pcapfile` makes internally. Doing it this way round is + what lets the link layer type be inspected (and reported on) *before* the first frame is decoded. Warns: @@ -204,9 +212,8 @@ def run(self) -> 'None': warn("'Extractor(engine=pypcapfile)' object does not support IPv6 reassembly; " "so 'ipv6=True' will be ignored", AttributeWarning, stacklevel=stacklevel()) - sfile = pcapfile.savefile.load_savefile( - NamedStream(ext._ifile, ext._ifnm), layers=0, lazy=True, - ) + stream = NamedStream(ext._ifile, ext._ifnm) + sfile = pcapfile.savefile.load_savefile(stream, layers=0, lazy=True) self._dlink = Enum_LinkType.get(sfile.header.ll_type) self._declf = self._get_decoder(sfile.header.ll_type) @@ -218,7 +225,7 @@ def run(self) -> 'None': ) # pylint: disable=logging-fstring-interpolation # extract & analyse file - self._extmp = iter(sfile.packets) + self._extmp = self._read_records(stream, sfile.header) def read_frame(self) -> 'PCAPFilePacket': """Read frames with PyPCAPFile engine. @@ -312,6 +319,62 @@ def _get_decoder(self, linktype: 'int') -> 'Optional[Callable[..., Any]]': return None return decoder + def _read_records(self, file: 'NamedStream', + header: 'PCAPFileHeader') -> 'Iterator[PCAPFilePacket]': + """Read the savefile's records, as the ``default`` engine reads them. + + :func:`pcapfile.savefile._read_a_packet` (pypcapfile 0.12.0) drops + records the ``default`` engine reads, in two ways: + + * It picks the byte order of each record header by testing + ``hdrp[0].byteorder == 'big'`` -- :class:`bytes` against :class:`str`, + never true on Python 3. A big-endian record header is unpacked + little-endian, its byte-swapped capture length overruns the file, and + the savefile ends at its first record (#1512). + * It ends the savefile at any record whose frame data is empty or shorter + than its capture length, so a zero-length record drops itself and every + record after it, and a truncated final record is dropped (#1571). + + This reads each record in the byte order of the header :mod:`pcapfile` + loaded, into the :class:`pcapfile.structs.pcap_packet` that function + builds with ``layers=0`` -- the frame hexlified, pointing at the + savefile's own header -- so :meth:`_decode` and + :mod:`pcapkit.toolkit.pypcapfile` see what :mod:`pcapfile` hands them. + It stops where :class:`~pcapkit.protocols.misc.pcap.frame.Frame` stops, + at a tail too short for a record header, and keeps a record whose capture + length runs past the data as that does: the capture length as declared, + the octets present as the frame. + + Args: + file: The savefile, just past its header, where + :func:`pcapfile.savefile.load_savefile` leaves it. + header: The savefile header :mod:`pcapfile` loaded. + + Yields: + Undecoded savefile packets, one per record. + + Warns: + ProtocolWarning: If a record's capture length runs past the data. + + """ + order = '>' if header.byteorder == b'big' else '<' + hdrp = ctypes.pointer(header) + number = 0 + while True: + number += 1 + record = file.read(16) + if len(record) < 16: + return + timestamp, timestamp_us, capture_len, packet_len = struct.unpack(f'{order}IIII', record) + data = file.read(capture_len) + if len(data) < capture_len: + warn(f'PCAP: [Frame {number}] captured length {capture_len} runs past the data, ' + f'which holds {len(data)} octet(s); kept as captured', + ProtocolWarning, stacklevel=stacklevel()) + yield cast('PCAPFilePacket', self._expkg.structs.pcap_packet( + hdrp, timestamp, timestamp_us, capture_len, packet_len, binascii.hexlify(data), + )) + def _decode(self, packet: 'PCAPFilePacket', frnum: 'int') -> 'PCAPFilePacket': """Decode a raw savefile packet down to :attr:`LAYERS` depth. @@ -327,7 +390,7 @@ def _decode(self, packet: 'PCAPFilePacket', frnum: 'int') -> 'PCAPFilePacket': Returns: The decoded packet, or ``packet`` unchanged when it could not be - decoded. + decoded, or holds no octets at all. Warns: AttributeWarning: If :mod:`pcapfile` could not decode the frame, e.g. @@ -335,7 +398,9 @@ def _decode(self, packet: 'PCAPFilePacket', frnum: 'int') -> 'PCAPFilePacket': the extraction, but it should not pass silently either. """ - if self._declf is None: + # A zero-length record holds nothing to decode, which is not a decoding + # failure: the ``default`` engine reads one without a warning too. + if self._declf is None or not packet.packet: return packet try: diff --git a/tests/foundation/engines/test_engine_agreement_runtime.py b/tests/foundation/engines/test_engine_agreement_runtime.py index d196cdc01f..f13affd332 100644 --- a/tests/foundation/engines/test_engine_agreement_runtime.py +++ b/tests/foundation/engines/test_engine_agreement_runtime.py @@ -365,15 +365,6 @@ class TestEngineAgreement(harness.RoundTripBase): 'Extractor swaps the format with a FormatWarning ' '(pcapkit/foundation/extraction.py:1439-1443)', 'FLOWS', 'file of ', tuple(f'{capture}/pypcapfile/flows' for capture in TRACED_IPV4_ONLY_PCAP)), - Gap(1512, 'the pypcapfile engine reads no frame from a big-endian PCAP, without a warning: ' - 'pcapfile.savefile.load_savefile (pypcapfile 0.12.0) yields no packets for one, ' - 'and the engine iterates that empty list (pcapkit/foundation/engines/' - 'pypcapfile.py:235, :249)', - ('FRAMES', 'RECORDS', 'INPUTS', 'DATAGRAMS'), - ('0 frames != 3', '0 timestamps != 3', 'frame 1: missing', '0 datagrams != 3'), - tuple(f'{capture}/pypcapfile/{aspect}' - for capture in ('big_endian.pcap', 'big_endian_nanosecond.pcap') - for aspect in ('frames', 'timestamps', 'ipv4', 'datagrams'))), Gap(1502, 'the default engine does not dissect LINKTYPE_RAW (101) frames, which scapy reads ' 'as IPv4: neither Frame.__proto__ nor PCAPNG.__proto__ registers LinkType.RAW ' '(pcapkit/protocols/misc/pcap/frame.py:90, pcapkit/protocols/misc/pcapng.py:699), ' diff --git a/tests/foundation/engines/test_pypcapfile_engine.py b/tests/foundation/engines/test_pypcapfile_engine.py index 82d998fd28..152ca6bca7 100644 --- a/tests/foundation/engines/test_pypcapfile_engine.py +++ b/tests/foundation/engines/test_pypcapfile_engine.py @@ -4,7 +4,9 @@ and :func:`pcapfile.linklayer.clookup`, so that the routing decisions -- format gating, the IPv6 capability gap, per-frame decoding and its failure path, output, reassembly, tracing, storage -- are covered whether or not `pypcapfile`_ is -installed. End-to-end agreement with the ``default`` engine lives in +installed. One test runs the real `pypcapfile`_ where it imports, over savefiles +built in memory, to pin the engine's record reader to the ``default`` engine's +reading in both byte orders (#1512, #1571). End-to-end agreement with the ``default`` engine lives in :mod:`tests.foundation.engines.test_new_engine_parity`. .. _pypcapfile: https://github.com/kisom/pypcapfile @@ -13,8 +15,11 @@ from __future__ import annotations import binascii +import ctypes +import importlib import importlib.util import io +import itertools import struct import sys import types @@ -71,12 +76,79 @@ def __init__(self, header, timestamp, timestamp_us, capture_len, packet_len, pac self.packet = packet +class FakeHeader(ctypes.Structure): + """Stand-in for :class:`pcapfile.structs.__pcap_header__`. + + A :mod:`ctypes` structure, like the real one, so that a packet can point at + it and its ``byteorder`` reads back as :class:`bytes`, as the real one does. + + """ + + _fields_ = [('ll_type', ctypes.c_uint), ('byteorder', ctypes.c_char_p), + ('ns_resolution', ctypes.c_bool)] + + +class UnreadPackets: + """Stand-in for the packet generator :func:`pcapfile.savefile.load_savefile` + returns, which pypcapfile 0.12.0 drops records from (#1512, #1571): the engine + reads the records itself, so taking this is a failure.""" + + def __iter__(self): + raise AssertionError("the engine took pcapfile's packet generator, which drops records") + + class FakeSaveFile: """Stand-in for :class:`pcapfile.savefile.pcap_savefile`.""" - def __init__(self, packets, ll_type: int = 1, ns_resolution: bool = False) -> None: - self.header = types.SimpleNamespace(ll_type=ll_type, ns_resolution=ns_resolution) - self.packets = packets + def __init__(self, ll_type: int = 1, ns_resolution: bool = False, + byteorder: bytes = b'little') -> None: + self.header = FakeHeader(ll_type=ll_type, byteorder=byteorder, ns_resolution=ns_resolution) + self.packets = UnreadPackets() + + +#: Savefile magic numbers, by ``(byte order, nanosecond)``. +MAGIC = {('little', False): b'\xd4\xc3\xb2\xa1', ('big', False): b'\xa1\xb2\xc3\xd4', + ('little', True): b'\x4d\x3c\xb2\xa1', ('big', True): b'\xa1\xb2\x3c\x4d'} + + +def build_savefile(records, *, byteorder: str, nanosecond: bool = False, + tail: bytes = b'') -> bytes: + """A PCAP savefile of ``(seconds, fraction, frame, original length)`` records, + then ``tail``. A distinct original length makes a swapped field show.""" + order = '<' if byteorder == 'little' else '>' + out = [MAGIC[byteorder, nanosecond] + struct.pack(f'{order}HHiIII', 2, 4, 0, 0, 0xFFFF, 1)] + for seconds, fraction, frame, length in records: + out.append(struct.pack(f'{order}IIII', seconds, fraction, len(frame), length) + frame) + return b''.join(out) + tail + + +def reference_records(data: bytes) -> list: + """Every record of a savefile, read with :mod:`struct` alone, as the ``default`` + engine reads them: to a tail too short for a record header, keeping a record + whose capture length runs past the data as ``(seconds, fraction, captured + length, original length, the octets present)``.""" + byteorder, _ = next(key for key, magic in MAGIC.items() if magic == data[:4]) + order = '<' if byteorder == 'little' else '>' + records, offset = [], 24 + while len(data) - offset >= 16: + seconds, fraction, incl, orig = struct.unpack_from(f'{order}IIII', data, offset) + records.append((seconds, fraction, incl, orig, data[offset + 16:offset + 16 + incl])) + offset += 16 + incl + return records + + +def _importable(*modules: str) -> bool: + for module in modules: + try: + importlib.import_module(module) + except ImportError: + return False + return True + + +#: ``pypcapfile`` 0.12.0 installs on Python 3.12 and newer but cannot import +#: there (see :class:`PyPCAPFilePythonCeilingTests`), hence an import test. +HAS_PYPCAPFILE = _importable('pcapfile.savefile', 'pcapfile.linklayer', 'pcapfile.structs') class FakeDecoded: @@ -129,7 +201,7 @@ def engine(self, extractor, savefile=None, decoder=FakeDecoded): from pcapkit.foundation.engines.pypcapfile import PyPCAPFile if savefile is None: - savefile = FakeSaveFile([FakePacket(None, 1, 500000, 7, 7, HEXLIFIED_FRAME)]) + savefile = FakeSaveFile() engine = PyPCAPFile.__new__(PyPCAPFile) engine._expkg = types.SimpleNamespace( @@ -251,6 +323,193 @@ def test_run_installs_verbose_handler_reporting_the_chain(self) -> None: printer.assert_called_once() self.assertIn('ETHERNET:Raw', printer.call_args.args[0]) + ########################################################################## + # Records (#1512, #1571). + ########################################################################## + + def reading(self, data: bytes, decoder=FakeDecoded): + """An engine run over the savefile ``data``, its header loaded as + :func:`pcapfile.savefile.load_savefile` loads it: the first 24 octets + read, and the byte order and resolution recorded.""" + (byteorder, nanosecond), = (key for key, magic in MAGIC.items() if magic == data[:4]) + savefile = FakeSaveFile(byteorder=byteorder.encode(), ns_resolution=nanosecond) + extractor, _ = self.make_extractor(_ifile=io.BytesIO(data), _flag_q=True, + magic_number=data[:4]) + engine = self.engine(extractor, savefile=savefile, decoder=decoder) + + def load_savefile(stream, layers, lazy): + stream.read(24) + return savefile + + engine._expkg.savefile.load_savefile = mock.Mock(side_effect=load_savefile) + engine.run() + return engine, savefile.header + + def frames(self, engine) -> 'tuple[list, list]': + """Every frame ``engine`` reads, and the warnings it gives on the way.""" + frames = [] + with mock.patch('pcapkit.foundation.engines.pypcapfile.warn') as warn: + while True: + try: + frames.append(engine.read_frame()) + except StopIteration: + return frames, [call.args for call in warn.call_args_list] + + @staticmethod + def octets(frame) -> bytes: + """The frame octets a packet carries, decoded or not.""" + if isinstance(frame.packet, FakeDecoded): + return frame.packet.raw + return binascii.unhexlify(frame.packet) + + def test_records_are_read_in_the_header_byte_order(self) -> None: + records = [(1500000000, 123456, b'first-frame', 11), + (1500000001, 654321, b'second', 1200)] + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + engine, header = self.reading(build_savefile(records, byteorder=byteorder)) + frames, warned = self.frames(engine) + + self.assertEqual(warned, []) + self.assertEqual(len(frames), 2) + self.assertEqual(engine._extractor._frnum, 2) + for frame, (seconds, fraction, raw, length) in zip(frames, records): + self.assertEqual((frame.timestamp, frame.timestamp_us), (seconds, fraction)) + self.assertEqual((frame.capture_len, frame.packet_len), (len(raw), length)) + # handed to the decoder as pcapfile hands over a frame: + # hexlified, then un-hexlified by _decode + self.assertIsInstance(frame.packet, FakeDecoded) + self.assertEqual(frame.packet.raw, raw) + # and pointing at the savefile's own header, as pcapfile's do + self.assertEqual(ctypes.addressof(frame.header.contents), + ctypes.addressof(header)) + + def test_a_nanosecond_savefile_keeps_its_resolution(self) -> None: + from pcapkit.toolkit.pypcapfile import packet2timestamp + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + engine, _ = self.reading(build_savefile([(1500000000, 123456789, b'frame', 5)], + byteorder=byteorder, nanosecond=True)) + (frame,), _ = self.frames(engine) + self.assertEqual(frame.timestamp_us, 123456789) + self.assertEqual(packet2timestamp(frame), + 1500000000 + 123456789 / 1_000_000_000) + + def test_a_zero_length_record_is_read_and_left_undecoded(self) -> None: + """pcapfile ends the savefile at a zero-length record (#1571); the ``default`` + engine reads it, and every record after it, without a warning.""" + cases = { + 'middle': [(1, 0, b'first', 5), (2, 0, b'', 0), (3, 0, b'third', 5)], + 'first': [(1, 0, b'', 0), (2, 0, b'second', 6), (3, 0, b'third', 5)], + } + for (case, records), byteorder in itertools.product(cases.items(), ('little', 'big')): + with self.subTest(case=case, byteorder=byteorder): + decoder = mock.Mock(side_effect=FakeDecoded) + engine, _ = self.reading(build_savefile(records, byteorder=byteorder), + decoder=decoder) + frames, warned = self.frames(engine) + + self.assertEqual([self.octets(frame) for frame in frames], + [raw for _, _, raw, _ in records]) + self.assertEqual([frame.timestamp for frame in frames], [1, 2, 3]) + self.assertEqual(warned, []) + # nothing to decode, so the decoder never sees the empty frame + self.assertEqual(decoder.call_count, 2) + self.assertNotIn(b'', [call.args[0] for call in decoder.call_args_list]) + + def test_a_truncated_final_record_is_kept_as_captured(self) -> None: + """pcapfile drops a final record whose capture length runs past the data + (#1571); the ``default`` engine keeps it, as declared, with the octets that + are there, and warns.""" + from pcapkit.utilities.warnings import ProtocolWarning + + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + order = '<' if byteorder == 'little' else '>' + tail = struct.pack(f'{order}IIII', 3, 4, 50, 60) + b'only-ten..' + engine, _ = self.reading(build_savefile([(1, 2, b'whole', 5)], + byteorder=byteorder, tail=tail)) + frames, warned = self.frames(engine) + + self.assertEqual([self.octets(frame) for frame in frames], + [b'whole', b'only-ten..']) + self.assertEqual((frames[1].capture_len, frames[1].packet_len), (50, 60)) + self.assertEqual(len(warned), 1) + message, category = warned[0][:2] + self.assertIs(category, ProtocolWarning) + self.assertIn('[Frame 2] captured length 50 runs past the data, ' + 'which holds 10 octet(s)', message) + + def test_a_tail_too_short_for_a_record_header_ends_the_savefile(self) -> None: + for byteorder in ('little', 'big'): + with self.subTest(byteorder=byteorder): + engine, _ = self.reading(build_savefile([(1, 2, b'whole', 5)], + byteorder=byteorder, tail=b'\x00' * 15)) + frames, warned = self.frames(engine) + self.assertEqual([self.octets(frame) for frame in frames], [b'whole']) + self.assertEqual(warned, []) + + @unittest.skipUnless(HAS_PYPCAPFILE, 'pypcapfile not installed, or not importable here') + def test_records_agree_with_the_default_engine_against_the_real_pcapfile(self) -> None: + """Against the real :mod:`pcapfile`, in both byte orders and resolutions: + the engine reads every record :func:`reference_records` reads, field for + field and octet for octet, into :mod:`pcapfile`'s own packet objects -- + including the records pypcapfile 0.12.0 drops (#1512, #1571).""" + import pcapfile.linklayer + import pcapfile.savefile + import pcapfile.structs + + from pcapkit.const.reg.linktype import LinkType + from pcapkit.corekit.io import NamedStream + from pcapkit.foundation.engines.pypcapfile import PyPCAPFile + from tests.foundation import _roundtrip as wire + + def frame(n: int) -> bytes: + return wire.ethernet(wire.ipv4(wire.udp(wire.payload_pattern(9 + n, n))), 0x0800) + + def fields(packet) -> tuple: + return (packet.timestamp, packet.timestamp_us, packet.capture_len, + packet.packet_len, binascii.unhexlify(packet.packet)) + + whole = [(1500000000 + n, 1000 * n + 7, frame(n), len(frame(n))) for n in range(3)] + cases = { + 'whole': (whole, b''), + 'zero-length middle record': ([whole[0], (1500000001, 0, b'', 0), whole[2]], b''), + 'zero-length first record': ([(1500000000, 0, b'', 0)] + whole[1:], b''), + 'truncated final record': (whole[:2], None), + } + for (case, (records, tail)), byteorder, nanosecond in itertools.product( + cases.items(), ('little', 'big'), (False, True)): + with self.subTest(case=case, byteorder=byteorder, nanosecond=nanosecond): + if tail is None: # the last record's header promises 4 octets more than follow + order = '<' if byteorder == 'little' else '>' + length = len(frame(2)) + 4 + tail = struct.pack(f'{order}IIII', 1500000002, 9, length, length) + frame(2) + data = build_savefile(records, byteorder=byteorder, nanosecond=nanosecond, + tail=tail) + extractor, _ = self.make_extractor(_ifile=io.BytesIO(data), _flag_q=True, + magic_number=data[:4]) + engine = PyPCAPFile.__new__(PyPCAPFile) + engine._expkg = pcapfile + engine._extmp = None + engine._extractor = extractor + engine.run() + self.assertEqual(engine.dlink, LinkType.ETHERNET) + with mock.patch('pcapkit.foundation.engines.pypcapfile.warn'): + packets = list(engine._extmp) + + self.assertEqual([fields(packet) for packet in packets], reference_records(data)) + for packet in packets: + self.assertIsInstance(packet, pcapfile.structs.pcap_packet) + self.assertIs(packet.header[0].ns_resolution, nanosecond) + + # where pcapfile reads a savefile whole, its packets are the engine's + data = build_savefile(whole, byteorder='little') + expected = pcapfile.savefile.load_savefile(NamedStream(io.BytesIO(data), 'whole.pcap'), + layers=0).packets + self.assertEqual([fields(packet) for packet in expected], reference_records(data)) + ########################################################################## # read_frame() ########################################################################## @@ -260,9 +519,12 @@ def prepared(self, packets=None, decoder=FakeDecoded, **overrides): packets = [FakePacket(types.SimpleNamespace(ns_resolution=False), 1, 500000, 7, 7, HEXLIFIED_FRAME)] extractor, sink = self.make_extractor(**overrides) - engine = self.engine(extractor, savefile=FakeSaveFile(iter(packets)), decoder=decoder) + engine = self.engine(extractor, decoder=decoder) with mock.patch('pcapkit.foundation.engines.pypcapfile.warn'): engine.run() + # read_frame() is under test here, not the record reader, so it is + # handed these packets as if read from the savefile + engine._extmp = iter(packets) return extractor, sink, engine def test_read_frame_decodes_to_layers_depth_and_routes_output(self) -> None: diff --git a/tests/interface/test_core.py b/tests/interface/test_core.py index 1abcb2fe8b..2c7f39bece 100644 --- a/tests/interface/test_core.py +++ b/tests/interface/test_core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import importlib.util import sys import types @@ -52,14 +53,29 @@ def __init__(self, header, timestamp, timestamp_us, capture_len, packet_len, pac self.packet = packet +class FakeHeader(ctypes.Structure): + """Stand-in for :class:`pcapfile.structs.__pcap_header__`, a :mod:`ctypes` + structure that the engine's packets point at, as the real one is.""" + + _fields_ = [('ll_type', ctypes.c_uint), ('byteorder', ctypes.c_char_p), + ('ns_resolution', ctypes.c_bool)] + + class FakeSaveFile: """Stand-in for :class:`pcapfile.savefile.pcap_savefile`.""" def __init__(self) -> None: - self.header = types.SimpleNamespace(ll_type=1, ns_resolution=False) + self.header = FakeHeader(ll_type=1, byteorder=b'little', ns_resolution=False) self.packets = [FakePacket(None, 1, 500000, 7, 7, b'payload')] +def fake_load_savefile(stream, *args, **kwargs) -> FakeSaveFile: + """Stand-in for :func:`pcapfile.savefile.load_savefile`, which reads the + 24-octet savefile header and leaves the records to be read after it.""" + stream.read(24) + return FakeSaveFile() + + class FakeDecoded: """Stand-in for a decoded link layer frame.""" @@ -354,7 +370,7 @@ def test_pypcapfile_constant_selects_the_pypcapfile_engine(self) -> None: package = types.ModuleType('pcapfile') savefile = types.ModuleType('pcapfile.savefile') - savefile.load_savefile = lambda *a, **kw: FakeSaveFile() # type: ignore[attr-defined] + savefile.load_savefile = fake_load_savefile # type: ignore[attr-defined] linklayer = types.ModuleType('pcapfile.linklayer') linklayer.clookup = lambda linktype: FakeDecoded # type: ignore[attr-defined] structs = types.ModuleType('pcapfile.structs')