fix(http): identify the HTTP version before parsing, not by trial and error (#800) - #814
Conversation
… error (#800) `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.
|
Two corrections from the author, both verified by me, and the second changes what this PR should be credited with. 1. My brief named the wrong base. I wrote 2. Garbage text does not read HTTP/2 on stock — #802 already closed that half. Measured by me on the real So #800's headline had two halves and only one was live. The Two defects its own probes caught that would otherwise have shipped, both worth noting because each would have been invisible in review:
Coverage discipline worth crediting: the first pass after the fix dropped Cross-review running. |
|
Cross-review verdict: GOOD TO GO (sonnet; author was opus). All seven load-bearing claims independently measured against both trees, nothing left unverified in budget. The preface is now positively identified, and the diagnosis is honest. The false-positive attack went further than I asked and the fix holds. Beyond Corpus regression clean, and it instrumented the corollary rather than inferring it. 23 captures, Scope discipline confirmed: no frame-header heuristic anywhere, Fail-before is 6 of 8, with the two exceptions named and legitimate. One discrepancy, non-load-bearing: the body claims 420 pytest subtests, measured 388. The reviewer caught its own probe artefact and said so — its first synthetic frame helper used payload-only length instead of this library's whole-frame convention, producing a false "preface+SETTINGS still fails" that contradicted the PR's table. It fixed it by diffing against the test file's real Flipping to |
`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.
…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
`TCP.__proto__` bound `httpv1.HTTP` directly for ports 80 and 8080, so a segment on either port was HTTP/1 by assertion of the port number: an HTTP/2 payload there was refused by the HTTP/1 parser and reported as `Raw`. Both versions share those ports on the wire, so the port cannot decide the version and the payload has to. * `pcapkit/protocols/transport/tcp.py` — ports 80 and 8080 now resolve to `pcapkit.protocols.application.http.HTTP`, which identifies the version before parsing (#800, landed via #814) rather than trial-parsing. * `examples/generators/dispatch.py` — `PINNED_TARGETS` for `tcp/80` and `tcp/8080` follow the repoint. * `pcapkit/protocols/transport/udp.py`, `docs/source/pep.rst` — the prose documenting the TCP/UDP asymmetry is now stale; both tables agree. * `tests/protocols/transport/test_tcp_http_dispatch_unit.py` — new; pins the descriptor, the TCP/UDP parity, and that HTTP/2 on port 80/8080 decodes as HTTP/2 while HTTP/1.1 still decodes as HTTP/1.1. All 231 HTTP/1.1 frames in the 23-capture corpus keep their chain; 9 frames in `options-transport.pcap` change `TCP:Raw` -> `TCP:HTTP/2`, which is the defect being fixed. `_guess_version` entry count over the corpus goes 0 -> 252.
…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.
Please follow the guide below
make pylint,make mypy,make isort) — all three run on the changed modules, no new findingsmake testpasses, and a test case covers the change —tests/protocols/application/scope; the full suite OOMs on this machinedocs/source/changelog/and regeneratedCHANGELOG.md, if the change is user-visible — N/A — changelog centralised in docs(changelog): shared 1.5.0 changelog — long-lived, merges last (#610, #616, #617, #618, #620) #657What is the purpose of your pull request?
fix— corrects a defectfeat— adds a featureperf— changes performance, not behaviourrefactor— changes neither behaviour nor performancetest— tests onlydocs— documentation onlyci— workflows or build toolingchore— anything elseDescription of your pull request and other information
Closes #800.
_guess_versionnow identifies before it parses. Measured on4530424dfvs this branch:ProtocolError: unknown HTTP versionversion='2', SETTINGS parsedProtocolError: unknown HTTP versionProtocolError: HTTP/2: connection preface with no frameb'foo bar baz\r\nX: y\r\n\r\n'ProtocolError: unknown HTTP versionversion='2'Upgrade: h2crequest /101version='1.1'unknown HTTP versionHTTP: invalid format, from the version identifiedThe preface is skipped rather than fed to
httpv2, and counts as header, soinfois byte-identical to reading that frame alone — without which the injectedpacket=self.packet.payloadsliced from octet 9 of a buffer whose frame starts at 24 and reported preface remnants as payload.Out of scope, deliberately:
Upgrade: h2cneeds per-connection state (one payload here, no flow context), and a mid-stream frame is undecidable — notype <= 9heuristic, since that is what misfires on binary HTTP/1 bodies. Both are documented in_guess_versionand pinned by tests.Protochain over all 23 sample captures (1604 frames, 231 HTTP-bearing) is byte-identical to
4530424df;pytest tests/protocols/application/118 passed / 420 subtests, cross-checked at 59 tests OK underunittest; coverage 100% onhttp.pyandhttpv1.py.