netprotocols 1.3.0__tar.gz → 2.0.0__tar.gz

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