diff --git a/CHANGELOG.md b/CHANGELOG.md index e880756b2..8bdea8c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,7 @@ Layer-limited extraction: `layer='link'` no longer sets the termination flag on - `TCP._make_mptcp_addaddr` could not build an `ADD_ADDR` option end to end: its `kind=`/`length=` arguments were rejected with `UnknownFieldWarning` and dropped, and `.pack()` then raised `KeyError: 'length'` from `port`'s own condition, `pkt['length'] in (10, 22)`. The cause was one layer up: `MPTCP`, the base of every Multipath TCP subtype schema, declared `kind` and `length` only under `typing.TYPE_CHECKING` rather than as real fields, unlike `Option`. That dropped `kind=`/`length=` for every `_make_mptcp_*` constructor, so `MPTCP` now declares both for real ([#541](https://github.com/JarryShaw/PyPCAPKit/issues/541)). The missing fields broke parsing too: a subtype schema's leading field read the `kind` octet itself, an off-by-two in field alignment rather than a wire-format change (a correct sender's octets were always right). Spec-correct `ADD_ADDR` and `MP_PRIO` options failed with `FieldError: TCP: [OptNo 30] 3 invalid IP version` and `KeyError: 'length'`; both parse now. - two dropped-keyword/wrong-cast defects flagged in review during this release and never filed until now: HIP's `_make_param_encrypted` passed `cipher=` to a schema with no such field, so an AES-cipher `ENCRYPTED` parameter built through `make` packed without its IV; and IPv6-Route's `RPL.post_process`, which runs on every `Schema.pack`, assumed `self.addresses` was still the concatenated `bytes` a parse leaves, and raised slicing the `list[bytes]` a `make`-built multi-address header holds ([#556](https://github.com/JarryShaw/PyPCAPKit/issues/556)). - two more defects [#541](https://github.com/JarryShaw/PyPCAPKit/issues/541) exposed by letting construction reach code that had never run. `MPTCP.subtype` was still `typing.TYPE_CHECKING`-only, so `TCP(options=[(Enum_Option.Multipath_TCP, ...)])` raised `AttributeError: ... has no attribute 'subtype'` for every subtype but `MP_JOIN`: the convenience constructor reads the schema straight back through `_read_mptcp_*` with no byte round trip, so `_MPTCP.post_process`, the only code that set `subtype`, never ran. Fixed on the construction path (`TCP._make_mode_mp`) rather than by adding a third real field the way `kind`/`length` got in [#541](https://github.com/JarryShaw/PyPCAPKit/issues/541): `subtype` is already packed as 4 bits of each subtype's own `test` bitfield, and a second field for the same bits would double-encode them or need a "derive, don't pack" field kind this library lacks ([#566](https://github.com/JarryShaw/PyPCAPKit/issues/566)). Separately, `_make_mptcp_capable` wrote `length=20 if rkey is None else 32` where [RFC 8684](https://datatracker.ietf.org/doc/html/rfc8684) section 3.1 gives 12 and 20, and `MPTCPCapable.rkey`'s condition (`pkt['length'] != 32`) dropped the receiver's key for exactly the length the maker used for "key present": a key-absent MP_CAPABLE could not be built, and a key-present one lost its key on the wire. Both, and the guard in `_read_mptcp_capable`, now agree on 12/20. **This changes MP_CAPABLE's packed output**: a 20-octet key-present option built or parsed under the old code becomes 12 octets with no key, or 20 octets with the key present, depending on which the caller meant ([#567](https://github.com/JarryShaw/PyPCAPKit/issues/567)). +- TCP's MP_CAPABLE now parses and builds all five lengths of [RFC 8684](https://datatracker.ietf.org/doc/html/rfc8684) section 3.1 -- 4 (SYN, no key), 12, 20, 22 (adds the Data-Level Length) and 24 (adds the Checksum) -- where `_read_mptcp_capable` raised `ProtocolError: TCP: [OptNo 30] invalid format` for anything but 12 and 20. In the schema `skey` is now present only for `length > 4` and `rkey` for `length > 12`, and two fields are new: `dl_len` (16 bits, `length > 20`) and `checksum` (2 octets, `length > 22`). `_make_mptcp_capable` takes `dl_len` and `checksum` too, where it used to drop them silently through `**kwargs`, and picks the length from how many of `skey`, `rkey`, `dl_len` and `checksum` are not `None`. A field given without the one before it raises `ProtocolError`. The data model `MPTCPCapable` gains `dl_len` and `checksum`, and `skey` becomes `Optional[int]`: code that constructs it directly must now pass both new fields, and `to_dict()` carries two more keys ([#1042](https://github.com/JarryShaw/PyPCAPKit/issues/1042)). - IPv6-Route's RPL routing data was five octets wide at the front where [RFC 6554](https://datatracker.ietf.org/doc/html/rfc6554) section 3 gives four, with three further defects behind it. `CmprI`, `CmprE` and `Pad` are each 4 bits sharing a 32-bit word with a 20-bit `Reserved`, but the schema declared `cmpr_i` and `cmpr_e` as whole octets, so a constructed two-address header packed to 41 octets while its `Hdr Ext Len` of 5 declared 48. Correcting the width is a **wire-format change** and reshapes the schema: `RPL(cmpr_i=..., cmpr_e=...)` is now `RPL(cmpr={'cmpr_i': ..., 'cmpr_e': ...})`, beside the existing `pad={'pad_len': ...}`. Behind it, the reader's `header.length % 16` guard read `Hdr Ext Len` as an octet count and assumed 16-octet addresses, which an SRH carries only when `CmprI` and `CmprE` are both 0 -- the unit confusion [#487](https://github.com/JarryShaw/PyPCAPKit/issues/487) fixed for Source Route and Type 2, left by [#489](https://github.com/JarryShaw/PyPCAPKit/pull/489) for want of a working RPL round trip to validate against. It is replaced by section 4.2's address-count arithmetic, `n = (((Hdr Ext Len * 8) - Pad - (16 - CmprE)) / (16 - CmprI)) + 1`, which the reader now requires to be non-negative and whole. `RPL.post_process` subtracted `pad_len` a second time from a buffer whose length callback had already taken it off, losing one address per `16 - CmprI` octets of padding, and set `ip` only when it had parsed octets, so `IPv6_Route(type=..., data={'ip': [...]})` raised a bare `AttributeError: 'RPL' object has no attribute 'ip'`. And `_make_data_type_rpl` computed `Pad` as `8 - length % 8` without the outer `% 8`, so an aligned address vector got a full 8 octets of padding where section 3 requires `Pad` to be 0 when `CmprI` and `CmprE` are both 0. The narrower `cmpr_i` also removes a latent divide-by-zero (a whole octet could hold 16, making `16 - CmprI` zero). `ipv6-route-type/RPL_Source_Route_Header` round-trips and its `EXPECTED_FAILURES` entry is deleted; none of this has been checked against a real RPL capture ([#564](https://github.com/JarryShaw/PyPCAPKit/issues/564)). - `L2TPv2` parsed any version nibble, so an L2TPv3 datagram was reported as v2 with a tunnel and session ID read out of v3's Control Connection ID. [RFC 2661](https://datatracker.ietf.org/doc/html/rfc2661) §3.1 fixes `Ver` at 2 and reserves 1 for L2F, and `L2TPv2.version` documented that any other value "is a different protocol reached through a different class", but nothing enforced it, so the hard-coded `Literal[2]` property and `info.version` answered 2 and 3 on the same octets. `read` now raises `ProtocolError`, degrading the payload to `Raw` through the existing `beholder` path with the reason recorded. **This affects real captures**: [RFC 3931](https://datatracker.ietf.org/doc/html/rfc3931) §4.1.2 puts L2TPv3 on port 1701 too, the port `UDP.__proto__` binds, so `Ethernet:IPv4:UDP:L2TPv2:Raw` with invented field values becomes `Ethernet:IPv4:UDP:Raw` with the octets preserved. diff --git a/docs/source/changelog/1.5.0.rst b/docs/source/changelog/1.5.0.rst index e4b1a232a..2a53de93c 100644 --- a/docs/source/changelog/1.5.0.rst +++ b/docs/source/changelog/1.5.0.rst @@ -1383,6 +1383,19 @@ Fixed MP_CAPABLE's packed output**: a 20-octet key-present option built or parsed under the old code becomes 12 octets with no key, or 20 octets with the key present, depending on which the caller meant (:issue:`567`). +* TCP's MP_CAPABLE now parses and builds all five lengths of :rfc:`8684` section 3.1 + -- 4 (SYN, no key), 12, 20, 22 (adds the Data-Level Length) and 24 (adds the + Checksum) -- where ``_read_mptcp_capable`` raised + ``ProtocolError: TCP: [OptNo 30] invalid format`` for anything but 12 and 20. In the schema ``skey`` is now present + only for ``length > 4`` and ``rkey`` for ``length > 12``, and two fields are new: + ``dl_len`` (16 bits, ``length > 20``) and ``checksum`` (2 octets, ``length > 22``). + ``_make_mptcp_capable`` takes ``dl_len`` and ``checksum`` too, where it used to + drop them silently through ``**kwargs``, and picks the length from how many of + ``skey``, ``rkey``, ``dl_len`` and ``checksum`` are not ``None``. A field given + without the one before it raises ``ProtocolError``. The data model + ``MPTCPCapable`` gains ``dl_len`` and ``checksum``, and ``skey`` becomes + ``Optional[int]``: code that constructs it directly must now pass both new fields, + and ``to_dict()`` carries two more keys (:issue:`1042`). * IPv6-Route's RPL routing data was five octets wide at the front where :rfc:`6554` section 3 gives four, with three further defects behind it. ``CmprI``, ``CmprE`` and ``Pad`` are each 4 bits sharing a 32-bit word with a 20-bit ``Reserved``, but the diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 1168a152a..fee1a7871 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -101,7 +101,7 @@ commands down instead of a figure that will be stale by the next merge: The grouping scheme was settled on :issue:`918`: **a section per top-level module, with** ``Added``/``Changed``/``Fixed`` **nested inside each** -- module granularity, not per-file and not per-subpackage. The file carries **9** module-level sections holding -164 entries, and no entry carries an inline kind label:: +165 entries, and no entry carries an inline kind label:: $ grep -cE '^\* \*\*(Added|Changed|Fixed)\*\*' docs/source/changelog/1.5.0.rst 0 diff --git a/pcapkit/protocols/data/transport/tcp.py b/pcapkit/protocols/data/transport/tcp.py index 80fdb939f..c7dab8aed 100644 --- a/pcapkit/protocols/data/transport/tcp.py +++ b/pcapkit/protocols/data/transport/tcp.py @@ -411,12 +411,16 @@ class MPTCPCapable(MPTCP): #: Flags. flags: 'MPTCPCapableFlag' #: Option sender's key. - skey: 'int' + skey: 'Optional[int]' #: Option receiver's key. rkey: 'Optional[int]' + #: Data-level length. + dl_len: 'Optional[int]' + #: Checksum. + checksum: 'Optional[bytes]' if TYPE_CHECKING: - def __init__(self, kind: 'OptionNumber', length: 'int', subtype: 'MPTCPOption', version: 'int', flags: 'MPTCPCapableFlag', skey: 'int', rkey: 'Optional[int]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long,redefined-builtin + def __init__(self, kind: 'OptionNumber', length: 'int', subtype: 'MPTCPOption', version: 'int', flags: 'MPTCPCapableFlag', skey: 'Optional[int]', rkey: 'Optional[int]', dl_len: 'Optional[int]', checksum: 'Optional[bytes]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long,redefined-builtin class MPTCPJoin(MPTCP): diff --git a/pcapkit/protocols/schema/transport/tcp.py b/pcapkit/protocols/schema/transport/tcp.py index 1e08ba8f6..cb2bf2e7e 100644 --- a/pcapkit/protocols/schema/transport/tcp.py +++ b/pcapkit/protocols/schema/transport/tcp.py @@ -844,19 +844,33 @@ class MPTCPCapable(MPTCP, code=Enum_MPTCPOption.MP_CAPABLE): 'ext': (1, 1), 'hsa': (7, 1), }) - #: Option sender's key. - skey: 'int' = UInt64Field() - #: Option receiver's key. - #: - #: :rfc:`8684` section 3.1 gives MP_CAPABLE as 12 octets without this key - #: and 20 octets with it, so the field is present only for the latter. + # NOTE: :rfc:`8684` section 3.1 figure 4 gives MP_CAPABLE five lengths -- + # 4 (SYN, header only), 12 (sender's key), 20 (both keys), 22 (both keys + # and Data-Level Length) and 24 (that plus the Checksum) -- so each field + # below is present from the length that first carries it. + #: Option sender's key (if option length > 4). + skey: 'int' = ConditionalField( + UInt64Field(), + lambda pkt: pkt['length'] > 4, + ) + #: Option receiver's key (if option length > 12). rkey: 'int' = ConditionalField( UInt64Field(), - lambda pkt: pkt['length'] == 20, + lambda pkt: pkt['length'] > 12, + ) + #: Data-level length (if option length > 20). + dl_len: 'int' = ConditionalField( + UInt16Field(), + lambda pkt: pkt['length'] > 20, + ) + #: Checksum (if option length > 22). + checksum: 'bytes' = ConditionalField( + BytesField(length=2), + lambda pkt: pkt['length'] > 22, ) if TYPE_CHECKING: - def __init__(self, kind: 'Enum_Option', length: 'int', test: 'MPTCPSubtypeCapable', flags: 'MPTCPCapableFlags', skey: 'int', rkey: 'Optional[int]') -> 'None': ... + def __init__(self, kind: 'Enum_Option', length: 'int', test: 'MPTCPSubtypeCapable', flags: 'MPTCPCapableFlags', skey: 'Optional[int]', rkey: 'Optional[int]', dl_len: 'Optional[int]', checksum: 'Optional[bytes]') -> 'None': ... class MPTCPJoin(MPTCP, code=Enum_MPTCPOption.MP_JOIN): # register as a placeholder diff --git a/pcapkit/protocols/transport/tcp.py b/pcapkit/protocols/transport/tcp.py index fdbf8e1a0..58de389bb 100644 --- a/pcapkit/protocols/transport/tcp.py +++ b/pcapkit/protocols/transport/tcp.py @@ -1500,7 +1500,7 @@ def _read_mptcp_unknown(self, schema: 'Schema_MPTCPUnknown', *, options: 'Option def _read_mptcp_capable(self, schema: 'Schema_MPTCPCapable', *, options: 'Option') -> 'Data_MPTCPCapable': # pylint: disable=unused-argument """Read Multipath Capable option. - Structure of ``MP_CAPABLE`` [:rfc:`6824`]: + Structure of ``MP_CAPABLE`` [:rfc:`8684`]: .. code-block:: text @@ -1510,13 +1510,15 @@ def _read_mptcp_capable(self, schema: 'Schema_MPTCPCapable', *, options: 'Option | Kind | Length |Subtype|Version|A|B|C|D|E|F|G|H| +---------------+---------------+-------+-------+---------------+ | Option Sender's Key (64 bits) | - | | + | (if option Length > 4) | | | +---------------------------------------------------------------+ | Option Receiver's Key (64 bits) | - | (if option Length == 20) | + | (if option Length > 12) | | | - +---------------------------------------------------------------+ + +-------------------------------+-------------------------------+ + | Data-Level Length (16 bits) | Checksum (16 bits, optional) | + +-------------------------------+-------------------------------+ Arguments: schema: parsed option schema @@ -1526,13 +1528,13 @@ def _read_mptcp_capable(self, schema: 'Schema_MPTCPCapable', *, options: 'Option Parsed option data. Raises: - ProtocolError: If length is **NOT** ``12`` or ``20``. + ProtocolError: If length is **NOT** ``4``, ``12``, ``20``, ``22`` + or ``24``. """ - # NOTE: :rfc:`8684` section 3.1 gives MP_CAPABLE as 12 octets without - # the receiver's key and 20 octets with it, hence this guard and the - # ``rkey=`` test below. - if schema.length not in (12, 20): + # NOTE: :rfc:`8684` section 3.1 defines exactly these five lengths; + # the schema's field conditions follow from them. + if schema.length not in (4, 12, 20, 22, 24): raise ProtocolError(f'{self.alias}: [OptNo {schema.kind}] invalid format') data = Data_MPTCPCapable( @@ -1545,8 +1547,10 @@ def _read_mptcp_capable(self, schema: 'Schema_MPTCPCapable', *, options: 'Option ext=bool(schema.flags['ext']), hsa=bool(schema.flags['hsa']), ), - skey=schema.skey, - rkey=schema.rkey if schema.length == 20 else None, + skey=schema.skey if schema.length > 4 else None, + rkey=schema.rkey if schema.length > 12 else None, + dl_len=schema.dl_len if schema.length > 20 else None, + checksum=schema.checksum if schema.length > 22 else None, ) return data @@ -2735,11 +2739,17 @@ def _make_mptcp_capable(self, subtype: 'Enum_MPTCPOption', opt: 'Optional[Data_M flag_req: 'bool' = False, flag_ext: 'bool' = False, flag_hsa: 'bool' = False, - skey: 'int' = 0, + skey: 'Optional[int]' = 0, rkey: 'Optional[int]' = 0, + dl_len: 'Optional[int]' = None, + checksum: 'Optional[bytes]' = None, **kwargs: 'Any') -> 'Schema_MPTCPCapable': """Make multipath TCP capable option. + Each field is present only if every field before it is, which selects + one of the five :rfc:`8684` section 3.1 lengths: 4 (``skey=None``), 12 + (``rkey=None``), 20 (``dl_len=None``), 22 (``checksum=None``) or 24. + Args: subtype: MPTCP subtype opt: option data @@ -2749,11 +2759,16 @@ def _make_mptcp_capable(self, subtype: 'Enum_MPTCPOption', opt: 'Optional[Data_M flag_hsa: use of HMAC-SHA1 flag skey: option sender's key rkey: option receiver's key + dl_len: data-level length + checksum: checksum **kwargs: arbitrary keyword arguments Returns: Constructed option schema. + Raises: + ProtocolError: If a field is given without the field before it. + """ if opt is not None: version = opt.version @@ -2762,12 +2777,19 @@ def _make_mptcp_capable(self, subtype: 'Enum_MPTCPOption', opt: 'Optional[Data_M flag_hsa = opt.flags.hsa skey = opt.skey rkey = opt.rkey + dl_len = opt.dl_len + checksum = opt.checksum + + fields = (skey, rkey, dl_len, checksum) + present = fields.index(None) if None in fields else len(fields) + if any(field is not None for field in fields[present:]): + raise ProtocolError(f'{self.alias}: [OptNo {Enum_Option.Multipath_TCP}] invalid format') return Schema_MPTCPCapable( kind=cast('Enum_Option', Enum_Option.Multipath_TCP), - # NOTE: :rfc:`8684` section 3.1 gives MP_CAPABLE as 12 octets - # without the receiver's key and 20 octets with it. - length=12 if rkey is None else 20, + # NOTE: :rfc:`8684` section 3.1 gives MP_CAPABLE as 4, 12, 20, 22 + # or 24 octets, by how many of the fields above are present. + length=(4, 12, 20, 22, 24)[present], test={ 'subtype': subtype.value, 'version': version, @@ -2779,6 +2801,8 @@ def _make_mptcp_capable(self, subtype: 'Enum_MPTCPOption', opt: 'Optional[Data_M }, skey=skey, rkey=rkey, + dl_len=dl_len, + checksum=checksum, ) def _make_mptcp_join(self, subtype: 'Enum_MPTCPOption', opt: 'Optional[Data_MPTCPJoin]' = None, **kwargs: 'Any') -> 'Schema_MPTCPJoin': diff --git a/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py b/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py index 9889a2963..61d76b829 100644 --- a/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_capable_length_unit.py @@ -51,6 +51,9 @@ ``Schema_TCP(options=..., ...)``, bypassing the makers entirely, so the *parse* direction is pinned independently of whatever the *pack* direction happens to produce. +#1042 widened the 12/20 split to all five lengths figure 4 defines -- 4, 12, 20, 22 and 24 +-- in :class:`TCPMPTCPCapableAllLengthsUnitTests`. + """ from __future__ import annotations @@ -300,5 +303,133 @@ def test_with_key_round_trip_is_twenty_octets(self) -> None: ) +#: Every MP_CAPABLE form of :rfc:`8684` section 3.1, keyed by ``Length`` (#1042). Each value +#: is ``(octets, fields)``: hand-built wire octets, and the option fields they carry. The flag +#: octet is ``0x81`` (``A`` "checksum required" and ``H`` HMAC-SHA256) except at 22, where it +#: is ``0x01`` -- the Data-Level Length without a checksum is the checksums-off form. +ALL_LENGTHS = { + 4: (bytes([0x1E, 0x04, 0x01, 0x81]), + {'skey': None, 'rkey': None, 'dl_len': None, 'checksum': None}), + 12: (bytes([0x1E, 0x0C, 0x01, 0x81]) + b'\xAA' * 8, + {'skey': 0xAAAAAAAAAAAAAAAA, 'rkey': None, 'dl_len': None, 'checksum': None}), + 20: (bytes([0x1E, 0x14, 0x01, 0x81]) + b'\xAA' * 8 + b'\xBB' * 8, + {'skey': 0xAAAAAAAAAAAAAAAA, 'rkey': 0xBBBBBBBBBBBBBBBB, 'dl_len': None, + 'checksum': None}), + 22: (bytes([0x1E, 0x16, 0x01, 0x01]) + b'\xAA' * 8 + b'\xBB' * 8 + b'\x12\x34', + {'skey': 0xAAAAAAAAAAAAAAAA, 'rkey': 0xBBBBBBBBBBBBBBBB, 'dl_len': 0x1234, + 'checksum': None}), + 24: (bytes([0x1E, 0x18, 0x01, 0x81]) + b'\xAA' * 8 + b'\xBB' * 8 + b'\x12\x34\xCD\xEF', + {'skey': 0xAAAAAAAAAAAAAAAA, 'rkey': 0xBBBBBBBBBBBBBBBB, 'dl_len': 0x1234, + 'checksum': b'\xCD\xEF'}), +} + + +class TCPMPTCPCapableAllLengthsUnitTests(unittest.TestCase): + """All five MP_CAPABLE lengths of :rfc:`8684` section 3.1 parse and round-trip (#1042). + + Before #1042, ``_read_mptcp_capable`` accepted only ``length in (12, 20)``, so the SYN + form (4) and both first-data-ACK forms (22, 24) raised ``ProtocolError: TCP: [OptNo 30] + invalid format``; the schema also read ``skey`` unconditionally and had no Data-Level + Length or Checksum field. + + """ + + def _parse(self, length: 'int') -> 'object': + from pcapkit.const.tcp.option import Option as Enum_Option + from pcapkit.protocols.transport.tcp import TCP + + octets, _ = ALL_LENGTHS[length] + # NOTE: pad with NOPs to the 4-octet boundary ``build_tcp_segment`` requires. + raw = build_tcp_segment(octets + b'\x01' * (-len(octets) % 4)) + return TCP(raw, len(raw)).info.options[Enum_Option.Multipath_TCP] + + def _assert_parse(self, length: 'int') -> None: + from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption + + _, fields = ALL_LENGTHS[length] + data = self._parse(length) + self.assertEqual(data.length, length) + self.assertEqual(data.subtype, Enum_MPTCPOption.MP_CAPABLE) + self.assertEqual(data.version, 1) + for name, value in fields.items(): + self.assertEqual(getattr(data, name), value, name) + + def _assert_round_trip(self, length: 'int') -> None: + """``make`` through the public ``TCP()`` constructor, then ``read`` the bytes back.""" + from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption + from pcapkit.const.tcp.option import Option as Enum_Option + from pcapkit.protocols.transport.tcp import TCP + + octets, fields = ALL_LENGTHS[length] + made = TCP( + options=[(Enum_Option.Multipath_TCP, { + 'subtype': Enum_MPTCPOption.MP_CAPABLE, 'version': 1, + 'flag_req': bool(octets[3] & 0x80), 'flag_hsa': bool(octets[3] & 0x01), + **fields, + })], + **TCPMPTCPCapablePublicConstructorUnitTests.TCP_BASE, # type: ignore[arg-type] + ) + packed = bytes(made) + self.assertEqual(packed[20:20 + length], octets) + + data = TCP(packed, len(packed)).info.options[Enum_Option.Multipath_TCP] + self.assertEqual(data.length, length) + for name, value in fields.items(): + self.assertEqual(getattr(data, name), value, name) + + def test_parse_length_4(self) -> None: + """SYN: only the first 4 octets, no key.""" + self._assert_parse(4) + + def test_parse_length_12(self) -> None: + """SYN/ACK: the sender's key.""" + self._assert_parse(12) + + def test_parse_length_20(self) -> None: + """ACK without data: both keys.""" + self._assert_parse(20) + + def test_parse_length_22(self) -> None: + """First data ACK: both keys and the Data-Level Length.""" + self._assert_parse(22) + + def test_parse_length_24(self) -> None: + """First data ACK: both keys, the Data-Level Length, and the Checksum.""" + self._assert_parse(24) + + def test_round_trip_length_4(self) -> None: + """``skey=None`` packs the 4-octet SYN form and reads it back.""" + self._assert_round_trip(4) + + def test_round_trip_length_12(self) -> None: + """``rkey=None`` packs the 12-octet form and reads it back.""" + self._assert_round_trip(12) + + def test_round_trip_length_20(self) -> None: + """Both keys pack the 20-octet form and read back.""" + self._assert_round_trip(20) + + def test_round_trip_length_22(self) -> None: + """``dl_len`` without ``checksum`` packs the 22-octet form and reads it back.""" + self._assert_round_trip(22) + + def test_round_trip_length_24(self) -> None: + """``dl_len`` and ``checksum`` pack the 24-octet form and read back.""" + self._assert_round_trip(24) + + def test_inconsistent_fields_are_rejected(self) -> None: + """A later field without the one before it has no RFC 8684 length, so ``make`` refuses.""" + from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as Enum_MPTCPOption + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.utilities.exceptions import ProtocolError + + tcp = TCP.__new__(TCP) + for kwargs in ({'skey': None, 'rkey': 1}, {'rkey': None, 'dl_len': 1}, + {'dl_len': None, 'checksum': b'\x00\x00'}): + with self.subTest(**{k: repr(v) for k, v in kwargs.items()}), \ + self.assertRaises(ProtocolError): + tcp._make_mptcp_capable(Enum_MPTCPOption.MP_CAPABLE, **kwargs) # pylint: disable=protected-access + + if __name__ == '__main__': unittest.main() diff --git a/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py b/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py index e9b764191..48eac11d8 100644 --- a/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py +++ b/tests/protocols/transport/test_tcp_mptcp_error_message_unit.py @@ -231,8 +231,8 @@ def test_no_doubled_separator_survives_anywhere_in_the_module(self) -> None: # four lines are deleted outright -- so when you legitimately change the # number of such messages, update the number here rather than loosening # the assertion. The one above it, on the doubled form, is the one that - # must stay at 0 forever. - self.assertEqual(source.count("f'{self.alias}: [OptNo"), 32) + # must stay at 0 forever. 33 since #1042 added the MP_CAPABLE maker's. + self.assertEqual(source.count("f'{self.alias}: [OptNo"), 33) if __name__ == '__main__': diff --git a/tests/protocols/transport/test_tcp_udp_unit.py b/tests/protocols/transport/test_tcp_udp_unit.py index 7791caa88..99771e1b0 100644 --- a/tests/protocols/transport/test_tcp_udp_unit.py +++ b/tests/protocols/transport/test_tcp_udp_unit.py @@ -582,6 +582,8 @@ def test_tcp_option_constructors_cover_data_model_and_mapping_paths(self) -> Non flags=tcp_data.MPTCPCapableFlag(req=True, ext=False, hsa=True), skey=1, rkey=2, + dl_len=None, + checksum=None, ) self.assertEqual(proto._make_mode_mp(Option.Multipath_TCP, capable).rkey, 2) self.assertEqual(proto._make_mptcp_unknown(