diff --git a/pcapkit/protocols/internet/mh.py b/pcapkit/protocols/internet/mh.py index 03038b3e6f..787c1a18f7 100644 --- a/pcapkit/protocols/internet/mh.py +++ b/pcapkit/protocols/internet/mh.py @@ -9470,7 +9470,7 @@ def _make_opt_cr(self, type: 'Enum_Option', option: 'Optional[Data_ContextReques ) def _make_opt_lmaa(self, type: 'Enum_Option', option: 'Optional[Data_LMAAddressOption]' = None, *, - code: 'LMAAddressCode | StdlibEnum | AenumEnum | str | int' = LMAAddressCode.IPv6_LMAA, + code: 'Optional[LMAAddressCode | StdlibEnum | AenumEnum | str | int]' = None, code_default: 'Optional[int]' = None, code_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long code_reversed: 'bool' = False, @@ -9483,7 +9483,8 @@ def _make_opt_lmaa(self, type: 'Enum_Option', option: 'Optional[Data_LMAAddressO type: Option type. option: Option data model. code: Option code, see - :class:`~pcapkit.protocols.internet.mh.LMAAddressCode`. + :class:`~pcapkit.protocols.internet.mh.LMAAddressCode`. If not + given, the code of the address family of ``address``. code_default: Default option code. code_namespace: Option code namespace. code_reversed: Reverse option code namespace. @@ -9495,17 +9496,27 @@ def _make_opt_lmaa(self, type: 'Enum_Option', option: 'Optional[Data_LMAAddressO Returns: Constructed option schema. + Raises: + ProtocolError: If an explicit ``code`` is not the code + :rfc:`5949#section-6.2.2` gives the address family of + ``address`` (:issue:`2280`). + Note: The option length is derived from the address family rather than from - ``code``, so that the two cannot be emitted disagreeing. + ``code``, so that the two cannot be emitted disagreeing. A data model + passed as ``option`` keeps its code as captured, even where it + disagrees with the family, so that the capture rebuilds byte for byte. """ if option is not None: reserved = getattr(option, 'reserved', b'\x00') - code_val = int(option.code) + code_val = int(option.code) # type: Optional[int] address = option.address + elif code is None: + code_val = None else: - code_val = self._make_index(code, code_default, namespace=code_namespace, + code_val = self._make_index(code, code_default, + namespace=code_namespace if code_namespace is not None else LMAAddressCode, reversed=code_reversed, pack=False) if isinstance(address, bytes) and len(address) not in (4, 16): @@ -9522,6 +9533,17 @@ def _make_opt_lmaa(self, type: 'Enum_Option', option: 'Optional[Data_LMAAddressO addr = parse_ip_address( address, f'{self.alias}: [OptNo {type}] invalid address') + # NOTE: RFC 5949 ties each code to one address family, so a code the + # caller did not choose is the family's own, and one they did choose has + # to agree with it (#2280). A data model passed as ``option`` skips the + # check, so that a captured mismatch still rebuilds byte for byte. + family_code = LMAAddressCode.IPv4_LMAA if addr.version == 4 else LMAAddressCode.IPv6_LMAA + if code_val is None: + code_val = int(family_code) + elif option is None and code_val != family_code: + raise ProtocolError(f'{self.alias}: [OptNo {type}] invalid code: ' + f'code does not match address family: {code_val}') + return Schema_LMAAddressOption( type=type, length=6 if addr.version == 4 else 18, diff --git a/tests/protocols/internet/test_mh_unit.py b/tests/protocols/internet/test_mh_unit.py index 36b1601c61..47ed4a2d3f 100644 --- a/tests/protocols/internet/test_mh_unit.py +++ b/tests/protocols/internet/test_mh_unit.py @@ -3067,6 +3067,66 @@ def test_mh_lmaa_rejects_a_bytes_address_of_the_wrong_width(self) -> None: self.assertEqual(schema.length, length) self.assertEqual(schema.address, expected) + def test_mh_lmaa_code_follows_the_address_family(self) -> None: + """The LMAA code is the one :rfc:`5949#section-6.2.2` gives the family. See #2280. + + The maker derives the length from the address, but the code defaulted + to ``IPv6_LMAA`` regardless, so ``address='10.0.0.1'`` packed + ``290601000a000001``: an IPv4 length with the IPv6 code. + """ + from pcapkit.const.mh.option import Option + from pcapkit.protocols.internet.mh import MH, LMAAddressCode + from pcapkit.protocols.schema.internet.mh import LMAAddressOption + from pcapkit.utilities.exceptions import BaseError, ProtocolError + + proto = object.__new__(MH) + lmaa = Option.Local_Mobility_Anchor_Address_Option + + for address, octets in ( + ('10.0.0.1', '290602000a000001'), + ('2001:db8::1', '291201' '00' '20010db8' + '00' * 10 + '0001'), + ): + with self.subTest(address=address, code='derived'): + schema = proto._make_opt_lmaa(lmaa, address=address) # type: ignore[arg-type] + self.assertEqual(schema.pack().hex(), octets) + with self.subTest(address=address, code='explicit, matching'): + code = LMAAddressCode.IPv4_LMAA if '.' in address else LMAAddressCode.IPv6_LMAA + schema = proto._make_opt_lmaa(lmaa, address=address, code=code) # type: ignore[arg-type] + self.assertEqual(schema.pack().hex(), octets) + with self.subTest(address=address, code='explicit, matching, by name'): + # #2280: a str code name has to resolve against LMAAddressCode even + # when code_namespace is not given, as every sibling _make_opt_* + # does -- _make_index's str resolution needs an enum default. + name = 'IPv4_LMAA' if '.' in address else 'IPv6_LMAA' + schema = proto._make_opt_lmaa(lmaa, address=address, code=name) # type: ignore[arg-type] + self.assertEqual(schema.pack().hex(), octets) + + for address, code in (('10.0.0.1', LMAAddressCode.IPv6_LMAA), + ('2001:db8::1', LMAAddressCode.IPv4_LMAA), + ('10.0.0.1', LMAAddressCode.Reserved)): + with self.subTest(address=address, code=code): + with self.assertRaises(ProtocolError) as context: + proto._make_opt_lmaa(lmaa, address=address, code=code) # type: ignore[arg-type] + self.assertIsInstance(context.exception, BaseError) + self.assertIn(f'invalid code: code does not match address family: {int(code)}', + str(context.exception)) + + for address, name in (('10.0.0.1', 'IPv6_LMAA'), ('2001:db8::1', 'IPv4_LMAA')): + with self.subTest(address=address, code=name): + with self.assertRaises(ProtocolError) as context: + proto._make_opt_lmaa(lmaa, address=address, code=name) # type: ignore[arg-type] + self.assertIsInstance(context.exception, BaseError) + self.assertIn('invalid code: code does not match address family:', + str(context.exception)) + + # a captured mismatch is data, not a request, so it still rebuilds as read + captured = bytes.fromhex('290601000a000001') + data = proto._read_opt_lmaa( # type: ignore[arg-type] + LMAAddressOption.unpack(captured), options=None) + self.assertEqual(data.code, LMAAddressCode.IPv6_LMAA) + rebuilt = proto._make_opt_lmaa(lmaa, data) # type: ignore[arg-type] + self.assertEqual(rebuilt.pack(), captured) + def test_mh_target_careof_address_suboption_round_trips_both_families(self) -> None: """The sub-option length is derived from the family, so both widths need pinning.