Skip to content

Commit 6af87f6

Browse files
authored
fix(extraction): accept a file object without a name as fin (#1506) (#1509)
1 parent a608b6e commit 6af87f6

2 files changed

Lines changed: 240 additions & 3 deletions

File tree

‎pcapkit/foundation/extraction.py‎

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,49 @@ def _restore_shipped_dumper(registry: 'dict[str, Any]', format: 'str', # pylint
182182
registry[format] = shipped if ext == shipped[1] else (shipped[0], ext)
183183

184184

185+
#: Engines whose library opens the input by *path*, ``Extractor._ifnm``, rather
186+
#: than reading ``Extractor._ifile``, so they cannot take an unnamed stream.
187+
_PATH_ENGINES = frozenset({'scapy', 'pyshark', 'pypcap', 'pcap_ct'})
188+
189+
190+
class _PeekableStream:
191+
"""Give a seekable binary stream without ``peek`` one.
192+
193+
:class:`Extractor` and the PCAP-NG engine read ahead with ``peek``, which
194+
:class:`io.BufferedReader` has and a plain :class:`typing.IO` such as
195+
:class:`io.BytesIO` does not (#1506). The read-ahead is a read and a seek
196+
back, and every other attribute is the stream's own. It is not an
197+
:class:`io.IOBase`, so it has no finaliser that could close the caller's
198+
stream behind their back (#610).
199+
200+
Arguments:
201+
stream: seekable binary stream
202+
203+
"""
204+
205+
def __init__(self, stream: 'IO[bytes]') -> 'None':
206+
self._stream = stream
207+
208+
def peek(self, size: 'int' = 0) -> 'bytes':
209+
"""Return up to ``size`` octets, at least one if any remain, without consuming them.
210+
211+
Arguments:
212+
size: number of octets wanted
213+
214+
Returns:
215+
The octets at the current position.
216+
217+
"""
218+
pos = self._stream.tell()
219+
try:
220+
return self._stream.read(max(size, 1))
221+
finally:
222+
self._stream.seek(pos, io.SEEK_SET)
223+
224+
def __getattr__(self, name: 'str') -> 'Any':
225+
return getattr(self._stream, name)
226+
227+
185228
class Extractor(Generic[_P]):
186229
"""Extractor for PCAP files.
187230
@@ -388,7 +431,12 @@ def format(self) -> 'Formats':
388431

389432
@property
390433
def input(self) -> 'str':
391-
"""Name of input PCAP file."""
434+
"""Name of input PCAP file.
435+
436+
For a binary IO object without a :obj:`str` ``name``, a placeholder
437+
naming its type, e.g. ``<BytesIO>``.
438+
439+
"""
392440
return self._ifnm
393441

394442
@property
@@ -931,7 +979,8 @@ def make_name(cls, fin: 'str | IO[bytes]' = 'in.pcap', fout: 'str' = 'out',
931979
932980
1. sanitise ``fin`` as the input PCAP filename; ``in.pcap`` as default value and
933981
append ``.pcap`` extension if needed and ``extension`` is :data:`True`; as well
934-
as test if the file exists; for a binary IO object, use its ``name``;
982+
as test if the file exists; for a binary IO object, use its ``name``,
983+
or a placeholder such as ``<BytesIO>`` if it has no :obj:`str` one;
935984
2. if ``nofile`` is :data:`True`, skips following processing;
936985
3. look up the output file extension registered for ``fmt``; should none be
937986
registered, then it raises :exc:`~pcapkit.utilities.exceptions.FormatError`;
@@ -977,7 +1026,13 @@ def make_name(cls, fin: 'str | IO[bytes]' = 'in.pcap', fout: 'str' = 'out',
9771026
if not os.path.isfile(ifnm):
9781027
raise FileNotFound(2, 'No such file or directory', ifnm)
9791028
else:
980-
ifnm = fin.name
1029+
# NOTE: a binary IO object need not have a ``name`` -- an
1030+
# :class:`io.BytesIO` has none, and one wrapping a descriptor has an
1031+
# :obj:`int` (#1506). The name is only a label here: the stream is
1032+
# read as given, and the output name comes from ``fout`` alone. So
1033+
# fall back to ``<BytesIO>``-style, after Python's own ``<stdin>``.
1034+
name = getattr(fin, 'name', None)
1035+
ifnm = name if isinstance(name, str) else f'<{type(fin).__name__}>'
9811036

9821037
if nofile:
9831038
ofnm = None
@@ -1234,6 +1289,11 @@ def __init__(self,
12341289
pcapkit.utilities.warnings.AttributeWarning: If ``trace_analyse`` is
12351290
requested while the extraction engine is PyShark.
12361291
1292+
Raises:
1293+
UnsupportedCall: If ``fin`` is a stream without a :obj:`str` ``name``
1294+
and the engine that will run -- Scapy, PyShark, PyPCAP or
1295+
pcap-ct -- opens its input by path.
1296+
12371297
"""
12381298
if fin is None:
12391299
fin = 'in.pcap'
@@ -1259,6 +1319,19 @@ def __init__(self,
12591319
"using 'format=\"json\"' instead", FormatWarning, stacklevel=stacklevel())
12601320
format = 'json'
12611321

1322+
# NOTE: these engines hand ``_ifnm`` to their library as a *path*, so a
1323+
# stream without a ``str`` name -- labelled ``<BytesIO>`` by
1324+
# :meth:`make_name` -- would be opened as a file of that name (#1506).
1325+
# Refuse before anything runs, rather than relying on no such file
1326+
# existing. ``dpkt``, ``pypcapfile`` and the default engine read
1327+
# ``_ifile`` and take a stream as it is.
1328+
if (self._exnam in _PATH_ENGINES and not isinstance(fin, str)
1329+
and not isinstance(getattr(fin, 'name', None), str)):
1330+
raise UnsupportedCall(f"'Extractor(engine={self._exnam})' requires a file on disk, "
1331+
f'but the {type(fin).__name__} given as fin has no name; '
1332+
'the engine opens savefiles by name and cannot read an '
1333+
'in-memory stream')
1334+
12621335
ifnm, ofnm, fmt, oext, files = self.make_name(fin, fout, format, extension, files=files, nofile=nofile)
12631336

12641337
self._ifnm = ifnm # input file name
@@ -1413,6 +1486,9 @@ def __init__(self,
14131486
# same one ``_cleanup`` uses (#610).
14141487
self._ifile = SeekableReader(self._ifile, buffer_size, buffer_save, buffer_path,
14151488
stream_closing=self._flag_s)
1489+
elif not hasattr(self._ifile, 'peek'):
1490+
logger.debug('input stream has no peek(), wrapping it in _PeekableStream')
1491+
self._ifile = cast('BufferedReader', _PeekableStream(self._ifile))
14161492

14171493
if not self._flag_q:
14181494
output, ext = self.__output__[fmt]
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)