netprotocols 1.0.0__tar.gz → 1.1.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 (74) hide show
  1. {netprotocols-1.0.0 → netprotocols-1.1.0}/.github/workflows/ci.yml +1 -1
  2. {netprotocols-1.0.0 → netprotocols-1.1.0}/.gitignore +1 -1
  3. {netprotocols-1.0.0 → netprotocols-1.1.0}/ARCHITECTURE.md +15 -0
  4. {netprotocols-1.0.0 → netprotocols-1.1.0}/CHANGELOG.md +41 -0
  5. {netprotocols-1.0.0 → netprotocols-1.1.0}/PKG-INFO +9 -9
  6. {netprotocols-1.0.0 → netprotocols-1.1.0}/README.md +8 -8
  7. {netprotocols-1.0.0 → netprotocols-1.1.0}/pyproject.toml +2 -1
  8. netprotocols-1.1.0/scripts/capture_fixtures.sh +104 -0
  9. netprotocols-1.1.0/scripts/capture_fixtures_supplement.sh +71 -0
  10. netprotocols-1.1.0/scripts/check_fixtures.py +228 -0
  11. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/__init__.py +15 -1
  12. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/_enums.py +8 -0
  13. netprotocols-1.1.0/src/netprotocols/checksum.py +158 -0
  14. netprotocols-1.1.0/src/netprotocols/layer3/__init__.py +19 -0
  15. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer3/ip.py +40 -4
  16. netprotocols-1.1.0/src/netprotocols/layer3/ipv6_ext.py +253 -0
  17. netprotocols-1.1.0/src/netprotocols/packet.py +88 -0
  18. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/conftest.py +37 -1
  19. netprotocols-1.1.0/tests/fixtures/MANIFEST.md +50 -0
  20. netprotocols-1.1.0/tests/fixtures/arp_exchange.pcap +0 -0
  21. netprotocols-1.1.0/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  22. netprotocols-1.1.0/tests/fixtures/icmpv4_external.pcap +0 -0
  23. netprotocols-1.1.0/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  24. netprotocols-1.1.0/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  25. netprotocols-1.1.0/tests/fixtures/ipv4_fragments.pcap +0 -0
  26. netprotocols-1.1.0/tests/fixtures/ipv6_fragments.pcap +0 -0
  27. netprotocols-1.1.0/tests/fixtures/ipv6_mld.pcap +0 -0
  28. netprotocols-1.1.0/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  29. netprotocols-1.1.0/tests/fixtures/staging/arp_exchange.pcap +0 -0
  30. netprotocols-1.1.0/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  31. netprotocols-1.1.0/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  32. netprotocols-1.1.0/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  33. netprotocols-1.1.0/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  34. netprotocols-1.1.0/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  35. netprotocols-1.1.0/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  36. netprotocols-1.1.0/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  37. netprotocols-1.1.0/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  38. netprotocols-1.1.0/tests/fixtures/staging/tcp_http.pcap +0 -0
  39. netprotocols-1.1.0/tests/fixtures/staging/tcp_https.pcap +0 -0
  40. netprotocols-1.1.0/tests/fixtures/staging/udp_dns.pcap +0 -0
  41. netprotocols-1.1.0/tests/fixtures/tcp_http.pcap +0 -0
  42. netprotocols-1.1.0/tests/fixtures/tcp_https.pcap +0 -0
  43. netprotocols-1.1.0/tests/fixtures/udp_dns.pcap +0 -0
  44. netprotocols-1.1.0/tests/test_checksum.py +239 -0
  45. netprotocols-1.1.0/tests/test_corpus.py +167 -0
  46. netprotocols-1.1.0/tests/test_fuzz.py +182 -0
  47. netprotocols-1.1.0/tests/test_ipv6_ext.py +177 -0
  48. {netprotocols-1.0.0 → netprotocols-1.1.0}/uv.lock +84 -1
  49. netprotocols-1.0.0/src/netprotocols/layer3/__init__.py +0 -4
  50. netprotocols-1.0.0/src/netprotocols/packet.py +0 -48
  51. {netprotocols-1.0.0 → netprotocols-1.1.0}/.github/workflows/release.yml +0 -0
  52. {netprotocols-1.0.0 → netprotocols-1.1.0}/LICENSE +0 -0
  53. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/_base.py +0 -0
  54. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer2/__init__.py +0 -0
  55. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer2/arp.py +0 -0
  56. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer2/ethernet.py +0 -0
  57. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer3/icmp.py +0 -0
  58. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer4/__init__.py +0 -0
  59. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer4/tcp.py +0 -0
  60. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/layer4/udp.py +0 -0
  61. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/py.typed +0 -0
  62. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/utils/__init__.py +0 -0
  63. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/utils/exceptions.py +0 -0
  64. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/utils/ipv4.py +0 -0
  65. {netprotocols-1.0.0 → netprotocols-1.1.0}/src/netprotocols/utils/mac.py +0 -0
  66. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_arp.py +0 -0
  67. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_contract.py +0 -0
  68. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_ethernet.py +0 -0
  69. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_icmp.py +0 -0
  70. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_ip.py +0 -0
  71. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_packet.py +0 -0
  72. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_tcp.py +0 -0
  73. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_udp.py +0 -0
  74. {netprotocols-1.0.0 → netprotocols-1.1.0}/tests/test_utils.py +0 -0
@@ -2,7 +2,7 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [master, overhaul]
5
+ branches: [master]
6
6
  pull_request:
7
7
  branches: [master]
8
8
 
@@ -146,4 +146,4 @@ amass.txt
146
146
  amass.log
147
147
 
148
148
  # Application-specific
149
- .logs/
149
+ .logs/tests/fixtures/staging/
@@ -115,8 +115,23 @@ flowchart LR
115
115
  I6 -->|58| IC6[ICMPv6]
116
116
  I6 -->|6| T
117
117
  I6 -->|17| U
118
+ I6 -->|0, 43, 44, 60| X[IPv6 extension headers]
119
+ X --> X
120
+ X -->|58| IC6
121
+ X -->|6| T
122
+ X -->|17| U
118
123
  ```
119
124
 
125
+ IPv6 extension headers (Hop-by-Hop Options, Routing, Fragment,
126
+ Destination Options) chain like any other protocol — each names what
127
+ follows via `next_header` — and may stack. Two guardrails: the
128
+ registry hands them out only inside an IPv6 chain (a garbage IPv4
129
+ packet with `protocol=0` cannot conjure a Hop-by-Hop layer), and a
130
+ Fragment header chains onward only when `fragment_offset == 0` — a
131
+ non-first fragment carries a slice from the middle of the original
132
+ payload, so no upper-layer header exists at its start. The same
133
+ offset rule applies to fragmented IPv4.
134
+
120
135
  The numbers on the arrows are the wire values — EtherTypes out of
121
136
  Ethernet, IP protocol numbers out of IPv4/IPv6 — and they live in
122
137
  [`_enums.py`](src/netprotocols/_enums.py) as `EtherType` and
@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-08-29
9
+
10
+ ### Added
11
+ - **IPv6 extension headers** (RFC 8200 §4.3-4.6): `IPv6HopByHopOptions`,
12
+ `IPv6Routing`, `IPv6Fragment`, `IPv6DestinationOptions`. The decode
13
+ chain now reaches ICMPv6/TCP/UDP behind them (MLD reports decode
14
+ fully). Extension headers dispatch only inside an IPv6 chain, and a
15
+ Fragment header chains onward only from the first fragment.
16
+ - **Checksums** (`netprotocols.checksum`): `internet_checksum()`
17
+ (RFC 1071), `compute()`/`verify()` covering the IPv4 header checksum,
18
+ ICMPv4, and TCP/UDP/ICMPv6 with IPv4/IPv6 pseudo-headers, and
19
+ `Packet.with_checksums()` for filling a whole stack on encode. The
20
+ UDP-only zero substitution is confined to the UDP arm.
21
+ - **Property-based fuzzing** of the decode path (hypothesis, dev-only):
22
+ decode() raises nothing outside `ProtocolError` for any input; the
23
+ chain walk never consumes past the frame; constrained round trips.
24
+ - `IPProtocol` gains `HOPOPT`, `IPV6_ROUTE`, `IPV6_FRAG`,
25
+ `IPV6_DSTOPTS` with display names.
26
+
27
+ ### Development
28
+ - The real-capture fixture corpus grew to 65 frames across 12
29
+ scenarios (MLD behind hop-by-hop, IPv6 fragment pairs); checksum
30
+ tests recompute every verifiable corpus frame to its captured wire
31
+ value.
32
+
33
+ ## [1.0.1] - 2026-08-29
34
+
35
+ ### Fixed
36
+ - `IPv4.next_protocol()` chained non-first fragments
37
+ (`fragment_offset > 0`) into garbage upper-layer decodes — a slice
38
+ from the middle of a fragmented payload has no header at its start.
39
+ Discovered by the new real-capture fixture corpus on live fragmented
40
+ traffic; the chain now ends at the IPv4 layer for those frames, as it
41
+ already did for unknown protocols.
42
+
43
+ ### Added (development)
44
+ - A real-capture fixture corpus under `tests/fixtures/` (56 frames,
45
+ 10 scenarios, every checksum internally verified — see its
46
+ MANIFEST.md) with corpus-wide invariant tests, plus the capture and
47
+ validation scripts under `scripts/`.
48
+
8
49
  ## [1.0.0] - 2026-08-21
9
50
 
10
51
  Complete rewrite. The public API is new; see the README and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netprotocols
3
- Version: 1.0.0
3
+ Version: 1.1.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
@@ -67,7 +67,11 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
67
67
  | 2 | Ethernet II | `Ethernet` | IEEE 802.3 |
68
68
  | 2 | ARP | `ARP` | RFC 826, IPv4-over-Ethernet binding |
69
69
  | 3 | IPv4 | `IPv4` | RFC 791, IHL/options aware |
70
- | 3 | IPv6 | `IPv6` | RFC 8200, fixed header |
70
+ | 3 | IPv6 | `IPv6` | RFC 8200 |
71
+ | 3 | IPv6 Hop-by-Hop Options | `IPv6HopByHopOptions` | RFC 8200 §4.3 |
72
+ | 3 | IPv6 Routing | `IPv6Routing` | RFC 8200 §4.4 |
73
+ | 3 | IPv6 Fragment | `IPv6Fragment` | RFC 8200 §4.5, first-fragment chaining |
74
+ | 3 | IPv6 Destination Options | `IPv6DestinationOptions` | RFC 8200 §4.6 |
71
75
  | 3 | ICMPv4 | `ICMPv4` | RFC 792, 8-byte header |
72
76
  | 3 | ICMPv6 | `ICMPv6` | RFC 4443, 8-byte header |
73
77
  | 4 | TCP | `TCP` | RFC 9293, data-offset/options aware |
@@ -127,8 +131,9 @@ frame = Packet(
127
131
  raw = bytes(frame) # ready for a raw socket
128
132
  ```
129
133
 
130
- Checksums are carried verbatim in both directions: the library neither
131
- computes nor verifies them (planned — see the roadmap).
134
+ Checksums are computed and verified on request — never silently:
135
+ `compute()`/`verify()` in `netprotocols.checksum`, and
136
+ `Packet.with_checksums()` to fill a whole stack before sending.
132
137
 
133
138
  ## Display helpers
134
139
 
@@ -150,12 +155,7 @@ network traffic monitor built on it.
150
155
 
151
156
  ## Roadmap
152
157
 
153
- - IPv6 extension headers (hop-by-hop, routing, fragment, destination
154
- options) — today the decode chain ends at `IPv6` for traffic behind
155
- them (e.g. MLD).
156
158
  - 802.1Q VLAN tags, DNS, DHCP, IGMP, GRE.
157
- - Checksum computation on encode and verification flags on decode.
158
- - Fuzzing of the decode path.
159
159
 
160
160
  ## Contributing
161
161
 
@@ -42,7 +42,11 @@ Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
42
42
  | 2 | Ethernet II | `Ethernet` | IEEE 802.3 |
43
43
  | 2 | ARP | `ARP` | RFC 826, IPv4-over-Ethernet binding |
44
44
  | 3 | IPv4 | `IPv4` | RFC 791, IHL/options aware |
45
- | 3 | IPv6 | `IPv6` | RFC 8200, fixed header |
45
+ | 3 | IPv6 | `IPv6` | RFC 8200 |
46
+ | 3 | IPv6 Hop-by-Hop Options | `IPv6HopByHopOptions` | RFC 8200 §4.3 |
47
+ | 3 | IPv6 Routing | `IPv6Routing` | RFC 8200 §4.4 |
48
+ | 3 | IPv6 Fragment | `IPv6Fragment` | RFC 8200 §4.5, first-fragment chaining |
49
+ | 3 | IPv6 Destination Options | `IPv6DestinationOptions` | RFC 8200 §4.6 |
46
50
  | 3 | ICMPv4 | `ICMPv4` | RFC 792, 8-byte header |
47
51
  | 3 | ICMPv6 | `ICMPv6` | RFC 4443, 8-byte header |
48
52
  | 4 | TCP | `TCP` | RFC 9293, data-offset/options aware |
@@ -102,8 +106,9 @@ frame = Packet(
102
106
  raw = bytes(frame) # ready for a raw socket
103
107
  ```
104
108
 
105
- Checksums are carried verbatim in both directions: the library neither
106
- computes nor verifies them (planned — see the roadmap).
109
+ Checksums are computed and verified on request — never silently:
110
+ `compute()`/`verify()` in `netprotocols.checksum`, and
111
+ `Packet.with_checksums()` to fill a whole stack before sending.
107
112
 
108
113
  ## Display helpers
109
114
 
@@ -125,12 +130,7 @@ network traffic monitor built on it.
125
130
 
126
131
  ## Roadmap
127
132
 
128
- - IPv6 extension headers (hop-by-hop, routing, fragment, destination
129
- options) — today the decode chain ends at `IPv6` for traffic behind
130
- them (e.g. MLD).
131
133
  - 802.1Q VLAN tags, DNS, DHCP, IGMP, GRE.
132
- - Checksum computation on encode and verification flags on decode.
133
- - Fuzzing of the decode path.
134
134
 
135
135
  ## Contributing
136
136
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netprotocols"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "Low-level implementations of common networking protocols"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ Changelog = "https://github.com/EONRaider/NETProtocols/blob/master/CHANGELOG.md"
29
29
 
30
30
  [dependency-groups]
31
31
  dev = [
32
+ "hypothesis>=6.100",
32
33
  "mypy>=1.14",
33
34
  "pytest>=8.3",
34
35
  "pytest-cov>=6.0",
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+ # Capture the real-frame fixture corpus (issue #27, plan item L-0).
3
+ #
4
+ # Run once with sudo from the repository root:
5
+ #
6
+ # sudo scripts/capture_fixtures.sh
7
+ #
8
+ # Writes per-scenario pcaps into tests/fixtures/staging/ and finishes by
9
+ # running scripts/check_fixtures.py over them. Frames used for checksum
10
+ # ground truth are captured INBOUND only (outbound frames often carry
11
+ # checksums left unfilled for NIC offload). Nothing is committed by this
12
+ # script: review the checker report, then hand the staging directory to
13
+ # the test-writing step.
14
+
15
+ set -euo pipefail
16
+
17
+ if [[ $EUID -ne 0 ]]; then
18
+ echo "This script needs root for tcpdump (or run it via sudo)." >&2
19
+ exit 1
20
+ fi
21
+
22
+ command -v tcpdump >/dev/null || { echo "tcpdump is required." >&2; exit 1; }
23
+
24
+ OUT="tests/fixtures/staging"
25
+ mkdir -p "$OUT"
26
+
27
+ IFACE=$(ip route show default | awk '/default/ {print $5; exit}')
28
+ GATEWAY=$(ip route show default | awk '/default/ {print $3; exit}')
29
+ [[ -n $IFACE ]] || { echo "No default interface found." >&2; exit 1; }
30
+ echo "[*] Default interface: $IFACE (gateway $GATEWAY)"
31
+ echo "[*] Writing to $OUT/"
32
+
33
+ # capture NAME IFACE COUNT FILTER -- TRAFFIC_CMD...
34
+ # Starts a bounded tcpdump, runs the traffic generator, waits.
35
+ capture() {
36
+ local name=$1 iface=$2 count=$3 filter=$4; shift 4; shift # consume --
37
+ echo "[>] $name"
38
+ tcpdump -i "$iface" -c "$count" -w "$OUT/$name.pcap" --immediate-mode \
39
+ -q $filter >/dev/null 2>&1 &
40
+ local pid=$!
41
+ sleep 1
42
+ "$@" >/dev/null 2>&1 || true
43
+ # Give tcpdump up to 15s to fill its count, then stop it either way.
44
+ for _ in $(seq 15); do kill -0 $pid 2>/dev/null || break; sleep 1; done
45
+ kill $pid 2>/dev/null || true
46
+ wait $pid 2>/dev/null || true
47
+ }
48
+
49
+ # --- Loopback scenarios (checksum ground truth: lo has no offload issue
50
+ # --- for inbound copies; we still filter inbound where it matters).
51
+ capture icmpv4_echo_lo lo 6 "icmp" -- ping -c 3 -i 0.3 127.0.0.1
52
+ capture icmpv6_echo_lo lo 6 "icmp6" -- ping -c 3 -i 0.3 ::1
53
+
54
+ # --- Default-interface scenarios, inbound-only where checksums matter.
55
+ capture udp_dns "$IFACE" 4 "inbound and udp and port 53" -- \
56
+ sh -c 'dig +tries=1 +time=3 example.com @1.1.1.1; dig +tries=1 +time=3 AAAA example.com @1.1.1.1'
57
+ capture tcp_http "$IFACE" 12 "inbound and tcp and port 80" -- \
58
+ curl -s -m 8 -o /dev/null http://example.com/
59
+ capture tcp_https "$IFACE" 12 "inbound and tcp and port 443" -- \
60
+ curl -s -m 8 -o /dev/null https://example.com/
61
+ capture icmpv4_external "$IFACE" 4 "inbound and icmp" -- \
62
+ ping -c 3 -i 0.4 -W 2 1.1.1.1
63
+ # A 2000-byte ping fragments on any 1500-MTU path: inbound echo replies
64
+ # arrive as an IPv4 fragment pair.
65
+ capture ipv4_fragments "$IFACE" 6 "inbound and (icmp or ip[6:2] & 0x3fff != 0)" -- \
66
+ ping -c 3 -i 0.4 -W 2 -s 2000 1.1.1.1
67
+ # TTL-exceeded errors from intermediate hops.
68
+ capture icmpv4_ttl_exceeded "$IFACE" 4 "inbound and icmp[icmptype] == 11" -- \
69
+ sh -c 'ping -c 2 -W 2 -t 2 1.1.1.1; ping -c 2 -W 2 -t 3 1.1.1.1'
70
+
71
+ # --- ARP (no checksums; both directions welcome). Flush the gateway
72
+ # --- neighbor entry to force a fresh exchange.
73
+ arp_traffic() {
74
+ ip neigh del "$GATEWAY" dev "$IFACE" 2>/dev/null || true
75
+ ping -c 1 -W 2 "$GATEWAY"
76
+ }
77
+ capture arp_exchange "$IFACE" 4 "arp" -- arp_traffic
78
+
79
+ # --- IPv6 NDP / MLD, ambient + provoked. MLD reports ride behind a
80
+ # --- hop-by-hop header (the L-1 fixture). Joining a multicast group
81
+ # --- provokes a report; pinging the all-nodes address provokes NDP.
82
+ ipv6_traffic() {
83
+ ping -6 -c 2 -W 2 "ff02::1%$IFACE" || true
84
+ python3 - "$IFACE" <<'PY' || true
85
+ import socket, struct, sys, time
86
+ sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
87
+ ifindex = socket.if_nametoindex(sys.argv[1])
88
+ group = socket.inet_pton(socket.AF_INET6, "ff02::deca:fbad")
89
+ sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP,
90
+ group + struct.pack("@I", ifindex))
91
+ time.sleep(3)
92
+ PY
93
+ }
94
+ capture ipv6_ndp_mld "$IFACE" 10 "icmp6" -- ipv6_traffic
95
+
96
+ echo
97
+ echo "[*] Captures done. Validating..."
98
+ CHECKER_PY=$(command -v python3)
99
+ if [[ -n ${SUDO_USER:-} ]]; then
100
+ chown -R "$SUDO_USER" "$OUT"
101
+ sudo -u "$SUDO_USER" "$CHECKER_PY" scripts/check_fixtures.py "$OUT"
102
+ else
103
+ "$CHECKER_PY" scripts/check_fixtures.py "$OUT"
104
+ fi
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Supplementary corpus capture: the two scenarios the first run missed
3
+ # (see tests/fixtures/MANIFEST.md "Known gaps").
4
+ #
5
+ # sudo scripts/capture_fixtures_supplement.sh
6
+ #
7
+ # 1. MLD behind hop-by-hop: the original run filtered with BPF `icmp6`,
8
+ # which matches next_header == 58 directly and therefore *excludes*
9
+ # MLD (which rides behind a hop-by-hop header, next_header == 0).
10
+ # This captures `ip6 proto 0` while joining/leaving a multicast
11
+ # group to provoke reports.
12
+ # 2. IPv6 fragment pair: an oversized ping -6 to an external host
13
+ # fragments on any 1500-MTU path (ip6 proto 44, inbound).
14
+
15
+ set -euo pipefail
16
+
17
+ if [[ $EUID -ne 0 ]]; then
18
+ echo "This script needs root for tcpdump (or run it via sudo)." >&2
19
+ exit 1
20
+ fi
21
+
22
+ OUT="tests/fixtures/staging"
23
+ mkdir -p "$OUT"
24
+ IFACE=$(ip route show default | awk '/default/ {print $5; exit}')
25
+ echo "[*] Default interface: $IFACE"
26
+
27
+ capture() {
28
+ local name=$1 iface=$2 count=$3 filter=$4; shift 4; shift
29
+ echo "[>] $name"
30
+ tcpdump -i "$iface" -c "$count" -w "$OUT/$name.pcap" --immediate-mode \
31
+ -q $filter >/dev/null 2>&1 &
32
+ local pid=$!
33
+ sleep 1
34
+ "$@" >/dev/null 2>&1 || true
35
+ for _ in $(seq 20); do kill -0 $pid 2>/dev/null || break; sleep 1; done
36
+ kill $pid 2>/dev/null || true
37
+ wait $pid 2>/dev/null || true
38
+ }
39
+
40
+ mld_traffic() {
41
+ python3 - "$IFACE" <<'PY' || true
42
+ import socket, struct, sys, time
43
+ ifindex = socket.if_nametoindex(sys.argv[1])
44
+ group = socket.inet_pton(socket.AF_INET6, "ff02::deca:fbad")
45
+ mreq = group + struct.pack("@I", ifindex)
46
+ for _ in range(3):
47
+ sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
48
+ sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq)
49
+ time.sleep(1.5)
50
+ sock.close() # leaving also emits an MLD Done/Report
51
+ time.sleep(1.5)
52
+ PY
53
+ }
54
+ capture ipv6_mld "$IFACE" 4 "ip6 proto 0" -- mld_traffic
55
+
56
+ ipv6_frag_traffic() {
57
+ # An IPv6-reachable anycast resolver; 2000-byte payload fragments
58
+ # on the way back.
59
+ ping -6 -c 3 -i 0.5 -W 3 -s 2000 2606:4700:4700::1111
60
+ }
61
+ capture ipv6_fragments "$IFACE" 8 "inbound and ip6 proto 44" -- ipv6_frag_traffic
62
+
63
+ echo
64
+ echo "[*] Validating supplement..."
65
+ CHECKER_PY=$(command -v python3)
66
+ if [[ -n ${SUDO_USER:-} ]]; then
67
+ chown -R "$SUDO_USER" "$OUT"
68
+ sudo -u "$SUDO_USER" "$CHECKER_PY" scripts/check_fixtures.py "$OUT"
69
+ else
70
+ "$CHECKER_PY" scripts/check_fixtures.py "$OUT"
71
+ fi
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the internal checksum consistency of captured fixture pcaps.
3
+
4
+ Deliberately standalone (stdlib only, its own RFC 1071 implementation,
5
+ its own minimal parsers): it must be able to disqualify a capture even
6
+ if netprotocols itself has a bug, and later it cross-validates the
7
+ library's checksum module (plan item L-2) with an independent
8
+ implementation.
9
+
10
+ Usage: python3 scripts/check_fixtures.py tests/fixtures/staging
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import struct
16
+ import sys
17
+ from collections import Counter
18
+ from pathlib import Path
19
+
20
+
21
+ def internet_checksum(data: bytes) -> int:
22
+ if len(data) % 2:
23
+ data += b"\x00"
24
+ total = sum(struct.unpack(f"!{len(data) // 2}H", data))
25
+ while total >> 16:
26
+ total = (total & 0xFFFF) + (total >> 16)
27
+ return ~total & 0xFFFF
28
+
29
+
30
+ def read_pcap(path: Path):
31
+ data = path.read_bytes()
32
+ if len(data) < 24:
33
+ raise ValueError("not a pcap: too short")
34
+ magic = data[:4]
35
+ if magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"):
36
+ endian = ">"
37
+ elif magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"):
38
+ endian = "<"
39
+ else:
40
+ raise ValueError(f"not a pcap: magic {magic.hex()}")
41
+ (network,) = struct.unpack_from(f"{endian}I", data, 20)
42
+ if network != 1:
43
+ raise ValueError(f"linktype {network} is not Ethernet")
44
+ cursor = 24
45
+ while cursor + 16 <= len(data):
46
+ _, _, incl_len, _ = struct.unpack_from(f"{endian}IIII", data, cursor)
47
+ cursor += 16
48
+ yield data[cursor : cursor + incl_len]
49
+ cursor += incl_len
50
+
51
+
52
+ def pseudo_v4(src: bytes, dst: bytes, proto: int, length: int) -> bytes:
53
+ return src + dst + struct.pack("!BBH", 0, proto, length)
54
+
55
+
56
+ def pseudo_v6(src: bytes, dst: bytes, proto: int, length: int) -> bytes:
57
+ return src + dst + struct.pack("!IBBBB", length, 0, 0, 0, proto)
58
+
59
+
60
+ def check_frame(frame: bytes, stats: Counter) -> list[str]:
61
+ """Validate one Ethernet frame; returns a list of failure strings."""
62
+ failures: list[str] = []
63
+ if len(frame) < 14:
64
+ stats["runt"] += 1
65
+ return failures
66
+ ethertype = struct.unpack_from("!H", frame, 12)[0]
67
+ payload = frame[14:]
68
+
69
+ if ethertype == 0x0806:
70
+ stats["arp"] += 1
71
+ return failures
72
+
73
+ if ethertype == 0x0800 and len(payload) >= 20:
74
+ ihl = (payload[0] & 0x0F) * 4
75
+ header = payload[:ihl]
76
+ stats["ipv4"] += 1
77
+ if (
78
+ internet_checksum(header[:10] + b"\x00\x00" + header[12:ihl])
79
+ != struct.unpack_from("!H", header, 10)[0]
80
+ ):
81
+ failures.append("ipv4-header-checksum")
82
+ src, dst = header[12:16], header[16:20]
83
+ proto = header[9]
84
+ total_length = struct.unpack_from("!H", header, 2)[0]
85
+ flags_frag = struct.unpack_from("!H", header, 6)[0]
86
+ if flags_frag & 0x1FFF:
87
+ stats["ipv4-fragment"] += 1
88
+ return failures # non-first fragment: no upper-layer header
89
+ if flags_frag & 0x2000 == 0x2000 and flags_frag & 0x1FFF == 0:
90
+ stats["ipv4-first-fragment"] += 1
91
+ return failures # first fragment: upper checksum spans all parts
92
+ upper = payload[ihl:total_length]
93
+ upper_len = len(upper)
94
+ if proto == 1 and upper_len >= 8:
95
+ stats["icmpv4"] += 1
96
+ if (
97
+ internet_checksum(upper[:2] + b"\x00\x00" + upper[4:])
98
+ != struct.unpack_from("!H", upper, 2)[0]
99
+ ):
100
+ failures.append("icmpv4-checksum")
101
+ elif proto == 6 and upper_len >= 20:
102
+ stats["tcp"] += 1
103
+ zeroed = upper[:16] + b"\x00\x00" + upper[18:]
104
+ if (
105
+ internet_checksum(pseudo_v4(src, dst, 6, upper_len) + zeroed)
106
+ != struct.unpack_from("!H", upper, 16)[0]
107
+ ):
108
+ failures.append("tcp-checksum")
109
+ elif proto == 17 and upper_len >= 8:
110
+ stats["udp"] += 1
111
+ wire = struct.unpack_from("!H", upper, 6)[0]
112
+ if wire == 0:
113
+ stats["udp-no-checksum"] += 1
114
+ else:
115
+ zeroed = upper[:6] + b"\x00\x00" + upper[8:]
116
+ computed = (
117
+ internet_checksum(
118
+ pseudo_v4(src, dst, 17, upper_len) + zeroed
119
+ )
120
+ or 0xFFFF
121
+ )
122
+ if computed != wire:
123
+ failures.append("udp-checksum")
124
+ return failures
125
+
126
+ if ethertype == 0x86DD and len(payload) >= 40:
127
+ stats["ipv6"] += 1
128
+ payload_length, next_header = struct.unpack_from("!HB", payload, 4)
129
+ src, dst = payload[8:24], payload[24:40]
130
+ upper = payload[40 : 40 + payload_length]
131
+ # Walk extension headers (hop-by-hop 0, routing 43, dest-opts 60).
132
+ while next_header in (0, 43, 60) and len(upper) >= 8:
133
+ stats["ipv6-ext-header"] += 1
134
+ ext_len = (upper[1] + 1) * 8
135
+ next_header, upper = upper[0], upper[ext_len:]
136
+ if next_header == 44 and len(upper) >= 8:
137
+ if struct.unpack_from("!H", upper, 2)[0] & 0xFFF8:
138
+ stats["ipv6-fragment"] += 1
139
+ return failures # non-first fragment: no upper header
140
+ stats["ipv6-first-fragment"] += 1
141
+ # The upper-layer checksum spans the reassembled datagram,
142
+ # not this fragment's slice: nothing verifiable here.
143
+ return failures
144
+ upper_len = len(upper)
145
+ if next_header == 58 and upper_len >= 4:
146
+ stats["icmpv6"] += 1
147
+ zeroed = upper[:2] + b"\x00\x00" + upper[4:]
148
+ if (
149
+ internet_checksum(pseudo_v6(src, dst, 58, upper_len) + zeroed)
150
+ != struct.unpack_from("!H", upper, 2)[0]
151
+ ):
152
+ failures.append("icmpv6-checksum")
153
+ elif next_header == 6 and upper_len >= 20:
154
+ stats["tcp6"] += 1
155
+ zeroed = upper[:16] + b"\x00\x00" + upper[18:]
156
+ if (
157
+ internet_checksum(pseudo_v6(src, dst, 6, upper_len) + zeroed)
158
+ != struct.unpack_from("!H", upper, 16)[0]
159
+ ):
160
+ failures.append("tcp6-checksum")
161
+ elif next_header == 17 and upper_len >= 8:
162
+ stats["udp6"] += 1
163
+ zeroed = upper[:6] + b"\x00\x00" + upper[8:]
164
+ computed = (
165
+ internet_checksum(pseudo_v6(src, dst, 17, upper_len) + zeroed)
166
+ or 0xFFFF
167
+ )
168
+ if computed != struct.unpack_from("!H", upper, 6)[0]:
169
+ failures.append("udp6-checksum")
170
+ return failures
171
+
172
+ stats[f"other-0x{ethertype:04x}"] += 1
173
+ return failures
174
+
175
+
176
+ def main() -> int:
177
+ staging = Path(
178
+ sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/staging"
179
+ )
180
+ pcaps = sorted(staging.glob("*.pcap"))
181
+ if not pcaps:
182
+ print(f"No pcaps found in {staging}/")
183
+ return 1
184
+
185
+ corpus_stats: Counter = Counter()
186
+ bad_files = 0
187
+ total_frames = 0
188
+ for pcap in pcaps:
189
+ stats: Counter = Counter()
190
+ failures: Counter = Counter()
191
+ frames = 0
192
+ try:
193
+ for frame in read_pcap(pcap):
194
+ frames += 1
195
+ for failure in check_frame(frame, stats):
196
+ failures[failure] += 1
197
+ except ValueError as e:
198
+ print(f"[FAIL] {pcap.name}: {e}")
199
+ bad_files += 1
200
+ continue
201
+ total_frames += frames
202
+ corpus_stats.update(stats)
203
+ verdict = "OK " if not failures else "FAIL"
204
+ detail = ", ".join(f"{k}x{v}" for k, v in sorted(stats.items()))
205
+ print(f"[{verdict}] {pcap.name}: {frames} frames ({detail})")
206
+ for failure, count in sorted(failures.items()):
207
+ print(f" !! {failure} x{count}")
208
+ if failures:
209
+ bad_files += 1
210
+
211
+ print(f"\nCorpus: {total_frames} frames across {len(pcaps)} files")
212
+ print(
213
+ "Coverage:",
214
+ ", ".join(f"{k}={v}" for k, v in sorted(corpus_stats.items())),
215
+ )
216
+ if bad_files:
217
+ print(
218
+ f"\n{bad_files} file(s) FAILED checksum consistency — "
219
+ f"do not commit these; re-capture or investigate offload "
220
+ f"(ethtool -K <iface> tx off rx off)."
221
+ )
222
+ return 1
223
+ print("\nAll files internally consistent.")
224
+ return 0
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
@@ -12,10 +12,17 @@ from netprotocols._enums import (
12
12
  EtherType,
13
13
  IPProtocol,
14
14
  )
15
+ from netprotocols.checksum import compute, internet_checksum, verify
15
16
  from netprotocols.layer2.arp import ARP
16
17
  from netprotocols.layer2.ethernet import Ethernet
17
18
  from netprotocols.layer3.icmp import ICMPv4, ICMPv6
18
19
  from netprotocols.layer3.ip import IPv4, IPv6
20
+ from netprotocols.layer3.ipv6_ext import (
21
+ IPv6DestinationOptions,
22
+ IPv6Fragment,
23
+ IPv6HopByHopOptions,
24
+ IPv6Routing,
25
+ )
19
26
  from netprotocols.layer4.tcp import TCP
20
27
  from netprotocols.layer4.udp import UDP
21
28
  from netprotocols.packet import Packet
@@ -30,7 +37,7 @@ from netprotocols.utils.exceptions import (
30
37
  from netprotocols.utils.ipv4 import validate_ipv4_addr
31
38
  from netprotocols.utils.mac import random_mac, validate_mac_addr
32
39
 
33
- __version__ = "1.0.0"
40
+ __version__ = "1.1.0"
34
41
 
35
42
  __all__ = [
36
43
  "ARP",
@@ -45,6 +52,10 @@ __all__ = [
45
52
  "IPProtocol",
46
53
  "IPv4",
47
54
  "IPv6",
55
+ "IPv6DestinationOptions",
56
+ "IPv6Fragment",
57
+ "IPv6HopByHopOptions",
58
+ "IPv6Routing",
48
59
  "InvalidFieldError",
49
60
  "InvalidIPv4AddressError",
50
61
  "InvalidMACAddressError",
@@ -54,7 +65,10 @@ __all__ = [
54
65
  "ProtocolError",
55
66
  "TruncatedHeaderError",
56
67
  "__version__",
68
+ "compute",
69
+ "internet_checksum",
57
70
  "random_mac",
58
71
  "validate_ipv4_addr",
59
72
  "validate_mac_addr",
73
+ "verify",
60
74
  ]
@@ -36,11 +36,15 @@ class IPProtocol(IntEnum):
36
36
  ``next_header``, so a single registry serves both.
37
37
  """
38
38
 
39
+ HOPOPT = 0x00
39
40
  ICMP = 0x01
40
41
  IGMP = 0x02
41
42
  TCP = 0x06
42
43
  UDP = 0x11
44
+ IPV6_ROUTE = 0x2B
45
+ IPV6_FRAG = 0x2C
43
46
  IPV6_ICMP = 0x3A
47
+ IPV6_DSTOPTS = 0x3C
44
48
 
45
49
  @property
46
50
  def display_name(self) -> str:
@@ -48,11 +52,15 @@ class IPProtocol(IntEnum):
48
52
 
49
53
 
50
54
  _IP_PROTOCOL_NAMES = {
55
+ IPProtocol.HOPOPT: "IPv6 Hop-by-Hop Options",
51
56
  IPProtocol.ICMP: "ICMP",
52
57
  IPProtocol.IGMP: "IGMP",
53
58
  IPProtocol.TCP: "TCP",
54
59
  IPProtocol.UDP: "UDP",
60
+ IPProtocol.IPV6_ROUTE: "IPv6 Routing",
61
+ IPProtocol.IPV6_FRAG: "IPv6 Fragment",
55
62
  IPProtocol.IPV6_ICMP: "IPv6-ICMP",
63
+ IPProtocol.IPV6_DSTOPTS: "IPv6 Destination Options",
56
64
  }
57
65
 
58
66