Skip to content

fix(http): check the buffer's length, not just the declared one, in HTTP/2's frame guard - #802

Merged
JarryShaw merged 1 commit into
mainfrom
fix/799-httpv2-buffer-length-guard
Sep 25, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/799-httpv2-buffer-length-guard

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Please follow the guide below

What is the purpose of your pull request?

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Closes #799.

httpv2.py's guard tested only the declared 24-bit length off the wire, never
the buffer. HTTP.unpack now rejects a non-empty buffer under nine octets
before the schema layer runs (an implicit-length, genuinely exhausted stream
still raises StreamEOFError, unchanged), and read's guard now also
requires schema.length <= length — a nine-octet buffer declaring 16777215
no longer parses and reports that length as fact.

A buffer that clears nine octets can still carry a frame whose own
fixed-width fields exceed what's left (GOAWAY at 9-16 octets, PUSH_PROMISE
at 9-12, padded DATA/HEADERS/PUSH_PROMISE), crashing the schema layer
with a bare struct.error. An earlier revision of this PR narrowed
_guess_version's struct.error suppression on the strength of a sweep that
never hit this class; a cross-review measured it (1136 escapes) and it's
reverted — the root cause is generic Schema.unpack/FieldBase.length
machinery shared by ten schema modules, filed as #805 rather than widened
here.

Two pre-#799 tests relied on this exact leniency to label garbage/the HTTP/2
preface as version='2' — updated to reflect that #799 correctly refuses
both now.

@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) bug 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

Cross-review verdict: NEEDS CHANGES (haiku; author was sonnet). The unpack guard is sound and well-tested — it is the _guess_version narrowing that regresses. I verified both blocking findings myself rather than relay them.

1. The narrowed suppress(ProtocolError) lets a bare struct.error escape HTTP(). One 16-octet GOAWAY, b'\x00\x00\x15\x07\x00\x00\x00\x00\x00' + b'\xff'*7:

BASE  HTTP(data, 16)            -> ProtocolError: unknown HTTP version   [ProtocolError -> BaseError -> ValueError]
HEAD  HTTP(data, 16)            -> error: bad char in struct format      [error -> Exception -> BaseException]
      HTTP(data, 16, version=2) -> ProtocolError: HTTP/2: invalid format    (both trees)

The MRO is the damning part: at head the escape is not a ValueError and cannot be caught as a protocol error — the exact contract #789 was raised to repair, and which http.py:120-127 argues for in its own comment. The guard only protects the outer 9-octet header; frames whose inner payload has a fixed width wider than the remainder (GOAWAY at buflen 9–16, PUSH_PROMISE 9–12, any over-padded DATA/HEADERS) still drive pkt['__length__'] negative into struct.calcsize('-Ns'). These are ordinary snaplen-truncation shapes. The review's grid puts it at 1136 new HTTP()-route escapes.

Why the original sweep measured zero: its nine byte patterns land on frame types 0x00/0xff/0x03, none of which has a short fixed-width payload field. Reproducing that shape gives 0 escapes; swapping in three patterns with a real type octet at offset 3 gives 88. Fifth instance this session of a probe that could not detect what it looked for.

2. #799's headline harm survives, unchanged. Verified on both trees:

buflen=9 declared=16777215 -> PARSED, reports length=16777215     (identical base and head)

Also true at buflens 10, 12, 16, 24 — overstated by 16,777,206 octets. The guard compares each quantity against the constant 9, never against the other. So Closes #799 overstates what lands.

Required: fix the root cause — a negative field length should raise ProtocolError in Schema.unpack/FieldBase.length, which closes both classes and makes the narrowing true — or drop the narrowing for now. Plus: correct the NOTE and the Raises: docstring (both now false), add a real-bytes regression test (both new arm tests use mock.patch, so they cannot see this), and state the residual rather than closing #799 on it.

Mitigation worth noting: Protocol.analyze catches bare Exception → Raw, so end-to-end extraction is unaffected on both trees. The blast radius is the direct HTTP() API.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
…TTP/2's frame guard

- httpv2.HTTP.read() tested only the 24-bit declared length off the wire
  (schema.length < 9), never how many octets the buffer actually held, so a
  frame backed by far fewer real octets than it declared still parsed and
  reported the declared, attacker-controlled length. Sweeping the declared
  length against a 4-octet buffer showed non-monotone accept/reject (9, 15,
  65535, 16777215 parsed; 10, 100 did not) -- an artifact of
  _read_http_settings's own unrelated (declared - 9) % 6 check, not evidence
  the buffer held what was declared.
- HTTP.unpack now rejects a non-empty buffer under nine octets before the
  schema layer runs (preserving StreamEOFError for a genuinely exhausted,
  implicit-length stream), and read's guard now also requires the declared
  length not to exceed the available buffer.
- A buffer that clears nine octets can still carry a frame type whose own
  fixed-width fields exceed what is left after the header (GOAWAY at 9-16
  octets, PUSH_PROMISE at 9-12, padded DATA/HEADERS/PUSH_PROMISE) and still
  crash the schema layer with a bare struct.error; that root cause is in
  generic Schema.unpack/FieldBase.length machinery shared by ten schema
  modules, out of scope here and filed as #805. _guess_version's last arm
  therefore keeps suppressing struct.error alongside ProtocolError.

Build/test: 75 tests green in tests/protocols/application/test_http_unit.py
and test_httpv2_payload_length_unit.py, 100% line+branch on both changed
files standalone; tests/corekit/ (218) and test_construction_keyword_check_unit.py
(22) unaffected; protochain over 23 sample captures / 1604 frames byte-
identical to main; mypy and pylint report no new findings.
@JarryShaw
JarryShaw force-pushed the fix/799-httpv2-buffer-length-guard branch from 958e3ed to 3d85e56 Compare September 25, 2026 20:42
@JarryShaw

Copy link
Copy Markdown
Owner Author

Revised to 3d85e56f6. Both blocking findings verified closed by me, on a git archive export of the new head:

PROVENANCE: /tmp/w802n/pcapkit/__init__.py
-- 1: the 16-octet GOAWAY --
  HTTP(data,16)            -> ProtocolError: unknown HTTP version    [ProtocolError -> BaseError -> ValueError]
  HTTP(data,16,version=2)  -> ProtocolError: HTTP/2: invalid format
-- 2: declared >> buffer --
  buflen=9,10,12,16,24  declared=16777215 -> ProtocolError x5   (all five PARSED before, reporting 16777215)
-- boundary --
  declared=9  buflen=9 -> PARSED        declared=10 buflen=9 -> ProtocolError
-- EOF signal --
  HTTPv2(b"") -> StreamEOFError         [StreamEOFError -> BaseError -> EOFError]

Blocker 1 was resolved by dropping the narrowing rather than by widening the guard, which is the right call: arm 2 goes back to suppress(ProtocolError, struct.error), and the root cause is filed as #805. The author checked the blast radius first and found ten schema modules keying a field length off pkt['__length__'] the same way — ftp, httpv1, httpv2, ngap, hip, ipv6_route, mh, ethernet, pcapng, sctp — which is too broad for this PR and is exactly why #805 exists. schema.py is also held by #788 right now, so touching it here would have collided.

A consequence worth noting: the Raises: docstring needed no separate fix — reverting the narrowing made it true again on its own.

Blocker 2 closed by adding schema.length > length to read()'s guard, which is the check #799 actually asks for rather than the length-floor it had.

Tightening it broke two pre-existing #787 tests, and the reason is the interesting part: both had been passing only because of the bug #799 fixes. test_http_guess_version_falls_through_to_the_http2_arm read b'not http at all''s first three bytes as declared length 7,237,492 against 15 real octets, and test_guess_version_reaches_http2_on_the_connection_preface read the preface's b'PRI' as 5,265,993. Both were asserting reachability via a frame header that nothing backed. Now fixed to self-consistent fixtures, with explicit assertions that the old inputs correctly raise.

The defeated structural test was deleted rather than patched, since its premise (both arms suppress only ProtocolError) no longer holds — right call over keeping a weak check. A real-bytes regression test replaces the mock.patch-only pair, and both changed files now reach 100% line+branch with test_http_unit.py alone, so the earlier directory-wide caveat is gone.

Flipping review: needs-changes → review: pending: the old verdict was pinned to the dead 958e3edae. Delta re-check dispatched to the same reviewer, which still holds its 18,144-case grid.

@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict at 3d85e56f6: GOOD TO GO (haiku delta re-check; author sonnet). Delivered inside the 25-minute budget with all five priorities done. I re-derived the decisive claim with my own self-testing grid, across the three patterns the original sweep could not reach:

                                  OLD 958e3edae      NEW 3d85e56f6
GOAWAY 0x07            HTTPv2     SSSSSSSSPPPPPPPP   SSSSSSSSPPPPPPPP
GOAWAY 0x07            HTTP()     SSSSSSSSPPPPPPPP   ........PPPPPPPP
PUSH_PROMISE 0x05      HTTPv2     SSSSPPPPPPPPPPPP   SSSSPPPPPPPPPPPP
PUSH_PROMISE 0x05      HTTP()     SSSSPPPPPPPPPPPP   ....PPPPPPPPPPPP
PADDED DATA 0x00/0x08  HTTPv2     SSSSSSSSSSSSSSSS   SSSSSSSSSSSSSSSS
PADDED DATA 0x00/0x08  HTTP()     SSSSSSSSSSSSSSSS   ................
struct.error escapes:             HTTPv2=28 HTTP()=28   HTTPv2=28 HTTP()=0

S = bare struct.error, . = ProtocolError, P = parsed, columns buflen 9…24. The HTTPv2-direct row staying non-zero is the self-test — a blind probe would read 0 on both rows, so the 0 on the HTTP() route is load-bearing rather than an artefact. The reviewer's full grid agrees at scale: base 1136 escapes / 0 via HTTP(), old head 2272 / 1136, new head 1136 / 0 — exactly base.

It also re-ran the 18,144-case sub-9 grid despite the unpack restructure into 0 < len(self) < 9 / elif length < 9: 18,144/18,144 uniform ProtocolError, and the same script still detects 7,272 struct.error + 296 parses on base.

Both rewritten #787 tests fail against true base, so they assert something real rather than merely passing. The first keeps the load-bearing entered == ['httpv1', 'httpv2'] assertion and swaps only the outcome clause; the second asserts in both directions and its docstring points the remaining gap at #800 instead of claiming a fix. No other fixture relies on an unbacked declared length — seven HTTP/2-touching files enumerated and run, all green, and test_httpv2_payload_length_unit.py's builder is (len(payload) + 9).to_bytes(3, 'big'), self-consistent by construction.

The deleted structural test lost nothing. Its replacement uses real wire bytes on the un-mocked guess path — the exact gap the old one had — and is load-bearing: run against the old-head library it errors with struct.error, i.e. it catches the original regression. Its assertions include assertNotIsInstance(struct.error), which is what makes the narrowing unrepeatable. Arm 1's "must not suppress" pin survives separately, so both arms still have a guard.

HTTPv2(b'') → StreamEOFError restored, verified across three trees so the check has a known positive and negative: old head gave ProtocolError there, base and new head give StreamEOFError. Explicit length=0 deliberately stays ProtocolError (base raised struct.error, so still an improvement) and the asymmetry is argued in code.

Coverage caveat gone: http.py 68/0/16/0 100% and httpv2.py 320/0/128/0 100% standalone with test_http_unit.py alone — the previously-uncovered line 224 is now reached. 52 passed / 369 subtests at head; against true base, 9 distinct methods fail.

Residual, stated not hidden: 1136 struct.error escapes remain via directly-constructed HTTPv2 — unchanged from base, correctly attributed to the negative-field-length root, documented in both the unpack docstring and the _guess_version NOTE with the 16-octet GOAWAY spelled out, and filed as #805. One scoping note for #805: read()'s new schema.length > length clause cannot prevent these, because the crash happens inside Schema.unpack during unpack() and read() never runs — so #805 must not be scoped as a read() fix.

UNVERIFIED and accepted: pylint, mypy and protochain identity were not re-run. The diff adds comments plus one comparison clause and one if/elif; the first pass established an identical message set, and no sample capture reaches httpv2 at all.

Flipping to review: good-to-go.

@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 f046b38 into main Sep 25, 2026
31 checks passed
@JarryShaw
JarryShaw deleted the fix/799-httpv2-buffer-length-guard branch September 25, 2026 21:44
@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 25, 2026
…k included

`main` moved from 73f09ae to 4530424 while this PR sat open, and the 1.5.0
section cited none of the 25 commits in between. Ten new bullets cover thirteen
of them, appended in merge order, with the file's own `**a breaking change**`
lead sentence on the three that are breaking:

- #754 -- AppType split into per-transport registries; the 1,004 portless and
  704 transportless rows stop being members. Breaking.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted, so
  `TCP.make(srcport=99999)` raises; per-transport `_missing_` spans. Breaking.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`: bare `@final` raises
  `InfoError`/`SchemaError` at first construction, deriving from a finalised
  class raises, and `SchemaError` is a `ValueError` where a caller may have
  been catching `TypeError`. Breaking.
- #772 (with #790's docstring reword), #766, #759, #787, #794 (with #791's
  citation repoint), #792/#798 and #802 -- the remaining seven.

Also re-ran the citation sweep against `origin/main` rather than the checkout.
One stale line number fixed: the `httpv2.py` `header.length != 9` guard the
`#692` entry calls out moved from `:562` to `:650` under #789 and #802. The
preamble's "reaching #726" becomes #805, the new maximum reference. Verified
unmoved on 4530424: `protocol.py:1411`, `schema/internet/ipv4.py:336`,
`traceflow.py` 146/149/162/424, the four `:type:` fields in `engine.rst`,
`reassembly.rst` and `traceflow.rst`, and `EXPECTED_FAILURES` at 43 entries.

Carries the previous round's #651/#646 corrections unchanged. Two literals were
reflowed so no ``literal`` wraps a line, which the generator's residual guard
refuses. `changelog_md.py --check` exit 0; `test_changelog_md.py` 47 passed,
37 subtests.
JarryShaw added a commit that referenced this pull request Sep 25, 2026
… error (#800) (#814)

`HTTP._guess_version` decided the version by trial-parsing -- try `httpv1`,
and if it declines, try `httpv2` -- which answers "did a parser accept this?"
where the question is "what is this?", and got both directions wrong. The
HTTP/2 connection preface came back `version='2'` only because `httpv2` read
its `b'PRI'` as a declared frame length of 5,265,993, and garbage text came
back `version='2'` the same way. #802 made that inconsistency a refusal, which
left a real HTTP/2 connection opening reported as not-HTTP at all.

* positively identify HTTP/2 by prefix-comparing the first 24 octets against
  `b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'` (RFC 9113 section 3.4), then parse the
  frame that follows the preface rather than the preface itself; a preface
  counts as header, so `length` includes it and `info.packet` no longer reports
  the tail of the preface as payload
* positively identify HTTP/1 with `_test_start_line`, which applies the
  parser's own anchored patterns and unpackings, and commit to that version
  instead of re-offering a malformed HTTP/1 message to the HTTP/2 arm
* keep the trial parse as a last resort only, for a mid-stream segment that
  carries neither preface nor start line; no frame-header heuristic is added
* leave `Upgrade: h2c` out of scope -- it is stateful and correctly HTTP/1.1
  on the wire -- and document why, with a test pinning it

`pytest tests/protocols/application/` 118 passed, 420 subtests; `unittest`
59 tests OK; protochain over all 23 sample captures (1604 frames, 231
HTTP-bearing) byte-identical to `4530424df`; coverage 100% on both changed
modules.
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. Four are breaking:

- #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.
- #772 (with #790's docstring reword), #766, #759, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #782 (a further #745-hazard instance),
  #704, #723, #739, #743/#746 (cross-dependent, one bullet each), #805
  (closes #802's own filed-as-out-of-scope), #796, #800 -- the other 16,
  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 #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
`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
`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
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

httpv2: the frame guard tests the declared length, not the buffer, so a 4-octet frame can report length=16777215

1 participant