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.
Files changed (199) hide show
  1. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/mutation.yml +13 -0
  2. {netprotocols-2.3.0 → netprotocols-2.4.0}/ARCHITECTURE.md +198 -38
  3. {netprotocols-2.3.0 → netprotocols-2.4.0}/CHANGELOG.md +24 -0
  4. {netprotocols-2.3.0 → netprotocols-2.4.0}/CONTRIBUTING.md +11 -6
  5. {netprotocols-2.3.0 → netprotocols-2.4.0}/PKG-INFO +55 -33
  6. {netprotocols-2.3.0 → netprotocols-2.4.0}/README.md +54 -32
  7. {netprotocols-2.3.0 → netprotocols-2.4.0}/docs/CLAIMS.md +125 -20
  8. {netprotocols-2.3.0 → netprotocols-2.4.0}/pyproject.toml +2 -2
  9. netprotocols-2.4.0/scripts/capture_fixtures_ipip.sh +161 -0
  10. netprotocols-2.4.0/scripts/capture_fixtures_ipsec.sh +124 -0
  11. netprotocols-2.4.0/scripts/capture_fixtures_sctp.sh +206 -0
  12. netprotocols-2.4.0/scripts/capture_fixtures_vxlan.sh +224 -0
  13. netprotocols-2.4.0/scripts/check_fixtures.py +753 -0
  14. netprotocols-2.4.0/scripts/ipsec_fixture_packets.py +418 -0
  15. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/__init__.py +13 -2
  16. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_base.py +21 -3
  17. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_defaults.py +37 -0
  18. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_enums.py +10 -0
  19. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/checksum.py +83 -17
  20. netprotocols-2.4.0/src/netprotocols/flow.py +135 -0
  21. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/__init__.py +5 -0
  22. netprotocols-2.4.0/src/netprotocols/layer3/ah.py +245 -0
  23. netprotocols-2.4.0/src/netprotocols/layer3/esp.py +116 -0
  24. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/ip.py +10 -6
  25. netprotocols-2.4.0/src/netprotocols/layer4/__init__.py +5 -0
  26. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/_ports.py +4 -3
  27. netprotocols-2.4.0/src/netprotocols/layer4/sctp.py +378 -0
  28. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/__init__.py +2 -0
  29. netprotocols-2.4.0/src/netprotocols/layer7/vxlan.py +190 -0
  30. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/packet.py +29 -19
  31. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/registry.py +15 -7
  32. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/walk.py +9 -4
  33. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/conftest.py +38 -0
  34. netprotocols-2.4.0/tests/fixtures/MANIFEST.md +260 -0
  35. netprotocols-2.4.0/tests/fixtures/ah.pcap +0 -0
  36. netprotocols-2.4.0/tests/fixtures/esp.pcap +0 -0
  37. netprotocols-2.4.0/tests/fixtures/ipip.pcap +0 -0
  38. netprotocols-2.4.0/tests/fixtures/sctp.pcap +0 -0
  39. netprotocols-2.4.0/tests/fixtures/staging/ah.pcap +0 -0
  40. netprotocols-2.4.0/tests/fixtures/staging/esp.pcap +0 -0
  41. netprotocols-2.4.0/tests/fixtures/staging/ipip.pcap +0 -0
  42. netprotocols-2.4.0/tests/fixtures/staging/sctp.pcap +0 -0
  43. netprotocols-2.4.0/tests/fixtures/staging/vxlan.pcap +0 -0
  44. netprotocols-2.4.0/tests/fixtures/vxlan.pcap +0 -0
  45. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/strategies.py +87 -0
  46. netprotocols-2.4.0/tests/test_ah.py +545 -0
  47. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_checksum.py +225 -47
  48. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_contract.py +39 -0
  49. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_corpus.py +17 -5
  50. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_diagnostics.py +12 -0
  51. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_docs.py +33 -4
  52. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_enums.py +4 -0
  53. netprotocols-2.4.0/tests/test_esp.py +307 -0
  54. netprotocols-2.4.0/tests/test_flow.py +356 -0
  55. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_fuzz.py +85 -3
  56. netprotocols-2.4.0/tests/test_ipip.py +250 -0
  57. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ipv6_ext.py +12 -3
  58. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_registry.py +43 -13
  59. netprotocols-2.4.0/tests/test_sctp.py +573 -0
  60. netprotocols-2.4.0/tests/test_vxlan.py +562 -0
  61. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_workflows.py +27 -0
  62. {netprotocols-2.3.0 → netprotocols-2.4.0}/uv.lock +1 -1
  63. netprotocols-2.3.0/scripts/check_fixtures.py +0 -332
  64. netprotocols-2.3.0/src/netprotocols/flow.py +0 -96
  65. netprotocols-2.3.0/src/netprotocols/layer4/__init__.py +0 -4
  66. netprotocols-2.3.0/tests/fixtures/MANIFEST.md +0 -128
  67. netprotocols-2.3.0/tests/test_flow.py +0 -174
  68. {netprotocols-2.3.0 → netprotocols-2.4.0}/.claude/skills/steward/SKILL.md +0 -0
  69. {netprotocols-2.3.0 → netprotocols-2.4.0}/.gitattributes +0 -0
  70. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/CODEOWNERS +0 -0
  71. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  72. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  73. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
  74. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/dependabot.yml +0 -0
  75. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/pull_request_template.md +0 -0
  76. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/ci.yml +0 -0
  77. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/codeql.yml +0 -0
  78. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/docs-lint.yml +0 -0
  79. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/fuzz.yml +0 -0
  80. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/pr-title-lint.yml +0 -0
  81. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/release.yml +0 -0
  82. {netprotocols-2.3.0 → netprotocols-2.4.0}/.github/workflows/scorecard.yml +0 -0
  83. {netprotocols-2.3.0 → netprotocols-2.4.0}/.gitignore +0 -0
  84. {netprotocols-2.3.0 → netprotocols-2.4.0}/LICENSE +0 -0
  85. {netprotocols-2.3.0 → netprotocols-2.4.0}/SECURITY.md +0 -0
  86. {netprotocols-2.3.0 → netprotocols-2.4.0}/benchmarks/baseline.json +0 -0
  87. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/_pcap.py +0 -0
  88. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark.py +0 -0
  89. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark_encode.py +0 -0
  90. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/benchmark_import.py +0 -0
  91. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures.sh +0 -0
  92. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_dhcp.sh +0 -0
  93. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
  94. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_gre.sh +0 -0
  95. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_http.sh +0 -0
  96. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_igmp.sh +0 -0
  97. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_supplement.sh +0 -0
  98. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/capture_fixtures_vlan.sh +0 -0
  99. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/check_mutmut_survivors.py +0 -0
  100. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/extract_http_streams.py +0 -0
  101. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/mutmut_survivors.txt +0 -0
  102. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/check_in_pyodide.py +0 -0
  103. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/package-lock.json +0 -0
  104. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/package.json +0 -0
  105. {netprotocols-2.3.0 → netprotocols-2.4.0}/scripts/pyodide/run_in_pyodide.mjs +0 -0
  106. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/_tlv.py +0 -0
  107. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/__init__.py +0 -0
  108. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/arp.py +0 -0
  109. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/ethernet.py +0 -0
  110. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer2/vlan.py +0 -0
  111. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/gre.py +0 -0
  112. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/icmp.py +0 -0
  113. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/igmp.py +0 -0
  114. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer3/ipv6_ext.py +0 -0
  115. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/tcp.py +0 -0
  116. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer4/udp.py +0 -0
  117. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/dhcp.py +0 -0
  118. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/dns.py +0 -0
  119. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/layer7/http.py +0 -0
  120. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/pcap.py +0 -0
  121. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/py.typed +0 -0
  122. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/__init__.py +0 -0
  123. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/exceptions.py +0 -0
  124. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/ipv4.py +0 -0
  125. {netprotocols-2.3.0 → netprotocols-2.4.0}/src/netprotocols/utils/mac.py +0 -0
  126. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/arp_exchange.pcap +0 -0
  127. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/dhcp.pcap +0 -0
  128. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/dns_tcp.pcap +0 -0
  129. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/gre.pcap +0 -0
  130. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/01.request +0 -0
  131. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/01.response +0 -0
  132. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/02.request +0 -0
  133. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/02.response +0 -0
  134. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/03.request +0 -0
  135. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/03.response +0 -0
  136. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/04.request +0 -0
  137. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/04.response +0 -0
  138. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/05.request +0 -0
  139. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/05.response +0 -0
  140. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/06.request +0 -0
  141. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/06.response +0 -0
  142. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/07.request +0 -0
  143. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/07.response +0 -0
  144. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/http/http.pcap +0 -0
  145. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  146. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_external.pcap +0 -0
  147. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  148. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  149. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/igmp.pcap +0 -0
  150. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
  151. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
  152. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_mld.pcap +0 -0
  153. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  154. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
  155. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/dhcp.pcap +0 -0
  156. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/dns_tcp.pcap +0 -0
  157. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/gre.pcap +0 -0
  158. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/http.pcap +0 -0
  159. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  160. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  161. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  162. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  163. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/igmp.pcap +0 -0
  164. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  165. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  166. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  167. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  168. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
  169. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
  170. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
  171. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
  172. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/tcp_http.pcap +0 -0
  173. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/tcp_https.pcap +0 -0
  174. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/udp_dns.pcap +0 -0
  175. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fixtures/vlan_icmp.pcap +0 -0
  176. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fuzz/fuzz_decode_frame.py +0 -0
  177. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/fuzz/fuzz_http_decode.py +0 -0
  178. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_arp.py +0 -0
  179. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_dhcp.py +0 -0
  180. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_dns.py +0 -0
  181. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ethernet.py +0 -0
  182. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_exports.py +0 -0
  183. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_extract_http_streams.py +0 -0
  184. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_gre.py +0 -0
  185. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_http.py +0 -0
  186. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_icmp.py +0 -0
  187. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_igmp.py +0 -0
  188. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_ip.py +0 -0
  189. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_mutmut_gate.py +0 -0
  190. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_packet.py +0 -0
  191. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_pattern_matching.py +0 -0
  192. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_pcap.py +0 -0
  193. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_tcp.py +0 -0
  194. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_tlv.py +0 -0
  195. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_udp.py +0 -0
  196. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_utils.py +0 -0
  197. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_version.py +0 -0
  198. {netprotocols-2.3.0 → netprotocols-2.4.0}/tests/test_vlan.py +0 -0
  199. {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
- **Text framing is the one variation.** `HTTP` has no fixed portion to
155
- unpack: `decode()` scans for the empty line that ends the head, then
156
- for the end the head's framing gives the body, so rule 1 is a scan
157
- rather than an unpack and `header_len` covers the whole message, not a
158
- `_struct` size. Its `decode()` also takes keyword-only context the
159
- bytes cannot carry — `request_method` (a response to `HEAD`, or a 2xx
160
- to `CONNECT`, has no body whatever its headers say) and `closed` (the
161
- buffer ends where the connection did) — and `request_method` is stored,
162
- but normalized: kept only when it changes the framing, so two messages
163
- with equal bytes and equal framing compare equal.
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, or a protocol
223
- this library doesn't implement yet).
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 — no
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
- registering in `ip.proto.v6` reaches the v6 chain alone. Inheritance
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, VXLAN, a proprietary
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 never
359
- import each other's classes at all.
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
- Because the guess can be wrong, the application class validates
376
- strictly on decode: a non-DNS datagram that happens to use port 53
377
- raises a `ProtocolError`, which the ordinary decode-error path absorbs
378
- (the chain keeps the layers it did decode and records the failure). So
379
- a mis-dispatch degrades to a diagnosed frame, never to garbage. This
380
- is why the port tables can be opened to third parties as safely as the
381
- authoritative ones: a wrong registration costs a diagnosed frame, not
382
- a wrong answer.
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), validate them in
400
- `__post_init__` (address formats; and IHL/data-offset must agree with
401
- `len(options)` — you cannot construct a self-contradictory header),
402
- and `bytes()` re-emits the exact on-wire form.
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: internet_checksum, compute, verify
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 dispatch)
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, compute()/verify()
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
- layer4/ TCP, UDP (+ port-based dispatch)
47
- layer7/ DNS, DHCP, HTTP
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. The authoritative description lives in
60
- the module docstring of
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.0
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.7.0` on a 97-frame corpus of
80
- real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured for
81
- 2.3.0 on 2026-10-23 — see [docs/CLAIMS.md](docs/CLAIMS.md) for every
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.6× faster than scapy** —
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; the gap moves both
87
- ways as this library and dpkt each change, and this file's job is to
88
- say so honestly rather than only when it's flattering.
89
- - **Decodes further into the stack than dpkt on 27 of 97 corpus
90
- frames** — DNS and DHCP payloads that dpkt leaves as raw bytes,
91
- netprotocols continues decoding. A throughput number means little
92
- without knowing how much work it bought.
93
- - **4.5× faster than dpkt at re-encoding** (`bytes(header)`,
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 57 ms; `scapy.all` takes 487+ ms** (`scripts/benchmark_import.py`),
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 106.1 KiB wheel against scapy's 2.47 MiB** — 23.9× smaller.
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.7.0 — a field name that does not exist
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/UDP conversation — both
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`, and
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 post-1.3.0 roadmap,
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.3.0 are all on PyPI, and the comparative claims above
505
- draw on the finished tree. New planned work
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
- 97-frame corpus of real captured traffic across 17 scenarios
520
- ([tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md)) plus
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