diff --git a/pcapkit/protocols/data/misc/pcapng.py b/pcapkit/protocols/data/misc/pcapng.py index 01f6323d7e..fcd16ae9a1 100644 --- a/pcapkit/protocols/data/misc/pcapng.py +++ b/pcapkit/protocols/data/misc/pcapng.py @@ -89,6 +89,11 @@ class Option(Data): #: Option data length. length: 'int' + if TYPE_CHECKING: + #: Padding of the option value to 32 bits, as captured; present only + #: when it is not all zeros (:issue:`1437`). + padding: 'bytes' + @info_final class UnknownOption(Option): @@ -147,6 +152,10 @@ class SectionHeaderBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' + def __init__(self, type: 'Enum_BlockType', length: 'int', byteorder: 'Literal["big", "little"]', version: 'VersionInfo', # pylint: disable=unused-argument section_length: 'int', options: 'OrderedMultiDict[Enum_OptionType, Option]') -> None: ... @@ -343,6 +352,13 @@ class InterfaceDescriptionBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Reserved octets, as captured; present only when they are not all + #: zeros (:issue:`1437`). + reserved: 'bytes' + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' + def __init__(self, type: 'Enum_BlockType', length: 'int', linktype: 'Enum_LinkType', snaplen: 'int', options: 'OrderedMultiDict[Enum_OptionType, Option]') -> None: ... @@ -475,6 +491,12 @@ class EnhancedPacketBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Padding of the packet data to 32 bits, as captured; present only when + #: it is not all zeros (:issue:`1437`). + padding_data: 'bytes' + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' #: Protocol chain. protocols: 'str' @@ -508,6 +530,9 @@ def __post_init__(self) -> 'None': ) if TYPE_CHECKING: + #: Padding of the packet data to 32 bits, as captured; present only when + #: it is not all zeros (:issue:`1437`). + padding_data: 'bytes' #: Protocol chain. protocols: 'str' @@ -530,6 +555,11 @@ class NameResolutionRecord(Data): #: Record value length. length: 'int' + if TYPE_CHECKING: + #: Padding of the record value to 32 bits, as captured; present only + #: when it is not all zeros (:issue:`1437`). + padding: 'bytes' + @info_final class UnknownRecord(NameResolutionRecord): @@ -637,6 +667,9 @@ def __post_init__(self) -> 'None': ) if TYPE_CHECKING: + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' #: Name resolution mapping (IP address -> name). mapping: 'MultiDict[IPv4Address | IPv6Address, str]' #: Name resolution mapping (name -> IP address). @@ -744,6 +777,10 @@ class InterfaceStatisticsBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' + def __init__(self, type: 'Enum_BlockType', length: 'int', interface_id: 'int', timestamp: 'dt_type', timestamp_epoch: 'Decimal', options: 'OrderedMultiDict[Enum_OptionType, Option]') -> 'None': ... @@ -847,6 +884,13 @@ class DecryptionSecretsBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Padding of the secrets data to 32 bits, as captured; present only when + #: it is not all zeros (:issue:`1437`). + padding_data: 'bytes' + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' + def __init__(self, type: 'Enum_BlockType', length: 'int', secrets_type: 'Enum_SecretsType', secrets_length: 'int', secrets_data: 'DSBSecrets', options: 'OrderedMultiDict[Enum_OptionType, Option]') -> 'None': ... @@ -948,6 +992,12 @@ class PacketBlock(PCAPNG): options: 'OrderedMultiDict[Enum_OptionType, Option]' if TYPE_CHECKING: + #: Padding of the packet data to 32 bits, as captured; present only when + #: it is not all zeros (:issue:`1437`). + padding_data: 'bytes' + #: Octets after ``opt_endofopt``, as captured; present only when the + #: option area holds any (:issue:`1437`). + padding_opts: 'bytes' #: Protocol chain. protocols: 'str' diff --git a/pcapkit/protocols/misc/pcapng.py b/pcapkit/protocols/misc/pcapng.py index f6fca7bfb2..ffa68313bc 100644 --- a/pcapkit/protocols/misc/pcapng.py +++ b/pcapkit/protocols/misc/pcapng.py @@ -223,6 +223,7 @@ KwArg(Any)], Schema_DSBSecrets] _FlagsEnum = TypeVar('_FlagsEnum', 'PacketDirection', 'PacketReception') + _S = TypeVar('_S', bound='Schema | bytes') # check Python version py37 = ((version_info := sys.version_info).major >= 3 and version_info.minor >= 7) @@ -1784,6 +1785,72 @@ def _block_area(block: 'Data_PCAPNG', name: 'str') -> 'Any': """ return getattr(block, f'{name}_raw', getattr(block, name)) + @staticmethod + def _read_padding(schema: 'Schema', data: 'Any', name: 'str' = 'padding', + key: 'Optional[str]' = None, *, nonzero: 'bool' = True) -> 'None': + """Keep a padding field of a parsed schema on its data model. + + Args: + schema: Parsed block, option or record schema. + data: Its data model, updated in place. + name: Name of the padding field on ``schema``. + key: Name to keep it as on ``data``; defaults to ``name``. + nonzero: Keep the octets only when they are not all zeros -- the + form a fresh build writes -- rather than whenever there are any. + + The octets are kept as ``key`` only when there is something a rebuild + would otherwise lose, so a data model reading as before for well-formed + input gains no key (:issue:`1437`). :meth:`_make_padding` writes them back. + + """ + padding = schema.__dict__.get(name) + if isinstance(padding, bytes) and (any(padding) if nonzero else padding): + data.__update__([(key or name, padding)]) + + @staticmethod + def _make_padding(schema: '_S', source: 'Any', name: 'str' = 'padding', + key: 'Optional[str]' = None) -> '_S': + """Write the padding octets :meth:`_read_padding` kept back onto a schema. + + Args: + schema: Constructed block, option or record schema, updated in place. + source: Data model or keyword arguments it was made from, if any. + name: Name of the padding field on ``schema``. + key: Name the octets are kept as on ``source``; defaults to ``name``. + + Returns: + The ``schema`` given. Without kept octets its padding stays zeros. + + """ + if source is None or isinstance(schema, bytes): + return schema + padding = source.get(key or name) + if isinstance(padding, (bytes, bytearray)) and name in schema.__fields__: + setattr(schema, name, bytes(padding)) + return schema + + def _make_options_trailer(self, block: 'Optional[Data_PCAPNG]', + options: 'list[Schema_Option | bytes]') -> 'int': + """Append the octets a parsed block held past its ``opt_endofopt``. + + Args: + block: Block data model, if any. + options: Constructed options, extended in place. + + Returns: + Number of octets appended. + + An option area ends at its ``opt_endofopt`` and the octets after it are + read as padding. :meth:`_read_padding` keeps them as ``padding_opts``, + and they are written back after the options, verbatim (:issue:`1437`). + + """ + trailer = block.get('padding_opts') if block is not None else None + if not isinstance(trailer, bytes) or not trailer: + return 0 + options.append(trailer) + return len(trailer) + @classmethod def _make_packet_data(cls, block: 'Data_PCAPNG') -> 'bytes | ProtocolBase': """Create the packet data of an EPB, SPB or PB from its data model. @@ -2030,6 +2097,8 @@ def _read_block_shb(self, schema: 'Schema_SectionHeaderBlock', *, section_length=schema.section_length, options=self._read_pcapng_options(schema.options), ) + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding', 'padding_opts', nonzero=False) return data def _read_block_idb(self, schema: 'Schema_InterfaceDescriptionBlock', *, @@ -2073,6 +2142,10 @@ def _read_block_idb(self, schema: 'Schema_InterfaceDescriptionBlock', *, snaplen=schema.snaplen, options=self._read_pcapng_options(schema.options), ) + # NOTE: The reserved octets, if they are not all zeros (:issue:`1437`). + self._read_padding(schema, data, 'reserved') + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding', 'padding_opts', nonzero=False) return data def _read_block_epb(self, schema: 'Schema_EnhancedPacketBlock', *, @@ -2135,6 +2208,11 @@ def _read_block_epb(self, schema: 'Schema_EnhancedPacketBlock', *, original_len=schema.original_len, options=self._read_pcapng_options(schema.options), ) + # NOTE: The padding of the packet data to 32 bits, if it is not all + # zeros (:issue:`1437`). + self._read_padding(schema, data, 'padding_data') + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding_opts', 'padding_opts', nonzero=False) # NOTE: A captured length running past the block is kept as declared, # and the packet data is the octets the block holds (:issue:`1405`). @@ -2186,6 +2264,9 @@ def _read_block_spb(self, schema: 'Schema_SimplePacketBlock', *, captured_len=len(schema.packet_data), original_len=schema.original_len, ) + # NOTE: The padding of the packet data to 32 bits, if it is not all + # zeros (:issue:`1437`). + self._read_padding(schema, data, 'padding', 'padding_data') # NOTE: A block holding more packet data than the snaplen of interface 0 # allows keeps the excess (:issue:`1384`); the specification says the @@ -2250,6 +2331,8 @@ def _read_block_nrb(self, schema: 'Schema_NameResolutionBlock', *, records=self._read_nrb_records(schema.records), options=self._read_pcapng_options(schema.options), ) + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding', 'padding_opts', nonzero=False) return data def _read_block_isb(self, schema: 'Schema_InterfaceStatisticsBlock', *, @@ -2300,6 +2383,8 @@ def _read_block_isb(self, schema: 'Schema_InterfaceStatisticsBlock', *, timestamp_epoch=timestamp_epoch, options=self._read_pcapng_options(schema.options), ) + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding', 'padding_opts', nonzero=False) return data def _read_block_systemd(self, schema: 'Schema_SystemdJournalExportBlock', *, @@ -2404,6 +2489,11 @@ def _read_block_dsb(self, schema: 'Schema_DecryptionSecretsBlock', *, secrets_data=secrets_data, options=self._read_pcapng_options(schema.options), ) + # NOTE: The padding of the secrets to 32 bits, if it is not all zeros + # (:issue:`1437`). + self._read_padding(schema, data, 'padding_data') + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding_opts', 'padding_opts', nonzero=False) return data def _read_block_cb(self, schema: 'Schema_CustomBlock', *, @@ -2515,6 +2605,11 @@ def _read_block_packet(self, schema: 'Schema_PacketBlock', *, original_len=schema.original_length, options=self._read_pcapng_options(schema.options), ) + # NOTE: The padding of the packet data to 32 bits, if it is not all + # zeros (:issue:`1437`). + self._read_padding(schema, data, 'padding_data') + # NOTE: The octets after ``opt_endofopt``, if any (:issue:`1437`). + self._read_padding(schema, data, 'padding_opts', 'padding_opts', nonzero=False) # NOTE: A captured length running past the block is kept as declared, # and the packet data is the octets the block holds (:issue:`1405`). @@ -2711,6 +2806,8 @@ def _read_pcapng_options(self, options_schema: 'list[Schema_Option]') -> 'Option else: meth = name[0] data = meth(schema, options=options) + # NOTE: The padding to 32 bits, if it is not all zeros (:issue:`1437`). + self._read_padding(schema, data) # record option data options.add(type, data) @@ -3826,6 +3923,8 @@ def _read_nrb_records(self, records_schema: 'list[Schema_NameResolutionRecord]') else: meth = name[0] data = meth(schema, records=records) + # NOTE: The padding to 32 bits, if it is not all zeros (:issue:`1437`). + self._read_padding(schema, data) # record option data records.add(type, data) @@ -4139,6 +4238,7 @@ def _make_block_shb(self, block: 'Optional[Data_SectionHeaderBlock]' = None, *, options_value, total_length = self._make_pcapng_options(options, namespace='opt') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) return Schema_SectionHeaderBlock( length=total_length + 28, @@ -4188,14 +4288,15 @@ def _make_block_idb(self, block: 'Optional[Data_InterfaceDescriptionBlock]' = No options_value, total_length = self._make_pcapng_options(options, namespace='if') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) - return Schema_InterfaceDescriptionBlock( + return self._make_padding(Schema_InterfaceDescriptionBlock( length=total_length + 20, linktype=linktype_val, snaplen=snaplen, options=options_value, length2=total_length + 20, - ) + ), block, 'reserved') def _make_block_epb(self, block: 'Optional[Data_EnhancedPacketBlock]' = None, *, interface_id: 'int' = 0, @@ -4252,8 +4353,9 @@ def _make_block_epb(self, block: 'Optional[Data_EnhancedPacketBlock]' = None, *, options_value, total_length = self._make_pcapng_options(options, namespace='epb') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) - return Schema_EnhancedPacketBlock( + return self._make_padding(Schema_EnhancedPacketBlock( length=total_length + 32 + packet_len, interface_id=interface_id, timestamp_high=timestamp_high, @@ -4263,7 +4365,7 @@ def _make_block_epb(self, block: 'Optional[Data_EnhancedPacketBlock]' = None, *, packet_data=packet_data, options=options_value, length2=total_length + 32 + packet_len, - ) + ), block, 'padding_data') def _make_block_spb(self, block: 'Optional[Data_SimplePacketBlock]' = None, *, original_len: 'Optional[int]' = None, @@ -4302,12 +4404,12 @@ def _make_block_spb(self, block: 'Optional[Data_SimplePacketBlock]' = None, *, packet_data = bytes(packet_data)[:captured_len] packet_len = math.ceil(len(packet_data) / 4) * 4 - return Schema_SimplePacketBlock( + return self._make_padding(Schema_SimplePacketBlock( length=packet_len + 16, original_len=original_len, packet_data=packet_data, length2=packet_len + 16, - ) + ), block, 'padding', 'padding_data') def _make_block_short( self, block: 'Data_EnhancedPacketBlock | Data_PacketBlock | Data_SectionHeaderBlock | Data_InterfaceDescriptionBlock', # pylint: disable=line-too-long @@ -4354,6 +4456,7 @@ def _make_block_nrb(self, block: 'Optional[Data_NameResolutionBlock]' = None, *, options_value, options_length = self._make_pcapng_options(options, namespace='ns') else: options_value, options_length = [], 0 + options_length += self._make_options_trailer(block, options_value) return Schema_NameResolutionBlock( length=options_length + records_length + 12, @@ -4394,6 +4497,7 @@ def _make_block_isb(self, block: 'Optional[Data_InterfaceStatisticsBlock]' = Non options_value, total_length = self._make_pcapng_options(options, namespace='isb') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) return Schema_InterfaceStatisticsBlock( length=total_length + 24, @@ -4500,15 +4604,16 @@ def _make_block_dsb(self, block: 'Optional[Data_DecryptionSecretsBlock]' = None, options_value, total_length = self._make_pcapng_options(options, namespace='dsb') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) - return Schema_DecryptionSecretsBlock( + return self._make_padding(Schema_DecryptionSecretsBlock( length=total_length + secrets_padded + 20, secrets_type=secrets_type_val, secrets_length=secrets_length, secrets_data=secrets_data_val, options=options_value, length2=total_length + secrets_padded + 20, - ) + ), block, 'padding_data') def _make_block_cb(self, block: 'Optional[Data_CustomBlock]' = None, *, pen: 'int' = 0, @@ -4622,8 +4727,9 @@ def _make_block_packet(self, block: 'Optional[Data_PacketBlock]' = None, *, options_value, total_length = self._make_pcapng_options(options, namespace='pack') else: options_value, total_length = [], 0 + total_length += self._make_options_trailer(block, options_value) - return Schema_PacketBlock( + return self._make_padding(Schema_PacketBlock( length=total_length + 32 + packet_len, interface_id=interface_id, drop_count=drop_count, @@ -4634,7 +4740,7 @@ def _make_block_packet(self, block: 'Optional[Data_PacketBlock]' = None, *, packet_data=packet_data, options=options_value, length2=total_length + 32 + packet_len, - ) + ), block, 'padding_data') def _make_pcapng_options(self, options: 'Option | list[Schema_Option | tuple[Enum_OptionType, dict[str, Any]] | bytes] | bytes', namespace: 'str') -> 'tuple[list[Schema_Option | bytes], int]': @@ -4688,7 +4794,7 @@ def _make_pcapng_options(self, options: 'Option | list[Schema_Option | tuple[Enu else: meth = name[1] - data = meth(code, **args) + data = self._make_padding(meth(code, **args), args) data_len = len(data.pack()) options_list.append(data) @@ -4715,7 +4821,7 @@ def _make_pcapng_options(self, options: 'Option | list[Schema_Option | tuple[Enu else: meth = name[1] - data = meth(code, option) + data = self._make_padding(meth(code, option), option) data_len = len(data.pack()) options_list.append(data) @@ -6151,7 +6257,7 @@ def _make_nrb_records(self, records: 'Record | list[Schema_NameResolutionRecord else: meth = name[1] - data = meth(code, **args) + data = self._make_padding(meth(code, **args), args) data_len = len(data.pack()) records_list.append(data) @@ -6177,7 +6283,7 @@ def _make_nrb_records(self, records: 'Record | list[Schema_NameResolutionRecord else: meth = name[1] - data = meth(code, record) + data = self._make_padding(meth(code, record), record) data_len = len(data.pack()) records_list.append(data) diff --git a/tests/protocols/misc/test_pcapng_padding_kept_1437_unit.py b/tests/protocols/misc/test_pcapng_padding_kept_1437_unit.py new file mode 100644 index 0000000000..c665def715 --- /dev/null +++ b/tests/protocols/misc/test_pcapng_padding_kept_1437_unit.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #1437 (part B): PCAP-NG padding rebuilds as captured. + +#1436 made :class:`~pcapkit.corekit.fields.strings.PaddingField` pack the octets +it is given, but the PCAP-NG reader never handed them on, so non-zero padding +parsed silently and came back zeroed: the packet data padding of an SPB, EPB and +Packet Block, the secrets padding of a DSB, the IDB reserved octets, and the +padding of every option and NRB record. Octets after ``opt_endofopt`` were +dropped outright, shrinking the block. Each is now kept on the data model -- +padding only when it is not all zeros, the octets after ``opt_endofopt`` +whenever there are any -- and written back by ``make``. + +Every case builds its own octets in memory and reads no capture. + +""" + +import struct +import warnings + +from tests.protocols.misc.test_pcapng_roundtrip2_unit import PCAPNGTestCase + + +def _block(type_: 'int', body: 'bytes') -> 'bytes': + """A little-endian block of ``type_`` around ``body``.""" + length = len(body) + 12 + return struct.pack(' 'bytes': + """A little-endian option of ``code`` holding ``value``, then ``padding``.""" + return struct.pack(' None: + for name, octets in BLOCKS.items(): + with self.subTest(name), warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.assertRebuilds(octets) + + def test_padding_is_kept_on_the_data_model(self) -> None: + """Each site's octets land under its key, exactly as captured.""" + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + spb = self._parse(BLOCKS['SPB packet data padding']).info + epb = self._parse(BLOCKS['EPB octets after opt_endofopt']).info + idb = self._parse(BLOCKS['IDB reserved octets']).info + shb = self._parse(BLOCKS['SHB opt_comment padding']).info + nrb = self._parse(BLOCKS['NRB IPv4 record padding']).info + self.assertEqual(spb.padding_data, b'\xee' * 3) + self.assertEqual(epb.padding_opts, b'\xdd' * 4) + self.assertEqual(idb.reserved, b'\xbb\xaa') + self.assertEqual(shb.options[1].padding, b'\xee' * 3) + self.assertEqual(next(iter(nrb.records.values())).padding, b'\xee' * 2) + + def test_zero_padding_is_not_recorded(self) -> None: + """All-zero padding, and an area ending at ``opt_endofopt``, add no key.""" + with warnings.catch_warnings(): + warnings.simplefilter('ignore') # one octet of Ethernet is too short + spb = self._parse(_block(3, struct.pack(' None: + from pcapkit.const.pcapng.block_type import BlockType + from pcapkit.const.pcapng.option_type import OptionType + from pcapkit.protocols.misc.pcapng import PCAPNG + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') # one octet of Ethernet is too short + spb = PCAPNG(num=2, sct=1, ctx=self._context(), type=BlockType.Simple_Packet_Block, + block={'packet_data': b'a'}) + self.assertEqual(spb.data.hex(), '03000000140000000100000061000000' '14000000') + + def shb(comment: 'dict[str, object]') -> 'str': + block = {'byteorder': 'little', 'options': [(OptionType.opt_comment, comment)]} + return PCAPNG(num=0, sct=1, ctx=None, type=BlockType.Section_Header_Block, + block=block).data[24:32].hex() + + self.assertEqual(shb({'comment': 'x'}), '0100010078000000') + # an option given as keyword arguments takes its padding as one + self.assertEqual(shb({'comment': 'x', 'padding': b'\xee' * 3}), '0100010078eeeeee') + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/tests/protocols/test_ipv4_l2tp_padding_guard_1437_unit.py b/tests/protocols/test_ipv4_l2tp_padding_guard_1437_unit.py new file mode 100644 index 0000000000..9c069e858f --- /dev/null +++ b/tests/protocols/test_ipv4_l2tp_padding_guard_1437_unit.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""GitHub issue #1437 (part B): IPv4 and L2TP padding already rebuild as captured. + +The issue listed the IPv4 Quick-Start report "Not Used" octet, the IPv4 octets +after the End of Option List, and the L2TPv2 offset pad as sites that might +still zero-fill on rebuild. Measured on ``main`` at ``16c87f901``, all of them +already round-trip byte for byte, so nothing changed; these cases guard that. + +Every case builds its own octets in memory and reads no capture. Classes are +imported inside each test, after :func:`~tests._support.reimport_once_per_class`. + +""" + +import io +import unittest + +from tests._support import reimport_once_per_class + + +def ipv4(options: 'str') -> 'bytes': + """An IPv4 header (protocol 59, no payload) carrying ``options``.""" + opts = bytes.fromhex(options) + ihl = 5 + len(opts) // 4 + return (bytes([0x40 | ihl, 0]) + (ihl * 4).to_bytes(2, 'big') + bytes.fromhex('00010000403b0000') + + bytes([10, 0, 0, 1, 10, 0, 0, 2]) + opts) + + +#: IPv4 options -> name. +IPV4 = { + 'Quick-Start report, Not Used octet aa': '19088aaa00000004', + 'Quick-Start request, reserved bits set': '19080aff0000000f', + 'octets after EOOL': '00aabbcc', + 'octets after NOP NOP EOOL': '010100cc', +} + +#: L2TPv2 messages whose offset pad is not zero. +L2TPV2 = { + 'data message': '0202000000000003aabbcc7878', + 'with length': '4202000e000000000003aabbcc78', +} + + +class TestIPv4L2TPPaddingGuard(unittest.TestCase): + """Pin the wire form of IPv4 and L2TPv2 padding across rebuilds.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_ipv4_rebuilds_byte_for_byte(self) -> None: + from pcapkit.protocols.internet.ipv4 import IPv4 + + for name, options in IPV4.items(): + with self.subTest(name): + raw = ipv4(options) + parsed = IPv4(io.BytesIO(raw), len(raw)) + self.assertEqual(bytes(IPv4.from_data(parsed.info).data).hex(), raw.hex()) + + def test_l2tpv2_rebuilds_byte_for_byte(self) -> None: + from pcapkit.protocols.link.l2tpv2 import L2TPv2 + + for name, wire in L2TPV2.items(): + with self.subTest(name): + raw = bytes.fromhex(wire) + parsed = L2TPv2(io.BytesIO(raw), len(raw)) + self.assertEqual(parsed.info.padding, b'\xaa\xbb\xcc') + self.assertEqual(bytes(L2TPv2.from_data(parsed.info).data).hex(), raw.hex()) + + +if __name__ == '__main__': + unittest.main()