fix(pcapng): keep the captured octets every packet block declares (#646) - #683
Conversation
Every PCAP-NG packet block lost its captured octets: they were extracted from the block schema and then overwritten by `ProtocolBase.__init__` with `self.packet.payload`, which the inherited `packet` had split at `PCAPNG.length` -- the wire's Block Total Length. The entry records the measurement on the committed `dhcp.pcapng`, the three affected block types and their three payload offsets, why the fix belongs at `PCAPNG.packet` rather than at the injection site, the 104-octet dump that made it a wire-format defect, why it ships labelled breaking, and that #678 is measurably unaffected. CHANGELOG.md regenerated with `util/changelog_md.py`; `--check` exits 0.
|
Follow-up filed for the one behavioural consequence this PR deliberately does not fix: #685 — Changelog entry for this PR is on |
41ac27c to
428baeb
Compare
|
Amended and re-pushed as Changelog on
|
* Every PCAP-NG packet block reported `packet == b''` while `captured_len` declared hundreds of octets. `PCAPNG.unpack` extracted the payload correctly and `ProtocolBase.__init__` then overwrote it with `self.packet.payload`, which the inherited `packet` had split at `PCAPNG.length` -- the wire's Block Total Length, not a header length -- consuming the whole block as header. * `PCAPNG.packet` is overridden to take the payload from the block schema's `__payload__` field and the header from the octets ahead of it, so the value injected into `_info` is the captured data. A PCAP-NG block carries a trailer after its payload, so no value of `length` could have made the inherited split work; the fix belongs here and not at the injection site. * Affects all three block types that carry captured octets: the Enhanced Packet Block, the Simple Packet Block and the obsolete Packet Block. * `PCAPNG.unpack` now reads that property instead of extracting the payload a second time, leaving one source of truth. * That property is a plain `@property`, not a `cached_property` like the inherited one: the inherited one caches because it reads the stream, this one only walks already-filled schema buffers, and caching it would make a second `unpack` on one instance return the first call's octets. * `ProtocolBase.packet` documents the contract it relies on. Docstring only; no behaviour change outside PCAP-NG. * Dumping through `PCAPIO` wrote record headers promising octets it never wrote -- a 104-octet PCAP for four blocks -- which pcapkit refused on re-read and scapy silently mis-parsed. It now round-trips. tests/protocols/test_pcapng_regression.py grows 4 tests to 11 and 3 subtests to 24, covering both byte orders, options present after the payload, and a snapped block; pcapkit/protocols/misc/pcapng.py holds 99.91% with its single miss unmoved, and pcapkit/protocols/protocol.py is unchanged in every coverage column, as a docstring-only change should be. Fixes #646
428baeb to
c332e1f
Compare
|
NEEDS CHANGES — cross-review verdict, since resolved. Recorded here because nothing in GitHub tracks a cross-review's result the way a check tracks CI's. Every agent-raised CR/PR here gets a review from a subagent on a different model, briefed to falsify rather than bless. This PR was authored on Opus 5 and reviewed on Sonnet (no model substitution was needed). It ran read-only, built its own synthetic captures rather than reusing this PR's helpers, and fanned out internally to attack the load-bearing claim from several directions. It returned NEEDS CHANGES with three findings. All three were real and all three are fixed in 1.
|
| File | Stmts | Miss | Branch | BrPart | Cover |
|---|---|---|---|---|---|
misc/pcapng.py base |
1527 | 1 | 666 | 1 | 99.91% |
misc/pcapng.py PR |
1545 | 1 | 672 | 1 | 99.91% |
protocols/protocol.py base |
503 | 228 | 170 | 16 | 49.18% |
protocols/protocol.py PR |
503 | 228 | 170 | 16 | 49.18% |
A related trap found while re-measuring, now noted in the PR body: coverage re-parses the source at report time, so a data file produced before an edit and reported after one reads far worse than reality — misc/pcapng.py appeared to fall to 38% that way.
3. The #678 truncation tally was unreproducible — fixed
The reviewer confirmed the comparative claim (base and PR byte-identical at every level) under three different methodologies, but could not reproduce the specific 96 / 1 / 1 / 3 breakdown, because "101 truncation levels" admits several readings that disagree. Fair. The set is now stated as the expression that produced it:
lengths = [max(1, len(raw) * i // 100) for i in range(101)] # 1508 octets -> 101 distinct: 1, 15, 30, ... 1508and compared level by level rather than only in aggregate, since a matching total can hide two levels that swapped. The SHA-256 of the sorted {prefix_length: outcome} mapping is ca44d3ee658087cf2a667c454f839dd931c1c04790b2fe32cee8e1a2e861b182 on both trees.
What it could not overturn
The load-bearing claim — that the payload is now correct for every affected block type, not just the one a fixture happens to have — survived. The reviewer independently derived the three __payload__ declarations and the three offsets (28 / 12 / 28) from the schemas rather than taking them from the PR body, then attacked with from-scratch captures across big-endian sections, EPB and SPB truncation, every mod-4 payload residue, EPB and Packet Block with real trailing options, multiple interfaces, SPB snaplen bounding, and the construction/pack path. Zero counterexamples across all of it.
It also independently built two plausible wrong fixes — hardcoding header offset 28 for every block type, and taking the payload as everything to end-of-buffer including the trailer — and confirmed the current tests catch both. It noted the SPB-offset guarantee rests on a single test method; recorded rather than actioned, and worth knowing.
Claims 1, 2, 4, 9, 10 and 12 reproduced exactly. The house-style sweep came back clean.
Post-fix state
c332e1f31, one commit on top of a18846c8f. tests/protocols + tests/toolkit + tests/dumpkit + the PCAP-NG engine and end-to-end modules: 770 passed, 5 skipped, 1801 subtests passed, exit code 0 read from a file, zero FAILED/SUBFAILED/ERROR. pylint and mypy under the repo's own flag sets remain byte-identical to base by finding category and count.
CI is not claimed green.
Fixes #646.
The defect, re-measured
The issue reported against
375e9d411and against the generatedtest.pcapng. Re-measured here on the committed fixtureexamples/captures/dhcp.pcapng, on CPython 3.14.7, withpcapkit.__file__pinned to this worktree:The octets are read correctly and then discarded. Same run, same blocks, reading the schema directly:
After this change,
info.packetis those 314 octets, andframe.packet.headeris the 28-octet Enhanced Packet Block prefix rather than the whole 348-octet block.The overwrite site, re-located
Every line number in the issue predates #640, which added
_DECLARED_KEYWORDSand the_Absentmachinery topcapkit/protocols/protocol.py.protocol.py:647protocol.py:994—self._info.__update__(packet=self.packet.payload)ProtocolBase.packetprotocol.py:196-205protocol.py:471-480PCAPNG.lengthmisc/pcapng.py:678-681misc/pcapng.py:678-681(unmoved)PCAPNG.unpack's extractionmisc/pcapng.py:902-909misc/pcapng.py:902-909(unmoved)The mechanism is exactly as reported and was verified rather than taken on trust:
PCAPNG.lengthreturnsself._info.length, which is assignedlength=schema.length— the wire's Block Total Length — so_read_packet(header=self.length)consumes the entire per-block buffer as header and leaves nothing for the payload. Measured on the fixture:frame.length == 348andlen(frame.packet.header) == 348for a 348-octet block.Which block types are affected
All three that carry captured octets, i.e. exactly
PCAPNG.PACKET_TYPESand exactly the three schemas declaring__payload__ = 'packet_data':0x00000006b''0x00000003b''0x00000002b''No committed fixture has an SPB or a Packet Block —
dhcp.pcapngis all EPBs — so a synthetic capture carrying one of each was built to measure them, and it is built in the test too. Every other block type (Section Header, Interface Description, Name Resolution, Interface Statistics, Decryption Secrets, Custom, systemd Journal Export) declares no payload field, reportedb''before, and still reportsb''.Where the fix belongs, and why
At the computation, not at the overwrite. The issue offered both. The overwrite at
protocol.py:994is correct for every protocol laid out as a header followed by its payload, which is all of them but this one, so it is not the defect.Two reasons the injection site is the wrong place to fix it:
lengthcan make the inherited split work. A PCAP-NG block carries a trailer after its payload — the option list, its padding, and a repeat of the Block Total Length._read_packet(header=N)takes everything afterNas payload, so even a correct 28 would yieldpacket_data + padding + options + trailer, not the 314 captured octets.__init__to not overwrite an already-setpackethealsframe.info.packetand leavesframe.packet— a documented property — still reporting the whole block as header andb''as payload. That is the same defect seen from the other side.So
PCAPNG.packetis overridden to take the payload from the block schema's__payload__field and the header from the octets ahead of it, summed out of the schema buffers so that the three different payload offsets need not be hard-coded per block type.PCAPNG.unpackthen reads that property instead of extracting the payload a second time of its own, which leaves one source of truth where there were two attempts at one.PCAPNG.packetis a plain@propertywhere the inherited one is acached_property, which is deliberate. The inherited one caches because it reads the stream, and a second read would consume octets that are gone; this one only walks buffers the schema layer has already filled, so it costs a few dict lookups and has nothing to amortise. Caching it would also reintroduce the same class of staleness this change removes by a different route:unpacknow reports the payload through this property, so a cache would make a secondunpackon one instance hand back the first call's octets withget_payloadnever reached, where the code before this change recomputed from the schema every time. Nothing in the tree callsunpacktwice on one instance today —__post_init__is its only caller — so that is an invariant being kept rather than a bug being fixed, andtests/protocols/misc/test_pcapng_unit.pynow asserts it directly: swap the block on a live instance,unpackagain, and the payload must be the new block's. Withcached_propertythat assertion failsb'payload' != b'cached'.pcapkit/protocols/protocol.pycarries a docstring change only —ProtocolBase.packetnow states the contract it relies on and names this as what happens when a protocol breaks it. No executable line changed there (503 statements before and after).The dump, which is the part that corrupted a file
PCAPIO._append_valuewritesvalue.packetafter each 16-octet record header, so the empty payload reached the file. Dumping the fourdhcp.pcapngblocks produced 104 octets: 24 of global header plus four record headers, each declaring hundreds of octets and delivering none. Every reader that walks byincl_len— which is all of them — loses frame sync at the first record. It now round-trips, and the test asserts both the dumped file's total size and each record's octets against the source capture's own hand-parsed bytes.Failing-then-passing evidence
Exit codes read from files, not from a pipeline, and each run printing the
pcapkit.__file__it resolved.Before — pristine
git archiveof the base commit, pinned by a rootconftest.py:After — this branch:
Note that several per-test lines read
PASSEDin the before-run while every one of their subtestsSUBFAILED; the exit code and the subtest tally are the signal, not the per-test line.The assertions are on real payload bytes, not on lengths — a length assertion passes under several wrong fixes, including a payload read from the wrong offset and one that picked up the block's 32-bit padding. Each synthetic payload has a different length modulo 4 (0, 1 and 1 octets of padding) for the same reason, and the
dhcp.pcapngexpectation is derived twice over: once as spelled-out head and tail literals, once by hand-parsing the file withstructalone so the expected value owes nothing to the code under test.Shapes neither fixture exercised
Two gaps were found in the first draft of these tests and closed. Neither
dhcp.pcapngnor the first synthetic capture had an option area after the payload —dhcp.pcapng's four blocks all haveoptions: len=0— and neither had a big-endian section. Options sitting after the captured data are exactly what a payload offset walked from the wrong end would swallow, and a block with none cannot tell the difference. Six shapes were then measured directly, all passing, each checking the actual payload bytes, thatheader + payloadequals the leading octets of the raw block, and thatlen(packet) == captured_len:epb_flags+opt_endofoptafter the payloadcaptured_len(19) <original_len(9999)snaplen(20) <original_len(34)The first, second and fourth are now in the committed test: the synthetic capture carries options on both blocks that can have them, the three-block-type test runs over both byte orders, and
test_a_snapped_block_carries_the_octets_that_are_presentcovers the two ways a block can be snapped. The payload offsets are stable at 28 / 12 / 28 regardless of byte order and of whether options are present, which is the property the walk has to have.Regression check
Scoped, on the rebased tree,
pcapkit.__file__printed and pinned:Zero
FAILED/SUBFAILED/ERRORlines. One pre-existing test did have to change:tests/protocols/misc/test_pcapng_unit.py::PCAPNGUnitTests::test_pcapng_remaining_constructor_branches_and_custom_dispatchstubs the schema withtypes.SimpleNamespace, and sinceunpacknow reaches the payload throughself.packet, those stubs needed the__fields__/__buffer__surface that property reads, plus_dataon the no-payload stub. The assertionget_payload.assert_called_once_with()becameassert_called_once_with('packet_data')because the call now names the field explicitly — which is deliberate, since the same name drives the offset walk and passing it keeps header and payload from being able to disagree. The branch that provesunpackskips re-unpacking when__header__is already set is still proven, by the shared__schema__.unpackmock still readingassert_called_once; it needed a second instance rather than a reused one only becausepacketis acached_property.Coverage
coverage run -m pytest(the repo usescoverage, notpytest-cov) overtests/protocols/misc/test_pcapng_unit.py,tests/protocols/test_pcapng_regression.pyandtests/foundation/engines/test_pcapng_engine.py.Both trees run this PR's current tests, which is the only comparison that isolates the code change: measuring the base with the old tests and the branch with the new ones moves the test suite and the library at once and then credits the difference to whichever you please.
misc/pcapng.pybasemisc/pcapng.pyPRprotocols/protocol.pybaseprotocols/protocol.pyPREvery one of the 18 new statements and 6 new branches in
misc/pcapng.pyis executed: the miss count is flat at 1, the partial-branch count flat at 1, and the percentage identical to two decimal places. That single miss is the same pre-existing statement in both —return cast('timezone', tzinfo)in_get_timezone— renumbered 1153 to 1265 by the insertions above it.protocol.pyis identical in every column, which is what a docstring-only change should produce and is the check that it really is docstring-only.tests/protocols/test_pcapng_regression.pygrows 4 tests to 11 and 3 subtests to 24.A note on how not to measure this, since it cost a wrong table once:
coveragere-parses the source at report time, so a data file produced before an edit and reported after one maps recorded line numbers onto shifted source and reads far worse than reality —misc/pcapng.pyappeared to drop to 38% that way. Every number above was reported from a run made against the source as committed.Adjacency
fix(pcapng): bound an option's payload to the area its block declares (#594) #676 (
pcapkit/protocols/schema/misc/pcapng.py, the A 16-bit padding shortfall band is still unbudgeted after #593: a crafted capture amplifies 1,637x, indistinguishable from a truncated one at the field layer #594 bound fix) — not touched. Its author's claim holds on inspection: its only changes to the two blocks I read wrap theoptionsfield's length inbounded_area(...), andoptionssits afterpacket_datain field order, so the offset walk here stops before reaching it.captured_len,captured_lengthandpacket_dataare untouched by it.One thing worth flagging: fix(pcapng): bound an option's payload to the area its block declares (#594) #676 also modifies
tests/protocols/misc/test_pcapng_unit.py, which this PR modifies too. The hunks do not overlap — fix(pcapng): bound an option's payload to the area its block declares (#594) #676 appends 400 lines at line 3180 with zero deletions, this PR edits lines 1881-1956 — so they should merge cleanly, but they are not independent files and whichever merges second should confirm it.Every EOF-truncated PCAP-NG file raises an uncaught ValueError: pcapng_block_selector passes a negative __length__ to SchemaField #678 (every EOF-truncated PCAP-NG raising an uncaught
ValueError) — neither better nor worse. Not fixed, as instructed. This change touches neitherpcapng_block_selectornor any length computation.The truncation set, stated so the tally is reproducible rather than asserted — "101 levels" admits several readings and they do not all give the same counts:
Whole-percent prefixes, with
i=0raised to 1 octet so no case is the empty file.1508 * i // 100happens to repeat no value, so the 101 levels are 101 distinct prefix lengths. On that set, both trees:ValueError: read length must be non-negative or -1FormatError: unknown file format: b'\n'ProtocolError: PCAP-NG: [if_tsresol] invalid length (expected 1, got 0)Compared level by level rather than only in aggregate: the SHA-256 of the sorted
{prefix_length: outcome}mapping isca44d3ee658087cf2a667c454f839dd931c1c04790b2fe32cee8e1a2e861b182on both trees, so no level changed behaviour even in a way that a matching aggregate could hide.EXPECTED_FAILURESintests/protocols/test_option_roundtrip_unit.py— imported rather than grepped, since**unpacking defeats a grep. 45 entries in both trees, byte-identical when sorted and serialised. No entry moved, and none was deleted.One behavioural consequence not fixed here
examples/captures/pcapng.txtis a committed legacy-smoke reference regenerated by hand fromexamples/legacy_smoke/Makefile, and its four frame-level|-- packet -> NILlines are now stale — those blocks do carry octets. No test asserts on it, it is outside this PR's file ownership, and regenerating it would pull in the unrelated pre-existing drift already present in the siblingexamples/captures/out.*. Filed separately rather than fixed in passing.Labels:
fix+breakingfixis uncontroversial — this is dropped data with a wire-format consequence.breakingis the argued one, and it is warranted. This is not a change that only corrects an error path: it changes the parse output of every PCAP-NG capture, for two properties, on the success path.frame.info.packetgoes fromb''to hundreds of octets for every packet block. Any caller that serialisesinfo— and the JSON, PList and tree dumpers all do — produces different output for the same input file.frame.packet.headergoes from the entire block to the pre-payload prefix (348 octets to 28 on the fixture's first frame).PCAPIOchange size and content. That is the point of the fix, but it is still a change to an artefact a caller may be diffing.The counter-argument is that nobody can sensibly have depended on an empty payload, and that restoring correct data is not a "break". That argument is about whether the change is desirable, which it is;
breakingis about whether output moves, which it does, everywhere, for every PCAP-NG file. A caller with a golden-file test will see it fail, so it should be announced rather than discovered — andbreakingis additive, so carrying it costs nothing thatfixconveys.Not claimed
CI is not claimed green. The verification here is local, scoped (
tests/protocols,tests/toolkit,tests/dumpkit, the PCAP-NG engine and end-to-end modules), and on 3.14.7 only. The four changed files also compile clean on 3.12.14 and 3.13.15, and the change introduces no version-gated syntax or semantics —cached_propertycomes through the existingpcapkit.utilities.compatshim thatProtocolBase.packetalready uses — but a single interpreter cannot see a version boundary and only the matrix can.pylintandmypywere run with the repository's own flag sets on both changed library files, in both trees, and the findings are identical: 50 pre-existingline-too-longand the rest unchanged by category and count for pylint, the same 10 pre-existing errors for mypy. Neither tool has a new complaint.Changelog
No changelog on this branch, per the #657 arrangement. The entry goes to
docs/changelog-1.5.0.