Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ src/netprotocols/
├── packet.py Packet composition, with_checksums(), flow_key()
├── checksum.py RFC 1071: internet_checksum, compute, verify
├── flow.py FlowKey, flow_key(): canonical bidirectional keys
├── pcap.py read_captures()/read_pcap()/read_pcapng(): classic
│ pcap and pcapng readers, from bytes not filenames
├── layer2/ ethernet.py, arp.py, vlan.py (802.1Q / 802.1ad)
├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
│ igmp.py, gre.py,
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
caught a SYN, so unlike NOP/Timestamps these otherwise depend on
`max_examples` alone stumbling into a well-formed TLV by chance
(#98).
- **A pcap/pcapng reader that takes bytes, not filenames.** New
`netprotocols.pcap`: `read_captures(buffer)` auto-detects classic
pcap vs. pcapng from its magic number and yields `CapturedFrame`
(`timestamp` in nanoseconds since the Unix epoch, normalized from
whatever resolution the source recorded, `data` the frame's raw
bytes); `read_pcap()`/`read_pcapng()` are the same for a caller who
already knows the format. pcapng support covers exactly the block
types frames can come from — Section Header, Interface Description
(read only for its `if_tsresol` option), Enhanced Packet, and Simple
Packet (which the format gives no timestamp at all, hence `0`);
every other block type is skipped wholesale. A malformed or
truncated capture raises the new `MalformedCaptureError`
(`ProtocolError` family, no `lax` mode — a corrupt *container* is a
different failure shape than a malformed header inside one already-
extracted frame). Format detection is eager; producing frames is
lazy (a generator), so a bad record downstream doesn't invalidate
what already iterated cleanly, and a huge capture is never forced
into a list of frames nobody asked for.

`tests/conftest.py` drops the private classic-pcap reader every test
file reached for — `pcap_frames()` is now a thin adapter over the
shipped `read_pcap()`, and `~10` test files were migrated onto it (a
real migration, not a rename: `tests/test_pcap.py` keeps its own
independent reference reader, deliberately never importing the
module it is cross-checking, the same "standalone, so a shared bug
can't cancel itself out" precedent as `scripts/benchmark.py` and
`scripts/check_fixtures.py`).

One design idea was tried and reverted on measurement: slicing each
frame lazily out of a `memoryview` over the whole buffer, to keep
large captures zero-copy. Measured across synthetic captures up to
~140MB, it was 0.91x-0.98x — never faster, sometimes slower — because
a real capture is many *small* frames, and a `memoryview` slice's own
overhead is paid per frame; #88's identical finding for a single
frame generalizes rather than being contradicted. `docs/CLAIMS.md`
5.8 is corrected accordingly — it previously forward-referenced this
issue with an unverified "1.8x" figure (#100).

## [2.0.0] - 2026-09-04

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,29 @@ to read `stopped_by`. Outside this one RFC-mandated case, a *complete*
frame that fails to decode is still a bug to raise on — `lax=True`
elsewhere is a capture tool's choice, not a default.

## Reading captures

`read_captures()` takes the bytes of a capture file — not a path — and
auto-detects classic pcap vs. pcapng from its magic number:

```python
from netprotocols import decode_frame, read_captures

data = open("traffic.pcap", "rb").read() # or however you got the bytes
for timestamp, frame in read_captures(data):
packet = decode_frame(frame, lax=True)
...
```

Each `CapturedFrame` is `(timestamp, data)` — `timestamp` normalized to
nanoseconds since the Unix epoch regardless of the source format's own
resolution (classic pcap's microseconds or nanoseconds; pcapng's
per-interface `if_tsresol`). `read_pcap()`/`read_pcapng()` are the same
thing for a caller who already knows the format and wants to skip
detection. A malformed or truncated capture raises
`MalformedCaptureError`, the same `ProtocolError` family every other
exception in this library belongs to.

## Flow keys

`Packet.flow_key()` (or the free function, `netprotocols.flow_key()`,
Expand Down
30 changes: 21 additions & 9 deletions docs/CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,15 +597,27 @@ the corpus, that is **0.95x** — 5% *slower* — because for one small
frame the view costs more to build than the copy it saves.

So the walker slices whatever it is handed and never converts: `bytes`
stays fastest for a single frame, and a `memoryview` over a large
contiguous capture buffer keeps slices zero-copy, which is the case
#100 measured at 1.8x. Byte-exact round-tripping through a
`memoryview` is asserted over the corpus.

This one is worth stating publicly *as a process claim*: the obvious
optimisation was proposed, measured, and rejected on its own numbers,
and both the number and its reproduction are written down. Rule 1 with
teeth.
stays fastest for a single frame. `netprotocols.pcap` (#100) tried the
same idea one level up — slicing each frame lazily out of a
`memoryview` over the whole capture buffer, instead of copying the
buffer once and slicing `bytes` per frame — on the theory that a
memoryview over a *large contiguous* buffer would keep those slices
zero-copy. Measured, it was not a win: **0.91x-0.98x** across
synthetic captures from ~6MB to ~140MB, sometimes measurably slower.
The reason generalizes #88's own finding rather than contradicting it:
a real capture is many *small* frames, not one large one, and a
`memoryview` slice's own object overhead is paid per frame — it adds
up faster than the one-time copy it was meant to avoid. `read_pcap`/
`read_pcapng`/`read_captures` therefore copy their input once up
front and return plain `bytes` per frame regardless of whether they
were given `bytes` or a `memoryview`; the parameter still accepts
either, for caller convenience, not for a performance contract.

This one is worth stating publicly *as a process claim* twice over:
the obvious optimisation was proposed, measured, and rejected on its
own numbers — once for a single frame (#88), and again for a whole
capture's worth of them (#100) — and every number and its reproduction
is written down. Rule 1 with teeth.

### 5.9 "Explains bad input instead of merely rejecting it"
**Status: VERIFIED** (#91)
Expand Down
12 changes: 12 additions & 0 deletions src/netprotocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
SOARecord,
)
from netprotocols.packet import Packet
from netprotocols.pcap import (
CapturedFrame,
read_captures,
read_pcap,
read_pcapng,
)
from netprotocols.registry import (
DEFAULT,
Registry,
Expand All @@ -54,6 +60,7 @@
InvalidIPv4AddressError,
InvalidMACAddressError,
InvalidManufacturerCodeError,
MalformedCaptureError,
MaxDepthExceededError,
ProtocolError,
TruncatedHeaderError,
Expand Down Expand Up @@ -82,6 +89,7 @@
"VLAN",
"ARPHardwareType",
"ARPOperation",
"CapturedFrame",
"DHCPOption",
"DNSOverTCP",
"DNSQuestion",
Expand All @@ -106,6 +114,7 @@
"InvalidMACAddressError",
"InvalidManufacturerCodeError",
"MXRecord",
"MalformedCaptureError",
"MaxDepthExceededError",
"NDPOption",
"Packet",
Expand All @@ -123,6 +132,9 @@
"flow_key",
"internet_checksum",
"random_mac",
"read_captures",
"read_pcap",
"read_pcapng",
"register",
"register_all",
"validate_ipv4_addr",
Expand Down
Loading
Loading