From 8dac30e9cd7e3ae74da99e5a37f0cda05aaaf0f1 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 8 Oct 2026 18:58:10 -0400 Subject: [PATCH] fix(pcapng): keep captured padding octets on rebuild (#1437) - PCAP-NG padding rebuilt as zeros: SPB/EPB/Packet Block packet data padding, DSB secrets padding, IDB reserved octets, and the padding of every option and NRB record. Each is now kept on the data model when it is not all zeros (`padding_data`, `reserved`, `padding`) and written back by make(); fresh builds still write zeros. - Octets after `opt_endofopt` were dropped, shrinking the block. They are kept as `padding_opts` and written back after the options. - IPv4 Quick-Start/EOOL padding and the L2TPv2 offset pad already round-trip on main; guard tests added, no code change. Refs #1437 (part B). --- pcapkit/protocols/data/misc/pcapng.py | 50 +++++++ pcapkit/protocols/misc/pcapng.py | 134 ++++++++++++++++-- .../test_pcapng_padding_kept_1437_unit.py | 133 +++++++++++++++++ .../test_ipv4_l2tp_padding_guard_1437_unit.py | 70 +++++++++ 4 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 tests/protocols/misc/test_pcapng_padding_kept_1437_unit.py create mode 100644 tests/protocols/test_ipv4_l2tp_padding_guard_1437_unit.py 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()