Skip to content

corekit: stop the option and list loops spinning forever on a truncated area (#431) - #432

Merged
JarryShaw merged 11 commits into
mainfrom
fix/optionfield-progress-guard
Sep 17, 2026
Merged

JarryShaw merged 11 commits into
mainfrom
fix/optionfield-progress-guard

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #431.

OptionField.unpack never returns for a well-formed 8-octet HOPOPT header. On main:

buf = bytes.fromhex('3b00' '0803810203' '00')   # next=59, len=0; SMF_DPD len=3, one Pad1
HOPOPT(buf, len(buf))                            # never returns

The loop is bounded by length -= len(data), but len(data) is what the option's schema recorded, not what the stream actually moved. For SMF_DPD those disagree by one octet, so length falls to 1 with the stream already exhausted and every later iteration reads a zero type, decodes Pad1, consumes nothing and decrements nothing. pcapkit parses untrusted input, so a peer that can put an SMF_DPD option 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 on main too.

Two defects, four commits

The one-octet over-read (b07f7839a). _SMFDPDOption.test's BitField namespace declared 'len': (1, 8), but those tuples are (bit_offset, bit_width) into the 3-octet forward match and Opt Data Len is octet 1, i.e. bits 8–15. For 08 03 81, (1, 8) decodes len as 16 where (8, 8) gives 3, so smf_dpd_data_selector built a SchemaField(length=16), Schema.unpack read the 6 octets available, and the nested SMFHashBasedDPDOption recorded its true 5. Fixing only the offset swaps the over-read for a two-octet under-read, because Opt Data Len excludes the option header while both mode schemas inherit and parse Option.type / Option.len — so the field is Opt Data Len + 2 wide. hopopt.py and ipv6_opts.py carried identical lines and both are fixed.

The missing progress guarantee (87cabdf84, 54378e3df, 3aed5187c). OptionField.unpack keeps length -= len(data), so nothing that parses today parses differently, and additionally requires each option to move the stream forward at least one octet — raising FieldValueError naming the option, the offset and the octets left:

Field options has an option that consumed no data: <Option.Pad1: 0> at offset 0 of 14, with 14 octet(s) of the option area left to parse

ListField.unpack has 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 on main — a SACK option declaring 22 octets in a 4-octet area leaves sack's ListField reading SACKBlock off an exhausted stream. 5 of 1500 random TCP headers hang there, none of them fixed by the OptionField guard. sctp.py's bounded() already works around precisely this and its docstring describes the hang; tcp.SACK, hip.LocatorSetParameter and mh.CGAParametersOption had no such clamp.

The guard's placement is load-bearing, and the first attempt had it wrong (3aed5187c). Run before the eool break 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 on main and 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 OptionField declarations were swept. What decides it is what the exhausted-stream read (type 0) means for that registry: where 0 is the eool (IPv4, TCP, PCAP-NG) the loop breaks and always did; where it is not, it spins — HOPOPT / IPv6-Opts / MH read 0 as Pad1, HIP as an unassigned parameter, SCTP as a DATA chunk, all with eool=None. All five confirmed hanging on main and raising FieldValueError here.

Verification

  • Differential sweep: 37 option- and list-bearing schemas × 120 random inputs = 4440 parses, same seed, main vs this branch. 457 outcomes changed, all HANG → FieldValueError; zero inputs that parsed on main parse differently.
  • Byte-identical output: 14 captures in examples/captures/ × tree and json with ip=True, tcp=True, reassembly=True — 28 digests, identical to a pristine origin/main tree, warnings included. Fixtures regenerate byte-identically with examples/generators/make_samples.py.
  • Full suite: 867 passed, 17 skipped, 849 subtests, 0 failures (main baseline 857 / 17 / 835).
  • The regression tests carry deadlines. Nine bodies are wrapped in a new tests._support.time_limit, a signal.alarm context 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.alarm rather than a watchdog thread, because these loops are pure Python and hold the GIL for a whole iteration, which also rules out an outer timeout(1) sending SIGTERM. It skips where SIGALRM is absent rather than silently running unbounded. Every new test fails or times out against a pristine origin/main tree.
  • mypy clean on the changed modules; pylint findings unchanged from main.

Interaction with #427

Conflict-free (git merge-tree exit 0, collections.py auto-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.SMFIdentificationBasedDPDOption carries an extra forward-matched test octet HOPOPT's does not, and Schema.__buffer__ records it although the stream never consumes it, so len(data) overshoots by one and a trailing Pad1 is lost — the same confusion in the other direction, not a hang; _MPTCP.test has the identical 'length': (1, 8) bug (60 instead of 12 for MP_CAPABLE), currently masked by TCP's eool=0; FieldBase.unpack does buffer[:length].rjust(length, b'\x00') with a wire-derived length, so 40 random octets of a PCAP-NG Decryption Secrets Block declare secrets_length = 3067771170 and cost ~3 s and ~3 GB — an unbounded allocation from untrusted input, present on main; and the IPv6 extension-header chain loop terminates on truncation but with AttributeError: 'NoPayload' object has no attribute 'next'.

…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`.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.unpack and ListField.unpack to prevent infinite loops on exhausted/truncated streams.
  • Fix SMF_DPD forward-match bit offsets and ensure SMF_DPD mode schemas receive Opt Data Len + 2 bytes (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 FieldValueError message reports offset {offset} of {self._length}, but offset is 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.

Comment thread pcapkit/corekit/fields/collections.py
Comment thread pcapkit/corekit/fields/collections.py
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`.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread pcapkit/corekit/fields/collections.py
Comment thread tests/_support.py Outdated
…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`.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

Comment thread tests/_support.py Outdated
@JarryShaw

Copy link
Copy Markdown
Owner Author

@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.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewed at head 7a73ee35f (7a73ee35ffcf692aa7dc0c167457feba607b5b82), diffed against main. No findings — this is a good-to-merge verdict, with the evidence below.

What I checked

The original hang. Reproduced #431's reproducer directly:

HOPOPT(bytes.fromhex('3b000803810203' '00'), 8)

Hangs on a pristine main clone (killed after a 5s timeout, no exception). On 7a73ee35f it returns in <1ms with SMF_DPD hav=b'\x81\x02\x03' (H-DPD) followed by a Pad1, matching the PR description exactly.

Guard ordering, on registries the PR's own tests don't cover. The PR tests the after-eool-break placement on HOPOPT/IPv6-Opts (spin) and IPv4/TCP (absorbed by the break). I additionally exercised two more eool=None registries named in the PR body but not covered by new tests:

  • SCTP's top-level chunks OptionField (SCTP.chunks, no eool, type 0 = Payload_Data): a 12-octet common header claiming 20 more octets of chunk data that aren't there —
    SCTP(struct.pack('!HHII', 1, 2, 0, 0), 32) — hangs on main (SIGKILL after 6s), raises FieldValueError: Field chunks has an option that consumed no data: <Chunk.Payload_Data: 0> at offset 0 of 20, ... on the branch in ~7ms.
  • HIP's param OptionField (no eool, type 0 = unassigned parameter): a 40-octet fixed HIP header with len=5 (claiming 8 octets of parameters that aren't present) hangs on main (SIGKILL after 6s) and raises FieldValueError: Field param has an option that consumed no data: <Parameter.Unassigned_0: 0> at offset 0 of 8, ... on the branch in <1ms.

Both confirm the fix generalizes correctly beyond the registries the new tests exercise.

No behaviour change on input that parses today.

  • IPv4(bytes.fromhex('4a00001800010000400600000a0000010a000002'), 20) and TCP(bytes.fromhex('00501f900000000100000002a002ffff00000000020405b4'), 24) — the two truncated-but-tolerated cases from the PR body — parse to the identical options on main and on 7a73ee35f.
  • Regenerated the full (gitignored) sample set via python examples/generators/make_samples.py (13 captures) plus the 2 committed ones (in.pcap, dhcp.pcapng) = 15 captures, dumped each to both tree and json with ip=True, tcp=True, reassembly=True. All 30 SHA-256 digests are byte-identical between a pristine main clone (44aa38ae8) and this branch.

Full suite, both trees, fixtures generated on both:

  • main (44aa38ae8, fresh clone, make_samples.py run): 877 passed, 17 skipped, 852 subtests, 0 failures.
  • 7a73ee35f: 893 passed, 17 skipped, 866 subtests, 0 failures. The +16 tests / +14 subtests is exactly the new tests this PR adds (I counted the new test methods in the diff); nothing regressed either side. This differs from the PR body's own "857 baseline" only because main has moved since the PR was authored — this is a same-day, apples-to-apples recount.

Environment: /local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/python 3.14.7, PYTHONSAFEPATH=1, PYTHONPATH pointed explicitly at each tree, pcapkit.__file__ printed before every run to confirm which tree executed.

Suspected, but not attributable to this diff

Fuzzing around the SMF_DPD sizing fix, I found that a maximally truncated 3-octet HOPOPT buffer, bytes.fromhex('3b0008') (an 8-octet header declared, a 6-octet option area declared, but only the 0x08 SMF_DPD type octet actually present), raises EOFError on main but an unrelated, uncaught struct.error: bad char in struct format on this branch.

That divergence is real but traces to a defect that predates this PR and that this PR doesn't touch: SMFIdentificationBasedDPDOption.id is

id: 'bytes' = BytesField(length=lambda pkt: pkt['len'] - (
    1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2)
))

which underflows to -1 whenever Opt Data Len == 0 and the null TaggerID type is selected — reachable with no truncation at all: bytes.fromhex('3b00') + b'\x08\x00\x00\x00\x00\x00' (a complete, correctly-sized 8-octet HOPOPT header) raises the identical struct.error on both main and 7a73ee35f. The 3-octet case only differs in which uncaught exception you get, because main's old (also wrong) bit offsets happen to size that specific truncated input to exactly 0 (tripping an unrelated EOFError short-circuit before ever reaching the broken subtraction), while this branch's corrected +2 sizing gives it 2 octets instead and reaches the pre-existing underflow. Not a hang, not a regression on anything that parses today, and not something this diff's guard is meant to cover — it looks like it belongs next to the PR's own "left for their own issues" list rather than blocking this change. Flagging it here rather than filing it separately since I found it while testing this exact code path; happy to open an issue if useful.

Verdict

Good to merge at 7a73ee35f. The fix does what it claims: the non-progress hang is gone on every registry I could construct a reproducer for (including two the PR's own tests don't cover), the after-eool-break ordering is correct for both eool-bearing and eool=None registries, and there is no detectable behaviour change on anything that parses today, per a byte-identical 30-digest capture sweep and a clean, zero-failure full-suite run on both trees.

@JarryShaw
JarryShaw merged commit 84233da into main Sep 17, 2026
24 checks passed
JarryShaw added a commit that referenced this pull request Sep 17, 2026
…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.
JarryShaw added a commit that referenced this pull request Sep 17, 2026
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.
JarryShaw added a commit that referenced this pull request Sep 18, 2026
…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.
@JarryShaw
JarryShaw deleted the fix/optionfield-progress-guard branch September 18, 2026 20:22
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

OptionField.unpack never returns for a well-formed HOPOPT header with an SMF_DPD option

2 participants