diff --git a/tests/protocols/_edge_roundtrip.py b/tests/protocols/_edge_roundtrip.py new file mode 100644 index 0000000000..70d279284f --- /dev/null +++ b/tests/protocols/_edge_roundtrip.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +"""Shared harness for the protocol edge-case round-trip modules. C.f. #1202. + +The owner's rule is that parse -> rebuild and make -> parse are byte-exact +everywhere. The ``test_*_edge_roundtrip_unit`` modules beside this one hold +hand-built edge-case packets -- minimum and maximum lengths, reserved bits set, +zero-length values, padding variants, unassigned codes -- and this module runs +them through three checks: + +* a **parse case** must parse, its ``data`` must be the input, and + ``from_data(info).data`` must be the input too; +* a **cut** of a parse case (the same octets one short, and half as long) must + either be rejected with an in-library exception or meet the same two + equalities -- a truncated packet may be refused, but not crash and not + rebuild differently; +* a **make case** must construct, its octets must parse back to themselves, and + ``from_data`` over the parsed ``info`` must construct the same octets again. + +A case that does not close goes in its module's ``KNOWN_FAILURES``, grouped by +root cause, and the table is asserted in both directions so that a fixed defect +turns the module red. Malformed inputs a protocol is entitled to refuse go in +``REJECTED`` instead, with the exception they raise. + +Nothing here imports :mod:`pcapkit` at module level: classes are named by +dotted path and resolved inside each test, after +:func:`~tests._support.reimport_once_per_class`, for the reason +:mod:`tests.protocols.link.test_ethernet_mac_roundtrip_unit` gives. + +""" + +from __future__ import annotations + +import fnmatch +import importlib +import importlib.util +import unittest +import warnings +from typing import TYPE_CHECKING, NamedTuple + +from tests._support import reimport_once_per_class, time_limit + +if TYPE_CHECKING: + from typing import Any, Optional + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + +#: Whole seconds one case may take; a working case takes milliseconds. +CASE_TIMEOUT = 30 + +#: Statuses a check can report. ``REJECTED`` is an in-library exception raised +#: while parsing; it is a pass for a cut and for a ``REJECTED`` entry only. +#: ``PADDED`` is a ``MISMATCH`` whose rebuild is the input followed by zero +#: octets, the signature of a short read zero-filled on parse. +STATUSES = ('OK', 'REJECTED', 'PARSE', 'SELF', 'REBUILD', 'MISMATCH', 'PADDED', + 'MAKE', 'REPARSE', 'REMAKE', 'TIMEOUT') + +#: The defect behind every ``PADDED`` cut, shared by all the family modules. +ZERO_FILLED_SHORT_READ = ( + 'A header cut short inside a fixed-width field parses, because ' + '``FieldBase.unpack`` zero-fills a short read (pcapkit/corekit/fields/field.py:586), ' + 'and nothing records the shortfall, so ``from_data`` rebuilds the field at full width.' +) + +#: The same read, where it is an IPv6 extension header rebuilding itself. +ZERO_FILLED_EXTENSION_HEADER = ( + 'A truncated IPv6 extension header rebuilds from its own ``info`` padded to its ' + 'declared length: the IPv6 extension-header instance of #1458.' +) + + +class Case(NamedTuple): + """One packet to parse and rebuild.""" + + #: Unique label, ``family/what``. + label: 'str' + #: Protocol class as ``module:Class``. + cls: 'str' + #: Wire octets. + raw: 'bytes' + #: Extra parse keywords, e.g. ``{'extension': True}``. + kwargs: 'dict[str, Any]' = {} + #: Octets the layer itself owns, when fewer than ``raw``: an extension + #: header parsed with ``extension=True`` covers only its own header in + #: ``data`` (#1446). :data:`None` means all of ``raw``. + owned: 'Optional[int]' = None + + +class MakeCase(NamedTuple): + """One ``make`` call to construct, parse and construct again.""" + + #: Unique label, ``family/make/what``. + label: 'str' + #: Protocol class as ``module:Class``. + cls: 'str' + #: ``make`` keywords. + kwargs: 'dict[str, Any]' + #: Extra parse keywords for the re-parse. + parse_kwargs: 'dict[str, Any]' = {} + + +class Outcome(NamedTuple): + """What one check found.""" + + status: 'str' + detail: 'str' + + +class Gap(NamedTuple): + """One root cause, and every case it stops.""" + + #: GitHub issue tracking the defect, or :data:`None` if not filed yet. + issue: 'Optional[int]' + #: The defect, with the ``file:line`` that causes it. + defect: 'str' + #: Expected status of every case below, or the statuses one root cause + #: shows as, of which each case must report one. + status: 'str | tuple[str, ...]' + #: Substring every failure detail must contain, or a tuple of which each + #: detail must contain at least one -- one per symptom or per case where + #: the details differ -- or ``''`` where the status is itself the signature + #: (``PADDED``). + fragment: 'str | tuple[str, ...]' + #: Labels of the cases this defect stops, as :mod:`fnmatch` patterns. Each + #: pattern must match at least one case, and every case it matches must + #: fail as recorded, so a pattern is only as wide as the defect. + cases: 'tuple[str, ...]' + + +class Reject(NamedTuple): + """A malformed input the protocol is entitled to refuse.""" + + #: Name of the in-library exception class raised. + exc: 'str' + #: Substring the message must contain. + fragment: 'str' + + +def _statuses(gap: 'Gap') -> 'tuple[str, ...]': + return (gap.status,) if isinstance(gap.status, str) else gap.status + + +def resolve(path: 'str') -> 'type': + """Import ``module:Class`` from the current :mod:`pcapkit` import.""" + module, _, name = path.partition(':') + return getattr(importlib.import_module(module), name) + + +def _describe(exc: 'BaseException') -> 'str': + return f'{type(exc).__name__}: {exc}' + + +def _in_library(exc: 'BaseException') -> 'bool': + base = importlib.import_module('pcapkit.utilities.exceptions').BaseError + return isinstance(exc, base) + + +def _diff(got: 'bytes', want: 'bytes') -> 'str': + return f'{len(got)} octets {got.hex()} != {len(want)} octets {want.hex()}' + + +def run_parse(case: 'Case') -> 'Outcome': + """Parse ``case.raw``, then rebuild it from ``info``.""" + cls = resolve(case.cls) + raw = case.raw if case.owned is None else case.raw[:case.owned] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + try: + proto = cls(case.raw, len(case.raw), **case.kwargs) + except Exception as exc: # pylint: disable=broad-except + return Outcome('REJECTED' if _in_library(exc) else 'PARSE', _describe(exc)) + if proto.data != raw: + return Outcome('SELF', _diff(proto.data, raw)) + try: + rebuilt = type(proto).from_data(proto.info).data + except Exception as exc: # pylint: disable=broad-except + return Outcome('REBUILD', _describe(exc)) + if rebuilt != raw: + if len(rebuilt) > len(raw) and rebuilt == raw.ljust(len(rebuilt), b'\x00'): + return Outcome('PADDED', _diff(rebuilt, raw)) + return Outcome('MISMATCH', _diff(rebuilt, raw)) + return Outcome('OK', '') + + +def run_make(case: 'MakeCase') -> 'Outcome': + """Construct, parse the octets back, and construct from the parsed ``info``.""" + cls = resolve(case.cls) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + try: + made = cls(**case.kwargs).data + except Exception as exc: # pylint: disable=broad-except + return Outcome('MAKE', _describe(exc)) + try: + parsed = cls(made, len(made), **case.parse_kwargs) + except Exception as exc: # pylint: disable=broad-except + return Outcome('REPARSE', _describe(exc)) + if parsed.data != made: + return Outcome('REPARSE', _diff(parsed.data, made)) + try: + again = type(parsed).from_data(parsed.info).data + except Exception as exc: # pylint: disable=broad-except + return Outcome('REMAKE', _describe(exc)) + if again != made: + return Outcome('REMAKE', _diff(again, made)) + return Outcome('OK', '') + + +def cuts(cases: 'tuple[Case, ...]') -> 'tuple[Case, ...]': + """Each case one octet short, and half as long; at most two per case.""" + out = [] # type: list[Case] + for case in cases: + size = len(case.raw) + for keep in sorted({size - 1, size // 2}): + if keep > 0: + owned = None if case.owned is None else min(case.owned, keep) + out.append(case._replace(label=f'{case.label}/cut{keep}', raw=case.raw[:keep], owned=owned)) + return tuple(out) + + +class EdgeRoundTripBase(unittest.TestCase): + """The assertions, shared by every family module. + + A subclass sets the four tables; this class is not collected itself + because it has no tables (:attr:`CASES` is empty). + + """ + + #: Parse cases. + CASES = () # type: tuple[Case, ...] + #: Make cases. + MAKE_CASES = () # type: tuple[MakeCase, ...] + #: Labels of malformed inputs that must be refused, and how. + REJECTED = {} # type: dict[str, Reject] + #: Known failures, one entry per root cause. + KNOWN_FAILURES = () # type: tuple[Gap, ...] + + def setUp(self) -> None: + if not self.CASES: + self.skipTest('base class') + if not HAS_RUNTIME: + self.skipTest('runtime dependencies not installed') + reimport_once_per_class(self) + + # -- helpers ---------------------------------------------------------- + + def _gaps(self) -> 'dict[str, Gap]': + """Every case label a ``KNOWN_FAILURES`` pattern matches, to its entry.""" + labels = sorted(self._labels()) + table = {} # type: dict[str, Gap] + for gap in self.KNOWN_FAILURES: + for pattern in gap.cases: + for label in fnmatch.filter(labels, pattern): + self.assertIs(table.setdefault(label, gap), gap, + f'{label} is listed under two root causes') + return table + + def _all_cases(self) -> 'tuple[Case, ...]': + return self.CASES + cuts(tuple(c for c in self.CASES if c.label not in self.REJECTED)) + + def _labels(self) -> 'set[str]': + return {c.label for c in self._all_cases()} | {c.label for c in self.MAKE_CASES} + + def _check(self, label: 'str', outcome: 'Outcome', passing: 'tuple[str, ...]', + gaps: 'dict[str, Gap]') -> None: + gap = gaps.get(label) + if gap is None: + self.assertIn( + outcome.status, passing, + f'{label} does not round-trip: {outcome.status}: {outcome.detail}. ' + f'If this is a new defect, add it to KNOWN_FAILURES under its root cause.') + return + self.assertIn( + outcome.status, _statuses(gap), + f'{label} was recorded as failing with {gap.status} ' + f'(#{gap.issue}: {gap.defect}) but came back {outcome.status}: ' + f'{outcome.detail}. If the defect is fixed, delete the label from its entry.') + fragments = (gap.fragment,) if isinstance(gap.fragment, str) else gap.fragment + if any(fragments): + self.assertTrue( + any(fragment in outcome.detail for fragment in fragments if fragment), + f'{label} fails, but not in the recorded way ({gap.defect}); ' + f'detail was {outcome.detail!r}') + + def _run(self, func: 'Any', case: 'Any') -> 'Outcome': + try: + with time_limit(CASE_TIMEOUT): + return func(case) + except TimeoutError as exc: + return Outcome('TIMEOUT', str(exc)) + + # -- the tables ------------------------------------------------------- + + def test_labels_are_unique(self) -> None: + labels = [c.label for c in self.CASES] + [c.label for c in self.MAKE_CASES] + self.assertEqual(len(labels), len(set(labels)), 'duplicate case labels') + + def test_tables_name_real_cases(self) -> None: + """Every ``KNOWN_FAILURES`` pattern and ``REJECTED`` label names a case.""" + labels = sorted(self._labels()) + stale = [pattern for gap in self.KNOWN_FAILURES for pattern in gap.cases + if not fnmatch.filter(labels, pattern)] + self.assertEqual(stale, [], 'KNOWN_FAILURES patterns that match no case') + self.assertEqual(sorted(set(self.REJECTED) - set(labels)), [], 'stale REJECTED labels') + self.assertEqual(sorted(set(self._gaps()) & set(self.REJECTED)), [], + 'a label cannot be both rejected and a known failure') + for gap in self.KNOWN_FAILURES: + for status in _statuses(gap): + self.assertIn(status, STATUSES) + self.assertNotEqual(status, 'OK', gap.defect) + self.assertTrue(gap.cases, f'empty KNOWN_FAILURES entry: {gap.defect}') + + # -- the checks ------------------------------------------------------- + + def test_parse_then_rebuild_is_byte_exact(self) -> None: + gaps = self._gaps() + for case in self.CASES: + if case.label in self.REJECTED: + continue + with self.subTest(case=case.label): + self._check(case.label, self._run(run_parse, case), ('OK',), gaps) + + def test_cut_is_rejected_or_byte_exact(self) -> None: + gaps = self._gaps() + for case in cuts(tuple(c for c in self.CASES if c.label not in self.REJECTED)): + with self.subTest(case=case.label): + self._check(case.label, self._run(run_parse, case), ('OK', 'REJECTED'), gaps) + + def test_make_then_parse_is_stable(self) -> None: + gaps = self._gaps() + for case in self.MAKE_CASES: + with self.subTest(case=case.label): + self._check(case.label, self._run(run_make, case), ('OK',), gaps) + + def test_malformed_input_is_rejected_in_library(self) -> None: + cases = {c.label: c for c in self.CASES} + for label, reject in self.REJECTED.items(): + with self.subTest(case=label): + outcome = self._run(run_parse, cases[label]) + self.assertEqual(outcome.status, 'REJECTED', f'{label}: {outcome.detail}') + self.assertTrue(outcome.detail.startswith(f'{reject.exc}: '), outcome.detail) + self.assertIn(reject.fragment, outcome.detail) diff --git a/tests/protocols/application/test_application_edge_roundtrip_unit.py b/tests/protocols/application/test_application_edge_roundtrip_unit.py new file mode 100644 index 0000000000..71ea8b7d11 --- /dev/null +++ b/tests/protocols/application/test_application_edge_roundtrip_unit.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""Application-layer edge cases round-trip byte for byte. C.f. #1202. + +FTP commands and replies, HTTP/1.* messages and HTTP/2 frames, at the edges of +their code ranges, with unassigned codes, zero-length values and padding +variants. The charset, CRLF and flag cases are already pinned by +:mod:`tests.protocols.application.test_ftp_http1_roundtrip_unit` and +:mod:`tests.protocols.application.test_http2_roundtrip_unit`; these are the +ones they do not build. The harness is :mod:`tests.protocols._edge_roundtrip`. + +Every case builds its own octets in memory and reads no capture. + +""" + +from __future__ import annotations + +import struct +import unittest + +from tests.protocols import _edge_roundtrip as edge +from tests.protocols._edge_roundtrip import Case, MakeCase + +FTP = 'pcapkit.protocols.application.ftp:FTP' +HTTP1 = 'pcapkit.protocols.application.httpv1:HTTP' +HTTP2 = 'pcapkit.protocols.application.httpv2:HTTP' + + +def frame(type_: int, flags: int, sid: int, payload: bytes) -> bytes: + """An HTTP/2 frame whose Length is the payload's.""" + return len(payload).to_bytes(3, 'big') + bytes([type_, flags]) + struct.pack('!I', sid) + payload + + +CASES = ( + # -- FTP --------------------------------------------------------------- + Case('ftp/unknown-command', FTP, b'XYZW arg\r\n'), + Case('ftp/lowercase-command', FTP, b'user anonymous\r\n'), + Case('ftp/three-letter-command', FTP, b'PWD\r\n'), + Case('ftp/arguments-only-spaces', FTP, b'NOOP \r\n'), + Case('ftp/4000-octet-argument', FTP, b'STOR ' + b'a' * 4000 + b'\r\n'), + Case('ftp/reply-100', FTP, b'100 x\r\n'), + Case('ftp/reply-599', FTP, b'599 x\r\n'), + Case('ftp/reply-110-assigned', FTP, b'110 MARK yyyy = mmmm\r\n'), + Case('ftp/reply-123-unassigned', FTP, b'123 x\r\n'), + Case('ftp/reply-999-unassigned', FTP, b'999 hi\r\n'), + Case('ftp/reply-099-unassigned', FTP, b'099 x\r\n'), + # -- HTTP/1.* ---------------------------------------------------------- + Case('http1/request-no-headers', HTTP1, b'GET / HTTP/1.1\r\n\r\n'), + Case('http1/unknown-method', HTTP1, b'BREW /pot HTTP/1.1\r\n\r\n'), + Case('http1/http-1.0', HTTP1, b'GET / HTTP/1.0\r\n\r\n'), + Case('http1/empty-header-value', HTTP1, b'GET / HTTP/1.1\r\nX-Empty:\r\n\r\n'), + Case('http1/header-without-space', HTTP1, b'GET / HTTP/1.1\r\nHost:a\r\n\r\n'), + Case('http1/header-value-padded-with-spaces', HTTP1, b'GET / HTTP/1.1\r\nA: spaced \r\n\r\n'), + Case('http1/repeated-header', HTTP1, b'GET / HTTP/1.1\r\nA: 1\r\nA: 2\r\n\r\n'), + Case('http1/body', HTTP1, b'POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc'), + Case('http1/status-100', HTTP1, b'HTTP/1.1 100 Continue\r\n\r\n'), + Case('http1/status-418', HTTP1, b"HTTP/1.1 418 I'm a teapot\r\n\r\n"), + Case('http1/status-empty-reason', HTTP1, b'HTTP/1.1 204 \r\n\r\n'), + Case('http1/status-599-unassigned', HTTP1, b'HTTP/1.1 599 X\r\n\r\n'), + Case('http1/status-999-unassigned', HTTP1, b'HTTP/1.1 999 X\r\n\r\n'), + # -- HTTP/2 ------------------------------------------------------------ + Case('http2/data-empty', HTTP2, frame(0, 0, 1, b'')), + Case('http2/data-end-stream-empty', HTTP2, frame(0, 1, 1, b'')), + Case('http2/data-16384-octets', HTTP2, frame(0, 0, 1, b'x' * 16384)), + Case('http2/data-padded-nonzero-pad', HTTP2, frame(0, 8, 1, b'\x03abc\xff\xee\xdd')), + Case('http2/data-padded-pad-only', HTTP2, frame(0, 8, 1, b'\x04' + bytes(4))), + Case('http2/headers-empty-block', HTTP2, frame(1, 4, 1, b'')), + Case('http2/headers-priority-padded', HTTP2, frame(1, 0x28, 1, b'\x02\x80\x00\x00\x03\x10\x82\x00\x00')), + Case('http2/priority-exclusive-max', HTTP2, frame(2, 0, 1, b'\xff' * 5)), + Case('http2/rst-stream-unassigned-error', HTTP2, frame(3, 0, 1, b'\xff' * 4)), + Case('http2/settings-empty', HTTP2, frame(4, 0, 0, b'')), + Case('http2/settings-unassigned-id', HTTP2, frame(4, 0, 0, b'\xff\xff\x00\x00\x00\x01')), + Case('http2/settings-id-0', HTTP2, frame(4, 0, 0, b'\x00\x00\x12\x34\x56\x78')), + Case('http2/push-promise-padded-nonzero-pad', HTTP2, frame(5, 0x0c, 1, b'\x02\x00\x00\x00\x02\x82\xab\xcd')), + Case('http2/ping-ack', HTTP2, frame(6, 1, 0, b'\xff' * 8)), + Case('http2/goaway-debug-data', HTTP2, frame(7, 0, 0, b'\x00\x00\x00\x01\x00\x00\x00\x02debug')), + Case('http2/goaway-unassigned-error', HTTP2, frame(7, 0, 0, b'\x00\x00\x00\x01\xff\xff\xff\xf0')), + Case('http2/window-update-0', HTTP2, frame(8, 0, 1, bytes(4))), + Case('http2/window-update-max', HTTP2, frame(8, 0, 0, b'\x7f\xff\xff\xff')), + Case('http2/continuation-empty', HTTP2, frame(9, 4, 1, b'')), + Case('http2/unassigned-type-with-payload', HTTP2, frame(0x0a, 0, 1, b'abcdef')), + Case('http2/unassigned-type-ff-max-stream', HTTP2, frame(0xff, 0xff, 0x7fffffff, b'')), +) + +MAKE_CASES = ( + MakeCase('ftp/make/unknown-command', FTP, {'cmmd': 'XYZW', 'args': 'a'}), + MakeCase('ftp/make/reply-599', FTP, {'code': 599, 'args': 'x'}), + MakeCase('http1/make/unknown-method', HTTP1, {'method': 'BREW', 'uri': '/pot'}), + MakeCase('http2/make/default', HTTP2, {}), + MakeCase('http2/make/unassigned-type', HTTP2, {'type': 0xfa, 'sid': 0x7fffffff}), +) + +REJECTED = {} # type: dict[str, edge.Reject] + +KNOWN_FAILURES = () # type: tuple[edge.Gap, ...] + + +@unittest.skipUnless(edge.HAS_RUNTIME, 'runtime dependencies not installed') +class ApplicationEdgeRoundTripTests(edge.EdgeRoundTripBase): + """Application-layer edge cases.""" + + CASES = CASES + MAKE_CASES = MAKE_CASES + REJECTED = REJECTED + KNOWN_FAILURES = KNOWN_FAILURES + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/internet/test_internet_edge_roundtrip_unit.py b/tests/protocols/internet/test_internet_edge_roundtrip_unit.py new file mode 100644 index 0000000000..a789de1bc7 --- /dev/null +++ b/tests/protocols/internet/test_internet_edge_roundtrip_unit.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +"""Internet-layer edge cases round-trip byte for byte. C.f. #1202. + +IPv4 and its option area, IPX, IPv6 and every extension header (standalone, in +extension mode, and inside an IPv6 chain), HIP, MH, AH and ESP, at their +minimum and maximum lengths, with reserved bits set, with zero-length options +and pads, and with unassigned codes. Per-code option coverage is +:mod:`tests.protocols.test_option_roundtrip_unit`'s; these are the header-level +cases it does not build. The harness is :mod:`tests.protocols._edge_roundtrip`. + +Every case builds its own octets in memory and reads no capture. + +""" + +from __future__ import annotations + +import struct +import unittest + +from tests.protocols import _edge_roundtrip as edge +from tests.protocols._edge_roundtrip import Case, Gap, MakeCase, Reject + +IPV4 = 'pcapkit.protocols.internet.ipv4:IPv4' +IPX = 'pcapkit.protocols.internet.ipx:IPX' +IPV6 = 'pcapkit.protocols.internet.ipv6:IPv6' +HOPOPT = 'pcapkit.protocols.internet.hopopt:HOPOPT' +IPV6_OPTS = 'pcapkit.protocols.internet.ipv6_opts:IPv6_Opts' +IPV6_ROUTE = 'pcapkit.protocols.internet.ipv6_route:IPv6_Route' +IPV6_FRAG = 'pcapkit.protocols.internet.ipv6_frag:IPv6_Frag' +AH = 'pcapkit.protocols.internet.ah:AH' +ESP = 'pcapkit.protocols.internet.esp:ESP' +MH = 'pcapkit.protocols.internet.mh:MH' +HIP = 'pcapkit.protocols.internet.hip:HIP' + +EXT = {'extension': True} + + +def _checksum(octets: bytes) -> int: + total = sum(int.from_bytes(octets[i:i + 2].ljust(2, b'\x00'), 'big') + for i in range(0, len(octets), 2)) + while total >> 16: + total = (total & 0xffff) + (total >> 16) + return ~total & 0xffff + + +def ipv4(options: bytes = b'', payload: bytes = b'', proto: int = 253, frag: int = 0, + tos: int = 0, ttl: int = 64, tlen: 'int | None' = None, ihl: 'int | None' = None, + version: int = 4, checksum: bool = True) -> bytes: + """An IPv4 header with ``options``, then ``payload``.""" + ihl = (20 + len(options)) // 4 if ihl is None else ihl + tlen = 20 + len(options) + len(payload) if tlen is None else tlen + head = struct.pack('!BBHHHBBH4s4s', (version << 4) | ihl, tos, tlen, 0x1234, frag, ttl, + proto, 0, bytes([192, 0, 2, 1]), bytes([198, 51, 100, 1])) + options + if checksum: + head = head[:10] + _checksum(head).to_bytes(2, 'big') + head[12:] + return head + payload + + +def ipx(tlen: 'int | None' = None, tc: int = 0, ptype: int = 0, body: bytes = b'', + checksum: bytes = b'\xff\xff') -> bytes: + """An IPX header, then ``body``.""" + tlen = 30 + len(body) if tlen is None else tlen + return (checksum + struct.pack('!HBB', tlen, tc, ptype) + bytes(4) + b'\x01' * 6 + + b'\x04\x51' + bytes(4) + b'\x02' * 6 + b'\x04\x52' + body) + + +def ipv6(nxt: int, body: bytes, tc: int = 0, fl: int = 0, plen: 'int | None' = None, + hlim: int = 64) -> bytes: + """An IPv6 header from ``::1`` to ``::2``, then ``body``.""" + plen = len(body) if plen is None else plen + return (struct.pack('!IHBB', (6 << 28) | (tc << 20) | fl, plen, nxt, hlim) + + bytes(15) + b'\x01' + bytes(15) + b'\x02' + body) + + +def opts(nxt: int, area: bytes) -> bytes: + """A HOPOPT/IPv6-Opts header holding ``area``, which must fill it.""" + assert not (len(area) + 2) % 8, len(area) + return bytes([nxt, (len(area) + 2) // 8 - 1]) + area + + +def rpl(cmpr_i: int, cmpr_e: int, count: int) -> bytes: + """An RPL Source Route header of ``count`` addresses, no next header.""" + addrs = (16 - cmpr_i) * (count - 1) + (16 - cmpr_e) + pad = -addrs % 8 + return (bytes([59, (addrs + pad) // 8, 3, 1, (cmpr_i << 4) | cmpr_e, pad << 4, 0, 0]) + + bytes(range(1, addrs + 1)) + bytes(pad)) + + +def hip(params: bytes = b'', pkt: int = 16, version: int = 2, fixed: int = 1, + controls: int = 0, nxt: int = 59, leading: int = 0) -> bytes: + """A HIP header from HIT ``11..`` to ``22..``, then ``params``.""" + return (bytes([nxt, len(params) // 8 + 4, (leading << 7) | pkt, (version << 4) | fixed]) + + b'\x00\x00' + struct.pack('!H', controls) + b'\x11' * 16 + b'\x22' * 16 + params) + + +def hip_param(code: int, body: bytes) -> bytes: + """A HIP parameter padded per :rfc:`7401#section-5.2.1`.""" + total = 11 + len(body) - (len(body) + 3) % 8 + return struct.pack('!HH', code, len(body)) + body + bytes(total - 4 - len(body)) + + +#: A Binding Update's fixed part, with a 4-octet option area to follow. +MH_BU = bytes([59, 1, 5, 0]) + bytes(2) + b'\x00\x01' + bytes(4) + +#: Extension headers on their own, next header 59: label -> (class, octets, +#: IPv6 next-header value that carries it). +EXTENSIONS = { + 'hopopt/pad1-x6': (HOPOPT, opts(59, bytes(6)), 0), + 'hopopt/padn-nonzero-contents': (HOPOPT, opts(59, b'\x01\x04\xde\xad\xbe\xef'), 0), + 'hopopt/padn-0-then-pad1s': (HOPOPT, opts(59, b'\x01\x00' + bytes(4)), 0), + 'hopopt/unassigned-skip': (HOPOPT, opts(59, b'\x1e\x04\xaa\xbb\xcc\xdd'), 0), + 'hopopt/unassigned-discard': (HOPOPT, opts(59, b'\x7e\x00\x01\x02\x00\x00'), 0), + 'hopopt/unassigned-zero-length': (HOPOPT, opts(59, b'\x3e\x00\x01\x02\x00\x00'), 0), + 'hopopt/max-length-2048': (HOPOPT, opts(59, (b'\x01\xff' + bytes(255)) * 7 + b'\x01\xf5' + bytes(245)), 0), + 'hopopt/router-alert-unassigned-value': (HOPOPT, opts(59, b'\x05\x02\xff\xff\x01\x00'), 0), + 'hopopt/jumbo-payload-0': (HOPOPT, opts(59, b'\xc2\x04' + bytes(4)), 0), + 'ipv6-opts/pad1-x6': (IPV6_OPTS, opts(59, bytes(6)), 60), + 'ipv6-opts/padn-nonzero-contents': (IPV6_OPTS, opts(59, b'\x01\x04\x01\x02\x03\x04'), 60), + 'ipv6-opts/unassigned': (IPV6_OPTS, opts(59, b'\xfe\x04\x01\x02\x03\x04'), 60), + 'ipv6-route/unassigned-type': (IPV6_ROUTE, bytes([59, 0, 200, 3]) + b'\xaa\xbb\xcc\xdd', 43), + 'ipv6-route/unassigned-type-24-octets': (IPV6_ROUTE, bytes([59, 2, 250, 0]) + bytes(range(20)), 43), + 'ipv6-route/type0-no-address': (IPV6_ROUTE, bytes([59, 0, 0, 0]) + bytes(4), 43), + 'ipv6-route/type0-reserved-set': (IPV6_ROUTE, bytes([59, 2, 0, 1]) + b'\xff' * 4 + bytes(16), 43), + 'ipv6-route/type2-reserved-set': (IPV6_ROUTE, bytes([59, 2, 2, 1]) + b'\x12\x34\x56\x78' + bytes(16), 43), + 'ipv6-route/rpl-uncompressed': (IPV6_ROUTE, rpl(0, 0, 1), 43), + 'ipv6-route/rpl-cmpr-15-15-x8': (IPV6_ROUTE, rpl(15, 15, 8), 43), + 'ipv6-route/rpl-cmpr-8-8-x2': (IPV6_ROUTE, rpl(8, 8, 2), 43), + 'ipv6-route/rpl-cmpr-15-0-x1': (IPV6_ROUTE, rpl(15, 0, 1), 43), + 'ipv6-route/rpl-cmpr-0-15-pad7': (IPV6_ROUTE, rpl(0, 15, 1), 43), + 'ipv6-route/rpl-cmpr-12-13-pad1': (IPV6_ROUTE, rpl(12, 13, 2), 43), + 'ipv6-frag/reserved-octet-set': (IPV6_FRAG, bytes([59, 0xff, 0, 0]) + b'\x00\x00\x00\x01', 44), + 'ipv6-frag/every-bit-set': (IPV6_FRAG, bytes([59, 0, 0xff, 0xff]) + b'\xff' * 4, 44), + 'ipv6-frag/atomic': (IPV6_FRAG, bytes([59, 0, 0, 0]) + bytes(4), 44), + 'ah/icv-empty': (AH, bytes([59, 1, 0, 0]) + b'\x00\x00\x00\x01' + b'\x00\x00\x00\x02', 51), + 'ah/reserved-set': (AH, bytes([59, 1, 0xff, 0xff]) + b'\x00\x00\x00\x01' + b'\x00\x00\x00\x02', 51), + 'ah/icv-12': (AH, bytes([59, 4, 0, 0]) + bytes(8) + b'\x11' * 12, 51), + 'ah/max-length-1028': (AH, bytes([59, 255, 0, 0]) + bytes(8) + b'\x22' * 1016, 51), + 'mh/brr-reserved-set': (MH, bytes([59, 1, 0, 0]) + bytes(2) + b'\xff\xff' + b'\x01\x06' + bytes(6), 135), + 'mh/hoti-reserved-set': (MH, bytes([59, 1, 1, 0]) + bytes(2) + b'\xff\xff' + bytes(8), 135), + 'mh/bu-every-flag': (MH, bytes([59, 1, 5, 0]) + bytes(2) + b'\x00\x01' + b'\xff' * 4 + b'\x01\x02\x00\x00', 135), + 'mh/bu-unassigned-option': (MH, MH_BU + b'\xfe\x02\x01\x02', 135), + 'mh/bu-zero-length-option': (MH, MH_BU + b'\xfe\x00\x00\x00', 135), + 'mh/bu-padn-nonzero-contents': (MH, MH_BU + b'\x01\x02\xde\xad', 135), + 'mh/bu-pad1-x4': (MH, MH_BU + bytes(4), 135), + 'mh/ba-unassigned-status': (MH, bytes([59, 1, 6, 0]) + bytes(2) + b'\x7f\xff' + b'\x00\x01' + bytes(6), 135), + 'mh/be-unassigned-status': (MH, bytes([59, 2, 7, 0]) + bytes(2) + b'\xff\x00' + bytes(16), 135), + 'mh/unassigned-type': (MH, bytes([59, 0, 200, 0]) + bytes(2) + b'\xab\xcd', 135), + 'mh/unassigned-type-16-octets': (MH, bytes([59, 1, 250, 0]) + bytes(2) + bytes(range(10)), 135), + 'mh/unassigned-type-max-length': (MH, bytes([59, 255, 200, 0]) + bytes(2) + bytes(2042), 135), +} + +CASES = ( + # -- IPv4 -------------------------------------------------------------- + Case('ipv4/min-header-no-payload', IPV4, ipv4()), + Case('ipv4/reserved-flag-set', IPV4, ipv4(frag=0x8000, payload=b'ab')), + Case('ipv4/df-mf-max-offset', IPV4, ipv4(frag=0x7fff, payload=b'ab')), + Case('ipv4/tos-ff', IPV4, ipv4(tos=0xff)), + Case('ipv4/ttl-0', IPV4, ipv4(ttl=0)), + Case('ipv4/protocol-255-reserved', IPV4, ipv4(proto=255, payload=b'xx')), + Case('ipv4/protocol-200-unassigned', IPV4, ipv4(proto=200, payload=b'xx')), + Case('ipv4/wrong-checksum', IPV4, ipv4(checksum=False)), + Case('ipv4/ihl-15-nop-pad', IPV4, ipv4(options=b'\x01' * 40)), + Case('ipv4/ihl-15-eool-pad', IPV4, ipv4(options=bytes(40))), + Case('ipv4/eool-then-nonzero-pad', IPV4, ipv4(options=b'\x00\xaa\xbb\xcc')), + Case('ipv4/nop-eool-eool-eool', IPV4, ipv4(options=b'\x01\x00\x00\x00')), + Case('ipv4/rr-length-3-no-slot', IPV4, ipv4(options=b'\x07\x03\x04\x00')), + Case('ipv4/rr-max-length-39', IPV4, ipv4(options=b'\x07\x27\x04' + bytes(36) + b'\x00')), + Case('ipv4/unassigned-option-length-2', IPV4, ipv4(options=b'\x1f\x02\x00\x00')), + Case('ipv4/unassigned-option-copied', IPV4, ipv4(options=b'\x9f\x04\xaa\xbb')), + Case('ipv4/unassigned-option-max-length-40', IPV4, ipv4(options=b'\x7f\x28' + bytes(38))), + Case('ipv4/ts-unassigned-flag', IPV4, ipv4(options=b'\x44\x0c\x05\x0f' + bytes(8))), + Case('ipv4/ts-overflow-15', IPV4, ipv4(options=b'\x44\x0c\x0d\xf1' + bytes(8))), + Case('ipv4/router-alert-unassigned-value', IPV4, ipv4(options=b'\x94\x04\xff\xff')), + Case('ipv4/security-length-3', IPV4, ipv4(options=b'\x82\x03\x01\x00')), + Case('ipv4/total-length-short-of-the-data', IPV4, ipv4(tlen=20) + bytes(6)), + Case('ipv4/total-length-short-of-the-header', IPV4, ipv4(options=b'\x01' * 4, tlen=20)), + Case('ipv4/total-length-past-the-data', IPV4, ipv4(tlen=100, payload=b'ab')), + Case('ipv4/tcp-no-payload', IPV4, ipv4(proto=6)), + Case('ipv4/udp-3-octet-payload', IPV4, ipv4(proto=17, payload=b'\x00\x01\x00')), + Case('ipv4/version-6', IPV4, ipv4(version=6)), + Case('ipv4/ihl-4', IPV4, ipv4(ihl=4)), + # -- IPX --------------------------------------------------------------- + Case('ipx/min-header', IPX, ipx()), + Case('ipx/transport-control-255', IPX, ipx(tc=255)), + Case('ipx/unassigned-packet-type', IPX, ipx(ptype=0xee, body=b'zz')), + Case('ipx/length-short-of-the-data', IPX, ipx(tlen=30) + b'\x00'), + Case('ipx/checksum-not-ffff', IPX, ipx(checksum=b'\x12\x34')), + Case('ipx/length-past-the-data', IPX, ipx(tlen=60, body=b'abc')), + # -- IPv6 -------------------------------------------------------------- + Case('ipv6/no-next-header-empty', IPV6, ipv6(59, b'')), + Case('ipv6/no-next-header-with-octets', IPV6, ipv6(59, b'junk')), + Case('ipv6/class-and-label-max', IPV6, ipv6(59, b'', tc=255, fl=0xfffff)), + Case('ipv6/hop-limit-0', IPV6, ipv6(59, b'', hlim=0)), + Case('ipv6/next-200-unassigned', IPV6, ipv6(200, b'xyz')), + Case('ipv6/payload-length-short-of-the-data', IPV6, ipv6(253, b'ab', plen=1)), + Case('ipv6/payload-length-0-with-octets', IPV6, ipv6(253, b'ab', plen=0)), + Case('ipv6/payload-length-past-the-data', IPV6, ipv6(253, b'ab', plen=100)), + Case('ipv6/hopopt-dstopts-route-frag', IPV6, + ipv6(0, opts(60, bytes(6)) + opts(43, b'\x01\x04' + bytes(4)) + + bytes([44, 0, 200, 0]) + bytes(4) + bytes([59, 0, 0, 0]) + bytes(4))), + Case('ipv6/ah-then-frag', IPV6, ipv6(51, bytes([44, 1, 0, 0]) + bytes(8) + bytes([59, 0, 0, 1]) + bytes(4))), + Case('ipv6/hopopt-then-trailer', IPV6, ipv6(0, opts(59, bytes(6)), plen=8) + b'\x00\x00'), + Case('ipv6/esp', IPV6, ipv6(50, b'\x00\x00\x00\x01\x00\x00\x00\x02' + bytes(8))), + *(Case(label, cls, raw) for label, (cls, raw, _) in EXTENSIONS.items()), + *(Case(f'{label}/extension', cls, raw, EXT) for label, (cls, raw, _) in EXTENSIONS.items()), + *(Case(f'{label}/in-ipv6', IPV6, ipv6(nxt, raw)) for label, (cls, raw, nxt) in EXTENSIONS.items()), + # In extension mode the header owns only its own 8 octets, not the UDP datagram after it. + Case('hopopt/udp-payload/extension', HOPOPT, opts(17, bytes(6)) + b'\x9c\x40\x9c\x41\x00\x0a\x00\x00ab', EXT, 8), + # -- ESP --------------------------------------------------------------- + Case('esp/spi-seq-only', ESP, b'\x00\x00\x00\x01\x00\x00\x00\x02'), + Case('esp/opaque-payload', ESP, b'\x00\x00\x00\x01\x00\x00\x00\x02' + bytes(8)), + Case('esp/opaque-payload/extension', ESP, b'\x00\x00\x00\x01\x00\x00\x00\x02' + bytes(8), EXT), + # -- HIP --------------------------------------------------------------- + Case('hip/no-parameters', HIP, hip(), EXT), + Case('hip/packet-type-0-reserved', HIP, hip(pkt=0), EXT), + Case('hip/packet-type-127-unassigned', HIP, hip(pkt=127), EXT), + Case('hip/version-1', HIP, hip(version=1), EXT), + Case('hip/version-15', HIP, hip(version=15), EXT), + Case('hip/every-control-bit', HIP, hip(controls=0xffff), EXT), + Case('hip/unassigned-parameter', HIP, hip(hip_param(0x3fff, b'abc')), EXT), + Case('hip/unassigned-parameter-empty', HIP, hip(hip_param(0x3fff, b'')), EXT), + Case('hip/unassigned-critical-parameter', HIP, hip(hip_param(0x0101, b'\x01\x02\x03\x04\x05')), EXT), + Case('hip/parameter-nonzero-pad', HIP, hip(struct.pack('!HH', 0x3fff, 1) + b'\x01\xff\xff\xff'), EXT), + Case('hip/seq-max', HIP, hip(hip_param(385, b'\xff' * 4)), EXT), + Case('hip/next-tcp-no-payload', HIP, hip(nxt=6), EXT), + Case('hip/no-parameters/not-extension', HIP, hip()), + Case('hip/fixed-bit-clear', HIP, hip(fixed=0), EXT), + Case('hip/leading-bit-set', HIP, hip(leading=1), EXT), +) + +MAKE_CASES = ( + MakeCase('ipv4/make/default', IPV4, {}), + MakeCase('ipv4/make/unassigned-protocol', IPV4, {'protocol': 200, 'payload': b'x'}), + MakeCase('ipv4/make/df-mf-max-offset-ttl-0', IPV4, {'df': True, 'mf': True, 'offset': 8191, 'ttl': 0}), + MakeCase('ipx/make/default', IPX, {}), + MakeCase('ipv6/make/default', IPV6, {}), + MakeCase('ipv6/make/max-fields', IPV6, {'traffic_class': 255, 'flow_label': 0xfffff, 'next': 59, 'hop_limit': 0}), + MakeCase('ipv6-frag/make/every-field-max', IPV6_FRAG, + {'next': 59, 'reserved_octet': b'\xff', 'offset': 8191, 'reserved': 3, 'mf': True, 'id': 0xffffffff}), + MakeCase('ah/make/reserved-set', AH, {'next': 59, 'reserved': 0xffff, 'spi': 1, 'seq': 2, 'icv': bytes(4)}), + MakeCase('ah/make/icv-empty', AH, {'next': 59}), + MakeCase('esp/make/default', ESP, {}), + MakeCase('hip/make/default', HIP, {'next': 59}, EXT), + MakeCase('hip/make/every-control-bit', HIP, + {'next': 59, 'controls_reserved': 0x7fff, 'controls_anonymous': True, 'packet': 127}, EXT), +) + +REJECTED = { + 'ipv4/version-6': Reject('ProtocolError', '[IPv4] invalid version: 6'), + 'ipv4/ihl-4': Reject('ProtocolError', 'resolved to a negative length'), +} + +KNOWN_FAILURES = ( + Gap(1456, + 'An RPL Source Route header under an IPv6 header parses with its compressed ' + 'addresses expanded against the IPv6 destination, and the rebuild writes them ' + 'back at 16 octets each, with CmprI/CmprE unchanged and Hdr Ext Len recomputed ' + 'from the 16-octet vector: ``_make_data_type_rpl`` takes ``addr.packed`` without ' + 'the ``[cmpr_i:]``/``[cmpr_e:]`` slice its ``ip=`` branch applies ' + '(pcapkit/protocols/internet/ipv6_route.py:795-797). Standalone, with no ' + 'destination to expand against, the addresses stay bytes and round-trip.', + # The rebuilt routing header's first five octets: Hdr Ext Len grown, or + # (cut63) the first address written out at 16 octets. + 'MISMATCH', ('023b100301ff', '023b04030188', '023b0303010f', '023b050301cd', '023b0e0301ff', + '023b0203018800000000000000000000000102'), + ('ipv6-route/rpl-cmpr-15-15-x8/in-ipv6', 'ipv6-route/rpl-cmpr-8-8-x2/in-ipv6', + 'ipv6-route/rpl-cmpr-0-15-pad7/in-ipv6', 'ipv6-route/rpl-cmpr-12-13-pad1/in-ipv6', + 'ipv6-route/rpl-cmpr-15-15-x8/in-ipv6/cut55', 'ipv6-route/rpl-cmpr-8-8-x2/in-ipv6/cut63', + 'ipv6-route/rpl-cmpr-0-15-pad7/in-ipv6/cut55', 'ipv6-route/rpl-cmpr-12-13-pad1/in-ipv6/cut55')), + Gap(1457, + 'A truncated RPL address vector is split by the octets actually read, not by ' + 'Hdr Ext Len (``RPL.post_process``, pcapkit/protocols/schema/internet/ipv6_route.py:280-305). ' + 'A short uncompressed address leaks ``ValueError`` from ``ipaddress.ip_address`` ' + '(PARSE); otherwise the rebuild recomputes Hdr Ext Len from the addresses read, ' + 'so it either fails the reader\'s own length check ' + '(``IPv6-Route: [TypeNo 3] invalid format``, REBUILD) or writes a smaller one ' + '(MISMATCH).', + ('PARSE', 'REBUILD', 'MISMATCH'), + ('ValueError: ', 'IPv6-Route: [TypeNo 3] invalid format', 'octets 3b010301880000000102'), + ('ipv6-route/rpl-uncompressed/cut*', 'ipv6-route/rpl-uncompressed/extension/cut*', + 'ipv6-route/rpl-cmpr-15-0-x1/cut*', 'ipv6-route/rpl-cmpr-15-0-x1/extension/cut*', + 'ipv6-route/rpl-cmpr-15-15-x8/cut8', 'ipv6-route/rpl-cmpr-15-15-x8/extension/cut8', + 'ipv6-route/rpl-cmpr-8-8-x2/cut*', 'ipv6-route/rpl-cmpr-8-8-x2/extension/cut*')), + Gap(1459, + 'HIP rejects a packet whose two fixed bits (the leading bit of Packet Type, ' + 'the trailing bit of Version) are not 0 and 1, where RFC 7401 section 5.1 ' + 'says a receiver MUST ignore them; the parse raises ``ProtocolError``.', + 'REJECTED', 'ProtocolError: HIP: invalid format', + ('hip/fixed-bit-clear', 'hip/leading-bit-set')), + Gap(1451, edge.ZERO_FILLED_EXTENSION_HEADER, 'PADDED', '', + ('ipv6-route/unassigned-type/cut*', 'ipv6-route/unassigned-type-24-octets/cut*', + 'ipv6-route/type0-no-address/cut*', 'ipv6-route/type0-reserved-set/cut*', + 'ipv6-route/type2-reserved-set/cut*', 'ipv6-route/rpl-cmpr-0-15-pad7/cut*', + 'ipv6-route/rpl-cmpr-12-13-pad1/cut*', 'ipv6-route/unassigned-type/extension/cut*', + 'ipv6-route/unassigned-type-24-octets/extension/cut*', + 'ipv6-route/type0-no-address/extension/cut*', + 'ipv6-route/type0-reserved-set/extension/cut*', + 'ipv6-route/type2-reserved-set/extension/cut*', + 'ipv6-route/rpl-cmpr-0-15-pad7/extension/cut*', + 'ipv6-route/rpl-cmpr-12-13-pad1/extension/cut*', 'ipv6-frag/reserved-octet-set/cut*', + 'ipv6-frag/every-bit-set/cut*', 'ipv6-frag/atomic/cut*', + 'ipv6-frag/reserved-octet-set/extension/cut*', 'ipv6-frag/every-bit-set/extension/cut*', + 'ipv6-frag/atomic/extension/cut*', 'ah/icv-empty/cut*', 'ah/reserved-set/cut*', + 'ah/icv-12/cut*', 'ah/max-length-1028/cut*', 'ah/icv-empty/extension/cut*', + 'ah/reserved-set/extension/cut*', 'ah/icv-12/extension/cut*', + 'ah/max-length-1028/extension/cut*', 'mh/hoti-reserved-set/cut*', + 'mh/be-unassigned-status/cut*', 'mh/unassigned-type/cut*', + 'mh/unassigned-type-16-octets/cut*', 'mh/unassigned-type-max-length/cut*', + 'mh/hoti-reserved-set/extension/cut*', 'mh/be-unassigned-status/extension/cut*', + 'mh/unassigned-type/extension/cut*', 'mh/unassigned-type-16-octets/extension/cut*', + 'mh/unassigned-type-max-length/extension/cut*', 'esp/spi-seq-only/cut*', + 'hip/no-parameters/cut*', 'hip/packet-type-0-reserved/cut*', + 'hip/packet-type-127-unassigned/cut*', 'hip/version-1/cut*', 'hip/version-15/cut*', + 'hip/every-control-bit/cut*', 'hip/next-tcp-no-payload/cut*', + 'hip/no-parameters/not-extension/cut*')), + Gap(1458, edge.ZERO_FILLED_SHORT_READ, 'PADDED', '', + ('ipx/min-header/cut*', 'ipx/transport-control-255/cut*', + 'ipx/unassigned-packet-type/cut16', 'ipx/length-short-of-the-data/cut15', + 'ipx/checksum-not-ffff/cut*', 'ipx/length-past-the-data/cut16', + 'ipv6/no-next-header-empty/cut*', 'ipv6/no-next-header-with-octets/cut22', + 'ipv6/class-and-label-max/cut*', 'ipv6/hop-limit-0/cut*', 'ipv6/next-200-unassigned/cut21', + 'ipv6/payload-length-short-of-the-data/cut21', 'ipv6/payload-length-0-with-octets/cut21', + 'ipv6/payload-length-past-the-data/cut21', 'ipv6/hopopt-dstopts-route-frag/cut36', + 'ipv6/ah-then-frag/cut30', 'ipv6/hopopt-then-trailer/cut25', 'ipv6/esp/cut28', + 'ipv6-route/unassigned-type/in-ipv6/cut24', + 'ipv6-route/unassigned-type-24-octets/in-ipv6/cut*', + 'ipv6-route/type0-no-address/in-ipv6/cut24', 'ipv6-route/type0-reserved-set/in-ipv6/cut*', + 'ipv6-route/type2-reserved-set/in-ipv6/cut*', 'ipv6-route/rpl-uncompressed/in-ipv6/cut32', + 'ipv6-route/rpl-cmpr-15-15-x8/in-ipv6/cut28', 'ipv6-route/rpl-cmpr-8-8-x2/in-ipv6/cut32', + 'ipv6-route/rpl-cmpr-15-0-x1/in-ipv6/cut32', 'ipv6-route/rpl-cmpr-0-15-pad7/in-ipv6/cut28', + 'ipv6-route/rpl-cmpr-12-13-pad1/in-ipv6/cut28', + 'ipv6-frag/reserved-octet-set/in-ipv6/cut24', 'ipv6-frag/every-bit-set/in-ipv6/cut24', + 'ipv6-frag/atomic/in-ipv6/cut24', 'ah/icv-empty/in-ipv6/cut26', + 'ah/reserved-set/in-ipv6/cut26', 'ah/icv-12/in-ipv6/cut*', + 'ah/max-length-1028/in-ipv6/cut*', 'mh/brr-reserved-set/in-ipv6/cut28', + 'mh/hoti-reserved-set/in-ipv6/cut*', 'mh/bu-every-flag/in-ipv6/cut28', + 'mh/bu-unassigned-option/in-ipv6/cut28', 'mh/bu-zero-length-option/in-ipv6/cut28', + 'mh/bu-padn-nonzero-contents/in-ipv6/cut28', 'mh/bu-pad1-x4/in-ipv6/cut28', + 'mh/ba-unassigned-status/in-ipv6/cut28', 'mh/be-unassigned-status/in-ipv6/cut*', + 'mh/unassigned-type/in-ipv6/cut24', 'mh/unassigned-type-16-octets/in-ipv6/cut*', + 'mh/unassigned-type-max-length/in-ipv6/cut*', 'hopopt/pad1-x6/in-ipv6/cut24', + 'hopopt/padn-nonzero-contents/in-ipv6/cut24', 'hopopt/padn-0-then-pad1s/in-ipv6/cut24', + 'hopopt/unassigned-skip/in-ipv6/cut24', 'hopopt/unassigned-discard/in-ipv6/cut24', + 'hopopt/unassigned-zero-length/in-ipv6/cut24', + 'hopopt/router-alert-unassigned-value/in-ipv6/cut24', + 'hopopt/jumbo-payload-0/in-ipv6/cut24', 'ipv6-opts/pad1-x6/in-ipv6/cut24', + 'ipv6-opts/padn-nonzero-contents/in-ipv6/cut24', 'ipv6-opts/unassigned/in-ipv6/cut24')), +) + + +@unittest.skipUnless(edge.HAS_RUNTIME, 'runtime dependencies not installed') +class InternetEdgeRoundTripTests(edge.EdgeRoundTripBase): + """Internet-layer edge cases.""" + + CASES = CASES + MAKE_CASES = MAKE_CASES + REJECTED = REJECTED + KNOWN_FAILURES = KNOWN_FAILURES + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/link/test_link_edge_roundtrip_unit.py b/tests/protocols/link/test_link_edge_roundtrip_unit.py new file mode 100644 index 0000000000..0051e26b80 --- /dev/null +++ b/tests/protocols/link/test_link_edge_roundtrip_unit.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Link-layer edge cases round-trip byte for byte. C.f. #1202. + +Ethernet, the 802.1Q/802.1ad tags, ARP and RARP, L2TPv2 and OSPF, at their +minimum and maximum lengths, with reserved bits set, with zero-length +addresses and offset pads, and with unassigned codes. The harness and the +meaning of each table are in :mod:`tests.protocols._edge_roundtrip`. + +Every case builds its own octets in memory and reads no capture. + +""" + +from __future__ import annotations + +import struct +import unittest + +from tests.protocols import _edge_roundtrip as edge +from tests.protocols._edge_roundtrip import Case, Gap, MakeCase, Reject + +ETHERNET = 'pcapkit.protocols.link.ethernet:Ethernet' +C_TAG = 'pcapkit.protocols.link.c_tag:C_Tag' +S_TAG = 'pcapkit.protocols.link.s_tag:S_Tag' +ARP = 'pcapkit.protocols.link.arp:ARP' +RARP = 'pcapkit.protocols.application.rarp:RARP' +L2TPV2 = 'pcapkit.protocols.link.l2tpv2:L2TPv2' +OSPF = 'pcapkit.protocols.application.ospf:OSPF' + +#: Destination and source MAC. +MACS = bytes.fromhex('0123456789ab') + bytes.fromhex('fedcba987654') + + +def arp(htype: int = 1, ptype: int = 0x0800, hlen: int = 6, plen: int = 4, + oper: int = 1, tail: bytes = b'') -> bytes: + """An ARP packet whose addresses are ``2 * (hlen + plen)`` counting octets.""" + return (struct.pack('!HHBBH', htype, ptype, hlen, plen, oper) + + bytes(i % 256 for i in range(2 * (hlen + plen))) + tail) + + +def ospf(type_: int = 1, plen: 'int | None' = None, auth: int = 0, + auth_data: bytes = bytes(8), body: bytes = b'', version: int = 2) -> bytes: + """An OSPF header, then ``body``; Packet Length counts both by default.""" + plen = 24 + len(body) if plen is None else plen + return struct.pack('!BBH4s4s2sH', version, type_, plen, bytes([1, 2, 3, 4]), bytes(4), + b'\xab\xcd', auth) + auth_data + body + + +CASES = ( + # -- Ethernet ---------------------------------------------------------- + Case('ethernet/header-only-unknown-type', ETHERNET, MACS + b'\x88\xb5'), + Case('ethernet/header-only-ipv4-type', ETHERNET, MACS + b'\x08\x00'), + Case('ethernet/length-field-0', ETHERNET, MACS + b'\x00\x00' + b'x'), + Case('ethernet/length-field-1500', ETHERNET, MACS + b'\x05\xdc' + b'xyzw'), + Case('ethernet/unassigned-type-ffff', ETHERNET, MACS + b'\xff\xff' + b'abc'), + Case('ethernet/broadcast-46-zero-octets', ETHERNET, b'\xff' * 6 + MACS[6:] + b'\x88\xb5' + bytes(46)), + Case('ethernet/max-1514', ETHERNET, MACS + b'\x88\xb5' + bytes(range(256)) * 5 + bytes(220)), + # -- 802.1Q / 802.1ad -------------------------------------------------- + Case('c-tag/pcp7-dei-vid4095', C_TAG, b'\xff\xff\x88\xb5' + b'pl'), + Case('c-tag/vid0-no-payload', C_TAG, b'\x00\x00\x88\xb5'), + Case('c-tag/header-only-ipv4-type', C_TAG, b'\x20\x01\x08\x00'), + Case('s-tag/dei-then-c-tag', S_TAG, b'\x1f\xff\x81\x00' + b'\x00\x01\x88\xb5'), + # -- ARP / RARP -------------------------------------------------------- + Case('arp/hlen0-plen0', ARP, arp(hlen=0, plen=0)), + Case('arp/reply', ARP, arp(oper=2)), + Case('arp/oper-0-unassigned', ARP, arp(oper=0)), + Case('arp/oper-ffff-unassigned', ARP, arp(oper=0xffff)), + Case('arp/htype-fffe-unassigned', ARP, arp(htype=0xfffe)), + Case('arp/ptype-unknown-plen3', ARP, arp(ptype=0x1234, plen=3)), + Case('arp/ipv6-plen16', ARP, arp(ptype=0x86dd, plen=16)), + Case('arp/hlen255', ARP, arp(hlen=255)), + Case('arp/18-octet-trailer', ARP, arp(tail=bytes(18))), + Case('rarp/request-reverse', RARP, arp(oper=3)), + Case('rarp/oper-unassigned', RARP, arp(oper=0x7fff)), + # -- L2TPv2 ------------------------------------------------------------ + Case('l2tpv2/minimal', L2TPV2, b'\x00\x02' + b'\x00\x01\x00\x02'), + Case('l2tpv2/control-every-field', L2TPV2, b'\xc8\x02' + b'\x00\x0c' + b'\x00\x01\x00\x02\x00\x03\x00\x04'), + Case('l2tpv2/offset-0', L2TPV2, b'\x02\x02' + b'\x00\x01\x00\x02' + b'\x00\x00' + b'pp'), + Case('l2tpv2/offset-3-nonzero-pad', L2TPV2, + b'\x02\x02' + b'\x00\x01\x00\x02' + b'\x00\x03' + b'\xaa\xbb\xcc' + b'pp'), + Case('l2tpv2/offset-past-the-data', L2TPV2, b'\x02\x02' + b'\x00\x01\x00\x02' + b'\x00\x09' + b'pp'), + Case('l2tpv2/reserved-bits', L2TPV2, b'\x34\xf2' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/priority', L2TPV2, b'\x01\x02' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/every-flag-and-reserved', L2TPV2, b'\xff\xf2' + b'\x00\x0e' + bytes(8) + b'\x00\x00'), + Case('l2tpv2/length-exact', L2TPV2, b'\x40\x02' + b'\x00\x0a' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/length-short-of-the-data', L2TPV2, b'\x40\x02' + b'\x00\x08' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/length-short-of-the-header', L2TPV2, b'\x40\x02' + b'\x00\x02' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/length-past-the-data', L2TPV2, b'\x40\x02' + b'\x00\x40' + b'\x00\x01\x00\x02' + b'pp'), + Case('l2tpv2/version-3', L2TPV2, b'\x00\x03' + b'\x00\x01\x00\x02'), + # -- OSPF -------------------------------------------------------------- + Case('ospf/hello-header-only', OSPF, ospf()), + Case('ospf/type-0-unassigned', OSPF, ospf(type_=0)), + Case('ospf/type-255-unassigned', OSPF, ospf(type_=255)), + Case('ospf/auth-simple-password', OSPF, ospf(auth=1, auth_data=b'password')), + Case('ospf/auth-ffff-unassigned', OSPF, ospf(auth=0xffff, auth_data=b'\xff' * 8)), + Case('ospf/auth-cryptographic-digest', OSPF, + ospf(auth=2, auth_data=b'\x00\x00\x01\x10\x00\x00\x00\x07', body=bytes(4)) + b'\x11' * 16), + Case('ospf/packet-length-short-of-the-data', OSPF, ospf(plen=24) + b'zz'), + Case('ospf/packet-length-past-the-data', OSPF, ospf(plen=200)), + Case('ospf/version-3', OSPF, ospf(version=3)), + Case('ospf/lsu-body', OSPF, ospf(type_=4, body=bytes(range(20)))), +) + +MAKE_CASES = ( + MakeCase('ethernet/make/default', ETHERNET, {}), + MakeCase('ethernet/make/unassigned-type', ETHERNET, {'type': 0xffff, 'payload': b'xy'}), + MakeCase('c-tag/make/every-tci-bit', C_TAG, {'pcp': 7, 'dei': True, 'vid': 4095, 'type': 0x88b5}), + MakeCase('arp/make/zero-length-addresses', ARP, + {'hlen': 0, 'plen': 0, 'sha': b'', 'spa': b'', 'tha': b'', 'tpa': b''}), + MakeCase('arp/make/unassigned-oper', ARP, {'oper': 0xffff}), + MakeCase('l2tpv2/make/every-optional-field', L2TPV2, + {'type': 1, 'length_flag': True, 'ns': 1, 'nr': 2, 'offset': 0, 'priority': True}), + MakeCase('l2tpv2/make/offset-pad', L2TPV2, {'offset': 3, 'padding': b'\x01\x02\x03', 'payload': b'pp'}), + MakeCase('l2tpv2/make/reserved-bits', L2TPV2, {'reserved': 0x34f0}), + MakeCase('ospf/make/default', OSPF, {}), + MakeCase('ospf/make/unassigned-codes', OSPF, {'type': 200, 'auth_type': 9}), +) + +REJECTED = { + 'l2tpv2/version-3': Reject('ProtocolError', 'L2TPv2: invalid version: 3'), +} + +KNOWN_FAILURES = ( + Gap(1455, + 'Octets past a length field shorter than the data are kept by the parse ' + '(``data``) but not by ``info``, so ``from_data`` drops them. IPv4 and ' + 'IPv6 keep them as ``trailer``; L2TPv2 (Length) and OSPF (Packet Length) ' + 'have no such key. pcapkit/protocols/link/l2tpv2.py, ' + 'pcapkit/protocols/application/ospf.py', + # The rebuild stops at the declared length. + 'MISMATCH', ('8 octets 4002000800010002 != ', + '24 octets 020100180102030400000000abcd00000000000000000000 != '), + ('l2tpv2/length-short-of-the-data', 'l2tpv2/length-short-of-the-data/cut9', + 'ospf/packet-length-short-of-the-data', 'ospf/packet-length-short-of-the-data/cut25')), + Gap(1458, edge.ZERO_FILLED_SHORT_READ, 'PADDED', '', + ('ethernet/header-only-*/cut*', 'ethernet/length-field-0/cut7', + 'ethernet/length-field-1500/cut9', 'ethernet/unassigned-type-ffff/cut8', + 'c-tag/pcp7-dei-vid4095/cut3', 'c-tag/vid0-no-payload/cut*', + 'c-tag/header-only-ipv4-type/cut*', 'arp/hlen0-plen0/cut*', 'arp/reply/cut*', + 'arp/oper-*-unassigned/cut*', 'arp/htype-fffe-unassigned/cut*', + 'arp/ptype-unknown-plen3/cut*', 'arp/ipv6-plen16/cut*', 'arp/hlen255/cut*', + 'arp/18-octet-trailer/cut23', 'rarp/*/cut*', 'l2tpv2/offset-past-the-data', + 'l2tpv2/offset-past-the-data/cut*', 'l2tpv2/minimal/cut*', + 'l2tpv2/control-every-field/cut*', 'l2tpv2/offset-0/cut5', + 'l2tpv2/offset-3-nonzero-pad/cut6', 'l2tpv2/reserved-bits/cut4', 'l2tpv2/priority/cut4', + 'l2tpv2/every-flag-and-reserved/cut*', 'l2tpv2/length-*/cut5', + 'ospf/hello-header-only/cut*', 'ospf/type-*-unassigned/cut*', + 'ospf/auth-simple-password/cut*', 'ospf/auth-ffff-unassigned/cut*', + 'ospf/auth-cryptographic-digest/cut22', 'ospf/packet-length-short-of-the-data/cut13', + 'ospf/packet-length-past-the-data/cut*', 'ospf/version-3/cut*', 'ospf/lsu-body/cut22')), +) + + +@unittest.skipUnless(edge.HAS_RUNTIME, 'runtime dependencies not installed') +class LinkEdgeRoundTripTests(edge.EdgeRoundTripBase): + """Link-layer edge cases.""" + + CASES = CASES + MAKE_CASES = MAKE_CASES + REJECTED = REJECTED + KNOWN_FAILURES = KNOWN_FAILURES + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/transport/test_transport_edge_roundtrip_unit.py b/tests/protocols/transport/test_transport_edge_roundtrip_unit.py new file mode 100644 index 0000000000..2839cf2f57 --- /dev/null +++ b/tests/protocols/transport/test_transport_edge_roundtrip_unit.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Transport-layer edge cases round-trip byte for byte. C.f. #1202. + +TCP and its option area, UDP, and SCTP with its chunks, at their minimum and +maximum lengths, with reserved bits and every flag set, with zero-length +values and padding variants, and with unassigned kinds and chunk types (all +four high-bit actions). Per-code option and chunk coverage is +:mod:`tests.protocols.test_option_roundtrip_unit`'s; these are the header-level +cases it does not build. The harness is :mod:`tests.protocols._edge_roundtrip`. + +Every case builds its own octets in memory and reads no capture. + +""" + +from __future__ import annotations + +import struct +import unittest + +from tests.protocols import _edge_roundtrip as edge +from tests.protocols._edge_roundtrip import Case, Gap, MakeCase, Reject + +TCP = 'pcapkit.protocols.transport.tcp:TCP' +UDP = 'pcapkit.protocols.transport.udp:UDP' +SCTP = 'pcapkit.protocols.transport.sctp:SCTP' + + +def tcp(opts: bytes = b'', payload: bytes = b'', flags: int = 0x002, reserved: int = 0, + offset: 'int | None' = None, urgent: int = 0, ports: 'tuple[int, int]' = (40000, 40001)) -> bytes: + """A TCP header with ``opts``, then ``payload``.""" + offset = (20 + len(opts)) // 4 if offset is None else offset + return struct.pack('!HHIIHHHH', *ports, 1, 0, (offset << 12) | (reserved << 9) | flags, + 65535, 0, urgent) + opts + payload + + +def udp(payload: bytes = b'', length: 'int | None' = None, ports: 'tuple[int, int]' = (40000, 40001), + checksum: int = 0) -> bytes: + """A UDP header, then ``payload``.""" + length = 8 + len(payload) if length is None else length + return struct.pack('!HHHH', *ports, length, checksum) + payload + + +def chunk(type_: int, flags: int, value: bytes, pad: 'bytes | None' = None) -> bytes: + """An SCTP chunk, zero-padded to four octets unless ``pad`` is given.""" + length = 4 + len(value) + pad = bytes(-length % 4) if pad is None else pad + return struct.pack('!BBH', type_, flags, length) + value + pad + + +#: SCTP common header. +COMMON = struct.pack('!HHII', 5000, 5001, 0xdeadbeef, 0) +#: DATA chunk TSN, stream, sequence and PPID. +DATA_HEAD = b'\x00\x00\x00\x01' + b'\x00\x01\x00\x02' + bytes(4) +#: INIT chunk fixed part. +INIT_HEAD = b'\x00\x00\x00\x01' + b'\x00\x00\xff\xff' + b'\x00\x01\x00\x01' + b'\x00\x00\x00\x01' + +CASES = ( + # -- TCP --------------------------------------------------------------- + Case('tcp/min-header', TCP, tcp()), + Case('tcp/every-flag', TCP, tcp(flags=0x1ff)), + Case('tcp/reserved-bits-set', TCP, tcp(reserved=7)), + Case('tcp/urgent-pointer-max', TCP, tcp(flags=0x020, urgent=0xffff)), + Case('tcp/offset-15-nop-pad', TCP, tcp(opts=b'\x01' * 40)), + Case('tcp/offset-15-eol-pad', TCP, tcp(opts=bytes(40))), + Case('tcp/eol-then-nonzero-pad', TCP, tcp(opts=b'\x00\xaa\xbb\xcc')), + Case('tcp/nop-eol-nop-eol', TCP, tcp(opts=b'\x01\x00\x01\x00')), + Case('tcp/mss-then-eol-pad', TCP, tcp(opts=b'\x02\x04\x05\xb4' + bytes(4))), + Case('tcp/unassigned-kind-length-2', TCP, tcp(opts=b'\xfd\x02\x00\x00')), + Case('tcp/unassigned-kind-200', TCP, tcp(opts=b'\xc8\x06abcd\x00\x00')), + Case('tcp/unassigned-kind-max-length-40', TCP, tcp(opts=b'\x63\x28' + bytes(38))), + Case('tcp/sack-permitted-then-nops', TCP, tcp(opts=b'\x04\x02\x01\x01')), + Case('tcp/window-scale-shift-255', TCP, tcp(opts=b'\x03\x03\xff\x00')), + Case('tcp/timestamps-max', TCP, tcp(opts=b'\x01\x01\x08\x0a' + b'\xff' * 8)), + Case('tcp/payload-unknown-ports', TCP, tcp(payload=b'hello', ports=(1, 2))), + Case('tcp/offset-4', TCP, tcp(offset=4)), + Case('tcp/offset-past-the-data', TCP, tcp(offset=15)), + # -- UDP --------------------------------------------------------------- + Case('udp/min-header', UDP, udp()), + Case('udp/length-0', UDP, udp(b'abc', length=0)), + Case('udp/length-short-of-the-data', UDP, udp(b'abcd', length=10)), + Case('udp/length-past-the-data', UDP, udp(b'ab', length=100)), + Case('udp/length-short-of-the-header', UDP, udp(b'ab', length=4)), + Case('udp/checksum-ffff', UDP, udp(b'ab', checksum=0xffff)), + Case('udp/ports-0', UDP, udp(b'ab', ports=(0, 0))), + Case('udp/ports-65535', UDP, udp(b'ab', ports=(65535, 65535))), + # -- SCTP -------------------------------------------------------------- + Case('sctp/common-header-only', SCTP, COMMON), + Case('sctp/data-1-octet-3-pad', SCTP, COMMON + chunk(0, 0x03, DATA_HEAD + b'X')), + Case('sctp/data-nonzero-pad', SCTP, COMMON + chunk(0, 0x03, DATA_HEAD + b'X', pad=b'\xff' * 3)), + Case('sctp/data-every-flag', SCTP, COMMON + chunk(0, 0xff, DATA_HEAD + b'XYZW')), + Case('sctp/unassigned-type-action-00', SCTP, COMMON + chunk(0x3f, 0, b'ab')), + Case('sctp/unassigned-type-action-01', SCTP, COMMON + chunk(0x7f, 0xff, b'')), + Case('sctp/unassigned-type-action-10', SCTP, COMMON + chunk(0xbf, 0, b'abcde')), + Case('sctp/unassigned-type-action-11', SCTP, COMMON + chunk(0xff, 0x55, b'abcdefgh')), + Case('sctp/shutdown-ack-every-flag', SCTP, COMMON + chunk(8, 0xff, b'')), + Case('sctp/cookie-ack', SCTP, COMMON + chunk(11, 0, b'')), + Case('sctp/abort-t-bit', SCTP, COMMON + chunk(6, 1, b'')), + Case('sctp/abort-unassigned-cause', SCTP, COMMON + chunk(6, 0, struct.pack('!HH', 0xfff0, 5) + b'z\x00\x00\x00')), + Case('sctp/init-unassigned-parameter', SCTP, + COMMON + chunk(1, 0, INIT_HEAD + struct.pack('!HH', 0xbfff, 5) + b'q\x00\x00\x00')), + Case('sctp/two-empty-chunks', SCTP, COMMON + chunk(11, 0, b'') + chunk(14, 0, b'')), + Case('sctp/heartbeat-empty-info', SCTP, COMMON + chunk(4, 0, struct.pack('!HH', 1, 4))), + Case('sctp/last-chunk-unpadded', SCTP, COMMON + chunk(0x3f, 0, b'a', pad=b'')), + Case('sctp/data-no-user-data', SCTP, COMMON + chunk(0, 0xff, DATA_HEAD)), +) + +MAKE_CASES = ( + MakeCase('tcp/make/default', TCP, {}), + MakeCase('tcp/make/every-flag', TCP, + {'ns': True, 'cwr': True, 'ece': True, 'urg': True, 'ack': True, 'psh': True, + 'rst': True, 'syn': True, 'fin': True, 'urgent': 65535, 'window': 0}), + MakeCase('udp/make/default', UDP, {}), + MakeCase('udp/make/ports-max', UDP, {'srcport': 65535, 'dstport': 65535, 'payload': b'x'}), + MakeCase('sctp/make/default', SCTP, {}), +) + +REJECTED = { + 'tcp/offset-4': Reject('ProtocolError', 'resolved to a negative length'), + 'tcp/offset-past-the-data': Reject('ProtocolError', 'TCP: header length 60 runs past the end of the data'), + 'sctp/data-no-user-data': Reject('ProtocolError', 'SCTP: [Chunk 0] invalid format'), +} + +KNOWN_FAILURES = ( + # ``sctp/last-chunk-unpadded`` is uncut: a final chunk sent without its pad + # octets reads the pad short, and the rebuild writes three zero octets. + Gap(1458, edge.ZERO_FILLED_SHORT_READ, 'PADDED', '', + ('sctp/last-chunk-unpadded', 'sctp/last-chunk-unpadded/cut8', + 'sctp/common-header-only/cut*', 'sctp/data-1-octet-3-pad/cut31', + 'sctp/data-nonzero-pad/cut31', 'sctp/unassigned-type-action-00/cut*', + 'sctp/unassigned-type-action-01/cut8', 'sctp/unassigned-type-action-10/cut23', + 'sctp/shutdown-ack-every-flag/cut8', 'sctp/cookie-ack/cut8', 'sctp/abort-t-bit/cut8', + 'sctp/two-empty-chunks/cut10', 'sctp/heartbeat-empty-info/cut10', 'udp/min-header/cut*', + 'udp/length-0/cut5', 'udp/length-short-of-the-data/cut6', 'udp/length-past-the-data/cut5', + 'udp/length-short-of-the-header/cut5', 'udp/checksum-ffff/cut5', 'udp/ports-0/cut5', + 'udp/ports-65535/cut5')), +) + + +@unittest.skipUnless(edge.HAS_RUNTIME, 'runtime dependencies not installed') +class TransportEdgeRoundTripTests(edge.EdgeRoundTripBase): + """Transport-layer edge cases.""" + + CASES = CASES + MAKE_CASES = MAKE_CASES + REJECTED = REJECTED + KNOWN_FAILURES = KNOWN_FAILURES + + +if __name__ == '__main__': + unittest.main()