Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pcapkit/corekit/fields/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def unpack(self, buffer: 'bytes | IO[bytes]', packet: 'dict[str, Any]') -> 'byte
field = self._item_type(packet)

if is_schema:
data = cast('SchemaField', self._item_type).unpack(file, packet)
data = cast('SchemaField', field).unpack(file, packet)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this independently rather than taking the PR's word for it.

Reproduced both sides of the fix. Reverted this one token locally (field → self._item_type) and reran tests/corekit/test_fields_collections.py::ListFieldSchemaItemTests: both new tests fail with exactly the errors quoted in the PR body —

test_a_length_callback_is_honoured: AssertionError: Lists differ: [-1, -1] != [2, 2]
test_a_callback_is_honoured:        AssertionError: Lists differ: ['A', 'A'] != ['A', 'B']

Restored the fix and the whole module passes, 9/9.

Census re-derived, not trusted. git grep -n "item_type=SchemaField" origin/main -- pcapkit finds exactly the four sites named (hip.py:260, mh.py:535, sctp.py:702, tcp.py:393) and no fifth. No SchemaField subclass exists anywhere in the package (git grep -n "SchemaField)" ... | grep -i class is empty), and _item_type is never read or written outside this file (git grep -n "_item_type" origin/main -- pcapkit | grep -v collections.py is empty), so isinstance(self._item_type, SchemaField) can only be true via one of those four literal declarations — no dynamic or indirect route in. None of the four passes callback=, and the length=lambda pkt: … each carries is on the outer ListField, not the inner SchemaField; the SchemaField itself gets either no length= or a static int (length=4 / length=8).

One correction to the PR's own reasoning, not to its conclusion. The PR states that for these four sites __call__ returns a copy "whose _length_callback is None." That's only true for the two sites with an explicit static length= on the inner SchemaField (sctp.py, tcp.py). For the other two (hip.py, mh.py — no length= on the SchemaField), SchemaField.__init__'s own default parameter value for length is lambda _: -1, which is not an int, so _length_callback gets set to that trivial lambda rather than None. Checked directly:

>>> sf = SchemaField(schema=Item)          # no length= passed, like hip.py/mh.py
>>> sf._length_callback is None
False

The practical conclusion still holds — resolving that trivial callback in __call__ reproduces _length=-1, _template='1024s', identical to what __init__ already left it at — so the copy is still equivalent to the original at these two sites. It's "the callback always resolves to the same constant" rather than "the callback is absent" that makes it inert here. Not a code issue, just worth tightening the description.

Byte-identical captures, reproduced independently. Ran examples/generators/make_samples.py at this commit and again with the token reverted, and diffed sha256sum over all 24 files under examples/captures/ (18 generated + 6 committed): identical digests both ways, identical generator log lines.

pack side, confirmed by reading the code. ListField.pack never calls self._item_type(packet) for a schema item — it hits isinstance(item, Schema) → item.pack(packet) directly, since every value produced by the schema branch of unpack is already a Schema instance. SchemaField.pack (misc.py) never references _length, _length_callback, or _template at all. So the elif self._item_type is not None: self._item_type.pack(item, packet) branch is structurally unreachable for SchemaField items in practice, and even if it were reached, .pack doesn't consult the state the bug discards — confirmed both halves independently.


end = file.tell()
if end <= offset:
Expand Down
106 changes: 106 additions & 0 deletions tests/corekit/test_fields_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,5 +314,111 @@ def subclasses(cls: 'Any') -> 'Any':
self.assertEqual(sorted(fell_back), [])


class ListFieldSchemaItemTests(unittest.TestCase):
"""``ListField.unpack``'s schema branch must unpack from the *configured*
per-item field, not from ``self._item_type`` itself.

``field = self._item_type(packet)`` builds a per-item copy through
:meth:`SchemaField.__call__ <pcapkit.corekit.fields.misc.SchemaField.__call__>`,
which applies both ``callback`` and ``length_callback`` to that copy -- the
schema branch then unpacked from ``self._item_type`` instead of from
``field``, discarding whatever the copy carries. C.f. #433.

No declaration in this package passes either argument, so nothing here
parses differently today; both cases below construct a ``ListField``
directly rather than through one of the four in-tree declarations, since
those are exactly the shape that hides the bug.

"""

def setUp(self) -> None:
purge_modules(['pcapkit'])

def test_a_length_callback_is_honoured(self) -> None:
"""The per-item field's own resolved length must reach its schema.

``field``'s ``length_callback`` resolves to ``2``; ``self._item_type``,
never having been called, is stuck at the ``-1`` its constructor left
it with. ``SchemaField.unpack`` threads its own ``self.length`` through
to ``Item.unpack``'s ``length`` argument, which lands in
``packet['__length__']`` -- so which one was used is directly visible
to ``Item.pre_unpack`` without ``Item`` ever needing to consume it.

"""
from pcapkit.corekit.fields.collections import ListField
from pcapkit.corekit.fields.misc import SchemaField
from pcapkit.corekit.fields.numbers import UInt8Field
from pcapkit.protocols.schema.schema import Schema, schema_final

recorded = [] # type: list[int]

@schema_final
class Item(Schema):
"""A single fixed-width byte: its own width never depends on
``__length__``, so the list keeps making progress whichever length
got recorded."""

marker: 'int' = UInt8Field()

@classmethod
def pre_unpack(cls, packet: 'dict[str, Any]') -> 'None':
recorded.append(packet['__length__'])

item_field = SchemaField(schema=Item, length=lambda pkt: 2)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor coverage gap, not blocking. This length_callback always returns the constant 2, so both items in the list resolve to the same length. That's sufficient to distinguish field (resolved to 2) from self._item_type (stuck at -1), which is exactly what this test needs to pin the regression this PR fixes — but it doesn't exercise a length_callback whose result varies per item (e.g. keyed off a per-item counter threaded through packet, the way a real item length legitimately could be). A hypothetical implementation that memoized the first resolution and reused it for every later item would still pass this test unchanged.

Confirmed this is a real gap and not just a style question: with the fix reverted, this test already fails on the constant case ([-1, -1] != [2, 2]), so it does catch today's bug. It just wouldn't catch a different bug where resolution happens once instead of per-item. Might be worth a follow-up case with a varying length if this mechanism gets touched again — not requesting a change to this PR for it, since a varying-length case wouldn't add anything to what's being fixed here.

list_field = ListField(length=2, item_type=item_field)

list_field.unpack(b'\x01\x02', {})

self.assertEqual(recorded, [2, 2])

def test_a_callback_is_honoured(self) -> None:
"""The per-item field's ``callback`` mutation must reach ``unpack``.

The callback is evaluated regardless: ``field = self._item_type(packet)``
runs it for its side effect even on the unpatched code. So what this
proves is not that the callback *fires*, but that the mutation it made
on its copy is what ``unpack`` actually used. It alternates
``field._schema`` between two otherwise-identical schemas, one per
item; unpacking from ``self._item_type`` instead would use the schema
fixed at construction time for every item.

"""
from pcapkit.corekit.fields.collections import ListField
from pcapkit.corekit.fields.misc import SchemaField
from pcapkit.corekit.fields.numbers import UInt8Field
from pcapkit.protocols.schema.schema import Schema, schema_final

seen = [] # type: list[str]

@schema_final
class TypeA(Schema):
marker: 'int' = UInt8Field()

@classmethod
def pre_unpack(cls, packet: 'dict[str, Any]') -> 'None':
seen.append('A')

@schema_final
class TypeB(Schema):
marker: 'int' = UInt8Field()

@classmethod
def pre_unpack(cls, packet: 'dict[str, Any]') -> 'None':
seen.append('B')

counter = {'n': 0}

def alternate(field: 'Any', packet: 'dict[str, Any]') -> 'None':
field._schema = TypeB if counter['n'] % 2 else TypeA # pylint: disable=protected-access
counter['n'] += 1

item_field = SchemaField(length=1, schema=TypeA, callback=alternate)
list_field = ListField(length=2, item_type=item_field)

list_field.unpack(b'\x01\x02', {})

self.assertEqual(seen, ['A', 'B'])


if __name__ == '__main__':
unittest.main()
Loading