test: pin the truncated-data half of the #431 short-read accommodation - #578
Conversation
#572) - Add TCP coverage (test_tcp_udp_unit.py): a segment with an unassigned option kind declaring length 12 (and 32), with only 6 data octets behind it, still parses -- FieldBase.unpack left-pads the short read with zero octets rather than raising. - Add the IPv4 equivalent (test_ipv4_unit.py), which needs the option area declared larger than the octets physically present, since IPv4._read_ipv4_options gates on the sum of *declared* option lengths where TCP gates on what was actually consumed. - Only the empty-tail half of #431 had coverage before this (test_an_option_area_longer_than_the_{segment,datagram}_still_parses); a candidate fix for #554 (#571) regressed the truncated-data half while the whole suite stayed green, which is the gap #572 tracks. - Add a changelog entry and regenerate CHANGELOG.md. Verified the new tests fail against a deliberately over-strict guard in FieldBase.unpack (raise whenever len(buffer) < length) with the same FieldValueError message #571 would produce, then pass again once the guard is reverted. No source file is touched in this commit. Build/test: full unit tier (pytest -q --ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py') -- 1118 passed, 8 skipped, 2670 subtests passed.
|
✅ GOOD TO MERGE — at Cross-model review (Opus 5; PR authored on Sonnet). Test-only, 4 files, +136/−0, no source file touched. I had built this case from #572's text before reading the PR, so the comparison is genuinely independent: on What I derived rather than accepted:
Two non-blocking notes and one unverified claim in the detailed comment. |
Detailed cross-review — #578 @
|
run on #578 + main |
exit | result |
|---|---|---|
truncation + empty-tail + FieldBaseUnpackBounds together |
0 |
15 passed, 2 subtests |
test_tcp_udp_unit.py and test_ipv4_unit.py in full |
0 |
43 passed, 33 subtests |
So the new tests and #569's ceiling coexist, which is unsurprising once you see why — the guard's length > 262144 conjunct means a 10- or 30-octet field never reaches it — but worth having measured rather than reasoned about. The throwaway branch has been deleted (git branch -D review-578-on-main, was 77eeba048); nothing was pushed.
A rebase before merge would be tidy but is not necessary on the evidence above.
Two notes, neither blocking
1. pytest 9.1.1 reported 1 passed alongside those three failures. The TCP parent test shows as PASSED while both its subtests SUBFAILED, because pytest-subtests is absent. This PR uses subTest, so anyone re-running the falsification check must take the exit code from $? and not the summary line — a casual reader of 3 failed, 1 passed could conclude the TCP test was insensitive to the guard when it is the opposite. Not a defect in the PR; a hazard in verifying it, and the reason I read every exit code from a file.
2. The parses emit warnings the tests tolerate silently. SchemaWarning: packet length < 0: -4 and -24 on the TCP cases, and several on the IPv4 one. That is the pre-existing consequence of a header declaring more than it carries and is not introduced here, but nothing in either test acknowledges it. A change that silenced or reworded that warning would pass both tests unnoticed. An assertWarns, or even a docstring sentence naming it as expected, would close that; I would not hold the PR for it.
What I did not verify
- The body's full unit tier tally (1118 passed, 8 skipped, 2670 subtests). Not reproduced — and it is now unreproducible as stated, since it was measured against the old base and
mainhas since gained test files of its own. I ran both touched files in full on the merged tree instead, which is the part that could plausibly interact. - The scope claim about corekit: reject short dynamic field buffers #571 — that it already covers the fixed-width short-read case in
test_unpack_preserves_fixed_length_short_input_compatibility, and that this PR covers only the callable-length case. I have standing instructions to leave corekit: reject short dynamic field buffers #571 alone and did not open it, so I take that on trust. It does not affect whether this PR's tests are correct or sensitive, both of which I checked directly. - CI, not waited on, per standing practice.
Verdict
GOOD TO MERGE at 0a1e66bd9. It closes exactly the gap #572 describes, asserts the padded reconstruction rather than the naive value, diagnoses the TCP/IPv4 accounting asymmetry correctly at the source, is candid about the one assertion it cannot make and why, and is demonstrably sensitive to the regression it exists to catch — proven by my own probe rather than by its checklist. It also passes on top of the current main that its own runs could not have covered.
#580) An independent cross-review of #578 (which merged before the review finished) found the two new tests correct in every constructed byte, assertion, and pass/fail outcome, but flagged several prose errors in their docstrings: - 0x4f is TCP's Option.Reserved_79, not an "unassigned" kind (the UnassignedOption *schema class* handles it, but the wire code itself is reserved) -- fixed the TCP docstring's wording. - The IPv4 docstring attributed the option_padding rewind-and-reread- as-padding mechanism to "#431 machinery" inside OptionField.unpack. It is actually in Schema.unpack (schema.py:890), added by #371, and predates #431; #431's own contribution to OptionField.unpack is only the post-break progress check, which performs no rewind here. - The IPv4 docstring claimed declaring an 8-octet option area would trip IPv4's stricter length-sum check "before the accommodation under test is ever reached." The accommodation does run -- the short data field is read and left-padded -- the outer check just discards that result afterwards. Fixed to say so. - The TCP docstring attributed the "sizes by what it consumed, not by the declared length" measurement to OptionField.unpack; it is TCP._read_tcp_options itself (tcp.py:698, `len(schema)`). - The TCP docstring's opening line ("cut short mid-option") was wrong for the TCP fixture specifically: nothing is truncated there (hdr_len == len(raw), and the test asserts a full round-trip); the over-declaration is internal to the option, not the capture. Reworded. Left the IPv4 opening line as-is, since that fixture genuinely is short. - The TCP docstring's justification for checking length=32 alongside 12 ("the fix would reject both identically") argued for one case being enough; replaced with the actual distinction (pad width scales with the declared length: 24 zero octets vs 4). - Switched both tests' fixed 6-octet trailing literal to bytes.fromhex(), matching the surrounding files' idiom. No assertion, constructed byte, or test outcome changes. Both tests still pass; both still fail against a reject-on-any-shortfall guard with the FieldValueError text quoted in #572. Build/test: unit tier (pytest -q --ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py') green on this branch, same as before the docstring changes.
Summary
Closes the coverage gap tracked by #572: only the empty-tail half of the
#431 accommodation had a test (
test_an_option_area_longer_than_the_{segment,datagram}_still_parsesin the TCP/IPv4 unit files). The truncated-data half — an option that
starts, declares more data than the capture actually holds, and runs out
partway through its own
datafield — had none, and that gap is what leta candidate fix for #554 (#571) regress it while the whole suite stayed
green. This is a test-only change; no source file is touched.
tests/protocols/transport/test_tcp_udp_unit.py): a segmentwith data offset 7 (8 octets of option area) carrying a reserved
option kind (
0x4f,Option.Reserved_79) that declareslength=12(and, in a second subtest,
length=32) with only 6 real data octetsbehind it. On
mainthis parses:FieldBase.unpackleft-pads the shortread with zero octets rather than raising, so the option's declared
length survives intact and its
datacomes back as the zero-paddedreconstruction. Reachable here because
TCP._read_tcp_optionssizeseach parsed option by
len(schema)— what it actually consumed — notby its self-reported
length, so its own threshold check never seesthe shortfall.
tests/protocols/internet/test_ipv4_unit.py): the sameUnassignedOption.datafield, same shortfall, same left-paddingoutcome — but
IPv4._read_ipv4_optionssums each option'sself-declared length rather than what it consumed, so the option area
has to be declared with headroom above the single option's declared
length, or that check discards the accommodation's result once its loop
over the parsed options finishes. The docstring on the test spells out
the resulting second-order effect (
Schema.unpack's pre-existingoption_paddingrewind, from Fix seven PCAP-NG parser defects (#341-#347) #371, re-reading the same octets oncemore as padding) and why it's immaterial to what the test actually pins.
Both tests were proven to fail against a deliberately over-strict guard —
FieldBase.unpackraising wheneverlen(buffer) < length— with the sameshape of
FieldValueError#571 would produce, then verified to pass againonce the guard was reverted (byte-identical restore confirmed by
md5sum).A changelog entry was added to
docs/source/changelog/1.5.0.rstandCHANGELOG.mdregenerated withutil/changelog_md.py.On #571
The reason this gap exists at all is
lux-liang's #571, which regressedthis exact path while the full suite passed — that PR is the occasion for
this test, and is cited as such in both files. #571 already covers the
sibling fixed-width short-read case in its own
test_unpack_preserves_fixed_length_short_input_compatibility; this PRis independently written and covers only the callable-length case
(
BytesField(length=lambda pkt: ...)) exercised through a full TCP/IPv4parse, which is the half #571 does not touch.
Test plan
pytest tests/protocols/transport/test_tcp_udp_unit.py tests/protocols/internet/test_ipv4_unit.py -k test_a_truncated_option_still_parses_its_declared_length -q— 2 passed, 2 subtests passedFieldBase.unpackguard — 3 failed (2 subtests + 1 IPv4):FieldValueError: Field data requires 10 octets, but only 6 are available.for thelength=12cases, andrequires 30 octets, but only 6 are availablefor thelength=32subtestmd5sum, tests pass againpytest -q --ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py'— 1118 passed, 8 skipped, 2670 subtests passedpython util/changelog_md.py --checkcleanEdit: this PR merged before its cross-review (on a different model,
per this repo's practice) finished. The review found the tests themselves
— every constructed byte, assertion, and pass/fail outcome — correct, but
found several factual errors in the docstring prose above and in the
tests' own docstrings, corrected in #580: this description's text has been
updated to match. See #580 for the full list and reasoning.