|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""A binary stream with no :obj:`str` ``name`` is a valid ``fin`` (#1506). |
| 3 | +
|
| 4 | +``fin`` is documented and typed as ``str | IO[bytes]`` -- "file name to be read |
| 5 | +or a binary IO object" -- on both :func:`pcapkit.extract` and |
| 6 | +:class:`~pcapkit.foundation.extraction.Extractor`. Two things on the way in |
| 7 | +assumed more than that and raised :exc:`AttributeError` on an |
| 8 | +:class:`io.BytesIO` before a single frame was read: |
| 9 | +
|
| 10 | +* :meth:`Extractor.make_name <pcapkit.foundation.extraction.Extractor.make_name>` |
| 11 | + read ``fin.name``, which a :class:`io.BytesIO` does not have. The name only |
| 12 | + labels the input -- the output name comes from ``fout`` alone -- so a stream |
| 13 | + without one is now labelled after its type, ``<BytesIO>``. |
| 14 | +* The magic number, and the PCAP-NG engine's block type, are read with |
| 15 | + ``peek``, which :class:`io.BufferedReader` has and :class:`typing.IO` does not. |
| 16 | +
|
| 17 | +The engines whose library opens the input by path -- Scapy, PyShark, PyPCAP and |
| 18 | +pcap-ct -- cannot read such a stream at all, so they refuse it with |
| 19 | +:exc:`~pcapkit.utilities.exceptions.UnsupportedCall` instead. |
| 20 | +
|
| 21 | +""" |
| 22 | + |
| 23 | +from __future__ import annotations |
| 24 | + |
| 25 | +import importlib.util |
| 26 | +import io |
| 27 | +import os |
| 28 | +import tempfile |
| 29 | +import unittest |
| 30 | +from unittest import mock |
| 31 | + |
| 32 | +from tests._support import sample_path |
| 33 | + |
| 34 | +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') |
| 35 | +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) |
| 36 | + |
| 37 | + |
| 38 | +@unittest.skipUnless(HAS_RUNTIME, 'pcapkit runtime dependencies are not installed') |
| 39 | +class TestUnnamedStream(unittest.TestCase): |
| 40 | + """``fin`` given as a stream that has no :obj:`str` ``name``.""" |
| 41 | + |
| 42 | + def _frames(self, capture: str) -> tuple[int, 'object', io.BytesIO]: |
| 43 | + """Extract ``capture`` from a :class:`io.BytesIO`, and count it by path too.""" |
| 44 | + import pcapkit # pylint: disable=import-outside-toplevel |
| 45 | + |
| 46 | + path = sample_path(capture) |
| 47 | + expected = len(pcapkit.extract(fin=path, nofile=True).frame) |
| 48 | + with open(path, 'rb') as file: |
| 49 | + stream = io.BytesIO(file.read()) |
| 50 | + return expected, pcapkit.extract(fin=stream, nofile=True), stream |
| 51 | + |
| 52 | + def test_pcap_from_bytesio(self) -> None: |
| 53 | + expected, extractor, stream = self._frames('in.pcap') |
| 54 | + self.assertGreater(expected, 0) |
| 55 | + self.assertEqual(len(extractor.frame), expected) # type: ignore[attr-defined] |
| 56 | + self.assertEqual(extractor.input, '<BytesIO>') # type: ignore[attr-defined] |
| 57 | + self.assertFalse(stream.closed, 'the caller owns the stream (#610)') |
| 58 | + |
| 59 | + def test_pcapng_from_bytesio(self) -> None: |
| 60 | + expected, extractor, stream = self._frames('dhcp.pcapng') |
| 61 | + self.assertGreater(expected, 0) |
| 62 | + self.assertEqual(len(extractor.frame), expected) # type: ignore[attr-defined] |
| 63 | + self.assertFalse(stream.closed, 'the caller owns the stream (#610)') |
| 64 | + |
| 65 | + def test_output_name_comes_from_fout(self) -> None: |
| 66 | + import pcapkit # pylint: disable=import-outside-toplevel |
| 67 | + |
| 68 | + with open(sample_path('in.pcap'), 'rb') as file: |
| 69 | + data = file.read() |
| 70 | + with tempfile.TemporaryDirectory() as tmp: |
| 71 | + fout = os.path.join(tmp, 'out') |
| 72 | + extractor = pcapkit.extract(fin=io.BytesIO(data), fout=fout, format='json') |
| 73 | + self.assertEqual(extractor.output, f'{fout}.json') |
| 74 | + self.assertTrue(os.path.isfile(f'{fout}.json')) |
| 75 | + |
| 76 | + def test_non_str_name_is_a_placeholder(self) -> None: |
| 77 | + """A stream opened on a descriptor has an :obj:`int` ``name``.""" |
| 78 | + import pcapkit # pylint: disable=import-outside-toplevel |
| 79 | + |
| 80 | + path = sample_path('in.pcap') |
| 81 | + with open(os.open(path, os.O_RDONLY), 'rb') as stream: |
| 82 | + self.assertIsInstance(stream.name, int) |
| 83 | + extractor = pcapkit.extract(fin=stream, nofile=True) |
| 84 | + self.assertEqual(extractor.input, '<BufferedReader>') |
| 85 | + self.assertEqual(len(extractor.frame), len(pcapkit.extract(fin=path, nofile=True).frame)) |
| 86 | + |
| 87 | + def test_make_name_labels_by_name_or_type(self) -> None: |
| 88 | + from pcapkit.foundation.extraction import Extractor # pylint: disable=import-outside-toplevel |
| 89 | + |
| 90 | + stream = io.BytesIO() |
| 91 | + self.assertEqual(Extractor.make_name(stream, nofile=True)[0], '<BytesIO>') |
| 92 | + stream.name = 'capture.pcap' # type: ignore[attr-defined] |
| 93 | + self.assertEqual(Extractor.make_name(stream, nofile=True)[0], 'capture.pcap') |
| 94 | + |
| 95 | + |
| 96 | +@unittest.skipUnless(HAS_RUNTIME, 'pcapkit runtime dependencies are not installed') |
| 97 | +class TestPathEngines(unittest.TestCase): |
| 98 | + """Engines that open the input by path refuse an unnamed stream up front. |
| 99 | +
|
| 100 | + Scapy and PyShark hand ``Extractor._ifnm`` to their library as a file name, |
| 101 | + so before the refusal the ``<BytesIO>`` label was opened as a path -- |
| 102 | + ``FileNotFoundError`` from inside :mod:`scapy`. The refusal is raised by |
| 103 | + :class:`~pcapkit.foundation.extraction.Extractor` itself, before the engine |
| 104 | + is constructed, so it does not depend on the engine being able to run. |
| 105 | +
|
| 106 | + """ |
| 107 | + |
| 108 | + def _data(self) -> bytes: |
| 109 | + with open(sample_path('in.pcap'), 'rb') as file: |
| 110 | + return file.read() |
| 111 | + |
| 112 | + def _assert_refused(self, engine: str) -> None: |
| 113 | + import pcapkit # pylint: disable=import-outside-toplevel |
| 114 | + from pcapkit.utilities.exceptions import UnsupportedCall # pylint: disable=import-outside-toplevel |
| 115 | + |
| 116 | + stream = io.BytesIO(self._data()) |
| 117 | + with self.assertRaises(UnsupportedCall) as caught: |
| 118 | + pcapkit.extract(fin=stream, nofile=True, engine=engine) # type: ignore[arg-type] |
| 119 | + self.assertIn(f"'Extractor(engine={engine})' requires a file on disk", str(caught.exception)) |
| 120 | + self.assertIn('BytesIO', str(caught.exception)) |
| 121 | + self.assertFalse(stream.closed, 'the caller owns the stream (#610)') |
| 122 | + |
| 123 | + @unittest.skipUnless(importlib.util.find_spec('scapy') is not None, 'scapy is not installed') |
| 124 | + def test_scapy_refuses_unnamed_stream(self) -> None: |
| 125 | + self._assert_refused('scapy') |
| 126 | + |
| 127 | + @unittest.skipUnless(importlib.util.find_spec('pyshark') is not None, 'pyshark is not installed') |
| 128 | + def test_pyshark_refuses_unnamed_stream(self) -> None: |
| 129 | + # NOTE: PyShark rules itself out on some interpreters (CPython 3.14), and |
| 130 | + # the extraction would then fall back to the default engine, which reads |
| 131 | + # the stream perfectly well. Clear that reason so the refusal is what is |
| 132 | + # under test; it fires before any PyShark code runs. |
| 133 | + from pcapkit.foundation.engines.pyshark import PyShark # pylint: disable=import-outside-toplevel |
| 134 | + |
| 135 | + with mock.patch.object(PyShark, 'unsupported_reason', return_value=None): |
| 136 | + self._assert_refused('pyshark') |
| 137 | + |
| 138 | + def test_named_stream_is_not_refused(self) -> None: |
| 139 | + """A stream with a real ``name`` gives the path engine a path to open.""" |
| 140 | + from pcapkit.foundation.extraction import Extractor # pylint: disable=import-outside-toplevel |
| 141 | + |
| 142 | + path = sample_path('in.pcap') |
| 143 | + with mock.patch.object(Extractor, 'run'), open(path, 'rb') as stream: |
| 144 | + extractor = Extractor(fin=stream, nofile=True, engine='scapy') |
| 145 | + self.assertEqual(extractor.input, path) |
| 146 | + |
| 147 | + @unittest.skipUnless(importlib.util.find_spec('dpkt') is not None, 'dpkt is not installed') |
| 148 | + def test_dpkt_reads_unnamed_stream(self) -> None: |
| 149 | + import pcapkit # pylint: disable=import-outside-toplevel |
| 150 | + |
| 151 | + expected = len(pcapkit.extract(fin=sample_path('in.pcap'), nofile=True, engine='dpkt').frame) |
| 152 | + stream = io.BytesIO(self._data()) |
| 153 | + extractor = pcapkit.extract(fin=stream, nofile=True, engine='dpkt') |
| 154 | + self.assertEqual(extractor._exnam, 'dpkt') # pylint: disable=protected-access |
| 155 | + self.assertGreater(expected, 0) |
| 156 | + self.assertEqual(len(extractor.frame), expected) |
| 157 | + self.assertFalse(stream.closed, 'the caller owns the stream (#610)') |
| 158 | + |
| 159 | + |
| 160 | +if __name__ == '__main__': |
| 161 | + unittest.main() |
0 commit comments