netprotocols 1.1.0__tar.gz → 1.3.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 (112) hide show
  1. netprotocols-1.3.0/.github/ISSUE_TEMPLATE/bug_report.yml +60 -0
  2. netprotocols-1.3.0/.github/ISSUE_TEMPLATE/config.yml +10 -0
  3. netprotocols-1.3.0/.github/ISSUE_TEMPLATE/protocol_request.yml +66 -0
  4. netprotocols-1.3.0/.github/pull_request_template.md +40 -0
  5. {netprotocols-1.1.0 → netprotocols-1.3.0}/ARCHITECTURE.md +69 -11
  6. netprotocols-1.3.0/CHANGELOG.md +296 -0
  7. netprotocols-1.3.0/CONTRIBUTING.md +143 -0
  8. {netprotocols-1.1.0 → netprotocols-1.3.0}/PKG-INFO +35 -6
  9. {netprotocols-1.1.0 → netprotocols-1.3.0}/README.md +34 -5
  10. {netprotocols-1.1.0 → netprotocols-1.3.0}/pyproject.toml +1 -1
  11. netprotocols-1.3.0/scripts/capture_fixtures_dhcp.sh +94 -0
  12. netprotocols-1.3.0/scripts/capture_fixtures_dns_tcp.sh +112 -0
  13. netprotocols-1.3.0/scripts/capture_fixtures_gre.sh +112 -0
  14. netprotocols-1.3.0/scripts/capture_fixtures_igmp.sh +66 -0
  15. netprotocols-1.3.0/scripts/capture_fixtures_vlan.sh +217 -0
  16. {netprotocols-1.1.0 → netprotocols-1.3.0}/scripts/check_fixtures.py +52 -7
  17. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/__init__.py +22 -4
  18. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/_enums.py +8 -0
  19. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/checksum.py +27 -2
  20. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer2/__init__.py +2 -1
  21. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer2/arp.py +15 -0
  22. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer2/ethernet.py +37 -13
  23. netprotocols-1.3.0/src/netprotocols/layer2/vlan.py +89 -0
  24. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer3/__init__.py +2 -0
  25. netprotocols-1.3.0/src/netprotocols/layer3/gre.py +157 -0
  26. netprotocols-1.3.0/src/netprotocols/layer3/icmp.py +328 -0
  27. netprotocols-1.3.0/src/netprotocols/layer3/igmp.py +269 -0
  28. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer3/ip.py +126 -3
  29. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer3/ipv6_ext.py +91 -2
  30. netprotocols-1.3.0/src/netprotocols/layer4/_ports.py +51 -0
  31. netprotocols-1.3.0/src/netprotocols/layer4/tcp.py +293 -0
  32. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer4/udp.py +9 -0
  33. netprotocols-1.3.0/src/netprotocols/layer7/__init__.py +3 -0
  34. netprotocols-1.3.0/src/netprotocols/layer7/dhcp.py +286 -0
  35. netprotocols-1.3.0/src/netprotocols/layer7/dns.py +436 -0
  36. netprotocols-1.3.0/tests/fixtures/MANIFEST.md +96 -0
  37. netprotocols-1.3.0/tests/fixtures/dhcp.pcap +0 -0
  38. netprotocols-1.3.0/tests/fixtures/dns_tcp.pcap +0 -0
  39. netprotocols-1.3.0/tests/fixtures/gre.pcap +0 -0
  40. netprotocols-1.3.0/tests/fixtures/igmp.pcap +0 -0
  41. netprotocols-1.3.0/tests/fixtures/staging/dhcp.pcap +0 -0
  42. netprotocols-1.3.0/tests/fixtures/staging/dns_tcp.pcap +0 -0
  43. netprotocols-1.3.0/tests/fixtures/staging/gre.pcap +0 -0
  44. netprotocols-1.3.0/tests/fixtures/staging/igmp.pcap +0 -0
  45. netprotocols-1.3.0/tests/fixtures/staging/vlan_icmp.pcap +0 -0
  46. netprotocols-1.3.0/tests/fixtures/vlan_icmp.pcap +0 -0
  47. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_arp.py +16 -0
  48. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_checksum.py +106 -23
  49. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_contract.py +6 -2
  50. netprotocols-1.3.0/tests/test_corpus.py +311 -0
  51. netprotocols-1.3.0/tests/test_dhcp.py +260 -0
  52. netprotocols-1.3.0/tests/test_dns.py +489 -0
  53. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_fuzz.py +80 -0
  54. netprotocols-1.3.0/tests/test_gre.py +245 -0
  55. netprotocols-1.3.0/tests/test_icmp.py +217 -0
  56. netprotocols-1.3.0/tests/test_igmp.py +329 -0
  57. netprotocols-1.3.0/tests/test_ip.py +221 -0
  58. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_ipv6_ext.py +93 -0
  59. netprotocols-1.3.0/tests/test_tcp.py +204 -0
  60. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_udp.py +9 -2
  61. netprotocols-1.3.0/tests/test_vlan.py +107 -0
  62. {netprotocols-1.1.0 → netprotocols-1.3.0}/uv.lock +1 -1
  63. netprotocols-1.1.0/CHANGELOG.md +0 -99
  64. netprotocols-1.1.0/src/netprotocols/layer3/icmp.py +0 -135
  65. netprotocols-1.1.0/src/netprotocols/layer4/tcp.py +0 -153
  66. netprotocols-1.1.0/tests/fixtures/MANIFEST.md +0 -50
  67. netprotocols-1.1.0/tests/test_corpus.py +0 -167
  68. netprotocols-1.1.0/tests/test_icmp.py +0 -55
  69. netprotocols-1.1.0/tests/test_ip.py +0 -92
  70. netprotocols-1.1.0/tests/test_tcp.py +0 -80
  71. {netprotocols-1.1.0 → netprotocols-1.3.0}/.github/workflows/ci.yml +0 -0
  72. {netprotocols-1.1.0 → netprotocols-1.3.0}/.github/workflows/release.yml +0 -0
  73. {netprotocols-1.1.0 → netprotocols-1.3.0}/.gitignore +0 -0
  74. {netprotocols-1.1.0 → netprotocols-1.3.0}/LICENSE +0 -0
  75. {netprotocols-1.1.0 → netprotocols-1.3.0}/scripts/capture_fixtures.sh +0 -0
  76. {netprotocols-1.1.0 → netprotocols-1.3.0}/scripts/capture_fixtures_supplement.sh +0 -0
  77. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/_base.py +0 -0
  78. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/layer4/__init__.py +0 -0
  79. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/packet.py +0 -0
  80. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/py.typed +0 -0
  81. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/utils/__init__.py +0 -0
  82. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/utils/exceptions.py +0 -0
  83. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/utils/ipv4.py +0 -0
  84. {netprotocols-1.1.0 → netprotocols-1.3.0}/src/netprotocols/utils/mac.py +0 -0
  85. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/conftest.py +0 -0
  86. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/arp_exchange.pcap +0 -0
  87. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  88. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/icmpv4_external.pcap +0 -0
  89. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  90. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  91. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
  92. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
  93. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/ipv6_mld.pcap +0 -0
  94. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  95. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
  96. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  97. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  98. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  99. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  100. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  101. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  102. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  103. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  104. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
  105. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
  106. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
  107. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/tcp_http.pcap +0 -0
  108. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/tcp_https.pcap +0 -0
  109. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/fixtures/udp_dns.pcap +0 -0
  110. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_ethernet.py +0 -0
  111. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_packet.py +0 -0
  112. {netprotocols-1.1.0 → netprotocols-1.3.0}/tests/test_utils.py +0 -0
@@ -0,0 +1,60 @@
1
+ name: Bug report
2
+ description: A protocol decodes, encodes, or chains incorrectly, or raises the wrong error.
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for the report. netprotocols decodes real traffic, so the most
9
+ useful reports come with the actual bytes that trigger the problem.
10
+ Please search [existing issues](https://github.com/EONRaider/NETProtocols/issues)
11
+ first.
12
+ - type: input
13
+ id: version
14
+ attributes:
15
+ label: Version
16
+ description: >-
17
+ netprotocols version
18
+ (`python -c "import netprotocols; print(netprotocols.__version__)"`)
19
+ and your Python version.
20
+ placeholder: "netprotocols 1.2.0, Python 3.12.3"
21
+ validations:
22
+ required: true
23
+ - type: textarea
24
+ id: what-happened
25
+ attributes:
26
+ label: What happened
27
+ description: What did you decode or build, and what went wrong?
28
+ placeholder: "IPv4.decode(...) returned X, but the header on the wire says Y."
29
+ validations:
30
+ required: true
31
+ - type: textarea
32
+ id: repro
33
+ attributes:
34
+ label: Reproduction
35
+ description: >-
36
+ A minimal snippet. Include the raw bytes as a hex string so it runs
37
+ standalone — that is by far the fastest path to a fix. Rendered as
38
+ Python; no backticks needed.
39
+ render: python
40
+ placeholder: |
41
+ from netprotocols import Ethernet
42
+
43
+ frame = bytes.fromhex("ffffffffffff0007...")
44
+ print(Ethernet.decode(frame))
45
+ validations:
46
+ required: true
47
+ - type: textarea
48
+ id: expected
49
+ attributes:
50
+ label: Expected behavior
51
+ description: What should have happened instead?
52
+ validations:
53
+ required: true
54
+ - type: checkboxes
55
+ id: checks
56
+ attributes:
57
+ label: Checklist
58
+ options:
59
+ - label: I included the raw bytes (or a pcap) needed to reproduce this.
60
+ - label: I searched existing issues for a duplicate.
@@ -0,0 +1,10 @@
1
+ # Freeform issues stay enabled (the roadmap and design discussions don't
2
+ # fit a form); the templates just make the common cases structured.
3
+ blank_issues_enabled: true
4
+ contact_links:
5
+ - name: Contributing guide
6
+ url: https://github.com/EONRaider/NETProtocols/blob/master/CONTRIBUTING.md
7
+ about: Dev setup, the QA ladder, and how to add a protocol.
8
+ - name: Protocol roadmap
9
+ url: https://github.com/EONRaider/NETProtocols/issues/22
10
+ about: Planned protocols and their ordering — check before requesting one.
@@ -0,0 +1,66 @@
1
+ name: Protocol request
2
+ description: Request support for a protocol netprotocols does not decode yet.
3
+ labels: ["enhancement", "roadmap"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ The roadmap ([#22](https://github.com/EONRaider/NETProtocols/issues/22))
9
+ tracks planned protocols and their ordering — check it and existing
10
+ issues first. Adding a protocol follows the cookbook in
11
+ [ARCHITECTURE.md](https://github.com/EONRaider/NETProtocols/blob/master/ARCHITECTURE.md);
12
+ contributions are welcome.
13
+ - type: input
14
+ id: protocol
15
+ attributes:
16
+ label: Protocol
17
+ placeholder: "e.g. DHCP, GRE, 802.1X"
18
+ validations:
19
+ required: true
20
+ - type: input
21
+ id: spec
22
+ attributes:
23
+ label: Specification
24
+ description: The RFC or standard number(s) that define the header.
25
+ placeholder: "RFC 2131"
26
+ validations:
27
+ required: true
28
+ - type: dropdown
29
+ id: layer
30
+ attributes:
31
+ label: Layer
32
+ description: Roughly where it sits in the stack.
33
+ options:
34
+ - Link (2)
35
+ - Network (3)
36
+ - Transport (4)
37
+ - Application (7)
38
+ - Other / not sure
39
+ validations:
40
+ required: true
41
+ - type: textarea
42
+ id: dispatch
43
+ attributes:
44
+ label: How it is reached
45
+ description: >-
46
+ What carries it — which lower layer, and which dispatch value
47
+ (EtherType, IP protocol number, or port)?
48
+ placeholder: "Behind UDP on ports 67/68."
49
+ validations:
50
+ required: false
51
+ - type: textarea
52
+ id: sample
53
+ attributes:
54
+ label: Sample traffic
55
+ description: >-
56
+ A pcap or a hex dump of a real frame makes this far easier to
57
+ implement and test — the fixture corpus is captured from real traffic.
58
+ validations:
59
+ required: false
60
+ - type: checkboxes
61
+ id: checks
62
+ attributes:
63
+ label: Checklist
64
+ options:
65
+ - label: I checked the roadmap (#22) and existing issues.
66
+ - label: I can provide a real capture (pcap or hex) of this protocol.
@@ -0,0 +1,40 @@
1
+ <!--
2
+ Thanks for contributing to netprotocols! Fill in the sections below and
3
+ delete this comment. CONTRIBUTING.md has the full workflow; the short
4
+ version is: keep the QA ladder green and update the docs your change
5
+ touches. Relative links below resolve from the repo root once this
6
+ becomes the PR description.
7
+ -->
8
+
9
+ ## Summary
10
+
11
+ <!-- What does this change do, and why? Link any related issue, e.g. "Closes #22". -->
12
+
13
+ ## What's included
14
+
15
+ <!-- The concrete changes, one bullet each: new/edited modules, tests, docs. -->
16
+
17
+ -
18
+
19
+ ## Verification
20
+
21
+ <!-- Paste the QA-ladder output, or tick the boxes. All four gates run in CI. -->
22
+
23
+ - [ ] `uv run ruff check` and `uv run ruff format --check` are clean
24
+ - [ ] `uv run mypy` is clean (strict)
25
+ - [ ] `uv run pytest` passes locally (CI runs it on Python 3.12 / 3.13 / 3.14)
26
+ - [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`
27
+
28
+ ### New protocol or dispatch change — also:
29
+
30
+ <!-- Delete this block if it doesn't apply. See ARCHITECTURE.md's cookbook. -->
31
+
32
+ - [ ] Followed the add-a-protocol cookbook in [ARCHITECTURE.md](ARCHITECTURE.md) and the decode contract in `src/netprotocols/_base.py`
33
+ - [ ] New `EtherType` / `IPProtocol` numbers carry display names in lockstep (a completeness test enforces it)
34
+ - [ ] Registered the class in the fuzz suite (`tests/test_fuzz.py::ALL_PROTOCOLS`)
35
+ - [ ] Added a fixture and updated [tests/fixtures/MANIFEST.md](tests/fixtures/MANIFEST.md) — a real capture where possible (see CONTRIBUTING.md for the capture scripts)
36
+ - [ ] Updated the README coverage table and the ARCHITECTURE.md chain diagram
37
+
38
+ ## Notes
39
+
40
+ <!-- Anything reviewers should know: trade-offs, follow-ups, companion PRs. Optional. -->
@@ -109,6 +109,11 @@ flowchart LR
109
109
  E[Ethernet] -->|0x0806| A[ARP]
110
110
  E -->|0x0800| I4[IPv4]
111
111
  E -->|0x86DD| I6[IPv6]
112
+ E -->|0x8100, 0x88A8, 0x9100| V[VLAN tag]
113
+ V -->|0x8100, 0x88A8, 0x9100| V
114
+ V -->|0x0806| A
115
+ V -->|0x0800| I4
116
+ V -->|0x86DD| I6
112
117
  I4 -->|1| IC4[ICMPv4]
113
118
  I4 -->|6| T[TCP]
114
119
  I4 -->|17| U[UDP]
@@ -132,6 +137,16 @@ non-first fragment carries a slice from the middle of the original
132
137
  payload, so no upper-layer header exists at its start. The same
133
138
  offset rule applies to fragmented IPv4.
134
139
 
140
+ VLAN tags chain the same way: a tag's `next_protocol` dispatches the
141
+ inner EtherType through the same registry Ethernet uses, so a QinQ
142
+ (`0x88A8`) or legacy double-tagged (`0x9100`) frame decodes as one
143
+ `VLAN` layer per tag and the innermost payload (IPv4/IPv6/ARP) chains
144
+ normally. The Tag Control Information word is split into its semantic
145
+ bitfields — `pcp`, `dei`, `vid` — as dataclass fields validated in
146
+ `__post_init__`, with the packed 16-bit view kept as the `tci`
147
+ property (the same pattern `IPv6` uses for `version` /
148
+ `traffic_class` / `flow_label`).
149
+
135
150
  The numbers on the arrows are the wire values — EtherTypes out of
136
151
  Ethernet, IP protocol numbers out of IPv4/IPv6 — and they live in
137
152
  [`_enums.py`](src/netprotocols/_enums.py) as `EtherType` and
@@ -158,6 +173,26 @@ the graph acyclic: shared registries live in the dependency-free
158
173
  `_enums.py`, and `next_protocol()` mappings import their target
159
174
  classes *inside the function body*, at call time.
160
175
 
176
+ ## Port-based dispatch is best-effort
177
+
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.
187
+
188
+ Because the guess can be wrong, the application class validates
189
+ strictly on decode: a non-DNS datagram that happens to use port 53
190
+ raises a `ProtocolError`, which the ordinary decode-error path absorbs
191
+ (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.
195
+
161
196
  ## The encode side, and the round-trip guarantee
162
197
 
163
198
  Every class also works in reverse. Constructors take the friendly
@@ -174,10 +209,14 @@ Protocol.decode(bytes(header)) == header # object → wire → object
174
209
  bytes(Protocol.decode(raw)) == raw[:header_len] # wire → object → wire
175
210
  ```
176
211
 
177
- One deliberate exception: **checksums are carried verbatim**. The
178
- library neither computes nor verifies them (yet — it's on the
179
- roadmap), which is exactly what you want when faithfully re-encoding
180
- captured traffic.
212
+ One deliberate boundary: **decode and encode carry checksums
213
+ verbatim** — exactly what you want when faithfully re-encoding
214
+ captured traffic. Computing and verifying them is explicit, via
215
+ `netprotocols.checksum`: `compute()`/`verify()` handle the IPv4 header
216
+ checksum, ICMPv4, and TCP/UDP/ICMPv6 with their IPv4/IPv6
217
+ pseudo-headers, and `Packet.with_checksums(payload)` fills a whole
218
+ stack before sending. (The RFC 768 subtlety lives where it belongs:
219
+ the `0x0000 → 0xFFFF` substitution applies to UDP alone.)
181
220
 
182
221
  `Packet` is the thin composition layer: it holds an ordered tuple of
183
222
  headers and `bytes(packet)` joins them, ready for a raw socket.
@@ -189,13 +228,24 @@ src/netprotocols/
189
228
  ├── __init__.py public API re-exports, __version__
190
229
  ├── _base.py Protocol ABC, decode contract, address helpers
191
230
  ├── _enums.py EtherType, IPProtocol, ARPOperation (imports nothing)
192
- ├── packet.py Packet composition
231
+ ├── packet.py Packet composition, with_checksums()
232
+ ├── checksum.py RFC 1071: internet_checksum, compute, verify
193
233
  ├── layer2/ ethernet.py, arp.py
194
- ├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6)
195
- ├── layer4/ tcp.py, udp.py
234
+ ├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
235
+ │ igmp.py,
236
+ │ ipv6_ext.py (Hop-by-Hop, Routing, Fragment,
237
+ │ Destination Options)
238
+ ├── layer4/ tcp.py, udp.py, _ports.py (well-known-port registry)
239
+ ├── layer7/ dns.py
196
240
  └── utils/ validators (mac.py, ipv4.py), exceptions.py
197
241
  tests/ one file per protocol + test_contract.py
198
- (truncation, lying lengths, chain walks)
242
+ (truncation, lying lengths, chain walks),
243
+ 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
246
+ (corpus checksums recompute to wire values), and
247
+ test_fuzz.py (hypothesis properties: decode never
248
+ raises outside ProtocolError)
199
249
  ```
200
250
 
201
251
  Tooling: [uv](https://docs.astral.sh/uv/) manages the environment
@@ -217,12 +267,20 @@ the same:
217
267
  (raise `TruncatedHeaderError` when the buffer can't satisfy it) and
218
268
  materialize the variable part as `bytes`.
219
269
  3. **Implement `__bytes__()`.** Pack the same fields back; the
220
- round-trip tests will hold you honest.
270
+ round-trip tests will hold you honest. When a payload is
271
+ self-referential — DNS names compress by pointing at earlier bytes,
272
+ so re-encoding a parsed record cannot reproduce the original — keep
273
+ that region as raw `bytes` and parse it through read-only accessors
274
+ that never re-encode. `bytes(decode(x)) == x` then holds by
275
+ construction (see `layer7/dns.py`).
221
276
  4. **Wire the chain.** The layer below decides when your class is
222
277
  next. For DNS that means UDP would implement `next_protocol()`
223
278
  consulting the ports. (For a new EtherType or IP protocol number,
224
- add the value to `_enums.py` and one mapping entry in
225
- `ethernet.py`/`ip.py`, using a deferred import.)
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`.)
226
284
  5. **Add display properties** for anything a human would want rendered
227
285
  (`flags_str`-style names, hex strings). Degrade gracefully on
228
286
  unknown values — return `"unknown (47)"`, never raise from a
@@ -0,0 +1,296 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.3.0] - 2026-09-01
9
+
10
+ ### Added
11
+ - **IEEE 802.1Q VLAN tags** (`VLAN`, 802.1Q-2018 §9.6 / 802.1ad QinQ):
12
+ single and stacked (QinQ `0x88A8`, legacy double-tagged `0x9100`)
13
+ tags decode as one layer per tag; the Tag Control Information word is
14
+ split into `pcp`/`dei`/`vid` dataclass fields validated in
15
+ `__post_init__` (`InvalidFieldError`), with the packed 16-bit view
16
+ kept as the `tci` property; `VLAN` is registered in the
17
+ property-based fuzz suite and the `EtherType` display names are
18
+ covered by the enum completeness test.
19
+ - **IGMPv3 group records** (`IGMPv3GroupRecord`, RFC 3376 §4.2): a v3
20
+ Membership Report (type `0x22`) now parses its group-record array on
21
+ demand. `IGMP.group_records` yields one `IGMPv3GroupRecord` per record
22
+ (record type + display name, multicast group, source-address list,
23
+ raw auxiliary data) and `IGMP.num_group_records` reads the count;
24
+ other message types return `None`. Parsing reads the raw body and
25
+ never re-encodes, so the byte-exact round-trip is preserved, and a
26
+ lying record/source count or a truncated record raises
27
+ `InvalidFieldError` (bounded — never hangs or over-reads).
28
+ - **IGMPv3 query fields** (RFC 3376 §4.1): a v3 Membership Query (type
29
+ `0x11`) now exposes its fields past the group address — the `s_flag`
30
+ (suppress router-side processing), `qrv`, `qqic`, and
31
+ `query_source_addresses` accessors parse the raw body on demand. A v2
32
+ (8-byte) query and non-query types return `None`, and a source count
33
+ that runs past the message raises `InvalidFieldError`.
34
+ - **DHCP** (`netprotocols.DHCP`, RFC 2131/2132) — the fixed BOOTP header
35
+ (op, xid, the client/your/server/gateway addresses, the 16-byte client
36
+ hardware address, and the server-name / boot-file fields) decodes in
37
+ full; the magic cookie and TLV options are kept raw and parsed on
38
+ demand. `option_map` walks the options (concatenating a value split
39
+ across appearances, RFC 3396) and `message_type` / `message_type_name`
40
+ read the DHCP message type (option 53). Dispatched from UDP by
41
+ well-known port (67/68); terminal, with a byte-exact round-trip and
42
+ the same bounded-parse contract as DNS (`InvalidFieldError` on a
43
+ missing cookie or an option that overruns the buffer).
44
+ - **GRE** (`netprotocols.GRE`, RFC 2784/2890) — Generic Routing
45
+ Encapsulation, dispatched from IPv4/IPv6 protocol 47. The four-byte
46
+ header (flags/version + protocol type) decodes with the optional
47
+ checksum, key, and sequence-number fields that the flag bits announce
48
+ kept raw and surfaced through accessors. The payload chains onward by
49
+ the `protocol_type` EtherType, so a GRE-tunnelled IPv4/IPv6 packet
50
+ keeps decoding; the round-trip stays byte-exact regardless of which
51
+ optional fields are present.
52
+ - **GRE checksum arm** (`netprotocols.checksum`, RFC 2784 §2.5):
53
+ `compute`/`verify` now cover GRE — the internet checksum over the GRE
54
+ header plus its payload with the checksum field zeroed and no
55
+ pseudo-header. `compute(gre, payload=...)` requires the
56
+ Checksum-Present bit (and its field) and raises `InvalidFieldError`
57
+ otherwise; `verify` of a header whose Checksum-Present bit is clear
58
+ returns `True` — a frame cannot fail a checksum it does not carry —
59
+ mirroring the UDP-over-IPv4 zero rule.
60
+ - **Richer address accessors** (README roadmap): read-only `_address`
61
+ properties return stdlib `ipaddress` objects alongside the canonical
62
+ `str` fields, for comparison, subnet membership, and arithmetic —
63
+ `IPv4.src_address`/`dst_address` and `ARP.spa_address`/`tpa_address`
64
+ as `ipaddress.IPv4Address`, `IPv6.src_address`/`dst_address` as
65
+ `ipaddress.IPv6Address`, and the four `DHCP` address fields as
66
+ `ciaddr_address`/`yiaddr_address`/`siaddr_address`/`giaddr_address`.
67
+ Purely additive: the `str` fields stay the round-tripping
68
+ representation. MAC addresses stay `str` — the stdlib has no EUI
69
+ type.
70
+ - **IPv4 options** (`IPv4Option`, RFC 791 §3.1): the options TLV list
71
+ now parses on demand, mirroring the TCP-option work —
72
+ `IPv4.parsed_options` yields one `IPv4Option` per option in wire
73
+ order (kind + `kind_name`, raw `data`). The common kinds are named:
74
+ End of Option List and No-Operation (single-byte; EOL ends the parse,
75
+ so padding after it is not returned), Record Route (7), Timestamp
76
+ (68), and Router Alert (148, RFC 2113); unknown kinds keep their raw
77
+ `data` with a numeric fallback name. Parsing reads the raw `options`
78
+ bytes and never re-encodes, so the byte-exact round-trip is
79
+ preserved; an option length below the 2-byte minimum or one that runs
80
+ past the options raises `InvalidFieldError` (bounded — never hangs or
81
+ over-reads).
82
+ - **DNS resource records** (`DNSResourceRecord`, RFC 1035 §4.1.3): the
83
+ answer, authority, and additional sections parse on demand —
84
+ `DNS.answers` / `DNS.authorities` / `DNS.additionals` yield records
85
+ (name, type + `rtype_name`, class, TTL, raw `rdata`, and a decoded
86
+ `rdata_text`: IPv4/IPv6 for A/AAAA, the target name for CNAME/NS/PTR,
87
+ and MX/TXT/SOA; hexadecimal otherwise). Parsing reads the raw sections
88
+ and never re-encodes, so the byte-exact round-trip holds; a record or
89
+ compressed name that runs past the message raises `InvalidFieldError`.
90
+ - **TCP options** (`TCPOption`, RFC 9293 §3.1): the options TLV list
91
+ now parses on demand — `TCP.parsed_options` yields one `TCPOption`
92
+ per option in wire order (kind + `kind_name`, raw `data`, and a
93
+ decoded `value` where this library understands the kind: the segment
94
+ size for MSS, the shift count for Window Scale, a `(tsval, tsecr)`
95
+ pair for Timestamps, and `(left_edge, right_edge)` pairs for SACK;
96
+ RFC 7323/2018). EOL and NOP are single-byte (EOL ends the parse);
97
+ unknown kinds keep their raw `data` with `value` degrading to `None`.
98
+ Parsing reads the raw `options` bytes and never re-encodes, so the
99
+ byte-exact round-trip is preserved, and an option length below the
100
+ TLV minimum or one that runs past the options raises
101
+ `InvalidFieldError` (bounded — never hangs or over-reads).
102
+ - **ICMP message bodies** (RFC 792 / RFC 4443): `ICMPv4`/`ICMPv6` gain a
103
+ raw `body` field — the message data after the 8-byte header — so a
104
+ decoded message is self-contained (like `IGMP`/`DNS`), `header_len`
105
+ consumes the whole IP payload, and the layer stays terminal with a
106
+ byte-exact round-trip. Echo requests/replies (v4 types 8/0, v6
107
+ 128/129) expose `identifier` / `sequence_number` split from `rest`,
108
+ and the error messages (v4 Destination Unreachable / Redirect / Time
109
+ Exceeded / Parameter Problem; v6 types 1-4) expose `embedded_packet` —
110
+ the invoking datagram, decodable as `IPv4`/`IPv6`. The accessors read
111
+ on demand and degrade to `None` for other message types or an empty
112
+ body, never raising. A decoded message now carries its body in
113
+ `bytes(layer)`, so `checksum.compute`/`verify` need no separate
114
+ `payload` for it (passing one for a header-only object still works).
115
+ - **IPv6 Neighbor Discovery** (`NDPOption`, RFC 4861): `ICMPv6` now
116
+ parses NDP messages on demand — `ndp_target_address` reads the
117
+ 16-byte target of a Neighbor Solicitation/Advertisement (135/136),
118
+ and `ndp_options` walks the option TLVs of Router
119
+ Solicitation/Advertisement, Neighbor Solicitation/Advertisement, and
120
+ Redirect at each message's own options offset. Each `NDPOption`
121
+ carries its `type` + `type_name` and raw `data`; a Source/Target
122
+ Link-Layer Address option (1/2) reads back as a MAC string via
123
+ `link_layer_address`, and Prefix Information (3) / MTU (5) stay raw.
124
+ Non-NDP message types return `None`. Option lengths count in 8-octet
125
+ units: a zero length (which must not loop), a length past the
126
+ message, or a body shorter than the message's fixed fields raises
127
+ - **IPv6 extension-header options** (`IPv6Option`, RFC 8200 §4.2): the
128
+ Hop-by-Hop and Destination Options headers now parse their option
129
+ TLVs on demand — `parsed_options` yields one `IPv6Option` per option
130
+ in wire order, padding included (Pad1 is a lone type byte; everything
131
+ else is type/length/data). Each option carries its `type` +
132
+ `type_name` (Pad1, PadN, Router Alert per RFC 2711, Jumbo Payload per
133
+ RFC 2675; unknown types keep their numeric value) and raw `data`, and
134
+ exposes the action-on-unrecognized bits (the two high bits of the
135
+ type) as `unrecognized_action`. Parsing reads the raw `options` bytes
136
+ and never re-encodes, so the byte-exact round-trip is preserved; a
137
+ missing length byte or option data that runs past the header raises
138
+ `InvalidFieldError` (bounded — never hangs or over-reads).
139
+ - **DNS over TCP** (`DNSOverTCP`, RFC 1035 §4.2.2): `TCP.next_protocol()`
140
+ now dispatches application protocols by well-known port
141
+ (`layer4._ports.tcp_app_class`). DNS over TCP is length-prefixed, so
142
+ port 53 chains through a 2-byte `DNSOverTCP` length shim — a layer
143
+ between `TCP` and the `DNS` message, like a VLAN tag between Ethernet
144
+ and its payload — and the walk reaches the DNS message (records and
145
+ all) at its true offset. Non-DNS ports still end the chain at TCP.
146
+
147
+ ### Development
148
+ - The real-capture fixture corpus gained `vlan_icmp.pcap` (802.1Q
149
+ single tag VID 100 + 802.1ad QinQ S-VID 200 / C-VID 30, over ARP and
150
+ ICMPv4), so the `VLAN` layer rides the corpus-wide invariants and the
151
+ transport-checksum recompute like every other protocol. It is a
152
+ direct tcpdump capture of real kernel-tagged frames — built over
153
+ 802.1Q / 802.1ad vlan devices on a veth pair with VLAN and checksum
154
+ offload disabled so the tags stay in-band in the saved bytes and the
155
+ inner checksums are genuine kernel output; see
156
+ `tests/fixtures/MANIFEST.md` and `scripts/capture_fixtures_vlan.sh`.
157
+ - The corpus gained real-capture `dhcp.pcap` (a DHCP DORA exchange from
158
+ `dnsmasq` + `dhclient`) and `gre.pcap` (IPv4-in-GRE ICMPv4 echo over a
159
+ plain and a keyed kernel tunnel), so the `DHCP` and `GRE` layers ride
160
+ the corpus-wide invariants on genuine bytes.
161
+ `scripts/capture_fixtures_dhcp.sh` and `scripts/capture_fixtures_gre.sh`
162
+ produce them, and `scripts/check_fixtures.py` now peels a GRE header to
163
+ verify the tunnelled inner IPv4/ICMP checksums.
164
+ - The corpus gained real-capture `dns_tcp.pcap` (an A and a TXT query +
165
+ response from `dig +tcp` against `dnsmasq` over a veth pair, offload
166
+ disabled), so the TCP application dispatch and the `DNSOverTCP`
167
+ length shim (#57) ride the corpus-wide invariants and the
168
+ TCP-checksum recompute on genuine bytes — every frame decodes
169
+ `Ethernet→IPv4→TCP→DNSOverTCP→DNS` with the length prefix agreeing
170
+ with the message it frames and a resolvable answer.
171
+ `scripts/capture_fixtures_dns_tcp.sh` produces it; the corpus is now
172
+ 97 frames across 17 scenarios.
173
+ - Contributor documentation: a `CONTRIBUTING.md` (dev setup, the QA
174
+ ladder, the decode contract, the add-a-protocol enforcement points,
175
+ and the fixture-capture workflow), a pull-request template, and
176
+ bug-report / protocol-request issue templates under `.github/`.
177
+ - README refresh: the Protocol coverage table gains the shipped `DHCP`
178
+ and `GRE` rows, the `DNS` row notes resource-record parsing and the
179
+ DNS-over-TCP length shim, and the `IGMP` row notes the IGMPv3 report
180
+ and query parsing. The Roadmap section now lists only the remaining
181
+ items, linking the open decoder-depth issues (#59-#66, tracked by
182
+ #67), and the stale corpus figure is corrected to 93 frames across 16
183
+ scenarios here and in `ARCHITECTURE.md`.
184
+
185
+ ## [1.2.0] - 2026-08-30
186
+
187
+ ### Added
188
+ - **IGMP** (`netprotocols.IGMP`, RFC 1112/2236/3376) — IPv4 multicast
189
+ group management, dispatched from IPv4 protocol 2. The common
190
+ four-byte header (type, max-response code, checksum) is decoded; the
191
+ `group_address` accessor reads the group for the message types that
192
+ carry one, and `netprotocols.checksum` gains an IGMP arm. v3
193
+ group-record parsing is a roadmap follow-up.
194
+ - **DNS** (`netprotocols.DNS`, RFC 1035) — the first application-layer
195
+ protocol. The 12-byte header and flag bits are decoded; the four
196
+ message sections are kept raw with on-demand, read-only accessors
197
+ (`question_name`, `question_type`, `question_class`) that decompress
198
+ names safely (bounded pointer-following; malformed or looping names
199
+ raise `InvalidFieldError`, never hang). Round-trip stays byte-exact.
200
+ - **Port-based dispatch** on UDP: `UDP.next_protocol()` reaches an
201
+ application protocol by well-known port (`netprotocols.layer4._ports`,
202
+ `{53: DNS}` for now), checked destination-then-source. Documented as
203
+ best-effort — a mis-dispatch degrades to the malformed-frame path.
204
+
205
+ ## [1.1.0] - 2026-08-29
206
+
207
+ ### Added
208
+ - **IPv6 extension headers** (RFC 8200 §4.3-4.6): `IPv6HopByHopOptions`,
209
+ `IPv6Routing`, `IPv6Fragment`, `IPv6DestinationOptions`. The decode
210
+ chain now reaches ICMPv6/TCP/UDP behind them (MLD reports decode
211
+ fully). Extension headers dispatch only inside an IPv6 chain, and a
212
+ Fragment header chains onward only from the first fragment.
213
+ - **Checksums** (`netprotocols.checksum`): `internet_checksum()`
214
+ (RFC 1071), `compute()`/`verify()` covering the IPv4 header checksum,
215
+ ICMPv4, and TCP/UDP/ICMPv6 with IPv4/IPv6 pseudo-headers, and
216
+ `Packet.with_checksums()` for filling a whole stack on encode. The
217
+ UDP-only zero substitution is confined to the UDP arm.
218
+ - **Property-based fuzzing** of the decode path (hypothesis, dev-only):
219
+ decode() raises nothing outside `ProtocolError` for any input; the
220
+ chain walk never consumes past the frame; constrained round trips.
221
+ - `IPProtocol` gains `HOPOPT`, `IPV6_ROUTE`, `IPV6_FRAG`,
222
+ `IPV6_DSTOPTS` with display names.
223
+
224
+ ### Development
225
+ - The real-capture fixture corpus grew to 65 frames across 12
226
+ scenarios (MLD behind hop-by-hop, IPv6 fragment pairs); checksum
227
+ tests recompute every verifiable corpus frame to its captured wire
228
+ value.
229
+
230
+ ## [1.0.1] - 2026-08-29
231
+
232
+ ### Fixed
233
+ - `IPv4.next_protocol()` chained non-first fragments
234
+ (`fragment_offset > 0`) into garbage upper-layer decodes — a slice
235
+ from the middle of a fragmented payload has no header at its start.
236
+ Discovered by the new real-capture fixture corpus on live fragmented
237
+ traffic; the chain now ends at the IPv4 layer for those frames, as it
238
+ already did for unknown protocols.
239
+
240
+ ### Added (development)
241
+ - A real-capture fixture corpus under `tests/fixtures/` (56 frames,
242
+ 10 scenarios, every checksum internally verified — see its
243
+ MANIFEST.md) with corpus-wide invariant tests, plus the capture and
244
+ validation scripts under `scripts/`.
245
+
246
+ ## [1.0.0] - 2026-08-21
247
+
248
+ Complete rewrite. The public API is new; see the README and
249
+ ARCHITECTURE.md for the current usage.
250
+
251
+ ### Changed
252
+ - The parsing core moved from `ctypes.BigEndianStructure` subclasses to
253
+ frozen, slotted dataclasses parsed with `struct.Struct`. Decoded and
254
+ constructed instances are now identical in type and compare equal
255
+ field-by-field.
256
+ - `next_protocol()` (returning the class that decodes the payload, or
257
+ `None`) replaces the stringly-typed `encapsulated_proto` chain.
258
+ Protocol number registries are exposed as `EtherType`, `IPProtocol`,
259
+ and `ARPOperation` enums.
260
+ - `header_len` is now an instance property: IPv4 honors IHL and TCP
261
+ honors the data offset, and both expose their raw `options` bytes.
262
+ - Field names modernized (`ethertype`, `src_port`, `total_length`,
263
+ `checksum`, ...); display helpers are properties such as
264
+ `protocol_name`, `flags_str`, and `checksum_hex_str`.
265
+ - Packaging moved to PEP 621 (`hatchling` + `uv`), src layout, Python
266
+ 3.12+, MIT license, `py.typed` (mypy strict clean).
267
+ - Validators are plain functions (`validate_mac_addr`,
268
+ `validate_ipv4_addr`) raising exceptions rooted at `ProtocolError`.
269
+
270
+ ### Fixed
271
+ - `TCP.header_len` was hardcoded to 32 bytes; the base header is 20 and
272
+ the real length follows the data offset.
273
+ - Decoding ignored IPv4 IHL and TCP data offset, so headers carrying
274
+ options were mis-sliced.
275
+ - Serialization returned a zeroed buffer (`Protocol.__str__`);
276
+ `bytes(header)` now re-emits the exact on-wire form, options included.
277
+ - `Packet` rejected instances of indirect `Protocol` subclasses
278
+ (e.g. `IPv4`, `ICMPv4`) due to validation against direct subclasses
279
+ only; membership is now checked with `isinstance`.
280
+ - Malformed input (truncated buffers, lying IHL/data-offset values) now
281
+ raises typed exceptions (`TruncatedHeaderError`, `InvalidFieldError`)
282
+ instead of arbitrary `ValueError`/`struct.error` leaks.
283
+
284
+ ### Removed
285
+ - The `Validator` descriptor infrastructure (it stored state on the
286
+ descriptor itself — class-shared — and was never wired into any
287
+ protocol class).
288
+ - Poetry manifests and the `setup.py`-era metadata.
289
+
290
+ ## [0.8.0] - 2022-08-19
291
+
292
+ Last release of the ctypes-based implementation, published to PyPI in
293
+ 2022. The repository had drifted behind the published releases; the
294
+ 0.8.0 sources were recovered from the PyPI sdist and committed (tagged
295
+ `v0.8.0`) before the 1.0.0 rewrite. Releases 0.4.4 through 0.8.0 exist
296
+ on PyPI only; their individual histories were not preserved.