Repository navigation
corekit: stop the option and list loops spinning forever on a truncated area (#431) - #432
Conversation
…data) (#431) `OptionField.unpack` never returned for a well-formed eight-octet HOPOPT header: from pcapkit.protocols.internet.hopopt import HOPOPT buf = bytes.fromhex('3b00' '0803810203' '00') # SMF_DPD len=3, then Pad1 HOPOPT(buf, len(buf)) # 15s of CPU, no return `len=0` means an 8-octet header, and the 6-octet option area holds an SMF_DPD option of length 3 followed by one `Pad1`. Nothing about it is malformed. A remote peer that can put such an option in a Hop-by-Hop header hangs the process, with no exception and no diagnostic -- and `pcapkit` parses untrusted input. The loop decremented its remaining `length` by `len(data)`, the size of the schema the option *reported*, which is not the number of octets the option took from the stream. The two part company for the six wrapper schemas whose `post_process` returns a *nested* schema, since `len(data)` then measures the nested schema and not what the wrapper read. Instrumented, on the bytes above: step 0: length=6 SMF_DPD len(data)=5 advanced 6 -> length 1 step 1: length=1 Pad1 len(data)=0 advanced 0 -> length 1 step 2..n: identical, forever Step 0 left `length` at 1 with the stream already exhausted, so from step 1 on every field read `b''`, `len(data)` was 0, and `length -= 0` made no progress. - Remember where the previous option ended, and require each iteration to move the stream forward at least one octet. That is a measure of progress the loop can take independently of what a schema reports, and it is what bounds the iteration count. A `FieldValueError` naming the option code, the offset and the octets left to parse replaces the hang; here it lands in 1ms and reads `at offset 6 of 6`, which is the tell that an earlier option over-read. - `length` is still decremented by `len(data)`, deliberately: every option this package can parse consumes at least its own type field, so no option area that parses today can reach the guard, and none parses differently. The `_SMFDPDOption` over-read that starts the mismatch is a separate defect and is fixed separately. Tests: `tests/protocols/schema/test_schema_unit.py` builds the smallest schema that separates the two measures -- a wrapper reading two octets and returning a nested schema that recorded one -- and asserts the loop raises rather than spins. `tests/_support.time_limit` gives it a `signal.alarm` deadline, so a regression fails the suite in five seconds instead of wedging CI; a watchdog thread cannot do that job, because the loop holds the GIL for the whole of an iteration. On the pristine tree the test fails with `TimeoutError: did not finish within 5s`. 858 passed, 17 skipped (857 before). Every capture in `examples/captures/` dumped to `tree` and `json` with `ip=True, tcp=True, reassembly=True` is byte-identical before and after.
…right octet (#431) `_SMFDPDOption` is the read-side wrapper that peeks at an `SMF_DPD` option to decide which of the two DPD mode schemas should parse it. It over-read the stream, which is what started the option-loop accounting mismatch behind the hang in #431. Two separate mistakes, both in the sizing: - `test`'s `BitField` namespace declared `'len': (1, 8)`. Those tuples are `(bit_offset, bit_width)` into the whole three-octet forward match, and `Opt Data Len` is octet 1, i.e. bits 8 to 15 -- not bits 1 to 8, which straddle the `Option Type` octet and take one bit of the length. For the option `08 03 81` it read `len` as **16** where the wire says 3, so `smf_dpd_data_selector` built a `SchemaField(length=16)` and `Schema.unpack` read 16 octets, or as much of the option area as there was. That is where the stray octet in #431 came from: six octets consumed against a nested schema that recorded five. - `smf_dpd_data_selector` then sized the field at `Opt Data Len` exactly. `Opt Data Len` counts only what follows the option header (RFC 8200 section 4.2), while both `SMFIdentificationBasedDPDOption` and `SMFHashBasedDPDOption` inherit `Option.type` and `Option.len` and parse those two octets themselves, so the area has to be `Opt Data Len + 2` octets wide. Fixing only the bit offsets would have swapped an over-read for a two-octet under-read. `ipv6_opts.py` carries the same two lines and gets the same fix; HOPOPT and IPv6-Opts now agree on a hash-based DPD option, which they did not before. With both, #431's eight-octet reproducer parses in 1ms instead of spinning forever: `SMF_DPD` of length 5 carrying `hav=b'\x81\x02\x03'` in H-DPD mode, then a `Pad1` of length 1, accounting for all six octets of the option area, and repacking to the octets it was read from. Deliberately not fixed here: `ipv6_opts.SMFIdentificationBasedDPDOption` carries an extra forward-matched `test` octet that HOPOPT's does not. `Schema.__buffer__` records it although the stream never consumes it, so `len(data)` overshoots by one and the option loop charges one octet too many against the area, losing a trailing `Pad1`. It is the same `len(data)`-is-not-consumed confusion, in the other direction, and it is not a hang -- it wants its own change. Tests: `tests/protocols/internet/test_ipv6_extension_unit.py` parses #431's reproducer verbatim plus two more hash-based shapes and two identification-based ones, for both HOPOPT and IPv6-Opts, asserting the option lengths account for the whole option area and that the header repacks byte-for-byte. Each runs under `tests._support.time_limit`, so on the pristine tree they fail with `TimeoutError` rather than hanging the run: 7 of the 10 cases do. The other three happen to size correctly despite the misread -- an option that fills the area exactly cannot over-read past it -- which is why one case is not enough. 862 passed, 17 skipped (857 before this branch). Every capture in `examples/captures/` dumped to `tree` and `json` with `ip=True, tcp=True, reassembly=True` is byte-identical before and after: no capture in the suite carries an `SMF_DPD` option.
…d subclass (#431) Found by sweeping for siblings of #431: the non-progress loop is not confined to `OptionField`. `ListField.unpack`'s schema branch has it too, and a single TCP segment reaches it: TCP(bytes.fromhex('0001000200000000000000006010000000000000051636cc'), 24) 24 octets. A data offset of 6 gives a four-octet option area, holding a `SACK` option that declares 22. `SACK.sack` is a `ListField` sized `Length - 2` with a `SchemaField` item type, so the loop is handed a 20-octet budget over two octets of stream: the first `SACKBlock` records what there is, the second finds the stream exhausted, records nothing, and `length -= len(data)` never falls. Randomly generated TCP headers with a plausible option area hang 5 times in 1500 on `origin/main`, and all 5 are this loop rather than the `OptionField` one -- the guard from the first commit of this branch does not cover them. - Require each schema item to move the stream forward, exactly as `OptionField` now does, and raise a `FieldValueError` naming the item index, the offset and the octets left when it does not. The item-typed branch is left alone: it sizes each item by `field.length`, which it read rather than recorded, so it already makes progress. `SCTP` had already met this and worked around it locally: `bounded()` in `schema/transport/sctp.py` clamps its two `ListField` lengths to the octets on hand, and its docstring describes precisely this hang -- "a `SchemaField` item that runs out of bytes parses to nothing, subtracts nothing, and the loop never terminates". That fix stays where it is; it turns the overrun into a short list the read handlers reject, which is a better answer for SCTP than an exception. The guard is what covers the sites with no such clamp: `tcp.SACK`, `hip.LocatorSetParameter` and `mh.CGAParametersOption` all size a `ListField` straight from a wire length field. Tests: `tests/protocols/transport/test_tcp_udp_unit.py` parses the segment above and a well-formed one-block `SACK` segment beside it, so that a guard which rejected real `SACK` options could not pass. Under `tests._support.time_limit`, so on the pristine tree it fails with `TimeoutError` rather than hanging. 863 passed, 17 skipped (857 before this branch). Captures still byte-identical to `origin/main` in `tree` and `json` with `ip=True, tcp=True, reassembly=True`.
…k, not before (#431) The progress guard added earlier on this branch ran before the loop's `code == self._eool` break, and that turned three protocols' tolerance of a truncated option area into an error. Found by sweeping every option registry for what the exhausted-stream read decodes to. An area declared longer than the octets behind it -- an over-long `ihl` or data offset, or a capture cut short by the snapshot length -- exhausts the stream early, and every field of the next option then reads `b''`, which decodes the type field as **0**. What 0 means is per-registry, and that is the whole story: - IPv4 `EOOL`, TCP `End_of_Option_List`, PCAP-NG `opt_endofopt` and `nrb_record_end` are all 0, and 0 is those registries' `eool`. The break has always absorbed the phantom option and reported the rest of the area as padding, which is how a snaplen-truncated packet parses at all. - HOPOPT, IPv6-Opts and MH read 0 as `Pad1`, HIP as an unassigned parameter and SCTP as a DATA chunk, and all five pass `eool=None` or no `eool` at all. They never reach the break, which is why they spin. So the guard belongs after the break: it then bounds exactly the registries that can spin and leaves the ones that cannot untouched. Measured, on `main` and on this branch before this commit: IPv4(bytes.fromhex('4a00001800010000400600000a0000010a000002'), 20) # ihl=10, 20 octets of options declared, none present # main: parses, options=[EOOL] before: FieldValueError TCP(bytes.fromhex('00501f900000000100000002a002ffff00000000020405b4'), 24) # data offset 10, four option octets present # main: parses, options=[MSS 1460, EOOL] before: FieldValueError Both parse again, to the same options `main` gives them. The captures were byte-identical either way -- none of them carries a truncated option area -- so the dump comparison could not have caught this, and only sweeping the registries did. The sweep also turned up a much simpler reproducer for #431 than the SMF_DPD one, involving no option schema at all: `HOPOPT(b'\x3b\x01', 2)`. Two octets, a declared 14-octet option area with nothing behind it, and `main` spins on the phantom `Pad1` forever. Tests: the truncated area is pinned for HOPOPT and IPv6-Opts in `test_ipv6_extension_unit.py` (raises; hangs on `main`), and the tolerated one for IPv4 in `test_ipv4_unit.py` and TCP in `test_tcp_udp_unit.py` (parses, with the options spelled out; these pass on `main` too, which is the point of them). 867 passed, 17 skipped. Captures still byte-identical to `origin/main`.
There was a problem hiding this comment.
🟡 Changes recommended
ListField.unpack currently computes a per-item field instance but unpacks using the original SchemaField, which can ignore callback/length updates and lead to incorrect parsing.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR addresses non-terminating parsing loops in core collection fields when option/list areas are truncated or when schemas report a different recorded length than the underlying stream consumption. It also fixes SMF_DPD option sizing in IPv6 Hop-by-Hop and Destination/Options headers, and adds regression tests (with hard time limits) to ensure hangs become diagnosable FieldValueErrors.
Changes:
- Add a progress guarantee to
OptionField.unpackandListField.unpackto prevent infinite loops on exhausted/truncated streams. - Fix SMF_DPD forward-match bit offsets and ensure SMF_DPD mode schemas receive
Opt Data Len + 2bytes (including option header). - Add regression tests (bounded by
tests._support.time_limit) for the HOPOPT/IPv6-Opts cases and a real TCP SACK/ListField hang reproducer.
File summaries
| File | Description |
|---|---|
| tests/protocols/transport/test_tcp_udp_unit.py | Adds a TCP SACK overrun regression test ensuring it errors rather than hangs, plus a truncation-tolerance pin. |
| tests/protocols/schema/test_schema_unit.py | Adds a minimal OptionField wrapper-schema reproducer guarded by a deadline. |
| tests/protocols/internet/test_ipv6_extension_unit.py | Adds SMF_DPD correctness + truncation regression tests under time limits for HOPOPT and IPv6-Opts. |
| tests/protocols/internet/test_ipv4_unit.py | Pins IPv4 behavior where over-declared option areas are tolerated via EOOL semantics. |
| tests/_support.py | Introduces a time_limit context manager used to prevent “hang instead of fail” regressions in tests. |
| pcapkit/protocols/schema/internet/ipv6_opts.py | Fixes SMF_DPD bit offsets and sizes SMF_DPD payload as Opt Data Len + 2. |
| pcapkit/protocols/schema/internet/hopopt.py | Same SMF_DPD fixes as ipv6_opts.py. |
| pcapkit/corekit/fields/collections.py | Adds stream-progress checks to ListField.unpack and OptionField.unpack to bound loops on truncated input. |
Review details
Suppressed comments (1)
pcapkit/corekit/fields/collections.py:352
- The new
FieldValueErrormessage reportsoffset {offset} of {self._length}, butoffsetis the current stream position (only relative when the option area starts at 0). Consider wording this as a stream offset to avoid implying it is always relative to the option-area start.
raise FieldValueError(
f'Field {self.name} has an option that consumed no data: '
f'{code!r} at offset {offset} of {self._length}, with '
f'{length} octet(s) of the option area left to parse'
)
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Brings in #427 (the option-parse shortcut), #428 (dispatch no longer writing to shared registries) and #430 (the generated typed `__init__`). One conflict, in `tests/protocols/transport/test_tcp_udp_unit.py`, where both sides appended test methods to the end of `TCPUDPUnitTests`. Both kept. `pcapkit/corekit/fields/collections.py` auto-merged, which is the file worth checking by hand rather than trusting: #427 rewrote the head of the `OptionField.unpack` loop and this branch rewrote its tail, so the two touch the same function without overlapping. The merged loop reads the type field through #427's shortcut, rewinds by its `consumed`, then subtracts `len(data)`, breaks on the end-of-option-list code, and only then checks that the option moved the stream. `git diff origin/main -- pcapkit/corekit/fields/collections.py` is additions only -- none of #427's work is lost, and the guard is still after the `eool` break, which is where #431's last commit had to move it.
…ot from the stream (#431) Review feedback on #432. Both guards reported `offset` straight from `file.tell()`, which is a position in whatever stream the field is reading and only a position in the *field* when that stream happens to start at zero. It does start at zero on the path these guards were written against, since `Schema.unpack` hands a `ListField` or an `OptionField` a `bytes` buffer -- but both methods are public and take an `IO[bytes]`, and a `SchemaField` passes the live file straight down. Off that path the number named an offset outside the field it was measuring: a three-octet option area read from a stream two octets in reported `at offset 5 of 3`. - Remember where the field begins and subtract it in the message, at both sites, so the two agree and the number is bounded by the length beside it. - The comparison keeps raw stream positions; only the diagnostic is relative. Progress is a fact about the stream, and rebasing the arithmetic would have been a second change dressed up as a wording fix. Tests: `tests/protocols/schema/test_schema_unit.py` calls both fields on a stream positioned two octets in and pins the relative offsets -- `at offset 3 of 3` for the option area and `item 2 at offset 2 of 8` for the list. Before this commit they read `offset 5 of 3` and `offset 4 of 8`. The `ListField` test also covers that guard on its own for the first time; it had only been exercised through the TCP `SACK` segment in `test_tcp_udp_unit.py`.
There was a problem hiding this comment.
🟡 Changes recommended
Two newly introduced diagnostics/helpers have correctness issues (ListField failing item index reporting and time_limit() not restoring prior alarms) that should be fixed before merging.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
…ng it (#431) Review feedback on #432. A process has one pending alarm, so `signal.alarm` does not add a deadline, it *replaces* one -- and `time_limit` discarded the seconds `signal.alarm(seconds)` returned, then called `signal.alarm(0)` unconditionally on the way out. Anything already scheduled was therefore cancelled and never put back. Measured: an enclosing `signal.alarm(30)` reads back as `0` after the `with`, so the outer deadline is silently gone -- and a helper whose whole purpose is that a hang fails rather than wedges was quietly removing other people's protection against exactly that. Nothing in the suite nests them today, but nothing stops it either. - Keep the return value of `signal.alarm(seconds)`; it is the only record of what was displaced and cannot be asked for again afterwards. - Re-arm it in the `finally` with the time spent in the body deducted, so an enclosing deadline keeps counting down across the `with` rather than restarting. - The existing cancel-then-restore order is unchanged and still deliberate: the alarm is cancelled before the handler is swapped back, so one firing in between cannot reach the old handler, and the re-armed alarm belongs to that handler rather than to `expire`. An enclosing deadline that *expired* while the body ran cannot be delivered when it was due -- the body held the process past that moment -- so it is re-armed for one second rather than cancelled. Honouring it a moment late is the lesser wrong; cancelling is how the outer timeout goes missing altogether. The same clamp covers an enclosing deadline shorter than `seconds`, which this one necessarily overran. Said in the docstring, since it is a decision rather than an implementation detail. Tests: `tests/test_support_helpers.py` gains `TimeLimitTests` -- an enclosing alarm survives with the handler restored, one overtaken by the body is re-armed rather than dropped, nothing is left pending when nothing was, and the deadline still fires on a body that overruns. That last one matters: without it the other three could be satisfied by never arming anything. The first two fail against the previous helper with `0 not greater than 0` and `0 not greater than or equal to 1`; the other two pass either way, which is the point of them. The module docstring said it covered `close_extractor`; it now says it covers the helpers in `tests._support`, of which that is one.
Review feedback on #432. `ListField.unpack`'s non-progress diagnostic read `item {len(temp)}`, and `len(temp)` is the number of items already parsed -- a 0-based index sitting behind a word that reads as a 1-based ordinal. So `item 2` denoted the *third* item, which is the wrong thing for whoever is reading the error to conclude. Reworded to `after 2 item(s), at offset 2 of 8`: a count of what was parsed, with no convention left for a reader to guess at. Picking a base instead would have made the number correct only for readers who knew which base had been picked. The `OptionField` message keeps naming the option code in that slot, so the two stay consistent -- there is no index there to be read either way. The review suggested `len(temp) + 1`. That fixes the ambiguity by choosing the 1-based reading, but it also moves the number, so it would have to move the expectation in `test_schema_unit.py` to `item 3` in the same commit; the review's claim that the *current* code fails that test is wrong, since the test seeks two octets in, parses two items, and fails on the third with `len(temp) == 2`. On the TCP `SACK` segment that started this, the message now reads: `Field sack has an item that consumed no data: after 1 item(s), at offset 2 of 20, with 18 octet(s) of the field left to parse` -- one block read off the two octets there were, the second finding nothing. Tests: the `ListField` expectation in `tests/protocols/schema/test_schema_unit.py` moves to `after 2 item\(s\), at offset 2 of 8`, and its docstring now says why 2 is a count rather than an ordinal, so the next reader does not have to re-derive it. 893 passed, 17 skipped. Captures still byte-identical to `origin/main`.
There was a problem hiding this comment.
🟢 Approval recommended
The changes address a concrete hang-on-untrusted-input defect with targeted guards and strong regression coverage, with only a minor docstring wording nit noted.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
|
@copilot Fix the code for all comments in this review thread. When a review comment includes a suggested change, apply the suggestion exactly. Do not make changes beyond what is described in the linked review thread. |
… it, not the body
|
Reviewed at head What I checkedThe original hang. Reproduced #431's reproducer directly: Hangs on a pristine Guard ordering, on registries the PR's own tests don't cover. The PR tests the after-
Both confirm the fix generalizes correctly beyond the registries the new tests exercise. No behaviour change on input that parses today.
Full suite, both trees, fixtures generated on both:
Environment: Suspected, but not attributable to this diffFuzzing around the SMF_DPD sizing fix, I found that a maximally truncated 3-octet HOPOPT buffer, That divergence is real but traces to a defect that predates this PR and that this PR doesn't touch: id: 'bytes' = BytesField(length=lambda pkt: pkt['len'] - (
1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2)
))which underflows to VerdictGood to merge at |
…erpreter, not the code Three things, all consequences of #432, #434 and #439 landing under the branch. - `INTERPRETER_GAPS`: seven PCAP-NG name-resolution cases round-trip from Python 3.11 on and fail to construct on 3.10, so one recorded outcome per case no longer suffices. The root cause is #439 in the *schema* hierarchy: `NameResolutionBlock.post_process` asks `isinstance(record, (IPv4Record, IPv6Record))` at `pcapkit/protocols/schema/misc/pcapng.py:1248`, every `Schema` subclass shares one `_abc_impl` on <= 3.10 (measured: `EndRecord._abc_impl is IPv4Record._abc_impl` is True on 3.10.20, False on 3.14.7), and the block's terminating `EndRecord` therefore tests True and has `.names` read off it. Measured in the real path, not inferred. The `Info` data models are unaffected on both interpreters, and that boundary is asserted too. The table overrides rather than sits beside `EXPECTED_FAILURES`, because the three `ns_dns*` options fail on every interpreter but for different reasons. - `test_schema_isinstance_is_interpreter_dependent` pins that mechanism, so the seven are excused by evidence about a named library bug rather than by a version comparison. On >= 3.11 they are still held to `OK`; fixing #439 turns 3.10 red. - The two SMF_DPD entries: `hopopt-option/SMF_DPD` now round-trips and its entry is deleted, since #429's over-read fix landed with #432's progress guard. `ipv6-opts-option/SMF_DPD` raises instead of hanging and is re-recorded as `PARSE`. The two schema modules are line-for-line duplicates, so that is one fix applied once where it was needed twice. The sweep keeps its deadline: it guards the next non-progress defect, not this one. Also: #434 gave IPv4 and HIP real registries, so the generator now reads `HIP.__parameter__` instead of falling back to enum-crossed-with-handler. No library file is touched. 3.14.7: 258 cases, 173 round-trip. 3.10.20: 258 cases, 169 round-trip -- the difference is exactly the four cases in `INTERPRETER_GAPS` that pass on 3.14.
The entry claimed #432's fix "was applied once where it was needed twice". That was wrong, and it was an inference from the behaviour rather than something measured: #432 touched both schema modules symmetrically, 26 lines each, changing `'len': (1, 8)` to `(8, 8)` and adding the `+ 2` to the selector's `SchemaField` in each. Neither file still carries `(1, 8)`. The real cause is one line, older than #432. Normalising the two modules for their protocol names leaves exactly one structural difference: `ipv6_opts.SMFIdentificationBasedDPDOption` declares a second, redundant `test` `ForwardMatchField` at :434 that `hopopt`'s equivalent does not. The enclosing `_SMFDPDOption` already has one in both modules, and it is that outer field the selector reads -- nothing reads the nested copy, and it is the only field in the class with no `#:` comment. A `ForwardMatchField` consumes nothing but still occupies a slot in `__buffer__`, so the nested schema over-reports its size by an octet and `OptionField` mis-counts the option area. Measured on the same octets, `1100080100010100`, identically on 3.10.20 and 3.14.7 -- so this one is not interpreter-dependent: hopopt __fields__ = [type, len, info, tid, id] len(schema) = 3 ipv6_opts __fields__ = [type, len, test, info, tid, id] len(schema) = 4 HOPOPT(...) -> options=[SMF_DPD, PadN] IPv6_Opts(...) -> ProtocolError: IPv6-Opts: invalid format at pcapkit/protocols/internet/ipv6_opts.py:497 Comment and `Gap.defect` text only; the recorded status and fragment are unchanged, and no library file is touched.
…entry #440's round-trip harness recorded this case as a PARSE gap; the fix in this branch closes the round trip, so the entry is removed rather than left stale. Kept the surrounding comment as history of both the #432 and #441 fixes, matching how the hopopt half of the same story was already handled.
Closes #431.
OptionField.unpacknever returns for a well-formed 8-octet HOPOPT header. Onmain:The loop is bounded by
length -= len(data), butlen(data)is what the option's schema recorded, not what the stream actually moved. ForSMF_DPDthose disagree by one octet, solengthfalls to 1 with the stream already exhausted and every later iteration reads a zero type, decodesPad1, consumes nothing and decrements nothing.pcapkitparses untrusted input, so a peer that can put anSMF_DPDoption in a Hop-by-Hop header can wedge the process with no exception and no diagnostic.HOPOPT(b'\x3b\x01', 2)— two octets, no option schema involved at all — is the smaller reproducer, and it hangs onmaintoo.Two defects, four commits
The one-octet over-read (
b07f7839a)._SMFDPDOption.test'sBitFieldnamespace declared'len': (1, 8), but those tuples are(bit_offset, bit_width)into the 3-octet forward match andOpt Data Lenis octet 1, i.e. bits 8–15. For08 03 81,(1, 8)decodeslenas 16 where(8, 8)gives 3, sosmf_dpd_data_selectorbuilt aSchemaField(length=16),Schema.unpackread the 6 octets available, and the nestedSMFHashBasedDPDOptionrecorded its true 5. Fixing only the offset swaps the over-read for a two-octet under-read, becauseOpt Data Lenexcludes the option header while both mode schemas inherit and parseOption.type/Option.len— so the field isOpt Data Len + 2wide.hopopt.pyandipv6_opts.pycarried identical lines and both are fixed.The missing progress guarantee (
87cabdf84,54378e3df,3aed5187c).OptionField.unpackkeepslength -= len(data), so nothing that parses today parses differently, and additionally requires each option to move the stream forward at least one octet — raisingFieldValueErrornaming the option, the offset and the octets left:ListField.unpackhas the same loop and the same hole, reachable from a real TCP segment, so it gets the same guard:TCP(bytes.fromhex('0001000200000000000000006010000000000000051636cc'), 24)hangs onmain— aSACKoption declaring 22 octets in a 4-octet area leavessack'sListFieldreadingSACKBlockoff an exhausted stream. 5 of 1500 random TCP headers hang there, none of them fixed by theOptionFieldguard.sctp.py'sbounded()already works around precisely this and its docstring describes the hang;tcp.SACK,hip.LocatorSetParameterandmh.CGAParametersOptionhad no such clamp.The guard's placement is load-bearing, and the first attempt had it wrong (
3aed5187c). Run before theeoolbreak it broke IPv4, TCP and PCAP-NG: a truncated option area exhausts the stream, the read decodes the type as 0, and 0 is those registries' end-of-option-list, so the break has always absorbed it.IPv4(bytes.fromhex('4a00001800010000400600000a0000010a000002'), 20)parses onmainand was being turned into an error. Captures were byte-identical either way — only sweeping the registries caught it. The check now runs after the break. That earlier commit was already pushed, so this is a follow-up commit rather than an amend.Which registries could hang, and which never could
All 34
OptionFielddeclarations were swept. What decides it is what the exhausted-stream read (type 0) means for that registry: where 0 is theeool(IPv4, TCP, PCAP-NG) the loop breaks and always did; where it is not, it spins — HOPOPT / IPv6-Opts / MH read 0 asPad1, HIP as an unassigned parameter, SCTP as a DATA chunk, all witheool=None. All five confirmed hanging onmainand raisingFieldValueErrorhere.Verification
mainvs this branch. 457 outcomes changed, allHANG→FieldValueError; zero inputs that parsed onmainparse differently.examples/captures/×treeandjsonwithip=True, tcp=True, reassembly=True— 28 digests, identical to a pristineorigin/maintree, warnings included. Fixtures regenerate byte-identically withexamples/generators/make_samples.py.mainbaseline 857 / 17 / 835).tests._support.time_limit, asignal.alarmcontext manager: a non-progress defect gives a test nothing to assert on — it wedges the run rather than failing it — so the deadline is as much part of the test as the assertion.signal.alarmrather than a watchdog thread, because these loops are pure Python and hold the GIL for a whole iteration, which also rules out an outertimeout(1)sendingSIGTERM. It skips whereSIGALRMis absent rather than silently running unbounded. Every new test fails or times out against a pristineorigin/maintree.main.Interaction with #427
Conflict-free (
git merge-treeexit 0,collections.pyauto-merges): #427 rewrites the top of the loop — the type-field shortcut and the rewind amount — while this touches the bottom and the pre-loop setup, and the locals do not collide. The merged tree was materialised and the full suite run in it: 859 passed, 35 skipped, 0 failures (the extra skips are git-dependent tests in a non-checkout scratch directory), with the #431 reproducer parsing correctly.tests/corekit/test_fields_collections.py, which #427 adds, is untouched here to avoid an add/add conflict.Left for their own issues, not fixed here
Verified while working:
ipv6_opts.SMFIdentificationBasedDPDOptioncarries an extra forward-matchedtestoctet HOPOPT's does not, andSchema.__buffer__records it although the stream never consumes it, solen(data)overshoots by one and a trailingPad1is lost — the same confusion in the other direction, not a hang;_MPTCP.testhas the identical'length': (1, 8)bug (60 instead of 12 forMP_CAPABLE), currently masked by TCP'seool=0;FieldBase.unpackdoesbuffer[:length].rjust(length, b'\x00')with a wire-derivedlength, so 40 random octets of a PCAP-NG Decryption Secrets Block declaresecrets_length = 3067771170and cost ~3 s and ~3 GB — an unbounded allocation from untrusted input, present onmain; and the IPv6 extension-header chain loop terminates on truncation but withAttributeError: 'NoPayload' object has no attribute 'next'.