ndn-rs Wiki
Pre-release. ndn-rs is working toward its first stable tag (
v0.1.0). The workspace version reads0.1.0, but no git tag or GitHub Release has been published yet — this wiki documentsmain. Pullghcr.io/quarmire/ndn-fwd:latestor build from source to try the current state. See the draft 0.1.0 release notes for the planned scope.
ndn-rs is a Named Data Networking (NDN) forwarder stack written in Rust. It models NDN as composable data pipelines with trait-based polymorphism, departing from the class hierarchy approach of NFD/ndn-cxx.
What is NDN?
Named Data Networking is a network architecture where communication is driven by named data rather than host addresses. Consumers request data by name (Interest packets), and the network locates and returns the data (Data packets). Every Data packet is cryptographically signed by its producer, enabling in-network caching and security that travels with the data.
Why ndn-rs?
- Library, not daemon –
ForwarderEngineembeds in any Rust application - Zero-copy pipeline – wire-format
Bytesflow from recv to send without re-encoding - Compile-time safety – packet ownership through the pipeline prevents use-after-short-circuit;
SafeDatatypestate enforces verification - Concurrent data structures –
DashMapPIT,RwLock-per-node FIB trie, sharded CS - Pluggable everything – faces, strategies, CS backends, and pipeline stages via traits
- Embedded to server –
no_stdTLV and packet crates run on Cortex-M; same code scales to multi-core routers
Navigating This Wiki
| Section | For… |
|---|---|
| Getting Started | Building, running, first program |
| Concepts | NDN fundamentals and ndn-rs data structures |
| Design | Architecture decisions and comparisons with NFD/ndnd |
| Deep Dive | Detailed walkthroughs of subsystems |
| Guides | How to extend ndn-rs |
| Benchmarks | Performance data and methodology |
| Reference | Spec compliance, external links |
| Explorer | Interactive crate map and pipeline visualizer |
Crate Map
Binaries ndn-fwd ndn-tools ndn-bench
| | |
Engine/App ndn-engine ndn-app ndn-ipc ndn-config ndn-discovery
| | |
Pipeline ndn-engine ndn-strategy ndn-security
| |
Faces ndn-faces ndn-faces ndn-faces ndn-faces
| |
Foundation ndn-store ndn-transport ndn-packet ndn-tlv
|
Embedded ndn-embedded
Research ndn-sim ndn-compute ndn-sync ndn-research ndn-strategy-wasm
Dependencies flow strictly downward. ndn-tlv and ndn-packet compile no_std.
{
"columns": [
{ "label": "Foundation", "nodes": [
{"id": "ndn-tlv"}, {"id": "ndn-packet"}, {"id": "ndn-store"},
{"id": "ndn-transport"}, {"id": "ndn-embedded"}
]},
{ "label": "Faces", "nodes": [
{"id": "ndn-faces"}, {"id": "ndn-faces"},
{"id": "ndn-faces"}, {"id": "ndn-faces"}
]},
{ "label": "Pipeline & Strategy", "nodes": [
{"id": "ndn-engine"}, {"id": "ndn-strategy"}, {"id": "ndn-security"}
]},
{ "label": "Engine & App", "nodes": [
{"id": "ndn-engine"}, {"id": "ndn-app"}, {"id": "ndn-ipc"},
{"id": "ndn-config"}, {"id": "ndn-discovery"}
]},
{ "label": "Binaries", "nodes": [
{"id": "ndn-fwd"}, {"id": "ndn-tools"}, {"id": "ndn-bench"}
]}
],
"satellites": {
"label": "Research (depend on engine / app / strategy)",
"nodes": [
{"id": "ndn-sim"}, {"id": "ndn-compute"},
{"id": "ndn-sync"}, {"id": "ndn-strategy-wasm"}
]
},
"edges": [
["ndn-tlv", "ndn-packet"],
["ndn-tlv", "ndn-embedded"],
["ndn-packet", "ndn-store"],
["ndn-packet", "ndn-transport"],
["ndn-transport", "ndn-faces"],
["ndn-transport", "ndn-faces"],
["ndn-transport", "ndn-faces"],
["ndn-transport", "ndn-faces"],
["ndn-faces", "ndn-engine"],
["ndn-faces", "ndn-engine"],
["ndn-faces", "ndn-engine"],
["ndn-faces", "ndn-engine"],
["ndn-store", "ndn-engine"],
["ndn-engine", "ndn-strategy"],
["ndn-engine", "ndn-security"],
["ndn-strategy", "ndn-engine"],
["ndn-security", "ndn-engine"],
["ndn-engine", "ndn-ipc"],
["ndn-engine", "ndn-discovery"],
["ndn-engine", "ndn-fwd"],
["ndn-engine", "ndn-tools"],
["ndn-engine", "ndn-bench"],
["ndn-app", "ndn-ipc"],
["ndn-app", "ndn-tools"],
["ndn-app", "ndn-bench"],
["ndn-config", "ndn-fwd"]
],
"satellite_edges": [
["ndn-sim", "ndn-engine"],
["ndn-compute", "ndn-engine"],
["ndn-sync", "ndn-app"],
["ndn-strategy-wasm", "ndn-strategy"]
]
}
Installation
This page covers how to build ndn-rs from source and install its binaries.
Prerequisites
- Rust 2024 edition – install via rustup. The workspace uses
edition = "2024", so you need a nightly or recent stable toolchain that supports it. - cargo – ships with rustup.
- Linux or macOS – some face types (raw Ethernet, SHM) are Unix-only. The core library compiles on Windows but network tooling is limited.
Clone and build
git clone https://github.com/user/ndn-rs.git # replace with actual URL
cd ndn-rs
# Build the entire workspace
cargo build
# Run all tests
cargo test
# Lint (treat warnings as errors)
cargo clippy -- -D warnings
# Format check
cargo fmt -- --check
Building the forwarder binary
The standalone forwarder lives in binaries/ndn-fwd:
cargo build -p ndn-fwd
The compiled binary is at target/debug/ndn-fwd (or target/release/ndn-fwd with --release).
To build the CLI tools (ping, peek, put, ctl, traffic, iperf):
cargo build -p ndn-tools
This produces several binaries: ndn-ping, ndn-peek, ndn-put, ndn-ctl, ndn-traffic, and ndn-iperf.
Optional features
ndn-fwd features
The forwarder binary has three optional feature flags, all enabled by default:
| Feature | Description | Gate |
|---|---|---|
spsc-shm | Shared-memory data plane between apps and forwarder (Unix only) | ndn-faces/spsc-shm |
websocket | WebSocket face for browser and remote clients | ndn-faces/websocket |
serial | Serial port face (RS-232 / USB-serial) | ndn-faces/serial |
To build without WebSocket support, for example:
cargo build -p ndn-fwd --no-default-features --features spsc-shm,serial
ndn-store features
The content store crate has an optional persistent backend:
| Feature | Description |
|---|---|
fjall | Persistent content store backed by fjall |
Enable it from a dependent crate or when running benchmarks:
cargo test -p ndn-store --features fjall
Running the forwarder
Copy the example configuration and adjust it for your environment:
cp ndn-fwd.example.toml ndn-fwd.toml
# Start the forwarder (needs sudo for raw sockets / privileged ports)
sudo ./target/debug/ndn-fwd --config ndn-fwd.toml
The config file can also be specified via the NDN_CONFIG environment variable:
sudo NDN_CONFIG=ndn-fwd.toml ./target/release/ndn-fwd
The log level defaults to info and can be overridden at runtime:
sudo ./target/release/ndn-fwd --config ndn-fwd.toml --log-level debug
Or via the standard RUST_LOG environment variable:
sudo RUST_LOG=ndn_engine=debug ./target/release/ndn-fwd --config ndn-fwd.toml
See Running the Forwarder for a detailed configuration walkthrough.
Verifying the installation
After the forwarder starts, you should see log output indicating that faces are created and the pipeline is running. Use ndn-ctl to confirm the forwarder is alive:
ndn-ctl status
This connects to the forwarder’s management socket (default /run/nfd/nfd.sock) and prints forwarding engine state.
Hello World
This tutorial shows how to run a complete Interest/Data exchange using the ndn-rs library in a single Rust program. No external router is needed – everything runs in-process via InProcFace channel pairs.
How it works
In NDN, communication follows a pull-based model: a consumer sends an Interest packet naming the data it wants, and a producer responds with a matching Data packet. The forwarding engine sits between them, matching Interests to Data via the PIT (Pending Interest Table) and FIB (Forwarding Information Base).
💡 Key insight: No external router is needed for this example. The
InProcFacecreates an in-process channel pair – one side for the application, one side for the engine. Packets flow through the full forwarding pipeline (PIT, FIB, CS, strategy) but never touch the network. This is the same mechanism used for production in-process applications.
sequenceDiagram
participant C as Consumer
participant E as Engine (PIT + FIB)
participant P as Producer
C->>E: Interest /ndn/hello
Note over E: PIT entry created<br/>FIB lookup -> Producer face
E->>P: Interest /ndn/hello
P->>E: Data /ndn/hello "hello, NDN!"
Note over E: PIT entry satisfied<br/>Forward Data to Consumer face
E->>C: Data /ndn/hello "hello, NDN!"
graph LR
subgraph "Application Process"
APP["Application code<br/>(Consumer / Producer)"]
end
subgraph "InProcFace Channel Pair"
direction TB
TX1["app_handle.send()"] -->|"mpsc"| RX1["engine face.recv()"]
TX2["engine face.send()"] -->|"mpsc"| RX2["app_handle.recv()"]
end
subgraph "ForwarderEngine"
PIPE["Pipeline<br/>(FIB + PIT + CS + Strategy)"]
end
APP -->|"Interest"| TX1
RX1 -->|"Interest"| PIPE
PIPE -->|"Data"| TX2
RX2 -->|"Data"| APP
style APP fill:#e8f4fd,stroke:#2196F3
style PIPE fill:#fff3e0,stroke:#FF9800
Dependencies
Add these to your Cargo.toml:
[dependencies]
ndn-app = { path = "crates/engine/ndn-app" }
ndn-engine = { path = "crates/engine/ndn-engine" }
ndn-faces = { path = "crates/faces/ndn-faces" }
ndn-packet = { path = "crates/foundation/ndn-packet", features = ["std"] }
ndn-transport = { path = "crates/foundation/ndn-transport" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
If you are working within the ndn-rs workspace, use workspace = true instead of path dependencies.
Full example
use ndn_app::{Consumer, EngineBuilder, Producer};
use ndn_engine::EngineConfig;
use ndn_faces::local::InProcFace;
use ndn_packet::Name;
use ndn_packet::encode::DataBuilder;
use ndn_store::FibNexthop;
use ndn_transport::FaceId;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Create in-process face pairs.
// Each InProcFace::new() returns a face (for the engine) and a handle
// (for the application). They are connected by an mpsc channel.
let (consumer_face, consumer_handle) = InProcFace::new(FaceId(1), 64);
let (producer_face, producer_handle) = InProcFace::new(FaceId(2), 64);
// 2. Build the forwarding engine with both faces.
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.face(consumer_face)
.face(producer_face)
.build()
.await?;
// 3. Install a FIB route: Interests for /ndn/hello -> producer face.
let prefix: Name = "/ndn/hello".parse()?;
engine.fib().add_nexthop(&prefix, FibNexthop { face_id: 2, cost: 0 });
// 4. Create Consumer and Producer from their handles.
let mut consumer = Consumer::from_handle(consumer_handle);
let mut producer = Producer::from_handle(producer_handle, prefix.clone());
// 5. Spawn the producer in a background task.
// It loops, waiting for Interests and replying with Data.
let producer_task = tokio::spawn(async move {
producer
.serve(|interest| {
let name = (*interest.name).clone();
async move {
let wire = DataBuilder::new(name, b"hello, NDN!").build();
Some(wire)
}
})
.await
});
// 6. Consumer sends an Interest and waits for the Data reply.
let data = consumer.fetch(prefix.clone()).await?;
println!("Received Data: {}", data.name);
println!("Content: {:?}", std::str::from_utf8(
data.content().unwrap().as_ref()
));
// 7. Clean shutdown: drop consumer/engine, then await the shutdown handle.
drop(consumer);
drop(engine);
shutdown.shutdown().await;
let _ = producer_task.await;
Ok(())
}
Step-by-step walkthrough
1. Create InProcFace pairs
InProcFace::new(face_id, capacity) creates a face for the engine side and a handle for the application side, connected by a bounded channel. The capacity parameter controls backpressure – 64 is a good default.
2. Build the engine
EngineBuilder wires the PIT, FIB, content store, pipeline stages, and strategy table. Calling .face(f) registers a face with the engine. .build().await returns the running ForwarderEngine and a ShutdownHandle.
🔧 Implementation note:
EngineBuilderuses sensible defaults for everything:LruCsfor caching,BestRouteStrategyat the root prefix, and the standard Interest/Data pipeline stages. You only need to configure what you want to customize. The builder pattern ensures the engine is fully wired before it starts processing packets.
%%{init: {"layout": "elk"}}%%
graph TD
EB["EngineBuilder::new(config)"]
EB -->|".face(consumer_face)"| F1["Face: Consumer"]
EB -->|".face(producer_face)"| F2["Face: Producer"]
EB -->|"default"| CS["ContentStore (LruCs)"]
EB -->|"default"| PIT["PIT (DashMap)"]
EB -->|"default"| FIB["FIB (NameTrie)"]
EB -->|"default"| ST["StrategyTable"]
EB -->|"default"| PIPE["Pipeline Stages"]
EB ==>|".build().await"| ENGINE["ForwarderEngine"]
EB ==>|".build().await"| SH["ShutdownHandle"]
ENGINE --- F1
ENGINE --- F2
ENGINE --- CS
ENGINE --- PIT
ENGINE --- FIB
ENGINE --- ST
ENGINE --- PIPE
style EB fill:#e8f4fd,stroke:#2196F3
style ENGINE fill:#c8e6c9,stroke:#4CAF50
style SH fill:#fce4ec,stroke:#E91E63
3. Add a FIB route
The FIB maps name prefixes to outgoing faces. add_nexthop(&prefix, FibNexthop { face_id, cost }) tells the engine: “forward Interests matching this prefix to this face.” Cost is used when multiple nexthops exist (lower wins).
4. Consumer and Producer
Consumer::from_handle(handle)wraps the application-side handle with methods likefetch()andget().Producer::from_handle(handle, prefix)wraps the handle with aserve()loop that dispatches incoming Interests to a callback.
5. The exchange
When consumer.fetch(name) is called, it builds an Interest packet, sends it through the InProcFace channel into the engine, which looks up the FIB, finds the producer face, and forwards the Interest. The producer’s serve() callback receives it, builds a Data packet, and sends it back through the engine to the consumer.
Connecting to an external router
If you have a running ndn-fwd instead of an embedded engine, applications connect via the router’s face socket:
use ndn_app::Consumer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Connect to the router's management socket.
let mut consumer = Consumer::connect("/run/nfd/nfd.sock").await?;
let data = consumer.fetch("/ndn/hello").await?;
println!("Got: {:?}", data.content());
Ok(())
}
This uses a Unix socket (with optional SHM data plane) instead of in-process channels, but the Consumer API is identical.
Next steps
- Running the Forwarder – deploy
ndn-fwdas a standalone forwarder - PIT, FIB, and Content Store – understand the data structures behind the exchange
- Pipeline Walkthrough – trace a packet through every pipeline stage
Running the Forwarder
This page explains how to configure and run ndn-fwd as a standalone NDN forwarder, connect applications and tools to it, and monitor its state.
Architecture overview
The forwarder is the central forwarding engine. Applications, remote peers, and management tools connect to it through various face types:
%%{init: {"layout": "elk"}}%%
graph TB
subgraph "ndn-fwd"
E[Forwarding Engine]
PIT[PIT]
FIB[FIB]
CS[Content Store]
E --- PIT
E --- FIB
E --- CS
end
subgraph "Local Applications"
App1["ndn-ping<br/>(SHM + Unix socket)"]
App2["Custom App<br/>(SHM + Unix socket)"]
end
subgraph "Remote Peers"
R1["Router B<br/>(UDP 6363)"]
R2["Router C<br/>(TCP 6363)"]
end
subgraph "Link-Local"
MC["Multicast Peers<br/>(224.0.23.170:56363)"]
end
subgraph "Browser / Remote"
WS["Web Client<br/>(WebSocket 9696)"]
end
subgraph "Management"
CTL["ndn-ctl"]
DASH["ndn-dashboard"]
end
App1 <-->|face socket| E
App2 <-->|face socket| E
R1 <-->|UDP| E
R2 <-->|TCP| E
MC <-->|UDP multicast| E
WS <-->|WebSocket| E
CTL -->|face socket| E
DASH -->|WebSocket / NDN| E
Configuration file
ndn-fwd reads a TOML configuration file. Start from the provided example:
cp ndn-fwd.example.toml ndn-fwd.toml
The file is loaded via --config (or -c) or the NDN_CONFIG environment variable.
Minimal configuration
🎯 Tip: The three most impactful config options are
cs_capacity_mb(how much RAM to dedicate to caching), the[[face]]entries (which transports to enable), and the[[route]]entries (where to forward Interests). Start with the minimal config below and add complexity as needed.
A minimal config to get started with UDP and multicast:
[engine]
cs_capacity_mb = 64
# UDP unicast face -- listen for incoming packets
[[face]]
kind = "udp"
bind = "0.0.0.0:6363"
# IPv4 multicast face -- link-local neighbor discovery
[[face]]
kind = "multicast"
group = "224.0.23.170"
port = 56363
# Static route: forward all /ndn Interests out the UDP face
[[route]]
prefix = "/ndn"
face = 0
cost = 10
[management]
transport = "ndn"
face_socket = "/run/nfd/nfd.sock"
[logging]
level = "info"
Full face type reference
UDP unicast
Point-to-point or open UDP face on port 6363 (the NDN default):
[[face]]
kind = "udp"
bind = "0.0.0.0:6363"
# Optional: restrict to a single remote peer
# remote = "10.0.0.1:6363"
TCP
Outbound connection to a remote router:
[[face]]
kind = "tcp"
remote = "192.168.1.1:6363"
# Optional: local bind address
# bind = "0.0.0.0:6363"
Multicast
Link-local neighbor discovery using the IANA-assigned NDN multicast group:
[[face]]
kind = "multicast"
group = "224.0.23.170"
port = 56363
# Optional: bind to a specific interface
# interface = "eth0"
Raw Ethernet multicast
Uses EtherType 0x8624 (IANA-assigned for NDN). Requires CAP_NET_RAW on Linux or root on macOS:
[[face]]
kind = "ether-multicast"
interface = "eth0"
Unix domain socket
Local app connectivity on Linux/macOS:
[[face]]
kind = "unix"
path = "/tmp/ndn-app.sock"
WebSocket
For browser clients or remote NDN-WS connections. Requires the websocket feature (enabled by default):
# Listen mode
[[face]]
kind = "web-socket"
bind = "0.0.0.0:9696"
# Or connect mode (mutually exclusive with bind)
# [[face]]
# kind = "web-socket"
# url = "ws://remote:9696"
Serial
Point-to-point over RS-232 / USB-serial. Requires the serial feature (enabled by default):
[[face]]
kind = "serial"
path = "/dev/ttyUSB0"
baud = 115200
Content store configuration
[cs]
variant = "lru" # "lru", "sharded-lru", or "null"
capacity_mb = 64
# shards = 4 # only for "sharded-lru"
admission_policy = "default" # "default" or "admit-all"
Static routes
Routes pre-load the FIB at startup. The face field is a zero-based index into the [[face]] list:
[[route]]
prefix = "/ndn"
face = 0
cost = 10
[[route]]
prefix = "/localhop"
face = 1
cost = 5
Routes can also be added at runtime via ndn-ctl.
Discovery
Enable neighbor discovery and service announcement:
[discovery]
node_name = "/ndn/site/router1"
profile = "lan" # "static", "lan", "campus", "mobile", etc.
served_prefixes = ["/ndn/site/sensors"]
Starting the forwarder
⚠️ Important:
sudois required when the forwarder uses raw sockets (Ethernet faces), privileged ports (UDP/TCP on port 6363 < 1024 on some systems), or multicast group membership. If you only use Unix socket faces and high-numbered ports,sudois not needed. On Linux, you can alternatively grantCAP_NET_RAWandCAP_NET_BIND_SERVICEcapabilities instead of running as root.
# Build in release mode for production
cargo build -p ndn-fwd --release
# Start (sudo required for raw sockets and privileged ports)
sudo ./target/release/ndn-fwd --config ndn-fwd.toml
Override the log level at runtime:
sudo ./target/release/ndn-fwd --config ndn-fwd.toml --log-level debug
# Or with RUST_LOG for per-crate control
sudo RUST_LOG="info,ndn_engine=debug,ndn_discovery=trace" \
./target/release/ndn-fwd --config ndn-fwd.toml
Connecting tools
ndn-ctl
ndn-ctl is the management CLI, similar to NFD’s nfdc. It connects to the forwarder’s face socket and sends management commands as NDN Interest/Data:
# Check forwarder status
ndn-ctl status
# List active faces
ndn-ctl face list
# Create a new UDP face at runtime
ndn-ctl face create udp4://192.168.1.1:6363
# Add a route
ndn-ctl route add /ndn --face 1 --cost 10
# List routes
ndn-ctl route list
# View content store info
ndn-ctl cs info
# List discovered neighbors
ndn-ctl neighbors list
# Announce / withdraw service prefixes
ndn-ctl service announce /ndn/myapp
ndn-ctl service withdraw /ndn/myapp
# Browse discovered services
ndn-ctl service browse
# Set forwarding strategy for a prefix
ndn-ctl strategy set /ndn --strategy /localhost/nfd/strategy/best-route
# Graceful shutdown
ndn-ctl shutdown
ndn-ping
Measure round-trip time to a named prefix. Run the server on one machine and the client on another (or the same machine):
# Server: register /ping and respond to ping Interests
sudo ndn-ping server --prefix /ping
# Client: send ping Interests and measure RTT
ndn-ping client --prefix /ping --count 10 --interval 1000
ndn-peek and ndn-put
Fetch or publish a single named Data packet:
# Fetch a packet by name
ndn-peek /ndn/example/data --timeout-ms 4000
# Publish a packet (another terminal)
ndn-put /ndn/example/data --content "hello"
ndn-iperf
Throughput measurement between two NDN endpoints:
# Server
sudo ndn-iperf server --prefix /iperf
# Client
ndn-iperf client --prefix /iperf --duration 10
Monitoring with ndn-dashboard
ndn-dashboard is a native desktop application (built with Dioxus) for managing and monitoring NDN forwarders. It communicates with the forwarder via the NDN management protocol over the face socket.
cargo build -p ndn-dashboard --release
./target/release/ndn-dashboard
The dashboard provides:
- Start Forwarder – launch
ndn-fwdas a managed subprocess with one of:- Quick Start (built-in defaults)
- Build Config – interactive config builder with startup faces, startup routes, CS settings, and log level
- Load Config File – point to an existing TOML file
- Saved Presets – one-click relaunch of saved configurations
- Overview – engine status, packet counters, throughput graphs
- Faces – active faces with per-face traffic statistics
- Routes – FIB entries and nexthop costs
- Content Store – cache occupancy and hit/miss rates
- Strategy – per-prefix forwarding strategy assignments
- Config – view and edit forwarder configuration; edit startup faces and routes; restart the managed forwarder with an updated config via ↺ Restart with Config
- Logs – real-time log viewer with filter and split-pane modes
- Tools – embedded
ndn-ping,ndn-iperf,ndn-peek, andndn-put - Light/Dark mode – toggle via the ☀/🌙 button in the toolbar
The dashboard connects to the forwarder’s face socket (default /run/nfd/nfd.sock). If the forwarder is started through the dashboard, log output is captured in the Logs view automatically.
Typical deployment
A common LAN deployment with two forwarders and local apps:
# Router A (10.0.0.1) -- ndn-fwd.toml:
# UDP face on :6363
# Multicast face on 224.0.23.170:56363
# Route /ndn -> UDP face
# Discovery enabled with node_name = "/ndn/site/routerA"
# Router B (10.0.0.2) -- same config with:
# remote = "10.0.0.1:6363" on the UDP face for a static tunnel
# node_name = "/ndn/site/routerB"
# On Router A, start a ping server:
sudo ndn-ping server --prefix /ndn/site/routerA/ping
# From Router B, ping across the link:
ndn-ping client --prefix /ndn/site/routerA/ping --count 5
Next steps
- Installation – build options and feature flags
- Hello World – embedded engine tutorial
- Discovery Protocols – how neighbor and service discovery work
- Performance Tuning – optimize
pipeline_threads, CS sharding, and SHM
NDN Overview for IP Developers
What If the Network Knew About Data?
Every network you have ever used works the same way: you ask to talk to a machine, and the network tries to connect you. “Send these bytes to 10.0.0.5.” The network does not know or care what those bytes mean – it just shuffles them toward an address. If the machine is down, you get nothing. If a copy of exactly what you need is sitting on a router one hop away, the network ignores it and dutifully tries to reach the original host anyway.
Named Data Networking starts from a different question: what if you could just ask for the data by name?
Instead of “connect me to host X,” you say “I need /ucla/papers/2024/ndn-overview.” Any node in the network that has a copy – the original producer, a router that cached it earlier, a nearby peer – can answer. The data itself carries a cryptographic signature from its producer, so it does not matter where it came from. You can verify it is authentic regardless.
This is not just a caching layer bolted onto IP. It is a fundamental architectural inversion: from “where is the host?” to “what is the data?” – and it changes everything about how forwarding, security, and multicast work.
NDN was conceived by Van Jacobson and developed into a full architecture by the NDN project team led by Lixia Zhang at UCLA, with collaborators across multiple universities and research labs.

The Core Shift: Addresses vs. Names
If you are coming from IP networking, the easiest way to feel the difference is to look at the same scenario through both lenses:
| IP Networking | Named Data Networking |
|---|---|
“Connect to host 10.0.0.5 and fetch /index.html” | “Fetch /example/site/index.html” |
| Security applied to the channel (TLS) | Security applied to the data itself (signature on every Data packet) |
| Caching requires explicit infrastructure (CDN) | Every router can cache and re-serve data natively |
| Routing tables map address prefixes to next hops | FIB maps name prefixes to next hops |
| No built-in multicast or aggregation | Duplicate Interests are aggregated automatically (PIT) |
The following diagram illustrates the contrast. In IP, the router is stateless and oblivious to content. In NDN, the router maintains a Pending Interest Table (PIT), a Content Store (CS), and a Forwarding Information Base (FIB) – it understands what data flows through it.
flowchart LR
subgraph IP["IP Model: Where do I send it?"]
direction LR
srcA["Host A\n10.0.0.1"] -->|"src: 10.0.0.1\ndst: 10.0.0.5"| routerIP["Router\n(stateless forward)"]
routerIP -->|"src: 10.0.0.1\ndst: 10.0.0.5"| dstB["Host B\n10.0.0.5"]
dstB -->|"response\nsrc: 10.0.0.5\ndst: 10.0.0.1"| routerIP
routerIP --> srcA
end
subgraph NDN["NDN Model: What data do I need?"]
direction LR
consumer["Consumer"] -->|"Interest\n/app/video/frame1"| routerNDN["Router\n(PIT + CS + FIB)"]
routerNDN -->|"Interest\n/app/video/frame1"| producer["Producer"]
producer -->|"Data\n/app/video/frame1\n+ signature"| routerNDN
routerNDN -->|"Data\n/app/video/frame1\n(cached for next request)"| consumer
end
The Two Packet Types
NDN’s network layer has exactly two packet types. That is not a simplification for this overview – it is the actual design.
An Interest is a request: “I want the data named X.” A consumer sends it into the network, and the network figures out where to find a copy. A Data packet is the answer: “Here is the data named X, signed by producer Y.” It travels back along the exact reverse path the Interest took, because the network remembers where each Interest came from.
There is no separate routing protocol for return traffic, no connection state, and no session layer. The Interest leaves breadcrumbs (PIT entries) on its way in, and the Data follows them back out.
How Data Stays Trustworthy Without Trusted Channels
In IP, you secure the pipe: TLS encrypts the channel between two endpoints, so you trust the data because you trust the connection. But this breaks down when data is cached, replicated, or served by a third party. Who signed the TLS certificate for a CDN edge node you have never heard of?
NDN sidesteps this entirely. Every Data packet carries a cryptographic signature from its original producer. A cached copy served by a router three hops away is exactly as trustworthy as one served by the producer directly – the consumer verifies the signature against a trust schema and either accepts or rejects the data, regardless of where it came from.
In ndn-rs, this principle is encoded into the type system. The SafeData type can only be constructed after signature verification succeeds. Application callbacks receive SafeData, never raw unverified Data. The compiler itself prevents you from forwarding unverified content.
Inside the Forwarder
An NDN forwarder is more sophisticated than an IP router. Where an IP router has a single table (the routing table) and is stateless per-packet, an NDN forwarder maintains three core data structures that work together on every packet:

Content Store (CS): In-Network Caching
Every NDN forwarder – not just dedicated cache servers – can store Data packets it has forwarded and serve them to future Interests for the same name. When an Interest arrives and the CS has a match, the forwarder responds immediately without forwarding the Interest upstream. In ndn-rs, the CS is trait-based (ContentStore) with pluggable backends: LRU, sharded, and persistent (RocksDB/redb).
Pending Interest Table (PIT): Stateful Forwarding
When an Interest arrives and the CS does not have the data, the forwarder creates a PIT entry recording which face the Interest came from and forwards it upstream. If a second Interest for the same name arrives before the Data comes back, the forwarder simply adds the new face to the existing PIT entry – it does not forward a duplicate Interest. When the Data finally arrives, the forwarder sends it back to all faces listed in the PIT entry, then removes the entry.
This built-in aggregation is one of NDN’s most powerful features. It provides native multicast, loop prevention, and congestion control at the network layer.
Forwarding Information Base (FIB): Name-Based Routing
The FIB maps name prefixes to outgoing faces, just as an IP routing table maps address prefixes to next hops. The key difference is longest-prefix match on hierarchical names rather than IP addresses. In ndn-rs, the FIB is a name trie with HashMap<Component, Arc<RwLock<TrieNode>>> per level, enabling concurrent longest-prefix match without holding parent locks.
Strategy Layer: Adaptive Forwarding
Instead of a single forwarding algorithm, NDN allows per-prefix forwarding strategies. A strategy decides which nexthop(s) to use, whether to probe alternative paths, whether to retry on a different face after a timeout, or whether to suppress forwarding entirely. This makes NDN forwarding adaptive in a way that IP routing generally is not.
In ndn-rs, a name trie (parallel to the FIB) maps prefixes to Arc<dyn Strategy> implementations. Strategies receive an immutable StrategyContext and return a ForwardingAction.
Packet Flow: A Complete Example
Let us trace what happens when a consumer requests data, step by step. The first request goes all the way to the producer. The second request for the same data is satisfied from the router’s cache.
sequenceDiagram
participant C as Consumer
participant R as Router
participant P as Producer
Note over R: PIT and CS are empty
C->>R: Interest /app/data/1
Note over R: CS lookup: miss
Note over R: PIT insert: record consumer face
Note over R: FIB lookup: /app -> producer face
R->>P: Interest /app/data/1
P->>R: Data /app/data/1 [signed]
Note over R: PIT match: found entry
Note over R: CS insert: cache the Data
Note over R: Forward to all PIT in-record faces
R->>C: Data /app/data/1 [signed]
Note over C: Verify signature against trust schema
C->>R: Interest /app/data/1 (again)
Note over R: CS lookup: hit!
R->>C: Data /app/data/1 [from cache]
Note over R: No PIT entry needed, no upstream forwarding
And here is how all three data structures cooperate within a single forwarder node. Notice how an Interest that hits the CS never touches the PIT or FIB, and how unsolicited Data (with no matching PIT entry) is dropped:
flowchart TD
interest["Incoming Interest\n/app/video/frame1"] --> cs_check{"Content Store\n(CS)"}
cs_check -->|"HIT: cached Data found"| reply["Return cached Data\nto incoming face"]
cs_check -->|"MISS"| pit_check{"Pending Interest\nTable (PIT)"}
pit_check -->|"Existing entry:\naggregate Interest\n(add in-record)"| suppress["Suppress\n(do not forward again)"]
pit_check -->|"New entry:\ncreate PIT entry"| fib_lookup{"Forwarding Information\nBase (FIB)"}
fib_lookup -->|"Longest-prefix match\n/app -> face 3, cost 10\n/app/video -> face 5, cost 5"| strategy["Strategy selects\nnexthop(s)"]
strategy --> forward["Forward Interest\nupstream"]
data["Incoming Data\n/app/video/frame1"] --> pit_match{"PIT Lookup"}
pit_match -->|"No match"| drop["Drop\n(unsolicited)"]
pit_match -->|"Match found"| cs_insert["Insert into CS"]
cs_insert --> fan_out["Send Data to all\nPIT in-record faces"]
fan_out --> consume["Remove PIT entry"]
NDN in Rust: Why the Fit Is Natural
If you have read this far, you might have noticed something: NDN’s architecture is full of shared ownership, concurrent access, and type-level safety guarantees. These are exactly the problems Rust’s ownership model and trait system are designed to solve.
ndn-rs is not a port of C++ code (like ndn-cxx/NFD) or Go code (like ndnd). It is a ground-up design that uses Rust’s strengths to express NDN concepts directly:
Arc<Name>– names are shared across PIT, FIB, and pipeline stages without copying. Rust’s reference counting ensures they are freed exactly when no longer needed.bytes::Bytes– zero-copy slicing for TLV parsing and Content Store storage. A cached Data packet in the CS is the same allocation that came off the wire.DashMapfor PIT – sharded concurrent access with no global lock on the hot path. Multiple pipeline tasks process packets in parallel without contention.PipelineStagetrait – each processing step (decode, CS lookup, PIT check, strategy, dispatch) is a composable trait object. The pipeline is a fixed sequence of stages, optimized by the compiler.SafeDatanewtype – the compiler prevents unverified data from being forwarded. This is not a runtime check you might forget; it is a type error.- Trait-based
ContentStore– swap cache backends (LRU, sharded, persistent) without changing the pipeline.
The architecture is documented in detail in ARCHITECTURE.md, and the next pages in this section cover the Interest/Data lifecycle through the pipeline, the PIT, FIB, and CS data structures, and a glossary of NDN terms.
Further Reading
The foundational papers and resources for understanding NDN:
- Van Jacobson, Diana K. Smetters, James D. Thornton, Michael F. Plass, Nicholas H. Briggs, and Rebecca L. Braynard. “Networking Named Content.” Proceedings of the 5th ACM International Conference on Emerging Networking Experiments and Technologies (CoNEXT), 2009. – The paper that started it all.
- Lixia Zhang, Alexander Afanasyev, Jeffrey Burke, Van Jacobson, kc claffy, Patrick Crowley, Christos Papadopoulos, Lan Wang, and Beichuan Zhang. “Named Data Networking.” ACM SIGCOMM Computer Communication Review, 44(3):66-73, 2014. – The NDN project paper describing the full architecture.
- NDN Publications – The complete list of NDN project publications covering security, routing, applications, and more.
- named-data.net – The NDN project homepage.
Interest and Data Lifecycle
Every NDN packet that enters ndn-rs follows a carefully choreographed journey. Let’s trace an Interest from the moment it arrives as raw bytes to the moment matching Data flows back to the consumer.
The Pipeline Machine
At the core of this journey is a pipeline – a fixed sequence of PipelineStage trait objects determined at build time so the compiler can monomorphize the hot path. A runner loop drains a shared mpsc channel fed by all face tasks, picks up each packet, and drives it through the appropriate pipeline. There are no hidden callbacks or middleware chains; the runner simply matches on the Action returned by each stage and decides what happens next.
💡 Key insight:
PacketContextis passed by value (moved) through each stage. This means ownership transfers at every step – a stage that short-circuits the pipeline consumes the context, and the compiler prevents any subsequent stage from accidentally using it. In C++, this invariant would require runtime checks; in Rust, it is a compile-time guarantee.
The stage contract is minimal:
#![allow(unused)]
fn main() {
pub trait PipelineStage: Send + Sync + 'static {
fn process(
&self,
ctx: PacketContext,
) -> impl Future<Output = Result<Action, DropReason>> + Send;
}
}
And the Action enum gives each stage explicit control over what comes next:
#![allow(unused)]
fn main() {
pub enum Action {
Continue(PacketContext), // pass to next stage
Send(PacketContext, SmallVec<[FaceId; 4]>), // forward and exit
Satisfy(PacketContext), // satisfy PIT entries and exit
Drop(DropReason), // discard silently
Nack(PacketContext, NackReason), // send Nack to incoming face
}
}
The Traveling Context
Before we follow a packet through the pipeline, it helps to understand what it carries. A PacketContext is born the moment bytes arrive on a face, and it accumulates information as each stage does its work:
#![allow(unused)]
fn main() {
pub struct PacketContext {
pub raw_bytes: Bytes, // original wire bytes
pub face_id: FaceId, // face the packet arrived on
pub name: Option<Arc<Name>>, // None until TlvDecodeStage
pub packet: DecodedPacket, // Raw -> Interest/Data after decode
pub pit_token: Option<PitToken>, // set by PitCheckStage
pub out_faces: SmallVec<[FaceId; 4]>, // populated by StrategyStage
pub lp_pit_token: Option<Bytes>, // LP PIT token echoed in responses
pub cs_hit: bool,
pub verified: bool,
pub arrival: u64, // ns since Unix epoch
pub tags: AnyMap, // extensible per-packet metadata
}
}
Notice that name starts as None – it won’t be populated until the TLV decode stage runs. This progressive population is deliberate: a Content Store hit can short-circuit the pipeline before expensive fields like nonce or lifetime are ever accessed.
flowchart LR
FC["FaceCheck\n──────────\nface_id\nraw_bytes\narrival"]
-->|"Continue"| DEC["TlvDecode\n──────────\nname\npacket"]
-->|"Continue"| CS["CsLookup\n──────────\ncs_hit"]
-->|"Continue"| PIT["PitCheck\n──────────\npit_token\nnonce check"]
-->|"Continue"| STR["Strategy\n──────────\nout_faces\nFIB match"]
-->|"Forward"| DSP["Dispatch\n──────────\nsend to\nout_faces"]
style FC fill:#e8f4fd,stroke:#2196F3
style DEC fill:#e8f4fd,stroke:#2196F3
style CS fill:#fff3e0,stroke:#FF9800
style PIT fill:#e8f4fd,stroke:#2196F3
style STR fill:#f3e5f5,stroke:#9C27B0
style DSP fill:#e8f5e9,stroke:#4CAF50
Now let’s follow an Interest through the full journey.
The Interest’s Journey
An Interest packet materializes on a face – perhaps a UDP datagram from a downstream consumer, or bytes pushed through an in-process InProcFace. Its journey begins.
flowchart TD
A["Packet arrives on Face"] --> B["FaceCheck"]
B -->|"face valid"| C["TlvDecode"]
B -->|"face down/invalid"| X1["Drop"]
C -->|"valid Interest"| D["CsLookup"]
C -->|"malformed TLV"| X2["Drop"]
D -->|"CS hit"| Y1["Send cached Data\nback to incoming face"]
D -->|"CS miss"| E["PitCheck"]
E -->|"duplicate nonce\n(loop detected)"| X3["Nack: Duplicate"]
E -->|"existing PIT entry\n(aggregate)"| X4["Suppress\n(add in-record, do not forward)"]
E -->|"new PIT entry"| F["Strategy"]
F --> G{"ForwardingAction?"}
G -->|"Forward"| H["Dispatch to\nnexthop face(s)"]
G -->|"ForwardAfter"| I["Schedule delayed\nforward + probe"]
G -->|"Nack"| X5["Nack to\nincoming face"]
G -->|"Suppress"| X6["Drop\n(policy decision)"]
First contact: FaceCheck
The very first thing the forwarder does is verify that the face the packet arrived on is still alive. If the face has been torn down or is in the process of shutting down, there’s no point proceeding – the packet is dropped immediately. This is a cheap guard that prevents stale packets from wasting pipeline resources.
Making sense of the bytes: TlvDecode
With the face confirmed, the raw Bytes are parsed into a typed Interest struct. The context’s name field is populated with an Arc<Name>, giving subsequent stages a shared, zero-copy reference to the packet’s identity. Malformed TLV drops the packet here.
🔧 Implementation note: Fields like nonce and lifetime are decoded lazily via
OnceLock<T>. They sit dormant inside the Interest struct, only computed when a later stage actually reads them. If the pipeline short-circuits before that happens, the CPU cycles are never spent.
The fast path: CsLookup
Now comes the moment that can make the entire rest of the pipeline irrelevant. The forwarder checks its Content Store for a cached Data packet matching this Interest’s name. If one is found, the cached wire-format Bytes are sent directly back to the incoming face. The pipeline short-circuits – no PIT entry is created, no FIB lookup occurs, no upstream forwarding happens.
📊 Performance: The CS short-circuit is the single most important optimization in the pipeline. A cache hit skips PIT insertion, FIB lookup, strategy invocation, and upstream forwarding – reducing a multi-stage pipeline to a hash lookup and a reference-count increment. With lazy
OnceLockdecoding, even the Interest’s nonce and lifetime fields are never parsed on this path.
Tracking the request: PitCheck
On a cache miss, the Interest reaches the Pending Interest Table. Here the forwarder must answer three questions at once. Has this exact Interest been seen before with the same nonce? If so, there’s a forwarding loop – the packet is Nacked with Duplicate. Is there already an outstanding PIT entry for this name from a different consumer? If so, the Interest is aggregated: its face is added as an in-record to the existing entry, but no new upstream Interest is sent. This is the mechanism behind NDN’s built-in multicast efficiency. Only if the entry is genuinely new does the Interest proceed to the next stage.
Deciding where to go: Strategy
For a fresh Interest that needs forwarding, the forwarder performs a longest-prefix match against the FIB to discover nexthop faces, then invokes the strategy assigned to that prefix. The strategy receives an immutable StrategyContext – it can observe the forwarder’s state but cannot mutate it – and returns a forwarding decision:
#![allow(unused)]
fn main() {
pub enum ForwardingAction {
Forward(SmallVec<[FaceId; 4]>),
ForwardAfter { faces: SmallVec<[FaceId; 4]>, delay: Duration },
Nack(NackReason),
Suppress,
}
}
Forward sends the Interest immediately. ForwardAfter enables probe-and-fallback patterns without the strategy needing to spawn its own timers – the forwarder handles the scheduling. Nack and Suppress end the journey here.
⚠️ Strategy isolation: Strategies cannot mutate global state. They receive a read-only snapshot and return a decision. This makes strategies safe to swap at runtime and prevents a buggy strategy from corrupting the FIB or PIT.
The final hop: Dispatch
The Interest is sent out on the selected nexthop face(s), and out-records are created in the PIT entry to track when each was sent. The Interest is now in flight, and the forwarder waits for a response.
The Satisfying Return: Data Pipeline
Somewhere upstream – perhaps one hop away, perhaps many – a producer or another router’s cache generates a Data packet matching the Interest. That Data now makes its way back through the network to our router.
flowchart TD
A["Data arrives on Face"] --> B["FaceCheck"]
B -->|"face valid"| C["TlvDecode"]
B -->|"face down/invalid"| X1["Drop"]
C -->|"valid Data"| D["PitMatch"]
C -->|"malformed TLV"| X2["Drop"]
D -->|"no PIT entry\n(unsolicited)"| X3["Drop"]
D -->|"PIT match found"| E["Strategy\n(after_receive_data)"]
E --> F["MeasurementsUpdate"]
F --> G["CsInsert"]
G --> H["Dispatch Data to\nall PIT in-record faces"]
H --> I["Remove PIT entry"]
The Data’s journey begins with the same FaceCheck and TlvDecode stages that every packet passes through. But after decoding, the paths diverge.
Finding who asked: PitMatch
The forwarder looks up the PIT for an entry matching this Data’s name. If no entry exists, the Data is unsolicited – nobody asked for it, so it is dropped. This is a fundamental NDN security property: routers only accept Data that was explicitly requested. When a match is found, the PIT entry reveals which downstream faces are waiting for this content.
Learning from success: Strategy and Measurements
The strategy is notified that Data arrived via after_receive_data. This allows it to update its internal state – mark a path as working, cancel retransmission timers, adjust preferences. Then the MeasurementsUpdate stage computes per-face, per-prefix statistics: EWMA RTT (derived from the gap between the out-record’s send timestamp and this Data’s arrival) and satisfaction rate. These measurements feed back into future strategy decisions, letting the forwarder learn which paths perform best.
📊 Performance: Measurements are stored in a
DashMap-backedMeasurementsTable, so updating statistics for one prefix never blocks lookups for another. The EWMA computation is a single multiply-and-add – negligible cost for valuable routing intelligence.
Caching for the future: CsInsert
Before the Data reaches its final recipients, it is inserted into the Content Store. The wire-format Bytes are stored directly – no re-encoding – so future cache hits can be served as zero-copy sends. The FreshnessPeriod is decoded once at insert time to compute a stale_at timestamp.
Delivering the payload: Dispatch
Finally, the Data is sent to every face listed in the PIT entry’s in-records. If three consumers requested the same content, all three receive it now. The PIT entry is consumed – removed from the table – and the lifecycle is complete.
The Power of Aggregation
The interplay between the Interest and Data pipelines reveals one of NDN’s most powerful properties. When multiple consumers request the same data, the PIT aggregates their Interests so that only a single Interest is forwarded upstream. When the Data returns, it fans out to all of them:
💡 Key insight: PIT aggregation is what makes NDN inherently multicast-friendly. Three consumers requesting the same video segment generate only one upstream Interest and one Data packet over the bottleneck link. The router’s PIT entry fans the Data out locally. This is fundamentally different from IP, where each consumer opens a separate connection and the same data traverses the network three times.
flowchart LR
c1["Consumer 1"] -->|"Interest\n/app/data/1"| router["Router"]
c2["Consumer 2"] -->|"Interest\n/app/data/1"| router
c3["Consumer 3"] -->|"Interest\n/app/data/1"| router
router -->|"Single Interest\n/app/data/1"| producer["Producer"]
producer -->|"Data\n/app/data/1"| router2["Router"]
router2 -->|"Data"| c1b["Consumer 1"]
router2 -->|"Data"| c2b["Consumer 2"]
router2 -->|"Data"| c3b["Consumer 3"]
subgraph PIT Entry
direction TB
pit_name["Name: /app/data/1"]
in1["InRecord: face 1 (Consumer 1)"]
in2["InRecord: face 2 (Consumer 2)"]
in3["InRecord: face 3 (Consumer 3)"]
out1["OutRecord: face 4 (upstream)"]
end
Consumer 1’s Interest arrives first and creates a new PIT entry. Consumer 2’s Interest for the same name finds the existing entry and is aggregated – an in-record is added, but no second Interest goes upstream. Consumer 3 is aggregated the same way. When the Data returns, the forwarder reads all three in-records and delivers the Data to each consumer. One upstream packet, three downstream deliveries.
When Things Go Wrong: The Nack Pipeline
Not every Interest finds its Data. Sometimes there is no route in the FIB. Sometimes the upstream path is congested. Sometimes the producer is unreachable. NDN handles these failures with Network Nacks – negative acknowledgements that flow back toward the consumer.
Nacks can be generated at two points in the Interest pipeline. A strategy that finds no viable nexthop returns ForwardingAction::Nack(reason), and any pipeline stage can return Action::Nack(ctx, reason) to signal a problem. The PitCheck stage does this when it detects a loop via duplicate nonce.
When a Nack arrives from upstream, it follows a shortened pipeline: decode, PIT match, and strategy notification. The strategy then faces a choice – try an alternative nexthop if one exists, or propagate the Nack downstream to the consumer. If no alternatives remain, the Nack flows back to every face in the PIT entry’s in-records, and the entry is removed.
⚠️ Nack propagation is conservative: A strategy will exhaust all available nexthops before propagating a Nack downstream. Only when every path has failed does the consumer learn that its Interest cannot be satisfied. This gives the network maximum opportunity to find the data through alternative routes.
The Full Picture
The two pipelines are symmetric and complementary. Interests flow upstream from consumer toward producer, driven by the FIB. Data flows downstream from producer toward consumer, guided by the PIT entries that Interests left behind. The Content Store sits at the junction, short-circuiting the loop when it can. And the strategy system ties it all together, learning from every Data arrival and every Nack to make better forwarding decisions over time.
This is the packet lifecycle in ndn-rs: a pipeline that is small enough to reason about stage by stage, yet powerful enough to express multicast aggregation, in-network caching, and adaptive forwarding – all without a single IP address in sight.
PIT, FIB, and Content Store
Three data structures sit at the heart of every NDN forwarder. Together they answer three questions: “Do I already have this data?” (CS), “Is someone already looking for it?” (PIT), and “Where should I look?” (FIB).
In ndn-rs, these three structures are not just containers – they are active participants in every packet’s lifecycle, tightly cooperating to move Interests upstream and Data downstream. Understanding how they work individually is important, but understanding how they work together is what makes the forwarding plane click.
The Cooperation
Before diving into each structure’s internals, let’s see how they collaborate. When an Interest arrives, the forwarder consults all three in sequence. When Data returns, the PIT and CS work in concert to cache and deliver it:
flowchart LR
subgraph Interest Path
I["Interest /a/b/c"] --> CS{"CS Lookup"}
CS -->|miss| PIT{"PIT Check"}
PIT -->|new entry| FIB{"FIB LPM"}
FIB -->|"/a/b -> face 3"| OUT["Forward to face 3"]
end
subgraph Data Path
D["Data /a/b/c"] --> PIT2{"PIT Match"}
PIT2 -->|"in-records: face 1, face 2"| CS2["CS Insert"]
CS2 --> SEND["Send to face 1, face 2"]
PIT2 -->|"remove entry"| DONE["PIT entry consumed"]
end
OUT -.->|"Data returns"| D
An Interest for /a/b/c first asks the CS: “Do you have it?” On a miss, the PIT records who is asking and checks for loops. Then the FIB answers: “Try face 3.” When Data comes back on face 3, the PIT reveals who to deliver it to, the CS caches a copy for next time, and the PIT entry is consumed. Three structures, one fluid motion.
Let’s now examine each structure in depth, keeping in mind how each one’s design decisions ripple through to the others.
Content Store: “Do I Already Have This?”
The CS is the first structure consulted when an Interest arrives, and it has the power to end the pipeline immediately. If a cached Data packet matches the Interest, the forwarder sends it back to the consumer without ever touching the PIT or FIB. This short-circuit is the single most important optimization in the forwarding plane.
The Trait Abstraction
The CS is defined as a trait, not a concrete type. This means the pipeline doesn’t care how data is cached – only that the interface is honored:
#![allow(unused)]
fn main() {
pub trait ContentStore: Send + Sync + 'static {
fn get(&self, interest: &Interest) -> impl Future<Output = Option<CsEntry>> + Send;
fn insert(&self, data: Bytes, name: Arc<Name>, meta: CsMeta) -> impl Future<Output = InsertResult> + Send;
fn evict(&self, name: &Name) -> impl Future<Output = bool> + Send;
fn capacity(&self) -> CsCapacity;
}
pub struct CsEntry {
pub data: Bytes, // wire-format, not re-encoded
pub stale_at: u64, // FreshnessPeriod decoded once at insert time
}
}
Three built-in backends implement this trait, each suited to different deployment scenarios.
LruCs is a byte-bounded LRU cache – the default choice for most deployments. When total cached bytes exceed the configured limit, the least recently used entries are evicted. It stores wire-format Bytes directly, so a cache hit sends the stored bytes to the face with no re-encoding or copying.
flowchart LR
subgraph "LRU Content Store (byte-bounded)"
direction LR
MRU["Most Recently Used"]
e1["/app/video/frame3\n512 bytes"]
e2["/app/data/item7\n1024 bytes"]
e3["/ndn/edu/paper\n2048 bytes"]
e4["/app/video/frame1\n768 bytes"]
LRU["Least Recently Used"]
MRU ~~~ e1 --- e2 --- e3 --- e4 ~~~ LRU
end
new["New insert:\n/app/news/article\n1500 bytes"] -->|"Insert at head\n(MRU end)"| MRU
LRU -->|"Evict from tail\nif over byte limit"| evicted["Evicted:\n/app/video/frame1"]
style new fill:#d4edda,stroke:#28a745
style evicted fill:#f8d7da,stroke:#dc3545
ShardedCs wraps any ContentStore implementation with sharding by name hash. Each shard is an independent instance of the inner store. When many pipeline tasks hit the CS concurrently, sharding ensures they don’t all contend on a single lock – the same principle that makes the PIT scale, applied here.
PersistentCs is backed by an on-disk key-value store (RocksDB or redb). It’s the right choice when the cache should survive restarts or when the dataset exceeds available memory. Its existence is invisible to the pipeline – the trait abstraction means swapping backends requires no code changes upstream.
Why Wire-Format Storage Matters
The CS stores Data as the original wire-format Bytes, not as decoded Rust structs. On a cache hit, those bytes are handed directly to face.send() with zero allocation and zero copying – Bytes uses reference-counted shared memory internally.
💡 Key insight: Storing wire-format bytes instead of decoded structs is a deliberate tradeoff. It means the CS cannot patch fields (e.g., decrementing a hop count) on cache hits. But NDN Data packets are immutable and cryptographically signed – modifying them would invalidate the signature anyway. So wire-format storage is both the fastest and the most correct approach.
This design choice connects directly to the PIT: because the CS returns raw bytes, a cache hit bypasses not just the PIT and FIB, but also any decoding that would be needed to re-encode the Data for transmission. The pipeline stage that performs CsLookup simply returns Action::Send with the cached bytes – no further stages execute.
Pending Interest Table: “Is Someone Already Looking?”
If the CS can’t satisfy an Interest, the next question is whether another consumer has already asked for the same data. The PIT tracks every outstanding Interest that has been forwarded upstream but not yet satisfied. It is the bridge between the Interest and Data pipelines – Interests create PIT entries on the way up, and Data consumes them on the way down.
Structure
#![allow(unused)]
fn main() {
// Conceptual: DashMap keyed on PIT token hash
type Pit = DashMap<PitToken, PitEntry>;
pub struct PitEntry {
pub name: Arc<Name>,
pub selector: Option<Selector>,
pub in_records: Vec<InRecord>,
pub out_records: Vec<OutRecord>,
pub nonces_seen: SmallVec<[u32; 4]>,
pub is_satisfied: bool,
pub created_at: u64,
pub expires_at: u64,
}
pub struct InRecord { pub face_id: FaceId, pub nonce: u32, pub expiry: u64, pub lp_pit_token: Option<Bytes>, }
pub struct OutRecord { pub face_id: u32, pub last_nonce: u32, pub sent_at: u64 }
}
The PIT key is a PitToken derived from the Interest name and selectors. Two Interests for the same name but different MustBeFresh or CanBePrefix values produce distinct PIT entries, matching NDN semantics exactly.
Each entry maintains two lists. In-records track which downstream faces sent Interests – these are the faces that need to receive Data when it arrives. Out-records track which upstream faces the Interest was forwarded to – these provide the timestamps needed to compute round-trip time and detect forwarding failures.
Concurrency Without Compromise
The PIT is the hottest data structure in the forwarder. Every Interest and every Data packet touches it. A naive global mutex would serialize all packet processing onto a single core. ndn-rs uses DashMap instead.
📊 Performance:
DashMapprovides sharded concurrent access – internally it is a fixed number ofRwLock<HashMap>shards. Different PIT entries hash to different shards, so operations on unrelated Interests never contend. A global mutex (as NFD uses) serializes all packet processing onto one core.DashMap’s sharded design means N cores can process N unrelated packets in parallel with zero contention. This is the single biggest architectural difference enabling multi-core scaling.
Loop Detection
Each Interest carries a random 32-bit nonce. When an Interest arrives and a PIT entry already exists for that name, the entry’s nonces_seen list is checked. If the nonce is already present, the Interest has looped back to a router it already visited – a forwarding loop. The forwarder responds with a Nack carrying the Duplicate reason code.
🔧 Implementation note:
nonces_seenusesSmallVec<[u32; 4]>to keep the common case (1–4 nonces) on the stack. Most PIT entries see exactly one nonce and are satisfied quickly. Only in unusual aggregation scenarios does the vector spill to the heap.
The Lifecycle of a PIT Entry
A PIT entry is born when a new Interest passes the CS (miss) and PIT (no existing entry) checks. It lives through possible aggregation – additional consumers requesting the same data add their in-records. It dies in one of three ways: Data arrives and satisfies it, its lifetime expires, or a Nack propagates through it.
stateDiagram-v2
[*] --> Created: Interest arrives,\nCS miss, no existing entry
Created --> Pending: In-record added\nfor incoming face
Pending --> Aggregated: Duplicate Interest\nfrom different face\n(add in-record)
Aggregated --> Aggregated: More duplicates
Pending --> Satisfied: Matching Data arrives
Aggregated --> Satisfied: Matching Data arrives
Satisfied --> [*]: Data sent to all\nin-record faces,\nentry removed
Pending --> Expired: Interest lifetime\nelapsed, no Data
Aggregated --> Expired: Interest lifetime\nelapsed, no Data
Expired --> [*]: Entry cleaned up\nby timing wheel
Pending --> Nacked: Upstream returns Nack,\nno alternative nexthops
Nacked --> [*]: Nack propagated\ndownstream, entry removed
🔧 Implementation note: ndn-rs does not scan the entire PIT for expired entries. Instead, a hierarchical timing wheel provides O(1) insertion and expiry notification. When a PIT entry is created, it is registered with the wheel at its
expires_attime. The wheel fires a callback when that time passes, and the entry is removed – no periodic sweeps, no wasted CPU.
The PIT’s connection to the other structures is direct and essential. It depends on the CS having already been checked (no point tracking an Interest the CS can satisfy). And when Data arrives, the PIT entry’s in-records tell the dispatch stage exactly where to send it – information that the FIB never had, because the FIB only knows about upstream paths, not about which downstream faces are waiting.
Forwarding Information Base: “Where Should I Look?”
When an Interest passes both the CS (miss) and PIT (new entry), the forwarder needs to decide where to send it. The FIB maps name prefixes to sets of nexthop faces, and a longest-prefix match (LPM) finds the best entry for any given name.
Structure
The FIB is a name trie where each node holds an optional FibEntry:
#![allow(unused)]
fn main() {
pub struct Fib(NameTrie<Arc<FibEntry>>);
pub struct FibEntry {
pub nexthops: Vec<FibNexthop>,
}
pub struct FibNexthop {
pub face_id: u32,
pub cost: u32,
}
}
The Concurrent Trie
Each trie node uses HashMap<NameComponent, Arc<RwLock<TrieNode<V>>>>:
#![allow(unused)]
fn main() {
pub struct NameTrie<V: Clone + Send + Sync + 'static> {
root: Arc<RwLock<TrieNode<V>>>,
}
struct TrieNode<V> {
entry: Option<V>,
children: HashMap<NameComponent, Arc<RwLock<TrieNode<V>>>>,
}
}
The Arc wrapper on each child node is the key to concurrency. When a thread performing LPM visits a child node, it grabs the child’s Arc, then releases the parent’s read lock. This hand-over-hand locking means concurrent lookups on different branches never contend after diverging from their common ancestor.
🔧 Implementation note: The
Arc<RwLock<TrieNode>>per child is what makes the FIB a concurrent trie, not just a trie behind a lock. A lookup for/a/b/cgrabs theArcfor nodea, releases the root lock, then grabs theArcforb, releasesa’s lock, and so on. Two concurrent lookups on different branches (e.g.,/a/band/x/y) never contend after the root level.
Here’s how a FIB trie looks in practice, with green nodes holding actual nexthop entries:
%%{init: {"layout": "elk"}}%%
flowchart TD
root["/ (root)"] --> ndn["/ndn"]
ndn --> edu["/ndn/edu"]
ndn --> com["/ndn/com"]
edu --> ucla["/ndn/edu/ucla\n☑ nexthop: face 3, cost 10"]
edu --> memphis["/ndn/edu/memphis\n☑ nexthop: face 5, cost 20"]
edu --> mit["/ndn/edu/mit\n☑ nexthop: face 7, cost 15"]
com --> google["/ndn/com/google\n☑ nexthop: face 2, cost 5"]
ucla --> papers["/ndn/edu/ucla/papers"]
ucla --> cs_dept["/ndn/edu/ucla/cs\n☑ nexthop: face 4, cost 8"]
style ucla fill:#d4edda,stroke:#28a745
style memphis fill:#d4edda,stroke:#28a745
style mit fill:#d4edda,stroke:#28a745
style google fill:#d4edda,stroke:#28a745
style cs_dept fill:#d4edda,stroke:#28a745
Intermediate nodes like /ndn/edu exist only for trie structure – they have no entry. A lookup for /ndn/edu/ucla/papers/2024 walks the trie and returns the deepest match at /ndn/edu/ucla.
Longest-Prefix Match in Action
LPM walks the trie component by component, tracking the deepest node that has an entry. For a lookup of /a/b/c/d:
- Read-lock root, check child
a, clone itsArc, release root lock. - Read-lock node
a, record its entry (if any), check childb, clone, release. - Continue until the name is exhausted or no child matches.
- Return the deepest recorded entry.
This is O(k) where k is the number of name components, with each level holding a lock only briefly. The result – a set of nexthops with associated costs – is handed to the strategy, which makes the final forwarding decision.
💡 Key insight: The FIB only provides candidates. It says “these faces might lead to the data.” The strategy layer, informed by measurements the PIT helped gather (RTT from out-record timestamps, satisfaction rates from PIT entry outcomes), makes the actual choice. The FIB and PIT are connected not just through the pipeline, but through the feedback loop that makes forwarding adaptive.
The Three Together: A Complete Example
Let’s trace a concrete scenario to see all three structures in action.
A consumer sends an Interest for /app/video/segment-42. The forwarder’s CS is checked first – no cached copy exists. The PIT is consulted next – no existing entry, so a new one is created with an in-record for face 1 (the consumer’s face). The FIB performs LPM and finds a match at /app/video with nexthop face 5 (cost 10) and face 7 (cost 20). The strategy picks face 5, the cheaper path. An out-record is added to the PIT entry with the current timestamp, and the Interest goes out on face 5.
Seconds later, a second consumer on face 2 sends the same Interest. The CS still has nothing. But the PIT already has an entry – the Interest is aggregated. Face 2 is added as a second in-record. No second Interest goes upstream.
The producer responds with Data on face 5. The PIT entry is matched, revealing two in-records (faces 1 and 2). The out-record’s timestamp is used to compute RTT, which is fed into the measurements table. The Data is inserted into the CS for future cache hits. Then the Data is dispatched to both face 1 and face 2. The PIT entry is consumed and removed.
If a third consumer now requests the same segment, the CS has the answer immediately. The PIT and FIB are never consulted. The cached bytes flow directly back to the consumer.
Summary Table
| Structure | Implementation | Key | Concurrency | Hot-Path Cost |
|---|---|---|---|---|
| FIB | NameTrie<Arc<FibEntry>> | Name prefix (trie) | RwLock per trie node | O(k) LPM, k = component count |
| PIT | DashMap<PitToken, PitEntry> | Name + selector hash | Sharded RwLock | O(1) insert/lookup |
| CS | Trait (LruCs, ShardedCs, PersistentCs) | Name | Implementation-dependent | O(1) LRU, zero-copy hit |
Together, these three structures form a self-reinforcing system. The CS absorbs repeated requests, keeping them off the network. The PIT aggregates concurrent requests, preventing duplicate upstream traffic. The FIB directs new requests toward the right sources, guided by measurements that the PIT helped collect. No single structure works in isolation – their power comes from how they cooperate.
Glossary
Terms used throughout the ndn-rs codebase and NDN literature. Organized alphabetically.
- Action
- Enum returned by each
PipelineStageto control packet flow. Variants:Continue(pass to next stage),Send(forward to faces and exit),Satisfy(satisfy PIT entries),Drop(discard),Nack(send negative acknowledgement). - InProcFace
- An in-process face that connects an application to the ndn-rs engine. Implemented as a pair of shared-memory ring buffers with a Unix socket control channel. Applications interact with the forwarder through
InProcFacerather than opening network sockets. - CanBePrefix
- An Interest selector indicating that the Interest name may be a proper prefix of the Data name. Without this flag, the Data name must exactly match the Interest name.
- CS (Content Store)
- A per-node cache of Data packets. Any router can serve a cached Data to a future Interest without forwarding upstream. In ndn-rs, CS is a trait (
ContentStore) with pluggable backends:LruCs,ShardedCs,PersistentCs. - Data
- One of the two NDN network-layer packet types. A Data packet carries a name, content, and a cryptographic signature. Data is always a response to an Interest and travels the reverse path.
- Face
- An NDN communication interface, analogous to a network interface in IP. A face can be a UDP tunnel, TCP connection, Ethernet link, Unix socket, in-process channel, or any transport that implements the
Facetrait (recv()+send()). Each face has a uniqueFaceId. - FaceId
- A
u32identifier assigned to each face when it is created. Used throughout PIT records, FIB nexthops, and pipeline dispatch. Application code does not use rawFaceIdvalues directly. - FIB (Forwarding Information Base)
- Maps name prefixes to sets of nexthop faces with costs. Implemented as a
NameTriewithArc<RwLock<TrieNode>>per level for concurrent longest-prefix match. Analogous to an IP routing table. - ForwardingAction
- Enum returned by a Strategy to tell the pipeline what to do with an Interest. Variants:
Forward(send to faces),ForwardAfter(delayed forward for probing),Nack(reject),Suppress(do not forward). - FreshnessPeriod
- A field in a Data packet specifying how long (in milliseconds) the Data should be considered “fresh” after arrival. Used by the CS to honor
MustBeFreshselectors. Decoded once at CS insert time and stored as astale_attimestamp. - HopLimit
- An optional field in an Interest packet, decremented at each hop. When it reaches zero the Interest is dropped. Prevents Interests from looping indefinitely in misconfigured networks.
- Interest
- One of the two NDN network-layer packet types. An Interest packet carries a name and optional selectors (CanBePrefix, MustBeFresh, Nonce, Lifetime, HopLimit). It requests a Data packet matching the given name.
- MustBeFresh
- An Interest selector requesting that the returned Data must not be stale (its
FreshnessPeriodmust not have expired). A CS entry whosestale_athas passed will not satisfy a MustBeFresh Interest. - Nack (Network Nack)
- A negative acknowledgement sent by a router when it cannot satisfy or forward an Interest. Carries a reason code (NoRoute, Congestion, Duplicate). Nacks travel the reverse Interest path, same as Data.
- Name
- A hierarchical identifier for NDN content. Composed of a sequence of
NameComponentvalues. Example:/ndn/example/data/v1. In ndn-rs, names useSmallVec<[NameComponent; 8]>for stack allocation in the common case and are shared viaArc<Name>. - NameComponent
- A single segment of an NDN name. Components are typed (GenericNameComponent, ImplicitSha256DigestComponent, ParametersSha256DigestComponent, etc.) and carry arbitrary bytes, not just UTF-8 strings.
- NDNLPv2 (NDN Link Protocol v2)
- A link-layer protocol that fragments, reassembles, and annotates NDN packets on a single hop. Carries hop-by-hop fields such as PIT tokens, Nack reasons, congestion marks, and fragmentation headers. In ndn-rs, NDNLPv2 headers are parsed before the packet enters the forwarding pipeline.
- Nonce
- A random 32-bit value carried in every Interest packet. Used for loop detection: if a PIT entry already contains the same nonce, the Interest is a loop and is Nacked. Stored in
SmallVec<[u32; 4]>per PIT entry. - PacketContext
- The per-packet state object passed by value through the pipeline. Contains raw bytes, decoded packet, face ID, name, PIT token, output face list, and extensible tags. Fields are populated progressively as stages execute.
- PipelineStage
- A trait representing one step in the forwarding pipeline. Each stage receives a
PacketContextand returns anAction. Built-in stages are monomorphized for zero-cost dispatch; plugin stages use dynamic dispatch viaBoxedStage. - PIT (Pending Interest Table)
- Records outstanding Interests that have been forwarded but not yet satisfied. Keyed by name + selector hash. Implemented as a
DashMapfor sharded concurrent access. Each entry tracks in-records (downstream faces), out-records (upstream faces), and nonces seen. - PitToken
- A hash derived from the Interest name and selectors, used as the PIT lookup key. Distinct from the NDNLPv2 wire-protocol PIT token (an opaque hop-by-hop value echoed in Data/Nack responses).
- SafeData
- A newtype wrapper around
Datathat can only be constructed by theValidatorafter successful signature verification. Application callbacks and the Content Store receiveSafeData, not rawData. This enforces at compile time that unverified data cannot be forwarded or consumed. - ShmFace
- A shared-memory face for high-throughput communication between an application and the ndn-rs router on the same host. Uses a ring buffer in a shared memory segment with a Unix socket control channel for signaling.
- Strategy
- A per-prefix forwarding policy that decides how to handle Interests and Data. Implements the
Strategytrait with methods likeafter_receive_interestandafter_receive_data. Strategies receive an immutableStrategyContextand returnForwardingActionvalues. A name trie parallel to the FIB maps prefixes to strategy instances. - TrustSchema
- A declarative specification of which keys are allowed to sign which names. Uses name pattern matching with capture groups. The
Validatorchecks incoming Data against the trust schema before constructingSafeData.
Building NDN Applications
ndn-app provides two ways to connect an application to NDN: through an external router process, or with the forwarder engine embedded directly in your binary. The high-level API — Consumer, Producer, Subscriber, Queryable — is the same either way.
This guide walks through both modes with complete, runnable examples.
The Two Connection Modes
graph LR
subgraph "External router mode"
App1["your app"] -->|"Unix socket\n/run/nfd/nfd.sock"| R["ndn-fwd process"]
R -->|"UDP / Ethernet"| Net["network"]
end
subgraph "Embedded engine mode"
App2["your app"] -->|"in-process\nInProcFace"| E["ForwarderEngine\n(same process)"]
E -->|"UDP / Ethernet"| Net2["network"]
end
External router — ndn-fwd runs as a separate process. Your app connects to its Unix socket. This is the standard deployment: one router per host, many apps sharing it. Requires a running router.
Embedded engine — the ForwarderEngine lives inside your binary. There is no router process. The engine owns all faces and FIB state. This is the right choice for mobile apps, CLI tools, test harnesses, and any scenario where a separate process is inconvenient.
The Consumer and Producer types accept both connection variants behind an NdnConnection enum. The API surface is identical — switching between modes is a one-line change.
Mode 1: External Router
Add ndn-app to your Cargo.toml:
[dependencies]
ndn-app = { path = "crates/engine/ndn-app" }
tokio = { version = "1", features = ["full"] }
Consumer
use ndn_app::{Consumer, AppError};
#[tokio::main]
async fn main() -> Result<(), AppError> {
let mut consumer = Consumer::connect("/run/nfd/nfd.sock").await?;
// Fetch raw content bytes — the simplest call.
let bytes = consumer.get("/example/hello").await?;
println!("{}", String::from_utf8_lossy(&bytes));
// Fetch the full Data packet (includes name, content, metadata).
let data = consumer.fetch("/example/hello").await?;
println!("name: {}", data.name());
if let Some(content) = data.content() {
println!("content: {} bytes", content.len());
}
Ok(())
}
fetch uses a 4-second Interest lifetime and a 4.5-second local timeout. For custom lifetimes, build the Interest wire yourself:
#![allow(unused)]
fn main() {
use std::time::Duration;
use ndn_packet::encode::InterestBuilder;
let wire = InterestBuilder::new("/example/sensor/temperature")
.lifetime(Duration::from_millis(500))
.must_be_fresh(true)
.build();
let data = consumer.fetch_wire(wire, Duration::from_millis(600)).await?;
}
To set a hop limit, forwarding hint, or application parameters, use fetch_with. The local timeout is derived automatically from the Interest lifetime:
#![allow(unused)]
fn main() {
use ndn_packet::encode::InterestBuilder;
// Limit the Interest to 4 forwarding hops
let data = consumer.fetch_with(
InterestBuilder::new("/ndn/remote/data").hop_limit(4)
).await?;
// Steer via a delegation prefix (forwarding hint)
let data = consumer.fetch_with(
InterestBuilder::new("/alice/files/photo.jpg")
.forwarding_hint(vec!["/campus/ndn-hub".parse()?])
).await?;
// ApplicationParameters — the ParametersSha256DigestComponent is
// computed and appended to the Name automatically
let data = consumer.fetch_with(
InterestBuilder::new("/service/query")
.app_parameters(b"filter=recent&limit=10")
).await?;
}
On the producer side these fields are accessible via the Interest argument:
#![allow(unused)]
fn main() {
producer.serve(|interest| async move {
println!("hop limit: {:?}", interest.hop_limit());
println!("params: {:?}", interest.app_parameters());
println!("hints: {:?}", interest.forwarding_hint());
// ...
Some(response_wire)
}).await
}
Producer
use bytes::Bytes;
use ndn_app::{Producer, AppError};
use ndn_packet::{Interest, encode::DataBuilder};
#[tokio::main]
async fn main() -> Result<(), AppError> {
// Register the prefix and start the serve loop.
let mut producer = Producer::connect("/run/nfd/nfd.sock", "/example").await?;
producer.serve(|interest: Interest| async move {
let name = interest.name.to_string();
println!("Interest for: {name}");
// Return Some(wire_bytes) to respond, None to silently drop.
let wire = DataBuilder::new((*interest.name).clone(), b"hello, NDN").build();
Some(wire)
}).await
}
The handler is async, so you can await database queries, file reads, or any other async work before returning the Data. Return None to drop the Interest without a response (the forwarder will Nack with NoRoute when the Interest times out).
Error handling
AppError has three variants:
#![allow(unused)]
fn main() {
match consumer.fetch("/example/data").await {
Ok(data) => { /* use data */ }
Err(AppError::Timeout) => {
// No response within the timeout window.
// The Interest either had no route or the producer didn't respond.
}
Err(AppError::Nacked { reason }) => {
// The forwarder sent an explicit Nack (e.g. NoRoute).
eprintln!("nacked: {:?}", reason);
}
Err(AppError::Engine(e)) => {
// Connection error or protocol violation.
eprintln!("engine error: {e}");
}
}
}
Mode 2: Embedded Engine
The embedded mode builds a ForwarderEngine inside your process and connects Consumer/Producer to it via in-process InProcFace channel pairs. No Unix sockets, no separate process.
use ndn_app::{Consumer, Producer, EngineBuilder};
use ndn_engine::EngineConfig;
use ndn_faces::local::InProcFace;
use ndn_packet::{Name, encode::DataBuilder};
use ndn_transport::FaceId;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create one InProcFace for the consumer and one for the producer.
// Each InProcFace::new returns the face (engine side) and its handle (app side).
let (consumer_face, consumer_handle) = InProcFace::new(FaceId(1), 64);
let (producer_face, producer_handle) = InProcFace::new(FaceId(2), 64);
// Build the engine, registering both faces.
let (engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
.face(consumer_face)
.face(producer_face)
.build()
.await?;
// Install a FIB route: Interests for /example go to the producer face.
let prefix: Name = "/example".parse()?;
engine.fib().add_nexthop(&prefix, FaceId(2), 0);
// Build Consumer and Producer from their handles.
let mut consumer = Consumer::from_handle(consumer_handle);
let mut producer = Producer::from_handle(producer_handle, prefix);
// Run the producer in a background task.
tokio::spawn(async move {
producer.serve(|interest| async move {
let wire = DataBuilder::new((*interest.name).clone(), b"hello from embedded engine").build();
Some(wire)
}).await.ok();
});
// Fetch from the consumer — goes through the in-process engine.
let bytes = consumer.get("/example/greeting").await?;
println!("{}", String::from_utf8_lossy(&bytes));
Ok(())
}
The embedded mode is useful for:
- Integration tests — spin up a full forwarding engine in
#[tokio::test]without any external process - Mobile / Android / iOS — ship the engine as part of your app binary; no system daemon required
- CLI tools — tools like
ndn-peekandndn-pingembed the engine so they work on machines that don’t havendn-fwdrunning
Mobile shortcut: If you are targeting Android or iOS, use
ndn-mobileinstead of assemblingEngineBuilderby hand. It pre-configures the engine with mobile-tuned defaults (8 MB CS, single pipeline thread, security enabled), exposes background suspend/resume lifecycle hooks, and provides Bluetooth face support. See the Mobile Apps guide.
Synchronous Applications
The blocking feature wraps Consumer and Producer in synchronous types that manage an internal Tokio runtime, following the same pattern as reqwest::blocking:
[dependencies]
ndn-app = { path = "crates/engine/ndn-app", features = ["blocking"] }
use ndn_app::blocking::{BlockingConsumer, BlockingProducer};
// No async, no #[tokio::main].
fn main() -> Result<(), ndn_app::AppError> {
let mut consumer = BlockingConsumer::connect("/run/nfd/nfd.sock")?;
let bytes = consumer.get("/example/hello")?;
println!("{}", String::from_utf8_lossy(&bytes));
Ok(())
}
BlockingProducer::serve takes a plain Fn(Interest) -> Option<Bytes> with no async:
#![allow(unused)]
fn main() {
let mut producer = BlockingProducer::connect("/run/nfd/nfd.sock", "/sensor")?;
producer.serve(|interest| {
// Synchronous handler — called on the runtime thread.
let reading = read_sensor(); // blocking I/O is fine here
let wire = DataBuilder::new((*interest.name).clone(), reading.as_bytes()).build();
Some(wire)
})?;
}
The blocking API is a good fit for Python extensions (ndn-python uses it internally), command-line tools, and any codebase that doesn’t use async.
Fetching with Security Verification
Consumer::fetch_verified validates the Data’s signature against a trust schema before returning it. The result is SafeData — a newtype that the compiler uses to enforce that only verified data reaches security-sensitive code.
#![allow(unused)]
fn main() {
use ndn_app::Consumer;
use ndn_security::KeyChain;
let keychain = KeyChain::open_or_create(
std::path::Path::new("/etc/ndn/keys"),
"/com/example/app",
)?;
let validator = keychain.validator();
let mut consumer = Consumer::connect("/run/nfd/nfd.sock").await?;
let safe_data = consumer.fetch_verified("/example/data", &validator).await?;
// safe_data is SafeData — the compiler knows it's been verified.
println!("verified: {}", safe_data.data().name());
}
If the certificate needed to verify the Data is not yet in the local cache, the validator expresses a side-channel Interest to fetch it. This happens transparently; fetch_verified waits for the certificate before returning.
Subscribe / Queryable
For datasets that change over time, Subscriber joins an SVS sync group and delivers new samples as they arrive. Queryable registers a prefix and handles request-response patterns more explicitly than Producer.
Subscriber
#![allow(unused)]
fn main() {
use ndn_app::{Subscriber, SubscriberConfig};
let mut sub = Subscriber::connect(
"/run/nfd/nfd.sock",
"/chat/room1",
SubscriberConfig::default(),
).await?;
while let Some(sample) = sub.recv().await {
println!(
"[{}] seq {}: {:?}",
sample.publisher,
sample.seq,
sample.payload,
);
}
}
SubscriberConfig::auto_fetch (default true) automatically expresses an Interest for each sync update and populates sample.payload with the fetched bytes. Set it to false if you only need the name/seq and will fetch content selectively.
Queryable
#![allow(unused)]
fn main() {
use ndn_app::{Queryable, AppError};
let mut queryable = Queryable::connect("/run/nfd/nfd.sock", "/compute").await?;
while let Some(query) = queryable.recv().await {
let name = query.interest().name().to_string();
let result = compute_something(&name);
let wire = DataBuilder::new(query.interest().name().clone())
.content(result.as_bytes())
.build_unsigned();
query.reply(wire).await?;
}
}
Queryable differs from Producer in that the reply goes directly to the querying consumer via the engine’s PIT, without re-entering the producer’s serve loop. It is better suited to stateless request handlers that want explicit control over the response.
Putting It Together: A Complete Sensor App
This example shows a sensor producer and a monitor consumer running side-by-side against an external router.
use std::time::Duration;
use bytes::Bytes;
use ndn_app::{Consumer, Producer, AppError};
use ndn_packet::{Interest, encode::DataBuilder};
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), AppError> {
const SOCKET: &str = "/run/nfd/nfd.sock";
const PREFIX: &str = "/ndn/sensor/temperature";
// Spawn the producer in a background task.
tokio::spawn(async move {
let mut producer = Producer::connect(SOCKET, PREFIX).await?;
producer.serve(|interest: Interest| async move {
// Read a sensor value and build a Data response.
let reading = format!("{:.1}", read_temperature());
let wire = DataBuilder::new((*interest.name).clone(), reading.as_bytes())
.freshness(Duration::from_secs(5))
.build();
Some(wire)
}).await
});
// Give the producer a moment to register its prefix.
sleep(Duration::from_millis(100)).await;
// Poll the sensor every second from the consumer.
let mut consumer = Consumer::connect(SOCKET).await?;
loop {
match consumer.get(PREFIX).await {
Ok(bytes) => println!("temperature: {}°C", String::from_utf8_lossy(&bytes)),
Err(AppError::Timeout) => eprintln!("no response"),
Err(e) => return Err(e),
}
sleep(Duration::from_secs(1)).await;
}
}
fn read_temperature() -> f32 { 23.5 }
Identity Management with NdnIdentity
ndn-identity provides a higher-level identity API that sits above the raw KeyChain. It manages certificate lifecycle, handles NDNCERT enrollment, and exposes a did() method that returns the W3C DID URI for the identity — all in a single type that persists across restarts.
Ephemeral Identities for Tests
NdnIdentity::ephemeral creates a throw-away identity entirely in memory. The key pair is generated fresh each time and is gone when the process exits. This is ideal for unit tests and integration tests where you want real signing behavior without touching the filesystem:
#![allow(unused)]
fn main() {
use ndn_identity::NdnIdentity;
use ndn_packet::encode::DataBuilder;
#[tokio::test]
async fn test_signed_producer() -> anyhow::Result<()> {
// A fresh Ed25519 identity for this test run
let identity = NdnIdentity::ephemeral("/test/sensor").await?;
println!("test DID: {}", identity.did());
// → did:ndn:test:sensor
// Get a signer and sign a packet
let signer = identity.signer()?;
let wire = DataBuilder::new("/test/sensor/reading".parse()?, b"23.5°C")
.sign(&*signer)
.await?;
// wire is now a signed Data packet whose key traces back to this identity
Ok(())
}
}
Persistent Identities for Applications
NdnIdentity::open_or_create creates the identity (and persists it to disk) on first run, then loads it on subsequent runs without re-generating keys:
use std::path::PathBuf;
use ndn_identity::NdnIdentity;
use ndn_app::Producer;
use ndn_packet::{Interest, encode::DataBuilder};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// First run: generates key, creates self-signed cert, saves to /var/lib/ndn/sensor-id
// Subsequent runs: loads existing key and cert from disk
let identity = NdnIdentity::open_or_create(
&PathBuf::from("/var/lib/ndn/sensor-id"),
"/ndn/sensor/node42",
).await?;
println!("Running as: {}", identity.name());
println!("DID: {}", identity.did());
let signer = identity.signer()?;
let mut producer = Producer::connect("/run/nfd/nfd.sock", "/ndn/sensor/node42").await?;
producer.serve(move |interest: Interest| {
// Clone the signer Arc for each handler invocation
let signer = signer.clone();
async move {
let reading = format!("{:.1}", read_sensor());
// Sign with the identity's key
let wire = DataBuilder::new((*interest.name).clone(), reading.as_bytes())
.sign(&*signer)
.await
.ok()?;
Some(wire)
}
}).await?;
Ok(())
}
fn read_sensor() -> f32 { 23.5 }
Advanced: Custom Validator
NdnIdentity implements Deref<Target = KeyChain>, so all KeyChain methods are available directly. For advanced scenarios — custom trust schemas, adding external trust anchors — use manager_arc() to access the underlying SecurityManager:
#![allow(unused)]
fn main() {
use ndn_identity::NdnIdentity;
use ndn_security::{Validator, TrustSchema};
use std::path::PathBuf;
let identity = NdnIdentity::open_or_create(
&PathBuf::from("/var/lib/ndn/app-id"),
"/example/app",
).await?;
// NdnIdentity Derefs to KeyChain — signer() and validator() are available directly
let signer = identity.signer()?;
// For advanced access, use manager_arc()
let mgr = identity.manager_arc();
let my_cert = mgr.get_certificate()?;
let validator = Validator::new(TrustSchema::hierarchical());
validator.cert_cache().insert(my_cert);
// ... use validator to verify incoming SafeData
}
For most applications identity.signer() covers the signing case and Consumer::fetch_verified covers the verification case. manager_arc() is the escape hatch for framework code that needs to share the manager across async tasks.
For factory provisioning with NDNCERT, see NdnIdentity::provision and the Fleet and Swarm Security guide.
Cargo Features
| Feature | What it enables |
|---|---|
| (default) | Consumer, Producer, Subscriber, Queryable |
blocking | BlockingConsumer, BlockingProducer via an internal Tokio runtime |
KeyChain lives in ndn-security and is re-exported by ndn-app for convenience.
Getting Started — Publish and Subscribe
This guide shows the two main ways to use ndn-rs:
- Embedded — forwarder runs inside your process (no IPC, ~20 ns round-trip)
- External — connect to a running
ndn-fwdvia Unix socket
Both modes share the same Consumer, Producer, and Subscriber API from ndn-app.
Prerequisites
# Cargo.toml
[dependencies]
ndn-app = "0.1"
tokio = { version = "1", features = ["full"] }
Mode 1: Embedded (in-process forwarder)
No external router needed — ideal for testing, mobile apps, and embedded targets.
The engine runs inside your process; InProcFace pairs replace IPC.
use ndn_app::{Consumer, EngineBuilder, Producer};
use ndn_engine::EngineConfig;
use ndn_faces::local::InProcFace;
use ndn_packet::{Name, encode::DataBuilder};
use ndn_transport::FaceId;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Create in-process face pairs: one for the consumer, one for the producer.
let (consumer_face, consumer_handle) = InProcFace::new(FaceId(1), 64);
let (producer_face, producer_handle) = InProcFace::new(FaceId(2), 64);
// 2. Build the forwarding engine with both faces.
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.face(consumer_face)
.face(producer_face)
.build()
.await?;
// 3. Route Interests for /hello → the producer face.
let prefix: Name = "/hello".parse()?;
engine.fib().add_nexthop(&prefix, FaceId(2), 0);
// 4. Producer: serve Data on request.
let producer = Producer::from_handle(producer_handle, prefix.clone());
tokio::spawn(async move {
producer.serve(|interest, responder| {
let name = (*interest.name).clone();
async move {
let wire = DataBuilder::new(name, b"Hello, NDN!").build();
responder.respond_bytes(wire).await.ok();
}
}).await
});
// 5. Consumer: fetch /hello/world.
let mut consumer = Consumer::from_handle(consumer_handle);
let data = consumer.fetch("/hello/world").await?;
println!("Received: {:?}", data.content());
shutdown.shutdown().await;
Ok(())
}
Mode 2: External (connect to ndn-fwd)
Start the forwarder first:
ndn-fwd --config /etc/ndn-fwd/config.toml
# or: ndn-fwd # uses /run/nfd/nfd.sock by default
Then connect from your app:
use ndn_app::{Consumer, Producer, AppError};
use ndn_packet::Name;
const SOCKET: &str = "/run/nfd/nfd.sock";
#[tokio::main]
async fn main() -> Result<(), AppError> {
// Producer side.
let mut producer = Producer::connect(SOCKET, "/hello").await?;
tokio::spawn(async move {
producer.serve(|_interest, responder| async move {
responder.respond_bytes(b"Hello from ndn-fwd!".to_vec().into()).await.ok();
}).await;
});
// Consumer side (separate connection).
let consumer = Consumer::connect(SOCKET).await?;
let name: Name = "/hello/world".parse().unwrap();
let data = consumer.fetch(&name).await?;
println!("Received: {:?}", data.content());
Ok(())
}
Publish/Subscribe (SVS sync)
Subscriber uses State Vector Sync to discover new publications without polling.
#![allow(unused)]
fn main() {
use ndn_app::Subscriber;
let mut sub = Subscriber::connect("/run/nfd/nfd.sock", "/chat/room1").await?;
while let Some(sample) = sub.recv().await {
println!("[{}] seq={}: {:?}", sample.publisher, sample.seq, sample.payload);
}
}
To use PSync instead of SVS:
#![allow(unused)]
fn main() {
let mut sub = Subscriber::connect_psync("/run/nfd/nfd.sock", "/chat/room1").await?;
}
Next Steps
- Building NDN Apps — in-depth guide with error handling, signing, chunked transfer
- CLI Tools —
ndn-peek,ndn-put,ndn-pingusage - Implementing a Face — add a new transport
- Performance Tuning — SHM transport, CS sizing, pipeline threads
Application Patterns
This page maps common application design patterns to the ndn-rs APIs that implement them. Each pattern includes the recommended crate/type, and a short code snippet showing the key call.
Fetch Content Once
Fetch a named piece of content by name and wait for a response.
API: ndn_app::Consumer::get or Consumer::fetch
#![allow(unused)]
fn main() {
let mut consumer = Consumer::connect("/run/nfd/nfd.sock").await?;
let bytes = consumer.get("/example/data").await?;
}
To set a hop limit, forwarding hint, or application parameters, use Consumer::fetch_with with an InterestBuilder:
#![allow(unused)]
fn main() {
use ndn_packet::encode::InterestBuilder;
// Limit to 4 forwarding hops
let data = consumer.fetch_with(
InterestBuilder::new("/ndn/remote/data").hop_limit(4)
).await?;
// Reach a producer via a delegation prefix (forwarding hint)
let data = consumer.fetch_with(
InterestBuilder::new("/alice/files/photo.jpg")
.forwarding_hint(vec!["/campus/ndn-hub".parse()?])
).await?;
// Parameterised fetch — ApplicationParameters triggers
// ParametersSha256DigestComponent auto-appended to the name
let data = consumer.fetch_with(
InterestBuilder::new("/service/query")
.app_parameters(b"filter=recent&limit=10")
).await?;
}
The local receive timeout is derived automatically from the Interest lifetime (+ 500 ms buffer). Call fetch_wire directly if you need a non-standard timeout.
Serve Content on Demand
Register a prefix and respond to Interests with dynamically generated Data.
API: ndn_app::Producer::connect + serve
#![allow(unused)]
fn main() {
let mut producer = Producer::connect("/run/nfd/nfd.sock", "/sensor").await?;
producer.serve(|interest| async move {
Some(DataBuilder::new((*interest.name).clone(), b"42").build())
}).await
}
Subscribe to a Live Data Stream
Receive all new data published to a shared group prefix (SVS-based sync).
API: ndn_app::Subscriber
#![allow(unused)]
fn main() {
let mut sub = Subscriber::connect(
"/run/nfd/nfd.sock",
"/chat/room1",
SubscriberConfig::default(),
).await?;
while let Some(sample) = sub.recv().await {
println!(
"[{}] {}",
sample.publisher,
String::from_utf8_lossy(&sample.payload.unwrap_or_default()),
);
}
}
Request/Response (RPC-style)
Handle request-response pairs where each reply goes only to the querying consumer.
API: ndn_app::Queryable
#![allow(unused)]
fn main() {
let mut queryable = Queryable::connect("/run/nfd/nfd.sock", "/compute").await?;
while let Some(query) = queryable.recv().await {
let result = do_work(query.interest());
query.reply(
DataBuilder::new(query.interest().name().clone())
.content(result.as_bytes())
.build_unsigned(),
).await?;
}
}
Transfer Large Content (Segmented)
Transfer content larger than a single packet, with automatic segmentation and reassembly.
API: ndn_app::ChunkedProducer + ChunkedConsumer
#![allow(unused)]
fn main() {
// Producer side
ChunkedProducer::connect(socket, "/files/report.pdf", &file_bytes).await?;
// Consumer side
let bytes = ChunkedConsumer::connect(socket).fetch("/files/report.pdf").await?;
}
Verify Content Before Use
Fetch Data and cryptographically verify it against a trust schema before use. The SafeData type ensures only verified data reaches sensitive code paths.
API: Consumer::fetch_verified + KeyChain
#![allow(unused)]
fn main() {
let keychain = KeyChain::load_or_init("/etc/ndn/keys").await?;
let safe_data = consumer
.fetch_verified("/example/data", &keychain.validator().await?)
.await?;
// safe_data: SafeData — compiler-enforced proof of verification
}
Embedded / Mobile (No External Router)
Run the full NDN forwarding engine inside your binary. No system daemon required.
For Android / iOS: use ndn_mobile::MobileEngine — a pre-configured wrapper with mobile-tuned defaults, lifecycle suspend/resume, and Bluetooth face support. See the Mobile Apps guide.
#![allow(unused)]
fn main() {
// ndn-mobile: one-liner setup, mobile-tuned defaults
use ndn_mobile::{Consumer, MobileEngine};
let (engine, handle) = MobileEngine::builder().build().await?;
let mut consumer = Consumer::from_handle(handle);
let mut producer = engine.register_producer("/my/prefix");
}
For desktop / testing: use ndn_app::EngineBuilder directly. See the Embedded Engine section.
#![allow(unused)]
fn main() {
use ndn_app::EngineBuilder;
use ndn_engine::EngineConfig;
use ndn_faces::local::InProcFace;
use ndn_transport::FaceId;
let mut builder = EngineBuilder::new(EngineConfig::default());
let app_face_id = builder.alloc_face_id();
let (face, handle) = InProcFace::new(app_face_id, 64);
let (engine, _shutdown) = builder.face(face).build().await?;
let mut consumer = ndn_app::Consumer::from_handle(handle);
}
Publish State to a Sync Group
Publish local state updates to a distributed sync group; all members receive updates.
API: ndn_sync::join_svs_group + SyncHandle
#![allow(unused)]
fn main() {
let sync = join_svs_group(&engine, "/chat/room1", "/ndn/mynode").await?;
sync.publish(b"hello everyone".to_vec()).await?;
while let Some(update) = sync.recv().await {
println!("from {}: {:?}", update.name, update.data);
}
}
Custom Forwarding Strategy
Override the default forwarding decision for a name prefix.
API: ndn_strategy::Strategy trait + EngineBuilder::strategy
#![allow(unused)]
fn main() {
struct MyStrategy;
impl Strategy for MyStrategy {
fn on_interest(&self, ctx: &StrategyContext, interest: &Interest) -> ForwardingAction {
// Custom forwarding logic
ForwardingAction::Forward(ctx.fib_lookup(interest.name()))
}
// on_nack and on_data_in omitted for brevity
}
let engine = EngineBuilder::new(config)
.strategy("/my/prefix", MyStrategy)
.build()
.await?;
}
Peer Discovery and Auto-FIB
Discover NDN neighbors on the local network and automatically populate the FIB.
API: ndn_discovery::UdpNeighborDiscovery + EngineBuilder::discovery
#![allow(unused)]
fn main() {
let discovery = UdpNeighborDiscovery::new(config)?;
let engine = EngineBuilder::new(config)
.discovery(discovery)
.build()
.await?;
// FIB entries for discovered neighbors are installed automatically
}
Integration Testing with Simulated Topology
Spin up a full forwarding engine in tests without any external processes or network.
API: ndn_sim::Simulation
#![allow(unused)]
fn main() {
let mut sim = Simulation::new();
let router = sim.add_router("r1");
let producer = sim.add_producer("p1", "/test");
sim.add_link(router, producer, LinkConfig::default());
let result = sim.send_interest(consumer, "/test/data").await?;
assert!(result.is_ok());
}
Synchronous / Non-Async Applications
Use NDN in blocking code (Python extensions, CLI tools, non-async Rust).
API: ndn_app::blocking::{BlockingConsumer, BlockingProducer}
#![allow(unused)]
fn main() {
let mut consumer = BlockingConsumer::connect("/run/nfd/nfd.sock")?;
let bytes = consumer.get("/example/hello")?;
}
Embedded / Constrained Devices (no_std)
Run a minimal forwarder on ARM Cortex-M or RISC-V with no heap allocator.
API: ndn_embedded::Forwarder (const-generic, no_std)
Refer to the Embedded Targets guide.
For a deeper walkthrough of the most common patterns, see Building NDN Applications.
Security Identity and Key Management
ndn-fwd requires a signing identity to sign Data packets and management
responses. This guide explains how identity is provisioned, what happens when
things go wrong, and how to manage keys with ndn-sec.
Identity resolution at startup
When ndn-fwd starts, it resolves a signing identity in priority order:
- Configured identity —
security.identityinndn-fwd.tomlpoints to a key name in the PIB atsecurity.pib_path(default:~/.ndn/pib/). - Ephemeral identity — if no identity is configured, or if the PIB fails
to load, an in-memory Ed25519 key is generated. The name is taken from:
security.ephemeral_prefix(config), or$HOSTNAME, orpid-<pid>as a last resort.
An ephemeral identity is never written to disk. It is recreated on every restart, so Data signed with it cannot be verified after the process exits.
PIB error recovery
If an identity is configured but the PIB fails (missing directory, corrupt
key file, permission error), ndn-fwd behaves differently depending on how
it is running:
- Interactive (TTY): an interactive menu is presented:
PIB error: <description> Options: [1] Generate a new key at <pib_path> [2] Continue with ephemeral identity (not persisted) [3] Abort Choice: - Daemon (no TTY): the error is logged as a structured
tracingevent atERRORlevel and the router falls back to ephemeral automatically.
Checking identity status
# From CLI (requires a running router)
ndn-ctl security identity-status
# Programmatically (MgmtClient)
let resp = mgmt_client.security_identity_status().await?;
// "identity=/ndn/myhost/KEY/abc is_ephemeral=false pib_path=/var/lib/ndn/pib"
The dashboard Security tab always shows a banner:
- Yellow — ephemeral identity; data cannot be verified after restart. The banner links to the config tab to set a persistent identity.
- Green — persistent identity loaded from PIB.
Managing keys with ndn-sec
# Generate a new anchor key
ndn-sec keygen --anchor /mynet/myhost
# Generate (skip if already exists — idempotent)
ndn-sec keygen --anchor --skip-if-exists /mynet/myhost
# List keys in the default PIB
ndn-sec list
# Use a custom PIB path
ndn-sec --pib /var/lib/ndn/pib list
# Export a certificate (DER)
ndn-sec export /mynet/myhost > myhost.ndnc
NixOS
On NixOS, / is read-only and DynamicUser = true is incompatible with
persistent key storage. The ndn-rs NixOS module handles this automatically:
services.ndn-fwd = {
enable = true;
identity = "/mynet/myhost"; # key name
pibPath = null; # defaults to /var/lib/ndn-fwd/pib
generateIdentity = true; # run ndn-sec keygen on every boot (idempotent)
};
With generateIdentity = true, the service runs:
ndn-sec --pib /var/lib/ndn-fwd/pib keygen --anchor --skip-if-exists /mynet/myhost
before starting ndn-fwd. This is idempotent — if the key already exists
the step is a no-op. Keys are persisted in
/var/lib/ndn-fwd/pib/ (the StateDirectory), which survives reboots.
The module creates a stable ndn-fwd user and group to own the state
directory and run the service.
Configuration reference
[security]
identity = "/mynet/myhost" # key name; omit for ephemeral
pib_path = "~/.ndn/pib" # path to FilePib directory
pib_type = "file" # "file" | "memory"
ephemeral_prefix = "/ndn/ephemeral" # name prefix for ephemeral identity
NDN on Android and iOS
ndn-mobile packages the NDN forwarding engine into a pre-configured crate tuned for Android and iOS. The forwarder runs inside the app process — no system daemon, no Unix sockets, no raw Ethernet faces. All app traffic uses in-process InProcFace channels (zero IPC overhead), while UDP faces handle LAN and WAN connectivity.
graph TD
subgraph "Your Mobile App"
C["Consumer / Producer"]
A["InProcHandle (tokio mpsc)"]
subgraph "MobileEngine"
FWD["ForwarderEngine\n(FIB · PIT · CS)"]
AF["InProcFace\n(in-process)"]
MF["MulticastUdpFace\n(LAN 224.0.23.170)"]
UF["UdpFace\n(unicast hub)"]
end
end
NET["NDN network"]
C <-->|"Interest / Data"| A
A <--> AF
AF <--> FWD
FWD <--> MF
FWD <--> UF
MF <-->|"UDP multicast"| NET
UF <-->|"UDP unicast"| NET
style FWD fill:#2d5016,color:#fff
style AF fill:#1a3a5c,color:#fff
style MF fill:#1a3a5c,color:#fff
style UF fill:#1a3a5c,color:#fff
When to Use ndn-mobile vs. Raw EngineBuilder
Use ndn-mobile when:
- You are targeting Android or iOS/iPadOS
- You want sensible mobile defaults (8 MB CS, single pipeline thread, full security validation) without assembling them from parts
- You need background suspend / foreground resume lifecycle hooks
Use raw EngineBuilder when:
- You are building a desktop router, simulation harness, or test fixture
- You need fine-grained control over every face (custom strategies, raw Ethernet, etc.)
Quick Start
Add ndn-mobile to your Cargo.toml:
[dependencies]
ndn-mobile = { path = "crates/platform/ndn-mobile" }
tokio = { version = "1", features = ["full"] }
Build the engine and fetch some content:
use ndn_mobile::{Consumer, MobileEngine};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (engine, handle) = MobileEngine::builder().build().await?;
let mut consumer = Consumer::from_handle(handle);
let data = consumer.fetch("/ndn/edu/example/data/1").await?;
println!("got {} bytes", data.content().map_or(0, |b| b.len()));
engine.shutdown().await;
Ok(())
}
MobileEngine::builder() returns a [MobileEngineBuilder] with mobile-tuned defaults:
| Setting | Default | Why |
|---|---|---|
| Content store | 8 MB | Typical phone cache budget |
| Pipeline threads | 1 | Minimises wake-ups and battery drain |
| Security profile | SecurityProfile::Default | Full chain validation |
| Multicast | disabled | Opt-in; requires a local IPv4 interface |
| Discovery | disabled | Opt-in alongside multicast |
| Persistent CS | disabled | Opt-in; requires the fjall feature |
LAN Connectivity: UDP Multicast
To discover and exchange content with other NDN nodes on the same Wi-Fi network, add a multicast face pointing at the device’s local interface:
#![allow(unused)]
fn main() {
use std::net::Ipv4Addr;
use ndn_mobile::MobileEngine;
let wifi_ip: Ipv4Addr = "192.168.1.10".parse()?;
let (engine, handle) = MobileEngine::builder()
.with_udp_multicast(wifi_ip)
.build()
.await?;
}
This joins the standard NDN multicast group 224.0.23.170:6363 on the specified interface. The face is created after build() returns, so it is immediately active.
On iOS, pass the current Wi-Fi interface IP obtained from NWPathMonitor or getifaddrs. On Android, use WifiManager.getConnectionInfo().getIpAddress().
Neighbor Discovery
When with_discovery is set alongside with_udp_multicast, the engine runs the NDN Hello protocol (SWIM-based) to discover other ndn-rs nodes on the LAN and populate the FIB automatically:
#![allow(unused)]
fn main() {
use std::net::Ipv4Addr;
use ndn_mobile::MobileEngine;
let (engine, handle) = MobileEngine::builder()
.with_udp_multicast("192.168.1.10".parse()?)
.with_discovery("/mobile/device/phone-alice")
.build()
.await?;
}
node_name identifies this device on the NDN network. DiscoveryProfile::Mobile is used automatically — it uses conservative hello intervals tuned for topology changes at human-movement timescales, with fast failure detection.
Calling with_discovery without with_udp_multicast logs a warning and silently disables discovery.
WAN Connectivity: Unicast UDP Peers
To connect to a known NDN hub (e.g. a campus router or testbed node):
#![allow(unused)]
fn main() {
use ndn_mobile::MobileEngine;
let hub: std::net::SocketAddr = "203.0.113.10:6363".parse()?;
let (engine, handle) = MobileEngine::builder()
.with_unicast_peer(hub)
.build()
.await?;
}
Multiple unicast peers can be added; each becomes a Persistent UDP face.
Producing Data
register_producer allocates a new InProcFace, installs a FIB route, and returns a ready Producer — all synchronously, with no async overhead:
#![allow(unused)]
fn main() {
let mut producer = engine.register_producer("/mobile/sensor/temperature");
producer.serve(|interest| async move {
let reading = read_sensor().to_string();
let wire = ndn_packet::encode::DataBuilder::new(
(*interest.name).clone(),
reading.as_bytes(),
).build();
Some(wire)
}).await?;
}
Call register_producer once per prefix. Each call creates an independent InProcFace — a producer registered on /a and one on /b are isolated and can run concurrently.
Multiple App Components
If several independent components in the same app (e.g. a background service and a UI layer) need their own NDN faces, use new_app_handle:
#![allow(unused)]
fn main() {
let (face_id, bg_handle) = engine.new_app_handle();
let (face_id2, ui_handle) = engine.new_app_handle();
// Install FIB routes for each component.
engine.add_route(&"/background/prefix".parse()?, face_id, 0);
engine.add_route(&"/ui/prefix".parse()?, face_id2, 0);
let mut bg_consumer = ndn_mobile::Consumer::from_handle(bg_handle);
let mut ui_consumer = ndn_mobile::Consumer::from_handle(ui_handle);
}
Background and Foreground Lifecycle
Mobile OSes aggressively restrict network I/O while apps are backgrounded. Call suspend_network_faces when the app moves to background and resume_network_faces when it returns to the foreground. The in-process InProcFace (and any active consumers / producers) remains fully functional throughout.
#![allow(unused)]
fn main() {
// Android: call from onStop() or onPause()
// iOS: call from applicationDidEnterBackground(_:) or sceneDidEnterBackground(_:)
engine.suspend_network_faces();
// ... app is in background; in-process communication still works ...
// Android: call from onStart() or onResume()
// iOS: call from applicationWillEnterForeground(_:) or sceneWillEnterForeground(_:)
engine.resume_network_faces().await;
}
suspend_network_faces cancels all network face tasks. resume_network_faces recreates the UDP multicast face (if one was configured) using the same FaceId, preserving the discovery module’s state.
Unicast peer faces are not automatically resumed — their socket addresses are not stored. Re-add them via engine.engine() after calling resume_network_faces.
Bluetooth NDN Faces
Bluetooth requires a platform-supplied connection. The Rust side accepts any async AsyncRead + AsyncWrite pair — you bridge from Android’s BluetoothSocket or iOS’s CBL2CAPChannel over FFI and wrap it with COBS framing:
#![allow(unused)]
fn main() {
use ndn_mobile::{bluetooth_face_from_parts, CancellationToken};
// reader and writer come from your platform bridge (JNI / C FFI).
let face = bluetooth_face_from_parts(
engine.engine().faces().alloc_id(),
"bt://AA:BB:CC:DD:EE:FF",
reader,
writer,
);
// Use network_cancel_token() so the face suspends with UDP faces on background.
engine.engine().add_face(face, engine.network_cancel_token().child_token());
}
bluetooth_face_from_parts uses COBS framing — the same codec as ndn-faces. COBS is correct for Bluetooth because RFCOMM and L2CAP are stream-oriented; 0x00 never appears in a COBS-encoded payload, making it a reliable frame boundary after a dropped connection.
Android (Kotlin → JNI)
- Open a
BluetoothSocketwithcreateRfcommSocketToServiceRecord()and callconnect(). - Get the socket fd via
getFileDescriptor()on the underlyingParcelFileDescriptor. - Pass the raw fd to Rust over JNI and wrap:
#![allow(unused)]
fn main() {
use std::os::unix::io::FromRawFd;
use tokio::net::UnixStream;
// SAFETY: fd is a valid, owned RFCOMM socket fd transferred from the JVM.
let stream = unsafe { UnixStream::from_raw_fd(raw_fd) };
let (r, w) = tokio::io::split(stream);
let face = bluetooth_face_from_parts(id, "bt://AA:BB:CC:DD:EE:FF", r, w);
}
iOS / iPadOS (Swift → C FFI)
- Use
CoreBluetoothto open an L2CAP channel (CBPeripheral.openL2CAPChannel) and retrieve theCBL2CAPChannel. - Bridge
inputStream/outputStreamto Rust via asocketpair(2)or a Swift-side copy loop. - Wrap the resulting fd in
tokio::io::splitand pass tobluetooth_face_from_parts.
Persistent Content Store
By default, the content store is an in-memory LRU cache that does not survive app restarts. To enable a persistent on-disk store, add the fjall feature and call with_persistent_cs:
[dependencies]
ndn-mobile = { path = "crates/platform/ndn-mobile", features = ["fjall"] }
#![allow(unused)]
fn main() {
let (engine, handle) = MobileEngine::builder()
.with_persistent_cs("/data/user/0/com.example.app/files/ndn-cs")
.build()
.await?;
}
On iOS, to share the content store between your main app and extensions (widgets, share extensions) in the same App Group:
// Swift — resolve the App Group container path, pass to Rust over FFI
let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")!
.appendingPathComponent("ndn-cs")
rust_engine_set_cs_path(url.path)
On Android, use Context.getFilesDir() in Kotlin/Java and pass the path to Rust via JNI.
Security Profiles
The default security profile (SecurityProfile::Default) performs full chain validation — every Data packet’s signature is verified against its cert chain up to a trust anchor. For isolated test networks or local-only deployments, this can be relaxed:
#![allow(unused)]
fn main() {
use ndn_mobile::{MobileEngine, SecurityProfile};
// Accept any signed packet (verify signature, skip cert-chain fetch):
let (engine, _handle) = MobileEngine::builder()
.security_profile(SecurityProfile::AcceptSigned)
.build()
.await?;
// Disable validation entirely (isolated test network only):
let (engine, _handle) = MobileEngine::builder()
.security_profile(SecurityProfile::Disabled)
.build()
.await?;
}
Do not use Disabled in production; it allows any unsigned or improperly signed Data to be accepted and cached.
Tuning Pipeline Threads
The default of one pipeline thread minimises battery drain. If your app saturates the forwarder at high Interest/Data rates (e.g. a video-streaming producer), increase the thread count:
#![allow(unused)]
fn main() {
let (engine, handle) = MobileEngine::builder()
.pipeline_threads(2)
.build()
.await?;
}
Profile first with cargo flamegraph or Android Profiler / Instruments before increasing this.
Platform Notes
| Feature | Android | iOS/iPadOS |
|---|---|---|
| InProcFace (in-process) | ✓ | ✓ |
| UDP multicast | ✓ (Wi-Fi) | ✓ (Wi-Fi) |
| UDP unicast | ✓ | ✓ |
| Neighbor discovery (Hello/UDP) | ✓ | ✓ |
| Bluetooth NDN (via FFI stream) | ✓ | ✓ |
| Persistent content store | ✓ | ✓ (incl. App Groups) |
| Background suspend / resume | ✓ | ✓ |
| Raw Ethernet (L2) | ✗ | ✗ |
| Unix domain socket IPC | ✗ | ✗ |
| POSIX shared-memory face | ✗ | ✗ |
Raw Ethernet, Unix socket IPC, and POSIX SHM faces are excluded because they require OS capabilities unavailable in the Android and iOS application sandboxes.
Cross-Compiling
Install the target toolchains:
# iOS device
rustup target add aarch64-apple-ios
# iOS simulator (Apple Silicon Mac)
rustup target add aarch64-apple-ios-sim
# Android arm64
rustup target add aarch64-linux-android
cargo install cargo-ndk
Build:
# iOS — requires Xcode and the iOS SDK
cargo build --target aarch64-apple-ios -p ndn-mobile
# Android — requires Android NDK r27+
cargo ndk -t arm64-v8a build -p ndn-mobile
# With persistent CS (fjall links C++)
cargo ndk -t arm64-v8a build -p ndn-mobile --features fjall
The CI workflow .github/workflows/mobile.yml runs cargo check against both targets on every push to main.
Complete Example: Mobile Sensor App
use std::net::Ipv4Addr;
use ndn_mobile::{Consumer, MobileEngine, SecurityProfile};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let wifi_ip: Ipv4Addr = get_wifi_ip(); // platform-specific
let (engine, consumer_handle) = MobileEngine::builder()
.with_udp_multicast(wifi_ip)
.with_discovery("/mobile/device/my-phone")
.security_profile(SecurityProfile::Default)
.build()
.await?;
// Producer: serve temperature readings
let mut producer = engine.register_producer("/mobile/sensor/temperature");
tokio::spawn(async move {
producer.serve(|interest| async move {
let reading = read_sensor();
let wire = ndn_packet::encode::DataBuilder::new(
(*interest.name).clone(),
reading.as_bytes(),
).build();
Some(wire)
}).await.ok();
});
// Consumer: fetch data from another node on the LAN
let mut consumer = Consumer::from_handle(consumer_handle);
let data = consumer.fetch("/mobile/device/other-phone/sensor/temp").await?;
println!("remote temp: {:?}", data.content());
engine.shutdown().await;
Ok(())
}
fn get_wifi_ip() -> Ipv4Addr { "192.168.1.42".parse().unwrap() }
fn read_sensor() -> String { "23.5".into() }
Running ndn-rs on Embedded Targets
NFD requires a full Linux userspace. ndnd requires Go’s runtime with garbage collection. ndn-rs runs on a Cortex-M4 with 256 KB of RAM. Here is how.
The ndn-rs workspace was designed from the start to support bare-metal embedded targets alongside the full-featured Tokio-based forwarder. Three crates form the embedded stack: ndn-tlv and ndn-packet compile with no_std (they just need an allocator), and ndn-embedded provides a minimal forwarding engine that requires no heap allocator at all in its default configuration. No async runtime, no threads, no dynamic dispatch on the hot path – just a polling loop and fixed-size data structures.
graph TD
subgraph "ndn-rs embedded stack"
A["ndn-embedded<br/><i>Forwarder, FIB, PIT, CS, Face trait</i><br/><code>#![no_std]</code> always"]
B["ndn-packet<br/><i>Interest, Data, Name, LpPacket</i><br/><code>no_std</code> via feature flag"]
C["ndn-tlv<br/><i>TlvReader, TlvWriter, varint codec</i><br/><code>no_std</code> via feature flag"]
end
A --> B
B --> C
subgraph "Hardware"
D["UART / SPI / LoRa / Ethernet MAC"]
end
A --> D
style A fill:#2d5016,color:#fff
style B fill:#1a3a5c,color:#fff
style C fill:#1a3a5c,color:#fff
style D fill:#5c3a1a,color:#fff
The no_std stack
ndn-tlv
ndn-tlv compiles without std when you disable the default feature:
ndn-tlv = { version = "...", default-features = false }
In this mode the crate still requires an allocator (for bytes::Bytes and BytesMut), but drops all std-specific dependencies. Everything works: TlvReader for zero-copy parsing, TlvWriter for encoding, and the read_varu64 / write_varu64 varint codec.
ndn-packet
ndn-packet follows the same pattern:
ndn-packet = { version = "...", default-features = false }
With std disabled, the crate drops two modules that require OS-level support:
| Module | Requires std | Why |
|---|---|---|
encode | Yes | Uses BytesMut in ways that depend on std I/O traits |
fragment | Yes | NDNLPv2 fragment reassembly needs ring for integrity checks |
Everything else compiles: Interest, Data, Name, NameComponent, MetaInfo, SignatureInfo, LpPacket, Nack. Names use SmallVec<[NameComponent; 8]>, so typical 4-8 component names stay on the stack.
Note: Both crates still require an allocator (
extern crate alloc) becausebytes::Bytesuses heap memory internally. If your target has no heap allocator, usendn-embeddeddirectly – itswiremodule encodes packets into caller-supplied&mut [u8]buffers with zero allocation.
The embedded forwarder
The ndn-embedded crate is the heart of the embedded story. It is #![no_std] unconditionally – there is no std feature to enable. It provides a single-threaded, synchronous Forwarder that processes one packet at a time in a polling loop.
Feature flags
| Feature | Default | Description |
|---|---|---|
alloc | off | Enables heap-backed collections via hashbrown (requires a global allocator) |
cs | off | Enables the optional ContentStore for caching Data packets |
ipc | off | Enables app-to-forwarder SPSC queues |
In the default configuration (no features enabled), ndn-embedded requires no heap allocator. All data structures are backed by heapless::Vec with compile-time capacity limits.
How it works
The Forwarder is parameterized by three const generics and a clock type:
#![allow(unused)]
fn main() {
pub struct Forwarder<const P: usize, const F: usize, C: Clock> {
pub pit: Pit<P>, // P = max pending Interests
pub fib: Fib<F>, // F = max routes
clock: C, // monotonic millisecond counter
}
}
The processing model is straightforward. Your MCU main loop calls two methods:
process_packet(raw, incoming_face, faces)– decodes one raw TLV packet, dispatches it as Interest or Data, performs FIB lookup / PIT insert / PIT satisfy, and callsface.send()on the appropriate outbound face.run_one_tick()– purges expired PIT entries using the supplied clock.
There is no async runtime, no task spawning, no channel multiplexing. The forwarder runs synchronously in whatever context you call it from – a bare loop {}, an RTOS task, or an Embassy executor.
Face abstraction
Faces use the nb::Result convention from embedded-hal:
#![allow(unused)]
fn main() {
pub trait Face {
type Error: core::fmt::Debug;
fn recv(&mut self, buf: &mut [u8]) -> nb::Result<usize, Self::Error>;
fn send(&mut self, buf: &[u8]) -> nb::Result<(), Self::Error>;
fn face_id(&self) -> FaceId; // u8, 0-254
}
}
A face wraps whatever transport your MCU has: UART, SPI, LoRa, raw Ethernet MAC, or a memory-mapped buffer for inter-core communication. The ErasedFace trait provides dynamic dispatch so the forwarder can iterate over a heterogeneous slice of faces.
For serial links (UART, SPI, I2C), ndn-embedded includes a COBS framing module (cobs) that eliminates 0x00 bytes from the payload and uses 0x00 as a frame delimiter. This is compatible with the desktop stack’s ndn-faces framing.
Clock
The Clock trait supplies the monotonic millisecond counter for PIT expiry:
#![allow(unused)]
fn main() {
pub trait Clock {
fn now_ms(&self) -> u32; // wraps after ~49 days
}
}
Three implementations are provided:
NoOpClock– always returns 0. PIT entries never expire (useful when you rely on FIFO eviction from a small PIT).FnClock(fn() -> u32)– wraps a function pointer to your hardware timer (SysTick, TIM2, etc.).- Your own impl – for Embassy, RTIC, or any other framework.
Wire encoding without allocation
The wire module encodes Interest and Data packets directly into &mut [u8] stack buffers, bypassing ndn-packet’s BytesMut-based encoder entirely:
#![allow(unused)]
fn main() {
let mut buf = [0u8; 256];
// From raw components:
let n = wire::encode_interest(&mut buf, &[b"ndn", b"sensor", b"temp"], 42, 4000, false, false)
.expect("buf too small");
// Or from a name string:
let n = wire::encode_interest_name(&mut buf, "/ndn/sensor/temp", 42, 4000, false, false)
.expect("buf too small");
// Data packets:
let n = wire::encode_data_name(&mut buf, "/ndn/sensor/temp", b"23.5")
.expect("buf too small");
}
Data packets are encoded with a DigestSha256 signature stub (type 0, 32 zero bytes). This produces well-formed packets that NDN forwarders accept and cache.
Supported targets
The embedded CI (.github/workflows/embedded.yml) validates the following builds on every push:
| Target | Architecture | Example boards |
|---|---|---|
thumbv7em-none-eabihf | ARM Cortex-M4F | STM32F4, nRF52840, LPC4088 |
The crate is designed to also support RISC-V (riscv32imac-unknown-none-elf) and ESP32 (xtensa-esp32-none-elf) targets. The CI currently cross-compiles against thumbv7em-none-eabihf; additional targets can be added as the ecosystem matures.
Cross-compiling
Install the target toolchain and build:
# ARM Cortex-M4F (the CI-tested target)
rustup target add thumbv7em-none-eabihf
cargo build -p ndn-embedded --target thumbv7em-none-eabihf
# With optional content store
cargo build -p ndn-embedded --features cs --target thumbv7em-none-eabihf
# With heap allocator support
cargo build -p ndn-embedded --features alloc --target thumbv7em-none-eabihf
# RISC-V
rustup target add riscv32imac-unknown-none-elf
cargo build -p ndn-embedded --target riscv32imac-unknown-none-elf
The ndn-tlv and ndn-packet crates can be cross-compiled independently if you only need packet encoding/decoding without the forwarder:
cargo check -p ndn-tlv --no-default-features --target thumbv7em-none-eabihf
cargo check -p ndn-packet --no-default-features --target thumbv7em-none-eabihf
Memory budget
Every data structure in ndn-embedded has a compile-time size. There are no surprise heap allocations. Here is how to estimate your RAM usage.
PIT
Each PitEntry is 24 bytes:
| Field | Type | Size |
|---|---|---|
name_hash | u64 | 8 bytes |
incoming_face | u8 | 1 byte |
nonce | u32 | 4 bytes |
created_ms | u32 | 4 bytes |
lifetime_ms | u32 | 4 bytes |
| (padding) | 3 bytes |
A Pit<64> costs 64 x 24 = 1,536 bytes. For a simple sensor node, Pit<16> (384 bytes) is plenty. The PIT uses FIFO eviction when full – the oldest pending Interest is dropped first.
FIB
Each FibEntry is 16 bytes:
| Field | Type | Size |
|---|---|---|
prefix_hash | u64 | 8 bytes |
prefix_len | u8 | 1 byte |
nexthop | u8 | 1 byte |
cost | u8 | 1 byte |
| (padding) | 5 bytes |
A Fib<8> costs 8 x 16 = 128 bytes. Most embedded nodes need only 2-4 routes (one default route, one per local prefix).
Content Store (optional)
The ContentStore<N, MAX_LEN> stores raw Data packet bytes in fixed-size arrays:
RAM = N * (MAX_LEN + 24 bytes overhead)
For example, ContentStore<4, 256> costs about 4 x 280 = 1,120 bytes. Enable the cs feature only if your node re-serves the same Data packets (e.g., a gateway caching upstream responses). Pure sensor nodes that produce unique readings every cycle should skip it.
Typical configurations
| Node type | PIT | FIB | CS | Total (approx.) |
|---|---|---|---|---|
| Sensor leaf | Pit<16> | Fib<4> | none | ~500 bytes |
| Sensor + cache | Pit<32> | Fib<8> | CS<4, 256> | ~2.2 KB |
| Edge gateway | Pit<128> | Fib<16> | CS<16, 512> | ~12 KB |
All of these fit comfortably in a Cortex-M4 with 64-256 KB of SRAM, leaving the bulk of memory for application logic, DMA buffers, and the stack.
Tip: The
Forwarderstruct itself is generic overPandF, so you can tune capacities per deployment without changing any code – just adjust the const generics at the instantiation site.
Example: sensor node producing temperature data
The following example shows a minimal sensor node that produces NDN Data packets containing temperature readings. It has two faces: a UART link to an upstream forwarder, and a local “app” face that generates Data in response to incoming Interests.
graph LR
subgraph "Sensor MCU (Cortex-M4)"
APP["App logic<br/><i>reads ADC, encodes Data</i>"]
FW["ndn-embedded<br/>Forwarder<16, 4, _>"]
UART_FACE["UART Face<br/>(face 0)"]
end
ROUTER["Upstream NDN<br/>forwarder"]
ROUTER -- "Interest /ndn/sensor/temp" --> UART_FACE
UART_FACE --> FW
FW -- "FIB: /ndn/sensor -> app" --> APP
APP -- "Data /ndn/sensor/temp" --> FW
FW --> UART_FACE
UART_FACE -- "Data (wire bytes)" --> ROUTER
style FW fill:#2d5016,color:#fff
style APP fill:#1a3a5c,color:#fff
style ROUTER fill:#5c3a1a,color:#fff
#![no_std]
#![no_main]
use ndn_embedded::{Forwarder, Fib, FnClock, wire};
use ndn_embedded::face::{Face, FaceId};
// -- Hardware-specific face implementation (pseudocode) --
struct UartFace {
// Your UART peripheral handle goes here
}
impl Face for UartFace {
type Error = ();
fn recv(&mut self, buf: &mut [u8]) -> nb::Result<usize, ()> {
// Read from UART RX ring buffer; return WouldBlock if empty.
// In practice, use COBS framing (ndn_embedded::cobs) to
// delimit packets on the byte stream.
Err(nb::Error::WouldBlock)
}
fn send(&mut self, buf: &[u8]) -> nb::Result<(), ()> {
// Write to UART TX; COBS-encode before sending.
Ok(())
}
fn face_id(&self) -> FaceId { 0 }
}
// -- Clock from SysTick --
fn read_systick_ms() -> u32 {
// Read your MCU's millisecond counter
0
}
// -- Main loop --
#[cortex_m_rt::entry]
fn main() -> ! {
// Initialize hardware (UART, ADC, SysTick, etc.)
let mut uart_face = UartFace { /* ... */ };
// Set up the FIB: route everything under /ndn to face 0 (upstream).
let mut fib = Fib::<4>::new();
fib.add_route("/ndn", 0);
// Create the forwarder with a 16-entry PIT and hardware clock.
let clock = FnClock(read_systick_ms);
let mut fw = Forwarder::<16, 4, _>::new(fib, clock);
// Packet buffers
let mut rx_buf = [0u8; 512];
let mut tx_buf = [0u8; 512];
loop {
// 1. Poll the UART face for incoming packets.
if let Ok(n) = uart_face.recv(&mut rx_buf) {
let mut faces: [&mut dyn ndn_embedded::face::ErasedFace; 1] =
[&mut uart_face];
fw.process_packet(&rx_buf[..n], 0, &mut faces);
}
// 2. If we received an Interest for our prefix, produce Data.
// (In a real application, you'd check whether the Interest
// matched /ndn/sensor/temp and read the ADC.)
let temperature = b"23.5";
if let Some(n) = wire::encode_data_name(
&mut tx_buf,
"/ndn/sensor/temp",
temperature,
) {
let _ = uart_face.send(&tx_buf[..n]);
}
// 3. Expire stale PIT entries.
fw.run_one_tick();
}
}
This is a simplified sketch. A production sensor node would:
- Use COBS framing (
ndn_embedded::cobs) to delimit NDN packets on the UART byte stream. - Only produce Data when an Interest actually matches (check the PIT or inspect the Interest name).
- Use a hardware RNG or counter for the Interest nonce when the node itself sends Interests upstream.
- Optionally enable the content store (
features = ["cs"]) if the same reading is requested multiple times within its freshness period.
The key point is that the entire NDN stack – TLV parsing, FIB longest-prefix match, PIT loop detection, packet forwarding – runs in under 2 KB of RAM with no heap allocator, no async runtime, and no operating system.
Browser WebSocket Interop Testing
ndn-rs includes a browser-based integration test suite that verifies NDN packet exchange between NDNts running inside a real Chromium browser and the ndn-rs forwarder over WebSocket.
Why browser tests?
The ndn-fwd WebSocket face is the primary transport for browser-based NDN
applications. Unit and integration tests in Rust can verify the Rust side, but
they cannot exercise the browser’s native WebSocket API and the JavaScript NDN
stack in the same way a real browser can. Playwright automates a headless
Chromium instance, giving confidence that:
- NDNts
WsTransportsuccessfully connects to ndn-fwd’s WS face. - Interest forwarding works across two independent browser WS connections.
- PIT aggregation deduplicates concurrent browser Interests correctly.
Test topology
browser page A (NDNts producer)
│ WebSocket (ws://localhost:9797)
▼
ndn-fwd ← started as a subprocess by the test
│ WebSocket (ws://localhost:9797)
▼
browser page B (NDNts consumer)
Both pages connect to the same ndn-fwd instance on different WebSocket
connections. Page A registers a prefix via the NFD management protocol
(rib/register) and produces Data. Page B fetches the Data by name.
ndn-fwd routes the Interest from B → A and the Data back from A → B.
Running locally
Prerequisites: Rust toolchain, Node.js ≥ 20, a release build of ndn-fwd.
# Build ndn-fwd
cargo build --release --bin ndn-fwd
# Install Node deps and build the NDNts browser bundle
cd testbed/tests/browser
npm install
node build.mjs
# Install Playwright's Chromium browser
npx playwright install chromium
# Run the tests (ndn-fwd is started automatically)
npm test
# Watch test output in a real browser window
npm run test:headed
NDNts bundle
build.mjs uses esbuild to bundle the following NDNts packages into
fixture-page/ndnts.bundle.js as a browser IIFE (window.NDNts):
| Package | Purpose |
|---|---|
@ndn/ws-transport | WebSocket transport (WsTransport.createFace) |
@ndn/endpoint | Consumer (consume) and Producer (produce) |
@ndn/packet | Interest, Data, Name types |
@ndn/fw | NDNts in-browser mini-forwarder (pulled in transitively) |
The bundle is gitignored and must be rebuilt after npm install.
Test scenarios
| Test | What is verified |
|---|---|
browser producer → ndn-fwd WS → browser consumer | End-to-end Interest-Data exchange through two WS faces |
PIT aggregation: two consumers fetch the same name | ndn-fwd coalesces concurrent Interests; one upstream request satisfies both |
sequential multi-fetch: 5 distinct names | WS face handles multiple request-reply cycles reliably |
CI
The workflow .github/workflows/browser.yml runs on:
- Push / PR touching the WebSocket face, ndn-fwd, or browser test code.
- Weekly cron (Monday 04:00 UTC) to catch NDNts upstream changes.
- Manual dispatch.
It builds ndn-fwd --release, installs Playwright, bundles NDNts, and runs
the Playwright tests. The Playwright HTML report is uploaded as a CI artifact
on failure.
Extending the tests
Add new scenarios to ws-transport.spec.ts. Common patterns:
// Open a fresh browser context per role to avoid shared JS state.
const ctx = await browser.newContext();
const page = await ctx.newPage();
await openPage(page); // loads fixture page, waits for NDNts bundle
// Start a producer (sends rib/register, waits 600 ms for propagation).
await startProducer(page, '/my/prefix', 'my-content');
// Consume from another page.
const content = await fetchData(consumerPage, '/my/prefix');
expect(content).toBe('my-content');
await ctx.close();
To test TLS WebSocket (wss://), configure ndn-fwd with a
[[face]] kind = "web-socket" and a tls sub-table, and change WS_URL in
the spec to a wss:// URL with { rejectUnauthorized: false } in the
Playwright launch options.
CLI Tools
You’ve got ndn-fwd running. Now what? The ndn-tools suite gives you the equivalent of ping, curl, and iperf for NDN networks.
Every tool in the suite communicates with the local router over the same IPC path: a Unix domain socket (default /run/nfd/nfd.sock) with an optional shared-memory data plane for high throughput. The management commands use the NFD-compatible control protocol; the data-plane tools use an InProcFace channel that plugs directly into the forwarding pipeline.
flowchart LR
subgraph "ndn-tools"
peek["ndn-peek"]
put["ndn-put"]
ping["ndn-ping"]
iperf["ndn-iperf"]
traffic["ndn-traffic"]
ctl["ndn-ctl"]
end
subgraph "ndn-fwd"
mgmt["Mgmt\n(NFD protocol)"]
pipeline["Forwarding\nPipeline"]
shm["SHM ring\nbuffer"]
end
ctl -- "control Interest/Data" --> mgmt
peek -- "InProcFace (IPC)" --> pipeline
put -- "InProcFace (IPC)" --> pipeline
ping -- "InProcFace / ForwarderClient" --> pipeline
iperf -- "ForwarderClient + SHM" --> shm
traffic -- "embedded engine" --> pipeline
All data-plane tools accept a --face-socket flag (or $NDN_FACE_SOCK environment variable) to override the default socket path, and --no-shm to fall back to pure Unix-socket transport when shared memory is unavailable.
ndn-peek and ndn-put
These are the simplest tools in the suite – the equivalent of curl for NDN. One sends a single Interest and prints the Data it gets back; the other publishes a file as named Data segments.
ndn-peek
Fetch a single named Data packet:
ndn-peek /ndn/edu/ucla/ping/42
ndn-peek /ndn/video/frame-001 --timeout-ms 2000
The first argument is the NDN name to request. By default, ndn-peek waits 4 seconds for a response before giving up. Override this with --timeout-ms.
ndn-put
Publish a file as one or more named Data segments:
ndn-put /ndn/files/readme README.md
ndn-put /ndn/video/clip clip.mp4 --chunk-size 4096
ndn-put reads the file, splits it into segments using the NDN segmentation convention, and registers a prefix handler on the router to serve them. The default segment size follows the NDN standard (NDN_DEFAULT_SEGMENT_SIZE); use --chunk-size to override it for larger payloads.
ndn-ping
Reachability and latency testing for named prefixes, modeled directly after ICMP ping. It runs in two modes: a server that registers a prefix and replies to ping Interests, and a client that sends those Interests and measures round-trip time.
Server
Start a ping responder on the default /ping prefix:
ndn-ping server
ndn-ping server --prefix /ndn/my-node/ping --freshness 1000 --sign
The server registers the prefix with the router, then loops: for every Interest it receives, it sends back an empty Data packet. Passing --sign generates an ephemeral Ed25519 identity and signs each reply – useful for testing the cost of cryptographic verification in the pipeline.
The --freshness flag sets the FreshnessPeriod on response Data in milliseconds. A value of 0 (the default) omits the field entirely, meaning the Content Store will not serve stale cached copies.
Client
Measure RTT to a running ping server:
ndn-ping client --prefix /ping --count 10
ndn-ping client --prefix /ndn/remote/ping -c 100 -i 500 --lifetime 2000
The client sends Interests sequentially, printing per-packet timing as they return. When it finishes (or you press Ctrl-C), it prints a summary that mirrors the familiar ping format:
--- /ping ping statistics ---
10 transmitted, 10 received, 0 nacked, 0.0% loss, time 9.0s
rtt min/avg/max/p50/p99/stddev = 45µs/62µs/120µs/58µs/120µs/22 µs
| Flag | Default | Meaning |
|---|---|---|
--prefix | /ping | Name prefix to ping |
-c, --count | 4 | Number of pings (0 = unlimited) |
-i, --interval | 1000 | Milliseconds between pings |
--lifetime | 4000 | Interest lifetime in milliseconds |
--no-shm | false | Disable shared-memory transport |
ndn-ctl
Runtime management of a running router, following the same noun verb pattern as NFD’s nfdc. Under the hood, ndn-ctl sends NFD management Interests over the IPC socket and decodes the ControlResponse Data that comes back.
Face management
ndn-ctl face create udp4://192.168.1.1:6363
ndn-ctl face create tcp4://router.example.com:6363
ndn-ctl face destroy 3
ndn-ctl face list
Route management
ndn-ctl route add /ndn --face 1 --cost 10
ndn-ctl route remove /ndn --face 1
ndn-ctl route list
Strategy management
ndn-ctl strategy set /ndn --strategy /localhost/nfd/strategy/best-route
ndn-ctl strategy unset /ndn
ndn-ctl strategy list
Content Store
ndn-ctl cs info # capacity, entry count, memory usage
ndn-ctl cs config --capacity 1000000 # set max capacity in bytes
ndn-ctl cs erase /ndn/video # evict cached entries by prefix
ndn-ctl cs erase /ndn/video --count 100 # evict at most 100 entries
Service discovery
ndn-ctl service list # locally announced services
ndn-ctl service browse # all known services (local + peers)
ndn-ctl service browse /ndn/sensors # filter by prefix
ndn-ctl service announce /ndn/my-app # announce a service at runtime
ndn-ctl service withdraw /ndn/my-app # withdraw announcement
Neighbors, status, and shutdown
ndn-ctl neighbors list
ndn-ctl status
ndn-ctl shutdown
Security (local, no router needed)
The security subcommands operate on the local PIB (Public Information Base) and do not require a running router:
ndn-ctl security init --name /ndn/my-identity
ndn-ctl security info
ndn-ctl security export --name /ndn/my-identity -o cert.hex
ndn-ctl security trust cert.ndnc
Tip: ndn-ctl also supports a
--bypassflag that uses the legacy JSON-over-Unix-socket transport instead of the NFD management protocol. This is mainly useful for debugging the router’s management layer itself.
ndn-traffic
A self-contained traffic generator that does not need a running router. It spins up an embedded forwarding engine with in-process InProcFace pairs and drives configurable Interest/Data traffic through the full pipeline. This makes it ideal for stress-testing the pipeline stages in isolation.
ndn-traffic --count 100000 --rate 50000 --size 1024 --concurrency 4
ndn-traffic --mode sink --count 10000 # no producer, all Interests Nack
| Flag | Default | Meaning |
|---|---|---|
--mode | echo | echo = producer replies with Data; sink = no producer (everything Nacks) |
--count | 10,000 | Total Interests to send (split across flows) |
--rate | 0 (unlimited) | Target aggregate rate in packets/sec |
--size | 1024 | Data payload size in bytes |
--concurrency | 1 | Number of parallel consumer flows |
--prefix | /traffic | Name prefix for generated traffic |
In echo mode, ndn-traffic creates a FIB route from the prefix to a producer face, so every Interest is satisfied. In sink mode, no producer exists, so every Interest results in a Nack – useful for measuring Nack processing overhead.
The output includes loss percentage, throughput in pps and Mbps, and latency percentiles (min, avg, p50, p95, p99, max):
ndn-traffic: mode=echo count=100000 rate=unlimited size=1024B concurrency=4
sent=100000 received=100000 lost=0 (0.00%)
throughput: 245000 pps, 2006.42 Mbps
latency: min=8us avg=16us p50=14us p95=32us p99=58us max=210us
elapsed: 0.41s
ndn-iperf
Sustained bandwidth measurement between two nodes, modeled after iperf3. Unlike ndn-traffic (which embeds its own engine), ndn-iperf connects to a running router and measures real network throughput including IPC overhead, SHM transfer, and pipeline processing.
Server
ndn-iperf server
ndn-iperf server --prefix /iperf --size 8192 --sign
ndn-iperf server --hmac --freshness 1000
The server registers its prefix, optionally announces it via service discovery, then replies to every Interest with a Data packet of the configured payload size. Signing options:
--sign: Ed25519 signatures (cryptographically strong, slower)--hmac: HMAC-SHA256 signatures (faster, uses a fixed benchmark key)
Client
ndn-iperf client
ndn-iperf client --prefix /iperf --duration 30 --window 128
ndn-iperf client --cc cubic --window 64 --duration 60
The client sends Interests in a sliding window and measures throughput, loss, and RTT. It prints periodic interval reports during the test and a final summary:
--- ndn-iperf results ---
duration: 10.02s
transferred: 78.45 MB (82247680 bytes)
throughput: 62.58 Mbps
packets: 10032 sent, 10030 received, 2 lost (0.0% loss)
RTT: avg=523us min=89us max=4201us
p50=412us p95=1205us p99=2880us
Congestion control
ndn-iperf includes three congestion control algorithms, selectable via --cc:
| Algorithm | Description |
|---|---|
aimd (default) | Additive-increase, multiplicative-decrease. Classic TCP-like behavior. |
cubic | CUBIC algorithm, less aggressive backoff on loss. |
fixed | Constant window, no adaptation. Good for controlled benchmarks. |
Fine-tuning flags for advanced use:
ndn-iperf client --cc aimd --ai 2.0 --md 0.7 --min-window 4 --max-window 512
ndn-iperf client --cc cubic --cubic-c 0.2 --window 32
| Flag | Default | Meaning |
|---|---|---|
--duration | 10 | Test duration in seconds |
--window | 64 | Initial (and for fixed, constant) window size |
--cc | aimd | Congestion control: aimd, cubic, or fixed |
--lifetime | 4000 | Interest lifetime in milliseconds |
--interval | 1 | Interval in seconds between periodic status reports |
-q, --quiet | false | Suppress periodic reports, show only the final summary |
Note: The
--windowflag also sets the initial slow-start threshold (ssthresh). This prevents unbounded slow start from rocketing the window on low-RTT links. For high-BDP (bandwidth-delay product) paths, increase--windowand--max-windowtogether.
ndn-bench
A lightweight micro-benchmark for measuring the overhead of the forwarding pipeline’s internal channels. It spins up an embedded engine and drives Interests through InProcFace channel round-trips, reporting latency percentiles and aggregate throughput.
ndn-bench
ndn-bench --interests 50000 --concurrency 20 --name /bench/test
| Flag | Default | Meaning |
|---|---|---|
--interests | 1000 | Total Interests to process |
--concurrency | 10 | Number of parallel worker tasks |
--name | /bench | Name prefix for benchmark Interests |
Sample output:
ndn-bench: 50000 interests, concurrency=20, prefix=/bench/test
ndn-bench: 1250000 interests/sec over 0.04s
rtt: n=50000 avg=3µs p50=2µs p95=5µs p99=8µs
Note: ndn-bench currently measures InProcFace channel overhead only (the Interest is not wired through the full pipeline). It is most useful for establishing a baseline cost for the IPC mechanism itself.
Common Workflows
The tools compose naturally. Here is a typical sequence for validating a new link and measuring its capacity.
1. Check reachability
First, verify that the remote prefix is reachable through the forwarding plane:
# On the remote node
ndn-ping server --prefix /ndn/remote
# On the local node
ndn-ping client --prefix /ndn/remote -c 10
If pings succeed, the FIB routes and faces are correctly configured. If they time out, use ndn-ctl to inspect the forwarding state:
ndn-ctl face list
ndn-ctl route list
ndn-ctl status
2. Measure throughput
Once reachability is confirmed, run a sustained bandwidth test:
# On the remote node
ndn-iperf server --prefix /ndn/remote/iperf --size 8192
# On the local node
ndn-iperf client --prefix /ndn/remote/iperf --duration 30 --window 128
Start with the default AIMD congestion control. If you see high loss or oscillating throughput, try CUBIC or a fixed window to isolate the issue:
ndn-iperf client --prefix /ndn/remote/iperf --cc fixed --window 32
3. Stress-test the local pipeline
For profiling the forwarder itself without network variables, use ndn-traffic with the embedded engine:
ndn-traffic --count 1000000 --concurrency 8 --size 4096
4. Monitor in real-time
While any of the above tests are running, use ndn-ctl in another terminal to watch the router state:
watch -n 1 ndn-ctl status
ndn-ctl cs info
ndn-ctl service browse
Docker Deployment
ndn-fwd is published as a minimal Docker image to the GitHub Container Registry. This page explains how to pull, configure, and run it.
Image tags
| Tag | Description |
|---|---|
latest | Latest stable release (tracks vX.Y.Z git tags) |
X.Y.Z | Specific release, e.g. 0.1.0 |
edge | Latest commit on main (may be unstable) |
docker pull ghcr.io/quarmire/ndn-fwd:latest
Quick start
docker run --rm \
-p 6363:6363/udp \
-p 6363:6363/tcp \
ghcr.io/quarmire/ndn-fwd:latest
This starts the forwarder with the built-in default configuration (UDP + TCP listeners on port 6363, no static routes).
Supplying a configuration file
The container reads its config from /etc/ndn-fwd/config.toml. Mount your own file over it:
docker run --rm \
-p 6363:6363/udp \
-p 6363:6363/tcp \
-v /path/to/ndn-fwd.toml:/etc/ndn-fwd/config.toml:ro \
ghcr.io/quarmire/ndn-fwd:latest
A minimal ndn-fwd.toml for a router with UDP and multicast faces:
[engine]
cs_capacity_mb = 64
[[face]]
kind = "udp"
bind = "0.0.0.0:6363"
[[face]]
kind = "multicast"
group = "224.0.23.170"
port = 56363
[[route]]
prefix = "/ndn"
face = 0
cost = 10
See Running the Forwarder for the full configuration reference.
Supplying TLS certificates
When the WebSocket face is configured with TLS, mount the certificate and key files into /etc/ndn-fwd/certs/ (the directory is pre-created in the image):
docker run --rm \
-p 6363:6363/udp \
-p 6363:6363/tcp \
-p 9696:9696/tcp \
-v /path/to/ndn-fwd.toml:/etc/ndn-fwd/config.toml:ro \
-v /path/to/cert.pem:/etc/ndn-fwd/certs/cert.pem:ro \
-v /path/to/key.pem:/etc/ndn-fwd/certs/key.pem:ro \
ghcr.io/quarmire/ndn-fwd:latest
Reference the mounted paths in ndn-fwd.toml:
[[face]]
kind = "web-socket"
bind = "0.0.0.0:9696"
tls_cert = "/etc/ndn-fwd/certs/cert.pem"
tls_key = "/etc/ndn-fwd/certs/key.pem"
Obtaining a certificate
With Let’s Encrypt / Certbot:
certbot certonly --standalone -d router.example.com
# Certificates written to /etc/letsencrypt/live/router.example.com/
Then mount the live directory:
-v /etc/letsencrypt/live/router.example.com/fullchain.pem:/etc/ndn-fwd/certs/cert.pem:ro
-v /etc/letsencrypt/live/router.example.com/privkey.pem:/etc/ndn-fwd/certs/key.pem:ro
Accessing the management socket
The router’s Unix management socket is created inside the container at /run/ndn-fwd/mgmt.sock. Bind-mount the directory to reach it from the host:
mkdir -p /run/ndn-fwd
docker run --rm \
-p 6363:6363/udp \
-p 6363:6363/tcp \
-v /path/to/ndn-fwd.toml:/etc/ndn-fwd/config.toml:ro \
-v /run/ndn-fwd:/run/ndn-fwd \
ghcr.io/quarmire/ndn-fwd:latest
Then use ndn-ctl from the host:
ndn-ctl --socket /run/ndn-fwd/mgmt.sock status
ndn-ctl --socket /run/ndn-fwd/mgmt.sock face list
ndn-ctl --socket /run/ndn-fwd/mgmt.sock route add /ndn --face 1 --cost 10
Docker Compose
A complete docker-compose.yml for a single-node testbed:
services:
ndn-fwd:
image: ghcr.io/quarmire/ndn-fwd:latest
restart: unless-stopped
ports:
- "6363:6363/udp"
- "6363:6363/tcp"
- "9696:9696/tcp"
volumes:
- ./ndn-fwd.toml:/etc/ndn-fwd/config.toml:ro
- ./certs/cert.pem:/etc/ndn-fwd/certs/cert.pem:ro
- ./certs/key.pem:/etc/ndn-fwd/certs/key.pem:ro
- ndn-mgmt:/run/ndn-fwd
volumes:
ndn-mgmt:
Building the image locally
From the repository root:
docker build -f binaries/ndn-fwd/Dockerfile -t ndn-fwd .
docker run --rm -p 6363:6363/udp ndn-fwd
Image details
| Property | Value |
|---|---|
| Base image | debian:trixie-slim |
| Runtime dependencies | ca-certificates |
| Default config path | /etc/ndn-fwd/config.toml |
| Certificate directory | /etc/ndn-fwd/certs/ |
| Management socket | /run/ndn-fwd/mgmt.sock |
| Exposed ports | 6363/udp, 6363/tcp, 9696/tcp |
The image uses a two-stage build: the builder stage compiles ndn-fwd from source using the official rust:1.85-slim image; the runtime stage copies only the compiled binary into a minimal debian:trixie-slim base with no Rust toolchain.
Performance Tuning
ndn-rs is fast out of the box. But if you’re pushing millions of packets per second or running on constrained hardware, here’s how to squeeze out more performance — and more importantly, how to decide which knobs to turn first.
The single most important principle in performance tuning is this: measure before you change anything. The sections below are organized around the bottleneck you actually have, not the one you think you have. If you skip straight to tweaking settings, you will waste time optimizing the wrong thing.
Find Your Bottleneck First
Not all tuning is equal. Doubling your Content Store size does nothing if your pipeline is CPU-bound. Adding Tokio workers is pointless if your CS lock is the contention point. Before turning any knob, ask: where is my bottleneck?
The following decision tree will guide you to the right section:
flowchart TD
A["Observe poor performance"] --> B{"Run cargo flamegraph\nor tracing analysis"}
B --> C{"CPU utilization\nhigh across cores?"}
C -- Yes --> D{"All cores busy,\nor one core pinned?"}
D -- "All cores busy" --> E["CPU-bound:\nPipeline parallelism\n& Tokio workers"]
D -- "One core pinned" --> F["Single-thread bottleneck:\nShardedCs or\nruntime config"]
C -- No --> G{"Memory pressure\nor OOM?"}
G -- Yes --> H["Memory-bound:\nCS sizing &\nPIT expiry"]
G -- No --> I{"Throughput plateaus\nbefore CPU/memory limit?"}
I -- Yes --> J["Throughput-bound:\nFace buffers, batch size,\nShardedCs"]
I -- No --> K["Latency-bound:\nCS capacity,\nsingle-thread mode,\nFIB depth"]
style E fill:#e8f4e8,stroke:#2d7a2d
style F fill:#e8f4e8,stroke:#2d7a2d
style H fill:#fff3e0,stroke:#e65100
style J fill:#e3f2fd,stroke:#1565c0
style K fill:#fce4ec,stroke:#c62828
CPU-bound
Your cores are maxed out processing packets. The forwarding pipeline itself — TLV decode, CS lookup, PIT operations, FIB longest-prefix match — is consuming all available CPU.
What to do: Increase Tokio worker threads to spread pipeline work across cores. If contention on shared data structures (CS, PIT) is the issue, switch to ShardedCs and confirm PIT sharding via DashMap is effective. See Tokio Runtime Configuration and ShardedCs.
Memory-bound
You’re running out of RAM, or garbage collection / eviction overhead is dominating. This is common on constrained devices or when caching large Data packets.
What to do: Reduce CS capacity, tighten PIT expiry lifetimes, or switch to a no-op CS on embedded targets. See Content Store Sizing and PIT Expiry.
Throughput-bound
CPU and memory look fine, but packets per second plateaus. This usually means something is blocking the pipeline: face readers stalling on a full channel, CS lock contention serializing concurrent lookups, or insufficient buffering on high-speed links.
What to do: Increase pipeline_channel_capacity, enlarge face buffers for fast links, and adopt ShardedCs. See Pipeline Channel Capacity and Face Buffer Sizes.
Latency-bound
Individual packet latency is too high, even though aggregate throughput is acceptable. Packets sit in queues too long, or CS misses dominate when hits would be possible with a larger cache.
What to do: Consider a current-thread runtime to eliminate cross-thread scheduling jitter. Increase CS capacity so more lookups are hits (CS hits short-circuit the pipeline early). Keep FIB prefixes short to reduce trie traversal depth. See Content Store Sizing and FIB Lookup Optimization.
Profiling: How to Find the Actual Bottleneck
⚠️ Important: Always profile before tuning. The most common mistake is optimizing the wrong thing. Run
cargo flamegraphorperf recordon a realistic workload first — the actual bottleneck is often not where you expect. The Criterion benchmark suite measures individual components in isolation, but production bottlenecks emerge from the interaction between components under load.
Tracing spans
ndn-rs instruments the pipeline with tracing spans. To see where time is spent, attach a tracing subscriber that records span durations. The tracing-timing or tracing-chrome crates produce per-span histograms and Chrome trace files, respectively.
A practical approach: run your workload with RUST_LOG=ndn_engine=trace and a timing subscriber, then look for spans with unexpectedly high durations. Common culprits are cs_lookup, pit_insert, and fib_lpm.
🔍 Tip: The library never initializes a tracing subscriber — that’s your binary’s responsibility. This means you can swap between a lightweight production subscriber and a detailed profiling subscriber without recompiling the engine.
Flamegraphs
Flamegraphs give you a visual map of where CPU time goes:
# Install cargo-flamegraph if needed
cargo install flamegraph
# Generate a flamegraph from a benchmark or your router binary
cargo flamegraph --bin ndn-fwd -- --config my-config.toml
# Or from the benchmark suite
cargo flamegraph --bench pipeline -- --bench "interest_pipeline"
Look for wide bars (functions consuming a lot of time). Common findings:
- Wide bars in
TlvDecode— your packets are large or complex; consider whether you need full decode on every path. - Wide bars in
RwLock::readorRwLock::write— lock contention; switch toShardedCsor check PIT access patterns. - Wide bars in
HashMap::get— FIB or PIT hash collisions; check name distributions.
The benchmark suite
ndn-rs includes a Criterion-based benchmark suite in crates/engine/ndn-engine/benches/pipeline.rs. Use it to measure the impact of individual tuning changes in isolation before applying them to production:
# Run all benchmarks
cargo bench -p ndn-engine
# Run a specific benchmark group
cargo bench -p ndn-engine -- "cs/"
# Generate HTML reports (in target/criterion/)
cargo bench -p ndn-engine
open target/criterion/report/index.html
Key benchmarks to watch:
| Benchmark | What it measures |
|---|---|
decode/interest | TLV decode cost per Interest |
cs/hit, cs/miss | Content Store lookup latency |
pit/new_entry, pit/aggregate | PIT insert and aggregation |
fib/lpm | FIB longest-prefix match at 10/100/1000 routes |
interest_pipeline/no_route | Full Interest pipeline (decode + CS miss + PIT new) |
data_pipeline | Full Data pipeline (decode + PIT match + CS insert) |
See Pipeline Benchmarks for detailed results and Methodology for how measurements are collected.
📊 Tip: Run benchmarks with
--save-baseline beforebefore a tuning change, then--baseline beforeafter. Criterion will show you a statistical comparison so you know whether your change actually helped or just added noise.
Pipeline Channel Capacity
The engine uses a shared mpsc channel to funnel packets from all face reader tasks into the pipeline runner. This is one of the most impactful tuning knobs because it sits at the convergence point of all inbound traffic.
#![allow(unused)]
fn main() {
let config = EngineConfig {
pipeline_channel_capacity: 4096, // default: 1024
..Default::default()
};
}
The tradeoff: Increasing pipeline_channel_capacity from 1024 to 4096 absorbs traffic bursts — a face reader can dump a batch of received packets without blocking, keeping the network stack moving. But a larger channel uses more memory and, more subtly, can hide backpressure problems. If your pipeline is slow, a deep queue lets packets pile up, increasing latency for all of them. You want the queue deep enough that face readers rarely block, but shallow enough that packets do not age in the queue.
When to increase: If tracing shows face reader tasks spending significant time blocked on channel sends, your channel is too small. This manifests as throughput drops that correlate with high face counts or bursty traffic patterns.
When to decrease: If you care about tail latency more than peak throughput (e.g., real-time applications), a smaller channel ensures packets are processed promptly or dropped, rather than delivered late.
🎯 Tip: The three highest-impact tuning knobs are, in order: (1) ShardedCs on multi-threaded runtimes (eliminates CS lock contention), (2) pipeline channel capacity (prevents face reader stalls), and (3) Tokio worker threads (scales forwarding across cores). Start with these before touching anything else.
Content Store Sizing
The LruCs is sized in bytes, not entry count. This is a deliberate design choice: a Content Store holding 1 KiB Data packets behaves very differently from one holding 100-byte packets, and byte-based sizing adapts automatically.
#![allow(unused)]
fn main() {
let cs = LruCs::new(256 * 1024 * 1024); // 256 MiB
}
The tradeoff: A larger CS means more cache hits, which short-circuit the Interest pipeline early (before PIT insertion, FIB lookup, or outbound forwarding). Each CS hit saves significant work. But memory is finite, and on constrained devices, an oversized CS starves the OS page cache or other processes.
Rules of thumb:
- Router with diverse traffic: 256 MiB – 1 GiB depending on available RAM. The CS stores wire-format
Bytes, so the overhead per entry is minimal beyond the packet data itself. - Edge device / IoT gateway: 16 – 64 MiB. Enough to cache frequently-requested sensor data or configuration objects.
- Embedded (no CS): Use a no-op CS implementation. Zero memory overhead, zero lookup cost.
When the byte limit is exceeded, the least-recently-used entries are evicted. This happens inline during insertion, so eviction cost is amortized across inserts rather than appearing as periodic GC pauses.
ShardedCs for Concurrent Access
Under high concurrency (many pipeline tasks hitting the CS simultaneously), lock contention on a single LruCs becomes the bottleneck. You will see this in flamegraphs as wide bars on RwLock::read or RwLock::write inside CS operations. ShardedCs distributes entries across multiple independent shards, each with its own lock:
#![allow(unused)]
fn main() {
use ndn_store::ShardedCs;
// 16 shards, 256 MiB total (16 MiB per shard).
let cs = ShardedCs::<LruCs>::new(16, 256 * 1024 * 1024);
}
The tradeoff: Sharding eliminates contention but sacrifices global LRU accuracy. Each shard maintains its own LRU list, so an entry that is “least recently used” globally might survive if its shard has capacity, while a more recently used entry in a full shard gets evicted. In practice, with reasonable shard counts (8–32), the hit rate impact is negligible and the throughput gain is substantial.
When to use ShardedCs:
- You are running a multi-threaded Tokio runtime (
multi_thread) - Benchmark or profiling shows CS lock contention
- Pipeline throughput plateaus despite available CPU
When plain LruCs is fine:
- Single-threaded runtimes or low-throughput scenarios
- CS hit rate is low (most Interests miss the cache anyway, so CS access is not the bottleneck)
📊 Performance: In benchmarks,
ShardedCswith 16 shards on an 8-core machine shows 3-5x throughput improvement over plainLruCswhen the CS hit rate is high. The tradeoff is slightly less optimal LRU ordering (each shard maintains its own LRU list), which can marginally reduce hit rates. For most workloads, the concurrency gain far outweighs the eviction accuracy loss.
FIB Lookup Optimization
The FIB is a name trie with HashMap<Component, Arc<RwLock<TrieNode>>> per level. Longest-prefix match traverses from the root to the deepest matching node, locking each level independently (so concurrent lookups on disjoint branches do not contend).
The key insight: lookup cost is proportional to name depth, not route count. A lookup for /a/b touches 2 trie levels; /a/b/c/d/e/f touches 6. The trie fans out at each level via hash maps, so 1,000 routes under /app with 2-component names is fast, while 10 routes with 10-component names is slower per lookup.
What you can control:
- Keep prefixes short. If your application can use shorter prefixes without ambiguity, do so. This is the single most effective FIB optimization.
- Minimize deep nesting. Hierarchical naming is powerful, but deeply nested names (8+ components) incur measurable per-lookup cost under high Interest rates.
For FIB sizes above ~10,000 routes, monitor LPM latency via the benchmark suite (see Pipeline Benchmarks). At that scale, hash collision rates in per-level HashMaps can start to matter; the benchmark will tell you if they do in your name distribution.
PIT Expiry Interval
PIT entries are expired using a hierarchical timing wheel with O(1) insert and cancel. Entries expire based on their Interest Lifetime (typically 4 seconds), and the timing wheel granularity is 1 ms.
The tradeoff: The timing wheel is efficient by design, so under normal loads you do not need to tune it. However, at extremely high Interest rates (>1M/s), the expiry tick task competes with pipeline work for Tokio worker time. If expiry falls behind, stale PIT entries accumulate, consuming memory and potentially causing false aggregation (a new Interest matches a stale PIT entry instead of being forwarded).
What to do if PIT memory grows: Check whether the timing wheel tick task is being starved. If it is, either dedicate a Tokio worker thread to it or use spawn_blocking for the expiry sweep. On memory-constrained devices, consider reducing the default Interest Lifetime at the application level to keep PIT entries short-lived.
⚠️ Warning: Reducing Interest Lifetime aggressively (below 1 second) can cause legitimate Interests to expire before Data returns, leading to retransmissions that increase load rather than reducing it. Only shorten lifetimes if your RTT budget genuinely supports it.
Face Buffer Sizes
Each face uses bounded mpsc channels for inbound and outbound packet buffering. The buffer size determines the tradeoff between burst absorption and memory usage.
#![allow(unused)]
fn main() {
// In your face constructor:
let (tx, rx) = mpsc::channel(256); // 256 packets
}
The tradeoff: Larger buffers absorb traffic bursts without dropping packets, which is critical on high-speed links where a momentary pipeline stall could cause packet loss. But each buffer slot holds a Bytes handle (~32 bytes plus the packet data), so over-buffering across many faces adds up. On a router with 100 active faces, the difference between 128 and 2048 per face is significant.
| Scenario | Suggested size | Rationale |
|---|---|---|
| Local face (App, SHM, Unix) | 128 – 256 | Low latency path, rarely bursts |
| Network face (UDP, TCP) | 256 – 512 | Network jitter causes bursty arrivals |
| High-throughput link (10G Ethernet) | 512 – 2048 | Must absorb line-rate bursts during pipeline stalls |
| Low-bandwidth link (Serial, BLE) | 16 – 64 | Limited bandwidth means limited burst size; save memory |
🔍 Tip: If you see packet drops on a face but the pipeline has spare capacity, the face buffer is too small. If you see high memory usage but low throughput, the face buffers may be too large across too many idle faces. Consider dynamically sizing buffers based on link speed if you have heterogeneous faces.
Tokio Runtime Configuration
ndn-rs is built on Tokio throughout. The runtime configuration is one of the highest-leverage tuning decisions because it determines how pipeline work is scheduled across cores.
Multi-threaded runtime (recommended for routers)
#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4) // match available cores
.max_blocking_threads(2) // for CS persistence, crypto
.enable_all()
.build()?;
}
worker_threads controls how many OS threads run the Tokio executor. Set this to the number of cores you want dedicated to forwarding. On a 4-core router, use 4. On a shared system where other services need CPU, use fewer. More workers means more parallelism, but also more contention on shared data structures (CS, PIT, FIB locks) — which is why ShardedCs matters on multi-threaded runtimes.
max_blocking_threads caps the thread pool used for blocking operations: PersistentCs (RocksDB/redb) disk I/O and signature validation. 2 is usually enough. Increasing this only helps if profiling shows blocking tasks queueing up.
Current-thread runtime (embedded / latency-sensitive)
#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
}
All tasks run cooperatively on one thread — no synchronization overhead, no cross-thread scheduling jitter, but no parallelism. This is the right choice for:
- Embedded deployments where only one core is available
- Latency-sensitive applications where predictable per-packet timing matters more than aggregate throughput
- Testing and debugging where deterministic execution simplifies reasoning
🔍 Tip: A single-threaded runtime with plain
LruCsoften has lower per-packet latency than a multi-threaded runtime withShardedCs, because there is no lock contention or cross-thread wake-up cost. If your bottleneck is latency rather than throughput, try single-threaded first.
Thread pinning on NUMA systems
For maximum throughput on NUMA systems, pin Tokio workers to specific cores to avoid cross-socket memory access:
#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.on_thread_start(|| {
// Pin to core using core_affinity or similar crate.
})
.build()?;
}
This is an advanced optimization that only matters on multi-socket servers. If you are running on a single-socket machine or a VM, thread pinning provides no benefit.
Putting It All Together
The tuning process should follow this cycle:
- Profile your workload with tracing and flamegraphs. Identify the actual bottleneck.
- Change one thing. Adjust the knob that addresses your identified bottleneck.
- Benchmark. Run the Criterion suite or your production workload and compare before/after.
- Repeat. If performance is still insufficient, profile again — the bottleneck may have shifted.
⚠️ Important: Resist the urge to change multiple settings at once. If you increase channel capacity, switch to ShardedCs, and add worker threads simultaneously, you will not know which change helped (or hurt). Methodical, one-change-at-a-time tuning converges faster than shotgun optimization.
Quick reference: which knob for which bottleneck
| Bottleneck | First knob to turn | Second knob | Third knob |
|---|---|---|---|
| CPU-bound | Tokio worker_threads | ShardedCs | FIB prefix depth |
| Memory-bound | CS byte capacity | PIT Interest Lifetime | Face buffer sizes |
| Throughput-bound | ShardedCs | pipeline_channel_capacity | Face buffer sizes |
| Latency-bound | Current-thread runtime | CS capacity (more hits) | FIB prefix depth |
Setting Up an NDNCERT CA
This guide walks through standing up an NDNCERT certificate authority from scratch. By the end you will have a running CA that accepts enrollment requests, issues 24-hour certificates, and handles both factory token provisioning and renewal via possession proof.
If you are deploying a fleet of devices and want the full architectural context, read Fleet and Swarm Security first. This guide focuses on the mechanics.
Prerequisites
- A running NDN router (
ndn-fwdon the same host, or reachable via UDP/Ethernet) - Rust toolchain and the ndn-rs workspace checked out
- An identity for the CA (either self-signed or issued by a higher-level CA)
The CA uses ndn-identity, ndn-cert, and ndn-app. Add them to your Cargo.toml:
[dependencies]
ndn-identity = { path = "crates/protocols/ndn-identity" }
ndn-cert = { path = "crates/protocols/ndn-cert" }
ndn-app = { path = "crates/engine/ndn-app" }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
Step 1: Create the CA Identity
The CA needs its own NDN identity — a key pair and a self-signed certificate (for a root CA) or a certificate issued by a higher authority (for a sub-CA). Use NdnIdentity::open_or_create so the identity persists across restarts.
#![allow(unused)]
fn main() {
use std::path::PathBuf;
use ndn_identity::NdnIdentity;
// Create (or load from disk) the CA's identity
let ca_identity = NdnIdentity::open_or_create(
&PathBuf::from("/var/lib/ndn/ca-identity"),
"/example/ca",
).await?;
println!("CA identity: {}", ca_identity.name());
println!("CA DID: {}", ca_identity.did());
}
If /var/lib/ndn/ca-identity is empty, open_or_create generates a new Ed25519 key pair and creates a self-signed certificate. On subsequent runs, it loads the existing key and certificate from disk. The identity directory should be on a persistent, access-controlled volume — it contains the CA’s private key.
For a sub-CA whose certificate was issued by a root CA, you would provision it with NdnIdentity::provision (see Step 5 of the Fleet Security guide) and then load it with open_or_create for subsequent CA operations.
Step 2: Configure Challenge Handlers
Challenges are how the CA verifies that an applicant is authorized to receive a certificate. You can configure multiple challenge types on a single CA; the CA advertises them all in its INFO response and the applicant picks the one it supports.
Token Challenge
Best for factory provisioning (ZTP). Pre-generate tokens and burn them into firmware.
#![allow(unused)]
fn main() {
use ndn_cert::{TokenStore, TokenChallenge};
let mut store = TokenStore::new();
// Add individual tokens
store.add("tok-a3f9b2e1d4c5".to_string());
store.add("tok-7e8f1a2b3c4d".to_string());
// Or add a batch
let tokens: Vec<String> = generate_tokens(1000);
store.add_many(tokens);
let token_challenge = TokenChallenge::new(store);
}
Tokens are single-use and permanently consumed when presented. There is no way to “reset” a token — generate new ones if needed.
Possession Challenge
Best for renewal and sub-namespace enrollment. The applicant proves it holds a certificate that the CA already trusts.
#![allow(unused)]
fn main() {
use ndn_cert::PossessionChallenge;
use ndn_security::Certificate;
// Trust anything signed by our own CA cert (for renewals)
let ca_cert = ca_identity.security_manager().get_certificate()?;
let possession_challenge = PossessionChallenge::new(vec![ca_cert]);
// Or trust a set of root-of-trust certificates (for ECU enrollment)
let ecu_roots: Vec<Certificate> = load_hardware_root_certs()?;
let ecu_challenge = PossessionChallenge::new(ecu_roots);
}
Step 3: Configure Namespace Policy
The namespace policy controls which certificate requests the CA will accept. The two main options are HierarchicalPolicy (default, recommended for most deployments) and DelegationPolicy (for custom rules).
#![allow(unused)]
fn main() {
use ndn_cert::HierarchicalPolicy;
// HierarchicalPolicy: only accept namespaces that are suffixes of the CA's own name.
// CA name: /example/ca
// Accepted: /example/ca/devices/sensor-001
// /example/ca/users/alice
// Rejected: /other-org/device/001 (different prefix)
// /example (CA's own name — not issued to applicants)
let policy = HierarchicalPolicy;
}
For a CA named /fleet/ca, this means it only issues certificates under /fleet/ca/.... If you want the CA to issue for /fleet/... (dropping the ca component), use a DelegationPolicy with explicit rules.
Step 4: Build and Start the CA
#![allow(unused)]
fn main() {
use std::time::Duration;
use ndn_cert::NdncertCa;
use ndn_app::Producer;
let ca = NdncertCa::builder()
.name("/example/ca")
.signing_identity(&ca_identity)
.challenge(token_challenge)
.challenge(possession_challenge)
.policy(HierarchicalPolicy)
.cert_lifetime(Duration::from_secs(24 * 3600)) // 24 hours
.build()?;
// Register the CA prefix with the router and start serving
let producer = Producer::connect("/run/nfd/nfd.sock", "/example/ca").await?;
println!("NDNCERT CA running on /example/ca");
ca.serve(producer).await?;
}
ca.serve(producer) drives the CA’s event loop — it processes incoming INFO, NEW, and CHALLENGE Interests until the producer is shut down. This call does not return until the producer is dropped or the router disconnects.
Step 5: Provision a Device
On the device side, NdnIdentity::provision handles the full NDNCERT client exchange:
#![allow(unused)]
fn main() {
use std::path::PathBuf;
use ndn_identity::{NdnIdentity, DeviceConfig, FactoryCredential, RenewalPolicy};
let config = DeviceConfig {
namespace: "/example/ca/devices/sensor-001".to_string(),
storage: PathBuf::from("/var/lib/ndn/sensor-identity"),
factory_credential: FactoryCredential::Token("tok-a3f9b2e1d4c5".to_string()),
ca_prefix: "/example/ca".parse()?,
renewal: RenewalPolicy::WhenPercentRemaining(20),
delegate: None,
};
let identity = NdnIdentity::provision(config).await?;
println!("Provisioned: {}", identity.did());
// → did:ndn:example:ca:devices:sensor-001
}
Step 6: Verify the Certificate Chain
After provisioning, verify the full chain from the device cert back to the trust anchor:
#![allow(unused)]
fn main() {
use ndn_security::{Validator, TrustSchema};
// Load the CA's certificate as a trust anchor
let ca_cert = ca_identity.security_manager().get_certificate()?;
// Build a validator that trusts the CA cert
let validator = Validator::builder()
.trust_anchor(ca_cert)
.schema(TrustSchema::hierarchical())
.build();
// Fetch a Data packet signed by the device
let data: Data = /* ... */;
match validator.validate_chain(&data).await {
ValidationResult::Valid(safe_data) => println!("valid: chain verified"),
ValidationResult::Invalid(e) => eprintln!("invalid: {e}"),
ValidationResult::Pending => println!("cert not yet fetched"),
}
}
Generating Provisioning Tokens
For factory-scale deployments, generate tokens programmatically and hand them to the manufacturing system:
#![allow(unused)]
fn main() {
use ndn_cert::TokenStore;
fn generate_tokens(count: usize) -> Vec<String> {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..count)
.map(|_| {
let bytes: [u8; 16] = rng.gen();
format!("tok-{}", hex::encode(bytes))
})
.collect()
}
// Generate 10,000 tokens for a production run
let tokens = generate_tokens(10_000);
// Write them to a file for the manufacturing system
let csv = tokens.join("\n");
std::fs::write("factory-tokens.csv", csv)?;
// Load them into the CA's token store
let mut store = TokenStore::new();
store.add_many(tokens);
}
Keep the token list in a secure location. A leaked token list allows unauthorized enrollment under your CA’s namespace.
Running Multiple CA Replicas
To run a second CA replica for high availability, point it at the same identity storage (read-only — the key material is the same on all replicas) and the same distributed token store. Register all replicas under the same prefix in the FIB.
FIB entry: /example/ca → face-1 (CA replica A)
face-2 (CA replica B)
face-3 (CA replica C)
The forwarder distributes Interests across all registered faces. If one replica is unreachable, Interests naturally route to the others (with a brief delay for the forwarder’s dead face detection).
For possession challenges, there is no shared state — each replica independently verifies the proof. For token challenges, the TokenStore backend must be shared (e.g., backed by Redis or etcd) so a token consumed at one replica is not reusable at another.
#![allow(unused)]
fn main() {
// Replica A and Replica B both use the same distributed token store
let store = TokenStore::from_redis("redis://ca-token-store:6379")?;
}
Rotating the CA Certificate
CA certificate rotation is a planned operation. The procedure:
-
Offline ceremony: On the machine holding the root (or parent) CA, issue a new sub-CA certificate with a later validity period. This is done with the same NDNCERT exchange, with the root CA as the issuer.
-
Update trust anchors: Distribute the new CA certificate to all validators in your fleet. This can be done via NDN sync (SVS) — validators subscribe to a trust anchor sync group and pick up the new cert automatically.
-
Transition period: Run the old and new CA certificates simultaneously. Devices renewing during the transition may receive either. Both are valid because both chain to the same root.
-
Retire old cert: Once the old CA cert’s validity period expires, it is automatically invalid. No explicit retirement step needed — short-lived certs handle this naturally.
The root CA offline ceremony is the most sensitive step. Use an air-gapped machine, record the ceremony, and rotate root CA certs infrequently (once every few years is typical).
Full Working Example
Here is a complete, self-contained CA and client example you can run directly:
use std::path::PathBuf;
use std::time::Duration;
use ndn_identity::{NdnIdentity, DeviceConfig, FactoryCredential, RenewalPolicy};
use ndn_cert::{NdncertCa, TokenStore, TokenChallenge, PossessionChallenge, HierarchicalPolicy};
use ndn_app::Producer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// --- CA setup ---
let ca_identity = NdnIdentity::open_or_create(
&PathBuf::from("/tmp/demo-ca-identity"),
"/demo/ca",
).await?;
let mut token_store = TokenStore::new();
token_store.add("demo-token-abc123".to_string());
let ca_cert = ca_identity.security_manager().get_certificate()?;
let possession = PossessionChallenge::new(vec![ca_cert]);
let ca = NdncertCa::builder()
.name("/demo/ca")
.signing_identity(&ca_identity)
.challenge(TokenChallenge::new(token_store))
.challenge(possession)
.policy(HierarchicalPolicy)
.cert_lifetime(Duration::from_secs(24 * 3600))
.build()?;
let producer = Producer::connect("/run/nfd/nfd.sock", "/demo/ca").await?;
// Spawn the CA in the background
tokio::spawn(async move {
ca.serve(producer).await.expect("CA failed");
});
// --- Device provisioning ---
let device_config = DeviceConfig {
namespace: "/demo/ca/device/001".to_string(),
storage: PathBuf::from("/tmp/demo-device-identity"),
factory_credential: FactoryCredential::Token("demo-token-abc123".to_string()),
ca_prefix: "/demo/ca".parse()?,
renewal: RenewalPolicy::WhenPercentRemaining(20),
delegate: None,
};
let device_identity = NdnIdentity::provision(device_config).await?;
println!("Device enrolled!");
println!(" Name: {}", device_identity.name());
println!(" DID: {}", device_identity.did());
// Use the device signer to sign Data packets
let signer = device_identity.signer()?;
println!(" Key name: {}", signer.key_name());
Ok(())
}
See Also
- NDNCERT Protocol Deep Dive — how the protocol works internally
- Fleet and Swarm Security — large-scale deployment patterns
- Identity and DIDs — how NDNCERT-issued certs map to W3C DIDs
- Building NDN Applications — using
NdnIdentityin your application
Fleet and Swarm Security
The Scene
It is 3 AM in a logistics hub. Somewhere on a production line, vehicle number 7,432 rolls off the assembly and is loaded onto a transport truck before sunrise. By noon it will be in a distribution center three states away. By next week it will be making autonomous deliveries in a city it has never visited.
That vehicle needs a cryptographic identity. Every sensor reading it produces, every route update it broadcasts, every command it receives — all of it needs to be signed and verifiable. Not because of a compliance checkbox, but because in an autonomous vehicle network, an unsigned packet is an unsigned check: you have no idea who wrote it or whether it has been tampered with.
You have 10,000 vehicles. Managing 10,000 keys manually is not an option. Neither is shipping them all to a central server for enrollment — that server becomes a single point of failure for your entire fleet. You need a system that:
- Provisions each vehicle automatically, with no human steps per vehicle
- Works even when individual vehicles are temporarily offline
- Allows each vehicle to bootstrap its own sub-systems without internet access
- Makes “revoke this vehicle” as simple as pressing a button, with no CRL distribution
- Scales to sub-systems: a vehicle has 40+ ECUs, each of which also needs a verifiable identity
This guide walks through exactly that system, built on NDNCERT and ndn-security.
The Trust Hierarchy
Before touching any code, it helps to visualize the chain of trust you are building:
Manufacturer root CA
(offline, air-gapped, ceremony once per year)
│
└── Fleet operations CA ← online, runs NDNCERT, issues 24h vehicle certs
│ /fleet/ca
│ │
│ └── Vehicle identity ← auto-renewed every ~19h
│ /fleet/vehicle/<vin>
│ │
│ └── ECU certs ← issued by the vehicle itself, offline
│ /fleet/vehicle/<vin>/ecu/brake
│ /fleet/vehicle/<vin>/ecu/lidar
│ /fleet/vehicle/<vin>/ecu/camera-front
│ /fleet/vehicle/<vin>/ecu/gps
│ ...
Each arrow represents a certificate signature. The root CA signs the fleet operations CA cert. The fleet CA signs each vehicle cert. Each vehicle signs its ECU certs. A remote verifier who holds the root CA cert as a trust anchor can walk this chain and verify the authenticity of a sensor reading from a front camera three levels deep — without contacting any CA at verification time.
The offline root CA is critical. If the fleet operations CA is compromised, you can revoke it from the offline root and reissue a new operations CA cert without changing the trust anchor burned into your systems. The blast radius of a CA compromise is bounded by the namespace scope of that CA.
Factory Provisioning: Zero Human Steps
At manufacture time, the factory generates a unique one-time token for each vehicle and burns it into the firmware alongside the fleet CA prefix. This is the only provisioning step that requires access to the factory system. Everything else happens automatically.
On first boot, the vehicle calls NdnIdentity::provision. This is a single async call that handles the entire NDNCERT exchange:
#![allow(unused)]
fn main() {
use std::path::PathBuf;
use ndn_identity::{NdnIdentity, DeviceConfig, FactoryCredential, RenewalPolicy};
let vin = read_vin_from_hardware(); // e.g. "1HGBH41JXMN109186"
let token = read_factory_token_from_firmware(); // burned in at manufacture
let config = DeviceConfig {
// The namespace this vehicle will own
namespace: format!("/fleet/vehicle/{vin}"),
// Where to persist the identity (survives reboots)
storage: PathBuf::from("/var/lib/ndn/identity"),
// The pre-provisioned token from the factory
factory_credential: FactoryCredential::Token(token),
// Where to find the fleet CA
ca_prefix: "/fleet/ca".parse()?,
// Renew when 20% lifetime remains (~19h for 24h certs)
renewal: RenewalPolicy::WhenPercentRemaining(20),
// No additional delegation needed
delegate: None,
};
let identity = NdnIdentity::provision(config).await?;
println!("enrolled as: {}", identity.did());
// → did:ndn:fleet:vehicle:1HGBH41JXMN109186
}
Under the hood, provision does the following:
- Generates an Ed25519 key pair (or loads an existing one from
storageif this is a restart after partial provisioning) - Sends an INFO Interest to
/fleet/ca/INFOto get the CA’s certificate and challenge type - Sends a NEW Interest with the vehicle’s public key and desired namespace
- Responds to the token challenge with the factory token
- Receives the signed certificate from the CA
- Saves the certificate and private key to
storage - Starts the background renewal task
From this point on, NdnIdentity::open_or_create will load the existing identity without re-enrolling:
#![allow(unused)]
fn main() {
// Subsequent boots: loads from disk, no NDNCERT exchange needed
let identity = NdnIdentity::open_or_create(
&PathBuf::from("/var/lib/ndn/identity"),
&format!("/fleet/vehicle/{vin}")
).await?;
}
The provisioning token is single-use. If someone intercepts the provisioning exchange and tries to replay the token, the CA rejects it — the token is already marked consumed. An attacker would need to intercept the token before the vehicle uses it, which requires access to the firmware at manufacture time.
ECU Delegation: The Vehicle as a Mini-CA
After the vehicle is enrolled, it starts its own NDNCERT CA for its sub-systems. This happens before any internet-facing communication — the ECU enrollment runs over the vehicle’s internal network (CAN bus, automotive Ethernet, or internal WiFi).
#![allow(unused)]
fn main() {
use ndn_cert::{NdncertCa, PossessionChallenge, HierarchicalPolicy};
use ndn_app::Producer;
// The vehicle's identity (just enrolled above)
let vehicle_identity = NdnIdentity::open_or_create(/* ... */).await?;
// Start a CA that issues certs under the vehicle's namespace
let ca = NdncertCa::builder()
.name(format!("/fleet/vehicle/{vin}/ca"))
.signing_identity(&vehicle_identity)
// ECUs prove ownership by signing with a hardware root-of-trust key
// (burned into the ECU at manufacture and stored in the vehicle's manifest)
.challenge(PossessionChallenge::new(load_ecu_root_certs()?))
// Only allow namespaces under /fleet/vehicle/<vin>/ecu/
.policy(HierarchicalPolicy)
.cert_lifetime(Duration::from_secs(7 * 24 * 3600)) // 1 week for ECUs
.build()?;
// Serve over the internal network face
let producer = Producer::connect("/var/run/ndn-internal.sock", "/fleet/vehicle").await?;
ca.serve(producer).await?;
}
Each ECU’s firmware knows its own role name (e.g., brake, lidar, camera-front) and runs a matching provisioning sequence using FactoryCredential::Existing, which presents the ECU’s hardware-bound root-of-trust certificate as a possession proof:
#![allow(unused)]
fn main() {
// On the brake ECU:
let ecu_config = DeviceConfig {
namespace: format!("/fleet/vehicle/{vin}/ecu/brake"),
storage: PathBuf::from("/var/lib/ndn/ecu-identity"),
factory_credential: FactoryCredential::Existing {
cert_name: load_hardware_cert_name()?,
key_seed: load_hardware_key_seed()?,
},
ca_prefix: format!("/fleet/vehicle/{vin}/ca").parse()?,
renewal: RenewalPolicy::WhenPercentRemaining(10),
delegate: None,
};
let ecu_identity = NdnIdentity::provision(ecu_config).await?;
}
This entire ECU enrollment happens on the vehicle’s internal bus. The vehicle does not need internet connectivity. The fleet CA does not need to know that ECUs exist. The vehicle is the trust authority for its own sub-systems.
Renewal: 24 Hours Without Drama
Vehicle certs have a 24-hour lifetime. Renewal is fully automatic. The background renewal task in NdnIdentity watches the certificate’s validity period and, when the remaining lifetime falls below the configured threshold (20% by default, i.e., about 19h into a 24h cert), initiates a new NDNCERT exchange using the possession challenge — the vehicle’s current valid certificate is the proof of identity.
Time 0h: Vehicle enrolls, receives 24h cert
Time 19h: Renewal threshold reached (80% used)
Background task sends CHALLENGE Interest to /fleet/ca
using possession of current cert as proof
Fleet CA issues new 24h cert
Time 24h: Old cert expires (renewal already done at 19h)
If the fleet CA is unreachable when renewal is attempted, the background task retries with exponential backoff. The vehicle continues operating on its current certificate. As long as the vehicle reconnects to the CA within the remaining 5 hours of validity, renewal succeeds before expiry.
For a vehicle that is completely offline for more than 24 hours — say, in an underground facility with no network coverage — the cert expires. When the vehicle reconnects, it re-enrolls using the possession challenge with its recently-expired cert. The CA can be configured to accept certs that expired within a grace period (configurable per CA policy), or it can require a new token (which the fleet operator provisions through the management interface). Either way, the human burden is minimal.
Revocation Without CRL
Short-lived certs make revocation simple enough that it barely deserves the word “revocation”. Here is the entire process for decommissioning a compromised vehicle:
- An operator opens the fleet management console and marks VIN-1234 as revoked.
- The console calls the CA’s management API:
ca.policy().block_namespace("/fleet/vehicle/vin-1234"). - The next time VIN-1234 tries to renew (within 5 hours), the CA rejects the renewal request.
- The vehicle’s certificate expires within 24 hours of the compromise being detected.
No CRL file to publish. No OCSP responder to query. No list of revoked serials to distribute to 10,000 other vehicles. The network does not need to know anything. Each vehicle’s cert either renews successfully or it does not, and within 24 hours of a failed renewal, the cert is gone.
For emergency situations where you cannot wait 24 hours, push a trust schema update that explicitly rejects the compromised namespace. This update propagates through NDN sync (SVS) to all validators in the fleet within seconds. But in practice, operators rarely need this — 24 hours is a small window for most threat scenarios.
Drone Swarms: The Same Model, No Infrastructure
The same architecture applies to a swarm of 500 drones. The key difference: there may be no internet connection at all. The swarm operates as an ad-hoc NDN mesh.
The swarm includes one or more CA nodes — designated drones (or a ground station) that run the fleet CA. The CA nodes run NDNCERT. Other drones in the swarm enroll by sending Interests that route through the mesh to the nearest CA node.
Because NDN routing is name-based, a drone doesn’t need to know which CA node handles its request. It just sends an Interest for /swarm/ca/INFO, and the forwarder routes it to whichever CA node has a FIB entry for that prefix. If one CA node crashes or flies out of range, the FIB re-converges and requests route to the next nearest CA.
#![allow(unused)]
fn main() {
// A drone in the swarm — same provisioning code as a vehicle
let drone_config = DeviceConfig {
namespace: format!("/swarm/drone/{serial}"),
storage: PathBuf::from("/data/ndn-identity"),
factory_credential: FactoryCredential::Token(firmware_token),
ca_prefix: "/swarm/ca".parse()?,
renewal: RenewalPolicy::WhenPercentRemaining(20),
delegate: None,
};
// This Interest routes through the ad-hoc mesh to the nearest CA node
// No IP, no DNS, no internet required
let identity = NdnIdentity::provision(drone_config).await?;
}
A nearby drone can relay the enrollment Interest through NDN’s normal forwarding. If the direct link to the CA is down, the Interest travels through intermediate nodes. This works because NDN routing is hop-by-hop and content-centric: any node with a FIB route to /swarm/ca will forward the Interest toward the CA.
Setting Up the Fleet CA
The fleet operations CA runs on a server (or a cluster of servers for high availability). Here is its complete setup:
use std::path::PathBuf;
use std::time::Duration;
use ndn_identity::{NdnIdentity, DeviceConfig, FactoryCredential, RenewalPolicy};
use ndn_cert::{NdncertCa, TokenStore, TokenChallenge, PossessionChallenge, HierarchicalPolicy};
use ndn_app::Producer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// The fleet CA's own identity — issued by the root CA during an offline ceremony
// Stored on disk; load it (or create it if this is the first run)
let ca_identity = NdnIdentity::open_or_create(
&PathBuf::from("/var/lib/ndn/fleet-ca-identity"),
"/fleet/ca",
).await?;
println!("Fleet CA running as: {}", ca_identity.did());
// Token store: pre-loaded with factory tokens for all vehicles
// (this list comes from the manufacturing system)
let mut token_store = TokenStore::new();
for token in load_factory_tokens_from_manufacturing_db()? {
token_store.add(token);
}
// Possession challenge: allows renewal using an existing /fleet cert
let fleet_root_cert = ca_identity.security_manager().get_certificate()?;
let possession = PossessionChallenge::new(vec![fleet_root_cert]);
// Build the CA
let ca = NdncertCa::builder()
.name("/fleet/ca")
.signing_identity(&ca_identity)
// Support both challenges: token for first enrollment, possession for renewal
.challenge(TokenChallenge::new(token_store))
.challenge(possession)
// Only issue for /fleet/ namespace
.policy(HierarchicalPolicy)
// 24-hour certs
.cert_lifetime(Duration::from_secs(24 * 3600))
.build()?;
// Connect to the router and register the CA prefix
let producer = Producer::connect("/run/nfd/nfd.sock", "/fleet/ca").await?;
println!("Fleet CA accepting enrollment requests...");
ca.serve(producer).await?;
Ok(())
}
For high availability, run two or three instances of this process, all pointing to the same (distributed) token store. Register all of them under the /fleet/ca prefix in the FIB. The forwarder load-balances across them.
What You Have Built
At the end of this setup:
- 10,000 vehicles each have a cryptographically verifiable identity, issued automatically with no human steps per vehicle.
- Each vehicle’s sub-systems have their own identities, issued by the vehicle without internet access.
- Every packet in the fleet is signed. Every signature can be traced back to the root CA that you control.
- Compromised vehicles are effectively decommissioned within 24 hours of detection, with no infrastructure changes.
- The entire trust infrastructure runs over NDN — it works on UDP, Ethernet, internal CAN bus, or a drone swarm mesh network with no internet connection.
See Also
- NDNCERT: Automated Certificate Issuance — the protocol details behind
NdnIdentity::provision - Identity and Decentralized Identifiers — how vehicle identities map to W3C DIDs
- Setting Up an NDNCERT CA — step-by-step CA configuration reference
- Security Model — the certificate chain validation that makes all of this work
Design Philosophy
NDN as Composable Data Pipelines
The central insight behind ndn-rs: Rust’s ownership model and trait system map naturally onto NDN’s packet processing as composable data pipelines with trait-based polymorphism. This stands in deliberate contrast to the class hierarchies used by NFD/ndn-cxx (C++) and the convention-based approach of ndnd (Go).
In ndn-rs, a packet enters the system as a Bytes buffer from a face, flows through a sequence of PipelineStage trait objects by value, and exits as a Bytes buffer sent to one or more faces. Each stage receives ownership of the PacketContext, processes it, and either passes it forward or consumes it (short-circuiting). The compiler enforces that no stage can accidentally use a packet after handing it off – a guarantee that NFD achieves only through runtime checks and careful documentation.
💡 Key insight: The central design principle is trait composition over class hierarchy. NFD and ndn-cxx model NDN with deep inheritance trees (Strategy, Face, Transport, LinkService). ndn-rs replaces each hierarchy with a flat trait and composes behaviors by wrapping types (e.g.,
StrategyFilterwraps aStrategy,ShardedCswraps aContentStore). This makes the extension points orthogonal – you can combine any strategy with any filter without writing a new subclass.
flowchart LR
subgraph "Interest Pipeline"
A["FaceCheck"] -->|"Continue"| B["TlvDecode"]
B -->|"Continue"| C["CsLookup"]
C -->|"Continue\n(miss)"| D["PitCheck"]
C -->|"Send\n(hit)"| OUT["Dispatch"]
D -->|"Continue"| E["Strategy"]
E -->|"Forward / Nack\n/ Suppress"| OUT
end
style A fill:#e8f4fd,stroke:#2196F3
style B fill:#e8f4fd,stroke:#2196F3
style C fill:#fff3e0,stroke:#FF9800
style D fill:#e8f4fd,stroke:#2196F3
style E fill:#f3e5f5,stroke:#9C27B0
style OUT fill:#e8f5e9,stroke:#4CAF50
Each stage receives PacketContext by value and returns an Action enum. The dispatcher inspects the action and either passes PacketContext to the next stage or terminates the pipeline.
Library, Not Daemon
ndn-rs is a library. There is no daemon/client split. The forwarding engine (ndn-engine) is a Rust library that any application can embed directly. The standalone router (ndn-fwd) is just one binary that links against this library; applications can equally embed the engine in-process and get a thin InProcFace backed by a shared memory ring buffer.
This means the same codebase runs as a full router, an embedded forwarder on a microcontroller (via ndn-embedded with no_std), or an in-process forwarder inside an application. No rewrite required.
⚠️ Important: ndn-rs is a library, not a daemon. There is no daemon/client split. Applications embed the forwarding engine directly and communicate via in-process
InProcFacechannels – no IPC serialization, no Unix socket round-trips. The standalonendn-fwdbinary is just one consumer of this library, not a privileged component. This is a fundamental departure from NFD’s architecture.
Key Design Decisions
| Decision | What | Why |
|---|---|---|
| Packet ownership by value | PacketContext is passed by value through PipelineStage::process(ctx) | Ownership transfer makes short-circuits and hand-offs compiler-enforced. A stage that drops the context ends the pipeline – no dangling references, no use-after-free. |
Arc<Name> sharing | Names are wrapped in Arc<Name> and shared across PIT, FIB, pipeline | NDN names appear in many places simultaneously (PIT entries, FIB lookups, CS keys, strategy context). Arc avoids copying multi-component names while keeping them immutable and thread-safe. |
DashMap PIT | DashMap<PitToken, PitEntry> for the Pending Interest Table | The PIT is the hottest data structure in the forwarder. DashMap provides sharded concurrent access without a global lock, enabling parallel Interest/Data processing across cores. |
NameTrie FIB | HashMap<Component, Arc<RwLock<TrieNode>>> per level | Concurrent longest-prefix match without holding parent locks. Each trie level is independently locked, so a lookup on /a/b/c does not block a concurrent lookup on /x/y. |
Wire-format Bytes in CS | Content Store stores the original wire-format Bytes | A cache hit sends the stored bytes directly to the outgoing face with zero re-encoding. Bytes is reference-counted, so multiple cache hits share the same allocation. |
SmallVec<[ForwardingAction; 2]> | Strategy returns a small-vec of forwarding actions | Most strategies produce 1-2 actions (forward + optional probe). SmallVec keeps them on the stack, avoiding a heap allocation on every Interest. |
SafeData typestate | Separate Data and SafeData types | The compiler enforces that only cryptographically verified data is inserted into the Content Store or forwarded. You cannot accidentally cache unverified data – it is a type error. |
u64 nanosecond timestamps | arrival, last_updated, and all timing fields use u64 ns since Unix epoch | Nanosecond resolution is needed for EWMA RTT measurements and sub-millisecond PIT expiry. A single u64 avoids the overhead and platform variance of Instant or SystemTime in hot paths. |
OnceLock lazy decode | Packet fields decoded on demand via OnceLock<T> | A Content Store hit may short-circuit before the nonce or lifetime fields are ever accessed. Lazy decode avoids wasted work on the fast path. |
SmallVec<[NameComponent; 8]> for names | Name components stored in a SmallVec | Typical NDN names have 4-8 components. SmallVec keeps them on the stack, eliminating a heap allocation for the common case. |
Trait-Based Polymorphism
Every extension point in ndn-rs is a trait:
Face– async send/recv over any transport (UDP, TCP, Ethernet, serial, shared memory, Bluetooth, Wifibroadcast)PipelineStage– a single processing step that takes aPacketContextby value and returns anActionStrategy– a forwarding decision function that readsStrategyContextand returnsForwardingActionvaluesContentStore– pluggable cache backend (LruCs,ShardedCs,FjallCs)Signer/Verifier– cryptographic operations decoupled from packet typesDiscoveryProtocol– neighbor and service discovery (SWIM, mDNS, etc.)
This trait-based approach means new transports, strategies, and cache backends can be added without modifying the core pipeline. The built-in pipeline is monomorphised at compile time for zero-cost dispatch; only plugin stages use dynamic dispatch via ErasedPipelineStage.
🔧 Implementation note: The built-in pipeline stages are monomorphised (generic, not
dyn) so the compiler can inline and optimize the hot path. Only user-provided plugin stages go throughdyn PipelineStage(viaErasedPipelineStage). This means the common case pays zero dynamic dispatch cost while still allowing runtime extensibility.
graph TB
subgraph "NFD / ndn-cxx: Class Hierarchy"
direction TB
SBase["class Strategy (virtual)"]
SBase --> BR["class BestRouteStrategy"]
SBase --> MC["class MulticastStrategy"]
SBase --> ASF["class AsfStrategy"]
SBase -.->|"Extend via<br/>virtual override"| NEW1["class MyStrategy"]
end
subgraph "ndn-rs: Trait Composition"
direction TB
STrait["trait Strategy"]
STrait -->|"impl"| BR2["BestRouteStrategy"]
STrait -->|"impl"| MC2["MulticastStrategy"]
STrait -->|"impl"| ASF2["AsfStrategy"]
STrait -.->|"impl for any type"| NEW2["MyStrategy"]
FTrait["trait Filter"] -->|"compose"| BR2
FTrait -->|"compose"| MC2
end
style SBase fill:#ffcdd2,stroke:#F44336
style STrait fill:#c8e6c9,stroke:#4CAF50
style FTrait fill:#c8e6c9,stroke:#4CAF50
Concurrency Model
ndn-rs is built on Tokio. Each face runs its own async task, pushing RawPacket values into a shared mpsc channel. A single pipeline runner drains the channel and processes packets inline through the stage sequence. A separate expiry task drains expired PIT entries on a 1 ms tick using a hierarchical timing wheel.
%%{init: {"layout": "elk"}}%%
graph TD
subgraph Faces
F1["UdpFace task"]
F2["TcpFace task"]
F3["InProcFace task"]
F4["EtherFace task"]
end
F1 -->|"RawPacket"| CH["mpsc channel"]
F2 -->|"RawPacket"| CH
F3 -->|"RawPacket"| CH
F4 -->|"RawPacket"| CH
CH --> PR["Pipeline Runner<br/>(drains channel)"]
PR -->|"spawn"| T1["per-packet task"]
PR -->|"spawn"| T2["per-packet task"]
PR -->|"spawn"| T3["per-packet task"]
T1 --> D["Dispatch<br/>(send to outgoing faces)"]
T2 --> D
T3 --> D
EX["Expiry task<br/>(1 ms tick, timing wheel)"] -.->|"evict expired<br/>PIT entries"| PIT["PIT"]
style CH fill:#fff3e0,stroke:#FF9800
style PR fill:#e8f4fd,stroke:#2196F3
style D fill:#e8f5e9,stroke:#4CAF50
style EX fill:#fce4ec,stroke:#E91E63
Tracing uses the tracing crate with structured spans per packet. The library never initializes a tracing subscriber – that is the binary’s responsibility, following the principle that libraries should not make policy decisions about logging output.
vs. NFD (C++)
📝 Note: This is not a “ndn-rs is better” page. NFD is the reference implementation with over a decade of production deployment, a large research community, and de facto spec authority. This comparison explains the architectural differences and the tradeoffs each design makes, so you can choose the right tool for your deployment.
This page compares ndn-rs with NFD (NDN Forwarding Daemon) and its companion library ndn-cxx. NFD is the reference implementation of an NDN forwarder, written in C++ and developed by the NDN project team since 2014. Understanding the differences explains why ndn-rs exists and the tradeoffs it makes.
Comparison Table
| Aspect | NFD / ndn-cxx | ndn-rs | Why ndn-rs chose differently |
|---|---|---|---|
| Architecture | Daemon (nfd) + client library (ndn-cxx). Applications link against ndn-cxx and communicate with nfd over a Unix socket. | Embeddable library. ndn-engine is a Rust library; applications can embed the forwarder in-process or run the standalone ndn-fwd binary. | The daemon/client split forces IPC overhead on every packet and prevents co-optimization of application and forwarder logic. A library architecture lets the same crate run as a router, an embedded forwarder, or an in-app engine. |
| Pipeline safety | Runtime checks. Stages pass raw pointers or shared_ptr references; the pipeline relies on documentation and conventions to ensure stages do not use a packet after forwarding it. | Ownership by value. PacketContext is moved through each PipelineStage. A stage that short-circuits consumes the context, making use-after-hand-off a compile error. | C++ has no ownership transfer semantics that the compiler enforces. Rust’s move semantics give pipeline correctness for free, eliminating an entire class of bugs that NFD must catch through careful coding and review. |
| Data copy on CS hit | Re-encodes on cache hit. When data is retrieved from the Content Store, NFD often re-encodes fields (e.g., updating the incoming face tag) before sending. | Zero-copy Bytes. The CS stores the original wire-format Bytes (reference-counted). A cache hit sends the stored buffer directly – no re-encoding, no copy. | Re-encoding is the dominant cost on cache hits. Storing wire-format bytes and using Bytes::clone() (which increments a reference count, not copies data) makes CS hits nearly free. |
| PIT concurrency | Global mutex. NFD’s PIT is protected by a single mutex; all pipeline processing is single-threaded within the forwarder. | DashMap (sharded concurrent hash map). The PIT is partitioned into shards, each with its own lock. Multiple cores can process packets in parallel without contention. | NDN PIT lookup is the hottest operation in the forwarder. A global mutex serializes all packet processing, limiting throughput to what one core can handle. Sharded locking scales with core count. |
| Strategy system | Class hierarchy. Strategies inherit from nfd::fw::Strategy (virtual methods). Adding a strategy requires C++ compilation and restarting the daemon. | Trait composition + WASM. Strategies implement the Strategy trait. Built-in strategies are compiled in; external strategies can be loaded as WASM modules at runtime via ndn-strategy-wasm, with hot-swap and no restart. | Class hierarchies are rigid – you cannot compose two strategies without writing a third class. Traits compose naturally (e.g., wrapping a strategy with a StrategyFilter). WASM allows deploying new forwarding logic to running routers without downtime. |
| Embedded support | Separate project. ndn-lite is a completely separate C implementation for embedded devices, with its own packet format library and limited compatibility. | Same crate, no_std. ndn-tlv and ndn-packet compile with no_std; ndn-embedded provides a minimal forwarder for bare-metal targets using the same types and encoding. | Maintaining two separate codebases (NFD + ndn-lite) means divergent behaviour, duplicated bugs, and incompatible APIs. A single codebase with feature flags ensures embedded and full forwarders parse packets identically. |
| Face abstraction | Inheritance. Face types inherit from nfd::face::Face with virtual methods. Transport and link service are separate class hierarchies. | Trait. Face is an async trait (recv() -> Bytes, send(Bytes)). Any type implementing the trait is a face. No inheritance, no link service split. | The inheritance-based split between Transport and LinkService adds complexity without clear benefit for most transports. A flat trait is simpler, and Rust’s async trait support means faces are naturally non-blocking. |
| Packet parsing | Eager decode. ndn-cxx decodes all TLV fields upfront when constructing an Interest or Data object. | Lazy decode via OnceLock. Fields are decoded on first access. A CS hit may never touch the nonce or lifetime fields. | Eager decoding wastes cycles on fields the pipeline never reads. In the common case (cache hit or simple forwarding), only the name and type are needed. Lazy decode pays only for what is used. |
| Name representation | ndn::Name is a std::vector<Component> stored inline. Copying a name copies all components. | Arc<Name> with SmallVec<[NameComponent; 8]>. Names are reference-counted and shared across PIT, FIB, CS, and pipeline without copying. | NDN names appear in many data structures simultaneously. Copying a 6-component name on every PIT insert and FIB lookup adds up. Arc sharing eliminates these copies entirely. |
| Build system | waf (Python-based). Complex dependency resolution, platform-specific patches, often requires manual intervention. | Cargo. Standard Rust toolchain. cargo build compiles everything; cross-compilation to embedded targets works out of the box. | Developer experience matters. Cargo’s dependency management, cross-compilation support, and reproducible builds reduce the barrier to contribution and deployment. |
Where NFD Has the Advantage
NFD is the reference implementation with over a decade of production deployment and research use. Its advantages include:
- Maturity: NFD has been tested in large-scale testbeds (NDN testbed, 30+ nodes worldwide) for years. ndn-rs is newer and less battle-tested.
- Spec conformance: NFD defines the de facto standard for NDN forwarding behaviour. When the spec is ambiguous, NFD’s behaviour is the answer.
- Ecosystem: Tools like
ndnping,ndnpeek,ndncatchunks, and the NFD management protocol are widely used and well-documented. ndn-rs implements compatible tools (ndn-tools) and follows the NFD management protocol, but coverage is still growing. - Community: A larger developer and research community means more strategies, more faces, and more real-world deployment experience.
Migration Considerations
ndn-rs follows the NFD management protocol for router administration, so existing tooling (nfd-status, nlsrc) can interact with an ndn-rs router. The wire format is identical (NDN TLV), so ndn-rs nodes interoperate with NFD nodes on the same network. The main differences are in the internal API: applications using ndn-cxx’s C++ API need to be rewritten in Rust using ndn-app.
vs. ndnd (Go)
📝 Note: ndnd and ndn-rs share a goal of being simpler and more modern than NFD, but they make fundamentally different language-level tradeoffs. This comparison focuses on how Go’s garbage collector and goroutine model differ from Rust’s ownership and async model for the specific workload of NDN packet forwarding.
This page compares ndn-rs with ndnd, an NDN forwarder written in Go. ndnd was created as a simpler, more modern alternative to NFD, leveraging Go’s concurrency primitives and garbage collector. Understanding the differences clarifies what ndn-rs gains from Rust’s ownership model and what tradeoffs that entails.
Comparison Table
| Aspect | ndnd (Go) | ndn-rs (Rust) | Why ndn-rs chose differently |
|---|---|---|---|
| Architecture | Single binary. ndnd is a standalone forwarder; applications communicate over a Unix socket or TCP. | Embeddable library. The forwarder engine is a library crate that can be embedded in-process, run as a standalone router, or compiled for bare-metal targets. | A single-binary forwarder forces IPC on every packet exchange between application and forwarder. Embedding the engine eliminates this overhead entirely and allows co-optimization of application and forwarding logic. |
| Pipeline model | Convention-based. Go functions pass packet structs through channels; the order of processing steps is enforced by code structure and developer discipline. | Ownership-based. PacketContext is moved by value through PipelineStage trait objects. Short-circuits consume the context, making use-after-hand-off a compile error. | Go’s garbage collector means any goroutine can hold a reference to a packet indefinitely. There is no compile-time guarantee that a forwarded packet is not also being read elsewhere. Rust’s ownership model makes the pipeline’s data flow explicit and compiler-checked. |
| PIT concurrency | sync.Mutex. ndnd protects the PIT with a standard Go mutex. | DashMap (sharded concurrent hash map). The PIT is split into independent shards, each with its own lock. | A single mutex serializes all PIT operations. Under load, goroutines queue up waiting for the lock. DashMap distributes contention across shards, allowing multiple cores to process PIT lookups in parallel. |
| Strategy system | Interface-based. Strategies implement a Go interface. Adding or changing a strategy requires recompilation. | Trait + WASM. Built-in strategies implement the Strategy trait; external strategies can be hot-loaded as WASM modules at runtime via ndn-strategy-wasm. Strategies compose via StrategyFilter wrappers. | Go interfaces are similar to Rust traits for dispatch, but lack composability (no generic wrappers without reflection). WASM hot-loading allows deploying new forwarding logic to running routers in production without downtime or recompilation. |
| Simulation | None built-in. Testing ndnd at scale requires deploying multiple instances, often using Mini-NDN (Mininet-based). | In-process simulation. ndn-sim provides SimFace and SimLink for building arbitrary topologies in a single process, with deterministic event replay and tracing. | Network simulation with real OS processes is slow, non-deterministic, and hard to debug. In-process simulation with simulated links enables fast, reproducible integration tests and research experiments without any external tooling. |
| Embedded targets | Not supported. Go’s runtime (garbage collector, goroutine scheduler) requires an OS with virtual memory. | Same crate, no_std. ndn-tlv and ndn-packet compile without the standard library; ndn-embedded provides a minimal forwarder for bare-metal microcontrollers. | NDN’s value proposition includes IoT and edge devices. A forwarder that cannot run on a microcontroller leaves that space to separate, incompatible implementations. Rust’s no_std support lets the same packet library run everywhere. |
| Memory model | Garbage collected. The Go runtime traces live objects and reclaims memory periodically. GC pauses are short but non-deterministic. | Ownership + reference counting. Memory is freed deterministically when the last owner drops. Arc provides shared ownership where needed (names, CS entries). No GC pauses. | GC pauses are problematic for a forwarder where latency matters. A 100-microsecond GC pause during a PIT lookup is a 100-microsecond latency spike for every pending Interest. Deterministic deallocation means predictable, low-tail-latency forwarding. |
| Packet lifetime | GC-managed. A packet struct lives as long as any goroutine holds a reference. The programmer does not think about when memory is freed. | Explicit. Bytes (reference-counted buffer) and Arc<Name> make sharing explicit. When the last reference is dropped, memory is freed immediately. | Explicit lifetime management is more work for the programmer but provides two benefits: (1) memory usage is predictable and bounded, critical for routers under load; (2) zero-copy sharing via Bytes::clone() (reference count increment, not data copy) is opt-in and visible in the code. |
| Error handling | Multiple returns (value, error). Errors are values; forgetting to check an error is a silent bug (though errcheck linters help). | Result<T, E>. The compiler forces every error to be handled. Ignoring a Result is a warning; discarding it silently requires an explicit let _ =. | In a forwarder, silently swallowed errors (e.g., a failed PIT insert) can cause hard-to-diagnose forwarding failures. Rust’s Result type makes error handling mandatory, catching these bugs at compile time. |
| Build and deploy | go build produces a single static binary. Fast compilation, simple deployment. | cargo build produces a single binary. Compilation is slower than Go but produces faster code. Cross-compilation to embedded targets is straightforward via Cargo. | Go’s fast compilation is a genuine advantage for developer iteration speed. ndn-rs accepts slower builds in exchange for zero-cost abstractions, no GC overhead, and the safety guarantees described above. |
Go’s GC vs. Rust’s Ownership for NDN
The choice between garbage collection and ownership-based memory management has particular implications for NDN packet processing:
PIT entry lifetime. A PIT entry must live exactly as long as the Interest is pending – typically milliseconds to seconds. In Go, the GC will eventually reclaim expired entries, but “eventually” may mean the entry lingers in memory for multiple GC cycles after expiry. In ndn-rs, dropping a PIT entry frees its memory immediately, and the hierarchical timing wheel ensures expired entries are drained on a 1 ms tick.
Content Store pressure. The CS is the largest memory consumer in a forwarder. In Go, cached Data packets are opaque to the GC – it must trace through them to determine liveness, adding GC overhead proportional to CS size. In ndn-rs, the CS stores Bytes (reference-counted buffers). Eviction drops the reference count; if no pipeline is currently sending that data, the buffer is freed instantly. The GC never needs to scan cached data because there is no GC.
Name sharing. An NDN name may appear simultaneously in the PIT, FIB, CS, and multiple pipeline contexts. In Go, the GC handles this transparently – all references are equal. In ndn-rs, Arc<Name> makes sharing explicit: cloning an Arc is a reference count increment (one atomic operation), and the name is freed when the last Arc is dropped. The cost is identical to Go’s approach at runtime, but the programmer can see exactly where names are shared.
Zero-copy forwarding. When a Data packet satisfies multiple PIT entries, ndnd typically copies the packet for each downstream face. In ndn-rs, Bytes::clone() creates a new handle to the same underlying buffer (one atomic increment). Multiple faces receive the same data without any copy – a pattern that is natural with reference counting but requires careful manual management in GC’d languages where “sharing” and “copying” look the same.
Where ndnd Has the Advantage
- Simplicity. ndnd’s codebase is smaller and easier for newcomers to read. Go’s lack of generics complexity (traits, lifetimes, associated types) means less to learn.
- Compilation speed. Go compiles significantly faster than Rust, improving developer iteration time.
- Goroutine ergonomics. Go’s goroutines and channels make concurrent code straightforward to write. Rust’s async model requires more explicit annotation (
async,.await,Sendbounds) and familiarity with pinning. - Community overlap. ndnd is actively maintained by NDN project contributors, ensuring close alignment with evolving NDN specifications.
vs. NDN-DPDK (C/Go, DPDK)
📝 Note: NDN-DPDK and ndn-rs are not direct competitors — they target fundamentally different deployment tiers. NDN-DPDK is purpose-built for carrier-grade, multi-Tbps line-rate forwarding on dedicated hardware. ndn-rs is designed to be embedded anywhere: in an application, on a microcontroller, or on a commodity server. This comparison explains the tradeoffs so you can choose the right tool for your throughput and deployment requirements.
This page compares ndn-rs with NDN-DPDK, a high-performance NDN forwarder that uses the DPDK (Data Plane Development Kit) kernel-bypass framework. NDN-DPDK is developed at the University of Memphis and is the reference implementation for high-throughput NDN forwarding research. It achieves multi-Tbps forwarding rates by bypassing the OS kernel entirely and dedicating CPU cores and NICs to packet processing.
Comparison Table
| Aspect | NDN-DPDK | ndn-rs | Rationale |
|---|---|---|---|
| Target deployment | Dedicated DPDK hardware. Requires DPDK-compatible NICs (Intel, Mellanox), huge pages, CPU core isolation, and root access. Designed for ISP or testbed core routers. | Commodity servers, embedded devices, and in-process embedding. Runs anywhere cargo build runs: a laptop, a Raspberry Pi, a microcontroller, or inside another application. | NDN-DPDK’s DPDK requirement makes it impractical for edge nodes, developer workstations, or IoT gateways. ndn-rs trades maximum throughput for universal deployability. |
| Peak throughput | Multi-Tbps at line rate. NDN-DPDK is the fastest NDN forwarder known; it processes packets in dedicated polling loops without any system-call overhead. | Hundreds of Gbps on high-core-count servers, limited by OS network stack. Not kernel-bypass, so there is one syscall per batch of packets. | If your requirement is maximizing raw forwarding throughput on dedicated hardware, NDN-DPDK wins. ndn-rs prioritizes low-latency, embeddable, general-purpose forwarding over peak throughput. |
| Implementation language | C (data plane) + Go (control plane). The DPDK fast path is written in C; the management and configuration layer is in Go. | Rust (full stack). The same language covers packet encoding, the engine core, the management protocol, and binaries. No FFI boundary on the critical path. | An FFI boundary between C and Go (or between C and any managed language) complicates error propagation, lifetime management, and tooling. A single-language stack is easier to analyze with profilers, sanitizers, and static analyzers. |
| Embeddability | Not embeddable. NDN-DPDK is a standalone daemon; applications talk to it over a management API. Embedding DPDK itself in a library is possible but extremely complex. | Embeddable library. ndn-engine is a regular Rust crate. An application adds it as a dependency, calls EngineBuilder::new(), and the forwarder runs in the same process with zero IPC overhead. | Embedding the forwarder in-process removes the application/router IPC boundary entirely. For producer applications that serve high-request-rate data (e.g., a video CDN node), eliminating the Unix socket round-trip is significant. |
| Memory model | Hugepage-backed DPDK mempools. Packet buffers live in pre-allocated hugepage memory; no dynamic allocation on the fast path. GC is absent; all memory is controlled by the mempool. | bytes::Bytes reference-counted buffers. Dynamic allocation via the system allocator; jemalloc is the default for the ndn-fwd binary. No hugepages; no DPDK mempool required. | DPDK mempools are the right tool for kernel-bypass line-rate forwarding, but they require upfront memory reservation and NUMA-aware configuration. Bytes is simpler to use and sufficient for general-purpose deployment. |
| Kernel bypass | Yes. DPDK binds the NIC directly, bypassing the kernel network stack entirely. No interrupt handling, no socket syscalls, no context switches on the packet path. | No. ndn-rs uses standard OS networking (UDP, TCP, Unix sockets). Kernel involvement adds latency and limits maximum throughput, but makes deployment trivial. | Kernel bypass requires root, DPDK-compatible NICs, and significant operational complexity. For the vast majority of NDN deployments — research labs, edge nodes, developer machines — the kernel overhead is acceptable. |
| Strategy system | Fixed strategy. NDN-DPDK implements a single, highly optimized forwarding strategy hardcoded for throughput. Changing strategy logic requires modifying and recompiling the C data plane. | Trait + WASM. Built-in strategies implement the Strategy trait; external strategies can be hot-loaded as WASM modules at runtime via ndn-strategy-wasm. | NDN-DPDK’s strategy inflexibility is a deliberate tradeoff for performance — branching in the data plane costs throughput. ndn-rs accepts lower peak throughput in exchange for runtime-configurable forwarding behaviour. |
| Simulation support | None built-in. Testing NDN-DPDK requires physical DPDK hardware or emulation (DPDK’s software PMD has limitations). | In-process simulation. ndn-sim provides SimFace and SimLink for building arbitrary topologies in a single process with deterministic event replay. | Simulation is essential for research and testing. Running NDN-DPDK experiments requires physical infrastructure; ndn-rs experiments can run on a laptop in CI. |
| Embedded / no_std targets | Not applicable. DPDK requires an OS, huge pages, and a DPDK-capable NIC driver. | Same crate, no_std. ndn-tlv and ndn-packet compile without the standard library; ndn-embedded targets bare-metal microcontrollers. | NDN-DPDK and ndn-rs serve non-overlapping ends of the hardware spectrum. ndn-rs intentionally covers the full range from microcontroller to server. |
| Operational complexity | High. Setup requires: DPDK-compatible NIC, hugepage configuration, CPU isolation, NUMA pinning, kernel module loading (or vfio/uio binding), and a Go control-plane daemon. | Low. cargo install ndn-fwd produces a single binary. Run it. No kernel modules, no hugepages, no NIC driver changes. | Operational simplicity matters for research deployments, CI, and edge nodes. NDN-DPDK’s setup complexity is justified only when the throughput gain is required. |
Where NDN-DPDK Has the Advantage
NDN-DPDK is the right choice when your requirement is maximum forwarding throughput on dedicated hardware:
- Line-rate forwarding. NDN-DPDK can saturate 100 GbE links (and beyond with multi-port configurations) with real NDN traffic. ndn-rs does not approach these rates on the same hardware because it does not bypass the kernel.
- NUMA-aware memory. DPDK mempools are NUMA-local by construction. On multi-socket servers, packet buffers are always allocated from the same NUMA node as the CPU processing them, eliminating cross-socket memory traffic.
- Polling model. NDN-DPDK’s data plane spins on RX queues without interrupts, trading CPU utilization for minimal and predictable latency at high packet rates.
- Research pedigree. NDN-DPDK is widely used in high-throughput NDN research and has published throughput results that serve as the community’s performance upper bound.
Interoperability
Both NDN-DPDK and ndn-rs use the standard NDN TLV wire format, so they interoperate on the same network. A typical deployment might use NDN-DPDK on core infrastructure routers and ndn-rs on edge nodes, producer applications, and embedded devices — the two layers communicate over standard NDN Faces (UDP multicast, TCP unicast) without any special configuration.
vs. NDNph / NDN-Lite (Embedded C/C++)
📝 Note: NDNph and NDN-Lite are the right answer for resource-constrained targets where ndn-rs cannot run: microcontrollers with under 8 KB RAM and no heap. This comparison is not about which project is better overall — it explains the design tradeoffs so you can pick the right implementation for your hardware target.
This page compares ndn-rs with two embedded NDN implementations: NDNph (a header-only C++ library targeting Arduino, ESP8266, and ESP32) and NDN-Lite (a C library targeting RIOT OS and bare-metal embedded systems). Both projects solve the same problem — running NDN on extremely resource-constrained devices — but from different starting points and with different design philosophies.
Comparison Table
| Aspect | NDNph / esp8266ndn | NDN-Lite | ndn-rs | Rationale |
|---|---|---|---|---|
| Language | C++ (header-only). Compiles with any Arduino-compatible C++ compiler. No build system required — include headers and compile. | C (C11). Targets GCC-based embedded toolchains (arm-none-eabi, RIOT OS build system). | Rust (edition 2024). Requires a Rust cross-compilation toolchain (rustup target add). | NDNph’s header-only approach minimises the barrier to entry on Arduino. NDN-Lite targets the existing C ecosystem in embedded RIOT OS projects. ndn-rs requires more toolchain setup but provides memory safety at compile time. |
| Minimum RAM | <8 KB for a basic consumer/producer. NDNph uses stack allocation and fixed-size arrays; there is no heap dependency. Runs on ATmega328 (the original Arduino Uno). | ~4–16 KB depending on feature set. NDN-Lite can be trimmed to a minimal footprint with compile-time feature flags, but is typically paired with devices larger than an ATmega. | Depends on no_std feature set. ndn-embedded targets Cortex-M with at least 64 KB RAM for a minimal forwarder. The TLV parser alone (ndn-tlv) can run in much less. | NDNph is unmatched for devices under 10 KB RAM. ndn-rs is appropriate for Cortex-M4/M33 and similar mid-range microcontrollers with a real-time OS or bare-metal async executor. |
| Dynamic allocation | None. NDNph avoids new/delete entirely; all buffers are fixed-size arrays declared on the stack or as class members. | Optional. NDN-Lite can work without malloc using static allocation pools, but some features use dynamic allocation. | no_std + alloc (optional). ndn-tlv and ndn-packet can operate without an allocator using fixed-size buffer slices; ndn-embedded uses a simple bump allocator for packet buffers. | Avoiding dynamic allocation prevents heap fragmentation and makes memory usage statically provable — critical for safety-critical embedded systems. ndn-rs’s no_std feature gates provide this property when needed, but are less deeply embedded in the design than NDNph. |
| Async model | None. NDNph is synchronous. Face I/O is polled in a main loop (face.loop()) with callback-based packet dispatch. | None. NDN-Lite uses synchronous callbacks and a simple event loop. No RTOS threading is assumed. | Async-first. ndn-rs is built on async/await with a pluggable executor. On embedded targets, ndn-embedded uses a cooperative async executor (embassy or a custom one). | Synchronous polling is simpler on microcontrollers with no RTOS and limited concurrency. ndn-rs’s async model is more expressive but requires more setup — an executor, task scheduling, and pinning — that pays off most on devices handling multiple concurrent Interests. |
| Security | Minimal. NDNph supports DigestSha256 signing and HMAC-SHA256. EdDSA/RSA is not supported in the base library. | Certificate-based security baked in. NDN-Lite includes ECDSA and HMAC signing, symmetric key scheduling, and certificate handling as core (not optional) features. This was a design priority from the start. | Modular. ndn-security provides a full signing and validation stack (DigestSha256, EdDSA, ECDSA, BLAKE3). On embedded targets, feature flags select a subset. | NDN-Lite’s strong security story was deliberate — IoT devices are a primary attack surface. ndn-rs matches the feature set but makes it opt-in via Cargo features, which reduces binary size for targets that do not need cryptographic validation. |
| Forwarder | None. NDNph is a consumer/producer library; it does not include a forwarding plane. Routing is implicit — the device has one face and no PIT-based multiplexing. | Minimal. NDN-Lite includes a simple forwarding table but is designed for end devices, not routers. | Full forwarding plane. ndn-embedded provides a real PIT, FIB, and Content Store running on bare-metal, with the same forwarding semantics as the full ndn-engine. | NDNph and NDN-Lite assume end-device roles. ndn-rs on embedded targets can act as a true NDN router (e.g., a Cortex-M33-based gateway that forwards between a BLE sensor network and an Ethernet uplink). |
| Code sharing with router | None. NDNph and NDN-Lite are independent codebases. A packet parsed by NDNph must be re-encoded if handed off to NFD or ndnd. | None. NDN-Lite is a separate codebase; its packet format is compatible but its API is not shared with any full forwarder. | Full sharing. ndn-tlv, ndn-packet, ndn-transport, and ndn-security are the same crates on embedded and server targets. The forwarder, tools, and embedded runtime all parse packets with the same code. | Divergent codebases accumulate divergent bugs. A format extension added to NFD and forgotten in NDN-Lite causes interoperability failures. Sharing a single codebase via no_std feature flags guarantees that the embedded device and the core router see identical wire format semantics. |
| Toolchain | Arduino IDE or PlatformIO. Familiar to the maker/embedded community; minimal setup. | GCC cross-compiler + RIOT OS build system, or CMake for bare-metal. More complex but standard in the embedded C world. | Rust cross-compiler via rustup. One command to add a target (rustup target add thumbv7em-none-eabihf), then cargo build. | NDNph’s Arduino IDE integration is the most accessible path for hobbyists. ndn-rs requires familiarity with Rust and cross-compilation toolchains, which is a higher bar. |
| Ecosystem / community | Small but active. NDNph is maintained by a single developer (yoursunny) with a focus on practical deployability on off-the-shelf hardware. | Maintained by the NDN project team, aligned with academic research. Published several papers on IoT security with NDN-Lite as the platform. | Shared with the ndn-rs full-stack community. Contributions to ndn-packet improve embedded and server paths simultaneously. | NDN-Lite has the strongest institutional backing for IoT NDN research. NDNph has the most real-world deployments on consumer hardware. ndn-rs benefits from a larger Rust ecosystem (crates.io) but has a smaller NDN-specific community. |
Where NDNph and NDN-Lite Have the Advantage
- Ultra-low RAM targets. On ATmega328 (2 KB SRAM), ATmega2560, or early ESP8266 modules with under 40 KB RAM, NDNph is the only practical option. ndn-rs’s
no_stdbuild currently requires more RAM than NDNph for an equivalent feature set. - Arduino ecosystem integration. NDNph works out of the box with Arduino libraries (Ethernet, WiFi, BLE). Mapping ndn-rs faces to Arduino network APIs requires custom glue code.
- Synchronous simplicity. For a device that does one thing (sense and produce data), NDNph’s synchronous main loop is far simpler to reason about than an async executor.
- NDN-Lite’s security framework. NDN-Lite’s key management and certificate scheduling primitives are more mature and better documented than ndn-rs’s embedded security story.
Code Sharing Benefit in Practice
The shared-codebase advantage becomes concrete when extending the NDN wire format. Adding a new TLV type to ndn-packet automatically makes it available on embedded targets without any additional porting work. A packet encoded by an ndn-embedded node and forwarded by an ndn-fwd server is parsed by the same ndn-packet::Data::from_wire() function — the same code path, the same validation logic, and the same test coverage on both platforms.
vs. NDNts (TypeScript) and mw-nfd (Multi-worker NFD)
📝 Note: NDNts and mw-nfd serve different audiences than ndn-rs. NDNts targets browser and Node.js applications where JavaScript is the only option. mw-nfd targets existing NFD deployments that need higher throughput without a full rewrite. This comparison explains each project’s niche and where ndn-rs overlaps or differs.
This page compares ndn-rs with two further implementations: NDNts, a full NDN stack written in TypeScript that runs in browsers and Node.js, and mw-nfd, a modified NFD that adds a multi-threaded worker model to the reference C++ forwarder. They occupy distinct niches — browser applications and high-throughput C++ deployments, respectively — but both are common points of comparison when evaluating NDN implementations.
NDNts (TypeScript / Browser)
| Aspect | NDNts | ndn-rs | Rationale |
|---|---|---|---|
| Runtime environment | Browser and Node.js. NDNts is the only NDN implementation that runs natively in a web browser via WebTransport and WebSocket faces. Bundle size is approximately 150 KB (gzip). | Native binary and WASM. ndn-rs compiles to native code for servers and embedded targets, and to WASM for in-browser simulation (ndn-sim). The WASM build is not a production forwarder — it is used for research and testing. | There is no substitute for NDNts when building a web application that needs to participate in an NDN network. ndn-rs does not compete here; it complements NDNts by acting as the router or producer that browser-based NDNts clients connect to. |
| Packet encoding | TypeScript with BigInt TLV encoding. NDNts encodes TLV lengths as JavaScript BigInt values to handle >32-bit length fields correctly. | bytes::Bytes zero-copy slicing. TLV is parsed directly over the wire buffer; no intermediate JavaScript objects are created per field. | JavaScript’s JIT compiler can optimise hot encoding/decoding paths, but the overhead of object allocation per TLV field is unavoidable in a GC’d runtime. ndn-rs’s lazy decode via OnceLock and zero-copy Bytes slicing avoids per-field allocation entirely. |
| Face types | WebTransport, WebSocket, Unix socket (Node.js only). The face set is constrained by what browsers expose — no raw UDP multicast, no Ethernet, no shared memory. | Full face set. UDP multicast, TCP unicast, Unix socket, shared-memory (ShmFace), in-process (InProcFace), and Ethernet (ndn-faces). | Browser security policy prevents raw socket access, so NDNts’s face options are inherently limited. ndn-rs runs outside the browser sandbox and can use any OS networking primitive. |
| Concurrency model | Single-threaded event loop. JavaScript is single-threaded; NDNts uses async/await over the event loop. True parallelism requires Web Workers with postMessage overhead for packet transfer. | Multi-core async. ndn-rs uses Tokio’s multi-threaded runtime; pipeline stages and face I/O run on a shared thread pool without message-passing overhead between threads. | JavaScript’s event loop model is excellent for I/O-bound tasks but cannot parallelize CPU-bound packet processing across cores. ndn-rs’s DashMap PIT and sharded locking are designed specifically to scale with core count. |
| Type safety | TypeScript (structural typing). NDNts is well-typed for a TypeScript library; type errors are caught at compile time. However, TypeScript types are erased at runtime, and any casts can silently bypass type checking. | Rust (nominal typing with ownership). The compiler enforces not just type correctness but ownership and lifetime invariants. PacketContext cannot be used after it has been forwarded — this is a type error, not a runtime panic. | Both projects prioritise correctness via type systems. Rust’s type system is strictly stronger: ownership eliminates a class of bugs (use-after-forward, data races) that TypeScript’s type system cannot express. |
| Signing and validation | Full signing suite. NDNts supports SHA-256, ECDSA, RSA, and HMAC signing. It has the most complete browser-compatible signing story of any NDN implementation, using the Web Crypto API when available. | Full signing suite via ndn-security. DigestSha256, EdDSA, ECDSA, BLAKE3, HMAC. Does not use Web Crypto (not in the browser). | NDNts’s Web Crypto integration is its signing advantage — hardware key storage via the browser’s credential manager is accessible to NDNts but not to ndn-rs. |
| Ecosystem fit | JavaScript/TypeScript ecosystem. NDNts is a set of npm packages; applications add it with npm install. Works with any JS build tool (Vite, webpack, esbuild). | Rust ecosystem. ndn-rs is a set of Cargo crates. Works with Rust’s standard toolchain and cross-compilation targets. | NDNts is the right choice whenever the application is already in JavaScript — a React SPA, an Electron app, a Next.js server. Rewriting in Rust to gain ndn-rs’s performance would rarely be justified for web applications. |
| Simulation | None built-in. Testing NDNts applications typically requires a real NFD or ndnd instance running locally. | In-process simulation. ndn-sim provides SimFace and SimLink for topology simulation without any external process. | The lack of a built-in simulation environment means NDNts integration tests depend on external infrastructure. ndn-rs integration tests are self-contained and run in CI without any setup. |
mw-nfd (Multi-worker NFD, C++)
| Aspect | mw-nfd | ndn-rs | Rationale |
|---|---|---|---|
| Architecture | Modified NFD with a worker thread pool. mw-nfd adds multiple worker threads to NFD’s pipeline, each owning a subset of faces, while sharing global PIT and FIB data structures with reader-writer locks. | Embeddable engine with DashMap PIT. ndn-rs is not a modification of an existing codebase; it was designed for concurrency from the start, with a sharded PIT and an async trait pipeline. | mw-nfd’s multi-threading is bolted onto a single-threaded codebase. Shared data structures still require coarse reader-writer locks in many places. ndn-rs’s DashMap is sharded at a fine granularity, designed so that independent PIT entries can be inserted and looked up simultaneously. |
| Deployment | Drop-in for existing NFD deployments. mw-nfd is intended to replace nfd in existing setups; it speaks the same management protocol and uses the same configuration format. | Independent binary (ndn-fwd) or embedded library. Compatible with NFD management protocol for monitoring tools; configuration is TOML-based rather than NFD-config. | mw-nfd’s drop-in story is a genuine advantage for organisations already running NFD. Migrating to ndn-rs requires re-expressing configuration in TOML and updating any scripts that invoke NFD-specific internals. The NFD management protocol compatibility means monitoring tools (nfd-status) work without modification. |
| Embeddability | Not embeddable. mw-nfd is a daemon; applications connect over a Unix socket. | Embeddable library. ndn-engine is a Rust crate that can be added as a dependency and run in-process. | Same argument as vs. NFD: eliminating the IPC boundary removes a round-trip on every packet and allows the application and forwarder to share data structures directly. |
| Memory safety | C++ (unsafe). mw-nfd inherits NFD’s C++ codebase. The multi-threading changes introduce new shared mutable state that is difficult to audit for data races. ThreadSanitizer can catch races at runtime, but not exhaustively. | Rust (memory-safe). The compiler rejects data races statically. Adding a new shared data structure forces the programmer to choose between Mutex, RwLock, or DashMap explicitly — the choice is visible in the type, not hidden in comments. | Introducing shared mutable state into a previously single-threaded C++ codebase is one of the highest-risk refactors in systems programming. Rust’s ownership model makes this safe by construction: if it compiles, there are no data races. |
| PIT concurrency | Global reader-writer lock per worker, or per-worker PIT partitions (implementation varies by version). Contention can occur when multiple workers process packets for the same name prefix. | DashMap: fine-grained sharding by name hash. Independent names in different shards never contend. Workers processing different name prefixes run in true parallel. | Fine-grained sharding is more work to implement correctly but scales better under diverse traffic. A global or coarse lock limits throughput gains from adding more workers. |
| Strategy system | Inherits NFD’s strategy system. Strategies are C++ classes; changing a strategy requires recompilation and daemon restart. mw-nfd adds no new strategy extensibility beyond NFD. | Trait + WASM. Built-in strategies compile in; external strategies load as WASM modules at runtime without a restart. | mw-nfd’s focus is throughput scaling, not strategy extensibility. ndn-rs adds runtime extensibility that mw-nfd does not attempt to address. |
| Forward compatibility | Tied to NFD’s release cadence. mw-nfd is a fork; divergence from upstream NFD grows over time and merging upstream changes requires effort. | Independent codebase. ndn-rs evolves on its own roadmap and tracks the NDN spec directly, not NFD’s implementation. | Maintaining a fork of a large C++ project accumulates technical debt. ndn-rs does not carry this burden, but also lacks a decade of NFD’s production hardening. |
Where NDNts and mw-nfd Have the Advantage
NDNts:
- Browser-native. No other NDN implementation runs in a web browser. If your application is a web app, NDNts is the answer.
- npm ecosystem. One
npm installand it works with any JavaScript build pipeline. No Rust toolchain required. - Web Crypto integration. Hardware-backed key storage via browser credentials is accessible only to browser-native code.
- Community. NDNts is actively maintained and has the widest adoption among JavaScript NDN developers.
mw-nfd:
- Drop-in for NFD. Existing NFD deployments gain multi-core throughput with minimal configuration changes.
- Spec authority. mw-nfd inherits NFD’s status as the reference implementation, so its forwarding behaviour is de facto correct by definition.
- Production history. mw-nfd runs in real testbed deployments; ndn-rs’s multi-core forwarding is newer and less battle-tested at scale.
Complementary Deployment Pattern
A common pattern that uses all three projects together: NDNts runs in a browser application (consumer and producer), connecting over WebSocket to an ndn-fwd instance (ndn-rs) acting as an edge forwarder, which peers upstream with mw-nfd or NDN-DPDK nodes on the testbed backbone. Each implementation operates in the tier where it has the clearest advantage.
NDN TLV Encoding
From Bytes on the Wire to Structured Packets
Every NDN packet – Interest, Data, Nack – is just a sequence of bytes when it arrives at a network interface. Before the forwarder can look up a name in the FIB, check the PIT, or consult a strategy, those raw bytes need to become structured data. The question is: how do you parse them efficiently, without copying memory you don’t need to, and without breaking on malformed input?
This is the job of the ndn-tlv crate, the lowest layer in the ndn-rs stack. It implements NDN’s Type-Length-Value wire format: a recursive encoding where every element starts with a type number, followed by a length, followed by that many bytes of value. The value itself can contain more TLV elements, nesting arbitrarily deep. An Interest packet is a TLV element whose value contains the Name (another TLV element, whose value contains name components, each of which is yet another TLV element), plus optional fields like Nonce and Lifetime.
The entire crate is built around one principle: parse without copying. Every slice of decoded data points back into the original byte buffer. When the Content Store serves a cache hit, the bytes that go out on the wire are the same bytes that came in – no serialization round-trip, no intermediate allocations.
📊 Performance: Zero-copy parsing means a Content Store hit can serve cached data by incrementing a reference count and handing out a
Bytesslice. No memcpy, no allocation. On a forwarder handling millions of packets per second, this is the difference between keeping up and falling behind.
The VarNumber Trick: Compact Headers for a Wide Range
Before we can parse TLV elements, we need to understand how NDN encodes the Type and Length fields themselves. Both are variable-width unsigned integers called VarNumbers, and the encoding is surprisingly elegant.
The problem: NDN type numbers range from small values like 0x05 (Interest) and 0x06 (Data) up to application-defined types that could be any 64-bit value. Fixed-width fields would waste space – most types and lengths fit in a single byte, but the encoding needs to handle the rare large values too.
NDN’s solution is a compact variable-width encoding. If the value fits in one byte (0 through 252), it’s encoded as-is. For larger values, a marker byte announces the width, followed by the value in big-endian:
| Value range | Wire bytes | Format |
|---|---|---|
| 0 – 252 | 1 | Single byte |
| 253 – 65535 | 3 | 0xFD + 2-byte big-endian |
| 65536 – 2^32 - 1 | 5 | 0xFE + 4-byte big-endian |
| 2^32 – 2^64 - 1 | 9 | 0xFF + 8-byte big-endian |
In practice, the vast majority of TLV elements use the 1-byte form. A name component of type 0x08 with a 4-byte value costs just 6 bytes of overhead (1 for type, 1 for length, 4 for value). The multi-byte forms exist for the rare cases where a packet carries a large payload or an application uses a high-numbered type.
⚠️ Spec requirement: The encoding is canonical – the smallest representation that fits the value must be used. Encoding the value 100 with the 3-byte form (
0xFD 0x00 0x64) is illegal even though it would decode correctly. ndn-rs rejects non-minimal encodings withTlvError::NonMinimalVarNumber. This prevents ambiguity: every value has exactly one valid encoding, which matters for signature verification (you don’t want two different wire encodings of the same logical packet producing different signature digests).
The three core functions that implement this in ndn-tlv are straightforward:
#![allow(unused)]
fn main() {
/// Read a VarNumber, returning (value, bytes_consumed).
pub fn read_varu64(buf: &[u8]) -> Result<(u64, usize), TlvError>;
/// Write a VarNumber, returning bytes written.
pub fn write_varu64(buf: &mut [u8], value: u64) -> usize;
/// Compute encoded size without allocating.
pub fn varu64_size(value: u64) -> usize;
}
The varu64_size function is particularly useful during encoding: you can calculate the total wire size of a packet before writing a single byte, which lets you pre-allocate the exact buffer size needed.
TlvReader: Parsing Without Copying a Single Byte
With VarNumbers understood, we can now follow a packet through the parsing pipeline. A raw Bytes buffer arrives from the network. We need to extract structured fields – the packet type, the name, the nonce – but we want to avoid copying any of the underlying data.
TlvReader makes this possible by wrapping a Bytes buffer and yielding sub-slices that share the same reference-counted allocation. When you call read_tlv(), it reads the type and length VarNumbers, then returns a Bytes slice pointing into the original buffer. No memcpy, no new allocation – just a pointer, a length, and an incremented reference count.
#![allow(unused)]
fn main() {
let raw: Bytes = receive_from_network();
let mut reader = TlvReader::new(raw);
// Read a complete TLV element: (type, value_bytes)
let (typ, value) = reader.read_tlv()?;
// value is a Bytes slice into the original allocation
// -- zero copy, reference counted
// Peek without advancing
let next_type = reader.peek_type()?;
// Scoped sub-reader for nested TLV parsing
let mut inner = reader.scoped(value.len())?;
}
The diagram below shows what’s happening in memory. There’s one allocation, shared across every slice:
graph LR
subgraph "Original Bytes buffer (single allocation, ref-counted)"
B0["T"] ~~~ B1["L"] ~~~ B2["V₁ V₂ V₃ V₄ V₅ V₆ ..."]
end
subgraph "After read_tlv()"
S1["typ = T"]
S2["value: Bytes slice → V₁ V₂ V₃ ..."]
end
subgraph "After scoped()"
S3["inner TlvReader → V₁ V₂ ..."]
end
B2 -. "zero-copy slice\n(same Arc'd allocation)" .-> S2
B2 -. "bounded sub-reader\n(no copy)" .-> S3
style B0 fill:#c44,color:#fff
style B1 fill:#c90,color:#fff
style B2 fill:#2d5a8c,color:#fff
style S2 fill:#2d5a8c,color:#fff
style S3 fill:#3a7ca5,color:#fff
💡 Key insight: The
scoped()method is what makes nested TLV parsing safe. It returns a sub-reader that is bounded to exactlylenbytes. If the inner parsing tries to read beyond the declared length of the enclosing element, it gets an error instead of silently consuming bytes from the next sibling element. This turns a class of subtle parsing bugs into hard failures.
The four key methods tell the full story of how parsing works:
read_tlv()reads type + length + value, advancing the cursor and returning(u64, Bytes). The returnedBytesis a zero-copy slice into the original buffer.peek_type()peeks at the next type number without advancing, so the parser can decide which field comes next without committing.scoped(len)returns a sub-reader bounded tolenbytes, for safely descending into nested TLV structures.skip_unknown(typ)handles forward compatibility by skipping unrecognized TLV elements – but only if they’re non-critical.
⚠️ Spec requirement: The critical-bit rule from NDN Packet Format v0.3 section 1.3 determines which unknown types can be safely skipped. Types 0 through 31 are always critical (grandfathered from earlier spec versions). For types 32 and above, odd numbers are critical and even numbers are non-critical.
skip_unknownenforces this automatically: encountering an unknown critical type is a hard error, while unknown non-critical types are silently skipped. This is how NDN achieves forward compatibility – new optional fields can be added to packets without breaking older parsers.
What Gets Parsed, and When
Not every field in a packet needs to be decoded immediately. Consider an Interest arriving at a forwarder. The pipeline always needs the Name (for FIB lookup), but the Nonce, Lifetime, and ApplicationParameters might never be accessed – especially on a Content Store hit, where the matching Data is returned without the Interest ever reaching the strategy layer.
An NDN Interest packet has the following wire structure:
graph TD
A["Interest (0x05)"] --> B["Name (0x07)"]
A --> C["CanBePrefix (0x21)"]
A --> D["MustBeFresh (0x12)"]
A --> E["Nonce (0x0A, 4 bytes)"]
A --> F["InterestLifetime (0x0C)"]
A --> G["HopLimit (0x22, 1 byte)"]
A --> H["ForwardingHint (0x1E)"]
A --> I["ApplicationParameters (0x24)"]
B --> B1["GenericNameComponent (0x08)\n'ndn'"]
B --> B2["GenericNameComponent (0x08)\n'test'"]
B --> B3["GenericNameComponent (0x08)\n'data'"]
H --> H1["Name (0x07)\nDelegation 1"]
style A fill:#2d5a8c,color:#fff
style B fill:#3a7ca5,color:#fff
style B1 fill:#5ba3c9,color:#fff
style B2 fill:#5ba3c9,color:#fff
style B3 fill:#5ba3c9,color:#fff
style E fill:#3a7ca5,color:#fff
style F fill:#3a7ca5,color:#fff
In ndn-rs, the Interest struct uses OnceLock<T> for lazy decoding. The Name is always decoded eagerly – every pipeline stage needs it. But fields like Nonce, Lifetime, and ApplicationParameters are decoded on first access. The raw Bytes slices for those fields are stored during the initial parse, and the actual decoding happens only when (and if) something reads the field. This means a Content Store hit can short-circuit before paying the cost of decoding the Nonce or signature fields.
🔧 Implementation note:
OnceLock<T>is perfect here because it provides interior mutability with initialization-once semantics. The first access decodes the field and stores the result; subsequent accesses return the cached value. Since the underlyingBytesslice is immutable and the decode is deterministic, this is safe even across threads.
The Data Packet and Its Signed Region
A Data packet (type 0x06) is more complex because it carries a cryptographic signature. The signed region spans from the Name through the SignatureInfo (inclusive), and the SignatureValue covers that region:
graph TD
D["Data (0x06)"] --> N["Name (0x07)"]
D --> MI["MetaInfo (0x14)"]
D --> CO["Content (0x15)"]
D --> SI["SignatureInfo (0x16)"]
D --> SV["SignatureValue (0x17)"]
N --> N1["GenericNameComponent (0x08)\n'ndn'"]
N --> N2["GenericNameComponent (0x08)\n'test'"]
N --> N3["GenericNameComponent (0x08)\n'data'"]
MI --> CT["ContentType (0x18)"]
MI --> FP["FreshnessPeriod (0x19)"]
CO --> PL["<application payload>"]
SI --> ST["SignatureType (0x1B)"]
SI --> KL["KeyLocator (0x1C)"]
SV --> SB["<signature bytes>"]
style D fill:#2d5a8c,color:#fff
style N fill:#3a7ca5,color:#fff
style N1 fill:#5ba3c9,color:#fff
style N2 fill:#5ba3c9,color:#fff
style N3 fill:#5ba3c9,color:#fff
style MI fill:#3a7ca5,color:#fff
style CO fill:#3a7ca5,color:#fff
style SI fill:#8c5a2d,color:#fff
style ST fill:#a5793a,color:#fff
style KL fill:#a5793a,color:#fff
style SV fill:#8c2d2d,color:#fff
style SB fill:#a53a3a,color:#fff
Understanding the signed region is critical for both parsing and encoding:
Data (0x06)
+-- Name (0x07)
| +-- NameComponent (0x08) ...
+-- MetaInfo (0x14)
| +-- ContentType (0x18)
| +-- FreshnessPeriod (0x19)
+-- Content (0x15)
| +-- <application payload>
+-- SignatureInfo (0x16) --|
| +-- SignatureType (0x1B) | signed region
| +-- KeyLocator (0x1C) |
+-- SignatureValue (0x17) --| covers above
💡 Key insight: Because the signature covers the wire-format bytes (not some abstract representation), zero-copy parsing is essential for verification. The forwarder can verify a signature directly against the original buffer without re-encoding anything. And because
Bytesslices share the underlying allocation, theTlvWriter::snapshot()method can capture the signed region as a cheap sub-slice rather than a copy.
TlvWriter: Building Packets for the Wire
Parsing is only half the story. When a producer creates a Data packet, or the forwarder generates a Nack, bytes need to flow in the other direction: from structured fields to wire format.
TlvWriter is backed by a growable BytesMut buffer. It handles the bookkeeping of emitting type and length VarNumbers, nesting elements correctly, and capturing byte ranges for signing:
#![allow(unused)]
fn main() {
let mut w = TlvWriter::new();
// Flat TLV element
w.write_tlv(0x08, b"component");
// Nested TLV -- closure writes inner content,
// write_nested wraps it with the correct outer type + length
w.write_nested(0x07, |inner| {
inner.write_tlv(0x08, b"ndn");
inner.write_tlv(0x08, b"test");
});
// Raw bytes (pre-encoded content, e.g. signed regions)
w.write_raw(&pre_encoded);
// Snapshot for signing: capture bytes from offset
let signed_region = w.snapshot(start_offset);
let wire_bytes: Bytes = w.finish();
}
The write_nested method solves a classic chicken-and-egg problem in TLV encoding. The outer element’s length field must contain the total size of the inner content, but you don’t know that size until you’ve written the inner content. write_nested handles this by writing the inner content to a temporary buffer first, then emitting the outer type, the minimal-length VarNumber for the inner size, and finally the inner bytes. The caller just writes inner elements in a closure and the framing happens automatically.
🔧 Implementation note: The
snapshotmethod is how signing works during encoding. A producer writes fields from Name through SignatureInfo, captures asnapshotof that byte range, computes the signature over it, and then writes the SignatureValue. The snapshot is aBytesslice into the writer’s buffer – no copy needed.
Stream Transports: Reassembling Packets from TCP
On datagram transports like UDP, each received message is exactly one NDN packet. But on stream transports like TCP and Unix sockets, packets arrive as a continuous byte stream with no built-in framing. A single read() call might return half a packet, or two and a half packets.
TlvCodec solves this by implementing Tokio’s Decoder and Encoder traits. It reassembles complete NDN packets from the byte stream by peeking at the type and length VarNumbers, then buffering until the full value has arrived:
#![allow(unused)]
fn main() {
// Used internally by TcpFace and other stream-based faces
let framed = Framed::new(tcp_stream, TlvCodec);
// Decoder yields complete Bytes frames (one NDN packet each)
while let Some(frame) = framed.next().await {
let pkt: Bytes = frame?;
// pkt is a complete TLV element: type + length + value
}
}
📊 Performance: The decoder pre-allocates buffer space based on the declared length as soon as it reads the length VarNumber. This avoids repeated reallocations as bytes trickle in for large packets. A 64 KB Data packet arriving over a slow TCP connection results in one allocation, not dozens of growing reallocs.
Serial Links Need a Different Approach
Everything described so far assumes a reliable transport – either datagrams with clear boundaries (UDP) or a byte stream where TLV length-prefix framing can work (TCP). Serial links like UART and RS-485 break both assumptions. There are no message boundaries, and byte-level errors (noise, dropped bytes) can desynchronize the parser. If a single byte of the length field is corrupted, a TLV parser will try to read millions of bytes and never recover.
SerialFace uses Consistent Overhead Byte Stuffing (COBS) to solve this problem. COBS provides unambiguous frame boundaries using a simple trick: the 0x00 byte is reserved as a frame delimiter, and any 0x00 bytes within the actual data are replaced with a run-length encoding scheme.
The process works in three steps:
- Each NDN TLV packet is COBS-encoded, replacing all
0x00bytes with a run-length scheme that the receiver can reverse. - A
0x00sentinel byte marks the end of each frame. - The receiver accumulates bytes until it sees
0x00, COBS-decodes the frame, and passes the resulting TLV bytes toTlvCodecfor normal NDN parsing.
If a byte is corrupted or dropped, the worst that happens is the current frame is garbled – the receiver discards it and resynchronizes at the next 0x00 boundary. The damage is contained to a single packet rather than cascading through all subsequent parsing.
📊 Performance: The overhead of COBS encoding is at most 1 byte per 254 bytes of payload – negligible for typical NDN packets. A 1 KB Interest costs at most 4 extra bytes of framing overhead. The tradeoff is worth it: on unreliable serial links, the alternative is a parser that can lose synchronization and never recover.
Running Without std
The ndn-tlv crate supports no_std environments (with an allocator). Disabling the default std feature (default-features = false in Cargo.toml) enables #![no_std] mode. An allocator is still required because TlvWriter uses BytesMut for its growable buffer.
🔧 Implementation note: This makes
ndn-tlvsuitable for embedded NDN nodes – microcontrollers running a minimal NDN stack can use the same TLV parsing code as the full forwarder. The zero-copy design actually matters more in embedded contexts, where memory is scarce and every unnecessary allocation counts.
Zero-Copy Pipeline
ndn-rs is designed so that a packet can travel from ingress face to egress face without a single data copy in the common case. This page explains the mechanisms that make this possible.
The Core Idea
When a face receives a packet from the network, it arrives as a bytes::Bytes buffer. That same buffer – the exact same allocation – can be sent out on another face without ever being copied. The Content Store can hold onto it for days and serve it to thousands of consumers, all sharing the same underlying memory.
This is possible because Bytes is a reference-counted, immutable byte buffer. Bytes::clone() increments an atomic counter and returns a new handle to the same data. Bytes::slice(start..end) creates a sub-view into the same allocation. Neither operation copies any packet data.
💡 Key insight:
Bytesis the foundational type that makes zero-copy possible. Every “copy” in the pipeline is actually a reference count increment (a single atomic operation). The underlying memory is freed only when the last handle is dropped. This is why the Content Store can hold a cached packet for days while simultaneously serving it to multiple consumers – they all share the same allocation.
graph TD
subgraph "Underlying Allocation"
BUF["Memory buffer<br/>[TLV type | name TLV | nonce | lifetime | content ... ]"]
end
H1["Bytes handle: raw_bytes<br/>(full packet)"] -->|"refcount +1"| BUF
H2["Bytes handle: name_bytes<br/>slice(4..38)"] -->|"refcount +1"| BUF
H3["Bytes handle: content_bytes<br/>slice(52..152)"] -->|"refcount +1"| BUF
H4["Bytes handle: CS entry<br/>clone() of raw_bytes"] -->|"refcount +1"| BUF
style BUF fill:#e8f4fd,stroke:#2196F3
style H1 fill:#fff3e0,stroke:#FF9800
style H2 fill:#fff3e0,stroke:#FF9800
style H3 fill:#fff3e0,stroke:#FF9800
style H4 fill:#fff3e0,stroke:#FF9800
Each Bytes handle (whether a full clone or a sub-slice) points into the same allocation. The buffer is freed only when the last handle is dropped.
Bytes Through the Pipeline
graph TD
A["Face recv()"] -->|"Bytes (wire format)"| B["PacketContext::new()"]
B -->|"raw_bytes: Bytes"| C["TlvDecodeStage"]
C -->|"Bytes::slice() for each field"| D{"CS Lookup"}
D -->|"Cache miss"| E["PitCheck / Strategy / Dispatch"]
D -->|"Cache hit: Bytes::clone()"| F["Face send()"]
E -->|"Forward: raw_bytes"| F
E -->|"CsInsert: Bytes::clone()"| G["Content Store"]
G -->|"Future hit: Bytes::clone()"| F
style A fill:#e8f4fd,stroke:#2196F3
style F fill:#e8f4fd,stroke:#2196F3
style G fill:#fff3e0,stroke:#FF9800
At every arrow in this diagram, the data does not move. Only a reference-counted handle is passed, cloned, or sliced.
PacketContext Carries Wire Bytes
Every packet entering the pipeline is wrapped in a PacketContext:
#![allow(unused)]
fn main() {
pub struct PacketContext {
/// Wire-format bytes of the original packet.
pub raw_bytes: Bytes,
/// Face the packet arrived on.
pub face_id: FaceId,
/// Decoded name -- hoisted because every stage needs it.
pub name: Option<Arc<Name>>,
/// Decoded packet -- starts as Raw, transitions after TlvDecodeStage.
pub packet: DecodedPacket,
// ... other fields
}
}
The raw_bytes field holds the original wire-format buffer for the packet’s entire lifetime in the pipeline. When a stage needs to send the packet, it uses raw_bytes directly – no re-encoding.
TlvReader: Zero-Copy Parsing
The TlvReader in ndn-tlv parses TLV-encoded packets without copying any data. It works by slicing into the source Bytes:
#![allow(unused)]
fn main() {
// TlvReader holds a Bytes and a cursor position.
// Reading a TLV value returns a Bytes::slice() -- same allocation, no copy.
let value: Bytes = reader.read_value(length)?; // Bytes::slice(), not memcpy
}
When TlvDecodeStage runs, it parses the packet’s type, name, and other fields by slicing into raw_bytes. The resulting Interest or Data struct contains Bytes handles pointing into the original buffer. The original buffer stays alive because Bytes is reference-counted – as long as any slice exists, the underlying allocation persists.
Content Store: Wire-Format Storage
The Content Store stores the wire-format Bytes directly, not a decoded Data struct:
CsInsert: store raw_bytes.clone() keyed by name
CsLookup: if hit, return stored Bytes -- this IS the wire packet, ready to send
This design means a cache hit is nearly free:
- Look up the name in the CS (hash map lookup).
- Clone the stored
Bytes(atomic reference count increment). - Send the cloned
Bytesto the outgoing face.
There is no re-encoding, no field patching, no serialization. The exact bytes that were received from the network are the exact bytes sent to the consumer. This is the fastest possible cache hit.
Arc<Name>: Shared Name References
Names are the most frequently shared data in an NDN forwarder. A single name may appear simultaneously in:
- The
PacketContextflowing through the pipeline - A PIT entry waiting for Data
- A FIB entry for route lookup
- A CS entry for cache lookup
- A
StrategyContextfor the forwarding decision - A
MeasurementsTableentry for RTT tracking
Copying a name with 6 components means copying 6 NameComponent values (each containing a Bytes slice and a TLV type). Arc<Name> eliminates all of these copies: cloning the Arc is a single atomic increment, and all holders share the same Name allocation.
📊 Performance: Without
Arc<Name>, every PIT insert, FIB lookup, CS insert, and strategy invocation would copy the full name (6 components = 6Bytesslices + 6 TLV type tags). WithArc, all of these operations are a single atomic increment. On a forwarder processing 1M packets/second, this eliminates millions of allocations per second.
graph TD
NAME["Arc<Name><br/>/ndn/app/video/frame/1<br/>(single heap allocation)"]
PC["PacketContext<br/>.name"] -->|"Arc::clone()"| NAME
PIT["PIT entry<br/>.name"] -->|"Arc::clone()"| NAME
FIB["FIB lookup result<br/>.prefix"] -->|"Arc::clone()"| NAME
CS["CS entry<br/>.key"] -->|"Arc::clone()"| NAME
SC["StrategyContext<br/>.name"] -->|"&Arc (borrow)"| NAME
MT["MeasurementsTable<br/>.key"] -->|"Arc::clone()"| NAME
style NAME fill:#c8e6c9,stroke:#4CAF50
style PC fill:#e8f4fd,stroke:#2196F3
style PIT fill:#fff3e0,stroke:#FF9800
style FIB fill:#f3e5f5,stroke:#9C27B0
style CS fill:#fff3e0,stroke:#FF9800
style SC fill:#fce4ec,stroke:#E91E63
style MT fill:#e8f4fd,stroke:#2196F3
#![allow(unused)]
fn main() {
// In PacketContext:
pub name: Option<Arc<Name>>,
// In StrategyContext:
pub name: &'a Arc<Name>,
// In PIT entry, CS key, measurements key: Arc<Name>
}
OnceLock: Lazy Decode
Not all packet fields are needed on every code path. A Content Store hit, for example, may never need the Interest’s nonce or lifetime – only the name matters for the lookup. Fields that are expensive to decode or rarely needed are wrapped in OnceLock<T>:
#![allow(unused)]
fn main() {
// Conceptual: field is decoded on first access, never before
let nonce: &u32 = interest.nonce(); // decodes from raw_bytes on first call, cached thereafter
}
graph LR
subgraph "Interest (OnceLock lazy decode)"
direction TB
N["name: Arc<Name><br/>--- DECODED ---"]
NO["nonce: OnceLock<u32><br/>--- not yet accessed ---"]
LT["lifetime: OnceLock<Duration><br/>--- not yet accessed ---"]
CBP["can_be_prefix: OnceLock<bool><br/>--- DECODED ---"]
MBF["must_be_fresh: OnceLock<bool><br/>--- not yet accessed ---"]
FH["forwarding_hint: OnceLock<...><br/>--- not yet accessed ---"]
end
RAW["raw_bytes: Bytes<br/>(wire format)"] -->|"decode on<br/>first access"| N
RAW -->|"decode on<br/>first access"| CBP
style N fill:#c8e6c9,stroke:#4CAF50
style CBP fill:#c8e6c9,stroke:#4CAF50
style NO fill:#eeeeee,stroke:#9E9E9E
style LT fill:#eeeeee,stroke:#9E9E9E
style MBF fill:#eeeeee,stroke:#9E9E9E
style FH fill:#eeeeee,stroke:#9E9E9E
style RAW fill:#e8f4fd,stroke:#2196F3
This means the pipeline pays only for what it uses. On the fast path (CS hit), the decode cost is minimal: parse the type byte, extract the name (a Bytes::slice()), look up the CS, and send.
Where Copies Do Happen
Zero-copy is not absolute. Copies occur in specific, deliberate places:
- NDNLPv2 fragmentation: If a packet must be fragmented for a face with a small MTU, each fragment is a new allocation containing a header plus a slice of the original.
- Signature computation: Computing a signature requires reading the signed portion of the packet, which may involve a copy into the signing buffer depending on the crypto library.
- Logging/tracing: Debug-level logging that formats packet contents creates string copies, but this is behind a log-level gate and never runs in production hot paths.
- Cross-face-type adaptation: When a packet moves between face types with different framing (e.g., UDP to Ethernet), the link-layer header is different. The NDN packet bytes themselves are not copied, but a new buffer may be allocated for the new framing around them.
⚠️ Important: These are the only places where data is copied. If you are implementing a new face or pipeline stage, avoid introducing additional copies. Use
Bytes::slice()for sub-views andBytes::clone()for sharing – neverVec::from()orto_vec()on the hot path.
The important invariant: the NDN packet data itself is never copied on the forwarding fast path. Framing and metadata may be allocated, but the TLV content – which is the bulk of the bytes – flows through untouched.
Pipeline Walkthrough
The Scene: A Packet Arrives
An Interest for /ndn/edu/ucla/cs/class arrives on a UDP face. The consumer application – maybe a student’s laptop running a content-fetching tool – has just expressed its desire for some course data. The Interest is nothing but raw bytes in a UDP datagram, sitting in a kernel socket buffer. It has no idea what’s about to happen to it.
Let’s follow it through every stage of the ndn-rs forwarding pipeline, from raw bytes to satisfied consumer.
The Cast of Characters
The pipeline is a fixed sequence of stages compiled at build time. Packets flow through stages as a PacketContext value that is passed by ownership – Rust’s move semantics ensure that exactly one stage owns the packet at any given moment. Each stage returns an Action enum that tells the pipeline runner what to do next:
Continue(ctx)– pass to the next stageSatisfy(ctx)– Data found, send it backSend(ctx)– forward out a faceDrop(reason)– discard the packetNack(reason)– send a Nack upstream
💡 Key insight: The pipeline is not a runtime plugin system. Stages are fixed at compile time, which lets the compiler inline aggressively and eliminate virtual dispatch overhead. The
Actionenum drives control flow without dynamic trait objects on the hot path.
The Full Journey
Here’s the complete sequence, from the consumer sending an Interest to receiving the Data back. We’ll walk through each box in detail below.
sequenceDiagram
participant App as Consumer App
participant FaceA as UdpFace (in)
participant Pipeline as Pipeline Runner
participant Decode as TlvDecodeStage
participant CS as CsLookupStage
participant PIT as PitCheckStage
participant Strategy as StrategyStage
participant FIB as FIB
participant FaceB as UdpFace (out)
participant Producer as Producer App
App->>FaceA: Interest (wire bytes)
FaceA->>Pipeline: InboundPacket { raw, face_id, arrival }
Pipeline->>Decode: process(ctx)
Note over Decode: LP-unwrap + TLV parse<br/>Set ctx.name, ctx.packet
Pipeline->>CS: process(ctx)
Note over CS: Lookup by name<br/>(miss path)
Pipeline->>PIT: process(ctx)
Note over PIT: Create PIT entry<br/>Record in-face, nonce<br/>Check for duplicate nonce
Pipeline->>Strategy: process(ctx)
Strategy->>FIB: lpm(name)
FIB-->>Strategy: nexthops
Note over Strategy: BestRoute selects<br/>lowest-cost nexthop
Strategy->>FaceB: send(Interest bytes)
FaceB->>Producer: Interest
Producer->>FaceB: Data (wire bytes)
FaceB->>Pipeline: InboundPacket { raw, face_id, arrival }
Pipeline->>Decode: process(ctx)
Note over Decode: Parse Data, set ctx.name
Pipeline->>PIT: PitMatchStage::process(ctx)
Note over PIT: Match by name<br/>Collect in-record faces<br/>Remove PIT entry
Pipeline->>Strategy: Validation
Note over Strategy: Signature/chain validation<br/>(optional)
Pipeline->>CS: CsInsertStage::process(ctx)
Note over CS: Insert Data into cache<br/>Fan-out to in-record faces
CS->>FaceA: send(Data bytes)
FaceA->>App: Data
Act I: Arrival
Bytes Hit the Wire
Our Interest for /ndn/edu/ucla/cs/class arrives as a UDP datagram on port 6363. The UdpFace has been waiting for exactly this moment. Each face runs its own Tokio task – a tight loop calling face.recv() and pushing results into a shared channel:
#![allow(unused)]
fn main() {
pub struct InboundPacket {
pub raw: Bytes, // Raw wire bytes (zero-copy from socket)
pub face_id: FaceId, // Which face received this
pub arrival: Instant, // Arrival timestamp
pub meta: InboundMeta, // Link-layer metadata (source MAC, etc.)
}
}
The raw field is a bytes::Bytes – a reference-counted, zero-copy buffer. The kernel wrote the datagram into a buffer, and Bytes lets us slice and share it without ever copying the actual packet data. This matters: our Interest might pass through six pipeline stages, and none of them will memcpy the wire bytes.
🔧 Implementation note: The
InboundPacketcaptures anInstantat arrival time, not a wall-clock timestamp. This is used later for PIT expiry calculations and RTT measurement – both of which need monotonic time, not time-of-day.
The Batch Drain: Amortizing Overhead
Here’s where the first clever optimization lives. The pipeline runner doesn’t process packets one at a time. It drains them in batches.
The runner blocks on the channel waiting for the first packet. But as soon as one arrives, it greedily pulls up to 63 more with non-blocking try_recv() calls. In a burst scenario – say, a hundred Interests arrive in quick succession – this amortizes the tokio::select! wakeup cost across the entire batch:
#![allow(unused)]
fn main() {
const BATCH_SIZE: usize = 64;
let first = tokio::select! {
_ = cancel.cancelled() => break,
pkt = rx.recv() => match pkt { ... },
};
batch.push(first);
while batch.len() < BATCH_SIZE {
match rx.try_recv() {
Ok(p) => batch.push(p),
Err(_) => break,
}
}
}
📊 Performance: Without batch drain, every packet pays the full cost of a
tokio::select!wakeup – parking and unparking the task, checking the cancellation token, etc. With batch drain, 64 packets share a single wakeup. Under load, this reduces per-packet scheduling overhead by up to 60x.
Here’s the task topology. Every face feeds into one shared channel, and the pipeline runner fans out to per-packet processing:
%%{init: {"layout": "elk"}}%%
flowchart LR
subgraph Face Reader Tasks
F1["UdpFace\nrecv loop"]
F2["TcpFace\nrecv loop"]
F3["EtherFace\nrecv loop"]
Fn["..."]
end
CH[/"mpsc channel\n(InboundPacket queue)"/]
F1 --> CH
F2 --> CH
F3 --> CH
Fn --> CH
subgraph Pipeline Runner
PR["run_pipeline\n(batch drain up to 64)"]
end
CH --> PR
subgraph Per-Packet Processing
PP1["spawn: process_packet"]
PP2["spawn: process_packet"]
PP3["spawn: process_packet"]
end
PR --> PP1
PR --> PP2
PR --> PP3
subgraph Face Send Tasks
S1["FaceA.send()"]
S2["FaceB.send()"]
S3["FaceC.send()"]
end
PP1 --> S1
PP2 --> S2
PP3 --> S3
style CH fill:#c90,color:#000
style PR fill:#2d5a8c,color:#fff
style PP1 fill:#5a2d8c,color:#fff
style PP2 fill:#5a2d8c,color:#fff
style PP3 fill:#5a2d8c,color:#fff
💡 Key insight: The single
mpscchannel is intentional. All faces – UDP, TCP, Ethernet, shared memory – feed into the same queue. This means Interest aggregation works correctly even when the same Interest arrives on different face types simultaneously. One channel, one PIT, one truth.
Parallel vs. Single-Threaded Mode
Before the runner dispatches each packet, it makes a choice based on the pipeline_threads configuration:
#![allow(unused)]
fn main() {
if parallel {
let d = Arc::clone(self);
tokio::spawn(async move { d.process_packet(pkt).await });
} else {
self.process_packet(pkt).await;
}
}
- Single-threaded (
pipeline_threads == 1): packets are processed inline in the pipeline runner task. No task spawn overhead, no cross-thread synchronization. Best for embedded devices, low-traffic deployments, or when you want deterministic packet ordering. - Parallel (
pipeline_threads > 1): each packet is spawned as a separate Tokio task and can run on any worker thread. Higher throughput under load, at the cost of task spawn overhead (~200ns per packet) and non-deterministic ordering.
⚠️ Important: In parallel mode, two Interests for the same name can race through the PIT check stage concurrently. The
DashMap-based PIT handles this correctly through fine-grained locking, but the ordering of in-records may differ between runs. If your application depends on Interest ordering, use single-threaded mode.
🔄 What happens next: The batch is drained, the mode is selected. Now each packet enters the pipeline proper. Our Interest for
/ndn/edu/ucla/cs/classis about to be decoded.
Act II: The Interest Pipeline
The Fragment Sieve: Reassembly on the Fast Path
Before our Interest enters the full pipeline, it passes through the fragment sieve. NDN Link Protocol (LP) packets can be fragmented – a single NDN packet split across multiple LP frames. The sieve collects fragments and only passes reassembled packets forward.
For our Interest, this is a no-op. A typical Interest for /ndn/edu/ucla/cs/class is well under the MTU, so it arrives as a single LP frame. The sieve checks the fragment header, sees it’s a complete packet, and passes it through immediately.
📊 Performance: The fragment sieve adds roughly 2 microseconds per packet, even on the fast path (no fragmentation). This is cheap insurance – without it, a fragmented packet would enter the TLV parser as a truncated blob and fail with a confusing decode error.
🔄 What happens next: Our Interest has survived the sieve intact. Time to find out what it actually says.
Decode: From Wire Bytes to Structured Packet
The TlvDecodeStage is where raw bytes become a structured NDN packet. It does two things:
- LP-unwraps the packet – strips the NDN Link Protocol header, extracting any LP fields (congestion marks, next-hop face hints, etc.)
- TLV-parses the bare NDN packet – determines that this is an Interest (type 0x05), decodes the name
/ndn/edu/ucla/cs/class, and creates a partially-decodedIntereststruct
#![allow(unused)]
fn main() {
let ctx = match self.decode.process(ctx) {
Action::Continue(ctx) => ctx,
Action::Drop(DropReason::FragmentCollect) => return,
Action::Drop(r) => { debug!(reason=?r, "drop at decode"); return; }
other => { self.dispatch_action(other); return; }
};
}
The word “partially” is critical here. The name is always decoded eagerly – every subsequent stage needs it. But other Interest fields like the Nonce (4 random bytes for loop detection) and InterestLifetime are behind OnceLock<T> – they’ll be decoded on first access, if and when they’re needed.
🔧 Implementation note: The
OnceLock<T>lazy decoding pattern means that a Content Store hit (coming up next) might satisfy the Interest without ever parsing the Nonce or Lifetime fields. On a cache-heavy workload, this saves measurable CPU time.
🔄 What happens next: We now have a decoded Interest with name
/ndn/edu/ucla/cs/class. But before it enters the forwarding path, someone else gets first dibs.
Discovery Hook: The Gatekeeper
After decode, the discovery subsystem gets first look at the packet. Hello Interests, service record browse packets, and SWIM protocol probes are consumed here and never enter the forwarding pipeline:
#![allow(unused)]
fn main() {
if self.discovery.on_inbound(&ctx.raw_bytes, ctx.face_id, &meta, &*self.discovery_ctx) {
return; // Consumed by discovery
}
}
Our Interest for /ndn/edu/ucla/cs/class is a normal forwarding packet, so discovery lets it pass. But if it had been a /localhop/_discovery/hello Interest, the journey would end here – handled entirely by the discovery subsystem.
🔄 What happens next: Discovery waves our Interest through. Now the real forwarding begins.
CS Lookup: The Triumphant Short-Circuit
The Content Store is checked first. If someone recently fetched /ndn/edu/ucla/cs/class and the Data is still cached, the Interest never even makes it to the PIT. The pipeline returns Action::Satisfy, and the cached Data is sent directly back to the consumer on the face the Interest arrived from.
This is the fastest possible path through the forwarder. No PIT entry created, no FIB lookup, no strategy consultation, no outbound Interest sent. The packet came in, found its answer in the cache, and went home. Total time: single-digit microseconds.
Because of the OnceLock<T> lazy decoding, a CS hit is even cheaper than it looks. The Interest’s Nonce and Lifetime fields were never decoded – they’re irrelevant when the answer is already sitting in the cache. The only field that mattered was the name, and that was decoded eagerly in the previous stage.
But today, we’re not so lucky. The Content Store doesn’t have /ndn/edu/ucla/cs/class cached. Action::Continue passes the Interest to the next stage.
💡 Key insight: Checking the Content Store before the PIT is a deliberate design choice. A CS hit satisfies the Interest with zero PIT state. If the PIT were checked first, we’d create (and then immediately clean up) a PIT entry for every cache hit – wasted work on what should be the fastest path.
🔄 What happens next: Cache miss. Our Interest needs to actually be forwarded. First, we need to record who’s waiting for the answer.
PIT Check: Leaving Breadcrumbs
The Pending Interest Table is the forwarder’s memory. It records which faces are waiting for which data, so that when a Data packet eventually arrives, the forwarder knows where to send it.
The PIT stage does three things for our Interest:
- Creates a PIT entry keyed by
(Name, Option<Selector>)– in our case,(/ndn/edu/ucla/cs/class, None) - Records the in-face – the UDP face our Interest arrived on. This is the breadcrumb: when Data comes back, follow this trail.
- Checks the nonce for duplicate suppression. Every Interest carries a random 4-byte nonce. If the same nonce for the same name has been seen recently from a different face, this Interest is a loop – drop it.
Our Interest has a fresh nonce, and there’s no existing PIT entry for this name. The stage creates a new entry and returns Action::Continue.
⚠️ Important: Interest aggregation happens here too. If a second Interest for
/ndn/edu/ucla/cs/classarrives (with a different nonce) while the first is still pending, the PIT stage adds a second in-record to the existing entry but does not forward the Interest again. When the Data returns, both consumers get it. This is one of NDN’s most powerful features – natural multicast without any special configuration.
🔄 What happens next: PIT entry created, breadcrumb laid. Now we need to figure out where to send this Interest.
Strategy Stage: The Forwarding Decision
This is where the forwarder’s intelligence lives. The strategy stage has two jobs: find the routes, then pick the best one.
FIB longest-prefix match. The Name trie is walked from the root, matching one component at a time: ndn -> edu -> ucla -> cs -> class. At each level, the trie follows HashMap<Component, Arc<RwLock<TrieNode>>> links. The longest matching prefix wins – maybe there’s a FIB entry for /ndn/edu/ucla pointing to two nexthops.
Strategy selection. A parallel name trie maps prefixes to Arc<dyn Strategy> implementations. The strategy for /ndn/edu/ucla might be the default BestRoute, or it might be a custom multicast strategy. The strategy receives an immutable StrategyContext – it can see the FIB entry, the PIT token, and the measurements table, but it cannot mutate global state.
Forwarding decision. The strategy returns one of:
Forward(faces)– send the Interest to these facesForwardAfter { delay, faces }– probe-and-fallback: try the primary face, and if no Data arrives withindelay, try the fallback facesNack(reason)– no route, or strategy decides to suppressSuppress– do nothing (used for Interest management)
The default BestRoute strategy selects the lowest-cost nexthop from the FIB entry. Our Interest for /ndn/edu/ucla/cs/class is enqueued on the selected outgoing face – say, another UDP face pointing toward a UCLA campus router.
The Interest leaves the forwarder. Now we wait.
🔄 What happens next: The Interest has been forwarded. Somewhere upstream, a producer (or another forwarder’s cache) will generate a Data packet. When it arrives, Act III begins.
The Packet Lifecycle at a Glance
Before we follow the Data return path, here’s the full state machine. Every packet eventually reaches one of the terminal states: Satisfied, Dropped, or Expired.
stateDiagram-v2
[*] --> Received: Raw bytes arrive on face
Received --> Decoded: TlvDecodeStage\n(LP unwrap + TLV parse)
Decoded --> Discovery: Discovery hook check
Discovery --> Consumed: Discovery packet\n(hello/probe/service)
Consumed --> [*]
Discovery --> Checked: Forwarding packet
state Checked {
[*] --> CSLookup
CSLookup --> CacheHit: Name match found
CSLookup --> PITCheck: Cache miss
PITCheck --> Duplicate: Same nonce detected
PITCheck --> Aggregated: Existing PIT out-record
PITCheck --> StrategyStage: New Interest
StrategyStage --> Forwarded: Forward to nexthop(s)
StrategyStage --> Nacked: No route / suppressed
}
CacheHit --> Satisfied: Data sent to in-face
Forwarded --> WaitingForData: PIT entry active
Duplicate --> Dropped
Nacked --> Dropped
WaitingForData --> Cached: Data arrives, CS insert
Cached --> Satisfied: Fan-out to in-record faces
WaitingForData --> Expired: PIT timeout
Satisfied --> [*]
Dropped --> [*]
Expired --> [*]
Aggregated --> [*]
Act III: The Data Returns
Closing the Loop
A Data packet for /ndn/edu/ucla/cs/class arrives on the outgoing face. A producer upstream – or another forwarder’s cache – has answered our Interest. The Data enters the pipeline through the same front door: face recv loop, mpsc channel, batch drain, fragment sieve, decode stage.
But this time, the decode stage identifies the packet as a Data (TLV type 0x06) instead of an Interest. The pipeline switches to the data path.
💡 Key insight: Interests and Data share the same inbound path through decode. The fork happens after decoding, based on the packet type. This means a single pipeline runner handles both directions – there’s no separate “data pipeline process.”
PIT Match: Following the Breadcrumbs
Remember the PIT entry we created in Act II? The Data’s name is /ndn/edu/ucla/cs/class – exactly matching our pending entry. The PIT match stage:
- Looks up the entry by name
- Collects all in-record faces – these are the consumers waiting for this Data. In our case, it’s the UDP face the original Interest arrived on. If Interest aggregation had occurred, there might be multiple faces here.
- Removes the PIT entry – its job is done
On no match, the Data is unsolicited – nobody asked for it. Unsolicited Data is dropped immediately. This is a security feature: the forwarder never forwards Data that wasn’t requested, preventing cache pollution attacks.
⚠️ Important: The PIT entry removal is atomic with the match. Between the match and the removal, no new Interest for the same name can sneak in and miss the Data. The
DashMapprovides this guarantee through its entry API.
🔄 What happens next: We have the Data and we know who’s waiting for it. But before we deliver it, we might want to verify it and cache it.
Validation: Trust but Verify
ValidationStage always runs. Security is on by default: if no SecurityManager is configured, the stage still verifies cryptographic signatures (AcceptSigned behaviour) — it just skips the certificate chain walk and namespace hierarchy check. To turn off all validation, you must explicitly set SecurityProfile::Disabled.
The SecurityProfile enum controls what the stage does:
| Profile | Behaviour |
|---|---|
Default (no SecurityManager) | Crypto-verify the signature; accept if valid. No chain walk. |
Default (with SecurityManager) | Full chain validation: trust schema + cert fetching + trust anchor check. |
AcceptSigned | Crypto-verify only; no chain walk. Explicit equivalent of the fallback above. |
Disabled | Skip all validation — every Data passes through unchecked. Must be set explicitly. |
Custom(validator) | Caller-supplied Validator with full control. |
The stage may produce three outcomes for each Data packet:
Action::Satisfy(valid) – the signature checks out; the Data is promoted toSafeDataAction::Drop(invalid signature) – cryptographic verification failed; discardAction::Pending(certificate needed) – the certificate for the signing key isn’t in the cache yet; a side-channel Interest is issued to fetch it
Pending packets are queued and re-validated by a periodic drain task once the missing certificate arrives. See the Security Model deep dive for the full certificate chain walk.
🔧 Implementation note: The
SafeDatatypestate ensures that code expecting verified data cannot accidentally receive unverified data – the compiler enforces the boundary regardless of whichSecurityProfilewas chosen.
CS Insert: Caching for the Future
The Data is inserted into the Content Store. The next Interest for /ndn/edu/ucla/cs/class – maybe from a different consumer, maybe from the same one a minute later – will get a cache hit in the CS lookup stage and never need to be forwarded at all.
#![allow(unused)]
fn main() {
let action = self.cs_insert.process(ctx).await;
self.dispatch_action(action);
}
Then comes the fan-out. The dispatch_action method sends the Data wireformat to every in-record face collected from the PIT match. Our original consumer’s UDP face receives the Data bytes. The face’s send task transmits them back to the consumer application.
The loop is closed. The student’s laptop receives the course data it asked for. The Interest left a breadcrumb trail through the PIT, the Data followed it home, and a cached copy now sits in the Content Store for the next person who asks.
📊 Performance: The Content Store stores wire-format
Bytes, not decoded structures. A future cache hit can send the cached bytes directly to a face without re-encoding. Zero-copy in, zero-copy out.
Act IV: When Things Go Wrong
The Nack Pipeline
Not every Interest gets answered with Data. Sometimes the upstream forwarder has no route, or the producer is unreachable. In these cases, a Nack arrives – a negative acknowledgment carrying the original Interest and a reason code.
When a Nack arrives for our previously forwarded Interest, the nack pipeline:
- Looks up the PIT entry by the nacked Interest’s name – the entry is still there, waiting
- Builds a
StrategyContextwith the FIB entry and measurements – the strategy needs the full picture - Asks the strategy what to do via
on_nack_erased:Forward(faces)– try alternate nexthops. The original Interest bytes are re-sent on a different face. Maybe there’s a backup route to UCLA.Nack(reason)– give up. Propagate the Nack back to all in-record consumers. The student’s application receives a Nack and can decide whether to retry.Suppress– silently drop. Used when the strategy has already initiated a retry viaForwardAfter.
💡 Key insight: The strategy’s ability to retry on alternate nexthops is what makes NDN resilient to path failures. A
BestRoutestrategy with two nexthops will try the second one when the first returns a Nack – automatic failover without any application-level retry logic.
Epilogue: The Architecture in Perspective
What we’ve traced is a single Interest-Data exchange, but the design scales to millions of concurrent flows. The key structural decisions that make this possible:
Arc<Name>– names are shared across PIT, FIB, and pipeline stages without copyingbytes::Bytes– zero-copy slicing from socket buffer through to Content StoreDashMapfor PIT – no global lock on the hot path; concurrent Interests for different names never contendOnceLock<T>for lazy decode – fields parsed only when accessed, saving CPU on cache hitsSmallVec<[NameComponent; 8]>for names – stack-allocated for typical 4-8 component names, heap-allocated only for unusually deep hierarchies- Compile-time pipeline – no virtual dispatch on the per-packet hot path; the compiler sees through every stage boundary
The pipeline processes each packet in isolation, but the shared state – PIT, FIB, CS, measurements – creates the emergent behavior that makes NDN work: Interest aggregation, multipath forwarding, ubiquitous caching, and loop-free routing. All from following a packet through a few stages.
Strategy Composition
The strategy system is how ndn-rs decides where to forward each Interest. Unlike NFD’s class hierarchy, ndn-rs strategies are composable trait objects that can be swapped at runtime – including hot-loading WASM modules without restarting the router.
The Strategy Trait
Every strategy implements the Strategy trait:
#![allow(unused)]
fn main() {
pub trait Strategy: Send + Sync + 'static {
/// Canonical name identifying this strategy (e.g., /localhost/nfd/strategy/best-route).
fn name(&self) -> &Name;
/// Synchronous fast path. Returns Some(actions) if the decision can be made
/// without async work, avoiding the Box::pin heap allocation. Returns None
/// to fall through to the async path.
fn decide(&self, ctx: &StrategyContext) -> Option<SmallVec<[ForwardingAction; 2]>>;
/// Called when an Interest arrives and needs a forwarding decision.
async fn after_receive_interest(&self, ctx: &StrategyContext) -> SmallVec<[ForwardingAction; 2]>;
/// Called when Data arrives.
async fn after_receive_data(&self, ctx: &StrategyContext) -> SmallVec<[ForwardingAction; 2]>;
/// Called when a PIT entry times out. Default: suppress.
async fn on_interest_timeout(&self, ctx: &StrategyContext) -> ForwardingAction;
/// Called when a Nack arrives. Default: suppress.
async fn on_nack(&self, ctx: &StrategyContext, reason: NackReason) -> ForwardingAction;
}
}
Key design points:
⚠️ Important: Strategies are immutable decision functions. They receive a read-only
StrategyContextand returnForwardingActionvalues – they cannot modify the FIB, PIT, or any global state. This is enforced by the borrow checker:StrategyContextcontains only shared references (&). If your strategy needs persistent state (e.g., a round-robin counter), use atomic types or interior mutability within the strategy struct itself.
- Pure decision function. A strategy reads state through
StrategyContextbut cannot modify forwarding tables directly. It returnsForwardingActionvalues and the pipeline runner acts on them. This prevents strategies from introducing subtle side effects. - Sync fast path. The
decide()method allows synchronous strategies (like BestRoute) to skip the async machinery entirely, avoiding aBox::pinallocation on every Interest. SmallVec<[ForwardingAction; 2]>return type. Most strategies produce 1-2 actions (forward to best face, optionally probe an alternative). SmallVec keeps these on the stack.
StrategyContext
Strategies receive an immutable view of the engine state:
#![allow(unused)]
fn main() {
pub struct StrategyContext<'a> {
/// The name being forwarded.
pub name: &'a Arc<Name>,
/// The face the Interest arrived on.
pub in_face: FaceId,
/// FIB entry for the longest matching prefix.
pub fib_entry: Option<&'a FibEntry>,
/// PIT token for the current Interest.
pub pit_token: Option<PitToken>,
/// Read-only access to EWMA measurements per (prefix, face).
pub measurements: &'a MeasurementsTable,
/// Cross-layer enrichment data (radio metrics, flow stats, etc.).
pub extensions: &'a AnyMap,
}
}
The context is a borrow, not an owned value – strategies cannot accidentally hold onto engine state beyond the forwarding decision. The extensions field provides an escape hatch for cross-layer data (e.g., wireless channel quality from ndn-research) without polluting the core context type.
ForwardingAction
The strategy’s output is one or more ForwardingAction values:
#![allow(unused)]
fn main() {
pub enum ForwardingAction {
/// Forward to these faces immediately.
Forward(SmallVec<[FaceId; 4]>),
/// Forward after a delay (enables probe-and-fallback patterns).
ForwardAfter { faces: SmallVec<[FaceId; 4]>, delay: Duration },
/// Send a Nack back to the incoming face.
Nack(NackReason),
/// Suppress -- do not forward (loop detected or policy decision).
Suppress,
}
}
ForwardAfter is particularly important: it enables strategies like ASF (Adaptive Smoothed RTT-based Forwarding) to send a probe on an alternative face after a short delay, falling back only if the primary face does not respond in time.
Strategy Table
The StrategyTable is a NameTrie that maps name prefixes to Arc<dyn Strategy>:
#![allow(unused)]
fn main() {
pub struct StrategyTable<S: Send + Sync + 'static + ?Sized>(NameTrie<Arc<S>>);
}
When an Interest arrives, the pipeline performs a longest-prefix match on the strategy table to find the strategy responsible for that name. This runs in parallel with the FIB lookup (which finds the nexthops) – the strategy table determines how to choose among nexthops, while the FIB determines which nexthops exist.
Operations:
lpm(name)– longest-prefix match, returns the strategy for the deepest matching prefix.insert(prefix, strategy)– register or replace a strategy at a prefix. This is how hot-swap works: insert a newArc<dyn Strategy>and all subsequent Interests under that prefix use the new strategy immediately.
🔧 Implementation note: Strategy hot-swap is lock-free for readers. The
StrategyTablestoresArc<dyn Strategy>, andinsert()atomically replaces theArc. In-flight packets that already cloned the oldArccontinue using the old strategy; new packets pick up the new one. There is no “strategy update” lock that blocks forwarding.
remove(prefix)– remove a strategy, causing Interests to fall back to a shorter prefix match (ultimately the root, where the default strategy lives).
Measurements Table
Strategies make informed decisions using the MeasurementsTable, a concurrent (DashMap-backed) table of per-prefix, per-face statistics:
#![allow(unused)]
fn main() {
pub struct MeasurementsTable {
entries: DashMap<Arc<Name>, MeasurementsEntry>,
}
pub struct MeasurementsEntry {
/// Per-face EWMA RTT measurements.
pub rtt_per_face: HashMap<FaceId, EwmaRtt>,
/// EWMA satisfaction rate (0.0 to 1.0).
pub satisfaction_rate: f32,
/// Last update timestamp (ns since Unix epoch).
pub last_updated: u64,
}
}
The MeasurementsUpdateStage in the Data pipeline updates these measurements on every satisfied Interest. RTT is computed as the difference between the Data arrival time and the PIT entry creation time, smoothed with EWMA (alpha = 0.125, matching TCP’s RTT estimation). Satisfaction rate tracks the fraction of Interests that receive Data vs. timing out.
Built-In Strategies
BestRouteStrategy. The default strategy. For each Interest, it selects the FIB nexthop with the lowest cost, excluding the incoming face (split-horizon). If measurements are available, it prefers the face with the lowest smoothed RTT among nexthops with equal cost. Simple, fast, and synchronous (uses the decide() fast path).
MulticastStrategy. Forwards every Interest to all FIB nexthops except the incoming face. Used for prefix discovery, sync protocols, and scenarios where redundant forwarding improves reliability (e.g., wireless networks with lossy links).
Strategy Filters
Strategies can be wrapped with StrategyFilter to modify their behaviour without subclassing:
#![allow(unused)]
fn main() {
pub trait StrategyFilter: Send + Sync + 'static {
fn name(&self) -> &str;
fn filter(&self, actions: SmallVec<[ForwardingAction; 2]>, ctx: &StrategyContext)
-> SmallVec<[ForwardingAction; 2]>;
}
}
A filter receives the actions produced by the inner strategy and can transform, prune, or augment them. For example, a rate-limiting filter could replace Forward with Suppress if the face is congested, without the inner strategy needing to know about congestion control.
WASM Strategies
The ndn-strategy-wasm crate enables loading forwarding strategies as WebAssembly modules at runtime:
- A WASM module exports functions matching the strategy interface (receive Interest, receive Data, timeout, nack).
- The router loads the WASM binary and wraps it in an
Arc<dyn Strategy>. - The wrapped strategy is inserted into the
StrategyTableat the desired prefix. - All subsequent Interests under that prefix are forwarded by the WASM strategy.
- To update: load a new WASM module and
insert()it at the same prefix. TheArcswap is atomic – in-flight packets continue with the old strategy; new packets pick up the new one.
This enables deploying experimental forwarding logic to production routers without recompilation, restart, or downtime.
🎯 Tip: WASM strategies are sandboxed – a buggy WASM module cannot crash the router or corrupt memory. Combined with hot-swap, this makes WASM strategies ideal for A/B testing forwarding logic in production: deploy version B at a sub-prefix, compare measurements, and roll back instantly if performance degrades.
Decision Flow
graph TD
A["Interest arrives"] --> B["Pipeline: Strategy stage"]
B --> C["StrategyTable.lpm(name)"]
C --> D{"Strategy found?"}
D -->|"No"| E["Nack: NoRoute"]
D -->|"Yes"| F["strategy.decide(ctx)"]
F --> G{"Sync result?"}
G -->|"Some(actions)"| H["Use actions directly"]
G -->|"None"| I["strategy.after_receive_interest(ctx)"]
I --> H
H --> J{"ForwardingAction type?"}
J -->|"Forward(faces)"| K["Set ctx.out_faces, continue pipeline"]
J -->|"ForwardAfter"| L["Schedule delayed forward"]
J -->|"Nack(reason)"| M["Send Nack to in_face"]
J -->|"Suppress"| N["Drop silently"]
K --> O["Dispatch to out faces"]
style A fill:#e8f4fd,stroke:#2196F3
style O fill:#e8f4fd,stroke:#2196F3
style E fill:#ffebee,stroke:#f44336
style M fill:#ffebee,stroke:#f44336
style N fill:#fff3e0,stroke:#FF9800
The sync fast path (decide()) is critical for performance: BestRouteStrategy, the most common strategy, always returns Some(actions) from decide(), meaning the async runtime is never involved in the forwarding decision for the common case.
IPC and Application Communication
The Problem: Talking to a Forwarder Shouldn’t Be This Hard
An application needs to talk to the NDN forwarder. In the reference C++ implementation (NFD), this means opening a Unix domain socket, serializing every Interest and Data packet into TLV wire format, pushing it through the kernel’s socket buffer, and deserializing on the other side. For a temperature sensor publishing one reading per second, the overhead is invisible. For a video streaming application pushing 8 KB frames at line rate, the kernel copies alone consume more CPU than the forwarding logic.
Can we do better?
The answer is yes – but “better” means different things depending on where your application lives relative to the forwarder. An application embedded in the same process has fundamentally different constraints than one running in a separate container. Rather than pick one transport and force everyone to use it, ndn-rs provides three tiers of escalating performance, with automatic negotiation so applications get the fastest transport available without changing their code.
The Three Tiers
The design follows a simple principle: the closer you are to the forwarder, the less ceremony you need. Each tier removes a layer of overhead.
flowchart LR
subgraph Tier1["Tier 1: Unix Socket"]
A1["Application"] -->|"serialize"| K1["Kernel Buffer"]
K1 -->|"deserialize"| F1["Forwarder"]
end
subgraph Tier2["Tier 2: Shared Memory"]
A2["Application"] -->|"memcpy into slot"| SHM["SPSC Ring Buffer<br/>(POSIX shm)"]
SHM -->|"read slot"| F2["Forwarder"]
end
subgraph Tier3["Tier 3: In-Process"]
A3["Application"] -->|"Arc pointer"| CH["mpsc Channel"]
CH -->|"Arc pointer"| F3["Forwarder"]
end
style Tier1 fill:#f9f0e8,stroke:#d4a574
style Tier2 fill:#e8f4e8,stroke:#74d474
style Tier3 fill:#e8e8f9,stroke:#7474d4
Tier 1: Unix Socket (~2 us per packet)
The baseline. An application connects to the router’s IPC socket (/run/nfd/nfd.sock on Unix, \\.\pipe\ndn on Windows) and sends TLV-encoded NDN packets over a stream. The IpcFace type abstracts over the platform differences – Unix domain sockets on Linux and macOS, Named Pipes on Windows – so application code is identical everywhere.
The control channel is always a socket, even when the data plane uses a faster transport. Management commands (create face, register prefix, query status) flow over this socket using the NFD management protocol: the application sends an Interest for a name like /localhost/nfd/rib/register/<parameters> and receives a Data packet containing a ControlResponse.
NDNLPv2 framing. External forwarders (NFD, yanfd/ndnd) use NDNLPv2 (LP) framing on all their Unix socket faces — they reject bare TLV packets silently. ForwarderClient and MgmtClient therefore wrap all outgoing packets in a minimal LpPacket (type 0x64) before sending, and strip LP from received packets via strip_lp. The wrapping is idempotent — packets that are already LP-wrapped pass through unchanged. ndn-fwd (our own forwarder) accepts both LP and bare TLV, so this is compatible in all deployment configurations.
📊 Performance. Unix sockets are fast for control traffic, but each packet crosses the kernel twice (send buffer, receive buffer) and requires at least one context switch. At high packet rates (100K+ pps), the kernel copy cost dominates. For a 1 KB Interest, the round trip through the kernel adds roughly 1.5-2 us on Linux.
This tier works everywhere and requires no special setup. It is the automatic fallback when shared memory is unavailable.
Tier 2: Shared Memory Ring Buffer (~200 ns per packet)
The performance tier for cross-process communication. Instead of pushing packets through the kernel, the application and forwarder share a POSIX shm_open region containing two lock-free SPSC (single-producer, single-consumer) ring buffers – one for each direction.
The SHM region layout is carefully designed around cache-line alignment to avoid false sharing between the producer and consumer cores:
flowchart TB
subgraph HDR["Header — 7 cache lines · 448 bytes"]
direction LR
h0["Magic\nCapacity\nSlot Size"] ~~~ h1["a2e Tail\napp writes →"] ~~~ h2["a2e Head\n← engine reads"] ~~~ h3["e2a Tail\nengine writes →"] ~~~ h4["e2a Head\n← app reads"] ~~~ h5["a2e Parked\nengine flag"] ~~~ h6["e2a Parked\napp flag"]
end
subgraph A2E["App → Engine Ring (capacity × slot_stride)"]
direction LR
a0["Slot 0\nlen · payload"] ~~~ a1["Slot 1\nlen · payload"] ~~~ a2[" ··· "] ~~~ aN["Slot N-1\nlen · payload"]
end
subgraph E2A["Engine → App Ring (capacity × slot_stride)"]
direction LR
e0["Slot 0\nlen · payload"] ~~~ e1["Slot 1\nlen · payload"] ~~~ e2[" ··· "] ~~~ eN["Slot N-1\nlen · payload"]
end
HDR ~~~ A2E
HDR ~~~ E2A
style HDR fill:#f0f4ff,stroke:#4a74c9
style A2E fill:#fff0f0,stroke:#c94a4a
style E2A fill:#f0fff0,stroke:#4ac94a
Each ring has 256 slots by default, each holding up to 8960 bytes (enough for any standard NDN packet). The producer writes a 4-byte length prefix followed by the packet payload into the next slot, then advances the tail index with a Release store. The consumer reads the slot when head != tail, using an Acquire load to ensure it sees the complete write. No locks, no CAS loops – just atomic loads and stores on cache-line-separated indices.
Wakeup without busy-waiting. When the consumer has drained the ring and has nothing to do, it needs to sleep efficiently. The implementation uses named FIFOs (pipes) integrated into Tokio’s epoll/kqueue event loop. Before sleeping, the consumer sets a parked flag in shared memory with SeqCst ordering. The producer checks this flag after each write – if the consumer is parked, it writes a single byte to the wakeup pipe. This conditional wakeup avoids a pipe syscall on every packet (the common case at high throughput is that the consumer is already spinning) while still integrating cleanly with Tokio’s async runtime.
💡 Key insight. The spin-then-park protocol gives the best of both worlds. At high packet rates, the consumer never touches the pipe – it spins for 64 iterations (sub-microsecond) and finds the next packet waiting. At low packet rates, it parks on the pipe and wakes up with zero latency via epoll/kqueue. The
SeqCstfences on the parked flag guarantee that the producer never misses a sleeping consumer.
⚠️ Important. Because the wakeup pipes are opened
O_RDWRby both sides (to avoid the FIFO blocking-open problem), EOF detection alone cannot tell the application that the engine has crashed. TheForwarderClientsolves this with a disconnect monitor on the control socket – see the ForwarderClient section below.
Tier 3: In-Process InProcFace (~20 ns per packet)
When the forwarder runs as a library inside the application process (the embedded mode), there is no serialization boundary at all. InProcFace is a pair of tokio::sync::mpsc channels – one for each direction. The application gets an InProcHandle; the forwarder gets the InProcFace. Sending a packet is a pointer handoff through the channel; the Bytes value (which is reference-counted) moves without copying.
#![allow(unused)]
fn main() {
// Create a linked face pair with 64-slot buffers
let (face, handle) = InProcFace::new(FaceId(1), 64);
// Application sends an Interest to the forwarder
handle.send(interest_bytes).await?;
// Forwarder receives it (in the pipeline runner)
let pkt = face.recv().await?;
}
This mode is ideal for mobile applications (Android/iOS) where the forwarder and application are in the same process anyway, and for testing where you want to spin up a full forwarding pipeline without any OS-level resources.
🔧 Implementation note. The
face_rxreceiver insideInProcFaceis wrapped in aMutexto satisfy the&selfrequirement of theFacetrait. This looks like it could be a contention point, but the pipeline’s single-consumer contract means the mutex never actually contends – only one task ever callsrecv().
ForwarderClient: Connecting to the Forwarder
Applications don’t pick a transport tier manually. The ForwarderClient handles negotiation automatically.
sequenceDiagram
participant App as Application
participant RC as ForwarderClient
participant Sock as Unix Socket
participant Router as ndn-fwd
App->>RC: ForwarderClient::connect("/run/nfd/nfd.sock")
RC->>Sock: Connect to face socket
Sock->>Router: New IPC connection
Note over RC: Try SHM data plane
RC->>Router: faces/create {Uri: "shm://app-<pid>-0"}
Router-->>RC: {FaceId: 5}
RC->>RC: SpscHandle::connect("app-<pid>-0")
Note over RC: SHM ready -- Unix socket becomes control-only
App->>RC: register_prefix("/myapp")
RC->>Router: rib/register {Name: "/myapp", FaceId: 5}
Router-->>RC: OK
App->>RC: send(interest_bytes)
RC->>Router: [via SHM ring buffer]
Router-->>RC: [Data via SHM ring buffer]
RC-->>App: data_bytes
The connection flow works like this:
-
Connect to the control socket.
ForwarderClient::connect()opens anIpcFaceto the router’s face socket. This socket handles management commands for the lifetime of the connection. -
Attempt SHM upgrade. The client generates a unique name (
app-<pid>-<counter>) and sends afaces/createcommand with URIshm://<name>. If the router supports SHM and the creation succeeds, the client callsSpscHandle::connect()to attach to the shared memory region. The control socket becomes a dedicated management channel. -
Fall back gracefully. If SHM setup fails (unsupported platform, permission error, feature not compiled in), the client logs a warning and reuses the Unix socket for both control and data traffic. The application’s
send/recvcalls work identically either way.
#![allow(unused)]
fn main() {
// Automatic SHM negotiation (preferred)
let client = ForwarderClient::connect("/run/nfd/nfd.sock").await?;
// Explicit Unix-only mode (skip SHM attempt)
let client = ForwarderClient::connect_unix_only("/run/nfd/nfd.sock").await?;
// Check which transport was negotiated
if client.is_shm() {
println!("Using shared memory data plane");
}
}
Prefix registration follows the NFD management protocol. register_prefix() sends an Interest for /localhost/nfd/rib/register with ControlParameters encoding the prefix name and the face ID (the SHM face ID if using SHM, or 0 to default to the requesting face). The router installs a FIB entry pointing the prefix at the application’s face.
Disconnect detection is subtle in SHM mode. Because the data plane reads from shared memory, the application cannot detect router death from a failed recv() – the SHM region persists after the router process exits. The ForwarderClient spawns a background monitor task (automatically, on the first recv() call) that watches the control socket. When the socket closes, the monitor fires a CancellationToken that propagates to the SpscHandle, causing its recv() to return None and send() to return Err(Closed).
🔧 Implementation note. The
NdnConnectionenum inndn-appunifies embedded and external connections behind a single interface.ConsumerandProducerwork with either mode, so application code does not need to know whether it’s talking to an in-process engine or an external router.
Chunked Transfer: Beyond the MTU
NDN packets have an 8800-byte MTU – a networking constraint that makes sense for router forwarding but is awkward for IPC, where payloads can be megabytes. The chunked transfer layer handles segmentation and reassembly transparently.
ChunkedProducer takes a name prefix and a Bytes payload, and splits it into fixed-size segments (8192 bytes by default, safely under the MTU). Each segment is identified by its zero-based index:
#![allow(unused)]
fn main() {
let payload = Bytes::from(large_buffer);
let producer = ChunkedProducer::new(prefix, payload, NDN_DEFAULT_SEGMENT_SIZE);
// Serve segments in response to Interests
for i in 0..producer.segment_count() {
let segment_data: &Bytes = producer.segment(i).unwrap();
// Build Data packet with name: /<prefix>/seg=<i>
}
}
ChunkedConsumer reassembles segments that may arrive out of order. You tell it how many segments to expect (from the FinalBlockId in the first Data packet), feed it segments as they arrive, and call reassemble() when complete:
#![allow(unused)]
fn main() {
let mut consumer = ChunkedConsumer::new(prefix, segment_count);
// Segments can arrive in any order
consumer.receive_segment(2, seg2_bytes);
consumer.receive_segment(0, seg0_bytes);
consumer.receive_segment(1, seg1_bytes);
if consumer.is_complete() {
let original: Bytes = consumer.reassemble().unwrap();
}
}
The beauty of chunked transfer over NDN is that the Content Store does the heavy lifting. Once a producer has published all segments, it can exit. Subsequent consumers fetching the same named payload get every segment from the CS cache without the producer being involved. This is fundamentally better than pipes or traditional IPC – the producer can be a batch job that ran once, and consumers retrieve the result later.
📊 Performance. Reassembly allocates a single
BytesMutwith the total size pre-computed, then copies each segment in order. For a 1 MB payload this is one allocation and 128 memcpys of 8 KB each – essentially memcpy speed.
Service Registry: Finding Each Other by Name
NDN’s name-based architecture naturally supports service discovery. The ServiceRegistry provides a simple mechanism for applications to advertise named services and find each other.
Services register under the /local/services/<name> namespace:
/local/services/<name>/info– aServiceEntrycontaining an application-defined capabilities blob/local/services/<name>/alive– a heartbeat Data packet with a shortFreshnessPeriod
Discovery is a single CanBePrefix Interest for /local/services – the Content Store returns all registered service descriptors.
#![allow(unused)]
fn main() {
let mut registry = ServiceRegistry::new();
// Advertise a service
registry.register("video-encoder", capabilities_bytes);
// Discover a service
if let Some(entry) = registry.lookup("video-encoder") {
// entry.capabilities contains the service's descriptor
}
}
The elegance of this design is in what happens when a service exits. The engine closes its face, removing the FIB entries that routed Interests to it. The heartbeat Data in the CS expires naturally (its FreshnessPeriod runs out and it is evicted). No deregistration protocol, no central registry daemon, no single point of failure. The NDN namespace is the service registry.
The MgmtClient also provides programmatic access to the router’s service discovery subsystem through the NFD management protocol:
#![allow(unused)]
fn main() {
let mgmt = MgmtClient::connect("/run/nfd/nfd.sock").await?;
// Announce a service prefix
mgmt.service_announce(&"/myapp/service".parse()?).await?;
// Browse all known services (local and from peers)
let services = mgmt.service_browse(None).await?;
// Withdraw when shutting down
mgmt.service_withdraw(&"/myapp/service".parse()?).await?;
}
💡 Key insight. NDN IPC is strongest when you need at least one of: discovery without prior knowledge of the producer, data that outlives the producer, data consumed by multiple processes without producer scaling, or seamless transparency between local and remote data sources. For processes that know at compile time they will communicate and do not need any of these properties, a direct
tokio::sync::mpscchannel is simpler and faster.
Putting It All Together
The IPC stack forms a clean layered architecture. At the bottom, transport faces (IpcFace, ShmFace, InProcFace) move bytes. In the middle, ForwarderClient and NdnConnection handle transport negotiation and provide a unified send/recv interface. At the top, Consumer, Producer, ChunkedProducer/ChunkedConsumer, and ServiceRegistry provide application-level abstractions.
An application that starts with Consumer::connect("/run/nfd/nfd.sock") gets SHM-accelerated data transfer, automatic prefix registration, and transparent chunked reassembly – without knowing or caring about any of the machinery described in this page. And if that application later moves into the same process as the forwarder (say, for a mobile deployment), only the connection setup changes. The rest of the code stays identical.
That is the goal: NDN’s name-based decoupling applied all the way down to the transport layer itself.
Discovery Protocols
The Problem: Finding Your Neighbors in a Content-Centric Network
When an ndn-fwd starts up, it knows nothing about its neighbors. It has faces configured – maybe a UDP socket, maybe a raw Ethernet interface – but no idea who else is out there. In IP networking, you’d configure static routes or run BGP. In NDN, the question is different: you don’t need to know addresses, you need to know who has the content your consumers want.
Discovery in ndn-rs solves this in three layers, each building on the one below. First, find your neighbors. Then, learn what content they serve. Finally, wire it all together so Interests flow to the right place automatically. The first two layers are fully implemented; the third builds on their foundation.
💡 Key insight: Discovery in NDN is fundamentally about content reachability, not host reachability. “Node B is alive” is useful, but “Node B serves
/app/video” is what actually populates your FIB and makes forwarding work.
The Discovery Trait: Plugging Into the Engine
All discovery protocols share a common interface. The DiscoveryProtocol trait lets the engine call into any protocol implementation at well-defined points – when faces come up, when packets arrive, and on periodic ticks:
#![allow(unused)]
fn main() {
trait DiscoveryProtocol: Send + Sync {
fn protocol_id(&self) -> ProtocolId;
fn claimed_prefixes(&self) -> &[Name];
fn tick_interval(&self) -> Duration;
fn on_face_up(&self, face_id: FaceId, ctx: &dyn DiscoveryContext);
fn on_face_down(&self, face_id: FaceId, ctx: &dyn DiscoveryContext);
fn on_inbound(&self, raw: &Bytes, incoming_face: FaceId, meta: &InboundMeta,
ctx: &dyn DiscoveryContext) -> bool;
fn on_tick(&self, now: Instant, ctx: &dyn DiscoveryContext);
}
}
🔧 Implementation note: The engine calls
on_inboundafter TLV decode but before the forwarding pipeline. If a discovery protocol returnstrue, the packet is consumed and never enters the Interest/Data pipeline. This keeps discovery traffic invisible to applications – they never see hello packets or browse requests cluttering their content streams.
Multiple protocols run simultaneously via CompositeDiscovery, which fans out every callback to each registered protocol. This is how neighbor discovery and service discovery coexist without knowing about each other.
Layer 1: Finding Your Neighbors
The Hello Protocol
Imagine two ndn-fwds, A and B, sitting on the same Ethernet segment. Neither knows the other exists. Here’s how they find each other.
Node A’s HelloProtocol fires its periodic tick and sends a multicast hello Interest to /ndn/local/nd/hello/<nonce>. The nonce is random – it ensures the Interest isn’t satisfied by a cached reply. Node B receives this Interest, recognizes it as a hello, and responds with a Data packet containing a HelloPayload:
- Node name – B’s NDN identity (e.g.,
/ndn/router-b) - Served prefixes – prefixes B can route (when
InHellomode is active) - Neighbor diffs – recent gossip about other nodes B knows about
When the hello Data arrives back at A, the protocol measures the round-trip time and feeds it into an EWMA estimator (alpha = 0.125, matching TCP’s RTO algorithm). Node A now knows Node B exists, how fast the link is, and potentially what content B serves.
sequenceDiagram
participant A as Node A
participant B as Node B
Note over A,B: Initial Discovery
A->>B: Hello Interest\n/ndn/local/nd/hello/<nonce1>
B->>A: Hello Data\n(node name, prefixes, neighbor diffs)
Note over A: Measure RTT, update EWMA\nState: Probing → Established
Note over A,B: Periodic Probing
loop Every tick interval
A->>B: Probe Interest\n/ndn/local/nd/probe/direct/<B>/<nonce>
B->>A: Probe Data (ACK)
Note over A: Update last_seen, RTT
end
Note over A,B: Liveness Timeout
A->>B: Probe Interest (no reply)
Note over A: timeout exceeded\nState: Established → Stale
A->>B: Unicast Hello (retry)
B->>A: Hello Data
Note over A: State: Stale → Established
Note over A,B: Peer Failure
A--xB: Probe Interest (no reply)
Note over A: miss_count >= threshold\nState: Stale → Absent
Note over A: Remove faces, withdraw FIB entries\nGossip removal to other peers
One Protocol, Many Link Types
The hello exchange works the same conceptually over UDP and Ethernet, but the details differ – UDP hellos are signed with Ed25519, Ethernet hellos are unsigned (the MAC layer provides implicit authentication on a local segment). Rather than duplicating the state machine, ndn-rs uses a generic HelloProtocol<T> parameterized over a LinkMedium:
pub type UdpNeighborDiscovery = HelloProtocol<UdpMedium>;
pub type EtherNeighborDiscovery = HelloProtocol<EtherMedium>;
The LinkMedium trait handles everything link-specific:
| Method | Purpose |
|---|---|
build_hello_data() | Build signed hello reply (Ed25519 for UDP, unsigned for Ethernet) |
handle_hello_interest() | Extract source address, create peer face |
verify_and_ensure_peer() | Verify signature, ensure unicast face exists |
send_multicast() | Broadcast on all multicast faces |
on_face_down() / on_peer_removed() | Link-specific cleanup |
🔧 Implementation note: The generic parameter is resolved at compile time, so
HelloProtocol<UdpMedium>andHelloProtocol<EtherMedium>are fully monomorphized – no dynamic dispatch on the hot path of hello processing.
SWIM: Gossip-Based Failure Detection
Simple periodic probing has a weakness: if Node A can’t reach Node B, is B actually dead, or is there just a transient link problem between A and B? If A immediately declares B dead, it might tear down perfectly good FIB entries for no reason.
This is where SWIM comes in. The core idea behind SWIM-style failure detection is that nodes don’t just check their own neighbors – they ask others to check too. It’s gossip-based: “Hey C, I can’t reach B. Can you reach B? Let me know what you find out.”
When swim_indirect_fanout > 0, the protocol works in three phases:
- Direct probes – on every tick, A sends a probe Interest to each established neighbor via
/ndn/local/nd/probe/direct/<target>/<nonce>. - Indirect probes – if a direct probe to B times out, A picks K other established neighbors and asks them to probe B on its behalf:
/ndn/local/nd/probe/via/<intermediary>/<target>/<nonce>. - Gossip piggyback – recent neighbor additions and removals are piggybacked onto hello Data payloads, bounded to 16 entries per packet. This is how information about topology changes propagates without dedicated announcement traffic.
sequenceDiagram
participant A as Node A
participant C as Node C (intermediary)
participant B as Node B (suspect)
Note over A,B: Direct probe fails
A->>B: Probe /ndn/local/nd/probe/direct/<B>/<nonce>
Note over A: Timeout — no reply from B
Note over A,C: SWIM indirect probe
A->>C: Indirect Probe\n/ndn/local/nd/probe/via/<C>/<B>/<nonce>
C->>B: Probe /ndn/local/nd/probe/direct/<B>/<nonce2>
alt B is alive
B->>C: Probe Data (ACK)
C->>A: Indirect Probe Data (alive)
Note over A: B is reachable via C\nKeep Established state
else B is truly dead
Note over C: Timeout — no reply from B
C->>A: Indirect Probe Nack (unreachable)
Note over A: Confirmed failure\nState → Stale / Absent
end
🌐 Protocol detail: The indirect probe mechanism means a single transient link failure between A and B won’t cause a false positive. Only when multiple nodes independently confirm that B is unreachable does A move B to the Absent state. This dramatically reduces unnecessary face teardowns and FIB churn in real networks.
Adaptive Probing
The probing interval isn’t fixed. A neighbor that just responded to a hello doesn’t need to be probed again immediately, but one that missed a probe deserves closer attention. The NeighborProbeStrategy trait (with implementations ReactiveStrategy, BackoffStrategy, PassiveStrategy, and CompositeStrategy) adapts the probing rate based on events:
on_probe_success(rtt)– received a reply, may back off probing frequencyon_probe_timeout()– missed a reply, may probe more aggressivelytrigger(event)– external events likeFaceUp,NeighborStale, orForwardingFailurecan force immediate probing
⚠️ Important: The adaptive probing strategies are composable.
CompositeStrategylayers multiple strategies together, so you can combine aBackoffStrategy(exponential backoff on success) with aReactiveStrategy(immediate probe on forwarding failure) to get both efficiency and responsiveness.
The Neighbor Lifecycle
As hello and probe traffic flows, each neighbor transitions through a well-defined lifecycle. Think of it as a trust progression: a node starts as unknown, becomes a tentative contact, graduates to a confirmed neighbor, and might eventually be declared unreachable.
stateDiagram-v2
[*] --> Probing : First hello sent
Probing --> Established : Hello Data reply received\n(RTT measured, FIB populated)
Established --> Stale : Liveness timeout exceeded\n(unicast hello + gossip sent)
Stale --> Established : Hello Data reply received
Stale --> Absent : miss_count ≥ liveness_miss_count\n(faces removed, FIB withdrawn)
Absent --> [*] : Entry removed
Each transition tells a story:
Probing to Established. A hello Data reply arrives from a new neighbor. The protocol records the RTT, updates the neighbor state, and creates a face binding. If InHello mode is active, any served prefixes advertised in the payload are immediately populated into the FIB – the neighbor is reachable and already telling us what content it has.
Established to Stale. On each tick, the protocol checks last_seen against liveness_timeout. When the timeout is exceeded, two things happen simultaneously: a unicast hello is sent directly to the stale neighbor’s face (maybe it just missed the multicast), and emergency gossip hellos are sent to K other established peers. The gossip accelerates convergence – if B is down, A wants C and D to know about it quickly.
Stale to Absent. When miss_count >= liveness_miss_count, the neighbor is declared unreachable. This triggers a cascade of cleanup: link-specific teardown fires via on_peer_removed, all associated faces and FIB entries are removed, and a DiffEntry::Remove is queued for gossip propagation so the entire network learns about the departure.
⚠️ Important: The neighbor table is engine-owned, not protocol-owned. This means it survives protocol swaps at runtime and can be shared across multiple simultaneous discovery protocols. If you replace
UdpNeighborDiscoverywith a custom protocol, the neighbors already discovered over Ethernet are still there.
The Neighbor Table
The neighbor table stores the state for every known peer:
#![allow(unused)]
fn main() {
pub struct NeighborEntry {
pub node_name: Name,
pub state: NeighborState,
/// Per-link face bindings: (face_id, source_mac, interface_name)
/// A peer may be reachable over multiple interfaces simultaneously.
pub faces: Vec<(FaceId, MacAddr, String)>,
pub rtt_us: Option<u32>, // EWMA RTT
pub pending_nonce: Option<u32>,
}
}
Mutations go through NeighborUpdate variants (Upsert, SetState, AddFace, RemoveFace, UpdateRtt, Remove) applied via DiscoveryContext::update_neighbor. This ensures all state changes are atomic and auditable – no partial updates to a neighbor entry.
Layer 2: What Content Do They Have?
Once you know your neighbors, the next question is: what content do they have? Knowing that Node B exists and has a 2ms RTT is useful, but knowing that Node B serves /app/video is what actually lets you forward Interests to the right place.
The ServiceDiscoveryProtocol handles this second layer. It runs alongside neighbor discovery inside the same CompositeDiscovery, handling service record publication, browsing, and demand-driven peer queries.
sequenceDiagram
participant P as Producer
participant R as Router
participant C as Consumer
Note over P,R: Service Announcement
P->>P: publish ServiceRecord\nprefix=/app/video, owner=face3
Note over R,P: Browse Phase
R->>P: Browse Interest\n/ndn/local/sd/services/ (CanBePrefix)
P->>R: Browse Data\n(ServiceRecord: /app/video)
Note over R: Auto-populate FIB:\n/app/video → face to P
Note over C,R: Consumer discovers via Router
C->>R: Browse Interest\n/ndn/local/sd/services/ (CanBePrefix)
R->>C: Browse Data\n(relayed ServiceRecord: /app/video)
Note over C: Auto-populate FIB:\n/app/video → face to R
Note over C,P: Normal forwarding now works
C->>R: Interest /app/video/frame/1
R->>P: Interest /app/video/frame/1
P->>R: Data /app/video/frame/1
R->>C: Data /app/video/frame/1
Announce, Browse, Withdraw
The lifecycle of a service record follows a simple pattern:
Publish. A producer registers a ServiceRecord containing an announced prefix, node name, freshness period, and capability flags. Records can be published with an owner face – when that face goes down, the record is automatically withdrawn. No orphaned advertisements cluttering the network.
Browse. The protocol sends browse Interests to /ndn/local/sd/services/ with CanBePrefix to all established neighbors. Peers respond with Data packets containing their local service records. This is how a router learns what each of its neighbors serves.
Withdraw. Calling withdraw(prefix) removes the local record. Peer records are evicted when the associated face goes down or when the auto-FIB TTL expires. The system is self-cleaning – if a producer disappears without explicitly withdrawing, the records expire on their own.
🌐 Protocol detail: When
relay_recordsis enabled, incoming service records from one neighbor are relayed to all other established neighbors (excluding the source face). This provides multi-hop service discovery without network-wide flooding – records propagate outward through the neighbor graph, one hop at a time.
The Peer List
Any node can express an Interest for /ndn/local/nd/peers to receive a snapshot of the current neighbor table as a compact TLV list:
PeerList ::= (PEER-ENTRY TLV)*
PEER-ENTRY ::= 0xE0 length Name
This is useful for monitoring tools and for applications that want to make topology-aware decisions without implementing their own discovery protocol.
The Payoff: FIB Auto-Population
Discovery isn’t just about knowing who’s there – it’s about knowing how to reach content. Every piece of discovery information ultimately exists to answer one question: “if an Interest arrives for prefix X, which face should I forward it to?”
When a service record Data arrives from a peer, the protocol automatically installs a FIB entry routing the announced prefix through that peer’s face:
#![allow(unused)]
fn main() {
struct AutoFibEntry {
prefix: Name,
face_id: FaceId,
expires_at: Instant,
node_name: Name,
}
}
💡 Key insight: Auto-FIB entries have a TTL and are expired by
on_tick. The browse interval adapts to be half the shortest remaining TTL, ensuring records are refreshed before they expire. A 10-second floor prevents excessive traffic. This means the FIB is always fresh without manual configuration – the network is self-organizing.
Here’s the full picture of how discovery feeds the forwarding plane:
- Neighbor discovery establishes that Node B is reachable on face 7 with 2ms RTT.
- Service discovery learns that Node B serves
/app/videoand/app/chat. - FIB auto-population installs entries:
/app/video-> face 7,/app/chat-> face 7. - An Interest arrives for
/app/video/frame/42. The FIB lookup matches/app/video, and the Interest is forwarded out face 7 – all without a single line of manual configuration.
When Node B eventually disappears (its face goes down, or its neighbor entry transitions to Absent), the auto-FIB entries are withdrawn, and the FIB returns to its previous state. The network heals itself.
Layer 3: Network-Wide Routing (Planned)
The current discovery layers provide link-local neighbor detection and one-hop (or relayed) service discovery. This is sufficient for many deployments – a local mesh of ndn-fwds can fully self-organize using just these two layers.
Network-wide routing will build on this foundation, using the neighbor table as the link-state database input. The gossip infrastructure (SVS gossip, epidemic gossip) in ndn-discovery provides the dissemination substrate. The vision is a system where discovery scales seamlessly from a two-node setup to a continent-spanning network, with the same protocols operating at every level.
Runtime Configuration
The HelloProtocol parameters can be tuned while the router is running without a restart. All fields are stored in an Arc<RwLock<DiscoveryConfig>> shared between the running protocol and the management handler.
| Parameter | Default | Description |
|---|---|---|
hello_interval_base | 5 s | Minimum hello period (backoff starts here) |
hello_interval_max | 20 s | Maximum hello period after full back-off |
liveness_miss_count | 3 | Missed hellos before a neighbor turns Stale |
gossip_fanout | 2 | Neighbors contacted per gossip tick |
swim_indirect_fanout | 2 | SWIM indirect probe targets (0 = disable probing) |
The management socket exposes these via:
# Read current values
/localhost/nfd/discovery/status (status dataset)
# Apply new values (URL query string in ControlParameters.Uri)
/localhost/nfd/discovery/config (command)
# e.g. Uri = "hello_interval_base_ms=3000&gossip_fanout=3"
The ndn-dashboard Fleet panel provides a GUI for these controls, including preset profiles (Static, LAN, Campus, Mobile, HighMobility).
See Also
- Routing Protocols — how discovery feeds the RIB via DVR
- Implementing a Discovery Protocol — developer guide
- Fleet and Swarm Security — trust bootstrap for discovered neighbors
Routing Protocols
ndn-rs separates the Routing Information Base (RIB) from the Forwarding Information Base (FIB). Routing protocols compute paths and write them into the RIB; the engine’s FIB is derived automatically.
Architecture
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Static │ │ DVR │ │ (future) │
│ Protocol │ │ Protocol │ │ NLSR / … │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ origin=255 │ origin=127 │ origin=128
└──────────────────┴──────────────────►│
▼
┌──────────────┐
│ RIB │ per-prefix,
│ (ndn-engine)│ per-face, best-cost
└──────┬───────┘
│ apply_to_fib()
▼
┌──────────────┐
│ FIB │ longest-prefix match
└──────────────┘
Multiple protocols run concurrently. Each owns routes under a unique origin value. The RIB arbitrates: for each prefix, per face_id, it picks the route with the lowest cost (ties broken by lowest origin value) and writes the result to the FIB atomically.
Route Origins
| Origin | Constant | Protocol |
|---|---|---|
| 0 | origin::APP | App-registered via management API |
| 64 | origin::AUTOREG | Auto-registration |
| 65 | origin::CLIENT | Client auto-registration |
| 66 | origin::AUTOCONF | Auto-configuration |
| 127 | origin::DVR | Distance Vector Routing (ndn-routing) |
| 128 | origin::NLSR | NLSR-compatible routing |
| 129 | origin::PREFIX_ANN | Prefix announcements |
| 255 | origin::STATIC | Static routes |
Built-in Protocols (ndn-routing)
StaticProtocol
Installs a fixed set of routes at startup and holds them until stopped. Suitable for single-hop links, testing, and hybrid deployments.
#![allow(unused)]
fn main() {
use ndn_routing::{StaticProtocol, StaticRoute};
use ndn_transport::FaceId;
let proto = StaticProtocol::new(vec![
StaticRoute {
prefix: "/ndn/edu/ucla".parse()?,
face_id: FaceId(3),
cost: 10,
},
]);
// register with EngineBuilder::routing_protocol(proto)
}
Routes use origin::STATIC (255) and CHILD_INHERIT flags, so /ndn/edu/ucla/cs is also reachable without explicit registration.
DvrProtocol
Distributed Bellman-Ford over NDN link-local multicast. Routes are learned from neighbors and expire if not refreshed (default TTL: 90 s). Features:
- Split horizon: routes learned via face F are not re-advertised on face F, preventing two-node loops.
- Periodic updates every 30 s.
- Face-down cleanup: all routes learned via a downed face are withdrawn immediately.
DVR needs both packet I/O (via discovery context) and RIB write access (via routing handle). It implements both DiscoveryProtocol and RoutingProtocol — register it with both systems:
#![allow(unused)]
fn main() {
use ndn_routing::DvrProtocol;
use ndn_discovery::DiscoveryProtocol;
use std::sync::Arc;
let dvr = DvrProtocol::new(my_node_name.clone());
let engine = EngineBuilder::new()
.discovery(Arc::clone(&dvr) as Arc<dyn DiscoveryProtocol>)
.routing_protocol(Arc::clone(&dvr))
.build()
.await?;
}
DVR Wire Format
Advertisements are sent as NDN Interest packets with AppParams:
Interest name: /ndn/local/dvr/adv
AppParams TLV:
DVR-UPDATE (0xD0)
NODE-NAME (0xD1) — sender's NDN node name
ROUTE* (0xD2) — zero or more routes
PREFIX (0xD3) — Name TLV
DVR-COST(0xD4) — big-endian u32
Packets with this name are consumed by on_inbound and never reach the forwarding pipeline.
Compatibility with ndnd DVR
The ndn-rs DVR is not interoperable with ndnd’s dv module. ndnd uses a fundamentally different architecture:
| ndn-rs DVR | ndnd DV | |
|---|---|---|
| Sync mechanism | Periodic broadcast Interest | SVS v3 state-vector sync |
| Name prefix | /ndn/local/dvr/adv | /localhop/<network>/32=DV/32=ADS/ACT |
| Advertisement unit | (prefix, cost) | (router, nexthop, cost) |
| Route table | prefix → cost | router → cost, then prefix → router |
| Cost infinity | u32::MAX | 16 |
| ECMP | No | Two-best-path |
| Loop prevention | Split horizon | Poison reverse + split horizon |
| Security | None | LightVerSec (Ed25519) |
For testbed interoperability, a future NLSR-compatible protocol (origin 128) is required. The ndn-rs DVR is designed for private, trust-homogeneous networks only.
RoutingManager
The engine owns a RoutingManager that controls all running protocols:
#![allow(unused)]
fn main() {
// Start a protocol
engine.routing().enable(Arc::new(my_protocol));
// Stop and flush its routes
engine.routing().disable(origin_value);
// Inspect running protocols
let origins: Vec<u64> = engine.routing().running_origins();
}
Disabling a protocol cancels its background task and synchronously flushes all its RIB routes, recomputing the FIB for affected prefixes. Any routes registered by other protocols for the same prefixes are immediately promoted.
RIB Details
The RIB stores RibRoute { face_id, origin, cost, flags, expires_at } per (prefix, face_id, origin) triple. Key invariants:
- Expiry: routes with
expires_atare drained every second by a background task. - Face teardown:
rib.handle_face_down(face_id, fib)is called automatically when a face goes down, flushing routes via that face and recomputing affected FIB entries. - FIB derivation: for each unique
face_id, the lowest-cost route across all origins wins. Equal-cost ties break by lowest origin value.
Runtime Configuration
DvrProtocol exposes two parameters that can be changed while the router is running without a restart:
| Parameter | Default | Description |
|---|---|---|
update_interval | 30 s | How often DVR broadcasts its distance vector |
route_ttl | 90 s | Expiry time for DVR-learned routes if not refreshed |
Both are stored in an Arc<RwLock<DvrConfig>> shared between the running protocol and the management handler. The management socket exposes them via:
# Read current values
/localhost/nfd/routing/dvr-status (status dataset)
# Apply new values (URL query string in ControlParameters.Uri)
/localhost/nfd/routing/dvr-config (command)
# e.g. Uri = "update_interval_ms=15000&route_ttl_ms=45000"
The ndn-dashboard Routing panel provides a GUI for these controls.
See Also
- Implementing a Routing Protocol — developer guide
- Implementing a Discovery Protocol — for protocols that need packet I/O
- PIT, FIB, and Content Store — forwarding tables overview
Sync Protocols
The Problem: Keeping Distributed Datasets in Agreement
Imagine three weather stations scattered across a valley. Each one periodically records temperature, humidity, and wind speed under its own NDN name prefix – /weather/station-north/temp/42, /weather/station-east/humidity/17, and so on. Every station needs every other station’s data. In the IP world, you’d build a database replication layer on top of TCP connections, manage reconnections, handle ordering, and worry about consistency. You’d essentially be reinventing a distributed database.
In NDN, the network already knows how to fetch named data. The missing piece is much narrower: each node needs to learn which names exist on other nodes. Once it knows a name exists, it can fetch the data using ordinary Interests. That’s exactly what sync protocols do – they are lightweight set-reconciliation protocols that tell each node “here are the names I have that you don’t.”
This turns out to be a profound simplification. Sync doesn’t move data. It moves knowledge about data, and lets NDN’s native Interest/Data exchange handle the rest.
ndn-rs ships two sync protocols in the ndn-sync crate: State Vector Sync (SVS) for small groups with frequent updates, and Partial Sync (PSync) for large namespaces with sparse changes. Both share a common SyncHandle API so applications can switch protocols without changing their code.
State Vector Sync: The Simple Protocol
SVS is the easiest sync protocol to understand because it works exactly the way you’d explain it on a whiteboard. Every node maintains a state vector – a table mapping each known node to the highest sequence number it has seen from that node.
The Data Structure
At the core is SvsNode, which wraps a HashMap<String, u64> behind a Tokio RwLock. Each entry maps a node’s name (as a canonical string key) to its latest known sequence number:
State Vector for station-north:
/weather/station-north -> seq 42 (that's us)
/weather/station-east -> seq 17 (last we heard)
/weather/station-south -> seq 31 (last we heard)
The node starts at sequence zero. Each time the local application publishes new data, it calls advance(), which atomically increments the local sequence number.
The Protocol
The SVS protocol loop is elegant in its simplicity. Every node periodically multicasts a Sync Interest that carries its entire state vector encoded in the Interest name. When a peer receives it, it compares the remote vector against its own. Any entry where the remote sequence number is higher than the local one represents a gap – data the local node hasn’t seen yet.
sequenceDiagram
participant A as Station North
participant B as Station East
participant C as Station South
Note over A: State: {N:42, E:15, S:31}
Note over B: State: {N:40, E:17, S:31}
Note over C: State: {N:42, E:17, S:33}
A->>B: Sync Interest {N:42, E:15, S:31}
A->>C: Sync Interest {N:42, E:15, S:31}
Note over B: N:42 > 40 → gap (41..42)<br/>E:15 < 17 → no gap<br/>S:31 = 31 → no gap
B->>B: Fetch /weather/station-north/seq/41
B->>B: Fetch /weather/station-north/seq/42
Note over C: N:42 = 42 → no gap<br/>E:15 < 17 → no gap<br/>S:31 < 33 → no gap
B->>A: Sync Interest {N:42, E:17, S:31}
Note over A: E:17 > 15 → gap (16..17)
A->>A: Fetch /weather/station-east/seq/16
A->>A: Fetch /weather/station-east/seq/17
The merge operation is the heart of the protocol. SvsNode::merge() takes a received state vector and returns a list of (node_key, gap_from, gap_to) tuples. Each tuple says: “this peer published data from sequence gap_from to gap_to that we haven’t seen.” The local vector is updated in the same operation, so duplicate detections are impossible.
Key design detail: The state vector is encoded on the wire as
<fnv1a-hash:8><seq:8>pairs – 16 bytes per node. For a group of 50 nodes, that’s 800 bytes, which fits comfortably in a single Interest packet. This is why SVS works well for small-to-medium groups but struggles as group size grows into the hundreds.
Wire Format
Sync Interests follow the naming convention /<group-prefix>/svs/<encoded-state-vector>. The state vector component is a binary blob of concatenated hash-sequence pairs. A jitter of 0–200ms is added to the one-second sync interval to prevent Interest collisions when nodes start simultaneously.
When the local application publishes new data, the protocol doesn’t wait for the next tick. It immediately increments the sequence number and sends a Sync Interest, so peers learn about the new data within milliseconds rather than waiting up to a full interval.
Partial Sync: The Scalable Protocol
SVS has an elegant simplicity, but it carries a fundamental cost: every Sync Interest must encode the entire state vector. As the number of publishers grows to thousands or the namespace becomes very large, the vector exceeds what fits in a name component. PSync solves this with a technique borrowed from database reconciliation: Invertible Bloom Filters (IBFs).
Invertible Bloom Filters
An IBF is a probabilistic data structure that can encode a set of elements in a fixed amount of space, regardless of the set size. More importantly, when you subtract one IBF from another, the result encodes the symmetric difference between the two sets – exactly the elements that one side has and the other lacks.
Each cell in the IBF stores three fields:
xor_sum– XOR of all element hashes mapped to this cellhash_sum– XOR of a secondary hash of each element (for verification)count– number of elements mapped to this cell
A cell is “pure” – meaning it holds exactly one element – when count is +1 or -1 and the hash_sum verifies against the xor_sum. The decoding algorithm repeatedly finds pure cells, extracts their element, and removes it from the filter. If all cells empty out, the decode succeeded.
Why the hash_sum check? Without it, two elements could cancel each other’s
xor_sumbut leavecount == 1, producing a false positive. The independenthash_sumcatches this: the probability that bothxor_sumandhash_sumhappen to match a valid element when they shouldn’t is vanishingly small.
The Protocol
PSync’s protocol flow has two phases: the requester sends an IBF in a Sync Interest, and the responder subtracts it from their local IBF, decodes the difference, and replies with the hashes the requester is missing.
sequenceDiagram
participant A as Node A
participant B as Node B
Note over A: Local set: {h1, h2, h3}<br/>Build IBF_A
Note over B: Local set: {h1, h3, h4, h5}<br/>Build IBF_B
A->>B: Sync Interest with IBF_A
Note over B: diff = IBF_B - IBF_A<br/>decode → A missing: {h4, h5}<br/> B missing: {h2}
B->>A: Sync Data: [h4, h5]
Note over A: SyncUpdate for h4<br/>SyncUpdate for h5
A->>A: Fetch data for h4, h5
The PSyncNode maintains a local HashSet<u64> of name hashes. When reconciling, it builds a fresh IBF from its set, subtracts the peer’s IBF, and decodes the result. The default configuration uses 80 cells with k=3 hash functions, which reliably handles set differences of up to about 40 elements. If the difference is too large for the IBF to decode, the operation returns None and the node falls back to a larger IBF or full enumeration.
Wire Format
Sync Interests use the name /<group-prefix>/psync/<ibf-encoded>, where the IBF is encoded as <xor_sum:8><hash_sum:8><count:8> triples – 24 bytes per cell. With the default 80 cells, the IBF component is 1,920 bytes. This is fixed regardless of whether the local set contains 10 elements or 10,000.
Sync Data replies carry concatenated 8-byte hashes of names the requester is missing. The requester emits a SyncUpdate for each hash, which the application uses to fetch the actual named data.
Name hashing uses FNV-1a over concatenated component values, with a 0xFF separator between components to prevent ambiguity (so /a/bc and /ab/c hash to different values).
When to Use Which
The choice between SVS and PSync comes down to group size and update frequency:
| Characteristic | SVS | PSync |
|---|---|---|
| Wire overhead per Interest | 16 bytes x nodes | 24 bytes x IBF cells (fixed) |
| Scales with | Group size (linear) | Set difference (fixed overhead) |
| Sweet spot | <100 nodes, frequent updates | Large namespaces, sparse updates |
| Detects | Exact sequence gaps | Set differences (no ordering) |
| Typical use | Chat rooms, IoT sensor groups, small clusters | Content distribution, routing tables, large pub/sub |
Use SVS when the group is small and updates are frequent. SVS gives you exact sequence numbers, so you know precisely which publications you missed and can fetch them in order. A chat application with 10 participants, a sensor network with 50 devices, or a coordination protocol among a handful of edge routers are all natural fits.
Use PSync when the namespace is large but the set difference at any given moment is small. A content distribution network with thousands of named objects, a link-state routing protocol exchanging LSA hashes, or a package repository synchronizing its catalog across mirrors – these all benefit from PSync’s fixed-size IBF encoding.
Integration with ndn-rs
The SyncHandle API
Both protocols expose the same SyncHandle type, which provides two operations: receiving notifications about new remote data, and announcing local publications. The handle owns a cancellation token; dropping it (or calling leave()) cleanly shuts down the background sync task.
flowchart LR
App["Application"]
SH["SyncHandle"]
BG["Background Task<br/>(svs_task / psync_task)"]
Net["Network<br/>(send/recv channels)"]
App -- "publish(name)" --> SH
SH -- "publish_tx" --> BG
BG -- "Sync Interests/Data" --> Net
Net -- "incoming packets" --> BG
BG -- "update_tx" --> SH
SH -- "recv() → SyncUpdate" --> App
Joining a Sync Group
To join an SVS group, provide the group prefix, your local name, and a pair of channels for sending and receiving packets:
#![allow(unused)]
fn main() {
use ndn_sync::{SvsConfig, join_svs_group};
let handle = join_svs_group(
"/ndn/svs/chat".parse().unwrap(), // group prefix
"/ndn/svs/chat/alice".parse().unwrap(), // local name
send_tx, // mpsc::Sender<Bytes> for outgoing packets
recv_rx, // mpsc::Receiver<Bytes> for incoming packets
SvsConfig::default(),
);
}
PSync is similar but doesn’t require a local name (the node is identified by its IBF contents):
#![allow(unused)]
fn main() {
use ndn_sync::{PSyncConfig, join_psync_group};
let handle = join_psync_group(
"/ndn/psync/catalog".parse().unwrap(),
send_tx,
recv_rx,
PSyncConfig::default(),
);
}
Publishing and Subscribing
Once you have a handle, the pattern is the same for both protocols:
#![allow(unused)]
fn main() {
// Announce a new publication
handle.publish("/ndn/svs/chat/alice/msg/42".parse().unwrap()).await?;
// Receive notifications about remote publications
while let Some(update) = handle.recv().await {
println!("New data from {}: {} (seq {}..{})",
update.publisher, update.name, update.low_seq, update.high_seq);
// Fetch the actual data using ordinary Interests...
}
}
The SyncUpdate struct tells you who published, the name prefix to fetch under, and (for SVS) the sequence range you missed. For PSync, where there’s no inherent ordering, low_seq and high_seq are both zero – the update simply tells you a new name hash appeared.
Connecting to InProcFace
In a typical ndn-rs deployment, the send/recv channels connect to an InProcFace. The application registers the sync group prefix with the router, and the InProcFace forwards matching Interests to the recv channel while the send channel pushes outgoing Interests through the router’s forwarding pipeline. The sync protocol itself is oblivious to the transport – it just reads from and writes to mpsc channels.
Configuration knobs: Both
SvsConfigandPSyncConfiglet you tune the sync interval (default 1 second), jitter range (default 200ms), and notification channel capacity (default 256). PSync additionally exposesibf_size(default 80 cells). Increasing the IBF size allows larger set differences to decode successfully, at the cost of larger Sync Interests.
Leaving a Group
The SyncHandle implements Drop, so simply dropping the handle cancels the background task. For explicit cleanup, call handle.leave(), which consumes the handle and cancels the task immediately.
Security Model
The Problem: Bolted-On vs. Built-In Security
In IP networking, security is an afterthought. TLS secures the channel between two endpoints, but the data itself has no inherent protection. Once a TLS session terminates at a CDN or cache, the original security guarantee evaporates. You trust the server, not the data.
NDN flips this entirely. Every Data packet is signed at birth, and the signature travels with the data forever. A cached copy served by a router three hops away is exactly as trustworthy as one delivered directly by the producer – the signature is over the content, not the channel. This is a profound architectural advantage, but it creates challenges that don’t exist in IP security:
- Key discovery is a networking problem. A Data packet says “I was signed by key
/sensor/node1/KEY/k1” – but that key’s certificate is itself an NDN Data packet that must be fetched over the network. - Trust is not transitive by default. Just because a signature is cryptographically valid doesn’t mean you should trust it. Which keys are authorized to sign which data? The answer requires policy, not just cryptography.
- Verification has a cost. Ed25519 verification is fast, but doing it for every packet on a high-throughput forwarder adds up. Local applications on the same machine shouldn’t pay that cost.
ndn-rs addresses all three challenges through a layered design: trust schemas define policy, certificate chain validation handles key discovery, and the SafeData typestate makes the compiler enforce that unverified data never reaches code that expects verified data.
flowchart LR
subgraph Producer
App["Application"] --> Sign["Sign with<br/>Ed25519 / HMAC"]
Sign --> Data["Signed Data Packet<br/>(signature is part of the wire format)"]
end
Data --> Network
subgraph Network["Network (routers, caches)"]
R1["Router"] -->|"forward + cache"| R2["Router"]
end
Network --> Validate
subgraph Consumer["Consumer / Forwarder"]
Validate["Validate signature"] --> Schema["Check trust schema"]
Schema --> Chain["Walk certificate chain"]
Chain --> Safe["SafeData"]
end
style Safe fill:#2d7a3a,color:#fff
The signature is embedded in the packet’s wire format. Routers can cache and forward the packet without breaking it. Any consumer, anywhere in the network, can independently verify the signature without contacting the original producer.
The Journey of a Data Packet
To understand how these pieces fit together, follow a Data packet arriving at a forwarder with profile = "default" (forwarder-level validation opted in).
A temperature reading arrives. The packet’s name is /sensor/node1/temp/1712400000, and its SignatureInfo field says it was signed by key /sensor/node1/KEY/k1. The raw bytes are sitting in a buffer. At this point, it’s just a Data – an unverified blob.
First question: does the policy allow this? Before touching any cryptography, the forwarder consults its trust schema. The schema has a rule saying data under /sensor/<node>/<type> must be signed by /sensor/<node>/KEY/<id>. The forwarder pattern-matches: <node> captures node1 in both the data name and the key name. The captures are consistent, so the schema allows this combination. If the key name had been /other-org/KEY/k1, the schema would reject immediately – no crypto needed.
Next: find the certificate. The key name /sensor/node1/KEY/k1 points to a certificate, which in NDN is just another Data packet containing the signer’s public key. The forwarder checks its CertCache first. On a cache hit, it already has the public key bytes and can proceed. On a miss, it sends a normal Interest for /sensor/node1/KEY/k1 – the certificate flows through the same Interest/Data machinery as any other content, and gets cached in the Content Store for future lookups.
Verify the signature. With the public key in hand, the forwarder runs Ed25519 verification over the packet’s signed region (everything from the Name through the SignatureInfo). If the signature doesn’t check out, the packet is rejected.
But who signed the certificate? The certificate for /sensor/node1/KEY/k1 is itself a signed Data packet. Maybe it was signed by /sensor/KEY/root. The forwarder walks up the chain: fetch that certificate, verify its signature, check its issuer, and so on – until it reaches a trust anchor (a self-signed certificate the forwarder was configured to trust at startup). If the chain exceeds a configurable maximum depth, or if a cycle is detected, validation fails.
The packet becomes SafeData. If the entire chain checks out, the Data is wrapped in a SafeData struct. From this point forward, the type system guarantees that this data has been verified. Code that expects SafeData literally cannot receive unverified data – it won’t compile.
flowchart LR
Data["Data Packet\n/sensor/node1/temp\nSigned by /sensor/node1/KEY/k1"]
--> SchemaCheck{"Trust schema\nallows\n(data, key)?"}
SchemaCheck -->|No| Reject["REJECT\nSchemaMismatch"]
SchemaCheck -->|Yes| CacheHit{"In\nCertCache?"}
CacheHit -->|Yes| VerifySig1["Verify signature\nwith cert pubkey"]
CacheHit -->|No| Fetch["Fetch cert\nvia Interest"]
Fetch -->|"Not found"| Pending["PENDING\n(retry later)"]
Fetch -->|Found| VerifySig1
VerifySig1 -->|Invalid| RejectSig["REJECT\nBadSignature"]
VerifySig1 -->|Valid| IsAnchor{"Issuer is\ntrust anchor?"}
IsAnchor -->|Yes| VerifyAnchor["Verify cert\nwith anchor key"]
IsAnchor -->|No| DepthCheck{"depth <\nmax_chain?"}
DepthCheck -->|No| RejectDeep["REJECT\nChainTooDeep"]
DepthCheck -->|Yes| CacheHit2{"Fetch issuer\ncert (cached?)"}
CacheHit2 -->|Yes| VerifySig1
CacheHit2 -->|No| FetchIssuer["Fetch issuer\nvia Interest"]
FetchIssuer --> VerifySig1
VerifyAnchor -->|Valid| Accept["ACCEPT\nSafeData ✓"]
VerifyAnchor -->|Invalid| RejectAnchor["REJECT\nBadSignature"]
style Accept fill:#2d7a3a,color:#fff
style Reject fill:#8c2d2d,color:#fff
style RejectSig fill:#8c2d2d,color:#fff
style RejectDeep fill:#8c2d2d,color:#fff
style RejectAnchor fill:#8c2d2d,color:#fff
style Pending fill:#8c6d2d,color:#fff
The result of validation is one of three outcomes:
#![allow(unused)]
fn main() {
pub enum ValidationResult {
/// Signature valid, chain terminates at a trust anchor.
Valid(Box<SafeData>),
/// Signature invalid or trust schema violated.
Invalid(TrustError),
/// Missing certificate -- needs fetching.
Pending,
}
}
The Pending state is important: because certificates are fetched over the network, validation can be asynchronous. A forwarder may need to pause validation, send an Interest for a missing certificate, and resume when the certificate arrives.
How Producers Sign Data
On the other side of the equation, a producer needs to create a cryptographic identity and attach signatures to outgoing Data packets.
KeyChain in ndn-security is the single entry point for NDN security in both applications and the forwarder:
#![allow(unused)]
fn main() {
use ndn_security::KeyChain;
// Ephemeral identity (tests, short-lived producers) — in-memory only
let keychain = KeyChain::ephemeral("/sensor/node1")?;
let signer = keychain.signer()?;
// Persistent identity — generates on first run, reloads on subsequent runs
let keychain = KeyChain::open_or_create(
std::path::Path::new("/var/lib/ndn/sensor-id"),
"/sensor/node1",
)?;
let signer = keychain.signer()?;
}
ndn-app re-exports KeyChain from ndn-security, so use ndn_app::KeyChain works too.
The SignWith extension trait provides a synchronous one-liner for signing a packet builder without spawning an async task — useful in closures and non-async contexts:
#![allow(unused)]
fn main() {
use ndn_security::SignWith;
use ndn_packet::encode::DataBuilder;
let wire = DataBuilder::new("/sensor/node1/temp".parse()?, b"23.5°C")
.sign_with_sync(&*signer)?; // returns Bytes directly
}
Under the hood, signing is handled by the Signer trait. Both traits in the security layer (Signer and Verifier) use BoxFuture for dyn-compatibility, so they can be stored as Arc<dyn Signer> in the key store and swapped at runtime:
#![allow(unused)]
fn main() {
pub trait Signer: Send + Sync + 'static {
fn sig_type(&self) -> SignatureType;
fn key_name(&self) -> &Name;
fn cert_name(&self) -> Option<&Name> { None }
fn public_key(&self) -> Option<Bytes> { None }
fn sign<'a>(&'a self, region: &'a [u8])
-> BoxFuture<'a, Result<Bytes, TrustError>>;
/// CPU-only signers (Ed25519, HMAC) override this to
/// avoid async overhead.
fn sign_sync(&self, region: &[u8]) -> Result<Bytes, TrustError>;
}
}
ndn-rs ships two signer implementations:
| Algorithm | Signer | Signature Size | Use Case |
|---|---|---|---|
| Ed25519 | Ed25519Signer | 64 bytes | Default for all Data packets |
| HMAC-SHA256 | HmacSh256Signer | 32 bytes | Pre-shared key authentication (~10x faster) |
| BLAKE3 | Blake3Signer | 32 bytes | High-throughput; experimental type code 6 (not yet in NDN Packet Format spec) |
Both implement sign_sync for a CPU-only fast path – no async state machine overhead when the operation is pure computation.
DataBuilder Signing Methods
DataBuilder exposes several signing methods with different performance and conformance characteristics:
| Method | Allocations | Crypto | NDN conformant | When to use |
|---|---|---|---|---|
sign_digest_sha256() | 1 | SHA-256 in-place | Yes | Default for all high-throughput production |
sign_sync(type, kl, fn) | 2 | caller-supplied | Yes | Ed25519 / HMAC — synchronous callers |
sign(type, kl, fn).await | 3+ | caller-supplied | Yes | Ed25519 / HMAC — async callers |
sign_none() | 1 | None | No | Benchmarking raw engine throughput only |
build() | ~4 | None (zeroed SigValue) | Partial | Tests / non-validating consumers |
sign_digest_sha256 fast-path details
The fast path achieves its performance by pre-computing all TLV sizes before any allocation, writing every field directly into a single BytesMut, and hashing &buf[signed_start..] in-place:
1 BytesMut::with_capacity(total_size)
├─ Data TLV header
├─ Name TLV ┐
├─ MetaInfo TLV │ signed region — hashed in-place, no copy
├─ Content TLV │
├─ SignatureInfo (5B) ─┘
└─ SignatureValue (34B = type+len+SHA256)
Known limitations (not currently addressable)
-
No
no_stdsupport. The fast path usesringfor SHA-256, which requires the standard library.no_stdcallers must usebuild()and sign the packet externally. Tracking: if aring-compatibleno_stdSHA-256 is adopted in future, the#[cfg(feature = "std")]gate can be lifted. -
No KeyLocator in DigestSha256. The SignatureInfo bytes are hardcoded to
[0x16, 0x03, 0x1B, 0x01, 0x00]— type, length, SignatureType=0 (DigestSha256) — with no room for a KeyLocator TLV. This covers the vast majority of uses; self-signed certificates that carry DigestSha256 + KeyLocator must usesign_syncinstead. -
debug_assertguards only. The size pre-computation is verified bydebug_assert_eq!guards that are compiled out in release builds. The math is fully deterministic (depends only on the name and content sizes, which don’t change between the compute and write phases), so this is safe — the guards exist to catch bugs during development, not to handle runtime variability. -
Duplicated encoding logic. Name/MetaInfo/Content encoding is shared via private helpers (
FastPathSizes,write_fields,put_vu) betweensign_digest_sha256andsign_none. Thesign_sync/sign/buildpaths useTlvWriter-based helpers (write_name,write_nni) instead. If a new optional field is added to MetaInfo (e.g.,ContentType), it must be added in both theTlvWriterpath and the fast-path helpers.
sign_none — benchmarking only
sign_none() produces a packet with no SignatureInfo or SignatureValue TLVs. It uses the same single-buffer fast path as sign_digest_sha256 (1 allocation, no crypto), making it the ceiling for producer throughput.
Validators will reject sign_none packets. It is only safe to use in pipelines where validation is explicitly disabled — currently, only FlowSignMode::None in ndn-iperf (selected by passing --sign none). Do not use in any production data plane.
Trust Schemas in Depth
Trust schemas are the policy layer that sits between raw cryptographic verification and actual trust. A valid signature from a stranger is meaningless; what matters is whether the signer was authorized to sign that particular data.
A schema is a collection of rules, each pairing a data name pattern with a key name pattern. Patterns use three component types:
#![allow(unused)]
fn main() {
pub enum PatternComponent {
Literal(NameComponent), // must match exactly
Capture(Arc<str>), // binds one component to a named variable
MultiCapture(Arc<str>), // binds one or more trailing components
}
}
The key insight is that capture variables must be consistent across both patterns. Consider a sensor network where temperature readings under /sensor/<node>/temp must be signed by that node’s own key:
#![allow(unused)]
fn main() {
SchemaRule {
data_pattern: NamePattern(vec![
Literal(comp("sensor")),
Capture("node"),
Capture("type"),
]),
key_pattern: NamePattern(vec![
Literal(comp("sensor")),
Capture("node"), // must match the same value as above
Literal(comp("KEY")),
Capture("id"),
]),
}
}
When a Data packet named /sensor/node1/temp arrives signed by /sensor/node1/KEY/k1, the schema matches: node captures node1 in both patterns. But if node2 tried to sign data for node1, the captures would conflict and the schema would reject the packet before any cryptographic verification occurs.
This is a lightweight but powerful mechanism. A few well-chosen rules can express policies like:
- Hierarchical trust: data and key must share the same organizational prefix
- Scope restriction: a department key can only sign data within its department
- Role-based signing: only keys under
/admin/KEY/can sign configuration updates
ndn-rs provides three built-in schemas for common cases:
TrustSchema::new()– empty, rejects everything (for strict configurations where you add rules explicitly)TrustSchema::accept_all()– wildcard, accepts any signed packet (for testing or trusted environments)TrustSchema::hierarchical()– data and key must share the same first name component; the actual hierarchy is enforced by the certificate chain walk
Text pattern format
Patterns can be written and parsed as human-readable strings. Components are
/-separated:
| Syntax | Meaning |
|---|---|
/literal | Matches exactly the component literal |
/<var> | Captures one component into var |
/<**var> | Captures all remaining components into var (must be last) |
A rule is written as <data_pattern> => <key_pattern>:
/sensor/<node>/<type> => /sensor/<node>/KEY/<id>
Parse and serialise in code:
#![allow(unused)]
fn main() {
use ndn_security::SchemaRule;
let rule = SchemaRule::parse("/sensor/<node>/<type> => /sensor/<node>/KEY/<id>")?;
println!("{}", rule.to_string());
}
Configuring rules in the router TOML
Add [[security.rule]] sections to ndn-fwd.toml. Rules are loaded at
startup and added to the active schema on top of whatever the profile setting
implies:
[security]
# "disabled" is the default — no validation, matching NFD's forwarder behaviour.
# Switch to "default" or "accept-signed" once trust anchors and keys are ready.
profile = "disabled"
trust_anchor = "/etc/ndn/ta.cert"
# Rules are appended on top of whatever the profile implies.
[[security.rule]]
data = "/sensor/<node>/<type>"
key = "/sensor/<node>/KEY/<id>"
[[security.rule]]
data = "/admin/<**rest>"
key = "/admin/KEY/<id>"
Profile defaults summary:
| Profile | Behaviour |
|---|---|
"disabled" | No validation — Data is cached and forwarded as-is (default) |
"accept-signed" | Verify signatures but skip certificate chain walking |
"default" | Full hierarchical chain validation with trust schema |
The default is "disabled" to match NFD’s behaviour: in NDN, Data validation is a
consumer-side concern. The producer signs; routers forward; consumers verify.
Enabling forwarder-level validation is an opt-in hardening measure that requires
all trust anchors and certificates to be provisioned first.
Additional [[security.rule]] entries are always appended regardless of profile.
Comparison with NFD/ndn-cxx: NFD’s forwarder does not validate Data at all —
ValidatorConfigis part of thendn-cxxapplication library, not the NFD daemon. ndn-rs matches this default (profile = "disabled") but additionally lets you opt in to forwarder-level validation, and supports runtime modification of rules without restarting — NFD’sValidatorConfigrequires a process restart to change its rules.
Runtime trust schema management API
The trust schema can be modified at runtime via NDN management commands sent to
/localhost/nfd/security/:
| Command | What it does |
|---|---|
schema-list | List all active rules with their indices |
schema-rule-add | Append one rule (pass Uri = rule string) |
schema-rule-remove | Remove rule at index (pass Count = index) |
schema-set | Replace entire schema (pass Uri = newline-separated rules) |
Using ndn-ctl (or any NFD-compatible management client):
# List the current rules
ndn-ctl security schema-list
# Add a new rule
ndn-ctl security schema-rule-add "/dept/<team>/<**rest> => /dept/<team>/KEY/<id>"
# Remove rule at index 0
ndn-ctl security schema-rule-remove 0
# Replace the whole schema (empty string rejects everything)
ndn-ctl security schema-set "/org/<**rest> => /org/KEY/<id>"
Using the Rust MgmtClient API:
#![allow(unused)]
fn main() {
use ndn_ipc::MgmtClient;
let client = MgmtClient::connect("/run/nfd/nfd.sock").await?;
// List rules
let resp = client.security_schema_list().await?;
println!("{}", resp.status_text);
// Add a rule
client.security_schema_rule_add("/sensor/<node>/<type> => /sensor/<node>/KEY/<id>").await?;
// Remove rule at index 0
client.security_schema_rule_remove(0).await?;
// Replace all rules at once
client.security_schema_set(
"/sensor/<node>/<type> => /sensor/<node>/KEY/<id>\n\
/admin/<**rest> => /admin/KEY/<id>"
).await?;
}
Changes take effect immediately for all subsequent validations. In-flight validations that have already passed the schema check are not affected.
Mutability design
The schema inside Validator is stored behind an Arc<RwLock<TrustSchema>>.
Reads (the hot validation path) acquire a shared lock for the duration of the
allows() call — typically a few microseconds for small schemas. Writes
(management API) acquire an exclusive lock, which blocks new reads momentarily
but does not affect already-in-progress pipeline tasks.
This design means the management API never requires rebuilding the validator or draining the pending queue — rules take effect atomically from the perspective of the validation path.
The Local Trust Escape Hatch
Not every Data packet needs cryptographic verification. Applications running on the same machine as the forwarder – connected via shared memory (SHM) or Unix sockets – are already authenticated by the operating system.
On Unix systems, SO_PEERCRED on a Unix socket provides the connecting process’s UID. If the forwarder trusts that UID, Data from that face skips the entire certificate chain walk:
#![allow(unused)]
fn main() {
SafeData::from_local_trusted(data, uid)
}
The resulting SafeData carries a TrustPath::LocalFace { uid } instead of TrustPath::CertChain(...), recording how trust was established. This matters for two reasons:
- Performance. Ed25519 verification, while fast, is not free. On a forwarder handling millions of local application packets per second, skipping crypto for trusted local faces is significant.
- Bootstrapping. A newly started application doesn’t have certificates yet. Local trust lets it communicate with the forwarder immediately, even before setting up its cryptographic identity.
The critical point is that the SafeData type is the same in both paths. Downstream code doesn’t need to know (or care) whether trust was established cryptographically or locally – it just receives a SafeData and knows the data has been through a trust check.
SafeData: The Compiler as Security Auditor
All of the mechanisms above converge on a single type: SafeData. This is a Data packet whose signature has been verified – either through the full certificate chain or via local trust.
#![allow(unused)]
fn main() {
pub struct SafeData {
pub(crate) inner: Data,
pub(crate) trust_path: TrustPath,
pub(crate) verified_at: u64, // nanoseconds since epoch
}
pub enum TrustPath {
/// Validated via full certificate chain.
CertChain(Vec<Name>),
/// Trusted because it arrived on a local face.
LocalFace { uid: u32 },
}
}
The pub(crate) fields are the key detail. Application code cannot construct a SafeData – only Validator::validate_chain() and SafeData::from_local_trusted() (both inside the ndn-security crate) can create one. This is the typestate pattern: the type itself encodes a security invariant.
Any API that accepts SafeData instead of Data is making a compile-time assertion: “this function only operates on verified data.” If a developer accidentally tries to pass an unverified Data packet to such a function, the code won’t compile. There’s no runtime check to forget, no boolean flag to misread, no error to swallow. The compiler is the security auditor, and it never takes a day off.
This is especially powerful in the forwarding pipeline. The Content Store insertion stage, for example, can require SafeData – guaranteeing that the cache will never serve unverified content, even if a bug elsewhere in the pipeline skips validation. The guarantee is structural, not procedural.
Identity and DID Integration
The security primitives described above — certificates, trust schemas, SafeData — are the foundation. But they answer how to verify data, not who to trust in the first place. Two higher-level layers sit above ndn-security to close that gap.
From Certificate to DID Document
ndn-security’s Certificate type is an NDN Data packet containing a public key, an identity name, a validity period, and an issuer signature. This maps directly onto a W3C DID Document: the identity name becomes the DID URI, the public key becomes a JsonWebKey2020 verification method, and the issuer signature establishes the chain of trust.
The ndn-did crate provides cert_to_did_document to perform this conversion explicitly, and name_to_did / did_to_name to translate between NDN name and DID URI forms. A Certificate issued by NDNCERT is simultaneously a valid DID Document with no additional encoding step. This means that any system in the W3C DID ecosystem — a DIF Universal Resolver driver, a Verifiable Credential verifier, a DIDComm messaging layer — can interoperate with NDN identities directly.
See Identity and Decentralized Identifiers for the full treatment: how did:ndn names are encoded, how resolution works over NDN transports, how to cross-anchor with did:web for web interoperability, and how did:key enables offline bootstrapping for factory-provisioned devices.
NdnIdentity: Identity Lifecycle Above KeyChain
ndn-security provides the low-level building blocks (KeyChain, Signer, Validator, CertCache). ndn-identity wraps KeyChain in an NdnIdentity type that adds certificate lifecycle management on top.
NdnIdentity implements Deref<Target = KeyChain>, so every KeyChain method (signer(), validator(), add_trust_anchor(), manager_arc()) is available directly on NdnIdentity without any bridging:
#![allow(unused)]
fn main() {
let identity = NdnIdentity::open_or_create(path, "/sensor/node1").await?;
// These call KeyChain methods via Deref — no indirection required
let signer = identity.signer()?;
let anchor = Certificate::decode(anchor_bytes)?;
identity.add_trust_anchor(anchor);
let validator = identity.validator();
}
Beyond KeyChain, NdnIdentity adds:
- Persistent storage — keys and certificates survive reboots via
NdnIdentity::open_or_create - Ephemeral identities —
NdnIdentity::ephemeralcreates a throw-away in-memory identity for tests - Automated NDNCERT enrollment —
NdnIdentity::provisionruns the full NDNCERT client exchange, handling token and possession challenges - Background renewal — configurable
RenewalPolicyautomatically renews certificates before they expire - DID access —
identity.did()returns thedid:ndnURI for the identity’s name without any conversion boilerplate
For most applications, NdnIdentity is the only security API they need. Direct use of Validator or CertCache is reserved for advanced scenarios like custom trust schema configuration or building a CA. When framework code needs the underlying Arc<SecurityManager>, call identity.manager_arc().
See NDNCERT: Automated Certificate Issuance for how certificate issuance works end-to-end, including the CA hierarchy, challenge types, and short-lived certificate renewal.
Why BLAKE3 (when SHA-NI is everywhere)
The BLAKE3 specification famously claims 3–8× the throughput of SHA-256. That number assumes a software implementation of SHA-256. On any CPU shipped in the last ten years — Intel Goldmont (2016) and Cannon Lake (2018) onward, AMD Zen (2017) onward, Apple M1 (2020) onward, every ARMv8.2-A and later phone — SHA-256 runs on a dedicated hardware instruction (Intel SHA-NI / ARMv8 SHA crypto extensions), and a hardware-accelerated SHA-256 hashes a few-hundred-byte buffer in roughly the same wall time as a single-threaded BLAKE3.
The empirical numbers from this project’s own bench harness on the
GitHub Actions ubuntu-latest runner make the point uncomfortably:
| Input size | SHA-256 (sha2 + SHA-NI) | BLAKE3 (single-thread, AVX2) | who wins |
|---|---|---|---|
| 100 B | ~96 ns | ~188 ns | SHA-256 +96% |
| 1 KB | ~657 ns | ~1.20 µs | SHA-256 +83% |
| 4 KB | ~2.55 µs | ~3.52 µs | SHA-256 +38% |
| 8 KB | ~5.07 µs | ~4.79 µs | BLAKE3 +6% |
For an NDN signed portion (typically a few hundred bytes to a couple KB — Name TLV + MetaInfo + Content + SignatureInfo), single-threaded BLAKE3 is slower than hardware SHA-256, not faster. So if BLAKE3 is in this project at all, “raw single-thread speed” cannot be the reason. It isn’t.
This document is the actual reason list, and the design space it opens up for ndn-rs.
What BLAKE3 has that SHA-256 does not
1. Internal Merkle tree structure
BLAKE3 hashes input in 1024-byte chunks, producing one chaining value per chunk, then combines chunks pairwise into a balanced binary tree whose root is the final 32-byte digest. Every intermediate node of that tree is itself a valid BLAKE3 output. This is the structural property that everything else on this list builds on.
SHA-256 is Merkle-Damgård: state is a single 256-bit chaining register that absorbs each 64-byte block sequentially. There is no way to address a sub-region of the input by its hash without re-hashing everything before it. The hash is a one-way streaming construction by design.
2. Verifiable streaming and partial verification
Because the BLAKE3 hash of any sub-tree is a valid BLAKE3 output, a verifier in possession of the root hash and a verification path (the sibling hashes along the path from a leaf chunk up to the root) can verify any individual chunk in isolation, without having seen the chunks before or after it.
This is the killer feature for content-centric networking. NDN
already chunks large Data into named segments (/file/v=1/seg=0,
/file/v=1/seg=1, …). With BLAKE3:
- The producer hashes all segments as the leaves of one BLAKE3 tree, then signs only the root with one ECDSA / Ed25519 / BLAKE3 signature.
- Each segment’s wire form carries its leaf-to-root verification path
(a few hundred bytes for a tree of thousands of segments —
O(log N)hashes). - A consumer that fetches segments out of order, in parallel, or skips segments it doesn’t need, can verify each one against the signed root as soon as it arrives. No “must wait for the full file” blocking.
SHA-256 cannot do this. To verify a SHA-256 signature on a multi-segment file you must have all bytes in their original order. The closest approximation is to sign each segment individually, which forces one signature per segment — orders of magnitude more public-key operations and bytes on the wire.
3. Linear-scaling parallel hashing on a single packet
The BLAKE3 reference implementation supports multi-threaded hashing
of a single buffer via the rayon feature, with throughput that
scales nearly linearly with cores up to ~16 threads. SHA-256 cannot
do this — its compression function is inherently sequential, so
hashing a 16 MB buffer takes 16× longer than hashing a 1 MB buffer
on the same core, no matter how many cores you have.
ndn-rs has the rayon feature enabled and Blake3Signer /
Blake3DigestVerifier automatically dispatch to Hasher::update_rayon
when the input crosses BLAKE3_RAYON_THRESHOLD (128 KiB, the rule-of-
thumb crossover from the blake3 docs). Per-packet signing of normal
NDN signed portions never reaches that threshold and is unaffected;
bulk content publication does.
The large/blake3-{single,rayon} and large/sha256 bench groups
exercise the crossover at 256 KB / 1 MB / 4 MB. Local numbers from a
multi-core development machine make the structural advantage concrete:
| Input size | BLAKE3 single-thread | BLAKE3 rayon | rayon speedup | SHA-256 SHA-NI | BLAKE3 rayon vs SHA-NI |
|---|---|---|---|---|---|
| 256 KB | 103 µs | 49 µs | 2.1× | 95 µs | 1.95× faster |
| 1 MB | 413 µs | 93 µs | 4.4× | 368 µs | 3.95× faster |
| 4 MB | 1.66 ms | 247 µs | 6.7× | 1.46 ms | 5.92× faster |
Three observations from the numbers:
- Single-thread BLAKE3 loses to SHA-NI at every size in the table. The narrative is identical to the per-packet bench above — SHA-256 with hardware acceleration is faster than single-thread BLAKE3 even at 4 MB. BLAKE3’s “I’m faster” story is not single-thread.
- rayon turns the loss into a 6× win by 4 MB. The crossover happens around 256 KB, and once amortised, rayon scales near-linearly with cores. SHA-NI cannot follow — there is no “SHA-NI rayon”.
- The crossover threshold matches the blake3 docs. Below ~128 KiB the rayon thread-spawn cost beats the per-byte savings; above it, rayon dominates. This is exactly why ndn-rs gates the dispatch on input size rather than always taking the rayon path.
For NDN this matters at the Content-Store insert and publication-time boundary, not the per-Interest hot path:
- A producer publishing a 1 GB Data object can compute its content digest in ~250 ms rather than ~1.6 s on a multi-core machine — a 6× wall-clock win that scales with available cores.
- A long-running ndn-fwd that ingests a large file via
ndn-putor via a sync protocol can checksum the body with whatever cores it has spare without bottlenecking on a single thread.
This is the only place where the “BLAKE3 is faster than SHA-256” claim survives in 2026: when you have many cores and an input that’s large enough to amortise the tree overhead. SHA-NI cannot follow you there; it accelerates one core at a time.
4. One algorithm: hash, MAC, KDF, XOF
BLAKE3 is also:
- Keyed mode (a fixed-time MAC) —
blake3::keyed_hash(&key, &input). Equivalent in security to HMAC-SHA-256 but with no inner/outer wrapping overhead — the key is consumed in the IV directly. - Key derivation —
blake3::derive_key(context, &key_material), domain-separated KDF replacing HKDF-Extract + HKDF-Expand. - Extendable output (XOF) —
Hasher::finalize_xof().fill(&mut buf)produces an arbitrary-length output stream from a single hash input. SHA-256 produces a fixed 256 bits and needs a second primitive (HKDF-Expand, AES-CTR, etc.) to stretch.
A SHA-256 deployment that wants the same surface needs three separately-audited primitives: SHA-256 for hashing, HMAC-SHA-256 for keyed mode, HKDF-SHA-256 for KDF. BLAKE3 collapses that to one audited primitive with one shared SIMD implementation.
For ndn-rs specifically this means:
Blake3KeyedSigner(signature type 7) is a real primitive, not a thin wrapper around two SHA-256 calls. The HMAC-SHA-256 path (signature type 4) goes throughkey⊕ipad ‖ msg → SHA-256 → key⊕opad ‖ digest → SHA-256, two compression chains; BLAKE3 keyed mode is one compression chain with the key plumbed into the IV.- A future NDNCERT or trust-bootstrapping flow could derive
per-session keys with
derive_key("ndn-rs/ndncert/v0.3", root)rather than HKDF-Extract + HKDF-Expand. - A future segmenter could use BLAKE3 XOF to derive per-segment encryption keys from a single content-publication key.
5. Smaller / simpler code on targets without a SHA extension
ndn-embedded (bare-metal no_std MCU forwarder) targets are
exactly the chips that do not have SHA-NI: Cortex-M0/M3/M4, RISC-V
microcontrollers, AVR. On those parts a software SHA-256
implementation costs around 8–12 cycles/byte. A software BLAKE3 on
the same parts is faster (3–5 cycles/byte even without SIMD) and the
single algorithm covers hash, keyed MAC, and KDF — relevant for
binary-size-constrained embedded firmware where pulling in
hkdf + hmac + sha2 separately would be expensive.
So the “BLAKE3 is faster” claim becomes true again in the segment of the deployment matrix where SHA extensions are absent. ndn-rs ships on both segments — the desktop forwarder runs on x86-with-SHA-NI; the embedded forwarder on Cortex-without-SHA-NI — and BLAKE3 is the algorithm that is well-tuned across both.
6. Constant code path across CPUs
sha2’s SHA-NI path is a different code path from its software
SSSE3 path, which is different from its ARMv8 crypto path, which is
different from its plain scalar path. Four implementations of the
same primitive, four sets of test coverage, four places a hardware
errata or compiler regression can lurk. BLAKE3’s implementation is
one SIMD code path with width-agnostic vector ops; the same code
runs on AVX2, AVX-512, NEON, and scalar fallback by varying lane
width, not by branching to a different routine. From an audit and
maintenance standpoint that’s a meaningful simplification.
How to actually speed up the BLAKE3 sign/verify pipeline in ways SHA-256 cannot
This is the design space that opens up once you accept that BLAKE3 is a tree, not a stream. Each item is something an ndn-rs deployment can do that a SHA-256 deployment fundamentally cannot. None of these exist in ndn-rs today; all of them are reachable from where the codebase is now.
A. Tree-signed segmented Data: one signature per file
Status: implemented in ndn_security::merkle; benched;
surprising results.
A producer publishing a multi-segment file (/foo/v=1/seg=0..N)
today must either (a) sign each segment Data packet individually, or
(b) put the whole file inside one giant Data and sign it once. Option
(a) is O(N) Ed25519 / ECDSA operations; option (b) breaks the NDN
chunking model and prevents partial fetch.
With a Merkle tree you can do both:
- Compute a Merkle tree over the segment Content bodies. The producer hashes each segment into a leaf, combines leaves pairwise into parent nodes, and repeats until a single 32-byte root hash emerges.
- Place the root in a single “manifest” Data packet (e.g.
/foo/v=1/_root) signed once with whatever algorithm the application prefers — Ed25519, ECDSA, BLAKE3-keyed — with a regular trust chain up to an anchor. - For each segment Data, set the SignatureValue to the segment’s
leaf hash plus its
log₂ Nsibling hashes up to the root. The KeyLocator Name points at the manifest Data. - A consumer fetches segments in any order, verifies the manifest
once via the normal cert-chain path, caches the root, and then
verifies each arriving segment with
1 + log₂ Ncheap hash operations against the cached root — no further asymmetric crypto.
The interesting question is how much this actually saves, and
whether BLAKE3 beats SHA-256 as the hash function inside the tree.
The bench in crates/engine/ndn-security/benches/merkle_segmented.rs
measures three schemes head-to-head.
Producer cost (publish a whole file)
| file / N | per-seg Ed25519 | SHA-256 Merkle | BLAKE3 Merkle |
|---|---|---|---|
| 1 MB / 256 | 4.42 ms | 0.92 ms | 1.05 ms |
| 4 MB / 1024 | 18.38 ms | 3.71 ms | 4.28 ms |
| 16 MB / 2048 | 55.59 ms | 13.89 ms | 15.95 ms |
Consumer cost (verify 10% of segments out of order)
| file / N / K | per-seg Ed25519 | SHA-256 Merkle | BLAKE3 Merkle |
|---|---|---|---|
| 1 MB / 256 / 25 | 619 µs | 112 µs | 123 µs |
| 4 MB / 1024 / 102 | 2.56 ms | 407 µs | 464 µs |
| 16 MB / 2048 / 204 | 6.00 ms | 1.39 ms | 1.57 ms |
(Apple Silicon, cargo bench --release. Absolute numbers scale
with CPU; the ratios are stable across machines.)
End-to-end through the forwarder pipeline
The numbers above measure producer/consumer costs in isolation
— encode, verify, raw primitive work. A companion bench at
binaries/ndn-bench/benches/merkle_e2e.rs wires the same three
schemes through an in-process ForwarderEngine with InProcFace
pairs and measures the 4 MB / 1024 segment / K=102 partial fetch
with the full pipeline in the loop: TLV decode on Interest
ingress, PIT insert, FIB longest-prefix lookup, BestRoute strategy,
dispatch to the producer face, the producer’s lookup-by-name
response, and a symmetric pipeline on the Data return path.
| scheme | isolated (K=102) | end-to-end | overhead | vs per-segment |
|---|---|---|---|---|
| per-segment Ed25519 | 2.56 ms | 2.52 ms | ~0 | 1.00× |
| SHA-256 Merkle | 407 µs | 540 µs | +33% | 4.67× |
| BLAKE3 Merkle | 464 µs | 571 µs | +23% | 4.42× |
Three observations:
- Per-segment Ed25519 pays almost nothing extra end-to-end. Crypto (~25 µs / verify × 102) dominates pipeline cost (~1–2 µs / packet), so the forwarder tax is in the noise.
- Merkle rows pay ~130 µs of pipeline overhead — roughly 1 µs per segment × 102 segments, which matches back-of-envelope estimates for a well-tuned NDN forwarder pipeline.
- The ~4.5× Merkle advantage survives the forwarder. The in-process 6.2× → end-to-end 4.67× ratio drop is from adding constant pipeline overhead to the Merkle row’s small denominator; the advantage is still an order of magnitude of wall-clock savings on the consumer side.
- The SHA-256 / BLAKE3 gap narrows from 14% to 6% end-to-end. Both Merkle variants pay the same forwarder overhead, so the constant forwarder cost dilutes the per-leaf hash difference. In a real deployment where the bench runs alongside actual network I/O, the gap would shrink further.
Two findings, both honest
1. The Merkle tree wins, but by ~4–5×, not 100×. My original back-of-envelope estimate predicted orders of magnitude. The actual win is real but smaller, because (a) Ed25519 is ~17 µs per sign on modern hardware, not the ~50 µs of ECDSA; (b) building a Merkle tree over 4 MB of segment content takes ~2 ms on its own; and (c) encoding 1024 segment Data packets has non-trivial allocation cost regardless of signature scheme. The honest per-segment amortized cost goes from ~17 µs to ~3–4 µs, a ~4–5× win. At 1024 segments that’s 17 ms → 3.7 ms on the producer, 2.5 ms → 0.4 ms on the consumer at K=102.
2. SHA-256 Merkle beats BLAKE3 Merkle by ~15% at NDN-typical
segment sizes — down from ~2× after the keyed_hash optimisation.
The initial Blake3Merkle implementation used the Hasher API
(Hasher::new() → update(prefix) → update(data) → finalize()),
which costs four function calls per leaf and per node plus per-call
state-setup overhead that dominates at 4 KB leaves and 64-byte
parent nodes. Switching to blake3::keyed_hash — a fused one-shot
API that takes its entire input in one call and skips the
incremental-processing state machine — closed the gap from ~2× to
~15%. The residual 15% is hardware SHA-NI doing its thing per leaf
byte, and that’s not something BLAKE3 can beat without CPU
instructions of its own. See crates/engine/ndn-security/src/ merkle.rs for the precomputed BLAKE3_LEAF_KEY / BLAKE3_NODE_KEY
derivation via Hasher::new_derive_key (done once at first use via
LazyLock).
Recommendation for v0.1.0: both hash choices are now in the
same ballpark, so pick based on cryptographic ergonomics rather
than raw speed. SHA-256 Merkle (signature type code 9,
provisional) is marginally faster (~15%) on SHA-NI hardware and is
the safe default. BLAKE3 Merkle (code 8, provisional) is
available when the surrounding application already uses BLAKE3 for
hashing / MAC / KDF / XOF and the single-primitive simplification
is worth a modest perf penalty, or when segment sizes are ≥16 KB
(the gap shrinks further) or a producer is hashing multi-GB files
with Hasher::update_rayon in parallel on many cores (where
BLAKE3 wins outright — see Section 3 above).
This is a more nuanced story than “BLAKE3 is faster at everything with a tree” (the earlier version of this page) or “SHA-256 always beats BLAKE3 at per-segment hashing” (the first version of this section after the initial bench): the right API choice for BLAKE3 closes most of the gap, and the protocol-level Merkle win is what matters regardless of hash choice.
What doesn’t change
The protocol-level argument for the Merkle approach is independent of which hash sits underneath. Both variants:
- Turn O(N) producer signatures into O(1).
- Turn O(K) consumer verifies (where K = partial fetch count) into O(1) asymmetric verify + O(K log N) cheap hashes.
- Allow out-of-order segment arrival with on-the-fly verification.
- Allow a single root signature to certify an entire file regardless of how many segments it spans.
The hash choice is a ~2× constant factor on top of that structural win. The structural win is the reason to adopt the Merkle approach; the hash choice is a tactical decision that can flip as segment sizes or hash implementations evolve.
B. Out-of-order parallel verification of segmented fetches
Status: design space.
NDN consumers fetch segments out of order routinely — pipelined
ndncatchunks, NDN sync protocols catching up after a partition,
SVS retrieving recent state. Today each Data packet must be verified
after its arrival but before it’s released to the
application. With segment-individual signatures, this is N
public-key verifies and they cannot be batched.
With tree-signed segments (item A), every segment can be verified the moment it arrives, in any order, on any thread, with no shared state between verifiers beyond an immutable copy of the manifest. Pipeline depth and core count both scale linearly. The verification tasks are embarrassingly parallel because each one needs only the segment Content, the verification path, and the root — never any other segment.
For sync protocols (PSync, SVS) that ship snapshots of large state this is a significant latency win on the receiving side.
C. Multi-thread hashing of large publications at producer time
Status: trivial to enable today via blake3 = { features = ["rayon"] }.
If the producer is the bottleneck — a sensor uploading a 100 MB log
file, an archive node ingesting historical snapshots — the BLAKE3
crate exposes Hasher::update_rayon(&data) which spreads the work
across all threads in the global rayon pool. SHA-256 cannot be
multi-threaded over a single buffer at all.
Concretely: hashing a 100 MB buffer on an 8-core CPU takes ~200 ms
with single-threaded BLAKE3 vs ~28 ms with update_rayon. That’s a
~7× speedup for free, no protocol changes, no design space — just a
crate feature flag.
This is the only one of the items in this list that ndn-rs could
adopt today with no protocol-level changes. A BlockingProducer
that hashes its content via update_rayon when the input exceeds
some threshold (say, 1 MB) costs nothing for small Data and dominates
SHA-256 for large publications.
D. Incremental verifiable updates for long-lived streams
Status: design space, longer term.
For sensor telemetry and other long-running streaming publications, a producer can maintain a BLAKE3 hasher across the lifetime of the stream and periodically (every K samples) emit a “checkpoint” Data packet containing the running root hash, signed once. Consumers catching up from any checkpoint verify forward from there using the tree’s incremental property, without re-hashing the full history.
This is more involved — the checkpoint cadence, the carrier name convention, and the backfill protocol all need design — but it’s the kind of thing that BLAKE3’s tree structure makes possible and SHA-256’s streaming structure forbids.
E. Single-primitive trust schema bootstrapping
Status: design space, smaller scope.
NDNCERT 0.3 currently uses ECDH (P-256) → HKDF-SHA-256 → AES-GCM-128
to bootstrap session keys. A future rev could replace HKDF-SHA-256
with blake3::derive_key, removing one audited primitive from the
trust path without changing the protocol’s security properties.
Smaller code, simpler audit, same guarantees.
This is the smallest item on the list and the most concrete. It
doesn’t require a protocol redesign or a new TLV type — just an
internal substitution in ndn-cert.
What stays SHA-256
For any single signed packet whose signed portion is under ~4 KB and
whose signature type is not name-based, SignatureSha256WithEcdsa
remains the right default. SHA-NI is faster than single-thread
BLAKE3 in that regime, ECDSA is well-understood by every NDN
ecosystem implementation, and the trust schema layer doesn’t care
which hash algorithm sits underneath the signature value.
The cases where BLAKE3 earns its keep are exactly the ones above: multi-segment files, large publications, multi-core hashing, and the cryptographic-surface-simplification angle on constrained-firmware targets. None of them are about beating SHA-NI at single-block hashing — that battle is over and SHA-NI won.
Appendix: streaming hash during TLV decode — investigated and rejected
A natural optimisation idea: instead of decode → hash the signed region after the fact, feed bytes to an incremental hasher during
TLV decode, so the digest is ready the moment parsing finishes.
Eliminates one byte pass over the signed region. Both sha2 and
blake3 expose Hasher::update for exactly this kind of streaming.
The hypothesis was that the second pass costs real time because the
bytes have been evicted from L1/L2 between decode and validate by
the intervening pipeline work.
The hypothesis didn’t survive contact with a micro-bench. A one-time investigation measured (a) sha2 / blake3 incremental-API overhead at NDN sizes, and (b) the cold-cache cost after evicting 2 MiB of memory between buffer setup and hash. Two results killed the idea:
Finding 1: SHA-256 cache locality is already a non-factor at NDN sizes
| size | warm Sha256::digest | post-eviction Sha256::digest | ratio |
|---|---|---|---|
| 256 B | 100 ns | 63 ns | 0.63× (cold faster!) |
| 1 KB | 392 ns | 342 ns | 0.87× |
| 4 KB | 1532 ns | 1419 ns | 0.93× |
| 16 KB | 5619 ns | 5614 ns | 1.00× |
The cold-hash measurement is the same speed or faster than the warm-hash measurement at every NDN-typical size. The post-eviction times being slightly lower is hardware-prefetch noise: the access pattern is sequential, the prefetcher predicts it perfectly, and the “cold cache” path benefits from L1 pre-population by the time the hash actually starts. Either way, the “savings from streaming SHA-256” is at most a handful of nanoseconds per packet, and is just as plausibly negative as positive.
Finding 2: BLAKE3 actively punishes the streaming pattern at large sizes
| size | warm oneshot | warm update(64-byte chunks) | overhead |
|---|---|---|---|
| 256 B | 197 ns | 211 ns | +7% |
| 1 KB | 781 ns | 820 ns | +5% |
| 4 KB | 1703 ns | 3451 ns | +103% |
| 16 KB | 6561 ns | 13785 ns | +110% |
This is the surprise. At 4 KB and 16 KB, feeding BLAKE3 in 64-byte
chunks (which is exactly what a TLV decoder would do, since most NDN
TLV bodies are tens to hundreds of bytes) is 2× slower than
calling update once on the full slice. With 256-byte chunks it’s
marginally better but still ~2× at 4 KB+.
Why: BLAKE3’s single-thread speed comes from its tree-mode SIMD
implementation processing multiple 1024-byte chunks in parallel
across SIMD lanes. When you call update(big_slice), BLAKE3 sees
the full buffer, splits it into chunks, and runs 4–16 chunks through
SIMD lanes simultaneously. When you call update(small_chunk)
repeatedly, BLAKE3 has no choice but to buffer up bytes until a full
chunk is available, then process them serially because there’s no
“next chunk” yet to fill the other SIMD lanes. The parallelism is
gone, and what’s left is the serial fallback plus per-call buffering
overhead.
So BLAKE3’s tree mode requires contiguous large updates to be fast. The current “hash the slice after decode” pattern is exactly the right shape for BLAKE3, and any move toward incremental updates would slow it down.
Conclusion
For both algorithms the verdict is the same — for opposite reasons:
- SHA-256: streaming saves nothing because there’s nothing to save. Cache locality at NDN sizes is already a no-op.
- BLAKE3: streaming actively costs ~2× at 4 KB+ because it defeats SIMD parallelism.
The current architecture — decode produces a slice, validator hashes the slice oneshot — is already optimal, not by accident
but because it matches the algorithms’ performance models. There is
no streaming-hash refactor to do. If the same idea comes up again,
re-run the bench code that lived briefly in
crates/engine/ndn-security/benches/security.rs (now removed; check
the git history for bench_streaming_feasibility) to confirm the
finding still holds on whatever hardware you care about.
Summary
| Question | Answer |
|---|---|
| Is BLAKE3 single-thread faster than SHA-256? | No, on any CPU with SHA-NI / ARMv8 SHA crypto. Yes, on every other CPU. |
| Can BLAKE3 do something SHA-256 cannot? | Yes: Merkle-tree partial verification, multi-thread hashing of one buffer, single primitive for hash/MAC/KDF/XOF. |
| Should small NDN signed packets use BLAKE3? | No. Use SignatureSha256WithEcdsa (the spec default). It’s faster on this hardware. |
| Should multi-segment NDN content trees use BLAKE3? | Yes, eventually. The protocol-level design space (item A above) is the place this project should focus future BLAKE3 work. |
Should ndn-embedded use BLAKE3 by default? | Yes. Microcontroller targets do not have SHA-NI; BLAKE3 is faster and smaller (one primitive instead of three). |
| Why ship the BLAKE3 SignatureType today? | To reserve the type-code space and keep ndn-rs interoperable with future NDN deployments that use the tree-verification design. The benchmark numbers are not the reason. |
Identity and Decentralized Identifiers
The Bootstrap Problem
NDN’s security model is cryptographically sound. Every packet is signed, every signature can be verified against a certificate chain, and the trust schema enforces that only authorized keys can sign data in a given namespace. But there is a quiet question lurking at the foundation of all of this: where does the first trust anchor come from?
When a newly deployed sensor wakes up and says “trust /sensor/factory/KEY/root”, you have to ask: who told you to trust that? If the answer is “it was burned into the firmware at manufacture time”, you are already doing identity management. You just haven’t given it a name yet.
This is the bootstrap problem, and it is not unique to NDN. The web solved it with browser-bundled root CA lists — a pragmatic but deeply centralized answer. PKI solved it with the same idea. NDN’s architecture gives us a much better tool.
NDN Names Are Already Identifiers
Here is the insight that changes everything: NDN names are not just routing labels. They are identifiers in the full sense of the word.
Consider /com/acme/alice. In IP terms, this looks like a path — something you might GET over HTTP. But in NDN, this name has a richer meaning. There is no IP address behind it. No server to connect to. The name is the identity. The certificate published at /com/acme/alice/KEY/... is the authoritative statement: “here is the public key that belongs to this identity.”
This is not a novel idea — it is just explicit about what NDN names actually are. The NDN architecture already specifies that key names have the form <identity>/KEY/<key-id>, and that certificates are signed Data packets. What we are doing with did:ndn is giving that existing structure a standard representation that the broader identity ecosystem can interoperate with.
The W3C Decentralized Identifiers (DID) specification defines exactly what NDN names already provide: a string identifier, a way to resolve it to a public key, and a mechanism for service discovery — all without depending on any central registry.
What W3C DIDs Are
A DID is a URI of the form did:<method>:<method-specific-id>. The method identifies how to resolve the identifier; the method-specific ID is opaque to the generic DID layer. Resolution yields a DID Document — a JSON-LD object containing:
- The DID itself (the
idfield) - One or more verification methods (public keys, in JsonWebKey2020 or other formats)
- References to those keys for specific relationships:
authentication,assertionMethod,keyAgreement,capabilityDelegation - Optional service endpoints (URLs, NDN prefixes, or anything else the controller wants to advertise)
The critical property is that resolution does not require a central lookup service. Each DID method defines its own resolution mechanism — DNS for did:web, the Ethereum blockchain for did:ethr, content-addressed storage for did:key. For did:ndn, resolution is an NDN Interest.
Here is a minimal DID Document for Alice:
{
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
"id": "did:ndn:com:acme:alice",
"verificationMethod": [{
"id": "did:ndn:com:acme:alice#key-0",
"type": "JsonWebKey2020",
"controller": "did:ndn:com:acme:alice",
"publicKeyJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}
}],
"authentication": ["did:ndn:com:acme:alice#key-0"],
"assertionMethod": ["did:ndn:com:acme:alice#key-0"]
}
This document says: Alice can authenticate with this Ed25519 key, and data she asserts is signed with the same key. Nothing here requires a server, a certificate authority, or a blockchain.
The did:ndn Method
The did:ndn method maps every NDN name to a DID using a single, unambiguous encoding: the base64url (no padding) of the complete NDN Name TLV, including the outer 07 <length> bytes.
did:ndn:<base64url(Name TLV)>
The method-specific identifier contains no colons — colons are not in the base64url alphabet — so there is no ambiguity between component separators and encoding markers.
NDN name: /com/acme/alice
Name TLV: 07 11 08 03 "com" 08 04 "acme" 08 05 "alice"
did:ndn: did:ndn:<base64url of the 20-byte TLV above>
NDN name (zone root, BLAKE3_DIGEST component): /<32 bytes>
Name TLV: 07 22 03 20 <32 bytes>
did:ndn: did:ndn:<base64url of the 36-byte TLV above>
This single form is lossless across all component types — GenericNameComponents, BLAKE3_DIGEST zone roots, versioned components, sequence numbers, and any future typed components — without type-specific special cases.
Note on earlier drafts: A previous version of this spec used a dual-form encoding: a “simple” colon-separated ASCII form and a
v1:binary fallback. This was found to be ambiguous: a name whose first component is literallyv1produced an identical DID string as a binary-encoded name whose base64url representation happened to begin withv1:. See did:ndn Method Specification §1.2 for details.
Converting Between Forms
The DID conversion utilities live in ndn_security::did (the ndn-did crate is a thin re-export shim for backwards compatibility):
#![allow(unused)]
fn main() {
use ndn_security::did::{name_to_did, did_to_name};
let name: Name = "/com/acme/alice".parse()?;
// Name → DID (always binary encoding)
let did = name_to_did(&name);
// result is "did:ndn:<base64url(Name TLV)>", no colons in the method-specific-id
assert!(did.starts_with("did:ndn:"));
assert!(!did["did:ndn:".len()..].contains(':'));
// DID → Name (round-trips correctly)
let recovered = did_to_name(&did)?;
assert_eq!(recovered, name);
// Zone root name (BLAKE3_DIGEST component) — same encoding, no special case
let zone_name: Name = /* from ZoneKey::zone_root_name() */;
let zone_did = name_to_did(&zone_name);
// still "did:ndn:<base64url>", no v1: prefix
}
Resolution: an NDN Interest
Resolving did:ndn:com:acme:alice means fetching the certificate at /com/acme/alice/KEY. The resolver sends an NDN Interest with CanBePrefix = true (to match any key ID under the /KEY component) and MustBeFresh = true (to avoid stale cached keys). The responding Data packet is the certificate.
The certificate is then converted to a DID Document:
#![allow(unused)]
fn main() {
use ndn_security::did::{UniversalResolver, cert_to_did_document};
use ndn_security::Certificate;
// High-level: resolve a did:ndn directly
let resolver = UniversalResolver::new();
let doc: DidDocument = resolver.resolve("did:ndn:com:acme:alice").await?;
// Low-level: convert an already-fetched certificate
let cert: Certificate = /* fetched from network or cache */;
let doc = cert_to_did_document(&cert);
}
The UniversalResolver handles multiple DID methods — did:ndn, did:web, did:key — under a single interface. Which method is used is determined by parsing the did: prefix. An application that consumes DIDs from multiple sources can use UniversalResolver without branching on method type.
Transport Independence
One of did:ndn’s most important properties is that it inherits NDN’s transport independence. Resolution sends an NDN Interest. Interests travel over any NDN face:
- UDP unicast or multicast
- Raw Ethernet (named Ethernet, IEEE 802 mac48 addressing)
- Bluetooth
- LoRa (long-range radio)
- Satellite links
- Serial / CAN bus
- WiFi in infrastructure or ad-hoc mode
- WifibroadcastNG (used in drone swarms)
There is no HTTP server to reach. There is no DNS record to look up. If the identity holder’s name is reachable over any NDN topology — even one that has no connection to the internet — the DID resolves.
This matters enormously for embedded and IoT deployments. A factory floor with no internet connection can still maintain a full DID-based identity infrastructure as long as NDN routing is configured internally. A swarm of drones with an ad-hoc mesh network between them can resolve each other’s DIDs over that mesh without any external infrastructure.
Cross-Anchoring with did:web
For systems that need to interoperate with web-based identity infrastructure — human-facing logins, OAuth clients, services that only speak HTTPS — did:ndn can be cross-anchored with did:web.
The idea is simple: publish the same public key at both a did:web endpoint and a did:ndn name, then include each as a sameAs or alsoKnownAs relation in both documents.
Alice controls:
did:ndn:com:acme:alice (resolves via NDN Interest)
did:web:alice.acme.com (resolves via HTTPS + well-known URL)
Both DID Documents refer to the same Ed25519 public key. A verifier that speaks did:web can verify Alice’s signatures without knowing anything about NDN. A verifier inside an NDN network can resolve did:ndn:com:acme:alice without HTTP.
Setting this up requires:
- Creating an
NdnIdentityfor/com/acme/alice(see below) - Serving the DID Document JSON at
https://alice.acme.com/.well-known/did.json(standarddid:webresolution path) - Including
"alsoKnownAs": ["did:ndn:com:acme:alice"]in thedid:webdocument
The key material is the same on both sides. There is no duplication of trust — just two resolution paths to the same cryptographic identity.
did:key for Offline and Embedded Devices
did:key is the simplest DID method: the public key itself is the identifier. There is no document to fetch. The DID encodes the public key bytes directly in the URI.
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK
This is ideal for factory-provisioned devices that need a stable identity before they have network connectivity. A device’s Ed25519 public key is generated at manufacture time, and the did:key derived from it is the device’s identifier — no registration step, no CA, no network required to establish the identity.
ndn-did can resolve did:key identifiers locally without any network call:
#![allow(unused)]
fn main() {
use ndn_security::did::UniversalResolver;
let resolver = UniversalResolver::new();
// Resolves instantly — the key is encoded in the DID itself
let doc = resolver.resolve(
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
).await?;
// Extract the verification method (the public key)
let vm = &doc.verification_method[0];
println!("key type: {}", vm.type_);
}
Once a did:key device enrolls with an NDNCERT CA and gets a proper namespace certificate, it transitions from did:key (offline bootstrapping) to did:ndn (full network identity). The factory credential that authorizes this transition can be a FactoryCredential::DidKey(did_key_string), which the CA verifies by checking that the enrollment request was signed with the key encoded in the DID.
Integration with ndn-security
ndn-security’s Certificate type and did:ndn are two representations of the same thing. A Certificate is an NDN Data packet containing a public key, an identity name, a validity period, and a signature from the issuer. A DID Document is a JSON-LD object containing a public key, an identity URI, and optionally a chain of trust. The mapping is direct.
cert_to_did_document performs this conversion:
#![allow(unused)]
fn main() {
use ndn_security::did::{cert_to_did_document, name_to_did};
use ndn_identity::NdnIdentity;
let identity = NdnIdentity::open_or_create(path, "/com/acme/alice").await?;
// NdnIdentity implements Deref<Target = KeyChain>, so manager_arc() is available directly
let mgr = identity.manager_arc();
let cert: Certificate = mgr.get_certificate()?;
let doc = cert_to_did_document(&cert);
// The DID in the document matches what name_to_did() would produce
assert_eq!(doc.id, name_to_did(cert.identity_name()));
}
The UniversalResolver, when resolving a did:ndn, uses ndn-security’s CertFetcher internally to retrieve the certificate over the network and then calls cert_to_did_document. This means DID resolution automatically benefits from the certificate cache, the trust schema, and the full certificate chain validation machinery.
The bridge between DID resolution and the Validator is equally clean. A resolved DidDocument can supply the trust anchor for a Validator:
#![allow(unused)]
fn main() {
use ndn_security::did::UniversalResolver;
use ndn_security::Validator;
let resolver = UniversalResolver::new();
let doc = resolver.resolve("did:ndn:com:acme:sensor-ca").await?;
// Build a Validator that trusts this DID's key as a trust anchor
let validator = Validator::builder()
.trust_anchor_from_did_document(&doc)?
.hierarchical_schema()
.build();
}
This closes the loop on the bootstrap problem. Instead of burning a raw certificate into firmware, you burn a DID. The device resolves that DID on first boot to obtain the CA’s public key, then uses that key as the trust anchor for all future certificate validation.
See Also
- NDNCERT: Automated Certificate Issuance — how devices obtain namespace certificates using NDNCERT, building on the identity foundation described here
- Security Model — the full certificate chain validation, trust schema, and
SafeDatatypestate - Fleet and Swarm Security — end-to-end walkthrough of identity management for 10,000 autonomous vehicles
- did:ndn Method Specification — the formal W3C DID method spec
NDNCERT: Automated Certificate Issuance
The Problem Without NDNCERT
Before NDNCERT, bootstrapping an NDN identity in a large deployment meant: generate a key pair, bring the public key to an administrator out-of-band, have them sign a certificate, distribute the certificate back to the device, and somehow make that certificate available in the network. For a handful of research nodes, this is manageable. For ten thousand sensors coming off a production line, it is a complete non-starter.
The web solved this problem with Let’s Encrypt: an automated CA that issues domain-validated certificates by having your server respond to a challenge. NDNCERT is Let’s Encrypt for NDN namespaces — but better suited to the diversity of NDN deployments. A Let’s Encrypt challenge requires port 80 or port 443. NDNCERT challenges run over NDN, which means they work on UDP, Ethernet, Bluetooth, and serial links. A challenge can even be an existing NDN certificate you already hold.
What NDNCERT Does
NDNCERT is a protocol for automated certificate issuance. An applicant that wants a certificate for a namespace contacts an NDNCERT CA, completes a challenge that proves it controls (or is authorized to control) that namespace, and receives a signed certificate. The whole exchange is a few Interest/Data round trips.
Three properties make NDNCERT particularly well-suited to NDN deployments:
- It runs over NDN. The protocol uses ordinary NDN Interests and Data packets. Any NDN transport works — UDP, Ethernet, Bluetooth, LoRa. No HTTP server needed.
- Certificates are short-lived by default (24h). This eliminates the need for revocation infrastructure. If a device is compromised, you stop renewing its certificate. The compromised cert expires within 24 hours with no CRL or OCSP required.
- Devices can be mini-CAs. After a vehicle or gateway receives its certificate, it can run NDNCERT itself and issue certificates for its sub-systems — ECUs, sensors, cameras — without any internet connection.
Protocol Walk-Through
The NDNCERT exchange has four messages. Here is the happy path:
Applicant CA
| |
|--- Interest /ca-prefix/INFO ---------->|
|<-- Data: CA name, challenges, policy --|
| |
|--- Interest /ca-prefix/NEW ---------->|
| (public key, desired namespace) |
|<-- Data: request-id, challenge type ---|
| |
|--- Interest /ca-prefix/CHALLENGE ----->|
| (request-id, challenge response) |
|<-- Data: status (pending/challenge) ---|
| |
| ... (may repeat for multi-round) ... |
| |
|<-- Data: status=success, certificate --|
INFO — the applicant fetches the CA’s info record. This tells it: what is the CA’s own certificate (so the applicant can verify subsequent responses), what challenge types are supported, and what namespace policy is in effect. The CA’s info record is a normal NDN Data packet, cached by the network. An applicant can discover the CA prefix via NDN discovery or configuration.
NEW — the applicant sends its public key and the namespace it wants. The CA checks the namespace against its policy and, if accepted, returns a request-id and the challenge type the applicant must complete.
CHALLENGE — the applicant sends its challenge response. Depending on the challenge type, this might be a one-time token, a possession proof, or a code from email. The CA verifies the response and either issues the certificate immediately or asks for another round (email challenges often require the user to click a link before the CA accepts).
Certificate — on success, the CHALLENGE response Data packet contains the signed certificate. The applicant installs it in its key store and is ready to sign Data packets.
Challenge Types
NDNCERT supports multiple challenge types, each suited to a different deployment scenario.
Token Challenge
A pre-provisioned one-time token is burned into the device at manufacture time. On first boot, the device presents the token as its challenge response. The CA looks up the token in its TokenStore, verifies it has not been used before, and issues the certificate.
This is Zero-Touch Provisioning (ZTP) for NDN. The factory generates tokens, burns them into firmware, and the field deployment happens automatically — no human intervention at boot time.
#![allow(unused)]
fn main() {
use ndn_cert::{TokenStore, TokenChallenge};
// At CA setup time, pre-generate tokens for 100 devices
let mut store = TokenStore::new();
store.add_many((0..100).map(|_| random_token()));
// Wire the challenge handler into the CA
let challenge = TokenChallenge::new(store);
}
#![allow(unused)]
fn main() {
// On the device, at first boot:
let config = DeviceConfig {
namespace: "/fleet/vehicle/vin-1234".to_string(),
factory_credential: FactoryCredential::Token("a3f9...".to_string()),
ca_prefix: "/fleet/ca".parse()?,
// ...
};
let identity = NdnIdentity::provision(config).await?;
}
Tokens are single-use. The TokenStore marks each token as consumed once used. If the same token is presented again (replay attack), the CA rejects it.
Possession Challenge
The applicant proves it already holds a certificate that the CA trusts. This is the right challenge for:
- Renewal — the device already has a valid (or recently expired) certificate. Presenting it proves continuity of identity.
- Sub-namespace enrollment — a vehicle with a
/fleet/vehicle/vin-1234certificate wants to enroll its brake ECU at/fleet/vehicle/vin-1234/ecu/brake. The ECU’s possession of the vehicle’s cert (or a delegation from it) authorizes the enrollment.
#![allow(unused)]
fn main() {
use ndn_cert::PossessionChallenge;
use ndn_security::Certificate;
// The CA trusts anything signed by the fleet root
let fleet_root: Certificate = load_fleet_root_cert()?;
let challenge = PossessionChallenge::new(vec![fleet_root]);
}
During the challenge exchange, the applicant signs a nonce (provided by the CA in the NEW response) with the trusted certificate’s corresponding private key. The CA verifies this signature against the trusted certificate list. Because the applicant had to sign with the private key, possession is proven — not just certificate presence.
Email Challenge (Feature-Gated)
The CA sends a verification code to an email address. The applicant retrieves the code and submits it in a subsequent CHALLENGE Interest. This is a human-in-the-loop challenge suitable for interactive enrollment of personal devices.
Enable it with the email-challenge feature flag in ndn-cert.
OAuth/OIDC (Planned)
The applicant redirects to a Google, GitHub, or enterprise OIDC provider, obtains an ID token, and presents it as the challenge response. This would allow “enroll using your GitHub account” flows for developer namespaces. The RFC for this challenge type is in progress.
CA Hierarchy and Namespace Scoping
A production NDNCERT deployment has a hierarchy of CAs, mirroring the organizational namespace hierarchy.
The root CA is offline and air-gapped. It issues certificates only to operational sub-CAs, and those issuance ceremonies happen infrequently (once per sub-CA, or during periodic rotation). The root CA’s certificate is distributed out-of-band as a trust anchor in firmware or configuration.
Operational sub-CAs handle day-to-day certificate issuance. Each sub-CA is namespace-scoped — it can only issue certificates under its own namespace prefix. A /com/acme/fleet/CA cannot issue a certificate for /com/rival/anything, even if someone asked nicely.
This scoping is enforced by the CA’s NamespacePolicy. The default is HierarchicalPolicy, which requires the requested namespace to be a suffix of the CA’s own name:
CA name: /com/acme/fleet
Allowed: /com/acme/fleet/vehicle/vin-1234
/com/acme/fleet/drone/unit-7
Not allowed: /com/acme/hr/employee/alice (different subtree)
/com/rival/vehicle/vin-9999 (different org)
For more complex cases, DelegationPolicy allows explicit rules — useful when a sub-CA needs to issue certificates in a namespace that doesn’t strictly match its own prefix.
graph TD
Root["Root CA\n/com/acme\n(offline, air-gapped)"]
Fleet["Fleet sub-CA\n/com/acme/fleet\n(online, NDNCERT)"]
HR["HR sub-CA\n/com/acme/hr\n(online, NDNCERT)"]
V1["Vehicle cert\n/com/acme/fleet/vehicle/vin-1234"]
V2["Vehicle cert\n/com/acme/fleet/drone/unit-7"]
E1["Employee cert\n/com/acme/hr/alice"]
Root -->|"issues cert for"| Fleet
Root -->|"issues cert for"| HR
Fleet -->|"issues cert for"| V1
Fleet -->|"issues cert for"| V2
HR -->|"issues cert for"| E1
Each arrow represents a certificate signature. The trust chain for /com/acme/fleet/vehicle/vin-1234 is: vehicle cert → fleet sub-CA cert → root CA cert (trust anchor). A validator with the root CA cert as its trust anchor can verify the entire chain.
Short-Lived Certificates and Revocation
The default certificate lifetime in NDNCERT is 24 hours. This is a deliberate design choice, not a limitation.
Traditional PKI spends enormous engineering effort on revocation. CRLs (Certificate Revocation Lists) are large, stale by the time they are published, and must be distributed to every relying party. OCSP (Online Certificate Status Protocol) requires an always-available responder. OCSP stapling helps but adds complexity.
With 24-hour certificates and automatic renewal, revocation becomes trivial: just don’t renew. If a vehicle is stolen or a device is compromised:
- The fleet operator marks the device’s namespace as revoked in the CA’s policy.
- The next time the device tries to renew (which it will, since certs are 24h), the CA rejects the renewal.
- Within 24 hours, the device’s certificate expires and becomes invalid.
- No CRL distribution required. No OCSP infrastructure required.
For time-sensitive revocation (e.g., an actively malicious device), the operator can additionally push a trust schema update that explicitly blocks that namespace — but in practice, the 24-hour window is short enough that this is rarely necessary.
Auto-renewal runs in the background via RenewalPolicy:
#![allow(unused)]
fn main() {
// Renew when less than 20% of the cert lifetime remains
// (i.e., renew every ~19h if the cert is 24h)
DeviceConfig {
renewal: RenewalPolicy::WhenPercentRemaining(20),
// ...
}
}
If the CA is unreachable during the renewal window, the device continues operating on its current certificate and retries renewal with exponential backoff. The cert does not expire unless the device is completely disconnected for the full 24-hour lifetime.
Devices as Mini-CAs
After a device enrolls with the fleet CA, it holds a certificate for its own namespace. Nothing stops it from running NDNCERT itself and issuing certificates for sub-namespaces — ECUs, sensors, or any other sub-systems.
A vehicle at /fleet/vehicle/vin-1234 runs its own CA. When the vehicle boots:
- The vehicle’s NDNCERT CA starts listening on the vehicle’s internal CAN bus (or Ethernet, or Bluetooth — whatever the internal network is).
- Each ECU (brake controller, lidar, GPS) sends a NEW Interest to the vehicle’s CA prefix.
- The vehicle CA issues each ECU a certificate under
/fleet/vehicle/vin-1234/ecu/<name>. - ECU enrollment is complete before the vehicle has any internet connection.
The entire sub-system identity hierarchy is established locally, signed by the vehicle’s key, which is signed by the fleet CA, which is signed by the root CA. A remote operator who trusts the root CA can verify the authenticity of data from any ECU in the fleet by walking the chain.
High Availability
NDNCERT CAs are stateless with respect to the network. The only shared state is the TokenStore (for token challenges). Everything else — the CA’s certificate, the namespace policy, the challenge handlers — is configuration, not runtime state.
This means running multiple CA replicas is straightforward. Point them all at the same TokenStore backend (backed by a distributed key-value store if needed), register them all in the FIB under the same CA prefix (/fleet/ca), and the forwarder’s load balancing distributes requests across replicas automatically. If one replica crashes, requests naturally route to the others.
For token challenges in a multi-replica setup, the TokenStore needs to be shared (so a token consumed by one replica is not reusable at another). For possession and other cryptographic challenges, there is no shared state at all — each replica independently verifies the proof.
See Also
- Setting Up an NDNCERT CA — step-by-step setup guide with full code examples
- Fleet and Swarm Security — NDNCERT in a large-scale deployment
- Identity and Decentralized Identifiers — how DID methods integrate with NDNCERT enrollment
- Security Model — the certificate chain validation that NDNCERT-issued certs participate in
Link-Layer and Wireless Faces
The Vision: NDN Doesn’t Need IP
NDN was designed around named data, not endpoint addresses. So why, when two nodes sit on the same wireless link, do we force NDN packets through an IP/UDP stack that adds 28 bytes of overhead per packet, requires ARP or NDP to resolve addresses, and imposes a protocol stack that was never designed for content-centric communication?
The answer is: we don’t have to. NDN has an IANA-assigned Ethertype (0x8624) specifically for carrying NDN packets directly on Ethernet frames. ndn-rs takes this seriously. Its link-layer face implementations bypass IP entirely, putting NDN TLV payloads directly onto the wire – or into the air – with nothing in between but the Ethernet (or radio) frame header.
graph TB
subgraph "Traditional NDN over IP"
A1[NDN TLV Packet] --> A2[UDP Header - 8 B]
A2 --> A3[IP Header - 20 B]
A3 --> A4[Ethernet Frame]
end
subgraph "ndn-rs Link-Layer Face"
B1[NDN TLV Packet] --> B2["Ethernet Frame<br/>Ethertype 0x8624"]
end
style A2 fill:#f96,stroke:#333
style A3 fill:#f96,stroke:#333
style B2 fill:#6b6,stroke:#333
This is not just about saving 28 bytes. Removing IP means removing ARP tables, NDP state machines, UDP checksum computation, socket buffer copies, and an entire address resolution protocol that is redundant when NDN names already identify content. On a constrained wireless link where every microsecond of airtime matters, this adds up.
Why this matters for wireless: 802.11 multicast and broadcast frames are sent at legacy rates (1-6 Mbps) because the MAC layer has no per-receiver rate knowledge for non-unicast frames. A 100-byte NDN Interest at 1 Mbps takes under 1 us of airtime. But those 28 bytes of IP/UDP overhead are not free – they increase frame size and, more importantly, they add an entire protocol layer that has to be processed at both ends. On battery-powered devices, CPU cycles are milliamp-hours.
Raw Ethernet: NamedEtherFace
The NamedEtherFace is the foundational link-layer face. It sends and receives NDN packets as raw Ethernet frames using the IANA-assigned Ethertype 0x8624. The implementation adapts to the host platform:
- Linux:
AF_PACKETraw sockets withSOCK_DGRAM(the kernel handles Ethernet header construction) - macOS:
PF_NDRV(Network Driver Raw) sockets - Windows: Npcap/WinPcap via the
pcapcrate
On Linux, the face uses TPACKET_V2 memory-mapped ring buffers (PACKET_RX_RING and PACKET_TX_RING) for zero-copy packet I/O. The kernel fills RX ring frames directly; userspace polls them without per-packet syscall overhead. This largely closes the throughput gap with UDP faces that benefit from GRO (Generic Receive Offload) and recvmmsg batching.
The ring geometry allocates 32 blocks of 4 KiB each (128 KiB per ring), with 2048-byte frames that comfortably fit a tpacket2_hdr (32 bytes), sockaddr_ll (20 bytes), and a full 1500-byte Ethernet payload. The face registers the socket’s file descriptor with Tokio’s AsyncFd reactor so that recv() and send() integrate naturally with the async runtime – the face task yields when no packets are available and wakes when the kernel signals readability.
Each NamedEtherFace is a unicast face bound to a specific peer. It stores the peer’s NDN node name and resolved MAC address:
#![allow(unused)]
fn main() {
pub struct NamedEtherFace {
id: FaceId,
node_name: Name, // NDN identity -- stable across channels
peer_mac: MacAddr, // resolved once at hello exchange
iface: String,
ifindex: i32,
radio: RadioFaceMetadata,
socket: AsyncFd<OwnedFd>,
ring: PacketRing, // TPACKET_V2 mmap'd RX + TX rings
}
}
MAC addresses are an implementation detail. The MAC address never surfaces above the face layer. The FIB contains entries like
(/node/B/prefix, face_id=7)– not MAC addresses. The PIT, strategy, and every pipeline stage operate purely on NDN names. Only insidesend()does the MAC appear, when constructing thesockaddr_lldestination address forsendto(). This means mobility is simple: when a peer moves, only the internal MAC binding updates. TheFaceId, FIB entries, PIT entries, and strategy state all remain valid.
Multicast Ethernet: Neighbor Discovery
The MulticastEtherFace complements the unicast NamedEtherFace. It joins the NDN Ethernet multicast group (01:00:5e:00:17:aa) on a specified interface and sends all outgoing packets to that multicast address.
Its primary role is neighbor discovery. The hello exchange works like this: a node broadcasts an Interest for /local/hello/nonce=XYZ on the multicast face at legacy rate. Responding neighbors send Data carrying their source MAC and node name. The recv_with_source() method extracts the sender’s MAC address from the TPACKET_V2 sockaddr_ll embedded in each ring frame – no extra syscall needed. Once a neighbor is discovered, the engine creates a unicast NamedEtherFace for it, and all subsequent traffic flows at full rate adaptation.
This is a cleaner resolution model than ARP in three ways: it is strictly one-hop (only directly adjacent neighbors), it is established once as a side effect of the hello exchange (not per-destination), and it is keyed on stable NDN node names rather than transient MAC-to-IP bindings.
The MulticastEtherFace also handles NDNLPv2 fragmentation transparently. When a packet exceeds the 1500-byte Ethernet MTU, it is fragmented before transmission and reassembled on the receiving end. The pipeline never sees fragments.
Wifibroadcast NG: WfbFace
Wifibroadcast NG (wfb-ng) is a radically different kind of link. It uses 802.11 monitor mode with raw frame injection to create a unidirectional broadcast link with forward error correction (FEC). There is no association, no ACK, no CSMA/CA – the entire 802.11 MAC is discarded. This makes it ideal for FPV drone video downlinks and long-range telemetry where you need to push data continuously without the overhead of bidirectional handshaking.
The catch: wfb-ng links are inherently one-way. A drone’s air unit broadcasts video on a downlink frequency; the ground station receives it. This creates a fundamental tension with NDN’s Interest-Data model, which requires Data to travel the exact reverse path of the Interest (the PIT InRecord is the only authorization for Data to flow in a direction).
The WfbFace models this asymmetry explicitly with a direction enum:
#![allow(unused)]
fn main() {
pub struct WfbFace {
id: FaceId,
direction: WfbDirection,
}
pub enum WfbDirection {
Rx, // receive-only (e.g., downlink from air unit)
Tx, // transmit-only (e.g., uplink from ground station)
}
}
A TX-only face parks its recv() task on std::future::pending() – it will never resolve, so the task simply yields forever without consuming CPU. An RX-only face rejects send() calls. This is type-safe asymmetry: you cannot accidentally try to send on a receive-only link.
FacePairTable: Bridging Asymmetric Links
The engine’s dispatcher uses a FacePairTable to pair RX and TX faces for asymmetric links. When Data needs to return on a wfb-ng RX face, the dispatcher redirects it to the paired TX face:
#![allow(unused)]
fn main() {
// In the dispatch stage, before sending Data:
let send_face_id = self.face_pairs
.get_tx_for_rx(ctx.pit_token.in_face)
.unwrap_or(ctx.pit_token.in_face); // normal faces unaffected
}
Normal (bidirectional) faces return None from the lookup and behave identically to before – the FacePairTable is a small, well-contained change that does not affect the common path.
Natural fit for named data broadcast: The drone broadcasts
/drone/video/seg=Ncontinuously on the downlink. Ground stations receive whatever segments they can; the Content Store buffers them; missed segments are re-requested on the uplink. wfb-ng’s FEC and NDN’s retransmission mechanism are complementary – FEC handles transient bit errors, NDN handles segment-level loss.
Bluetooth: BluetoothFace
The BluetoothFace targets two transport modes:
-
Bluetooth Classic (RFCOMM): On Linux, a paired RFCOMM channel appears as
/dev/rfcommN. It presents as a serial stream, so it reuses the sameStreamFace+ COBS framing pattern asSerialFace. Throughput reaches approximately 3 Mbps with 20-40 ms latency. -
BLE (L2CAP Connection-Oriented Channels): Rather than the 20-byte GATT NUS limit, L2CAP CoC provides bidirectional stream channels with negotiated MTU up to 65535 bytes (around 247 bytes in practice). NDN Interests fit comfortably; NDNLPv2 fragmentation handles larger Data packets.
The BLE mode has an interesting interaction with the Content Store. When a BLE peripheral sleeps between connection intervals (to save battery), consumers with CS hits get data without waking the peripheral at all. Battery-powered sensors only need to push data once per freshness period – the network caches the rest.
Implementation status: The
BluetoothFacestruct is defined with aFaceIdbut theFacetrait implementation awaits a Tokio-compatible RFCOMM crate (such asbluerorbtleplug). The design intent is to useStreamFace<ReadHalf<RfcommStream>, WriteHalf<RfcommStream>, CobsCodec>, making it structurally identical toSerialFacewith a different underlying transport.
Serial: SerialFace
The SerialFace carries NDN packets over UART, RS-485, and other serial links. It wraps tokio-serial as an async stream and uses tokio_util::codec::Framed with a custom CobsCodec.
#![allow(unused)]
fn main() {
pub type SerialFace = StreamFace<
ReadHalf<SerialStream>,
WriteHalf<SerialStream>,
CobsCodec,
>;
}
Opening a serial face is straightforward:
#![allow(unused)]
fn main() {
let face = serial_face_open(FaceId(1), "/dev/ttyUSB0", 115200)?;
}
Why COBS?
Serial links have no inherent framing. TCP and UDP have length-prefix framing (TCP stream + TLV length fields, UDP datagram boundaries), but a UART is just a byte stream. There is no delimiter, no packet boundary, no way to tell where one NDN packet ends and the next begins. If you lose synchronization – a common occurrence on noisy serial links – there is no recovery mechanism built into the transport.
COBS (Consistent Overhead Byte Stuffing) solves this elegantly. It encodes the payload so that 0x00 never appears in the data, making 0x00 a reliable frame delimiter. The wire format is simply:
[ COBS-encoded payload ] [ 0x00 ]
After line noise or a partial read, the decoder discards bytes until the next 0x00 and resyncs. Recovery is always at most one frame away. The overhead is minimal: at most 1 byte per 254 input bytes, roughly 0.4%.
The maximum frame length defaults to 8800 bytes (matching the NDN maximum packet size plus COBS overhead). Frames exceeding twice this limit in the decode buffer are discarded as corrupt – a safety valve against runaway input on a noisy line.
Use cases: UART sensor nodes, RS-485 multi-drop industrial buses (NDN’s broadcast-and-respond model maps naturally onto multi-drop – an Interest broadcast reaches all nodes, the node with the named data responds, and CS caching reduces bus traffic in dense polling scenarios), and LoRa radio modems (kilometer range at approximately 5.5 kbps at SF7).
Multi-Radio Architecture
On a multi-radio wireless node – say, a mesh relay with a 2.4 GHz client-facing radio and a 5 GHz backhaul radio – the routing decision (which next hop) and the radio decision (which channel and radio) are the same decision at different timescales. Traditional approaches like OLSR try to bridge them, but the bridge is always incomplete because radio state lives outside the routing protocol.
NDN unifies them because the name namespace is the coordination medium. Channel state, neighbor tables, link quality, and radio configuration are all named data. A node wanting the channel load on a neighbor’s wlan1 simply expresses an Interest for /radio/node=neighbor/wlan1/survey.
graph LR
subgraph "Multi-Radio NDN Node"
Engine[NDN Engine<br/>FIB + PIT + CS]
Strategy[MultiRadioStrategy]
RT[RadioTable<br/>DashMap of FaceId to LinkMetrics]
CM[ChannelManager<br/>nl80211 Netlink]
Engine --> Strategy
Strategy --> RT
CM --> RT
end
subgraph "Radio 0 -- 2.4 GHz (Access)"
wlan0[wlan0 ch 6]
EF0a[NamedEtherFace peer=A]
EF0b[NamedEtherFace peer=B]
end
subgraph "Radio 1 -- 5 GHz (Backhaul)"
wlan1[wlan1 ch 36]
EF1[NamedEtherFace peer=relay2]
end
Engine --> EF0a
Engine --> EF0b
Engine --> EF1
EF0a --- wlan0
EF0b --- wlan0
EF1 --- wlan1
RadioTable: Per-Face Link Metrics
The RadioTable is a DashMap<FaceId, LinkMetrics> – concurrent reads from many pipeline tasks, writes from the nl80211 monitoring task. Each face’s entry tracks:
#![allow(unused)]
fn main() {
pub struct RadioFaceMetadata {
pub radio_id: u8, // physical radio index (0-based)
pub channel: u8, // current 802.11 channel number
pub band: u8, // 2 = 2.4 GHz, 5 = 5 GHz, 6 = 6 GHz
}
pub struct LinkMetrics {
pub rssi_dbm: i8, // received signal strength
pub retransmit_rate: f32, // MAC-layer retransmission rate (0.0-1.0)
pub last_updated: u64, // ns since Unix epoch
}
}
The RadioFaceMetadata is attached directly to each NamedEtherFace at construction time. A node reachable on both wlan0 and wlan1 gets two separate NamedEtherFace entries with different RadioFaceMetadata – the FIB can have nexthop entries for both with different costs, and the strategy selects based on current link quality from the RadioTable.
ChannelManager: nl80211 Channel Switching
The ChannelManager runs as a companion task alongside the engine. It performs three functions:
- Reads nl80211 survey data and per-station metrics continuously via Netlink – channel utilization, per-station RSSI, MCS index, retransmission counts
- Publishes link state as named NDN content under
/radio/local/<iface>/statewith short freshness periods - Subscribes to neighbor radio state via standing Interests on
/radio/+/state, keeping the localRadioTablecurrent
Channel switches are coordinated through NDN itself. To request a neighbor switch channels, a node expresses an Interest to /radio/node=neighbor/wlan1/switch/ch=36. The neighbor validates credentials via prefix authorization, executes the nl80211 switch, and returns an Ack Data with actual switch latency. This is cleaner than any IP-based radio management protocol – authenticated, named, cached, and using the same forwarding infrastructure as data traffic.
Channel switch and the PIT: A channel switch causes brief interface unavailability (10-50 ms). During this window, PIT entries may time out. The strategy suppresses retransmissions during the switch, and XDP/eBPF forwarding cache entries for the affected interface are flushed before issuing the nl80211 command.
MultiRadioStrategy
The MultiRadioStrategy holds an Arc<RadioTable> and reads it on every after_receive_interest call to rank faces by current link quality. It separates radios into roles:
- Access radio: client-facing Interests and Data
- Backhaul radio: inter-node forwarding
The FIB contains separate nexthop entries per radio for the same name prefix. For established flows, a FlowTable maps name prefixes to preferred radio faces based on observed throughput and RTT (EWMA). A video stream like /video/stream that consistently performs better via the 5 GHz radio is sent directly to the preferred face without consulting the FIB. The FIB serves as the fallback for new flows.
Face Type Comparison
%%{init: {"layout": "elk"}}%%
graph TD
subgraph "Face Type Hierarchy"
Face["trait Face<br/>recv() + send()"]
Face --> IP["IP-based"]
Face --> L2["Link-layer"]
Face --> Local["In-process"]
IP --> UDP[UdpFace]
IP --> TCP[TcpFace]
IP --> MUDP[MulticastUdpFace]
L2 --> Ether["NamedEtherFace<br/>AF_PACKET / PF_NDRV / Npcap"]
L2 --> MEther["MulticastEtherFace<br/>01:00:5e:00:17:aa"]
L2 --> Wfb["WfbFace<br/>monitor mode injection"]
L2 --> BT["BluetoothFace<br/>RFCOMM / L2CAP CoC"]
L2 --> Serial["SerialFace<br/>COBS framing"]
Local --> App[InProcFace]
Local --> Compute[ComputeFace]
end
style L2 fill:#6b6,stroke:#333
style Ether fill:#6b6,stroke:#333
style MEther fill:#6b6,stroke:#333
style Wfb fill:#6b6,stroke:#333
style BT fill:#6b6,stroke:#333
style Serial fill:#6b6,stroke:#333
| Face | Transport | Framing | Privileges | Platform | Typical Use |
|---|---|---|---|---|---|
NamedEtherFace | Raw Ethernet (0x8624) | TPACKET_V2 mmap rings | CAP_NET_RAW / root | Linux, macOS, Windows | Per-neighbor unicast, full rate adaptation |
MulticastEtherFace | Ethernet multicast | TPACKET_V2 mmap rings | CAP_NET_RAW / root | Linux, macOS, Windows | Neighbor discovery, local-subnet broadcast |
WfbFace | 802.11 monitor mode | Raw frame injection + FEC | root | Linux | FPV drone links, long-range unidirectional |
BluetoothFace | RFCOMM / L2CAP CoC | COBS (via StreamFace) | BlueZ access | Linux | Short-range IoT, sensor networks |
SerialFace | UART / RS-485 | COBS (0x00 delimiter) | Device access | All | Embedded sensors, LoRa modems, industrial bus |
Face System: Auto-Configuration and Interface Monitoring
The Problem with Static Face Configuration
NFD requires every multicast face to be listed explicitly in nfd.conf. On a machine with four Ethernet ports, you must write four [[face]] entries — and if a USB Ethernet adapter is plugged in, the router has no idea it exists until you restart with an updated config.
ndn-rs solves this with a [face_system] config section that mirrors NFD’s auto-detection logic with three key additions: glob-based whitelist/blacklist filtering, a runtime interface watcher (Linux), and an explicit ad_hoc link-type flag for wireless MANET deployments.
Configuration
[face_system.ether]
# Enumerate all eligible interfaces at startup and create a MulticastEtherFace for each.
auto_multicast = true
# Include only physical Ethernet interfaces; exclude virtual/Docker bridges.
whitelist = ["eth*", "enp*", "en*"]
blacklist = ["lo", "lo0", "docker*", "virbr*"]
[face_system.udp]
# Similarly auto-create MulticastUdpFace (224.0.23.170:6363) per interface IPv4 address.
auto_multicast = true
# Set to true for Wi-Fi IBSS / MANET — changes link type from MultiAccess to AdHoc.
ad_hoc = false
whitelist = ["*"]
blacklist = ["lo", "lo0"]
[face_system]
# Subscribe to OS interface events and auto-create/destroy faces as interfaces
# appear or disappear (Linux only; warning logged on macOS/Windows).
watch_interfaces = true
Interface Filtering
The whitelist and blacklist fields accept glob patterns using * (any sequence) and ? (one character). The filter logic follows NFD’s precedence: blacklist is checked first and takes priority; the whitelist then must match.
interface_allowed("eth0", whitelist=["eth*"], blacklist=["lo"])
→ true (matches "eth*", not blocked)
interface_allowed("docker0", whitelist=["*"], blacklist=["docker*"])
→ false (blacklisted regardless of whitelist)
interface_allowed("virbr0", whitelist=["eth*", "enp*"], blacklist=[])
→ false (not in whitelist)
An interface also must be UP, MULTICAST-capable, and not a loopback to be eligible, regardless of the filter patterns.
Link Type: MultiAccess vs AdHoc
Every face exposes a link_type() method that strategies use to decide whether to apply multi-access Interest suppression:
| Link Type | When to use | Effect on strategies |
|---|---|---|
PointToPoint | Unicast TCP, UDP, serial | Normal forwarding; no suppression |
MultiAccess | Ethernet multicast, UDP multicast (LAN/AP) | Suppress duplicate Interests heard from the same face |
AdHoc | Wi-Fi IBSS, MANET, vehicular | Disable suppression — not all nodes hear every frame |
Set [face_system.udp] ad_hoc = true when deploying in an ad-hoc wireless mesh where multicast frames are not reliably delivered to all nodes. The ad_hoc() builder method can also be used programmatically:
#![allow(unused)]
fn main() {
let face = MulticastUdpFace::ndn_default(iface_addr, face_id).await?;
let face = face.ad_hoc(); // sets LinkType::AdHoc
engine.add_face_with_persistency(face, cancel, FacePersistency::Permanent);
}
Interface Hotplug (Linux)
When watch_interfaces = true, a background task opens a RTMGRP_LINK netlink socket and receives RTM_NEWLINK / RTM_DELLINK messages from the kernel. On Added events, a new multicast face is created for the interface (if it passes the whitelist/blacklist filter). On Removed events, the face’s cancellation token is fired, which causes the sender/reader tasks to exit and the face to be removed from the face table.
This enables zero-config hotplug: plug in a USB Ethernet adapter, and within milliseconds a MulticastEtherFace appears on it. Unplug it, and the face disappears cleanly.
The Linux netlink parsing uses only libc — no additional crates. On macOS and Windows, watch_interfaces logs a warning and is silently ignored; polling-based alternatives can be added in future.
Performance: Link-Layer vs IP
The honest performance picture for wireless: a single 802.11 hop has 300-500 us minimum latency (DIFS backoff + transmission + ACK). Engine overhead of 10-50 us is 3-15% of that total. The question is not “how do I match kernel bridge performance” but “what is the forwarding overhead relative to what I gain from NDN-aware forwarding decisions.” A kernel bridge cannot make multi-radio selection decisions at all.
For the kernel fast path on wireless interfaces: XDP is not supported (no 802.11 driver implements ndo_xdp_xmit). The realistic alternative is tc eBPF (cls_bpf), which runs post-mac80211 and supports bpf_redirect() between wireless interfaces. The aya crate provides the Rust interface for loading tc eBPF programs and managing BPF maps. But even without kernel acceleration, the userspace forwarding cache (first packet takes the full pipeline; subsequent packets for known (in_face, name_hash) skip all stages) reduces hot-path overhead to approximately 1-2 us.
In-Network Compute
The Insight: Names Are Already Computations
NDN names data, not hosts. A consumer expressing an Interest for /ndn/edu/ucla/cs/class does not care which machine stores the data – it cares about the data itself. So take that idea one step further: if names identify data, why not name computations?
An Interest for /compute/sum/3/5 does not need to reach a specific server. It needs to reach any node capable of computing the answer. A router with a registered handler computes 8, wraps it in a Data packet, and sends it back. The Content Store caches the result automatically. The next ten consumers asking for /compute/sum/3/5 get a cache hit – they never even reach the compute node.
This is not a bolt-on RPC system. It falls out naturally from how NDN already works: names identify content, the network routes by name, and the CS caches by name. Computation is just another way to produce content.
Key insight: In IP networks, ten clients calling the same REST endpoint produce ten server requests. In NDN, the first computes the result, and the next nine are CS hits. Memoization is free – you get it from the network architecture itself.
The Four Levels of Compute Integration
ndn-rs approaches in-network compute as an escalating series of capabilities. Each level builds on the previous one, and each requires progressively deeper integration with the forwarder.
%%{init: {"layout": "elk"}}%%
graph TD
L1["Level 1: Named Results<br/><i>Application produces named data</i>"]
L2["Level 2: Router-Side Handler<br/><i>ComputeHandler trait + ComputeRegistry</i>"]
L3["Level 3: ComputeFace<br/><i>Dedicated face with CS memoization</i>"]
L4["Level 4: Aggregation PIT<br/><i>Fan-out, combine, cache</i>"]
L1 --> L2 --> L3 --> L4
style L1 fill:#e8f5e9,stroke:#388e3c
style L2 fill:#e3f2fd,stroke:#1976d2
style L3 fill:#fff3e0,stroke:#f57c00
style L4 fill:#fce4ec,stroke:#c62828
Level 1: Named Results (No Engine Changes)
The simplest form of in-network compute requires zero changes to the forwarder. A producer application names its outputs with computation parameters embedded in the name:
/sensor/room42/temperature/aggregated/window=60s
The application computes the 60-second average via an InProcFace, publishes it as Data, and the CS caches it. Consumers expressing Interests for this name do not know or care whether the Data came from a live computation or a cache hit. This is the most underappreciated form of in-network compute – it already works with the standard pipeline.
Level 2: Router-Side Handler
Level 2 moves computation into the router process itself. Instead of a separate application producing named data, the router has registered handler functions that respond to Interests directly. This is where the ComputeHandler trait and ComputeRegistry come in (covered in detail below).
The advantage: no IPC overhead, no separate process to manage, and the handler runs in the same async runtime as the forwarder. The cost: the handler must be compiled into (or dynamically loaded by) the router.
Level 3: ComputeFace with CS Memoization
Level 3 gives computation a dedicated face in the face table. The ComputeFace implements the Face trait, so the FIB routes Interests to it like any other face. Internally, it dispatches to the ComputeRegistry and injects the resulting Data back into the pipeline. The pipeline’s CS insert stage caches the result automatically.
This is where the architecture pays off. The compute subsystem does not need special hooks into the pipeline – it is just another face. The FIB entry /compute points at the ComputeFace, and the standard Interest pipeline handles everything else: PIT aggregation, nonce deduplication, CS lookup on future requests.
Level 4: Aggregation PIT
The most ambitious level: the forwarder fans out a single Interest into multiple sub-Interests, collects partial results, combines them, and returns a single Data to the consumer. A wildcard Interest like /sensor/+/temperature/avg triggers the aggregation strategy, which fans out to /sensor/room1/temperature, /sensor/room2/temperature, and so on. When all results arrive (or a timeout fires), a combine function produces the final Data.
This is implementable as a strategy type plus an AggregationPitEntry – the existing pipeline architecture accommodates it without structural changes.
The ComputeHandler Trait
At the heart of the compute system is a simple trait:
#![allow(unused)]
fn main() {
pub trait ComputeHandler: Send + Sync + 'static {
fn compute(
&self,
interest: &Interest,
) -> impl Future<Output = Result<Data, ComputeError>> + Send;
}
}
A handler receives an Interest, extracts parameters from the name components, performs its computation, and returns a Data packet. The ComputeError enum covers the two failure modes:
NotFound– no handler matched this name (returned asNonefrom dispatch, not as an error)ComputeFailed(String)– the handler ran but the computation itself failed
Handlers are async. A handler can fetch data from other sources, call into libraries, or even express its own Interests through an InProcFace to gather inputs before producing a result.
Design note: The
ComputeHandlertrait usesimpl Futurein the return position, which avoids requiring handlers to manually box their futures. Internally,ComputeRegistryuses anErasedHandlertrait withPin<Box<dyn Future>>for type-erased storage in the name trie. This means handler authors get ergonomic async syntax while the registry pays the boxing cost once at dispatch time.
Handler Registration with ComputeRegistry
The ComputeRegistry maps name prefixes to handler instances using a NameTrie – the same trie structure used by the FIB. Registration is straightforward:
#![allow(unused)]
fn main() {
let registry = ComputeRegistry::new();
// Register a handler for /compute/sum
let prefix = Name::from_uri("/compute/sum");
registry.register(&prefix, SumHandler);
// Register a different handler for /compute/thumbnail
let prefix = Name::from_uri("/compute/thumbnail");
registry.register(&prefix, ThumbnailHandler);
}
When an Interest arrives, the registry performs a longest-prefix match against the Interest name. This means /compute/sum/3/5 matches the /compute/sum handler, which can then extract 3 and 5 from the remaining name components.
sequenceDiagram
participant Consumer
participant Pipeline as Pipeline Runner
participant CS as Content Store
participant CF as ComputeFace
participant Registry as ComputeRegistry
participant Handler as SumHandler
Consumer->>Pipeline: Interest /compute/sum/3/5
Pipeline->>CS: lookup(/compute/sum/3/5)
Note over CS: Cache miss
Pipeline->>CF: send(Interest)
CF->>Registry: dispatch(Interest)
Registry->>Handler: compute(Interest)
Handler-->>Registry: Data(8)
Registry-->>CF: Ok(Data)
CF->>Pipeline: inject Data
Pipeline->>CS: insert(/compute/sum/3/5, Data)
Pipeline->>Consumer: Data(8)
Note over Consumer,Handler: Second request for same computation
Consumer->>Pipeline: Interest /compute/sum/3/5
Pipeline->>CS: lookup(/compute/sum/3/5)
Note over CS: Cache hit!
Pipeline->>Consumer: Data(8)
Versioning falls out naturally from the name structure. /compute/fn/v=2/thumb/photo.jpg routes to a different handler than /compute/fn/v=1/thumb/photo.jpg – they are simply different FIB entries pointing at different handler registrations. Running multiple versions simultaneously requires no special machinery.
CS Memoization: The Magic
The most powerful property of in-network compute in NDN is that memoization is structural, not opt-in. Here is what happens after a compute result is produced:
- The
ComputeFaceinjects the Data back into the pipeline. - The Data pipeline runs its normal stages: PIT match, strategy update, CS insert, dispatch.
- The CS stores the Data keyed by its name.
- Any future Interest for the same name hits the CS before reaching the
ComputeFace.
The compute handler is never called again for the same inputs. The CS eviction policy (LRU, sharded, or persistent) determines how long results are cached – but the memoization itself requires zero code from the handler author.
graph LR
I1["Interest<br/>/compute/sum/3/5"] --> CS1{CS Lookup}
CS1 -->|miss| CF["ComputeFace<br/>dispatches to handler"]
CF --> D["Data(8)"]
D --> CSI["CS Insert"]
CSI --> R1["Return Data to consumer"]
I2["Interest<br/>/compute/sum/3/5<br/><i>(later)</i>"] --> CS2{CS Lookup}
CS2 -->|hit| R2["Return Data to consumer"]
style CS1 fill:#fff3e0,stroke:#f57c00
style CS2 fill:#e8f5e9,stroke:#388e3c
style CF fill:#e3f2fd,stroke:#1976d2
Contrast with IP: A traditional microservice behind a load balancer needs an explicit caching layer (Redis, Memcached, Varnish) to avoid redundant computation. The cache key must be manually constructed, invalidation must be manually managed, and the caching layer is an additional piece of infrastructure. In NDN, the name is the cache key, the CS is the cache, and the pipeline is the cache-aside logic. There is nothing to configure.
Use Cases
Edge Computing
A fleet of IoT gateways each runs an ndn-rs router with compute handlers registered for local inference tasks. An Interest for /inference/object-detect/camera7/frame=1234 routes to the nearest gateway that has the handler and the camera feed. The result is cached – if multiple subscribers want the same detection result, only one inference runs.
Sensor Aggregation
Level 4 aggregation shines here. A monitoring dashboard expresses Interest for /datacenter/rack3/+/cpu/avg/window=5m. The aggregation strategy fans out to every server in rack 3, collects CPU metrics, averages them, and returns a single Data packet. The result is cached for the window duration. No polling infrastructure, no time-series database query – the network computes and caches the answer.
Named Function Networking
The research frontier: treat the network as a distributed computation fabric. A computation DAG where each node is a named function and each edge is a named data dependency. Expressing an Interest for the root node causes the network to recursively resolve dependencies. Each intermediate result is cached in the CS at whatever router computed it.
This maps naturally onto federated learning workflows:
/model/v=5/gradient/shard=3is computed locally at the data shard, cached in the local CS- An aggregator expresses Interests for all shards – the FIB routes each to the right node
- The aggregator combines gradients and publishes the updated model
- The aggregator does not know where shard 3 lives – the network handles routing
Honest Limitations
Two practical constraints deserve mention:
Long-running compute. An NDN Interest has a lifetime. If computation takes 30 seconds but the Interest lifetime is 4 seconds, the consumer must re-express periodically. ndn-rs supports Nack(NotYet) with a retry-after hint as one mitigation, and versioned notification namespaces as another. These are engineering solutions, not architectural gaps.
Large results. A 100 MB computation result must be segmented into many Data packets, requiring the consumer to pipeline segment Interests. The chunked transfer layer handles this correctly, but it adds latency compared to streaming a TCP response. For edge and wireless environments where ndn-rs operates, the tradeoff is usually acceptable – and the caching benefits compound over multiple consumers.
Simulation
The Problem: Testing Distributed Protocols Without Distributed Hardware
How do you test a distributed networking protocol without deploying real hardware? The NDN research community’s answer has been Mini-NDN, which uses Mininet to spin up heavyweight OS-level containers – each running its own forwarder process, its own IP stack, its own filesystem. It works, but it’s slow to set up, hard to reproduce exactly, and nearly impossible to step through with a debugger when something goes wrong at the protocol level.
ndn-rs takes a different approach: simulate the entire network in a single process.
The ndn-sim crate creates virtual forwarder nodes connected by virtual links, all running cooperatively on the Tokio runtime. Each node is a real ForwarderEngine – the exact same code that runs in production – but its faces are backed by async channels instead of sockets. The links between nodes model real-world impairments: propagation delay, bandwidth limits, random loss, jitter. Because everything runs in one process with one address space, you get deterministic control, easy debugging, and the ability to inspect any node’s FIB, PIT, or Content Store at any point during the simulation.
Key insight: The simulation doesn’t approximate the forwarder – it is the forwarder.
SimFaceimplements the sameFacetrait asUdpFaceorTcpFace. Any bug you find in simulation exists in production, and any fix you verify in simulation works in production. There’s no simulation-vs-reality gap.
SimFace: A Face That Goes Nowhere (On Purpose)
At the lowest level, a SimFace is a Face implementation backed by Tokio MPSC channels. Each SimFace is one endpoint of a SimLink. When a forwarder engine calls send() on a SimFace, the packet doesn’t touch a socket – it enters the link’s impairment pipeline, where it may be delayed, dropped, or bandwidth-shaped before emerging at the other end.
#![allow(unused)]
fn main() {
impl Face for SimFace {
fn id(&self) -> FaceId { self.id }
fn kind(&self) -> FaceKind { FaceKind::Internal }
async fn recv(&self) -> Result<Bytes, FaceError> {
self.rx.lock().await.recv().await
.ok_or(FaceError::Closed)
}
async fn send(&self, pkt: Bytes) -> Result<(), FaceError> {
// Apply loss, bandwidth shaping, delay...
}
}
}
From the engine’s perspective, this is just another face. It doesn’t know (or care) that the “network” is a chain of Tokio operations in the same process.
SimLink: Virtual Links That Behave Like Real Ones
The interesting part isn’t the channel – it’s what happens between send() and the packet arriving at the remote recv(). A SimLink models delay, loss, bandwidth limits, and jitter, all running on Tokio timers. Each direction of a link runs an independent impairment pipeline, so you can model asymmetric links (think satellite: fast downlink, slow uplink).
flowchart LR
subgraph "SimLink"
subgraph "SimFace A (FaceId 10)"
A_send["send()"]
A_recv["recv()"]
end
subgraph "Link Pipeline A→B"
L1["Loss filter\n(random drop)"]
BW1["Bandwidth shaper\n(serialization delay)"]
D1["Delay + jitter\n(propagation)"]
end
subgraph "Async Channel A→B"
CH1[/"mpsc channel\n(buffered)"/]
end
subgraph "Link Pipeline B→A"
L2["Loss filter\n(random drop)"]
BW2["Bandwidth shaper\n(serialization delay)"]
D2["Delay + jitter\n(propagation)"]
end
subgraph "Async Channel B→A"
CH2[/"mpsc channel\n(buffered)"/]
end
subgraph "SimFace B (FaceId 11)"
B_send["send()"]
B_recv["recv()"]
end
A_send --> L1 --> BW1 --> D1 --> CH1 --> B_recv
B_send --> L2 --> BW2 --> D2 --> CH2 --> A_recv
end
style L1 fill:#c44,color:#fff
style L2 fill:#c44,color:#fff
style BW1 fill:#c90,color:#000
style BW2 fill:#c90,color:#000
style D1 fill:#2d5a8c,color:#fff
style D2 fill:#2d5a8c,color:#fff
style CH1 fill:#555,color:#fff
style CH2 fill:#555,color:#fff
The send path applies impairments in a deliberate order:
- Loss – a random roll against
loss_rate. If the packet is going to be dropped, there’s no reason to simulate its serialization or propagation. It vanishes silently, just like a real radio frame lost to interference. - Bandwidth shaping – a serialization delay computed from the packet’s size and the link’s
bandwidth_bps. Anext_tx_readycursor serializes transmissions to model link capacity: a second packet arriving while the link is “busy” waits until the first has finished transmitting. - Delay + jitter – a base propagation delay plus uniform random jitter in
[0, max_jitter]. This models the physical reality that packets don’t all take exactly the same time to traverse a link.
Implementation note: When delay is non-zero, delivery is handled by a spawned background task so
send()returns immediately. This models store-and-forward behavior – the sending forwarder doesn’t block waiting for the packet to “arrive,” just as a real NIC doesn’t stall the CPU while bits traverse a cable.
Creating a link is straightforward. For a symmetric link (same properties in both directions):
#![allow(unused)]
fn main() {
let (face_a, face_b) = SimLink::pair(
FaceId(10), FaceId(11),
LinkConfig::wifi(),
128, // channel buffer size
);
}
For asymmetric links where each direction has different characteristics:
#![allow(unused)]
fn main() {
let (face_a, face_b) = SimLink::pair_asymmetric(
FaceId(10), FaceId(11),
config_a_to_b,
config_b_to_a,
128,
);
}
Link Presets: Common Network Types
Rather than forcing you to pick delay and bandwidth numbers from scratch, LinkConfig ships presets for common link types:
| Preset | Delay | Jitter | Loss | Bandwidth |
|---|---|---|---|---|
direct() | 0 | 0 | 0% | Unlimited |
lan() | 1 ms | 100 us | 0% | 1 Gbps |
wifi() | 5 ms | 2 ms | 1% | 54 Mbps |
wan() | 50 ms | 5 ms | 0.1% | 100 Mbps |
lossy_wireless() | 10 ms | 5 ms | 5% | 11 Mbps |
And when the presets don’t fit, custom configurations are just a struct literal away:
#![allow(unused)]
fn main() {
let satellite = LinkConfig {
delay: Duration::from_millis(300),
jitter: Duration::from_millis(20),
loss_rate: 0.005,
bandwidth_bps: 10_000_000,
};
}
Example: A geostationary satellite link has roughly 300ms one-way delay and modest bandwidth. You can model this directly without writing any simulation-specific code – just plug in the numbers and the link behaves accordingly.
Topology Builder: Describe Your Network, Let the Simulation Wire It Up
Working with individual SimLinks and SimFaces is fine for unit tests, but for topology-level experiments you want something more declarative. The Simulation type lets you describe a network as a graph of nodes and links, and it handles all the plumbing: creating engines, wiring up faces, installing FIB routes.
#![allow(unused)]
fn main() {
let mut sim = Simulation::new();
// Add forwarding nodes
let producer = sim.add_node(EngineConfig::default());
let router = sim.add_node(EngineConfig::default());
let consumer = sim.add_node(EngineConfig::default());
// Connect them
sim.link(consumer, router, LinkConfig::lan());
sim.link(router, producer, LinkConfig::wifi());
// Pre-install FIB routes
sim.add_route(consumer, "/ndn/data", router);
sim.add_route(router, "/ndn/data", producer);
// Start all engines
let mut running = sim.start().await?;
}
That’s it. Five lines of topology description, and you have a three-node network with realistic link characteristics and working forwarding tables. The builder handles the tedious parts: assigning face IDs, creating bidirectional links, translating node-level routes into face-level FIB entries.
What Happens When You Call start()
When start() is called, the builder performs four steps:
- Instantiates all
ForwarderEngines viaEngineBuilder - Creates
SimLinkpairs and adds the faces to each engine - Installs FIB routes using the face map (translating
NodeIdpairs toFaceIds) - Returns a
RunningSimulationhandle
Example Topologies
A simple linear chain – consumer, router, producer – is the most common test topology:
graph LR
C["Consumer<br/>(node 0)"] -->|"LAN<br/>1ms, 1Gbps"| R["Router<br/>(node 1)"]
R -->|"WiFi<br/>5ms, 54Mbps, 1% loss"| P["Producer<br/>(node 2)"]
style C fill:#2d5a8c,color:#fff
style R fill:#5a2d8c,color:#fff
style P fill:#2d7a3a,color:#fff
But the power of simulation really shows with multi-path topologies. A diamond topology gives a consumer two paths to the producer, with different characteristics on each:
graph TD
C["Consumer\n(node 0)"] -->|"LAN\n1ms, 1Gbps"| R1["Router 1\n(node 1)"]
C -->|"WiFi\n5ms, 54Mbps\n1% loss"| R2["Router 2\n(node 2)"]
R1 -->|"LAN\n1ms, 1Gbps"| P["Producer\n(node 3)"]
R2 -->|"WAN\n50ms, 100Mbps"| P
style C fill:#2d5a8c,color:#fff
style R1 fill:#5a2d8c,color:#fff
style R2 fill:#5a2d8c,color:#fff
style P fill:#2d7a3a,color:#fff
This is the topology you want for testing strategy selection. The fast reliable path goes through Router 1 (2ms total, no loss), while the slower path goes through Router 2 (55ms, with WiFi loss on the first hop). Strategies like BestRoute or AsfStrategy can probe both paths and adapt – and you can verify that they actually do, because you control every variable.
Interacting with a Running Simulation
The RunningSimulation handle gives you runtime access to the network while it’s running:
#![allow(unused)]
fn main() {
// Access individual engines
let engine = running.engine(router);
// Add routes at runtime
running.add_route(consumer, "/ndn/new-prefix", router)?;
// Get the face connecting two nodes
let face_id = running.face_between(consumer, router);
// Shut down all engines
running.shutdown().await;
}
Important: The engines in a running simulation are real
ForwarderEngineinstances. You can access their FIB, PIT, and Content Store directly for assertions. This means your integration tests can verify not just end-to-end behavior (“did the consumer get the data?”) but internal state (“did the intermediate router cache it? did the PIT entry get cleaned up?”).
Event Tracing: Replay Every Packet’s Journey
When something goes wrong in a distributed protocol, the hardest part is usually figuring out where it went wrong. A consumer didn’t get its data – but was the Interest forwarded? Did it reach the producer? Did the Data come back but get dropped by a lossy link? Was there a PIT entry at the intermediate router?
The SimTracer captures structured, timestamped events at every node, so you can reconstruct the complete journey of every packet through the network after the fact.
#![allow(unused)]
fn main() {
let tracer = SimTracer::new();
// Record events with automatic timestamping
tracer.record_now(
0, // node index
Some(1), // face id
EventKind::InterestIn, // event classification
"/ndn/test/data", // NDN name
None, // optional detail
);
// After simulation: analyze
let all_events = tracer.events();
let node0_events = tracer.events_for_node(0);
let cache_hits = tracer.events_of_kind(&EventKind::CacheHit);
// Export to JSON
let json = tracer.to_json();
}
Event Kinds
The tracer captures events across the full lifecycle of a packet:
| Kind | Description |
|---|---|
InterestIn / InterestOut | Interest received / forwarded |
DataIn / DataOut | Data received / sent |
CacheHit / CacheInsert | Content Store events |
PitInsert / PitSatisfy / PitExpire | PIT lifecycle |
NackIn / NackOut | Nack events |
FaceUp / FaceDown | Face lifecycle |
StrategyDecision | Strategy forwarding decision |
Custom(String) | User-defined events |
The JSON export produces a compact array of events, ordered by timestamp, that can be loaded into visualization tools or processed by scripts:
[
{"t":1000,"node":0,"face":1,"kind":"interest-in","name":"/ndn/test/data"},
{"t":1050,"node":0,"face":null,"kind":"cache-hit","name":"/ndn/test/data"},
{"t":5200,"node":1,"face":2,"kind":"data-out","name":"/ndn/test/data","detail":"fresh"}
]
Performance: The tracer uses interior mutability (
Mutex<Vec<Event>>) and records monotonic nanosecond timestamps. The overhead per event is a mutex lock and a struct push – negligible compared to the simulated network delays. For high-throughput simulations, you can selectively enable tracing on specific nodes or event kinds to reduce noise.
Putting It Together: Common Use Cases
Testing Forwarding Strategies
Build a topology with multiple paths and verify that a custom strategy selects the optimal one under varying conditions. This is one of the most common uses of the simulation framework – strategies are hard to test in isolation because they depend on the full forwarding pipeline, FIB state, and PIT behavior.
#![allow(unused)]
fn main() {
let mut sim = Simulation::new();
let c = sim.add_node(EngineConfig::default());
let r1 = sim.add_node(EngineConfig::default());
let r2 = sim.add_node(EngineConfig::default());
let p = sim.add_node(EngineConfig::default());
// Two paths: fast but lossy vs. slow but reliable
sim.link(c, r1, LinkConfig { delay: Duration::from_millis(5), loss_rate: 0.1, ..Default::default() });
sim.link(c, r2, LinkConfig { delay: Duration::from_millis(20), loss_rate: 0.0, ..Default::default() });
sim.link(r1, p, LinkConfig::lan());
sim.link(r2, p, LinkConfig::lan());
sim.add_route(c, "/ndn/data", r1);
sim.add_route(c, "/ndn/data", r2);
// ... start, send Interests, measure satisfaction rate
}
Example: With 10% loss on the fast path, an adaptive strategy should initially try both paths, observe that the fast path drops packets, and gradually shift traffic to the slow-but-reliable path. The simulation lets you measure exactly when and how the strategy converges.
Evaluating Caching Policies
Deploy a tree topology and measure cache hit rates under different Content Store implementations. Because each node’s EngineConfig is independent, you can mix and match CS configurations within the same simulation:
#![allow(unused)]
fn main() {
// Configure each node with a different CS size
let config_small = EngineConfig { cs_capacity: 100, ..Default::default() };
let config_large = EngineConfig { cs_capacity: 10_000, ..Default::default() };
}
Use the tracer’s CacheHit and CacheInsert events to measure hit rates at each node, and compare LRU vs. sharded vs. persistent Content Store backends under identical workloads.
Measuring Convergence After Network Events
Test how quickly the discovery protocol establishes neighbor relationships and populates the FIB after network partitions and merges:
#![allow(unused)]
fn main() {
// Start with all nodes connected
let mut running = sim.start().await?;
// Simulate partition: remove the link face
// (cancel the face task to simulate link failure)
// Wait and measure: how long until discovery re-establishes routes?
}
Bandwidth and Latency Profiling
Use asymmetric links to model real-world conditions where upload and download characteristics differ dramatically:
#![allow(unused)]
fn main() {
sim.link_asymmetric(ground, satellite,
LinkConfig { delay: Duration::from_millis(300), bandwidth_bps: 1_000_000, ..Default::default() },
LinkConfig { delay: Duration::from_millis(300), bandwidth_bps: 10_000_000, ..Default::default() },
);
}
Example: A satellite ground station has 1 Mbps uplink but 10 Mbps downlink, both with 300ms propagation delay. By modeling this asymmetry, you can verify that Interest packets (small, mostly uplink) flow smoothly while large Data responses (downlink) don’t overwhelm the return path.
Browser Simulation with ndn-wasm
The Question Nobody Thought to Ask
For years the standard answer to “how do I learn NDN?” has been: read the spec, clone NFD, build it, fight with its dependencies, deploy it on two VMs, and send your first Interest packet into the void. If the Data comes back, congratulations — you’ve run NDN. If it doesn’t, you have no idea whether the packet was malformed, the FIB was wrong, or the face just isn’t up yet.
The ndn-explorer takes a different approach. What if you could run a real NDN pipeline — with a working FIB trie, a live PIT, an LRU Content Store, and all six forwarding stages — inside a web browser, with no installation required, stepping through each stage’s decision in real time?
That’s what ndn-wasm is. Not a teaching toy that pretends to route packets. An actual simulation of the NDN forwarding pipeline, compiled to WebAssembly, that the ndn-explorer uses to power its animated pipeline traces, topology sandbox, and TLV inspector.
The Architecture Decision: Reimplement, Don’t Port
The honest first idea was to compile the real ndn-engine to wasm32-unknown-unknown. This runs into a wall almost immediately. The production engine depends on DashMap (which uses thread-local state that doesn’t exist on WASM), Tokio’s rt-multi-thread feature (which requires pthread), and scattered tokio::spawn calls that assume preemptive multitasking. These aren’t superficial problems — they go all the way down to how the engine handles concurrent packet processing.
The second idea — and the one that actually works — is to write a purpose-built simulation crate that shares the wire-format libraries (ndn-tlv, ndn-packet) but reimplements the forwarding data structures using single-threaded primitives that compile cleanly to WASM. No DashMap. No background tasks. No mutexes. Just HashMap, Vec, and deterministic execution.
This trade-off has a cost (more on that later), but it has a significant benefit: the simulation runs synchronously, which makes it better for visualization than the real engine would be. The explorer can step through stages one at a time, emit trace events at each decision point, and hand control back to the JavaScript renderer between stages — something that would require substantial instrumentation to achieve with the real async pipeline.
What ndn-wasm Actually Contains
The FIB Trie
The Forwarding Information Base in ndn-wasm is a proper name-component trie, not a string-prefix shortcut. Each node in the trie holds a HashMap<String, TrieNode> of its children, indexed by name component. Longest-prefix matching walks the trie one component at a time, tracking the deepest node that has a next_hop set, and returns that face ID.
#![allow(unused)]
fn main() {
fn lookup_lpm(&self, name: &[&str]) -> Option<u32> {
let mut node = &self.root;
let mut best = node.next_hop;
for component in name {
match node.children.get(*component) {
Some(child) => { node = child; best = best.or(node.next_hop); }
None => break,
}
}
best
}
}
This is the same algorithm the production FIB uses — just with HashMap instead of Arc<RwLock<TrieNode>>. In a single-threaded WASM context that’s all you need.
The insert_route() function creates trie nodes for missing components on the path, so routes like /ndn/ucla/cs and /ndn/mit/csail live in a shared /ndn node with two subtrees. No string interning, no hash collisions from treating the whole name as a key.
The PIT
The Pending Interest Table tracks outstanding Interests by a composite key (name, can_be_prefix, must_be_fresh). Each entry stores the arrival time, expiry deadline, and nonce set for deduplication. When a second Interest arrives for the same name with the same nonce as a pending entry, it’s detected and dropped — loop prevention working exactly as the spec requires.
Expiry is handled via evict_expired(), which the pipeline calls before each lookup. This is the simplified version of the production timing wheel: instead of O(1) slot-based expiry, it’s a linear scan. For the browser simulation this is fine — the PIT rarely holds more than a few dozen entries at a time.
Aggregation — two different consumers sending the same Interest — is modeled correctly. A second Interest that matches an existing PIT entry records its face as an additional downstream face. When the Data returns, the pipeline dispatches it to all of them.
The Content Store
The CS is an LRU cache implemented with a HashMap<String, CsEntry> for O(1) lookup and a Vec<String> that tracks insertion order. When capacity is exceeded, the oldest entry is evicted. Each entry records its freshness period alongside the content, so MustBeFresh Interests only hit entries that are still within their freshness window.
The lookup supports CanBePrefix: when the flag is set, the CS scans for any stored name that starts with the requested name’s components. This is a linear scan in the WASM implementation (vs. the trie-based scan in the production CS), but it produces correct results.
Hit rate tracking is built in. The CS counts total lookups and hits, so the pipeline stage panel in the explorer can display a live hit rate as you run packets through.
The Pipeline
This is the heart of ndn-wasm. Two pipelines — one for Interest packets, one for Data — each implemented as a sequence of function calls that emit StageEvent structs.
Interest pipeline:
| Stage | What it checks | What it can do |
|---|---|---|
TlvDecode | Parses Name, CanBePrefix, MustBeFresh, Lifetime, Nonce from wire format | Emits decoded fields as trace detail |
CsLookup | Looks up the name in the Content Store | Short-circuits: emits cache_hit, returns Data without touching PIT or FIB |
PitCheck | Checks nonce against pending entries; inserts or aggregates | Detects loops; models aggregation |
Strategy | FIB trie lookup; BestRoute / Multicast / Suppress decision | Selects face(s); marks as forwarded or nacked |
Data pipeline:
| Stage | What it checks | What it can do |
|---|---|---|
TlvDecode | Parses Name, Content, SignatureInfo from wire format | Emits decoded fields |
PitMatch | Finds pending entries whose Interest matches the Data name | Short-circuits if no pending entry (unsolicited Data) |
Validation | Checks the sig_valid flag on the packet | Drops invalid Data before it reaches the cache |
CsInsert | Stores the Data in the Content Store | Evicts if over capacity |
Each stage produces a StageEvent with a name, verdict (Continue / Drop / Nack / CacheHit / Forwarded / Satisfied), the face ID involved, and a JSON detail blob. The explorer’s pipeline view collects these events and animates the packet bubble through the stages in sequence.
#![allow(unused)]
fn main() {
pub struct StageEvent {
pub stage: &'static str,
pub verdict: Verdict,
pub face_id: Option<u32>,
pub detail: serde_json::Value,
}
}
This event stream is what makes the animated pipeline possible. The real async engine emits tracing spans — useful for logs, but not structured enough to drive a step-by-step visual without significant extra work. Here, the events are designed from the start to be consumed by a renderer.
The Topology
Above the individual pipeline, SimTopology models a multi-hop network. Nodes hold their own WasmPipeline instance (and therefore their own FIB, PIT, and CS). Links connect pairs of nodes, each with a direction-aware face ID. When you call load_topology_scenario("triangle-caching"), the topology builds three nodes, wires three links, and calls propagate_route() to populate FIB tables.
propagate_route() does a BFS from each producer’s prefix outward through the topology, installing FIB entries at each hop. It stops at nodes that already have the prefix in their FIB, which prevents redundant writes in looped topologies — though it currently doesn’t carry a visited set, so a true cycle in the topology graph would loop. The scenarios that ship with the explorer are all acyclic, so this hasn’t been a problem in practice.
TLV Encoding and Decoding
TLV encoding delegates to the real ndn-tlv::TlvWriter, which means the bytes produced by tlv_encode_interest() and tlv_encode_data() are spec-compliant. You can copy the hex string out of the TLV Inspector, decode it with an independent NDN library, and get the same field values back.
Decoding uses ndn-packet’s parser for Interest and Data, then extracts fields into a WasmTlvNode tree that JavaScript can walk. Each node carries its type code, decoded type name, byte range in the original buffer, and a human-readable value string.
Where the Simulation Diverges from Production
Understanding the gaps is as important as understanding what works. ndn-wasm is a faithful simulation of NDN semantics, not a production-grade forwarder. Here’s exactly where it takes shortcuts:
Signatures Are a Flag, Not a Calculation
The most visible simplification: when you build a Data packet in the explorer and mark it “invalid”, what changes is a boolean sig_valid flag, not any bytes in the packet. The Validation pipeline stage checks this flag and drops the packet if false.
The wire-format Data packets that tlv_encode_data() produces do include a SignatureValue field — but it’s 32 bytes of 0xAA. There’s no ECDSA, no HMAC, no SHA-256. This means a packet produced by the explorer and decoded by a real NDN library will have a syntactically valid but cryptographically meaningless signature. For educational purposes — watching the Validation stage accept or reject a packet — this is sufficient. For anything security-critical, you want the real ndn-security crate.
NDNLPv2 Is Absent
The production forwarder speaks NDNLPv2, the link-layer fragmentation and signaling protocol that carries Interests, Data, and Nacks in a common envelope. ndn-wasm skips this entirely. The simulation works at the application-layer PDU level: packets are NDN Interest and Data directly, never wrapped in NDNLPv2 fragments. This means link-layer Nacks (the NoRoute / CongestionMark signaling that real forwarders exchange) are simulated by directly setting a verdict in the strategy stage, not by constructing and parsing a real NDNLPv2 Nack packet.
Link Impairments Are Modeled but Ignored in Routing
Every SimLink carries bandwidth_bps and loss_rate fields. The topology UI even lets you set them. But right now they’re inert — the routing logic doesn’t consult them, packets aren’t dropped probabilistically, and bandwidth-delay products aren’t reflected in timing. The fields are there because they should affect strategy decisions (specifically ASF, which measures per-face RTT and satisfaction rate). Wiring them into the pipeline is the natural next step when ASF strategy is implemented.
No ASF Strategy
The Adaptive Smoothed RTT-based Forwarding strategy — the production implementation that measures per-face RTT and satisfaction rate and re-ranks faces accordingly — isn’t in ndn-wasm yet. The simulation offers three strategies: BestRoute (first FIB match, single face), Multicast (all FIB matches, all faces), and Suppress (no forwarding, for dead-end tests). ASF requires a measurements table that persists across packet runs and feeds back into the FIB decision — doable in a single-threaded model, just not implemented yet.
No CertFetcher
The production Validation stage can trigger a side-channel Interest to fetch a missing certificate before completing signature verification. ndn-wasm has no async side channels — the simulation is fully synchronous — so certificate chasing isn’t modeled. If you want to show a certificate chain in the explorer, it’s currently done by building the chain manually in the scenario definition.
The Path to Compiling the Real Engine
The three concrete blockers between the real ndn-engine and a WASM binary:
DashMap — ndn-transport, ndn-store, ndn-engine, and ndn-strategy all use DashMap for concurrent access to the PIT, FIB, face table, and measurements table. DashMap uses thread-local storage internally and doesn’t compile on wasm32. The fix is a feature flag that swaps DashMap<K, V> for Mutex<HashMap<K, V>> under target_arch = "wasm32". This is semantically correct (WASM is single-threaded) and mechanically straightforward — it’s just a lot of call sites to update.
rt-multi-thread — ndn-engine enables Tokio’s multi-thread runtime for production use. The multi-thread runtime requires OS threads. The fix is a wasm feature that removes rt-multi-thread from the Tokio dependency and switches to current_thread. The pipeline logic itself is all async fn and doesn’t depend on parallelism — it would run correctly on a single-threaded executor.
tokio::spawn — Various places in the engine and faces spawn background tasks using tokio::spawn. On WASM, the equivalent is wasm_bindgen_futures::spawn_local. Faces also use tokio::time::sleep for delays; gloo_timers provides the WASM equivalent. This is mostly mechanical substitution, but it requires touching sim_face.rs and any face that creates background tasks.
None of these are insurmountable. Together they represent a few days of careful refactoring and a restructured Cargo.toml with feature flags. The payoff would be running the real forwarding engine — the exact same binary that runs the production forwarder — in the browser, with the explorer’s trace events emitted via the production tracing infrastructure rather than ndn-wasm’s bespoke StageEvent structs.
Building ndn-wasm
The crate lives in crates/sim/ndn-wasm/ and is built with wasm-pack. From the repository root:
bash tools/ndn-explorer/build-wasm.sh
This runs:
wasm-pack build crates/sim/ndn-wasm \
--target web \
--out-dir tools/ndn-explorer/wasm \
--out-name ndn_wasm \
--no-typescript \
--release
The output is four files in tools/ndn-explorer/wasm/:
ndn_wasm.js— the ES module loader that wasm-pack generatesndn_wasm_bg.wasm— the compiled WASM binaryndn_wasm_bg.js— glue for wasm-bindgen’s memory modelpackage.json— metadata, not needed by the explorer directly
After the build, open tools/ndn-explorer/index.html in a browser. The WASM badge in the top-right corner of the nav will switch from WASM — to WASM ✓, confirming that the Rust simulation is active. If the badge stays grey, open the browser console — the most common cause is a missing WASM file or a CORS error from loading a file:// URL (use a local dev server instead).
On every push to main that touches crates/sim/ndn-wasm/, the GitHub Actions wiki workflow rebuilds the WASM binary as part of deploying the GitHub Pages site. The build step runs with continue-on-error: true, so a WASM compile failure doesn’t block the site deploy — the explorer falls back to its JavaScript simulation until the next successful build.
Feature Comparison: ndn-wasm vs. Production Engine
| Feature | ndn-wasm | ndn-engine (production) |
|---|---|---|
| FIB: longest-prefix match | ✓ Component trie | ✓ Concurrent Arc |
| PIT: aggregation & nonce dedup | ✓ | ✓ DashMap-based |
| CS: LRU, CanBePrefix, MustBeFresh | ✓ | ✓ Pluggable backends |
| Interest pipeline (all 4 stages) | ✓ | ✓ |
| Data pipeline (all 4 stages) | ✓ | ✓ |
| BestRoute strategy | ✓ | ✓ |
| Multicast strategy | ✓ | ✓ |
| ASF strategy | ✗ | ✓ |
| NDNLPv2 (fragmentation, Nack) | ✗ | ✓ |
| Real cryptographic signatures | ✗ Simulated flag | ✓ ECDSA / HMAC / SHA-256 |
| CertFetcher (async cert chain) | ✗ | ✓ |
| Link impairments (loss, delay) | Modeled, not applied | ✓ ndn-sim |
| Multi-hop topology | ✓ BFS route propagation | ✓ SimLink channels |
| TLV wire format (encode/decode) | ✓ Real ndn-tlv | ✓ Same library |
| Structured trace events | ✓ StageEvent (for viz) | ✓ tracing spans |
| Runs in browser | ✓ | ✗ (blocked by DashMap + rt-multi-thread) |
| Thread-safe concurrent access | ✗ Single-threaded | ✓ |
The table tells the story clearly: ndn-wasm wins on the things that matter for an interactive educational tool — complete pipeline semantics, real TLV encoding, multi-hop topology — and gives up the things that don’t make sense in a single-threaded browser context (thread-safe concurrency) or that are too complex to simulate without the full security stack (real crypto, certificate fetching).
The goal was never to replace the production engine. It was to bring NDN forwarding semantics into a context where a newcomer can click “Run Packet” and watch, step by step, how an Interest finds its way from a consumer to a producer and a Data packet finds its way back.
That part works.
Interoperability Testing
ndn-rs is tested against three other NDN implementations across an 8-scenario matrix:
| Implementation | Role(s) tested |
|---|---|
| ndn-cxx (C++) | consumer and producer, via NFD and ndn-fwd |
| NDNts (TypeScript/Node.js) | consumer and producer, via yanfd and ndn-fwd |
| NFD (C++ forwarder) | external forwarder (ndn-rs as an application client) |
| yanfd (Go forwarder) | external forwarder (ndn-rs as an application client) |
Scenario Matrix
| Scenario | Consumer | Forwarder | Producer |
|---|---|---|---|
fwd/cxx-consumer | ndn-cxx (ndnpeek) | ndn-fwd | ndn-rs (ndn-put) |
fwd/cxx-producer | ndn-rs (ndn-peek) | ndn-fwd | ndn-cxx (ndnpoke) |
fwd/ndnts-consumer | NDNts (ndncat) | ndn-fwd | ndn-rs (ndn-put) |
fwd/ndnts-producer | ndn-rs (ndn-peek) | ndn-fwd | NDNts (ndncat) |
app/nfd-cxx-producer | ndn-rs (ndn-peek) | NFD | ndn-cxx (ndnpoke) |
app/nfd-cxx-consumer | ndn-cxx (ndnpeek) | NFD | ndn-rs (ndn-put) |
app/yanfd-ndnts-producer | ndn-rs (ndn-peek) | yanfd | NDNts (ndncat) |
app/yanfd-ndnts-consumer | NDNts (ndncat) | yanfd | ndn-rs (ndn-put) |
ndn-rs appears in bold in every row — it is the implementation under test. The tests run in Docker Compose with each forwarder on a shared virtual network. Results are published automatically to Interop Test Results.
The Journey to Full Interoperability
Getting ndn-rs to pass all eight scenarios required resolving a series of compatibility gaps, roughly in order from fundamental wire format through protocol semantics to test infrastructure. Each gap is described below.
1. NDNLPv2 Framing on Unix Sockets
The problem. The NDN Link Protocol v2 (NDNLPv2, type 0x64) is the standard framing used on all NDN faces, including Unix-domain sockets. NFD and yanfd wrap every Interest and Data inside an LpPacket before writing it to the socket, and they expect the same from their peers.
ndn-rs was sending bare TLV bytes on Unix sockets — no 0x64 wrapper. Every packet sent to NFD or yanfd was silently discarded. Every packet received from them was an LpPacket that ndn-rs stripped correctly, but the asymmetry meant the external forwarder got nothing back.
The fix. encode_lp_packet() was added and called unconditionally on outgoing bytes for all Unix-socket faces to external forwarders. The uses_lp flag (auto-detected from the first inbound packet) gates LP wrapping for ndn-fwd’s own local-app faces.
2. NonNegativeInteger Encoding
The problem. NDN’s NonNegativeInteger type mandates minimal encoding: the value 0 uses a zero-byte TLV value field, values 1–255 use one byte, 256–65535 use two bytes, and so on. ndn-rs was emitting some integers with extra leading zeros — most visibly for SegmentNameComponent values (e.g., segment 0 as a two-byte value instead of one byte).
ndn-cxx and NDNts both enforce this strictly. Segment and version numbers decoded to wrong values, causing content-fetching pipelines to fail silently (looking for a segment that wasn’t there, or returning the wrong data).
The fix. The TLV integer encoder was corrected to always use the smallest encoding for the given value. Existing unit tests were extended to cover the boundary cases.
3. Default Signature: DigestSha256
The problem. ndn-cxx’s ndnpeek validates Data signatures it receives. When ndn-rs produced Data with no signature (or with an ephemeral Ed25519 key that the consumer had never seen), the consumer rejected the packet.
Ed25519 authentication requires a trust anchor — the consumer must have the producer’s certificate. That is appropriate for production but not for an interop smoke-test between strangers. DigestSha256 (type 0x01 per NDN Packet Format v0.3) is a self-contained integrity check: the SignatureValue is SHA-256 of the signed portion of the packet; no key distribution is needed.
The fix. DigestSha256 became the default signing mode for all ndn-rs-produced Data (DataBuilder::sign_digest_sha256()). Consumers can verify it with the public material already in the packet.
4. CanBePrefix Response Naming
The problem. Segmented-fetch consumers (NDNts ndncat get-segmented, ndn-cxx ndnpeek --pipeline) work in two phases:
- Send a CanBePrefix Interest for the prefix (e.g.,
/example). - Extract the versioned name from the first response, then fetch each segment by its
SegmentNameComponent(TLV0x32).
Step 2 relies on the response being named /example/v=<timestamp>/<seg=0>, where the VersionNameComponent (TLV 0x36) sits at name[-2]. NDNts specifically probes name[-2] when using --ver=cbp (version via CanBePrefix).
ndn-put was responding to CanBePrefix Interests with a Data named /example/<seg=0> — no version component. NDNts found no version at name[-2], assumed the response was a bare (non-versioned) packet, and aborted or returned wrong content.
The fix. ndn-put now always builds a versioned prefix (/<name>/v=<µs-timestamp>) at startup and responds to CanBePrefix discovery with a Data named /<name>/v=<ts>/<seg=0>, matching ndnputchunks behavior exactly.
5. NDNts Signed Interest v0.3 Format
The problem. NDNts uses the NDN Packet Format v0.3 Signed Interest format for management commands (rib/register, etc.). A v0.3 Signed Interest looks like:
/prefix/params-sha256=<digest> ← ParametersSha256DigestComponent at name[-1]
ApplicationParameters TLV ← ControlParameters encoded here
InterestSignatureInfo TLV
InterestSignatureValue TLV
The ParametersSha256DigestComponent (type 0x02) appears as name[4] when the Interest name has four ordinary components. ndn-fwd’s management handler read ControlParameters from name[4], found the digest component (not ControlParameters), and returned a parse error. The rib/register attempt was silently dropped.
The fix. The management handler now falls back to interest.app_parameters() when the named position doesn’t decode as ControlParameters:
#![allow(unused)]
fn main() {
let params = parsed.params
.or_else(|| interest.app_parameters()
.and_then(|app| ControlParameters::decode(app).ok()));
}
This handles both the legacy four-component format and the v0.3 Signed Interest format in one path.
6. Dataset Queries Must Use Unsigned Interests
The problem. The NFD Management Protocol distinguishes between two kinds of requests:
- Command verbs (rib/register, face/create, …) — modify state; require a Signed Interest.
- Dataset queries (face/list, fib/list, rib/list) — read state; accept unsigned Interests.
yanfd enforces this strictly: a signed Interest to a dataset endpoint is rejected. NFD accepts either but logs a warning.
ndn-ctl was sending Signed Interests for everything, including face list and route list. Against yanfd, these always failed. The interop scripts were using the returned face list to figure out which face NDNts had connected on; a failed face list meant the route registration fallback couldn’t work.
As a workaround, ndn-ctl used nfdc (ndn-cxx’s management tool) for face listing. This introduced a dependency on ndn-cxx being installed in the test container.
The fix. ndn-ctl was updated to send unsigned Interests for dataset-query verbs and Signed Interests only for command verbs. The nfdc dependency was removed.
7. Nack LP Packet Pass-Through
The problem. When no route exists for a prefix, a forwarder sends back a Nack(NoRoute) message wrapped in an LP packet:
LpPacket (0x64)
Nack (0xFD0320)
NackReason: NoRoute (0x64)
Fragment
Interest (0x05) …
The strip_lp() function was designed to unwrap LP-framed packets and return the inner bytes. For data LP packets it returned the inner Interest or Data bytes correctly. For Nack LP packets, however, it returned the raw LP bytes unchanged (starting with 0x64). The caller then tried Data::decode(bytes_starting_with_0x64) and got:
Error: decode: unknown packet type 0x64
This appeared as a fetch failure with no indication that a NoRoute Nack had been received. It made timing-related flakes very hard to diagnose, since the consumer saw a decode error rather than a routing error.
The fix. strip_lp() was updated to detect Nack LP packets and return an Err(Nack) instead of raw bytes. Callers that only handle Data (like ndn-peek) propagate this as an intelligible “NoRoute Nack received” error.
8. Face ID Recycling in NDNts Route Registration
The problem. When NDNts’s automatic rib/register fails (e.g., because of a Signed Interest format mismatch — see §5), the interop script must manually register a route to NDNts’s face. To do that, it needs to know NDNts’s face ID.
The original approach was a PRE/POST face-list diff:
PRE=$(ndn-ctl face list) # snapshot before ndncat starts
ndncat put-segmented & # start NDNts producer
sleep 1
POST=$(ndn-ctl face list) # snapshot after
NDNTS_FACE=$(comm -13 <(echo "$PRE") <(echo "$POST") | ...)
The assumption: any face ID in POST but not in PRE belongs to ndncat.
This broke because of LIFO face ID recycling. The face table’s free list is a Vec (stack). When the PRE ndn-ctl process connects, it gets face ID 1. When it disconnects, face ID 1 goes back on the free list. When ndncat connects next, it also gets face ID 1 (reused). When the POST ndn-ctl connects, it gets face ID 2. The diff sees:
- PRE:
{1} - POST:
{1 (ndncat), 2 (POST ndn-ctl)} comm -13:{2}— the POST ndn-ctl’s own face, not ndncat’s
The script then ran route add --face 2, which pointed to the now-disconnected POST ndn-ctl. That face was immediately removed from the FIB when ndn-ctl exited, leaving no route and producing a NoRoute Nack.
The fix. The PRE/POST diff was replaced with a two-step strategy:
- FIB inspection (primary): After the sleep, query the FIB. If NDNts’s prefix is already there, it self-registered via rib/register — no manual step needed.
- Lowest-non-reserved face (fallback): If the prefix is not in the FIB, enumerate all faces with ID below
0xFFFF_0000(the reserved range) and pick the lowest numeric ID. NDNts connected before this query, so it has the smallest face ID currently active.
NDNTS_FACE=$(
ndn-ctl --socket "${FWD_SOCK}" face list \
| grep -oE 'faceid=[0-9]+' | sed 's/faceid=//' \
| awk '$1 < 4294901760' | sort -n | head -1
)
9. Registration Timing: Node.js and NFD
The problem. Two timing issues caused intermittent failures under the original sleep 0.5 wait:
-
Node.js startup: NDNts runs on Node.js, which loads and JIT-compiles modules before doing anything. In a cold Docker container, startup plus rib/register plus FIB propagation took more than 500 ms, causing ndn-peek to send its Interest before NDNts had registered.
-
NFD’s RIB manager: NFD separates route management into a dedicated RIB daemon (nrd). A rib/register from ndnpoke travels: ndnpoke → NFD socket → RIB handler → FIB. This IPC hop adds latency that ndn-fwd (which applies RIB changes in the same async task) doesn’t have.
Both failures produced a NoRoute Nack (see §7), which appeared as “unknown packet type 0x64” before that fix was in place.
The fix. Sleep durations were matched to each forwarder’s characteristics:
| Scenario | Sleep | Reason |
|---|---|---|
| ndn-fwd + NDNts | 2 s | Node.js JIT warmup + rib/register + FIB |
| NFD + ndn-cxx | 1 s | NFD RIB manager IPC hop |
| ndn-fwd + ndn-cxx | 0.5 s | C++ startup is fast; ndn-fwd applies routes inline |
| yanfd + NDNts | 2 s | yanfd RIB propagation + Node.js startup |
10. Manual Route Registration Fallback
The problem. Even after fixing the Signed Interest format (§5), NDNts’s automatic rib/register is not guaranteed: the RIB handler may not be ready, the Interest may expire in transit, or the route may be registered on the wrong prefix scope. Without a fallback, any registration failure means no route and a silent test failure.
The fix. All NDNts interop scripts now check whether automatic registration succeeded (via FIB inspection) and, if not, explicitly call:
ndn-ctl route add "${PREFIX}" --face "${NDNTS_FACE}"
This makes the happy path (NDNts self-registers) fast and the fallback path (manual) robust, with diagnostic output at each decision point so failures are visible in CI logs.
Current Status
See Interop Test Results for the live test matrix from the most recent CI run.
All eight scenarios pass on every scheduled weekly run and on every push to main that touches testbed/ or binaries/ndn-fwd/. Failures are tracked as separate quality signals — they do not block merges but are investigated promptly.
What Is Not Yet Tested
- NDNCERT handshake between ndn-rs and ndn-cxx CAs
- SVS (State Vector Sync) against NDNts SVS library
- Multicast UDP face interop (ndn-fwd ↔ NFD multicast group)
- ndn-cxx signed Interests for management (tested with NDNts v0.3 format; ndn-cxx uses a different format)
Implementing a Face
This guide walks through implementing a custom face type for ndn-rs. Faces are the abstraction over network transports – every link-layer connection (UDP, TCP, Ethernet, serial, in-process channel) is a face.
The Face Trait
The core trait lives in ndn-transport (crates/foundation/ndn-transport/src/face.rs):
#![allow(unused)]
fn main() {
pub trait Face: Send + Sync + 'static {
fn id(&self) -> FaceId;
fn kind(&self) -> FaceKind;
fn remote_uri(&self) -> Option<String> { None }
fn local_uri(&self) -> Option<String> { None }
fn recv(&self) -> impl Future<Output = Result<Bytes, FaceError>> + Send;
fn send(&self, pkt: Bytes) -> impl Future<Output = Result<(), FaceError>> + Send;
}
}
Key points:
id()returns aFaceId(u32)assigned by theFaceTable. Callface_table.alloc_id()to get one before constructing your face.kind()returns aFaceKindvariant classifying the transport. This determines the face’s scope (local vs. non-local) and is used for NFD management reporting.recv()is called from a single dedicated task per face. It blocks (async) until a packet arrives or the face closes.send()may be called concurrently from multiple pipeline tasks. It takes&self, so internal synchronization is required if the underlying transport is not inherently concurrent.remote_uri()/local_uri()are optional and used for NFD management status reporting.
There is also an optional recv_with_addr() method for multicast/broadcast faces that need to return the link-layer sender address alongside the packet.
stateDiagram-v2
[*] --> Created: FaceTable.alloc_id()
Created --> Registered: face_table.insert(face)
Registered --> Running: tokio::spawn(recv task)
Running --> Running: recv() / send() loop
Running --> Closing: error or shutdown signal
Closing --> Removed: face_table.remove(id)
Removed --> [*]: FaceId recycled
Adding a FaceKind Variant
If your transport does not fit an existing FaceKind, add a new variant:
- Add the variant to the
FaceKindenum incrates/foundation/ndn-transport/src/face.rs - Update
scope()to classify it asLocalorNonLocal - Update the
DisplayandFromStrimplementations
#![allow(unused)]
fn main() {
pub enum FaceKind {
// ... existing variants ...
MyTransport,
}
}
If your transport is network-facing, return FaceScope::NonLocal from scope(). If it is same-host IPC, return FaceScope::Local.
Example: A Face Wrapping a Custom Transport
Here is a minimal face wrapping a hypothetical CustomSocket type:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use bytes::Bytes;
use tokio::sync::mpsc;
use ndn_transport::{Face, FaceId, FaceKind, FaceError};
pub struct CustomFace {
id: FaceId,
/// Incoming packets buffered by the reader task.
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
/// Sender half for outgoing packets, consumed by a writer task.
tx: mpsc::Sender<Bytes>,
}
impl CustomFace {
pub fn new(
id: FaceId,
socket: CustomSocket,
buffer_size: usize,
) -> (Self, CustomFaceReader) {
let (in_tx, in_rx) = mpsc::channel(buffer_size);
let (out_tx, out_rx) = mpsc::channel(buffer_size);
let face = Self {
id,
rx: tokio::sync::Mutex::new(in_rx),
tx: out_tx,
};
// The reader/writer tasks run separately.
let reader = CustomFaceReader {
socket: socket.clone(),
in_tx,
out_rx,
};
(face, reader)
}
}
impl Face for CustomFace {
fn id(&self) -> FaceId {
self.id
}
fn kind(&self) -> FaceKind {
FaceKind::Tcp // or your custom variant
}
fn remote_uri(&self) -> Option<String> {
Some("custom://10.0.0.1:9000".to_string())
}
async fn recv(&self) -> Result<Bytes, FaceError> {
self.rx
.lock()
.await
.recv()
.await
.ok_or(FaceError::Closed)
}
async fn send(&self, pkt: Bytes) -> Result<(), FaceError> {
self.tx
.send(pkt)
.await
.map_err(|_| FaceError::Closed)
}
}
}
Registering with FaceTable
The engine’s FaceTable manages all active faces. After constructing your face:
#![allow(unused)]
fn main() {
// Allocate an ID from the table.
let id = face_table.alloc_id();
// Construct the face with that ID.
let (face, reader) = CustomFace::new(id, socket, 256);
// Register it. The table wraps it in Arc<dyn ErasedFace>.
face_table.insert(face);
// Spawn the reader/writer task.
tokio::spawn(reader.run());
}
The FaceTable uses DashMap<FaceId, Arc<dyn ErasedFace>> internally. Pipeline stages clone the Arc handle out of the table before calling send(), so no table lock is held during I/O. Face IDs are recycled when a face is removed.
Design Tips
recv: one task, one consumer
💡 Key insight:
recv()is called from exactly one dedicated task per face. The engine spawns this task automatically. You never need to makerecv()safe for concurrent callers – it is inherently single-consumer. This simplifies implementation: you can use atokio::sync::Mutex<Receiver>without worrying about contention.
recv() is only ever called from the face’s own reader task. The engine spawns one task per face that loops on recv() and pushes decoded packets into the shared pipeline channel. You do not need to make recv() safe for concurrent callers.
send: must be &self and synchronized
⚠️ Important:
send()takes&self, not&mut self. Multiple pipeline tasks may callsend()concurrently on the same face. You must provide internal synchronization. The idiomatic pattern is to hold anmpsc::Sender(which isClone + Send) and delegate actual I/O to a dedicated writer task. Do not use aMutex<Socket>directly – it would serialize all outgoing traffic through a single lock.
send() is called from arbitrary pipeline tasks – potentially many at once. Since the signature is &self (not &mut self), you must synchronize internally. The standard pattern is an mpsc::Sender that buffers outgoing packets for a dedicated writer task:
graph LR
P1[Pipeline task 1] -->|send| TX[mpsc::Sender]
P2[Pipeline task 2] -->|send| TX
TX --> Writer[Writer task]
Writer --> Socket[Transport socket]
The mpsc::Sender::send() is itself safe to clone and call from multiple tasks.
Backpressure via mpsc channels
Use bounded mpsc::channel(capacity) for both the inbound and outbound paths. This provides natural backpressure:
- Inbound: if the pipeline is slow, the reader task blocks on
in_tx.send()until there is room, applying backpressure to the transport. - Outbound: if the transport is slow,
send()blocks onout_tx.send()until the writer task drains the queue, propagating backpressure to the pipeline.
A capacity of 128–256 packets is a reasonable starting point. Too small and you starve throughput; too large and you add latency during congestion.
LP encoding convention
graph TD
subgraph "Network Face (NonLocal scope)"
direction LR
I1["Interest / Data<br/>(bare TLV)"] --> LP["LpPacket wrapper<br/>(type 0x50)"]
LP --> FRAG{"MTU exceeded?"}
FRAG -->|"No"| W1["Wire: single LpPacket"]
FRAG -->|"Yes"| W2["Wire: LpPacket fragments"]
end
subgraph "Local Face (Local scope)"
direction LR
I2["Interest / Data<br/>(bare TLV)"] --> W3["Passed as-is<br/>(no LP wrapping)"]
end
style LP fill:#fff3e0,stroke:#FF9800
style W3 fill:#c8e6c9,stroke:#4CAF50
Network-facing transports (UDP, TCP, Ethernet, serial) should wrap packets in an NDNLPv2 LpPacket envelope before writing to the wire. Local transports (Unix, App, SHM) send the raw packet as-is. The existing StreamFace makes this explicit via an lp_encode constructor parameter – follow the same convention based on FaceKind::scope().
🎯 Tip: When in doubt about whether your face needs LP wrapping, check
FaceKind::scope(). If it returnsNonLocal, you almost certainly need LP encoding. StudyUdpFace(simplest network face) orInProcFace(simplest local face) as reference implementations for your transport category.
Error handling
Return FaceError::Closed when the underlying transport is permanently gone. Return FaceError::Io(e) for transient I/O errors. Return FaceError::Full if a non-blocking send would exceed buffer capacity (the pipeline may retry or Nack).
Existing face implementations
Study these for patterns:
| Face | Crate | Notes |
|---|---|---|
UdpFace | ndn-faces | Datagram transport, simplest network face |
TcpFace | ndn-faces | Stream transport via StreamFace helper |
InProcFace | ndn-faces | In-process channel pair, no serialization |
ShmFace | ndn-faces | Shared-memory ring buffer, highest throughput |
NamedEtherFace | ndn-faces | Raw Ethernet via AF_PACKET |
SerialFace | ndn-faces | UART/serial with framing |
WfbFace | ndn-faces | Wifibroadcast NG integration |
WebSocketFace | ndn-faces | WebSocket transport |
ComputeFace | ndn-compute | Named function networking |
Implementing a Strategy
This guide covers how to write a custom forwarding strategy for ndn-rs. Strategies are pure decision functions – they read state through an immutable StrategyContext and return ForwardingAction values telling the pipeline what to do.
The Strategy Trait
The trait lives in ndn-strategy (crates/engine/ndn-strategy/src/strategy.rs):
#![allow(unused)]
fn main() {
pub trait Strategy: Send + Sync + 'static {
/// Canonical name identifying this strategy (e.g. /localhost/nfd/strategy/my-strategy).
fn name(&self) -> &Name;
/// Synchronous fast path. Return Some(actions) to skip the async overhead.
fn decide(&self, _ctx: &StrategyContext) -> Option<SmallVec<[ForwardingAction; 2]>> {
None // default: fall through to async path
}
/// Called when an Interest needs a forwarding decision.
fn after_receive_interest(
&self,
ctx: &StrategyContext,
) -> impl Future<Output = SmallVec<[ForwardingAction; 2]>> + Send;
/// Called when Data arrives and needs forwarding.
fn after_receive_data(
&self,
ctx: &StrategyContext,
) -> impl Future<Output = SmallVec<[ForwardingAction; 2]>> + Send;
/// Called when a PIT entry times out. Default: Suppress.
fn on_interest_timeout(
&self,
_ctx: &StrategyContext,
) -> impl Future<Output = ForwardingAction> + Send {
async { ForwardingAction::Suppress }
}
/// Called when a Nack arrives. Default: Suppress.
fn on_nack(
&self,
_ctx: &StrategyContext,
_reason: NackReason,
) -> impl Future<Output = ForwardingAction> + Send {
async { ForwardingAction::Suppress }
}
}
}
Synchronous vs. async path
Most strategies make decisions synchronously – they just look at the FIB entry and measurements. Override decide() to return Some(actions) in this case. The engine’s ErasedStrategy wrapper skips the Box::pin heap allocation when decide() returns Some.
Only use the async after_receive_interest() path if you genuinely need to await something (e.g., a remote lookup or a timer for delayed probing).
⚠️ Important: Strategies are immutable –
StrategyContextprovides only shared (&) references to engine state. A strategy cannot modify the FIB, PIT, or CS. If your strategy needs mutable state (e.g., a packet counter or a round-robin index), useAtomicU64or other atomic types within the strategy struct itself. Do not useMutexunless you must protect complex state – atomics avoid lock contention on the hot path.
StrategyContext
The context provides a read-only view of engine state:
#![allow(unused)]
fn main() {
pub struct StrategyContext<'a> {
/// The name being forwarded.
pub name: &'a Arc<Name>,
/// The face the packet arrived on.
pub in_face: FaceId,
/// FIB entry for the longest matching prefix (None = no route).
pub fib_entry: Option<&'a FibEntry>,
/// PIT token for the current Interest.
pub pit_token: Option<PitToken>,
/// EWMA RTT and satisfaction measurements per (prefix, face).
pub measurements: &'a MeasurementsTable,
/// Cross-layer enrichment data (radio metrics, flow stats, etc.).
pub extensions: &'a AnyMap,
}
}
The FibEntry contains a Vec<FibNexthop> where each nexthop has a face_id and cost. Use fib_entry.nexthops_excluding(ctx.in_face) for split-horizon filtering.
ForwardingAction Variants
#![allow(unused)]
fn main() {
pub enum ForwardingAction {
/// Forward to these faces immediately.
Forward(SmallVec<[FaceId; 4]>),
/// Forward after a delay (enables probe-and-fallback).
ForwardAfter { faces: SmallVec<[FaceId; 4]>, delay: Duration },
/// Send a Nack back to the requester.
Nack(NackReason),
/// Suppress -- do not forward (loop or policy decision).
Suppress,
}
}
A strategy can return multiple actions in its SmallVec. For example, a probing strategy might return a primary Forward and a ForwardAfter probe simultaneously.
NackReason variants: NoRoute, Duplicate, Congestion, NotYet.
MeasurementsTable
The MeasurementsTable tracks per-(prefix, face) performance data:
- EWMA RTT (
EwmaRtt): smoothed RTT in nanoseconds, variance, sample count. Updated on every Data arrival. - Satisfaction rate: EWMA of Interest satisfaction (0.0–1.0). Updated on Data arrival and PIT timeout.
Access it from the strategy context:
#![allow(unused)]
fn main() {
if let Some(entry) = ctx.measurements.get(ctx.name) {
for (face_id, rtt) in &entry.rtt_per_face {
tracing::debug!(%face_id, srtt_ms = rtt.srtt_ns / 1e6, "RTT measurement");
}
tracing::debug!(rate = entry.satisfaction_rate, "satisfaction");
}
}
The table is updated automatically by the MeasurementsUpdateStage in the Data pipeline. Strategies only read from it.
🔧 Implementation note: The
MeasurementsTableis the primary mechanism for strategies to maintain state without being stateful themselves. Instead of tracking RTT in the strategy struct, read it from the measurements table – it is updated automatically on every satisfied Interest. This keeps strategies pure and composable: swapping a strategy at a prefix preserves the accumulated measurements.
Example: Round-Robin Load Balancer
This strategy distributes Interests across FIB nexthops using round-robin selection:
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};
use smallvec::{SmallVec, smallvec};
use ndn_packet::Name;
use ndn_pipeline::{ForwardingAction, NackReason};
use ndn_strategy::{Strategy, StrategyContext};
pub struct RoundRobinStrategy {
name: Name,
counter: AtomicU64,
}
impl RoundRobinStrategy {
pub fn new() -> Self {
Self {
name: "/localhost/nfd/strategy/round-robin".parse().unwrap(),
counter: AtomicU64::new(0),
}
}
}
impl Strategy for RoundRobinStrategy {
fn name(&self) -> &Name {
&self.name
}
fn decide(&self, ctx: &StrategyContext) -> Option<SmallVec<[ForwardingAction; 2]>> {
let Some(fib) = ctx.fib_entry else {
return Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]);
};
let nexthops = fib.nexthops_excluding(ctx.in_face);
if nexthops.is_empty() {
return Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]);
}
let idx = self.counter.fetch_add(1, Ordering::Relaxed) as usize % nexthops.len();
Some(smallvec![ForwardingAction::Forward(
smallvec![nexthops[idx].face_id]
)])
}
async fn after_receive_interest(
&self,
ctx: &StrategyContext<'_>,
) -> SmallVec<[ForwardingAction; 2]> {
// Unreachable when decide() always returns Some.
self.decide(ctx).unwrap()
}
async fn after_receive_data(
&self,
_ctx: &StrategyContext<'_>,
) -> SmallVec<[ForwardingAction; 2]> {
// Fan-back to PIT in-record faces is handled by the engine.
SmallVec::new()
}
}
}
A complete runnable example is in examples/strategy-custom/.
Registration via StrategyTable
Using EngineBuilder (recommended)
The simplest way to register a strategy:
#![allow(unused)]
fn main() {
let (_engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.strategy(RoundRobinStrategy::new())
.build()
.await?;
}
This registers the strategy at its name() prefix. Interests whose names match that prefix (via longest-prefix match) will use this strategy.
Direct StrategyTable access
For dynamic registration at runtime, use the StrategyTable directly:
#![allow(unused)]
fn main() {
use ndn_store::StrategyTable;
let table: StrategyTable<dyn Strategy> = StrategyTable::new();
// Register for a specific prefix.
let prefix: Name = "/app/video".parse().unwrap();
table.insert(&prefix, Arc::new(RoundRobinStrategy::new()));
// Register as the default (root prefix).
table.insert(&Name::root(), Arc::new(BestRouteStrategy::new()));
}
The StrategyTable is a NameTrie that performs longest-prefix match, just like the FIB. The most specific matching strategy wins. If no strategy matches, the engine uses the strategy registered at the root prefix.
Design Guidelines
📝 Note: Following these guidelines ensures your strategy works correctly with hot-swap, WASM loading, and strategy composition via
StrategyFilter. Violating them (e.g., holdingArcreferences to engine internals) may cause subtle bugs when strategies are replaced at runtime.
-
Keep strategies pure. A strategy should not mutate global state. It reads from
StrategyContextand returns actions. Side effects belong in pipeline stages. -
Prefer
decide()overafter_receive_interest(). The synchronous path avoids a heap allocation per packet. -
Always handle the no-FIB case. Return
Nack(NackReason::NoRoute)whenctx.fib_entryisNone. -
Always apply split-horizon. Use
fib_entry.nexthops_excluding(ctx.in_face)to avoid sending an Interest back out the face it arrived on. -
Use measurements for adaptive strategies. The
MeasurementsTableprovides RTT and satisfaction data per face. An RTT-aware strategy might prefer the face with the lowest smoothed RTT. -
Return empty
SmallVecfromafter_receive_data(). Data fan-back to PIT consumers is handled by the engine. Only override this if your strategy needs to intercept Data (rare). -
Name your strategy following NFD convention. Use
/localhost/nfd/strategy/<name>so NFD management tools can discover and display it.
Built-in Strategies
| Strategy | Behavior |
|---|---|
BestRouteStrategy | Forward on the lowest-cost FIB nexthop (default) |
MulticastStrategy | Forward on all FIB nexthops (flood) |
See crates/engine/ndn-strategy/src/ for the implementations.
Hot-Loadable WASM Forwarding Strategies
Forwarding strategies are the brains of an NDN router. They decide, for every arriving Interest, which nexthop face (or faces) should carry it toward the data. In a traditional setup, changing that decision logic means editing Rust code, recompiling the router binary, and restarting – during which every in-flight packet is dropped and every PIT entry is lost. For a production router handling thousands of prefixes, that is a steep price to pay for tweaking a single algorithm.
ndn-rs solves this with WASM strategies: self-contained WebAssembly modules that implement the forwarding decision function, loaded into a running router at any time, assigned to specific prefixes, and replaced or rolled back in seconds – all without restarting or recompiling anything.
How It Works
The ndn-strategy-wasm crate embeds a Wasmtime
runtime inside the router process. A WASM strategy module is a .wasm binary
that exports an on_interest() function (and optionally on_nack()). The
router loads the binary, validates it, links it against a set of host-provided
functions, and wraps it in a WasmStrategy struct that implements
ErasedStrategy – the same trait object interface that native Rust strategies
use. From the pipeline’s perspective, a WASM strategy is indistinguishable from
a compiled-in one.
flowchart LR
A[".wasm binary"] -->|load| B["wasmtime::Module"]
B -->|link host ABI| C["WasmStrategy"]
C -->|Arc<dyn ErasedStrategy>| D["StrategyTable"]
D -->|longest-prefix match| E["StrategyStage in pipeline"]
Each time an Interest arrives, the pipeline’s StrategyStage performs a
longest-prefix match against the StrategyTable. If the matched entry is a
WasmStrategy, the router creates a fresh Wasmtime Store, populates it with
the current StrategyContext (incoming face, FIB nexthops, RTT measurements,
RSSI, satisfaction rates), calls the guest’s on_interest() export, and
collects the resulting ForwardingAction values.
Key detail: Each invocation gets its own
Store. There is no mutable state that persists between calls inside the WASM module. This makes the execution stateless and deterministic, which simplifies reasoning about correctness.
Writing a WASM Strategy
A WASM strategy is a program compiled to wasm32-unknown-unknown that imports
functions from the "ndn" namespace and exports entry points the router will
call.
The Guest ABI
The host exposes these imported functions to the WASM guest:
| Function | Signature | Description |
|---|---|---|
get_in_face | () -> u32 | Face ID the Interest arrived on |
get_nexthop_count | () -> u32 | Number of FIB nexthops for this name |
get_nexthop | (index: u32, out_face_id: u32, out_cost: u32) -> u32 | Write face ID and cost to guest memory; returns 0 on success |
get_rtt_ns | (face_id: u32) -> f64 | RTT in nanoseconds, or -1.0 if unknown |
get_rssi | (face_id: u32) -> i32 | RSSI in dBm, or -128 if unknown |
get_satisfaction | (face_id: u32) -> f32 | Satisfaction rate [0.0, 1.0], or -1.0 |
forward | (face_ids_ptr: u32, count: u32) | Forward Interest to the listed faces |
nack | (reason: u32) | Send a Nack (0=NoRoute, 1=Duplicate, 2=Congestion, 3=NotYet) |
suppress | () | Suppress the Interest silently |
The guest module must export:
on_interest()(required) – called for every Interest matching the assigned prefix.on_nack()(optional) – called when a Nack arrives on an out-record face.memory(required) – the guest’s linear memory, so the host can write nexthop data into it viaget_nexthop.
A Minimal Strategy in Rust
This strategy forwards every Interest to the lowest-cost nexthop, or sends a NoRoute Nack if the FIB has no entry:
#![allow(unused)]
fn main() {
// Cargo.toml:
// [lib]
// crate-type = ["cdylib"]
//
// Build with:
// cargo build --target wasm32-unknown-unknown --release
// Declare host imports from the "ndn" namespace.
#[link(wasm_import_module = "ndn")]
extern "C" {
fn get_nexthop_count() -> u32;
fn get_nexthop(index: u32, out_face_id: *mut u32, out_cost: *mut u32) -> u32;
fn forward(face_ids_ptr: *const u32, count: u32);
fn nack(reason: u32);
}
#[no_mangle]
pub extern "C" fn on_interest() {
unsafe {
let count = get_nexthop_count();
if count == 0 {
nack(0); // NoRoute
return;
}
// Read the first (lowest-cost) nexthop.
let (mut face_id, mut cost) = (0u32, 0u32);
get_nexthop(0, &mut face_id, &mut cost);
// Forward to that single face.
forward(&face_id, 1);
}
}
}
An RTT-Aware Strategy
This strategy picks the nexthop with the lowest observed RTT, falling back to cost-based ordering when RTT data is unavailable:
#![allow(unused)]
fn main() {
#[link(wasm_import_module = "ndn")]
extern "C" {
fn get_nexthop_count() -> u32;
fn get_nexthop(index: u32, out_face_id: *mut u32, out_cost: *mut u32) -> u32;
fn get_rtt_ns(face_id: u32) -> f64;
fn forward(face_ids_ptr: *const u32, count: u32);
fn nack(reason: u32);
}
#[no_mangle]
pub extern "C" fn on_interest() {
unsafe {
let count = get_nexthop_count();
if count == 0 {
nack(0);
return;
}
let mut best_face: u32 = 0;
let mut best_rtt: f64 = f64::MAX;
for i in 0..count {
let (mut face_id, mut cost) = (0u32, 0u32);
get_nexthop(i, &mut face_id, &mut cost);
let rtt = get_rtt_ns(face_id);
// Use RTT if available, otherwise use cost as a tiebreaker.
let score = if rtt < 0.0 { cost as f64 * 1e9 } else { rtt };
if score < best_rtt {
best_rtt = score;
best_face = face_id;
}
}
forward(&best_face, 1);
}
}
}
Building
# From your strategy crate directory:
cargo build --target wasm32-unknown-unknown --release
# The output is at:
# target/wasm32-unknown-unknown/release/my_strategy.wasm
The resulting .wasm file is typically 1-10 KB for a pure forwarding strategy.
No standard library is needed – #![no_std] works fine and produces smaller
binaries.
Hot-Loading and Prefix Assignment
Loading a WASM strategy into a running router is a two-step process: create the
WasmStrategy instance, then insert it into the StrategyTable at the desired
prefix.
sequenceDiagram
participant Op as Operator
participant Mgmt as Management API
participant ST as StrategyTable
participant Pipeline as StrategyStage
Op->>Mgmt: Load WASM binary + assign to /prefix
Mgmt->>Mgmt: WasmStrategy::from_file() or from_bytes()
Mgmt->>ST: strategy_table.insert(/prefix, Arc<WasmStrategy>)
Note over ST: Old strategy Arc dropped<br/>when last in-flight packet finishes
Pipeline->>ST: lpm(/prefix/name) on next Interest
ST-->>Pipeline: Arc<WasmStrategy>
Pipeline->>Pipeline: Execute on_interest() in WASM sandbox
Programmatic Loading
#![allow(unused)]
fn main() {
use ndn_strategy_wasm::WasmStrategy;
// Load from a file on disk.
let strategy = WasmStrategy::from_file(
Name::from_str("/localhost/nfd/strategy/my-rtt-strategy")?,
"/path/to/my_strategy.wasm",
10_000, // fuel limit
)?;
// Or load from in-memory bytes.
let strategy = WasmStrategy::from_bytes(
Name::from_str("/localhost/nfd/strategy/my-rtt-strategy")?,
&wasm_bytes,
10_000,
)?;
// Assign to a prefix. Takes effect immediately for new Interests.
engine.strategy_table().insert(
&Name::from_str("/app/video")?,
Arc::new(strategy),
);
}
Rollback
Rolling back is just another insert call. Keep the previous strategy around
(or know its name) and re-assign:
#![allow(unused)]
fn main() {
// Roll back to the built-in best-route strategy.
engine.strategy_table().insert(
&Name::from_str("/app/video")?,
Arc::new(BestRouteStrategy::new()),
);
}
Or remove the prefix-specific override entirely, letting it inherit from a parent prefix:
#![allow(unused)]
fn main() {
engine.strategy_table().remove(&Name::from_str("/app/video")?);
}
Because StrategyTable stores Arc<dyn ErasedStrategy>, the swap is atomic
from the perspective of the pipeline. In-flight packets that already obtained an
Arc clone of the old strategy will finish naturally. New packets pick up the
new strategy immediately. There is no window of inconsistency and no dropped
packets.
Via the Management Protocol
The router’s NFD-compatible management API supports strategy assignment through
the standard strategy-choice/set and strategy-choice/unset commands:
strategy-choice/set Name=/app/video Strategy=/localhost/nfd/strategy/wasm-rtt
strategy-choice/unset Name=/app/video
strategy-choice/list
Safety and Sandboxing
A forwarding strategy runs on the hot path of every packet. A bug in a native strategy can crash the entire router. WASM strategies run inside a sandbox that provides strong isolation guarantees:
Fuel-limited execution. Every WASM invocation is given a fixed fuel budget
(default: 10,000 instructions, roughly 50 microseconds worst case). If the
module exhausts its fuel – for example, due to an infinite loop – Wasmtime
traps the execution and the router returns ForwardingAction::Suppress for
that packet. The router itself continues running without interruption.
Memory cap. Guest modules are limited to a fixed memory allocation (default: 1 MB). A module cannot allocate unbounded memory or access memory outside its own linear address space.
No I/O access. WASM modules have no access to the filesystem, network, or
system clock. The only way they can interact with the outside world is through
the host-provided "ndn" namespace functions. A malicious or buggy module
cannot open sockets, read files, or exfiltrate data.
Graceful degradation. Every failure mode – instantiation failure, missing
exports, fuel exhaustion, traps – results in Suppress. The Interest is
silently dropped rather than forwarded to an incorrect face. This is the safest
default: the PIT entry will eventually time out, and the consumer can retry.
Note: The fuel limit is configurable per
WasmStrategyinstance. For strategies that need more computation (e.g., iterating over many nexthops with cross-layer data), increase the fuel budget. Monitor thestrategytracing span for fuel exhaustion warnings.
When to Use WASM Strategies
WASM strategies are not a replacement for native Rust strategies in all cases. Native strategies have zero overhead and full access to Rust’s type system. WASM strategies trade a small amount of performance for operational flexibility.
Research and experimentation. Testing a new forwarding algorithm no longer requires a full Rust build cycle. Write the logic, compile to WASM (sub-second for small modules), upload it to the router, assign it to a test prefix, and observe the results. If it misbehaves, roll back in seconds.
A/B testing. Assign different WASM strategies to different prefixes and
compare their performance using the measurements table. For example, run an
RTT-based strategy on /app/video and a multicast strategy on
/app/video-experimental, then compare satisfaction rates.
Multi-tenant routers. In a shared infrastructure setting, different tenants can supply their own forwarding strategies for their prefix space. The WASM sandbox ensures that one tenant’s strategy cannot interfere with another’s traffic or crash the router.
Rapid incident response. If a particular prefix is experiencing poor forwarding performance, an operator can upload a patched strategy targeting just that prefix without touching the rest of the router’s configuration.
Teaching and prototyping. The guest ABI is simple enough that a strategy can
be written in WAT (WebAssembly Text) directly, or in any language that compiles
to wasm32-unknown-unknown. This makes it accessible for students and
researchers who may not be familiar with the full ndn-rs Rust codebase.
Implementing a Discovery Protocol
This guide walks through writing a custom discovery protocol for ndn-rs. Discovery protocols run inside the engine — they observe face lifecycle events and inbound packets, and they mutate engine state (faces, FIB routes, the neighbor table) through a narrow context interface.
The DiscoveryProtocol Trait
The trait lives in ndn-discovery (crates/engine/ndn-discovery/src/protocol.rs):
#![allow(unused)]
fn main() {
pub trait DiscoveryProtocol: Send + Sync + 'static {
fn protocol_id(&self) -> ProtocolId;
fn claimed_prefixes(&self) -> &[Name];
fn on_face_up(&self, face_id: FaceId, ctx: &dyn DiscoveryContext);
fn on_face_down(&self, face_id: FaceId, ctx: &dyn DiscoveryContext);
fn on_inbound(
&self,
raw: &Bytes,
incoming_face: FaceId,
meta: &InboundMeta,
ctx: &dyn DiscoveryContext,
) -> bool;
fn on_tick(&self, now: Instant, ctx: &dyn DiscoveryContext);
fn tick_interval(&self) -> Duration {
Duration::from_millis(100)
}
}
}
Key points:
protocol_id()returns aProtocolId(&'static str)— a short ASCII tag like"swim"or"beacon". Used to label FIB routes so they can be bulk-removed when the protocol shuts down.claimed_prefixes()declares which NDN name prefixes this protocol owns.CompositeDiscoverychecks at construction time that no two protocols overlap. All discovery traffic lives under/ndn/local/.on_inbound()is called for every raw packet before it enters the forwarding pipeline. Returntrueto consume the packet (preventing forwarding); returnfalseto let it pass through. This is how hello packets and probes are intercepted without polluting the forwarding plane.on_tick()is called periodically attick_interval. Use it to send hellos, check timeouts, rotate probes, and gossip state.- Protocols cannot hold mutable references to engine internals. All mutations go through
DiscoveryContext.
The DiscoveryContext Interface
DiscoveryContext is the boundary between your protocol and the engine. It gives you everything you need without exposing engine internals:
#![allow(unused)]
fn main() {
pub trait DiscoveryContext: Send + Sync {
// Face management
fn alloc_face_id(&self) -> FaceId;
fn add_face(&self, face: Arc<dyn ErasedFace>) -> FaceId;
fn remove_face(&self, face_id: FaceId);
// FIB management (routes are tagged with your ProtocolId)
fn add_fib_entry(&self, prefix: &Name, nexthop: FaceId, cost: u32, owner: ProtocolId);
fn remove_fib_entry(&self, prefix: &Name, nexthop: FaceId, owner: ProtocolId);
fn remove_fib_entries_by_owner(&self, owner: ProtocolId);
// Neighbor table
fn neighbors(&self) -> Arc<dyn NeighborTableView>;
fn update_neighbor(&self, update: NeighborUpdate);
// Direct packet send (bypasses pipeline)
fn send_on(&self, face_id: FaceId, pkt: Bytes);
fn now(&self) -> Instant;
}
}
All FIB routes you install should carry your ProtocolId as the owner. The engine calls remove_fib_entries_by_owner when the protocol shuts down, cleaning up all routes in one call regardless of how many prefixes you registered.
Designing Your Protocol’s Packet Format
Discovery protocols communicate using NDN Interest and Data packets, just like any other NDN traffic. The difference is that discovery packets are intercepted in on_inbound before they reach the pipeline, and they are sent directly via ctx.send_on() rather than through a Consumer.
Choosing prefixes
All discovery traffic must live under /ndn/local/ — this prefix is link-local scoped and never forwarded off the local subnet. Choose sub-prefixes that are specific to your protocol:
/ndn/local/nd/hello neighbor discovery hellos
/ndn/local/nd/probe/direct SWIM direct probes
/ndn/local/nd/probe/via SWIM indirect probes
/ndn/local/sd/register service registration
/ndn/local/sd/query service lookup
Intercepting packets
In on_inbound, check whether the raw packet belongs to your protocol by inspecting its NDN name prefix before full decoding:
#![allow(unused)]
fn main() {
fn on_inbound(
&self,
raw: &Bytes,
incoming_face: FaceId,
meta: &InboundMeta,
ctx: &dyn DiscoveryContext,
) -> bool {
// Fast path: check if this looks like one of our packets.
// Decode only what you need to route it internally.
let Ok(interest) = Interest::decode(raw.clone()) else {
return false;
};
if interest.name().has_prefix(&self.hello_prefix) {
self.handle_hello(interest, incoming_face, meta, ctx);
return true; // consumed — do not forward
}
false // not ours — let the pipeline handle it
}
}
Returning true prevents the packet from entering the forwarding pipeline. Only return true for packets your protocol actually handles.
Example: A Simple Beacon Protocol
Here is a minimal but complete protocol that periodically broadcasts a beacon Interest on every known face, and tracks which peers respond.
State
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use bytes::Bytes;
use ndn_discovery::{
DiscoveryContext, DiscoveryProtocol, InboundMeta,
NeighborEntry, NeighborState, NeighborUpdate, ProtocolId,
};
use ndn_packet::{Name, encode::InterestBuilder};
use ndn_transport::FaceId;
pub struct BeaconProtocol {
node_name: Name,
beacon_prefix: Name,
hello_interval: Duration,
state: Mutex<BeaconState>,
}
struct BeaconState {
known_faces: Vec<FaceId>,
last_hello: Option<Instant>,
// peer name → last-seen time
peers: HashMap<Name, Instant>,
}
impl BeaconProtocol {
pub fn new(node_name: Name) -> Self {
Self {
beacon_prefix: "/ndn/local/beacon".parse().unwrap(),
node_name,
hello_interval: Duration::from_secs(1),
state: Mutex::new(BeaconState {
known_faces: Vec::new(),
last_hello: None,
peers: HashMap::new(),
}),
}
}
}
}
Implementing the trait
#![allow(unused)]
fn main() {
impl DiscoveryProtocol for BeaconProtocol {
fn protocol_id(&self) -> ProtocolId {
ProtocolId("beacon")
}
fn claimed_prefixes(&self) -> &[Name] {
std::slice::from_ref(&self.beacon_prefix)
}
fn on_face_up(&self, face_id: FaceId, _ctx: &dyn DiscoveryContext) {
self.state.lock().unwrap().known_faces.push(face_id);
}
fn on_face_down(&self, face_id: FaceId, ctx: &dyn DiscoveryContext) {
let mut s = self.state.lock().unwrap();
s.known_faces.retain(|&id| id != face_id);
}
fn on_inbound(
&self,
raw: &Bytes,
incoming_face: FaceId,
_meta: &InboundMeta,
ctx: &dyn DiscoveryContext,
) -> bool {
let Ok(interest) = ndn_packet::Interest::decode(raw.clone()) else {
return false;
};
if !interest.name().has_prefix(&self.beacon_prefix) {
return false;
}
// Extract the sender name from the Interest name:
// /ndn/local/beacon/<sender-name-uri>/<nonce>
let components: Vec<_> = interest.name().components().collect();
if components.len() < 4 {
return false;
}
// The third component onwards (after /ndn/local/beacon) is the sender name.
// In a real implementation you would encode this properly in the payload.
let sender_name: Name = "/ndn/example/peer".parse().unwrap(); // placeholder
// Update neighbor table.
ctx.update_neighbor(NeighborUpdate::SetState {
name: sender_name.clone(),
state: NeighborState::Established { last_seen: ctx.now() },
});
// If this is a new peer, install a FIB route and record it.
{
let mut s = self.state.lock().unwrap();
if !s.peers.contains_key(&sender_name) {
ctx.add_fib_entry(
&sender_name,
incoming_face,
10,
self.protocol_id(),
);
ctx.update_neighbor(NeighborUpdate::Upsert(
NeighborEntry::new(sender_name.clone()),
));
}
s.peers.insert(sender_name, ctx.now());
}
true // consumed
}
fn on_tick(&self, now: Instant, ctx: &dyn DiscoveryContext) {
let (should_send, faces) = {
let mut s = self.state.lock().unwrap();
let due = s.last_hello
.map(|t| now.duration_since(t) >= self.hello_interval)
.unwrap_or(true);
if due {
s.last_hello = Some(now);
}
(due, s.known_faces.clone())
};
if !should_send {
return;
}
// Build a beacon Interest: /ndn/local/beacon/<my-name>/<nonce>
let beacon_name: Name = format!(
"/ndn/local/beacon/{}/{}",
self.node_name,
rand::random::<u32>()
).parse().unwrap();
let wire = InterestBuilder::new(beacon_name)
.lifetime(Duration::from_millis(500))
.build();
// Broadcast on every known face.
for face_id in faces {
ctx.send_on(face_id, wire.clone());
}
// Evict peers not seen in 3× the hello interval.
let deadline = now - self.hello_interval * 3;
let mut s = self.state.lock().unwrap();
let stale: Vec<_> = s.peers
.iter()
.filter(|(_, &t)| t < deadline)
.map(|(n, _)| n.clone())
.collect();
for name in stale {
s.peers.remove(&name);
ctx.remove_fib_entries_by_owner(self.protocol_id());
ctx.update_neighbor(NeighborUpdate::Remove(name));
}
}
fn tick_interval(&self) -> Duration {
Duration::from_millis(100)
}
}
}
Registering with the engine
#![allow(unused)]
fn main() {
use ndn_engine::{EngineBuilder, EngineConfig};
let node_name: Name = "/ndn/site/mynode".parse()?;
let beacon = BeaconProtocol::new(node_name);
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.discovery(beacon)
.build()
.await?;
}
If you want to run two protocols simultaneously, wrap them in CompositeDiscovery:
#![allow(unused)]
fn main() {
use ndn_discovery::CompositeDiscovery;
let discovery = CompositeDiscovery::new()
.add(UdpNeighborDiscovery::new(config)?)
.add(MyServiceDiscovery::new());
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.discovery(discovery)
.build()
.await?;
}
CompositeDiscovery checks at construction time that no two protocols claim overlapping prefixes and routes inbound packets to the correct protocol based on name prefix match.
Testing Your Protocol in Isolation
Because DiscoveryProtocol only interacts with the engine through DiscoveryContext, you can test it without a running engine by implementing a stub context:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use ndn_discovery::{NeighborTable, NeighborTableView};
struct StubCtx {
fib: Mutex<Vec<(Name, FaceId)>>,
neighbors: Arc<NeighborTable>,
}
impl StubCtx {
fn new() -> Self {
Self {
fib: Mutex::new(Vec::new()),
neighbors: NeighborTable::new(),
}
}
}
impl DiscoveryContext for StubCtx {
fn alloc_face_id(&self) -> FaceId { FaceId(99) }
fn add_face(&self, _: Arc<dyn ndn_transport::ErasedFace>) -> FaceId { FaceId(99) }
fn remove_face(&self, _: FaceId) {}
fn add_fib_entry(&self, prefix: &Name, nexthop: FaceId, _cost: u32, _owner: ProtocolId) {
self.fib.lock().unwrap().push((prefix.clone(), nexthop));
}
fn remove_fib_entry(&self, _: &Name, _: FaceId, _: ProtocolId) {}
fn remove_fib_entries_by_owner(&self, _: ProtocolId) {
self.fib.lock().unwrap().clear();
}
fn neighbors(&self) -> Arc<dyn NeighborTableView> { self.neighbors.clone() }
fn update_neighbor(&self, update: NeighborUpdate) {
self.neighbors.apply(update);
}
fn send_on(&self, _: FaceId, _: Bytes) {}
fn now(&self) -> Instant { Instant::now() }
}
#[test]
fn face_up_registers_face() {
let protocol = BeaconProtocol::new("/ndn/test/node".parse().unwrap());
let ctx = StubCtx::new();
protocol.on_face_up(FaceId(1), &ctx);
assert!(protocol.state.lock().unwrap().known_faces.contains(&FaceId(1)));
}
#[test]
fn face_down_removes_face() {
let protocol = BeaconProtocol::new("/ndn/test/node".parse().unwrap());
let ctx = StubCtx::new();
protocol.on_face_up(FaceId(1), &ctx);
protocol.on_face_down(FaceId(1), &ctx);
assert!(!protocol.state.lock().unwrap().known_faces.contains(&FaceId(1)));
}
}
}
Design Checklist
Before shipping a discovery protocol:
-
claimed_prefixes()covers every name prefix the protocol sends or listens for - All FIB entries are installed with your
ProtocolIdso they clean up automatically on shutdown -
on_inboundreturnsfalsefor packets that don’t belong to your protocol -
on_tickchecks elapsed time before sending — never assumes it is called exactly attick_interval - State mutations happen inside
Mutex—on_inboundandon_tickmay be called from different tasks - You handle the case where
on_face_downfires before anyon_inboundfor that face - The protocol has at least a stub test that verifies the context interactions without a running engine
Implementing a Routing Protocol
This guide walks through writing a custom routing protocol for ndn-rs. Routing protocols manage routes in the engine’s RIB (Routing Information Base), which is then used to derive the FIB.
Overview
A routing protocol is a Tokio background task that:
- Runs until cancelled
- Installs routes via
RoutingHandle::rib - Calls
rib.apply_to_fib()after each mutation
The RoutingProtocol trait in ndn_engine:
#![allow(unused)]
fn main() {
pub trait RoutingProtocol: Send + Sync + 'static {
/// Unique origin value for this protocol's routes.
fn origin(&self) -> u64;
/// Start as a Tokio background task.
///
/// Runs until `cancel` is cancelled. Use `handle.rib` to install/remove
/// routes and `handle.rib.apply_to_fib(&prefix, &handle.fib)` to push
/// changes into the FIB.
fn start(&self, handle: RoutingHandle, cancel: CancellationToken) -> JoinHandle<()>;
}
}
The RoutingHandle provides:
handle.rib— write routeshandle.fib— needed forrib.apply_to_fib()handle.faces— enumerate active faceshandle.neighbors— read neighbor table (discovered peers)
Minimal example: periodic beacon
#![allow(unused)]
fn main() {
use ndn_engine::{RibRoute, RoutingHandle, RoutingProtocol};
use ndn_transport::FaceId;
use ndn_packet::Name;
use ndn_config::control_parameters::origin;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
struct BeaconProtocol {
prefix: Name,
face_id: FaceId,
}
impl RoutingProtocol for BeaconProtocol {
fn origin(&self) -> u64 {
origin::AUTOCONF // 66 — pick an appropriate value
}
fn start(&self, handle: RoutingHandle, cancel: CancellationToken) -> JoinHandle<()> {
let prefix = self.prefix.clone();
let face_id = self.face_id;
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tokio::select! {
_ = interval.tick() => {
handle.rib.add(&prefix, RibRoute {
face_id,
origin: origin::AUTOCONF,
cost: 1,
flags: ndn_config::control_parameters::route_flags::CHILD_INHERIT,
expires_at: Some(std::time::Instant::now()
+ std::time::Duration::from_secs(120)),
});
handle.rib.apply_to_fib(&prefix, &handle.fib);
}
_ = cancel.cancelled() => break,
}
}
})
}
}
}
Choosing an origin value
Use a value from ndn_config::control_parameters::origin that matches your protocol’s role:
| Value | Constant | When to use |
|---|---|---|
| 0–63 | APP, AUTOREG, CLIENT | Application-managed routes |
| 64–126 | AUTOCONF…custom | Auto-configuration, custom protocols |
| 127 | DVR | Distance vector routing |
| 128 | NLSR | Link-state routing, NLSR-compatible |
| 255 | STATIC | Permanent static routes |
Lower origin values win tie-breaks when multiple protocols register the same prefix via the same face at the same cost.
The dual-protocol pattern (packet I/O)
Some routing algorithms — like DVR — need to send and receive NDN packets (route advertisements). The RoutingProtocol trait doesn’t provide packet I/O; that’s the DiscoveryProtocol domain. The solution is to implement both traits on the same struct, sharing state via Arc<Inner>.
┌─────────────────────────────┐
│ DiscoveryProtocol impl │ ← on_inbound() receives route adverts
│ (registered with engine │ ← on_tick() sends route adverts
│ discovery system) │
│ │
│ RoutingProtocol impl │ ← start() stores RoutingHandle in OnceLock
│ (registered with │ ← on_inbound() writes to rib via stored handle
│ RoutingManager) │
└─────────────────────────────┘
The DvrProtocol in crates/protocols/ndn-routing/src/protocols/dvr.rs is the reference implementation.
Pattern skeleton
#![allow(unused)]
fn main() {
use std::sync::{Arc, OnceLock};
struct MyInner {
// Routing handle — populated by RoutingProtocol::start()
routing: OnceLock<ndn_engine::RoutingHandle>,
// Protocol state
// ...
}
#[derive(Clone)]
pub struct MyProtocol {
inner: Arc<MyInner>,
}
impl DiscoveryProtocol for MyProtocol {
// ...
fn on_inbound(&self, raw: &Bytes, face: FaceId, _meta: &InboundMeta, _ctx: &dyn DiscoveryContext) -> bool {
let Some(handle) = self.inner.routing.get() else {
return false; // not yet started
};
// decode `raw`, update handle.rib, call handle.rib.apply_to_fib(...)
true
}
}
impl RoutingProtocol for MyProtocol {
fn origin(&self) -> u64 { MY_ORIGIN }
fn start(&self, handle: ndn_engine::RoutingHandle, cancel: CancellationToken) -> JoinHandle<()> {
let _ = self.inner.routing.set(handle); // bridge the two systems
tokio::spawn(async move { cancel.cancelled().await })
}
}
}
Register both with the engine builder:
#![allow(unused)]
fn main() {
let proto = Arc::new(MyProtocol::new(node_name));
let engine = EngineBuilder::new()
.discovery(Arc::clone(&proto) as Arc<dyn DiscoveryProtocol>)
.routing_protocol(Arc::clone(&proto))
.build().await?;
}
RIB API reference
#![allow(unused)]
fn main() {
// Install or update a route.
rib.add(&prefix, RibRoute { face_id, origin, cost, flags, expires_at });
// Remove a specific (face_id, origin) route.
rib.remove(&prefix, face_id, origin);
// Remove all routes via face_id for this prefix.
rib.remove_nexthop(&prefix, face_id);
// Remove all routes registered by this origin (across all prefixes).
// Returns affected prefixes; call apply_to_fib for each.
let affected = rib.flush_origin(my_origin);
for prefix in affected {
rib.apply_to_fib(&prefix, &fib);
}
// Push computed best nexthops into the FIB.
// Always call this after add/remove to keep the FIB in sync.
rib.apply_to_fib(&prefix, &fib);
}
RibRoute fields:
| Field | Type | Notes |
|---|---|---|
face_id | FaceId | Outgoing face |
origin | u64 | Your protocol’s origin value |
cost | u32 | Route cost (lower preferred) |
flags | u64 | CHILD_INHERIT (1), CAPTURE (2) |
expires_at | Option<Instant> | None = permanent |
Use CHILD_INHERIT so that /ndn/edu/ucla/cs is automatically covered by a route for /ndn/edu/ucla.
Testing your protocol
The simplest test strategy: build an engine with your protocol, register a prefix, and verify it appears in the FIB.
#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_static_route_appears_in_fib() {
use ndn_engine::EngineBuilder;
use ndn_routing::{StaticProtocol, StaticRoute};
let engine = EngineBuilder::new()
.routing_protocol(StaticProtocol::new(vec![
StaticRoute {
prefix: "/ndn/test".parse().unwrap(),
face_id: FaceId(1),
cost: 10,
},
]))
.build()
.await
.unwrap();
// Give the task a moment to install routes.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let fib = engine.fib();
assert!(fib.lookup(&"/ndn/test/sub".parse().unwrap()).is_some());
}
}
For protocols with packet I/O, consider using ndn-sim to create a simulated network topology and verify routes converge correctly.
Adding to ndn-routing
To add a new protocol to the ndn-routing crate:
- Create
crates/protocols/ndn-routing/src/protocols/your_protocol.rs - Implement
RoutingProtocol(andDiscoveryProtocolif needed) - Add
pub mod your_protocol;tocrates/protocols/ndn-routing/src/protocols/mod.rs - Add
pub use protocols::your_protocol::YourProtocol;tocrates/protocols/ndn-routing/src/lib.rs
See protocols/static.rs for a minimal example and protocols/dvr.rs for the full dual-protocol pattern.
Pipeline Benchmarks
ndn-rs ships a Criterion-based benchmark suite that measures individual pipeline stage costs and end-to-end forwarding latency. The benchmarks live in crates/engine/ndn-engine/benches/pipeline.rs.
Running Benchmarks
# Run the full suite
cargo bench -p ndn-engine
# Run a specific benchmark group
cargo bench -p ndn-engine -- "cs/"
cargo bench -p ndn-engine -- "fib/lpm"
cargo bench -p ndn-engine -- "interest_pipeline"
# View HTML reports after a run
open target/criterion/report/index.html
Criterion generates HTML reports with statistical analysis, throughput charts, and comparison against previous runs in target/criterion/.
Approximate Relative Cost of Pipeline Stages
%%{init: {'theme': 'default'}}%%
pie title Pipeline Stage Cost Breakdown (approximate)
"TLV Decode" : 30
"CS Lookup (miss)" : 10
"PIT Check" : 15
"FIB LPM" : 20
"Strategy" : 10
"Dispatch" : 15
The chart above shows approximate relative costs for a typical Interest pipeline traversal (CS miss path). TLV decode and FIB longest-prefix match dominate because they involve parsing variable-length names and traversing trie nodes. CS lookup on a miss and strategy execution are comparatively cheap. Actual proportions depend on name length, table sizes, and cache state – run the benchmarks to get precise numbers for your workload.
Benchmark Harness Architecture
graph LR
subgraph "Setup (per iteration)"
PB["Pre-built wire packets<br/>(realistic names, ~100 B content)"]
end
subgraph "Benchmark Loop (Criterion)"
PB --> S1["Stage under test<br/>(e.g. TlvDecodeStage)"]
S1 --> M["Measure:<br/>latency (ns/op)<br/>throughput (ops/sec, bytes/sec)"]
end
subgraph "Full Pipeline Benchmarks"
PB --> FP["All stages in sequence<br/>(decode -> CS -> PIT -> FIB -> strategy -> dispatch)"]
FP --> M2["End-to-end latency"]
end
RT["Tokio current-thread runtime<br/>(no I/O, no scheduling jitter)"] -.->|"runs"| S1
RT -.->|"runs"| FP
style PB fill:#e8f4fd,stroke:#2196F3
style M fill:#c8e6c9,stroke:#4CAF50
style M2 fill:#c8e6c9,stroke:#4CAF50
style RT fill:#fff3e0,stroke:#FF9800
What Is Benchmarked
TLV Decode
Groups: decode/interest, decode/data
Measures the cost of TlvDecodeStage – parsing raw wire bytes into a decoded Interest or Data struct and setting ctx.name. Tested with 4-component and 8-component names to show scaling with name length.
Throughput is reported in bytes/sec to make comparisons across packet sizes meaningful.
Content Store Lookup
Group: cs
cs/hit: lookup of a name that exists in the CS. Measures the fast path where a cached Data is returned and the Interest pipeline short-circuits (no PIT or strategy involved).cs/miss: lookup of a name not in the CS. Measures the overhead added to every Interest that proceeds past the CS stage.
Uses a 64 MiB LruCs with a pre-populated entry for the hit case.
PIT Check
Group: pit
pit/new_entry: inserting a new PIT entry for a never-seen name. Uses a fresh PIT per iteration to isolate insert cost.pit/aggregate: second Interest with a different nonce hitting an existing PIT entry. This is the aggregation path where the Interest is suppressed (returned asAction::Drop).
FIB Longest-Prefix Match
Group: fib/lpm
Measures LPM lookup time with 10, 100, and 1000 routes in the FIB. Routes have 2-component prefixes; the lookup name has 4 components (2 matching + 2 extra). This isolates trie traversal cost from name parsing.
PIT Match (Data Path)
Group: pit_match
pit_match/hit: Data arriving that matches an existing PIT entry. Seeds the PIT with a matching Interest, then measures the match and entry extraction.pit_match/miss: Data arriving with no matching PIT entry (unsolicited Data, dropped).
CS Insert
Group: cs_insert
cs_insert/insert_replace: steady-state replacement of an existing CS entry (same name, new Data). Measures the cost when the CS is warm.cs_insert/insert_new: inserting a unique name on each iteration. Measures cold-path cost including NameTrie node creation.
Validation Stage
Group: validation_stage
validation_stage/disabled: passthrough when noValidatoris configured. Measures the baseline overhead of the stage itself.validation_stage/cert_via_anchor: full Ed25519 signature verification using a trust anchor. Includes schema check, key lookup, and cryptographic verify.
Full Interest Pipeline
Groups: interest_pipeline, interest_pipeline/cs_hit
interest_pipeline/no_route: decode + CS miss + PIT new entry. Stops before the strategy stage to isolate pure pipeline overhead. Tested with 4 and 8 component names.interest_pipeline/cs_hit: decode + CS hit. Measures the fast path where a cached Data satisfies the Interest immediately.
Full Data Pipeline
Group: data_pipeline
Decode + PIT match + CS insert. Seeds the PIT with a matching Interest, then runs the full Data path. Tested with 4 and 8 component names. Throughput is reported in bytes/sec.
Decode Throughput
Group: decode_throughput
Batch decoding of 1000 Interests in a tight loop. Reports throughput in elements/sec rather than latency, giving a peak-rate estimate for the decode stage.
Benchmark Design Notes
- All async benchmarks use a current-thread Tokio runtime with no I/O, isolating CPU cost from scheduling jitter.
- Packet wire bytes are built with realistic name lengths (4 and 8 components) and ~100 B Data content.
- The PIT is cleared between iterations where noted to ensure consistent starting state.
- Each benchmark group uses Criterion’s
Throughputannotations so reports show both latency and throughput.
Interpreting Results
Criterion reports median latency by default. Look for:
- Regression alerts: Criterion flags changes >5% from the baseline. CI uses a 10% threshold (see Methodology).
- Outliers: high outlier percentages suggest contention or GC pauses. The current-thread runtime minimizes this.
- Throughput numbers: useful for capacity planning. If
decode_throughputshows 2M Interest/sec, that is the ceiling before other stages are considered.
The HTML report at target/criterion/report/index.html includes violin plots, PDFs, and regression analysis for each benchmark.
SHA-256 vs BLAKE3 in this bench
signing/sha256-digest uses sha2::Sha256 (rustcrypto), which on
both x86_64 and aarch64 ships runtime CPUID dispatch through the
cpufeatures crate and uses Intel
SHA-NI / ARMv8 SHA crypto when the CPU exposes them. Effectively
every modern CI runner and consumer CPU does, so the absolute
SHA-256 numbers in this table are SHA-NI numbers — there is no
practical “software SHA” baseline left to compare against.
That makes BLAKE3 a comparison between a hardware-accelerated SHA-256 and an AVX2/NEON-vectorised BLAKE3, and it shows: BLAKE3 is not single-thread faster than SHA-256 on these CPUs at the input sizes a typical NDN signed portion has (a few hundred bytes to a few KB). The “BLAKE3 is 3–8× faster than SHA-256” claim refers to BLAKE3 vs plain software SHA-256 — true on chips without SHA extensions, but no longer the common case. See Why BLAKE3 for the actual reasons ndn-rs supports BLAKE3 (Merkle-tree partial verification of segmented Data, multi-thread hashing, single algorithm for hash + MAC + KDF + XOF) — none of which are about raw single- thread throughput.
Latest CI Results
Last updated by CI on 2026-04-15 (ubuntu-latest, stable Rust)
| Benchmark | Median | ± Variance |
|---|---|---|
cs/hit | 762 ns | ±34 ns |
cs/miss | 524 ns | ±2 ns |
cs_insert/insert_new | 10.21 µs | ±18.18 µs |
cs_insert/insert_replace | 943 ns | ±14 ns |
data_pipeline/4 | 1.88 µs | ±66 ns |
data_pipeline/8 | 2.27 µs | ±38 ns |
decode/data/4 | 394 ns | ±26 ns |
decode/data/8 | 464 ns | ±0 ns |
decode/interest/4 | 481 ns | ±0 ns |
decode/interest/8 | 556 ns | ±2 ns |
decode_throughput/4 | 442.84 µs | ±39.54 µs |
decode_throughput/8 | 525.64 µs | ±7.39 µs |
fib/lpm/10 | 35 ns | ±0 ns |
fib/lpm/100 | 96 ns | ±0 ns |
fib/lpm/1000 | 96 ns | ±0 ns |
interest_pipeline/cs_hit | 921 ns | ±1 ns |
interest_pipeline/no_route/4 | 1.40 µs | ±33 ns |
interest_pipeline/no_route/8 | 1.55 µs | ±20 ns |
large/blake3-rayon/hash/1MB | 122.33 µs | ±2.48 µs |
large/blake3-rayon/hash/256KB | 40.89 µs | ±1.36 µs |
large/blake3-rayon/hash/4MB | 439.02 µs | ±2.45 µs |
large/blake3-single/hash/1MB | 252.69 µs | ±923 ns |
large/blake3-single/hash/256KB | 61.68 µs | ±321 ns |
large/blake3-single/hash/4MB | 999.28 µs | ±3.07 µs |
large/sha256/hash/1MB | 659.90 µs | ±893 ns |
large/sha256/hash/256KB | 164.78 µs | ±243 ns |
large/sha256/hash/4MB | 2.64 ms | ±1.82 µs |
lru/evict | 189 ns | ±3 ns |
lru/evict_prefix | 2.00 µs | ±2.06 µs |
lru/get_can_be_prefix | 297 ns | ±0 ns |
lru/get_hit | 213 ns | ±0 ns |
lru/get_miss_empty | 140 ns | ±0 ns |
lru/get_miss_populated | 188 ns | ±0 ns |
lru/insert_new | 1.99 µs | ±1.46 µs |
lru/insert_replace | 376 ns | ±4 ns |
name/display/components/4 | 452 ns | ±1 ns |
name/display/components/8 | 866 ns | ±8 ns |
name/eq/eq_match | 39 ns | ±0 ns |
name/eq/eq_miss_first | 2 ns | ±0 ns |
name/eq/eq_miss_last | 38 ns | ±0 ns |
name/has_prefix/prefix_len/1 | 7 ns | ±0 ns |
name/has_prefix/prefix_len/4 | 24 ns | ±1 ns |
name/has_prefix/prefix_len/8 | 35 ns | ±3 ns |
name/hash/components/4 | 86 ns | ±0 ns |
name/hash/components/8 | 163 ns | ±8 ns |
name/parse/components/12 | 679 ns | ±9 ns |
name/parse/components/4 | 236 ns | ±1 ns |
name/parse/components/8 | 468 ns | ±1 ns |
name/tlv_decode/components/12 | 301 ns | ±1 ns |
name/tlv_decode/components/4 | 140 ns | ±0 ns |
name/tlv_decode/components/8 | 210 ns | ±0 ns |
pit/aggregate | 2.32 µs | ±125 ns |
pit/new_entry | 1.23 µs | ±7 ns |
pit_match/hit | 1.61 µs | ±7 ns |
pit_match/miss | 1.95 µs | ±12 ns |
sharded/get_hit/1 | 229 ns | ±0 ns |
sharded/get_hit/16 | 228 ns | ±2 ns |
sharded/get_hit/4 | 233 ns | ±7 ns |
sharded/get_hit/8 | 229 ns | ±3 ns |
sharded/insert/1 | 2.56 µs | ±1.60 µs |
sharded/insert/16 | 1.91 µs | ±1.59 µs |
sharded/insert/4 | 2.58 µs | ±1.73 µs |
sharded/insert/8 | 2.44 µs | ±1.66 µs |
signing/blake3-keyed/sign_sync/100B | 182 ns | ±0 ns |
signing/blake3-keyed/sign_sync/1KB | 1.20 µs | ±0 ns |
signing/blake3-keyed/sign_sync/2KB | 2.41 µs | ±2 ns |
signing/blake3-keyed/sign_sync/4KB | 3.54 µs | ±2 ns |
signing/blake3-keyed/sign_sync/500B | 618 ns | ±1 ns |
signing/blake3-keyed/sign_sync/8KB | 4.80 µs | ±4 ns |
signing/blake3-plain/sign_sync/100B | 199 ns | ±0 ns |
signing/blake3-plain/sign_sync/1KB | 1.21 µs | ±1 ns |
signing/blake3-plain/sign_sync/2KB | 2.41 µs | ±3 ns |
signing/blake3-plain/sign_sync/4KB | 3.53 µs | ±4 ns |
signing/blake3-plain/sign_sync/500B | 633 ns | ±3 ns |
signing/blake3-plain/sign_sync/8KB | 4.80 µs | ±10 ns |
signing/ed25519/sign_sync/100B | 20.73 µs | ±297 ns |
signing/ed25519/sign_sync/1KB | 24.20 µs | ±97 ns |
signing/ed25519/sign_sync/2KB | 28.03 µs | ±144 ns |
signing/ed25519/sign_sync/4KB | 35.16 µs | ±73 ns |
signing/ed25519/sign_sync/500B | 22.26 µs | ±814 ns |
signing/ed25519/sign_sync/8KB | 50.29 µs | ±91 ns |
signing/hmac/sign_sync/100B | 276 ns | ±4 ns |
signing/hmac/sign_sync/1KB | 836 ns | ±1 ns |
signing/hmac/sign_sync/2KB | 1.49 µs | ±3 ns |
signing/hmac/sign_sync/4KB | 2.74 µs | ±2 ns |
signing/hmac/sign_sync/500B | 518 ns | ±0 ns |
signing/hmac/sign_sync/8KB | 5.27 µs | ±3 ns |
signing/sha256-digest/sign_sync/100B | 101 ns | ±0 ns |
signing/sha256-digest/sign_sync/1KB | 664 ns | ±1 ns |
signing/sha256-digest/sign_sync/2KB | 1.30 µs | ±2 ns |
signing/sha256-digest/sign_sync/4KB | 2.54 µs | ±5 ns |
signing/sha256-digest/sign_sync/500B | 341 ns | ±0 ns |
signing/sha256-digest/sign_sync/8KB | 5.07 µs | ±6 ns |
validation/cert_missing | 192 ns | ±0 ns |
validation/schema_mismatch | 146 ns | ±2 ns |
validation/single_hop | 46.71 µs | ±93 ns |
validation_stage/cert_via_anchor | 48.11 µs | ±134 ns |
validation_stage/disabled | 617 ns | ±2 ns |
verification/blake3-keyed/verify/100B | 304 ns | ±0 ns |
verification/blake3-keyed/verify/1KB | 1.32 µs | ±1 ns |
verification/blake3-keyed/verify/2KB | 2.52 µs | ±67 ns |
verification/blake3-keyed/verify/4KB | 3.65 µs | ±13 ns |
verification/blake3-keyed/verify/500B | 740 ns | ±0 ns |
verification/blake3-keyed/verify/8KB | 4.92 µs | ±6 ns |
verification/blake3-plain/verify/100B | 309 ns | ±0 ns |
verification/blake3-plain/verify/1KB | 1.32 µs | ±1 ns |
verification/blake3-plain/verify/2KB | 2.52 µs | ±6 ns |
verification/blake3-plain/verify/4KB | 3.65 µs | ±6 ns |
verification/blake3-plain/verify/500B | 744 ns | ±1 ns |
verification/blake3-plain/verify/8KB | 4.92 µs | ±10 ns |
verification/ed25519-batch/1 | 54.78 µs | ±410 ns |
verification/ed25519-batch/10 | 248.72 µs | ±606 ns |
verification/ed25519-batch/100 | 2.27 ms | ±7.78 µs |
verification/ed25519-batch/1000 | 18.58 ms | ±156.20 µs |
verification/ed25519-per-sig-loop/1 | 42.34 µs | ±141 ns |
verification/ed25519-per-sig-loop/10 | 421.42 µs | ±2.02 µs |
verification/ed25519-per-sig-loop/100 | 4.29 ms | ±6.06 µs |
verification/ed25519-per-sig-loop/1000 | 43.16 ms | ±68.38 µs |
verification/ed25519/verify/100B | 41.75 µs | ±99 ns |
verification/ed25519/verify/1KB | 43.81 µs | ±88 ns |
verification/ed25519/verify/2KB | 45.57 µs | ±77 ns |
verification/ed25519/verify/4KB | 49.28 µs | ±110 ns |
verification/ed25519/verify/500B | 42.93 µs | ±677 ns |
verification/ed25519/verify/8KB | 57.63 µs | ±106 ns |
verification/sha256-digest/verify/100B | 102 ns | ±0 ns |
verification/sha256-digest/verify/1KB | 662 ns | ±0 ns |
verification/sha256-digest/verify/2KB | 1.30 µs | ±0 ns |
verification/sha256-digest/verify/4KB | 2.55 µs | ±1 ns |
verification/sha256-digest/verify/500B | 341 ns | ±0 ns |
verification/sha256-digest/verify/8KB | 5.08 µs | ±105 ns |
Forwarder Comparison Benchmarks
This page is automatically updated by the
testbed CI workflow
on every push to main and weekly on Mondays.
Transport note:
unixsocket numbers are shown for all forwarders. ndn-fwd also supports an in-process SHM face (not tested here). Numbers using different transports are not directly comparable.
Last run: 2026-04-14 (ubuntu-latest, stable ndn-rs)
| Metric | ndn-fwd | ndn-fwd-internal | nfd | yanfd |
|---|---|---|---|---|
| internal-throughput (unix) | n/a | 3.13 Gbps / 49788 Int/s | n/a | n/a |
| latency p50/p99 (unix) | 281µs / 415µs | n/a | 290µs / 384µs | 333µs / 493µs |
| throughput (unix) | 3.25 Gbps / 50344 Int/s | n/a | 692.18 Mbps / 10868 Int/s | 1.31 Gbps / 26504 Int/s |
Benchmark Methodology
This page describes how ndn-rs benchmarks are collected, what is measured, and how regressions are tracked in CI.
Framework: Criterion.rs
ndn-rs uses Criterion.rs for all microbenchmarks. Criterion provides:
- Statistical rigor: each benchmark runs a configurable number of samples (default: 100) after a warmup period. Results are analyzed for statistical significance before reporting changes.
- Stable baselines: results are persisted in
target/criterion/across runs. Subsequent runs compare against the baseline and report whether performance changed. - HTML reports: violin plots, PDFs, and linear regression charts for visual inspection.
Default configuration
| Parameter | Value |
|---|---|
| Warmup time | 3 seconds |
| Measurement time | 5 seconds |
| Sample size | 100 iterations per sample |
| Noise threshold | 1% (changes below this are not reported) |
| Confidence level | 95% |
These are Criterion defaults. Individual benchmark groups may override them (e.g., increasing measurement time for high-variance benchmarks).
What Is Measured
Latency (primary metric)
Each benchmark measures the wall-clock time to execute a single operation (e.g., one TLV decode, one CS lookup, one full pipeline pass). Criterion reports:
- Median: the primary metric. More robust to outliers than mean.
- Mean: reported alongside median for completeness.
- Standard deviation: indicates measurement stability.
- MAD (Median Absolute Deviation): robust spread estimate.
Throughput (derived)
Benchmarks annotated with Throughput::Bytes(n) or Throughput::Elements(n) also report throughput in bytes/sec or operations/sec. This is derived from the latency measurement.
Isolation
Benchmarks are designed to isolate CPU cost from external factors:
Current-thread Tokio runtime
All async benchmarks use tokio::runtime::Builder::new_current_thread(). This eliminates:
- Cross-thread scheduling jitter
- Work-stealing overhead
- Cache line bouncing between cores
The measured latency reflects pure single-threaded processing cost.
No I/O
Benchmarks operate on in-memory data structures only. No network sockets, no disk I/O. Face tables are created fresh or stubbed. The CS uses in-memory LruCs, not PersistentCs.
Pre-built wire bytes
Packet wire bytes are constructed once before the benchmark loop using encode_interest() / encode_data_unsigned(). The benchmark measures only the decode and processing path, not encoding.
Fresh state per iteration (where applicable)
Benchmarks that measure “new entry” paths (PIT insert, CS insert) create fresh data structures per iteration or call .clear() to avoid measuring eviction or resizing costs mixed with the target operation.
Hardware Notes
Benchmark results are hardware-dependent. When reporting numbers:
- State the CPU model, core count, and clock speed.
- State the memory configuration (DDR4/DDR5, speed).
- Note whether the system was idle during the run.
- Note the OS and kernel version (scheduler behavior varies).
For reproducible comparisons, always run benchmarks on the same hardware or use relative changes (% regression) rather than absolute numbers.
Criterion’s statistical model accounts for some system noise, but co-located workloads (VMs, containers, browser tabs) can still skew results. For best accuracy, run on a quiet machine.
CI Regression Tracking
github-action-benchmark
CI runs the benchmark suite on every push to main and on pull requests. Results are tracked using github-action-benchmark, which:
- Parses Criterion’s JSON output.
- Stores historical data in a dedicated branch (
gh-pagesorbenchmarks). - Compares the current run against the stored baseline.
- Comments on the PR with a summary of changes.
Alert threshold
The CI alert threshold is 10%. If any benchmark regresses by more than 10% compared to the baseline:
- The CI check is marked as failed.
- A comment is posted on the PR identifying the regressed benchmarks.
- The PR author is expected to investigate before merging.
The 10% threshold is intentionally generous to avoid false positives from system noise while still catching meaningful regressions. Criterion’s own statistical test (95% confidence) provides a secondary guard.
Baseline management
- The baseline is updated on every merge to
main. - Pull request runs compare against the
mainbaseline but do not update it. - To manually update the baseline locally:
cargo bench -p ndn-engine -- --save-baseline main.
Running Benchmarks Locally
# Full suite
cargo bench -p ndn-engine
# Specific group
cargo bench -p ndn-engine -- "cs/"
# Compare against a saved baseline
cargo bench -p ndn-engine -- --baseline main
# Save a new baseline
cargo bench -p ndn-engine -- --save-baseline my-branch
# Open the HTML report
open target/criterion/report/index.html
Tips for reliable local results
- Close background applications (browsers, IDEs with indexing, etc.).
- Disable CPU frequency scaling if possible (
cpupower frequency-set -g performanceon Linux). - Run the full suite twice – the first run warms caches and establishes a baseline, the second gives you the comparison.
- Use
--sample-size 300for high-variance benchmarks if the default 100 is insufficient.
API Reference
This page enumerates all public API surfaces in ndn-rs, organized by crate. Each crate serves a distinct layer of the stack; most application developers will only need ndn-app and occasionally ndn-packet.
ndn-app — Application API (highest-level)
The recommended entry point for application developers. Wraps the engine, IPC, and security layers behind a simple, ergonomic interface. The same API works whether you connect to an external ndn-fwd process or embed the engine directly in your binary (see Building NDN Applications).
| Type / Function | Description |
|---|---|
Consumer::connect(socket) | Connect to an external router via Unix socket |
Consumer::from_handle(handle) | Connect via an in-process InProcFace handle (embedded mode) |
Consumer::get(name) | Fetch content bytes by name (convenience wrapper) |
Consumer::fetch(name) | Fetch the full Data packet |
Consumer::fetch_wire(wire, timeout) | Send a hand-built Interest wire and await Data |
Consumer::fetch_verified(name, validator) | Fetch and cryptographically verify; returns SafeData |
Producer::connect(socket, prefix) | Register a prefix and connect to an external router |
Producer::from_handle(handle, prefix) | Register a prefix against an in-process InProcFace handle |
Producer::serve(handler) | Run the serve loop with an async Interest → Option<Bytes> handler |
Subscriber::connect(socket, prefix, config) | Join an SVS sync group and receive a Sample stream |
Subscriber::recv() | Await the next Sample from the sync group |
Queryable::connect(socket, prefix) | Register a prefix for request-response handling |
Queryable::recv() | Await the next Query; call query.reply(wire) to respond |
KeyChain | Re-exported from ndn-security — see that crate for the full API |
NdnConnection | Enum unifying external (ForwarderClient) and embedded (InProcFace) connections |
blocking::BlockingConsumer | Synchronous wrapper — no async required |
blocking::BlockingProducer | Synchronous serve loop — plain Fn(Interest) → Option<Bytes> |
ChunkedConsumer | Reassemble multi-segment content transparently |
ChunkedProducer | Segment and serve large content automatically |
Note: This is the recommended entry point for application developers.
ndn-packet — Wire Format
Encode and decode NDN packets. Zero-copy parsing built on bytes::Bytes; lazy field decoding via OnceLock.
| Type / Function | Description |
|---|---|
Name::parse(s) / Name::from_str(s) | Parse a URI-encoded NDN name |
Name::append(component) | Append a component, returning a new Name |
Name::components() | Iterator over NameComponent |
Name::has_prefix(prefix) | Prefix test |
NameComponent | Typed variants: GenericNameComponent, Segment, Version, Timestamp, KeywordNameComponent, ParametersSha256DigestComponent, etc. |
Interest::decode(bytes) | Decode an Interest packet |
Interest::name() | Borrowed Name |
Interest::nonce() | 4-byte nonce (decoded lazily) |
Interest::lifetime() | Duration from InterestLifetime |
Interest::can_be_prefix() | Whether CanBePrefix flag is set |
Interest::must_be_fresh() | Whether MustBeFresh flag is set |
Data::decode(bytes) | Decode a Data packet |
Data::name() | Borrowed Name |
Data::content() | Option<Bytes> content |
Data::implicit_digest() | Compute or return cached SHA-256 implicit digest |
InterestBuilder::new(name) | Start building an Interest |
InterestBuilder::lifetime(duration) | Set InterestLifetime |
InterestBuilder::must_be_fresh(bool) | Set MustBeFresh |
InterestBuilder::can_be_prefix(bool) | Set CanBePrefix |
InterestBuilder::build() | Encode to wire Bytes |
DataBuilder::new(name) | Start building a Data packet |
DataBuilder::content(bytes) | Set content |
DataBuilder::freshness(duration) | Set FreshnessPeriod in MetaInfo |
DataBuilder::sign_sync(signer) | Sign and finalize (sync fast-path, no heap allocation) |
DataBuilder::build_unsigned() | Encode without a signature (testing / internal use) |
name!() | Macro for compile-time name construction |
Nack::decode(bytes) | Decode an NDNLPv2-framed Nack |
Nack::reason() | NackReason enum |
ndn-engine — Forwarder Engine
The core forwarding engine. Used directly when embedding the engine in a binary or when building custom tooling. Most application code reaches this layer only through ndn-app.
| Type / Function | Description |
|---|---|
EngineBuilder::new(config) | Create a new builder |
EngineBuilder::face(face) | Register a Face implementation |
EngineBuilder::strategy(prefix, strategy) | Install a strategy for a name prefix |
EngineBuilder::validator(validator) | Set the data-plane validator |
EngineBuilder::content_store(cs) | Plug in a custom ContentStore backend |
EngineBuilder::discovery(protocol) | Register a discovery protocol |
EngineBuilder::security(profile) | Set the SecurityProfile |
EngineBuilder::build() | Spawn Tokio tasks; returns (ForwarderEngine, ShutdownHandle) |
ForwarderEngine::fib() | Access the Fib for manual route installation |
ForwarderEngine::shutdown() | Gracefully stop all pipeline tasks |
EngineConfig | Serde-deserializable config: pipeline_threads, cs_capacity, pit_capacity, idle_face_timeout, etc. |
PipelineStage trait | Implement to insert a custom stage into the fixed pipeline |
ndn-security — Signing and Validation
Cryptographic signing, trust-chain validation, and identity management. The SafeData newtype is the compiler-enforced proof that a Data packet has been verified. KeyChain is the single entry point for NDN security in both applications and the forwarder.
| Type / Function | Description |
|---|---|
KeyChain::ephemeral(name) | Create an in-memory, self-signed identity (tests / short-lived producers) |
KeyChain::open_or_create(path, name) | Load from a file-backed PIB, generating on first run |
KeyChain::from_parts(mgr, name, key_name) | Construct from a pre-built SecurityManager (framework use) |
KeyChain::signer() | Obtain an Arc<dyn Signer> for the local identity |
KeyChain::validator() | Build a Validator pre-configured with this identity’s trust anchors |
KeyChain::add_trust_anchor(cert) | Add an external trust anchor certificate |
KeyChain::manager_arc() | Escape hatch to the underlying Arc<SecurityManager> |
SignWith trait | .sign_with_sync(&signer) — synchronous signing extension for DataBuilder / InterestBuilder |
Validator::new(schema) | Create a validator from a TrustSchema |
Validator::validate(data) | Async validate; returns Valid(SafeData) / Invalid / Pending |
Validator::validate_chain(data) | Walk and verify the full certificate chain to a trust anchor |
Signer trait | sign(&self, region) — async; sign_sync for CPU-only signers |
Ed25519Signer | Ed25519 signing (default identity type) |
HmacSha256Signer | Symmetric HMAC-SHA-256 (~10× faster than Ed25519) |
TrustSchema::new() | Empty schema — rejects everything (explicit strict configuration) |
TrustSchema::hierarchical() | Data and key must share the same first name component |
TrustSchema::accept_all() | Accept any correctly-signed packet (no namespace check) |
SafeData | Newtype wrapping a verified Data — compiler-enforced proof of validation |
SecurityManager::auto_init() | First-run identity generation; driven by auto_init = true in TOML |
CertFetcher | Async cert fetching with deduplication (concurrent requests for the same cert share one Interest) |
SecurityProfile | Default / AcceptSigned / Disabled / Custom — engine auto-wires the validation stage |
did::name_to_did(name) | Encode an NDN Name as a did:ndn: URI string |
did::did_to_name(did) | Decode a did:ndn: URI back to a Name |
did::cert_to_did_document(cert) | Convert an NDN Certificate to a W3C DidDocument |
did::UniversalResolver | Multi-method DID resolver: did:ndn, did:key, did:web |
ndn-sync — State Synchronization
Distributed state synchronization. Two protocols are provided: SVS (State Vector Sync) and PSync (partial sync via invertible Bloom filters).
| Type / Function | Description |
|---|---|
join_svs_group(engine, group_prefix, node_name) | Start an SVS sync participant; returns SyncHandle |
join_psync_group(engine, group_prefix, node_name) | Start a PSync participant; returns SyncHandle |
SyncHandle::publish(data) | Publish a local update to the group |
SyncHandle::recv() | Await the next SyncUpdate from a remote member |
SvsNode | Low-level SVS node (state-vector sync) |
PSyncNode | Low-level PSync node with Ibf (invertible Bloom filter) |
ndn-discovery — Discovery Protocols
Pluggable link-local neighbor discovery and service discovery. Protocols run inside the engine and observe face lifecycle events and inbound packets through a narrow context interface.
| Type / Trait | Description |
|---|---|
DiscoveryProtocol trait | protocol_id, claimed_prefixes, on_face_up, on_face_down, on_inbound, on_tick, tick_interval |
DiscoveryContext trait | add_fib_entry, remove_fib_entry, remove_fib_entries_by_owner, update_neighbor, send_on, neighbors, add_face, remove_face, now |
UdpNeighborDiscovery | SWIM-based neighbor discovery over UDP; direct and indirect probing with K-gossip piggyback |
EtherNeighborDiscovery | SWIM-based neighbor discovery over raw Ethernet |
SvsServiceDiscovery | SVS-backed push service record notifications |
CompositeDiscovery | Multiplexes multiple protocols; verifies non-overlapping prefix claims at construction |
ProtocolId | &'static str tag identifying a protocol; used to label and bulk-remove FIB routes |
NeighborUpdate | Upsert, SetState, Remove variants applied to the neighbor table |
NeighborEntry | A record in the neighbor table (name, face, capabilities, last-seen) |
ndn-transport — Face Abstraction
The Face trait and face lifecycle types. Consumed by ndn-engine; implement this trait to add a new link-layer transport.
| Type / Trait | Description |
|---|---|
Face trait | async fn recv(&self) -> Result<Bytes> and async fn send(&self, pkt: Bytes) -> Result<()> |
ErasedFace | Object-safe erasure of Face for storage in FaceTable |
FaceId | Opaque numeric face identifier |
FaceKind | Enum: Udp, Tcp, Ether, App, Shm, WebSocket, Serial, Internal, etc. |
FaceTable | DashMap-backed registry of all active faces |
FacePersistency | OnDemand / Persistent / Permanent — NFD-compatible face lifecycle |
FaceScope | Local / NonLocal — enforces /localhost scope boundary |
ndn-store — Data Plane Tables
The PIT, FIB, and Content Store. The ContentStore trait is pluggable; the FIB and PIT are the canonical implementations used by the engine.
| Type / Trait | Description |
|---|---|
ContentStore trait | insert, lookup, evict_prefix, len, current_bytes, set_capacity |
LruCs | LRU eviction content store (default) |
ShardedCs<C> | Shards any ContentStore by first name component to reduce lock contention |
FjallCs | Persistent LSM-tree content store via fjall (feature: fjall); survives process restart |
ObservableCs | Wraps any CS with atomic hit/miss/insert/eviction counters and an optional observer callback |
NameTrie | Per-node RwLock longest-prefix-match trie; used by FIB and strategy table |
Pit | Pending Interest Table; DashMap-backed with hierarchical timing-wheel expiry |
PitEntry | A single pending Interest record (name, selector, incoming faces, expiry) |
Fib | Forwarding Information Base; NameTrie<Vec<NextHop>> |
ndn-strategy — Forwarding Strategy
Forwarding decision logic. Strategies receive an immutable StrategyContext and return a ForwardingAction.
| Type / Trait | Description |
|---|---|
Strategy trait | on_interest, on_nack, on_data_in |
StrategyFilter trait | Compose pre/post-processing around any strategy |
ContextEnricher trait | Insert typed cross-layer data into the packet’s AnyMap |
BestRouteStrategy | Forward to the lowest-cost FIB nexthop; retry on Nack |
MulticastStrategy | Forward to all FIB nexthops simultaneously |
ComposedStrategy | Wraps any strategy with a StrategyFilter chain |
ForwardingAction | Forward(faces), ForwardAfter(delay, faces), Nack(reason), Suppress |
StrategyContext | Immutable view: FIB lookup, measurements, face table |
MeasurementsTable | DashMap of EWMA RTT and satisfaction rate per face/prefix |
WasmStrategy | Hot-loadable WASM forwarding strategy via wasmtime; fuel-limited (feature: wasm) |
ndn-sim — Simulation
Topology-based simulation for integration tests and the WASM browser sandbox. No external processes or network interfaces required.
| Type | Description |
|---|---|
Simulation::new() | Create a new simulation environment |
Simulation::add_router(name) | Add a router node |
Simulation::add_link(a, b, config) | Connect two nodes with a SimLink |
Simulation::add_consumer(name, ...) | Add a consumer node |
Simulation::add_producer(name, prefix) | Add a producer node |
Simulation::run() | Drive the simulation to completion |
SimLink | A link between two nodes (bandwidth, latency, loss) |
LinkConfig | Configuration for a SimLink: bandwidth, latency, loss_rate |
SimTracer | Collects trace events for post-run inspection |
Note: Used for integration tests and the WASM browser simulation.
ndn-ipc — App-to-Router IPC
The low-level transport between application processes and the router. Application developers should use ndn-app instead.
| Type | Description |
|---|---|
ForwarderClient::connect(socket) | Connect to an ndn-fwd Unix socket |
ForwarderClient::send_interest(wire) | Send an Interest and await the response |
ForwarderClient::register_prefix(prefix) | Register a name prefix for inbound Interests |
InProcFace | In-process channel face (engine side of the pair) |
InProcHandle | In-process channel handle (application side of the pair) |
SpscFace | Zero-copy SHM ring face (engine side); 256-slot SPSC buffer |
SpscHandle | Application-side handle for the SHM ring |
UnixFace | Domain socket face with TLV codec framing |
ndn-embedded — no_std Forwarder
A minimal, no_std, no_alloc forwarder for microcontrollers. Sizing is entirely compile-time via const generics.
| Type | Description |
|---|---|
Forwarder<N> | Const-generic forwarder; N is the maximum simultaneous pending Interests |
Pit<N> | no_std, no_alloc Pending Interest Table |
Fib<N> | no_std, no_alloc Forwarding Information Base |
Targets: ARM Cortex-M, RISC-V, ESP32. Uses COBS framing for serial link layers.
ndn-wasm — Browser Simulation
A wasm-bindgen wrapper exposing the forwarding engine to JavaScript for the interactive browser simulation in ndn-explorer.
| Type | Description |
|---|---|
WasmTopology | JavaScript-accessible simulation topology |
WasmPipeline | JavaScript-accessible pipeline trace runner |
ndn-config — Configuration
Serde-deserializable configuration types. Used by ndn-fwd to load TOML config files.
| Type | Description |
|---|---|
RouterConfig | Top-level TOML config; deserializes the full router configuration |
FaceConfig | #[serde(tag = "kind")] enum — one variant per face type; invalid combinations rejected at parse time |
EngineConfig | Pipeline threads, CS capacity, PIT capacity, idle face timeout |
SecurityConfig | auto_init, trust anchor paths, SecurityProfile selection |
For a guided introduction to the application-level API, see Building NDN Applications. For pattern-based API selection, see Application Patterns.
NDN Specification Compliance
ndn-rs is wire-compatible with NFD and other NDN forwarders for the core Interest/Data exchange, NDNLPv2 framing, and basic certificate validation. Five items from the original compliance audit remain open — none affect wire-level interoperability with NFD on the plain forwarding path — and two forwarding-behavior features used by the wider ecosystem (forwarding hints and PIT tokens) are not yet active in the pipeline and are tracked separately in “Not Yet Implemented” below.
Reference Specifications
Note: NDN is not CCNx. NDN Architecture and RFC 8609 define CCNx 1.0 semantics and packet encoding respectively and are not applicable to NDN. The NDN protocol is defined by the documents below.
| Document | Scope |
|---|---|
| NDN Packet Format v0.3 | Canonical TLV encoding, packet types, name components |
| NDN Architecture (NDN-0001) | Project architecture vision and research roadmap — motivates the design but does not specify forwarding behavior |
| NFD Developer Guide (NDN-0021) | The de-facto reference for NFD’s forwarding pipeline, strategy API, and management protocol |
| NDNLPv2 | Link-layer protocol: fragmentation, reliability, per-hop headers |
| NDN Certificate Format v2 | Certificate TLV layout, naming conventions, validity period |
| NDNCERT Protocol 0.3 | Automated certificate issuance over NDN |
Note on forwarding specification. There is no single comprehensive document defining NDN forwarding behavior. NDN-0001 is the architecture vision. NDN-0021 (the NFD Developer Guide) describes one implementation’s behavior, which the community treats as the de-facto reference. Recent forwarding developments — in particular forwarding hints (partially described in NFD redmine issues #3000 and #3333) and PIT tokens (pioneered by NDN-DPDK) — are not yet folded into any single spec document. ndn-rs tracks these as open work, not as completed compliance items; see “Not Yet Implemented” below.
What’s Implemented
TLV Wire Format (NDN Packet Format v0.3)
The TLV codec handles all four VarNumber encoding widths and enforces shortest-encoding on read — a NonMinimalVarNumber error is returned for non-minimal forms. TLV types 0–31 are grandfathered as always critical regardless of LSB, per NDN Packet Format v0.3 §1.3. TlvWriter::write_nested uses minimal length encoding. Zero-component Names are rejected at decode time.
Packet Types
Interest — full encode/decode: Name, Nonce, InterestLifetime, CanBePrefix, MustBeFresh, HopLimit, ForwardingHint, ApplicationParameters with ParametersSha256DigestComponent verification, and InterestSignatureInfo/InterestSignatureValue for signed Interests with anti-replay fields (SignatureNonce, SignatureTime, SignatureSeqNum).
Data — full encode/decode: Name, Content, MetaInfo (ContentType including LINK, KEY, NACK, PREFIX_ANN), FreshnessPeriod, FinalBlockId, SignatureInfo, SignatureValue. Data::implicit_digest() computes SHA-256 of the wire encoding for exact-Data retrieval via ImplicitSha256DigestComponent.
Nack — encode/decode with NackReason (NoRoute, Duplicate, Congestion).
Typed name components — KeywordNameComponent (0x20), SegmentNameComponent (0x32), ByteOffsetNameComponent (0x34), VersionNameComponent (0x36), TimestampNameComponent (0x38), SequenceNumNameComponent (0x3A) — all with typed constructors, accessors, and Display/FromStr.
NDNLPv2 Link Protocol
All network faces use NDNLPv2 LpPacket framing (type 0x64). Fully implemented:
- LpPacket encode/decode — Nack header, Fragment, Sequence (0x51), FragIndex (0x52), FragCount (0x53)
- Fragmentation and reassembly —
fragment_packetsplits oversized packets;ReassemblyBuffercollects fragments and reassembles on receive - Reliability — TxSequence (0x0348), Ack (0x0344); per-hop adaptive RTO on unicast UDP faces (NDNLPv2 §6)
- Per-hop headers — PitToken (0x62), CongestionMark, IncomingFaceId (0x032C), NextHopFaceId (0x0330), CachePolicy/NoCache (0x0334/0x0335), NonDiscovery (0x034C), PrefixAnnouncement (0x0350)
encode_lp_with_headers()— encodes all optional LP headers in correct TLV-TYPE order- Nack framing — correctly wrapped as LpPacket with Nack header and Fragment, not standalone TLV
Forwarding Semantics (NDN Architecture)
- HopLimit — decoded (TLV 0x22); Interests with HopLimit=0 are dropped; decremented before forwarding
- Nonce —
ensure_nonce()adds a random Nonce to any Interest that lacks one before forwarding - FIB — name trie with longest-prefix match, multi-nexthop entries with costs
- PIT — DashMap-based, Interest aggregation, nonce-based loop detection, ForwardingHint included in PIT key per NDN Architecture §4.2, expiry via hierarchical timing wheel
- Content Store — pluggable backends (LRU, sharded, persistent); MustBeFresh/CanBePrefix semantics; CS admission policy rejects FreshnessPeriod=0 Data; NoCache LP header respected; implicit digest lookup
- Strategy — BestRoute and Multicast with per-prefix StrategyTable dispatch; MeasurementsTable tracking EWMA RTT and satisfaction rate per face/prefix
- Scope enforcement —
/localhostprefix restricted to local faces inbound and outbound
Security
- Ed25519 — type code 5 per spec; sign and verify end-to-end
- HMAC-SHA256 — symmetric signing for high-throughput use cases
- BLAKE3 — two distinct experimental type codes pending reservation on the NDN TLV SignatureType registry:
- Plain BLAKE3 digest (type 6) —
Blake3Signer/Blake3DigestVerifier; analogous toDigestSha256(type 0). Provides integrity and self-certifying naming but no authentication — anyone can produce a valid signature. - Keyed BLAKE3 (type 7) —
Blake3KeyedSigner/Blake3KeyedVerifier; analogous toSignatureHmacWithSha256(type 4). Requires a 32-byte shared secret; provides both integrity and authentication. - Rationale for distinct codes: sharing one code between plain and keyed modes enables a downgrade substitution attack where an attacker strips the keyed signature and replaces it with a plain BLAKE3 digest over their forged content — on the wire both look identical, and a verifier dispatching on type code alone would accept the forgery. Using two codes mirrors the existing NDN pattern (
DigestSha256vs.HmacWithSha256). - BLAKE3 is 3–8× faster than SHA-256 on modern SIMD CPUs.
- Plain BLAKE3 digest (type 6) —
- Signed Interests — InterestSignatureInfo/InterestSignatureValue with anti-replay fields
- Trust chain validation —
Validator::validate_chain()walks Data → cert → trust anchor; cycle detection; configurable depth limit;CertFetcherdeduplicates concurrent cert requests - Trust schema — native
SchemaRulerules in adata_pattern => key_patterntext grammar, plus import of LightVerSec (LVS) binary trust schemas viaTrustSchema::from_lvs_binary— interoperable with the compiled output of python-ndn, NDNts@ndn/lvs, and ndndstd/security/trust_schema. SupportsValueEdgeliteral matches,PatternEdgecaptures, andSignConstraintgraph walks; user functions ($eq,$regex, …) parse cleanly but do not dispatch in v0.1.0 and are flagged viaLvsModel::uses_user_functions(). Version0x00011000of the binary format is accepted - Certificate TLV format —
Certificate::decode()parses ValidityPeriod (0xFD) with NotBefore/NotAfter; certificate time validity enforced;AdditionalDescriptionTLV constants defined - ValidationStage — sits in Data pipeline between PitMatch and CsInsert; drops Data failing chain validation
- NDNCERT 0.3 — all four routes (INFO/PROBE/NEW/CHALLENGE/REVOKE) now use TLV wire format; JSON protocol types removed from CA handler
- Self-certifying namespaces —
ZoneKeyinndn-security: zone root =BLAKE3_DIGEST(blake3(ed25519_pubkey));Name::zone_root_from_hash(),Name::is_zone_root()inndn-packet - DID integration —
ZoneKey::zone_root_did()bridges zone names ↔did:ndn:v1:…DIDs; top-levelDidDocument,UniversalResolver,name_to_did,did_to_nameexports added tondn_security
Transports
- UDP / TCP / WebSocket — standard IP transports with NDNLPv2 framing
- Multicast UDP — NFD-compatible multicast group (
224.0.23.170:6363) - Ethernet — raw AF_PACKET frames with Ethertype 0x8624 (Linux); PF_NDRV (macOS); Npcap (Windows)
- Unix socket — local IPC
- Shared memory (SHM) — zero-copy ring for same-host apps
- Serial/UART — COBS framing over tokio-serial
- Bluetooth LE — NDNts/esp8266ndn-compatible GATT server (
bluetoothfeature, Linux/BlueZ and macOS/CoreBluetooth); Service UUID099577e3-0788-412a-8824-395084d97391, CScc5abb89-a541-46d8-a351-2f95a6a81f49(client→server write), SC972f9527-0d83-4261-b95d-b1b2fc73bde4(server→client notify); oversized packets are fragmented via NDNLPv2 at the Face layer — the BLE protocol itself defines no framing, matching NDNts and esp8266ndn exactly; interoperable with Web Bluetooth API and ESP32 devices
Management
NFD-compatible TLV management protocol over Unix domain socket (/localhost/nfd/). Modules: rib, faces, fib, strategy-choice, cs, status.
Not Yet Implemented
Two forwarding-behavior features used by the wider NDN ecosystem are not yet handled by ndn-rs. Both are tracked in issue #13 and are slated for v0.2.0.
| Feature | Spec/reference | Status in ndn-rs |
|---|---|---|
| Forwarding hint handling in the forwarding pipeline | NFD redmine #3000, #3333 | ForwardingHint is parsed and included in the PIT key, but the pipeline does not perform hint-based FIB lookup or fallback — it treats hints as opaque |
| PIT tokens (NDN-DPDK convention) | NDNLPv2 PitToken field (0x62) | PitToken LP header is encoded/decoded on the wire, but is not generated or consumed by the forwarder — upstream producers cannot use it to demultiplex |
Remaining Compliance Gaps
Five items remain unresolved. None affect wire-level interoperability with NFD.
| Gap | Spec reference | Impact |
|---|---|---|
/localhop scope — only /localhost is enforced; /localhop packets (one-hop restriction) are forwarded without checking | NDN Architecture §4.1 | Low — affects multi-hop scenarios involving /localhop prefixes |
Name canonical ordering — no Ord impl on Name or NameComponent; cannot use BTreeMap or .sort() with NDN names | NDN Packet Format v0.3 §2.1 | Low — affects sorted data structures; doesn’t affect forwarding |
Certificate naming convention — cert Data packets use arbitrary names instead of /<Identity>/KEY/<KeyId>/<IssuerId>/<Version> | NDN Certificate Format v2 §4 | Moderate — certificates not exchangeable with ndn-cxx in the standard way |
| Certificate content encoding — public key bytes stored raw rather than DER-wrapped SubjectPublicKeyInfo | NDN Certificate Format v2 §5 | Moderate — same; interoperability with external cert issuers limited |
| TLV element ordering — recognized elements accepted in any order; spec requires defined order | NDN Packet Format v0.3 §1.4 | Low — lenient decoding; packets we produce are correctly ordered |
Summary
%%{init: {'theme': 'default'}}%%
pie title Spec Compliance (41 tracked items)
"Resolved" : 34
"Not yet implemented (forwarding features)" : 2
"Remaining compliance gaps" : 5
34 explicitly tracked compliance items are resolved. Two forwarding-behavior features used by the wider ecosystem — forwarding-hint dispatch and PIT-token echo — are partially wired but not yet active end-to-end. Five compliance gaps remain in certificate format details, name ordering, and lenient TLV parsing; none prevent interoperability with NFD on the plain forwarding path.
NDN Forwarder Comparison
A feature comparison of major open-source NDN forwarder implementations, ordered from features common to all to features found in only one. This page is reference, not advocacy: cells for other projects reflect what their upstream documentation states at the time of writing.
Legend
| Marker | Meaning |
|---|---|
| ✅ | Supported |
| ➖ | Partial, external project, or library-only |
| ❌ | Not supported |
For the ndn-fwd column, supported features are further annotated with release status:
| Marker | Meaning |
|---|---|
| ✅ | Ready for v0.1.0 — implemented, tested, and in the default build |
| ◐ | Partial — feature-gated, incomplete, not integration-tested, or not in default members |
| ○ | Future — stub, experimental, or explicitly planned for a later release |
Table
| Feature | NFD (C++) | NDNd (Go) | NDN-DPDK (C) | ndn-fwd (Rust) |
|---|---|---|---|---|
| ── Core NDN protocol ── | ||||
| TLV Interest / Data (v0.3) | ✅ | ✅ | ✅ | ✅ |
| PIT · CS · FIB | ✅ | ✅ | ✅ | ✅ |
| Nack / NDNLPv2 | ✅ | ✅ | ✅ | ✅ |
| Best-route strategy | ✅ | ✅ | ✅ | ✅ |
| Multicast strategy | ✅ | ✅ | ✅ | ✅ |
| NFD management TLV protocol | ✅ | ✅ | ➖ GraphQL | ✅ |
| ── Common transports ── | ||||
| UDP · TCP · Unix | ✅ | ✅ | ✅ | ✅ |
| Ethernet (AF_PACKET / L2) | ✅ | ✅ | ✅ | ◐ |
| WebSocket | ✅ | ✅ | ❌ | ✅ |
| WebSocket + TLS listener | ✅ | ✅ | ❌ | ◐ |
| HTTP/3 WebTransport | ❌ | ✅ | ❌ | ❌ |
| ── Strategies ── | ||||
| ASF (adaptive SRTT) | ✅ | ✅ | ➖ | ✅ |
| Pluggable strategy extension point | ➖ compile-in | ➖ compile-in | ➖ eBPF | ✅ trait |
| Hot-loadable WASM strategies | ❌ | ❌ | ❌ | ◐ |
| ── Content store backends ── | ||||
| In-memory LRU | ✅ | ✅ | ✅ mempool | ✅ |
| Sharded / parallel CS | ❌ | ❌ | ✅ | ✅ |
| Disk-backed CS | ❌ | ❌ | ❌ | ✅ Fjall |
| ── Routing / sync ── | ||||
| Static routes | ✅ | ✅ | ✅ | ✅ |
| NLSR (link-state) | ➖ external | ❌ | ➖ external | ➖ external |
| Distance-vector routing | ❌ | ✅ ndn-dv | ❌ | ✅ built-in |
| SVS / PSync | ➖ library | ➖ ndnd/std | ❌ | ✅ library |
| SWIM neighbour discovery | ❌ | ❌ | ❌ | ✅ |
| ── Performance / hardware ── | ||||
| Zero-copy packet path | ➖ | ➖ | ✅ DPDK | ✅ Bytes |
| Kernel-bypass I/O (DPDK / XDP) | ❌ | ❌ | ✅ | ❌ |
| 100 Gb/s-class throughput | ❌ | ❌ | ✅ | ❌ |
| ── Less common transports ── | ||||
| Shared-memory SPSC face | ❌ | ❌ | ✅ memif | ✅ ShmFace (Unix) |
| Serial / COBS (embedded) | ❌ | ❌ | ❌ | ◐ |
| BLE GATT face | ❌ | ❌ | ❌ | ◐ |
| Wifibroadcast (WFB) face | ❌ | ❌ | ❌ | ○ |
| In-process face | ❌ | ❌ | ❌ | ✅ |
| ── Security ── | ||||
| ECDSA / RSA / Ed25519 / HMAC | ✅ | ✅ | ➖ | ✅ |
| SHA-256 digest signatures | ✅ | ✅ | ✅ | ✅ |
| BLAKE3 plain + keyed (sig-types 6/7) | ❌ | ❌ | ❌ | ✅ |
| LightVerSec binary trust schema | ➖ library | ✅ ndnd/std | ❌ | ✅ |
| NDNCERT 0.3 client | ➖ ndncert | ✅ certcli | ❌ | ✅ |
| Compile-time verified-vs-unverified Data type split | ❌ | ❌ | ❌ | ✅ SafeData |
| ── Deployment model ── | ||||
| Standalone daemon | ✅ | ✅ | ✅ | ✅ |
| Forwarder embeddable as library | ❌ | ❌ | ❌ | ✅ |
Bare-metal no_std build | ❌ | ❌ | ❌ | ○ ndn-embedded |
| Mobile (Android / iOS) | ➖ NDN-Lite | ❌ | ❌ | ○ ndn-mobile |
| WebAssembly / in-browser simulation | ❌ | ❌ | ❌ | ◐ ndn-wasm |
| Built-in network simulator | ➖ ndnSIM | ❌ | ❌ | ✅ ndn-sim |
| ── Ecosystem / tooling ── | ||||
| CLI tools (peek/put/ping/etc.) | ✅ ndn-tools | ✅ | ➖ | ✅ |
| Throughput / latency bench suite | ➖ external | ➖ internal | ✅ | ✅ |
| Multi-forwarder compliance testbed | ❌ | ❌ | ❌ | ✅ Docker Compose |
| Desktop GUI management | ❌ | ❌ | ❌ | ◐ Dioxus |
| Python bindings | ➖ separate | ❌ | ❌ | ◐ PyO3 |
| JVM / Swift bindings | ❌ | ❌ | ❌ | ◐ BoltFFI |
| In-network named-function compute | ❌ | ❌ | ❌ | ◐ ndn-compute |
ndn-fwd v0.1.0 status notes
The markers above reflect the state of the main branch as the v0.1.0
release is prepared.
Partial (◐) in v0.1.0:
- Ethernet L2, WebSocket TLS, Serial COBS — functional but behind non-default Cargo features; not exercised by the default CI matrix.
- BLE GATT face — implementation present under the
bluetoothfeature with a known TODO around macOS TX drain; not yet interop-tested. - Hot-loadable WASM strategies —
ndn-strategy-wasmexists as a proof of concept but is not yet wired intondn-engineas a runtime loader. - WebAssembly browser sim (
ndn-wasm) — builds forwasm32-unknown-unknownbut not in default workspace members. - Dioxus desktop dashboard — compiles and runs against a live forwarder but is not formally release-tested.
- Python (PyO3) and JVM/Swift (BoltFFI) bindings — build on a developer machine with platform toolchains installed but are not part of default members or CI artefacts.
ndn-compute— experimental named-function compute runtime; API surface is not frozen for v0.1.0.
Future (○) — post-v0.1.0:
- Wifibroadcast (WFB) face — placeholder crate;
recv/sendcurrently returnFaceError::Closed. ndn-embeddedbare-metal no_std forwarder — skeleton exists; MCU targets and allocators not yet wired up.ndn-mobileAndroid / iOS forwarder — requires platform toolchains (NDK, Xcode) and is not yet part of any release build.
Notes on other forwarders
- NDN-DPDK is a specialised high-throughput forwarder targeting
DPDK-capable NICs; absence of WebSocket or a standard-library-style app API
reflects that focus, not a gap. Strategies are implemented as eBPF programs
loaded via the DPDK BPF library and executed on the uBPF virtual machine
(see
container/strategycode/README.mdupstream). - NDNd subsumes the earlier YaNFD project:
ndnd/fwis the continuation of YaNFD, shipped alongsidendnd/dv(distance-vector routing),ndnd/std(Go application library with Light VerSec binary schema support), and security tooling (sec,certcli). Its sampleyanfd.config.ymlalso exposes an HTTP/3 WebTransport listener. - NFD is the reference implementation; many features listed as
“➖ external” (NLSR, ndncert, ndn-tools) are maintained as separate
projects under the
named-dataorganisation and are the canonical implementations of those features. - ndn-fwd uses the
Face,Strategy,ContentStore,RoutingProtocol, andDiscoveryProtocoltraits as extension points. The engine itself is a library crate (ndn-engine); thendn-fwdbinary is a thin wrapper around it, which enables the embeddable /no_std/ mobile / WebAssembly build targets. - Rows marked “library” mean the feature exists as an application-level library in that project’s ecosystem but is not a built-in forwarder capability.
Sources
- NFD: named-data/NFD
- NDNd (incl. former YaNFD): named-data/ndnd
- NDN-DPDK: usnistgov/ndn-dpdk
- ndn-fwd: this repository — see
ARCHITECTURE.md
BLAKE3 Signature Types
This document specifies two NDN SignatureType values backed by the BLAKE3
cryptographic hash function. They follow the structure and conventions of
the existing DigestSha256 and SignatureHmacWithSha256 definitions in the
NDN Packet Format Specification.
| Name | TLV-VALUE of SignatureType | Authenticated | Output length |
|---|---|---|---|
DigestBlake3 | 6 | no | 32 octets |
SignatureBlake3Keyed | 7 | yes (32-byte shared key) | 32 octets |
Both type codes are reserved on the
NDN TLV SignatureType registry.
1. DigestBlake3
DigestBlake3 provides a content-integrity digest of an Interest or Data
packet computed with the BLAKE3 hash function. Like DigestSha256, it
provides no information about the provenance of a packet and no guarantee
that the packet originated from any particular signer; it is intended for
self-certifying content names and high-throughput integrity checks where
authentication is provided by other means (for example, a name carried
inside an enclosing signed packet).
- The TLV-VALUE of
SignatureTypeis6. KeyLocatoris forbidden; if present, it MUST be ignored.- The signature is the unkeyed BLAKE3 hash, in default 256-bit output mode, of the signed portion of the Data or Interest as defined in §3.1 of the NDN Packet Format Specification.
SignatureValue = SIGNATURE-VALUE-TYPE
TLV-LENGTH ; == 32
32OCTET ; == BLAKE3{Data signed portion}
InterestSignatureValue = INTEREST-SIGNATURE-VALUE-TYPE
TLV-LENGTH ; == 32
32OCTET ; == BLAKE3{Interest signed portion}
The hash function is BLAKE3 as defined in the BLAKE3 specification, §2 (“BLAKE3”), invoked with no key material and no key-derivation context, producing a 32-octet output.
2. SignatureBlake3Keyed
SignatureBlake3Keyed defines a message authentication code calculated over
the signed portion of an Interest or Data packet using BLAKE3 in keyed
mode. It is the BLAKE3 analogue of SignatureHmacWithSha256: the verifier
and signer share a 32-byte symmetric secret, and a successful verification
demonstrates that the packet was produced by a holder of that secret.
- The TLV-VALUE of
SignatureTypeis7. KeyLocatoris required and MUST identify the shared key by name, using theKeyDigestorNameform as appropriate.- The signature is BLAKE3 in keyed mode (BLAKE3 specification, §2.3 “Keyed Hashing”) with the 32-octet shared key, computed over the signed portion of the Data or Interest, producing a 32-octet output.
- The shared key MUST be exactly 32 octets in length. Implementations MUST reject keys of any other length rather than padding or truncating.
SignatureValue = SIGNATURE-VALUE-TYPE
TLV-LENGTH ; == 32
32OCTET ; == BLAKE3-keyed(key, Data signed portion)
InterestSignatureValue = INTEREST-SIGNATURE-VALUE-TYPE
TLV-LENGTH ; == 32
32OCTET ; == BLAKE3-keyed(key, Interest signed portion)
3. Rationale: distinct type codes for plain and keyed BLAKE3
NDN already separates an unauthenticated digest (DigestSha256, type 0)
from its keyed counterpart (SignatureHmacWithSha256, type 4). The
plain-vs-keyed split for BLAKE3 follows the same pattern, and exists for
the same reason: a verifier that dispatches on signature type alone must be
able to tell which algorithm to run.
If both modes shared a single type code, an attacker holding a packet
signed with SignatureBlake3Keyed could strip the keyed signature, replace
the Content with arbitrary forged bytes, and append an unkeyed BLAKE3 hash
of the new payload. On the wire the two values are indistinguishable — both
are 32-octet BLAKE3 outputs — so a verifier that selected the unkeyed code
path would accept the forgery. Distinct type codes prevent this downgrade
by forcing the verifier to commit to a verification algorithm before
inspecting the signature value.
4. Why BLAKE3
BLAKE3 was chosen because it offers ~3–8× the throughput of SHA-256 on modern x86 and ARM CPUs (due to internal SIMD parallelism and a tree-structured compression function) while providing equivalent cryptographic guarantees: 256-bit collision resistance for the unkeyed mode and 128-bit security for the keyed mode against a key-recovery adversary, as analysed in the BLAKE3 specification. For NDN deployments that sign or verify large numbers of small Data packets — sensor telemetry, named-data sync digests, high-rate pub/sub — the per-packet hashing cost is often the dominant security overhead, and BLAKE3 reduces it without changing the surrounding KeyChain or trust-schema model.
5. Test vectors
All vectors operate on the signed portion of a packet (the byte sequence
that the signer hashes), not on a full packet. Implementers can verify
against any conforming BLAKE3 library: blake3::hash for DigestBlake3,
blake3::keyed_hash for SignatureBlake3Keyed. All hex strings are
lowercase, big-endian byte order.
Vector 1 — DigestBlake3, empty signed portion
signed portion : (empty, 0 octets)
SignatureValue : af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262
This output matches the BLAKE3 specification’s published hash of the empty input and serves as a sanity check that the underlying hash library is configured correctly.
Vector 2 — DigestBlake3, sample Data signed portion
The signed portion below is the byte concatenation of a Name TLV
(/ndn/test/blake3), a MetaInfo TLV (ContentType=BLOB, FreshnessPeriod=4000
ms), a Content TLV ("BLAKE3 NDN test"), and a SignatureInfo TLV with
SignatureType=6.
signed portion (57 octets):
0719080308036e646e0804746573740806626c616b6533360100140c180102
190210a0150f424c414b4533204e444e207465737416030f0106
SignatureValue : e709c95387c663ff293bce17cf7ec685840ce9d0bc7785ce6fbd0b9fda3aaedb
Vector 3 — SignatureBlake3Keyed, empty signed portion, all-zero key
key (32 octets) : 0000000000000000000000000000000000000000000000000000000000000000
signed portion : (empty, 0 octets)
SignatureValue : a7f91ced0533c12cd59706f2dc38c2a8c39c007ae89ab6492698778c8684c483
Vector 4 — SignatureBlake3Keyed, sample Data signed portion
Same Name, MetaInfo, and Content as Vector 2, with SignatureType=7 in the
SignatureInfo TLV and a key of 0x00 0x01 ... 0x1f.
key (32 octets) : 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
signed portion (57 octets):
0719080308036e646e0804746573740806626c616b6533360100140c180102
190210a0150f424c414b4533204e444e207465737416030f0107
SignatureValue : 997f788fb4a8d03156ef964ffc52cfb6b7889ef88e4bea0c8d28c4db5e1686a3
6. Interoperability
A reference implementation is available in the ndn-security crate of the
ndn-rs project:
Blake3Signer/Blake3DigestVerifier—DigestBlake3(type 6)Blake3KeyedSigner/Blake3KeyedVerifier—SignatureBlake3Keyed(type 7)
Both signers expose the same Signer trait used by the rest of the
KeyChain and integrate with the LightVerSec trust schema validator.
7. References
- NDN Packet Format Specification, §3 “Signature”: https://docs.named-data.net/NDN-packet-spec/current/signature.html
- BLAKE3 specification: https://github.com/BLAKE3-team/BLAKE3-specs/blob/master/blake3.pdf
- NDN TLV registry — SignatureType: https://redmine.named-data.net/projects/ndn-tlv/wiki/SignatureType
Interoperability Test Results
This page is automatically updated by the
testbed CI workflow
on every push to main and weekly on Mondays.
The test matrix exercises ndn-rs against ndn-cxx, NDNts, NFD, and yanfd in both consumer and producer roles. See Interoperability Testing for the full scenario descriptions and the compatibility challenges resolved along the way.
Last run: 20260414T215945Z · 8 passed, 0 failed, 0 skipped
| Scenario | Result | Description |
|---|---|---|
| ndn-fwd as Forwarder | ||
fwd/cxx-consumer | ✅ PASS | ndn-cxx consumer ← ndn-fwd → ndn-rs producer |
fwd/cxx-producer | ✅ PASS | ndn-rs consumer ← ndn-fwd → ndn-cxx producer |
fwd/ndnts-consumer | ✅ PASS | NDNts consumer ← ndn-fwd → ndn-rs producer |
fwd/ndnts-producer | ✅ PASS | ndn-rs consumer ← ndn-fwd → NDNts producer |
| ndn-rs as Application Library | ||
app/nfd-cxx-producer | ✅ PASS | ndn-rs consumer → NFD → ndn-cxx producer (with signature validation) |
app/nfd-cxx-consumer | ✅ PASS | ndn-cxx consumer → NFD → ndn-rs producer (ndn-cxx validates signature) |
app/yanfd-ndnts-producer | ✅ PASS | ndn-rs consumer → yanfd → NDNts producer |
app/yanfd-ndnts-consumer | ✅ PASS | NDNts consumer → yanfd → ndn-rs producer |
did:ndn DID Method Specification
Method Name: ndn
Status: Draft
Authors: ndn-rs contributors
Specification: This document follows the W3C DID Method Registry template.
Abstract
The did:ndn DID method binds W3C Decentralized Identifiers to Named Data Networking (NDN) names. It supports two resolution strategies:
- CA-anchored DIDs — rooted in a certificate authority hierarchy, resolved by fetching an NDNCERT certificate
- Zone DIDs — self-certifying, resolved by fetching a signed DID Document Data packet at the zone root name
Both strategies use NDN Interest/Data exchange for resolution; no DNS, HTTP, or blockchain infrastructure is required.
1. Method-Specific Identifier Syntax
The ABNF for did:ndn identifiers is:
did-ndn = "did:ndn:" base64url
base64url = 1*(ALPHA / DIGIT / "-" / "_")
The method-specific identifier is the base64url-encoded (no padding) complete NDN Name TLV wire format, including the outer Name-Type (0x07) and TLV-Length octets.
did:ndn:<base64url(Name TLV)>
This single form handles all NDN names — GenericNameComponents, BLAKE3_DIGEST zone roots, versioned components, sequence numbers, and any future typed components — without type-specific special cases or dual-form ambiguity.
1.1 Encoding Examples
NDN name: /com/acme/alice
Name TLV (hex): 07 11 08 03 com 08 04 acme 08 05 alice
did:ndn: did:ndn:<base64url of above>
NDN name: /<blake3_digest(pubkey)> (zone root, type 0x03)
Name TLV (hex): 07 22 03 20 <32 bytes>
did:ndn: did:ndn:<base64url of above>
The method-specific identifier contains no colons (: is not in the base64url alphabet), which unambiguously distinguishes the current encoding from the deprecated dual-form encoding described in §1.2.
1.2 Deprecated Encoding (Backward Compatibility)
Earlier drafts of this spec defined two forms that are now deprecated:
| Form | Syntax | Problem |
|---|---|---|
| Simple | did:ndn:com:acme:alice | Ambiguous when first component equals v1 |
| v1 binary | did:ndn:v1:<base64url> | v1: sentinel collides with a name whose first component is literally v1 |
Ambiguity example: Both of the following produced did:ndn:v1:BwEA under the old scheme:
- The binary encoding of a name with a single zero-byte GenericNameComponent
- The simple encoding of the name
/v1/BwEA(two ASCII components)
The unified binary form eliminates this: every NDN name maps to exactly one DID string.
Implementations must still accept the deprecated forms in did_to_name for backward compatibility, but must not produce them. The presence of a : in the method-specific identifier identifies a deprecated DID; the presence of v1: as the first two characters identifies the deprecated binary form specifically.
2. DID Document Structure
A did:ndn DID Document conforms to the W3C DID Core data model and is serialised as JSON-LD.
2.1 CA-Anchored DID Document
Derived from the NDNCERT certificate at <identity-name>/KEY:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1"
],
"id": "did:ndn:com:acme:alice",
"verificationMethod": [{
"id": "did:ndn:com:acme:alice#key-0",
"type": "JsonWebKey2020",
"controller": "did:ndn:com:acme:alice",
"publicKeyJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url(pubkey)>"
}
}],
"authentication": ["did:ndn:com:acme:alice#key-0"],
"assertionMethod": ["did:ndn:com:acme:alice#key-0"],
"capabilityInvocation": ["did:ndn:com:acme:alice#key-0"],
"capabilityDelegation": ["did:ndn:com:acme:alice#key-0"]
}
2.2 Zone DID Document
Published as a signed NDN Data packet at the zone root name. Zone owners must publish this document for resolvers to find it. The document:
- Must include an Ed25519
verificationMethodwhose public key satisfiesblake3(pubkey) == zone_root_component - May include an X25519
keyAgreementmethod for encrypted content (derived from the Ed25519 seed or generated independently) - May include
serviceendpoints (e.g., sync group prefixes, router prefixes)
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1"
],
"id": "did:ndn:<base64url(zone-root-Name-TLV)>",
"verificationMethod": [
{
"id": "did:ndn:<base64url>...#key-0",
"type": "JsonWebKey2020",
"controller": "did:ndn:<base64url>...",
"publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }
},
{
"id": "did:ndn:<base64url>...#key-agreement-0",
"type": "JsonWebKey2020",
"controller": "did:ndn:<base64url>...",
"publicKeyJwk": { "kty": "OKP", "crv": "X25519", "x": "..." }
}
],
"authentication": ["did:ndn:<base64url>...#key-0"],
"assertionMethod": ["did:ndn:<base64url>...#key-0"],
"keyAgreement": ["did:ndn:<base64url>...#key-agreement-0"],
"capabilityInvocation": ["did:ndn:<base64url>...#key-0"],
"capabilityDelegation": ["did:ndn:<base64url>...#key-0"],
"service": []
}
3. CRUD Operations
3.1 Create
CA-anchored: Enroll with an NDNCERT CA. The CA issues a certificate at <identity-name>/KEY/<version>/<issuer>. The DID is derived from the identity name.
Zone: Generate an Ed25519 keypair. Compute zone_root = blake3(public_key). Construct the zone root name as a single BLAKE3_DIGEST component. Sign and publish the DID Document as an NDN Data packet at the zone root name.
#![allow(unused)]
fn main() {
use ndn_security::{ZoneKey, build_zone_did_document};
let zone_key = ZoneKey::from_seed(&seed);
let doc = build_zone_did_document(&zone_key, x25519_key, services);
// Publish doc as a signed Data packet at zone_key.zone_root_name()
}
3.2 Read (Resolution)
Resolution is performed by an NdnDidResolver wired with an NDN fetch function.
CA-anchored: The resolver sends an Interest for <identity-name>/KEY. The Data response contains a certificate in NDNCERT TLV format. The DID Document is derived from the certificate’s public key.
Zone: The resolver sends an Interest for the zone root name. The Data response contains a JSON-LD DID Document. After parsing, the resolver verifies:
doc.id == requested_didblake3(doc.ed25519_public_key) == zone_root_name_component
If either check fails, invalidDidDocument is returned.
3.3 Update
CA-anchored: Certificate renewal via NDNCERT. The identity prefix and DID are unchanged.
Zone: Publish a new signed DID Document at the same zone root name with updated keys, services, or metadata. The zone root name is immutable — it is derived from the original public key. To rotate the Ed25519 signing key, use zone succession.
3.4 Deactivate (Zone Succession)
A zone owner signals deactivation by publishing a succession document at the old zone root name:
#![allow(unused)]
fn main() {
use ndn_security::build_zone_succession_document;
let doc = build_zone_succession_document(&old_zone_key, "did:ndn:<base64url-of-new-zone>");
// Publish doc at old_zone_key.zone_root_name()
}
The succession document:
- Has
alsoKnownAs: ["did:ndn:v1:<new-zone>"] - Has empty
assertionMethod,capabilityInvocation,capabilityDelegation - Still carries the old Ed25519 key so verifiers can authenticate the succession claim
Resolvers that receive a succession document should:
- Set
deactivated: trueinDidDocumentMetadata - Expose the successor DID via
alsoKnownAsfor the caller to follow
4. Security Considerations
4.1 Zone DID Binding
The cryptographic binding of a zone DID to its public key is enforced at resolution time by verifying blake3(pubkey) == zone_root_component. This check is mandatory and must not be skipped, even when the document is fetched over a trusted channel.
4.2 Data Packet Authentication
Zone DID Documents must be signed as NDN Data packets with the zone’s Ed25519 key. Resolvers must validate the Data packet signature before extracting document bytes. If the NDN-layer signature is invalid, the resolution result is internalError.
4.3 CA-Anchored Trust
CA-anchored DID resolution trusts the NDNCERT CA hierarchy. The CA’s identity and signing policy are out of scope for this specification; see NDNCERT: Automated Certificate Issuance for the CA protocol.
4.4 Succession Attacks
An attacker who compromises the old zone private key could publish a fraudulent succession document. Zone owners should retire old keys promptly after succession and distribute the new zone DID via authenticated out-of-band channels.
4.5 Replay
NDN Interest/Data exchange uses nonce-based deduplication. DID Document Data packets should include a freshness period so that resolvers prefer fresh copies over cached stale documents.
5. Privacy Considerations
5.1 Name Correlation
did:ndn:com:acme:alice directly encodes the NDN identity namespace prefix. This leaks organizational hierarchy to any observer. Zone DIDs (did:ndn:v1:…) are pseudonymous — the base64url blob reveals nothing about the owner’s identity beyond the public key.
5.2 Resolution Traffic
Sending an NDN Interest for <identity-name>/KEY reveals to network nodes along the path that the requester is resolving that DID. Interest aggregation in NDN routers limits this to one forwarded Interest per prefix per time window.
5.3 Key Agreement
Zone DID Documents may include an X25519 keyAgreement key. This key must be generated independently from the Ed25519 signing key (or derived via a one-way function) so that compromise of the signing key does not imply compromise of encrypted content.
6. Rust API Reference
| Type / Function | Description |
|---|---|
ZoneKey::from_seed(&[u8; 32]) | Derive Ed25519 key and zone root name from a 32-byte seed |
ZoneKey::zone_root_name() | The /<blake3_digest> NDN name |
ZoneKey::zone_root_did() | did:ndn:v1:<base64url> string |
build_zone_did_document(&ZoneKey, x25519, services) | Construct a zone DID Document |
build_zone_succession_document(&ZoneKey, successor_did) | Construct a succession document |
cert_to_did_document(&Certificate, x25519) | Derive a DID Document from an NDNCERT certificate |
NdnDidResolver::with_fetcher(fn) | Wire a CA-anchored cert fetch function |
NdnDidResolver::with_did_doc_fetcher(fn) | Wire a zone DID Document fetch function |
UniversalResolver::resolve(did) | Resolve a DID, returns DidResolutionResult |
UniversalResolver::resolve_document(did) | Resolve and return just the DidDocument |
name_to_did(&Name) | Encode an NDN name as a did:ndn string |
did_to_name(&str) | Decode a did:ndn string back to an NDN name |
7. Conformance
This method is intended to be registered with the W3C DID Method Registry. Until registered, the method name ndn is used informally by ndn-rs and associated projects.
External Links and References
NDN Project
- named-data.net – official NDN project site
- NDN Publications – research papers, technical reports, and presentations
- NDN Testbed Status – live status of the global NDN testbed
Specifications
Note: RFC 8569 and RFC 8609 are CCNx specifications (Content-Centric Networking, a related but distinct architecture). They are not NDN specifications. NDN specs are published by the named-data.net project.
- NDN Packet Format v0.3 – canonical TLV encoding for Interest, Data, and LpPacket; type assignments
- NDN Architecture (NDN-0001) – architecture vision and research roadmap; motivates the design but does not specify forwarding behavior
- NDNLPv2 Link Protocol – fragmentation, reliability, congestion marking
- NDN Certificate Format v2 – certificate naming convention, content format, validity period
- NDNCERT 0.3 – automated certificate issuance protocol
Reference Implementations
- NFD (NDN Forwarding Daemon) – the reference C++ forwarder
- NFD Developer Guide – architecture and internals of NFD
- ndn-cxx – C++ client library for NDN (used by NFD and NDN applications)
- ndn-cxx Documentation – API reference and tutorials
- ndnd – Go implementation of an NDN forwarder
- python-ndn – Python NDN client library
NDN Community
- named-data Mailing List – ndn-interest mailing list for technical discussion
- NDN GitHub Organization – source code for all official NDN software
- NDN Frequently Asked Questions
Related Research
- NDN Technical Memos – design rationale and protocol analysis
- ICN Research Group (ICNRG) – IRTF research group on Information-Centric Networking (parent research area of NDN)
- RFC 7927: Information-Centric Networking Research Challenges – overview of ICN challenges and open problems
Rust Ecosystem (used by ndn-rs)
- Tokio – async runtime
- bytes – zero-copy byte buffers
- DashMap – concurrent hash map (used for PIT)
- Criterion.rs – microbenchmark framework
- tracing – structured logging and diagnostics
- smallvec – stack-allocated small vectors (used for names and forwarding actions)
App Ideas
NDN applications feel architecturally different from REST APIs almost immediately, and the difference is not superficial. In a traditional client-server model, the application is responsible for knowing where the data lives. You hardcode a hostname, consult a service registry, or do a DNS lookup, and then you open a connection to a specific machine. If that machine is unavailable, you get nothing. If the same data is available on a closer replica, you have to know about it in advance and route around to it yourself. Caching is something you add deliberately, as a separate layer, with explicit cache-key management and invalidation logic.
NDN inverts this. When a consumer expresses an Interest for /sensor/room42/temperature, it does not address a host. It names a piece of data, and the network finds it. If the nearest router has it cached, the consumer gets a response in microseconds without the Interest ever leaving the local machine. If no router has it cached, the Interest propagates outward until a node that can satisfy it responds. The consumer’s code is identical in both cases — consumer.get("/sensor/room42/temperature") works whether the data is in-process, cached at the router down the hall, or produced by a device across the internet. The location is the network’s problem, not the application’s.
This principle extends to scale in a way that REST cannot match cleanly. The same ndn-app API — Consumer, Producer, Subscriber, Queryable — works identically when the application is wired to an in-process embedded engine via ndn-embedded, connected to a ndn-fwd on localhost, or talking to a forwarder on the far side of a WAN link. Application code has no mode switches, no configuration for “local vs. remote,” no special handling for offline or disconnected scenarios. The following ideas are all built from this foundation.
Distributed Sensor Mesh
Key NDN feature: Location-independent naming — the namespace is the configuration; sensors are discovered by name, not by address.
A building or industrial facility might have hundreds of environmental sensors ranging from tiny microcontroller nodes that have no operating system to gateway machines running full Linux stacks. In IP, connecting these into a unified monitoring system requires careful orchestration: each sensor needs a fixed address or a dynamic registration mechanism, the gateway must maintain per-sensor state, and the cloud dashboard must know where to query. Adding a new sensor means updating the registry.
In NDN, every sensor simply publishes under its name. A soil moisture sensor in a greenhouse publishes readings under /greenhouse/bed3/moisture, and a temperature probe under /greenhouse/bed3/temp. The gateway, the local dashboard, and the cloud backend all express Interests for whichever names they care about — they do not need to know whether they are talking to the sensor directly or to a router that has a cached reading. ndn-embedded lets each microcontroller node run a minimal forwarder directly, without a router process, while ndn-app’s Producer handles the publication side on the gateway. The namespace itself is the configuration. New sensors appear in the network simply by starting to publish; existing consumers pick them up through prefix wildcards without any out-of-band registration.
Collaborative Document Editing
Key NDN feature: State-vector sync (SVS) — decentralized multi-writer convergence without a coordination server.
Collaborative editing tools in the IP world require a central server to mediate conflicts, synchronize state, and broadcast changes to all participants. Even architectures that appear peer-to-peer, like operational transform or CRDT-based editors, typically rely on a coordination server that all clients maintain connections to. Offline editing introduces synchronization debt that must be resolved on reconnection, often through a bespoke reconciliation protocol.
SVS sync, exposed through ndn-app’s Subscriber, turns this into a much smaller problem. Each participant publishes their edits under their own name prefix — Alice under /doc/project-spec/alice/edit, Bob under /doc/project-spec/bob/edit — and the SVS state vector tells every node which sequence numbers it has seen from every other node. When two writers are offline and then reconnect, the sync group converges automatically: each side’s Subscriber discovers the missing sequence ranges and fetches them as ordinary named data. There is no “master copy” to reconcile against. The multi-writer property falls out from the data-layer security model: every edit is signed by its author, so the system can attribute and sequence edits correctly even if they have passed through untrusted intermediaries.
Named Video Distribution
Key NDN feature: In-network caching — every forwarder becomes a CDN node automatically, with no operator configuration.
Video streaming at scale is one of the clearest places where NDN’s in-network caching pays off immediately. A traditional CDN is an expensive, carefully engineered overlay that replicates content from an origin server to geographically distributed edge nodes. Operating one requires contracts, geographic presence, and significant infrastructure. Even then, the unit of caching is an HTTP response, tied to a URL served from a specific hostname — the CDN configuration must be maintained as a separate operational concern.
NDN makes every router a potential cache. When a producer publishes a video file segmented under /media/lecture-series/ndn-intro/seg/0 through /media/lecture-series/ndn-intro/seg/4199, every forwarder that satisfies an Interest for any segment automatically caches that segment in its Content Store. The tenth viewer watching the same lecture from the same office building gets served from the nearest LAN router, not from the producer. The producer never sees the repeat traffic. From the application’s perspective, the Consumer simply fetches sequential segments by name; the fact that the first viewer warmed the cache for everyone else is an emergent property of the architecture, not something the application or the operator had to configure.
Field Data Collection
Key NDN feature: Pull model with persistent named data — data sits in the namespace and waits to be fetched; the collector needs no live connection to the device.
Survey teams, ecologists, and field engineers often work in environments with intermittent or absent network connectivity. A traditional field data collection app must either hold all data locally and batch-sync on reconnection, or require a live connection to the central server to record anything. Both approaches require explicit code: a local buffer with sync logic, conflict detection, and a custom protocol for catch-up on reconnect.
NDN’s pull model means none of this needs to be written explicitly. A field device running ndn-embedded publishes each observation under a timestamped name — /survey/transect7/obs/20250617T142305 — and the local Content Store holds it. When the device later comes within range of a gateway or mobile hotspot, a data collection agent on the other side simply expresses Interests for the names it does not yet have. The sync group, via Subscriber, tells the agent which sequence numbers exist; the agent fetches the gaps. The field device does not need to know when it is online or offline, does not need to manage a retry queue, and does not need to initiate a push. The data sits in the named namespace and waits to be fetched. Data-layer security means the collected observations carry the field device’s signature, so their provenance is verifiable even after they have passed through an intermediate cache.
Edge Compute with Structural Memoization
Key NDN feature: In-network computation — computation results are named data; the Content Store automatically memoizes them across all consumers.
A common pattern in IoT and scientific computing is running the same transformation repeatedly on the same input data. An image processing pipeline might downsample the same high-resolution frame for a dozen different consumers needing different thumbnail sizes. In IP, ten consumers requesting the same thumbnail from a REST endpoint produce ten invocations of the resize computation, unless the application author has explicitly wired in a caching layer.
The ndn-compute crate eliminates this class of redundant work at the architecture level. A compute handler registered under /compute/thumbnail receives Interests of the form /compute/thumbnail/width=320/src=<digest>, performs the resize, and returns a Data packet. The forwarder’s Content Store caches the result by name. The eleventh consumer requesting the same thumbnail at the same width gets a cache hit before the Interest ever reaches the compute handler — the handler never runs again for those inputs. This is not an optimization the developer opts into; it falls out of how the pipeline handles Data packets, and it applies to any computation that can be expressed as a named function of named inputs. In more advanced deployments, intermediate routers can perform the computation themselves if they have the capability registered — a request that enters the network at one edge node might never reach the origin producer if a closer node can compute the answer. The ndn-compute ComputeFace is simply another face in the face table, indistinguishable to the pipeline from a UDP or Ethernet face.
Distributed Configuration Management
Key NDN feature: In-network caching with MustBeFresh — forwarders serve fresh configuration to all consumers without the producer seeing every poll.
Distributing configuration to a fleet of services or devices is a deceptively hard problem. IP approaches typically involve a configuration service (etcd, Consul, a custom REST API), which every node must be able to reach, whose address must be known in advance, and whose availability becomes a system-wide dependency. Rolling out a new configuration value requires either push delivery to every node or polling by every node, both of which require connection management.
NDN reduces this to a named data publication. An operator publishes a new configuration under /config/fleet/v=42 with a freshness period set to the desired polling interval. Every node in the fleet runs a Consumer that periodically expresses an Interest for /config/fleet with MustBeFresh set. As the Interest propagates, any router with a fresh cached copy answers immediately; only nodes that have stale or absent caches forward the Interest toward the configuration producer. Nodes that are temporarily offline simply continue using their cached configuration until they reconnect, at which point a stale cache triggers a fresh Interest automatically. Because configuration data is signed at the data layer, nodes can verify that a configuration came from the authorized publisher without needing a secure channel to the configuration service.
Peer-to-Peer Messaging with Named Identities
Key NDN feature: Location-independent routing + data-layer security — messages route to a name, not an address; signatures are carried in the data, not the transport.
Messaging applications that avoid a central server face a fundamental tension in the IP world: without a server, how does a message find its recipient? Federated protocols (Matrix, ActivityPub) require a home server per domain. Fully decentralized approaches (Briar, Meshtastic) typically bind delivery to the physical proximity of devices or use custom flooding protocols.
NDN gives identities a name, and the network routes by name. Alice’s inbox is /msg/alice and she runs a Producer on that prefix. Bob sends a message by expressing an Interest for /msg/alice/from=bob/ts=1718620800, which routes through the network to wherever Alice’s Producer is registered. The message content is signed with Bob’s key, so Alice can verify who sent it without trusting the transport path. Because NDN routes by prefix rather than address, Alice can move between networks — home WiFi, mobile, a VPN — without updating her peers’ address books. The routing infrastructure adjusts; the name stays constant. Group messaging maps naturally onto SVS sync groups: each participant publishes their messages under their own prefix, and all members’ Subscriber instances converge on the full set of messages without a group server.
Cross-Environment Digital Twin
Key NDN feature: Unified local/remote interface — the same
ConsumerAPI and the same name work across an MCU, a LAN router, and a WAN-connected cloud backend without protocol bridging.
A digital twin — a live, queryable model of a physical asset — requires bridging data from embedded sensors through local edge processing to cloud analytics dashboards. In IP, this bridge is usually built with a stack of adapters: MQTT from the device to a broker, REST from the broker to a database, a query API from the database to the dashboard. Each hop involves a format conversion, an address binding, and a connection to manage. The embedded device speaks a different protocol from the dashboard.
NDN collapses this stack. The physical sensor publishes its state as named data — /twin/conveyor-7/bearing-temp — via ndn-embedded running directly on the microcontroller. The local edge dashboard expresses an Interest for that name and gets the reading, potentially from a router cache, with no direct connection to the sensor required. The cloud analytics platform expresses the same Interest over a WAN face and gets the same data, signed by the same sensor, without any transcoding or protocol bridging. All three environments — embedded MCU, local LAN, cloud backend — run the same Consumer API against the same name. The digital twin is simply the named namespace; what changes per environment is only which forwarder the application connects to, not the application code itself.
Release v0.1.0 — Stable API (upcoming)
Status: draft / unreleased. v0.1.0 has not been tagged yet. The workspace
Cargo.tomlreads0.1.0andmainis converging on the release, but nov0.1.0git tag exists and no GitHub Release has been published. This page describes what the release will contain once cut. To use the current state, trackmaindirectly or pullghcr.io/quarmire/ndn-fwd:latest(theedgeandlatestcontainer tags are rebuilt on every push). The release will be tagged after the remaining open items from yoursunny’s review issues are confirmed resolved.
Target tag: v0.1.0
Previous: v0.1.0-alpha
Summary
v0.1.0 will be the first stable release of ndn-rs. It locks in all public names and crate structure that will remain stable through the v0.x series (barring explicit semver breaking changes). The key themes are:
- Name finalization — binary
ndn-router→ndn-fwd;RouterClient→ForwarderClient;AppFace→InProcFace; four face crates → onendn-faces. - API completeness —
Responderpattern for producers, consumer convenience methods,BlockingForwarderClientfor FFI, PSync subscriber variant. - Security ergonomics —
KeyChainsigning,trust_only,build_validator. - Config robustness — env-var expansion, parse-time validation.
- WebSocket TLS — self-signed and user-supplied cert modes; ACME deferred to 0.2.0.
- Operations — Docker image and default config for
ndn-fwd.
Breaking Changes
Binary rename: ndn-router → ndn-fwd
The standalone forwarder binary is now ndn-fwd. Update any scripts, systemd
units, or Docker entrypoints.
ndn-packet default features
ndn-packet previously enabled std by default. From 0.1.0 onwards, the default
is empty — this is required for no_std embedded targets to work without
default-features = false. Any downstream crate that was relying on the implicit
std feature must now add it explicitly:
ndn-packet = { version = "0.1", features = ["std"] }
AppError typed variants
AppError::Engine(anyhow::Error) is removed. Use the new variants:
#![allow(unused)]
fn main() {
AppError::Connection(ForwarderError) // IPC transport errors
AppError::Closed // connection dropped
AppError::Protocol(String) // malformed response
}
Producer::serve handler signature
The handler now receives a Responder for replying:
#![allow(unused)]
fn main() {
// Before (alpha):
producer.serve(|interest| async move {
Some(bytes) // return None to ignore
}).await;
// After (stable):
producer.serve(|interest, responder| async move {
responder.respond_bytes(bytes).await.ok();
// or: responder.nack(NackReason::NoRoute).await.ok();
}).await;
}
Crate consolidation
| Removed | Replacement |
|---|---|
ndn-face-net | ndn-faces (feature net, websocket) |
ndn-face-local | ndn-faces (feature local, spsc-shm) |
ndn-face-serial | ndn-faces (feature serial) |
ndn-face-l2 | ndn-faces (feature l2, bluetooth, wfb) |
ndn-pipeline | ndn-engine::pipeline |
New Features
Responder pattern
Handlers can now send Nacks as well as Data, and the responder’s ownership ensures exactly-once replies:
#![allow(unused)]
fn main() {
producer.serve(|interest, responder| async move {
match handle(&interest) {
Ok(bytes) => { responder.respond_bytes(bytes).await.ok(); }
Err(_) => { responder.nack(NackReason::NoRoute).await.ok(); }
}
}).await;
}
Consumer convenience methods
#![allow(unused)]
fn main() {
// Fetch multiple names in parallel.
let results = consumer.fetch_all(&[name_a, name_b]).await;
// Retry up to 3 times.
let data = consumer.fetch_with_retry(&name, 3).await?;
// Fetch all segments and reassemble.
let bytes = consumer.fetch_segmented(&prefix).await?;
// Fetch and verify signature.
let safe = consumer.get_verified(&name).await?;
}
BlockingForwarderClient
For C FFI, Python bindings, or other non-async contexts:
#![allow(unused)]
fn main() {
use ndn_ipc::BlockingForwarderClient;
let mut client = BlockingForwarderClient::connect("/tmp/ndn.sock")?;
client.register_prefix(&"/app/prefix".parse()?)?;
client.send(interest_wire)?;
let reply = client.recv(); // blocks until Data or timeout
}
KeyChain signing helpers
#![allow(unused)]
fn main() {
let kc = KeyChain::ephemeral("/com/example/app")?;
// Sign a Data packet.
let wire = kc.sign_data(DataBuilder::new(name, content))?;
// Build a validator trusting only one anchor prefix.
let v = KeyChain::trust_only("/ndn/testbed")?;
}
WebSocket TLS
#![allow(unused)]
fn main() {
use ndn_faces::net::websocket::{WebSocketFace, TlsConfig};
let listener = WebSocketFace::listen_tls(
"0.0.0.0:9696".parse()?,
TlsConfig::SelfSigned, // or TlsConfig::UserSupplied { cert_pem, key_pem }
).await?;
loop {
let face = listener.accept(next_face_id()).await?;
engine.add_face(Box::new(face)).await;
}
}
Roadmap: v0.2.0
- WebSocket ACME certificate renewal with SVS-based fleet distribution
- BLE face full GATT server implementation (NDNts
@ndn/web-bluetooth-transport) - WfbFace 802.11 monitor-mode injection
- ASF forwarding strategy
- PSync network-layer face integration
Release: 0.1.0-alpha
Tagged: 2026-04-06 · Branch: main · GitHub Release
This is the first tagged release of ndn-rs: a Named Data Networking forwarder stack written in Rust, built from scratch to prove that NDN’s architecture maps cleanly onto Rust’s ownership model and async runtime.
What started as “can Rust model NDN’s pipeline better than C++ can?” turned into a complete forwarder stack, wire-format library, embedded forwarder, browser simulation, management layer, and discovery stack. This release tags all of that as a coherent baseline.
Everything in this release should be considered unstable. APIs will break. The 0.1.0 designation means “the stack works and interoperates with NFD,” not “the API is stable for downstream crates.”
What This Release Contains
The Forwarding Pipeline
The core insight driving ndn-rs is that NDN’s forwarding pipeline is a data processing pipeline, not a class hierarchy. PacketContext is a value type passed through a fixed sequence of PipelineStage trait objects. Each stage returns an Action (Continue, Send, Satisfy, Drop, Nack) that drives dispatch. Returning Continue hands ownership of the context to the next stage — use-after-hand-off is a compile error, not a runtime bug.
The Interest pipeline: TlvDecodeStage → CsLookupStage → PitCheckStage → StrategyStage → PacketDispatcher
The Data pipeline: TlvDecodeStage → PitMatchStage → ValidationStage → CsInsertStage → PacketDispatcher
Dispatch is non-blocking throughout: each face has a bounded 512-slot send queue, so the pipeline runner is never stalled waiting for a slow TCP connection to drain. The fragment sieve runs single-threaded; per-packet tokio tasks run the full pipeline in parallel across cores.
Wire-Format Fidelity
One of the non-negotiable constraints was bit-exact interoperability with ndnd and ndn-cxx. This required a detailed compliance audit against RFC 8569, NDN Packet Format v0.3, and NDNLPv2. The audit found 25 gaps — mostly in encoding widths, framing conventions, and obscure packet types — all of which are resolved in this release. Some highlights:
NonNegativeIntegernow uses minimal lengths (1/2/4/8 bytes per spec, not always 8).DataBuilder::build()omits MetaInfo when no freshness period is set. An absent MetaInfo means “no freshness constraint”;FreshnessPeriod=0means “immediately stale.” These are not the same, and NFD treats them differently.- Nack packets are NDNLPv2-framed (wrapped in
LpPacket) rather than bare 0x0320 TLV, which NFD silently dropped. ParametersSha256DigestComponentis computed and validated, not just carried along.
The InterestBuilder and DataBuilder APIs expose this correctly without requiring callers to know the wire format. Signed Interests (NDN v0.3 §5.4) are fully supported, including auto-generated anti-replay nonce and timestamp fields.
The Security Stack
NDN’s security model is content-centric: data is secured at the object level, not the channel level. Every Data packet carries a signature; verification walks a certificate chain from the signing key to a trust anchor. This release implements the full chain:
ValidationStage sits between PitMatch and CsInsert in the Data pipeline. When a validator is configured, it checks the packet’s signature against the trust schema, then walks the certificate chain via Validator::validate_chain(). If a certificate is missing from the cache, CertFetcher issues a side-channel Interest to fetch it — with deduplication, so ten simultaneous Data packets all needing the same certificate share one Interest.
The SecurityProfile enum (Default, AcceptSigned, Disabled, Custom) lets operators choose how strict validation is per deployment. SecurityManager::auto_init() generates an Ed25519 identity on first startup so nodes are always signed — “security by default” without per-node configuration ceremony.
Two interesting performance wins in the signing path: Signer::sign_sync() and DataBuilder::sign_sync() eliminate the Box::pin heap allocation that the async path required, saving ~1.2M allocations/sec at line rate on a signing benchmark. For deployments where asymmetric key distribution isn’t needed, HmacSha256Signer is approximately 10× faster than Ed25519.
Network Faces: Every Medium
The face layer abstracts the network medium behind a two-method trait:
#![allow(unused)]
fn main() {
trait Face: Send + Sync {
async fn recv(&self) -> Result<Bytes>;
async fn send(&self, pkt: Bytes) -> Result<()>;
}
}
This release implements that trait for more media than expected:
UDP and TCP — including an auto-created per-peer UDP face on listener sockets. A subtle bug: listener-created UDP faces were replying from an ephemeral port, not from port 6363. Peers expecting replies from the well-known port were silently dropping them. Fixed by having all listener-created UDP faces share the listener’s socket via Arc<UdpSocket>.
Raw Ethernet — three platform implementations: Linux (AF_PACKET + TPACKET_V2 mmap rings for zero-copy I/O), macOS (PF_NDRV sockets with NDRV_SETDMXSPEC for EtherType filtering), and Windows (Npcap bridged via background threads). All use the IANA NDN Ethernet multicast group 01:00:5e:00:17:aa and EtherType 0x8624.
WebSocket — binary-frame WebSocket, compatible with NFD’s WebSocket transport. Useful for browser clients.
Serial / COBS — UART, LoRa, RS-485 support via COBS framing. COBS encodes packets so 0x00 never appears in the payload, making it a reliable frame delimiter for resync after line noise.
NDNLPv2 per-hop reliability — unicast UDP faces now implement the NDNLPv2 reliability protocol (retransmit, Ack, adaptive RTO), eliminating the throughput instability that unrecovered UDP loss causes.
SHM Local Faces
The zero-copy local face — SpscFace / SpscHandle — is the data plane between applications and the forwarder. Applications write to a shared-memory ring; the forwarder reads from it without copying. The ring is 256 slots; the wakeup mechanism uses a named FIFO wrapped in AsyncFd, which integrates directly into Tokio’s epoll/kqueue loop with no blocking thread transitions.
This was more work than expected. The initial Linux implementation used futex syscalls, which looked correct but had a cross-process problem: FUTEX_PRIVATE_FLAG keys on virtual addresses and only works within a single process. SHM spans processes via physical pages, so the futex must use plain FUTEX_WAIT without the private flag. After that fix, Linux and macOS were converged on the same FIFO-based path for simplicity.
Discovery
The discovery layer implements SWIM (Scalable Weakly-consistent Infection-style Membership) for link-layer peer discovery. Each node sends periodic hello Interests; missed hellos trigger direct probes; missed direct probes trigger K indirect probes via randomly chosen established neighbors. The result is a failure detector that converges quickly without flooding the network.
Hello packets use a spec-compliant TLV format (HelloPayload with NODE-NAME, SERVED-PREFIX, CAPABILITIES, NEIGHBOR-DIFF fields). The NEIGHBOR-DIFF field carries SWIM gossip piggybacked on every hello, so membership information disseminates for free.
Two higher-level discovery protocols layer on top: EpidemicGossip for pull-gossip over /ndn/local/nd/gossip/, and SvsServiceDiscovery for push notifications using the SVS sync protocol.
Sync Protocols
ndn-sync provides two sync primitives:
SVS (State Vector Sync) — each node maintains a (node-key → sequence-number) state vector. When vectors differ, the holder of the higher sequence number knows the other side is behind and sends the missing data. Used by service discovery and the Subscriber API.
PSync (Partial Sync via IBF) — nodes exchange Invertible Bloom Filters representing their local data sets. Subtracting two IBFs yields the symmetric difference: what each side has that the other lacks. Useful for larger data sets where exchanging full state vectors would be expensive.
Mobile Support (Android / iOS)
ndn-mobile packages the forwarder for Android and iOS. It runs in-process inside the app binary — no system daemon — using InProcFace channels (zero IPC overhead) for app traffic and standard UDP faces for LAN/WAN connectivity.
Key features: MobileEngine::builder() with mobile-tuned defaults (8 MB CS, single pipeline thread, full security validation); with_udp_multicast + with_discovery for LAN neighbor discovery; suspend_network_faces / resume_network_faces for battery-efficient app lifecycle; bluetooth_face_from_parts for wrapping a platform-supplied async stream with COBS framing; optional persistent CS via the fjall feature.
See the Mobile Apps guide.
The Embedded Forwarder
ndn-embedded is a #![no_std] NDN forwarder for ARM Cortex-M, RISC-V, and ESP32. It shares only the TLV codec with the full stack; everything else is const-generic and stack-allocated. Pit<N> and Fib<N> size is fixed at compile time. The Forwarder is single-threaded; run_one_tick() purges expired PIT entries. No heap allocator required for the core.
This crate exists because several NDN use cases are inherently embedded: mesh radio nodes, IoT sensors, environmental monitoring. A full Tokio runtime is inappropriate for a device with 256 KB of RAM.
WASM Browser Simulation
ndn-wasm brings the NDN forwarding pipeline to the browser. It’s a standalone Rust reimplementation (not a port of ndn-engine) that compiles to wasm32-unknown-unknown with wasm-pack. The explorer uses it to drive animated pipeline traces, a multi-hop topology sandbox, and a TLV inspector.
See the ndn-wasm deep-dive for a detailed analysis of what it replicates faithfully (FIB trie, PIT, CS, all pipeline stages) and where it simplifies (signature validation is a flag, not cryptography).
NFD Management Compatibility
The router speaks NFD’s TLV management protocol: ControlParameters (TLV 0x68), ControlResponse (TLV 0x65), standard name conventions (/localhost/nfd/<module>/<verb>). The ndn-ctl CLI sends NFD-format commands; any tool that can talk to NFD can talk to ndn-rs.
Design Decisions That Didn’t Make the Cut
A few approaches were tried and reverted:
Multiple pipeline runners — the pipeline channel can be read by multiple tasks in parallel using Arc<Mutex<Receiver>>. This was prototyped and benchmarked. Result: 2–4× slower than a single runner, because the bottleneck isn’t draining the channel — it’s the per-packet decode/PIT/strategy work, which already runs in parallel via tokio::spawn. Multiple runners just add contention.
iceoryx2 shared-memory transport — referenced in early config documentation. Never implemented; removed from all source files.
SHM futex-based wakeup on Linux — the atomic-wait crate initially provided futex wait/wake. Discovered that FUTEX_PRIVATE_FLAG doesn’t work cross-process over SHM. Replaced with libc::SYS_futex without the private flag, then later replaced entirely with the same FIFO+AsyncFd approach used on macOS, eliminating the platform divergence.
What’s Next
The major gaps between this release and a stable 1.0:
- ASF (Adaptive Smoothed RTT-based Forwarding) strategy — the production-grade adaptive forwarding strategy is not yet implemented. BestRoute and Multicast are stable; ASF requires the measurements table, which exists, but the adaptation logic is not wired.
- PSync network layer — the IBF data structure is implemented; the Interest/Data exchange protocol that runs it over NDN faces is not.
- Real engine in the browser —
ndn-wasmis a standalone simulation. Compiling the realndn-engineto WASM requires replacingDashMap(thread-local state), removingrt-multi-threadfrom Tokio, and substitutingwasm_bindgen_futures::spawn_localfortokio::spawn. None of these are fundamental; they’re a few days of careful refactoring. - Bluetooth RFCOMM crate —
BluetoothFace(ndn-mobile) accepts anyAsyncRead + AsyncWritepair and works correctly with a platform-supplied stream. What’s missing is a pure-Rust async crate for opening RFCOMM connections natively (without going through Android/iOS native APIs). Once such a crate exists,ndn-mobilecan open Bluetooth connections directly from Rust rather than requiring a native bridge. - API stabilization — essentially every public API has at least one rough edge. The 0.2.0 cycle will focus on stabilizing
ndn-app,ndn-packet, andndn-transportas the crates most likely to be used by downstream code.
FAQ
General
Is ndn-rs production-ready?
ndn-rs is a research and development platform. The core forwarding pipeline, face implementations, and management protocol are all functional, and the stack is suitable for research deployments, prototyping, and embedded NDN applications. If you are considering production use, evaluate stability and feature completeness against your specific requirements – the codebase is moving quickly and APIs may still shift.
How does ndn-rs relate to NFD and ndn-cxx?
The mapping is straightforward once you see the two layers:
- ndn-rs (the library) is analogous to ndn-cxx (the C++ library). Both provide the core data structures, TLV codec, forwarding engine, and face abstractions that applications link against.
- ndn-fwd (the standalone binary) is analogous to NFD (the daemon). Both are thin executables that instantiate the library, open network faces, and run a forwarding loop.
The key difference is architectural philosophy. ndn-cxx and NFD are written in C++ with class hierarchies and virtual dispatch. ndn-rs models the same concepts as composable data pipelines with Rust traits, Arc-based shared ownership, and zero-copy Bytes throughout. Both implement the same NDN protocol (NDN Packet Format v0.3, NDNLPv2), so they interoperate on the wire.
What’s the difference between ndn-rs and ndn-fwd?
ndn-rs is the library crate – it contains the forwarding engine, PIT, FIB, Content Store, face abstractions, strategies, and the management protocol. You can embed it directly in your application.
ndn-fwd is a standalone binary that depends on ndn-rs. It reads a configuration file, opens network faces (UDP, TCP, Ethernet, etc.), starts the management listener, and runs the forwarding pipeline. If you just want a forwarder running on a machine, ndn-fwd is what you install. If you want to build NDN into your own application, you depend on ndn-rs as a library.
Can I use ndn-rs in my application without running ndn-fwd?
Absolutely – this is one of the main design goals. Your application can instantiate the forwarding engine directly, create in-process InProcFace channels, and exchange Interests and Data without any IPC overhead. The latency for an in-process InProcFace round-trip is on the order of ~20 ns via mpsc channels, compared to ~2 us for Unix socket IPC to a separate daemon.
That said, if your application needs to talk to remote NDN nodes or other local applications, you will want either ndn-fwd running as a forwarder, or your application opening its own network faces through the library.
Can ndn-rs interoperate with NFD?
Yes. ndn-rs uses the standard NDN TLV wire format and NDNLPv2 link protocol, so UDP, TCP, and WebSocket faces can connect to NFD nodes without any translation layer. The management protocol follows NFD’s command format for compatibility, meaning tools written for NFD (like nfdc) can also manage ndn-fwd.
How does ndn-rs handle security differently from ndn-cxx?
NDN’s security model is baked into the data itself – every Data packet carries a signature, and consumers validate signatures against a trust schema. ndn-rs implements the same signing and validation algorithms (ECDSA, RSA, HMAC-SHA256) but leans on Rust’s type system to enforce correctness at compile time.
The SafeData vs Data distinction is a good example: the type system ensures that only signature-verified data can be inserted into the Content Store or forwarded to faces. In ndn-cxx, this invariant is enforced by convention and runtime checks. In ndn-rs, passing unverified Data where SafeData is expected is a compile error.
The ndn-security crate also provides the trust schema engine and keychain, but unlike ndn-cxx it avoids C library dependencies (no OpenSSL) in favor of pure-Rust cryptography crates (ring, p256, rsa).
Does ndn-rs support no_std / embedded?
The ndn-tlv and ndn-packet crates compile with no_std (with alloc). The ndn-embedded crate provides a minimal forwarding engine for Cortex-M and similar targets. See the embedded CI workflow for tested targets.
Architecture
Why is ndn-rs a library instead of a daemon?
The analogy to draw is with ndn-cxx, not NFD. ndn-cxx is the C++ library that applications link against; NFD is one particular binary built on top of it. ndn-rs takes the same approach: the forwarding engine is a library, and ndn-fwd is one binary that uses it.
This means applications can embed the full forwarding engine directly, which unlocks several things. In-process communication through InProcFace channels avoids IPC serialization entirely. The compiler can see through the entire pipeline and optimize accordingly. And applications that need custom forwarding behavior (novel strategies, application-layer caching, compute-on-fetch) can extend the engine in-process rather than through an external plugin API.
For users who just want a standalone forwarder, ndn-fwd provides exactly that – a thin binary that reads a config file and runs the engine.
Why DashMap for the PIT instead of a single Mutex?
The PIT sits on the hot path of every single packet. A single Mutex would serialize all pipeline tasks behind one lock, turning your multi-core machine into a single-threaded forwarder under load. DashMap provides sharded concurrent access – multiple pipeline tasks can insert and look up PIT entries in parallel as long as they hash to different shards. In practice, NDN traffic has enough name diversity that shard contention is rare.
Why does the CS store wire-format Bytes?
When you get a Content Store hit, the goal is to send the cached Data packet back to the requesting face as fast as possible. Storing wire-format Bytes means a CS hit boils down to face.send(cached_bytes.clone()) – one atomic reference count increment and you are done. There is no re-encoding step from a decoded Data struct back to TLV wire format. For a forwarder where CS hit rate directly determines throughput, this matters.
Why Arc<Name> everywhere?
A single Interest creates references to its name in the PIT entry, the FIB lookup, the CS lookup, and the pipeline context – four places that all need the same name simultaneously. Arc<Name> shares one heap allocation across all of them without copying the name’s component bytes. Combined with SmallVec<[NameComponent; 8]> for stack-allocating typical short names, this keeps per-packet allocation pressure low.
Performance
What throughput can ndn-rs achieve?
Run cargo bench -p ndn-engine for pipeline throughput numbers on your hardware. The main cost centers are TLV decode (scales with name length), CS lookup (depends on backend and hit rate), and PIT operations (O(1) via DashMap hash lookup). The pipeline is designed so that a CS hit short-circuits before most of the Interest pipeline runs, which is where the zero-copy Bytes storage pays off the most.
How do I profile ndn-rs?
The library uses the tracing crate for structured logging with per-packet spans. Set RUST_LOG=ndn_engine=trace for detailed per-packet traces, or RUST_LOG=ndn_engine=debug for a less verbose view of forwarding decisions. For CPU profiling, cargo flamegraph or perf record on the ndn-fwd binary will show you where time is spent.
Development
How do I add a new face type?
See the Implementing a Face guide. The short version: implement the Face trait (recv and send), add a FaceKind variant, and register with the FaceTable.
How do I add a custom forwarding strategy?
See the Implementing a Strategy guide. Implement the Strategy trait, which receives an immutable StrategyContext and returns a ForwardingAction, then register via the StrategyTable.
How do I run the benchmarks?
cargo bench -p ndn-packet # Name operations
cargo bench -p ndn-store # Content Store (LRU, Sharded, Fjall)
cargo bench -p ndn-engine # Full pipeline
cargo bench -p ndn-faces # Face latency/throughput
cargo bench -p ndn-security # Signing and validation
See Benchmark Methodology for details.