ferranet 0.2.0

A modern, async-first, zero-copy datalink-layer (L2) networking library
Documentation
# ferranet

> ⚠️ **Beta — use with care.** The core paths are exercised by unit, property, fuzz, Miri, and
> CI-run `veth` integration tests, and the `AF_PACKET` backends (ring and basic) have been
> validated on real hardware — a Raspberry Pi Zero 2 W over a direct ethernet link, under the
> `pibench` capture and flood workloads. Known gaps: the `AF_XDP` backend runs end-to-end over
> `veth` (generic/copy mode) but is unvalidated on a real NIC (zero-copy, multi-queue), the
> throughput numbers in `BENCHMARKS.md` are still `veth`-only, and the API may see further
> breaking changes before 1.0. Review the `unsafe` yourself before relying on it.

A modern, async-first, zero-copy **datalink-layer (Layer 2)** networking library for Rust.

`ferranet` lets you open a network interface and send/receive raw Ethernet frames with batching
and zero-copy receive. It is a spiritual successor to [`pnet`](https://github.com/libpnet/libpnet)'s
datalink module, rebuilt around the lessons its original author shared in a
[retrospective](https://github.com/rustasync/team/issues/15#issuecomment-408595008):

- **Async first.** A Tokio-based API is the primary surface, with a blocking API for
  maximum-throughput use.
- **Zero-copy receive.** Received frames borrow directly from a kernel-mapped `PACKET_MMAP` ring
  and are valid "until the end of the block" — no copying.
- **Batching.** A `TPACKET_V3` RX ring (block-based) and `TPACKET_V2` TX ring amortize syscalls.
- **Justified `unsafe`.** All `unsafe` is concentrated in one module, each block carrying a
  `SAFETY` argument resting on the kernel's `TP_STATUS_*` ownership protocol.
- **The fd is yours.** `as_fd()` exposes the underlying descriptor for interop.

## Status

v0.2, **Linux only**, behind a `sys::RawChannel` abstraction boundary so other platforms
(BSD/macOS, Windows) can be added later without API churn. Two backends ship today:

- **`AF_PACKET`/`PACKET_MMAP`** (default) — the zero-copy TPACKET_V3 RX ring / TPACKET_V2 TX ring.
- **`AF_XDP`** (optional, `--features xdp`) — the kernel-bypass tier. `XdpSocket` drives the UMEM
  and fill/completion/RX/TX rings; steering traffic in still requires an XDP redirect program
  (see below).

Packet *parsing/construction* is intentionally out of scope (a separate concern).

The default ring backend needs **Linux ≥ 3.2**; the `AF_XDP` backend needs ≥ 4.18. See
[`docs/kernel-compat.md`](docs/kernel-compat.md) for the per-feature kernel matrix and required
capabilities.

Interface enumeration includes per-interface IP addresses: `ferranet::interfaces()` returns
`Interface { name, index, mac, ips, flags, mtu }`, where each `IfAddr` carries the address, its
netmask, and a `prefix_len()`.

## Roadmap / TODO

Before a 1.0 this needs, roughly in priority order:

- [ ] **Real-hardware validation.** The `AF_PACKET` backends are validated on real hardware
      (Raspberry Pi Zero 2 W, `pibench` capture/flood). Still to do: the throughput benches and the
      `hw` test suite against cabled NICs (`FERRANET_BENCH_IFACE_*` / `FERRANET_HW_IFACE_*`) —
      the `BENCHMARKS.md` numbers are `veth`-only and forwarding-bound.
- [ ] **Finish AF_XDP.** The receive/transmit path is covered end-to-end over `veth` in generic
      mode (`tests/xdp_veth.rs`, redirect program loaded via `aya`). Still to do: a multi-queue
      NIC, zero-copy mode, and `need_wakeup`; integrate loading of the XDP redirect program so
      the backend is turnkey instead of bring-your-own.
- [ ] **API stabilisation.** Audit and settle the public surface; today it's unstable.
- [ ] **cBPF socket filters** (`SO_ATTACH_FILTER`) — optional in-kernel capture filtering.
- [ ] **Zero-copy transmit** — a `send_with(len, |slot| …)` builder to drop the user→ring copy.
- [ ] **Hardware timestamps** (`SO_TIMESTAMPING`/PHC) — only software RX timestamps today.
- [ ] **More `PACKET_FANOUT` control** — flags (defrag, rollover) and BPF-steered fanout.
- [ ] **Exact snap length**`snaplen` currently rounds up to a power-of-two frame size.
- [ ] **Other platforms** — BSD/macOS (BPF) and Windows behind the existing `RawChannel` boundary.
- [ ] **Continuous fuzzing** and a checked-in corpus; widen CI (MSRV, feature matrix).

Packet parsing/construction stays out of scope by design — pair ferranet with
[`etherparse`](https://crates.io/crates/etherparse) for dissection.

### AF_XDP (`xdp` feature)

```rust,no_run
# #[cfg(feature = "xdp")]
# fn run() -> ferranet::Result<()> {
use ferranet::{XdpConfig, XdpSocket, interfaces};

let idx = interfaces()?.into_iter().find(|i| i.name == "eth0").unwrap().index;
let mut sock = XdpSocket::open(idx, XdpConfig { queue_id: 0, ..XdpConfig::default() })?;
let block = sock.recv()?;                 // zero-copy frames from the UMEM
for frame in block.frames() { /* ... */ }
# Ok(())
# }
```

Unlike `AF_PACKET`, an AF_XDP socket only receives frames that an **XDP program redirects** into its
`XSKMAP` for the bound `(interface, queue)` — the kernel ships no default. `ferranet` builds and
drives the socket and rings; attaching the redirect program is left to the operator or a higher
layer (`libxdp`/`aya`/`xdp-loader`), exactly as `libxdp` and `xsk-rs` separate the socket from the
program. The data-path math is unit-tested; end-to-end use needs a real NIC/queue (or `veth` in
generic XDP mode) with a redirect program attached.

## Example

```rust,no_run
use ferranet::Channel;

#[tokio::main]
async fn main() -> ferranet::Result<()> {
    let (mut tx, mut rx) = Channel::builder("eth0").promiscuous(true).build_async()?;

    // Zero-copy, batched receive.
    let block = rx.recv_block().await?;
    for frame in block.frames() {
        println!("{} bytes, type {:?}", frame.data().len(), frame.packet_type());
    }

    // Send a raw frame (must include the Ethernet header for SOCK_RAW).
    tx.send(b"\xff\xff\xff\xff\xff\xff\x02\x00\x00\x00\x00\x01\x88\xb5hi").await?;
    Ok(())
}
```

A blocking equivalent is available via `Channel::builder(..).build_sync()`.

### Multi-core receive (PACKET_FANOUT)

Open a group of receivers and let the kernel load-balance frames across them — one per core:

```rust,no_run
use ferranet::{Channel, FanoutMode};

# fn main() -> ferranet::Result<()> {
let receivers = Channel::builder("eth0")
    .fanout(FanoutMode::Hash)        // flow-consistent steering
    .build_fanout_rx(4)?;            // 4 sockets in one fanout group

for mut rx in receivers {
    std::thread::spawn(move || {
        loop {
            let block = rx.recv_block()?;
            for frame in block.frames() { /* ... */ }
        }
        # #[allow(unreachable_code)] ferranet::Result::Ok(())
    });
}
# Ok(())
# }
```

`build_fanout_rx_async` returns `AsyncReceiver`s for the same pattern on Tokio.

## Permissions

Opening an `AF_PACKET` socket requires the `CAP_NET_RAW` capability. For local use:

```sh
# Grant the capability to a built binary:
sudo setcap cap_net_raw+ep ./target/debug/examples/sniff

# Or run inside an unprivileged user + network namespace:
unshare --user --map-root-user --net ./your-program
```

## Testing

Unit tests need no privileges:

```sh
cargo test
```

**Property tests** (`proptest`) cover the pure parsing/validation surfaces — MAC parsing round-trips,
ring-geometry invariants, snap-length derivation, and the `TPACKET_V3` block parser's correctness —
and run as part of `cargo test`.

**Fuzzing** (`cargo-fuzz`/libFuzzer) hammers the block parser — the one place ferranet does pointer
reads with bounds checks over variable input — with coverage-guided garbage, including out-of-range
offsets and adversarial frame counts:

```sh
cargo install cargo-fuzz
cargo +nightly fuzz run parse_block          # see fuzz/
```

**Miri** checks the pure-memory `unsafe` for undefined behaviour, out-of-bounds, and provenance
violations — chiefly the block parser's `read_unaligned`/offset math and frame construction, driven
over plain heap buffers:

```sh
cargo +nightly miri test --lib
```

Miri cannot interpret syscalls, so the FFI paths (sockets, `mmap`, `PACKET_*`, AF_XDP, interface
enumeration) are `#[ignore]`d under Miri and covered by the `veth`/`dummy`/`hw` tests instead.

For deterministic, privilege-free testing of packet handling, the `dummy` backend gives you a real
`Sender`/`Receiver` pair wired to in-memory FIFO queues instead of a NIC (a parity to libpnet's
`dummy` backend, and usable by downstream code the same way):

```rust,no_run
let mut net = ferranet::dummy::channel()?;
net.inject.inject(frame_bytes);              // queue a frame to be received
let block = net.rx.recv_block()?;            // ... and receive it
net.tx.send(other_frame)?;                   // send a frame ...
assert_eq!(net.drain.try_recv().unwrap(), other_frame); // ... and read what was sent
# Ok::<(), ferranet::Error>(())
```

It is backed by a pipe for readiness, so it works with the blocking API, `poll`, and the async API
alike. See `tests/dummy.rs` for the full suite (send/receive ordering, idle-blocking, injected
errors, end-of-stream).

End-to-end tests use a `veth` pair and are `#[ignore]`d by default. Run them inside an unprivileged
namespace, which provides `CAP_NET_RAW` without root. Build first (the namespace has no network, so
compilation must happen outside it), then run:

```sh
cargo test --test veth --no-run
unshare --user --map-root-user --net \
    env RUSTC_WRAPPER= cargo test --test veth -- --ignored --test-threads=1
```

To exercise **real hardware**, the `hw` suite (also `#[ignore]`d) runs roundtrip, VLAN, RX-timestamp,
and fanout-distribution checks against two cabled ports you name via the environment:

```sh
cargo test --test hw --no-run
sudo FERRANET_HW_IFACE_A=enp1s0f0 FERRANET_HW_IFACE_B=enp1s0f1 \
    ./target/debug/deps/hw-<hash> --ignored --test-threads=1
```

It skips cleanly when those vars are unset, so `cargo test` stays green everywhere.

## Low-power / embedded hardware

On constrained devices (small ARM SoCs, little RAM, small caches, single-queue NICs at low packet
rates) the goal is *less memory moved, smaller footprint, fewer wakeups, less CPU per frame* — not
peak Mpps. ferranet has several knobs for this:

```rust
use ferranet::{Channel, RingConfig};

let (mut tx, mut rx) = Channel::builder("eth0")
    // Small rings (~256 KiB vs the 8 MiB default) stay cache-resident and cut RAM ~32×.
    .rx_ring(RingConfig::small().snaplen(128))   // capture only headers
    .build_sync()?;
# Ok::<(), ferranet::Error>(())
```

- **`RingConfig::small()`** — a ~256 KiB RX ring instead of 8 MiB. Ample at low rates and keeps the
  ring hot in L2.
- **`RingConfig::snaplen(n)`** — for header-only monitoring, captures roughly the first `n` bytes
  per frame. The kernel truncates its copy into the ring, so far less memory/bandwidth is used and
  many more frames pack per block; `Frame::wire_len()` still reports the true length. The single
  biggest lever for header-parsing agents.
- **Lazy frame metadata**`timestamp()`/`vlan()`/`packet_type()` are decoded on access for
  ring-captured frames, so a loop that only reads `frame.data()` pays nothing for them.
- **Sync-only build** — depend with `default-features = false` to drop Tokio: smaller binary, no
  async reactor, fewer threads. The blocking `build_sync()` API is all you need for a capture loop.
- **Wakeup vs latency** — raise `RingConfig { retire_blk_tov_ms, .. }` (default 60 ms) to wake less
  often under light traffic (lower power), or lower it for snappier delivery.
- **One core, pinned** — on a single-queue NIC use a single channel (no `PACKET_FANOUT`) and pin the
  capture thread to a core; build with `-C target-cpu=<soc>` for the target.

### Detecting dropped packets

For all-packets capture, the thing that loses data is the ring overflowing — so drop detection is
first-class:

```rust
let block = rx.recv_block()?;
if block.is_losing() {                 // zero-syscall, inline: the kernel flagged drops
    let s = rx.stats()?;               // exact, cumulative counts (and clears the flag)
    eprintln!("dropped {} packets, {} ring freezes", s.dropped, s.freezes);
}
# Ok::<(), ferranet::Error>(())
```

- **`Block::is_losing()`** — reads the `TP_STATUS_LOSING` bit the kernel sets on a block when it has
  dropped packets. No syscall, checkable every receive.
- **`Receiver::stats()`** — monotonic cumulative `received` / `dropped` / `freezes` since the
  channel opened (ferranet accumulates the kernel's reset-on-read counter for you). Reading it also
  clears the losing flag, so the pattern is: `is_losing()` tells you *that* you're dropping,
  `stats()` tells you *how much*.

## Benchmarking

Performance is a primary goal, so the crate ships [Criterion](https://github.com/bheisler/criterion.rs)
benchmarks that produce statistically-sampled results plus an HTML report and SVG plots under
`target/criterion/`. Run everything with `scripts/bench.sh`, or individually:

```sh
cargo bench --bench parse                                   # privilege-free parse hot loop
cargo bench --bench throughput --no-run                     # build outside the namespace…
unshare --user --map-root-user --net env RUSTC_WRAPPER= cargo bench --bench throughput   # …run inside it
# open target/criterion/report/index.html
```

Headline numbers on a `veth` pair, including a same-harness comparison against **libpnet**'s real
datalink channel (`pnet_datalink` 0.35). Full results, methodology, and honest caveats in
[`BENCHMARKS.md`](BENCHMARKS.md):

- **RX: ferranet's zero-copy ring is ~3× libpnet** (1.90 vs 0.61 Mpps at 64 B), drops nothing under
  overload, and clears **20+ Gbit/s single-core at MTU** (libpnet ~7). Even ferranet's *basic*
  backend beats libpnet ~1.8× (libpnet polls per packet).
- **TX: ferranet ~1.45× libpnet** (0.68 vs 0.47 Mpps); over `veth` all backends are
  forwarding-bound, so the ring's batched-flush win is partly masked and widens on a real NIC.
- **`PACKET_FANOUT` distributes perfectly evenly** across N receivers (e.g. `[942, 940, 942, 945]` k
  for N=4), parallelizing RX across cores. Aggregate scaling needs a multi-queue NIC; a single-queue
  `veth` caps the demo.
- Next tier is **AF_XDP** (~10–20× over `AF_PACKET`), a planned backend behind the same boundary.

## License

MIT OR Apache-2.0.