fix(ipv4): terminate the SEC bitmap, reject its terminator bit, and narrow SID to 16 bits - #543
Conversation
, #537) * `_make_opt_sec` never set RFC 1108's field termination indicator, so every SEC option the library wrote with an authority was one its own reader warned about -- including in the project's own `options-ipv4.pcap`. Bit 0 of the final octet is now set. * `_make_opt_sec` sized the bitmap from the highest authority *index* rather than the bit count, so a lone `GENSER` (value 0) built a zero-octet bitmap and raised a bare `IndexError`, and every multiple of eight under-sized by an octet. It sizes from the count and rejects a non-authority index with `ProtocolError`. * `Field_Termination_Indicator` (index 7) was accepted as an authority, writing an option that read as terminated while carrying none. Index 7, 15 and 23 are termination bits by `_read_opt_sec`'s own numbering and are now rejected; the reader's `range(7)` is left alone, being the correct half. * `SIDOption.sid` was a `UInt32Field` where RFC 791 gives a 2-octet Stream ID, so a well-formed option over-read by two octets (`packet length < 0: -2`) and re-emitted six octets wide, dragging NOP/EOOL padding in behind it. Narrowed to `UInt16Field`; a parsed datagram now rebuilds byte-identically. * Adds a UDP-in-IPv4 payload test, closing the gap that let a fix special-casing `(Protocol, Raw, NoPayload)` pass all four of #536's tests. Full pytest suite green: 1181 passed, 17 skipped, 2661 subtests.
|
✅ GOOD TO MERGE — independently reproduced all five fails-without proofs at exit code 1 (SEC terminator bit, SEC bitmap sizing |
Detailed review (independent verification, falsify-not-bless)Head sha reviewed: RFC claims — verified against the source directly, not taken from the PRRead Falsification — all five fails-without proofs independently reproduced, exit codes read from a fileWorking in my own worktree at this head, I made one surgical revert at a time, ran the specific new test, confirmed the failure, then reverted my edit and confirmed a clean diff before the next one:
All five match the PR's claimed evidence exactly. Re-derived: 43 ProtocolBase descendants, 40 outside the three special-cased classesWalked Call 3 (the coordinator's flagged subtle claim) -- independently settled, and it holdsThe claim: narrowing SID from 6 to 4 octets removes the only existing trigger for the multi-NOP padding arm ( I verified this by direct removal rather than by reasoning about it: temporarily changed both new triggers so the constructed SEC option became 4 octets / 32-bit-aligned instead of 5 (so neither reaches the padding branch at all), then re-ran Baseline (unmodified head): 471 stmts, 0 miss, 178 branch, 1 brpart, 99%, missing only the pre-existing With both new triggers neutralized: 471 stmts, 3 miss, 178 branch, 4 brpart, 99%, now also missing line 1309 -- exactly the data-model-branch multi-NOP line the coordinator asked about -- confirming Call 3 is correct: this line does go uncovered without the new SEC trigger, meaning the narrowed SID genuinely was the sole prior trigger and the PR's fix is a real, necessary catch rather than incidental churn. (Two other lines, 1582 and 1898 -- unrelated ProtocolError raises in Call 4 -- the four changed pre-existing assertions, checked against the actual diff hunksRead each hunk directly rather than accepting the PR's categorization:
All four check out as claimed; none was changed merely to make a red test green for an unrelated reason. The generator diff -- not a bug-hiding edit
The most-checkable outcome -- confirmed, after I initially mismeasured itRegenerating EXPECTED_FAILURES -- confirmed unchanged, with a correction to my own initial readImported (not grepped): 55 entries, unchanged from main. The two surviving ipv4-option keys are Coverage -- independently measured, matches exactly
CI statusNot run -- GitHub Actions has been backed up throughout this review session (queued/pending with no declared incident); verdict is on local evidence only, per standing instruction. What remains unverified
|
|
Follow-up on the full-suite number promised above: my own run of This is narrower than the PR's own claimed "Full suite: 1181 passed, 17 skipped, 2661 subtests" -- |
Closes #534
Closes #537
Three defects in the IPv4 SEC option, one in the SID option schema, and one test-coverage gap that #536 left behind. They are in one PR because they are one file cluster —
pcapkit/protocols/internet/ipv4.py, its schema, and the IPv4 option tests.Every measurement below was re-derived on this branch's base, not taken from the issues.
#537(a) — the writer emitted an option its own reader rejected
RFC 1108 §2.2 makes bit 0 of each protection-authority octet the field termination indicator:
0means another octet follows,1means this is the last._read_opt_secenforces it._make_opt_secbuilt the bitmap purely from authority bit positions and never set it.Fix:
data_list[-1] = b'1'after the bitmap is built. Intermediate octets keep the0they were initialised with, which is exactly what "another octet follows" means, so one assignment is the whole indicator.Fails without the fix — reverted
data_list[-1] = b'1', kept everything else:#537(b) — a bare
IndexErrorescapedint_len = math.ceil(max_auth / 8)sized the bitmap from the highest bit index rather than the bit count. WithGENSER(value 0) the only authority,int_lenwas 0,data_listwas empty, anddata_list[0] = b'1'raisedIndexError: list assignment index out of range— not an in-library exception, and out of a_make_opt_*helper. The same off-by-one under-sized by a whole octet at every exact multiple of eight; no shipped enum member has value 8, so that half was latent rather than reachable.Fix:
math.ceil((max_auth + 1) / 8).Fails without the fix — restored
math.ceil(max_auth / 8):#537(c) — index 7 was both an enum member and the terminator bit
Enum_ProtectionAuthoritymember 7 is namedField_Termination_Indicator— structure, not an authority — yet the writer accepted it, producingdata=b'\x01': an option reading as validly terminated while encoding zero authorities.The writer and reader disagreed, and this records which one won.
_read_opt_secloopsrange(7)per octet and maps octetbasebitbitto authoritybase * 8 + bit, so the numbering it produces skips 7, 15 and 23 — those positions are termination bits and nothing else. The reader is right and the writer was wrong, so the rejection is on the write side andrange(7)is deliberately left alone. Widening it would make the reader report a terminator as an authority.Fix: reject
auth % 8 == 7withProtocolError, andauth < 0too — a negative index would have written to the terminator through Python's negative indexing rather than raising, which is silent corruption and the one way theIndexErrorcould still have been reached after (b).Rejecting the whole congruence class rather than only the named
7follows from the reader's numbering: 15 and 23 are termination bits for the same reason and are just as undeliverable, they simply have no name in the enumeration yet.The message uses
getattr(auth, 'name', auth)rather thanEnum_ProtectionAuthority.get(auth), because the latter runs_missing_for an unnamed index and extends the enumeration as a side effect — an error path should not mutate a registry.Fails without the fix — removed the validation loop:
#534 —
SIDOption.sidwas 32 bits where RFC 791 gives 16RFC 791 §3.1 gives the Stream ID option as four octets: type, length, and a two-octet identifier.
_make_opt_sidalready wrotelength=4; only the schema field disagreed, so it over-read a well-formed option by two octets on the way in and over-wrote it by two on the way out. Six not being a multiple of four, the option area then reached the 32-bit padding branch and picked up aNOPand anEOOL.88040037off the wirepacket length < 0: -288040000003788040037SIDOption, NOPOption, EOOLOptionSIDOption8804000000370100, 28 octets, ihl 788040037, 24 octets, ihl 6Fix:
UInt16Field. A parsed datagram now rebuilds byte-identically to the octets it was read from.Fails without the fix — restored
UInt32Field:Coverage gap #536 left behind
#536 fixed
Schema.packto checkisinstance(data, ProtocolBase)instead ofProtocol. Its reviewer showed by falsification that a wrong fix passes all four of #536's new tests, because every shipped test's "real protocol" payload is onlyRaw(...)orNoPayload().Reproduced. Applying
— special-casing exactly the classes the tests use — gives:
while the general defect survives for the other 40
ProtocolBasesubclasses. Measured by a__subclasses__()walk:Protocolhas 0 descendants,ProtocolBasehas 43, and 40 are outside{Protocol, Raw, NoPayload}.New test:
test_schema_pack_packs_a_second_real_protocol_as_payload— a real UDP nested in IPv4, plus TCP and IPv4 as second and third payloads, asserting each is aProtocolBaseand none of the three classes a special-case list would name. It also asserts the uncovered-descendant count, so the reason a class list cannot be the fix is recorded as a number rather than a remark.Fails without the fix (fake fix applied):
Passes with the real fix restored (
schema.pybyte-identical tomain):EXIT CODE 0.pcapkit/protocols/schema/schema.pyis not modified by this PR — part 3 is test-only.The
OptNo 130warning is gone from the regenerated fixtureexamples/captures/options-ipv4.pcapregenerated withpython examples/generators/make_samples.pyand re-extracted:The SEC option now reads back with no
ProtocolWarningat all, and its flags round-trip to(GENSER, NSA). The captures are gitignored, so nothing generated is committed.The generator's workaround comment at
examples/generators/options.py:469is updated rather than deleted: the pair of authorities is kept, because two bits set say more than one, but it is now a coverage choice and no longer routes around a defect. That the library no longer needs it is proved by the new test passing[GENSER]alone.EXPECTED_FAILURESreconciliationBefore: 55 entries. After: 55 entries — unchanged. Imported rather than grepped, since it uses
**unpacking.No entry became stale and none started passing, so there was nothing to reconcile.
test_round_trip_is_identity_or_a_recorded_gapandtest_expected_failures_name_real_caseswere each run in isolation and checked by exit code (0), not by the summary line —pytest-subtestsis not installed and pytest 9.1.1 prints a failing subtest's parent asPASSED.Test changes that are not new tests
test_a_parsed_sid_option_re_emits_two_octets_too_wideis removed fromtests/protocols/test_option_roundtrip_unit.py, which is what #534 asks for: it existed only because the defect could not be expressed as anEXPECTED_FAILURESentry, its cycle closing either way. Its coverage is not lost — it is inverted and relocated totest_ipv4_sid_option_is_four_octets_wide_on_the_wire, which starts from the same wire octets and asserts more: byte-identical rebuild, no padding, no warning. The module docstring and theEXPECTED_FAILUREScomment are updated to point at it, and the now-unusedwarningsimport is dropped.Four existing assertions changed because they encoded the old behaviour:
Field_Termination_Indicatoras an authority — i.e. they were unknowingly relying on defect (c) to terminate the option. One now passes[GENSER]alone and the expected octets are unchanged (b'\x81'); the other uses(GENSER, NSA), which is also the more faithful data model since_read_opt_secnever puts the terminator inflags.32 -> 28withihl 7 -> 6,12 -> 8,8 -> 4,20 -> 24(the last grew because a SEC option was added, see below).#506's EOOL-padding coverage was at risk and is preserved deliberately. SID's old 6-octet width was what drove the 32-bit alignment branch in two places; with SID 4-aligned it no longer reaches that branch at all, which would have silently dropped the coverage. So both paths get an explicit trigger instead:
OrderedMultiDict, which also restores thefor _ in range(pad_len - 1)multi-NOP arm — that line went uncovered when SID stopped being a6 % 4 == 2trigger.Verification
Repo venv,
PYTHONSAFEPATH=1, andpcapkit.__file__asserted to be inside this worktree on every measurement, so no editable install in site-packages could shadow the tree under test.EXIT CODE 0— 1181 passed, 17 skipped, 2661 subtests.tests/protocols/:EXIT CODE 0— 488 passed, 1381 subtests.tests/test_docstring_contract.py:EXIT CODE 0. (It failed onpcapkit/vendor/ipx/packet.pybefore the rebase; that was pre-existing onmain— verified against a pristine tree — and docs(hopopt): cite RFC 8200 section 4.2 for the Opt Data Len sentence (#530) #538 has since fixed it.)Coverage of the two changed library files,
mainversus this branch:maininternet/ipv4.pyschema/internet/ipv4.py+6 statements and +6 branches, all covered, with no new gap — the single remaining partial (
322->324) is pre-existing.Found but deliberately NOT fixed
_read_opt_sec'sfor bit in range(7)is left as it is. It is the correct half of IPv4 SEC option: the writer emits an option its own reader rejects, and IndexErrors on a single authority of value 0 #537(c): bit 0 of each octet is structure, so excluding it is right, and the writer was changed to agree. Widening it torange(8)would make the reader report a terminator as an authority.remaining dataand on an unknown authority. Loosening or tightening that is a behaviour change for parsing real-world captures and is out of scope here; the writer no longer produces any of those warnings.ProtectionAuthority._missing_extends the enumeration on any non-negative int lookup, soProtectionAuthority(9999)permanently addsUnassigned_9999to a process-wide enum. The new code avoids triggering it, but the underlying registry-mutation-on-read behaviour is untouched — it is shared by everyconstenum in the tree and is a separate design question._read_opt_sec'sbase < schema.length - 4guard for theremaining datawarning reads as an off-by-one risk for a 1-octet bitmap, but it is correct as written (length - 4is the last octet's index) and no change was needed.SIDOptionhas no upper-bound validation onsid. A caller passingsid=0x10000now overflows a 16-bit field rather than being rejected with a clear message. Pre-existing for every other narrow field in the schema and not specific to this change.Not done
Not merged, not tagged, not released.