diff --git a/pcapkit/protocols/application/ospf.py b/pcapkit/protocols/application/ospf.py index 58c90e6f7f..b5bff2ec31 100644 --- a/pcapkit/protocols/application/ospf.py +++ b/pcapkit/protocols/application/ospf.py @@ -214,7 +214,7 @@ def make(self, auth_type_default: 'Optional[int]' = None, auth_type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long auth_type_reversed: 'bool' = False, - auth_data: 'bytes | Schema_CryptographicAuthentication | Data_CryptographicAuthentication' = b'\x00\x00\x00\x00\x00\x00\x00\x00', + auth_data: 'bytes | Schema_CryptographicAuthentication | Data_CryptographicAuthentication | dict[str, Any]' = b'\x00\x00\x00\x00\x00\x00\x00\x00', # pylint: disable=line-too-long payload: 'bytes | ProtocolBase | Schema' = b'', trailer: 'bytes' = b'', **kwargs: 'Any') -> 'Schema_OSPF': @@ -237,7 +237,9 @@ def make(self, auth_type_default: Default value for ``auth_type`` if not specified. auth_type_namespace: Namespace for ``auth_type``. auth_type_reversed: Reverse namespace for ``auth_type``. - auth_data: Authentication data. + auth_data: Authentication data: 8 octets, or, under cryptographic + authentication, also a schema, a data model, or the + :obj:`dict` form of a data model. payload: Payload data. trailer: Octets after the packet, outside the Packet Length, such as the message digest appended under cryptographic @@ -414,12 +416,13 @@ def _read_encrypt_auth(self, schema: 'Schema_CryptographicAuthentication') -> 'D return auth def _make_encrypt_auth(self, - auth_data: 'bytes | Schema_CryptographicAuthentication | Data_CryptographicAuthentication' # pylint: disable=line-too-long + auth_data: 'bytes | Schema_CryptographicAuthentication | Data_CryptographicAuthentication | dict[str, Any]' # pylint: disable=line-too-long ) -> 'Schema_CryptographicAuthentication': """Make Authentication field when Cryptographic Authentication is employed. Args: - auth_data: Authentication data. + auth_data: Authentication data, as raw octets, a schema, a data + model, or the :obj:`dict` form of a data model. Returns: Authentication schema. @@ -442,4 +445,16 @@ def _make_encrypt_auth(self, len=auth_data.len, seq=auth_data.seq, ) + # NOTE: The ``to_dict()`` form of the info above, which ``from_data`` + # passes through as is (:issue:`1464`). + if isinstance(auth_data, dict): + try: + return Schema_CryptographicAuthentication( + reserved=auth_data.get('reserved', b'\x00\x00'), + key_id=auth_data['key_id'], + len=auth_data['len'], + seq=auth_data['seq'], + ) + except KeyError as exc: + raise ProtocolError(f'OSPF: missing {exc} in auth_data: {auth_data!r}') from None raise ProtocolError(f'OSPF: invalid type for auth_data: {auth_data!r}') diff --git a/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py b/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py new file mode 100644 index 0000000000..e1da893f8c --- /dev/null +++ b/tests/protocols/application/test_ospf_auth_dict_roundtrip_unit.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""OSPF rebuilds byte for byte from the dict form of its info, :issue:`1464`. + +Under cryptographic authentication (AuType 2) the ``auth`` field is a +:class:`~pcapkit.protocols.data.application.ospf.CryptographicAuthentication`, +which ``to_dict()`` flattens into a plain :obj:`dict`. ``from_dict`` cannot +restore it, since the field is declared ``bytes | CryptographicAuthentication``, +so :meth:`OSPF._make_encrypt_auth +` has to accept +that :obj:`dict`; it used to raise ``invalid type for auth_data``. + +Every case builds its own octets in memory and reads no capture. + +""" +from __future__ import annotations + +import importlib.util +import struct +import unittest + +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) + + +def packet(autype: int, auth: bytes, trailer: bytes = b'') -> bytes: + """A header-only OSPFv2 Hello with ``auth`` and, past its Packet Length, ``trailer``.""" + return struct.pack('!BBHIIHH', 2, 1, 24, 0x01010101, 0, 0, autype) + auth + trailer + + +def crypto(reserved: int = 0, key_id: int = 1, auth_len: int = 16, seq: int = 7) -> bytes: + """The 8-octet Authentication field of AuType 2.""" + return struct.pack('!HBBI', reserved, key_id, auth_len, seq) + + +#: One packet per AuType: none, simple password, cryptographic, and unassigned. +PACKETS = { + 'autype-0': packet(0, bytes(8)), + 'autype-0-nonzero-auth': packet(0, bytes(range(1, 9))), + 'autype-1': packet(1, b'secret!!'), + 'autype-2': packet(2, crypto(), b'\xaa' * 16), + 'autype-2-nonzero-reserved': packet(2, crypto(reserved=0xbeef, key_id=255, seq=0xffffffff), b'\xaa' * 16), + 'autype-2-no-digest': packet(2, crypto(auth_len=0)), + 'autype-7-unassigned': packet(7, b'ABCDEFGH'), + 'autype-65535-unassigned': packet(0xffff, b'\xff' * 8), +} + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class TestOSPFAuthDictRoundTrip(unittest.TestCase): + """Pin the dict-form rebuild of the Authentication field.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_from_data_rebuilds_dict_form_byte_for_byte(self) -> None: + from pcapkit.protocols.application.ospf import OSPF + + for name, raw in PACKETS.items(): + with self.subTest(packet=name): + parsed = OSPF(raw, len(raw)) + self.assertEqual(OSPF.from_data(parsed.info.to_dict()).data, raw) + + def test_from_data_rebuilds_object_form_byte_for_byte(self) -> None: + from pcapkit.protocols.application.ospf import OSPF + + for name, raw in PACKETS.items(): + with self.subTest(packet=name): + parsed = OSPF(raw, len(raw)) + self.assertEqual(OSPF.from_data(parsed.info).data, raw) + + def test_to_dict_flattens_cryptographic_authentication(self) -> None: + # The premise of the fix: the dict form really does carry a plain dict. + from pcapkit.protocols.application.ospf import OSPF + + raw = PACKETS['autype-2-nonzero-reserved'] + auth = OSPF(raw, len(raw)).info.to_dict()['auth'] + self.assertEqual(auth, {'reserved': b'\xbe\xef', 'key_id': 255, 'len': 16, 'seq': 0xffffffff}) + + def test_make_accepts_dict_auth_data(self) -> None: + from pcapkit.protocols.application.ospf import OSPF + + proto = OSPF(router_id='1.1.1.1', auth_type=2, + auth_data={'reserved': b'\xbe\xef', 'key_id': 255, 'len': 16, 'seq': 0xffffffff}) + self.assertEqual(proto.data, packet(2, crypto(reserved=0xbeef, key_id=255, seq=0xffffffff))) + + def test_make_dict_auth_data_defaults_reserved_to_zero(self) -> None: + from pcapkit.protocols.application.ospf import OSPF + + proto = OSPF(router_id='1.1.1.1', auth_type=2, auth_data={'key_id': 1, 'len': 16, 'seq': 7}) + self.assertEqual(proto.data, packet(2, crypto())) + + def test_make_dict_auth_data_missing_key_is_refused(self) -> None: + from pcapkit.protocols.application.ospf import OSPF + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaisesRegex(ProtocolError, r"^OSPF: missing 'seq' in auth_data: "): + OSPF(auth_type=2, auth_data={'key_id': 1, 'len': 16}) + + +if __name__ == '__main__': + unittest.main()