fix(pcap): Frame.len is the on-wire length, cap_len the captured one (#618) - #635
Conversation
65cb0c9 to
97f7b95
Compare
Cross-review verdict: GOOD TO GOIndependent cross-review by a Claude Sonnet subagent (this PR was authored by It fanned out internally and reached 3–4x independent triangulation on most claims, Verdicts
What it disputed, and what was done about itThree real concerns. All three were verified here rather than taken on trust, all
One further inaccuracy, found by a reviewer fork as an unreconciled hash and traced What the review could not checkIt did not re-run the |
…one (#618) BREAKING CHANGE to a public attribute. The PCAP and PCAP-NG readers filled these two from opposite wire fields, so `frame.len` meant the captured length out of a `.pcap` and the on-wire length out of a `.pcapng`. * `Frame.read` wrote `len=incl_len, cap_len=orig_len` and now writes `len=orig_len, cap_len=incl_len`, matching `toolkit.pcapng.block2frame`. Code reading either attribute from a `.pcap` now gets the other field's value, and for a truncated frame that is a different number rather than a relabelling. * Which reader to move was a decision, not a typo fix: the PCAP spelling is the older of the two (`c43892af`, 2022-01-11, docstrings agreeing a day later) and the PCAP-NG one arrived 15 months later (`25f216f4`). Both were self-consistent. Wireshark's `packet-frame.c` breaks the tie -- `frame.len` is "Frame length on the wire", `frame.cap_len` is "Frame length stored into the capture file", and `frame_len < cap_len` raises `frame.len_lt_caplen`, `PI_MALFORMED` -- so the later spelling is the one that fits the names. * The data model documented the inverted meanings; its docstrings now match. * `_decode_next_layer` is handed `frame.cap_len`, not `frame.len`: it needs the octets present, which is the value it already got, so dissection is unchanged. No test pins that line and the comment there records why. * New `test_frame_length_runtime.py` covers both readers on the only frames that can tell the two fields apart -- the truncated ones #614 added. `tests/protocols/misc/ tests/toolkit/ tests/dumpkit/` passes 116 tests and 234 subtests; `tests/foundation/ tests/integration/` passes 315 and 489. The new module is 6 tests and 28 subtests, and fails on `main` with `96 != 1200`. Fixes #618
97f7b95 to
210c68c
Compare
Rewritten: changelog entry moved out, rebased onto current
|
The bullet #635 originally carried, moved here verbatim so that #635 touches only the two `Frame` modules, `pcapkit/toolkit/pcapng.py` and its two test files. Covers: the breaking change to a public attribute -- `Frame.len` is the on-wire length and `cap_len` the captured one, which the PCAP and PCAP-NG readers had filled from opposite wire fields. 41 lines added to the entry file; `CHANGELOG.md` regenerated, not edited.
Fixes #618.
The defect, re-verified on current
mainThe two readers filled the same two attributes from opposite wire fields, so one
caller reading
frame.lengot different meanings depending on the container theframe arrived in.
main(6c3d1b0d9)pcapkit/protocols/misc/pcap/frame.py:261-262len=_ilen(incl_len, captured),cap_len=_olen(orig_len, on-wire)pcapkit/toolkit/pcapng.py:270-271len=block.original_len(on-wire),cap_len=block.captured_len(captured)Line numbers re-derived on
6c3d1b0d9, after #614 moved this file; they areunchanged from the ones the issue quotes. Measured, both readers, one run:
Frames 1 and 2 satisfy either assignment, which is why this survived: the two
lengths are equal unless the snapshot length truncated the frame, so every
pre-#614 fixture compared a value against itself.
Which reader was wrong, and why — from the specifications, fetched
Both container formats define the underlying fields identically, so neither
format is the odd one out. Quoted from the text, not from memory:
pcap-savefile(5)(tcpdump.org):incl_lenis "a 4-byte value giving thenumber of bytes of captured data that follow the per-packet header";
orig_lengives "the number of bytes that would have been present had the packet not been
truncated by the snapshot length". And: "The two lengths will be equal if the
number of bytes of packet data are less than or equal to the snapshot length."
draft-ietf-opsawg-pcapng(Enhanced Packet Block): Captured Packet Length is"an unsigned integer that indicates the number of octets captured from the
packet (i.e., the length of the Packet Data field)", being "the minimum value
among the Original Packet Length and the snapshot length for the interface";
Original Packet Length is "the number of octets of packet data that would have
been provided had the packet not been truncated", and "It SHOULD NOT be less
than the Captured Packet Length."
draft-ietf-opsawg-pcapgives the classic format the same two definitions.So the specs fix what
incl_len/orig_lenmean, but not whatlenandcap_lenshould mean — those names are not in either spec. They areWireshark's, and Wireshark's
epan/dissectors/packet-frame.csettles it:frame.len— label"Frame Length", description"Frame length on the wire"frame.cap_len— label"Capture Length", description"Frame length stored into the capture file"frame.len_lt_caplen, "Frame length is less than capturedlength", registered
PI_MALFORMED/PI_ERROR, fires onif (frame_len < cap_len)where
cap_len = tvb_captured_length(tvb)andframe_len = tvb_reported_length(tvb).That last one is decisive on its own: if
lenwere the captured length, thenlen < cap_lenwould be the ordinary truncated case rather than a malformedone.
One weaker corroboration, labelled weak because the cross-review rightly pushed
back on it:
pcapkit/toolkit/pyshark.py'spacket2dictcopies tshark'sframe_infofield names into its output dict, so acap_lenkey in pcapkit'soutput can already carry tshark's meaning of the word. But it does that
generically —
getattr(frame, field) for field in frame.field_names— and neverreads
Data_Frame.cap_len, so it is a naming coincidence rather than a codedependency. It is not load-bearing here.
Seniority does not settle it, and this PR does not pretend otherwise
Raised by the cross-review, then verified here with a pickaxe search over each file:
len=incl_len(captured)c43892af, with the data model's docstrings agreeing inf2a09794a day laterlen=original_len(on-wire)25f216f4, "implemented toolkit functions for PCAPNG"Same author, 15 months apart, and both internally consistent — the PCAP
reader's code and its docstrings agreed with each other for four years. So this is
not a typo being repaired, and the older convention is not self-evidently the
intended one. #618 itself calls it "an owner decision".
What breaks the tie is that the two names are borrowed rather than invented.
They are Wireshark's, and Wireshark defines them the other way round — decisively
so via
frame.len_lt_caplen. The later spelling is the one that fits the namesit uses, so that is the one kept.
Verdict: the PCAP reader is the one that moves. The PCAP-NG reader is left
alone apart from a comment recording that it is now the reference and that it is
the newer of the two, so that nobody reverses this later on seniority grounds.
Consumers whose behaviour changes
Every read of
Data_Frame.len/.cap_lenin the repository, traced:pcapkit/protocols/misc/pcap/frame.py—_decode_next_layer(frame, network, …)frame.lenwhen that was the captured length; it now readsframe.cap_len, which is the same value. Rewired in this PR so the call site names what it means; dissection output is byte-identical (measured).pcapkit/foundation/engines/pcap.py:166,168—ofile(frame.info.to_dict(), …)to_dict()copies every attribute, solenandcap_lenare literal keys in the JSON/plist/tree output file. A consumer of that file sees the two swap. The committed fixturesexamples/captures/out.{json,plist,txt}are unchanged, because they derive fromin.pcap, which is not truncated.pcapkit/foundation/traceflow/tcp.py:170—output(packet.frame, …)trace_formatinjson/plist/tree, by the sameto_dict()route.pcapkit/dumpkit/pcap.py:190-195—PCAPIO._append_valueframe_info.incl_len/frame_info.orig_len, never from these two — andframe_infowas already correct in both readers. Sotrace_format='pcap'and every PCAP write path are untouched, and a PCAP-NG frame dumped to PCAP still gets the right record header.tests/protocols/misc/pcap/test_frame_runtime.py:29in.pcapis untruncated, so the assertion held either way. Comment added saying so, since it reads like it pins #618 and does not.tests/protocols/misc/pcap/test_frame_runtime.py:92frame.info.len, now byframe.info.cap_len.arp.pcapis untruncated so it passed either way — a latent wrong-field read rather than a live failure.Checked and carrying no such read:
foundation/extraction.py, the IP and TCPreassemblers,
traceflow/traceflow.py,dumpkit/{common,null}.py,protocols/misc/pcapng.py, the other six engines and toolkits, anddocs/**(no
.rstnames either attribute). The hundreds of other.len/pkt['len']hits belong to unrelated per-protocol schemas.
Which fixture has differing lengths, and by how much
This matters more than usual: with
incl_len == orig_lena test cannot tell thetwo fields apart, which is the same failure mode as inputs that are all multiples
of 8.
big_endian.pcaplittle_endian.pcapbig_endian_nanosecond.pcaptest.pcapngThe three
.pcapones are what #614 added (snaplen96 against a 1200-octetdatagram); the PCAP-NG one already existed.
test_the_fixture_really_holds_a_truncated_frameand its PCAP-NG twin assert the truncation is present and that exactly one frame
carries it, so a fixture regenerated without it fails loudly instead of turning
the module green for the wrong reason.
Failing without the fix, passing with it
Run from immutable
git archivesnapshots, withpcapkitpinned by stripping theeditable install's
MetaPathFinderand asserting onpcapkit.__file__, and theexit code read from a file rather than off a pipeline. The test file is
byte-identical on both sides (
md5 c74fe2bf35a45540d59d04bb385d6481); onlypcapkit/protocols/misc/pcap/frame.pydiffers — before260cee48528f4382edb4b2d2d74e6c3b, aftera025f5bda00f72c4222817ea52031fdf, eachhashed out of an immutable snapshot of its commit rather than out of a live
working tree.
Before —
mainat6c3d1b0d9:Lists differ: [1200, 32] != [96, 32]is the disagreement itself, verbatim: thecap_lenof the two truncated frames, 1200 out of the.pcapagainst 32 out ofthe
.pcapng.After — this branch:
Note that pytest 9.1.1 prints
PASSEDon the parent of a test whose subtestsfailed — the
beforerun's top line fortest_len_is_the_on_wire_length_…readsPASSEDwhile three of its subtests failed. The exit code and the summary lineare the truth.
One mutant survives this test, and it is disclosed rather than papered over.
Reverting only the
_decode_next_layerargument toframe.lenwhile keeping theattribute swap leaves the whole selection green — confirmed independently by the
cross-review and re-confirmed here (19 passed, exit 0). The reason: the truncated
frame dissects to
Ethernet:IPv4:UDP:Raw, andRaw.readtakes alengthit neveruses (
pylint: disable=unused-argumenton its signature), so the over-long valuereaches nothing that checks it. The shipped argument is the correct one — it is
also the value
_import_next_layerdefaults to — but nothing here would catch aregression of that line, and the comment at the call site says so. Closing it needs
a fixture whose truncation lands in a length-checked field instead of bottoming out
in
Raw.Both readers are covered, which is the point:
PcapFrameLengthRuntimeTestspins the one that was wrong,
PcapngFrameLengthRuntimeTestsis the control andpasses on
maintoo — it exists so a later change cannot "fix" the disagreementfrom the other end — and
ReadersAgreeRuntimeTestsasserts the cross-formatproperty the issue actually reports.
Regressions and coverage
The full suite was deliberately not run:
coverage run -m pytest tests/reached 41.4 GB RSS on this host today and was killed.
Coverage cannot go backwards, because no executable statement was added to or
removed from
pcapkit— the change is two swapped keyword values, one attributerename at a call site, and comments. Measured on both trees with the same
selection:
pcapkit/protocols/misc/pcap/frame.pypcapkit/protocols/data/misc/pcap/frame.pypcapkit/toolkit/pcapng.pySince the changed lines already executed, the meaningful figure is the test count:
that selection goes from 17 tests / 14 subtests to 23 tests / 42 subtests,
the new module contributing 6 tests and 28 subtests.
Also clean:
mypyon the three changed modules (Success: no issues found in 3 source files),pycodestyleon both test files,rstcheckon the changelogentry, and
python util/changelog_md.py --check(exit 0,CHANGELOG.mdregenerated rather than hand-edited).
pylintreports the identical finding setbefore and after, with only line numbers shifted — no new findings.
Found and deliberately not fixed
block2framehands on aData_Framewith no packet octets. Every packetblock read out of
test.pcapngcomes back withpacket == b''while declaring acaptured_lenin the hundreds, soblock2frame'sframe.__update__(packet=block.packet)copies nothing. The consequence is that dumping a PCAP-NG frame through
PCAPIOwrites a record header declaring
incl_lenof 314 followed by zero octets of data.That is a separate defect from #618, it is pre-existing and unaffected by this
change, and it is why the PCAP-NG test here asserts
cap_lenagainst the block'sown field rather than against
len(frame.packet)as the PCAP test does. Worth itsown issue.
Files changed
pcapkit/protocols/misc/pcap/frame.py— the swap, and the_decode_next_layerrewiringpcapkit/protocols/data/misc/pcap/frame.py— docstrings, which documented the inverted meaningspcapkit/toolkit/pcapng.py— comment only, pinning it as the referencetests/protocols/misc/pcap/test_frame_length_runtime.py— newtests/protocols/misc/pcap/test_frame_runtime.py— one corrected field read, two commentsdocs/source/changelog/1.5.0.rst,CHANGELOG.md— changelog entry, regenerated