From ebbd68a062607c33d24e284af30c210ca627d5df Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 23 Oct 2026 18:09:01 -0400 Subject: [PATCH] fix(protocols): convert capture timestamps without the platform's fromtimestamp limits (#2099) datetime.fromtimestamp(ts, tz) breaks the instant down with the C library's gmtime(), and the Windows CRT's gmtime_s takes nothing before 1970 or after 3000, so in-range year-1 and year-9999 PCAP-NG timestamps took the invalid-timestamp fallback and were dated 1970. - new pcapkit.utilities.timestamp.fromtimestamp, not in compat, which holds backward-compatibility shims: split the float as CPython does (modf, half-even microseconds, carry), add to the epoch, tz.fromutc; ValueError/OverflowError as CPython on 64-bit Linux - use it in PCAPNG._read_timestamp, Frame.read, MH._read_opt_timestamp and MesgIDOption.post_process (MH had the same Windows gap) - document it on its own utilities page; the two tests that faked the module's datetime now fake the converter - new test emulates Windows' range and pins exact datetimes for both extremes, if_tzone rendering, MH pre-1970/post-3000, and the fallback Linux results identical to the stdlib on 1.2M fuzzed (value, tz) pairs. --- docs/source/pcapkit/utilities/index.rst | 5 +- docs/source/pcapkit/utilities/timestamp.rst | 26 ++ pcapkit/protocols/internet/mh.py | 6 +- pcapkit/protocols/misc/pcap/frame.py | 6 +- pcapkit/protocols/misc/pcapng.py | 11 +- pcapkit/protocols/schema/internet/mh.py | 5 +- pcapkit/utilities/timestamp.py | 80 ++++++ .../misc/pcap/test_header_frame_unit.py | 22 +- ...t_fromtimestamp_windows_range_2099_unit.py | 249 ++++++++++++++++++ tests/protocols/misc/test_pcapng_unit.py | 24 +- 10 files changed, 386 insertions(+), 48 deletions(-) create mode 100644 docs/source/pcapkit/utilities/timestamp.rst create mode 100644 pcapkit/utilities/timestamp.py create mode 100644 tests/protocols/misc/test_fromtimestamp_windows_range_2099_unit.py diff --git a/docs/source/pcapkit/utilities/index.rst b/docs/source/pcapkit/utilities/index.rst index f16849acc7..7b3058c061 100644 --- a/docs/source/pcapkit/utilities/index.rst +++ b/docs/source/pcapkit/utilities/index.rst @@ -6,8 +6,8 @@ Module Utilities :mod:`pcapkit.utilities` holds the cross-cutting services :mod:`pcapkit` is built with: user-refined exceptions and warnings, logging, the tunable limits -of :mod:`~pcapkit.utilities.config`, a compatibility layer, and wrappers around -third-party libraries. It imports nothing from +of :mod:`~pcapkit.utilities.config`, a compatibility layer, timestamp +conversion, and wrappers around third-party libraries. It imports nothing from :mod:`pcapkit` outside itself; see :ref:`utilities-or-corekit`. .. toctree:: @@ -18,6 +18,7 @@ third-party libraries. It imports nothing from logging config chardet + timestamp Version Compatibility ===================== diff --git a/docs/source/pcapkit/utilities/timestamp.rst b/docs/source/pcapkit/utilities/timestamp.rst new file mode 100644 index 0000000000..bf2eb1790f --- /dev/null +++ b/docs/source/pcapkit/utilities/timestamp.rst @@ -0,0 +1,26 @@ +Timestamp Conversion +==================== + +.. module:: pcapkit.utilities.timestamp + +:mod:`pcapkit.utilities.timestamp` converts a UNIX timestamp to an aware +:class:`~datetime.datetime` without the C library's range (:issue:`2099`). It is +shared by the PCAP and PCAP-NG readers and the MH timestamp options, which is +why it lives here rather than beside any one of them. + +.. function:: fromtimestamp(timestamp, tz) + + ``datetime.datetime.fromtimestamp(timestamp, tz)`` as 64-bit Linux computes + it, on every platform: whole seconds and microseconds split from a float as + CPython splits them, rounding half to even, then added to the UNIX epoch and + rendered in ``tz``. The standard library breaks the instant down with the C + library's ``gmtime()``, whose range on Windows is 1970 to 3000, so there it + raises :exc:`OSError` for instants :class:`~datetime.datetime` holds. + + :raises ValueError: if ``timestamp`` is NaN or names a year outside 1 to 9999 + :raises OverflowError: if ``timestamp`` is past a 64-bit ``time_t``, or ``tz`` + moves the result out of range + + .. versionadded:: 1.5.0 + + Every capture and MH timestamp is converted with it (:issue:`2099`). diff --git a/pcapkit/protocols/internet/mh.py b/pcapkit/protocols/internet/mh.py index 54097cf670..16d3a95d97 100644 --- a/pcapkit/protocols/internet/mh.py +++ b/pcapkit/protocols/internet/mh.py @@ -497,6 +497,7 @@ from pcapkit.protocols.schema.internet.mh import VendorSpecificOption as Schema_VendorSpecificOption from pcapkit.protocols.schema.schema import Schema from pcapkit.utilities.exceptions import EnumValueError, ProtocolError, UnsupportedCall +from pcapkit.utilities.timestamp import fromtimestamp from pcapkit.utilities.warnings import ProtocolWarning, RegistryWarning, warn if TYPE_CHECKING: @@ -4088,8 +4089,9 @@ def _read_opt_timestamp(self, schema: 'Schema_TimestampOption', *, data = Data_TimestampOption( type=schema.type, length=self._mh_option_length(schema.length), - timestamp=datetime.datetime.fromtimestamp( - seconds + fraction / 65536, tz=datetime.timezone.utc), + # not the stdlib's ``fromtimestamp``, which takes no time after 3000 + # on Windows (#2099) + timestamp=fromtimestamp(seconds + fraction / 65536, datetime.timezone.utc), pmip_timestamp=Data_PMIPv6Timestamp(seconds=seconds, fraction=fraction), ) return data diff --git a/pcapkit/protocols/misc/pcap/frame.py b/pcapkit/protocols/misc/pcap/frame.py index 00343734b1..81deb9d44f 100644 --- a/pcapkit/protocols/misc/pcap/frame.py +++ b/pcapkit/protocols/misc/pcap/frame.py @@ -49,6 +49,7 @@ from pcapkit.protocols.schema.misc.pcap.header import RESOLUTION from pcapkit.utilities.compat import localcontext from pcapkit.utilities.exceptions import StreamEOFError, UnsupportedCall, stacklevel +from pcapkit.utilities.timestamp import fromtimestamp from pcapkit.utilities.warnings import ProtocolWarning, RegistryWarning, warn if TYPE_CHECKING: @@ -287,8 +288,9 @@ def read(self, length: 'Optional[int]' = None, *, _read: 'bool' = True, # aware UTC datetime too, and so does the PCAP-NG reader, which fills # the same # :attr:`Data_Frame.time ` - # field through :func:`pcapkit.toolkit.pcapng.block2frame`. - _time = datetime.datetime.fromtimestamp(_irat[0] / _irat[1], datetime.timezone.utc) + # field through :func:`pcapkit.toolkit.pcapng.block2frame`. Converted + # without the C library, as the PCAP-NG reader does (#2099). + _time = fromtimestamp(_irat[0] / _irat[1], datetime.timezone.utc) except ValueError: warn(f'PCAP: invalid timestamp: {_epch}', ProtocolWarning, stacklevel=stacklevel()) _time = datetime.datetime.fromtimestamp(0, datetime.timezone.utc) diff --git a/pcapkit/protocols/misc/pcapng.py b/pcapkit/protocols/misc/pcapng.py index 4deb01a0db..84ced594ad 100644 --- a/pcapkit/protocols/misc/pcapng.py +++ b/pcapkit/protocols/misc/pcapng.py @@ -212,6 +212,7 @@ from pcapkit.utilities.compat import StrEnum, localcontext from pcapkit.utilities.exceptions import (BaseError, EnumValueError, FormatError, ProtocolError, StreamEOFError, UnsupportedCall, stacklevel) +from pcapkit.utilities.timestamp import fromtimestamp from pcapkit.utilities.warnings import (AttributeWarning, DeprecatedFormatWarning, ProtocolWarning, RegistryWarning, warn) @@ -1877,10 +1878,12 @@ def _read_timestamp(self, timestamp_high: 'int', timestamp_low: 'int', *, try: # NOTE: ``tzone`` survives only here, as the zone the instant is # *rendered* in -- which is all a ``tzinfo`` can affect on an aware - # datetime. It never moves the instant itself. - ts_datetime = datetime.datetime.fromtimestamp(ts_ratio[0] / ts_ratio[1], tzone) - # an epoch past what ``datetime`` or the platform's ``time_t`` holds, - # e.g. from an extreme ``if_tsoffset``, raises one of these + # datetime. It never moves the instant itself. Not the stdlib's + # ``fromtimestamp``, whose range is the C library's: Windows' takes + # nothing before 1970 or after 3000 (#2099). + ts_datetime = fromtimestamp(ts_ratio[0] / ts_ratio[1], tzone) + # an epoch past what ``datetime`` or a 64-bit ``time_t`` holds, e.g. + # from an extreme ``if_tsoffset``, raises one of these except (ValueError, OverflowError, OSError): warn(f'PCAP-NG: [Block {self._type}] invalid timestamp: {timestamp_epoch}', ProtocolWarning, stacklevel=stacklevel()) diff --git a/pcapkit/protocols/schema/internet/mh.py b/pcapkit/protocols/schema/internet/mh.py index 6fe6e74adb..51467a279d 100644 --- a/pcapkit/protocols/schema/internet/mh.py +++ b/pcapkit/protocols/schema/internet/mh.py @@ -62,6 +62,7 @@ from pcapkit.corekit.fields.strings import BitField, BytesField, PaddingField, StringField from pcapkit.protocols.schema.schema import EnumSchema, Schema, schema_final from pcapkit.utilities.logging import SPHINX_TYPE_CHECKING +from pcapkit.utilities.timestamp import fromtimestamp __all__ = [ 'MH', @@ -848,7 +849,9 @@ def post_process(self, packet: 'dict[str, Any]') -> 'MesgIDOption': ts_sec = self.seconds - 2_208_988_800 # 70 years ts_frac = self.fraction / 2**32 # fraction of a second - self.timestamp = datetime.datetime.fromtimestamp(ts_sec + ts_frac, tz=datetime.timezone.utc) + # not the stdlib's ``fromtimestamp``, which takes no time before 1970, + # i.e. no NTP time before 2_208_988_800, on Windows (#2099) + self.timestamp = fromtimestamp(ts_sec + ts_frac, datetime.timezone.utc) return self diff --git a/pcapkit/utilities/timestamp.py b/pcapkit/utilities/timestamp.py new file mode 100644 index 0000000000..53949e751c --- /dev/null +++ b/pcapkit/utilities/timestamp.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""Timestamp Conversion +====================== + +.. module:: pcapkit.utilities.timestamp + +:mod:`pcapkit.utilities.timestamp` converts a UNIX timestamp to an aware +:class:`~datetime.datetime` without the C library's range (:issue:`2099`). + +""" + +import datetime +import math +import operator +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import tzinfo + +__all__ = ['fromtimestamp'] + +# A UNIX timestamp as an aware datetime, without the C library (#2099). +# :meth:`datetime.datetime.fromtimestamp` breaks the instant down with the +# platform's ``gmtime()`` when given a ``tz``, so its range is the C library's +# rather than ``datetime``'s: the Windows CRT's ``gmtime_s`` takes no time before +# 1970 and none after 3000, and CPython raises :exc:`OSError` there for instants +# ``datetime`` holds. This splits a float into whole seconds and microseconds +# exactly as CPython does -- :func:`math.modf`, the fraction times ``1e6`` +# rounded half to even, then carried into ``[0, 1e6)`` -- and adds them to the +# epoch, so every instant gets the result glibc's ``gmtime_r`` gives. It is not +# a version gate: the same arithmetic runs on every interpreter. +# +# The errors are CPython's on 64-bit Linux, by class: :exc:`ValueError` for NaN +# or a year outside 1 to 9999, and :exc:`OverflowError` for whole seconds past +# a 64-bit ``time_t`` or a ``tz`` offset that pushes the result out of range. +# The one exception is a year past what a C ``int`` holds, from about 6.7e16 +# seconds, where glibc's own ``gmtime_r`` fails first and CPython raises +# :exc:`OSError` instead; this raises :exc:`ValueError` there too. +_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) +#: First and last whole second since the UNIX epoch in years 1 to 9999. +_EPOCH_MIN = -62_135_596_800 +_EPOCH_MAX = 253_402_300_799 +#: Microseconds per second, the resolution of :class:`~datetime.datetime`. +_MICROSECONDS = datetime.timedelta(seconds=1) // datetime.timedelta(microseconds=1) +#: A 64-bit ``time_t`` holds ``[-_TIME_T, _TIME_T)``. +_TIME_T = 2**63 + + +def fromtimestamp(timestamp: 'float | int', tz: 'tzinfo') -> 'datetime.datetime': + """:meth:`datetime.datetime.fromtimestamp` with a ``tz``, minus the C library's range. + + The result equals ``datetime.datetime.fromtimestamp(timestamp, tz)`` on + 64-bit Linux for every ``timestamp`` that call accepts, on every platform. + + Args: + timestamp: Seconds since the UNIX epoch. + tz: Timezone the result is rendered in. + + Raises: + ValueError: If ``timestamp`` is NaN, or names a year outside 1 to 9999. + OverflowError: If ``timestamp`` is past a 64-bit ``time_t``, or ``tz`` + moves the result out of the range :class:`~datetime.datetime` holds. + + """ + if isinstance(timestamp, float): + if math.isnan(timestamp): + raise ValueError('Invalid value NaN (not a number)') + frac, whole = math.modf(timestamp) + # ``round`` of a float is half to even; the carry is -1, 0 or 1 + carry, micro = divmod(round(frac * _MICROSECONDS), _MICROSECONDS) + whole += carry + else: + whole, micro = operator.index(timestamp), 0 + if not -_TIME_T <= whole < _TIME_T: + raise OverflowError('timestamp out of range for platform time_t') + seconds = int(whole) + if not _EPOCH_MIN <= seconds <= _EPOCH_MAX: + raise ValueError(f'year is out of range for timestamp {timestamp!r}') + utc = _EPOCH + datetime.timedelta(seconds=seconds, microseconds=micro) + return tz.fromutc(utc.replace(tzinfo=tz)) diff --git a/tests/protocols/misc/pcap/test_header_frame_unit.py b/tests/protocols/misc/pcap/test_header_frame_unit.py index 0c19d32dc8..84c180e6da 100644 --- a/tests/protocols/misc/pcap/test_header_frame_unit.py +++ b/tests/protocols/misc/pcap/test_header_frame_unit.py @@ -288,18 +288,6 @@ class Other(Raw): self.assertEqual(Frame.read(no_read, _read=False).number, 5) self.assertEqual(no_read._file.tell(), 0) - real_datetime = datetime.datetime - - class FakeDateTime: - calls = 0 - - @classmethod - def fromtimestamp(cls, *args): - if cls.calls == 0: - cls.calls += 1 - raise ValueError - return real_datetime.fromtimestamp(*args) - bad_time = object.__new__(Frame) bad_time.__header__ = Schema_Frame(ts_sec=1, ts_usec=0, incl_len=0, orig_len=0, packet=b'') @@ -307,12 +295,10 @@ def fromtimestamp(cls, *args): bad_time._nsec = False bad_time._fnum = 6 bad_time._file = io.BytesIO(b'') - # Faked through the module's name in frame.py, not through - # ``datetime.datetime`` itself: PyPy's datetime is pure Python and - # reads its own ``datetime`` global, so faking that fails the real call - # the fake hands on to (#1958). - fake_module = SimpleNamespace(**dict(vars(datetime), datetime=FakeDateTime)) - with mock.patch('pcapkit.protocols.misc.pcap.frame.datetime', fake_module), \ + # Faked through frame.py's own binding of + # :func:`pcapkit.utilities.timestamp.fromtimestamp`, which it calls + # instead of :meth:`datetime.datetime.fromtimestamp` since #2099. + with mock.patch('pcapkit.protocols.misc.pcap.frame.fromtimestamp', side_effect=ValueError), \ mock.patch.object(Frame, '_decode_next_layer', lambda self, info, proto=None, length=None, packet=None: info), \ mock.patch('pcapkit.protocols.misc.pcap.frame.warn') as warn: diff --git a/tests/protocols/misc/test_fromtimestamp_windows_range_2099_unit.py b/tests/protocols/misc/test_fromtimestamp_windows_range_2099_unit.py new file mode 100644 index 0000000000..362dde6422 --- /dev/null +++ b/tests/protocols/misc/test_fromtimestamp_windows_range_2099_unit.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #2099: timestamps convert without the platform's ``gmtime()`` range. + +:meth:`datetime.datetime.fromtimestamp` with a ``tz`` breaks the instant down +with the C library's ``gmtime()``, and the Windows CRT's ``gmtime_s`` takes +nothing before 1970 or after 23:59:59, December 31, 3000, UTC. On the first +Windows run (#2095) a PCAP-NG packet dated in year 1 or year 9999 therefore took +the invalid-timestamp fallback and was dated 1970. + +Windows is emulated here by a ``datetime`` whose ``fromtimestamp`` raises what +CPython raises there, ``OSError(EINVAL)``, outside that range. Every reader is +asserted to give the exact :class:`~datetime.datetime` it gives on Linux. + +:mod:`pcapkit` is imported inside each test, after +:func:`~tests._support.reimport_once_per_class`. + +""" + +import contextlib +import datetime +import decimal +import errno +import struct +import sys +import types +import unittest +import warnings +from typing import TYPE_CHECKING +from unittest import mock + +from tests.protocols.misc.test_pcapng_roundtrip2_unit import PCAPNGTestCase + +if TYPE_CHECKING: + from typing import Any, Iterator + +#: Test tier (#1958): splits floats into seconds and microseconds, which PyPy's +#: and GraalPy's pure-Python datetime do in code of their own. +__tier__ = 'ALL' + +UTC = datetime.timezone.utc + +#: The last instant ``gmtime_s`` breaks down: 23:59:59, December 31, 3000, UTC. +WINDOWS_MAX = int(datetime.datetime(3001, 1, 1, tzinfo=UTC).timestamp()) - 1 + +#: Year 1 and year 9999, a day inside ``datetime``'s range, as #1340 pins them. +YEAR_1 = -62135510400 +YEAR_9999 = 253402214399 + +_REAL_DATETIME = datetime.datetime + + +class WindowsDateTime(_REAL_DATETIME): + """A ``datetime`` whose ``fromtimestamp`` has the Windows CRT's range.""" + + @classmethod + def fromtimestamp(cls, timestamp: 'float', tz: 'Any' = None) -> 'datetime.datetime': # type: ignore[override] + if not 0 <= timestamp <= WINDOWS_MAX: + raise OSError(errno.EINVAL, 'Invalid argument') + return _REAL_DATETIME.fromtimestamp(timestamp, tz) + + +@contextlib.contextmanager +def windows_gmtime() -> 'Iterator[None]': + """Give every loaded :mod:`pcapkit` module that binds :mod:`datetime` the Windows range.""" + fake = types.SimpleNamespace(**dict(vars(datetime), datetime=WindowsDateTime)) + with contextlib.ExitStack() as stack: + for name, module in list(sys.modules.items()): + if (name == 'pcapkit' or name.startswith('pcapkit.')) and getattr(module, 'datetime', None) is datetime: + stack.enter_context(mock.patch.object(module, 'datetime', fake)) + yield + + +def _idb(*options: 'tuple[int, str, int]') -> 'bytes': + """An Ethernet IDB carrying fixed-size ``options`` and ``opt_endofopt``.""" + body = struct.pack(' 'bytes': + """A 36-octet Enhanced Packet Block on interface 0.""" + body = struct.pack(' None: + for value in (YEAR_1, -1, WINDOWS_MAX + 1, YEAR_9999): + with self.subTest(timestamp=value), self.assertRaises(OSError): + WindowsDateTime.fromtimestamp(float(value), UTC) + self.assertEqual(WindowsDateTime.fromtimestamp(float(WINDOWS_MAX), UTC), + _REAL_DATETIME(3000, 12, 31, 23, 59, 59, tzinfo=UTC)) + + +class TestFromTimestamp(unittest.TestCase): + """:func:`pcapkit.utilities.timestamp.fromtimestamp` against the stdlib inside the CRT range.""" + + def test_matches_the_stdlib_where_both_can_answer(self) -> None: + from pcapkit.utilities.timestamp import fromtimestamp + + zones = (UTC, datetime.timezone(datetime.timedelta(hours=-5)), + datetime.timezone(datetime.timedelta(seconds=86399))) + # half-microsecond ties round to even, as CPython's do + for value in (0, 0.0, 0.0000005, 0.0000015, 0.0000025, 0.9999995, 1700000000.123456789, + 5 + 1 / 65536, 4294967296.000001, float(WINDOWS_MAX) + 0.9999994): + for zone in zones: + with self.subTest(timestamp=value, tz=zone): + expected = _REAL_DATETIME.fromtimestamp(value, zone) + actual = fromtimestamp(value, zone) + self.assertEqual(actual, expected) + self.assertEqual(actual.utcoffset(), expected.utcoffset()) + self.assertEqual(actual.microsecond, expected.microsecond) + self.assertIs(actual.tzinfo, zone) + self.assertIs(type(actual), _REAL_DATETIME) + + def test_errors_are_cpythons(self) -> None: + from pcapkit.utilities.timestamp import fromtimestamp + + cases = ( + (float('nan'), ValueError), (float('inf'), OverflowError), (2.0**63, OverflowError), + (2**63, OverflowError), (-62135596801.0, ValueError), (253402300800.0, ValueError), + ) + for value, error in cases: + with self.subTest(timestamp=value), self.assertRaises(error): + fromtimestamp(value, UTC) + # the instant is in range and the zone pushes it out + with self.assertRaises(OverflowError): + fromtimestamp(-62135596800.0, datetime.timezone(datetime.timedelta(hours=-5))) + + +class TestPCAPNGOnWindows(PCAPNGTestCase): + """PCAP-NG packet timestamps decode alike on Windows and Linux.""" + + def _packet(self, idb: 'bytes', epb: 'bytes') -> 'tuple[Any, list[str]]': + from pcapkit.protocols.misc.pcapng import PCAPNG + + context = self._context() + with warnings.catch_warnings(record=True) as caught, windows_gmtime(): + warnings.simplefilter('always') + interface = PCAPNG(idb, len(idb), num=1, sct=1, ctx=context) + context.interfaces[0] = interface.info + packet = PCAPNG(epb, len(epb), num=2, sct=1, ctx=context) + # rebuilt outside the emulation, as nothing on that path converts + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + rebuilt = PCAPNG.from_data(packet.info, num=2, sct=1, ctx=context).data + self.assertEqual(rebuilt.hex(), epb.hex()) + return packet, [str(item.message) for item in caught if str(item.message).startswith('PCAP-NG')] + + def test_in_range_offsets_decode_exactly(self) -> None: + # a float epoch this far from 1970 holds whole multiples of 2**-15 + # seconds, hence half a second rather than one microsecond + cases = ( + (YEAR_1, 0, _REAL_DATETIME(1, 1, 2, tzinfo=UTC)), + (YEAR_1, 500_000, _REAL_DATETIME(1, 1, 2, 0, 0, 0, 500_000, tzinfo=UTC)), + (YEAR_9999, 0, _REAL_DATETIME(9999, 12, 30, 23, 59, 59, tzinfo=UTC)), + (YEAR_9999, 500_000, _REAL_DATETIME(9999, 12, 30, 23, 59, 59, 500_000, tzinfo=UTC)), + (-5, 0, _REAL_DATETIME(1969, 12, 31, 23, 59, 55, tzinfo=UTC)), + (0, 0, _REAL_DATETIME(1970, 1, 1, tzinfo=UTC)), + (5, 250_000, _REAL_DATETIME(1970, 1, 1, 0, 0, 5, 250_000, tzinfo=UTC)), + ) + for offset, micros, expected in cases: + with self.subTest(tsoffset=offset, micros=micros): + packet, messages = self._packet(_idb((14, ' None: + for seconds in (-3600, 3600): + zone = datetime.timezone(datetime.timedelta(seconds=seconds)) + for offset, expected in ((YEAR_1, _REAL_DATETIME(1, 1, 2, tzinfo=UTC)), + (YEAR_9999, _REAL_DATETIME(9999, 12, 30, 23, 59, 59, tzinfo=UTC))): + with self.subTest(tzone=seconds, tsoffset=offset): + packet, messages = self._packet(_idb((10, ' None: + for offset in (-62135596801, 253402300800, 2**63 - 1): + with self.subTest(tsoffset=offset): + packet, messages = self._packet(_idb((14, ' None: + from tests._support import reimport_once_per_class + + reimport_once_per_class(self) + + def test_mesg_id_before_1970(self) -> None: + from pcapkit.const.mh.option import Option + from pcapkit.protocols.internet.mh import MH + from pcapkit.protocols.schema.internet.mh import MesgIDOption + + for interval in (_REAL_DATETIME(1900, 1, 1, tzinfo=UTC), + _REAL_DATETIME(1950, 6, 1, 12, 0, 0, 500_000, tzinfo=UTC), + _REAL_DATETIME(1969, 12, 31, 23, 59, 59, tzinfo=UTC)): + with self.subTest(interval=interval): + made = object.__new__(MH)._make_opt_mesg_id(Option.MESG_ID_OPTION_TYPE, interval=interval) + octets = made.pack() + with windows_gmtime(): + read = MesgIDOption.unpack(octets) + self.assertEqual(read.timestamp, interval) + self.assertIs(read.timestamp.tzinfo, UTC) + self.assertEqual(read.pack(), octets) + + def test_timestamp_option_after_3000(self) -> None: + from pcapkit.const.mh.option import Option + from pcapkit.protocols.internet.mh import MH + + proto = object.__new__(MH) + cases = ( + (WINDOWS_MAX + 1, 0, _REAL_DATETIME(3001, 1, 1, tzinfo=UTC)), + (YEAR_9999, 0x8000, _REAL_DATETIME(9999, 12, 30, 23, 59, 59, 500_000, tzinfo=UTC)), + (5, 1, _REAL_DATETIME(1970, 1, 1, 0, 0, 5, 15, tzinfo=UTC)), + ) + for seconds, fraction, expected in cases: + with self.subTest(seconds=seconds, fraction=fraction): + schema = proto._make_opt_timestamp(Option.Timestamp_Option, seconds=seconds, fraction=fraction) + with windows_gmtime(): + data = proto._read_opt_timestamp(schema, options=None) # type: ignore[arg-type] + self.assertEqual(data.timestamp, expected) + self.assertIs(data.timestamp.tzinfo, UTC) + rebuilt = proto._make_opt_timestamp(Option.Timestamp_Option, data) + self.assertEqual(rebuilt.pack(), schema.pack()) + + # past year 9999 it raises what it raises on Linux + schema = proto._make_opt_timestamp(Option.Timestamp_Option, seconds=2**48 - 1, fraction=0) + with windows_gmtime(), self.assertRaises(ValueError): + proto._read_opt_timestamp(schema, options=None) # type: ignore[arg-type] + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/misc/test_pcapng_unit.py b/tests/protocols/misc/test_pcapng_unit.py index 4e889c76d4..3c2ab11d9f 100644 --- a/tests/protocols/misc/test_pcapng_unit.py +++ b/tests/protocols/misc/test_pcapng_unit.py @@ -555,30 +555,16 @@ def now(cls, tz): with mock.patch('pcapkit.protocols.misc.pcapng.datetime.datetime', NoTimezoneDateTime): self.assertEqual(PCAPNG._get_local_timezone(), datetime.timezone.utc) - real_datetime = datetime.datetime - - class FakeDateTime: - calls = 0 - - @classmethod - def fromtimestamp(cls, *args): - if cls.calls == 0: - cls.calls += 1 - raise ValueError - return real_datetime.fromtimestamp(*args) - pcapng._ctx = types.SimpleNamespace(interfaces=[interface], section=types.SimpleNamespace(byteorder='little')) pcapng._type = BlockType.Enhanced_Packet_Block - # Faked through the module's name in pcapng.py, not through - # ``datetime.datetime`` itself: PyPy's datetime is pure Python and - # reads its own ``datetime`` global, so faking that fails the real call - # the fake hands on to (#1958). - fake_module = types.SimpleNamespace(**dict(vars(datetime), datetime=FakeDateTime)) - with mock.patch('pcapkit.protocols.misc.pcapng.datetime', fake_module), \ + # Faked through pcapng.py's own binding of + # :func:`pcapkit.utilities.timestamp.fromtimestamp`, which it calls + # instead of :meth:`datetime.datetime.fromtimestamp` since #2099. + with mock.patch('pcapkit.protocols.misc.pcapng.fromtimestamp', side_effect=ValueError), \ mock.patch('pcapkit.protocols.misc.pcapng.warn') as warn: timestamp, epoch = pcapng._read_timestamp(0, 1, interface_id=0) - self.assertEqual(timestamp, real_datetime.fromtimestamp(0, datetime.timezone.utc)) + self.assertEqual(timestamp, datetime.datetime.fromtimestamp(0, datetime.timezone.utc)) # #361: 1 unit at if_tsresol=1e9 plus the 2s if_tsoffset. The leading # ``Decimal(7200)`` this used to carry was this interface's if_tzone # (+02:00) leaking into the epoch; the fallback datetime above is aware