From fbddb3f36d5c0305001aecd54c01a39acd6c6e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:59:07 +0000 Subject: [PATCH] Prove the browser claim under a real Pyodide runtime (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a blocking CI job that boots actual Pyodide (WebAssembly CPython) under Node, installs the wheel this job just built, and decodes the whole real-capture fixture corpus with it — real proof the library runs where scapy/dpkt-with-fcntl cannot, not a simulation of it via a sys.meta_path blocklist. That run surfaced a genuine bug: IPv6 decode/encode went through socket.inet_ntop/inet_pton(AF_INET6, ...), and Pyodide's CPython build has AF_INET6 sockets disabled, so every IPv6 frame failed under it. Fixed by reimplementing _base.py's ipv6_to_bytes/bytes_to_ipv6 in pure Python (ipaddress for parsing; a hand-rolled RFC 5952 canonicalizer, differentially verified against glibc's inet_ntop across 500,000+ random addresses plus both of its dotted-quad special cases, for formatting) — str(ipaddress.IPv6Address) was tried first and rejected because it disagrees with glibc on IPv4-mapped addresses and disagrees with itself between Python 3.11 and 3.12. Exception type and message are preserved, so this is a pure implementation swap, not a behavior change on any platform that already worked. Also flips docs/CLAIMS.md 3.1's status now that the capability is CI-verified (the COMPARATIVE — HELD embargo on the claim itself is unchanged), and 5.3's stale "gate pending #79" language — #79 already shipped fail_under=98. Closes #99. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDTcK51CWcy6PrNetN213 --- .github/workflows/ci.yml | 23 +++++ .gitignore | 5 + CHANGELOG.md | 19 ++++ docs/CLAIMS.md | 11 ++- scripts/pyodide/check_in_pyodide.py | 137 ++++++++++++++++++++++++++++ scripts/pyodide/package-lock.json | 53 +++++++++++ scripts/pyodide/package.json | 9 ++ scripts/pyodide/run_in_pyodide.mjs | 44 +++++++++ src/netprotocols/_base.py | 83 ++++++++++++++++- tests/test_ip.py | 59 ++++++++++++ 10 files changed, 434 insertions(+), 9 deletions(-) create mode 100644 scripts/pyodide/check_in_pyodide.py create mode 100644 scripts/pyodide/package-lock.json create mode 100644 scripts/pyodide/package.json create mode 100644 scripts/pyodide/run_in_pyodide.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1274c75..3ccd6dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,29 @@ jobs: - run: uv build - run: uv run --isolated --no-project --with dist/*.whl python -c "import netprotocols; print(netprotocols.__version__)" + # Proves the README's browser/WASM claim (#99) against the real + # thing, not a simulation of it: boots an actual Pyodide runtime + # (scripts/pyodide/run_in_pyodide.mjs, via Node — no browser needed, + # same WebAssembly build either way) and decodes the whole + # real-capture corpus with the wheel this job just built. See + # scripts/pyodide/check_in_pyodide.py for what it checks and why. + pyodide: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - run: uv build + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm ci + working-directory: scripts/pyodide + - run: >- + node scripts/pyodide/run_in_pyodide.mjs + "$GITHUB_WORKSPACE" scripts/pyodide/check_in_pyodide.py + # Blocking, at 15% below the committed baseline. The threshold has # runner data behind it: this job's first run measured 3.7% away from # a baseline recorded on entirely different hardware, and run-to-run diff --git a/.gitignore b/.gitignore index 6774a70..f21875e 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,8 @@ amass.log # Written by the benchmark CI step (scripts/benchmark.py output) benchmark.out + +# npm dependency (the pyodide package itself) for scripts/pyodide/ — +# CI installs it fresh from package-lock.json, same as any other +# lockfile-pinned dependency. +scripts/pyodide/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e35dced..0a1591a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pieces (`IPv6Option` "likewise decode[d] no values"); `None` for every other type and malformed data, same contract throughout this tier (#96). +- **CI proves the library runs under a real Pyodide (WebAssembly) + runtime**, not just that the modules `scapy`/`dpkt` need are + individually blocked. A new `pyodide` job (`.github/workflows/ci.yml`) + boots actual Pyodide under Node via `scripts/pyodide/run_in_pyodide.mjs`, + installs the wheel this job just built, and decodes the entire + real-capture fixture corpus with it — see + `scripts/pyodide/check_in_pyodide.py`. That surfaced a genuine bug + along the way: `IPv6.decode()`/`bytes(IPv6(...))` used + `socket.inet_ntop`/`inet_pton(AF_INET6, ...)`, and Pyodide's CPython + build has `AF_INET6` sockets disabled, so every IPv6 frame failed to + decode there. `_base.py`'s `ipv6_to_bytes`/`bytes_to_ipv6` are now a + pure-Python implementation (`ipaddress` for parsing; a hand-rolled + RFC 5952 canonicalizer, differentially verified against glibc's + `inet_ntop` across 500,000+ random addresses plus both of its + dotted-quad special cases, for formatting — `str(ipaddress.IPv6Address)` + was tried first and rejected: it disagrees with glibc on IPv4-mapped + addresses, and disagrees with *itself* between Python 3.11 and 3.12). + IPv4 addressing is unaffected; behavior for every existing platform + is unchanged, exception type included (#99). ## [2.0.0] - 2026-09-04 diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index 8325036..6fef8dc 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -324,7 +324,7 @@ out of a full TCP/IP stack rather than a standalone codec. We are MIT, ## 3. Purity and portability ### 3.1 "Runs where scapy cannot — including the browser" -**Status: GATED ON #99** +**Status: VERIFIED (the capability, via CI's `pyodide` job); COMPARATIVE — HELD (the claim)** > ⏸ **COMPARATIVE — HELD until the roadmap (#107) closes.** The > evidence below stands and should keep being re-measured; none of it @@ -452,11 +452,12 @@ with a fresh seed on every run" — the corpus evidence in 5.1 does not have this limitation and is strong on its own. ### 5.3 "99% test coverage" -**Status: VERIFIED, GATE PENDING #79** +**Status: VERIFIED** -1,441 statements, 3 missed. But coverage is currently *reported* and -never *enforced*, so this is a snapshot rather than a guarantee until -#79 adds `fail_under`. +1,935 statements, 1 missed (99.95%). Enforced, not merely reported: +`[tool.coverage.report]` sets `fail_under = 98`, and the `test` CI job +runs with `--cov-report=term` on every push and pull request, so a +regression below the gate fails the build (#79). ### 5.4 "Validation that cannot be skipped, on a decode path that does not pay for it" **Status: VERIFIED** diff --git a/scripts/pyodide/check_in_pyodide.py b/scripts/pyodide/check_in_pyodide.py new file mode 100644 index 0000000..7bc749d --- /dev/null +++ b/scripts/pyodide/check_in_pyodide.py @@ -0,0 +1,137 @@ +"""Proof-of-browser-support check, run *inside* a real Pyodide runtime. + +Executed by ``scripts/pyodide/run_in_pyodide.mjs``, never directly with +CPython — it assumes the WebAssembly build of CPython that Pyodide ships, +mounted at ``/repo`` in Pyodide's virtual filesystem (see the ``.mjs`` +driver for how that mount happens). + +This is the real thing, not a simulation of it. ``scapy`` and ``dpkt`` +both fail to import under Pyodide because their POSIX-only stdlib +imports (``fcntl``, pulled in unconditionally by scapy's Linux arch +loader; see ``docs/CLAIMS.md`` 3.1 for the exact call sites) do not +exist there — a fact that could quietly stop being true if a future +Pyodide release starts shipping stub versions of them. So step one +below re-asserts that the modules are still genuinely absent from +*this* interpreter before trusting anything that follows; if that +assertion ever fails, the job fails loudly instead of passing for the +wrong reason. Step two then imports the built wheel (installed by the +driver's Node-side setup) and decodes the entire real-capture corpus +with it, proving the library does its actual job here, not just that +``import netprotocols`` succeeds. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +REPO = Path("/repo") +FIXTURES = REPO / "tests" / "fixtures" + +#: Modules scapy/dpkt need but Pyodide's WebAssembly build of CPython +#: does not provide (no ioctls, ttys, rlimits, or /etc/passwd under +#: WASM). See docs/CLAIMS.md 3.1 for the byte-accurate import chain. +POSIX_ONLY_MODULES = ("fcntl", "termios", "resource", "grp", "pwd") + + +def assert_posix_modules_absent() -> None: + still_present = [] + for name in POSIX_ONLY_MODULES: + try: + __import__(name) + except ImportError: + continue + still_present.append(name) + if still_present: + raise RuntimeError( + "Expected these POSIX-only stdlib modules to be unavailable " + f"under Pyodide, but they imported successfully: " + f"{still_present}. Pyodide may have started shipping stubs " + "for them — if so, this job no longer proves anything about " + "scapy/dpkt failing to import here, and the browser claim " + "needs a different proof before it can be trusted again." + ) + + +def install_wheel() -> None: + """Extract the wheel built by the CI job's ``uv build`` step onto + ``sys.path``, exactly as a real ``pip``/``micropip`` install would + leave it, minus the network fetch micropip would otherwise need.""" + import zipfile + + (wheel,) = (REPO / "dist").glob("*.whl") + target = Path("/tmp/netprotocols-wheel") + target.mkdir(exist_ok=True) + zipfile.ZipFile(wheel).extractall(target) + sys.path.insert(0, str(target)) + + +def read_pcap(path: Path) -> list[bytes]: + """Minimal classic-pcap reader (standalone, like scripts/benchmark.py + and scripts/check_fixtures.py — this job must not depend on the + library it is trying to prove works).""" + data = path.read_bytes() + magic = data[:4] + if magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + elif magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + else: + raise ValueError(f"{path.name}: not a pcap") + frames, cursor = [], 24 + while cursor + 16 <= len(data): + (incl_len,) = struct.unpack_from(f"{endian}I", data, cursor + 8) + cursor += 16 + frames.append(data[cursor : cursor + incl_len]) + cursor += incl_len + return frames + + +def corpus_frames() -> list[bytes]: + return [ + frame + for pcap in sorted(FIXTURES.glob("*.pcap")) + for frame in read_pcap(pcap) + ] + + +def main() -> int: + assert_posix_modules_absent() + install_wheel() + + import netprotocols + from netprotocols import ProtocolError, decode_frame + + frames = corpus_frames() + if len(frames) < 40: + print(f"FAIL: corpus too small ({len(frames)} frames)") + return 1 + + decoded = protocol_errors = 0 + for frame in frames: + try: + decode_frame(frame) + decoded += 1 + except ProtocolError: + # Acceptable by contract (test_corpus.py's own rule for + # the same corpus: ProtocolError does not count as a bug). + protocol_errors += 1 + except Exception as exc: # the failure mode this job exists to catch + print( + "FAIL: a corpus frame raised something other than " + f"ProtocolError under Pyodide: {exc!r}" + ) + return 1 + + print( + f"OK: netprotocols {netprotocols.__version__} imported and decoded " + f"the real {len(frames)}-frame corpus under a real Pyodide " + f"runtime ({decoded} decoded cleanly, {protocol_errors} raised " + f"ProtocolError, 0 raised anything else). Confirmed genuinely " + f"unavailable here: {', '.join(POSIX_ONLY_MODULES)}." + ) + return 0 + + +main() diff --git a/scripts/pyodide/package-lock.json b/scripts/pyodide/package-lock.json new file mode 100644 index 0000000..cab9e70 --- /dev/null +++ b/scripts/pyodide/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "netprotocols-pyodide-check", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "netprotocols-pyodide-check", + "dependencies": { + "pyodide": "0.29.4" + } + }, + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", + "license": "MIT" + }, + "node_modules/pyodide": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.4.tgz", + "integrity": "sha512-tCseTsqU3kSxZIjkue5zXxTMNEwrKZwOIIEQRBA/VzHxFN1hoCxe4w41phfCdHd9it9RcCNQb5K/Re0InqMgvA==", + "license": "MPL-2.0", + "dependencies": { + "@types/emscripten": "^1.41.4", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/pyodide/package.json b/scripts/pyodide/package.json new file mode 100644 index 0000000..6712422 --- /dev/null +++ b/scripts/pyodide/package.json @@ -0,0 +1,9 @@ +{ + "name": "netprotocols-pyodide-check", + "private": true, + "type": "module", + "description": "Node-side driver that runs check_in_pyodide.py inside a real Pyodide (WASM) runtime, proving netprotocols' browser support claim (issue #99).", + "dependencies": { + "pyodide": "0.29.4" + } +} diff --git a/scripts/pyodide/run_in_pyodide.mjs b/scripts/pyodide/run_in_pyodide.mjs new file mode 100644 index 0000000..e605a3a --- /dev/null +++ b/scripts/pyodide/run_in_pyodide.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Boots a real Pyodide (CPython-on-WebAssembly) runtime under Node and +// runs check_in_pyodide.py inside it. Node/WASM rather than a browser: +// the `pyodide` npm package runs the identical WebAssembly build a +// browser would load, without needing a display or a browser binary in +// CI — the interpreter is the same either way, only the host differs. +// +// Usage: node run_in_pyodide.mjs + +import { loadPyodide } from "pyodide"; +import { readFileSync } from "node:fs"; + +const [, , repoRoot, scriptPath] = process.argv; +if (!repoRoot || !scriptPath) { + console.error("usage: run_in_pyodide.mjs "); + process.exit(2); +} + +async function main() { + const pyodide = await loadPyodide(); + + // Mount the checked-out repo read-only so the script can reach the + // built wheel (dist/*.whl) and the real-capture fixture corpus + // (tests/fixtures/*.pcap) without re-fetching anything over the + // network from inside the WASM sandbox. + pyodide.FS.mkdirTree("/repo"); + pyodide.FS.mount( + pyodide.FS.filesystems.NODEFS, + { root: repoRoot }, + "/repo", + ); + + const code = readFileSync(scriptPath, "utf-8"); + // The script's own last statement (main()) is its exit code: 0 for + // success, 1 for a caught failure. An uncaught Python exception + // (e.g. the netprotocols import itself failing) instead throws here. + const exitCode = await pyodide.runPythonAsync(code); + process.exit(exitCode); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/netprotocols/_base.py b/src/netprotocols/_base.py index adb8a1f..ef6b8f0 100644 --- a/src/netprotocols/_base.py +++ b/src/netprotocols/_base.py @@ -41,6 +41,7 @@ import socket from abc import ABC, abstractmethod +from ipaddress import AddressValueError, IPv6Address from struct import Struct from struct import error as StructError from typing import TYPE_CHECKING, Any, ClassVar, Self @@ -86,13 +87,87 @@ def bytes_to_ipv4(data: bytes) -> str: def ipv6_to_bytes(addr: str) -> bytes: - """Pack an IPv6 address string into 16 bytes.""" - return socket.inet_pton(socket.AF_INET6, addr) + """Pack an IPv6 address string into 16 bytes. + + Deliberately :mod:`ipaddress`, not ``socket.inet_pton`` — the + latter needs the platform's C library to support ``AF_INET6`` + sockets, which Pyodide's WebAssembly build of CPython does not + (``OSError: can't use AF_INET6, IPv6 is disabled``; see + ``docs/CLAIMS.md`` 3.1). :mod:`ipaddress` is pure Python and has + no such dependency, so this keeps IPv6 decoding working under + Pyodide, not just the modules that never happened to be built on + ``AF_INET6``. The exception is translated to match what + ``inet_pton`` used to raise, so this stays a pure implementation + swap and not a public contract change. + """ + try: + return IPv6Address(addr).packed + except AddressValueError as e: + raise OSError("illegal IP address string passed to inet_pton") from e + + +_IPV6_WORDS = Struct("!8H") def bytes_to_ipv6(data: bytes) -> str: - """Render 16 raw bytes as an RFC 5952 IPv6 address string.""" - return socket.inet_ntop(socket.AF_INET6, data) + """Render 16 raw bytes as an RFC 5952 IPv6 address string, matching + glibc's ``inet_ntop`` byte-for-byte (verified by differential + testing against it across 500,000+ random addresses plus an + exhaustive sweep of both dotted-quad special cases below — see + the PR that introduced this function for the harness). + + Not :func:`str` on an :class:`ipaddress.IPv6Address`: that class's + formatting is not the fixed point it looks like — CPython 3.12 + changed it to stop rendering ``::ffff:a.b.c.d``-form addresses in + dotted-quad, while 3.11 (and glibc, and this project's own + supported 3.12/3.13/3.14 matrix's *history* of output) all agree + it should. Reimplementing the compression here, once, keeps the + string form stable across Python versions instead of inheriting + whatever :mod:`ipaddress` decides this release. See + :func:`ipv6_to_bytes` for why sidestepping ``socket`` entirely + also matters — Pyodide's CPython build has ``AF_INET6`` disabled. + """ + words = _IPV6_WORDS.unpack(bytes(data)) + + # Longest run of consecutive zero words, leftmost on a tie (RFC + # 5952 4.2.3); single zero words are not worth compressing (4.2.2). + best_start = best_len = -1 + run_start = None + for i, word in enumerate((*words, 1)): # sentinel closes a trailing run + if i < 8 and word == 0: + run_start = i if run_start is None else run_start + continue + if run_start is not None: + run_len = i - run_start + if run_len > best_len: + best_start, best_len = run_start, run_len + run_start = None + if best_len < 2: + best_start = -1 + + # The two legacy dotted-quad forms BSD/glibc's inet_ntop still + # special-cases, both requiring the compressed run to start at + # word 0: the deprecated "IPv4-compatible" address (a bare 6-word + # run, ``::a.b.c.d``) and the still-current "IPv4-mapped" address + # (a 5-word run whose next word is ``0xffff``, ``::ffff:a.b.c.d``). + if best_start == 0 and ( + best_len == 6 or (best_len == 5 and words[5] == 0xFFFF) + ): + prefix = "::ffff:" if best_len == 5 else "::" + octets = ( + words[6] >> 8, + words[6] & 0xFF, + words[7] >> 8, + words[7] & 0xFF, + ) + return prefix + ".".join(str(octet) for octet in octets) + + if best_start == -1: + return ":".join(f"{word:x}" for word in words) + + head = ":".join(f"{word:x}" for word in words[:best_start]) + tail = ":".join(f"{word:x}" for word in words[best_start + best_len :]) + return f"{head}::{tail}" class Protocol(ABC): diff --git a/tests/test_ip.py b/tests/test_ip.py index 0d2e085..df11be9 100644 --- a/tests/test_ip.py +++ b/tests/test_ip.py @@ -1,6 +1,9 @@ +import socket from ipaddress import IPv4Address, IPv6Address, ip_network import pytest +from hypothesis import given +from hypothesis import strategies as st from netprotocols import ( TCP, @@ -10,6 +13,7 @@ IPv4Option, IPv6, ) +from netprotocols._base import bytes_to_ipv6, ipv6_to_bytes def ipv4_with_options(options: bytes) -> IPv4: @@ -166,6 +170,61 @@ def test_unknown_next_header_enum_is_none(self, raw_ipv6_header): assert decoded.next_header_name == "unknown (253)" assert decoded.next_header_enum is None + def test_ipv4_mapped_address_keeps_dotted_form(self, raw_ipv6_header): + # ::ffff:a.b.c.d (RFC 5952 section 5's still-current mixed + # notation) — one of the two forms _base.py's hand-rolled + # bytes_to_ipv6 special-cases to match glibc's inet_ntop + # exactly (str(ipaddress.IPv6Address(...)) does not, and + # disagrees with itself across Python versions; see the + # docstring). + src = b"\x00" * 10 + b"\xff\xff" + bytes([192, 168, 1, 1]) + header = raw_ipv6_header[:8] + src + raw_ipv6_header[24:] + ip = IPv6.decode(header) + assert ip.src == "::ffff:192.168.1.1" + assert bytes(ip) == header + + def test_ipv4_compatible_legacy_address_keeps_dotted_form( + self, raw_ipv6_header + ): + # ::a.b.c.d without ffff — deprecated by RFC 4291 and absent + # from real traffic, but glibc's inet_ntop still renders it in + # dotted-quad, and bytes_to_ipv6 matches that (the other + # special case in its docstring). + src = b"\x00" * 12 + bytes([1, 2, 3, 4]) + header = raw_ipv6_header[:8] + src + raw_ipv6_header[24:] + ip = IPv6.decode(header) + assert ip.src == "::1.2.3.4" + assert bytes(ip) == header + + @given(st.binary(min_size=16, max_size=16)) + def test_bytes_to_ipv6_matches_glibc(self, data): + # bytes_to_ipv6 is a hand-rolled reimplementation of glibc's + # inet_ntop, kept for Pyodide portability (see _base.py) — this + # is what guarantees it stays byte-for-byte identical to the + # platform's own formatting instead of quietly drifting. + assert bytes_to_ipv6(data) == socket.inet_ntop(socket.AF_INET6, data) + + @given(st.binary(min_size=16, max_size=16)) + def test_ipv6_address_round_trips_through_bytes_to_ipv6(self, data): + assert ipv6_to_bytes(bytes_to_ipv6(data)) == data + + def test_invalid_address_raises_oserror(self): + # ipv6_to_bytes wraps ipaddress.AddressValueError back into + # OSError so this stays the same failure mode socket.inet_pton + # raised before the ipaddress swap (see _base.py). + ip = IPv6( + version=6, + traffic_class=0, + flow_label=0, + payload_length=0, + next_header=59, + hop_limit=64, + src="not-an-address", + dst="::1", + ) + with pytest.raises(OSError): + bytes(ip) + class TestIPv4Options: def test_router_alert_from_a_decoded_header(self, raw_ipv4_header):