corekit: unpack a ListField's schema items from the configured field - #453
Conversation
…433) * `ListField.unpack`'s schema branch built a per-item field via `self._item_type(packet)` -- which applies `SchemaField.__call__`'s `callback` and `length_callback` to a fresh copy -- and then unpacked from `self._item_type` itself, discarding that copy. Any per-item `callback` or `length_callback` a `SchemaField` item carried was silently dropped. * Fix: unpack from `field`, the configured copy, instead of from `self._item_type`. * Latent rather than live: all four in-tree `ListField` declarations whose item is a `SchemaField` (`mh.py`, `hip.py`, `tcp.py`, `sctp.py`) pass only `schema=` and, in two cases, a static `length=`; none passes `callback` or `length_callback`, so the discarded copy was always equivalent to the original and nothing parses differently. Regenerated sample captures are byte-identical to a pristine `origin/main` tree. * `pack` was checked for the mirror-image problem and does not have it: it never builds a per-item configured copy to discard in the first place, and `SchemaField.pack` does not consult `_length`/`_length_callback` at all. * Added `ListFieldSchemaItemTests`, constructing a `ListField` directly with a `SchemaField` item carrying a `length_callback` and one carrying a `callback`, asserting each is honoured -- the shape the four in-tree declarations hide. Full suite: 928 passed, 17 skipped, 1268 subtests passed (baseline on a pristine a4c8d62: 926 passed, 17 skipped, 1268 subtests -- the difference is exactly the two new tests).
|
|
||
| if is_schema: | ||
| data = cast('SchemaField', self._item_type).unpack(file, packet) | ||
| data = cast('SchemaField', field).unpack(file, packet) |
There was a problem hiding this comment.
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
FalseThe 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.
| def pre_unpack(cls, packet: 'dict[str, Any]') -> 'None': | ||
| recorded.append(packet['__length__']) | ||
|
|
||
| item_field = SchemaField(schema=Item, length=lambda pkt: 2) |
There was a problem hiding this comment.
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.
|
Reviewing in place of Copilot. Head reviewed: What I checked and actually ranCI. Position relative to main. The diff itself. Census, re-derived rather than trusted.
Tests, reproduced by hand, not just read. Applied the PR's two changed files to my own worktree, ran the module: 9/9 pass. Reverted just the one token ( Restored the fix, both pass again. Left two inline notes on the diff: one is a correction to the PR's own stated mechanism ( Byte-identical captures, regenerated myself. mypy / pylint, run myself, not read from the PR. Full suite — measured directly on this branch. VerdictGOOD TO MERGE at The fix is the one-token change it claims to be, the census of affected call sites is exhaustive and accurate (checked for indirect routes, found none), the |
Closes #433.
The defect
ListField.unpack's schema branch,pcapkit/corekit/fields/collections.py:166-169:fieldis the per-item copySchemaField.__call__produces: it runs the item'scallbackon the copy and, if alength_callbackwas given, resolves the copy's own_length/_templatefrom it (pcapkit/corekit/fields/misc.py:548-566). The schema branch builtfieldand then unpacked fromself._item_typeinstead — the original, never-called object — discarding whatever the copy carried. The sibling non-schema branch a few lines below (length -= field.length,file.read(field.length)) already usesfieldcorrectly; only the schema branch had the bug.As the issue's own follow-up comment established: the callback still runs (line 166 evaluates
self._item_type(packet)unconditionally, for its side effect), but its effect on the copy is what gets thrown away, and alength_callback's resolved length never reaches the actual.unpack()call.Independent census of call sites
Re-derived from
git grep -n "item_type=SchemaField" origin/main -- pcapkit, not taken from the issue on trust:pcapkit/protocols/schema/internet/hip.py:260SchemaField(schema=Locator)pcapkit/protocols/schema/internet/mh.py:535SchemaField(schema=CGAParameter)pcapkit/protocols/schema/transport/sctp.py:702SchemaField(length=4, schema=GapAckBlock)pcapkit/protocols/schema/transport/tcp.py:393SchemaField(length=8, schema=SACKBlock)Same four sites the issue names, and no fifth. None passes
callback=or a callablelength=, so this confirms the issue's own claim: this is a latent trap, not a live defect. Every in-tree declaration'sfieldcopy is equivalent toself._item_type(both resolve to the same static_length, and the default no-op callback), so nothing parses differently today.The fix
One token: unpack from
fieldinstead of fromself._item_type.castis kept (only line 404's other usage remains otherwise; import stays needed regardless), becausefield's static type is the broadFieldBase[Any]and the cast documents that this branch treats it as theSchemaFieldtheis_schemacheck already established.packdoes not have the mirror-image problemChecked
ListField.pack(same file, lines 90-117) for the equivalent bug. It does not have it, for a structural reason rather than luck:packnever builds a per-item configured copy to discard in the first place. For a schema item it hitsisinstance(item, Schema)and callsitem.pack(packet)directly (items areSchemainstances once unpacked); theelif self._item_type is not None: self._item_type.pack(item, packet)branch is only reached for non-Schema, non-bytesitems. Crucially,SchemaField.pack(misc.py:568-595) never consults_length,_template, or anything__call__'slength_callbackbranch would have set — it just delegates straight tovalue.pack(...)— so calling_item_type(packet).pack(...)instead of_item_type.pack(...)would be a no-op change even if made. No fix needed there.Testing
tests/corekit/test_fields_collections.py, newListFieldSchemaItemTests, two tests, constructing aListFielddirectly (not through one of the four in-tree declarations, which are exactly the shape that hides the bug):test_a_length_callback_is_honoured: an itemSchemaField(schema=Item, length=lambda pkt: 2).Item.pre_unpackrecordspacket['__length__'], which is fed by the item field's own resolved.length. Assertsrecorded == [2, 2].test_a_callback_is_honoured: an itemSchemaField(length=1, schema=TypeA, callback=alternate), wherealternatemutatesfield._schemato alternate between two otherwise-identical schemas per item. Assertsseen == ['A', 'B'].Before the fix (reverted locally to confirm), both fail:
[-1, -1]:self._item_type's raw_lengthis-1from__init__(a callablelength=always leaves the raw attribute at-1until__call__resolves it), and unpacking from the never-called original never resolves it.['A', 'A']: the callback's mutation of the copy's_schemanever reaches the unconfigured original, so every item unpacks with the schema fixed at construction time.After the fix, both pass.
Byte-identical capture verification
examples/captures/is generated (gitignored aside from a handful of committed fixtures) byexamples/generators/make_samples.py. Ran it against the buggy tree and the fixed tree and diffedsha256sumover every file in the directory (24 files, including the committed fixtures): identical. Same 18 captures written, same byte sizes, same digests, same "left out"/"warned" lines in both runs.Full suite
Measured directly, not taken from any other source:
a4c8d62b1checkout (this branch's actual base;git rev-list --count HEAD..origin/mainwas 0 at the time this branch was cut):926 passed, 17 skipped, 1268 subtests passedin 574.78s.928 passed, 17 skipped, 1268 subtests passedin 587.70s.The difference is exactly the two new tests; no regressions, no other change in skip/subtest counts.
mypyclean on the changed file.pylintfindings on the changed file are unchanged from theorigin/mainbaseline (same list, same lines — none on the changed line).Note:
origin/mainadvanced toe2d8ed6d1partway through this session (another PR merged). This branch stays ona4c8d62b1as the task specified, and both the byte-identical and full-suite comparisons above were measured against that exact sha.