Skip to content

fix(fields): raise ProtocolError, not struct.error, on a negative resolved field length - #811

Merged
JarryShaw merged 1 commit into
mainfrom
fix/805-negative-length-guard
Sep 25, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/805-negative-length-guard

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

What is the purpose of your pull request?

  • fix — corrects a defect

Description

FieldBase.length called struct.calcsize on a template built from a
negative resolved field length (e.g. '-5s', from a length=lambda pkt: pkt['__length__'] callback going negative once the running counter is
overdrawn), which raised a bare struct.error — uncatchable by ordinary
caller code. It now catches that and raises ProtocolError.

This is the one choke point every affected schema module shares
(application/{ftp,httpv1,httpv2,ngap}.py, internet/{hip,ipv6_route,mh}.py,
link/ethernet.py, misc/pcapng.py, transport/sctp.py), so no other file
needs a change, and every non-negative length still returns the same value —
well-formed input is unaffected. schema.py's separate running-counter
warning is deliberately untouched: converting it would reject a SETTINGS
frame with a short trailing entry that parses fine today.

Closes #805

…olved field length (#805)

- FieldBase.length (pcapkit/corekit/fields/field.py) now catches struct.error
  from struct.calcsize and re-raises ProtocolError. A length=lambda pkt:
  pkt['__length__']-style callback can resolve negative once Schema.unpack's
  running counter has been overdrawn by a preceding field, building a
  template such as '-5s' that struct.calcsize cannot size -- pre-fix this
  escaped as a bare struct.error, uncatchable by ordinary caller code.
  This is the one choke point every __length__-keyed field in the ten
  affected schema modules shares (application/ftp.py, httpv1.py, httpv2.py,
  ngap.py; internet/hip.py, ipv6_route.py, mh.py; link/ethernet.py;
  misc/pcapng.py; transport/sctp.py), so no other module needs a change, and
  success is untouched: the property still returns the same value for every
  non-negative length.
- Left schema.py's running-counter warning alone: measured that converting
  it would reject a SETTINGS frame with a short trailing entry, which parses
  successfully today while only warning.
- Updated test_http_unit.py's existing pinning test, which documented the
  direct-construction leak as expected pre-#805 behaviour, to assert
  ProtocolError instead; the guess-path's struct.error suppression stays as
  defence in depth.

New: tests/corekit/test_fields_field.py::FieldBaseLengthNegativeResolvedLengthTests
and tests/protocols/application/test_httpv2_negative_length_unit.py, each shown
failing with a bare struct.error on stock code and passing after this change.

Build/test: coverage run -m unittest over the touched files and their existing
suites, 117 tests green; field.py's new lines fully covered, http.py and
httpv2.py 100%.
@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) bug Issues reporting a defect (set by the bug report template; a default, not an assessment) breaking Breaks public-facing behaviour or API (apply alongside the type label) review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Verified by me on a git archive export, with a self-tested probe — and the fix does what it claims:

                                          BASE            PR #811
inner-field shortfall, 28 cases      28 struct.error   28 ProtocolError
  (GOAWAY buflen 9-16, PUSH_PROMISE 9-12, over-padded DATA 9-24)
16-octet GOAWAY, direct HTTPv2         struct.error      ProtocolError
16-octet GOAWAY, via HTTP()            ProtocolError     ProtocolError
well-formed: SETTINGS empty, SETTINGS+1, PING    PARSED        PARSED

Placing the guard in FieldBase.length rather than Schema.unpack is the right call and better than what the issue proposed: it is the single point every length=lambda pkt: pkt['__length__'] field passes through, so one try/except struct.error → ProtocolError covers all ten blast-radius modules instead of ten patches. And it correctly left the running-counter warning at schema.py:898-900 alone, which is what would have rejected working input.

Two process problems to fix before this is ready, neither about the code:

  1. The PR checklist's pylint / mypy / isort boxes are ticked but were not run — the author says so plainly, "ticked on the assumption they'd pass". A ticked box that nobody verified is a false claim in the PR, and worse than an unticked one. Either run them or untick them.
  2. git stash was used to toggle the fix for the before/after measurements. That is explicitly forbidden in this repo's working rules, because a bare stash/stash pop has silently eaten work here before. It happened to leave no residue — the shared repo shows 0 stash entries — but the method needs to be git archive exports or git restore --source=<sha>, not stash. Noting it so it is not repeated.

One claim I could not reproduce and am not holding against the PR: the issue's exhaustive 1136 escape count. My own sweep covers 28 representative cases; the author reports the same and says so honestly rather than asserting the figure.

breaking applied. ProtocolError is a ValueError; struct.error is not — so a caller catching struct.error around a parse will now miss it. That is a public-contract change even though the affected path previously crashed.

review: pending, cross-review dispatched to the reviewer that identified this root cause on #802 in the first place.

Still UNVERIFIED: a regression sweep of the nine other blast-radius modules' own suites was still running when the budget expired. The structural argument is sound — a try/except around an unchanged success path cannot alter a path it did not touch — and my three well-formed controls parse identically on both trees, but the sweep itself did not finish.

@JarryShaw JarryShaw added the test Pull requests that add or correct tests (test: subject prefix) label Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict: GOOD TO GO (haiku — the same reviewer that identified this root cause on #802; author was sonnet). One non-blocking finding, verified by me, plus a correction to the reviewer.

It reproduced the 1136 figure the author could not, and it goes to zero:

tree                  non-ProtocolError escapes   via HTTPv2 direct   via HTTP()
base 4530424df                   1136                   1136              0
head bb001679c                      0                      0              0

characterise.py, buflen 9…24 — every S became ., and every P cell is unchanged, so nothing that legitimately parsed stopped parsing. Its 18,144-case sub-9 grid stays uniform ProtocolError on both trees, so #802's guarantee is not regressed. Classifier self-tested on both runs.

The finding, confirmed by me independently — try/except struct.error attributes every cause to one it never checks:

PROVENANCE: /tmp/w811/pcapkit/__init__.py
KNOWN-POS negative count   '-5s'       -> ProtocolError: ... resolved to a negative length; template='-5s'
KNOWN-NEG valid            '>Q'        -> 8            (no raise)
KNOWN-NEG zero-len         '0s'        -> 0            (no raise)
RISK malformed char        '>z'        -> ProtocolError: ... resolved to a negative length; template='>z'
RISK count, no format      '!5'        -> ProtocolError: ... resolved to a negative length; template='!5'
RISK plausible typo        '>Q5x3q1z'  -> ProtocolError: ... resolved to a negative length; template='>Q5x3q1z'

'-5s' and '>z' raise the same struct.error text, so the message's claim is an assumption rather than a derivation. Non-blocking because no route to a malformed template exists — every _template is f'{n}s', a closed five-way lookup at numbers.py:188-196, or a literal; the message prints the template verbatim so the misdiagnosis self-corrects on inspection; and no in-tree catcher breaks (issubclass(ProtocolError, struct.error) is False, and the two bare except struct.error at protocol.py:1453/:1550 wrap local struct.unpack/pack and never reach FieldBase.length). Worth a follow-up: gate the diagnosis on self._length < 0 and re-raise the original otherwise, so a future field class with a typo'd __template__ stays a programming error rather than being reported as malformed input.

Correcting the reviewer, not the PR. It reported that it could not locate max(packet['__length__'], 0) at pcapng.py:485 ("that line is inside a docstring table") nor a bounded() in transport/sctp.py, and asked that those be treated as unconfirmed. Both are real:

pcapng.py:485  return SchemaField(length=max(packet['__length__'], 0), schema=schema)   <- code; the docstring ends at :482
transport/sctp.py: 5 hits for bounded(    (pattern self-tested: 46 hits for 'def ')

So the #805 worker's original blast-radius claims stand, and the reviewer's correction of them does not. Its conclusion — that the fix is inert on those nine modules — is unaffected, since it rests on the no-new-success-path argument plus a catcher audit plus two green suites, all of which I accept.

Its sharper check on the nine modules is worth keeping: rather than re-running nine suites, it looked for anything that depended on catching struct.error from field.length, and found none — the textual hits in test_pcapng_unit/test_ipv6_extension_unit/test_mh_unit/test_hip_unit are historical prose about already-fixed bugs, and the only live assertRaises(StructError) sites (test_protocol_base_unit.py:205,210,220) are on unrelated paths. test_pcapng_unit 93 tests OK, test_sctp_unit+test_protocol_base_unit+both PR files 94 tests OK, cross-checked with python -m unittest.

Still outstanding, and it is the author's to fix: the checklist's make pylint / mypy / isort and make test passes boxes are ticked while the author states they were not run. Coverage is UNVERIFIED (budget went to the four priorities). Flipping to review: good-to-go on the code; please untick or run those boxes before merging.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw
JarryShaw merged commit f3a0665 into main Sep 25, 2026
31 checks passed
@JarryShaw
JarryShaw deleted the fix/805-negative-length-guard branch September 25, 2026 22:25
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Sep 25, 2026
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 4530424 to 3cbdf89 while this PR sat open. Four are the
new PRs merged in that window (#811-#814); the other four are older defects
(#704, #723, #739, #743/#746) whose fixes had merged earlier but were never
cited. Eight new bullets cover them, appended in merge order:

- #704 -- `SystemdJournalExportBlock.post_process` skipped a binary field's
  trailing newline by reading to EOF, discarding every field behind it.
- #723 -- the same block split entries on a bare `b'\n\n'`, shredding binary
  data that contains that byte pair; fixed alongside an independent
  trailing-separator/EOF ambiguity.
- #739 -- five more registrars (`register_engine`/`_reassembly`/`_traceflow`,
  `register_dumper` x2) sat outside #718/#726's identity guard.
- #743, #746 -- `pypcapfile`'s `IP.src`/`.dst` are dotted-decimal text, not
  packed bytes, and its frames need un-hexlifying before decoding; the two
  fixes are cross-dependent and landed together.
- #805 -- `FieldBase.length`'s `struct.calcsize` on a negative resolved
  length raised a bare `struct.error`; now `ProtocolError`. Closes the
  follow-on #802's own entry filed as out of scope.
- #796 -- thirteen `re.sub` sites under `pcapkit/vendor/` passed
  `re.MULTILINE` positionally as `count`, not as `flags=`.
- #800 -- `httpv2._guess_version` now identifies a connection preface before
  parsing it, rather than by trial and error.

Derived the gap by diffing `git log 73f09ae..origin/main` against
`gh pr view --json state,mergedAt` for every candidate number, not from
commit-subject text alone. `CHANGELOG.md` regenerated with
`util/changelog_md.py`; `--check` exit 0 and `test_changelog_md.py`'s 47
tests pass. Sphinx's full-site build did not finish inside budget --
`pcapkit.const.reg`'s autodoc page is slow regardless of this change --
so verified instead with `docutils --report=1`, which parses the updated
file with zero messages.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…eneric wrap

- test_guess_version_reports_a_preface_with_no_frame_as_such's GOAWAY case
  asserted str(exception) == 'HTTP/2: invalid format', which #814 produced
  when the sixteen-octet GOAWAY's oversized declared length drove the
  ``debug`` field negative and struct.calcsize raised a bare struct.error.
- #811 (fix(fields): raise ProtocolError, not struct.error, on a negative
  resolved field length) landed after #814's branch point and fixed that
  exact case at its root: FieldBase.length now raises ProtocolError itself,
  so http.py's ``except ProtocolError: raise`` passes it through unchanged
  instead of reaching the ``except (ValueError, struct.error)`` wrap that
  produced #814's message. The assertion was stale, not the behaviour.
- Update the assertion to the field-level message ("Field debug resolved to
  a negative length; template='-1s'") while keeping every other check #814
  cared about: still a catchable BaseError, still not a bare struct.error,
  still chained to the original struct.error via __cause__.

Verified the failure on stock 3cbdf89 (CPython 3.14) and the fix passing
on both 3.14 and 3.10; tests/protocols/application/ and tests/corekit/
otherwise pass (338 passed, 16 skipped -- 5 unrelated pre-existing failures
in test_http_runtime.py were just missing generated sample captures, fixed
by running examples/generators/make_samples.py). No production code
changed, so coverage of pcapkit/protocols/application/http.py (98%) and
pcapkit/corekit/fields/field.py (75%) is unchanged.

Closes #822
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…k comment (#824)

The fallback-arm comment in `_guess_version` (roughly :358-395) documented
measurements from before #799 and #811 landed, so three sentences described
behaviour the code no longer has:

- `HTTP(io.BytesIO(b'\x00' * 8), 8)` was cited as raising a bare
  `struct.error`; #799's nine-octet guard now answers `ProtocolError:
  unknown HTTP version` instead, re-measured on this tree.
- The 16-octet `GOAWAY` example was cited as still raising a bare
  `struct.error` through `httpv2.HTTP` directly, with the fix left as future
  work "tracked as #805". #811 closed that at `FieldBase.length`; the same
  input now raises `ProtocolError: Field debug resolved to a negative
  length; template='-1s'`.
- Both sentences are rewritten in the past tense with the current, re-measured
  outcome, keeping the surrounding causal explanation (why the last arm still
  suppresses `struct.error`, as defence in depth) intact.

Comment-only change, no behaviour touched. Verified `tests/protocols/application/`
still passes (122 passed, 1 pre-existing failure: #822's
`test_guess_version_reports_a_preface_with_no_frame_as_such`, confirmed failing
identically on stock `origin/main`, fixed by open PR #823 rather than here).

Closes #824
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…ent (#824)

The `_guess_version` comments (roughly :310-405) documented measurements
from before #799 and #811 landed, so several sentences described behaviour
the code no longer has:

- `HTTP(io.BytesIO(b'\x00' * 8), 8)` was cited as raising a bare
  `struct.error`; #799's nine-octet guard now answers `ProtocolError:
  unknown HTTP version` instead, re-measured on this tree.
- A 16-octet `GOAWAY` was cited as still raising a bare `struct.error`
  through `httpv2.HTTP` directly, present tense, in two places (the fallback
  arm and the preface arm's #805-residual note) — one with the fix left as
  future work "tracked as #805". #811 closed that at `FieldBase.length`; the
  same input now raises `ProtocolError: Field debug resolved to a negative
  length; template='-1s'`.
- Restored the dropped note that #811 deliberately left `Schema.unpack`'s
  own running-counter warning alone, since converting it would reject a
  `SETTINGS` frame with a short trailing entry that parses successfully
  today while only warning — that `SchemaWarning` still fires.
- Reworded the last arm's "stays regardless" permanence claim to attribute
  the decision to #811 and point at #825 as the open question, rather than
  asserting the suppression can never be removed.

All rewritten past tense with the re-measured, current outcome, keeping the
causal explanation for each suppression/conversion intact. Comment-only, no
behaviour touched — confined to `pcapkit/protocols/application/http.py`
(21 code objects, 0 structurally differing in bytecode, non-code constants,
names, or def line numbers).

Verified `tests/protocols/application/` passes (123 passed, 0 failed, 432
subtests). isort/mypy/pylint clean on this file (pylint's 5 pre-existing
findings are all outside the changed lines).

Closes #824
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…in FieldBase.length (#825)

struct.calcsize raises the identical bare struct.error for a malformed
template as for a negative resolved count (measured on 3.14.7:
calcsize('-1s') and calcsize('Xs') both raise "bad char in struct
format"). #811's guard caught that blanket and always reported
"resolved to a negative length", misdiagnosing a typo'd template.

- pcapkit/corekit/fields/field.py: add _RE_NEGATIVE_LENGTH_TEMPLATE,
  matching a negative resolved count's leading '-', optionally preceded
  by one of NumberField's byte-order prefixes ('@=<>!'). Every template
  built from a resolved length is f'{length}s' (strings.py, misc.py,
  collections.py, and numbers.py's build_template `else` arm before it
  gets byte-order-prefixed as f'{endian}{struct_fmt}') -- so the minus
  sign is always there, but a prefix in front of it defeats a plain
  '^-\d+' anchor (caught by #827's cross-review: NumberField(length=-1)
  produced '>-1s', misdiagnosed as malformed). FieldBase.length checks
  the pattern once struct.calcsize has failed, and raises a distinct,
  template-naming ProtocolError for anything else calcsize cannot size.
  Both branches still chain from the real struct.error via `from
  error`, so no bare struct.error escapes either way, and the
  negative-length message and __cause__ chain are byte-for-byte
  unchanged for the existing (unprefixed) case -- so the pinned message
  assertion at tests/protocols/application/test_http_unit.py:662 needs
  no change.
- tests/corekit/test_fields_field.py: three new tests on
  FieldBaseLengthNegativeResolvedLengthTests -- a malformed template
  raises ProtocolError naming the template and not claiming a negative
  length; neither category ever leaks a bare struct.error; and a real
  NumberField(length=-1), whose template comes out byte-order-prefixed
  ('>-1s'), is still reported as negative, not malformed. All three
  fail on stock/pre-fix code with the wrong diagnosis and pass after.

#825 also asks whether the negative length should be prevented, not
just correctly reported, by bounding pkt['__length__'] at the schema
layer. Measured directly (both a global clamp in schema.py's decrement
and a narrow max(pkt['__length__'], 0) on GoawayFrame.debug /
ContinuationFrame.fragment, matching the existing sctp.py/pcapng.py
convention): the GOAWAY repro still raises ProtocolError either way,
just a different one ("HTTP/2: [Type 7] invalid format" from httpv2.py's
own schema.length > length guard, __cause__ None) that breaks the
pinned assertion in three ways, not just its message -- so the one
relaxation this change is allowed would not save it. Left for its own
change with the schema.py owner's input; not attempted here.

Build: mypy and isort (-l100 -ppcapkit) clean on the changed source
file. tests/corekit/test_fields_field.py's affected test class (5/5)
and the application-side pinned scenario verified directly; full
tests/corekit and tests/protocols/application suites re-run.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…ent (#824)

The `_guess_version` comments (roughly :310-405) documented measurements
from before #799 and #811 landed, so several sentences described behaviour
the code no longer has:

- `HTTP(io.BytesIO(b'\x00' * 8), 8)` was cited as raising a bare
  `struct.error`; #799's nine-octet guard now answers `ProtocolError:
  unknown HTTP version` instead, re-measured on this tree.
- A 16-octet `GOAWAY` was cited as still raising a bare `struct.error`
  through `httpv2.HTTP` directly, present tense, in two places (the fallback
  arm and the preface arm's #805-residual note) — one with the fix left as
  future work "tracked as #805". #811 closed that at `FieldBase.length`; the
  same input now raises `ProtocolError: Field debug resolved to a negative
  length; template='-1s'`.
- Restored the dropped note that #811 deliberately left `Schema.unpack`'s
  own running-counter warning alone, since converting it would reject a
  `SETTINGS` frame with a short trailing entry that parses successfully
  today while only warning — that `SchemaWarning` still fires.
- Reworded the last arm's "stays regardless" permanence claim to attribute
  the decision to #811 and point at #825 as the open question, rather than
  asserting the suppression can never be removed.

All rewritten past tense with the re-measured, current outcome, keeping the
causal explanation for each suppression/conversion intact. Comment-only, no
behaviour touched — confined to `pcapkit/protocols/application/http.py`
(21 code objects, 0 structurally differing in bytecode, non-code constants,
names, or def line numbers).

Verified `tests/protocols/application/` passes (123 passed, 0 failed, 432
subtests). isort/mypy/pylint clean on this file (pylint's 5 pre-existing
findings are all outside the changed lines).

Closes #824
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…in FieldBase.length (#825)

struct.calcsize raises the identical bare struct.error for a malformed
template as for a negative resolved count (measured on 3.14.7:
calcsize('-1s') and calcsize('Xs') both raise "bad char in struct
format"). #811's guard caught that blanket and always reported
"resolved to a negative length", misdiagnosing a typo'd template.

- pcapkit/corekit/fields/field.py: add _RE_NEGATIVE_LENGTH_TEMPLATE,
  matching a negative resolved count's leading '-', optionally preceded
  by one of NumberField's byte-order prefixes ('@=<>!'). Every template
  built from a resolved length is f'{length}s' (strings.py, misc.py,
  collections.py, and numbers.py's build_template `else` arm before it
  gets byte-order-prefixed as f'{endian}{struct_fmt}') -- so the minus
  sign is always there, but a prefix in front of it defeats a plain
  '^-\d+' anchor (caught by #827's cross-review: NumberField(length=-1)
  produced '>-1s', misdiagnosed as malformed). FieldBase.length checks
  the pattern once struct.calcsize has failed, and raises a distinct,
  template-naming ProtocolError for anything else calcsize cannot size.
  Both branches still chain from the real struct.error via `from
  error`, so no bare struct.error escapes either way, and the
  negative-length message and __cause__ chain are byte-for-byte
  unchanged for the existing (unprefixed) case -- so the pinned message
  assertion at tests/protocols/application/test_http_unit.py:662 needs
  no change.
- tests/corekit/test_fields_field.py: three new tests on
  FieldBaseLengthNegativeResolvedLengthTests -- a malformed template
  raises ProtocolError naming the template and not claiming a negative
  length; neither category ever leaks a bare struct.error; and a real
  NumberField(length=-1), whose template comes out byte-order-prefixed
  ('>-1s'), is still reported as negative, not malformed. All three
  fail on stock/pre-fix code with the wrong diagnosis and pass after.

#825 also asks whether the negative length should be prevented, not
just correctly reported, by bounding pkt['__length__'] at the schema
layer. Measured directly (both a global clamp in schema.py's decrement
and a narrow max(pkt['__length__'], 0) on GoawayFrame.debug /
ContinuationFrame.fragment, matching the existing sctp.py/pcapng.py
convention): the GOAWAY repro still raises ProtocolError either way,
just a different one ("HTTP/2: [Type 7] invalid format" from httpv2.py's
own schema.length > length guard, __cause__ None) that breaks the
pinned assertion in three ways, not just its message -- so the one
relaxation this change is allowed would not save it. Left for its own
change with the schema.py owner's input; not attempted here.

Build: mypy and isort (-l100 -ppcapkit) clean on the changed source
file. tests/corekit/test_fields_field.py's affected test class (5/5)
and the application-side pinned scenario verified directly; full
tests/corekit and tests/protocols/application suites re-run.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…s in #817 and #821

Two bullets, both non-breaking, appended after the #800 entry in merge
order. Bullet count 128 to 130 (`grep -cE '^\* \*\*'`).

- #804 (PR #817) -- the three `__repr__` methods #798 left `%`-formatted
  are f-strings now, dropping `consider-using-f-string` from both const
  modules and both vendor templates; the other bespoke templates in
  `{const,vendor}/{ftp,http}/` still carry the disable, so #804's claim
  holds for this pair only, not for those directories.

- #682 (PR #821) -- `TCP.__proto__` no longer binds `httpv1.HTTP` directly
  for ports 80/8080; both repoint to the generic HTTP proxy `_guess_version`
  identifies through, which only became reliable once #800/#814 landed.
  `udp.py` already pointed there, so that side of the PR is prose-only
  (its port rows and docstring), not a code change, and the entry says so.
  Protochain over the 23 sample captures is *not* byte-identical: 9 frames
  in `options-transport.pcap` go `Raw` to `HTTP/2`, all 231 HTTP/1.1 frames
  are unaffected, and `_guess_version`'s entry count goes 0 to 252.

  Not marked `**a breaking change to**`: PR #821's own labels are
  `bug,fix,docs,test`, no `breaking`, unlike #759/#783 and #805/#811 last
  round, whose crediting PRs did carry it. The entry does say what a
  `breaking`-blind reader would still want to know -- TCP:80/8080 traffic
  that is neither valid HTTP/1 nor preface-carrying now reaches
  `_guess_version`'s fall-through arm instead of the direct `httpv1` bind's
  unconditional `Raw`, which is where the 12 (of 252) fall-throughs the PR
  measured come from.

`util/changelog_md.py` regenerated `CHANGELOG.md`, first pass, no
line-spanning literal this round; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 28, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 28, 2026
…s in #817 and #821

Two bullets, both non-breaking, appended after the #800 entry in merge
order. Bullet count 128 to 130 (`grep -cE '^\* \*\*'`).

- #804 (PR #817) -- the three `__repr__` methods #798 left `%`-formatted
  are f-strings now, dropping `consider-using-f-string` from both const
  modules and both vendor templates; the other bespoke templates in
  `{const,vendor}/{ftp,http}/` still carry the disable, so #804's claim
  holds for this pair only, not for those directories.

- #682 (PR #821) -- `TCP.__proto__` no longer binds `httpv1.HTTP` directly
  for ports 80/8080; both repoint to the generic HTTP proxy `_guess_version`
  identifies through, which only became reliable once #800/#814 landed.
  `udp.py` already pointed there, so that side of the PR is prose-only
  (its port rows and docstring), not a code change, and the entry says so.
  Protochain over the 23 sample captures is *not* byte-identical: 9 frames
  in `options-transport.pcap` go `Raw` to `HTTP/2`, all 231 HTTP/1.1 frames
  are unaffected, and `_guess_version`'s entry count goes 0 to 252.

  Not marked `**a breaking change to**`: PR #821's own labels are
  `bug,fix,docs,test`, no `breaking`, unlike #759/#783 and #805/#811 last
  round, whose crediting PRs did carry it. The entry does say what a
  `breaking`-blind reader would still want to know -- TCP:80/8080 traffic
  that is neither valid HTTP/1 nor preface-carrying now reaches
  `_guess_version`'s fall-through arm instead of the direct `httpv1` bind's
  unconditional `Raw`, which is where the 12 (of 252) fall-throughs the PR
  measured come from.

`util/changelog_md.py` regenerated `CHANGELOG.md`, first pass, no
line-spanning literal this round; `--check` exit 0.
`test_changelog_md.py` 47 passed.
@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

breaking Breaks public-facing behaviour or API (apply alongside the type label) bug Issues reporting a defect (set by the bug report template; a default, not an assessment) fix Pull requests that fix a defect (fix: subject prefix) test Pull requests that add or correct tests (test: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

corekit: a negative field length only warns in Schema.unpack, instead of raising ProtocolError

1 participant