netprotocols 1.3.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netprotocols-2.0.0/.github/workflows/ci.yml +91 -0
- netprotocols-2.0.0/.github/workflows/release.yml +61 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/.gitignore +3 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/ARCHITECTURE.md +204 -42
- netprotocols-2.0.0/CHANGELOG.md +744 -0
- netprotocols-2.0.0/PKG-INFO +330 -0
- netprotocols-2.0.0/README.md +305 -0
- netprotocols-2.0.0/benchmarks/baseline.json +13 -0
- netprotocols-2.0.0/docs/CLAIMS.md +680 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/pyproject.toml +10 -1
- netprotocols-2.0.0/scripts/benchmark.py +449 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/__init__.py +29 -1
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/_base.py +42 -4
- netprotocols-2.0.0/src/netprotocols/_defaults.py +105 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/checksum.py +9 -3
- netprotocols-2.0.0/src/netprotocols/flow.py +96 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer2/arp.py +20 -15
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer2/ethernet.py +31 -24
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer2/vlan.py +23 -9
- netprotocols-2.0.0/src/netprotocols/layer3/__init__.py +28 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer3/gre.py +12 -7
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer3/icmp.py +69 -5
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer3/igmp.py +28 -5
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer3/ip.py +105 -72
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer3/ipv6_ext.py +48 -18
- netprotocols-2.0.0/src/netprotocols/layer4/__init__.py +4 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer4/_ports.py +30 -15
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer4/tcp.py +44 -11
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer4/udp.py +6 -4
- netprotocols-2.0.0/src/netprotocols/layer7/__init__.py +4 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer7/dhcp.py +16 -3
- netprotocols-2.0.0/src/netprotocols/layer7/dns.py +554 -0
- netprotocols-2.0.0/src/netprotocols/packet.py +174 -0
- netprotocols-2.0.0/src/netprotocols/registry.py +385 -0
- netprotocols-2.0.0/src/netprotocols/utils/exceptions.py +118 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/utils/ipv4.py +19 -3
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/utils/mac.py +22 -4
- netprotocols-2.0.0/src/netprotocols/walk.py +173 -0
- netprotocols-2.0.0/tests/strategies.py +274 -0
- netprotocols-2.0.0/tests/test_contract.py +217 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_corpus.py +10 -10
- netprotocols-2.0.0/tests/test_diagnostics.py +552 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_dns.py +58 -0
- netprotocols-2.0.0/tests/test_docs.py +115 -0
- netprotocols-2.0.0/tests/test_exports.py +82 -0
- netprotocols-2.0.0/tests/test_flow.py +174 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_fuzz.py +26 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_icmp.py +48 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_ipv6_ext.py +40 -0
- netprotocols-2.0.0/tests/test_packet.py +103 -0
- netprotocols-2.0.0/tests/test_pattern_matching.py +256 -0
- netprotocols-2.0.0/tests/test_registry.py +296 -0
- netprotocols-2.0.0/tests/test_version.py +54 -0
- netprotocols-2.0.0/tests/test_walk.py +414 -0
- netprotocols-2.0.0/tests/test_workflows.py +118 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/uv.lock +27 -1
- netprotocols-1.3.0/.github/workflows/ci.yml +0 -47
- netprotocols-1.3.0/.github/workflows/release.yml +0 -18
- netprotocols-1.3.0/CHANGELOG.md +0 -296
- netprotocols-1.3.0/PKG-INFO +0 -198
- netprotocols-1.3.0/README.md +0 -173
- netprotocols-1.3.0/src/netprotocols/layer3/__init__.py +0 -21
- netprotocols-1.3.0/src/netprotocols/layer4/__init__.py +0 -4
- netprotocols-1.3.0/src/netprotocols/layer7/__init__.py +0 -3
- netprotocols-1.3.0/src/netprotocols/layer7/dns.py +0 -436
- netprotocols-1.3.0/src/netprotocols/packet.py +0 -88
- netprotocols-1.3.0/src/netprotocols/utils/exceptions.py +0 -48
- netprotocols-1.3.0/tests/test_contract.py +0 -108
- netprotocols-1.3.0/tests/test_packet.py +0 -37
- {netprotocols-1.3.0 → netprotocols-2.0.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/.github/pull_request_template.md +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/CONTRIBUTING.md +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/LICENSE +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_dhcp.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_gre.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_igmp.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_supplement.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/capture_fixtures_vlan.sh +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/scripts/check_fixtures.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/_enums.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/py.typed +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/conftest.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/MANIFEST.md +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/arp_exchange.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/dhcp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/dns_tcp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/gre.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/icmpv4_external.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/igmp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/ipv6_mld.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/dhcp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/dns_tcp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/gre.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/igmp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/tcp_http.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/tcp_https.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/udp_dns.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/fixtures/vlan_icmp.pcap +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_arp.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_checksum.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_dhcp.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_ethernet.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_gre.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_igmp.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_ip.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_tcp.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_udp.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_utils.py +0 -0
- {netprotocols-1.3.0 → netprotocols-2.0.0}/tests/test_vlan.py +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
# Callable so the release workflow can run this exact ladder against a
|
|
9
|
+
# tagged commit before publishing (see release.yml). Keeping one
|
|
10
|
+
# definition means the release gate cannot drift from the PR gate.
|
|
11
|
+
workflow_call:
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
lint:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
|
+
- uses: astral-sh/setup-uv@v6
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- run: uv run ruff check
|
|
22
|
+
- run: uv run ruff format --check
|
|
23
|
+
|
|
24
|
+
typecheck:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v5
|
|
28
|
+
- uses: astral-sh/setup-uv@v6
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
- run: uv run mypy
|
|
32
|
+
|
|
33
|
+
test:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
strategy:
|
|
36
|
+
matrix:
|
|
37
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v5
|
|
40
|
+
- uses: astral-sh/setup-uv@v6
|
|
41
|
+
with:
|
|
42
|
+
python-version: ${{ matrix.python-version }}
|
|
43
|
+
- run: uv run --frozen pytest --cov=netprotocols --cov-report=term
|
|
44
|
+
|
|
45
|
+
build:
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v5
|
|
49
|
+
- uses: astral-sh/setup-uv@v6
|
|
50
|
+
- run: uv build
|
|
51
|
+
- run: uv run --isolated --no-project --with dist/*.whl python -c "import netprotocols; print(netprotocols.__version__)"
|
|
52
|
+
|
|
53
|
+
# Blocking, at 15% below the committed baseline. The threshold has
|
|
54
|
+
# runner data behind it: this job's first run measured 3.7% away from
|
|
55
|
+
# a baseline recorded on entirely different hardware, and run-to-run
|
|
56
|
+
# spread on one machine is ~2%. 15% is therefore roughly four times
|
|
57
|
+
# the noise it has to tolerate, which leaves real regressions (Tier 1
|
|
58
|
+
# moved this number by 30-90% a step) nowhere to hide.
|
|
59
|
+
#
|
|
60
|
+
# A failure re-measures once before failing, because a shared runner
|
|
61
|
+
# can lose a slice of CPU to a neighbour: a genuine regression fails
|
|
62
|
+
# both attempts, a noisy one usually does not.
|
|
63
|
+
#
|
|
64
|
+
# Pinned to the baseline's interpreter on purpose: normalization
|
|
65
|
+
# cancels how fast the machine is, not which CPython it runs (see the
|
|
66
|
+
# module docstring in scripts/benchmark.py). If a change is a
|
|
67
|
+
# deliberate trade, re-record with --update-baseline and say why.
|
|
68
|
+
benchmark:
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
steps:
|
|
71
|
+
- uses: actions/checkout@v5
|
|
72
|
+
- uses: astral-sh/setup-uv@v6
|
|
73
|
+
with:
|
|
74
|
+
python-version: "3.12"
|
|
75
|
+
- name: Corpus decode throughput
|
|
76
|
+
shell: bash
|
|
77
|
+
run: |
|
|
78
|
+
# Keep the benchmark's own exit status: piping the whole thing
|
|
79
|
+
# into tee would report tee's, and wrapping it in a { } group
|
|
80
|
+
# would report the trailing echo's -- either way the gate
|
|
81
|
+
# would pass while the check failed.
|
|
82
|
+
status=0
|
|
83
|
+
uv run --frozen --group bench python scripts/benchmark.py \
|
|
84
|
+
--check --threshold 15 --compare >benchmark.out 2>&1 || status=$?
|
|
85
|
+
{
|
|
86
|
+
echo '```'
|
|
87
|
+
cat benchmark.out
|
|
88
|
+
echo '```'
|
|
89
|
+
} >>"$GITHUB_STEP_SUMMARY"
|
|
90
|
+
cat benchmark.out
|
|
91
|
+
exit "$status"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
# Run the full QA ladder against the tagged commit before anything is
|
|
12
|
+
# uploaded. This is not redundant with the CI workflow's own triggers:
|
|
13
|
+
# those fire on pushes and pull requests targeting master, and a tag
|
|
14
|
+
# push matches neither, so without this job a release ran no checks at
|
|
15
|
+
# all. A tag can also point at a commit that never landed on master.
|
|
16
|
+
# A published version can only be yanked, never replaced.
|
|
17
|
+
ci:
|
|
18
|
+
uses: ./.github/workflows/ci.yml
|
|
19
|
+
|
|
20
|
+
publish:
|
|
21
|
+
needs: ci
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
environment: pypi
|
|
24
|
+
permissions:
|
|
25
|
+
id-token: write
|
|
26
|
+
contents: read
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v5
|
|
29
|
+
- uses: astral-sh/setup-uv@v6
|
|
30
|
+
- run: uv build
|
|
31
|
+
# The QA ladder cannot catch a mistagged release: every check
|
|
32
|
+
# passes because the code is fine and only the tag is wrong. Read
|
|
33
|
+
# the version back out of the artifact that is about to be
|
|
34
|
+
# uploaded, rather than re-parsing pyproject.toml, so this
|
|
35
|
+
# compares against what PyPI will actually receive.
|
|
36
|
+
- name: Verify the tag matches the version being published
|
|
37
|
+
run: |
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
# Only the "v*" trigger keeps a bare "1.3.0" tag from reaching
|
|
40
|
+
# this step, where it would compare equal and pass. Check the
|
|
41
|
+
# prefix here too, so the contract survives a wider trigger.
|
|
42
|
+
case "${GITHUB_REF_NAME}" in
|
|
43
|
+
v*) tag="${GITHUB_REF_NAME#v}" ;;
|
|
44
|
+
*)
|
|
45
|
+
echo "::error::Tag ${GITHUB_REF_NAME} does not start with 'v'."
|
|
46
|
+
echo "Release tags must be of the form v<version>." >&2
|
|
47
|
+
exit 1
|
|
48
|
+
;;
|
|
49
|
+
esac
|
|
50
|
+
built=$(uv run --isolated --no-project --with dist/*.whl \
|
|
51
|
+
python -c \
|
|
52
|
+
"from importlib.metadata import version; print(version('netprotocols'))")
|
|
53
|
+
if [ "$tag" != "$built" ]; then
|
|
54
|
+
echo "::error::Tag ${GITHUB_REF_NAME} would publish version ${built}."
|
|
55
|
+
echo "The tag and the packaged version must agree." >&2
|
|
56
|
+
echo "Either retag as v${built}, or set the version in" >&2
|
|
57
|
+
echo "pyproject.toml (and __version__) to ${tag} and retag." >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
echo "Tag ${GITHUB_REF_NAME} matches packaged version ${built}."
|
|
61
|
+
- run: uv publish --trusted-publishing always
|
|
@@ -61,6 +61,58 @@ Three design choices worth knowing:
|
|
|
61
61
|
version = ver_ihl >> 4
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
## Structural pattern matching
|
|
65
|
+
|
|
66
|
+
Every decoded header works with `match`/`case` today, and nothing in
|
|
67
|
+
`src/` was written to make that true — it falls out of the two design
|
|
68
|
+
choices above, for reasons worth being precise about:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
match eth:
|
|
72
|
+
case Ethernet(ethertype=EtherType.IPV4):
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
match ip:
|
|
76
|
+
case IPv4(protocol=IPProtocol.TCP, ttl=t) if t > 32:
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
1. **`@dataclass` auto-generates `__match_args__`** from the field
|
|
81
|
+
declaration order — `Ethernet.__match_args__ ==
|
|
82
|
+
('dst', 'src', 'ethertype')` — so a *positional* class pattern
|
|
83
|
+
(`case Ethernet(dst, src, ethertype)`) works with no extra code. An
|
|
84
|
+
ordinary class has to declare `__match_args__` by hand to get that;
|
|
85
|
+
this library never does.
|
|
86
|
+
2. **Every field with a fixed vocabulary of wire values is an
|
|
87
|
+
`IntEnum`**, defined in [`_enums.py`](src/netprotocols/_enums.py),
|
|
88
|
+
and `IntEnum` subclasses `int`. A *value* pattern therefore matches
|
|
89
|
+
the plain integer the wire actually carries —
|
|
90
|
+
`case Ethernet(ethertype=EtherType.IPV4)` matches an instance whose
|
|
91
|
+
`ethertype` field holds the `int` `0x0800` — with no cast, no
|
|
92
|
+
adapter, and no separate "typed" view to keep in sync with the raw
|
|
93
|
+
field.
|
|
94
|
+
|
|
95
|
+
Keyword class patterns (`ClassName(field=pattern)`) work on *any*
|
|
96
|
+
object exposing that attribute; `__match_args__` is only needed for
|
|
97
|
+
the positional form. What a frozen dataclass adds is that the
|
|
98
|
+
attribute is guaranteed to exist exactly as declared, with no
|
|
99
|
+
proxying: a class pattern here either matches a real field or fails
|
|
100
|
+
outright — a mistyped field name in a keyword pattern silently never
|
|
101
|
+
matches rather than raising, on any object, dataclass or not, so this
|
|
102
|
+
buys reliability rather than a new failure mode, not immunity from
|
|
103
|
+
that one. (How this compares to other Python packet libraries is
|
|
104
|
+
tracked in `docs/CLAIMS.md`, not stated here — see the embargo notes
|
|
105
|
+
there.)
|
|
106
|
+
|
|
107
|
+
**Keeping this working is a contributor obligation, not a given.**
|
|
108
|
+
Field declaration order is part of a class's public API — reordering
|
|
109
|
+
fields changes what a positional pattern binds to, silently, for
|
|
110
|
+
anyone matching by position. And a field carrying a closed set of wire
|
|
111
|
+
values should stay an `IntEnum` sourced from `_enums.py` rather than
|
|
112
|
+
degrade to a bare `int`; the moment it does, `case Header(field=SomeEnum.X)`
|
|
113
|
+
stops matching and fails silently (the pattern falls through to the
|
|
114
|
+
next `case`, it does not raise) rather than loudly.
|
|
115
|
+
|
|
64
116
|
## The decode contract
|
|
65
117
|
|
|
66
118
|
`decode(data)` is the heart of the library, and every protocol obeys
|
|
@@ -90,12 +142,43 @@ Errors form a small typed hierarchy rooted at `ProtocolError`:
|
|
|
90
142
|
```
|
|
91
143
|
ProtocolError
|
|
92
144
|
├── TruncatedHeaderError buffer shorter than the header claims
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
145
|
+
├── InvalidFieldError field value violates the protocol's rules
|
|
146
|
+
│ ├── InvalidMACAddressError
|
|
147
|
+
│ ├── InvalidIPv4AddressError
|
|
148
|
+
│ └── InvalidManufacturerCodeError
|
|
149
|
+
└── MaxDepthExceededError a frame's chain exceeded decode_frame's max_depth
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Every raise carries structured context, not just a message.** A
|
|
153
|
+
formatted string is easy for a human to read and impossible for a
|
|
154
|
+
fuzzer, conformance suite, or validation tool to act on without
|
|
155
|
+
regexing it — so every raise site in `src/` attaches `protocol` (the
|
|
156
|
+
class that raised) and, where meaningful, `field` (the attribute at
|
|
157
|
+
fault), `offset`, and `expected`/`actual`:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
try:
|
|
161
|
+
packet = decode_frame(frame)
|
|
162
|
+
except ProtocolError as e:
|
|
163
|
+
print(e.protocol, e.field, e.offset, e.frame_offset, e.expected, e.actual)
|
|
97
164
|
```
|
|
98
165
|
|
|
166
|
+
`offset` is relative to whatever buffer the error was found in: the
|
|
167
|
+
`data` argument of a `decode()` call when `field` is `None` or names a
|
|
168
|
+
fixed-header value, or the attribute `field` names when it's a raw
|
|
169
|
+
`bytes` field parsed on demand (`options`, `body`, `sections`) — a TCP
|
|
170
|
+
option error's `offset` is relative to `header.options`, not to the
|
|
171
|
+
frame. `decode_frame` is the only code holding the cursor needed to
|
|
172
|
+
rebase a layer's own offset to the whole frame, so it does — the
|
|
173
|
+
result lands in `frame_offset`, left `None` by a bare
|
|
174
|
+
`SomeClass.decode()` call with no frame to rebase against. A
|
|
175
|
+
`__post_init__` validation error (values, not bytes) leaves `offset`
|
|
176
|
+
`None` too; there is nothing to be positioned in.
|
|
177
|
+
|
|
178
|
+
All five fields default to `None` and adding them never changes a
|
|
179
|
+
message string — every existing `str(err)` and `match=` assertion
|
|
180
|
+
holds unchanged.
|
|
181
|
+
|
|
99
182
|
## Walking a frame: the next_protocol() chain
|
|
100
183
|
|
|
101
184
|
A captured frame is a chain of headers, each one naming what it
|
|
@@ -154,44 +237,114 @@ Ethernet, IP protocol numbers out of IPv4/IPv6 — and they live in
|
|
|
154
237
|
space is shared (1 is ICMPv4, 58 is ICMPv6, 6 is TCP, 17 is UDP — no
|
|
155
238
|
collisions).
|
|
156
239
|
|
|
157
|
-
A complete frame walk is a five-line loop
|
|
240
|
+
A complete frame walk is a five-line loop — which is exactly why it
|
|
241
|
+
ships as [`decode_frame()`](src/netprotocols/walk.py) rather than as
|
|
242
|
+
something every caller retypes:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
packet = decode_frame(frame) # Packet(Ethernet(...), IPv4(...), TCP(...))
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Underneath it is the loop the two answers imply:
|
|
158
249
|
|
|
159
250
|
```python
|
|
160
|
-
layers, cursor, protocol = [], 0,
|
|
251
|
+
layers, cursor, protocol = [], 0, start
|
|
161
252
|
while protocol is not None:
|
|
162
253
|
header = protocol.decode(frame[cursor:])
|
|
163
254
|
layers.append(header)
|
|
164
255
|
cursor += header.header_len
|
|
165
|
-
protocol = header.next_protocol()
|
|
256
|
+
protocol = header.next_protocol(registry)
|
|
166
257
|
```
|
|
167
258
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
259
|
+
The shipped version adds what a copy-pasted loop never has: a bounded
|
|
260
|
+
depth (`max_depth`, so a crafted frame cannot make the walk grind), an
|
|
261
|
+
explicit `start=` layer for buffers that begin mid-stack, a `lax=True`
|
|
262
|
+
mode that reports partial success on `packet.stopped_by` instead of
|
|
263
|
+
raising, and the `registry`/`decode_as` hook above. The walk slices
|
|
264
|
+
whatever it is handed and never converts: `bytes` stays fastest for one
|
|
265
|
+
frame, a `memoryview` over a big capture buffer stays zero-copy.
|
|
266
|
+
Wrapping each frame in a `memoryview` internally measured *slower*
|
|
267
|
+
(0.95x on the corpus), so the walker does not.
|
|
268
|
+
|
|
269
|
+
**Where do those arrows live?** Not in the protocol classes. Every
|
|
270
|
+
one of them is a row in a dispatch table owned by
|
|
271
|
+
[`registry.py`](src/netprotocols/registry.py), and `next_protocol()`
|
|
272
|
+
is a single `dict.get` against one of five tables named after the wire
|
|
273
|
+
field they dispatch on:
|
|
274
|
+
|
|
275
|
+
| Table | Dispatches on | Read by |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `ethertype` | Ethernet II EtherType | `Ethernet`, `VLAN`, `GRE` |
|
|
278
|
+
| `ip.proto` | IPv4 `protocol` | `IPv4` |
|
|
279
|
+
| `ip.proto.v6` | IPv6 `next_header` | `IPv6`, extension headers |
|
|
280
|
+
| `udp.port` | UDP well-known port | `UDP` |
|
|
281
|
+
| `tcp.port` | TCP well-known port | `TCP` |
|
|
282
|
+
|
|
283
|
+
`ip.proto.v6` **inherits** `ip.proto`: the two share a number space
|
|
284
|
+
but not a table, because the four extension headers must be reachable
|
|
285
|
+
only inside an IPv6 chain. Registering in `ip.proto` reaches both;
|
|
286
|
+
registering in `ip.proto.v6` reaches the v6 chain alone. Inheritance
|
|
287
|
+
is resolved when you register, not when you look up, so the gating
|
|
288
|
+
costs nothing on the hot path — an IPv4 packet with `protocol=0`
|
|
289
|
+
cannot conjure a Hop-by-Hop layer because the table it reads has no
|
|
290
|
+
entry for `0`.
|
|
291
|
+
|
|
292
|
+
**Extending the walk without forking.** The tables are public. A
|
|
293
|
+
protocol this library does not implement — MPLS, VXLAN, a proprietary
|
|
294
|
+
telemetry header — is one registration away from participating in
|
|
295
|
+
frame walks like any built-in:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from netprotocols import Protocol
|
|
299
|
+
from netprotocols.registry import register
|
|
300
|
+
|
|
301
|
+
@register("ethertype", 0x8847)
|
|
302
|
+
class MPLS(Protocol):
|
|
303
|
+
...
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Registrations land in the process-wide `DEFAULT` registry. When they
|
|
307
|
+
must not — you are embedding this library, or isolating a test — build
|
|
308
|
+
your own with `Registry.from_defaults()` and hand it to the caller
|
|
309
|
+
that walks frames. Registering over an existing entry raises
|
|
310
|
+
`RegistryConflictError` unless you pass `override=True`, so two
|
|
311
|
+
packages claiming the same port cannot silently resolve by import
|
|
312
|
+
order; re-registering the *same* class to the same key is a no-op,
|
|
313
|
+
because a decorator re-runs whenever its module does.
|
|
314
|
+
|
|
315
|
+
**Why is the built-in map in its own module?** `Ethernet` must name
|
|
316
|
+
`ARP`, `IPv4`, and `IPv6` — but `arp.py` imports from the Ethernet
|
|
317
|
+
side of the world (EtherType names). Importing layer modules from each
|
|
318
|
+
other at module level would create cycles, so the built-in
|
|
319
|
+
registrations live together in
|
|
320
|
+
[`_defaults.py`](src/netprotocols/_defaults.py), whose imports sit
|
|
321
|
+
inside `install()` and run once from `__init__.py` after every
|
|
322
|
+
protocol class exists. Shared enums stay in the dependency-free
|
|
323
|
+
`_enums.py`. The layer modules bind their table at import and never
|
|
324
|
+
import each other's classes at all.
|
|
175
325
|
|
|
176
326
|
## Port-based dispatch is best-effort
|
|
177
327
|
|
|
178
|
-
The
|
|
179
|
-
authoritative field
|
|
180
|
-
|
|
181
|
-
port
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
328
|
+
The `ethertype` and `ip.proto` tables dispatch on a single
|
|
329
|
+
authoritative field: it exists to say what follows. The two port
|
|
330
|
+
tables break that model, and differ in kind rather than just in name.
|
|
331
|
+
A port is a *guess* — any service may run on any port, and the
|
|
332
|
+
discriminator is split across the source and destination ports.
|
|
333
|
+
Dispatch therefore checks the destination port first (a request
|
|
334
|
+
targets the server's well-known port) and then the source port (a
|
|
335
|
+
response comes from it). DNS on UDP 53 and DHCP on 67/68 decode as a
|
|
336
|
+
fourth layer; on TCP, port 53 names `DNSOverTCP`, a shim that consumes
|
|
337
|
+
the 2-byte length prefix (RFC 1035 §4.2.2) and chains onward, so the
|
|
338
|
+
walk stays uniform.
|
|
187
339
|
|
|
188
340
|
Because the guess can be wrong, the application class validates
|
|
189
341
|
strictly on decode: a non-DNS datagram that happens to use port 53
|
|
190
342
|
raises a `ProtocolError`, which the ordinary decode-error path absorbs
|
|
191
343
|
(the chain keeps the layers it did decode and records the failure). So
|
|
192
|
-
a mis-dispatch degrades to a diagnosed frame, never to garbage.
|
|
193
|
-
|
|
194
|
-
|
|
344
|
+
a mis-dispatch degrades to a diagnosed frame, never to garbage. This
|
|
345
|
+
is why the port tables can be opened to third parties as safely as the
|
|
346
|
+
authoritative ones: a wrong registration costs a diagnosed frame, not
|
|
347
|
+
a wrong answer.
|
|
195
348
|
|
|
196
349
|
## The encode side, and the round-trip guarantee
|
|
197
350
|
|
|
@@ -225,24 +378,28 @@ headers and `bytes(packet)` joins them, ready for a raw socket.
|
|
|
225
378
|
|
|
226
379
|
```
|
|
227
380
|
src/netprotocols/
|
|
228
|
-
├── __init__.py public API re-exports, __version__
|
|
381
|
+
├── __init__.py public API re-exports, __version__, registry install
|
|
229
382
|
├── _base.py Protocol ABC, decode contract, address helpers
|
|
230
383
|
├── _enums.py EtherType, IPProtocol, ARPOperation (imports nothing)
|
|
231
|
-
├──
|
|
384
|
+
├── registry.py public dispatch tables: register(), Registry
|
|
385
|
+
├── walk.py decode_frame(): the shipped chain walker
|
|
386
|
+
├── _defaults.py the built-in decoder map, installed at import
|
|
387
|
+
├── packet.py Packet composition, with_checksums(), flow_key()
|
|
232
388
|
├── checksum.py RFC 1071: internet_checksum, compute, verify
|
|
233
|
-
├──
|
|
389
|
+
├── flow.py FlowKey, flow_key(): canonical bidirectional keys
|
|
390
|
+
├── layer2/ ethernet.py, arp.py, vlan.py (802.1Q / 802.1ad)
|
|
234
391
|
├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
|
|
235
|
-
│ igmp.py,
|
|
392
|
+
│ igmp.py, gre.py,
|
|
236
393
|
│ ipv6_ext.py (Hop-by-Hop, Routing, Fragment,
|
|
237
394
|
│ Destination Options)
|
|
238
|
-
├── layer4/ tcp.py, udp.py, _ports.py (
|
|
239
|
-
├── layer7/ dns.py
|
|
395
|
+
├── layer4/ tcp.py, udp.py, _ports.py (port-table dispatch)
|
|
396
|
+
├── layer7/ dns.py, dhcp.py
|
|
240
397
|
└── utils/ validators (mac.py, ipv4.py), exceptions.py
|
|
241
398
|
tests/ one file per protocol + test_contract.py
|
|
242
399
|
(truncation, lying lengths, chain walks),
|
|
243
400
|
test_corpus.py (invariants over the real-capture
|
|
244
|
-
corpus in tests/fixtures
|
|
245
|
-
|
|
401
|
+
corpus in tests/fixtures/ — see its MANIFEST.md for
|
|
402
|
+
the frame and scenario counts), test_checksum.py
|
|
246
403
|
(corpus checksums recompute to wire values), and
|
|
247
404
|
test_fuzz.py (hypothesis properties: decode never
|
|
248
405
|
raises outside ProtocolError)
|
|
@@ -273,14 +430,15 @@ the same:
|
|
|
273
430
|
that region as raw `bytes` and parse it through read-only accessors
|
|
274
431
|
that never re-encode. `bytes(decode(x)) == x` then holds by
|
|
275
432
|
construction (see `layer7/dns.py`).
|
|
276
|
-
4. **Wire the chain.**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
the
|
|
283
|
-
|
|
433
|
+
4. **Wire the chain.** Register the class against the value that
|
|
434
|
+
names it, in the table that carries that value — `register("udp.port",
|
|
435
|
+
53, DNS)`. Nothing in the layer below changes. If you are adding a
|
|
436
|
+
protocol *to this library* rather than to your own program, add the
|
|
437
|
+
wire value to `_enums.py` too (display name in lockstep — a test
|
|
438
|
+
enforces it) and put the registration in `_defaults.py` alongside
|
|
439
|
+
the rest. Numbers valid only inside an IPv6 chain go in
|
|
440
|
+
`ip.proto.v6` rather than `ip.proto`, which is the whole of the
|
|
441
|
+
gating.
|
|
284
442
|
5. **Add display properties** for anything a human would want rendered
|
|
285
443
|
(`flags_str`-style names, hex strings). Degrade gracefully on
|
|
286
444
|
unknown values — return `"unknown (47)"`, never raise from a
|
|
@@ -290,6 +448,10 @@ the same:
|
|
|
290
448
|
trips, truncated input, and (if variable-length) an options case
|
|
291
449
|
and a lying-length case. Export the class from `__init__.py`.
|
|
292
450
|
|
|
451
|
+
Steps 1–3, 5 and 6 are the same whether the protocol ships here or in
|
|
452
|
+
your own package; only step 4 differs, and only in *where* the
|
|
453
|
+
registration lives.
|
|
454
|
+
|
|
293
455
|
If you follow the six steps, your protocol automatically composes with
|
|
294
456
|
`Packet`, participates in frame walks, and inherits the library's
|
|
295
457
|
error behavior — that's the whole point of the shared contract.
|