Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions docs/CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down
137 changes: 137 additions & 0 deletions scripts/pyodide/check_in_pyodide.py
Original file line number Diff line number Diff line change
@@ -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()
53 changes: 53 additions & 0 deletions scripts/pyodide/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions scripts/pyodide/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
44 changes: 44 additions & 0 deletions scripts/pyodide/run_in_pyodide.mjs
Original file line number Diff line number Diff line change
@@ -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 <repo-root> <python-script>

import { loadPyodide } from "pyodide";
import { readFileSync } from "node:fs";

const [, , repoRoot, scriptPath] = process.argv;
if (!repoRoot || !scriptPath) {
console.error("usage: run_in_pyodide.mjs <repo-root> <python-script>");
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);
});
Loading
Loading