From ab26e6f8125a3dce746e99501062b999aba48691 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 16 Oct 2026 23:04:19 -0400 Subject: [PATCH] fix(protocols)!: make NTPTimestamp and PMIPv6Timestamp Info classes (#1886) - Define both as Data models in protocols.data.internet.mh, beside the options that hold them, so from_dict resolves their annotations and rebuilds them; export them as MH_NTPTimestamp and MH_PMIPv6Timestamp - Read the Timestamp option's pair by attribute when rebuilding it, since an Info unpacks to its keys - Add versionchanged on both classes, the module and its docs page, and versionremoved for NTPTimestamp's 1.4.x path - Correct the 1.5.0 changelog's claim that no NamedTuple remains, since TCPSegment is one, and regenerate CHANGELOG.md from it - Point the tests at the new home and register both names as moved BREAKING CHANGE: NTPTimestamp is no longer a tuple, and protocols.internet.mh no longer binds it; the dumpers write both timestamps keyed by field. Closes #1886 --- CHANGELOG.md | 2 +- docs/source/changelog/1.5.0.rst | 6 +- docs/source/contributing/pep.rst | 4 +- docs/source/pcapkit/protocols/internet/mh.rst | 42 ++-- pcapkit/protocols/data/__init__.py | 1 + pcapkit/protocols/data/internet/__init__.py | 3 + pcapkit/protocols/data/internet/mh.py | 62 +++++- pcapkit/protocols/internet/mh.py | 63 +++--- pcapkit/protocols/schema/internet/mh.py | 4 +- ...test_no_moved_name_re_exports_1744_unit.py | 4 + .../test_mh_timestamp_info_1886_unit.py | 179 ++++++++++++++++++ tests/protocols/internet/test_mh_unit.py | 14 +- 12 files changed, 314 insertions(+), 70 deletions(-) create mode 100644 tests/protocols/internet/test_mh_timestamp_info_1886_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e864e46..911f20311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,7 @@ Preceded by `1.5.0a1` (2026-09-15), `1.5.0b1` to `1.5.0b3` (all 2026-09-19) and #### Changed -- `Probe`, `CipherSuite` and `IntegritySuite` are `Info` subclasses rather than `typing.NamedTuple`, and no `NamedTuple` remains in the package. They are Mappings now, so `len()` and iteration yield field names rather than values. +- `Probe`, `CipherSuite` and `IntegritySuite` are `Info` subclasses rather than `typing.NamedTuple`. They are Mappings now, so `len()` and iteration yield field names rather than values. `TCPSegment` in `pcapkit.toolkit.pcap` stays a `typing.NamedTuple`, since it is built for every TCP frame and an `Info` costs more to construct. - `ModuleDescriptor.klass` reads an already-imported module out of `sys.modules` rather than re-entering `importlib.import_module`, since next layer dispatch resolves a descriptor there on a per-frame path. Neither a registry *hit* holding a `ModuleDescriptor` nor a *miss* writes the resolved class back: a memoised hit went stale on `importlib.reload` ([#1559](https://github.com/JarryShaw/PyPCAPKit/issues/1559)), and recording a miss in a class-level `collections.defaultdict` is the defect [#421](https://github.com/JarryShaw/PyPCAPKit/issues/421)/[#426](https://github.com/JarryShaw/PyPCAPKit/pull/426) fixed at this layer, [#425](https://github.com/JarryShaw/PyPCAPKit/issues/425)/[#428](https://github.com/JarryShaw/PyPCAPKit/pull/428) for the option, chunk and block registries, and [#555](https://github.com/JarryShaw/PyPCAPKit/issues/555)/[#560](https://github.com/JarryShaw/PyPCAPKit/pull/560) at the schema layer. A resolution now costs ~117 ns rather than ~436 ns, which is **not measurable in** `extract()` **wall clock**. The change is taken for its shape: `sys.modules` stays the only module cache, whereas a memo of the class would serve the pre-reload class after an `importlib.reload` forever and fail `isinstance` against the live one. Proposed by `@Ts-Boom` in [#563](https://github.com/JarryShaw/PyPCAPKit/pull/563); the implementation differs because that one added a second, never-invalidated cache of resolved classes ([#574](https://github.com/JarryShaw/PyPCAPKit/issues/574)). - `SeekableReader.truncate()` raises instead of returning a size, and the misspelled `writeable()` is now `writable()`. Both break external callers; nothing inside the package calls either. `io.IOBase` documents one gate over two methods -- "If False, write() and truncate() will raise OSError" -- and this reader's `writable()` answers False, yet `truncate` returned its new size, where `open(path, 'rb').truncate()` raises. The refusal is `UnsupportedOperation('truncate')`, the same one `write` raises; pcapkit's subclasses `io.UnsupportedOperation`. The old resizing is kept as the private `_truncate_buffer`: it resizes a private lookback window, not the stream, and it is the only route to the "position sits before the window" state that `seek` and the buffered read paths must refuse, for which [#643](https://github.com/JarryShaw/PyPCAPKit/issues/643) and [#644](https://github.com/JarryShaw/PyPCAPKit/issues/644) landed tests. `writeable()` was never an override of anything, so `io`, `shutil` and third parties never saw it, and the value reported is unchanged ([#645](https://github.com/JarryShaw/PyPCAPKit/issues/645)). - **a breaking change to** `@final`, now enforced at runtime on `Info` and `Schema`. `info_final` and `schema_final` both end `return final(cls)`, so every finalised class carried `typing.final`'s `__final__` marker and nothing read it. `@final` **alone on an** `Info` **or** `Schema` **subclass -- without** `@info_final` **or** `@schema_final` **-- now raises** `InfoError` **or** `SchemaError` **at first construction**, the class being marked final but never finalised and so carrying no generated `__init__`. `__init_subclass__` cannot catch that, since `final` applies after class creation, so the check sits in the existing one-shot `FinalisedState.NONE` branch and a finalised class pays nothing. **Deriving from a finalised class raises too**, from new `Info.__init_subclass__` and `Schema.__init_subclass__` hooks (before, only `EnumSchema` had one). `SchemaError` **is a** `ValueError`, so code catching `TypeError` around a subclass declaration will not see it. `@info_final @final` and `@final @info_final` both finalise silently; the re-entry check keys on `__finalised__` rather than `__final__`, because only the former records *the decorator* having run. `@info_final` twice warns and returns the class unchanged. `pcapkit.utilities.compat` takes `final` from `typing_extensions` below 3.11 rather than 3.8, because `typing.final` only records `__final__` from 3.11. The guard cannot fire on the library's own classes ([#778](https://github.com/JarryShaw/PyPCAPKit/issues/778)). diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index e4752df81..ed608ca95 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -382,8 +382,10 @@ Changed ~~~~~~~ * ``Probe``, ``CipherSuite`` and ``IntegritySuite`` are ``Info`` subclasses rather - than ``typing.NamedTuple``, and no ``NamedTuple`` remains in the package. They are - Mappings now, so ``len()`` and iteration yield field names rather than values. + than ``typing.NamedTuple``. They are Mappings now, so ``len()`` and iteration yield + field names rather than values. ``TCPSegment`` in ``pcapkit.toolkit.pcap`` stays a + ``typing.NamedTuple``, since it is built for every TCP frame and an ``Info`` costs + more to construct. * ``ModuleDescriptor.klass`` reads an already-imported module out of ``sys.modules`` rather than re-entering ``importlib.import_module``, since next layer dispatch resolves a descriptor there on a per-frame path. Neither a registry *hit* holding a diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index b53d47249..60d8d2ece 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -215,8 +215,8 @@ looks like an ordinary field and is not: 48/16 fixed-point split, where NTP counts from 1900 in a 32/32 one, so reading one as the other is wrong in the epoch and in both field widths. They have separate types for that reason: - :class:`~pcapkit.protocols.internet.mh.PMIPv6Timestamp` and - :class:`~pcapkit.protocols.internet.mh.NTPTimestamp`. + :class:`~pcapkit.protocols.data.internet.mh.PMIPv6Timestamp` and + :class:`~pcapkit.protocols.data.internet.mh.NTPTimestamp`. DTLS ~~~~ diff --git a/docs/source/pcapkit/protocols/internet/mh.rst b/docs/source/pcapkit/protocols/internet/mh.rst index 514362dde..5700160b1 100644 --- a/docs/source/pcapkit/protocols/internet/mh.rst +++ b/docs/source/pcapkit/protocols/internet/mh.rst @@ -19,6 +19,22 @@ Octets Bits Name Description 6 48 ``mh.data`` Message Data ======= ========= ================== =============================== +.. versionremoved:: 1.5.0 + + ``pcapkit.protocols.internet.mh.NTPTimestamp``, with no alias; use + :class:`pcapkit.protocols.data.internet.mh.NTPTimestamp` instead + (:issue:`1886`). + +.. versionchanged:: 1.5.0 + + :class:`~pcapkit.protocols.data.internet.mh.NTPTimestamp` and + :class:`~pcapkit.protocols.data.internet.mh.PMIPv6Timestamp` are + :class:`~pcapkit.corekit.infoclass.Info` data models rather than named + tuples, so they no longer index, unpack, hash or compare equal to a + :obj:`tuple`, and the dumpers write them keyed by field, as + ``{"seconds": ..., "fraction": ...}``. For ``NTPTimestamp`` this is a + breaking change from 1.4.x (:issue:`1886`). + .. autoclass:: pcapkit.protocols.internet.mh.MH :no-members: :show-inheritance: @@ -273,20 +289,6 @@ Octets Bits Name Description Auxiliary Data -------------- -.. autoclass:: pcapkit.protocols.internet.mh.NTPTimestamp - :members: - :show-inheritance: - - .. attribute:: seconds - :type: int - - Seconds since 1 January 1900. - - .. attribute:: fraction - :type: int - - Fraction of a second. - .. autoclass:: pcapkit.protocols.internet.mh.FastBindingAcknowledgmentStatus :members: :undoc-members: @@ -297,10 +299,6 @@ Auxiliary Data :undoc-members: :show-inheritance: -.. autoclass:: pcapkit.protocols.internet.mh.PMIPv6Timestamp - :members: - :show-inheritance: - .. autoclass:: pcapkit.protocols.internet.mh.LocalizedRoutingStatus :members: :undoc-members: @@ -1061,6 +1059,10 @@ Data Models :members: :show-inheritance: +.. autoclass:: pcapkit.protocols.data.internet.mh.NTPTimestamp + :members: + :show-inheritance: + .. autoclass:: pcapkit.protocols.data.internet.mh.CGAParametersRequestOption :members: :show-inheritance: @@ -1241,6 +1243,10 @@ Data Models :members: :show-inheritance: +.. autoclass:: pcapkit.protocols.data.internet.mh.PMIPv6Timestamp + :members: + :show-inheritance: + .. autoclass:: pcapkit.protocols.data.internet.mh.RestartCounterOption :members: :show-inheritance: diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 353b1f50f..00c027cc7 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -178,6 +178,7 @@ 'MH_MulticastAcknowledgementOption', 'MH_LMAControlledMAGParametersOption', 'MH_MAGMultipathBindingOption', 'MH_MAGIdentifierOption', 'MH_AnchoredPrefixOption', 'MH_LocalPrefixOption', 'MH_PreviousMAAROption', 'MH_ServingMAAROption', 'MH_DLIFLinkLocalAddressOption', 'MH_DLIFLinkLayerAddressOption', + 'MH_NTPTimestamp', 'MH_PMIPv6Timestamp', 'MH_ContextRequest', 'MH_FlowIdentificationSuboption', 'MH_UnassignedFlowIdentificationSuboption', 'MH_PadFlowIdentificationSuboption', 'MH_BIDReferenceSuboption', diff --git a/pcapkit/protocols/data/internet/__init__.py b/pcapkit/protocols/data/internet/__init__.py index fc740c988..634442464 100644 --- a/pcapkit/protocols/data/internet/__init__.py +++ b/pcapkit/protocols/data/internet/__init__.py @@ -323,6 +323,7 @@ from pcapkit.protocols.data.internet.mh import MultiPrefixExtension as MH_MultiPrefixExtension from pcapkit.protocols.data.internet.mh import NATDetectionOption as MH_NATDetectionOption from pcapkit.protocols.data.internet.mh import NonceIndicesOption as MH_NonceIndicesOption +from pcapkit.protocols.data.internet.mh import NTPTimestamp as MH_NTPTimestamp from pcapkit.protocols.data.internet.mh import Option as MH_Option from pcapkit.protocols.data.internet.mh import \ PadFlowIdentificationSuboption as MH_PadFlowIdentificationSuboption @@ -331,6 +332,7 @@ PermanentHomeKeygenTokenOption as MH_PermanentHomeKeygenTokenOption from pcapkit.protocols.data.internet.mh import \ PerSessionBitRateAttribute as MH_PerSessionBitRateAttribute +from pcapkit.protocols.data.internet.mh import PMIPv6Timestamp as MH_PMIPv6Timestamp from pcapkit.protocols.data.internet.mh import PreviousMAAROption as MH_PreviousMAAROption from pcapkit.protocols.data.internet.mh import QoSAttribute as MH_QoSAttribute from pcapkit.protocols.data.internet.mh import \ @@ -478,6 +480,7 @@ 'MH_MulticastAcknowledgementOption', 'MH_LMAControlledMAGParametersOption', 'MH_MAGMultipathBindingOption', 'MH_MAGIdentifierOption', 'MH_AnchoredPrefixOption', 'MH_LocalPrefixOption', 'MH_PreviousMAAROption', 'MH_ServingMAAROption', 'MH_DLIFLinkLocalAddressOption', 'MH_DLIFLinkLayerAddressOption', + 'MH_NTPTimestamp', 'MH_PMIPv6Timestamp', 'MH_ContextRequest', 'MH_FlowIdentificationSuboption', 'MH_UnassignedFlowIdentificationSuboption', 'MH_PadFlowIdentificationSuboption', 'MH_BIDReferenceSuboption', diff --git a/pcapkit/protocols/data/internet/mh.py b/pcapkit/protocols/data/internet/mh.py index 83e4255d0..bf8f2e8fb 100644 --- a/pcapkit/protocols/data/internet/mh.py +++ b/pcapkit/protocols/data/internet/mh.py @@ -68,8 +68,7 @@ from pcapkit.corekit.multidict import OrderedMultiDict from pcapkit.protocols.internet.mh import (FastBindingAcknowledgmentStatus, IPv6AddressPrefixCode, LMAAddressCode, - LocalizedRoutingStatus, NTPTimestamp, - PMIPv6Timestamp) + LocalizedRoutingStatus) __all__ = [ 'MH', @@ -108,6 +107,8 @@ 'LocalPrefixOption', 'PreviousMAAROption', 'ServingMAAROption', 'DLIFLinkLocalAddressOption', 'DLIFLinkLayerAddressOption', + 'NTPTimestamp', 'PMIPv6Timestamp', + 'ContextRequest', 'FlowIdentificationSuboption', @@ -983,6 +984,33 @@ class AuthOption(Option): def __init__(self, type: 'Enum_Option', length: 'int', subtype: 'Enum_AuthSubtype', spi: 'int', data: 'bytes') -> 'None': ... +@info_final +class NTPTimestamp(Data): + """Data model for NTP timestamp format, c.f., :rfc:`1305`. + + .. versionchanged:: 1.5.0 + + An :class:`~pcapkit.corekit.infoclass.Info` data model in this module, + rather than a named tuple in :mod:`pcapkit.protocols.internet.mh`, which + no longer binds the name (:issue:`1886`). This breaks 1.4.x code that + treats it as a tuple: it no longer indexes, unpacks, hashes, orders, + compares equal to a :obj:`tuple`, or has ``_replace`` and ``_asdict``. + :attr:`seconds` and :attr:`fraction` read as before, and it still + pickles. The dumpers write it keyed by field, as + ``{"seconds": ..., "fraction": ...}``, where they wrote + ``{"type": "NTPTimestamp", "value": [...]}``. + + """ + + #: Seconds since 1 January 1900. + seconds: 'int' + #: Fraction of a second. + fraction: 'int' + + if TYPE_CHECKING: + def __init__(self, seconds: 'int', fraction: 'int') -> 'None': ... + + @info_final class MesgIDOption(Option): """Data model for Mobility Message Replay Protection option.""" @@ -1259,6 +1287,36 @@ class LinkLocalAddressOption(Option): def __init__(self, type: 'Enum_Option', length: 'int', address: 'IPv6Address') -> 'None': ... +@info_final +class PMIPv6Timestamp(Data): + """Data model for Proxy Mobile IPv6 timestamp format, c.f., :rfc:`5213#section-8.8`. + + Note: + This is **not** an :rfc:`1305` NTP timestamp, which is why it is a type of + its own rather than a reuse of :class:`NTPTimestamp`. It counts from the + UNIX epoch rather than NTP's 1900 one, and splits its 64 bits 48/16 + rather than 32/32, so each field means something different in the two. + + .. versionchanged:: 1.5.0 + + An :class:`~pcapkit.corekit.infoclass.Info` data model in this module, + rather than the named tuple in :mod:`pcapkit.protocols.internet.mh` it + was in the 1.5.0 pre-releases (:issue:`1886`). It no longer indexes, + unpacks, hashes, orders, compares equal to a :obj:`tuple`, or has + ``_replace`` and ``_asdict``. The dumpers write it keyed by field, as + ``{"seconds": ..., "fraction": ...}``. + + """ + + #: Seconds since 1 January 1970, in the leading 48 bits. + seconds: 'int' + #: Fraction of a second, in units of 1/65536, in the trailing 16 bits. + fraction: 'int' + + if TYPE_CHECKING: + def __init__(self, seconds: 'int', fraction: 'int') -> 'None': ... + + @info_final class TimestampOption(Option): """Data model for MH Timestamp option.""" diff --git a/pcapkit/protocols/internet/mh.py b/pcapkit/protocols/internet/mh.py index 96a7e7e05..75a7fb97f 100644 --- a/pcapkit/protocols/internet/mh.py +++ b/pcapkit/protocols/internet/mh.py @@ -20,6 +20,22 @@ .. [*] https://en.wikipedia.org/wiki/Mobile_IP#Changes_in_IPv6_for_Mobile_IPv6 +.. versionremoved:: 1.5.0 + + ``pcapkit.protocols.internet.mh.NTPTimestamp``, with no alias; use + :class:`pcapkit.protocols.data.internet.mh.NTPTimestamp` instead + (:issue:`1886`). + +.. versionchanged:: 1.5.0 + + :class:`~pcapkit.protocols.data.internet.mh.NTPTimestamp` and + :class:`~pcapkit.protocols.data.internet.mh.PMIPv6Timestamp` are + :class:`~pcapkit.corekit.infoclass.Info` data models rather than named + tuples, so they no longer index, unpack, hash or compare equal to a + :obj:`tuple`, and the dumpers write them keyed by field, as + ``{"seconds": ..., "fraction": ...}``. For ``NTPTimestamp`` this is a + breaking change from 1.4.x (:issue:`1886`). + """ import collections import datetime @@ -231,6 +247,7 @@ from pcapkit.protocols.data.internet.mh import MultiPrefixExtension as Data_MultiPrefixExtension from pcapkit.protocols.data.internet.mh import NATDetectionOption as Data_NATDetectionOption from pcapkit.protocols.data.internet.mh import NonceIndicesOption as Data_NonceIndicesOption +from pcapkit.protocols.data.internet.mh import NTPTimestamp as Data_NTPTimestamp from pcapkit.protocols.data.internet.mh import \ PadFlowIdentificationSuboption as Data_PadFlowIdentificationSuboption from pcapkit.protocols.data.internet.mh import PadOption as Data_PadOption @@ -238,6 +255,7 @@ PermanentHomeKeygenTokenOption as Data_PermanentHomeKeygenTokenOption from pcapkit.protocols.data.internet.mh import \ PerSessionBitRateAttribute as Data_PerSessionBitRateAttribute +from pcapkit.protocols.data.internet.mh import PMIPv6Timestamp as Data_PMIPv6Timestamp from pcapkit.protocols.data.internet.mh import PreviousMAAROption as Data_PreviousMAAROption from pcapkit.protocols.data.internet.mh import QoSAttribute as Data_QoSAttribute from pcapkit.protocols.data.internet.mh import \ @@ -535,36 +553,6 @@ __all__ = ['MH'] -class NTPTimestamp(collections.namedtuple('NTPTimestamp', 'seconds fraction')): - """NTP timestamp format, c.f., :rfc:`1305`.""" - - __slots__ = () - - #: Seconds since 1 January 1900. - seconds: int - #: Fraction of a second. - fraction: int - - -class PMIPv6Timestamp(collections.namedtuple('PMIPv6Timestamp', 'seconds fraction')): - """Proxy Mobile IPv6 timestamp format, c.f., :rfc:`5213#section-8.8`. - - Note: - This is **not** an :rfc:`1305` NTP timestamp, which is why it is a type of - its own rather than a reuse of :class:`NTPTimestamp`. It counts from the - UNIX epoch rather than NTP's 1900 one, and splits its 64 bits 48/16 - rather than 32/32, so each field means something different in the two. - - """ - - __slots__ = () - - #: Seconds since 1 January 1970, in the leading 48 bits. - seconds: int - #: Fraction of a second, in units of 1/65536, in the trailing 16 bits. - fraction: int - - class FastBindingAcknowledgmentStatus(EnumLookup, IntEnum): """[FastBindingAcknowledgmentStatus] Fast Binding Acknowledgment Status Codes. @@ -3372,7 +3360,7 @@ def _read_opt_mesg_id(self, schema: 'Schema_MesgIDOption', *, type=schema.type, length=self._mh_option_length(schema.length), timestamp=schema.timestamp, - ntp_timestamp=NTPTimestamp(schema.seconds, schema.fraction), + ntp_timestamp=Data_NTPTimestamp(seconds=schema.seconds, fraction=schema.fraction), ) return data @@ -4094,7 +4082,7 @@ def _read_opt_timestamp(self, schema: 'Schema_TimestampOption', *, 1900 one, and splits the 64 bits 48/16 rather than 32/32. Reading it as an NTP timestamp would be wrong in both the epoch and the field widths, which is why it gets its own - :class:`~pcapkit.protocols.internet.mh.PMIPv6Timestamp`. + :class:`~pcapkit.protocols.data.internet.mh.PMIPv6Timestamp`. Args: schema: Parsed option schema. @@ -4115,7 +4103,7 @@ def _read_opt_timestamp(self, schema: 'Schema_TimestampOption', *, length=self._mh_option_length(schema.length), timestamp=datetime.datetime.fromtimestamp( seconds + fraction / 65536, tz=datetime.timezone.utc), - pmip_timestamp=PMIPv6Timestamp(seconds, fraction), + pmip_timestamp=Data_PMIPv6Timestamp(seconds=seconds, fraction=fraction), ) return data @@ -8382,7 +8370,7 @@ def _make_opt_auth(self, type: 'Enum_Option', option: 'Optional[Data_AuthOption] ) def _make_opt_mesg_id(self, type: 'Enum_Option', option: 'Optional[Data_MesgIDOption]' = None, *, - timestamp: 'Optional[NTPTimestamp]' = None, + timestamp: 'Optional[Data_NTPTimestamp]' = None, interval: 'Optional[dt_type]' = None, **kwargs: 'Any') -> 'Schema_MesgIDOption': """Make MH mobility message replay protection option. @@ -8410,8 +8398,8 @@ def _make_opt_mesg_id(self, type: 'Enum_Option', option: 'Optional[Data_MesgIDOp ts_sec = math.floor(int_ts) ts_frc = min(round((int_ts - ts_sec) * 2**32), 0xFFFF_FFFF) # 1/2**32 seconds per unit - timestamp = NTPTimestamp(seconds=ts_sec + 2_208_988_800, # 70 years - fraction=ts_frc) + timestamp = Data_NTPTimestamp(seconds=ts_sec + 2_208_988_800, # 70 years + fraction=ts_frc) return Schema_MesgIDOption( type=type, @@ -8982,7 +8970,8 @@ def _make_opt_timestamp(self, type: 'Enum_Option', option: 'Optional[Data_Timest """ if option is not None: - seconds, fraction = option.pmip_timestamp + seconds = option.pmip_timestamp.seconds + fraction = option.pmip_timestamp.fraction if not 0 <= seconds < 2 ** 48: raise ProtocolError(f'{self.alias}: [OptNo {type}] invalid timestamp seconds: {seconds}') diff --git a/pcapkit/protocols/schema/internet/mh.py b/pcapkit/protocols/schema/internet/mh.py index 0d24131e7..ba825c8be 100644 --- a/pcapkit/protocols/schema/internet/mh.py +++ b/pcapkit/protocols/schema/internet/mh.py @@ -1209,8 +1209,8 @@ class TimestampOption(Option, code=Enum_Option.Timestamp_Option): integer part occupies the leading **48** bits and whose fractional part occupies the trailing **16**, each unit of the latter being 1/65536 of a second. NTP splits its 64 bits 32/32 from a different epoch, so reusing - :class:`~pcapkit.protocols.internet.mh.NTPTimestamp` here would misread - both halves. + :class:`~pcapkit.protocols.data.internet.mh.NTPTimestamp` here would + misread both halves. """ diff --git a/tests/project/test_no_moved_name_re_exports_1744_unit.py b/tests/project/test_no_moved_name_re_exports_1744_unit.py index 69153ff08..15964bd30 100644 --- a/tests/project/test_no_moved_name_re_exports_1744_unit.py +++ b/tests/project/test_no_moved_name_re_exports_1744_unit.py @@ -53,6 +53,8 @@ ('pcapkit.foundation.traceflow.data.data', 'DeferredPacket'), ('pcapkit.protocols.application.ngap', 'ProcedureCode'), ('pcapkit.protocols.application.ngap', 'ProtocolIE'), + ('pcapkit.protocols.internet.mh', 'NTPTimestamp'), + ('pcapkit.protocols.internet.mh', 'PMIPv6Timestamp'), ) #: Removed paths the old module still uses, as ``(old module, name, new home)``. @@ -73,6 +75,8 @@ 'pcapkit/protocols/misc/pcapng.py', 'pcapkit/protocols/misc/pcapng.rst'), ('pcapkit.corekit.fields.field', 'NoValueType', 'pcapkit/corekit/fields/field.py', 'pcapkit/corekit/fields/field.rst'), + ('pcapkit.protocols.internet.mh', 'NTPTimestamp', + 'pcapkit/protocols/internet/mh.py', 'pcapkit/protocols/internet/mh.rst'), ) diff --git a/tests/protocols/internet/test_mh_timestamp_info_1886_unit.py b/tests/protocols/internet/test_mh_timestamp_info_1886_unit.py new file mode 100644 index 000000000..b000b5541 --- /dev/null +++ b/tests/protocols/internet/test_mh_timestamp_info_1886_unit.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""``NTPTimestamp`` and ``PMIPv6Timestamp`` are ``Info`` data models (GitHub issue #1886). + +Both were named tuples in :mod:`pcapkit.protocols.internet.mh`. They are now +:class:`~pcapkit.protocols.data.data.Data` models in +:mod:`pcapkit.protocols.data.internet.mh`, beside the options that hold them, so +:meth:`~pcapkit.corekit.infoclass.Info.from_dict` can resolve the annotations of +those options and rebuild them. These checks pin what that changes: + +* each is an :class:`~pcapkit.corekit.infoclass.Info`, read by attribute and no + longer a tuple; +* a Mobility Header carrying the option that holds it rebuilds byte for byte, + from its info and from ``info.to_dict()``; +* the dumpers write it keyed by field; +* it survives :mod:`pickle`. + +Every case builds its packet as octets in memory and reads no capture. + +""" + +import importlib.util +import json +import os +import pickle +import plistlib +import struct +import tempfile +import unittest +import warnings + +from tests._support import reimport_once_per_class + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: NTP seconds and fraction of the Mobility Message Replay Protection option: +#: the UNIX epoch counted from 1900, plus a quarter of a second. +NTP = (0x83aa7e80, 0x40000000) +#: :rfc:`5213#section-8.8` seconds and fraction of the Timestamp option: a +#: 48-bit UNIX time, plus half a second in 1/65536ths. +PMIP = (1_700_000_000, 0x8000) + +#: A Binding Refresh Request (type 0) with no next header, carrying the +#: Mobility Message Replay Protection option (type 10), the Timestamp option +#: (type 27) and a PadN that aligns it to 32 octets, i.e. ``Header Len`` 3. +MH_OCTETS = (bytes([59, 3, 0, 0]) + b'\x00\x00' + b'\x00\x00' + + bytes([10, 8]) + struct.pack('!II', *NTP) + + bytes([27, 8]) + PMIP[0].to_bytes(6, 'big') + PMIP[1].to_bytes(2, 'big') + + bytes([1, 2, 0, 0])) + +MAC = bytes.fromhex('0123456789ab' 'fedcba987654') +SRC6 = bytes.fromhex('20010db8' + '00' * 11 + '01') +DST6 = bytes.fromhex('20010db8' + '00' * 11 + '02') + +#: Little-endian global header: v2.4, Ethernet. +GLOBAL = bytes.fromhex('d4c3b2a1' '0200' '0400' '00000000' '00000000' 'ffff0000' '01000000') + + +def capture() -> 'bytes': + """A PCAP file of one Ethernet/IPv6 frame whose next header is :data:`MH_OCTETS`.""" + packet = (MAC + b'\x86\xdd' + + struct.pack('!IHBB16s16s', 6 << 28, len(MH_OCTETS), 135, 64, SRC6, DST6) + + MH_OCTETS) + return GLOBAL + struct.pack(' 'list[object]': + """Every value held under ``key`` by a mapping anywhere inside ``node``.""" + found = [] # type: list[object] + if isinstance(node, dict): + for name, value in node.items(): + if name == key: + found.append(value) + found.extend(find(value, key)) + elif isinstance(node, list): + for value in node: + found.extend(find(value, key)) + return found + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class MHTimestampInfoTests(unittest.TestCase): + """Both timestamps are data models, as the other nested values of the MH models are.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + quiet = warnings.catch_warnings() + quiet.__enter__() # pylint: disable=unnecessary-dunder-call + self.addCleanup(quiet.__exit__, None, None, None) + warnings.simplefilter('ignore') + + def parse(self) -> 'tuple[object, object, object]': + """The parsed header, and its replay protection and timestamp options.""" + from pcapkit.const.mh.option import Option + from pcapkit.protocols.internet.mh import MH + + info = MH(MH_OCTETS, extension=True).info + return (info, info.options[Option.MESG_ID_OPTION_TYPE], + info.options[Option.Timestamp_Option]) + + def test_both_are_info_data_models_read_by_attribute(self) -> None: + from pcapkit.corekit.infoclass import Info + from pcapkit.protocols.data.data import Data + from pcapkit.protocols.data.internet import mh as data + from pcapkit.utilities.exceptions import UnsupportedCall + + _, mesg_id, timestamp = self.parse() + for name, value, model, expected in ( + ('ntp_timestamp', mesg_id.ntp_timestamp, 'NTPTimestamp', NTP), + ('pmip_timestamp', timestamp.pmip_timestamp, 'PMIPv6Timestamp', PMIP), + ): + with self.subTest(field=name): + self.assertIsInstance(value, Info) + self.assertIsInstance(value, Data) + self.assertNotIsInstance(value, tuple) + cls = getattr(data, model) + self.assertIs(type(value), cls) + self.assertEqual((value.seconds, value.fraction), expected) + self.assertEqual(list(value), ['seconds', 'fraction']) + + # constructed positionally or by keyword alike, and immutable + self.assertEqual(cls(*expected), value) + self.assertEqual(cls(seconds=expected[0], fraction=expected[1]), value) + with self.assertRaises(UnsupportedCall): + value.seconds = 0 # type: ignore[misc] + + # the option's annotation resolves at runtime, so the nested + # model comes back as itself rather than as a plain mapping + option = mesg_id if name == 'ntp_timestamp' else timestamp + rebuilt = type(option).from_dict(option.to_dict()) + self.assertIs(type(rebuilt[name]), cls) + self.assertEqual(rebuilt, option) + + def test_a_header_carrying_each_option_rebuilds_byte_for_byte(self) -> None: + from pcapkit.protocols.internet.mh import MH + + info, _, _ = self.parse() + for label, source in (('info', info), ('info.to_dict()', info.to_dict())): + with self.subTest(source=label): + self.assertEqual(MH.from_data(source).data, MH_OCTETS) + + def test_the_dumpers_key_each_by_field(self) -> None: + from pcapkit import extract + + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, 'in.pcap') + with open(src, 'wb') as file: + file.write(capture()) + dumps = {} + for fmt in ('json', 'plist'): + extractor = extract(fin=src, fout=os.path.join(tmpdir, f'out-{fmt}'), + format=fmt, store=False) + with open(extractor.output, 'rb') as file: + dumps[fmt] = file.read() + + for fmt, tree in (('json', json.loads(dumps['json'])), + ('plist', plistlib.loads(dumps['plist']))): + for key, (seconds, fraction) in (('ntp_timestamp', NTP), ('pmip_timestamp', PMIP)): + with self.subTest(format=fmt, field=key): + self.assertEqual(find(tree, key), [{'seconds': seconds, 'fraction': fraction}]) + + def test_pickling_round_trips(self) -> None: + from pcapkit.corekit.infoclass import Info + + _, mesg_id, timestamp = self.parse() + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): + for value in (mesg_id.ntp_timestamp, timestamp.pmip_timestamp, mesg_id, timestamp): + with self.subTest(protocol=protocol, type=type(value).__name__): + restored = pickle.loads(pickle.dumps(value, protocol)) + self.assertIsInstance(restored, Info) + self.assertIs(type(restored), type(value)) + self.assertEqual(restored, value) + for value, expected in ((mesg_id.ntp_timestamp, NTP), (timestamp.pmip_timestamp, PMIP)): + restored = pickle.loads(pickle.dumps(value, protocol)) + self.assertEqual((restored.seconds, restored.fraction), expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/internet/test_mh_unit.py b/tests/protocols/internet/test_mh_unit.py index 238cfe8d9..ad722aa6f 100644 --- a/tests/protocols/internet/test_mh_unit.py +++ b/tests/protocols/internet/test_mh_unit.py @@ -480,7 +480,7 @@ def test_mh_option_constructors_cover_known_options_and_dispatch(self) -> None: from pcapkit.const.mh.option import Option from pcapkit.corekit.multidict import OrderedMultiDict from pcapkit.protocols.data.internet import mh as data - from pcapkit.protocols.internet.mh import MH, NTPTimestamp + from pcapkit.protocols.internet.mh import MH from pcapkit.protocols.schema.internet import mh as schema from pcapkit.utilities.exceptions import ProtocolError @@ -527,7 +527,7 @@ def test_mh_option_constructors_cover_known_options_and_dispatch(self) -> None: proto._make_opt_auth(Option.AUTH_OPTION_TYPE, data=b'a') self.assertEqual(proto._make_opt_mesg_id( Option.MESG_ID_OPTION_TYPE, - timestamp=NTPTimestamp(seconds=1, fraction=2), + timestamp=data.NTPTimestamp(seconds=1, fraction=2), ).seconds, 1) self.assertGreater(proto._make_opt_mesg_id( Option.MESG_ID_OPTION_TYPE, @@ -609,7 +609,7 @@ def test_mh_callable_registry_data_model_and_property_edges(self) -> None: from pcapkit.corekit.multidict import OrderedMultiDict from pcapkit.protocols.data.internet import mh as data from pcapkit.protocols.internet.internet import Internet - from pcapkit.protocols.internet.mh import MH, NTPTimestamp + from pcapkit.protocols.internet.mh import MH from pcapkit.protocols.schema.internet import mh as schema proto = object.__new__(MH) @@ -821,7 +821,7 @@ def make_extension(code: CGAExtension, extension: data.UnknownExtension | None = length=8, timestamp=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), - ntp_timestamp=NTPTimestamp(seconds=1, fraction=2)), + ntp_timestamp=data.NTPTimestamp(seconds=1, fraction=2)), ).fraction, 2) self.assertEqual(proto._make_opt_signature( Option.Signature, @@ -2181,13 +2181,15 @@ def test_mh_pmipv6_timestamp_is_not_an_ntp_timestamp(self) -> None: data = proto._read_opt_timestamp(schema, options=None) # type: ignore[arg-type] self.assertEqual(data.timestamp, dt.datetime(2000, 1, 1, tzinfo=dt.timezone.utc)) - self.assertEqual(tuple(data.pmip_timestamp), (epoch_2000, 0)) + self.assertEqual((data.pmip_timestamp.seconds, data.pmip_timestamp.fraction), + (epoch_2000, 0)) # a half-second is 0x8000 of the 16-bit fraction, and survives the trip half = proto._make_opt_timestamp( # type: ignore[arg-type] Option.Timestamp_Option, seconds=epoch_2000, fraction=0x8000) parsed = proto._read_opt_timestamp(half, options=None) # type: ignore[arg-type] - self.assertEqual(tuple(parsed.pmip_timestamp), (epoch_2000, 0x8000)) + self.assertEqual((parsed.pmip_timestamp.seconds, parsed.pmip_timestamp.fraction), + (epoch_2000, 0x8000)) self.assertEqual( proto._make_opt_timestamp(Option.Timestamp_Option, parsed).pack(), # type: ignore[arg-type] half.pack())