diff --git a/pcapkit/const/tcp/mp_tcp_option.py b/pcapkit/const/tcp/mp_tcp_option.py index fc5621d0f4..f9653d0c51 100644 --- a/pcapkit/const/tcp/mp_tcp_option.py +++ b/pcapkit/const/tcp/mp_tcp_option.py @@ -24,7 +24,7 @@ class MPTCPOption(EnumRegistry, IntEnum): - """[MPTCPOption] Multipath TCP options [:rfc:`6824`]""" + """[MPTCPOption] Multipath TCP options [:rfc:`8684`]""" #: Multipath Capable [:rfc:`8684#section-3.1`] MP_CAPABLE = 0x0 diff --git a/pcapkit/protocols/transport/tcp.py b/pcapkit/protocols/transport/tcp.py index 99b71098b2..51bf127972 100644 --- a/pcapkit/protocols/transport/tcp.py +++ b/pcapkit/protocols/transport/tcp.py @@ -398,7 +398,7 @@ class TCP(Transport[Data_TCP, Schema_TCP], Enum_Option.Quick_Start_Response: 'qs', # [RFC 4782] Quick-Start Response Enum_Option.User_Timeout_Option: 'timeout', # [RFC 5482] User Timeout Option Enum_Option.TCP_Authentication_Option: 'ao', # [RFC 5925] TCP Authentication Option - Enum_Option.Multipath_TCP: 'mp', # [RFC 6824] Multipath TCP + Enum_Option.Multipath_TCP: 'mp', # [RFC 8684] Multipath TCP Enum_Option.TCP_Fast_Open_Cookie: 'fastopen', # [RFC 7413] Fast Open }, ) # type: DefaultDict[int, str | tuple[OptionParser, OptionConstructor]] @@ -1530,7 +1530,7 @@ def _read_mode_ao(self, schema: 'Schema_Authentication', *, options: 'Option') - def _read_mode_mp(self, schema: 'Schema_MPTCP', *, options: 'Option') -> 'Data_MPTCP': # pylint: disable=unused-argument """Read Multipath TCP option. - Structure of ``MP-TCP`` [:rfc:`6824`]: + Structure of ``MP-TCP`` [:rfc:`8684`]: .. code-block:: text diff --git a/pcapkit/vendor/tcp/mp_tcp_option.py b/pcapkit/vendor/tcp/mp_tcp_option.py index 4198d6afbb..e2d68739e5 100644 --- a/pcapkit/vendor/tcp/mp_tcp_option.py +++ b/pcapkit/vendor/tcp/mp_tcp_option.py @@ -19,7 +19,7 @@ class MPTCPOption(Vendor): - """Multipath TCP options [:rfc:`6824`]""" + """Multipath TCP options [:rfc:`8684`]""" #: Value limit checker. FLAG = 'isinstance(value, int) and 0 <= value <= 255' diff --git a/tests/const/test_const_mp_tcp_option_rfc_2330_unit.py b/tests/const/test_const_mp_tcp_option_rfc_2330_unit.py new file mode 100644 index 0000000000..1fabd861d2 --- /dev/null +++ b/tests/const/test_const_mp_tcp_option_rfc_2330_unit.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""#2330 -- the MPTCP Option Subtypes table cites the RFC that defines it. + +:class:`~pcapkit.const.tcp.mp_tcp_option.MPTCPOption` is generated from IANA's +"MPTCP Option Subtypes" registry, whose own reference is +````: RFC 8684 specifies MPTCP v1 and +obsoletes the experimental v0 of RFC 6824, which the registry's note mentions +only to point at for the v0 subtypes. Every assigned record in the registry +carries an ``rfc8684`` spec -- the ``0x9-0xe`` *Unassigned* range carries an +empty ```` and renders no member at all -- so every member comment the +crawler renders cites RFC 8684, with a ``#section-...`` anchor wherever the +record's spec names a section and without one for ``Reserved_for_Private_Use`` +(``0xf``), whose spec is a bare ````. The +option kind that carries these subtypes cites RFC 8684 too -- +:attr:`Option.Multipath_TCP `, +kind 30, which is what IANA's "TCP Option Kind Numbers" registry gives for it. +The class docstring, however, cited RFC 6824, in the crawler's template +(:file:`pcapkit/vendor/tcp/mp_tcp_option.py`) and in the generated module +alike, naming an obsoleted document as the authority for a table none of whose +entries come from it. + +The assertions are the invariants that defect broke, rather than the one string +that was wrong: + +* the class docstring names the registry's own reference, and names nothing + else, so the table's attribution cannot sit on a document its entries do not + come from + (:meth:`MPTCPOptionCitationTests.test_the_class_docstring_cites_the_registrys_rfc`); +* no citation anywhere in the generated table reaches back to the obsoleted v0 + document, which is what a partial regeneration would reintroduce + (:meth:`MPTCPOptionCitationTests.test_the_table_cites_no_obsoleted_rfc`). + This is deliberately not a pin on *every* role equalling RFC 8684: RFC 8684 + section 7.2 puts future assignments to this registry under Standards Action, + "a reference to its specification" each, so a legitimate future subtype would + add a citation to some other RFC; +* the crawler's template and the module it generates carry that citation + identically, since the generator rewrites the module from the template and a + fix to one alone would be undone by the next Vendor Update + (:meth:`MPTCPOptionCitationTests.test_the_template_and_the_module_agree`); +* the parser's subtype-agnostic citations name the registry's RFC as well + (:meth:`MPTCPOptionCitationTests.test_the_option_kind_map_cites_the_registrys_rfc` + and + :meth:`MPTCPOptionCitationTests.test_the_generic_mptcp_figure_cites_the_registrys_rfc`), + for the reason the next paragraph gives. + +:mod:`pcapkit.protocols.transport.tcp` carried the same defect, corrected +alongside the table: the option-kind map's comment against +``Option.Multipath_TCP``, which attributes the kind rather than any subtype's +layout, and the ``Structure of MP-TCP`` figure in ``TCP._read_mode_mp``, which +draws the subtype-agnostic option header that RFC 8684 figure 3 and RFC 6824 +figure 3 draw identically. Neither states anything v0-specific, so neither had +reason to name the obsoleted document. The RFC 6824 citations that *remain* in +that module and its schemas are deliberate and out of scope: those document the +legacy v0 layouts the parsers still accept -- the ADD_ADDR ``IPVer`` nibble, +which RFC 8684 redraws as reserved bits and ``E``, and the 4-octet MP_PRIO +``AddrID`` form, which RFC 8684 section 5 removed -- which is a claim about the +wire rather than about this registry. The assertions above therefore read the +map entry and the docstring each name, never the whole module. + +Every assertion here fails on the parent commit except +:meth:`MPTCPOptionCitationTests.test_the_template_and_the_module_agree`, which +passes there: the parent cites RFC 6824 in the template and in the module +consistently, so the agreement check catches only a fix applied to one side of +the pair. + +The crawler is read as source rather than imported, so this runs on the legs +where the ``vendor`` extra is absent: that extra is installed by the +``project-tests`` and ``gate`` jobs, and by the ``test`` and ``integration`` +jobs only on their latest-interpreter cell. + +""" +from __future__ import annotations + +import ast +import importlib.util +import pathlib +import re +import unittest + +from tests._support import reimport_once_per_class + +#: Test tier (#1958): a citation in committed source and in a docstring, which no +#: interpreter difference can reach. +__tier__ = 'LATEST' + +#: Repository root, i.e. the grandparent of the directory holding this file. +ROOT = pathlib.Path(__file__).resolve().parents[2] + +#: The generated table. +CONST = ROOT / 'pcapkit' / 'const' / 'tcp' / 'mp_tcp_option.py' + +#: The crawler whose template renders it. +VENDOR = ROOT / 'pcapkit' / 'vendor' / 'tcp' / 'mp_tcp_option.py' + +#: The parser that dispatches on these subtypes. +TRANSPORT = ROOT / 'pcapkit' / 'protocols' / 'transport' / 'tcp.py' + +#: The RFC the registry gives as its reference: MPTCP v1, which obsoletes RFC 6824's v0. +REGISTRY_RFC = '8684' + +#: The obsoleted document the registry's note points at for the v0 subtypes only. +OBSOLETED_RFC = '6824' + +#: An :rst:role:`rfc` role's number, with or without an anchor fragment after it. +RFC_ROLE = re.compile(r':rfc:`(?P\d+)(?:#[^`]*)?`') + +#: A plain comment's bracketed attribution, as the option-kind map spells it. +RFC_BRACKET = re.compile(r'\[RFC (?P\d+)\]') + +#: The option-kind map's entry for the kind that carries the MPTCP subtypes. +KIND_ENTRY = 'Enum_Option.Multipath_TCP:' + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + + +def _class_docstring(path: 'pathlib.Path', name: 'str') -> 'str': + """The docstring of class ``name`` in ``path``, read without importing it. + + Args: + path: Module to parse. + name: Name of the class whose docstring is wanted. + + Returns: + The docstring, exactly as the source spells it. + + """ + tree = ast.parse(path.read_text(encoding='utf-8')) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == name: + doc = ast.get_docstring(node, clean=False) + if doc is None: + raise AssertionError(f'{name} in {path} carries no docstring') + return doc + raise AssertionError(f'{path} defines no class {name}') + + +def _method_docstring(path: 'pathlib.Path', cls: 'str', name: 'str') -> 'str': + """The docstring of ``cls.name`` in ``path``, read without importing it. + + Args: + path: Module to parse. + cls: Name of the class holding the method. + name: Name of the method whose docstring is wanted. + + Returns: + The docstring, exactly as the source spells it. + + """ + tree = ast.parse(path.read_text(encoding='utf-8')) + for node in ast.walk(tree): + if not (isinstance(node, ast.ClassDef) and node.name == cls): + continue + for child in node.body: + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == name: + doc = ast.get_docstring(child, clean=False) + if doc is None: + raise AssertionError(f'{cls}.{name} in {path} carries no docstring') + return doc + raise AssertionError(f'{cls} in {path} defines no method {name}') + raise AssertionError(f'{path} defines no class {cls}') + + +class MPTCPOptionCitationTests(unittest.TestCase): + """The class, its members and its template name one RFC: the registry's.""" + + def setUp(self) -> 'None': + reimport_once_per_class(self) + + def test_the_table_cites_no_obsoleted_rfc(self) -> 'None': + # Nothing in the generated table may reach back to the v0 document the + # registry's note keeps only as a pointer. Deliberately not a pin on + # every role equalling the registry's RFC: RFC 8684 section 7.2 admits + # future assignments here under Standards Action, each with its own + # specification reference, so a new subtype may legitimately cite a + # document that does not exist yet. + source = CONST.read_text(encoding='utf-8') + self.assertNotIn(OBSOLETED_RFC, set(RFC_ROLE.findall(source))) + + def test_the_class_docstring_cites_the_registrys_rfc(self) -> 'None': + # The class docstring is the table's attribution, so it names the + # registry's own reference and nothing besides -- a second citation + # there would be a second answer to "what defines this table". + doc = _class_docstring(CONST, 'MPTCPOption') + self.assertEqual(set(RFC_ROLE.findall(doc)), {REGISTRY_RFC}) + + def test_the_template_and_the_module_agree(self) -> 'None': + # Vendor.__init__ reads the crawler class's own docstring as DOCS and + # renders it as "[{NAME}] {DOCS}", so the two are one string in two + # places: correcting either alone lets the generator put the other back. + template = _class_docstring(VENDOR, 'MPTCPOption') + self.assertEqual(_class_docstring(CONST, 'MPTCPOption'), + f'[MPTCPOption] {template}') + + def test_the_option_kind_map_cites_the_registrys_rfc(self) -> 'None': + # The map's comment attributes the option kind, not any one subtype's + # wire layout, so it names the document IANA gives for kind 30. The + # v0-specific citations elsewhere in this module are left alone, which + # is why this reads the one entry rather than the whole file. + lines = [line for line in TRANSPORT.read_text(encoding='utf-8').splitlines() + if KIND_ENTRY in line] + self.assertEqual(len(lines), 1, f'{TRANSPORT} must map {KIND_ENTRY} exactly once') + self.assertEqual(RFC_BRACKET.findall(lines[0]), [REGISTRY_RFC]) + + def test_the_generic_mptcp_figure_cites_the_registrys_rfc(self) -> 'None': + # ``_read_mode_mp`` draws the subtype-agnostic option header, which RFC + # 8684 figure 3 and RFC 6824 figure 3 draw identically -- so the figure + # carries no v0-specific claim and cites the current document. + doc = _method_docstring(TRANSPORT, 'TCP', '_read_mode_mp') + self.assertEqual(set(RFC_ROLE.findall(doc)), {REGISTRY_RFC}) + + @unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') + def test_the_imported_enums_docstring_carries_the_citation(self) -> 'None': + from pcapkit.const.tcp.mp_tcp_option import MPTCPOption + + self.assertEqual(MPTCPOption.__doc__, + f'[MPTCPOption] Multipath TCP options [:rfc:`{REGISTRY_RFC}`]') + + +if __name__ == '__main__': + unittest.main()