netprotocols 2.3.0__tar.gz → 2.4.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.3.0 → netprotocols-2.4.0}/.github/workflows/mutation.yml +13 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/ARCHITECTURE.md +198 -38
- {netprotocols-2.3.0 → netprotocols-2.4.0}/CHANGELOG.md +24 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/CONTRIBUTING.md +11 -6
- {netprotocols-2.3.0 → netprotocols-2.4.0}/PKG-INFO +55 -33
- {netprotocols-2.3.0 → netprotocols-2.4.0}/README.md +54 -32
- {netprotocols-2.3.0 → netprotocols-2.4.0}/docs/CLAIMS.md +125 -20
- {netprotocols-2.3.0 → netprotocols-2.4.0}/pyproject.toml +2 -2
- netprotocols-2.4.0/scripts/capture_fixtures_ipip.sh +161 -0
- netprotocols-2.4.0/scripts/capture_fixtures_ipsec.sh +124 -0
- netprotocols-2.4.0/scripts/capture_fixtures_sctp.sh +206 -0
- netprotocols-2.4.0/scripts/capture_fixtures_vxlan.sh +224 -0
- netprotocols-2.4.0/scripts/check_fixtures.py +753 -0
- netprotocols-2.4.0/scripts/ipsec_fixture_packets.py +418 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/__init__.py +13 -2
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_base.py +21 -3
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_defaults.py +37 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_enums.py +10 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/checksum.py +83 -17
- netprotocols-2.4.0/src/netprotocols/flow.py +135 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/__init__.py +5 -0
- netprotocols-2.4.0/src/netprotocols/layer3/ah.py +245 -0
- netprotocols-2.4.0/src/netprotocols/layer3/esp.py +116 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/ip.py +10 -6
- netprotocols-2.4.0/src/netprotocols/layer4/__init__.py +5 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/_ports.py +4 -3
- netprotocols-2.4.0/src/netprotocols/layer4/sctp.py +378 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/__init__.py +2 -0
- netprotocols-2.4.0/src/netprotocols/layer7/vxlan.py +190 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/packet.py +29 -19
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/registry.py +15 -7
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/walk.py +9 -4
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/conftest.py +38 -0
- netprotocols-2.4.0/tests/fixtures/MANIFEST.md +260 -0
- netprotocols-2.4.0/tests/fixtures/ah.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/esp.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/ipip.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/sctp.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/staging/ah.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/staging/esp.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/staging/ipip.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/staging/sctp.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/staging/vxlan.pcap +0 -0
- netprotocols-2.4.0/tests/fixtures/vxlan.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/strategies.py +87 -0
- netprotocols-2.4.0/tests/test_ah.py +545 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_checksum.py +225 -47
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_contract.py +39 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_corpus.py +17 -5
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_diagnostics.py +12 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_docs.py +33 -4
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_enums.py +4 -0
- netprotocols-2.4.0/tests/test_esp.py +307 -0
- netprotocols-2.4.0/tests/test_flow.py +356 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_fuzz.py +85 -3
- netprotocols-2.4.0/tests/test_ipip.py +250 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ipv6_ext.py +12 -3
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_registry.py +43 -13
- netprotocols-2.4.0/tests/test_sctp.py +573 -0
- netprotocols-2.4.0/tests/test_vxlan.py +562 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_workflows.py +27 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/uv.lock +1 -1
- netprotocols-2.3.0/scripts/check_fixtures.py +0 -332
- netprotocols-2.3.0/src/netprotocols/flow.py +0 -96
- netprotocols-2.3.0/src/netprotocols/layer4/__init__.py +0 -4
- netprotocols-2.3.0/tests/fixtures/MANIFEST.md +0 -128
- netprotocols-2.3.0/tests/test_flow.py +0 -174
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.claude/skills/steward/SKILL.md +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.gitattributes +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/CODEOWNERS +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/dependabot.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/pull_request_template.md +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/ci.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/codeql.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/docs-lint.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/fuzz.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/pr-title-lint.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/release.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/scorecard.yml +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/.gitignore +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/LICENSE +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/SECURITY.md +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/benchmarks/baseline.json +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/_pcap.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark_encode.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark_import.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_dhcp.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_gre.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_http.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_igmp.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_supplement.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_vlan.sh +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/check_mutmut_survivors.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/extract_http_streams.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/mutmut_survivors.txt +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/check_in_pyodide.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/package-lock.json +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/package.json +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/run_in_pyodide.mjs +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_tlv.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/arp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/ethernet.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/vlan.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/gre.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/icmp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/igmp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/tcp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/udp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/dhcp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/dns.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/http.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/pcap.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/py.typed +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/exceptions.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/ipv4.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/mac.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/arp_exchange.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/dhcp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/dns_tcp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/gre.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/01.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/01.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/02.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/02.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/03.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/03.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/04.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/04.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/05.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/05.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/06.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/06.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/07.request +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/07.response +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/http.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_external.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/igmp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_mld.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/dhcp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/dns_tcp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/gre.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/http.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/igmp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/tcp_http.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/tcp_https.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/udp_dns.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/vlan_icmp.pcap +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fuzz/fuzz_decode_frame.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fuzz/fuzz_http_decode.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_arp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_dhcp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_dns.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ethernet.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_exports.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_extract_http_streams.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_gre.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_http.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_icmp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_igmp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ip.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_mutmut_gate.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_packet.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_pattern_matching.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_pcap.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_tcp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_tlv.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_udp.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_utils.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_version.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_vlan.py +0 -0
- {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_walk.py +0 -0
|
@@ -49,6 +49,19 @@ jobs:
|
|
|
49
49
|
key: mutmut-nightly-${{ github.run_id }}
|
|
50
50
|
restore-keys: |
|
|
51
51
|
mutmut-nightly-
|
|
52
|
+
# The restored cache also carries mutants/mutmut-stats.json, the
|
|
53
|
+
# map from each function to the tests that call it. When that map
|
|
54
|
+
# exists, mutmut 3.8 re-traces only tests it has never seen, so a
|
|
55
|
+
# function added since the cached run (a new helper called only
|
|
56
|
+
# by existing tests) maps to no tests and every one of its
|
|
57
|
+
# mutants reads "no tests"; so does an existing test edited to
|
|
58
|
+
# call a function it didn't before. Without the map, mutmut
|
|
59
|
+
# traces the configured test selection in full, which costs one
|
|
60
|
+
# traced pytest pass. Nothing else is lost: with the positional
|
|
61
|
+
# filters below, mutmut re-runs every in-scope mutant regardless
|
|
62
|
+
# of a cached result.
|
|
63
|
+
- name: Drop the cached function-to-test map
|
|
64
|
+
run: rm -f mutants/mutmut-stats.json
|
|
52
65
|
- name: Install the project with dev dependencies
|
|
53
66
|
run: uv sync --group dev --frozen
|
|
54
67
|
# File-level scope (src/netprotocols/checksum.py, _tlv.py,
|
|
@@ -151,16 +151,39 @@ the same three rules (spelled out in [`_base.py`](src/netprotocols/_base.py)):
|
|
|
151
151
|
how many bytes this header consumed — a *property*, so IPv4 answers
|
|
152
152
|
`ihl * 4` and TCP answers `data_offset * 4`.
|
|
153
153
|
|
|
154
|
-
**
|
|
155
|
-
unpack: `decode()` scans for the empty line that ends
|
|
156
|
-
for the end the head's framing gives the body, so rule 1
|
|
157
|
-
rather than an unpack and `header_len` covers the whole
|
|
158
|
-
`_struct` size. Its `decode()` also takes keyword-only
|
|
159
|
-
bytes cannot carry — `request_method` (a response to `HEAD`,
|
|
160
|
-
to `CONNECT`, has no body whatever its headers say) and
|
|
161
|
-
buffer ends where the connection did) — and
|
|
162
|
-
but normalized: kept only when it changes
|
|
163
|
-
with equal bytes and equal framing compare
|
|
154
|
+
**Two protocols vary this.** The first is text framing. `HTTP` has no
|
|
155
|
+
fixed portion to unpack: `decode()` scans for the empty line that ends
|
|
156
|
+
the head, then for the end the head's framing gives the body, so rule 1
|
|
157
|
+
is a scan rather than an unpack and `header_len` covers the whole
|
|
158
|
+
message, not a `_struct` size. Its `decode()` also takes keyword-only
|
|
159
|
+
context the bytes cannot carry — `request_method` (a response to `HEAD`,
|
|
160
|
+
or a 2xx to `CONNECT`, has no body whatever its headers say) and
|
|
161
|
+
`closed` (the buffer ends where the connection did) — and
|
|
162
|
+
`request_method` is stored, but normalized: kept only when it changes
|
|
163
|
+
the framing, so two messages with equal bytes and equal framing compare
|
|
164
|
+
equal.
|
|
165
|
+
|
|
166
|
+
The second is `SCTP`. Like ICMPv4, ICMPv6 and IGMP it has no length of
|
|
167
|
+
its own: the packet is the whole IP payload, and the walk does not trim
|
|
168
|
+
a frame to the IP header's declared length. Those three keep the whole
|
|
169
|
+
buffer, Ethernet's zero padding included: ICMPv4's and IGMP's internet
|
|
170
|
+
checksum is unchanged by appended zero bytes, and an IPv6 frame carrying
|
|
171
|
+
ICMPv6 is never short enough to be padded. SCTP's CRC32c does change
|
|
172
|
+
when zero bytes are appended, so `decode()` walks the chunk headers to
|
|
173
|
+
find where the packet ends and leaves a run of zero bytes where the next
|
|
174
|
+
chunk would start undecoded, since that cannot begin a chunk and is what
|
|
175
|
+
an Ethernet sender normally pads a short frame with. Rule 3 holds for
|
|
176
|
+
those zeros; any other trailing bytes become part of the packet. Past
|
|
177
|
+
the 12-byte common header `decode()` never raises, like the three it
|
|
178
|
+
resembles: a remainder the walk cannot parse — a chunk cut by a snap
|
|
179
|
+
length, an IP fragment or an ICMP error's quote, or a nonzero trailer
|
|
180
|
+
such as an Ethernet FCS, which cannot be told from a broken chunk — is
|
|
181
|
+
kept verbatim in `chunks`, the ports and verification tag still decode,
|
|
182
|
+
and `verify()` returns `False`. `parsed_chunks` raises only when the
|
|
183
|
+
remainder does not walk: a trailer that happens to (an FCS whose bytes
|
|
184
|
+
read as a chunk header) parses as a phantom chunk, so `verify()` is the
|
|
185
|
+
reliable signal. See
|
|
186
|
+
[`layer4/sctp.py`](src/netprotocols/layer4/sctp.py)'s module docstring.
|
|
164
187
|
|
|
165
188
|
Decoding accepts `bytes` or `memoryview`. Views are a decode-time
|
|
166
189
|
transient only: every stored field is a scalar or materialized
|
|
@@ -219,8 +242,12 @@ holds unchanged.
|
|
|
219
242
|
A captured frame is a chain of headers, each one naming what it
|
|
220
243
|
carries. In this library that fact is typed: every decoded header
|
|
221
244
|
answers `next_protocol()` with the *class* that decodes its payload,
|
|
222
|
-
or `None` when the chain ends (nothing encapsulated,
|
|
223
|
-
|
|
245
|
+
or `None` when the chain ends (nothing encapsulated, a protocol this
|
|
246
|
+
library doesn't implement yet, or a payload no header names in the
|
|
247
|
+
clear — ESP's Next Header sits in its protected trailer, encrypted
|
|
248
|
+
unless the Security Association uses a confidentiality-free transform
|
|
249
|
+
(e.g. NULL, RFC 2410, or AES-GMAC, RFC 4543), and at an offset only
|
|
250
|
+
the SA determines, so the decoder never reads it).
|
|
224
251
|
|
|
225
252
|
```mermaid
|
|
226
253
|
flowchart LR
|
|
@@ -235,14 +262,36 @@ flowchart LR
|
|
|
235
262
|
I4 -->|1| IC4[ICMPv4]
|
|
236
263
|
I4 -->|6| T[TCP]
|
|
237
264
|
I4 -->|17| U[UDP]
|
|
265
|
+
I4 -->|132| S[SCTP]
|
|
238
266
|
I6 -->|58| IC6[ICMPv6]
|
|
239
267
|
I6 -->|6| T
|
|
240
268
|
I6 -->|17| U
|
|
269
|
+
I6 -->|132| S
|
|
270
|
+
I4 -->|4| I4
|
|
271
|
+
I4 -->|41| I6
|
|
272
|
+
I6 -->|4| I4
|
|
273
|
+
I6 -->|41| I6
|
|
274
|
+
I4 -->|50| ESP[ESP]
|
|
275
|
+
I6 -->|50| ESP
|
|
241
276
|
I6 -->|0, 43, 44, 60| X[IPv6 extension headers]
|
|
242
277
|
X --> X
|
|
243
278
|
X -->|58| IC6
|
|
244
279
|
X -->|6| T
|
|
245
280
|
X -->|17| U
|
|
281
|
+
X -->|132| S
|
|
282
|
+
X -->|50| ESP
|
|
283
|
+
I4 -->|51| AH[AH]
|
|
284
|
+
AH -->|6| T
|
|
285
|
+
AH -->|17| U
|
|
286
|
+
AH -->|50| ESP
|
|
287
|
+
I6 -->|51| AH6[AHv6]
|
|
288
|
+
X -->|51| AH6
|
|
289
|
+
AH6 -->|0, 43, 44, 60| X
|
|
290
|
+
AH6 -->|6| T
|
|
291
|
+
AH6 -->|17| U
|
|
292
|
+
AH6 -->|50| ESP
|
|
293
|
+
U -->|4789| VX[VXLAN]
|
|
294
|
+
VX -->|always| E
|
|
246
295
|
```
|
|
247
296
|
|
|
248
297
|
IPv6 extension headers (Hop-by-Hop Options, Routing, Fragment,
|
|
@@ -255,6 +304,17 @@ non-first fragment carries a slice from the middle of the original
|
|
|
255
304
|
payload, so no upper-layer header exists at its start. The same
|
|
256
305
|
offset rule applies to fragmented IPv4.
|
|
257
306
|
|
|
307
|
+
IPsec's AH chains the same way, through its own `next_header`, onward
|
|
308
|
+
to anything the IP header before it could have carried (the diagram
|
|
309
|
+
draws only the transport, ESP and extension-header edges). It is the
|
|
310
|
+
one header that comes as a pair for that reason: `AH` after IPv4 and
|
|
311
|
+
`AHv6` after IPv6 have one wire format but dispatch through their own
|
|
312
|
+
family's table, so IPv4 → AH → `0` stops rather than conjuring a
|
|
313
|
+
Hop-by-Hop layer, while IPv6 → AH → Destination Options — an order
|
|
314
|
+
RFC 8200 §4.1 permits — keeps decoding. ESP, the other IPsec header,
|
|
315
|
+
ends the walk: what it carries is named only inside its protected
|
|
316
|
+
trailer.
|
|
317
|
+
|
|
258
318
|
VLAN tags chain the same way: a tag's `next_protocol` dispatches the
|
|
259
319
|
inner EtherType through the same registry Ethernet uses, so a QinQ
|
|
260
320
|
(`0x88A8`) or legacy double-tagged (`0x9100`) frame decodes as one
|
|
@@ -269,8 +329,9 @@ The numbers on the arrows are the wire values — EtherTypes out of
|
|
|
269
329
|
Ethernet, IP protocol numbers out of IPv4/IPv6 — and they live in
|
|
270
330
|
[`_enums.py`](src/netprotocols/_enums.py) as `EtherType` and
|
|
271
331
|
`IPProtocol`. IPv4 and IPv6 share one registry because the number
|
|
272
|
-
space is shared (1 is ICMPv4, 58 is ICMPv6, 6 is TCP, 17 is UDP
|
|
273
|
-
collisions
|
|
332
|
+
space is shared (1 is ICMPv4, 58 is ICMPv6, 6 is TCP, 17 is UDP, 132
|
|
333
|
+
is SCTP — no collisions; 51 is AH in both, decoded by a class of each
|
|
334
|
+
family's own).
|
|
274
335
|
|
|
275
336
|
A complete frame walk is a five-line loop — which is exactly why it
|
|
276
337
|
ships as [`decode_frame()`](src/netprotocols/walk.py) rather than as
|
|
@@ -310,22 +371,41 @@ field they dispatch on:
|
|
|
310
371
|
| Table | Dispatches on | Read by |
|
|
311
372
|
|---|---|---|
|
|
312
373
|
| `ethertype` | Ethernet II EtherType | `Ethernet`, `VLAN`, `GRE` |
|
|
313
|
-
| `ip.proto` | IPv4 `protocol` | `IPv4` |
|
|
314
|
-
| `ip.proto.v6` | IPv6 `next_header` | `IPv6`, extension headers |
|
|
374
|
+
| `ip.proto` | IPv4 `protocol` | `IPv4`, `AH` |
|
|
375
|
+
| `ip.proto.v6` | IPv6 `next_header` | `IPv6`, extension headers, `AHv6` |
|
|
315
376
|
| `udp.port` | UDP well-known port | `UDP` |
|
|
316
377
|
| `tcp.port` | TCP well-known port | `TCP` |
|
|
317
378
|
|
|
318
379
|
`ip.proto.v6` **inherits** `ip.proto`: the two share a number space
|
|
319
380
|
but not a table, because the four extension headers must be reachable
|
|
320
|
-
only inside an IPv6 chain. Registering in `ip.proto` reaches both
|
|
321
|
-
|
|
381
|
+
only inside an IPv6 chain. Registering in `ip.proto` reaches both,
|
|
382
|
+
except at a key `ip.proto.v6` owns itself (51, below); registering in
|
|
383
|
+
`ip.proto.v6` reaches the v6 chain alone. Inheritance
|
|
322
384
|
is resolved when you register, not when you look up, so the gating
|
|
323
385
|
costs nothing on the hot path — an IPv4 packet with `protocol=0`
|
|
324
386
|
cannot conjure a Hop-by-Hop layer because the table it reads has no
|
|
325
387
|
entry for `0`.
|
|
326
388
|
|
|
389
|
+
A child's own registration also *shadows* the inherited entry, which
|
|
390
|
+
is how AH's pair is wired: `AHv6` is registered in `ip.proto.v6` first,
|
|
391
|
+
then `AH` in `ip.proto`, and the parent write is not inherited over a
|
|
392
|
+
key the child already owns. That works the other way round too: a
|
|
393
|
+
third party replacing protocol 51 in `ip.proto` (`override=True`)
|
|
394
|
+
changes IPv4 dispatch only, and must register in `ip.proto.v6` as well
|
|
395
|
+
to reach IPv6. `_defaults.FAMILY_SPECIFIC_NUMBERS` names the numbers
|
|
396
|
+
wired this way, and the tests hold the two tables to differing at
|
|
397
|
+
those keys alone.
|
|
398
|
+
|
|
399
|
+
A row need not name a class of its own. IP-in-IP (protocols 4 and 41)
|
|
400
|
+
has no header between the outer IP header and the inner one, so its
|
|
401
|
+
rows in `ip.proto` point straight at `IPv4` and `IPv6`, and a tunneled
|
|
402
|
+
packet walks `IPv4 → IPv4 → …` with no marker layer, as in scapy and
|
|
403
|
+
dpkt. Anything that needs "the" IP header of a transport layer must
|
|
404
|
+
therefore take the nearest one before it, as `Packet.with_checksums()`
|
|
405
|
+
and `Packet.flow_key()` do, not the first.
|
|
406
|
+
|
|
327
407
|
**Extending the walk without forking.** The tables are public. A
|
|
328
|
-
protocol this library does not implement — MPLS,
|
|
408
|
+
protocol this library does not implement — MPLS, a proprietary
|
|
329
409
|
telemetry header — is one registration away from participating in
|
|
330
410
|
frame walks like any built-in:
|
|
331
411
|
|
|
@@ -355,8 +435,15 @@ registrations live together in
|
|
|
355
435
|
[`_defaults.py`](src/netprotocols/_defaults.py), whose imports sit
|
|
356
436
|
inside `install()` and run once from `__init__.py` after every
|
|
357
437
|
protocol class exists. Shared enums stay in the dependency-free
|
|
358
|
-
`_enums.py`. The layer modules bind their table at import and
|
|
359
|
-
|
|
438
|
+
`_enums.py`. The layer modules bind their table at import, and every
|
|
439
|
+
class a header chooses by a field value (EtherType, IP protocol, port)
|
|
440
|
+
comes out of a table, never from an import. Two modules do import
|
|
441
|
+
another's class, where there is no field to look up. `layer3/icmp.py`
|
|
442
|
+
names `IPv4` and `IPv6` to start the walk of the packet an ICMP error
|
|
443
|
+
quotes, which is not dispatch, and `layer7/vxlan.py` names `Ethernet`
|
|
444
|
+
directly, because a VXLAN payload is always an Ethernet frame. Neither
|
|
445
|
+
can form a cycle: `ip.py` and `ethernet.py` import no other layer
|
|
446
|
+
module.
|
|
360
447
|
|
|
361
448
|
## Port-based dispatch is best-effort
|
|
362
449
|
|
|
@@ -372,14 +459,56 @@ fourth layer; on TCP, port 53 names `DNSOverTCP`, a shim that consumes
|
|
|
372
459
|
the 2-byte length prefix (RFC 1035 §4.2.2) and chains onward, so the
|
|
373
460
|
walk stays uniform.
|
|
374
461
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
462
|
+
VXLAN on UDP 4789 (RFC 7348) is the one port entry whose payload is a
|
|
463
|
+
whole Ethernet frame. `VXLAN.next_protocol()` always answers
|
|
464
|
+
`Ethernet`, and the walk carries on from there exactly as it does at
|
|
465
|
+
the start of a frame — the inner frame's VLAN tags, ARP, IPv4 or IPv6
|
|
466
|
+
dispatch through the same `ethertype` table, and the registry a caller
|
|
467
|
+
passed reaches them too. The walker needed no change for this:
|
|
468
|
+
`start=` only chooses the first class, and every later one comes from
|
|
469
|
+
the previous header's `next_protocol()`, whatever its layer. An
|
|
470
|
+
encapsulating endpoint normally sends to 4789, the IANA port (RFC 7348
|
|
471
|
+
§5), from a source port hashed from the inner flow, so the
|
|
472
|
+
destination-first lookup finds VXLAN even when that hash lands on
|
|
473
|
+
another registered port. The same precedence means a DNS reply to a
|
|
474
|
+
client whose ephemeral port happens to be 4789 dispatches to VXLAN,
|
|
475
|
+
never to DNS (and is then rejected at the I flag about half the time).
|
|
476
|
+
Only 4789 is registered, and Linux still gives a vxlan device `dstport`
|
|
477
|
+
8472, the port it used before IANA assigned one, when `ip link add`
|
|
478
|
+
names none, so a capture from such a device needs
|
|
479
|
+
`decode_as={"udp.port": {8472: VXLAN}}`. An ICMP echo tunnelled through
|
|
480
|
+
VXLAN walks seven layers (`Ethernet → IPv4 → UDP → VXLAN → Ethernet →
|
|
481
|
+
IPv4 → ICMPv4`), and a hostile frame nested in itself gains four per
|
|
482
|
+
level until `max_depth` stops it.
|
|
483
|
+
|
|
484
|
+
Because the guess can be wrong, each application class validates on
|
|
485
|
+
decode, but it can only reject what its format lets it tell apart from
|
|
486
|
+
noise, and for most formats that is little. DNS needs only its 12-byte
|
|
487
|
+
header and DHCP its 236-byte fixed part; past that, almost any bytes
|
|
488
|
+
decode cleanly as either, and even a datagram to port 53 with no
|
|
489
|
+
payload at all, padded to Ethernet's 60-byte minimum, decodes the
|
|
490
|
+
padding as a DNS header. VXLAN can reject more, because its header has
|
|
491
|
+
one bit it requires: the I flag, which random bytes leave clear half the
|
|
492
|
+
time. Over 10,000 random 300-byte UDP payloads per port, decoded with
|
|
493
|
+
`decode_frame(..., lax=True)`, the walk ended with `stopped_by` set for
|
|
494
|
+
50.1% of those sent to 4789 and for none of those sent to 53 or 67.
|
|
495
|
+
With lengths drawn uniformly from 0-600 bytes instead, in frames not
|
|
496
|
+
padded to Ethernet's minimum, the figures are about 52%, 2% and 39%,
|
|
497
|
+
and every DNS and DHCP rejection is a payload too short for the fixed
|
|
498
|
+
header; padded to the minimum, as on the wire, the DNS figure falls to
|
|
499
|
+
0%, since the padding itself decodes as a DNS header. The VXLAN
|
|
500
|
+
header cannot reject more than it does: RFC 7348 says to ignore its
|
|
501
|
+
reserved bits, so rejecting those would refuse real traffic, such as
|
|
502
|
+
the Group Based Policy extension's.
|
|
503
|
+
|
|
504
|
+
A rejected guess takes the ordinary decode-error path: the chain keeps
|
|
505
|
+
the layers it did decode and records the failure. An accepted one
|
|
506
|
+
yields a layer that decoded cleanly and is wrong, and nothing in the
|
|
507
|
+
`Packet` says so. A caller who needs certainty does not leave it to the
|
|
508
|
+
port: `decode_as` or a derived `Registry` names the class for a port
|
|
509
|
+
the caller knows, overriding the default for that walk. Third-party
|
|
510
|
+
entries in the port tables carry the same risk and no more: a wrong
|
|
511
|
+
registration costs what a wrong guess does.
|
|
383
512
|
|
|
384
513
|
`HTTP` is deliberately absent from the `tcp.port` table. The unit a
|
|
385
514
|
datagram protocol decodes is the payload; HTTP's is a message, which
|
|
@@ -396,10 +525,14 @@ bytes instead.
|
|
|
396
525
|
## The encode side, and the round-trip guarantee
|
|
397
526
|
|
|
398
527
|
Every class also works in reverse. Constructors take the friendly
|
|
399
|
-
forms (string addresses, integer fields)
|
|
400
|
-
`__post_init__
|
|
401
|
-
|
|
402
|
-
|
|
528
|
+
forms (string addresses, integer fields) and validate some of them in
|
|
529
|
+
`__post_init__`: Ethernet, ARP and IPv4 check address formats (IPv6
|
|
530
|
+
and DHCP do not, so a malformed address there fails only in
|
|
531
|
+
`bytes()`), and IPv4's IHL and TCP's data offset must agree with
|
|
532
|
+
`len(options)`. For a valid header, `bytes()` re-emits the exact
|
|
533
|
+
on-wire form. Constructors do not check everything decode does, so
|
|
534
|
+
some headers can be built that decode would never produce (see
|
|
535
|
+
below).
|
|
403
536
|
|
|
404
537
|
The test suite enforces the round trip in both directions for every
|
|
405
538
|
protocol:
|
|
@@ -409,6 +542,21 @@ Protocol.decode(bytes(header)) == header # object → wire → object
|
|
|
409
542
|
bytes(Protocol.decode(raw)) == raw[:header_len] # wire → object → wire
|
|
410
543
|
```
|
|
411
544
|
|
|
545
|
+
The first holds for canonical headers, the ones decode itself
|
|
546
|
+
produces, when decode is given the context it produced them with
|
|
547
|
+
(HTTP's `request_method` and `closed`). A constructor accepts more
|
|
548
|
+
than that, and for those headers the first line can fail: spellings
|
|
549
|
+
are normalised on the way back (a MAC written with dashes decodes with
|
|
550
|
+
colons, an IPv6 address decodes compressed); GRE's optional `fields`
|
|
551
|
+
must agree with its flag bits; and VXLAN accepts a clear I flag on
|
|
552
|
+
purpose, so that a test of another stack can send one, though
|
|
553
|
+
`VXLAN.decode()` rejects it as its guard against a wrong port guess
|
|
554
|
+
(see *Port-based dispatch is best-effort*). The round-trip property
|
|
555
|
+
tests generate canonical headers only: addresses spelled as decode
|
|
556
|
+
spells them, GRE fields sized from its flags, VXLAN with the I flag
|
|
557
|
+
set, and HTTP messages that either need no context or are decoded with
|
|
558
|
+
the context they carry.
|
|
559
|
+
|
|
412
560
|
One deliberate boundary: **decode and encode carry checksums
|
|
413
561
|
verbatim** — exactly what you want when faithfully re-encoding
|
|
414
562
|
captured traffic. Computing and verifying them is explicit, via
|
|
@@ -416,7 +564,16 @@ captured traffic. Computing and verifying them is explicit, via
|
|
|
416
564
|
checksum, ICMPv4, and TCP/UDP/ICMPv6 with their IPv4/IPv6
|
|
417
565
|
pseudo-headers, and `Packet.with_checksums(payload)` fills a whole
|
|
418
566
|
stack before sending. (The RFC 768 subtlety lives where it belongs:
|
|
419
|
-
the `0x0000 → 0xFFFF` substitution applies to UDP alone.)
|
|
567
|
+
the `0x0000 → 0xFFFF` substitution applies to UDP alone.) SCTP is the
|
|
568
|
+
one transport that does not use the internet checksum: its CRC32c
|
|
569
|
+
(`crc32c()`, RFC 9260 Appendix A) covers the whole packet with no
|
|
570
|
+
pseudo-header, and goes on the wire least significant byte first, so
|
|
571
|
+
`compute()` returns it byte-swapped to match the field as decoded.
|
|
572
|
+
|
|
573
|
+
That is also why `SCTP.decode()` bounds its own extent (see "The decode
|
|
574
|
+
contract" above): otherwise Ethernet's padding would land inside the
|
|
575
|
+
CRC's span, and a packet under 46 bytes from a real NIC would fail to
|
|
576
|
+
verify.
|
|
420
577
|
|
|
421
578
|
`Packet` is the thin composition layer: it holds an ordered tuple of
|
|
422
579
|
headers and `bytes(packet)` joins them, ready for a raw socket.
|
|
@@ -434,18 +591,21 @@ src/netprotocols/
|
|
|
434
591
|
├── walk.py decode_frame(): the shipped chain walker
|
|
435
592
|
├── _defaults.py the built-in decoder map, installed at import
|
|
436
593
|
├── packet.py Packet composition, with_checksums(), flow_key()
|
|
437
|
-
├── checksum.py RFC 1071
|
|
594
|
+
├── checksum.py RFC 1071 internet_checksum, SCTP's crc32c,
|
|
595
|
+
│ compute, verify
|
|
438
596
|
├── flow.py FlowKey, flow_key(): canonical bidirectional keys
|
|
439
597
|
├── pcap.py read_captures()/read_pcap()/read_pcapng(): classic
|
|
440
598
|
│ pcap and pcapng readers, from bytes not filenames
|
|
441
599
|
├── layer2/ ethernet.py, arp.py, vlan.py (802.1Q / 802.1ad)
|
|
442
600
|
├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
|
|
443
|
-
│ igmp.py, gre.py,
|
|
601
|
+
│ igmp.py, gre.py, esp.py (IPsec ESP),
|
|
602
|
+
│ ah.py (IPsec AH: AH + AHv6),
|
|
444
603
|
│ ipv6_ext.py (Hop-by-Hop, Routing, Fragment,
|
|
445
604
|
│ Destination Options)
|
|
446
|
-
├── layer4/ tcp.py, udp.py, _ports.py (port-table
|
|
605
|
+
├── layer4/ tcp.py, udp.py, sctp.py, _ports.py (port-table
|
|
606
|
+
│ dispatch)
|
|
447
607
|
├── layer7/ dns.py, dhcp.py, http.py (the HTTP/1.1 grammar
|
|
448
|
-
│ and the HTTP codec class)
|
|
608
|
+
│ and the HTTP codec class), vxlan.py
|
|
449
609
|
└── utils/ validators (mac.py, ipv4.py), exceptions.py
|
|
450
610
|
tests/ one file per protocol + test_contract.py
|
|
451
611
|
(truncation, lying lengths, chain walks),
|
|
@@ -7,8 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.0] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **IP-in-IP decodes through (#177).** IP protocol 4 (IPv4 in IPv4, RFC 2003; IPv4 in IPv6, RFC 2473) and 41 (6in4, RFC 4213; IPv6 in IPv6, RFC 2473) now dispatch from the outer IP header straight to the inner `IPv4` or `IPv6`, from either outer family, so `decode_frame` walks a tunneled packet to its inner transport instead of stopping at the outer header. The encapsulation has no header of its own, so there is no new class, only two `ip.proto` rows and two `IPProtocol` members, `IPIP` and `IPV6` ("IPv4 encapsulation", "IPv6 encapsulation"). A hostile run of nested protocol-4 headers is stopped by `max_depth`, like any other long chain.
|
|
15
|
+
- **`ESP`: IPsec Encapsulating Security Payload headers (RFC 4303) (#178).** IP protocol 50 now dispatches from IPv4 and IPv6 to `netprotocols.ESP`, which decodes the cleartext 8-byte prefix, `spi` and `sequence_number` (with `spi_hex_str` for display), and keeps everything after it — payload, IV, padding, Pad Length, Next Header and ICV — as one opaque `ciphertext: bytes`, so `bytes(ESP.decode(x)) == x`; like an ICMP `body`, it runs to the end of the frame, so on a datagram shorter than Ethernet's minimum it ends with the link-layer padding, not the ICV. There is no cryptography: without the Security Association's keys and algorithms nothing in the packet says where the ciphertext ends and the trailer begins, so `ESP.next_protocol()` returns `None` and a frame walk ends at ESP by design. `IPProtocol` gains `ESP` ("ESP").
|
|
16
|
+
- **`AH` and `AHv6`: the IPsec Authentication Header (RFC 4302) (#179).** IP protocol 51 now dispatches to `netprotocols.AH` from IPv4 and to its subclass `AHv6` from IPv6 and its extension headers. Both decode the fixed fields — `next_header`, `payload_len`, `reserved`, `spi`, `sequence_number` — and keep the ICV raw in `icv`, sized by Payload Len (`(payload_len + 2) * 4 - 12` bytes, any alignment padding included) and never verified. `next_protocol()` chains onward through `next_header`, so an AH-wrapped TCP segment keeps decoding into `TCP`; the pair exists because the onward table must be the outer family's: `AH` reads `ip.proto`, so IPv4 → AH → 0 cannot conjure a Hop-by-Hop layer, and `AHv6` reads `ip.proto.v6`, so IPv6 → AH → Destination Options (RFC 8200 §4.1) keeps decoding. `isinstance(layer, AH)`, `packet[AH]` and `case AH(...)` match either. A Payload Len below 1 raises `InvalidFieldError`, one past the buffer `TruncatedHeaderError`, and `AHv6` also rejects a header that is not a multiple of 8 octets (an odd Payload Len), all with `field="payload_len"`. `IPProtocol` gains `AH` ("AH"). Registering over protocol 51 in `ip.proto` now reaches IPv4 only: `AHv6` is `ip.proto.v6`'s own entry, which inheritance does not overwrite; `_defaults.FAMILY_SPECIFIC_NUMBERS` names it.
|
|
17
|
+
- **`VXLAN`: VXLAN tunnels decode through to the inner frame (RFC 7348, #180).** UDP port 4789 dispatches to the new `netprotocols.VXLAN` (also `netprotocols.layer7.VXLAN`), which decodes the 8-byte header into `flags`, `reserved1`, `vni` and `reserved2`, in wire order, with `bytes(VXLAN.decode(x)) == x`. `next_protocol()` always returns `Ethernet`: the payload is a whole Ethernet frame, so `decode_frame` walks on into the inner frame's VLAN tags, ARP, IPv4 or IPv6 through the existing `ethertype` table, and a `registry` or `decode_as` passed to the walk applies inside the tunnel too. The walker needed no change. RFC 7348 §5 requires the I flag (`VXLAN.I_FLAG`, `0x08`), and decoding a header without it raises `InvalidFieldError` (`field="flags"`, `offset=0`); that check is also what diagnoses about half of the non-VXLAN datagrams that happen to use port 4789, instead of decoding them as a tunnel around a garbage inner frame. Construction accepts a clear I flag, so such a header can be built to test how another stack drops it, and `bytes()` of it is the header with the I flag clear; `decode()` rejects it (ARCHITECTURE.md's round-trip guarantee now says it covers canonical headers, the ones decode produces given its context). The cost is on decode: a capture whose I flag is clear raises at the VXLAN header's `frame_offset` (a `lax=True` walk keeps only Ethernet, IP and UDP), and its inner frame, 8 bytes on, decodes with a second `decode_frame(..., start=Ethernet)` walk. The reserved bits are kept verbatim and never rejected, since the RFC says to ignore them on receipt and extensions such as Group Based Policy reuse them. Lookup tries the destination port first, so a datagram to 4789 dispatches to VXLAN whatever source port the sender's flow hash picked, even a registered one (and so does a DNS reply to a client whose ephemeral port is 4789, which dispatches to VXLAN, never to DNS). Only 4789 is registered: a capture from a Linux device created without a `dstport`, which then uses Linux's pre-IANA 8472, needs `decode_as={"udp.port": {8472: VXLAN}}`. A frame that nests VXLAN in itself is stopped by `max_depth`, four layers per level.
|
|
18
|
+
- **SCTP (#176): `netprotocols.SCTP`, layer 4, RFC 9260.** IP protocol 132 (`IPProtocol.SCTP`, "SCTP") now dispatches from IPv4 and IPv6, extension headers included, to `SCTP`, which decodes the 12-byte common header (`src_port`, `dst_port`, `verification_tag`, `checksum`) and keeps the chunks as raw `chunks` bytes, padding included, so `bytes(SCTP.decode(x)) == x[:header_len]` holds by construction. `parsed_chunks` splits them on demand into a flat tuple of `SCTPChunk(type, flags, value)`, which names the RFC 9260 chunk types (DATA, INIT, INIT ACK, SACK, HEARTBEAT, HEARTBEAT ACK, ABORT, SHUTDOWN, SHUTDOWN ACK, ERROR, COOKIE ECHO, COOKIE ACK, ECNE, CWR, SHUTDOWN COMPLETE) and the common extensions (AUTH, I-DATA, ASCONF, ASCONF-ACK, RE-CONFIG, PAD, FORWARD-TSN, I-FORWARD-TSN) via `type_name`, falling back to `"unknown (n)"`; `flags_str` names the DATA/I-DATA I/U/B/E bits and the ABORT/SHUTDOWN COMPLETE T bit, and `unrecognized_action` reads the type's two high bits. `bytes(SCTPChunk(...))` emits the padded wire form, so a packet can be built from a list of chunks. SCTP has no length field, the chain walk does not trim to the IP length, and CRC32c, unlike the internet checksum, changes when zero bytes are appended, so `decode` walks the chunk headers to find where the packet ends: a run of zero bytes where the next chunk would start is left undecoded as link-layer padding (a short Ethernet frame's). Past the 12-byte common header `decode` never raises, like `ICMPv4` and `IGMP`: a remainder the walk cannot parse (a chunk cut by a snap length, an IP fragment or an ICMP error's quote; a nonzero trailer such as an Ethernet FCS) is kept verbatim in `chunks`, so the ports, verification tag and checksum survive in strict and lax walks and in `ICMPv4.embedded_chain` (when the quote holds the whole 12-byte common header), and `verify()` returns `False` for the cut packet. `parsed_chunks` raises on such a remainder when it does not walk (a trailer that happens to walk parses as a phantom chunk, so `verify()` is the reliable signal): `InvalidFieldError` for a chunk length below 4, `TruncatedHeaderError` for a chunk that runs past the bytes, so a hostile length can neither loop nor over-read. A final chunk whose padding the buffer cuts short is accepted as is. `flow_key()` and `Packet.flow_key()` key SCTP on its ports like TCP and UDP. The chain ends at SCTP; payload-protocol dispatch is not implemented.
|
|
19
|
+
- **`netprotocols.crc32c()`, and SCTP checksums in `compute`/`verify`/`Packet.with_checksums()`.** The standard library has no CRC32c (`zlib.crc32` is the IEEE CRC-32), so `checksum.py` gains a table-driven Castagnoli CRC, pinned to RFC 3720 Appendix B.4's vectors. `compute(sctp)` is the CRC32c of the whole packet with the checksum field zeroed and no pseudo-header (`ip` is ignored), returned byte-swapped: the CRC goes on the wire least significant byte first (RFC 9260 Appendix A), and `SCTP.checksum` reads its four bytes big-endian. `with_checksums()` fills SCTP layers like the other transports.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`Packet.flow_key()` keys a tunneled flow on the inner IP header.** It used the first IPv4/IPv6 layer in the packet, which in a tunnel is the outer one: the key carried the tunnel endpoints' addresses and the tunnel's protocol number (4, 41, or 47 for GRE, which already reached this) instead of the conversation's. It now uses the IP header nearest before the transport layer, as `Packet.with_checksums()` already did, and returns `None` when no IP header precedes the transport.
|
|
24
|
+
- **`flow_key()` takes the protocol number from the transport layer, not the IP header.** `FlowKey.protocol` was read from `IPv4.protocol` / `IPv6.next_header`, which name whatever directly follows the IP header: an IPv6 flow behind a Hop-by-Hop, Routing or Destination Options header was keyed protocol 0, 43 or 60, and an AH-protected one 51, so neither matched the same conversation's unwrapped key. (A Routing header with segments left still keys on the IPv6 destination, which is the next segment rather than the final destination, so a source-routed flow's two directions can still differ.) It is now 6 for `TCP` and 17 for `UDP` wherever the segment sits, as `FlowKey`'s docstring already promised; `Packet.flow_key()` and `flow_key()` share one transport-to-number table in `netprotocols.flow`.
|
|
25
|
+
|
|
10
26
|
### Development
|
|
11
27
|
|
|
28
|
+
- **Every comparative number the README quotes was re-measured for this release** (Rule 4 of `docs/CLAIMS.md`), on the same machine class as 2.3.0's figures but on the 140-frame corpus Tier 1 grew from 97, against `dpkt 1.9.8` and `scapy 2.8.0`: decode is at parity with dpkt (from 5.3% slower to 3.0% faster over three runs; the README keeps "within 15%") and 4.1× ahead of scapy, encode 4.18× ahead of dpkt, import 55.0 ms against `scapy.all`'s 428.0 ms, and the wheel is 124.4 KiB, 21.7× smaller than scapy 2.8.0's 2.63 MiB. Decode depth against dpkt is 39 of 140 frames deeper, the 12 new ones being the VXLAN frames and one SCTP frame. `docs/CLAIMS.md` records each run and what moved, and corrects the 2.3.0 figures' scapy version: `uv.lock` already pinned 2.8.0 when they were taken, so they most likely name 2.7.0 in error.
|
|
29
|
+
- **The nightly mutation gate no longer reports a new function's mutants as untested.** `mutation.yml` restores `mutants/` from the previous night, and that directory holds `mutmut-stats.json`, mutmut's map from each function to the tests that call it. mutmut 3.8 re-traces only tests it has never seen, so a function added since then and called only by existing tests (here `checksum.py`'s `_gre_checksum` and `_sctp_checksum`) mapped to no tests, and all 49 of their mutants failed the gate as "no tests". The workflow now deletes the map before each run, so mutmut traces its configured test selection in full; a local reproduction went from 49 "no tests" to 49 killed. `tests/test_workflows.py` pins the step.
|
|
30
|
+
- **A real SCTP association joins the corpus: `sctp.pcap`, 16 frames (140 across 22 scenarios).** The capture host's kernel has no SCTP, so `scripts/capture_fixtures_sctp.sh` runs usrsctp's example client and echo server (`libusrsctp-examples`) in two netns over a veth pair, on raw IPv4: INIT through SHUTDOWN COMPLETE, with DATA, SACK and a HEARTBEAT exchange from each end, then one script-built packet with an unknown chunk type (63); the script fails unless the client completes the association and the capture holds those 16 frames, one chunk each. usrsctp builds those packets' IPv4 headers (`IP_HDRINCL`, the kernel filling length and checksum); the kernel builds the script-built packet's. `scripts/check_fixtures.py` verifies every SCTP CRC32c with its own bit-at-a-time implementation, and `tests/test_sctp.py` covers truncation, lying lengths, link-layer padding, nonzero chunk padding and IPv6 carriage with constructed packets. SCTP joins the fuzz suite (`ALL_PROTOCOLS`, a round-trip strategy, a chunk-accessor property), the truncation contract test, the corpus checksum cases and the corpus coverage set. `registry.py`'s two `register("ip.proto", 132, SCTP)` examples, and the registry tests that registered stand-ins at 132, now use 253 (reserved for experimentation, RFC 3692), since 132 is built in and a different class there raises `RegistryConflictError`.
|
|
31
|
+
- **An IPsec AH capture joins the corpus: `ah.pcap`, 6 frames (124 across 21 scenarios).** `scripts/ipsec_fixture_packets.py` now also builds AH with HMAC-SHA-256-128 — transport mode over a TCP SYN, an ICMPv4 echo and UDP, tunnel mode over inner IPv4, and an AH-over-ESP bundle — computing each ICV over the IPv4 header's immutable fields, the AH header and the payload (RFC 4302 §3.3.3). Because the ICV covers the outer header, the script builds that header itself and `scripts/capture_fixtures_ipsec.sh` sends whole datagrams with `IP_HDRINCL`; the post-capture comparison found them byte-identical on the wire. The new keys are published in `tests/fixtures/MANIFEST.md`, and `scripts/check_fixtures.py` verifies every AH ICV with them before the checksums, inner packet or ESP packet behind it, which it then requires to be readable, as it does ESP's; an AH frame on an unpublished SPI fails, as does one whose ICV the checker cannot compute (IPv4 header options, or an IPv6 outer header). `esp.pcap` is unchanged. IPv6-outer AH, with its 4 bytes of ICV padding, is covered by constructed frames in `tests/test_ah.py`, and `with_checksums()` is tested to recompute a transport checksum through AH from either family. `AH` and `AHv6` join the fuzz, round-trip, contract and corpus-coverage suites, and the registry tests now hold `ip.proto` and `ip.proto.v6` to differing only at the IPv6-only numbers and at 51.
|
|
32
|
+
- **An IPsec ESP capture joins the corpus: `esp.pcap`, 6 frames (118 across 20 scenarios).** The capture host's kernel has no xfrm ESP transform, so the new stdlib-only `scripts/ipsec_fixture_packets.py` builds genuine ESP — NULL encryption (RFC 2410), HMAC-SHA-256-128 integrity (RFC 4868), RFC 4303 padding and trailer — in transport mode (ICMPv4, UDP) and tunnel mode (inner IPv4), and `scripts/capture_fixtures_ipsec.sh` sends it through a raw protocol-50 socket over a veth pair, so the kernel builds the outer IPv4 header; the script then checks that every byte it built arrived unchanged. The test keys are published in `tests/fixtures/MANIFEST.md`, and `scripts/check_fixtures.py` verifies every ESP ICV with them, then the padding and the inner packet's checksums. An ESP frame on an SPI with no published key fails the checker rather than being skipped, as does anything an ICV vouched for that it cannot read, at any depth below the ESP header (an unknown protocol or EtherType, a header too short to check, a fragment); and tunnel nesting past its depth bound (8 encapsulations) now fails instead of passing unchecked, whatever the chain ends in, for GRE and IP-in-IP as well as ESP; an IPv4 header with an invalid IHL now fails as `ipv4-ihl` on every path, where it was checked on (or crashed the checker). IPv6-outer ESP is covered by constructed frames in `tests/test_esp.py`, since the capture host has no IPv6. `ESP` joins the fuzz, round-trip, contract and corpus-coverage suites.
|
|
33
|
+
- **A real VXLAN capture joins the corpus: `vxlan.pcap`, 11 frames (112 across 19 scenarios).** `scripts/capture_fixtures_vxlan.sh` builds a kernel VXLAN tunnel on port 4789 between two netns over a veth underlay and captures ARP, ICMP echoes and one UDP datagram across two segments: VNI 1193046 (`0x123456`) with `udpcsum`, so the outer UDP checksum is real, and VNI 100 with `noudpcsum`, so the outer checksum is zero. `scripts/check_fixtures.py` peels VXLAN on destination port 4789 and verifies the inner frame's checksums. `tests/test_checksum.py` now checks every transport in a frame against its nearest enclosing IP header, not just the first, so a VXLAN frame yields both its outer UDP and its inner transport, and holds zero UDP-over-IPv4 checksums apart as "not used" (they must `verify()`; there is nothing to recompute). The corpus's deepest chain is now 7 layers, and `walk.py`, `docs/CLAIMS.md` and ARCHITECTURE.md say so. ARCHITECTURE.md no longer lists VXLAN among protocols this library does not implement.
|
|
34
|
+
- **A real IP-in-IP capture joins the corpus: `ipip.pcap`, 4 frames (101 across 18 scenarios).** `scripts/capture_fixtures_ipip.sh` sends ICMP echoes through raw IP sockets opened for protocols 4 and 41 over a veth pair, so the kernel builds the outer header as a tunnel endpoint would; this works where the `ipip`/`sit` drivers are not built. The capture host had no IPv6, so the corpus holds IPv4 in IPv4 and IPv6 in IPv4; the script also captures the two IPv6-outer combinations on a kernel with IPv6, and `tests/test_ipip.py` covers them with constructed frames. `scripts/check_fixtures.py` peels IP-in-IP and verifies the inner checksums. `tests/test_checksum.py` now pairs each transport with its nearest enclosing IP header rather than the first. The coverage figure in `docs/CLAIMS.md` §5.3 and `pyproject.toml` is re-measured (2,682 statements, 1 missed).
|
|
35
|
+
- **README's Roadmap section now leads with the current roadmap, #170**, instead of describing the completed #107 as if nothing had replaced it and promising that "new planned work will open fresh issues" — #170 had been open since 2026-09-09. It names #170's five tier themes without restating its tiers or target versions, which moved up one minor version when 2.3.0 shipped the HTTP codec outside them; `tests/test_docs.py` now checks the section leads with #170 and links none of the tier epics.
|
|
12
36
|
- **Fixed a concurrency-group deadlock that cancelled the 2.3.0 release run before any job could start.** `ci.yml`'s concurrency group was keyed on `${{ github.workflow }}`, which inside a `workflow_call` resolves to the *caller's* name ("Release"), not "CI" — so release.yml calling ci.yml on a tag push made both the top-level run and the nested `ci` job claim the identical group `Release-refs/tags/<tag>`, and GitHub cancelled the run as a self-deadlock. The group now uses a literal `ci` prefix instead. `tests/test_workflows.py` gains a guard: no workflow declaring `workflow_call` may key its own concurrency group on `github.workflow`.
|
|
13
37
|
|
|
14
38
|
## [2.3.0] - 2026-10-23
|
|
@@ -39,12 +39,14 @@ is 80; Markdown is excluded from linting.
|
|
|
39
39
|
src/netprotocols/
|
|
40
40
|
_base.py the Protocol base class and the decode contract
|
|
41
41
|
_enums.py EtherType / IPProtocol / ARP* registries + display names
|
|
42
|
-
checksum.py RFC 1071 internet checksum,
|
|
42
|
+
checksum.py RFC 1071 internet checksum, SCTP's CRC32c,
|
|
43
|
+
compute()/verify()
|
|
43
44
|
packet.py Packet: an ordered stack of headers, with_checksums()
|
|
44
45
|
layer2/ Ethernet, VLAN, ARP
|
|
45
|
-
layer3/ IPv4, IPv6, IPv6 extension headers, ICMPv4/v6, IGMP
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
layer3/ IPv4, IPv6, IPv6 extension headers, ICMPv4/v6, IGMP,
|
|
47
|
+
GRE, IPsec ESP and AH
|
|
48
|
+
layer4/ TCP, UDP, SCTP (+ port-based dispatch)
|
|
49
|
+
layer7/ DNS, DHCP, HTTP, VXLAN
|
|
48
50
|
utils/ exceptions, MAC/IPv4 helpers
|
|
49
51
|
tests/ unit tests, corpus invariants, fuzzing, fixtures/
|
|
50
52
|
scripts/ fixture capture + validation
|
|
@@ -56,8 +58,11 @@ Every protocol is a frozen, slotted dataclass whose fields mirror the
|
|
|
56
58
|
on-wire header. `decode(data)` receives the entire remaining buffer and
|
|
57
59
|
must parse its fixed portion, tolerate trailing bytes, and raise a
|
|
58
60
|
subclass of `ProtocolError` (never a bare `struct.error` or
|
|
59
|
-
`ValueError`) on malformed input.
|
|
60
|
-
|
|
61
|
+
`ValueError`) on malformed input. (SCTP, which has no length field
|
|
62
|
+
and a CRC32c that trailing zeros change, finds its own extent instead:
|
|
63
|
+
it leaves only zero padding after it undecoded, and keeps any other
|
|
64
|
+
trailing or unparseable bytes in its `chunks` rather than raising.)
|
|
65
|
+
The authoritative description lives in the module docstring of
|
|
61
66
|
[`src/netprotocols/_base.py`](src/netprotocols/_base.py) — read it
|
|
62
67
|
before adding a protocol.
|
|
63
68
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: netprotocols
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Low-level implementations of common networking protocols
|
|
5
5
|
Project-URL: Homepage, https://github.com/EONRaider/NETProtocols
|
|
6
6
|
Project-URL: Repository, https://github.com/EONRaider/NETProtocols
|
|
@@ -76,28 +76,31 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
|
|
|
76
76
|
|
|
77
77
|
## Why NETProtocols
|
|
78
78
|
|
|
79
|
-
Measured against `dpkt 1.9.8` and `scapy 2.
|
|
80
|
-
real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured for
|
|
81
|
-
2.
|
|
79
|
+
Measured against `dpkt 1.9.8` and `scapy 2.8.0` on the 140-frame corpus
|
|
80
|
+
of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured for
|
|
81
|
+
2.4.0 on 2026-10-25 — see [docs/CLAIMS.md](docs/CLAIMS.md) for every
|
|
82
82
|
number below, its reproduction command, and its caveats):
|
|
83
83
|
|
|
84
|
-
- **Within 15% of dpkt on decode, 4.
|
|
84
|
+
- **Within 15% of dpkt on decode, 4.1× faster than scapy** —
|
|
85
85
|
`uv run --group bench python scripts/benchmark.py --compare`. dpkt is
|
|
86
|
-
the faster of the two comparators on this corpus
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
the faster of the two comparators, and on this corpus it and
|
|
87
|
+
netprotocols are at parity, the sign of the gap flipping between
|
|
88
|
+
runs. The gap moves both ways as this library and dpkt each change, and this
|
|
89
|
+
file's job is to say so honestly rather than only when it's
|
|
90
|
+
flattering.
|
|
91
|
+
- **Decodes further into the stack than dpkt on 39 of those 140
|
|
92
|
+
frames** — DNS, DHCP and VXLAN-tunnelled payloads that dpkt leaves as
|
|
93
|
+
raw bytes, and 802.1Q tags it folds into the Ethernet header,
|
|
94
|
+
netprotocols decodes as layers of their own. A throughput number
|
|
95
|
+
means little without knowing how much work it bought.
|
|
96
|
+
- **4.2× faster than dpkt at re-encoding** (`bytes(header)`,
|
|
94
97
|
`scripts/benchmark_encode.py`) — the other half of "codec" that
|
|
95
98
|
nobody else benchmarks.
|
|
96
|
-
- **Imports in
|
|
99
|
+
- **Imports in 55 ms; `scapy.all` takes 428+ ms** (`scripts/benchmark_import.py`),
|
|
97
100
|
and never touches the host doing it — importing scapy populates live
|
|
98
101
|
interface and routing tables as a side effect of the import
|
|
99
102
|
statement.
|
|
100
|
-
- **A
|
|
103
|
+
- **A 124.4 KiB wheel against scapy's 2.63 MiB** — 21.7× smaller.
|
|
101
104
|
- **Regression-gated in CI, which — as far as we could establish by
|
|
102
105
|
auditing ten comparable Python packet libraries' CI configurations
|
|
103
106
|
(dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark,
|
|
@@ -144,7 +147,7 @@ both are: a codec that turns bytes into typed objects and back.
|
|
|
144
147
|
One `mypy --strict` run says more than the bullets above:
|
|
145
148
|
|
|
146
149
|
```
|
|
147
|
-
# scapy 2.
|
|
150
|
+
# scapy 2.8.0 — a field name that does not exist
|
|
148
151
|
p.ThisFieldDoesNotExist → Any (no error)
|
|
149
152
|
|
|
150
153
|
# dpkt 1.9.8 — the whole module
|
|
@@ -172,10 +175,15 @@ ip.proto → error: "IPv4" has no attribute
|
|
|
172
175
|
| 3 | ICMPv6 | `ICMPv6` | RFC 4443, 8-byte header |
|
|
173
176
|
| 3 | IGMP | `IGMP` | RFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields |
|
|
174
177
|
| 3 | GRE | `GRE` | RFC 2784/2890, IP protocol 47; payload chains onward by EtherType |
|
|
178
|
+
| 3 | IP-in-IP | `IPv4`, `IPv6` | RFC 2003/4213/2473, IP protocols 4 and 41; no header of its own, so the outer IP header dispatches straight to the inner one |
|
|
179
|
+
| 3 | IPsec ESP | `ESP` | RFC 4303, IP protocol 50; SPI and sequence number decoded, the protected remainder kept as opaque `ciphertext` — the chain ends here by design, since without the Security Association nothing says where the Next Header is |
|
|
180
|
+
| 3 | IPsec AH | `AH`, `AHv6` | RFC 4302, IP protocol 51; fixed fields decoded and the ICV kept raw (never verified), then the payload chains onward by `next_header` through the outer IP family's table — `AHv6`, an `AH` subclass, is what IPv6 dispatches to |
|
|
175
181
|
| 4 | TCP | `TCP` | RFC 9293, data-offset/options aware |
|
|
176
182
|
| 4 | UDP | `UDP` | RFC 768 |
|
|
183
|
+
| 4 | SCTP | `SCTP` | RFC 9260, IP protocol 132; common header + chunk list (`SCTPChunk`) parsed on demand; CRC32c compute/verify |
|
|
177
184
|
| 7 | DNS | `DNS` | RFC 1035, over UDP and TCP (`DNSOverTCP` length shim); on-demand name decompression + resource-record parsing |
|
|
178
185
|
| 7 | DHCP | `DHCP` | RFC 2131/2132, over UDP 67/68; TLV options parsed on demand |
|
|
186
|
+
| 7 | VXLAN | `VXLAN` | RFC 7348, over UDP 4789; the payload is a whole Ethernet frame, which walks on through the EtherType table |
|
|
179
187
|
| 7 | HTTP/1.1 | `HTTP` | RFC 9112/9110; one message per decode from a buffer you reassembled; ambiguities reported as anomalies; not port-dispatched |
|
|
180
188
|
|
|
181
189
|
## Decoding a captured frame
|
|
@@ -296,8 +304,8 @@ exception in this library belongs to.
|
|
|
296
304
|
|
|
297
305
|
`Packet.flow_key()` (or the free function, `netprotocols.flow_key()`,
|
|
298
306
|
for a header pair that never went through `decode_frame`) returns a
|
|
299
|
-
canonical, direction-independent key for a TCP
|
|
300
|
-
directions of one flow produce the same key:
|
|
307
|
+
canonical, direction-independent key for a TCP, UDP or SCTP
|
|
308
|
+
conversation — both directions of one flow produce the same key:
|
|
301
309
|
|
|
302
310
|
```python
|
|
303
311
|
request = decode_frame(client_to_server_frame)
|
|
@@ -306,7 +314,7 @@ assert request.flow_key() == reply.flow_key()
|
|
|
306
314
|
```
|
|
307
315
|
|
|
308
316
|
It returns `None`, not an error, when there is nothing to key on: no
|
|
309
|
-
enclosing IP layer, no TCP/UDP layer, or a transport layer without
|
|
317
|
+
enclosing IP layer, no TCP/UDP/SCTP layer, or a transport layer without
|
|
310
318
|
ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot
|
|
311
319
|
convention for message types that have none).
|
|
312
320
|
|
|
@@ -449,7 +457,8 @@ raw = bytes(frame) # ready for a raw socket
|
|
|
449
457
|
```
|
|
450
458
|
|
|
451
459
|
Checksums are computed and verified on request — never silently:
|
|
452
|
-
`compute()`/`verify()` in `netprotocols.checksum
|
|
460
|
+
`compute()`/`verify()` in `netprotocols.checksum` (the internet
|
|
461
|
+
checksum, and SCTP's CRC32c via `crc32c()`), and
|
|
453
462
|
`Packet.with_checksums()` to fill a whole stack before sending.
|
|
454
463
|
|
|
455
464
|
## Display helpers
|
|
@@ -472,7 +481,26 @@ monitor built on it (formerly Packet-Sniffer).
|
|
|
472
481
|
|
|
473
482
|
## Roadmap
|
|
474
483
|
|
|
475
|
-
The
|
|
484
|
+
The current roadmap,
|
|
485
|
+
[#170](https://github.com/EONRaider/NETProtocols/issues/170), closes the
|
|
486
|
+
protocol gap against dpkt, scapy and pypacker in five tiers: transport
|
|
487
|
+
and tunneling, routing, L2 discovery, AAA and infrastructure services,
|
|
488
|
+
and TLS record framing with SNI. That issue tracks each tier's
|
|
489
|
+
protocols and target version, so they are not restated here. The
|
|
490
|
+
HTTP/1.1 codec
|
|
491
|
+
([#214](https://github.com/EONRaider/NETProtocols/pull/214),
|
|
492
|
+
[#223](https://github.com/EONRaider/NETProtocols/pull/223),
|
|
493
|
+
[#226](https://github.com/EONRaider/NETProtocols/pull/226)) shipped as
|
|
494
|
+
2.3.0 outside those tiers, and the first tier — IP-in-IP, VXLAN, ESP, AH
|
|
495
|
+
and SCTP
|
|
496
|
+
([#230](https://github.com/EONRaider/NETProtocols/pull/230),
|
|
497
|
+
[#231](https://github.com/EONRaider/NETProtocols/pull/231),
|
|
498
|
+
[#232](https://github.com/EONRaider/NETProtocols/pull/232),
|
|
499
|
+
[#233](https://github.com/EONRaider/NETProtocols/pull/233),
|
|
500
|
+
[#234](https://github.com/EONRaider/NETProtocols/pull/234)) — as 2.4.0
|
|
501
|
+
(see [CHANGELOG.md](CHANGELOG.md)).
|
|
502
|
+
|
|
503
|
+
The roadmap before it, the post-1.3.0
|
|
476
504
|
[#107](https://github.com/EONRaider/NETProtocols/issues/107), is
|
|
477
505
|
complete — five tiers plus a competitor CI audit
|
|
478
506
|
([#124](https://github.com/EONRaider/NETProtocols/issues/124)) and the
|
|
@@ -492,19 +520,12 @@ decode-throughput fix
|
|
|
492
520
|
CodeFactor tooling cleanup
|
|
493
521
|
([#156](https://github.com/EONRaider/NETProtocols/issues/156)) are now
|
|
494
522
|
released as
|
|
495
|
-
[2.2.1](https://github.com/EONRaider/NETProtocols/releases/tag/v2.2.1)
|
|
496
|
-
and the HTTP/1.1 codec
|
|
497
|
-
([#214](https://github.com/EONRaider/NETProtocols/pull/214),
|
|
498
|
-
[#223](https://github.com/EONRaider/NETProtocols/pull/223),
|
|
499
|
-
[#226](https://github.com/EONRaider/NETProtocols/pull/226)) as 2.3.0
|
|
500
|
-
(see [CHANGELOG.md](CHANGELOG.md)).
|
|
523
|
+
[2.2.1](https://github.com/EONRaider/NETProtocols/releases/tag/v2.2.1).
|
|
501
524
|
2.1.0's and 1.3.1/1.4.0's work shipped folded into 2.0.0 and 2.2.0
|
|
502
525
|
rather than as their own versions, but the roadmap's original "only at
|
|
503
526
|
a major version bump" release policy wasn't actually held to: 2.0.0,
|
|
504
|
-
2.2.0, 2.2.1 and now 2.
|
|
505
|
-
draw on the finished tree.
|
|
506
|
-
will open fresh issues rather than restating a list here, so this
|
|
507
|
-
section stays accurate without upkeep. The wave before this one — TCP
|
|
527
|
+
2.2.0, 2.2.1, 2.3.0 and now 2.4.0 are all on PyPI, and the comparative claims above
|
|
528
|
+
draw on the finished tree. The wave before that one — TCP
|
|
508
529
|
and IPv4 option parsing, ICMP message bodies, NDP, IPv6
|
|
509
530
|
extension-header TLV options, the GRE checksum arm, a DNS-over-TCP
|
|
510
531
|
corpus fixture and `ipaddress` accessors — shipped in 1.3.0; see
|
|
@@ -516,8 +537,9 @@ Development uses [uv](https://docs.astral.sh/uv/): `uv sync`, then
|
|
|
516
537
|
`uv run pytest`, `uv run mypy`, and `uv run ruff check` — all three are
|
|
517
538
|
enforced by CI; the test suite runs across Python 3.12–3.14, while
|
|
518
539
|
`mypy` and `ruff check` run on 3.12. The test suite is anchored by a
|
|
519
|
-
|
|
520
|
-
([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md)
|
|
540
|
+
140-frame corpus of real captured traffic across 22 scenarios
|
|
541
|
+
([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md), which says
|
|
542
|
+
which layers a script built rather than the kernel) plus
|
|
521
543
|
property-based fuzzing of the decode path. See
|
|
522
544
|
[CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow and
|
|
523
545
|
[ARCHITECTURE.md](ARCHITECTURE.md) for the design and the
|