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.
- {netprotocols-1.0.0 → netprotocols-1.0.1}/.github/workflows/ci.yml +1 -1
- {netprotocols-1.0.0 → netprotocols-1.0.1}/.gitignore +1 -1
- {netprotocols-1.0.0 → netprotocols-1.0.1}/CHANGELOG.md +16 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/PKG-INFO +1 -1
- {netprotocols-1.0.0 → netprotocols-1.0.1}/pyproject.toml +1 -1
- netprotocols-1.0.1/scripts/capture_fixtures.sh +104 -0
- netprotocols-1.0.1/scripts/capture_fixtures_supplement.sh +71 -0
- netprotocols-1.0.1/scripts/check_fixtures.py +225 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/__init__.py +1 -1
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/ip.py +8 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/conftest.py +37 -1
- netprotocols-1.0.1/tests/fixtures/MANIFEST.md +48 -0
- netprotocols-1.0.1/tests/fixtures/arp_exchange.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/icmpv4_external.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/ipv4_fragments.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/arp_exchange.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/tcp_http.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/tcp_https.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/staging/udp_dns.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/tcp_http.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/tcp_https.pcap +0 -0
- netprotocols-1.0.1/tests/fixtures/udp_dns.pcap +0 -0
- netprotocols-1.0.1/tests/test_corpus.py +167 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/uv.lock +1 -1
- {netprotocols-1.0.0 → netprotocols-1.0.1}/.github/workflows/release.yml +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/ARCHITECTURE.md +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/LICENSE +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/README.md +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/_base.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/_enums.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/arp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer2/ethernet.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/__init__.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer3/icmp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/__init__.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/tcp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/layer4/udp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/packet.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/py.typed +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/exceptions.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/ipv4.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/src/netprotocols/utils/mac.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_arp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_contract.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_ethernet.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_icmp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_ip.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_packet.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_tcp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_udp.py +0 -0
- {netprotocols-1.0.0 → netprotocols-1.0.1}/tests/test_utils.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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())
|
|
@@ -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)
|
|
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`.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|