fix(corekit): pad a short field read on the tail, not the head (#604) - #621
Conversation
* `FieldBase.unpack` zero-filled a short read with `rjust()`, putting the padding at the front. A short read loses the *trailing* octets -- the buffer ran out -- so this is wrong for both byte orders, not only little-endian. Measured: one octet of a four-octet little-endian 120 read as 2013265920, and three octets of a four-octet big-endian 0x01020304 read as 0x10203. Now `ljust()`, which answers 120 and 0x1020300. A full read is untouched at every width and order. * The big-endian half scales the value *down* and so passes a sanity check, which is why only the inflating little-endian half was ever reported. * Removes the unhandled `MemoryError` at `pcapkit/protocols/protocol.py:1016` on a truncated PCAP-NG capture: a one-octet read of a little-endian 32-bit block length became 0x78000000 (1.88 GiB) and was passed to `file.read()` as an allocation size. It now reads 120 and fails as an ordinary parse error. * Adds `FieldBaseShortReadPaddingSideTests` -- both orders at 2, 4 and 8 octets at every truncation point, signed and byte-string fields, an unpack/pack cycle, and the full-read and empty-buffer guard rails. Eight of its ten cases fail without the fix; the two that pass on both trees are the guard rails. * Retargets three assertions in `tests/corekit/test_fields_field.py` and one in `tests/protocols/internet/test_ipv4_unit.py` that pinned the old side. All 20 example captures parse to identical frame counts, 1594 in total, and 197 of 198 truncation outcomes are unchanged.
|
✅ GOOD TO MERGE — head |
Cross-review appendix — PR #621Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head Methodology note: a bytecode-cache trap, caught mid-reviewThis repo's worktrees accumulate The correctness claim (not re-derived, per brief)
Load-bearing item 1 — the deliberately-failing test, confirmed to fail exactly as claimed
The failure is exactly the claimed mismatch — expected Load-bearing item 2 — truncated-capture-still-parses, independently sweptBuilt a truncation harness against all 20 real capture files in Full parses, both sides identical: 1594 frames total across all 20 files, both under head ( The one claimed divergence, reproduced exactly: truncating
Both are Spot-checked: the disclosed MemoryError→ValueError chain (#594)Truncated
Matches the PR body's table exactly. Subtest-count reconciliationRan Not independently checked
Disagreement logNone. Every claim I checked — the pinned test's exact failure shape, the full-parse identity across 20 captures, the specific |
|
✅ GOOD TO MERGE — head |
Cross-review appendix — PR #621Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head Diff scope
The The known-failing test — confirmed exact(Exit code read from the actual pytest process, not through a pipe to The shippability sweep — reproduced, plus one additional case beyond what was disclosedBuilt a bounded, single-process sweep (19 real captures in Full parses, untruncated, both ways: all 19 captures parse to identical frame counts under both Truncation sweep, ~12 cut points each across 5 files (
Given my ~60-point coarse sweep already turned up 2 divergences (~3%) against the PR's own reported 1-in-232 (~0.4%), the true divergence rate is likely higher than "1 of 232" suggests, or the two sweeps used different (non-comparable) sampling methodologies — I can't tell which without their script. Either way, every divergence found, in both sweeps, preserves the load-bearing property: the padding side changes what a truncated field decodes to, which can flip which exception fires, but truncated input never silently parses in one direction and errors in the other. That property, not the specific count, is what makes this shippable.
|
|
❌ NEEDS CHANGES — head |
Cross-review appendix — PR #621Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head The change itself
What this PR leaves failing — confirmed exactly, and nothing moreBoth subtests of that one test, and no other test in the file. The assertion detail confirms the cause is purely the padding side, not a semantic regression: The two files this PR does update are green: Two measurement notes for anyone reproducing this. First, The blocker, and why it changed since this PR was writtenThe PR correctly declined to edit
So the work is unblocked and belongs to this PR: rebase onto current The #571/#431 constraint — independently swept, and it holdsBuilt my own sweep rather than trusting the reported one: for each of 20 real captures, parse fully and record frame count plus a digest of every frame's protocol chain, then re-parse at 13 truncation points per file (fractional offsets plus fixed small offsets and the 5222 the PR names). Ran it twice — once on this head, once with only line 527 reverted to Zero full parses change outcome, and the 1594-frame total matches the PR's figure exactly. Decisively, the parsed-vs-raised split across truncations is identical on both sides (2 / 258) — there is no truncation that used to raise and now silently yields wrong data, nor the reverse. That is the property that would have made this unshippable, and it is clean. One correction to the PR's claimThe PR reports "231 of 232 outcomes byte-identical, the single difference being But on a denser grid (260 points rather than 232) three differ, not one — the two extra points simply weren't sampled by the PR's grid: All three remain exception→exception, so none weakens the conclusion. Worth noting though that CoveragePer the brief, the flat 84% is correctly not treated as evidence — line 527 already executed before the change. The meaningful axis is the subtest count, and the 164 subtests passing in the two updated files is consistent with the claimed 195 → 328 growth across the PR's full selection, which I did not run in full (host-safety: no whole-tree runs). Disagreement logThe fix: agree. Correct, minimal, well-commented, and the constraint that mattered is verified intact by an independent sweep. Verdict is NEEDS CHANGES on one narrow point only: the PR leaves One dispute with the PR's own numbers: "231 of 232, one difference" understates it slightly; a denser grid gives three of 260, one of which changes exception type. Conclusion unaffected. One dispute with the review brief rather than the PR: |
|
Retraction: I am withdrawing my ✅ GOOD TO MERGE above and joining the ❌ NEEDS CHANGES verdict. My verdict rested on "PR #612 owns
So the dissenting review's read is correct: this file is unowned, still red under #621's fix, and merging #621 as-is turns |
|
This merged before the NEEDS CHANGES verdict and my retraction above were posted, and Merge timestamp Verified directly against current The squash-merged commit's
|
Cross-review — PR #621 (posted after merge;
|
#604) (#621) (#627) * `TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length` still expected `b'\x00' * zeroes + trailing`, the head-padded short read that #604 removed. Both subtests failed on that assertion once #621 landed, so `main` was red. The expectation is now `trailing + b'\x00' * zeroes`. * The docstring above it said `FieldBase.unpack` "left-pads" the short read and described the value as four zero octets followed by the six real ones. Both are inverted, so the prose no longer contradicts its own assertion. Test-only; `pcapkit/` is untouched. The file goes 2 failed / 17 passed / exit 1 to 17 passed / 2 subtests passed / exit 0, and the two files #621 updated stay at 61 passed / 164 subtests / exit 0.
…604) (#628) * `TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length` expected a truncated TCP option's `data` as the synthesised zero octets followed by the real ones, which is what `rjust()` produced. #621 made the padding `ljust()` everywhere but could not retarget this file, because #612 owned it at the time; it has been red on `main` since #621 merged. * The real octets now come first for both parametrised widths, and the docstring above the assertion says tail-padding rather than left-padding. * Test-only: no library code changes. The sibling case in `tests/protocols/internet/test_ipv4_unit.py` was already retargeted in #621. Measured against `main` at 2221c2d: two subtest failures before, none after. `tests/protocols/transport/` and `tests/corekit/test_fields_field.py` together give 177 passed, 232 subtests passed.
Fixes #604
The defect, and where it lives now
FieldBase.unpackzero-fills a short read withrjust. The issue citespcapkit/corekit/fields/field.py:244; on the currentmainthe line has moved topcapkit/corekit/fields/field.py:506(#593's budget work landed above it):rjustputs the zeros at the front, which asserts that the octets never readwere the leading ones. A short read asserts the opposite: the buffer ran out, so
what is missing is whatever came after what was read. The zeros belong at the
end. It is now
ljust.ljustis correct for both byte orders — confirmedThe comment on #604 is right that this is not a little-endian-only defect, and the
fix is therefore not byte-order-conditional. Measured on
origin/main(
PADS WITH: rjust, tree identity asserted before import) and on this branch:rjust(before)ljust(after)120(0x78)20132659201201200x010203040x102030x10203000x10203000x01020x10x1000x1000x01020x2000x20x20x01020304050607080x1020304050x1020304050000000x1020304050000000x4050607080000000x4050607080x405060708Both directions are wrong under
rjust; they merely fail opposite ways. Thebig-endian half scales the value down, so it passes a sanity check far more
easily than the inflated little-endian half — which is why only the loud one was
ever reported. A full read is byte-identical before and after, at every width and
both orders, because the padding is only ever consulted when the buffer falls
short.
The
MemoryErrorchain does resolve — with a caveat worth statingThe #594 worker's report was reported-but-unverified going in. It is now
verified, with one correction.
Truncating
examples/captures/dhcp_little_endian.pcapng(1772 octets) leaves aone-octet read of a little-endian 32-bit PCAP-NG block length. Read out of the
failing frame's own locals on the unfixed tree:
rjust2013265920(0x78000000, 1.88 GiB)self._file.read()2214592512(0x84000000, 2.06 GiB)self._file.read()2214592512(0x84000000, 2.06 GiB)self._file.read()Under a 1 GiB
RLIMIT_AS, all three raiseMemoryErrorat exactlypcapkit/protocols/protocol.py:1016(return self._file.read(*args, **kwargs)),as reported. On this branch all three instead end in the ordinary, already-handled
ValueError: read length must be non-negative or -1atpcapkit/protocols/schema/schema.py:857, and the same reads report120and132.The caveat: whether the symptom is a
MemoryErrordepends on how muchaddress space the process can get. At a 4 GiB cap the ~1.9–2.1 GiB allocation
succeeds and the parse goes on to fail with the same
ValueErrorit now fails withdirectly — so on an unconstrained host the observable bug is a ~1.2 million-fold
allocation amplification from a 1772-octet file rather than a crash. The
161figure in the report is real at a 1 GiB cap; a cross-check at a 2 GiB cap saw
ValueErrorat 161 andMemoryErroronly at 641 and 1389, which is consistentwith the sizes above rather than contradicting them.
A truncated capture must still parse — verified both ways
This changes what a truncated field reports, which is the point. It must not
change whether a capture parses (#431; PR #571 was declined for breaking that,
and #593's budget is built around preserving it). Both sides swept against the
same base (
a2be2cc1a), fixtures regenerated for each, tree identity and paddingside asserted at import:
ValueErrorplus 1
struct.erroron each side; the same 2 truncated variants still parse.ValueErrorone already-failing case raises:many_interfaces.pcapngcut to 5222 octets moves fromValueError: 393216 is not a valid BlockType(corekit/fields/numbers.py:513)to
ValueError: read length must be non-negative or -1(
protocols/schema/schema.py:857). Both are ordinary parse failures at atruncation point, neither is a crash, and it failed before the change too.
Tests
FieldBaseShortReadPaddingSideTests, appended totests/corekit/test_fields_field.py(extended additively — #593's structure isuntouched): 10 cases, 133 subtests, covering both byte orders at 2, 4 and 8 octets
at every truncation point, the two figures #604 reports as literals, the
value-preserving property stated as a property rather than a table, both failure
directions as inequalities, a signed field, a byte-string field, and an
unpack-then-pack cycle.
Run against the unfixed tree first (a pristine
git archiveexport,PADS WITH: rjustasserted), then against this branch:8 of the 10 cases fail without the fix. The 2 that pass on both trees are
deliberate guard rails and must not move:
test_a_full_read_is_untouched_at_every_width_and_order— a complete fieldcannot change.
test_an_entirely_empty_buffer_still_reads_as_zero— an empty buffer pads toall zeros either way, which is the OptionField.unpack never returns for a well-formed HOPOPT header with an SMF_DPD option #431 end-of-option-list /
Pad1behaviourthe option and list loops depend on.
Scoped suite runs (never the whole tree — this host has no swap):
EXPECTED_FAILURESintests/protocols/test_option_roundtrip_unit.pyisunaffected — no entry flips, so that file is not touched.
Coverage of
pcapkit/corekit/fields/field.pyis 84% before and after(126 statements, 16 missed, 26 branches, 5 partial, identical missing-line sets
modulo the comment lines added). That is honest rather than a win: line 506 was
already executed by the existing suite, so a statement counter cannot see this
change. What rises is behavioural coverage — 195 → 328 subtests in
tests/corekit/, i.e. 133 new assertions over byte order × width × truncationpoint, which is exactly the axis statement coverage is blind to.
One test left failing, deliberately — needs the owner of that file
tests/protocols/transport/test_tcp_udp_unit.py::TCPUDPUnitTests::test_a_truncated_option_still_parses_its_declared_lengthfails on this branch, in both its subtests (
declared_length=12and32). Itpins the old padding side:
Under
ljustthe correct expectation istrailing + b'\x00' * zeroes, and thedocstring at lines 1307–1310 ("left-pads … four zero octets followed by the six
real ones") needs the same inversion. I have not made that change: that file is
concurrently owned by another worker in this batch, and editing it risked clobbering
their work. It is a one-line assertion plus a prose sentence. The sibling case in
tests/protocols/internet/test_ipv4_unit.pyis the same defect and is fixed here,since that file was not contended.
Note line 1350,
self.assertEqual(bytes(proto.__header__), raw), passes — the packpath is byte-preserving and is not affected.
Found while here, deliberately not fixed
pcapkit/corekit/io.py:328—SeekableReader.truncatedoesio.BytesIO(temp.rjust(size, b'\x00')). Same family of defect:_bufferisfront-anchored to
_buffer_set, so growing it should append capacity at the end,and
rjustinstead shoves the cached content to the end while_buffer_setisleft alone, corrupting the index-to-offset mapping. Reproduced: after reading
b'abcd'andtruncate(8), aseek(0)read returnsb'\x00\x00\x00e'.tests/corekit/test_io.py:131-150only checkstruncate()'s return value, neverits content, so nothing catches it. Out of scope for FieldBase.unpack pads a short read with rjust regardless of byte order, silently corrupting little-endian values #604 and it is not a
blind
rjust→ljustswap —SeekableReaderalso inheritsio.BufferedReader'sown C-level buffering, so the fix wants its own investigation. Worth its own
issue.
ValueErrorfrom library code. Every truncated capture in the sweep endsin
ValueError: read length must be non-negative or -1raised frompcapkit/protocols/schema/schema.py:826/:857, not frompcapkit.utilities.exceptions. Pre-existing, unchanged by this PR, and contraryto the in-library exception convention.
OptionFieldselectorsuse a 2-octet type (
hip.py:313,sctp.py:427,sctp.py:232,mh.py:814, andPCAP-NG's
Option.type, the only one witheoolwired up). When a capture endsmid-type-field exactly one octet survives, which is a genuinely partial read
rather than an empty one, so it decodes to 0 under neither padding side unless the
surviving octet is itself
0x00. That path was therefore already not a reliableend-of-option-list route before this change;
ljustchanges which non-zero valueis produced, not whether it terminates. No existing test exercises it. Narrow, but
worth a look on its own.
Provenance of the measurements
Every figure above was produced with
PYTHONSAFEPATH=1, the target tree insertedat
sys.path[0],assert pcapkit.__file__.startswith(<tree>)before any otherimport, and the padding side read back out of
inspect.getsource(FieldBase.unpack)and printed alongside the result — so no number here is attributed to a tree it was
not measured on.
MemoryErrorwork ran under an explicitRLIMIT_AS(1 GiB or4 GiB, stated per result) because this host has 62 GB RAM and no swap.