Packet Wire API
July 5, 2026 ยท View on GitHub
crafter::wire is the Rust packet I/O abstraction. It keeps the library's
normal Packet model at the center while adding stream primitives for capture,
transform, and transmit.
Application code should enter through PacketWire when it wants a stream of
PacketRecord values from a backend or wants to write packet records through a
backend. The pcap codec and libpcap integration are owned by the pcap wire
backend rather than a separate public crate module.
Model
A PacketWire represents one opened packet-capable backend or interface. It is
not a global device manager and it does not own application concurrency. If an
application needs Wi-Fi plus Bluetooth, two Wi-Fi adapters, or Wi-Fi plus SDR,
open one PacketWire for each backend and run the resulting sources and
writers in the application's own threads, tasks, or channels.
An opened wire may support reading, writing, or both:
source()consumes a wire and returns onePacketSource.writer()consumes a wire and returns onePacketWriter.split()consumes a wire and returns both capabilities when the backend supports both.
Unsupported directions are typed errors. For example, a pcap file input is read-only, a pcap recorder is write-only, and a raw socket wire is write-only.
Sniffer owns one PacketSource and an inbound transform chain.
Transmitter owns one PacketWriter and an outbound transform chain. Each
transform receives a PacketRecord and may emit zero, one, or many
PacketRecord values. That shape is what lets stateful transforms buffer
handshake frames, wait for fragments, emit multiple reassembled packets, or drop
records that are not ready yet while keeping every stream item packet-shaped.
Packet Records
PacketRecord is the common stream item. It always contains a Packet; opaque
or undecoded data is represented as a Raw layer inside that packet rather than
as a separate byte-only object.
PacketRecord also carries PacketMetadata:
- origin, such as captured or generated;
- backend kind, interface name, or file path;
- pcap timestamp, original length, captured length, captured bytes, and emitted length when available;
- link type and pcap link type;
- transform trace entries;
- medium metadata, including Wi-Fi placeholders for SSID bytes, BSSID, channel, protection state, Dot11 frame kind, and decrypt state.
Metadata is deliberately inspectable. Packet processing tools should be able to
log record.packet().summary() and inspect record.metadata() without guessing
which backend or transform produced the record.
Offline Capture
Prefer offline pcap examples in tests, docs, and generated tools unless a live run is explicitly needed.
use crafter::prelude::*;
let source = PacketWire::pcap_file("fixtures/input.pcap")
.filter("tcp or udp")
.open()?
.source()?;
let mut sniffer = Sniffer::new(source)
.with(Dot11Metadata::new())
.count(10);
while let Some(record) = sniffer.next_record()? {
println!("{}", record.packet().summary());
println!("{:?}", record.metadata());
}
# Ok::<(), crafter::CrafterError>(())
The pcap file constructor accepts an optional libpcap BPF filter. The source
decodes each record into a PacketRecord and preserves pcap timestamp, lengths,
captured bytes, and link metadata.
UDP/5353 records decode as Dns layers with mDNS context when the enclosing
IP/UDP headers are valid, so pcap workflows still use the normal packet stream
shape:
use crafter::prelude::*;
let source = PacketWire::pcap_file(
"crafter/tests/fixtures/pcaps/ethernet-ipv4-udp-mdns-query.pcap",
)
.filter("udp port 5353")
.open()?
.source()?;
for record in Sniffer::new(source).collect_records()? {
if let Some(dns) = record.packet().layer::<Dns>() {
println!("{}", dns.summary());
}
}
# Ok::<(), crafter::CrafterError>(())
TLS records use the same pcap source path. Apply a TCP BPF filter to keep the
offline fixture bounded, then inspect the decoded Tls layer through the normal
packet APIs:
use crafter::prelude::*;
# fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = PacketWire::pcap_file(
"crafter/tests/fixtures/pcaps/raw-ipv4-tcp-tls-client-hello.pcap",
)
.filter("tcp port 443")
.open()?
.source()?;
for record in Sniffer::new(source).no_timeout().collect_records()? {
if let Some(tls) = record.packet().layer::<Tls>() {
println!("{}", tls.summary());
println!("{:?}", record.metadata().timestamp());
}
}
# Ok(())
# }
Offline Writing
Use a pcap recorder when the output should be deterministic and inspectable.
use crafter::prelude::*;
let writer = PacketWire::pcap_recorder("target/out.pcap", LinkType::Ethernet)
.open()?
.writer()?;
let packet =
Ethernet::new()
/ Ipv4::new().src("192.0.2.10")?.dst("198.51.100.20")?
/ Udp::new().sport(40000).dport(53)
/ Raw::from("payload");
let mut tx = Transmitter::new(writer);
let reports = tx.send(packet)?;
for report in reports {
println!("{:?}", report);
}
# Ok::<(), crafter::CrafterError>(())
The recorder compiles the packet from each PacketRecord, validates compatible
link metadata when present, writes the pcap record, and returns a WriteReport.
Transform Chains
PacketTransform is the state-machine contract shared by inbound and outbound
pipelines.
use crafter::prelude::*;
struct KeepIpv4;
impl PacketTransform for KeepIpv4 {
fn name(&self) -> &'static str {
"keep-ipv4"
}
fn transform(
&mut self,
record: PacketRecord,
emit: &mut dyn FnMut(PacketRecord) -> crafter::wire::Result<()>,
) -> crafter::wire::Result<()> {
if record.packet().layer::<Ipv4>().is_some() {
emit(record)?;
}
Ok(())
}
}
Transforms are ordered. The output from one transform becomes the input to the next. If a transform emits no records, the rest of the chain is skipped for that input. If it emits many records, downstream transforms and writers see each record in emission order.
Built-in transform shapes include:
- Wi-Fi metadata annotation, such as
Dot11Metadata; - WPA/WPA2 key-state tracking and protected-data decryption, such as
WpaDecrypt; - IPv4 and IPv6 receive-side fragment reassembly through
IpDefrag; - IPv4 and IPv6 transmit-side fragmentation through
IpFragment.
TCP stream reassembly and application reconstruction are out of scope for the
built-in wire transforms. Tools that need those workflows can implement their
own PacketTransform, but the crate keeps its built-in transforms at packet
datagram scope.
IpDefrag is an inbound transform for sources and sniffers. It consumes
fragmented IPv4 records and supported IPv6 Fragment Header records, buffers
bounded per-datagram state, and emits a reassembled packet-shaped record when
all byte ranges are present. Metadata stays inspectable through
PacketMetadata::ip_defrag_metadata() and transform traces.
use crafter::prelude::*;
let source = PacketWire::pcap_file(
"crafter/tests/fixtures/pcaps/raw-ipv4-ipfragment-generated.pcap",
)
.open()?
.source()?;
let records = Sniffer::new(source)
.with(IpDefrag::new())
.collect_records()?;
for record in records {
println!("{}", record.packet().summary());
println!("{:?}", record.metadata().ip_defrag_metadata());
}
# Ok::<(), crafter::CrafterError>(())
IpFragment is an outbound transform with an explicit MTU. For IPv4, the
default Ipv4DontFragmentPolicy::Error honors Don't Fragment (DF): when a
DF-set packet exceeds the configured MTU, the transform returns a structured
ip-fragment error instead of silently fragmenting. Callers that want an
explicit no-fragment result can configure
IpFragmentConfig::new(mtu).dont_fragment_policy(Ipv4DontFragmentPolicy::PassThrough),
which emits the original record with IpFragmentReason::DontFragment metadata
and an ipv4 don't-fragment pass-through trace. The only override that plans
fragmentation despite DF is the explicit
Ipv4DontFragmentPolicy::FragmentAnyway policy; the legacy
honor_dont_fragment(false) builder maps to that same override.
Place IpFragment on Transmitter, not on Sniffer. Offline writers keep
fragment output deterministic and reviewable:
use crafter::prelude::*;
let writer = MemoryPacketWriter::dry_run();
let mut tx = Transmitter::new(writer).with(IpFragment::new(1280));
let reports = tx.send(
Ipv6::new().src_str("2001:db8:20::1")?.dst_str("2001:db8:20::2")?
/ Udp::new().sport(53000).dport(53001)
/ Raw::from_bytes(&[0u8; 1600]),
)?;
assert!(reports.iter().all(|report| report.is_dry_run()));
# Ok::<(), crafter::CrafterError>(())
Use documentation address space in examples and dry-run or pcap writers by default. Provider-backed lab sessions are the live path when a workflow needs real packet exchange.
For IpDefrag and IpFragment validation, run transform tests, oracle offline
checks, and pcap checks before any live workflow. Live fragment behavior must go
through a disposable lab provider session with --confirm-live-run, artifacts
under target/lab/ip-fragment-*, and teardown or skip artifacts that explain
what happened. Do not validate IP fragmentation by sending raw live traffic from
the developer machine; use the lab-backed workflow in validation.md
and lab.md.
DHCP Packet Streams
Dhcpv4 and Dhcpv6 remain normal packet layers when used through
PacketWire. Offline pcap readers decode DHCP traffic into PacketRecord
values when the enclosing IP/UDP headers and ports identify the protocol;
unknown DHCPv6 option codepoints and unknown DHCPv4 option payloads remain
inside the typed layer instead of becoming side-channel metadata.
Raw socket writing for DHCP examples should use dry-run planning by default:
use crafter::prelude::*;
let packet =
Ipv6::new().src("2001:db8::10")?.dst("2001:db8::1")?
/ Udp::dhcpv6_client()
/ Dhcpv6::solicit(0x010203).client_id([
0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0x00, 0x06, 0x01,
]);
let writer = PacketWire::raw_socket_interface("dry-run0")
.network_layer()
.open()?
.writer()?;
let reports = Transmitter::new(writer).send(packet)?;
assert!(reports.iter().all(|report| report.is_dry_run()));
# Ok::<(), crafter::CrafterError>(())
Live DHCP traffic can change network state. Keep examples and tests offline or dry-run, and run authorized live behavior only from a disposable provider-backed endpoint or lab session with artifacts collected after the run.
WpaDecrypt is an inbound transform. It accepts one or more configured SSIDs
with either passphrases or pre-derived PMKs, observes beacons, RSN information,
EAPOL-Key handshakes, and protected Dot11 data frames, and emits decrypted
packet records when the observed WPA2-PSK CCMP-128 state verifies.
Put Dot11Metadata before WpaDecrypt so the WPA transform can extend the
same Wi-Fi metadata record:
use crafter::prelude::*;
let source = PacketWire::pcap_file(
"crafter/tests/fixtures/pcaps/wpa2-psk-ccmp-unicast.pcap",
)
.open()?
.source()?;
let records = Sniffer::new(source)
.with(Dot11Metadata::new())
.with(WpaDecrypt::new().network("libcrafter-wpa", "libcrafter-pass")?)
.collect_records()?;
for record in records {
println!("{}", record.packet().summary());
if let Some(wpa) = record
.metadata()
.wifi()
.and_then(|wifi| wifi.wpa_metadata())
{
println!("{:?} {:?}", wpa.cipher(), wpa.decrypt_reason());
}
}
# Ok::<(), crafter::CrafterError>(())
The first complete decrypt path is passive WPA2-Personal with CCMP-128. TKIP,
GCMP, CCMP-256, WPA3/SAE, enterprise authentication, password cracking,
deauthentication, channel management, and AP or supplicant behavior are outside
this transform. Unsupported ciphers or AKMs remain packet-shaped observations
with inspectable WpaMetadata such as WpaDecryptReason::UnsupportedCipher or
WpaDecryptReason::UnsupportedAkm; they are not treated as silently decrypted
or guessed plaintext.
By default WpaDecrypt suppresses handshake-only records and undecryptable
protected originals while still passing non-Wi-Fi and unprotected non-handshake
records. Use WpaDecryptConfig::new().pass_originals(true) for diagnostic
captures that need the original protected records annotated with WPA metadata.
Real SSIDs, passphrases, PMKs, live captures, and unauthorized RF observations
do not belong in tracked files or automated tests. Use synthetic offline pcap
fixtures for examples and CI.
Live Capture
Live examples must be explicit and bounded. Use disposable provider-backed endpoints or an authorized lab environment for real traffic. Do not run live capture against networks you do not control.
use crafter::prelude::*;
use std::time::Duration;
let source = PacketWire::pcap_interface("eth0")
.filter("icmp")
.timeout(Duration::from_secs(1))
.snaplen(262_144)
.promisc(true)
.immediate_mode(true)
.nonblocking(true)
.open()?
.source()?;
let records = Sniffer::new(source)
.timeout(Duration::from_secs(3))
.count(5)
.collect_records()?;
for record in records {
println!("{}", record.packet().summary());
}
# Ok::<(), crafter::CrafterError>(())
PacketWire::pcap_interface opens one live libpcap interface. It can expose a
source, and when the platform and libpcap backend support it, a pcap interface
writer. Use split() only when the application needs both capabilities from
the same interface target and is prepared to handle a typed capability error.
Live Writing
For raw socket sends through the wire API, use the dedicated raw socket
constructor. It defaults to dry-run planning; .live() is the explicit opt-in
for live raw socket transmission, and live opens require an explicit
.link_layer() or .network_layer() send class.
use crafter::prelude::*;
let writer = PacketWire::raw_socket_interface("eth0")
.network_layer()
.open()?
.writer()?;
let packet =
Ipv4::new().src("192.0.2.10")?.dst("198.51.100.20")?
/ Udp::new().sport(40000).dport(33434)
/ Raw::from("dry-run payload");
let reports = Transmitter::new(writer).send(packet)?;
assert!(reports.iter().all(|report| report.is_dry_run()));
# Ok::<(), crafter::CrafterError>(())
Dry-run raw socket writers still plan IPv4, IPv6, Ethernet, and radiotap records without opening a live backend.
Live send requires .live(), an explicit send class, and the normal raw socket
privileges for the selected platform:
use crafter::prelude::*;
let writer = PacketWire::raw_socket_interface("eth0")
.network_layer()
.live()
.open()?
.writer()?;
let mut tx = Transmitter::new(writer);
let _reports = tx.send(
Ipv4::new().src("192.0.2.10")?.dst("198.51.100.20")?
/ Udp::new().sport(40000).dport(33434)
/ Raw::from("authorized live payload"),
)?;
# Ok::<(), crafter::CrafterError>(())
A live raw socket writer owns one reusable PacketSender, so repeated
Transmitter writes reuse the opened backend for one homogeneous send class.
Use .link_layer() for Ethernet and radiotap frames. Use .network_layer() for
bare IPv4 packets; full-header IPv6 network-layer live sends remain unsupported.
If a tool needs both Ethernet/radiotap frames and bare IPv4 packets, open
separate writers or senders. A writer opened for one class fails with a typed
error when given the other class instead of switching backends silently.
Parallel Interfaces
Parallel work is application-owned. Open one wire per interface or backend and move each source or writer into the runtime the application already uses.
use crafter::prelude::*;
let wifi_source = PacketWire::pcap_interface("wlan0mon")
.filter("type mgt or type data")
.open()?
.source()?;
let ethernet_source = PacketWire::pcap_interface("eth0")
.filter("ip")
.open()?
.source()?;
let wifi = Sniffer::new(wifi_source).with(Dot11Metadata::new()).spawn()?;
let ethernet = Sniffer::new(ethernet_source).count(100).spawn()?;
let wifi_records = wifi.join()?;
let ethernet_records = ethernet.join()?;
# Ok::<(), crafter::CrafterError>(())
The crate deliberately does not provide a multi-wire builder. Keeping each
PacketWire tied to one backend makes ownership, capabilities, live safety, and
failure reporting explicit.