netprotocols 1.0.0__tar.gz → 1.0.1__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 (63) hide show
  1. {netprotocols-1.0.0 → netprotocols-1.0.1}/.github/workflows/ci.yml +1 -1
  2. {netprotocols-1.0.0 → netprotocols-1.0.1}/.gitignore +1 -1
  3. {netprotocols-1.0.0 → netprotocols-1.0.1}/CHANGELOG.md +16 -0
  4. {netprotocols-1.0.0 → netprotocols-1.0.1}/PKG-INFO +1 -1
  5. {netprotocols-1.0.0 → netprotocols-1.0.1}/pyproject.toml +1 -1
  6. netprotocols-1.0.1/scripts/capture_fixtures.sh +104 -0
  7. netprotocols-1.0.1/scripts/capture_fixtures_supplement.sh +71 -0
  8. netprotocols-1.0.1/scripts/check_fixtures.py +225 -0
  9. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/__init__.py +1 -1
  10. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/ip.py +8 -0
  11. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/conftest.py +37 -1
  12. netprotocols-1.0.1/tests/fixtures/MANIFEST.md +48 -0
  13. netprotocols-1.0.1/tests/fixtures/arp_exchange.pcap +0 -0
  14. netprotocols-1.0.1/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  15. netprotocols-1.0.1/tests/fixtures/icmpv4_external.pcap +0 -0
  16. netprotocols-1.0.1/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  17. netprotocols-1.0.1/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  18. netprotocols-1.0.1/tests/fixtures/ipv4_fragments.pcap +0 -0
  19. netprotocols-1.0.1/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  20. netprotocols-1.0.1/tests/fixtures/staging/arp_exchange.pcap +0 -0
  21. netprotocols-1.0.1/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  22. netprotocols-1.0.1/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  23. netprotocols-1.0.1/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  24. netprotocols-1.0.1/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  25. netprotocols-1.0.1/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  26. netprotocols-1.0.1/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  27. netprotocols-1.0.1/tests/fixtures/staging/tcp_http.pcap +0 -0
  28. netprotocols-1.0.1/tests/fixtures/staging/tcp_https.pcap +0 -0
  29. netprotocols-1.0.1/tests/fixtures/staging/udp_dns.pcap +0 -0
  30. netprotocols-1.0.1/tests/fixtures/tcp_http.pcap +0 -0
  31. netprotocols-1.0.1/tests/fixtures/tcp_https.pcap +0 -0
  32. netprotocols-1.0.1/tests/fixtures/udp_dns.pcap +0 -0
  33. netprotocols-1.0.1/tests/test_corpus.py +167 -0
  34. {netprotocols-1.0.0 → netprotocols-1.0.1}/uv.lock +1 -1
  35. {netprotocols-1.0.0 → netprotocols-1.0.1}/.github/workflows/release.yml +0 -0
  36. {netprotocols-1.0.0 → netprotocols-1.0.1}/ARCHITECTURE.md +0 -0
  37. {netprotocols-1.0.0 → netprotocols-1.0.1}/LICENSE +0 -0
  38. {netprotocols-1.0.0 → netprotocols-1.0.1}/README.md +0 -0
  39. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/_base.py +0 -0
  40. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/_enums.py +0 -0
  41. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/__init__.py +0 -0
  42. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/arp.py +0 -0
  43. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/ethernet.py +0 -0
  44. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/__init__.py +0 -0
  45. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/icmp.py +0 -0
  46. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/__init__.py +0 -0
  47. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/tcp.py +0 -0
  48. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/udp.py +0 -0
  49. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/packet.py +0 -0
  50. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/py.typed +0 -0
  51. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/__init__.py +0 -0
  52. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/exceptions.py +0 -0
  53. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/ipv4.py +0 -0
  54. {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/mac.py +0 -0
  55. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_arp.py +0 -0
  56. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_contract.py +0 -0
  57. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_ethernet.py +0 -0
  58. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_icmp.py +0 -0
  59. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_ip.py +0 -0
  60. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_packet.py +0 -0
  61. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_tcp.py +0 -0
  62. {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_udp.py +0 -0
  63. {netprotocols-1.0.0 → netprotocols-1.0.1}/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/
@@ -5,6 +5,22 @@ 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.0.1] - 2026-08-29
9
+
10
+ ### Fixed
11
+ - `IPv4.next_protocol()` chained non-first fragments
12
+ (`fragment_offset > 0`) into garbage upper-layer decodes — a slice
13
+ from the middle of a fragmented payload has no header at its start.
14
+ Discovered by the new real-capture fixture corpus on live fragmented
15
+ traffic; the chain now ends at the IPv4 layer for those frames, as it
16
+ already did for unknown protocols.
17
+
18
+ ### Added (development)
19
+ - A real-capture fixture corpus under `tests/fixtures/` (56 frames,
20
+ 10 scenarios, every checksum internally verified — see its
21
+ MANIFEST.md) with corpus-wide invariant tests, plus the capture and
22
+ validation scripts under `scripts/`.
23
+
8
24
  ## [1.0.0] - 2026-08-21
9
25
 
10
26
  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.0.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "netprotocols"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "Low-level implementations of common networking protocols"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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,225 @@
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
+ stats["ipv6-fragment"] += 1
138
+ if struct.unpack_from("!H", upper, 2)[0] & 0xFFF8:
139
+ return failures
140
+ next_header, upper = upper[0], upper[8:]
141
+ upper_len = len(upper)
142
+ if next_header == 58 and upper_len >= 4:
143
+ stats["icmpv6"] += 1
144
+ zeroed = upper[:2] + b"\x00\x00" + upper[4:]
145
+ if (
146
+ internet_checksum(pseudo_v6(src, dst, 58, upper_len) + zeroed)
147
+ != struct.unpack_from("!H", upper, 2)[0]
148
+ ):
149
+ failures.append("icmpv6-checksum")
150
+ elif next_header == 6 and upper_len >= 20:
151
+ stats["tcp6"] += 1
152
+ zeroed = upper[:16] + b"\x00\x00" + upper[18:]
153
+ if (
154
+ internet_checksum(pseudo_v6(src, dst, 6, upper_len) + zeroed)
155
+ != struct.unpack_from("!H", upper, 16)[0]
156
+ ):
157
+ failures.append("tcp6-checksum")
158
+ elif next_header == 17 and upper_len >= 8:
159
+ stats["udp6"] += 1
160
+ zeroed = upper[:6] + b"\x00\x00" + upper[8:]
161
+ computed = (
162
+ internet_checksum(pseudo_v6(src, dst, 17, upper_len) + zeroed)
163
+ or 0xFFFF
164
+ )
165
+ if computed != struct.unpack_from("!H", upper, 6)[0]:
166
+ failures.append("udp6-checksum")
167
+ return failures
168
+
169
+ stats[f"other-0x{ethertype:04x}"] += 1
170
+ return failures
171
+
172
+
173
+ def main() -> int:
174
+ staging = Path(
175
+ sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/staging"
176
+ )
177
+ pcaps = sorted(staging.glob("*.pcap"))
178
+ if not pcaps:
179
+ print(f"No pcaps found in {staging}/")
180
+ return 1
181
+
182
+ corpus_stats: Counter = Counter()
183
+ bad_files = 0
184
+ total_frames = 0
185
+ for pcap in pcaps:
186
+ stats: Counter = Counter()
187
+ failures: Counter = Counter()
188
+ frames = 0
189
+ try:
190
+ for frame in read_pcap(pcap):
191
+ frames += 1
192
+ for failure in check_frame(frame, stats):
193
+ failures[failure] += 1
194
+ except ValueError as e:
195
+ print(f"[FAIL] {pcap.name}: {e}")
196
+ bad_files += 1
197
+ continue
198
+ total_frames += frames
199
+ corpus_stats.update(stats)
200
+ verdict = "OK " if not failures else "FAIL"
201
+ detail = ", ".join(f"{k}x{v}" for k, v in sorted(stats.items()))
202
+ print(f"[{verdict}] {pcap.name}: {frames} frames ({detail})")
203
+ for failure, count in sorted(failures.items()):
204
+ print(f" !! {failure} x{count}")
205
+ if failures:
206
+ bad_files += 1
207
+
208
+ print(f"\nCorpus: {total_frames} frames across {len(pcaps)} files")
209
+ print(
210
+ "Coverage:",
211
+ ", ".join(f"{k}={v}" for k, v in sorted(corpus_stats.items())),
212
+ )
213
+ if bad_files:
214
+ print(
215
+ f"\n{bad_files} file(s) FAILED checksum consistency — "
216
+ f"do not commit these; re-capture or investigate offload "
217
+ f"(ethtool -K <iface> tx off rx off)."
218
+ )
219
+ return 1
220
+ print("\nAll files internally consistent.")
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
@@ -30,7 +30,7 @@ from netprotocols.utils.exceptions import (
30
30
  from netprotocols.utils.ipv4 import validate_ipv4_addr
31
31
  from netprotocols.utils.mac import random_mac, validate_mac_addr
32
32
 
33
- __version__ = "1.0.0"
33
+ __version__ = "1.0.1"
34
34
 
35
35
  __all__ = [
36
36
  "ARP",
@@ -172,6 +172,14 @@ class IPv4(Protocol):
172
172
  return self.ihl * 4
173
173
 
174
174
  def next_protocol(self) -> type[Protocol] | None:
175
+ """See :meth:`Protocol.next_protocol`.
176
+
177
+ A non-first fragment (``fragment_offset > 0``) carries a slice
178
+ from the middle of the original payload — no upper-layer header
179
+ exists at its start — so the chain ends here for those.
180
+ """
181
+ if self.fragment_offset > 0:
182
+ return None
175
183
  return _ip_protocol_class(self.protocol)
176
184
 
177
185
  @property
@@ -2,11 +2,47 @@
2
2
 
3
3
  Sources include the Wireshark sample captures at
4
4
  https://wiki.wireshark.org/SampleCaptures (arp-storm.pcap, among
5
- others) and locally captured frames.
5
+ others), locally captured frames, and the real-capture corpus under
6
+ ``tests/fixtures/`` (see its MANIFEST.md).
6
7
  """
7
8
 
9
+ import struct
10
+ from pathlib import Path
11
+
8
12
  import pytest
9
13
 
14
+ FIXTURES = Path(__file__).parent / "fixtures"
15
+
16
+
17
+ def read_pcap(path: Path) -> list[bytes]:
18
+ """Minimal classic-pcap reader for the fixture corpus (test-only;
19
+ independent of any library or application pcap code)."""
20
+ data = path.read_bytes()
21
+ magic = data[:4]
22
+ if magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"):
23
+ endian = ">"
24
+ elif magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"):
25
+ endian = "<"
26
+ else:
27
+ raise ValueError(f"{path.name}: not a pcap")
28
+ frames = []
29
+ cursor = 24
30
+ while cursor + 16 <= len(data):
31
+ (incl_len,) = struct.unpack_from(f"{endian}I", data, cursor + 8)
32
+ cursor += 16
33
+ frames.append(data[cursor : cursor + incl_len])
34
+ cursor += incl_len
35
+ return frames
36
+
37
+
38
+ def corpus_frames() -> list[tuple[str, int, bytes]]:
39
+ """Every corpus frame as ``(pcap_name, index, frame_bytes)``."""
40
+ return [
41
+ (pcap.name, index, frame)
42
+ for pcap in sorted(FIXTURES.glob("*.pcap"))
43
+ for index, frame in enumerate(read_pcap(pcap))
44
+ ]
45
+
10
46
 
11
47
  @pytest.fixture
12
48
  def raw_eth_header() -> bytes:
@@ -0,0 +1,48 @@
1
+ # Real-capture fixture corpus
2
+
3
+ Captured 2026-08-29 with `scripts/capture_fixtures.sh` (tcpdump on `lo`
4
+ and the default interface) and validated by
5
+ `scripts/check_fixtures.py`: **every frame's checksums verify
6
+ internally** (IPv4 header, TCP/UDP pseudo-header, ICMPv4/v6). Frames
7
+ used as checksum ground truth were captured **inbound only** — outbound
8
+ frames routinely leave checksums to NIC offload. 56 frames across 10
9
+ scenarios. Addresses are as-captured from the capture host's network.
10
+
11
+ | File | Frames | Contents |
12
+ |---|---|---|
13
+ | `arp_exchange.pcap` | 4 | ARP requests ×3 + reply ×1 (fresh exchange after neighbor flush) |
14
+ | `icmpv4_echo_lo.pcap` | 6 | Loopback echo request/reply pairs (types 8 and 0) |
15
+ | `icmpv4_external.pcap` | 2 | Inbound echo replies from 1.1.1.1 |
16
+ | `icmpv4_ttl_exceeded.pcap` | 3 | Time Exceeded (type 11) errors from intermediate hops |
17
+ | `icmpv6_echo_lo.pcap` | 6 | Loopback ICMPv6 echo pairs (types 128/129) |
18
+ | `ipv4_fragments.pcap` | 5 | Fragmented 2028-byte echo replies: first fragments (offset 0, MF) + non-first fragments |
19
+ | `ipv6_ndp_mld.pcap` | 10 | NDP Neighbor Solicitation/Advertisement (135/136) + echo 128/129 |
20
+ | `tcp_http.pcap` | 4 | Inbound HTTP-port segments over IPv6: ACK, PSH-ACK ×2, FIN-ACK — all carrying options |
21
+ | `tcp_https.pcap` | 12 | Inbound HTTPS segments over IPv4 (7) and IPv6 (5): ACK/PSH-ACK with options |
22
+ | `udp_dns.pcap` | 4 | DNS responses (src port 53) over IPv4 (1) and IPv6 (3), with payloads |
23
+
24
+ Consumed by `tests/test_corpus.py` (corpus-wide invariants +
25
+ representative field asserts), mirrored into Packet-Sniffer's test
26
+ suite, and later reused as fuzz seeds and pcap-replay goldens.
27
+
28
+ ## Known gaps (pending `scripts/capture_fixtures_supplement.sh`)
29
+
30
+ - **MLD behind a hop-by-hop extension header** — the original capture
31
+ filter used BPF `icmp6`, which matches only `next_header == 58`
32
+ *directly*; MLD hides behind hop-by-hop (`next_header == 0`) and was
33
+ filtered out. The supplement captures `ip6 proto 0`.
34
+ - **IPv6 fragment pair** — no fragmented IPv6 traffic was generated in
35
+ the first run; the supplement sends an oversized `ping -6`.
36
+ - No inbound SYN-ACK was caught (handshake timing vs. capture start);
37
+ options-bearing segments are otherwise abundant.
38
+ - Unknown-EtherType frames are exercised by deliberately synthetic
39
+ fixtures in the unit tests (their point is being unknown), not by the
40
+ corpus.
41
+
42
+ ## Provenance note
43
+
44
+ This corpus exposed a real defect on arrival: non-first IPv4 fragments
45
+ were chained into garbage upper-layer decodes ("ICMPv4 type 192" from
46
+ mid-payload bytes) because `IPv4.next_protocol()` ignored
47
+ `fragment_offset`. Fixed alongside the corpus's introduction; see
48
+ `tests/test_corpus.py::TestFragmentHandling`.
@@ -0,0 +1,167 @@
1
+ """Corpus-wide invariants over the real-capture fixtures.
2
+
3
+ Every frame in tests/fixtures/ was captured from live traffic and
4
+ validated for internal checksum consistency by scripts/check_fixtures.py
5
+ before being committed (see tests/fixtures/MANIFEST.md). These tests
6
+ hold the library to its contract against all of them at once.
7
+ """
8
+
9
+ import contextlib
10
+
11
+ import pytest
12
+
13
+ from conftest import FIXTURES, corpus_frames, read_pcap
14
+ from netprotocols import (
15
+ ARP,
16
+ TCP,
17
+ UDP,
18
+ Ethernet,
19
+ ICMPv4,
20
+ ICMPv6,
21
+ IPv4,
22
+ IPv6,
23
+ Protocol,
24
+ ProtocolError,
25
+ )
26
+
27
+ CORPUS = corpus_frames()
28
+
29
+
30
+ def walk(frame: bytes) -> tuple[list[Protocol], int]:
31
+ """The documented chain walk; returns (layers, bytes consumed)."""
32
+ layers: list[Protocol] = []
33
+ cursor = 0
34
+ protocol: type[Protocol] | None = Ethernet
35
+ while protocol is not None:
36
+ header = protocol.decode(frame[cursor:])
37
+ layers.append(header)
38
+ cursor += header.header_len
39
+ protocol = header.next_protocol()
40
+ return layers, cursor
41
+
42
+
43
+ def corpus_ids() -> list[str]:
44
+ return [f"{name}#{index}" for name, index, _ in CORPUS]
45
+
46
+
47
+ class TestCorpusInvariants:
48
+ def test_corpus_is_present_and_generous(self):
49
+ assert len(CORPUS) >= 40
50
+ assert len(list(FIXTURES.glob("*.pcap"))) >= 10
51
+
52
+ @pytest.mark.parametrize(
53
+ "frame", [f for _, _, f in CORPUS], ids=corpus_ids()
54
+ )
55
+ def test_decodes_raising_nothing_outside_protocol_error(self, frame):
56
+ # ProtocolError is acceptable by contract; nothing else is.
57
+ with contextlib.suppress(ProtocolError):
58
+ walk(frame)
59
+
60
+ @pytest.mark.parametrize(
61
+ "frame", [f for _, _, f in CORPUS], ids=corpus_ids()
62
+ )
63
+ def test_every_layer_round_trips_byte_exactly(self, frame):
64
+ layers, _ = walk(frame)
65
+ cursor = 0
66
+ for layer in layers:
67
+ assert bytes(layer) == frame[cursor : cursor + layer.header_len]
68
+ cursor += layer.header_len
69
+
70
+ @pytest.mark.parametrize(
71
+ "frame", [f for _, _, f in CORPUS], ids=corpus_ids()
72
+ )
73
+ def test_consumption_never_exceeds_frame(self, frame):
74
+ _, consumed = walk(frame)
75
+ assert consumed <= len(frame)
76
+
77
+
78
+ class TestCorpusCoverage:
79
+ """The corpus must keep exercising every protocol the library ships."""
80
+
81
+ def test_protocol_coverage(self):
82
+ seen = {
83
+ type(layer) for _, _, frame in CORPUS for layer in walk(frame)[0]
84
+ }
85
+ assert {Ethernet, ARP, IPv4, IPv6, ICMPv4, ICMPv6, TCP, UDP} <= seen
86
+
87
+ def test_arp_covers_both_operations(self):
88
+ opers = {
89
+ layer.oper
90
+ for _, _, frame in CORPUS
91
+ for layer in walk(frame)[0]
92
+ if isinstance(layer, ARP)
93
+ }
94
+ assert {1, 2} <= opers
95
+
96
+ def test_tcp_options_present_in_real_traffic(self):
97
+ assert any(
98
+ layer.options
99
+ for _, _, frame in CORPUS
100
+ for layer in walk(frame)[0]
101
+ if isinstance(layer, TCP)
102
+ )
103
+
104
+
105
+ class TestFragmentHandling:
106
+ """Regression: real fragmented traffic exposed that IPv4 chained
107
+ non-first fragments into garbage upper-layer decodes (mid-payload
108
+ bytes decoded as 'ICMPv4 type 192')."""
109
+
110
+ def fragments(self) -> list[IPv4]:
111
+ frames = read_pcap(FIXTURES / "ipv4_fragments.pcap")
112
+ layers = [walk(frame)[0] for frame in frames]
113
+ return [stack[1] for stack in layers if isinstance(stack[1], IPv4)]
114
+
115
+ def test_corpus_contains_a_fragment_pair(self):
116
+ offsets = {ip.fragment_offset for ip in self.fragments()}
117
+ assert 0 in offsets
118
+ assert any(offset > 0 for offset in offsets)
119
+
120
+ def test_non_first_fragments_do_not_chain(self):
121
+ for ip in self.fragments():
122
+ if ip.fragment_offset > 0:
123
+ assert ip.next_protocol() is None
124
+
125
+ def test_first_fragments_chain_to_real_icmp(self):
126
+ for ip in self.fragments():
127
+ if ip.fragment_offset == 0:
128
+ assert ip.next_protocol() is ICMPv4
129
+
130
+
131
+ class TestRepresentativeFrames:
132
+ """Hand-verified field asserts, one frame per scenario family."""
133
+
134
+ def test_ttl_exceeded_error_message(self):
135
+ frame = read_pcap(FIXTURES / "icmpv4_ttl_exceeded.pcap")[0]
136
+ layers, _ = walk(frame)
137
+ assert [type(layer) for layer in layers] == [Ethernet, IPv4, ICMPv4]
138
+ icmp = layers[2]
139
+ assert isinstance(icmp, ICMPv4)
140
+ assert icmp.type == 11
141
+ assert icmp.type_name == "Time Exceeded"
142
+
143
+ def test_loopback_echo_pair(self):
144
+ frames = read_pcap(FIXTURES / "icmpv4_echo_lo.pcap")
145
+ types = [walk(frame)[0][2].type for frame in frames] # type: ignore[attr-defined]
146
+ assert 8 in types and 0 in types
147
+
148
+ def test_ndp_neighbor_discovery(self):
149
+ frames = read_pcap(FIXTURES / "ipv6_ndp_mld.pcap")
150
+ types = {
151
+ layer.type
152
+ for frame in frames
153
+ for layer in walk(frame)[0]
154
+ if isinstance(layer, ICMPv6)
155
+ }
156
+ assert 135 in types # Neighbor Solicitation
157
+ assert 136 in types # Neighbor Advertisement
158
+
159
+ def test_dns_responses_over_both_ip_versions(self):
160
+ frames = read_pcap(FIXTURES / "udp_dns.pcap")
161
+ stacks = [[type(layer) for layer in walk(frame)[0]] for frame in frames]
162
+ assert any(stack[1] is IPv4 and stack[2] is UDP for stack in stacks)
163
+ assert any(stack[1] is IPv6 and stack[2] is UDP for stack in stacks)
164
+ for frame in frames:
165
+ udp = walk(frame)[0][2]
166
+ assert isinstance(udp, UDP)
167
+ assert udp.src_port == 53
@@ -353,7 +353,7 @@ wheels = [
353
353
 
354
354
  [[package]]
355
355
  name = "netprotocols"
356
- version = "1.0.0"
356
+ version = "1.0.1"
357
357
  source = { editable = "." }
358
358
 
359
359
  [package.dev-dependencies]
File without changes
File without changes