Skip to content

Commit d40f799

Browse files
authored
fix(dumpkit): scale a PCAP trace record's fraction to the trace's resolution (#1500) (#1529)
1 parent 6fadec1 commit d40f799

4 files changed

Lines changed: 325 additions & 20 deletions

File tree

‎pcapkit/dumpkit/pcap.py‎

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
:mod:`dictdumper`.
1010
1111
"""
12+
import decimal
1213
import struct
1314
import sys
1415
from typing import TYPE_CHECKING
@@ -27,6 +28,7 @@
2728
from pcapkit.const.reg.linktype import LinkType as Enum_LinkType
2829
from pcapkit.corekit.version import VersionInfo
2930
from pcapkit.protocols.data.misc.pcap.frame import Frame as Data_Frame
31+
from pcapkit.protocols.data.misc.pcap.frame import FrameInfo as Data_FrameInfo
3032

3133
__all__ = [
3234
'PCAPIO',
@@ -49,6 +51,28 @@
4951
#: the two spellings writing the same octets for every input.
5052
_UINT32_MASK = 0xFFFF_FFFF
5153

54+
#: Units per second of a record's ``ts_usec`` field, keyed by the global header's
55+
#: nanosecond flag.
56+
_RESOLUTION = {
57+
False: 1_000_000,
58+
True: 1_000_000_000,
59+
}
60+
61+
62+
def _truncates_to(scaled: 'int', den: 'int', fraction: 'int') -> 'bool':
63+
"""Whether ``fraction`` is ``scaled / den`` truncated to a whole unit.
64+
65+
Args:
66+
scaled: numerator of a frame's offset into its second, in units
67+
den: denominator of that offset
68+
fraction: a record header's ``ts_usec`` field
69+
70+
Returns:
71+
:data:`True` if ``fraction <= scaled / den < fraction + 1``.
72+
73+
"""
74+
return fraction * den <= scaled < (fraction + 1) * den
75+
5276

5377
class PCAPIO(DumperBase):
5478
"""PCAP file dumper.
@@ -67,6 +91,10 @@ class PCAPIO(DumperBase):
6791
_ghdr: 'Data_Header'
6892
#: Record header packer, in the global header's byte order.
6993
_rechdr: 'struct.Struct'
94+
#: Units per second of a record's fraction, in this file's resolution.
95+
_tsres: 'int'
96+
#: Units per second of a record's fraction, in the other resolution.
97+
_tsalt: 'int'
7098

7199
##########################################################################
72100
# Properties.
@@ -168,6 +196,12 @@ def _dump_header(self, *, protocol: 'Enum_LinkType | StdlibIntEnum | AenumIntEnu
168196
#: validates and normalises it.
169197
self._rechdr = _RECORD_HEADER[self._ghdr.magic_number.byteorder]
170198

199+
#: int: Units per second of the fraction this file's records carry, and
200+
#: of the one the other resolution would, taken from :attr:`self._ghdr
201+
#: <_ghdr>` for the same reason as :attr:`self._rechdr <_rechdr>`.
202+
self._tsres = _RESOLUTION[self._ghdr.magic_number.nanosecond]
203+
self._tsalt = _RESOLUTION[not self._ghdr.magic_number.nanosecond]
204+
171205
def _append_value(self, value: 'Data_Frame', file: 'IO[bytes]', name: 'str') -> 'None': # pylint: disable=unused-argument
172206
"""Call this function to write contents.
173207
@@ -178,9 +212,11 @@ def _append_value(self, value: 'Data_Frame', file: 'IO[bytes]', name: 'str') ->
178212
179213
Notes:
180214
A PCAP record is a 16-octet header followed by the packet octets, and
181-
both are already in hand: the header fields are exactly
182-
``value.frame_info`` and the octets are exactly ``value.packet``. So
183-
this writes them directly, rather than handing them to
215+
both are already in hand: the header fields are ``value.frame_info``,
216+
with the fraction in this file's resolution (see
217+
:meth:`self._make_timestamp <_make_timestamp>`), and the octets are
218+
exactly ``value.packet``. So this writes them directly, rather than
219+
handing them to
184220
:class:`~pcapkit.protocols.misc.pcap.frame.Frame`, whose constructor
185221
packs the record and then **dissects it again** through the whole
186222
protocol stack to arrive at bytes it was given. That round trip
@@ -202,9 +238,60 @@ def _append_value(self, value: 'Data_Frame', file: 'IO[bytes]', name: 'str') ->
202238
# dict-capable trace format on the strength of that error.
203239
packet = value.packet
204240
frame_info = value.frame_info
241+
ts_sec, ts_frac = self._make_timestamp(frame_info, value.time_epoch)
205242

206-
file.write(self._rechdr.pack(frame_info.ts_sec & _UINT32_MASK,
207-
frame_info.ts_usec & _UINT32_MASK,
243+
file.write(self._rechdr.pack(ts_sec & _UINT32_MASK,
244+
ts_frac & _UINT32_MASK,
208245
frame_info.incl_len & _UINT32_MASK,
209246
frame_info.orig_len & _UINT32_MASK) + packet)
210247
self._fnum += 1
248+
249+
def _make_timestamp(self, frame_info: 'Data_FrameInfo',
250+
time_epoch: 'decimal.Decimal | float | int') -> 'tuple[int, int]':
251+
"""Record header seconds and fraction, in this file's resolution.
252+
253+
Args:
254+
frame_info: the frame's record header fields
255+
time_epoch: the frame's UNIX timestamp
256+
257+
Returns:
258+
The ``ts_sec`` and ``ts_usec`` fields to write.
259+
260+
Notes:
261+
``frame_info`` does not say which resolution its ``ts_usec`` is in.
262+
A PCAP frame carries its file's, and one converted from PCAP-NG
263+
carries its interface's (c.f. :func:`~pcapkit.toolkit.pcapng.block2frame`),
264+
either of which may differ from this file's (:issue:`1500`).
265+
``time_epoch`` settles it: the fraction is in whichever resolution
266+
truncates the frame's offset into its second to exactly that value.
267+
268+
The fraction is written as is when that is this file's resolution,
269+
so a record traced at its own resolution keeps its octets, and also
270+
when neither resolution fits, as for a frame built by hand. When it
271+
is the other resolution, the fraction is recomputed from
272+
``time_epoch`` at this file's -- microseconds times 1000, or
273+
nanoseconds divided by 1000 and **truncated** rather than rounded.
274+
That is how :func:`~pcapkit.toolkit.pcapng.block2frame` and
275+
:meth:`Frame.make <pcapkit.protocols.misc.pcap.frame.Frame.make>`
276+
reduce a timestamp to a fraction, and how libpcap scales a
277+
nanosecond file down (``sf-pcap.c``). It also keeps the second:
278+
999,999,999 ns truncates to 999,999 us, where rounding would reach
279+
a whole second. A recomputed fraction of a second or more is carried
280+
into ``ts_sec``, so that multiplying by 1000 cannot overflow the
281+
field.
282+
283+
"""
284+
ts_sec = frame_info.ts_sec
285+
ts_usec = frame_info.ts_usec
286+
287+
# NOTE: In integers rather than in :class:`decimal.Decimal`, whose
288+
# context precision would round a long fraction, e.g. from an interface
289+
# with a binary ``if_tsresol``.
290+
num, den = decimal.Decimal(time_epoch).as_integer_ratio()
291+
offset = num - ts_sec * den # ``time_epoch - ts_sec``, over ``den``
292+
if (_truncates_to(offset * self._tsres, den, ts_usec)
293+
or not _truncates_to(offset * self._tsalt, den, ts_usec)):
294+
return ts_sec, ts_usec
295+
296+
carry, ts_frac = divmod(offset * self._tsres // den, self._tsres)
297+
return ts_sec + carry, ts_frac
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# -*- coding: utf-8 -*-
2+
"""A PCAP dumper writes each record's fraction in its own file's resolution. C.f. #1500.
3+
4+
:class:`~pcapkit.dumpkit.pcap.PCAPIO` wrote a frame's ``ts_usec`` unscaled, so a
5+
microsecond capture traced with ``trace_nanosecond=True`` -- or a nanosecond one
6+
traced without it -- wrote every fraction 1000 times off. A frame converted from
7+
PCAP-NG (:func:`~pcapkit.toolkit.pcapng.block2frame`) carries its interface's
8+
resolution and was written the same way.
9+
10+
Each case below writes frames as :mod:`pcapkit` read them, reads the file back
11+
with :mod:`struct` alone and with :mod:`pcapkit`, and checks every timestamp
12+
against the source frame: exactly when the file is at least as fine as the
13+
source, and truncated to the microsecond when it is coarser.
14+
15+
Every case builds its own octets and reads no capture. Classes are imported
16+
inside each test, after :func:`~tests._support.reimport_once_per_class`.
17+
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import decimal
23+
import importlib.util
24+
import io
25+
import os
26+
import tempfile
27+
import unittest
28+
import warnings
29+
from typing import TYPE_CHECKING
30+
31+
from tests._support import close_extractor, reimport_once_per_class
32+
from tests.foundation._roundtrip import ethernet, ipv4, pcap, pcapng, read_pcap, tcp
33+
34+
if TYPE_CHECKING:
35+
from typing import Any
36+
37+
HAS_RUNTIME = all(importlib.util.find_spec(name) is not None
38+
for name in ('tbtrim', 'aenum', 'chardet', 'dictdumper'))
39+
40+
SECOND = 1_500_000_000
41+
#: Fractions per input resolution: the ends of the second, a value whose
42+
#: nanoseconds sit at exactly half a microsecond, and a sub-microsecond one.
43+
FRACTIONS = {
44+
False: (0, 1, 123_456, 999_999),
45+
True: (0, 999, 1_000, 123_456_500, 123_456_789, 999_999_999),
46+
}
47+
48+
49+
def _frames(count: 'int') -> 'list[bytes]':
50+
"""``count`` TCP segments of one connection, client to server."""
51+
return [ethernet(ipv4(tcp(b'x' * number, seq=1000 + number, syn=not number), proto=6), 0x0800)
52+
for number in range(count)]
53+
54+
55+
def _epoch(sec: 'int', frac: 'int', nanosecond: 'bool') -> 'decimal.Decimal':
56+
"""The UNIX timestamp of a record header's two fields."""
57+
return decimal.Decimal(sec) + decimal.Decimal(frac) / (10 ** 9 if nanosecond else 10 ** 6)
58+
59+
60+
def _expected(sec: 'int', frac: 'int', in_nsec: 'bool', out_nsec: 'bool') -> 'tuple[int, int]':
61+
"""The seconds and fraction a file of resolution ``out_nsec`` should hold."""
62+
if in_nsec == out_nsec:
63+
return sec, frac
64+
if out_nsec:
65+
return sec, frac * 1000
66+
return sec, frac // 1000
67+
68+
69+
@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed')
70+
class TestPCAPTraceResolution(unittest.TestCase):
71+
"""Every fraction is written, and reads back, in the output file's resolution."""
72+
73+
def setUp(self) -> None:
74+
reimport_once_per_class(self)
75+
self.tmpdir = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
76+
self.addCleanup(self.tmpdir.cleanup)
77+
78+
def read(self, data: 'bytes') -> 'list[Any]':
79+
"""The frames :mod:`pcapkit` reads from ``data``, a PCAP or PCAP-NG file."""
80+
from pcapkit import extract
81+
82+
with warnings.catch_warnings():
83+
warnings.simplefilter('ignore')
84+
extractor = extract(fin=io.BytesIO(data), nofile=True, store=True)
85+
close_extractor(extractor)
86+
return list(extractor.frame)
87+
88+
def dump(self, frames: 'list[Any]', nanosecond: 'bool') -> 'str':
89+
"""Write ``frames``, each a :class:`~pcapkit.protocols.data.misc.pcap.frame.Frame`."""
90+
from pcapkit.const.reg.linktype import LinkType
91+
from pcapkit.dumpkit.pcap import PCAPIO
92+
93+
path = os.path.join(self.tmpdir.name, f'out-{len(os.listdir(self.tmpdir.name))}.pcap')
94+
dumper = PCAPIO(path, protocol=LinkType.ETHERNET, byteorder='little', nanosecond=nanosecond)
95+
for frame in frames:
96+
dumper(frame)
97+
return path
98+
99+
def reread(self, path: 'str') -> 'list[decimal.Decimal]':
100+
"""Every frame's timestamp, as :mod:`pcapkit` reads the file at ``path``."""
101+
with open(path, 'rb') as file:
102+
return [frame.info.time_epoch for frame in self.read(file.read())]
103+
104+
def check(self, path: 'str', records: 'list[tuple[int, int]]', in_nsec: 'bool', out_nsec: 'bool') -> None:
105+
"""The file at ``path`` holds ``records`` rescaled, and reads back as their instants."""
106+
with open(path, 'rb') as file:
107+
written = read_pcap(file.read())
108+
self.assertEqual(written.nanosecond, out_nsec)
109+
want = [_expected(sec, frac, in_nsec, out_nsec) for sec, frac in records]
110+
self.assertEqual([record[:2] for record in written.records], want)
111+
self.assertEqual(self.reread(path), [_epoch(sec, frac, out_nsec) for sec, frac in want])
112+
113+
def test_a_pcap_frame_is_written_at_the_files_resolution(self) -> None:
114+
"""#1500: a PCAP frame's fraction is scaled up, or truncated down, to the file's."""
115+
for in_nsec in (False, True):
116+
for out_nsec in (False, True):
117+
with self.subTest(in_nsec=in_nsec, out_nsec=out_nsec):
118+
records = [(SECOND + i, frac) for i, frac in enumerate(FRACTIONS[in_nsec])]
119+
data = pcap([(sec, frac, frame) for (sec, frac), frame
120+
in zip(records, _frames(len(records)))], nanosecond=in_nsec)
121+
frames = self.read(data)
122+
self.assertEqual([frame.info.time_epoch for frame in frames],
123+
[_epoch(sec, frac, in_nsec) for sec, frac in records])
124+
125+
path = self.dump([frame.info for frame in frames], out_nsec)
126+
self.check(path, records, in_nsec, out_nsec)
127+
if in_nsec == out_nsec:
128+
with open(path, 'rb') as file:
129+
self.assertEqual(file.read()[24:], data[24:])
130+
131+
def test_a_pcapng_frame_is_written_at_the_files_resolution(self) -> None:
132+
"""#1500: so is a frame converted from PCAP-NG at its interface's resolution."""
133+
from pcapkit.toolkit.pcapng import block2frame
134+
135+
for in_nsec in (False, True):
136+
for out_nsec in (False, True):
137+
with self.subTest(in_nsec=in_nsec, out_nsec=out_nsec):
138+
records = [(SECOND + i, frac) for i, frac in enumerate(FRACTIONS[in_nsec])]
139+
data = pcapng([(sec, frac, frame) for (sec, frac), frame
140+
in zip(records, _frames(len(records)))], nanosecond=in_nsec)
141+
blocks = self.read(data)
142+
self.assertEqual([block.nanosecond for block in blocks], [in_nsec] * len(records))
143+
144+
# what the PCAP-NG engine hands a flow's dumper
145+
frames = [block2frame(block.info, nanosecond=block.nanosecond) for block in blocks]
146+
self.check(self.dump(frames, out_nsec), records, in_nsec, out_nsec)
147+
148+
def test_a_trace_reads_back_as_its_source_frames(self) -> None:
149+
"""#1500: a flow traced at either resolution re-reads as its frames' timestamps."""
150+
from pcapkit import extract
151+
152+
for container, build in (('pcap', pcap), ('pcapng', pcapng)):
153+
for in_nsec in (False, True):
154+
for out_nsec in (False, True):
155+
with self.subTest(container=container, in_nsec=in_nsec, out_nsec=out_nsec):
156+
records = [(SECOND + i, frac) for i, frac in enumerate(FRACTIONS[in_nsec])]
157+
data = build([(sec, frac, frame) for (sec, frac), frame
158+
in zip(records, _frames(len(records)))], nanosecond=in_nsec)
159+
tracedir = tempfile.mkdtemp(dir=self.tmpdir.name)
160+
with warnings.catch_warnings():
161+
warnings.simplefilter('ignore')
162+
extractor = extract(fin=io.BytesIO(data), nofile=True, store=False,
163+
trace=True, tcp=True, trace_fout=tracedir,
164+
trace_format='pcap', trace_nanosecond=out_nsec)
165+
close_extractor(extractor)
166+
flows = list(extractor.trace.tcp)
167+
self.assertEqual([list(flow.index) for flow in flows],
168+
[list(range(1, len(records) + 1))])
169+
self.check(flows[0].fpout, records, in_nsec, out_nsec)
170+
171+
def test_nanoseconds_are_truncated_not_rounded(self) -> None:
172+
"""#1500: 999,999,999 ns is 999,999 us of the same second, and half a microsecond drops."""
173+
from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo
174+
175+
frames = []
176+
for frac in (999_999_999, 123_456_500, 500):
177+
frame = Frame(frame_info=FrameInfo(ts_sec=SECOND, ts_usec=frac, incl_len=0, orig_len=0),
178+
time='time', number=1, time_epoch=_epoch(SECOND, frac, True), len=0, cap_len=0)
179+
frame.__update__(packet=b'')
180+
frames.append(frame)
181+
with open(self.dump(frames, False), 'rb') as file:
182+
written = read_pcap(file.read())
183+
self.assertEqual([record[:2] for record in written.records],
184+
[(SECOND, 999_999), (SECOND, 123_456), (SECOND, 0)])
185+
186+
def test_a_rescaled_fraction_of_a_second_or_more_is_carried(self) -> None:
187+
"""#1500: a fraction past the second moves into ``ts_sec``, so 1000x cannot overflow."""
188+
# Neither fraction is normalised, and 5,000,000 us is 5e9 ns, past 2**32.
189+
for in_nsec, frac, want in ((False, 5_000_000, (SECOND + 5, 0)),
190+
(True, 1_500_000_123, (SECOND + 1, 500_000))):
191+
with self.subTest(in_nsec=in_nsec):
192+
data = pcap([(SECOND, frac, _frames(1)[0])], nanosecond=in_nsec)
193+
frames = self.read(data)
194+
path = self.dump([frame.info for frame in frames], not in_nsec)
195+
with open(path, 'rb') as file:
196+
written = read_pcap(file.read())
197+
self.assertEqual(written.records[0][:2], want)
198+
self.assertEqual(self.reread(path), [_epoch(*want, not in_nsec)])
199+
if not in_nsec:
200+
self.assertEqual(self.reread(path), [frame.info.time_epoch for frame in frames])
201+
202+
def test_a_binary_resolution_keeps_its_sub_microsecond_part(self) -> None:
203+
"""#1500: a fraction truncated to microseconds is rescaled from the exact timestamp."""
204+
from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo
205+
206+
# What :func:`~pcapkit.toolkit.pcapng.block2frame` makes of tick 1 of an
207+
# ``if_tsresol`` of 2**-10: 0.0009765625 s, truncated to 976 us.
208+
epoch = decimal.Decimal(SECOND) + decimal.Decimal(1) / 1024
209+
frame = Frame(frame_info=FrameInfo(ts_sec=SECOND, ts_usec=976, incl_len=0, orig_len=0),
210+
time='time', number=1, time_epoch=epoch, len=0, cap_len=0)
211+
frame.__update__(packet=b'')
212+
with open(self.dump([frame], True), 'rb') as file:
213+
written = read_pcap(file.read())
214+
self.assertEqual(written.records[0][:2], (SECOND, 976_562))
215+
216+
def test_a_frame_whose_timestamp_fits_neither_resolution_is_written_as_given(self) -> None:
217+
"""A hand-built frame whose ``time_epoch`` fits neither resolution keeps its fields."""
218+
from pcapkit.protocols.data.misc.pcap.frame import Frame, FrameInfo
219+
220+
for nanosecond in (False, True):
221+
with self.subTest(nanosecond=nanosecond):
222+
frame = Frame(frame_info=FrameInfo(ts_sec=7, ts_usec=123_456, incl_len=0, orig_len=0),
223+
time='time', number=1, time_epoch=decimal.Decimal('2.5'), len=0, cap_len=0)
224+
frame.__update__(packet=b'')
225+
with open(self.dump([frame], nanosecond), 'rb') as file:
226+
written = read_pcap(file.read())
227+
self.assertEqual(written.records[0][:2], (7, 123_456))
228+
229+
230+
if __name__ == '__main__':
231+
unittest.main()

‎tests/foundation/traceflow/test_traceflow_reread_roundtrip_runtime.py‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,7 @@ class TestTraceFlowRereadRuntime(harness.RoundTripBase):
101101
"""Every sample capture's flows read back as exactly their frames."""
102102

103103
STATUSES = ('ERROR', 'HEADER', 'COUNT', 'RECORD')
104-
KNOWN_FAILURES = (
105-
Gap(1500, 'a microsecond PCAP-NG interface traced to a nanosecond PCAP keeps its '
106-
'microsecond fraction under the nanosecond magic: PCAPIO._append_value writes '
107-
'frame_info.ts_usec unscaled (pcapkit/dumpkit/pcap.py:206-207) and tcp_traceflow '
108-
"converts at the block's own resolution (pcapkit/foundation/engines/pcapng.py:304)",
109-
'RECORD', 'timestamp ', ('many_interfaces.pcapng',)),
110-
)
104+
KNOWN_FAILURES = () # type: tuple[Gap, ...]
111105

112106
def setUp(self) -> None:
113107
super().setUp()

0 commit comments

Comments
 (0)