ndn-rs
ndn-rs is a Named Data Networking (NDN) stack in Rust. It ships a
forwarder (ndn-fwd), an embeddable engine (ndn-engine), and a
Develop-tier umbrella crate (ndn-rs-prelude, library name ndn)
that an application reaches for to fetch a Data by name or serve
one.
The first stable boundary is the spec-aligned core plus the tooling needed to run and verify it. Browser, embedded, mobile, BLE, WebRTC, in-network compute, network coding, ABE, and dashboard work live in extension or research scopes unless their individual pages say otherwise.
The project is primarily AI-authored and still carries known spec-compliance findings. Do not cite ndn-rs as a reference implementation of NDN; use the live audit tracker and spec-compliance page to decide what has actually been witnessed.
Three API tiers separate audience from intent:
- Develop — application authors. The 5-minute
path: connect a
Consumer, callfetch_object. - Extend — protocol, strategy, and face
authors. Implement
Strategy,RoutingProtocol, orFacewithout forking the engine. - Instrument — researchers and
measurement tooling. Feature-gated
experimental-instrument; observe every packet, inject a strategy, wire two engines.
Start here
- New to NDN? → NDN overview.
- Writing your first app? → Five-minute app.
- Running a node? → Running the forwarder.
- Building a strategy or face? → Extend tier.
- Checking release readiness? → v0.1.0 boundary.
The source lives at
github.com/Quarmire/ndn-rs.
The crate map and pipeline shape are in
ARCHITECTURE.md.
Why NDN is different
If you have built anything for the web, you already carry a mental model of how networking works — and it is the one thing you have to set down before the rest of this wiki makes sense. This page is about that one idea. Everything else in ndn-rs is a consequence of it.
The model you already have
You name a place. A URL resolves to an address; you open a connection to the machine at that address; you trust what comes back because you trust the connection — the TLS certificate proves you are talking to the right server. The data itself is incidental. Copy that same file onto a different server and all of the trust evaporates: new connection, new certificate, prove it again.
This works. It is also why a cache is a special case, why “is this the real file?” collapses into “did I reach the real server?”, and why two people downloading the same thing open two separate connections.
The model NDN uses
NDN names the data, not the place. You ask for /alice/photo. You do
not say where it lives, and you do not care who answers — a nearby cache,
a peer, or the original producer, whichever is closest. What comes back
is a signed packet. You trust it because the bytes carry their own
signature and that signature satisfies a rule you chose — not because of
which connection delivered them.
Everything else follows from the flip
Once data is named and self-securing, the rest of NDN stops looking arbitrary:
- Caching is automatic. Any node that already forwarded
/alice/photocan answer the next request for it. Ten consumers asking for the same name become one fetch and nine cache hits — multicast you get for free, with no special protocol. - There are no connections to manage. A request and its reply are matched by name, not by a socket pinned between two hosts.
- Links are faces, not sockets. A face is an NDN-layer link over whatever bearer you have — UDP, Bluetooth, shared memory, a browser data channel. The data does not change; only the face does.
- Forwarding is a decision, not a fixed route. When a request could go several ways, a strategy chooses. Routing populates the options; the strategy picks among them per packet.
The mechanics behind each of these — the Pending Interest Table, the Forwarding Information Base, the Content Store — are laid out in the NDN overview, and traced through a single request in Interest and Data lifecycle.
Security is the floor, not a feature
Because trust rides with the data, signing and verification are not an add-on you remember to switch on — they are how a packet earns the right to move at all. Every Data packet is signed. A verifier checks the signature against a trust policy that answers one question: is this key allowed to sign this name?
ndn-rs makes this hard to get wrong on purpose. Verified data has a
distinct type — SafeData — and the forwarding path only accepts
SafeData. Unverified data is not “discouraged”; the compiler will not
let you forward it. How keys, certificates, and policies fit together is
in Identity and keys.
What is standard, and what ndn-rs adds
The core of ndn-rs implements published NDN community specifications. On top of that it adds pragmatic engineering — browser transports, in-network compute, network coding, attribute-based encryption — that have no community spec behind them. Those are always marked extension in this wiki, so you are never left guessing whether something is real NDN or specific to ndn-rs. When it matters, the spec-compliance summary records what has actually been verified against the wire format.
Where to go next
You have the one idea. Pick the path that matches why you are here — each assumes the flip above and nothing more.
Still want the mechanics first? Start with the NDN overview.
One packet, six depths
NDN has a small number of moving parts, and the fastest way to meet all
of them is to follow a single request. We will ask for one name,
/alice/photo, and watch signed Data come back.
Read top to bottom and stop at the depth that answers your question. The first depth is the whole idea; the last is the trust check that makes it safe. You do not need all six to start building — you need depth 1.
Depth 1 — You fetch a name
The consumer expresses an Interest for /alice/photo. That is the
entire request: a name. No address, no host, no open connection. Whatever
node can satisfy the name will; the consumer never learns or cares which
one did.
If you only ever read one depth, read this one. The rest is how the network makes it fast and safe.
Depth 2 — A cache may answer
The first node to see the Interest checks its Content Store — a cache
of Data it has already handled. If /alice/photo is there and still
fresh, that node returns it immediately and the request goes no further.
This is why popular names are cheap: the tenth request for the same photo is usually a local cache hit, not a round trip to the producer.
Depth 3 — Identical requests aggregate
On a cache miss, the node records the pending request in its Pending
Interest Table (PIT). If a second consumer asks for /alice/photo
while the first is still outstanding, the node does not forward it again —
it adds the new requester to the existing PIT entry. One Data comes back
and fans out to everyone waiting. Aggregation and multicast are the same
mechanism.
Depth 4 — Forwarding is a decision
To make progress the node consults its Forwarding Information Base (FIB), which lists the next hops known to lead toward the name. When more than one hop qualifies, a strategy decides: which face to use, whether to try several, when to retransmit, how to react to a failure. Routing fills in the options; the strategy chooses among them, per packet.
Depth 5 — It crosses a face
The chosen next hop is reached over a face — an NDN-layer link that could be UDP, TCP, Bluetooth, shared memory, or a browser data channel. The Interest and Data are the same regardless; only the face underneath changes. Swapping bearers does not change the packet or its security.
Depth 6 — The reply is verified
When the Data arrives, the node does not simply pass it along. It checks
the signature against a trust policy — is this key allowed to sign
this name? Only Data that passes becomes forwardable and cacheable; in
ndn-rs that verified form has its own type, SafeData, and the
forwarding path accepts nothing else. Unverified Data stops here.
How that check is rooted in keys you chose is the subject of Trust, first.
Reading further
- The same journey with every field and the forwarder state machine: Interest and Data lifecycle.
- The components as a reference, not a narrative: NDN overview.
- Why any of this is shaped the way it is: Why NDN is different.
Trust, first
In NDN you do not trust a connection — you trust the data. That sentence is easy to nod at and easy to underestimate, so this page is the mechanics: every Data packet is signed, and a rule you chose decides whether the signature actually counts.
This is the one non-negotiable. You can skip strategies, skip exotic faces, skip in-network compute — but the moment you accept a packet, you are making a trust decision, and NDN makes you make it on purpose.
A signature is not a verdict
Anyone can generate a key and sign anything with it. A valid signature only tells you the bytes were not altered since signing — it does not tell you the signer was allowed to produce this name. The real question is never “is this signed?” but “is this key permitted to sign this name?”
Treating “signed” as “trusted” is the mistake NDN is built to prevent.
Follow the chain to an anchor you pinned
Each Data carries a KeyLocator pointing at the certificate for its signing key. That certificate was itself signed by an issuer, whose certificate was signed by another, up to a trust anchor — a key you decided to trust ahead of time. Verification walks that chain and succeeds only if it ends at an anchor you pinned.
Trust is therefore rooted in a choice you made, not in whoever happens to answer the Interest. Move the data to a different node and nothing about this changes; the chain travels with the bytes.
Names bind to keys by rule
The chain proves who signed. A trust schema decides whether that
signer was entitled to. A schema is a set of rules tying name patterns
to the keys allowed to sign them — for example, that Data under
/alice/... must be signed by a key named under /alice/KEY/.... A
signature from the wrong key is rejected even when it is cryptographically
valid, because the binding is wrong.
This is what “names bind to keys” means in practice: authority follows the namespace, by rule, not by trust-on-faith.
SafeData — the check the compiler enforces
ndn-rs makes this difficult to skip. Data that has passed verification has
its own type, SafeData, and the forwarding and caching paths accept only
SafeData. Unverified Data is not merely discouraged — it is a
different type, and the compiler will not let it onto the wire. The trust
decision is structural, not a convention you have to remember.
Choosing your policy
A trust policy answers “may this key sign this name?” ndn-rs ships several:
InsecureTrust— accepts anything. Tests only; never in deployment.StaticTrust— an explicit allowlist of keys.- schema-based trust — name-pattern rules, the usual production choice.
HierarchicalPolicy— a parent-name key may sign child-name Data.
The catalog, with the trade-offs of each, is in Trust policies. How keys, certificates, and key chains are created and stored is in Identity and keys.
Why first, and not last
In connection-based systems, security is something you add: get the bytes moving, then bolt on TLS, then audit what leaked. NDN inverts the order. Because trust rides with the data, the verification check is the precondition for a packet to move at all — there is no “insecure but working” stage to retrofit later. Deciding what you trust is step one, not a hardening pass.
Reading further
- The request this check sits at the end of: One packet, six depths, depth 6.
- Keys, certificates, key chains, signing: Identity and keys.
- The policy catalog: Trust policies.
App author
You want to fetch and publish named data from your own program. You do not need to run a forwarder, write a strategy, or learn the engine internals — you need a client API and a trust policy. This page is the shortest line to a working app.
Assumes the one idea from Why NDN is different: you fetch a name, and what comes back is signed.
Consumer and call fetch_object to pull
a Data by name. → Five-minute app.
Publishing the other side is the ten-minute producer.
Where to stop
You can build a complete application without ever touching the forwarder’s internals.
The path
- Five-minute app — fetch a
Databy name. - Ten-minute producer — serve one.
- Trust, first — choose the policy that decides what you accept.
- Building an application — the fuller walkthrough.
- Develop tier — the stable API surface, as reference.
Operator
You want to run a node and watch traffic move through it — not write Rust. Your job is to start the forwarder, point faces at peers, and read what it reports. This page is the line from nothing to a running, observable node.
Assumes the one idea from Why NDN is different: the forwarder moves named data, and any node may cache it.
ndn-fwd and see it forward a request.
→ Running the forwarder.
Then watch it live in the dashboard.
Where to stop
Running and observing a node is a configuration job, not a programming one.
The path
- Running the forwarder — start a node.
- ndn-fwd — the binary, its flags, and faces.
- Config reference — every knob, by category.
- Logging — read what the node reports.
- Running the dashboard — watch faces, routes, and strategy live.
Extender
You want to change how the stack behaves — a new forwarding strategy, a new face over some bearer, a routing protocol — without forking the engine. The engine exposes these as traits you implement and register. This page points you at the seams.
Assumes Why NDN is different and the mechanics in One packet, six depths — strategies live at depth 4, faces at depth 5.
Strategy trait and register it on a prefix.
→ Writing a strategy.
The trait surface is the Extend tier.
Where to stop
You extend through traits, not by editing the engine. If a change needs a core engine edit, that is a signal to reconsider the seam — the extension points are designed so you should not have to.
Strategy (how an Interest is forwarded), Face
(a link over a bearer), and RoutingProtocol (what fills the
FIB). Pick the one that matches your change; ignore the rest.
The path
- Extend tier — the trait surface:
Strategy,Face,RoutingProtocol. - Writing a strategy — the forwarding-decision seam.
- Implementing a face — a transport + link service.
- Interest and Data lifecycle — where your code runs in the pipeline.
Researcher
You want to observe what the engine does, measure it, and wire engines together for experiments. The instrument surface lets you tap every packet and inject behaviour without changing the engine — it is opt-in and looser than the application or extension APIs by design.
Assumes One packet, six depths: you are here to watch and measure that pipeline, not just use it.
experimental-instrument
feature and is not part of any NDN community spec — it is an ndn-rs
research surface. Expect a looser stability promise than Develop or Extend.
Where to stop
The instrument tier is for measurement and experiments, not production
data paths. If your tap is shaping real traffic rather than observing it,
you have crossed into the Extender path
(a Strategy or Face), which carries a firmer contract.
The path
- Instrument tier —
TapFace, packet observation, wiring two engines. - Interest and Data lifecycle — the pipeline you are measuring.
- Performance — throughput knobs and how to benchmark.
Five-minute app
Security is step one, not a hardening pass. In NDN the signature check is the precondition for accepting data at all — so this first app signs and verifies, rather than leaving it for later. The one-paragraph “why” is Trust, first; the mistakes to avoid are in Security pitfalls.
This page gets you from cargo new to a complete, verified exchange: a
producer signs one Data with its identity, a consumer fetches it and accepts it
only after the signature checks out against a pinned trust anchor.
Run it
The whole exchange is one runnable file — no external forwarder, engine in-process:
cargo run -p example-secure-fetch
verified: 21 bytes under /demo/alice/thing — signature checked against /demo/alice
explicit path: same Data verifies too
The source is examples/secure-fetch/src/main.rs in the repository.
The three lines that are the point
// Producer: sign each Data with the identity key — not an unsigned build().
let wire = DataBuilder::new(name, b"authenticated payload")
.sign_with_sync(&*signer)?;
// Consumer: decide trust once, then the short verb is safe.
let mut consumer = consumer.verifying(producer_kc.validator());
let safe = consumer.fetch("/demo/alice/thing").await?; // -> SafeData
sign_with_syncsigns with the producer’s identity key. (The bareDataBuilder::build()carries only a digest — integrity, not authorship.)verifying(validator)pins the trust anchor once; after it,consumer.fetch(name)returnsSafeData— the obvious call is the verified one. You can only obtain aSafeDataby verifying, so “did I check this packet?” is answered by the compiler, not by convention. (fetch_verified(name, &validator)is the one-shot equivalent when you don’t want to hold the validator.)
Why there is no quiet way to skip it
The consumer never has to remember to verify, because the type system asks for
the decision. fetch_verified gives you SafeData. The lower-level
fetch_unverified gives you Unverified<Data>, which you cannot use until you
either .verify(&validator) it (getting SafeData) or call the loud, greppable
.trust_unchecked():
let unverified = consumer.fetch_unverified("/demo/alice/thing").await?;
let safe = unverified.verify(&validator).await?; // → SafeData
// or, only where no trust schema applies (tests, local IPC):
// let raw = unverified.trust_unchecked(); // loud and searchable
There is also a bare consumer.fetch(name) that returns raw, unverified
Data — a low-level primitive the safe methods build on. Reach for it only when
you are deliberately handling verification yourself; otherwise prefer
fetch_verified. See Security pitfalls.
Talking to a standalone forwarder
To split producer and consumer into separate processes against a running
ndn-fwd, the consumer pins the producer’s certificate as a trust anchor and
calls fetch_verified exactly as above — the only difference is where the anchor
comes from. That full flow (publishing, anchor distribution) is the
Ten-minute producer.
Next steps
- Serve and verify across two processes: Ten-minute producer.
- Understand the trust decision you just made: Trust, first and Identity and keys.
- Avoid the common footguns: Security pitfalls.
- Fetch a multi-segment object: Develop tier →
fetch_object.
Ten-minute producer
This page extends the Five-minute app into a
producer/consumer pair: one process serves a Data, another fetches
it.
Prerequisites
A running forwarder at /tmp/ndn-fwd.sock. See
Running the forwarder.
The producer
use ndn::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let keychain = KeyChain::ephemeral("/example")?;
// connect registers the prefix; with_signer signs what we publish.
let producer = Producer::connect("/tmp/ndn-fwd.sock", "/example")
.await?
.with_signer(keychain.signer()?);
producer
.publish_object("/example/hello".parse()?, b"hello, ndn".to_vec().into(), 0)
.await?;
// Keep the process alive while the forwarder serves requests.
tokio::signal::ctrl_c().await?;
Ok(())
}
publish_object(name, content, chunk_size) segments the object and
serves it on demand, signing each segment with the configured
signer (without with_signer it emits DigestSha256 — integrity, not
authorship). chunk_size == 0 uses the default segment size.
Aggregated signing — one signature per object (extension)
For larger objects, signing every segment is wasteful: a FLIC-style
manifest signs the object once. publish_object uses
Aggregation::Auto, which switches to a manifest for medium objects
(roughly 8–256 segments) and stays per-segment for small ones. To choose
explicitly:
use ndn::{Aggregation, PublishOptions};
producer.publish_object_with(
"/example/big".parse()?,
content,
PublishOptions { chunk_size: 0, aggregation: Aggregation::Manifest },
).await?;
How it works: the RDR …/32=metadata Data — already the single signed
discovery object — carries the ordered per-segment SHA-256 hashes and is
marked ContentType::Manifest. That one signature authenticates the whole
object; segments are then served plain and authenticated by hash-match
against the manifest. The manifest also doubles as a content listing.
This is transparent to the consumer: fetch_object detects a manifest
from the metadata and verifies segments by hash automatically — the code
below is unchanged whether the object was per-segment-signed or aggregated.
The hash list is built in the same pass that segments the object, so the per-segment commitments are essentially free on top of hashing the data once. The current manifest is a single flat packet (objects beyond ~256 segments fall back to per-segment signing); nested-manifest DAGs for very large files are a planned extension. This is an ndn-rs extension, not an adopted NDN community wire spec.
The consumer — verify what you fetch
The producer signed its Data; the consumer’s job is to check that signature
against a trust anchor it has decided to pin. Verifying is not an optional extra
step — it is how the consumer decides the data is authentic.
use ndn::prelude::*;
use ndn::{Consumer, KeyChain};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut consumer = Consumer::connect("/tmp/ndn-fwd.sock").await?;
// Build a validator that pins the producer's certificate as a trust anchor.
// Across processes the anchor is distributed out-of-band — a cert file, a
// `did:ndn`, or NDNCERT enrollment; see "Identity and keys" below.
let keychain = KeyChain::ephemeral("/consumer")?;
keychain.add_trust_anchor(producer_cert); // the /example producer's anchor
let validator = keychain.validator();
// fetch_verified returns SafeData only if the signature checks out.
let safe = consumer.fetch_verified("/example/hello", &validator).await?;
println!("{}", String::from_utf8_lossy(safe.data().content().unwrap_or_default()));
Ok(())
}
fetch_verified returns SafeData — proof
the signature verified. The unauthenticated siblings fetch / fetch_object
return raw Data/bytes and do not check the signature; use them only when
you are handling trust yourself (see
Security pitfalls). For a complete, runnable
signed-and-verified exchange in one file, see
example-secure-fetch.
What happens on the wire
The producer signs the Data and announces /example/hello. The consumer’s
Interest is routed by the forwarder’s FIB to the producer’s face; the returned
Data is cached in the Content Store, so repeat calls within the cache lifetime
never reach the producer. The full PIT/FIB/CS story is in
Interest and Data lifecycle.
Signing identities
KeyChain::ephemeral keeps the key in memory (regenerated each run) — fine for a
demo. For a persistent identity backed by a key file, use
KeyChain::open_or_create(path, name); for enrollment under a CA, see
Identity and keys. The producer signs with the
keychain’s default identity unless overridden via SigningInfo.
Next steps
- Understand the trust decision you made by pinning that anchor: Trust, first and Trust policies.
- Distribute anchors and enroll identities instead of hard-coding a cert: Identity and keys.
- Serve dynamic responses, or subscribe to a multi-publisher stream: Develop tier → Responder / Subscriber.
Running the forwarder
This page gets a local ndn-fwd process listening on a Unix socket
in under five minutes. For production deployments see
Self-hosting.
Build and run
From the workspace root:
cargo run -p ndn-fwd
ndn-fwd reads ndn-fwd.toml from the current directory; if absent
it uses defaults. The example config is at
examples/ndn-fwd.example.toml.
By default the forwarder:
- Listens on
/tmp/ndn-fwd.sockfor application IPC. - Listens on UDP/6363 for cross-host NDN traffic.
- Stores PIB and KeyChain at
~/.ndn/. - Logs to stderr at
infolevel (override withRUST_LOG).
WebRTC datachannel faces are opt-in: they pull in the full WebRTC
stack, so build with --features webrtc to enable the
[listeners.webrtc] config section.
Verify
In another terminal:
ndn-ctl status
ndn-ctl ships in binaries/tooling/ndn-tools/. It speaks the
TLV management protocol over the same Unix socket and prints
face / route / strategy state.
Minimal config
ndn-fwd.toml:
[mgmt]
socket = "/tmp/ndn-fwd.sock"
[face.udp]
listen = "0.0.0.0:6363"
[log]
filter = "info,ndn_engine=debug"
Every knob is documented in Config reference.
Stopping
Ctrl-C shuts down cleanly. The forwarder closes faces, persists
strategy choices, and exits. State that survives a restart lives in
~/.ndn/ and the on-disk config.
Next steps
- Connect an application to this forwarder: Five-minute app.
- Run as a system service with
systemdordocker-compose: Self-hosting. - Configure faces (UDP, TCP, Unix, WebTransport, WebRTC, BLE, Ethernet, shared memory): Face transports.
- Tune for throughput: Performance.
NDN overview
Named Data Networking is a content-centric network architecture. A packet carries a name, not an address. There are two packet types: the Interest (a request for content) and the Data (the content itself, signed by its producer). Routers forward Interests toward producers and Data back to consumers along the reverse path.
This page covers the four ideas you need to read the rest of the wiki: names, the Interest/Data pair, signing, and trust.
Names
A name is an ordered sequence of components. Components are arbitrary bytes; conventionally they are written as URI segments.
/example/blog/post/2026-05-20/v=42/seg=3
Each component carries a TLV type (regular, sequence number, version, segment, timestamp, keyword, parameters-sha256-digest, implicit-sha256-digest, etc.). The type system is documented in the NDN Packet Specification; ndn-rs’s coverage is tracked in the spec-compliance summary.
Component types in ndn-rs are ndn_packet::NameComponent variants;
naming a Data packet under /example/blog/post/seg=0 parses to
five components.
Interest and Data
An Interest names what the consumer wants. A Data carries the content, the producer’s signature, and metadata (freshness, content type, signature info).
| Packet | Fields | Built by |
|---|---|---|
| Interest | name, nonce, lifetime, optional MustBeFresh, optional ForwardingHint, optional signed-Interest fields | InterestBuilder |
| Data | name, content, content-type, freshness, signature-info, signature-value | DataBuilder |
A consumer expresses the Interest; some producer signs and returns the Data. The flow through the forwarder — the PIT, the FIB, the Content Store — is in Interest and Data lifecycle.
Signing
Every Data packet is signed. The signature covers the name, the
content, and the signature info (which carries the key locator).
ndn-rs ships these signature types:
| SigType | Algorithm | Use |
|---|---|---|
| 0 | DigestSha256 | Content addressing; no producer identity. |
| 1 | SignatureSha256WithRsa | Legacy RSA producers. |
| 3 | SignatureSha256WithEcdsa | ECDSA P-256; common producer choice. |
| 4 | SignatureHmacWithSha256 | Symmetric HMAC (controlled deployments). |
| 5 | SignatureEd25519 | Ed25519; preferred for new deployments. |
| 6 | SignatureBlake3 | Content addressing with BLAKE3. |
| 7 | SignatureKeyedBlake3 | Keyed BLAKE3. |
Codes 6 and 7 are registered in the NDN TLV registry. The signing
entry point is KeyChain::sign; see
Identity and keys.
Trust
A signature is not a verdict. A verifier consults a trust policy to decide whether the signing key is allowed to sign the requested name.
ndn-rs models this as two traits:
TrustPolicy— “should this key be trusted for this name?” Returns yes / no / chain-up-to-cert-X.ValidationPolicy— composition ofTrustPolicydecisions into a full validator (allow custom override rules, chained policies).
Concrete policies that ship in-tree: InsecureTrust (anything goes;
tests only), StaticTrust (allowlist of keys), LvsTrust
(Light Versatile Schema rules), HierarchicalPolicy (parent-name
key signs child-name data). Tabular catalog: Trust policies.
Cache and content store
Routers cache Data they have forwarded. A subsequent Interest with the same name may be answered from the cache rather than reaching the original producer. This makes NDN naturally multicast: ten consumers asking for the same name hit one Data exchange and nine cache responses.
ndn-rs’s Content Store is crates/ndn-store/. It implements
the ContentStore trait; the default is an LRU with policy hooks
for freshness and Must-Be-Fresh handling.
Forwarding strategy
How an Interest is forwarded toward a producer (which face out, when
to retransmit, how to react to NACKs) is the strategy’s call.
ndn-rs ships BestRouteStrategy (default), MulticastStrategy, and
ComposedStrategy. The trait is Strategy in
Extend tier; the default is what runs
under any prefix the operator has not pinned.
Faces
A face is a logical link between two NDN nodes. It is not a TCP/UDP/IP socket: it is an NDN-layer object that owns a transport (byte send/recv) and a link service (NDNLPv2 framing). One face per peer, one face type per transport — UDP, TCP, Unix, WebTransport, WebRTC, BLE, Ethernet, shared memory, in-process. The catalog is in Face transports.
Reading further
- The packet lifecycle: Interest and Data lifecycle.
- Identities, certs, key chains: Identity and keys.
- One-page jargon reference: Glossary.
- Wire format details: the spec-compliance summary.
Interest and Data lifecycle
This page traces an Interest from the application that expresses it to the producer that answers it, and the Data back along the reverse path. The three tables that govern the trip — PIT, FIB, Content Store — are introduced as they appear.
The state machine
%% In stateDiagram-v2 a transition label ends at the first ':', so a
%% literal '::' reparses and errors. #58; is the colon entity → 'Consumer::fetch'.
stateDiagram-v2
[*] --> Expressed: app calls Consumer#58;#58;fetch
Expressed --> InPIT: forwarder records pending
InPIT --> Forwarded: strategy selects nexthop
Forwarded --> Awaiting: face sends Interest
Awaiting --> Satisfied: matching Data returns
Awaiting --> CachedHit: cache hit before send
Awaiting --> NackReceived: face returns NACK
Awaiting --> TimedOut: lifetime expires
CachedHit --> Satisfied
Satisfied --> [*]
NackReceived --> [*]
TimedOut --> [*]
Every arrow is a public observable: Consumer::fetch resolves on
the Satisfied, NackReceived, or TimedOut transitions. The
state itself lives in the forwarder’s PIT.
The data flow
graph LR
subgraph Consumer
C[App]
end
subgraph Forwarder
ICS[Content Store]
PIT[PIT]
FIB[FIB]
STR[Strategy]
IFC[Face in]
OFC[Face out]
end
subgraph Producer
P[App]
end
C -->|Interest| IFC --> ICS
ICS -->|miss| PIT
PIT --> STR
STR --> FIB
FIB --> OFC
OFC -->|Interest| P
P -->|Data| OFC
OFC -->|via PIT| IFC
IFC --> ICS
IFC -->|Data| C
That’s the only diagram pair in the wiki (state + flow, both on this page) — every other page sticks to one.
PIT — Pending Interest Table
Indexed by name. Each entry records every face that has expressed an
Interest for this name and is still waiting. When a Data arrives,
the PIT entry is consumed: the Data is sent out on every recorded
face, the entry is removed.
ndn-rs’s PIT is a DashMap (crates/ndn-engine/src/pit/). No
global lock on the hot path. The PIT entry is consulted via the
Pit accessor from the Instrument tier.
The PIT is what makes NDN naturally multicast: ten consumers asking for the same name leave one PIT entry; the producer’s Data satisfies all ten in-records in a single fan-out.
One Data can satisfy several PIT entries at once — an exact-name
entry plus any CanBePrefix entry at a shorter prefix. All matching
entries are satisfied and the union of their downstream faces is served
(deduplicated by face), matching NFD’s findAllDataMatches.
Forwarded Data is not echoed back out the face it arrived on — except
on an ad-hoc link (LinkType::AdHoc), where re-radiating onto the
shared medium is how other listeners hear it. This is what lets a single
broadcast face act as a relay for the neighbours behind it.
FIB — Forwarding Information Base
Indexed by name prefix. Each entry lists the faces the forwarder
will consider for Interests under that prefix. The Strategy
chooses which face (or faces) to use; the FIB is the candidate set.
Routes land in the FIB via:
Producer::publish_object(the producer announces a prefix to its local forwarder).- A
RoutingProtocolimpl (NLSR, DV, static — see Extend tier). - Operator
nfdc registerover the management protocol.
Content Store
A bounded cache of Data packets. The forwarder consults the CS
before the PIT: if the requested name (subject to MustBeFresh) is
in the cache, the cached Data is returned and the Interest never
needs to leave the forwarder.
ndn-rs’s default Content Store is LRU; the ContentStore trait
allows custom impls. See crates/ndn-store/.
Unsolicited Data — Data that arrives with no matching PIT entry
(e.g. overheard on a broadcast medium) — is dropped by default. The
UnsolicitedDataPolicy knob ([cs] unsolicited_policy, or
EngineBuilder::unsolicited_data_policy) can opt to cache it instead:
admit-network is the choice for a broadcast/ad-hoc bearer, so a later
Interest is served locally. Admitted Data is cached only (never
forwarded) and still must pass validation before entering the CS.
Strategy decisions
When the FIB has nexthops, the strategy decides:
- Which face(s) to send the Interest on.
- Whether to retransmit, and when (via
Strategy::schedule). - How to react to NACKs and timeouts.
Strategies that ship in-tree: BestRouteStrategy (probe primary,
fall back to secondaries), MulticastStrategy (send everywhere
matching the FIB), ComposedStrategy (chain strategies by prefix).
Where to set things
| You want to… | Reach for |
|---|---|
| Express an Interest from app code | Consumer::fetch / fetch_object |
| Serve Data from app code | Producer::publish_object |
| Install a route by hand | ndn-ctl route add <prefix> <nexthop> |
| See what’s in the PIT/FIB/CS | Instrument tier engine.pit() / fib() / cs() |
| Replace the strategy under a prefix | ndn-ctl strategy set <prefix> <strategy> |
See also
- NDN overview — names, signing, trust.
- Develop tier —
ConsumerandProducer. - Extend tier —
Strategycontract. - Management verbs —
nfdc/ndn-ctlverbs.
Identity and keys
An NDN identity is a name plus a key pair, signed by a certificate.
The KeyChain is the single object that holds identities, their
keys, and the policies that govern signing and validation. This
page covers the three things an application author needs to know:
identities, signing info, and trust policies.
KeyChain
use ndn::prelude::*;
use ndn::KeyChain;
fn run() -> Result<(), Box<dyn std::error::Error>> {
// In-memory, self-signed — tests and short-lived producers:
let keychain = KeyChain::ephemeral("/com/example/alice")?;
// Or file-backed, generated on first run and reloaded after:
let keychain = KeyChain::open_or_create("/var/lib/ndn/pib".as_ref(), "/com/example/alice")?;
Ok(()) }
The opened keychain knows its identity, its signing key, and any
trust anchors that have been added. KeyChain lives at
crates/ndn-security/src/keychain.rs; the Develop tier re-exports it
as ndn::KeyChain.
Identities
A KeyChain is one identity: a name, a signing key, and that key’s
certificate. To hold several identities, hold several keychains.
use ndn::prelude::*;
fn run() -> Result<(), Box<dyn std::error::Error>> {
let keychain = KeyChain::ephemeral("/alice")?;
let _name = keychain.name(); // /alice
let _key_name = keychain.key_name(); // /alice/KEY/<key-id>
let _signer = keychain.signer()?; // signs with that key
Ok(()) }
The key is Ed25519 (or ECDSA via KeyChain::ephemeral_ecdsa); its
certificate is a Data packet under the standard NDN naming convention
(/<identity>/KEY/<key-id>/<issuer-id>/<version>). A file-backed
keychain persists both in its PIB (see below).
Certificates
A certificate is a signed Data packet whose content is the identity’s
public key, with a validity period and a key locator pointing to the
issuer’s key. A fresh keychain’s cert is self-signed — it is its own
trust anchor; a CA-issued cert chains to the CA instead.
| Verb | API |
|---|---|
| Sign with this identity | keychain.signer()? / keychain.sign_data(builder) |
| Trust another identity’s cert | keychain.add_trust_anchor(cert) |
| Get a CA-issued cert | NDNCERT enrollment — Identity::enroll(config) |
| Issue a cert for another key | SecurityManager::certify(subject, pubkey, issuer, validity) |
| Operator workflow | NDNCERT setup |
SigningInfo
SigningInfo is the “sign me with X” selector that KeyChain::sign_packet
resolves before signing — useful when a keychain holds more than the
default key, or to force digest-only.
use ndn::prelude::*;
fn run() -> Result<(), Box<dyn std::error::Error>> {
let info = SigningInfo::identity("/alice".parse()?); // by identity name
let _info = SigningInfo::key("/alice/KEY/k1".parse()?); // by specific key
let _info = SigningInfo::digest_sha256(); // integrity only, no key
let keychain = KeyChain::ephemeral("/alice")?;
let wire = keychain.sign_packet(DataBuilder::new("/alice/note", b"hi"), &info)?;
Ok(()) }
Under the hood a SigningInfo resolves to a SignerSelection
(KeyChain::resolve_selection) before the bytes are signed.
Trust policies
When you fetch a Data, the Consumer’s ValidationPolicy decides
whether to accept it. Both the policy and its building blocks are
re-exported from the Develop umbrella:
use ndn::{InsecureTrust, StaticTrust, LvsTrust, HierarchicalPolicy, ValidationPolicy};
| Policy | What it accepts |
|---|---|
InsecureTrust | Any signature. Tests only. |
StaticTrust | Signatures from an explicit allowlist of keys. |
LvsTrust | Light Versatile Schema rules (LVS) — pattern-based. |
HierarchicalPolicy | Parent-name key signs child-name data. |
AcceptAllPolicy | Skips validation entirely (degraded mode). |
For a tabular catalog with rule examples: Trust policies.
PIB backends
The keychain’s key and cert live in a PIB (Personal Information Base), chosen by the constructor:
| Constructor | Storage |
|---|---|
KeyChain::ephemeral(name) | In-memory; nothing persisted. |
KeyChain::open_or_create(path, name) | File-backed PIB at path. |
The lower-level PIB type is ndn_security::pib::FilePib (file-backed;
native builds also carry a SQLite-backed SqlitePib). Use FilePib
directly for the SafeBag import/export below.
SafeBag — exporting identities
A SafeBag is a passphrase-encrypted bundle of an identity’s
certificate and private key. The format is interoperable with
ndnsec; the operator workflow is in
NDNCERT setup → invite tokens.
The file-based PIB exports and imports the bundle directly:
use ndn_security::pib::FilePib;
fn run(key_name: &ndn_packet::Name) -> Result<(), Box<dyn std::error::Error>> {
let pib = FilePib::open("~/.ndn/pib")?;
let bytes = pib.export_safebag(key_name, b"passphrase")?;
std::fs::write("alice.safebag", &bytes)?;
// Receiving side — the embedded cert names the key:
let dst = FilePib::new("~/.ndn/pib")?;
dst.store_safebag(key_name, &bytes, b"passphrase")?;
Ok(()) }
From the command line (ndn-sec)
The same workflow is available without writing code. ndn-sec
manages a file-based PIB and moves whole identities through the
SafeBag wire for both supported signature types — Ed25519
(ndn-rs-native) and ECDSA P-256 (interoperable with ndn-cxx / NFD
and ndnsec):
# Generate a key (Ed25519 default; ECDSA for ndn-cxx interop).
ndn-sec keygen /alice
ndn-sec keygen /alice --type ecdsa
# Export an identity as a SafeBag (base64 by default — paste/email-safe;
# `--format raw` for binary). Prompts for the passphrase if --password
# is omitted.
ndn-sec export /alice -o alice.safebag
# Import elsewhere. The file may be raw TLV or base64, and the key name
# is read from the embedded certificate. `--anchor` also trusts it.
ndn-sec import alice.safebag
ndnsec import alice.safebag # ndn-cxx accepts the ECDSA form too
The PIB location follows --pib, then $NDN_PIB, then ~/.ndn/pib.
The dashboard’s Security view performs the same import/anchor
operations over the management protocol, and its Settings view shows
which PIB the connected forwarder uses.
When you need more: Identity
KeyChain is the atom — sign and verify; most code needs nothing
else. When you need the identity lifecycle NDN leaves to
applications, reach for Identity (in ndn-identity). It derefs to a
KeyChain (signs and verifies identically) and adds enrollment
under a CA, rotation (change the operational key under the prior
key’s authority), recovery (a pre-committed authority installs a
new key if yours is lost), and device delegation — e.g.
Identity::create(KeyChain::ephemeral("/alice")?, recovery)?. Creating
a recoverable principal designates the recovery authority up front: by
design you cannot silently make an unrecoverable identity.
(NdnIdentity is a deprecated alias for Identity.)
See also
- NDNCERT setup — operator and joiner workflow for automated certificate issuance.
- Trust policies — concrete policy catalog with rule shapes.
- Develop tier → KeyChain — full API surface.
crates/ndn-security/— implementation; the trait surfaces are intrust.rs,validation_policy.rs,keychain.rs.
Glossary
One-line definitions for the terms the rest of the wiki uses without re-defining.
| Term | Definition |
|---|---|
| Cert | A signed Data packet whose content is a public key; carries validity period and issuer key locator. |
| Connection | The Develop-tier trait an app uses to reach an engine; concrete forms are IpcConnection and InProcConnection. |
| Consumer | The Develop-tier type that expresses Interests and receives Data. |
| Content Store (CS) | Per-forwarder cache of Data packets, consulted before the PIT. |
| Data | The signed content packet that answers an Interest. |
| Develop tier | API tier for application authors; ships as the ndn umbrella crate. |
| DiscoveryProtocol | Extend-tier trait for neighbour discovery. |
| Engine | The forwarder runtime; ForwarderEngine in ndn-engine. |
| EngineBuilder | Builder that assembles an engine from faces, strategies, and routing protocols. |
| Extend tier | API tier for protocol, strategy, and face authors. |
| Face | NDN-layer link to a peer; Transport + LinkService. |
| FaceKind | Classification of a face (local, on-demand, persistent, permanent). |
| FIB | Forwarding Information Base — name-prefix → candidate faces. |
| fetch_object | Develop-tier verb that performs RDR-shaped segmented fetch. |
| ForwardingHint | Optional Interest field that delegates name lookup to a different prefix. |
| InProcConnection | Develop-tier connection to an embedded engine in the same process. |
| InProcFace | Face whose transport is an in-process channel; no IO. |
| Instrument tier | API tier for researchers; feature-gated experimental-instrument. |
| Interest | The request packet; names what the consumer wants. |
| IPC | The Unix-socket management + data plane between apps and ndn-fwd. |
| IpcConnection | Develop-tier connection to an external ndn-fwd over Unix socket. |
| KeyChain | The object holding identities, keys, certs, and signing/validation policy. |
| LinkService | NDNLPv2 framing layer between a Transport and the engine. |
| LVS | Light Versatile Schema — pattern-based trust policy language. |
| Management protocol | TLV Interests under /localhost/<forwarder>/<module>/<verb>. |
| MgmtModule | Extend-tier trait that owns the verbs for one management module. |
| Name | Ordered sequence of NameComponent values; the NDN address. |
| NACK | Negative acknowledgement; carries a NackReason. |
| NDNCERT | Automated certificate issuance protocol; ndn-cert crate. |
| NDNLPv2 | The link-layer protocol between faces; fragmentation, IncomingFaceId, congestion marks. |
| ndn-fwd | The standalone forwarder binary. |
| ndn umbrella | The Develop-tier crate; package ndn-rs-prelude, library ndn. |
| PIB | Personal Information Base — the on-disk identity/key store. |
| PIT | Pending Interest Table — name → in-records (face IDs waiting on this name). |
| Producer | The Develop-tier type that registers a prefix and serves Data. |
| Prefix | A leading slice of a name; a registration covers a prefix. |
| Queryable / Query | Develop-tier request/reply primitive (one Interest → one Data). |
| RDR | Realtime Data Retrieval — discovery shape for segmented objects (<name>/32=metadata). |
| Responder | Develop-tier closure-style producer (one closure → one Data). |
| RoutingProtocol | Extend-tier trait that produces FIB updates. |
| SafeBag | Passphrase-encrypted bundle of an identity, keys, and certs. |
| SigningInfo | “Sign me with X” descriptor; resolves to a SignerSelection in the KeyChain. |
| Strategy | Extend-tier trait that decides which face an Interest goes out on. |
| Subscriber | Develop-tier multi-publisher stream subscriber (SVS pub/sub shape). |
| SVS | State Vector Sync; the multi-publisher sync protocol that Subscriber consumes. |
| TapFace | Instrument-tier virtual face that records every wire packet sent to it. |
| Transport | Trait for raw byte send/recv; pairs with a LinkService to form a Face. |
| TrustPolicy | Extend-tier trait answering “should this key sign that name?”. |
| ValidationPolicy | Extend-tier trait composing TrustPolicy decisions into a verdict chain. |
Choosing
The rest of the wiki documents features one at a time. These pages do the opposite: they start from your situation and tell you which feature to reach for — and, just as importantly, what it costs you.
Two rules run through all of them:
- Default to the simplest thing. Plain signed Data over a UDP face with a static route covers more cases than newcomers expect. Add a capability only when a concrete need justifies its cost.
- Every capability has a cost. More confidentiality, more throughput, more autonomy in routing — each buys something and charges something (key distribution, Linux-only code, convergence traffic, compute). The tables below put the charge next to the benefit so you can decide.
Anything marked extension is an ndn-rs addition with no NDN community spec behind it — fine to use, but know that you are leaving the standardised core. See Why NDN is different for that distinction.
The decisions
- Confidentiality — who may read the data (signing already says who wrote it).
- Faces & transports — which bearer a link runs over.
- Routing & discovery — how Interests find producers.
- Reliability & throughput — surviving loss and going faster.
- When to use in-network compute — moving computation to the data. extension
Confidentiality
Signing answers who wrote this. Confidentiality answers who may read it. They are separate decisions, and most NDN data needs only the first — a public dataset is still fully secure when it is signed and unencrypted. Reach for encryption only when the content itself must be hidden from nodes that nonetheless carry or cache it.
| You want | Reach for | What it costs |
|---|---|---|
| Hide content from everyone without a shared key (point-to-point, or a group that already shares one) | AEAD symmetric encryption (ndn-crypto-core: seal_in_place / open_in_place, ChaCha20-Poly1305) | You own key distribution; no built-in policy or revocation; anyone with the key reads everything. |
| Let many consumers read by attribute or policy without per-recipient keys | ABE extension (ndn-abe: CP-ABE and multi-authority ABE) | Pairing crypto (BN-254) — larger ciphertext, slower; producer-side encryption only today; no in-stack delegation to weak devices. |
AEAD — the simple floor
When the parties already share a secret (or you have an out-of-band way to hand one over), AEAD is the cheap, obvious choice. The producer seals the content; holders of the key open it. NDN’s caching still works — caches carry the ciphertext blindly. The catch is the part NDN does not solve for you here: getting the key to the right readers, and rotating it. If “share everything with everyone who has the key” matches your access model, stop here.
ABE — one-to-many by policy
When readership is defined by what someone is rather than which key
they hold — “any cardiologist in the west region” — attribute-based
encryption lets the producer encrypt once under a policy, and any consumer
whose attributes satisfy it decrypts. No per-recipient re-encryption, no
recipient list. The cost is real: BN-254 pairing operations are heavier
than symmetric crypto, ciphertexts are larger, and in ndn-rs this is a
producer-side capability — the ndn-abe crate — outside the spec-aligned
core.
How to decide
- Does the content need hiding at all? If authenticity is enough, sign and stop — see Trust, first.
- Is readership a fixed group with a shared secret? AEAD.
- Is readership defined by attributes/policy, one-to-many? ABE, and budget for the pairing-crypto cost and the producer-only constraint.
Faces & transports
A face is an NDN-layer link; the transport underneath it is a separate choice. The same Interest and Data ride any of them unchanged, so picking a transport is about your deployment, not your protocol. Start from where the two ends are.
| The two ends are… | Reach for | What it costs |
|---|---|---|
| Two processes on one host | Unix socket, or shared memory extension (spsc-shm) | shm is the fastest path but same-host only; Unix sockets are simpler and still local. |
| Across a network, general purpose | UDP (default) or TCP | UDP is the common, cross-implementation choice; TCP adds head-of-line blocking but traverses some middleboxes better. |
| A browser tab and a forwarder | WebSocket or WebTransport extension | Needs a listener and (for WebTransport) a cert; browser-side only. |
| Two browsers / NAT-bound peers | WebRTC extension | Requires signaling/relay infrastructure to establish the channel. |
| Constrained radio, no IP | BLE or Wi-Fi Aware extension | Small MTUs force NDNLPv2 fragmentation; throughput and range are limited. |
| Max throughput on a Linux NIC | Ethernet, or AF_XDP extension (af-xdp feature) | AF_XDP is Linux-only kernel-bypass — fastest, but ties you to that platform and a raw NIC. |
How to decide
- Same host? Use Unix sockets; reach for shared memory only when you have measured the local path as a bottleneck.
- Interoperating with other NDN forwarders? Stay on UDP/TCP — they are the lingua franca.
- In a browser? WebSocket is the low-friction start; WebTransport when you need datagrams and have the cert plumbing.
- On a radio without IP? BLE or Wi-Fi Aware, and budget for fragmentation overhead at small MTUs.
- Chasing line rate on Linux? Ethernet, then AF_XDP — but only after the ordinary path is proven too slow (Reliability & throughput).
The full per-transport catalogue — URIs, MTUs, scopes — is in Face transports. To add a bearer of your own, see Implementing a face.
Routing & discovery
Routing fills the FIB with the next hops that lead toward a name; a strategy then chooses among them per packet. How the FIB gets filled is the decision here, and it ranges from “you type the routes” to “the network works them out.”
| Your network is… | Reach for | What it costs |
|---|---|---|
| Small and fixed | Static routes (StaticProtocol) | Zero protocol overhead, but you maintain every route by hand; no adaptation to failures. |
| Multi-router, needs link-state convergence | NLSR (NlsrProtocol) | Routing traffic and per-router state; converges after topology changes. |
| Multi-router, distance-vector style | DV (DvProtocol) | Lighter state than link-state; follows the published distance-vector routing spec. |
| Mobile / ad-hoc, peers come and go | Self-learning strategy | No routing protocol — learns next hops by broadcasting and validating prefix announcements; flooding cost on first reach. |
| Multiple producers sharing a dataset | SVS state-vector sync (ndn-sync) | Not reachability routing — a sync layer for who-has-what; adds per-dataset state vectors. |
Routing vs. sync — don’t confuse them
The first four rows answer “which way do I forward an Interest for this name.” SVS answers a different question: “which data items exist in a shared collection, and which am I missing.” A pub/sub or multi-writer dataset usually wants SVS on top of one of the routing options, not instead of it.
How to decide
- Handful of nodes, stable links? Static routes. Don’t run a routing protocol you don’t need.
- A real multi-router topology that changes? NLSR for link-state, or DV if you prefer distance-vector and want the lighter state.
- No stable topology at all (mobile, mesh)? Lean on the self-learning strategy rather than a routing protocol.
- Synchronising a dataset across producers? Add SVS — and still pick one of the above for reachability.
The protocol types and their state codes are in the NDN overview and the strategy seam in Writing a strategy.
Reliability & throughput
Two separate worries that newcomers often merge: reliability is getting the data despite loss; throughput is moving it fast. NDN already gives you a floor on both — start there and add levers only when you have measured a need.
Reliability — surviving loss
| You face… | Reach for | What it costs |
|---|---|---|
| Occasional loss, normal links | The default — a consumer re-expresses the Interest; the PIT and caches absorb the rest | Nothing; it is how NDN works. |
| A persistently lossy link | ReliabilityFeature on that face (sequence numbers, acks, retransmit) | Per-face state and ack traffic; enable it only on the link that needs it. |
| Loss you can’t retransmit through — multicast, broadcast, one-way radio | Network coding / FEC extension (ndn-coding: K-of-N recode) | Parity overhead on the wire and encode/decode compute; pays off when retransmission is impossible or expensive. |
Throughput — going faster
| You need… | Reach for | What it costs |
|---|---|---|
| Ordinary speed | A plain UDP face | Nothing; fine for most deployments. |
| More packets/sec on Linux | Batched syscalls — recvmmsg / sendmmsg (opt-in feature) | Linux-only; a build feature, off by default. |
| Several cores on one listener | SO_REUSEPORT with the rx_sockets knob | No gain on loopback/macOS — needs a real multi-queue NIC. |
| Line rate, kernel-bypass | AF_XDP extension (af-xdp feature) | Linux-only, raw NIC, extra setup; the last lever, not the first. |
How to decide
- Is loss actually hurting you? Measure before adding anything — the default re-expression covers a lot.
- One bad link? Turn on
ReliabilityFeaturefor that face only. - Can’t retransmit (broadcast/one-way)? That is the case FEC is for — see Network coding (FEC).
- Throughput-bound? Walk the levers in order — batched I/O, then
SO_REUSEPORT, then AF_XDP — and re-measure at each step. Performance has the benchmarking story.
Every throughput lever past the plain face is Linux-specific and opt-in, so none of them change behaviour until you ask for it.
When to use in-network compute
In-network compute lets you name a computation — not just stored bytes — and have it run where the inputs already are, with the result returned as ordinary Data and cached like any other. It is a sharp tool with a narrow sweet spot.
| You have… | Compute is… | Why |
|---|---|---|
| Large inputs near their source, a small result | A good fit | Move the function to the data instead of dragging the data to the function. |
| A result many consumers will ask for | A good fit | The computed Data caches; later requests are cache hits, not recomputation. |
| A simple fetch of existing data | Not needed | Plain Interest/Data already does this — don’t add a moving part. |
| A one-off, unique-per-caller computation | A poor fit | Nothing to cache and nothing to amortise; the machinery buys you nothing. |
| Unclear trust in who computed it | Premature | A computed result is signed by the executor, not the original producer — settle what that signature means to you first. |
How to decide
- Would plain fetch do? Then use it. Compute earns its place only when computation-near-data or result-reuse is the point.
- Is the result cacheable and shared? That is where the win is — many consumers, one execution.
- Do you trust the executor’s signature on the result? Decide this before deploying; it is a different trust question than fetching a producer’s own Data (Trust, first).
The mechanics — naming functions, passing arguments, the result contract — are in In-network compute.
The Node cookbook
Node is the one entry point an application reaches for. It owns a single
forwarder handle and exposes every NDN application pattern over it with one
NDN-native vocabulary — fetch, serve, object, publish, subscribe,
query. Each recipe below is one pattern; they all run on the same Node.
The per-pattern types (Consumer, Producer, Publisher, Subscriber,
Queryable) remain available as lower-level building blocks via
Node::connection; Node is the surface most apps want.
Connect
use ndn::prelude::*;
use ndn::Node;
async fn run() -> Result<(), ndn::AppError> {
let node = Node::connect("/run/nfd/nfd.sock").await?;
Ok(()) }
Node::connect is the full node: because it can re-dial, every pattern is
available. A Node::from_connection(conn) built from a single pre-made
connection serves fetch / object / serve, but the patterns that need their
own stream (publish / subscribe / query / serve_object) return
AppError::Unsupported — use connection() for those, or
Node::from_provider with a ConnectionProvider that mints more streams (how
the in-process app_node makes every pattern work). (One connection multiplexes
fetch and serve with no cross-talk; the sync and query patterns each get a
dedicated connection to the same forwarder.)
Fetch one Data
async fn run(node: ndn::Node) -> Result<(), ndn::AppError> {
let data = node.fetch("/peer/greeting").await?; // unverified
println!("{} bytes", data.content().map(|c| c.len()).unwrap_or(0));
Ok(()) }
Fetch, verified
Decide trust once with verifying; then fetch returns SafeData — proof the
signature checked out.
async fn run(node: ndn::Node, validator: ndn::Validator) -> Result<(), ndn::AppError> {
let safe = node.verifying(validator).fetch("/peer/greeting").await?;
let _ = safe; Ok(()) }
Fetch an object (RDR)
object(name) is a fluent builder for a (possibly segmented) RDR object. Chain
the modifiers, then a terminal verb. This replaces the old fetch_object_*
method family.
async fn run(node: ndn::Node, validator: ndn::Validator) -> Result<(), ndn::AppError> {
// simple — whole object in memory
let bytes = node.object("/alice/photo").fetch().await?;
// verified + forwarding hint + a progress bar
let bytes = node.object("/alice/photo")
.verify(validator)
.hint(["/gateway"])
.progress(|done, total| eprintln!("{done}/{total}"))
.fetch().await?;
let _ = bytes; Ok(()) }
Terminal verbs pick the delivery shape:
| Verb | Returns | Memory | Notes |
|---|---|---|---|
.fetch() | Bytes | whole object | reassembled in order |
.stream(have, on_segment) | u64 (size) | flat | per-segment; have(seg) skips for resume |
.to_file(&file) | u64 (bytes) | flat | positioned writes; unix; requires .verify() |
Serve dynamic content
serve registers a prefix and runs the handler for each matching Interest,
concurrently with any fetches on the same Node. Serving stops when the
returned guard is dropped. The handler gets the Interest and a Responder.
async fn run(node: ndn::Node) -> Result<(), ndn::AppError> {
let _guard = node.serve("/alice/notes", |interest, reply| async move {
let body = format!("note for {}", &*interest.name);
let _ = reply.respond((*interest.name).clone(), body).await;
}).await?;
// ... keep _guard alive while serving ...
Ok(()) }
Serve a static object or file
async fn run(node: ndn::Node) -> Result<(), ndn::AppError> {
let _g = node.serve_object("/alice/notes/today", "buy milk").await?;
#[cfg(unix)]
let _g = node.serve_file("/alice/share/big.bin", "/path/to/big.bin").await?;
Ok(()) }
Segments are DigestSha256 (unsigned). For signed serving, build a
Producer with a signer from connection().
Publish / subscribe (dataset sync)
async fn run(node: ndn::Node) -> Result<(), ndn::AppError> {
let publisher = node.publish("/svs/chatroom", "/alice").await?;
publisher.put(b"hello room").await?;
let subscriber = node.subscribe("/svs/chatroom", "/alice").await?;
// while let Some(sample) = subscriber.recv().await { ... }
Ok(()) }
Query responder
async fn run(node: ndn::Node) -> Result<(), ndn::AppError> {
let queryable = node.query("/alice/svc").await?;
// while let Some(q) = queryable.recv().await { q.reply(answer).await?; }
Ok(()) }
Typed objects (feature serde)
With ndn-app’s serde feature, objects (de)serialize as JSON Content:
async fn run(node: ndn::Node, validator: ndn::Validator) -> Result<(), ndn::AppError> {
#[derive(serde::Serialize, serde::Deserialize)] struct Profile { name: String }
let me = Profile { name: "alice".into() };
let _g = node.serve_object_typed("/alice/profile", &me).await?;
let them: Profile = node.object("/bob/profile").verify(validator).fetch_as().await?;
let _ = them; Ok(()) }
In-process — no sockets (tests, mobile, browser)
Two Nodes on one embedded engine talk to each other with no forwarder process.
Ideal for tests.
use ndn::{EngineAppExt, EngineBuilder};
use ndn_engine::EngineConfig;
use tokio_util::sync::CancellationToken;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let (engine, _shutdown) = EngineBuilder::new(EngineConfig::default()).build().await?;
let cancel = CancellationToken::new();
let alice = engine.app_node(cancel.child_token());
let bob = engine.app_node(cancel.child_token());
let _g = alice.serve("/alice", |i, r| async move {
let _ = r.respond((*i.name).clone(), "hi").await;
}).await?;
let data = bob.fetch("/alice/greeting").await?; // routes engine → FIB → alice
let _ = data; Ok(()) }
app_node is a full node: the dedicated-stream patterns (publish /
subscribe / query / serve_object) work too — each allocates a fresh app
face on the engine. SVS sync fans out across faces only under a multicast
strategy, so build the engine with
EngineBuilder::new(cfg).strategy(MulticastStrategy::new()) when two in-process
nodes publish/subscribe to each other.
Escape hatch
node.connection() returns the underlying multiplexed Connection, so you can
drop to the lower-level Consumer / Producer / Publisher / … building
blocks — for a signer, a custom congestion strategy, or anything Node doesn’t
surface.
async fn run(node: ndn::Node, keychain: ndn::KeyChain) -> Result<(), Box<dyn std::error::Error>> {
use ndn::Producer;
let signed = Producer::new(node.connection(), "/alice/notes".parse()?)
.with_signer(keychain.signer()?);
let _ = signed; Ok(()) }
See also
- Develop tier — the full type inventory behind
Node. - Building an application — end-to-end guide.
Develop tier — the ndn umbrella
The Develop tier is what an application reaches for when it wants to
fetch a Data by name or serve one and treat the forwarder as
opaque. Everything below is a re-export of ndn-app, ndn-packet,
and ndn-security from the ndn-rs-prelude crate (library name
ndn).
Start with
Node. For most apps the one type to learn isNode— a single handle that exposes every pattern (fetch/serve/object/publish/subscribe/query) over one forwarder connection. The per-pattern types below (Consumer,Producer, …) are the building blocksNodedelegates to, reachable vianode.connection()when you need them.
Package vs library: Cargo.toml carries ndn-rs-prelude = "0.1";
imports read use ndn::Consumer;. The split is recorded in
crates/ndn-rs-prelude/Cargo.toml.
Inventory
graph LR
A[Application] -->|imports| U(ndn umbrella)
U --> P[Consumer / Producer / Responder]
U --> S[Subscriber / Queryable / Query]
U --> K[KeyChain / SigningInfo / TrustPolicy]
U --> T[Connection / IpcConnection / InProcConnection]
U --> X[Name / Interest / Data / *Builder]
| Re-export | Source | What it does |
|---|---|---|
Consumer | ndn_app::Consumer (crates/ndn-app/src/consumer.rs) | Express Interests; fetch a single Data or a segmented object. |
Producer | ndn_app::Producer (crates/ndn-app/src/producer.rs) | Register a prefix; serve Data on demand. |
Responder | ndn_app::Responder (crates/ndn-app/src/responder.rs) | Callback-style producer (one closure → one Data). |
Subscriber, SubscriberConfig, Sample | ndn_app::subscriber | SVS-style multi-publisher stream subscription. |
Queryable, Query | ndn_app::queryable | Request/reply primitive (one Interest → one Data). |
KeyChain | ndn_security::KeyChain | Identity / key / cert management; entry point for signing. |
SigningInfo, SignerSelection | ndn_security::{SigningInfo, SignerSelection} | “Sign me with X” descriptor. |
ValidationPolicy, TrustPolicy | ndn_security::{ValidationPolicy, TrustPolicy} | Trust-decision contracts. |
Connection, IpcConnection, InProcConnection | ndn_app::* | Trait + concrete connections; unifies external forwarder and embedded engine. |
Name, NameComponent, Interest, Data, NackReason | ndn_packet::* | Decoded packets. |
InterestBuilder, DataBuilder | ndn_packet::encode::* | Builder-style packet construction. |
AppError | ndn_app::error::AppError | Single error type at the Develop tier boundary. |
Consumer
use ndn::prelude::*;
use ndn::Consumer;
async fn run() -> Result<(), ndn::AppError> {
let mut consumer = Consumer::connect("/tmp/ndn-fwd.sock").await?;
let data = consumer.fetch("/example/hello").await?;
println!("got {} bytes", data.content().len());
Ok(()) }
fetch(name)expresses one Interest, returns oneData.fetch_object(name)performs RDR discovery (<name>/32=metadata) and reassembles segmentedData. Segments are fetched pipelined (a sliding window of in-flight Interests, retransmitting on a stall), so throughput iswindow / RTT × chunkrather than one round-trip per segment.object(name)returns a fluent builder —object(name).verify(v).hint([..]) .progress(cb).to_file(&file)streams each verified segment to a file at its byte offset as it arrives, so an arbitrarily large object is received with flat memory;.fetch()reassembles in memory,.stream(..)hands each segment to a callback. See The Node cookbook. (The oldfetch_object_*methods are deprecated in favour of this builder.)fetch_on(face_id, name)pins the Interest to a face viaNextHopFaceId— useful for measurement or multipath tests.- The Consumer applies the configured
ValidationPolicyto every returnedDatabefore handing it back.
Producer
use ndn::prelude::*;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let keychain = KeyChain::ephemeral("/example")?;
// connect registers the prefix; with_signer makes published Data signed.
let producer = Producer::connect("/tmp/ndn-fwd.sock", "/example")
.await?
.with_signer(keychain.signer()?);
producer
.publish_object("/example/hello".parse()?, b"hi".to_vec().into(), 0)
.await?;
Ok(()) }
publish_object(name, content, chunk_size)segments and serves the object — signing each segment with the configured signer, elseDigestSha256.chunk_size == 0uses the default.publish_object_from_file(name, file, size, chunk_size)serves a file-backed object: segments are read from the file on demand (positioned reads), so an arbitrarily large file is published without ever loading it into memory.connect(socket, prefix)registers the prefix; re-publishing the same name replaces the content (FreshnessPeriodgoverns the cache).
Responder
A Responder is the reply handle passed to Producer::serve — a
closure-style producer for when each Interest needs a dynamic reply.
With a signer configured, respond signs the reply.
use ndn::prelude::*;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let keychain = KeyChain::ephemeral("/example")?;
let producer = Producer::connect("/tmp/ndn-fwd.sock", "/example/time")
.await?
.with_signer(keychain.signer()?);
producer.serve(|interest, responder| async move {
let now = format!("{:?}", std::time::SystemTime::now());
// `respond` signs with the configured signer.
responder.respond((*interest.name).clone(), now.into_bytes()).await.ok();
}).await?;
Ok(()) }
Subscriber
A Subscriber joins a multi-publisher stream (SVS pub/sub shape).
Each peer publishes under its own name; Subscriber reassembles a
total order and yields Sample items.
use ndn::prelude::*;
use ndn::Subscriber;
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut sub = Subscriber::connect("/tmp/ndn-fwd.sock", "/svs/chatroom").await?;
while let Some(sample) = sub.recv().await {
println!("{}: {:?}", sample.publisher, sample.payload);
}
Ok(()) }
Note: in v0.1.0 the Subscriber is read-only. Publishing into a
sync group from Develop-tier code is filed for v0.1.x.
Connection
Connection is the trait the Develop types accept; two concrete
implementations cover the typical deployments.
| Type | Where the engine lives | Typical use |
|---|---|---|
IpcConnection | External ndn-fwd over Unix socket | Production apps on Linux/macOS. |
InProcConnection | Embedded ForwarderEngine in the same process | Tests, mobile, browser. |
Embedded engine
use ndn::prelude::*;
use ndn::{Consumer, InProcConnection};
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_face::local::InProcFace;
use ndn_transport::FaceId;
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (face, handle) = InProcFace::new(FaceId(1), 64);
let (_engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
.face(face)
.build()
.await?;
let mut consumer = Consumer::new(InProcConnection::from_handle(handle));
let _ = consumer.fetch("/example/hello").await?;
Ok(()) }
EngineBuilder is in crates/ndn-engine/. The umbrella does
not re-export it: the Develop tier treats the engine as opaque, and
embedding it is an Extend-tier or test-time concern.
KeyChain
use ndn::prelude::*;
fn run() -> Result<(), Box<dyn std::error::Error>> {
// A KeyChain is one identity; `ephemeral` / `open_or_create` generate its key
// and self-signed cert.
let keychain = KeyChain::ephemeral("/alice")?;
// Sign a Data with that key → ready-to-send wire bytes.
let wire = keychain.sign_data(DataBuilder::new("/alice/notes/1", b"hi"))?;
Ok(()) }
Persistence backends: SQLite-backed PIB on native targets; IndexedDB PIB on wasm32. See Identity and keys.
Wasm target
The umbrella compiles for wasm32-unknown-unknown but exports a
smaller surface: Name, Interest, Data, InterestBuilder,
DataBuilder, SigningInfo, TrustPolicy. Consumer, Producer,
KeyChain, and the connection types stay native-only because
ndn-app pulls the full Tokio runtime.
Browser callers build the engine in-page with
ndn_engine::WasmEngineBuilder and drive the Producer shape from
ndn-engine directly. The split is intentional and documented in
the prelude crate’s top-level docs.
Cross-platform parity
The endpoint API keeps the same names and arguments on every target, so application logic ports unchanged. The execution model is the only divergence — and it is forced by the platform, not chosen:
| Axis | native / browser | embedded (ndn-embedded) |
|---|---|---|
| Suspension | async fn … .await | nb::Result poll (WouldBlock) |
| Result | owned Data / Bytes | borrowed &[u8] into an MTU buffer |
| Sizing | heap | const-generic buffers, no alloc |
| Ownership | Arc<Engine>, &self | &mut Forwarder<…> |
The packet, name, and signing primitives (Name, Interest, Data,
Ed25519 sign/verify) are the same crates on all three targets, so
only the seam above changes. ndn-embedded exposes
Consumer::fetch(name) and Producer::serve(handler) — the same verbs
as this tier, returning nb::Result rather than a future:
use ndn_embedded::{Consumer, Producer};
// native: let data = consumer.fetch("/ndn/sensor/temp").await?;
// embedded: poll the same call (here driven to completion with nb::block!)
let mut c: Consumer<_> = Consumer::new(&mut face, seed);
let data: &[u8] = nb::block!(c.fetch("/ndn/sensor/temp"))?;
// native: producer.serve(|interest, responder| async move { … }).await?;
// embedded: one Interest per poll, from your event loop
let mut p: Producer<_> = Producer::new(&mut face, "/app");
let _ = p.serve(|_name, out| {
out[..2].copy_from_slice(b"on");
Some(2) // content length, or None to decline
});
What this tier does not expose
The Develop tier deliberately omits:
- Direct
ForwarderEngineaccess (PIT/FIB/CS tables) — that’s the Instrument tier. Strategy,RoutingProtocol,Face,LinkServicetraits — that’s the Extend tier.- Per-crate error enums (
ConfigError,TrustError, etc.) — they collapse intoAppErrorat this boundary.
See also
- Building an application — end-to-end guide that uses every type on this page.
- Five-minute app — Consumer in 20 lines.
- Ten-minute producer — Producer + Consumer pair.
Extend tier — protocol, strategy, and face authors
The Extend tier is the union of trait surfaces a protocol author implements to plug a new routing algorithm, forwarding strategy, or face transport into ndn-rs without forking the engine. It is not a single crate: each trait lives next to the subsystem it extends.
graph TB
subgraph Engine
E[ForwarderEngine]
end
R[RoutingProtocol] -->|installs into| E
S[Strategy] -->|EngineBuilder::strategy / register_strategy!| E
F[Face = Transport + LinkService] -->|EngineBuilder::face| E
D[DiscoveryProtocol] -->|installs into| E
M[MgmtModule] -->|MgmtRouter| E
Trait inventory
| Trait | Crate path | Purpose |
|---|---|---|
Strategy + StrategyContext + ScheduledEvent | ndn_strategy::strategy (crates/ndn-strategy/src/strategy.rs:56) | Forwarding-strategy contract; returns ForwardingActions, schedule() for timers. |
register_strategy! macro | ndn_strategy::registry | linkme-backed registry; strategies auto-register. |
RoutingProtocol + RoutingHandle | ndn_engine::routing (crates/ndn-engine/src/routing.rs) | Pluggable routing-plane; produces a typed RoutingProtocolStatus. |
InstallableProtocol + PostBuildQueue | ndn_engine::installable (crates/ndn-engine/src/installable.rs) | “Install yourself into an EngineBuilder” trait. |
Transport | ndn_transport::transport (crates/ndn-transport/src/transport.rs) | Raw byte send/recv. |
LinkService + LinkServiceFrame | ndn_transport::link_service | NDNLPv2 framing, IncomingFaceId, congestion-mark policy. |
Face = Transport + LinkService | ndn_transport::face (crates/ndn-transport/src/face.rs:298) | The composition the engine sees. |
DiscoveryProtocol (+ contexts) | ndn_discovery_core (crates/ndn-discovery-core/src/) | Neighbor discovery contract. |
MgmtModule + MgmtContext + MgmtRouter | ndn_mgmt::module (crates/ndn-mgmt/src/module.rs) | Per-module management verb authorship. |
NotificationStream | ndn_mgmt::notification | Async notification dataset publisher. |
TrustPolicy | ndn_security::trust (crates/ndn-security/src/trust.rs) | “Should this signing key be trusted for this name?” |
ValidationPolicy | ndn_security::validation_policy | Pluggable verdict chain. |
Signer / Verifier | ndn_security::{signer, verifier} | Crypto primitives. |
Strategy
A Strategy is a pure decision function: each hook reads an immutable
StrategyContext and returns ForwardingAction values for the engine
to execute. It never sends packets or mutates tables itself. The
contract lives at crates/ndn-strategy/src/strategy.rs:56.
use std::sync::Arc;
use smallvec::{SmallVec, smallvec};
use ndn_packet::Name;
use ndn_transport::{ForwardingAction, NackReason};
use ndn_strategy::{ErasedStrategy, Strategy, StrategyContext, register_strategy};
register_strategy!(
RANDOM_REG,
b"random",
1,
|| Arc::new(RandomNexthopStrategy::new()) as Arc<dyn ErasedStrategy>,
);
pub struct RandomNexthopStrategy { name: Name }
impl Strategy for RandomNexthopStrategy {
fn name(&self) -> &Name { &self.name }
// Synchronous fast path; `Some(actions)` short-circuits the async hooks.
fn decide(&self, ctx: &StrategyContext) -> Option<SmallVec<[ForwardingAction; 2]>> {
let fib = ctx.fib_entry?;
match fib.nexthops_excluding(ctx.in_face).first() {
Some(nh) => Some(smallvec![ForwardingAction::Forward(smallvec![nh.face_id])]),
None => Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]),
}
}
async fn after_receive_interest(
&self, ctx: &StrategyContext<'_>,
) -> SmallVec<[ForwardingAction; 2]> { self.decide(ctx).unwrap() }
async fn after_receive_data(
&self, _ctx: &StrategyContext<'_>,
) -> SmallVec<[ForwardingAction; 2]> { SmallVec::new() }
}
name() returns &Name (a strategy name is an NDN name). Hooks take
&self and &StrategyContext — never &mut, and the Interest/Data are
read off the context, not passed as arguments. register_strategy!
collects entries at link time via linkme on native targets; the engine
reads the slice at startup. EngineBuilder::strategy(...) installs one
directly. Full walkthrough: Writing a strategy.
In-tree references: crates/ndn-strategy/src/best_route.rs,
crates/ndn-strategy/src/multicast.rs, and
examples/strategy-custom/.
RoutingProtocol
A RoutingProtocol produces FIB updates. It also reports a typed
RoutingProtocolStatus so the management plane can describe state
without parsing free-form strings.
In-tree references: crates/ndn-routing/src/protocols/static.rs
(static FIB), …/nlsr/protocol.rs (link-state),
…/dv/... (distance vector). The DV implementation uses the
typed status codes 201/202/204/206/208/210/301.
To install a routing protocol into an engine, implement
InstallableProtocol. EngineBuilder::install(protocol) then wires
it through. See crates/ndn-engine/src/installable.rs.
Face
A Face is Transport + LinkService. The transport handles raw
bytes; the link service handles NDNLPv2 framing, fragmentation,
IncomingFaceId, and congestion marks.
use ndn_transport::{Transport, LinkService, Face, LpLinkService};
pub struct MyTransport { /* ... */ }
impl Transport for MyTransport { /* send / recv / close */ }
let face = Face::new(MyTransport { /* ... */ }, LpLinkService::default());
LpLinkService is the default link service (NDNLPv2). For raw
bytes-in-bytes-out, use PassthroughLinkService.
Twelve face transports ship in-tree; the catalog is in
Face transports. To add a new
transport, implement Transport and pick a link service.
In-tree references: crates/ndn-face/src/{net,local,l2,serial}/.
DiscoveryProtocol
A DiscoveryProtocol brings neighbors to the routing plane. It owns
discovery state, exposes a NeighborContext, and may react to face
up/down via FaceLifecycleContext.
In-tree references: crates/ndn-discovery-core/src/no_discovery.rs
(zero-discovery default), the autoconf path in
crates/ndn-discovery/src/.
MgmtModule
A MgmtModule answers /localhost/nfd/<module>/<verb> Interests for
a given module. The mgmt-router fans verbs out to modules based on
the second name component.
use async_trait::async_trait;
use ndn_config::{ControlParameters, ControlResponse, control_response::status};
use ndn_mgmt::{MgmtModule, MgmtContext, MgmtResponse};
pub struct MyModule;
#[async_trait]
impl MgmtModule for MyModule {
// The wire module name (second name component), as a byte string.
fn name(&self) -> &'static [u8] { b"my-module" }
// The router has already validated the command name and authorisation;
// dispatch a verb to a response payload (Control or Dataset).
async fn dispatch(
&self,
verb: &[u8],
params: ControlParameters,
ctx: &MgmtContext<'_>,
) -> MgmtResponse {
let _ = (params, ctx); // pull engine / source_face / handlers as needed
match verb {
b"list" => MgmtResponse::Dataset(/* encoded TLV dataset */ Default::default()),
_ => ControlResponse::error(status::NOT_FOUND, "unknown verb").into(),
}
}
}
MgmtResponse is Control(Box<ControlResponse>) or Dataset(Bytes);
ControlResponse::ok/error build the control variant and .into()
wraps it. Register the module with MgmtRouter::register(Arc::new(MyModule)).
In-tree references: each verb has its own module file at
crates/ndn-mgmt/src/modules/{faces,fib,rib,strategy,cs,forwarder_status,routing}.rs.
The verb catalog itself is in Management verbs.
Trust and validation
TrustPolicy answers “should this key sign that name?”. The
KeyChain consults it before signing; the validator consults it
before accepting a verified Data. ValidationPolicy chains
verdicts so a deployment can compose hierarchical + LVS + custom
overrides.
In-tree references: crates/ndn-security/src/trust.rs,
crates/ndn-security/src/validation_policy.rs.
Concrete policies and the LVS rule schema: Trust policies.
Conventions
- Every Extend-tier trait has at least one in-tree reference impl.
Grep for
impl <Trait> forto find one. - Every Extend-tier trait has
///docs on the trait and every required method, including preconditions, ownership, and threading. - Extend-tier surfaces stay SemVer-stable across v0.1.x patches.
See also
- Writing a strategy — full walkthrough.
- Implementing a face — full walkthrough.
- Instrument tier — researcher access below the Extend trait surface.
examples/strategy-custom/,examples/strategy-composed/,examples/context-enricher/,examples/wasm-strategy/.
Instrument tier — researcher / measurement surface
The Instrument tier exposes engine internals (PIT, FIB, CS, strategy table, measurements) and packet-tap primitives for researchers, measurement tooling, and in-process tests. It sits below the Extend tier: protocol authors implement traits; researchers read engine state directly.
Stability: Instrument items are feature-gated behind
experimental-instrument. The shape of the feature surface follows
SemVer; individual items inside may move between patch releases.
Out-of-feature use is intentionally inconvenient (see §“gating”
below).
When to reach for this tier
- You’re measuring forwarding behaviour and want PIT / CS hit counts at the source.
- You’re writing a researcher experiment that wires two in-process engines back-to-back and observes wire packets between them.
- You’re building tooling (dashboard, OpenTelemetry bridge, packet sniffer) that needs structured engine state.
Inventory
| Item | Crate path | Purpose |
|---|---|---|
ForwarderEngine::fib() / rib() / pit() / cs() / strategy_table() / measurements() / routing() / discovery_ctx() | ndn_engine::engine (crates/ndn-engine/src/engine.rs) | Direct table access. |
ContextEnricher | ndn_engine::enricher | Pipeline-stage hook for cross-layer enrichment. |
observability::targets | ndn_engine::observability::targets | Tracing target taxonomy. |
InProcFace::new_kind | ndn_face::local::InProcFace | Synthesize an in-process face with a custom FaceKind. |
CallbackFace | ndn_face::callback::CallbackFace | Virtual face whose send-path is a Rust callback. |
TapFace | ndn_face::callback::TapFace | Records every wire packet sent to it without participating in forwarding. |
Gating
Each carrier crate declares the feature:
# crates/ndn-engine/Cargo.toml — and likewise for ndn-face
[features]
experimental-instrument = []
Items carry #[cfg_attr(not(feature = "experimental-instrument"), doc(hidden))]
so they remain pub (the workspace itself calls them) but are
absent from cargo doc output unless the consuming crate opts in.
To use the tier in your own crate:
[dependencies]
ndn-engine = { version = "0.1", features = ["experimental-instrument"] }
ndn-face = { version = "0.1", features = ["experimental-instrument"] }
TapFace
TapFace is the workhorse for wire-packet tracing.
use ndn_face::callback::TapFace;
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_transport::FaceId;
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let tap = TapFace::new(FaceId(99));
// `.face()` consumes the transport, so grab a shared handle to the
// capture buffer first:
let buf = tap.capture_handle(); // Arc<Mutex<Vec<Bytes>>>
let (_engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
.face(tap)
.build()
.await?;
// Drive traffic, then read every wire packet the engine routed to FaceId(99).
// The handle's `Mutex` is `tokio::sync::Mutex`, so the lock is `.await`ed:
for bytes in buf.lock().await.iter() {
// parse / inspect / log
}
Ok(()) }
When the tap is not handed to the builder, drain the buffer in one
shot with the async captured() method, which returns Vec<Bytes>
and clears the tap:
use ndn_face::callback::TapFace;
use ndn_transport::FaceId;
async fn run() {
let tap = TapFace::new(FaceId(99));
// ... drive traffic ...
let packets: Vec<bytes::Bytes> = tap.captured().await;
}
TapFace does not participate in forwarding: the engine sends to
it, the bytes accumulate, and nothing is returned. Use it alongside
real faces to record what the engine would have sent over them.
In-tree reference: crates/ndn-face/src/callback.rs.
Engine table access
With the feature enabled, the engine exposes its tables:
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_store::pit::{PitKeyDiscriminator, PitToken};
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
.build()
.await?;
// FIB inspection. `dump()` returns a `Vec<(Name, Arc<FibEntry>)>`
// snapshot of every installed prefix; `nexthops` is a field on
// `FibEntry`, each carrying a `face_id` and a `cost`:
for (name, entry) in engine.fib().dump() {
let faces: Vec<_> = entry.nexthops.iter().map(|n| n.face_id).collect();
println!("{name} -> {faces:?}");
}
// PIT inspection. The PIT is a sharded `DashMap` keyed by `PitToken`,
// so there is no global iterator on the hot path. Read live size with
// `len()`, and inspect a known name under a closure with
// `with_named_entry` (which holds the shard lock for the call):
println!("pending interests: {}", engine.pit().len());
let name: ndn_packet::Name = "/probe".parse()?;
engine.pit().with_named_entry(
&name,
PitKeyDiscriminator::Classical,
|entry| {
println!(
"{} has {} in-records",
entry.name,
entry.in_records.len(),
);
},
);
Ok(()) }
These accessors expose live tables. The PIT closures
(with_entry, with_entry_mut, with_named_entry) hold the
relevant shard lock for the duration of the call, so keep the body
short. Mutating PIT state for injection experiments (e.g. fabricating
in-records) is filed for v0.1.x.
CallbackFace
CallbackFace builds a virtual face whose send-path runs an
application callback: the engine routes an Interest to it, the
callback returns Some(Data) to satisfy it or None to emit a
NoRoute Nack. Use it when a function can directly produce Data
for any name.
The callback is async — it returns a
BoxFuture<'static, Option<Data>>:
use ndn_face::callback::CallbackFace;
use ndn_packet::{Data, Interest};
use ndn_transport::FaceId;
let face = CallbackFace::new(FaceId(7), |interest: Interest| {
Box::pin(async move {
// ... look up / synthesize Data for `interest.name` ...
None::<Data>
})
});
When the lookup is synchronous, CallbackFace::from_fn takes a
plain Fn(Interest) -> Option<Data> and wraps it for you:
use ndn_face::callback::CallbackFace;
use ndn_packet::{Data, Interest};
use ndn_transport::FaceId;
let face = CallbackFace::from_fn(FaceId(7), |_interest: Interest| {
None::<Data>
});
Two-engine experiments
A common Instrument-tier pattern is wiring two engines through
in-process faces. InProcFace::new(id, buffer) returns a linked
(InProcFace, InProcHandle): the engine holds the face, and the
handle drives the recv/send channels from the other side. (Use
InProcFace::new_kind to stamp a non-default FaceKind on the
engine side.)
use ndn_face::local::InProcFace;
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_transport::FaceId;
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Each engine gets its own InProcFace; the test harness owns both
// handles and shuttles wire bytes between them.
let (face_a, handle_a) = InProcFace::new(FaceId(1), 64);
let (face_b, handle_b) = InProcFace::new(FaceId(2), 64);
let (_engine_a, _s_a) = EngineBuilder::new(EngineConfig::default()).face(face_a).build().await?;
let (_engine_b, _s_b) = EngineBuilder::new(EngineConfig::default()).face(face_b).build().await?;
// Forward whatever engine A emits into engine B (and vice-versa):
while let Some(bytes) = handle_a.recv().await {
handle_b.send(bytes).await.ok();
}
Ok(()) }
The audit witness at testbed/tests/audit/phase3_fetch_object_rdr.sh
uses this shape to verify segmented fetch_object end-to-end without
opening any network sockets.
What this tier does not expose
- Structured packet-trace export (jsonl, OTLP, pcap).
TapFaceships raw bytes only; export formats are v0.2 candidates. - PIT injection / fake in-records. v0.1.x.
- Strategy injection at runtime (bypass
register_strategy!). v0.1.x if a use case appears.
See also
- Develop tier — application-author surface.
- Extend tier — protocol-author trait surface.
- Logging —
observability::targetsis the same taxonomy the operator-facing logging page uses.
Building an application
This guide walks through a small but complete NDN application: a
note-taking service that publishes signed notes and answers
Interests for them. By the end you’ll have used KeyChain,
Producer, Consumer, Responder, and a TrustPolicy.
For the 20-line warmup, see Five-minute app.
Setup
cargo new --bin notes
cd notes
cargo add ndn-rs-prelude tokio --features tokio/full
cargo add anyhow
Run a forwarder in another terminal (see Running the forwarder).
Step 1 — Create an identity
use ndn::prelude::*;
use ndn::KeyChain;
fn run() -> anyhow::Result<()> {
// Generates the key + self-signed cert for `/alice` on first run; reloads it
// from the file-backed PIB on every run after.
let keychain = KeyChain::open_or_create("/var/lib/ndn/pib".as_ref(), "/alice")?;
println!("identity {}", keychain.name());
Ok(()) }
Idempotent: a KeyChain is one identity, and open_or_create reuses the
existing key from the PIB on reruns. (For a throwaway in-memory identity, use
KeyChain::ephemeral("/alice").)
Step 2 — Publish a static note
use ndn::prelude::*;
async fn publish(keychain: KeyChain) -> anyhow::Result<()> {
let producer = Producer::connect("/tmp/ndn-fwd.sock", "/alice/notes")
.await?
.with_signer(keychain.signer()?);
producer
.publish_object("/alice/notes/2026-05-20/v=1".parse()?, b"buy milk".to_vec().into(), 0)
.await?;
Ok(()) }
connect registers the prefix /alice/notes; with_signer makes
publish_object sign each segment with /alice’s key (without it,
the object would be DigestSha256 only). The object is served on
demand.
Step 3 — Serve dynamic notes with Responder
Producer::serve answers each Interest with a freshly-built Data via
a Responder. Useful when the content depends on the current time or
the Interest’s selectors. With a signer configured, respond signs.
use ndn::prelude::*;
async fn serve(keychain: KeyChain) -> anyhow::Result<()> {
let producer = Producer::connect("/tmp/ndn-fwd.sock", "/alice/clock")
.await?
.with_signer(keychain.signer()?);
producer.serve(|interest, responder| async move {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// `respond` signs the reply with the configured signer.
responder.respond((*interest.name).clone(), format!("{now}").into_bytes()).await.ok();
}).await?;
tokio::signal::ctrl_c().await?;
Ok(()) }
Step 4 — Fetch with validation
A consumer that trusts /alice’s key and rejects anything else:
use ndn::Consumer;
use ndn_security::{Certificate, TrustSchema, Validator};
async fn fetch(alice_cert: Certificate) -> anyhow::Result<()> {
// Pin /alice's certificate as a trust anchor; accept only what chains to it.
let validator = Validator::new(TrustSchema::hierarchical());
validator.add_trust_anchor(alice_cert);
let mut consumer = Consumer::connect("/tmp/ndn-fwd.sock").await?;
// `fetch_verified` returns `SafeData` only if the signature verifies and the
// schema accepts it. Prefer it over the bare `fetch`, which returns unverified
// `Data`. (Need to decide per call? `fetch_unverified` returns `Unverified<Data>`,
// which forces an explicit `.verify(&validator)` or a loud `.trust_unchecked()`.)
let safe = consumer.fetch_verified("/alice/notes/today", &validator).await?;
println!("{}", String::from_utf8_lossy(safe.data().content().unwrap_or_default()));
Ok(()) }
A hierarchical schema says: accept Data under /alice/... if its
signature chains up to a key under /alice.
Step 5 — Subscribe to a stream
If /alice is one publisher in a multi-publisher feed, use
Subscriber:
use ndn::prelude::*;
use ndn::Subscriber;
async fn sub() -> anyhow::Result<()> {
let mut sub = Subscriber::connect("/tmp/ndn-fwd.sock", "/team/notes").await?;
while let Some(sample) = sub.recv().await {
println!("{}: {:?}", sample.publisher, sample.payload);
}
Ok(()) }
In v0.1.0 Subscriber is read-only; sync-group publishing is
filed for v0.1.x.
Step 6 — Run the engine in-process
For tests or “talk to yourself” scenarios:
use ndn::prelude::*;
use ndn::{Consumer, InProcConnection};
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_face::local::InProcFace;
use ndn_transport::FaceId;
async fn embed() -> anyhow::Result<()> {
let (face, handle) = InProcFace::new(FaceId(1), 64);
let (_engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
.face(face)
.build()
.await?;
let mut consumer = Consumer::new(InProcConnection::from_handle(handle));
let _ = consumer.fetch("/alice/clock").await?;
Ok(()) }
The Develop tier deliberately keeps EngineBuilder outside the
umbrella — see Develop tier → embedded engine.
Where each piece lives
| Concern | Type | Crate |
|---|---|---|
| Identity, keys, signing | KeyChain, SigningInfo | ndn-security |
| Publishing | Producer, Responder | ndn-app |
| Fetching | Consumer | ndn-app |
| Subscribing | Subscriber | ndn-app |
| Validation | ValidationPolicy, HierarchicalPolicy, LvsTrust | ndn-security |
| Connection | IpcConnection, InProcConnection | ndn-app |
What to read next
- NDNCERT setup — automate cert issuance for apps instead of self-signed identities.
- Trust policies — write a custom policy.
- Develop tier — full API surface.
- Logging — observe what your app is doing inside the forwarder.
Writing a strategy
A forwarding strategy decides which face an Interest goes out on, when it retransmits, and how it reacts to Nacks and timeouts. This guide walks through writing a third-party strategy, registering it, and pinning it under a prefix.
The trait surface is Extend tier → Strategy;
the contract lives at crates/ndn-strategy/src/strategy.rs:56.
When to write a strategy
- Your protocol needs a forwarding rule the built-ins don’t cover (e.g. weighted round-robin, latency-aware, energy-aware).
- You’re researching strategy behaviour and want a measurement fixture.
- You’re building a sandboxed strategy (WASM); see
crates/ndn-wasm-strategy/.
For everything else the built-ins (BestRouteStrategy,
MulticastStrategy, ComposedStrategy) are usually correct.
The decision model
A Strategy does not send Interests or mutate forwarding tables.
It is a pure decision function: each method reads an immutable
a StrategyContext and returns one or more ForwardingAction values
(Forward, ForwardAfter, Nack, Suppress, Broadcast) that the
engine then executes. This keeps strategies side-effect-free and testable
in isolation. ForwardingAction and NackReason live in ndn-transport
(re-exported as ndn_engine::pipeline).
The skeleton
use std::sync::Arc;
use smallvec::{SmallVec, smallvec};
use ndn_packet::Name;
use ndn_transport::{ForwardingAction, NackReason};
use ndn_strategy::{ErasedStrategy, Strategy, StrategyContext, register_strategy};
type Actions = SmallVec<[ForwardingAction; 2]>;
// macro args: a `static` ident, short name, behaviour version, and a
// capture-free builder (the entry lives in a `static`).
register_strategy!(
RANDOM_REG, b"random", 1,
|| Arc::new(RandomNexthopStrategy::new()) as Arc<dyn ErasedStrategy>,
);
pub struct RandomNexthopStrategy {
name: Name, // /localhost/nfd/strategy/random
counter: std::sync::atomic::AtomicU64,
}
impl Strategy for RandomNexthopStrategy {
// `&Name`, not `&'static str` — a strategy name is an NDN name.
fn name(&self) -> &Name { &self.name }
// Synchronous fast path; `Some(_)` short-circuits the async hooks.
fn decide(&self, ctx: &StrategyContext) -> Option<Actions> {
let nexthops = ctx.fib_entry?.nexthops_excluding(ctx.in_face); // split horizon
if nexthops.is_empty() {
return Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]);
}
let i = self.counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed) as usize
% nexthops.len();
Some(smallvec![ForwardingAction::Forward(smallvec![nexthops[i].face_id])])
}
// Required. Implement the body when a decision must `.await`.
async fn after_receive_interest(&self, ctx: &StrategyContext<'_>) -> Actions {
self.decide(ctx).unwrap()
}
async fn after_receive_data(&self, _ctx: &StrategyContext<'_>) -> Actions {
SmallVec::new() // pipeline forwards Data to PIT in-records
}
}
register_strategy! collects entries at link time via linkme on native
targets; the engine reads the slice at startup. On wasm32 it defines a
pub static and external crates call ndn_strategy::registry::register
during engine setup.
Methods you can implement
| Method | When called | Default |
|---|---|---|
name(&self) -> &Name | Registration / strategy-choice lookup | Required. |
decide(&self, ctx) | Synchronous fast path, before the async hooks | None (fall through). |
after_receive_interest(&self, ctx) | Each Interest on the matching prefix | Required. |
after_receive_data(&self, ctx) | Each incoming Data (bookkeeping / egress) | No actions. |
on_interest_timeout(&self, ctx) | A pending Interest times out | Suppress. |
on_nack(&self, ctx, reason) | A Nack arrives | Suppress. |
schedule(&self, ctx, delay, callback) | Run code later | Cancellable ScheduledEvent. |
Every hook takes &self and &StrategyContext — never &mut, and the
Interest/Data are read off the context, not passed as arguments. Full
contracts are on the Strategy trait docstring.
Pinning under a prefix
A strategy doesn’t take over the whole forwarder; it owns a prefix.
Operators pin one via the strategy-choice management module — with
nfdc-style tooling:
nfdc strategy set /research /localhost/nfd/strategy/random
The strategy name resolves through the registry by its short name
(random), running the registered builder. Strategies coexist by
namespace; the engine uses the longest-prefix match for each Interest.
Using the StrategyContext
StrategyContext is an immutable view of engine state. A strategy
reads it and returns actions; it has no send_* methods. The fields:
ctx.fib_entry: Option<&FibEntry>— the matched FIB entry (None= no route). Pick nexthops via.nexthopsor.nexthops_excluding(ctx.in_face)for split horizon.ctx.in_face: FaceId— the face the packet arrived on.ctx.name: &Arc<Name>— the packet name.ctx.pit_token,ctx.measurements— PIT token, measurements table.ctx.signals,ctx.extensions— cross-layer inputs (see below).ctx.runtime— spawn/sleep handle backingschedule().
Forwarding is expressed by returning actions, not by calling the
context. The full surface is in crates/ndn-strategy/src/context.rs.
Cross-layer signals
ctx.signals exposes external/environmental inputs — radio link quality
(RSSI, SNR, congestion) and node state (GPS position, battery) — distinct
from ctx.measurements (derived from observed traffic):
// Prefer the nexthop with the strongest signal.
let best = nexthops.iter().copied().max_by_key(|n| {
ctx.signals.link(n.face_id).and_then(|l| l.rssi_dbm).unwrap_or(i8::MIN)
});
Signals are pushed by signal sources (a background driver feeds a
shared store), so reading them never blocks: register a source with
EngineBuilder::signal_source(...) from ndn-signal-sources (radio
metrics, GPS; pluggable hardware/mock backends). A measured strategy can
share one decision kernel across native and embedded sans-IO targets;
ndn-strategy-cclf is the worked example, with taxonomy and units in
docs/signals.md.
Testing
The in-process engine is the right fixture; EngineBuilder::strategy(...)
takes the strategy value directly and installs it as the default:
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.strategy(RandomNexthopStrategy::new())
.build()
.await?;
To pin under a specific prefix instead, install via the
strategy-choice module at runtime (see Pinning, above). Because every
hook is a pure function of &StrategyContext, you can also
unit-test decision logic by constructing a StrategyContext with a
hand-built FibEntry and asserting on the returned ForwardingActions —
the in-tree strategies’ #[cfg(test)] modules do exactly this.
examples/strategy-custom/
shows the end-to-end shape.
Built-in references
crates/ndn-strategy/src/best_route.rs— lowest-cost nexthop with split horizon;on_nackretries the next-best nexthop.crates/ndn-strategy/src/multicast.rs— fan out to every nexthop.crates/ndn-strategy/src/composed.rs— chain a strategy with filters.
A WASM strategy targets wasm32-unknown-unknown and loads at runtime via
ndn-wasm-strategy (same Strategy trait); see examples/wasm-strategy/.
Conventions
- A strategy
name()is itself an NDN name; the registry keys on the short component (random). - A strategy may hold internal state; the engine never serialises it.
- Strategies are
Send + Sync; hooks may be called concurrently.
See also
- Extend tier → Strategy — trait inventory.
- Interest and Data lifecycle.
- Management verbs —
strategy set/unset. - Examples:
strategy-custom,strategy-composed,context-enricher.
Implementing a face
A face is the NDN-layer link to a peer. It owns a Transport (raw
byte send/recv) and a LinkService (NDNLPv2 framing). This guide
walks through writing a new face transport and wiring it into the
engine.
The trait surface is Extend tier → Face; the catalog of shipped transports is in Face transports.
When to add a new face
- Your transport (a new wireless link, a new IPC channel, a new overlay) isn’t already covered by the in-tree faces.
- You need a face with non-standard framing (e.g. wrapping NDN in a different envelope).
For most NDN-over-Internet work, UDP or TCP already exists. For
browser deployments WebTransport and WebRTC ship; for in-process
work the InProc face ships.
The two halves
graph LR
subgraph Face
T[Transport: bytes in/out]
L[LinkService: NDNLPv2]
end
E[Engine] <--> L <--> T
T <--> P[Peer]
Transportmoves byte slices to/from a peer. It knows nothing of NDN packets — onlyBytes.LinkServiceframes byte sequences into NDN-layer packets: applies/strips NDNLPv2 headers, handles fragmentation, sets IncomingFaceId, marks congestion.Face = Transport + LinkServiceis the composition the engine sees.
Writing the transport
Transport is defined in crates/ndn-transport/src/transport.rs.
Send and receive return impl Future (write them as async fn);
the trait is not #[async_trait]. Both send_bytes and
recv_bytes take &self — send_bytes may be called concurrently
and must synchronise internally, while recv_bytes has a single
consumer (the face’s own reader task).
use ndn_transport::{Transport, FaceId, FaceKind, FaceError};
use bytes::Bytes;
pub struct MyTransport {
id: FaceId,
// ... your socket / handle / whatever
}
impl Transport for MyTransport {
fn id(&self) -> FaceId { self.id }
fn kind(&self) -> FaceKind { FaceKind::Udp /* the closest classification */ }
// Optional; default `None`. Used for management-protocol display.
fn remote_uri(&self) -> Option<String> { Some("my4://peer".into()) }
fn local_uri(&self) -> Option<String> { None }
async fn send_bytes(&self, wire: Bytes) -> Result<(), FaceError> {
// hand `wire` to your link
Ok(())
}
async fn recv_bytes(&self) -> Result<Bytes, FaceError> {
// pull the next frame; `Err(FaceError::Closed)` when the link ends
todo!()
}
}
To tear a transport down, drop it (and cancel the face’s tasks via
the CancellationToken you pass at wiring time) — there is no
shutdown method on the trait. FaceError has three variants:
Closed, Io, and Full.
send_bytes/recv_bytes are the only required I/O methods. The trait
also provides defaulted hooks you may override: send_batch (a
sendmmsg-style burst), recv_bytes_with_addr (multicast sender
address), send_mtu / set_send_mtu (link MTU and LP fragmentation
threshold), and set_persistency.
FaceKind vs FacePersistency
FaceKind classifies the link; the engine uses it to pick a
default LinkService and resolve face scope. Variants include Udp,
Tcp, Unix, App, Shm, Ethernet, Serial, Bluetooth, and
the browser kinds. FacePersistency is a separate axis —
OnDemand, Persistent, or Permanent — supplied when you wire the
face into the engine, not returned from the transport.
Picking a LinkService
| LinkService | When to use |
|---|---|
LpLinkService | Anything reachable over a lossy or fragmented link. Handles NDNLPv2 framing, fragmentation, IncomingFaceId. Default for wire kinds. |
PassthroughLinkService | Reliable, ordered, MTU-large links where you want bytes-in/bytes-out (e.g. shared memory, in-process). Default for local kinds. |
default_link_service_for_kind returns the right one for your
FaceKind. The convenience constructor Face::from_transport uses
it; Face::new lets you choose explicitly.
Assembling the face
Face::new(transport: Arc<dyn ErasedTransport>, link_service: Arc<dyn LinkService>) (crates/ndn-transport/src/face.rs:367) composes the
two halves. ErasedTransport is the object-safe view of Transport,
auto-implemented for every Transport, so any concrete transport
becomes Arc<dyn ErasedTransport> just by wrapping it in Arc:
use std::sync::Arc;
use ndn_transport::{Face, LpLinkService};
let face = Face::new(
Arc::new(MyTransport { /* ... */ }),
Arc::new(LpLinkService::default()),
);
// Or let the kind pick the LinkService for you:
let face = Face::from_transport(MyTransport { /* ... */ });
Wiring a face into the engine
There is no general FaceListener trait. Each transport owns its own
accept/dial loop and hands the resulting transport to the engine. The
engine accepts a bare Transport and composes the Face for you:
EngineBuilder::face(transport)— add a face at build time.engine.add_face(transport, cancel)— add one at runtime (cancelis atokio_util::sync::CancellationTokenthat stops the face’s tasks).add_face_with_persistencysets theFacePersistencyexplicitly.
For a listening transport, run an accept loop and call add_face per
connection. IpcListener in
crates/ndn-face/src/local/ipc.rs is the in-tree pattern: its
accept(face_id) returns one face per connection, which the owning
task then wires in.
use ndn_engine::EngineBuilder;
use tokio_util::sync::CancellationToken;
async fn run(transport: MyTransport) -> anyhow::Result<()> {
let (engine, _shutdown) = EngineBuilder::new(Default::default())
.face(transport) // one initial face
.build()
.await?;
// Later, accept loops add more faces at runtime:
// engine.add_face(next_transport, CancellationToken::new());
Ok(()) }
Wasm-compatibility check
The dashboard’s browser engine builds for wasm32-unknown-unknown.
If your face is intended to run in-browser, vet every dependency for
wasm support (no mio, no raw sockets, no tokio::net). The
ndn-face-webtransport-wasm and ndn-face-webrtc crates are the
in-tree reference for browser faces. Browser engines are assembled
with WasmEngineBuilder::add_face(Arc<Face>) — mirror your builder
method there if the face is meant to run in-browser.
Built-in references
| Face | Crate | Transport shape |
|---|---|---|
| UDP | crates/ndn-face/src/net/udp.rs | UDP socket per peer. |
| TCP | crates/ndn-face/src/net/tcp.rs | TCP connection. |
| IPC | crates/ndn-face/src/local/ipc.rs | Unix socket / named pipe. |
| InProc | crates/ndn-face/src/local/in_proc.rs | In-process channel. |
| Shm | crates/ndn-face-shm/src/spsc.rs | Shared-memory ring (spsc-shm). |
| Ether | crates/ndn-face/src/l2/ether.rs | Raw Ethernet. |
| Bluetooth | crates/ndn-face-bluetooth/src/lib.rs | BLE L2CAP. |
| Serial | crates/ndn-face-serial/src/lib.rs | UART. |
| WebTransport | crates/ndn-face-webtransport* | QUIC datagrams. |
| WebRTC | crates/ndn-face-webrtc/ | Datachannel. |
| SharedWorker | crates/ndn-face-shared-worker/ | Per-origin engine sharing. |
| BoltFFI | crates/ndn-boltffi/ | FFI bridge. |
See also
- Extend tier → Face — trait inventory.
- Face transports — catalog with feature flags and use cases.
crates/ndn-transport/—Transport,LinkService, andFacedefinitions.
In-network compute
In-network compute lets a forwarder answer an Interest with computed Data instead of stored Data. A named function runs at the node, and its result is injected back into the pipeline — so it caches in the Content Store and aggregates in the PIT exactly like fetched content.
The API lives in ndn-compute (crates/ndn-compute). It is
layered: reach for a richer entry point only when you need it.
| Tier | Entry point | Use |
|---|---|---|
| 0 | ComputeHandler | raw &Interest -> Data |
| 1 | ComputeService::function / ComputeClient::call | typed Rust args and results |
| 2 | ComputeService::executor_function / ComputeExecutor | sandboxed or native bytes→bytes kernels |
| 3 | ComputeService::job / ComputeClient::call_job | long-running jobs via thunks |
Attaching to an engine
ComputeService::attach allocates a synthetic compute face on a running
engine. Each registration wires a FIB route for the function prefix to
that face.
use ndn_compute::{ComputeService, ComputeClient};
let compute = ComputeService::attach(&engine);
// A typed function: result cached, identical calls coalesce.
compute.function("/calc/add", |(a, b): (i64, i64)| async move { Ok(a + b) });
// Consumer side:
let mut client = ComputeClient::new(consumer);
let sum: i64 = client.call("/calc/add", (2, 3)).await?;
assert_eq!(sum, 5);
Arguments are framed into the name components after the prefix and the
result into the Data content, using the ComputeArgs / ComputeValue
codec. Implement those traits for your own types when the built-in
scalar and byte framings are not enough.
Determinism: transparent vs. opaque
Whether a result may be cached is a property of the name, not a flag.
- A transparent function (
function) is fully determined by its invocation name. The result carries a freshness period, so it caches and concurrent identical calls collapse into one execution. - An opaque function (
opaque_function) may return a different value each call. The client appends an unpredictable nonce component and requests a fresh result, so calls never alias.
Opaque calls must distinguish themselves with a name component, never
with the ApplicationParameters digest — the forwarder strips that
digest before keying the PIT and Content Store (see Interest and Data
lifecycle), so two otherwise
identical opaque calls would collapse onto one entry.
compute.opaque_function("/rng/u64", |_: ()| async move { Ok(rand_u64()) });
let n: u64 = client.call_opaque("/rng/u64", ()).await?;
Sandboxed kernels
ComputeExecutor is the backend seam: a plain Fn(&[u8]) -> Result<Bytes>,
or a WasmExecutor loaded from a .wasm module, both plug in behind the
same trait. Build with the wasm-exec feature to pull in the wasmtime
backend.
use ndn_compute::WasmExecutor;
let kernel = WasmExecutor::from_file("thumbnail.wasm", /* fuel */ 5_000_000)?;
compute.executor_function("/img/thumbnail", kernel);
Each invocation runs in a fresh sandbox with a fuel budget; a runaway
guest traps instead of hanging the node. The guest exports compute
plus memory and imports three host functions (input_len,
read_input, write_output). An executor’s input rides one name
component, so executor-backed functions stay transparent.
Long-running jobs
When a computation cannot finish within roughly one round trip, register
it as a job. The invocation Interest returns a thunk — a small Data
object naming where the result will appear, plus a completion estimate —
and the computation runs in the background. The client polls the thunk
name until the result is ready.
use std::time::Duration;
compute.job("/render/scene", Duration::from_millis(500), |scene_id: u64| async move {
Ok(render(scene_id))
});
let pixels: Vec<u8> = client
.call_job("/render/scene", 7u64, Duration::from_millis(200), Duration::from_secs(30))
.await?;
call_job runs the whole handshake: fetch the invocation name, read the
thunk, then poll. Identical arguments map to the same thunk, so several
clients waiting on the same job share one execution.
Pulling parameters by reference
When an input is too large to put in the name, register the function with
function_ref and take the parameter’s name as the argument. The handler
receives a ComputeContext and fetches the value itself:
compute.function_ref("/sum", |param: String, ctx| async move {
let bytes = ctx.fetch(param.parse::<ndn_packet::Name>().unwrap()).await?;
Ok(bytes.iter().map(|&b| b as u64).sum::<u64>())
});
The referenced name must be routable to a producer — the consumer publishes the parameter under a name and the compute node fetches it.
When the consumer holds no routable name, use function_reflexive instead: the
invocation Interest carries an unpredictable reflexive name, and the node
Interests the parameters back along the reverse path the Interest arrived on, so
the consumer answers without registering any prefix. This relies on the
forwarders on the path supporting reflexive forwarding.
Testing
The in-process engine is the testing fixture — drive a ComputeClient
over an InProcFace handle and assert the result. The crate’s
tests/end_to_end.rs shows the pattern for every tier, including
caching, coalescing, the WASM round trip, and the job handshake.
See also
- Develop tier — the
Consumer/Producersurface the compute client builds on. - Interest and Data lifecycle — PIT aggregation and Content Store admission, which compute results ride on.
Network coding (FEC)
ndn-coding (crates/ndn-coding) adds end-to-end forward error
correction over named Data. A producer publishes K source + (N−K) parity
segments per generation; a consumer recovers the payload once any K of the
N segments arrive. Every coded segment is an ordinary named, signed Data
object, so caches, the PIT, and signature verification work unchanged —
the forwarder is never modified.
This is phase F1. In-network recoding (F2) and link-layer coding (F3) are out of scope; see “What’s not here”.
When it helps
FEC pays off when segments are lost, or when an object is fetched over multiple paths or to multiple receivers: a receiver that misses a source segment fetches a parity segment instead of waiting for a retransmission. On a single clean path it is pure overhead — you fetch K, they all arrive, and the parity is unused. Reach for it on lossy or multi-path links.
Producer
CodedProducer wraps an ndn-app Producer. Encode one object as a
generation and serve its N coded segments by name:
use ndn_coding::{CodedProducer, FecPolicy};
let policy = FecPolicy::systematic(8, 12).unwrap(); // K=8, N=12
let coded = CodedProducer::new(producer, policy);
coded.serve_object("/alice/clip/v=3".parse()?, payload, /*generation*/ 1).await?;
Each segment is served at <object>/<index> (index 0..N). Sources are
0..K, parity K..N.
Consumer
CodedFetcher pulls K-of-N segments and recovers the payload, requesting
parity when a segment is slow or lost:
use ndn_coding::CodedFetcher;
let fetcher = CodedFetcher::new();
let payload = fetcher.fetch(&consumer, "/alice/clip/v=3".parse()?, &policy).await?;
The fetcher sends a window of segment Interests, correlates each reply by
the FEC index in its metadata, and on a per-segment timeout pulls the next
(parity) index — stopping as soon as the decoder reaches rank K. Spreading
the segment Interests lets the forwarder’s strategy answer them over
different next-hops or caches; that is where the multi-path benefit comes
from. Tune the window and timeouts with FetchConfig.
Building blocks
If you need finer control, the core layer is usable directly and carries
no async runtime (it builds for wasm32 / embedded with
--no-default-features):
segment_payload(payload, &policy, generation_id)→ the N coded segment bodies.CodedAssembler::absorb_content(content)→Some(payload)once K independent segments have been absorbed.
use ndn_coding::{segment_payload, CodedAssembler};
let segments = segment_payload(&payload, &policy, 1)?;
let mut asm = CodedAssembler::new();
for seg in &segments {
if let Some(recovered) = asm.absorb_content(&seg.content)? {
// recovered == payload
break;
}
}
Policy and management
Which prefixes are coded — and with what K/N and role — is policy, set two ways:
- Config:
[[coding.policy]]blocks in the forwarder TOML. - Management:
/localhost/nfd/coding/{set,unset,list}, with a role ofproduced(content this node publishes) orconsumed(content it fetches).
The encode/decode mechanism itself is the library API above; you do not drive it over the management protocol.
Wire format
A segment’s Content is a FecMetadata TLV (generation, role, index, K,
N, field, optional padding length) followed by the row bytes. The arithmetic
is GF(2^8) with a Vandermonde generator, so any K of the N segments
suffice. The full layout is in docs/notes/coding-wire-spec-2026-05-22.md;
the TLV codes are provisional pending F2.
What’s not here
- F2 — in-network RLNC recoding. A recoded packet is a new linear
combination the producer never signed. Authenticating it is settled in
the F2 trust-model doctrine (verify recovered sources against a
producer-signed generation descriptor; optional delegated-recoder
signing inside a trust domain). Implemented behind the
f2-recodeandf2-recode-facefeatures — off by default, with provisional wire codes — so it does not affect the default build. - F3 — link-layer (inter-flow) coding. COPE-style XOR of frames for
different next-hops on a shared broadcast medium, recovered via overhearing.
The coding core ships behind the
f3-linkfeature (off by default); wiring it into a broadcast face is a face/link-driver concern.
NDNCERT setup
NDNCERT automates certificate issuance: an applicant requests a certificate under some name; a CA challenges them (token, e-mail, proof-of-possession); on success the CA signs and returns the cert. This guide covers running an NDNCERT CA, joining as a user, and the invite-token flow.
The implementation lives in crates/ndn-cert/. The CA binary
is binaries/tooling/enroll-ndncert/. Tokens are managed by
binaries/ndn-fwd-tokens/.
Run a CA
A CA is an InstallableProtocol that registers /ca/<ca-name>/CA
and serves the NDNCERT verbs.
cargo run -p enroll-ndncert -- ca \
--identity /lab/ca \
--listen /tmp/ndn-fwd.sock \
--policy issue-all-under /lab
What this does:
- Opens the default
KeyChain, ensures/lab/caexists with a self-signed cert. - Connects to the running forwarder at
/tmp/ndn-fwd.sock. - Registers the CA prefix and starts serving NEW / CHALLENGE / etc.
- Issues certs only for names under
/lab(the--policyflag).
The CA’s IssuancePolicy decides whether an authenticated
applicant gets a cert. See
crates/ndn-cert/src/issuance_policy.rs. The default is
AcceptAllIssuance — fine for lab deployments, not for production.
Join as a user
cargo run -p enroll-ndncert -- join \
--ca /lab/ca \
--identity /lab/alice \
--challenge token \
--token <TOKEN>
Walkthrough:
- Generate a key under
/lab/aliceif one doesn’t exist. - Send NEW to
/lab/ca/CA/NEWcarrying the key. - CA replies with a session ID and a challenge list.
- Send CHALLENGE with the token.
- CA verifies the token, signs the cert, returns it.
- Joiner imports the cert into its
KeyChainand persists the identity as aSafeBag.
The resulting SafeBag is spec-canonical and importable via
ndnsec import.
Invite tokens
Tokens turn the joiner-side step into a copy-paste flow. The
operator generates a token, hands it (QR or paste) to the joiner,
and the joiner runs enroll-ndncert join.
Generate a token:
cargo run -p ndn-fwd-tokens -- generate \
--ca /lab/ca \
--identity-prefix /lab/alice \
--ttl 7d \
--max-uses 1
Print as QR:
cargo run -p ndn-fwd-tokens -- generate --qr ...
Tokens are stored on the operator’s machine; revocation is single-use or TTL-bounded.
Challenge types
| Challenge | Joiner proves | Use |
|---|---|---|
token | Possession of a one-time secret | Operator-issued invites. |
proof-of-possession | Holds a private key for a previously-issued cert | Renewal. |
email | Receives a mail token | Public deployments (with an SMTP-aware adapter). |
acme-dns01 | Wins an ACME DNS-01 challenge | Domain-bound names; see testbed/tests/audit/acme_dns01.sh. |
The challenge surface is in crates/ndn-cert/src/challenge/.
Client-side enrollment uses ndn_identity::NdncertClient; standard token and
possession challenges have typed helpers, and named custom challenges can pass
their challenge identifier plus structured parameters through the same
NDNCERT NEW/CHALLENGE session.
Issuance policy
The post-challenge gate. An IssuancePolicy impl returns:
Accept(cert)— sign and return the cert.Reject(reason)— refuse, with a human-readable reason.Defer(callback)— out-of-band hold (admin approval, etc.).
Built-ins in crates/ndn-cert/src/issuance_policy.rs:
AcceptAllIssuance (default), NamespacePolicy,
ChallengeHandler (three-stage seam recorded in
project_f7_issuance_policy).
Configuration
CA-side ndn-fwd.toml section:
[ndncert.ca]
identity = "/lab/ca"
allow-namespace = ["/lab"]
challenge = ["token", "proof-of-possession"]
issuance-policy = "accept-all"
Run as a system service via the docker-compose stack — see Self-hosting.
Renewal
A holder of cert-N proves possession of its key to obtain
cert-N+1. The Producer can be configured to auto-renew before
expiry; see crates/ndn-cert/src/auto_renew.rs.
Challenge attestations
A CA can record how a challenge was satisfied directly in the
issued certificate. The record rides in the cert’s
SignatureInfo → AdditionalDescription (the non-critical
extension point used for cert metadata), so it is covered by the
CA’s signature and skipped cleanly by verifiers that don’t read it.
It is off by default — issued certs are byte-identical to the
plain flow until you opt in. CaConfig::new takes the CA prefix,
an info string, the default and maximum validity windows, the
challenge handlers, and the pre-challenge namespace policy;
emit_attestations(true) is a builder method on the result:
use std::time::Duration;
use ndn_cert::ca::CaConfig;
use ndn_cert::challenge::token::{TokenChallenge, TokenStore};
use ndn_cert::policy::HierarchicalPolicy;
fn build() -> CaConfig {
let prefix: ndn_packet::Name = "/lab/ca/CA".parse().unwrap();
let config = CaConfig::new(
prefix,
"lab CA".to_string(),
Duration::from_secs(30 * 24 * 3600), // default validity
Duration::from_secs(90 * 24 * 3600), // max validity
vec![Box::new(TokenChallenge::new(TokenStore::new()))],
Box::new(HierarchicalPolicy),
)
.emit_attestations(true);
config
}
CaConfig::new defaults the post-challenge IssuancePolicy to
AcceptAllIssuance; set config.issuance afterward to install a
stricter gate.
With it enabled, a token-challenge cert carries a single-leaf set
naming token. Composite challenges record one leaf per satisfied
sub with that sub’s own evidence: all-of carries every sub,
nofm carries the n that were met, and any-of carries the one
that won. A cross-process device-approval leaf additionally
carries the approving device’s identity and signature, verifiable
independently of the CA. The dashboard’s trust-path inspector
renders the leaves, and security/validate returns them under
challenge_attestations.
The wire shape and the per-handler evidence each leaf carries are
documented in docs/ndncert-attestations.md; see
crates/ndn-cert/src/attestation.rs for the types.
See also
- Identity and keys — KeyChain surface that NDNCERT writes into.
- Trust policies — what a consumer checks against the cert chain.
- Self-hosting — running CA and forwarder as containers.
crates/ndn-cert/— implementation and protocol shape.
Security pitfalls
The security model is sound, but a few defaults and habits let you wire an insecure path without noticing. Each pitfall below names the mistake and the checked alternative. See Trust, first for the model and Trust policies for the policy catalog.
Consuming data without verifying it
Consumer::fetch and Consumer::get return raw Data / bytes. They do not
validate the signature — an application that uses their result has accepted
unauthenticated data.
Use the checked surfaces instead:
fetch_verified(name, &validator)returnsSafeDataonly when the signature verifies and the trust schema accepts it. This is the default to reach for.fetch_unverified(name)returnsUnverified<Data>, which has no way out except.verify(&validator)(yieldingSafeData) or.trust_unchecked()(a deliberate, greppable bypass). You cannot use the data by accident.
A full worked example is the secure_fetch integration test in ndn-app.
Treating “signed” as “trusted”
A valid signature is necessary, not sufficient. Two specific traps:
DigestSha256is integrity, not identity. It proves the bytes match their own digest — anyone can compute one. It identifies no signer. Do not accept it as authentication for data that crosses a trust boundary.- A permissive schema accepts too much. An empty or
accept_allschema will pass packets you did not mean to trust. Validation is only as strong as the schema you give theValidator.
Storing an identity is not trusting it
Loading or creating a KeyChain for an identity does not make that
identity’s data trusted. Trust comes from pinning an anchor: add the
certificate to the Validator (add_trust_anchor, or KeyChain::trust_only),
then the schema decides what that anchor may sign. Forgetting the anchor makes
verification fail (or silently pass nothing), which is easy to misread as “it
works.”
Shipping test-only trust into production
These exist for tests and degraded/offline modes and must never reach production:
InsecureTrust— accepts without checking.accept_allschema /AcceptAllPolicy— skips schema enforcement.AcceptAllIssuance— a CA that issues to anyone.
They are loud in the API for a reason; confirm none survive into a deployed build.
Confusing adoption with enrollment
These are different acts with different outcomes:
- Adopting a trust context gives you the anchors to verify its data. It does not let you sign as a member.
- Enrolling gets you a certificate so you can be verified by others. It does not, by itself, give you anchors to verify them.
If verification of incoming data fails, check that you adopted (have the anchor), not only that you enrolled.
Running an open forwarder without flood protection
The Pending Interest Table is not hard-capped (the same as NFD): a fixed
ceiling would have to drop in-flight Interests. Reaping is time-based, so a
spoofed-name Interest flood from an untrusted face grows the PIT to roughly
rate × InterestLifetime and can exhaust memory.
If a forwarder accepts Interests from untrusted faces, enable the ndn-ratelimit
inbound hook for per-face / per-prefix admission control — it is the
PIT-exhaustion defence and is opt-in (off by default). The Dead Nonce List and
the signed-Interest replay guard are capacity-bounded, but they do not bound
the PIT itself. Treat a forwarder with no rate limiter on a public face the way
you would treat a bare NFD with no face/strategy limits.
“Verified” in the Content Store includes DigestSha256
The forwarder caches Data once ctx.verified is set, and a correct
DigestSha256 (a bare content hash, no signer) counts as verified for that
integrity gate — the same as NFD, which caches DigestSha256 Data. This is
integrity, not authenticity: it proves the bytes match the digest, not that
any identity vouched for them. The application layer still refuses to treat
DigestSha256 as authenticated (Unverified::verify rejects it by default). So
read “only verified Data is cached/forwarded” as “integrity-checked at the
engine; authenticity is the app’s verifying() decision.”
Browser trust uses the client wall-clock
On wasm32 the security timestamp source is web-time, i.e. Date.now() — a
wall-clock the user can change. Certificate validity-window checks and the
signed-Interest SignatureTime replay defence on an in-browser engine therefore
trust the client clock. The cryptographic chain still verifies; but a user who
moves their clock can affect validity-window and replay-window decisions. Don’t
rely on browser-side time for security-critical freshness; anchor those decisions
on a trusted forwarder where it matters.
See also
- Trust, first — why a valid signature is not trust.
- Trust policies — the schema/policy catalog.
- NDNCERT setup — issuance and challenge configuration.
Running the dashboard
ndn-dashboard is a Dioxus web application that talks to a running
forwarder over the TLV management protocol. It speaks no HTTP and
no IP — it speaks NDN to a face it opens against the forwarder.
The dashboard runs in three modes:
| Mode | Engine location | Use |
|---|---|---|
| Local | Embedded in the page (?engine=local) | Demo, offline, browser-only research. |
| Remote forwarder | An external ndn-fwd | Production operator UI. |
| Multi-forwarder | Switch between forwarders at runtime | Comparing live deployments. |
Run with an external forwarder
In one terminal:
cargo run -p ndn-fwd
In another:
cargo run -p ndn-dashboard
Open http://localhost:8080/. The dashboard opens a face against
the forwarder at /tmp/ndn-fwd.sock and starts streaming live
state.
You can point at a different forwarder via the URL:
http://localhost:8080/?forwarder=ws://my-host:9696
The selector accepts Unix sockets (when running natively), WebSocket URLs (for browser-to-forwarder), and WebTransport URLs.
Run with the in-page engine
Compile the dashboard with the browser-engine feature:
cargo run -p ndn-dashboard --features browser-engine
Then visit http://localhost:8080/?engine=local. The dashboard
instantiates a WasmEngineBuilder-built engine in the page; the
PIT/FIB/CS visible in the dashboard is the engine inside the tab.
Useful for offline demos and for understanding the forwarder’s behaviour without setting one up.
How it’s organized
The sidebar groups every panel under three top-level buckets, separating the appliance from your identity from what you publish:
- Engine — the forwarder you operate: Overview, Strategy, Coding, Rate Limit, Routing, Radio, Logs, Fleet, Tools.
- Identity — who you are: a Trust Context summary (the roots this node trusts, its CA, your identities with cert-expiry up front, and where your signing key lives on this machine — which custodian holds it and whether each signature needs an explicit confirmation), the detailed Security tabs behind it, and Session.
- Compose — what you publish: the prefixes a local producer or client has registered on the attached engine.
Each bucket collapses independently and shows a live count on its header — faces for Engine, distinct identities for Identity, published prefixes for Compose. For a single forwarder with one identity the grouping stays out of the way; it earns its keep once you attach more than one engine or hold more than one trust context.
The bar across the top is the Attach bar, and it has two independent axes: the Engine you operate (which forwarder, on which socket) and the identity you’re Acting as. They’re separate on purpose — you can browse one engine while signing as a given identity, and switch either without disturbing the other. With one engine and one identity the bar reads as a single line; the second axis appears only when there’s a choice to make.
Selecting a row opens a detail inspector on the right. A face shows its full detail — every counter, URI, scope, link type, MTU, link-service flag status (local fields, LP reliability, congestion marking), a throughput sparkline, and the routes that forward through it; a route shows its strategy, FIB nexthops, and RIB origins/flags/expiration. The inspector is also where you act: change a route’s strategy, add or remove a specific nexthop, or click a nexthop face to jump straight to that face’s detail (and back to a route from the face’s “Routes via this face” list). None of that needs to widen the table. The inspector closes with its ✕, and on narrow screens it becomes a bottom sheet so the list above it stays visible.
What the dashboard shows
- Faces — every face the forwarder knows, with kind, address, byte/packet counters, and lifetime events.
- FIB — routes by prefix, with strategy and nexthop list.
- PIT — pending Interests, in-records, out-records, scheduled retransmissions.
- Content Store — entries by name, size, freshness, hit count.
- Strategy table — per-prefix strategy choice with override controls.
- Routing — protocol status (
StaticProtocol,NlsrProtocol,DvProtocol) with typed state codes. - Discovery — neighbour table from the active discovery protocol.
- Identities —
KeyChainview: identities, keys, certs, policies.
Every panel reads from a single mgmt verb — see Management verbs.
Configuration
Browser → forwarder runs over WebSocket or WebTransport. The
forwarder’s ndn-fwd.toml must expose one of those listeners:
[face.ws]
listen = "0.0.0.0:9696"
[face.webtransport]
listen = "0.0.0.0:4443"
cert = "/etc/ndn-fwd/wt.pem"
key = "/etc/ndn-fwd/wt.key"
See Config reference for every knob.
Multi-forwarder runtime profile
The dashboard speaks one wire protocol against multiple forwarder implementations. The runtime profile selector decides which dialect of the mgmt protocol to emit (verbs are identical; some optional fields differ).
http://localhost:8080/?forwarder=ws://other-host:9696&profile=compat
Project memory project_dashboard_multi_forwarder records the
profile catalogue.
Security
The dashboard’s mgmt operations require authentication. The
forwarder’s [mgmt.auth] section sets the signing identity that
mgmt commands must carry; the dashboard signs its commands with a
key held by the operator. The flow is in
NDNCERT setup → “fleet operator key”.
For lab use the default config disables auth on /tmp/ndn-fwd.sock
because anything with filesystem access already owns the host. Do
not run an open mgmt socket on a network face.
See also
crates/ndn-dashboard/— implementation.- Management verbs — every verb the dashboard issues.
- ndn-fwd — forwarder ops.
- Config reference — listener configuration.
Remote-signer pairing
ndn-trust:// envelope, the
sign-request exchange) are specific to ndn-rs.
Remote-signer pairing lets a machine sign its NDN commands with a key that never leaves another device. A phone (or any key-holder) keeps the private key; an operator console receives individual signatures, on demand, only within a scope and time window the operator approved on the device.
This is useful when you operate a forwarder from a machine you don’t fully trust — a shared workstation, a kiosk — and don’t want a signing key to rest on it.
The two layers
Pairing keeps authority and custody separate:
- Authority is a name-bound, signed object — a capability granting the console permission to act in a namespace. It never names a host, device, or session.
- Custody is the device-bound key plus its local consent (a biometric, or a live grant). The key is never copied; only signatures cross the wire.
The flow
sequenceDiagram
participant C as Console
participant P as Phone (key-holder)
C->>P: Capability{Request} QR (scope, window)
P->>P: Operator consents → scoped grant
P->>C: Capability{Grant} (operator certificate)
C->>C: Provision remote signer
Note over C,P: later, per command
C->>P: WireSignRequest (the command's signed region)
P->>P: In scope? auto-sign : prompt / deny
P->>C: WireSignResponse (signature)
- Request. The console shows a
ndn-trust://capability/…request naming the scope it wants and for how long. - Consent. The phone scans it and the operator approves a scope on the device. The phone returns a grant carrying its operator certificate.
- Provision. The console imports the grant and wires a remote signer
addressed to the phone’s
…/signerresponder. - Sign. Each command’s signed region travels to the phone as a sign request; the phone signs it within the grant and returns the signature. The console assembles the signed command and sends it to the forwarder.
The sign exchange rides the same forwarder the console already manages — it is ordinary Interest/Data, not a side channel.
Scopes
A grant is bounded by an action class and a hard expiry — never “forever”:
| Class | Covers | Default |
|---|---|---|
Route | /localhost/nfd/rib/* | auto-sign in window |
Face | /localhost/nfd/faces/* | auto-sign in window |
Strategy | /localhost/nfd/strategy-choice/* | auto-sign in window |
Sensitive | …/security/*, …/ca/* | always prompt |
A request outside the granted class falls through to the device’s consent gate; sensitive actions always prompt, even under a broad grant.
Doing it from the dashboard
The dashboard’s Pairing screen (Identity bucket) drives the console side:
- Open Pairing. A request QR is shown for a scope (e.g.
/localhost/nfd/rib) and a window. - On the phone, scan the QR and approve the scope.
- Paste the grant the phone shows back into Complete pairing and select Pair this console.
- Issue a command (e.g. add a route). It is signed by the phone over NDN; no key lives on the console.
See Running the dashboard for launching the console against a forwarder.
Reaching the device
For the console’s sign request to reach the phone, the forwarder needs a route
to the phone’s …/signer prefix. A mobile node announces this itself with a
/localhop/nfd/rib/register command signed by its operator key; the gateway
installs the route for the requesting face after validating the command
against its localhop trust anchors (see
Trust policies). A node whose certificate is
not cached locally but is reachable over NDN is fetched and validated on
demand, so it need not have enrolled against the gateway directly.
What this gives you
- The signing key never leaves the device.
- Authorisation is bounded by an explicit scope and a hard expiry.
- Sensitive commands always require a fresh, on-device confirmation.
- Returned signatures verify against the operator certificate carried in the grant, so the console can detect a tampered or substituted reply.
Related
- Identity and keys — the custody model.
- NDNCERT setup — issuing the operator certificate a grant carries.
- Trust policies — localhop command validation and trust anchors.
- Security pitfalls — what pairing does and does not protect against.
Self-hosting
This guide deploys the full ndn-rs stack — forwarder, NDNCERT CA,
signaling relay, dashboard — to your own host with
docker-compose. The artefacts live at deploy/ in the repo.
Per project memory project_self_hosted_stack, the stack landed
2026-05-10 and is the canonical self-hosting recipe.
Prerequisites
- Docker + docker-compose (or compatible: Podman, Colima).
- A host you can reach over UDP/6363 (or whichever face port you expose) and HTTPS/443 (for the dashboard).
- A domain name (optional but convenient).
What ships in deploy/
| File | Purpose |
|---|---|
deploy/docker-compose.yml | Forwarder + CA + relay + dashboard. |
deploy/ndn-fwd.example.toml | Forwarder config; copy to ndn-fwd.toml. |
deploy/install.sh | Bootstrap: pulls images, seeds identities, brings up the stack. |
deploy/backup.sh | Snapshots PIB, KeyChain, and config. |
deploy/relay/Dockerfile | Signaling relay for WebRTC datachannel face. |
Bring up the stack
cd deploy/
cp examples/ndn-fwd.example.toml ndn-fwd.toml
# edit ndn-fwd.toml for your host:
# [mgmt] socket, [face.udp] listen, [face.ws] listen, [ndncert.ca] identity
./install.sh
install.sh runs (in order):
docker-compose pull— fetch images.- Generates a fresh CA identity if one does not exist under
data/ndn/. - Brings up
ndn-fwd,ndncert-ca,signaling-relay, andndn-dashboardservices. - Reports the URLs and the operator invite token.
Persistent state
Mounted under deploy/data/:
| Path | Contents |
|---|---|
data/ndn/pib.db | KeyChain identities + keys (back up regularly). |
data/ndn/certs/ | Issued certificates. |
data/ndn/strategy-choice.toml | Per-prefix strategy pins. |
data/ndn/routes.toml | Static route pins. |
backup.sh tars these into a single archive; restore by extracting
to the same paths before install.sh.
Image policy
Per project memory feedback_docker_rust_version, the Dockerfiles
use rust:slim (latest stable), not pinned rust:X.Y-slim. The
MSRV is whatever the latest stable provides.
Update
docker-compose pull
docker-compose up -d
The forwarder rolls cleanly: open faces drain, the engine restarts, faces re-establish. Active app sessions over IPC are interrupted and reconnect.
Exposing only what you need
The default compose file exposes:
- UDP/6363 — NDN-over-UDP face.
- TCP/6363 — NDN-over-TCP face.
- TCP/443 — dashboard + WebSocket face (behind reverse proxy).
- Internal-only — Unix socket for IPC; CA
/CAnamespace; relay.
If you don’t need the dashboard externally, drop the 443 export. If you don’t need cross-host faces, drop the 6363 export and run the stack as a local NDN substrate only.
Trust roots
The CA’s self-signed cert is the trust anchor for everything else.
On first run, install.sh writes the cert to
deploy/data/ndn/trust-root.cert. Distribute that cert to clients
that need to verify identities under this CA’s namespace.
Operator key
The operator key is the identity that signs mgmt commands. By
default install.sh creates /<your-ca>/operator and stores it
encrypted under data/ndn/operator.safebag. To use it from another
machine, copy the SafeBag and import:
ndn-ctl identity import /tmp/operator.safebag
See also
- Running the dashboard — dashboard modes.
- NDNCERT setup — CA configuration in depth.
- ndn-fwd — forwarder ops surface.
- Config reference — every
ndn-fwd.tomlknob.
ndn-fwd
ndn-fwd is the standalone forwarder binary. This page covers
day-2 operation: starting, inspecting state, applying changes,
shutting down cleanly.
For first-run setup see Running the forwarder. For containerised deployment see Self-hosting.
Lifecycle
ndn-fwd --config /etc/ndn-fwd/ndn-fwd.toml
ndn-fwd reads --config (or $NDN_CONFIG, or ./ndn-fwd.toml),
opens its listeners, restores strategy and route pins from
~/.ndn/strategy-choice.toml and ~/.ndn/routes.toml, and starts
serving.
Ctrl-C (or SIGTERM) triggers a clean shutdown: open faces drain,
the PIT is allowed to empty (up to a grace window), strategy and
route pins are persisted, the process exits 0.
The systemd unit at deploy/systemd/ndn-fwd.service (when using the
deploy stack) handles restart and journal capture.
Inspecting state
ndn-ctl is the operator CLI. It speaks the TLV management
protocol against the running forwarder.
| Verb | What it shows |
|---|---|
ndn-ctl status | Forwarder summary: NFD-compatible status, version, timestamps, uptime, table counts, and packet counters. |
ndn-ctl face list | Every face with kind, scope, persistency, byte/packet counters. |
ndn-ctl fib list | FIB entries by prefix with nexthops. |
ndn-ctl rib list | RIB entries (registered prefixes, origin, expiry). |
ndn-ctl strategy list | Per-prefix strategy choice. |
ndn-ctl cs info | Content store size, hits, misses, eviction count. |
ndn-ctl routing status | Active routing protocol and its typed status. |
ndn-ctl neighbor list | Discovery-protocol neighbour table. |
Full verb catalogue: Management verbs.
The graphical equivalent is ndn-dashboard; see
Running the dashboard.
Applying changes
Most knobs can be set at runtime via the mgmt protocol; restart is
only needed for [engine] and [face.*] listener changes.
| Change | Restart? |
|---|---|
| Add/remove a route | No — ndn-ctl route add / route remove. |
| Pin a strategy | No — ndn-ctl strategy set. |
| Adjust CS capacity | No (LRU resize) / Yes (variant change). |
| Add a new face listener | Yes — re-read [face.*]. |
| Change pipeline depth | Yes — [engine] knobs. |
| Change log filter | No — ndn-ctl log set <filter>. |
Management trust & bootstrap
Privileged management commands (adding a trust anchor, importing a key,
editing the trust schema or routes) are always signed and validated
by the forwarder — even when require_signed_commands = false. A fresh
forwarder with no configured trust anchor therefore refuses them with
403 … no validator is configured. This is intentional: forwarder trust
cannot be bootstrapped over the unauthenticated management channel
(anyone with socket access could otherwise install anchors).
Bootstrap trust out-of-band, once per forwarder:
# 1. Create the operator identity + a self-signed trust anchor.
ndn-sec --pib /etc/ndn/mgmt-pib keygen --anchor /op/alice
# 2. Point the forwarder at that anchor PIB and restart.
# [security.mgmt]
# require_signed_commands = true
# trust_anchor_pib = "/etc/ndn/mgmt-pib"
# 3. Export the operator identity to carry into the dashboard / another host.
ndn-sec --pib /etc/ndn/mgmt-pib export /op/alice -o op-alice.safebag
Now the operator can issue signed commands: ndn-ctl reads the PIB
directly, and the dashboard signs after you import op-alice.safebag
(its key is loaded into the dashboard keyring; commands validate against
the anchor configured in step 2). Read-only datasets (*/list,
status/general, cs/info) stay unsigned and work without any of this.
Mind the section.
trust_anchor_piblives under[security.mgmt]— it configures who may manage this forwarder. It is not the same as[security] identity, which is the forwarder’s own signing key (a separate role; you don’t need it just to accept operator commands). A misplaced key is now rejected at startup with anunknown fielderror rather than being silently ignored.
Faces and prefixes
Apps register their own prefixes via the IPC face. The operator adds static routes for cross-host paths:
ndn-ctl face create udp://10.0.0.1:6363
ndn-ctl route add /lab faceid:<id>
The face ID comes from face create’s output; it is monotonic and
never recycled (project memory feedback_face_id_no_recycle).
Per-face NDNLPv2 options are toggled with face update (the faces/update
flag bits — 0 = LocalFields, 1 = LpReliability, 2 = CongestionMarking):
ndn-ctl face update <id> --flags 0x2 # enable LpReliability on a lossy link
Bits outside --mask (default: the bits in --flags) are preserved. See
Per-face NDNLPv2 local fields.
Logs
ndn-fwd writes structured logs to stderr (or the journal under
systemd). Filter and target taxonomy: Logging.
State that survives a restart
| Path | Contents |
|---|---|
~/.ndn/pib.db | KeyChain identities, keys, certs. |
~/.ndn/strategy-choice.toml | Per-prefix strategy pins. |
~/.ndn/routes.toml | Static route pins added via ndn-ctl route add --persist. |
~/.ndn/measurements/ (optional) | Strategy measurement state. |
Move or back these up via deploy/backup.sh. Restore by copying
back before starting ndn-fwd.
See also
- Config reference — every
ndn-fwd.tomlknob. - Logging — log targets and filters.
- Performance — tuning under load.
- Management verbs — one row per
nfdc/ndn-ctlverb.
Config reference
ndn-fwd.toml configures the forwarder. The shipped example is
examples/ndn-fwd.example.toml — every option with
its default in a comment.
This page lists the option groups; consult examples/ndn-fwd.example.toml
for the full set with defaults.
File location
| Order | Path |
|---|---|
| 1 | --config <path> (highest precedence) |
| 2 | $NDN_CONFIG |
| 3 | ./ndn-fwd.toml |
| 4 | ~/.config/ndn-rs/ndn-fwd.toml |
| 5 | /etc/ndn-fwd/ndn-fwd.toml |
Absent file → defaults from ndn-config.
[engine] — pipeline tuning
| Key | Default | Use |
|---|---|---|
cs_capacity_mb | 64 | Content-store capacity (MiB). Deprecated alias for [cs] capacity_mb. |
pipeline_channel_cap | 4096 | Depth of the inter-task pipeline channel. Increase under sustained load. |
pipeline_threads | 0 | Pipeline parallelism. 0 = auto (CPU count). 1 = single-threaded inline. |
[cs] — content store
| Key | Default | Values |
|---|---|---|
variant | lru | lru, sharded-lru, null. |
capacity_mb | 64 | Capacity in MiB. |
shards | (auto) | Number of LRU shards (sharded-lru only). |
admission_policy | default | default (PIT-bound) or admit-all. |
unsolicited_policy | drop-all | Caching of Data with no pending PIT entry: drop-all, admit-local, admit-network, admit-all. Use admit-network on a broadcast/ad-hoc bearer. |
Persistent backends (feature-gated). For a CS that survives restarts, prefer
the SQLite backend in production: it implements true LRU eviction with
race-free byte/entry accounting. The fjall (LSM) backend currently evicts in NDN
key order rather than by recency, its accounting can drift under concurrent
insert, and eviction is O(N) per insert (a recency-indexed rewrite is tracked).
In-memory lru / sharded-lru remain the default and are unaffected.
[mgmt] — management plane
| Key | Default | Use |
|---|---|---|
socket | /tmp/ndn-fwd.sock | IPC socket the management protocol listens on. |
prefix | /localhost/ndn-fwd | Management protocol prefix. |
auth.require | false | Require signed mgmt commands on non-Unix faces. |
auth.signer | (none) | Identity name allowed to sign mgmt commands. |
[[face]] — face listeners
Repeated table. Order determines face index (used by [[route]]).
[[face]]
kind = "udp"
bind = "0.0.0.0:6363"
[[face]]
kind = "multicast"
group = "224.0.23.170"
port = 56363
[[face]]
kind = "webtransport"
listen = "0.0.0.0:4443"
cert = "/etc/ndn-fwd/wt.pem"
key = "/etc/ndn-fwd/wt.key"
Face kinds: udp, tcp, unix, multicast, ether,
webtransport, ws, webrtc, shm, serial, bluetooth.
See Face transports for the per-kind options.
[[route]] — static routes
[[route]]
prefix = "/lab"
face = 0 # face index from [[face]] order
cost = 100 # optional
[discovery] — neighbour discovery
| Key | Default | Use |
|---|---|---|
enabled | true | Run the autoconf discovery protocol. |
discovery_transport | udp | udp, ether, both. |
interval_ms | 5000 | HELLO interval. |
[demo_ca] — embedded NDNCERT CA
Off by default; enabled = true runs an in-process CA. See
NDNCERT setup.
| Key | Default | Use |
|---|---|---|
enabled | false | Run the embedded CA. |
prefix | /demo/CA | CA prefix; issues under the parent namespace. |
tokens | [] | Legacy: non-empty selects a token challenge (ignored when challenge is set). |
emit_attestations | false | Embed a challenge attestation in each issued cert. |
[[demo_ca.challenge]] — explicit challenge set (replaces the tokens
shortcut). Each entry has kind = "nop"|"token"|"pin"|"email":
[[demo_ca.challenge]]
kind = "email"
ttl_secs = 300
smtp = { host = "smtp.example.com", port = 587, from = "ca@example.com", starttls = true }
email delivers via SMTP only with --features smtp (lettre); otherwise (or
with smtp.log_only = true) the code is logged. [demo_ca.require_attestation]
(prefix/kind/require_signed) gates issuance on a challenge attestation.
possession/yubikey/device-approval are wired in code.
[log] — log filter
| Key | Default | Use |
|---|---|---|
filter | info | RUST_LOG-style filter (info,ndn_engine=debug). |
format | compact | compact, json, pretty. |
with_target | true | Include the tracing target. |
[rate_limit] — token-bucket rate limits
Per project memory project_dashboard_multi_forwarder.
See also
examples/ndn-fwd.example.toml— the source of truth; every key with defaults and inline comments.- ndn-fwd — operator workflows.
- Face transports — per-face configuration shapes.
Logging
ndn-rs uses the tracing crate for structured logging. Binaries
initialise a subscriber; libraries never do. This page covers the
filter syntax, the target taxonomy, and the two observability
options beyond stderr: tokio-console and OpenTelemetry.
Filter syntax
tracing-subscriber reads EnvFilter directives. Set via
RUST_LOG env var, the [log] section in ndn-fwd.toml, or
ndn-ctl log set at runtime.
RUST_LOG=info,ndn_engine=debug,ndn_face::net::udp=trace ndn-fwd
| Directive | Effect |
|---|---|
info | All targets at info or above. |
ndn_engine=debug | Engine internals at debug. |
ndn_face=trace | Every face at trace. |
[span_name=value]=debug | Records with span_name="value" at debug. |
off | Silence. |
Target taxonomy
The Instrument-tier module ndn_engine::observability::targets
catalogues the targets the engine and faces use. Stable across v0.1.x.
| Target | What it logs |
|---|---|
ndn_engine::pipeline | Pipeline stage entry/exit, packet dispatch. |
ndn_engine::pit | PIT inserts, satisfies, expiries. |
ndn_engine::fib | FIB inserts and lookups. |
ndn_engine::cs | CS hits, inserts, evictions. |
ndn_engine::strategy | Strategy decisions. |
ndn_engine::routing | Routing protocol events. |
ndn_engine::dispatch::outbound | Outbound packet dispatch. |
ndn_face::* | Per-face transport events. |
ndn_mgmt::* | Mgmt verb dispatch and replies. |
ndn_cert::* | NDNCERT protocol events. |
ndn_discovery::* | Discovery protocol events. |
The taxonomy is what the dashboard and OpenTelemetry bridge key off; custom Extend-tier code should use its own crate-rooted target to stay distinguishable.
tokio-console
tokio-console lets you inspect async task state in real time
(spawn count, busy/idle, polls, wakers).
RUSTFLAGS="--cfg tokio_unstable" cargo run -p ndn-fwd --features tokio-console
In another terminal:
cargo install tokio-console
tokio-console
The forwarder exposes the console listener on 127.0.0.1:6669 by
default. Useful for diagnosing stuck strategies and pipeline
backpressure. See crates/ndn-engine/Cargo.toml for the
feature gate.
OpenTelemetry (NDN-native)
binaries/tooling/ndn-otel-bridge/ is the OTel bridge. It speaks
NDN to a running forwarder, subscribes to the mgmt notification
streams, and emits OTLP/HTTP-protobuf spans to a collector.
cargo run -p ndn-otel-bridge -- \
--forwarder /tmp/ndn-fwd.sock \
--otlp http://otel-collector:4318
The bridge is NDN-native: it doesn’t read stderr or scrape /metrics.
Project memory feedback_dioxus_ndn_native records why.
TraceContext propagation
NDNLPv2 carries an optional TraceContext field (Phase-3 work). When
present, the strategy stamps it into outbound packets, enabling
cross-router span linkage. The wire inject/extract lives in
crates/ndn-engine/src/; downstream forwarders honour and
propagate it.
Recipes
| You want to… | Filter |
|---|---|
| See every PIT operation | ndn_engine::pit=trace |
| Trace one prefix through the pipeline | use a span tag: info,[forwarding{name=/lab/foo}]=debug |
| Watch face byte counters | ndn_engine::dispatch::outbound=debug |
| Watch strategy decisions | ndn_engine::strategy=debug |
| Watch routing updates | ndn_engine::routing=debug |
| Watch mgmt verbs in flight | ndn_mgmt=debug |
See also
crates/ndn-engine/src/observability/— target definitions.- Performance — when high log levels hurt throughput.
- ndn-fwd — runtime log filter via mgmt.
Performance
This page covers the knobs that move the forwarder’s throughput and latency under load, and the diagnostics to spot which one is biting you.
The pipeline shape (PacketContext by value, DashMap PIT,
bytes::Bytes zero-copy, no global lock on the hot path) is in
ARCHITECTURE.md. This page is operational, not architectural.
Throughput knobs
| Knob | Where | Effect |
|---|---|---|
[engine] pipeline_threads | ndn-fwd.toml | 0 (auto) / 1 (inline) / N. Inline is lowest-latency; N is highest-throughput. |
[engine] pipeline_channel_cap | ndn-fwd.toml | Inter-task channel depth. Raise if you see “channel full, packet dropped”. |
[cs] variant | ndn-fwd.toml | sharded-lru cuts lock contention at high concurrency. |
[cs] capacity_mb | ndn-fwd.toml | Bigger cache → more hits → less producer round-trip. |
| Log filter | RUST_LOG / [log] filter | debug/trace is expensive on hot paths. Run production at info. |
| Face MTU / fragmentation | [[face]] mtu | Larger MTU = fewer NDNLPv2 fragments. |
| UDP socket buffers | (automatic) | UDP faces request 4 MiB receive / 1 MiB send on bind, reducing drops under bursty load. Linux caps this at net.core.rmem_max (often ~208 KiB by default) — raise it with sysctl -w net.core.rmem_max=4194304 to realise the full request. |
Latency knobs
| Knob | Effect |
|---|---|
pipeline_threads = 1 | No inter-task hop on the hot path. |
tracing filter off on hot targets | Removes per-packet log work. |
[cs] admission_policy = "default" | Only PIT-satisfying Data goes into CS (cache is hot for actual traffic). |
| Strategy choice | BestRouteStrategy is single-nexthop; MulticastStrategy fans out (higher load). |
Diagnostic recipes
Where is time going?
Run with tokio-console:
RUSTFLAGS="--cfg tokio_unstable" cargo run -p ndn-fwd --features tokio-console
Look for tasks with high busy ratios and long poll times. Pipeline worker tasks are named; strategies expose their own spans.
Is the channel full?
Set RUST_LOG=ndn_engine::pipeline=warn and watch for
“channel full, packet dropped”. If frequent: raise
pipeline_channel_cap or add pipeline_threads.
Is the PIT churning?
ndn-ctl cs info shows PIT churn indirectly via expiry counts.
For raw counts, instrument-tier engine.pit().len() over time, or
read the mgmt notification stream pit-events.
Is the CS missing?
ndn-ctl cs info reports hits and misses. Hit ratio under
20% on a workload that ought to repeat → capacity too small or
admission policy excluding traffic.
Benchmarks
In-tree benchmarks live under binaries/tooling/ndn-bench/.
Wasm-target and native-target numbers are tracked internally;
rerun the benchmarks to refresh.
For your own workload, capture before/after with ndn-bench and
compare. Single numbers in isolation are not informative; the
shape of the curve under varying concurrency is.
Project-memory pointers
feedback_face_id_provenance— theface_idprovenance hazard cost a 170× throughput regression. Code touching face IDs in the hot path should re-read it.feedback_face_id_no_recycle— face IDs are monotonic; never recycle. Closes ABA hazards in caches keyed by face ID.feedback_pre_push_ci_checks— full CI run before push catches performance-regressing clippy lints.
See also
- ndn-fwd — runtime ops and the mgmt verbs that expose counters.
- Logging — observe what the pipeline is doing.
- Config reference — every knob with its default.
binaries/tooling/ndn-bench/— workload generator.
Face transports
Catalogue of face transports shipped in ndn-rs, each row pointing at the
implementation crate, the [[face]] kind value, and the typical use. For the
Face = Transport + LinkService shape see Extend tier → Face;
for writing a new transport see Implementing a face.
Catalogue
| Kind | Crate | [[face]] kind | Typical use |
|---|---|---|---|
| UDP | crates/ndn-face/src/net/udp.rs | udp | NDN-over-UDP across hosts. |
| TCP | crates/ndn-face/src/net/tcp.rs | tcp | NDN-over-TCP across hosts (firewall-friendlier). |
| Multicast UDP | crates/ndn-face/src/net/multicast.rs | multicast | Link-local neighbour discovery (group 224.0.23.170); auto-created per interface via [face_system.udp] auto_multicast. |
| Unix socket | crates/ndn-face/src/local/unix.rs | unix | App-to-forwarder IPC. |
| In-process | crates/ndn-face/src/local/in_proc.rs | (programmatic) | Embedded engine, tests. |
| Shared memory | crates/ndn-face-shm/src/spsc.rs | shm | High-throughput per-host IPC (feature spsc-shm). |
| Ethernet unicast | crates/ndn-face/src/l2/ether.rs | ether (TOML) or faces/create ether://[<mac>]/<iface> | Point-to-point link to a known peer MAC. EtherType 0x8624; requires CAP_NET_RAW/root. |
| Ethernet multicast | crates/ndn-face/src/l2/multicast_ether.rs | ether-multicast | Group 01:00:5e:00:17:aa; auto-created per interface via [face_system.ether] auto_multicast (Linux). |
| 802.11 monitor-mode | crates/ndn-face-monitor-wifi/ | wfb | Connectionless raw 802.11 injection (named-radio): MonitorWifiFace over a FrameIo backend (ndn-frame-io). |
| Bluetooth LE — central | crates/ndn-face-bluetooth/src/central/ | ble://<name-or-addr> (via faces/create) | Dial a peripheral as GATT client (Linux/macOS/Windows). |
| Bluetooth LE — peripheral | crates/ndn-face-bluetooth/src/lib.rs | [listeners.ble] | GATT server; advertises the NDN service (Linux/macOS). |
| Serial (UART) | crates/ndn-face-serial/src/lib.rs | serial | Embedded / microcontroller. |
| WebSocket | crates/ndn-face-websocket/ | ws | Browser-to-forwarder over WebSocket. |
| WebTransport | crates/ndn-face-webtransport/; wasm: crates/ndn-face-webtransport-wasm/ | [listeners.webtransport]; dial via [[face]] kind = "web-transport" or faces/create wts://… | Browser↔forwarder and forwarder↔forwarder (NAT-traversing) over QUIC datagrams; oversized packets are NDNLPv2-fragmented to maxDatagramSize (interoperates with NDNts H3Transport). |
| QUIC | crates/ndn-face-quic/ | [listeners.quic]; dial via [[face]] kind = "quic" or faces/create quic://… | Forwarder-to-forwarder backbone over raw QUIC (TLS 1.3, connection migration, 0-RTT); one reliable bidi stream of NDN TLV. No HTTP/3 layer (does not reach browsers). |
| WebRTC datachannel | crates/ndn-face-webrtc/ | webrtc | Browser ↔ browser, browser ↔ relay. |
| SharedWorker | crates/ndn-face-shared-worker/ | (programmatic) | Per-origin engine sharing across tabs. |
| Callback / Tap | crates/ndn-face/src/callback.rs | (Instrument tier) | Researcher: virtual face whose send-path is a closure. |
| BoltFFI | crates/ndn-boltffi/ | (programmatic) | FFI bridge for non-Rust hosts. |
Configuration shape
UDP unicast face listener:
[[face]]
kind = "udp"
bind = "0.0.0.0:6363"
# remote = "10.0.0.1:6363" # optional: point-to-point only
TLS faces — WebTransport (browser↔forwarder and NAT-traversing
forwarder↔forwarder over QUIC datagrams) and QUIC (native router-to-router
backbone, TLS 1.3 with connection migration) — share one listener cert_source
shape (self_signed_dev / pem / acme, resolved by ndn-acme) and one
dialer trust policy (cert_sha256 leaf-pin or webpki):
[listeners.webtransport] # or [listeners.quic]
enabled = true
listen = "0.0.0.0:4443"
cert_source = { type = "self_signed_dev", hostnames = ["localhost"] }
[[face]] # outbound dial
kind = "web-transport" # or "quic"
remote = "wts://peer.example:4443"
cert_sha256 = "ab12…64hex" # pin the peer's logged leaf hash; or webpki = true
A self-signed WebTransport cert is capped at 13 days (Chrome’s
serverCertificateHashes limit); a self-signed QUIC cert is long-lived (a
pinned dialer trusts the leaf hash, not the expiry). Listener cert status
(notAfter, renewal state) is readable via
/localhost/nfd/webtransport/cert-status. The WebTransport face interoperates
with ndnd’s HTTP/3 face (witness testbed/tests/audit/wt02_ndnd_interop.sh);
QUIC does not reach browsers — that is WebTransport’s role.
A shared-memory face (kind = "shm", path, capacity_mb) gives per-host IPC.
The full per-kind option set is in examples/ndn-fwd.example.toml.
Ethernet faces (EtherType 0x8624, CAP_NET_RAW/root) come in three forms — a
unicast link to a known peer MAC, a multicast group face, and per-interface
auto-creation:
[[face]] # unicast point-to-point
kind = "ether"
interface = "eth0"
peer-mac = "aa:bb:cc:dd:ee:ff"
[[face]] # multicast group face (Linux)
kind = "ether-multicast"
interface = "eth0"
[face_system.ether] # auto-create a multicast face per matching NIC
auto_multicast = true # + add/remove on hotplug with watch_interfaces
A unicast Ethernet face can also be created at runtime:
nfdc face create ether://[aa:bb:cc:dd:ee:ff]/eth0. The peer MAC is explicit —
neighbour discovery is a separate path. As with every wire face, NDNLPv2
framing and fragmentation are owned by the paired LpLinkService: the face is
payload-only and reports a 1500-byte send-MTU, so the link service fragments
once at the Ethernet boundary (it does not re-frame at the transport).
Bluetooth LE
BLE has two roles, modelled as distinct faces (central and peripheral are
distinct GATT roles, not a flag): a central dials a peripheral as GATT
client, a peripheral runs the GATT server. Both use the NDNts
web-bluetooth-transport GATT profile, so they interoperate with browser Web
Bluetooth and esp8266ndn. Requires ndn-fwd --features bluetooth. The wire
rules — UUIDs, the NDNLPv2-vs-NDNts framing split, and its automatic
disambiguation — are normative in
NDN over BLE — GATT profile.
A central is an outgoing face created at runtime via faces/create with a
ble:// URI (ble://ndn-rs-esp32c3, ble://AA:BB:CC:DD:EE:FF); ?framing=
and ?adapter= ride the query string, other per-face knobs go through the
faces module. A peripheral is a listener ([listeners.ble] → enabled = true) whose accept loop yields one face per connected central. Because the
peripheral carries controller state it gets a small management module
/localhost/nfd/ble/<verb>: list (status: advertising, adapter, central
count), start and stop (toggle advertising at runtime). The module returns
404 without --features bluetooth.
Programmatic faces (not in ndn-fwd.toml)
Some faces are constructed by application code rather than by listener config:
InProcFace— created in the same process, paired between two engines. The pattern is in Develop tier → embedded engine.CallbackFace/TapFace— Instrument tier. See Instrument tier.- SharedWorker face —
crates/ndn-face-shared-worker/. Mounted via the dashboard’s browser-engine profile.
Connectionless named-radio faces — extension
These are ndn-rs extensions with no NDN community-spec basis; the wire is ordinary NDNLPv2 over the radio’s native broadcast primitive.
A node — especially a phone — can carry NDN over connectionless broadcast
radios with no AP, no pairing, and no host addresses (the NDN name is the
only addressing). Each is link_type() == AdHoc with a small MTU, so the
LpLinkService fragments NDN across the radio’s frames automatically.
| Face | Crate | Radio primitive | Notes |
|---|---|---|---|
| Wi-Fi Aware (NAN) | crates/ndn-face-wifi-aware/ | NAN follow-up messages (255 B) | AP-less peer Wi-Fi; service pub/sub discovery; bulk over NDP (request_ndp → UdpFace). |
| BLE advertising | crates/ndn-face-ble-adv/ | BLE 5 extended advertisement (~245 B) | Near-universal; pairless broadcast; presence + small Interest/Data (bulk wants a connection face). |
| 802.11 monitor | crates/ndn-face-monitor-wifi/ | raw-injected 802.11 frames | Per-frame MCS, no association/ARQ. |
The physical radio sits behind a backend trait (NanBackend, AdvBackend)
with a hardware-free Loopback*Bus for tests, so the face is exercised without
hardware; a platform supplies the radio by implementing the trait (Android JNI,
BlueZ, an MCU). These faces are not mutually exclusive — a node holds a set
of them at once and a mesh strategy fans Interests over all (see the
Face system section of ARCHITECTURE.md in the repo root). On mobile they’re
attached at runtime via ndn-mobile (attach_wifi_aware / attach_ble) or the
ndn-boltffi FFI seam (NdnNanBackend / NdnBleBackend + the engine’s
*_deliver_* methods); nearby peers surface at the localhost dataset
/localhost/discovery/peers.
Tap-to-share over these faces (extension). To send a file to a tapped peer,
an app serves an offer board (NdnEngine/NdnClient::start_offer_board): a
certificate at /ndn/node/<id>/cert (the TOFU pin target) and a signed manifest
at /ndn/node/<id>/offers, both reached over the per-peer route with no hint.
Each offered file (add_offer) is a signed RDR object under the offerer’s own
identity, fetched by a peer with fetch_object_verified_hinted(name, "/ndn/node/<peerId>") — the ForwardingHint steers it to the peer, whose node
strips the hint via its NetworkRegionTable (declared at discovery start) and
serves it by name. See the Connectionless named-radio faces section of
ARCHITECTURE.md for the full flow.
Wi-Fi Direct bulk upgrade — extension
The connectionless radios above are great for discovery and small control but
are duty-cycled or small-MTU, so bulk transfer wants a real Wi-Fi link. The
pattern (the same one Quick Share / AirDrop use) is discover over Wi-Fi Aware
/ BLE, then upgrade to Wi-Fi Direct for bulk. Crucially this stays
data-centric: once a Wi-Fi P2P group forms it is just a multi-access IP subnet
(the group owner runs DHCP on 192.168.49.0/24), so the host-centric
group-owner election and DHCP live entirely below the Face. Above it there is
no host — only FaceKind::WifiDirect faces carrying names:
| Use | Face | Notes |
|---|---|---|
| 1:1 bulk | UdpFace::with_kind(WifiDirect) (unicast) | Full Wi-Fi MCS rate (5 GHz); LinkProfile cost 8 so cost-aware forwarding prefers it over NDP/LAN UDP (10), Wi-Fi Aware (20), BLE (50). |
| One-to-many | MulticastUdpFace::with_kind(WifiDirect) | Join 224.0.23.170:56363 on the group interface; MulticastStrategy + PIT aggregation give one-Interest-to-many, one-Data-to-many — the “seed a file to the room” path. Wi-Fi multicast is basic-rate/un-ACKed, so it is for coordination + Interests (and FEC-coded bulk), not raw 1:1 throughput. |
No new crate or Cargo feature — these are the existing UDP faces re-tagged to
the real radio. On mobile they attach via ndn-mobile
(attach_wifi_direct_face / attach_wifi_direct_multicast_face) or the
ndn-boltffi FFI (attachWifiDirectFace / attachWifiDirectMulticastFace),
mirroring the Wi-Fi Aware NDP bulk path (attach_ndp_face). The same shape maps
to a Wi-Fi SoftAP “portable router” (one node provides the medium; peers
join and multicast by name) and to Apple’s Wi-Fi Aware framework on iOS (the
same NAN standard behind NanBackend).
Link service per face
The default LinkService is LpLinkService (NDNLPv2 framing); the
high-throughput exceptions use PassthroughLinkService — InProcFace always,
and ShmFace by default (switch it to LpLinkService for cross-host wire
compatibility). See crates/ndn-transport/src/link_service.rs for the trait.
Per-face NDNLPv2 local fields
IncomingFaceId and NextHopFaceId are NDNLPv2 local fields, gated per-face by
the LocalFields flag (off by default). If IncomingFaceId always reads
0/absent, the face hasn’t enabled LocalFields — enable it first via
faces/update (Flags+Mask bit 0 = LocalFields, 1 = LpReliability, 2 =
CongestionMarking):
ndn-ctl faces update face-id 263 flags 0x1 mask 0x1 # LocalFields on
With it enabled: IncomingFaceId (0x032C) is attached to packets sent out that
face — the face the packet arrived on (or the reserved Content-Store id 254 on
a cache hit), readable from LpInfo (Consumer::fetch_with_meta); and
NextHopFaceId (0x0330) is honoured on Interests arriving on it, pinning an
Interest to an egress face past the FIB (Consumer::fetch_on). A face without
LocalFields ignores NextHopFaceId, so an untrusted peer cannot steer
forwarding. Local-scope only; the bundled routing protocol enables it on its
own faces.
Compile-time gates
| Feature | Carrier crate | Effect |
|---|---|---|
spsc-shm | ndn-face-shm | Enable the shared-memory transport. |
ether-linux | ndn-face | Linux raw-Ethernet face. |
ether-macos | ndn-face | macOS BPF face. |
ether-windows | ndn-face | Windows packet-driver face. |
bluetooth | ndn-face-bluetooth | BLE GATT central (ble://) + peripheral ([listeners.ble]). |
serial | ndn-face-serial | Serial face. |
wasm32 | ndn-face (auto) | WebTransport-wasm + WebRTC + SharedWorker. |
See also
- Implementing a face — author guide.
- Config reference — every
[[face]]listener key. crates/ndn-transport/— the trait surface.
NDN over Bluetooth LE — GATT profile
Status: Draft / experimental. This page documents the GATT profile and framing rules ndn-rs implements, written normatively so other implementations can interoperate. The ndn-rs implementation has not been validated on hardware against a second implementation; treat the conformance language as the intended contract, not a proven one.
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119.
1. Scope
This profile carries NDN packets (Interest, Data, Nack) between two peers over a
single BLE GATT connection. One peer is the GATT server (NDN peripheral),
the other the GATT client (NDN central). It reuses the service and
characteristic UUIDs established by NDNts @ndn/web-bluetooth-transport and
esp8266ndn, and adds an optional capability characteristic and a framing
self-negotiation procedure so the two historical framings interoperate.
2. GATT service
A conformant peripheral MUST expose one primary GATT service:
| Element | UUID | Properties |
|---|---|---|
| NDN service | 099577e3-0788-412a-8824-395084d97391 | primary |
| CS — client→server | cc5abb89-a541-46d8-a351-2f95a6a81f49 | Write Without Response |
| SC — server→client | 972f9527-0d83-4261-b95d-b1b2fc73bde4 | Notify |
| Framing capability (optional) | 099577e3-0788-412a-8824-395084d97392 | Read |
- The central writes outbound packets to CS (Write Without Response).
- The central subscribes to SC notifications for inbound packets.
- The peripheral MUST advertise the service UUID so a central can filter on it.
The central role is reachable from a Web Bluetooth browser; the peripheral role is not (the Web Bluetooth API exposes no GATT-server surface).
3. Framings
Each ATT write/notification carries one frame. Two framings are defined; they share the same UUIDs and are distinguished by the first octet of the frame.
3.1 NDNLPv2 (framing id 0x01)
Each frame is exactly one NDNLPv2 LpPacket (TLV-TYPE 0x64 = 100), as defined
by the NDN Link Protocol v2. A frame therefore begins with the octet 0x64.
Packets larger than the link MTU are carried as multiple LpPacket fragments
with Sequence/FragIndex/FragCount; reassembly is the NDNLPv2 reassembly
buffer’s responsibility. This framing carries the full NDNLPv2 feature set
(Nack, PIT token, congestion marks, per-hop reliability).
3.2 NDNts 1-byte header (framing id 0x02)
A lightweight fragmentation used by NDNts @ndn/web-bluetooth-transport and
esp8266ndn:
- A packet that fits in one frame is sent bare, with no header octet —
i.e. the frame begins with the packet’s own TLV-TYPE (
0x05Interest,0x06Data, …). - A fragmented packet prepends a 1-octet header to each fragment:
- first fragment:
0x80 | (seq & 0x7F) - continuation fragments:
seq & 0x7F seqstarts at 0 and increments modulo 128 per fragment.
- first fragment:
- The receiver concatenates fragment payloads starting at a first-fragment header until the buffered bytes form a complete TLV (TLV-TYPE + TLV-LENGTH + value). This framing carries no NDNLPv2 fields.
3.3 First-octet disambiguation
A receiver MAY infer the framing of a connection from the first frame:
| First octet | Framing |
|---|---|
0x64 | NDNLPv2 |
0x80–0xFF | NDNts (first fragment) |
any other (0x05, 0x06, …) | NDNts (bare packet) |
This is unambiguous for the first frame of a connection. The value 0x64 can
recur as an NDNts continuation header (seq == 100), but only mid-stream after
a first fragment, by which point the framing is already latched.
4. Fragmentation and MTU
The usable frame payload is the negotiated ATT_MTU minus 3 (ATT opcode + handle). The default 23-octet ATT_MTU is too small for practical NDN packets; implementations SHOULD negotiate an ATT_MTU of at least 185. A sender MUST NOT emit a frame larger than the usable payload; it MUST fragment per the framing in use (§3). Where the negotiated MTU is not exposed to the sender (e.g. Web Bluetooth), the sender SHOULD fragment at a conservative payload of 244 octets.
5. Framing negotiation
The two framings are not wire-compatible. Peers select one per connection without manual configuration as follows.
5.1 Peripheral (responder)
A peripheral MUST be able to receive either framing. On the first inbound CS write of a connection it SHOULD detect the framing per §3.3, latch it for that central, and use the same framing for all SC notifications to that central (“mirror”). A peripheral serving multiple centrals MUST track framing per central.
5.2 Central (initiator)
A central speaks first and therefore cannot detect the peer’s framing from traffic. It SHOULD determine the framing as follows:
- If the application forced a framing, use it.
- Otherwise, after connecting, attempt to read the framing capability
characteristic (§2):
- If present, interpret its first octet as a framing id (§3) and use that framing.
- If absent (characteristic not found), the peer is a legacy peer that
predates this characteristic; the central MUST assume NDNts (
0x02). - If present but unreadable, the central SHOULD assume NDNLPv2 (
0x01).
Because only peers implementing this profile expose the capability characteristic, its absence is a reliable signal for the NDNts framing across the deployed ecosystem; no probing or dual transmission is required.
5.3 Framing capability characteristic
A peer that prefers NDNLPv2 SHOULD expose the framing capability characteristic
as a read-only characteristic whose value is a single octet equal to its
preferred framing id (0x01 for NDNLPv2). A static cached value is sufficient;
no dynamic read handler is required. Peers implementing only the NDNts framing
MAY omit it.
6. Conformance
- A peripheral MUST expose the service with the CS and SC characteristics (§2), MUST accept either framing (§5.1), and SHOULD expose the framing capability characteristic when it prefers NDNLPv2.
- A central MUST write to CS and subscribe to SC, and SHOULD follow §5.2 to select a framing.
- An implementation supporting only the NDNts framing remains conformant for interop with NDNts/esp8266ndn peers; it simply omits the capability characteristic and the NDNLPv2 framing.
7. Interoperability notes
- The service/characteristic UUIDs and the NDNts 1-byte framing are taken from
NDNts
@ndn/web-bluetooth-transportandesp8266ndn’sBleServerTransport; this profile is backward compatible with them (they appear as NDNts-framing peers that omit the capability characteristic). - The framing capability characteristic and the NDNLPv2 framing on this profile are ndn-rs additions proposed here for standardization.
8. ndn-rs implementation
| Element | Location |
|---|---|
| Profile constants, listener, per-central faces | crates/ndn-face-bluetooth/src/ |
| Framing codec + detection | crates/ndn-face-bluetooth/src/framing.rs |
Native central (Linux bluer, macOS/Windows btleplug) | …/bluetooth/central/ |
| Browser central (Web Bluetooth) | crates/ndn-face-webble/ |
See Face transports → Bluetooth LE for the
operator-facing ble:// / [listeners.ble] surface.
Management verbs
ndn-rs’s management plane is a set of MgmtModule impls, each
owning the verbs under one module name. Commands and datasets ride
TLV Interests under /localhost/<forwarder>/<module>/<verb>.
For the module-author surface see Extend tier → MgmtModule.
For runtime use see ndn-ctl in ndn-fwd.
Module catalogue
One module per row; one file per module under
crates/ndn-mgmt/src/modules/.
| Module | File | What it owns |
|---|---|---|
forwarder-status | modules/status.rs | Forwarder-wide info dataset (uptime, version, counters). |
faces | modules/faces.rs | Face create/destroy/list and per-face counters. |
fib | modules/fib.rs | FIB list, add-nexthop, remove-nexthop. |
rib | modules/rib.rs | RIB register, unregister, list. |
strategy-choice | modules/strategy.rs | Strategy set, unset, list. |
cs | modules/cs.rs | Content store info, config, erase. |
routing | modules/routing.rs | Routing-protocol typed status (per RoutingProtocolStatus). |
discovery | modules/discovery.rs | Discovery-protocol neighbours. |
measurements | modules/measurements.rs | Strategy measurement state (Instrument-leaning). |
coding | modules/coding.rs | Per-prefix coding adapters. |
rate-limit | modules/rate_limit.rs | Token-bucket rate-limit state. |
security | modules/security.rs | KeyChain operations. |
log | modules/log.rs | Runtime log-filter set/get. |
neighbors | modules/neighbors.rs | Neighbour table (joins discovery info with face state). |
service | modules/service.rs | Service lifecycle (drain, shutdown). |
The catalogue tracks crates/ndn-mgmt/src/modules/mod.rs —
add a module there, it appears here.
Verb shape
Every command verb takes the same shape:
/localhost/<forwarder>/<module>/<verb>/<params-tlv>/<signed-Interest-fields>
Reply: signed Data carrying a ControlResponse (status code +
text + optional dataset).
Datasets (read-only) use:
/localhost/<forwarder>/<module>/list
reassembled segmented Data.
Selected verbs
faces:
| Verb | Effect |
|---|---|
create | Create a face with given URI. Returns face-id. |
destroy | Destroy a face by face-id. |
update | Adjust persistency / congestion-policy on an existing face. |
list | Stream FaceStatus dataset. |
events | Notification stream of FaceEvent (open / close / counters). |
fib:
| Verb | Effect |
|---|---|
add-nexthop | Add a face as a nexthop for a prefix; with cost. |
remove-nexthop | Drop a nexthop. |
list | Stream FibEntry dataset. |
rib:
| Verb | Effect |
|---|---|
register | Register a prefix (with origin, cost, expiry). |
unregister | Drop a registration. |
list | Stream RibEntry dataset. |
events | Notification stream of RouteEvent. |
strategy-choice:
| Verb | Effect |
|---|---|
set | Pin a strategy under a prefix. |
unset | Revert to inherited strategy. |
list | Stream StrategyChoice dataset. |
events | Notification stream of StrategyEvent. |
cs:
| Verb | Effect |
|---|---|
info | Return capacity / hit / miss / size. |
config | Adjust admission policy at runtime. |
erase | Erase entries matching a prefix. |
reflexive (ndn-rs extension; reflexive-forwarding control):
| Verb | Effect |
|---|---|
enable / disable | Toggle installing new reverse routes (disable drains gracefully). |
config | Set the per-face cap and route-lifetime ceiling. |
flush | Drop all reverse routes immediately. |
info | Settings, live route count, and counters. |
The full per-module verb list is in each module file’s docstring.
Authentication
The forwarder’s [mgmt.auth] section decides whether commands need
a signed Interest. Local Unix-socket commands are unauthenticated
by default (filesystem permissions are the security boundary).
Network-face mgmt commands are signed; the signer identity is
configured via [mgmt.auth] signer = "/your/operator".
Notifications
Notification verbs (events) return an SVS-style stream of typed
events. The Develop-tier consumer is Subscriber:
use ndn::Subscriber;
async fn run() -> anyhow::Result<()> {
let mut sub = Subscriber::connect(
"/tmp/ndn-fwd.sock",
"/localhost/ndn-fwd/faces/events",
).await?;
while let Some(sample) = sub.recv().await {
println!("face event: {:?}", sample.payload);
}
Ok(()) }
See also
- Extend tier → MgmtModule.
- ndn-fwd —
ndn-ctloperator CLI. - Running the dashboard — graphical mgmt consumer.
crates/ndn-mgmt/src/modules/— implementation.
Trust policies
The trust policy decides whether a signing key may sign a given name. ndn-rs splits this across two traits:
TrustPolicy— an operator handle: whichSignerto use on egress (signer), whichValidatorto apply on ingress (validator).ValidationPolicy— the composable acceptance check: given aData, its key locator, and a chain-walk depth, return aPolicyVerdict(Allow,NeedCert, orDeny).
Implementations live in crates/ndn-security/src/. See
Extend tier and
Identity and keys.
Built-in policies
| Policy | Source | Accepts | Use |
|---|---|---|---|
InsecureTrust | trust.rs | DigestSha256; validator accepts anything. | Tests; never production. |
StaticTrust | trust.rs | Fixed signer + hierarchical validator. | Closed groups, known signers. |
LvsTrust | trust.rs + lvs.rs | Light Versatile Schema rules. | Pattern-based deployments. |
HierarchicalPolicy | validation_policy.rs | Signing key’s identity is a prefix of the data name. | Standard NDN trust shape. |
AcceptAllPolicy | validation_policy.rs | Skip validation entirely. | Migration / degraded mode. |
ChainedPolicy | validation_policy.rs | All members Allow (first Deny/NeedCert short-circuits). | Composition. |
Hierarchical example
Pin a Validator to turn a Consumer into a VerifiedConsumer, whose
fetch returns SafeData (a failed chain walk errors instead):
use ndn::prelude::*;
use ndn_security::{TrustSchema, Validator};
async fn run() -> anyhow::Result<()> {
let validator = Validator::new(TrustSchema::hierarchical());
let mut consumer = Consumer::connect("/tmp/ndn-fwd.sock").await?
.verifying(validator);
let _safe = consumer.fetch("/lab/alice/notes/2026-05-20").await?; // SafeData
Ok(()) }
Static example
StaticTrust is a TrustPolicy with a fixed signer and a hierarchical
validator. Build from a KeyChain (carries its anchors) or a bare signer
via StaticTrust::new(..):
use std::sync::Arc;
use ndn::{KeyChain, StaticTrust, TrustPolicy};
fn build() -> anyhow::Result<StaticTrust> {
let keychain = Arc::new(KeyChain::ephemeral("/lab/alice")?);
let trust = StaticTrust::from_keychain(keychain)?;
Ok(trust) }
LVS example
A textual Light Versatile Schema is compiled to LVS binary form by an
external compiler; LvsModel::decode reads it and LvsTrust wraps it
with a signer (also LvsTrust::from_keychain(model, keychain)):
use std::sync::Arc;
use ndn::LvsTrust;
use ndn_security::LvsModel;
fn build() -> anyhow::Result<LvsTrust> {
let bytes = std::fs::read("trust.tlv")?; // compiled LVS model
let model = Arc::new(LvsModel::decode(&bytes)?);
let trust = LvsTrust::new(model, None); // None ⇒ DigestSha256 egress
Ok(trust) }
Writing a custom policy
Custom acceptance logic implements ValidationPolicy::check, returning a
boxed future of PolicyVerdict (Allow / Deny(TrustError) / NeedCert(Name)):
use std::{future::Future, pin::Pin, sync::Arc};
use ndn_packet::{Data, Name};
use ndn_security::{ChainedPolicy, HierarchicalPolicy, PolicyVerdict, TrustError, ValidationPolicy};
pub struct MyPolicy;
impl ValidationPolicy for MyPolicy {
fn check<'a>(
&'a self,
data: &'a Data,
key_locator: &'a Name,
_depth: usize,
) -> Pin<Box<dyn Future<Output = PolicyVerdict> + Send + 'a>> {
Box::pin(async move {
let ok = data.name.has_prefix(&"/lab".parse().unwrap())
&& key_locator.has_prefix(&"/lab/ca".parse().unwrap());
if ok {
PolicyVerdict::Allow
} else {
PolicyVerdict::Deny(TrustError::SchemaMismatch)
}
})
}
}
// ChainedPolicy: members run in order; first Deny/NeedCert short-circuits.
let mut chain = ChainedPolicy::new(vec![Arc::new(MyPolicy) as Arc<dyn ValidationPolicy>]);
chain.push(Arc::new(HierarchicalPolicy::new()));
Where the policy runs
| Location | What is checked |
|---|---|
KeyChain::sign / Producer::publish_object | “May this key sign this name?” (pre-sign guard) |
VerifiedConsumer::fetch(name) returning SafeData | “Is this signature trustworthy for this name?” (validator) |
Subscriber::recv | Validator (per SubscriberConfig). |
Trust contexts and onboarding
A node adopts a set of trust contexts (a Keyring). Each
SignedTrustContext binds a namespace to its own anchors and schema, and
the validator routes each packet to its namespace’s context — no cross-talk.
use std::sync::Arc;
use ndn_security::{Certificate, SignedTrustContext, TrustSchema, Validator};
fn wire(home_anchor: Certificate) -> anyhow::Result<()> {
let validator = Validator::new(TrustSchema::hierarchical());
let home = Arc::new(SignedTrustContext::hierarchical("/home/bob".parse()?));
home.add_anchor(home_anchor);
validator.adopt_context(home); // data under /home/bob validates against it
Ok(()) }
Onboarding (ndn-cert) splits into adopt-to-verify (anchor + schema to
verify data; free, pinned by a BootstrapTicket fingerprint) and
enroll-to-be-verified (a cert to produce, gated by NDNCERT challenges;
see NDNCERT setup).
See also
- Identity and keys — KeyChain and SigningInfo.
- NDNCERT setup — issuing the certs these policies validate.
- Extend tier — implementing custom policies.
crates/ndn-security/— the implementation.
Dashboard extensions
The dashboard surfaces optional ndn-rs features as scoped, capability-gated panels, kept separate from core engine state so the main model does not grow with each one. A forwarder that does not implement the matching management dataset shows the panel as unsupported.
Network coding extension
The dashboard treats network coding as an optional ndn-rs extension surface, displaying prefix, role, generation parameters, and capability state. Non-ndn-rs forwarders show it as unsupported unless they implement the matching management dataset. See Network coding (FEC).
Rate limit
Rate-limit cells are shown as scoped operational state: management-command limits, face limits, and prefix limits. Compatible forwarders may expose read-only cells; ndn-rs profiles can later enable mutation adapters behind the same capability gate.
Compute extension
Compute services appear as service names plus diagnostics. The extension registry keeps compute separate from core engine state so service-specific tools can be added without growing the main dashboard model. See In-network compute.
Spec compliance
ndn-rs’s compliance with the NDN Packet Specification and the
NDNCERT specification is tracked by live witness scripts under
testbed/tests/audit/ that exit non-zero when a compliance claim
regresses. This page summarises which areas are covered.
The live source of truth is testbed/EXPECTED_FAILURES.md. This page
is a reader-facing map to the witnesses, not a substitute for the
tracker. A feature is release-ready only when the corresponding
witness passes or the tracker explains why the remaining work is out
of scope.
Coverage areas
| Area | Spec source | Audit section | Witness prefix |
|---|---|---|---|
| Name and component types | Packet spec §2 (Name) | A.01 – A.04 | a*_blake3_*, a19_a20_uri_* |
| Interest TLV | Packet spec §3 | A.05 – A.08 | a05_a18_tlv_strictness |
| Data TLV | Packet spec §4 | A.09 – A.16 | a10_databuilder_build_sig |
| Signature types | Packet spec §5 | A.16, A.17, BLAKE3 | a16_signature_value_length, a17_blake3_registered |
| KeyLocator rules | Packet spec §5.5 | A.15 | a15_keylocator_rules |
| LP TLV (NDNLPv2) | LP spec | A.11, A.12 | a11_nack_reason_documented, a12_nack_lp_only |
| LP IncomingFaceId / LocalFields | NDNLPv2 §local fields | X.02 | x02_incoming_face_id_local_fields |
| Nonce length | Packet spec §3 | A.13 | a13_nonce_length_rejected |
| FinalBlockId / UriComponent | Naming convention | A.19, A.20 | a19_a20_uri_finalblockid |
| Signed Interests | Packet spec §3 (signed) | A.09 | a09_signed_interest_verify |
| Persistent-state Interest | Persistent Interest design | (interop) | persistent_interest_* |
| NDNCERT issued cert | NDNCERT spec | C.07, C.08, C.18, N.13 | acme_dns01.sh, cert_* |
| Architectural cleanup | Phase 2 ARCH-1..20 | (ARCH-N) | arch* (per-item witnesses) |
| Tiered API surface | Phase 3 §3 | tier docs | phase3_* |
The testbed/tests/audit/*.sh scripts are the runnable witnesses;
each exits non-zero when the claim it tracks regresses.
As of the 2026-05-28 release-readiness pass, the tracker-driven audit
harness runs every script named by testbed/EXPECTED_FAILURES.md and
reports 54 PASS / 0 FAIL / 0 SKIP. The report for that local pass was
captured in
testbed/tests/audit/transcripts/release_audit_run_all_after.txt; the
important release signal is zero divergences from the tracker.
Grep-only checks are not release-quality proof for protocol behavior. They
may guard documentation wording, removed APIs, or source inventory, but packet,
security, forwarding, and management claims need a RUST-UNIT, RUST-INTEG,
INTEROP-SCRIPT, or WIRE-CAPTURE witness before this page should describe
them as complete.
Reading the witnesses
Each witness is a shell script with exit-code semantics:
0— finding passes / claim holds.1— finding fails / claim regressed; the script prints the exact diagnostic.2— live interop precondition is missing; Rust/local witnesses may have passed, but the Docker leg did not run.
# Run a single witness:
bash testbed/tests/audit/a17_blake3_registered.sh ; echo exit=$?
# Run every release-tracked audit witness:
RESULTS_DIR=/tmp/ndn-audit-results bash testbed/tests/audit/run_all.sh
The audit harness scaffold is testbed/tests/audit/_template.sh;
new findings follow the same shape (project memory
feedback_witness_first_compliance).
Cross-impl on-disk references
Per project memory feedback_cross_reference_standard, every
audit finding cites the source implementation it tracks against
the upstream NDN reference implementations cloned on disk. The
references live alongside each witness script’s # Finding:
header comment.
Recently closed blockers
These audit rows were release blockers in the pre-v0.1 tracker and now have passing behavioral or live witnesses:
| Finding | Witness | Resolution |
|---|---|---|
| A.09 | a09_signed_interest_verify.sh | Signed Interest signer bytes are checked against the decoded final-wire Interest::signed_region(), and KeyChain-signed Interests verify against that region. |
| A.15 | a15_keylocator_rules.sh | KeyLocator presence/absence is enforced by SignatureType and surfaced by outer packet decoders. |
| C.01 | c01_rsa_ecdsa_verifiers.sh | RSA-SHA256 and ECDSA-SHA256 now have behavioral verifier witnesses for valid signature, wrong signature, malformed key, and validator dispatch. |
| C.09 | c09_safebag_ndnsec_interop.sh | SafeBag portability is now witnessed through reference ndnsec: ndn-rs exports an ECDSA-P256 SafeBag, ndnsec imports/re-exports it, and ndn-rs decrypts and verifies the returned SafeBag. SafeBag encryption now uses the ndn-cxx-compatible PBES2/PBKDF2-HMAC-SHA256/AES-256-CBC profile. |
| C.12 | c12_mgmt_sign_digest.sh, c12_mgmt_sign_key.sh, c12_mgmt_dataset_fresh.sh | MgmtClient command Interests are decoded and checked for DigestSha256 over the spec signed region; the key-backed script now registers a signed route against Docker NFD and verifies it with nfdc route list. Dataset queries now set CanBePrefix+MustBeFresh, and the follow-up witness proves ndn-ctl route rib-list sees a freshly registered NFD route instead of stale cached dataset Data. |
| C.13 | c13_ndncert_challenge_tlv.sh, c13_ndncert_live_interop.sh | NDNCERT CHALLENGE parameters use TLV ParameterKey/ParameterValue, and the live witness enrolls against upstream ndncert-ca-server, completes the PIN challenge, fetches the issued Certificate v2 Data, decodes it with ndn-rs, and checks the issuer chain prefix. |
| Validator config | validator_config_behavior.sh | The configuration-validator release claim is now behavioral: ordered first-match rules, no-match denial, exact KeyLocator-prefix checking, and hierarchical checking all have Rust witnesses. Full ndn-cxx validator.conf parsing is not advertised. |
| C.16 | c16_lvs_user_fn_failsafe.sh | LVS binary schemas with unsupported user functions fail closed: the parser flags the function call, trust-schema import rejects enforcement, and direct policy evaluation denies a fixture that would match if the constraint were ignored open. |
| D.01 | d01_hoplimit_decrement.sh | HopLimit is decremented on the incoming pipeline; the Docker witness proves HopLimit=2 still reaches an NFD producer while HopLimit=1 is dropped after decrementing to zero. |
| D.04 | d04_pit_aggregation_selectors.sh | PIT entries aggregate by Name/ForwardingHint with per-in-record selectors, and CS lookup rejects stale cached Data for MustBeFresh Interests at both store and engine stage level. |
| D.02 / I.11 | d02_localhop_scope.sh | /localhop scope is covered by Rust unit behavior and live interop: remote TCP drops, local Unix face passes. |
| E.04 | e04_dataset_segmentation.sh | Management datasets are returned as versioned segmented Data with FinalBlockId; the Docker witness verifies this for /localhost/nfd/faces/list. |
| E.05 | e05_notification_streams.sh | NotificationStream<T> has unit coverage for publisher/subscriber delivery and semantic face events; the Docker live witness mutates strategy-choice and fetches the resulting /localhost/nfd/strategy-choice/notifications/seq=<n> event Data with ndn-mgmt-notification-fetch. |
| Management FaceStatus | nfdc_interop_face_list.sh | The Docker interop image ships reference NFD nfdc; nfdc face list decodes ndn-fwd FaceStatus Data, including required Flags, without tripping NFD’s strict dataset decoder. |
| N.12 | n12_mgmt_response_signing.sh | ndn-fwd testbed boots with a persistent ECDSA-P256 identity; reference nfdc status decodes live management Data, and ndn-mgmt-response-verify verifies /localhost/nfd/cs/config ControlResponse Data against the shared PIB trust anchor. |
| G.03 | g03_psync_interop.sh | The Docker interop image builds upstream C++ PSync plus a deterministic FullProducer fixture; the witness runs it against ndn-rs ndn-psync-consumer through NFD and requires five distinct expected Sync update prefixes. |
| G.04 | g04_nlsr_interop.sh | ndn-rs NLSR now has a live C++ NLSR sidecar witness. The Docker test requires bidirectional route convergence: ndn-fwd-nlsr installs /test/r1/data, and C++ NLSR/NFD installs /test/r2/data with origin=nlsr; a pcap is saved with the transcript. |
| G.06 | g06_swim_vs_autoconfig.sh | SWIM artifacts are absent, Rust AutoConfig hub-discovery witnesses pass, and upstream ndn-autoconfig succeeds through an NDN-FCH fixture by creating a hub face and registering / plus /localhop/nfd routes on ndn-fwd. |
| G.09 | g09_prefix_announcement_consume.sh | PrefixAnnouncement is now covered through decode, validation, route installation, and live forwarding-path use: a validated announcement installs /learned, and a later /learned/item Interest reaches the announcing face. Tampered or untrusted announcements install no route. |
| Management status | testbed/tests/compliance/mgmt_protocol.sh | ndn-ctl status renders the NFD-compatible status/version/startTime/uptime signal expected by the live compliance suite. |
| N.05 | n05_nack_no_reason.sh | A Nack header without NackReason decodes as None, not Other(0). |
| N.06 | n06_dead_nonce_list.sh | The engine inserts retiring PIT nonces into DeadNonceList on satisfaction/expiry and consults it before PIT aggregation, so repeated (Name, Nonce) after PIT erasure drops as a loop. |
| N.08 | n08_unsolicited_data_policy.sh | UnsolicitedDataPolicy is now engine-witnessed for all four NFD-compatible modes: drop-all, admit-all, admit-local, and admit-network. Admitted unsolicited Data is cache-only and still must pass the verified-Data CS gate. |
| N.09 | n09_multiaccess_nack_policy.sh | Nacks are treated as point-to-point feedback: generated Nacks are suppressed on shared-medium ingress, incoming shared-medium Nacks are ignored, and propagation skips multi-access/ad-hoc downstream faces. A live UDP fixture now injects a real socket-originated Nack on the shared-medium face and proves it is not propagated. |
| N.10 | n10_command_replay.sh | Signed management commands enforce the SignatureTime window and per-signer strictly-increasing replay rule, and ndn-fwd mounts management with the replay cache wired by default. |
| N.14 | n14_trust_anchor_validity.sh | Expired and not-yet-valid trust anchors are rejected before entering the anchor set or cert cache; valid anchors still insert. |
| N.02 | n02_lp_reassembly_collision.sh | LP reassembly is keyed by sender endpoint as well as fragment sequence: packet-level tests cover overlapping sequences, engine tests prove FaceAddr-derived UDP/MAC endpoint IDs reach TlvDecodeStage, and a live UDP fixture reassembles colliding fragment sequences from two real socket source addresses. |
The remaining shared-medium depth work is now optional transport breadth: Ethernet/BLE captures can extend the same invariants beyond the live UDP socket fixtures when those environments are available.
Intentional extensions (downstream-relied-upon divergences)
A few ndn-rs behaviors deliberately extend or diverge from stock NFD/ndn-cxx.
They are intentional, not findings, and downstream stacks (notably NDF)
build on them, so each is pinned by a passing regression-guard witness in
testbed/tests/audit/ and tracked in testbed/EXPECTED_FAILURES.md under
“NDF-relied-upon extensions”. They are listed here so the divergence is part of
the documented compliance surface rather than an untracked delta.
| Extension | What diverges | Witness |
|---|---|---|
Persistent Interest / SubscriptionRequest (TLV 0x230) | A subscription Interest creates a persistent PIT entry with per-InRecord persistence state and a data-count budget, distinct from a classical one-shot entry at the same name. Stock NFD has no such TLV. The value optionally carries a trailing SubscriptionId (stable correlation handle) so a subscription can survive a face flap: its surviving budget is parked on face-down and spliced back when re-expressed with the same id (F15 B1), and on an upstream face-down the stale out-record is pruned so a re-expressed subscription re-establishes the upstream leg instead of aggregate-suppressing (F15 B2). | f16i_subscription_persistence.sh, f15_subscription_survival.sh |
ReplayGuard monotonic=false shared-key mode | Signed-Interest replay protection uses AND-semantics (a replay only when every shared anti-replay field agrees), and supports a non-monotonic mode for a key shared across devices, where an out-of-order seq is legitimate but an exact in-window repeat is still rejected. | f16ii_replay_guard_shared_key.sh |
ContentHashTarget::InnerTlvType delegated hashing | A face may be configured to hash a specific inner TLV type (e.g. 364) rather than the whole Content, so a delegating consumer can name/verify by that inner digest. | f16iii_inner_tlv_hashing.sh |
Implicit-digest fetch (<auth>/blocks/<hash>) | Content-Store lookup and CanBePrefix resolution by ImplicitSha256DigestComponent, supporting content-hash-addressed retrieval. (Standard NDN component, but pinned here as a relied-upon retrieval path.) | f14_implicit_digest_fetch.sh |
The SignedTrustContext wire object also carries an optional non-critical
provenance hint (SOURCE_BUNDLE_HASH, TLV 0x041A) recording the SHA-256
digest of the source trust bundle a context was projected from; old nodes skip
it per the NDN evolvability rule.
Strictness divergences (stricter than ndn-cxx/NFD)
| Area | ndn-rs behavior | References |
|---|---|---|
| Non-minimal VAR-NUMBER | read_varu64 rejects a TLV-TYPE/TLV-LENGTH written in a longer-than-minimal form (TlvError::NonMinimalVarNumber). | ndn-cxx / NFD / NDNts decode such forms leniently. |
| NonNegativeInteger widths (LP/SVS) | The core Interest/Data and management decoders enforce the NDN {1,2,4,8}-octet rule, but the LP (decode_be_u64) and SVS (decode_nni) integer decoders are currently lenient — they fold an off-width value into a u64 (truncating >8 bytes) rather than rejecting it. NFD rejects off-width LP integer fields. | NFD rejects; ndn-rs core/mgmt reject; ndn-rs LP/SVS accept. |
The LP/SVS NNI-width leniency is a known internal inconsistency (audit I-2):
the same rule is enforced in some decoders and not others. It is tracked to be
made uniformly strict as part of consolidating the duplicated TLV/NNI codecs onto
one ndn-tlv API (audit R-1).
This is intentional: a minimal-length VAR-NUMBER is the canonical wire form, and rejecting non-minimal encodings avoids two distinct wire representations of the same logical packet. The NDN Packet Format recommends minimal encoding, and the reference implementations emit minimal forms, so in practice interop is not affected — a conforming peer never sends a non-minimal length. ndn-rs likewise encodes minimally. The only one-directional risk is a hand-crafted or deliberately non-minimal encoder; a differential test against python-ndn / NDNts is tracked to confirm the references never emit non-minimal lengths in the wild. (Audit finding I-1.)
TLV codepoint allocations
ndn-rs’s TLV allocations split into three classes:
- IANA / registry codes ndn-rs implements (forwarding).
- ndn-rs-internal codes used only on in-process or shared-memory faces (no wire reach).
- Codes reserved for v0.1.x.
Releasing under v0.1.0
Release-readiness is gated on every critical-severity audit finding
being closed, the cross-implementation interop suite running in the
interop image, and the release notes matching the live tracker.
The 2026-05-28 Docker baseline for testbed/tests/interop/run_all.sh
passes all eight ndn-cxx and NDNts packet-exchange scenarios. The same pass now
also includes live G.03 PSync, G.04 NLSR, G.06 AutoConfig, C.09 SafeBag, N.12
management-response signing, and E.05 notification-stream witnesses. Remaining
work is mostly deeper optional fixture coverage and smaller semantic policy
rows, not a blocked generic interop harness. For heavy AutoConfig/testbed
rebuilds on Docker Desktop, prefer
NDN_TESTBED_BUILD_JOBS=2 testbed/tools/up-g06-low-memory.sh so the reference
C++ and Rust tool images build sequentially.
See also
testbed/tests/audit/— runnable witness scripts.- v0.1.0 release boundary — what is in scope once the release is tagged.
Inside ndn-rs
Part I of this book teaches you to use ndn-rs — fetch and serve named data, sign and verify, run a forwarder. Part II is for people who work on it: adding a transport, a strategy, a sync dialect; fixing a bug in the forwarding plane; or porting the core to a new target.
It assumes you have read at least Why NDN is different and One packet, six depths — the mental model of consumer-driven Interest/Data exchange over a stateful forwarding plane is load-bearing everywhere below.
How this part is organised
Architecture — the shape of the codebase and why it has that shape. Start with the layer map and the crate graph, then read the forwarding pipeline walkthrough. The security model, determinism seam, and sans-IO/no_std pages each explain one opinionated design decision that pervades the code.
Cookbooks — task-shaped, copy-adaptable recipes. Each names the trait you implement, the crate it lives in, and where the built-in implementations are so you can read a working example.
Working on ndn-rs — the testing guide (what each layer of the test suite covers and how to run it), the spec conformance matrix (spec section ↔ crate ↔ test), the cross-repo contract (the API the sibling repos depend on), and the contribution workflow.
Decision records — short, immutable notes explaining why a load-bearing decision was made, so the reasoning outlives the commit that made it.
The one-paragraph orientation
ndn-rs is a Rust workspace of ~30 library crates layered
foundation → forwarding → security → app, plus platform and protocol crates
off to the side. The wire layer targets the real NDN Packet Format v0.3
and NDNLPv2 (not a private dialect), so it interoperates with NFD, ndn-cxx,
ndnd, and NDNts. The forwarding plane is async (tokio) with sharded lock-free
tables; the security model makes data-centric verification
compiler-enforced; and a
determinism seam lets the whole engine
run against a virtual clock for reproducible tests and simulation. Two
sans-IO seed crates share byte-identical
forwarding and crypto logic between the native engine and a bare-metal
no_std build.
The layer map
This is the orientation page: where everything lives, which way the arrows point, and where to start reading. If you have just cloned the workspace and want the shape of the thing before you dive into any one crate, read this first, then follow the links out to the deeper pages.
ndn-rs is a Cargo workspace of ~30 library crates. They are grouped on disk
into seven directories under crates/, and they form a single directed acyclic
graph in which dependencies only ever point toward the foundation. The
crate graph page renders that graph visually; this page
gives you the map in words and tables.
The seven directories
The crates/ tree groups crates by role, not by dependency layer — the two are
related but not identical (a crate’s directory is where it lives; its layer
is who may depend on it).
| Directory | Representative crates | Responsibility |
|---|---|---|
crates/core | ndn-tlv, ndn-foundation-types, ndn-packet, ndn-crypto-core, ndn-signals-core, ndn-runtime, ndn-transport, ndn-storage, ndn-mgmt-wire, ndn-frame-io, ndn-ble-framing | The wire codec, packet and name/hash types, the no-alloc crypto core, the cross-layer signal taxonomy, the clock/runtime seam, the face/transport abstraction, and the storage-backend traits. |
crates/forwarding | ndn-store, ndn-fwd-core, ndn-strategy, ndn-discovery-core, ndn-engine, ndn-ipc, ndn-mgmt, ndn-pathcontrol | The three forwarding tables, the sans-IO forwarding rules, the strategy framework, and ndn-engine — the crate that assembles the whole forwarding plane. |
crates/faces | ndn-face, ndn-face-local | Concrete faces: OS-socket transports (ndn-face) and the in-process channel face (ndn-face-local), split out so wasm consumers get the channel face without OS sockets. |
crates/security | ndn-security, ndn-cert, ndn-identity | Data-centric verification and signing (ndn-security), NDNCERT issuance (ndn-cert), and identity lifecycle / renewal (ndn-identity). |
crates/app | ndn-app, ndn-rs-prelude | The developer-facing Node API and a prelude that re-exports the common surface. |
crates/platform | ndn-config, ndn-observability | Forwarder configuration and NDN-native span publishing. |
crates/protocols | ndn-sync | Dataset synchronisation (SVS and PSync). |
The face/transport abstraction (the Transport, LinkService, and Face
types) lives in crates/core/ndn-transport; the concrete faces that implement
it live in crates/faces. If you are writing a new face, start at
Implementing a face.
The dependency layering
Logically the graph is four layers deep, and dependencies flow one way:
graph LR
App[App and platform] --> Sec[Security]
App --> Fwd[Forwarding and faces]
Sec --> Found[Foundation]
Fwd --> Found
App --> Found
Arrows read as depends on. Nothing in the foundation reaches back up into forwarding, security, or app code — the foundation is the widest, most-depended-on layer and knows nothing of the layers above it. This is what lets the foundation crates compile for constrained targets that the upper layers never touch (see below).
The DAG is enforced, not merely observed
The layering is a checked invariant. Each crate declares a scope in its manifest:
[package.metadata.scope]
classification = "spec"
The spec set is the pure library we ship as ndn-rs. The rule the CI guard
enforces is that the spec set is closed under dependency: a spec crate may
only depend, at runtime, on other spec crates. Extension, research, and
tooling crates are downstream consumers and may depend on anything. The guard
lives at testbed/dep-direction-guard.py and runs on every PR; the moment a
spec crate grows an edge into an unclassified or extension crate, the build
fails with the offending crate -> dep pair named.
Classification is orthogonal to directory. Most crates are spec, but a few
that live alongside them are deliberately extension — ndn-storage (the
pluggable KV backends), ndn-config, and ndn-pathcontrol — because they are
downstream of, not part of, the closed library core. Do not assume a crate’s
directory tells you its classification; the manifest does.
The four peer targets
The same code compiles for several very different machines. CI proves this on every PR by building the relevant crate subsets for each target:
| Target | Toolchain triple | What it is | Verified in CI |
|---|---|---|---|
| Native | host (Linux x86-64; macOS in nightly) | the full stack: engine, faces, security, app | test, lint, docs jobs |
| Browser | wasm32-unknown-unknown | the forwarding chain up to and including the engine, running in a browser tab | wasm32 job |
| Bare metal | riscv32imc-unknown-none-elf | the no_std seed chain on a microcontroller with no OS and no allocator-of-convenience | no_std job |
“Native” itself spans more than one machine — the nightly matrix adds macOS for
the ndrv/kqueue face paths — but the load-bearing claim is that the foundation
and forwarding rules are written once and pass on all three architectures. How
that is achieved (sans-IO seed crates, no_std+alloc wire types,
portable-atomic for MCUs) is the subject of
sans-IO and no_std, and the reasoning is recorded in
ADR 0003. The browser build works because
the time/spawn seam swaps a web-time clock in for Tokio; see
the determinism seam and
ADR 0004.
Where a contributor’s API starts
Application authors do not touch most of this graph. The developer-facing entry
point is Node in crates/app/ndn-app/src/node.rs: one handle to a forwarder
over which every NDN pattern is available with one vocabulary.
Node::connect(socket)dials a runningndn-fwdover its Unix socket; an in-process engine can supply aNodetoo.node.fetch(name)/node.verifying(validator)/node.object(name)are the consumer side (unverified fetch, verifying fetch, and RDR object fetch).node.serve(prefix, handler)answers Interests for a prefix.node.publish/node.subscribedrive dataset sync;node.queryruns a responder stream.
The per-pattern building blocks (Consumer, Producer, Publisher,
Subscriber, Queryable) remain reachable through Node::connection() for
code that needs the lower level. For the application-author’s view rather than
the contributor’s, start at Building an app.
Reading order from here
- The crate graph — the interactive picture of the same DAG.
- The forwarding pipeline — the path a packet takes
through
ndn-engine. - The security model — why unverified data is a type error, not a code-review finding.
- The determinism seam — why the test suite runs in ~25 seconds without sleeping.
- sans-IO and no_std — how one codebase serves the datacenter and the microcontroller.
The sibling repos that consume ndn-rs (the forwarder binary, the simulator, the embedded forwarder, the mobile app) sit outside this workspace; the surface they depend on is the cross-repo contract.
The crate graph
ndn-rs is a workspace of ~30 library crates. They form a directed acyclic graph: dependencies flow one way, foundation → forwarding → app. The graph below is interactive (drag to pan, scroll to zoom).
{
"columns": [
{ "label": "Foundation (no_std)", "nodes": [
{ "id": "ndn-tlv" },
{ "id": "ndn-foundation-types" },
{ "id": "ndn-packet" },
{ "id": "ndn-crypto-core" },
{ "id": "ndn-signals-core" },
{ "id": "ndn-time" },
{ "id": "ndn-runtime" }
]},
{ "label": "Store & transport", "nodes": [
{ "id": "ndn-storage" },
{ "id": "ndn-store" },
{ "id": "ndn-transport" },
{ "id": "ndn-fwd-core" },
{ "id": "ndn-mgmt-wire" }
]},
{ "label": "Forwarding & faces", "nodes": [
{ "id": "ndn-strategy" },
{ "id": "ndn-face" },
{ "id": "ndn-discovery-core" },
{ "id": "ndn-engine" }
]},
{ "label": "Security", "nodes": [
{ "id": "ndn-security" },
{ "id": "ndn-cert" },
{ "id": "ndn-identity" }
]},
{ "label": "App & platform", "nodes": [
{ "id": "ndn-app" },
{ "id": "ndn-mgmt" },
{ "id": "ndn-ipc" },
{ "id": "ndn-config" },
{ "id": "ndn-sync" },
{ "id": "ndn-observability" }
]}
],
"edges": [
["ndn-packet", "ndn-tlv"],
["ndn-packet", "ndn-foundation-types"],
["ndn-foundation-types", "ndn-tlv"],
["ndn-fwd-core", "ndn-signals-core"],
["ndn-store", "ndn-packet"],
["ndn-store", "ndn-fwd-core"],
["ndn-transport", "ndn-packet"],
["ndn-mgmt-wire", "ndn-packet"],
["ndn-strategy", "ndn-transport"],
["ndn-strategy", "ndn-signals-core"],
["ndn-face", "ndn-transport"],
["ndn-engine", "ndn-store"],
["ndn-engine", "ndn-strategy"],
["ndn-engine", "ndn-transport"],
["ndn-engine", "ndn-runtime"],
["ndn-engine", "ndn-discovery-core"],
["ndn-engine", "ndn-security"],
["ndn-security", "ndn-packet"],
["ndn-cert", "ndn-security"],
["ndn-identity", "ndn-cert"],
["ndn-app", "ndn-engine"],
["ndn-app", "ndn-security"],
["ndn-mgmt", "ndn-engine"],
["ndn-mgmt", "ndn-mgmt-wire"],
["ndn-ipc", "ndn-packet"],
["ndn-config", "ndn-mgmt-wire"],
["ndn-sync", "ndn-packet"],
["ndn-observability", "ndn-transport"]
],
"satellites": {
"label": "Consumed by sibling repos",
"nodes": [
{ "id": "ndn-fwd" },
{ "id": "ndn-ext" },
{ "id": "ndn-sim" },
{ "id": "ndn-mobile" },
{ "id": "ndn-embedded" },
{ "id": "ndn-repo" }
]
},
"satellite_edges": [
["ndn-fwd", "ndn-engine"],
["ndn-ext", "ndn-engine"],
["ndn-sim", "ndn-engine"],
["ndn-mobile", "ndn-app"],
["ndn-embedded", "ndn-fwd-core"],
["ndn-repo", "ndn-sync"]
]
}
The diagram is a curated view — it shows the load-bearing edges, not every
Cargo.tomlline. The authoritative graph is the manifests;cargo treerenders it in full.
Reading the graph
The columns are dependency layers, and edges only ever point leftward (toward
foundation). There are no cycles — this is enforced, not just observed: a CI
dependency-direction guard keeps the spec-classified crates closed under
dependency, so a new edge that would break the layering fails the build.
The layers, briefly
- Foundation is
no_std: the TLV codec, packet types, shared name/hash primitives, the no-alloc crypto core, the cross-layer signal taxonomy, the named-time core (ndn-time— uncertainty-bounded samples, the Marzullo combiner, measurement provenance; see ADR 0007), and the clock/runtime seam. Everything else builds on these. - Store & transport holds the three forwarding tables (
ndn-store), the pluggable async KV backends (ndn-storage), the face/transport abstraction and NDNLPv2 link service (ndn-transport), the sans-IO forwarding rules (ndn-fwd-core), and the NFD management wire codec (ndn-mgmt-wire). - Forwarding & faces is the strategy framework, the concrete OS faces, the
discovery trait surface, and
ndn-engine— the crate that assembles the whole forwarding plane. - Security layers certificates and identity lifecycle on top of the
ndn-securitycore (see the security model). - App & platform is the developer-facing
Node/Consumer/ProducerAPI, the management dispatcher, IPC, config, the sync protocols, and NDN-native observability.
The satellites are the sibling repos in ndn-workspace that consume
ndn-rs. Which exact items they depend on is the subject of the
cross-repo contract — that surface is what
semver-checks protects.
For why the foundation is a separate no_std layer at all, see
ADR 0003.
The forwarding pipeline
This is the heart of ndn-rs: the path a packet takes from arriving on a face to being answered, forwarded, or dropped. If you are fixing a forwarding bug or adding a stage, read this first.
The animation shows the two halves. An Interest walks left-to-right through the ingress stages; a Data returns right-to-left along the reverse path the PIT recorded. The defining property of NDN is visible in the picture: the Data needs no routing on the way back, because forwarding is stateful — the PIT remembers who asked.
The three tables
Every NDN forwarder is organised around three tables. In ndn-rs they live in
ndn-store:
| Table | Type | What it holds | Keyed by |
|---|---|---|---|
| Content Store (CS) | ContentStore trait (LruCs, ShardedCs, FjallCs, …) | recently-seen Data, in wire form | name |
| Pending Interest Table (PIT) | Pit over DashMap | Interests awaiting Data, and the faces that want them | PitToken (name-hash + discriminator) |
| Forwarding Information Base (FIB) | Fib over a NameTrie | name prefix → next-hop faces + costs | longest-prefix match |
The CS makes any router a cache; the PIT makes the return path free; the FIB is the routing table. A per-prefix forwarding strategy decides how to use the FIB (which next-hops, when to retry, when to give up).
The ingress path (Interest)
The stages live in ndn-engine/src/stages/ and are driven in order by the
dispatcher in ndn-engine/src/dispatcher/. Reading order for the Interest
path:
- Decode — the raw bytes are parsed. If they are an NDNLPv2
LpPacket(type0x64), the link layer strips fragmentation/PitToken/Nack headers first; the inner Interest is decoded lazily (fields are only parsed when read, so a CS hit never touches fields it doesn’t need). - Content Store — if the CS holds fresh Data matching the name, it is returned immediately and no further stage runs. This is the first short-circuit.
- PIT — the Interest is looked up by
PitToken. If an entry already exists (someone else asked for this name), the new Interest is aggregated: its incoming face is added as an in-record and the Interest is not forwarded again. If it is new, an entry is created. This is where flow balance and loop detection (nonce + dead-nonce-list) happen. - Strategy — a fresh (un-aggregated) Interest is handed to the strategy
registered for its longest-matching prefix. The strategy reads the FIB entry
and returns a
ForwardingAction: forward to some faces, forward after a delay, Nack, or suppress. See Add a forwarding strategy. - Egress — chosen next-hops are LP-framed and queued to each face’s sender task.
The return path (Data)
- PIT match — the Data’s name is matched against pending entries. No match → the Data is unsolicited and (by default) dropped.
- Validate — on faces that require it, the signature is checked before the Data is allowed to propagate. This is where the security model becomes load-bearing: a router will not forward Data it cannot authenticate on a validating face.
- CS insert — admissible Data is cached (admission policy decides what is worth caching; the default admits only Data with a non-zero freshness period, matching NFD).
- Reverse forward — the Data is sent to every face recorded as an in-record on the PIT entry, then the entry is consumed. One Interest, one Data, one PIT entry — flow balance.
Why it is sharded and async, not a single-threaded core
The dispatcher runs the pipeline as tokio tasks over sharded, interior-mutable
tables (DashMap for the PIT, per-node RwLock in the FIB trie). Interest and
Data for the same name are steered to the same shard so aggregation and
matching stay correct without a global lock. A partitioned data-plane mode
(NDN-DPDK style) hashes the first name components to worker lanes for
throughput. The key invariant that keeps this safe: stages consume their
PacketContext — every stage variant except “continue” takes the context by
value, so a use-after-handoff is a compile error rather than a data race.
Where to look in the code
| You want to change… | Start in |
|---|---|
| how a stage behaves | ndn-engine/src/stages/ |
| how packets are steered to workers | ndn-engine/src/dispatcher/ |
| the tables themselves | ndn-store/src/{pit,fib,content_store}.rs |
| a forwarding decision | ndn-strategy/src/ and the cookbook |
| the sans-IO forwarding rules shared with embedded | ndn-fwd-core/src/ |
The pure forwarding rules — longest-prefix match, freshness predicates,
conformance checks — live in the no_std ndn-fwd-core crate so the native
engine and the bare-metal forwarder apply byte-identical logic. See
sans-IO and no_std.
The security model
NDN’s central thesis is secure the data, not the channel: every Data packet is signed, and a consumer must verify that signature against a trust anchor before trusting the bytes. In most stacks that verification is a discipline — the API hands you the bytes and trusts you to call the validator. ndn-rs makes it a type transition the compiler enforces, so “did I actually verify this?” is answered by the type of the value in your hand, not by reading the call site. This is the design recorded in ADR 0002; this page is how it works in the code.
The security core is ndn-security; certificates layer on in ndn-cert and
identity lifecycle in ndn-identity.
Two types, one invariant
The whole model rests on a pair of dual types in ndn-security.
SafeData (crates/security/ndn-security/src/safe_data.rs) is “this Data was
verified.” Its fields are pub(crate), so nothing outside ndn-security can
construct one with a struct literal. In a normal build the only way to
obtain a SafeData is to run a Validator and have the signature check
succeed — the crate mints it inside Validator::validate. A SafeData records
not just the payload but how it earned trust, in a TrustPath:
TrustPath variant | Meaning |
|---|---|
CertChain(Vec<Name>) | Validated via a certificate chain to a trust anchor. |
LocalFace { uid } | Trusted because it arrived on a local face with known process credentials. |
DigestSha256 | Only a self-contained SHA-256(signed_region) == sig_value integrity check — no key, no anchor. |
Unverified<T> (crates/security/ndn-security/src/unverified.rs) is the other
half: “this was not verified.” It is freely constructible on purpose —
unverified is the safe, pessimistic state to be in — and it has exactly two ways
out:
.verify(validator).await— validate against aValidator, yieldingSafeDataon success. This is the safe path..trust_unchecked()— accept the value without verification, deliberately. The name is loud and greppable by design, so a security audit reduces to grepping fortrust_unchecked. There is no silent path to usable bytes.
There is also .peek(), which borrows the inner value (e.g. to read a name and
pick a validator) without consuming the wrapper or claiming any trust.
Integrity is not authentication
A bare SHA-256 digest proves the bytes were not corrupted; it says nothing about
who produced them. ndn-rs refuses to conflate the two. Unverified::verify
returns Err(VerifyError::UnauthenticatedDigest) for a packet that validates
only as DigestSha256, even though the digest itself is arithmetically correct.
Accepting integrity-only data is possible but must be asked for explicitly, via
.verify_allowing_digest() — for local, in-process, or digest-addressed content
where an unauthenticated-but-intact packet is genuinely acceptable.
The honest subtlety in the code: the Validator itself does report a valid
DigestSha256 packet as ValidationResult::Valid with the DigestSha256
trust path — it labels the trust path truthfully. The refusal to treat that as
“verified” is a consumer policy applied by Unverified::verify, not a claim
that the digest failed. VerifyError has three variants:
| Variant | Cause |
|---|---|
Invalid(TrustError) | Signature was cryptographically invalid, or the trust schema rejected the pair. |
Pending | The signing certificate chain is not yet resolved (async fetch in flight). |
UnauthenticatedDigest | The packet verified only as DigestSha256 — integrity, not identity. |
The verification flow
graph LR
F[fetch name] --> U[Unverified Data]
U -->|trust_unchecked| Raw[raw Data - explicit bypass]
U -->|verify validator| V{Validator.validate}
V -->|Valid| S[SafeData]
V -->|Invalid| E1[VerifyError.Invalid]
V -->|Pending| E2[VerifyError.Pending]
V -->|DigestSha256 only| E3[VerifyError.UnauthenticatedDigest]
On the application side this is wired through Node
(crates/app/ndn-app/src/node.rs). A plain node.fetch(name) returns raw,
unverified Data — the ergonomic-but-unverified path. node.verifying(validator)
returns a VerifiedConsumer whose fetch returns SafeData, so the payload is
signature-checked before it reaches your logic. The RDR object path funnels
through one choke point (accept_content in crates/app/ndn-app/src/consumer.rs):
with a validator present it runs Unverified::new(data).verify(&v) and errors
if the Data does not authenticate; without one it takes the content as-is.
Validators default to deny
A Validator (crates/security/ndn-security/src/validator/mod.rs) dispatches
each packet to the trust context selected by its name’s namespace
(longest-prefix match) and validates against that context’s schema and anchors
only — never “any anchor I happen to hold.” The default policy is deny:
validation fails unless the selected context authorizes the (data_name, key_name) pair and the signature and certificate chain all check out.
Trust policy is a TrustSchema (crates/security/ndn-security/src/trust_schema.rs):
TrustSchema::new()is empty — it rejects everything.TrustSchema::hierarchical()requires the data name and key name to share a top-level component (full hierarchy enforcement is the chain walk’s job; the schema fixes the namespace). This is the default aKeyChainhands out.TrustSchema::accept_all()accepts any signed packet regardless of name relationship — for theAcceptSignedprofile and tests only.
KeyChain::validator() builds a validator over the identity’s anchors with
TrustSchema::hierarchical(), and KeyChain::trust_only(prefix) builds a
consumer-side validator that trusts only certificates under a given anchor
prefix. You reach an accept-all posture only by asking for it by name.
Interoperable trust schemas (LVS)
Beyond ndn-rs’s native SchemaRules, a schema can import a LightVerSec (LVS)
model from the TLV binary format used by python-ndn, NDNts (@ndn/lvs), and
ndnd, via TrustSchema::from_lvs_binary(wire)
(crates/security/ndn-security/src/lvs.rs). The two rule sources are OR’d:
allows returns true if either the native rules or the imported LVS model
permits the pair. The import is fail-closed — a schema that uses user functions
($eq, $regex, …) is rejected with LvsError::UserFunctionsNotSupported
rather than loaded and silently mis-enforced, unless a handler registry is
supplied via from_lvs_binary_with_user_fns.
The signing side
Signing is symmetric to validation and also flows through KeyChain
(crates/security/ndn-security/src/keychain.rs), the single entry point for NDN
security in both applications and the forwarder. Construct one with
KeyChain::ephemeral(name) (in-memory, self-signed, ideal for tests and
short-lived producers), KeyChain::open_or_create(path, name) (file-backed PIB
that generates on first run and reloads thereafter), or KeyChain::from_parts
(for framework code that builds a SecurityManager first).
The Signer trait (crates/security/ndn-security/src/signer.rs) abstracts the
signing key; concrete signers include Ed25519Signer, EcdsaP256Signer (for
ndn-cxx interop, which lacks Ed25519), HmacSha256Signer, and the plain/keyed
Blake3Signer/Blake3KeyedSigner. A signer exposes sig_type() and both an
async sign() and a defaulted sign_sync() (the default refuses rather than
panics, so signers whose keys live behind an async boundary surface a
recoverable error).
Which key signs a given packet is chosen by a SignerSelection
(crates/security/ndn-security/src/signing_info.rs), wrapped in a SigningInfo
and passed to KeyChain::sign_packet:
SignerSelection | Resolves to |
|---|---|
Identity(name) | The identity’s default key. |
Key(name) | A specific named key from the key store. |
Cert(name) | The key behind a named certificate (which pins the KeyLocator). |
HmacKey(name) | A named HMAC key (kept distinct from Key for future HMAC-only policy). |
Digest | DigestSha256 — integrity only, no key. |
Suggested { for_name } | The schema’s recommended signer (falls back to the default key until LVS-driven selection is wired). |
KeyChain::sign_packet routes Digest through the sign_digest_sha256 fast
path and resolves every other selection against the local PIB.
The forwarder’s invariant
Type-enforced verification pays off at the forwarder too. The design goal in
ADR 0002 is that only SafeData
is forwarded on a validating face. In ndn-engine this is realised by the
validation stage (crates/forwarding/ndn-engine/src/stages/validation.rs):
on a face that carries a Validator, a Data packet that fails validation is
dropped (Action::Drop(DropReason::ValidationFailed)) and never propagates,
while a packet that validates sets ctx.verified = true on its PacketContext.
The Content Store admission gate (crates/forwarding/ndn-engine/src/stages/cs.rs)
then admits only ctx.verified Data, so unverified bytes cannot poison the
cache for downstream consumers.
One honest nuance for contributors: at the forwarder the trust verdict travels
as a boolean PacketContext::verified flag rather than as the SafeData type
itself — the SafeData/Unverified type discipline is the application-layer
enforcement, and the pipeline mirrors it with the flag. Both express the same
invariant: data that cannot be authenticated does not flow where authenticated
data is required.
See also
- ADR 0002 · Type-enforced verification
- The forwarding pipeline — where the validation stage sits on the Data return path.
- Security pitfalls and Trust policies — the application author’s view.
The determinism seam
A forwarder is full of time. PIT entries expire, nonces age out of the dead-nonce list, strategies retry after delays, faces time out, sync suppresses on jittered timers. If every one of those reads the system clock directly, the engine is non-deterministic: timeout tests are flaky, and a multi-node simulation can never be replayed. ndn-rs routes every engine time read through one seam so that a test or a simulator can supply logical time instead. The decision is recorded in ADR 0004; this page is the mechanism.
The seam: ndn-runtime
The seam is three small traits in crates/core/ndn-runtime/src/lib.rs, composed
into one Runtime:
| Trait | Method | Purpose |
|---|---|---|
Spawn | spawn(&self, BoxFuture) | Start a background task. |
Sleep | sleep(&self, Duration) -> BoxFuture | Wait for a duration. |
Now | now() -> Instant | Monotonic instant — durations, timeouts, interval timing. |
Now | unix_nanos() -> u64 | Wall-clock nanoseconds since the Unix epoch — cross-node timestamps: PIT/Interest-lifetime deadlines, Data freshness, certificate validity. |
The split between now() and unix_nanos() matters: now() is monotonic and
process-local (never goes backwards, not comparable across machines), while
unix_nanos() is absolute and comparable across nodes. Code that computes a
deadline or measures an interval uses now(); code that stamps or reads a
value that travels on the wire uses unix_nanos().
unix_nanos() has a default implementation that reads the system clock, so
a production runtime gets it for free and need not implement it. That default is
exactly the hinge the seam turns on.
Real clock vs. virtual clock
graph LR
Eng[Engine and background tasks] --> Now[Now seam]
Now -->|native| Tok[TokioRuntime - real clock]
Now -->|wasm32| Web[WasmRuntime - web-time]
Now -->|sim and tests| Virt[VirtualClock - AtomicU64 logical time]
- Native uses
TokioRuntime, which reads the real monotonic and wall clocks and spawns Tokio tasks. - Browser uses
WasmRuntime, which spawns viawasm-bindgen-futures, sleeps viagloo-timers, and readsweb_time::Instant(a proxy forperformance.now()) — so the same engine builds and runs in a tab, where the Tokio timer wheel would panic. - Simulation and tests supply a virtual runtime that overrides
unix_nanos()(andnow()/sleep()) to return logical time driven by anAtomicU64. Because the engine reads only through this one seam, advancing the atomic advances the engine’s whole notion of time, deterministically.
The unit test virtual_runtime_can_drive_logical_epoch_time in
ndn-runtime’s lib.rs is the seam in miniature: it wraps an
Arc<AtomicU64> in a VirtualClock, reads unix_nanos() back as 1_000,
stores 5_000, and reads 5_000 — “the logical epoch is whatever the sim
sets.” A scheduler advances that atomic the same way; the engine follows.
One packet, one instant: ctx.arrival
Threading time through a seam is only half the job. A single packet touches
several stages (Content Store, PIT, strategy), and if each stage re-read the
clock, one packet’s decisions could straddle two different instants. So the
forwarding path stamps a packet’s arrival timestamp once, at ingress, into
PacketContext::arrival (crates/forwarding/ndn-engine/src/pipeline/context.rs),
and every downstream stage reads that rather than the clock:
- the Content Store freshness/insert stage uses
let now_ns = ctx.arrival;(stages/cs.rs), - the PIT stage anchors expiry and aggregation windows to
ctx.arrival(stages/pit.rs), - the strategy stage reads
ctx.arrivalfor its timing (stages/strategy.rs).
All of a packet’s time-derived decisions are therefore anchored to one
consistent instant, which is both correct and reproducible. Background tasks —
which are not tied to a single packet — take an injected now instead: the
expiry sweeps read runtime.unix_nanos() / runtime.now()
(crates/forwarding/ndn-engine/src/expiry.rs), and the signals driver measures
elapsed time from runtime.now().
Why the suite runs in ~25 seconds
Because timeout-dependent behaviour is driven by logical time, a test advances
the clock and asserts instead of sleeping and hoping. No wall-clock waits, no
sleep scattered through the tests, and parallel execution under nextest stays
correct because there is no global mutable clock to contend on — the virtual
clock is a per-test Arc<AtomicU64>. That is why the full suite completes in
roughly 25 seconds.
The same seam is what lets the simulator be honest. ndn-sim (a sibling repo
that depends on ndn-engine) runs the real ForwarderEngine against a
virtual clock, so a simulated multi-node run is deterministic and replayable —
the simulator is not a separate mock of the forwarder, it is the forwarder
with time supplied externally.
What is not yet through the seam
The seam is in place across the forwarding path, background tasks, the Nack-path
out-records, and the discovery clock. A few reads remain direct: FaceState
stamps its last_activity from SystemTime::now() in
crates/forwarding/ndn-engine/src/engine.rs. These FaceState timestamp reads
are the last direct clock reads being routed through the seam — the polish work
called out in ADR 0004’s status note. If
you are adding engine code, the correct source of time is runtime.now() /
runtime.unix_nanos() or ctx.arrival; a reach for SystemTime::now() or
Instant::now() in the forwarding path is a review red flag.
See also
- ADR 0004 · Virtualize the clock
- The forwarding pipeline — the stages that read
ctx.arrival. - Testing — how the suite exercises timeouts without waiting.
sans-IO and no_std
ndn-rs targets everything from a datacenter forwarder to a bare-metal
microcontroller. The native engine is async, allocates freely, and uses
DashMap/RwLock; an embedded forwarder is #![no_std], often no-alloc,
single-threaded, and has no async runtime. The danger is obvious: two
implementations of “longest-prefix match” or “is this Data still fresh?” will
drift apart, and the bugs will differ per target. ndn-rs avoids that by
extracting the pure protocol logic into sans-IO seed crates that both builds
share. The reasoning is ADR 0003; this
page is how it is built and how CI keeps it honest.
The sans-IO philosophy
Sans-IO means the protocol rules are separated from the machinery that does
I/O. A seed crate contains the parts that are neither async, nor I/O-bound, nor
allocation-dependent, nor opinionated about how the tables are stored — just
pure functions and small state machines over borrowed bytes. The async, sharded
native engine and the synchronous, heapless embedded forwarder each keep their
own table containers and their own I/O, but both call into the same rules, so
a rule lives exactly once.
graph TD
Core[ndn-fwd-core - no_std rules: lpm, freshness, conformance] --> Eng[ndn-engine - async, tokio, DashMap]
Core --> Emb[ndn-embedded - sync, heapless, bare metal]
Crypto[ndn-crypto-core - no_std, no alloc crypto] --> Sec[ndn-security - native]
Crypto --> Emb
The two seed crates
ndn-fwd-core (crates/forwarding/ndn-fwd-core/) is the sans-IO seed of
the forwarder. It is #![no_std] and #![forbid(unsafe_code)], and it holds
the container-independent forwarding decisions:
lpm— the FIB longest-prefix-match selection rule (longest wins, length-guarded). The native trie and the constrained linear table are different containers pinned to this one selection rule.freshness— the Content Store freshness predicate, in both the absolute-deadline form the native CS uses and the wrapping relative-period form the constrained CS uses, so the comparison and its wrap correctness are defined once.conformanceandstrategy/store/pipelineseeds.
Deliberately not in the seed: anything that does I/O, anything async, and any
tracing spans — instrumentation belongs in the adopting I/O layer, not in the
pure functions.
ndn-crypto-core (crates/core/ndn-crypto-core/) is the security analog:
#![no_std] and no alloc (slice-based), it holds the security-critical
primitives and wire ops — Ed25519 sign/verify over a Data’s signed region and
the signed-Data layout — used byte-identically by the native ndn-security and
the embedded forwarder, instead of each re-deriving the signed-Data wire. Out of
scope here, and rightly so: key storage (PIB/TPM), TrustSchema evaluation,
and async cert fetch / NDNCERT — the heavy machinery that stays in
ndn-security or is offloaded to capable nodes.
The no_std floor
The no_std set is wider than the two seeds, because the wire layer has to run
on the bare-metal floor too. These crates carry #![no_std]:
| Crate | Role | Alloc? |
|---|---|---|
ndn-tlv | TLV codec | no_std + alloc |
ndn-foundation-types | shared name/hash primitives | no_std + alloc |
ndn-packet | Interest/Data packet types | no_std + alloc |
ndn-crypto-core | crypto primitives + signed-Data wire | no_std, no alloc |
ndn-signals-core | cross-layer signal taxonomy | no_std |
ndn-mgmt-wire | NFD management wire codec | no_std |
ndn-storage | storage-backend traits + flash log | no_std + alloc |
ndn-fwd-core | forwarding rules | no_std |
The wire layer (ndn-tlv, ndn-packet, ndn-foundation-types) is no_std but
uses alloc, so the same encoder/decoder runs on both native and constrained
targets — the constraint is “no operating system,” not “no heap.” ndn-crypto-core
is stricter still (no alloc) because it must run where even a global allocator is
undesirable.
portable-atomic for MCUs without hardware CAS
bytes::Bytes and Arc refcounting need atomic compare-and-swap. Some small
single-core microcontrollers (the riscv32imc floor among them) have no
hardware CAS instruction. ndn-packet therefore exposes a portable-atomic
feature that routes Bytes and Arc refcounting through the
portable-atomic-util crate:
portable-atomic = ["dep:portable-atomic-util", "bytes/extra-platforms"]
The dependent binary picks the CAS polyfill via a portable-atomic feature or a
rustflag — typically --cfg portable_atomic_unsafe_assume_single_core, which is
sound precisely because the target is single-core. This is how the wire types
compile for a chip that a naive Arc would reject at link time.
A concrete no_std+alloc backend: the flash store
ndn-storage shows the pattern end to end. Its FlashLogBackend
(crates/core/ndn-storage/src/flash.rs) is a SyncBackend generic over the
embedded-storage NorFlash trait, so the board/HAL plugs in the concrete chip
(ESP32 esp-storage, an STM32 HAL) and the engine stays hardware-independent
and CI-testable against a mock flash. It keeps an ordered index in RAM (a
BTreeMap, hence no_std+alloc) and uses flash as an append-only,
power-loss-atomic write-ahead log: each write is one length-framed record
with a validity footer (len ^ COMMIT_MAGIC), so a torn tail from an
interrupted write is simply dropped on replay. Compaction is double-buffered
across two halves (A/B ping-pong) with a generation-tagged superblock written
last, so a power cut mid-compaction leaves the last committed state intact. It
is the kind of logic that has to be exactly right on the constrained target,
which is why it lives on the no_std floor rather than in a std-only crate.
CI keeps the boundary honest
The seam would rot without enforcement, so two CI jobs guard it on every PR
(.github/workflows/ci.yml):
- The dependency-direction guard (
testbed/dep-direction-guard.py) keeps thespec-classified crate set closed under dependency, so an accidentalstd/tokioedge into a seed crate fails the build. See the layer map for the classification mechanism. - The
riscv32imcjob actually compiles the seed chain forriscv32imc-unknown-none-elfwith-Dwarningsand--cfg=portable_atomic_unsafe_assume_single_core: it buildsndn-foundation-types,ndn-tlv, andndn-packetwith--no-default-features --features portable-atomic, thenndn-signals-coreandndn-fwd-corewith--no-default-features. If the wire layer or the rules pick up astddependency, this job breaks immediately.
The bare-metal forwarder itself, ndn-embedded, is a sibling repo that consumes
ndn-fwd-core; the surface it depends on is part of the
cross-repo contract. The payoff of all this
structure is the property stated on the forwarding pipeline
page: the native engine and the bare-metal forwarder apply byte-identical
forwarding logic, because the rules exist exactly once.
See also
- ADR 0003 · sans-IO seed crates
- The layer map — the
specclassification and the peer targets. - The forwarding pipeline — where the shared rules are applied on the native side.
- Add a storage backend — implementing a
SyncBackendlike the flash store.
Add a face transport
A face is the NDN-layer link to a peer. In ndn-rs a face is always
Face = Transport + LinkService:
Transportmoves opaqueBytesto and from one peer. It knows nothing of NDN packets — only byte frames.LinkServiceframes those bytes into NDN-layer packets: NDNLPv2 fragmentation, PitToken, Nack, congestion marks,IncomingFaceIdtagging.
You almost never write a LinkService; you write a Transport and compose it
with one of the two built-in link services. This page is the terse contributor
recipe. The fuller walkthrough — FaceKind vs FacePersistency, the accept-loop
pattern, the wasm vetting checklist — is the Part I guide
Implementing a face; read it alongside
this. The definitions live in ndn-transport/src/{transport.rs, link_service, face.rs}; the concrete faces in ndn-face/ and ndn-face-local/.
graph LR
E[Engine] <--> L[LinkService: NDNLPv2] <--> T[Transport: bytes in/out] <--> P[Peer]
Step 1 — implement Transport
Transport (ndn-transport/src/transport.rs) uses return-position impl Future — write the two I/O methods as async fn; it is not
#[async_trait]. Only four methods are required: id, kind, send_bytes,
recv_bytes. Everything else is defaulted.
use bytes::Bytes;
use ndn_transport::{FaceError, FaceId, FaceKind, Transport};
pub struct MyTransport {
id: FaceId,
// your socket / channel / handle
}
impl Transport for MyTransport {
fn id(&self) -> FaceId { self.id }
fn kind(&self) -> FaceKind { FaceKind::Udp } // closest classification
async fn send_bytes(&self, wire: Bytes) -> Result<(), FaceError> {
// hand `wire` to your link
Ok(())
}
async fn recv_bytes(&self) -> Result<Bytes, FaceError> {
// pull the next frame; Err(FaceError::Closed) when the link ends
todo!()
}
}
Contract notes:
send_bytestakes&selfand may be called concurrently — synchronise internally.recv_byteshas a single consumer (the face’s own reader task), so it need not be re-entrant.FaceErrorhas exactly three variants:Closed,Io,Full. ReturnClosedwhen the link ends.- There is no
shutdownmethod — drop the transport and cancel the face’s tasks via theCancellationTokenyou pass at wiring time. - Useful defaulted overrides:
send_batch(asendmmsg-style burst for a fragment train),recv_bytes_with_addr(multicast sender address),send_bytes_with_source(in-process source-face tagging),send_mtu/set_send_mtu(link MTU → LP fragmentation threshold),set_persistency. - The object-safe
ErasedTransportis auto-implemented for everyTransport, soArc<MyTransport>becomesArc<dyn ErasedTransport>for free — the face table holds the erased form.
The simplest real transport to copy is InProcFace
(ndn-face-local/src/lib.rs): a pair of tokio::sync::mpsc channels, ~40 lines
of Transport. For the wire pattern, copy UdpFace (ndn-face/src/net/udp.rs)
or TcpFace (ndn-face/src/net/tcp.rs).
Step 2 — pick a LinkService
| LinkService | Use for |
|---|---|
LpLinkService | Lossy or MTU-bounded links. NDNLPv2 framing, fragmentation, IncomingFaceId. Default for wire kinds. |
PassthroughLinkService | Reliable, ordered, large-MTU links (shared memory, in-process): bytes in, bytes out. Default for local kinds. |
default_link_service_for_kind(kind) returns the right one for a FaceKind, so
you rarely choose by hand.
Step 3 — compose and register
Face::from_transport composes the two halves, letting the kind pick the link
service; Face::new(transport, link_service) lets you choose explicitly. But you
usually hand the engine a bare Transport and let it compose the Face:
use ndn_engine::EngineBuilder;
use tokio_util::sync::CancellationToken;
// Build time — one initial face:
let (engine, _shutdown) = EngineBuilder::new(Default::default())
.face(MyTransport { /* ... */ })
.build()
.await?;
// Runtime — add per accepted connection. `cancel` stops the face's tasks:
engine.add_face(MyTransport { /* ... */ }, CancellationToken::new());
EngineBuilder::face(transport) adds a face at build time;
engine.add_face(transport, cancel) adds one at runtime, and
add_face_with_persistency sets the FacePersistency explicitly (OnDemand,
Persistent, Permanent). There is no general FaceListener trait: a listening
transport runs its own accept loop and calls add_face per connection —
IpcListener (ndn-face/src/local/ipc.rs) is the in-tree pattern.
For a browser face, assemble with WasmEngineBuilder::add_face(Arc<Face>) and
vet every dependency for wasm32-unknown-unknown (no mio, no raw sockets, no
tokio::net) — see the Part I guide’s wasm checklist.
Testing
Wire two InProcFace pairs (or two engines over a channel) and assert a Data
comes back for an Interest. The ndn-face integration tests
(ndn-face/tests/callback_face_forwarder.rs,
ndn-face/tests/shared_medium_live.rs) are the model; the reliability path is
covered by ndn-transport/tests/reliability_loss_recovery.rs. Run with
cargo nextest run -p ndn-face (see the testing guide). A new
wire face that speaks to an external peer should also add a script to the
interop suite.
Built-in references
| Face | Crate / file | Shape |
|---|---|---|
| InProc | ndn-face-local/src/lib.rs | tokio::sync::mpsc channel pair (simplest) |
| UDP | ndn-face/src/net/udp.rs | UDP socket per peer |
| TCP | ndn-face/src/net/tcp.rs | TCP connection |
| IPC / Unix | ndn-face/src/local/{ipc,unix}.rs | Unix socket / named pipe |
| Callback | ndn-face/src/callback.rs | CallbackFace — virtual face over an app callback |
| Ethernet | ndn-face/src/l2/ether.rs | Raw Ethernet / AF_PACKET |
Shared memory (ndn-face-shm), serial (ndn-face-serial), Bluetooth
(ndn-face-bluetooth), WebTransport, and WebRTC ship as separate face crates.
Note the workspace lint policy: ndn-face is one of only two crates permitted to
use unsafe (raw sockets, sendmmsg/FFI), behind scoped allows — see
the contribution workflow.
See also
- Implementing a face — the full Part I guide.
- The forwarding pipeline — how faces feed the dispatcher.
Add a forwarding strategy
A forwarding strategy owns the per-prefix decision the pipeline makes for a
fresh Interest: which next-hop(s) to forward to, whether to wait, whether to
Nack. This page is the terse contributor recipe. For the fuller walkthrough —
StrategyContext fields, cross-layer signals, pinning under a prefix — read the
Part I guide Writing a strategy; this page
assumes you have skimmed it and focuses on getting a new strategy compiled,
registered, and tested in-tree.
The trait lives in ndn-strategy/src/strategy.rs; the registry macro in
ndn-strategy/src/registry.rs. A complete, compiled example is
examples/strategy-custom/src/main.rs — the snippets below are {{#include}}d
straight from it, so what you read here is what the CI book job builds.
The decision model
A Strategy is a pure, synchronous decision function. It does not send
Interests, mutate the FIB, or touch I/O. Each hook reads an immutable
StrategyContext and returns SmallVec<[ForwardingAction; 2]>; the engine
executes the returned actions. ForwardingAction (Forward, ForwardAfter,
Nack, Suppress, Broadcast) and NackReason live in ndn-transport and
are re-exported as ndn_engine::pipeline.
Sans-IO / no-block rule. The hooks are synchronous and must not block or
.await. Defer any timed work throughForwardingAction::ForwardAfter(for deferred forwarding) orStrategy::schedule(for arbitrary strategy code such as probing or RTT sampling). The clock behindscheduleis the injected engineRuntime, which is what lets timeout-sensitive strategy tests run under a virtual clock — see ADR 0004.
The trait surface
| Method | When called | Default |
|---|---|---|
name(&self) -> &Name | Registration / strategy-choice lookup | Required |
decide(&self, ctx) | Synchronous fast path, before after_receive_interest | None (fall through) |
after_receive_interest(&self, ctx) | Each fresh Interest on the matching prefix | Required |
after_receive_data(&self, ctx) | Each satisfying Data (bookkeeping / egress) | Required |
on_interest_timeout(&self, ctx) | A pending Interest times out | Suppress |
on_nack(&self, ctx, reason) | A Nack arrives | Suppress |
schedule(&self, ctx, delay, cb) | Run strategy code later | Cancellable ScheduledEvent |
decide is the cheap fast path: Some(actions) short-circuits and
after_receive_interest is never reached; None falls through to it. A strategy
that is fully synchronous (most are) can put all its logic in decide and leave
after_receive_interest unreachable.
The implementation
From examples/strategy-custom/src/main.rs — a round-robin next-hop picker with
split horizon:
#![allow(unused)]
fn main() {
impl Strategy for RandomStrategy {
fn name(&self) -> &Name {
&self.name
}
// The synchronous fast path. Returning `Some(actions)` here avoids the
// overhead of boxing an async future. Most strategies can be fully
// synchronous — only use the async path if you need to await something
// (e.g., a remote lookup or timer).
fn decide(&self, ctx: &StrategyContext) -> Option<SmallVec<[ForwardingAction; 2]>> {
let Some(fib) = ctx.fib_entry else {
return Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]);
};
// Exclude the face the Interest arrived on (split horizon).
let nexthops = fib.nexthops_excluding(ctx.in_face);
if nexthops.is_empty() {
return Some(smallvec![ForwardingAction::Nack(NackReason::NoRoute)]);
}
// Pick a nexthop using round-robin.
let idx = self.counter.fetch_add(1, Ordering::Relaxed) as usize % nexthops.len();
let chosen = nexthops[idx].face_id;
tracing::info!(
name = %ctx.name,
in_face = %ctx.in_face,
chosen_face = %chosen,
nexthop_count = nexthops.len(),
"RandomStrategy: forwarding"
);
Some(smallvec![ForwardingAction::Forward(smallvec![chosen])])
}
// Required by the trait but unreachable when `decide()` always returns Some.
fn after_receive_interest(
&self,
_ctx: &StrategyContext<'_>,
) -> SmallVec<[ForwardingAction; 2]> {
unreachable!("decide() always returns Some")
}
fn after_receive_data(&self, _ctx: &StrategyContext<'_>) -> SmallVec<[ForwardingAction; 2]> {
SmallVec::new()
}
}
}
Points worth noting:
name()returns&Name, not a string — a strategy name is an NDN name under/localhost/nfd/strategy/<name>.nexthops_excluding(ctx.in_face)implements split horizon (never forward back out the arrival face).ctx.fib_entryisNonewhen there is no route — answer that withNack(NoRoute).- Actions are returned, never sent. The engine performs the egress.
Registration
register_strategy! (ndn-strategy/src/registry.rs) records a StrategyEntry
into a linkme::distributed_slice at link time on native targets; the engine
reads the slice at startup and resolves names through it. On wasm32 (no
life-before-main) it defines a pub static that an external crate hands to
ndn_strategy::registry::register during engine setup. The macro takes a
static ident, the NFD short name, a behaviour version, and a capture-free
builder:
use std::sync::Arc;
use ndn_strategy::{ErasedStrategy, register_strategy};
register_strategy!(
RANDOM_REG, b"random", 1,
|| Arc::new(RandomStrategy::new()) as Arc<dyn ErasedStrategy>,
);
The name follows the NFD strategy-name convention:
/localhost/nfd/strategy/<name>/v=N, where <name> is the registered short
component (random) and N is the version. create_by_name and
create_by_name_version resolve it; the built-in best-route registers as
/localhost/nfd/strategy/best-route/v=5, for example.
Installing and testing
The in-process engine is the fixture. EngineBuilder::strategy(...) takes the
strategy value directly and installs it as the default — this is what the
example’s main does:
#![allow(unused)]
fn main() {
// Create an in-process engine with our custom strategy.
let (_engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.strategy(RandomStrategy::new()) // <-- register custom strategy
.build()
.await?;
}
To pin a strategy under a specific prefix instead of the default, use the
strategy-choice management module (see
Add a management module and the Part I guide’s
“Pinning under a prefix”).
Because every hook is a pure function of &StrategyContext, you can unit-test
decision logic without an engine at all: build a StrategyContext with a
hand-made FibEntry and assert on the returned actions. The built-in
strategies’ #[cfg(test)] modules do exactly this. Run the suite with
cargo nextest run -p ndn-strategy (see the testing guide).
Built-in references
| Strategy | File | Behaviour |
|---|---|---|
best-route | ndn-strategy/src/best_route.rs | Lowest-cost next-hop, split horizon; on_nack retries the next-best |
multicast | ndn-strategy/src/multicast.rs | Fan out to every next-hop |
composed | ndn-strategy/src/composed.rs | Chain a strategy with context filters |
congestion_aware and self_learning ship as additional built-ins in the same
crate. A sandboxed WASM strategy targets wasm32-unknown-unknown and loads at
runtime through ndn-wasm-strategy (same Strategy trait).
See also
- Writing a strategy — the full Part I guide.
- The forwarding pipeline — where the strategy stage sits.
- ADR 0004 · Virtualize the clock — why
schedulegoes through the injected runtime.
Add a management module
The forwarder is configured over NDN itself: an operator sends a signed command
Interest to /localhost/nfd/<module>/<verb> carrying ControlParameters, and
the forwarder answers with a ControlResponse in a Data packet. This is the
NFD management protocol, and ndn-rs is wire-compatible with it, so nfdc and
other NFD tooling drive an ndn-rs forwarder unchanged.
A management module owns one <module> name component (rib, faces,
cs, …) and the verbs under it. This page shows how to add one. The trait and
router live in ndn-mgmt/src/module.rs; the built-in modules in
ndn-mgmt/src/modules/; the wire types in ndn-mgmt-wire.
The dispatch model
graph LR
I["command Interest<br/>/localhost/nfd/<module>/<verb>/<params>/<sig>"] --> R[MgmtRouter]
R -->|"module byte-string"| M[MgmtModule]
M -->|"dispatch(verb, params, ctx)"| Resp[ControlResponse]
The MgmtRouter (ndn-mgmt/src/module.rs) keys modules by their exact
byte-string name, dispatched on the second name component. The router has
already parsed the command name and checked authorisation before it calls your
module — your module only produces the response payload.
Step 1 — implement MgmtModule
MgmtModule is an #[async_trait] trait with two methods:
use async_trait::async_trait;
use ndn_mgmt_wire::{ControlParameters, ControlResponse, control_response::status};
use ndn_mgmt::module::{MgmtContext, MgmtModule};
use ndn_mgmt::MgmtResponse;
pub struct EchoModule;
#[async_trait]
impl MgmtModule for EchoModule {
fn name(&self) -> &'static [u8] { b"echo" }
async fn dispatch(
&self,
verb: &[u8],
params: ControlParameters,
ctx: &MgmtContext<'_>,
) -> MgmtResponse {
match verb {
b"ping" => {
// Read engine state through `ctx`; do not block.
let echo = ControlParameters { name: params.name, ..Default::default() };
ControlResponse::ok("OK", echo).into()
}
_ => ControlResponse::error(status::NOT_FOUND, "unknown echo verb").into(),
}
}
}
MgmtContext<'_> (ndn-mgmt/src/module.rs) is the per-Interest dispatch
context. The fields you will reach for most: ctx.engine: &ForwarderEngine (the
forwarding plane — FIB, PIT, CS, face table), ctx.source_face: Option<FaceId> (who sent the command, for authorisation-aware verbs),
ctx.config: &dyn MgmtConfig (redacted running config), and ctx.cancel. A
dispatch is async but must not stall the mgmt task — read state and return.
Step 2 — the wire types
ControlParameters and ControlResponse come from ndn-mgmt-wire and are the
real NFD TLVs — do not invent your own envelope.
ControlParameters(ndn-mgmt-wire/src/control_parameters.rs) is a struct of optional NFD fields (name,face_id,uri,cost,flags,mask,capacity,count,strategy, …). Construct with..Default::default()and fill the fields your verb uses.ControlResponse(ndn-mgmt-wire/src/control_response.rs) has three constructors:ok(text, params)(echo parameters back),ok_empty(text)(status text only), anderror(code, text). Status codes live incontrol_response::status—OK = 200,NOT_FOUND = 404,BAD_PARAMS, and the rest of the NFD set.MgmtResponse(ndn-mgmt/src/lib.rs) is the module’s return type. AControlResponseconverts into it with.into(); theDatasetvariant is for status-dataset verbs (e.g.faces/list) that return raw TLV blocks instead.
For a real, complete module read ndn-mgmt/src/modules/cs.rs — CsModule
implements cs/config, cs/info, and cs/erase in ~110 lines, matching NFD’s
CsManager flag semantics (Admit/Serve bits, capacity). Note how it pulls
engine.cs() off the context and echoes the effective state back in the
response.
Step 3 — register the module
Built-in modules are installed by register_builtins
(ndn-mgmt/src/modules/mod.rs), which does router.register(Arc::new(...)) for
each. Your out-of-tree module is registered as an extra module rather than by
editing that function:
use std::sync::Arc;
use ndn_mgmt::{build_mgmt_router, MgmtModule};
let extra: Vec<Arc<dyn MgmtModule>> = vec![Arc::new(EchoModule)];
let router = build_mgmt_router(&extra);
The forwarder host passes extra modules through MgmtHandles::extra_modules when
it calls mount_management, so a downstream crate can add a module without
touching ndn-mgmt. The management prefix /localhost/nfd (from mgmt_prefix())
is registered in the FIB by mount_management; unknown module names return
NOT_FOUND automatically.
NFD compatibility
Because the wire types and status codes are NFD’s, keep your verbs
NFD-shaped: use the existing ControlParameters fields where they fit, mirror
NFD’s Flags+Mask pattern for boolean toggles (as cs/config does), and reuse
the standard status codes. New wire fields must go through ndn-mgmt-wire (see
the conformance matrix and
ADR 0001). The built-in module set is:
rib, faces, fib, strategy (i.e. strategy-choice), cs, status,
measurements, config, ext, log, and several extension modules; native
builds add routing, neighbors, and security.
Testing
Drive the router directly (build_mgmt_router(&[...]) then router.dispatch(...))
or mount management on an in-process engine and send a real command Interest.
The ndn-mgmt integration tests are the model —
ndn-mgmt/tests/faces_create_idempotent.rs and
ndn-mgmt/tests/notifications.rs. Run with cargo nextest run -p ndn-mgmt (see
the testing guide).
See also
- Management verbs — the catalogue of shipped verbs.
- ADR 0001 · Real NDN wire format — why the wire is NFD’s, not a dialect.
Add a sync dialect
NDN dataset synchronisation lets a group of nodes converge on a shared set of
named publications without a server. ndn-rs ships two families in ndn-sync:
- SVS (State Vector Sync) — nodes multicast a state vector (per-producer sequence numbers) and fetch the gaps. Two wire dialects coexist: v2 (ndn-svs, C++) and v3 (ndnd, Go).
- PSync — IBF/Bloom-filter set reconciliation (
psync.rs,psync_bloom.rs,psync_partial.rs), wire-compatible with the C++ PSync.
This page covers adding a new SVS wire dialect — a third format that the rest
of the crate consumes through the same interface as v2 and v3. The dialect
selector is ndn-sync/src/dialect.rs; the notification core is
ndn-sync/src/svs_sync.rs; the v3 codec is ndn-sync/src/svs_local.rs.
The seam: WireDialect over StateEntry
The crate never branches on wire format outside dialect.rs. A single
WireDialect enum selects (a) the Sync Interest name version and (b) the
state-vector codec, behind a StateEntry-based encode/decode interface:
// ndn-sync/src/dialect.rs
pub enum WireDialect { V2, V3 }
impl WireDialect {
pub fn sync_version(self) -> u64; // v=N name component
pub fn encode_state_vector(self, entries: &[StateEntry]) -> Bytes;
pub fn decode_state_vector(self, bytes: &Bytes) -> Option<Vec<StateEntry>>;
}
StateEntry { name, boot, seq } (svs_local.rs) is the shared vocabulary. v2
has no boot dimension, so its entries decode with boot = 0; v3 carries a
BootstrapTime that disambiguates a producer’s pre- and post-restart sequence
spaces. The two dialects differ only in these two functions.
| Dialect | Sync Interest name | State-vector TLVs |
|---|---|---|
| v2 (ndn-svs) | <group>/v=2 | StateVector=201, StateVectorEntry=202, SeqNo=204, Name=7 |
| v3 (ndnd) | <group>/v=3 | SvsData/StateVector 0xC9/0xCA, entry/boot/seq 0xD2/0xD4/0xD6 |
Adding a dialect
- Add a variant to
WireDialect(e.g.V4). - Assign the Sync Interest version in
sync_version()— thev=Ncomponent appended after the group prefix. - Write the codec — an
encode/decodepair over&[StateEntry], wired intoencode_state_vector/decode_state_vector. Define the wire TLV constants as namedconsts at the top of the module (as v2 does withTLV_STATE_VECTOR = 201); do not hard-code magic numbers inline. Decode must reject an over-large vector up front (entries.len() > MAX_TRACKED_PRODUCERS) before it reachesmerge— untrusted input bounds are load-bearing here. - Round-trip test it. Every dialect needs the three tests v2/v3 already
have in
dialect.rs: a boot-preserving (or boot-ignoring) round-trip, and adialects_are_not_cross_decodablecheck that your format does not silently decode as another. See the testing guide.
Because the notification core, merge logic, and suppression FSM all speak
StateEntry, a new dialect changes nothing above dialect.rs.
The transport-agnostic mpsc<Bytes> boundary
The SVS core never touches a socket. It is driven entirely over a pair of
tokio::sync::mpsc channels of Bytes, so the identical protocol code runs
natively, in the browser, and in a simulator:
// ndn-sync/src/svs_sync.rs
pub fn join_svs_group(
group: Name,
local_name: Name,
send: mpsc::Sender<Bytes>, // Sync Interests out (you bridge to a face)
recv: mpsc::Receiver<Bytes>, // Sync Interests in
config: SvsConfig,
) -> SyncHandle;
The caller owns the bridge between these channels and an actual face; the sync layer stays pure. A new dialect inherits this boundary unchanged — you only supply bytes.
The suppression FSM
svs_sync.rs runs the ndn-svs two-state suppression FSM to damp Interest storms
in a large group:
- Steady — emit the state vector periodically.
- Reply-to-stale (suppressing) — when an incoming Sync Interest carries an
older vector than ours, schedule a single catch-up reply within
SvsConfig::suppression_period(~200 ms) instead of every node replying at once; an incoming newer-or-equal vector cancels the pending reply.
A new dialect reuses this FSM verbatim — it is format-independent.
Authentication seam
Sync Interests carry the state vector in ApplicationParameters; on an untrusted
link an unauthenticated peer could inject false state or hijack a producer’s
sequence space (a real risk for v2, which has no boot timestamp). Signing closes
that. The core calls SyncValidator::validate before merge and
SyncSigner::sign on every outgoing Interest (ndn-sync/src/security.rs):
pub trait SyncSigner: Send + Sync + fmt::Debug {
fn sign(&self, builder: InterestBuilder) -> Bytes;
}
pub trait SyncValidator: Send + Sync + fmt::Debug {
fn validate(&self, raw: &Bytes) -> Result<(), Rejected>;
}
Both default to Insecure (SIGNER_TYPE_NULL); HmacKey (SIGNER_TYPE_HMAC,
a shared group key) is the closed-group default. These are dialect-independent —
opt in through SvsConfig. The signed-Interest encoding reuses ndn-packet’s
spec-compliant InterestBuilder/signed_region machinery, so a signed Sync
Interest is a real Signed Interest (§5.4).
Above the notification core
If you need publications rather than raw sequence numbers, build on the Layer 1
data plane (svsync.rs): SvSync adds a DataStore, canonical svs_data_name
naming, publish_data, and a windowed fetch_range pipeline. SvsPubSub
(pubsub.rs) layers named publications and prefix subscriptions on top. A new
dialect flows up through both unchanged.
See also
- ADR 0001 · Real NDN wire format — why the dialects are byte-exact ndn-svs / ndnd, not an ndn-rs invention.
- The conformance matrix — SVS/PSync test coverage.
Add a storage backend
“Storage” in ndn-rs is two distinct layers, and adding a backend means picking the right one:
| Layer | Trait | Crate | What it is |
|---|---|---|---|
| Content Store | ContentStore | ndn-store | The forwarder’s cache table: name → wire Data, freshness, admission, capacity |
| KV engine | Backend / SyncBackend | ndn-storage | A domain-agnostic ordered byte key→value store the repo/persistent tiers build on |
A ContentStore is a forwarding table (one of the three in
the pipeline). A Backend is Layer 0
of the storage stack — no NDN names, just ordered bytes — reused by every data
model (the named CS/Repo store, content-addressed blob stores, embedded flash).
Choose the layer, then follow the matching recipe.
Recipe A — a Content Store
ContentStore (ndn-store/src/content_store.rs) is async so a disk-backed
store is supported; in-memory ones complete synchronously. Only four methods are
required — the rest are defaulted:
use bytes::Bytes;
use std::sync::Arc;
use ndn_packet::{Interest, Name};
use ndn_store::{ContentStore, CsCapacity, CsEntry, CsMeta, InsertResult};
pub struct MyCs { /* ... */ }
impl ContentStore for MyCs {
async fn get(&self, interest: &Interest) -> Option<CsEntry> {
// Match by name; honour MustBeFresh, CanBePrefix, and a trailing
// ImplicitSha256DigestComponent. Return None on a miss.
todo!()
}
async fn insert(&self, data: Bytes, name: Arc<Name>, meta: CsMeta) -> InsertResult {
// Store the *wire bytes* verbatim. Callers supply well-formed, signed
// Data — the CS does NOT re-verify signatures.
todo!()
}
async fn evict(&self, name: &Name) -> bool { todo!() }
fn capacity(&self) -> CsCapacity { CsCapacity::bytes(0) }
}
Key contracts:
- Store wire bytes, not decoded packets.
CsEntry { data: Bytes, stale_at: u64, name: Arc<Name> }holds the encoded Data so a cache hit re-emits the original bytes with no re-encoding.CsEntry::is_fresh(now_ns)implements the freshness predicate;CsMeta::stale_atis the nanosecond deadline derived from the Data’sFreshnessPeriod. insertreturnsInsertResult—Inserted,Replaced, orSkipped.- The CS does not verify signatures. Validation happens earlier on the return path (see the pipeline). A CS that re-verifies would double-pay.
- The defaulted admin methods matter for NFD parity:
admit_enabled/serve_enabledback thecs/configAdmit/Serve toggles (when serving is off,getreturnsNone; when admission is off,insertis a no-op), andvariant_name/statsbackcs/info. Copy the real handling fromLruCs. ErasedContentStore(the object-safe, boxed-future view) is auto-implemented for anyContentStorevia a blanket impl — you getArc<dyn ErasedContentStore>for free.
The smallest real implementor to copy is NullCs (same file); the production
one is LruCs (ndn-store/src/lru_cs.rs), a byte-bounded LRU with a name-trie
prefix index for CanBePrefix.
The admission-policy hook. Whether a Data packet is worth caching is a
separate decision from how to cache it, expressed by CsAdmissionPolicy:
pub trait CsAdmissionPolicy: Send + Sync + 'static {
fn should_admit(&self, data: &ndn_packet::Data) -> bool;
}
DefaultAdmissionPolicy admits only Data with a positive FreshnessPeriod —
matching NFD, because caching FreshnessPeriod=0 Data churns evictions without
ever satisfying a MustBeFresh Interest. AdmitAllPolicy admits everything.
Register it with the engine:
use std::sync::Arc;
use ndn_engine::EngineBuilder;
let engine = EngineBuilder::new(Default::default())
.content_store(Arc::new(MyCs { /* ... */ }))
.build().await?;
Built-in stores: LruCs (in-memory LRU), ShardedCs<C> (wraps another
ContentStore, sharding by name hash for concurrency), FjallCs (fjall
feature, on-disk), SqliteCs (sqlite-cs feature, the Android persistent
backend), and NullCs.
Recipe B — a KV engine (Backend / SyncBackend)
ndn-storage is the pluggable ordered-byte engine (ndn-storage/src/lib.rs).
Keys sort lexicographically, so a parent byte-prefix precedes its descendants —
which is what makes prefix scans the primitive for CanBePrefix lookups and
“last-N under a name”. There are two facets:
Backend— async, object-safe (Arc<dyn Backend>viaasync_trait):get/put/delete/scan_prefix/first_under/write_batch/name.SyncBackend— the synchronous core,no_std + alloc, with borrowed&[u8]keys. Blocking engines implement this directly.
The idiom for a blocking engine: implement SyncBackend with direct calls,
then make the async Backend a thin spawn_blocking wrapper over it — one
source of truth, two surfaces. FjallBackend and RedbBackend are the worked
examples; here is the shape from FjallBackend:
impl SyncBackend for MyEngine {
fn get(&self, key: &[u8]) -> StorageResult<Option<Bytes>> { /* blocking */ }
// put / delete / scan_prefix / write_batch ...
}
#[async_trait]
impl Backend for MyEngine {
async fn get(&self, key: Vec<u8>) -> StorageResult<Option<Bytes>> {
let this = self.clone();
tokio::task::spawn_blocking(move || SyncBackend::get(&this, &key))
.await.map_err(StorageError::backend)?
}
// ...
}
Notes:
- A genuine miss is
Ok(None); only a real fault isErr. Do not collapse an engine error into “miss” —StorageErrorsurfaces it. write_batchshould be atomic where the engine supports a transaction; the default applies ops sequentially (not atomic), so override it.- A non-blocking sync engine (in-memory, embedded flash) needs no async impl at
all:
SyncAsAsyncbridges anySyncBackendintoBackendinline. Wrap anyBackendinInstrumentedfor per-optracingspans.
Built-in engines: MemoryBackend (async, in-memory), SyncMemoryBackend
(sync feature, no_std in-memory floor), FjallBackend (fjall, LSM),
RedbBackend (redb, ACID B-tree), and FlashLogBackend (flash, over
embedded-storage::NorFlash). To expose a KV engine as a name→wire store, wrap
it in NamedStore<B> — name_key(name) encodes an NDN Name to a storage key
whose byte-prefix ordering matches NDN component order.
Testing
Both traits are exercised the same way: assert a get after an insert/put, a
prefix scan, eviction, and the freshness/admission predicates. The store
benches (ndn-store/benches/{lru,sharded,fjall}.rs) and the storage tests are
the models. Run with cargo nextest run -p ndn-store -p ndn-storage (see
the testing guide). An embedded SyncBackend must also compile
under --no-default-features --features sync for the no_std target.
See also
- The forwarding pipeline — the CS as one of the three tables.
- The crate graph — where
ndn-storeandndn-storagesit.
The testing guide
ndn-rs tests behaviour, by exercising behaviour — not by grepping source
text. That is a deliberate choice with a history: an earlier ~290-script
“audit-witness” suite asserted things about the spelling of the code and
rotted silently. It was retired in favour of cargo nextest as the single
source of truth for in-repo behaviour. Read
ADR 0005 for the full story; the
short version is that a test should survive a refactor that moves code around,
and a green run should mean something.
This page is the map: the layers, what each guards, and how to run them.
The layers at a glance
| Layer | Location | What it guards | How to run |
|---|---|---|---|
| Unit | inline #[cfg(test)] mod tests in src/*.rs (~229 files) | Per-function logic, decode/encode edge cases, decision kernels | cargo nextest run -p <crate> |
| Integration | each crate’s tests/*.rs (~66 files) | Cross-module behaviour, end-to-end packet flow in-process | cargo nextest run --workspace |
| Property | tests/props.rs in ndn-tlv, ndn-packet, ndn-mgmt-wire, ndn-sync (proptest) | Wire invariants over random input (round-trips, “never panics”) | cargo nextest run --workspace |
| Doctests | ```rust blocks in /// docs | Documented API examples still compile and run | cargo test --workspace --doc |
| Fuzz | crates/core/ndn-packet/fuzz/ (libFuzzer) | Decoder robustness on adversarial bytes | cargo +nightly fuzz run <target> |
| Bench | benches/*.rs (criterion, 6 crates) | Performance, and that benches still compile | cargo bench --workspace |
| Interop | testbed/interop/*.sh (opt-in) | Behaviour against real external peers / sibling binaries | ./testbed/interop/run_all.sh |
The fast suite (the PR gate)
The default run is the whole in-repo behaviour suite:
cargo nextest run --workspace # ~1800 tests in ~25s
cargo test --workspace --doc # doctests (nextest does not run these)
cargo nextest runs each test in its own process, in parallel, isolated — so a
green run is a real signal. The suite finishes in ~25 seconds for ~1800
tests, which is only possible because timeout-dependent behaviour is tested
under a virtual clock rather than by sleeping — see
ADR 0004. This is the same work the CI
test job runs on every PR.
Two nextest profiles live in .config/nextest.toml:
| Profile | fail-fast | Retries | Slow-timeout | Extra |
|---|---|---|---|---|
default (local) | true — stop early so the break is at the bottom of your terminal | none | warn at 60s, kill at 3× | — |
ci (the gate) | false — report the full failure set | 1 — absorbs scheduler jitter in convergence/suppression-window tests; a pass-only-on-retry test is flagged FLAKY | warn at 120s, kill at 2× | JUnit → junit.xml |
Select the gate profile with cargo nextest run --workspace --profile ci.
What “integration” covers here
The in-process engine is the workhorse fixture: an integration test wires faces
(often InProcFace pairs), installs FIB routes, and asserts a Data comes back
for an Interest — no sockets, no sleeps. Representative files:
ndn-engine/tests/forwarding_conformance.rs, .../self_learning.rs,
.../congestion_feedback.rs; ndn-app/tests/node.rs,
.../rdr_round_trip.rs, .../secure_fetch.rs;
ndn-sync/tests/convergence.rs; ndn-mgmt/tests/notifications.rs.
Property tests
proptest generates random inputs to check invariants the old GREP-PROOFs only
gestured at. The wire crates carry a tests/props.rs each — e.g.
ndn-tlv/tests/props.rs (varu64_roundtrip_with_minimal_width,
read_tlv_never_panics), ndn-packet/tests/props.rs (interest_roundtrip,
data_roundtrip, name_uri_roundtrip), and ndn-sync/tests/props.rs
(psync_iblt_decode_never_panics).
Fuzzing the wire surface
The decoders are the untrusted-input boundary, so they are fuzzed with libFuzzer
under crates/core/ndn-packet/fuzz/ (a self-contained sub-workspace). Four
targets, each with a committed seed corpus under fuzz/seeds/<target>/:
| Target | Guards |
|---|---|
decode | Full Interest/Data packet decode |
name | Name / URI parsing |
tlv | The TLV varint / type-length codec |
mgmt_wire | NFD ControlParameters / ControlResponse decode |
Run one locally (nightly toolchain + cargo-fuzz):
cargo +nightly fuzz run decode crates/core/ndn-packet/fuzz/seeds/decode
The grown corpus (fuzz/corpus/) is git-ignored; only the seeds are committed.
Nightly CI runs each target for 5 minutes from its seeds.
Interop (external peers)
Some behaviour cannot be covered by an in-process test: talking to a real
Dockerized NFD / ndnd / NDNCERT CA / C++ PSync, or spawning sibling-repo
binaries (ndn-fwd, ndn-dashboard) across process/socket boundaries. Those
survived the witness-suite retirement as the explicitly opt-in
testbed/interop/ suite — deliberately not part of the PR gate:
./testbed/interop/run_all.sh # everything
./testbed/interop/run_all.sh psync ndncert # name-filtered subset
Each script exits 0 PASS / 1 FAIL / 2 SKIP (a missing prerequisite such as
Docker or a sibling checkout). Recorded evidence — including live interop pcaps
(c13_ndncert_live_interop_after.pcap, g04_nlsr_interop_after.pcap) — is kept
under testbed/transcripts/, and the frozen audit ledger is
testbed/EXPECTED_FAILURES.md. Some scripts still carry pre-split path rot and
must be revalidated before their class is wired into a scheduled job; this is
called out in testbed/interop/README.md.
The nightly deep lane
The PR gate stays fast by pushing everything slow or broad into
.github/workflows/nightly.yml (daily, plus manual workflow_dispatch):
| Job | What it does |
|---|---|
coverage | cargo llvm-cov nextest --workspace, emits lcov.info + a step summary |
feature-matrix | cargo hack check --each-feature --no-dev-deps over the wire/API crates — catches a feature combination that doesn’t compile |
macos | cargo nextest run --workspace on macos-latest (the ndrv face + kqueue paths) |
bench-smoke | cargo bench --workspace -- --test — runs each bench once to catch bench rot without trusting timing |
fuzz | cargo +nightly fuzz run <t> for all four targets, 5 min each from the seed corpora |
The PR gate itself (ci.yml)
For completeness, every job the ci.yml lane runs (all required, parallel,
cache-backed): lint (fmt + clippy, -D warnings), test (nextest ci
profile + doctests), docs (rustdoc, broken links are errors), msrv
(cargo check on 1.90), wasm32 (the forwarding chain up to ndn-engine),
no_std (the riscv32imc seed chain), deny (cargo-deny), dep-direction
(the spec-set-closed guard), and book (this wiki builds, internal links
resolve, {{#include}} snippets still compile). The lint and dependency policy
those jobs enforce is documented in
the contribution workflow.
What to add when you touch code
- New behaviour → an integration or unit test that exercises it.
- New decode path / wire field → extend the round-trip and property tests, and update the conformance matrix.
- New timeout-sensitive logic → test it under the virtual clock, not
sleep. - A new external-peer surface → an opt-in
testbed/interop/script, not a gate test.
Do not add a test that asserts how the source is written. If you find yourself grepping the codebase in a test, you are re-creating the mistake ADR 0005 retired.
Spec conformance matrix
ndn-rs speaks the real NDN wire format — the byte-exact TLVs of the NDN
Packet Format v0.3 and the surrounding specs, not an ndn-rs dialect. That is a
foundational decision recorded in
ADR 0001; this page is its evidence. For
each spec area it names the implementing crate and one or more real tests
(verify them with cargo nextest run -p <crate>) that pin the behaviour to the
spec.
Type numbers are the spec’s, verbatim
The TLV type-number assignments live in the tlv_type module of
crates/core/ndn-packet/src/lib.rs. A sample, showing they are v0.3’s numbers
and not a re-mapping:
| Field | Type | Field | Type | |
|---|---|---|---|---|
INTEREST | 0x05 | DATA | 0x06 | |
NAME | 0x07 | NAME_COMPONENT | 0x08 | |
ImplicitSha256Digest | 0x01 | ParametersSha256Digest | 0x02 | |
CAN_BE_PREFIX | 0x21 | MUST_BE_FRESH | 0x12 | |
NONCE | 0x0a | INTEREST_LIFETIME | 0x0c | |
HOP_LIMIT | 0x22 | APP_PARAMETERS | 0x24 | |
META_INFO | 0x14 | CONTENT | 0x15 | |
SIGNATURE_INFO | 0x16 | SIGNATURE_VALUE | 0x17 | |
INTEREST_SIGNATURE_INFO | 0x2C | INTEREST_SIGNATURE_VALUE | 0x2E | |
LP_PACKET | 0x64 | LP_PIT_TOKEN | 0x62 | |
VALIDITY_PERIOD | 0xFD | NOT_BEFORE / NOT_AFTER | 0xFE / 0xFF |
(A handful of non-spec extensions — SUBSCRIPTION_REQUEST, REFLEXIVE_NAME —
are marked provisional in source and live outside the standard number space.)
The mapping
Every test name below is a real #[test]/#[tokio::test] function; the file it
lives in is given so you can read the assertion. Paths are relative to the repo
root.
| NDN spec area | Crate | Representative tests (file) | Status |
|---|---|---|---|
| Packet Format v0.3 — TLV codec (varint, type-length, minimal-form, critical-type rules) | ndn-tlv | varu64_roundtrip_9byte, read_varu64_rejects_non_minimal_3byte (src/reader.rs); read_tlv_never_panics (tests/props.rs) | Conformant |
| Interest | ndn-packet | decode_with_all_fields, decode_with_forwarding_hint, decode_with_hop_limit (src/interest.rs); interest_roundtrip (tests/props.rs) | Conformant |
| Data | ndn-packet | decode_meta_info_freshness, implicit_digest_is_sha256_of_raw, signed_region_excludes_sig_value (src/data.rs); data_roundtrip (tests/props.rs) | Conformant |
| Signed Interest (§5.4) — AppParameters + InterestSignatureInfo/Value + ParametersSha256Digest | ndn-packet, ndn-security | decode_signed_interest_sig_info, a02_decode_rejects_app_params_without_psdc, a02_a21_decode_rejects_psdc_not_last (ndn-packet/src/interest.rs); c11_validate_signed_interest_returns_valid (ndn-security/src/validator/mod.rs) | Conformant |
| NDNLPv2 — LpPacket, fragmentation/reassembly, PitToken, Nack | ndn-packet, ndn-transport | encode_decode_lp_nack_roundtrip, n03_lp_decode_rejects_out_of_order_headers (ndn-packet/src/lp/decode.rs); multi_fragment_roundtrip, out_of_order_reassembly (ndn-packet/src/fragment.rs); lp_link_service_fragments_at_mtu (ndn-transport/src/link_service/mod.rs) | Conformant |
| Certificate v2 (§10) — NDN cert Data, ValidityPeriod, DER SPKI content | ndn-security, ndn-cert | c07_keychain_ephemeral_cert_name_has_four_trailing_components, c08_cert_content_body_is_der_spki, c18_cert_validity_period_is_iso8601_inside_signature_info (ndn-security/tests/cert_format.rs); n13_serialize_cert_returns_parseable_data_tlv (ndn-cert/tests/n13_serialize_data.rs) | Conformant |
| NDNCERT 0.3 — CA protocol, challenges, ECDH channel | ndn-cert | new_request_body_is_self_signed_cert, challenge_request_is_envelope_with_selected_challenge_first (src/client.rs); ecdh_key_agreement_produces_same_session_key (src/ecdh.rs); f7_default_policy_issues_cert (tests/f7_issuance_policy.rs) | Conformant |
RDR (Realtime Data Retrieval — 32=metadata) | ndn-app | metadata_roundtrip, metadata_name_appends_keyword (src/rdr.rs); fetch_object_reassembles_publish_object (tests/rdr_round_trip.rs); verified_fetch_object_rejects_unsigned (tests/rdr_verified.rs) | Conformant |
| Naming conventions (segmenting, versioning, typed components, URI) | ndn-foundation-types, ndn-packet | append_segment, version_roundtrip, keyword_component_roundtrip, a19_uri_roundtrip_canonical_typed_form (ndn-packet/src/name.rs); name_canonical_order_is_total (ndn-packet/tests/props.rs) | Conformant |
| SVS (State Vector Sync — v2 ndn-svs, v3 ndnd) | ndn-sync | encode_svs_data_byte_level, process_sync_accepts_new_boot (src/svs_local.rs); v2_roundtrip_ignores_boot, v3_roundtrip_carries_boot, dialects_are_not_cross_decodable (src/dialect.rs); hmac_rejects_tampered_state_vector (src/security.rs) | Conformant (v2 + v3) |
| PSync (IBF set reconciliation) | ndn-sync | ibf_cell_vector_matches_psync_cpp, reconcile_one_sided_difference, g03_reconcile_n20 (src/psync.rs); hash_name_matches_psync_cpp (src/psync_sync.rs); psync1_huge_count_rejected_without_allocating (src/psync_bloom.rs) | Conformant |
A recurring pattern in these tests is wire-compatibility against the reference
implementation: ibf_cell_vector_matches_psync_cpp and
hash_name_matches_psync_cpp assert byte-identical output with the C++ PSync;
encode_svs_data_byte_level pins the ndnd v3 encoding; dialects_are_not_cross_decodable
guards that a v2 vector never silently decodes as v3.
Robustness, not just round-trips
Wire fidelity includes rejecting malformed and adversarial input safely. The
a0x/n0x/w1 tests encode audit findings as behaviour:
a03_interest_decode_rejects_unknown_critical_tlv_in_body,
interest_decode_huge_length_does_not_panic (ndn-packet/tests/w1_overflow.rs),
n01_oversized_frag_count_does_not_allocate, and
psync1_huge_count_rejected_without_allocating all assert a decoder refuses or
bounds hostile input rather than panicking or over-allocating. The four
fuzz targets (decode, name, tlv,
mgmt_wire) extend this coverage continuously.
Interop transcripts
Byte-level tests prove ndn-rs matches the spec on paper; the
testbed/transcripts/ directory holds the evidence it matches real peers on the
wire. It is a flat archive of ~119 recorded interop runs (paired
*_before.txt/*_after.txt), including two live pcaps:
c13_ndncert_live_interop_after.pcap (NDNCERT against a live CA) and
g04_nlsr_interop_after.pcap (NLSR). These back the opt-in
interop suite; the frozen audit ledger is
testbed/EXPECTED_FAILURES.md.
Keeping the matrix honest
When you touch a wire path — a new TLV field, a new packet shape, a codec change
— add or extend the round-trip/property tests and add a row (or test name)
here. The CI book job fails on a broken link, and the test job fails on a
broken assertion, but neither notices a spec area that silently loses coverage.
That is a review responsibility; see
the contribution workflow.
The cross-repo contract
ndn-rs is the foundation of a workspace of sibling repositories — ndn-ext,
ndn-fwd, ndn-repo, ndn-sim, ndn-embedded, ndn-mobile, ndn-dashboard —
each a standalone cargo workspace that depends on ndn-rs crates by path. The
contract is the public API those siblings consume. Breaking it does not break
one crate; it breaks the workspace. This page names that surface so you know what
to treat with the most care.
The load-bearing edges are also drawn in the crate graph as the “consumed by sibling repos” satellites. This page makes the item-level detail explicit.
What “the contract” is
A change is contract-breaking if it alters a public item a sibling imports in
a way that stops the sibling compiling or changes its runtime behaviour: renaming
or removing a pub type/trait/fn, changing a signature, tightening a bound,
reordering enum variants a sibling matches on, or changing wire behaviour behind
a stable API. Adding items is safe; changing or removing existing ones is not.
Two mechanisms keep this honest:
build_all.sh(at thendn-workspaceroot, one level above ndn-rs) compiles every sibling in order against your working tree:
It loops# from the ndn-workspace root ./build_all.sh # cargo build across all siblings ./build_all.sh --all-features # extra cargo flags are forwardedndn-rs ndn-ext ndn-fwd ndn-mobile ndn-embedded ndn-repo ndn-simandcargo builds each. A redbuild_all.shafter an ndn-rs change is a contract break — even when ndn-rs itself is green.cargo-semver-checksruns in CI (thesemverjob inci.yml, on pull requests) to catch the break mechanically — it diffs the public API of the load-bearing crates against the PR’s merge-base and fails on a major-incompatible change, so a breaking edit is caught in the PR rather than in a sibling’s build.build_all.shis the complementary end-to-end backstop; run it whenever you touch a top-5 crate.
The top five: treat with most care
These are the crates the siblings reach into deepest. Change a pub item here
only deliberately, and run build_all.sh when you do.
| Crate | Key exported items | Consumed by |
|---|---|---|
ndn-packet | Name, NameComponent, Data, Interest, Selector, MetaInfo, Nack, NackReason, SignatureInfo, encode::InterestBuilder / encode::DataBuilder, tlv_type | All siblings — the universal core |
ndn-transport | FaceId, FaceError, FaceKind, FacePersistency, Face, Transport, LinkService, ForwardingAction, NackReason | ndn-ext, ndn-fwd, ndn-mobile, ndn-sim |
ndn-app | Node, Consumer, Producer, EngineAppExt, Subscription, Publisher, prelude | ndn-ext, ndn-mobile, ndn-sim, ndn-repo, ndn-dashboard |
ndn-engine | ForwarderEngine, EngineBuilder, EngineConfig, ShutdownHandle, pipeline::{ForwardingAction, PacketContext, DecodedPacket, …} | ndn-fwd, ndn-ext, ndn-sim |
ndn-security | Signer, Validator, KeyChain, SafeData, Verifier, TrustPolicy, SecurityManager, safebag::SafeBag, custodian (feature) | ndn-ext, ndn-mobile, ndn-dashboard, ndn-fwd |
Names in the table are verified against each crate’s src/lib.rs. Two nuances
worth remembering when you edit signatures:
InterestBuilder/DataBuilderare underndn_packet::encode, which is#[cfg(feature = "std")]-gated (also reachable viandn_app::prelude).EngineBuilder/EngineConfigare non-wasm only; the browser build usesWasmEngineBuilder/WasmEngineConfig. A change to the native builder should be mirrored there — the dashboard’s wasm engine depends on it.custodianinndn-securityis#[cfg(feature = "custodian")]and not re-exported at the crate root; theSafeBagtype lives inndn_security::safebag.
Dependency intensity
How deeply each sibling reaches into the top five (approximate use counts) —
a rough guide to who feels a break the hardest:
| Sibling | ndn-packet | ndn-transport | ndn-app | ndn-engine | ndn-security |
|---|---|---|---|---|---|
ndn-ext | heavy | heavy | heavy | heavy | heavy |
ndn-sim | moderate | light | moderate | heavy | — |
ndn-mobile | moderate | light | heavy | light | moderate |
ndn-fwd | moderate | moderate | light | light | moderate |
ndn-repo | moderate | light | light | light | light |
ndn-dashboard | light | light | light | light | moderate |
ndn-embedded | light | — | — | — | — |
Reading the table:
ndn-extis the heaviest consumer of every crate — it is where most extensions live, so a contract change is felt there first and worst. If you break something,ndn-extusually tells you.ndn-simleans onndn-engineandndn-app(it drives simulated forwarders) but pulls nondn-security— a security-only change cannot break it.ndn-embeddeddepends onndn-packetalone, consistent with ano_stdtarget: it pulls none of engine/transport/app/security. Anything you keepno_std-clean in the foundation crates keeps it building.
How to change the contract safely
- Prefer additive changes. New methods, new optional fields, new variants at the end — none of these break a sibling.
- When you must break it, do it deliberately and update the siblings in the
same logical change (they are separate repos, but the workspace builds as a
whole). Run
build_all.shfrom the workspace root before you consider it done. - Bump versions honestly. ndn-rs is pre-1.0; a breaking change to a top-five
crate is a minor bump, and the CI
semverjob will hold you to it. - Load-bearing decisions get an ADR. If a contract change encodes a design decision the workspace now depends on, record it — see about ADRs and the contribution workflow.
See also
- The crate graph — the same edges, drawn.
- The contribution workflow — the dependency-direction rule and the PR checklist.
Contribution workflow
This page is the checklist for landing a change in ndn-rs: the lint policy and
why it is shaped the way it is, the toolchain and MSRV discipline, the
dependency-direction rule, and a copy-pasteable command block that mirrors the CI
gate. Everything here is enforced by .github/workflows/ci.yml, so a change that
passes locally passes the gate.
The lint policy
Lints are set once, workspace-wide, in Cargo.toml under [workspace.lints];
every crate inherits them. CI escalates warnings to errors with
RUSTFLAGS: -Dwarnings, so “warn” here means “advisory while you hack, error
at the gate.”
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
unimplemented = "warn"
unsafe_code = "deny" is the load-bearing one. The protocol and forwarding
core is unsafe-free by policy — a memory-safety bug in a packet decoder or the
pipeline is a security bug, and denying unsafe there means the reviewer never
has to audit for it. Only the two OS-I/O leaf crates that genuinely need it —
ndn-face (raw sockets, sendmmsg/recvmmsg, AF_PACKET/ndrv/pcap FFI) and
ndn-frame-io — opt back in, each with an #![allow(unsafe_code)] scoped to
the module or function that owns the raw boundary. unsafe is walled off in
the crates whose whole job is to touch the OS, and forbidden everywhere else. The
one other exception is the linkme link_section in the strategy registry,
carried inside the register_strategy! expansion with a scoped #[allow] so
registrant crates stay clean.
dbg_macro, todo, and unimplemented are warned so a dbg! print or a
todo!() placeholder cannot reach main — they fail the gate.
Toolchain and MSRV
Two distinct versions, do not conflate them:
- Pinned toolchain (
rust-toolchain.toml): 1.96.0, withrustfmtandclippyand thewasm32-unknown-unknown+riscv32imc-unknown-none-elftargets. This is what every contributor and CI job builds with — it defines what rustfmt and clippy enforce. rustup auto-installs it on firstcargoinvocation. - MSRV (
rust-version = "1.90"inCargo.toml): the oldest compiler a consumer may use. The floor is edition 2024 (1.85); the binding constraint is fjall’s declaredrust-version(1.90). ThemsrvCI job runscargo check --workspaceon exactly this version.
MSRV discipline: do not reach for a std/language feature newer than 1.90 in a
crate a sibling consumes, or the msrv job goes red. Bump either version only
deliberately, and only with a matching CI run — the pin changes what the whole
project’s lints enforce.
The dependency-direction rule
The crate graph is a DAG, and it is enforced, not merely observed. The
spec-classified crates are closed under dependency: a spec crate may only
depend on other spec crates, so the wire/protocol layer can never accidentally
pull in an extension or app-layer crate. The dep-direction CI job runs
python3 testbed/dep-direction-guard.py; a new Cargo.toml edge that would
break the layering fails the build. If you need functionality from a higher
layer inside a lower one, invert the dependency with a trait (as ndn-mgmt does
with MgmtConfig) rather than adding the edge. See
the crate graph for the layers.
The pre-PR checklist
Before opening a PR, run the gate locally. This block mirrors the CI jobs in order:
# format + lint (CI escalates warnings to errors)
cargo fmt --all
RUSTFLAGS="-Dwarnings" cargo clippy --workspace --all-targets
# tests: the fast suite + doctests
cargo nextest run --workspace --profile ci
cargo test --workspace --doc
# docs: broken intra-doc links are errors
RUSTDOCFLAGS="-Dwarnings" cargo doc --workspace --no-deps
# the wiki builds and its {{#include}} snippets still compile
mdbook build docs/wiki
For a change that touches the cross-target or dependency surface, also run the jobs the gate runs for those:
cargo +1.90 check --workspace # MSRV
cargo check --target wasm32-unknown-unknown -p ndn-engine # wasm surface
cargo deny check # advisories + licenses
python3 testbed/dep-direction-guard.py # spec set closed
Then, the content checklist:
- fmt / clippy / nextest / doctests / rustdoc are green.
- New behaviour has a test that exercises it — behaviour, not source text (see the testing guide and ADR 0005).
- If you touched a wire path, the round-trip/property/fuzz tests are updated and you added a row or test name to the conformance matrix.
- If you changed a top-five public API, you ran
build_all.shfrom the workspace root — see the cross-repo contract. - If the change encodes a load-bearing decision, you wrote an ADR.
When to write an ADR
An Architecture Decision Record captures a decision the rest of the project now depends on — a wire-format choice, a layering constraint, a security posture, a determinism seam. If someone six months from now would ask “why is it built this way, and can I change it?”, the answer belongs in an ADR, not a commit message. The existing records (real NDN wire format, type-enforced verification, the sans-IO seed crates, the virtualized clock, retiring the witness suite) are the model. See about ADRs for the format and index.
Cross-repo care
ndn-rs is consumed by sibling repositories, and its public API is a contract:
breaking it breaks the workspace, not just one crate. Before changing a pub
item in ndn-packet, ndn-transport, ndn-app, ndn-engine, or
ndn-security, read the cross-repo contract — it
names the surface to treat with the most care and explains how to change it
safely.
See also
- The testing guide — layers, how to run, the philosophy.
- The cross-repo contract — the public API the siblings consume.
- About ADRs — when and how to record a decision.
Architecture Decision Records
An ADR captures one significant, hard-to-reverse decision: the context that forced it, the choice made, and the consequences accepted. It answers the question a future contributor asks when they find something surprising — “why is it done this way?” — without making them excavate the git history or interrupt whoever wrote it.
ADRs are immutable once accepted. A decision that no longer holds is not
edited; a new ADR supersedes it, and the old one is marked Superseded by NNNN. This is what makes the record trustworthy: it is a log of what was
decided and when, not a description of the current state (that is what the
rest of this book and the code are for).
When to write one
Write an ADR when a decision:
- is expensive to reverse (a wire format, a public trait shape, a cross-crate boundary), or
- will look wrong to someone who doesn’t know the context (why the PIT is uncapped, why verification is a type and not a function), or
- was contested — you chose A over a reasonable B and want the reasoning on record.
Do not write one for routine changes the code and tests already explain.
Format
Copy an existing ADR. Keep it short — context, decision, consequences, and the
alternatives you rejected with one line each on why. Number sequentially;
never renumber. Status is one of Proposed, Accepted, Superseded by NNNN.
The log
| # | Decision | Status |
|---|---|---|
| 0001 | Target the real NDN wire format, not a private dialect | Accepted |
| 0002 | Make data-centric verification compiler-enforced (SafeData) | Accepted |
| 0003 | Share native + embedded logic through sans-IO seed crates | Accepted |
| 0004 | Virtualize the clock behind a runtime seam for determinism | Accepted |
| 0005 | Retire the audit-witness scripts in favour of nextest | Accepted |
| 0006 | Draw the radio foundation boundary: backends + capability descriptors | Accepted |
| 0007 | Named-time crate boundary: no_std core, stamp types, provenance-as-type | Accepted |
0001 · Target the real NDN wire format, not a private dialect
Status: Accepted
Context
A clean-slate NDN stack could define its own packet encoding — simpler to implement, free to evolve. But NDN already has a published wire specification (NDN Packet Format v0.3) and a decade-old ecosystem of interoperating implementations: NFD and ndn-cxx (C++), python-ndn, NDNts (TypeScript), NDN-DPDK (Go), ndnd (Go), and a running global testbed. A private dialect would be an island.
Decision
Encode and decode the real NDN Packet Format v0.3 and NDNLPv2, byte for byte, and treat interoperation with the existing ecosystem as a correctness requirement rather than a nice-to-have.
Concretely, the type numbers in ndn-packet are the spec’s: Interest 0x05,
Data 0x06, LpPacket 0x64, the signed-Interest TLVs 0x2C/0x2E, and so on.
The NFD management wire codec in ndn-mgmt-wire is cross-checked against
ndn-cxx’s tlv-nfd.hpp; strategy names carry NFD’s /v=N version convention;
the trust-schema loader imports python-ndn’s LightVerSec binary format; and the
NDNCERT and sync codecs are validated against recorded interop transcripts
(testbed/transcripts/, including live pcaps).
Consequences
- Positive: ndn-rs forwarders and apps can, in principle, join the existing testbed and talk to NFD/ndnd peers. The conformance surface is checkable against an external spec, not just internally consistent (see the conformance matrix).
- Positive: the wire layer is a stable contract, which makes fuzzing and property-testing it worthwhile (they encode the spec’s invariants).
- Cost: we inherit the spec’s quirks — evolvability critical-bit rules, non-minimal-encoding rejection, the URI naming conventions — and must implement them faithfully even where a private format would be simpler.
- Cost: experimental/provisional TLV types must be chosen in the even (non-critical) range so stock forwarders ignore rather than reject them.
Alternatives considered
- A private, simpler encoding. Rejected: it would forfeit the entire reason to build an NDN stack — interoperation with the data-centric ecosystem — for a one-time implementation saving.
- Spec-compatible “mostly”. Rejected: partial compatibility is the worst of both worlds; a peer that almost interoperates fails in the field in ways that are expensive to debug.
0002 · Make data-centric verification compiler-enforced (SafeData)
Status: Accepted
Context
NDN’s central security thesis is secure the data, not the channel: every Data packet is signed, and a consumer must verify that signature against a trust anchor before trusting the bytes. In the reference stacks this is a discipline — the API hands you the Data and you are expected to call the validator. Nothing stops you from skipping it, and “did I actually verify this?” is answerable only by reading the call site.
For a security-first stack that is too weak. Unverified data flowing into application logic is the single most likely security bug, and it should not be possible to write it by accident.
Decision
Make verification a type transition the compiler enforces, in
ndn-security:
- The trusted payload type is
SafeData. Its constructor is crate-private — the only way to obtain one is to pass aValidatorand have the signature check succeed. - The consumer surface returns
Unverified<Data>, which has exactly two exits:.verify(validator)(yieldsSafeDataor an error) or the deliberately loud, greppable.trust_unchecked(). There is no silent path to usable bytes. - A bare SHA-256 digest does not count as authentication:
verifyrejectsDigestSha256-only packets withUnauthenticatedDigestunless the caller explicitly opts in. Integrity is not identity. - Validators default to deny (hierarchical trust schema), never accept-all.
Consequences
- Positive: “unverified data used as if trusted” becomes a type error, not a
code-review finding. Auditing reduces to grepping for
trust_unchecked. - Positive: the type discipline is strongest at the app/consumer boundary,
where
SafeDatais the only currency. Inside the forwarder the equivalent guarantee is enforced dynamically — aPacketContext::verifiedflag set by the validation stage, with Data dropped on a validating face if it isn’t set — rather than by the type, because the pipeline moves wire bytes, notSafeDatavalues. - Cost: the API is slightly more ceremonious than “fetch returns bytes” — every consumer path names the verification step. This is intended friction.
- Cost: an escape hatch must exist (
trust_unchecked) for the genuinely unauthenticated cases (opportunistic caching, digest-addressed content); its visibility is the mitigation.
Alternatives considered
- Validator-as-function, verification-by-convention (the ndn-cxx model). Rejected: it makes the safe path and the unsafe path look identical at the call site.
- Mandatory verification with no escape hatch. Rejected: some NDN patterns (digest-named immutable content, in-network caching of not-yet-trusted data) legitimately handle unverified bytes; forbidding it outright would push users to unsafe work-arounds that hide the fact.
0003 · Share native + embedded logic through sans-IO seed crates
Status: Accepted
Context
ndn-rs targets everything from datacenter forwarders to bare-metal
microcontrollers. The native engine is async (tokio), allocates freely, and
uses DashMap/RwLock. A microcontroller forwarder is #![no_std], often
no-alloc, single-threaded, and has no async runtime. Naively these are two
separate implementations of the same protocol — and two implementations of
“longest-prefix match” or “is this Data still fresh?” will drift apart, and the
bugs will differ per target.
Decision
Extract the pure, sans-IO protocol logic — the parts that are neither async
nor I/O-bound nor allocation-dependent — into #![no_std] “seed” crates that
both the native and embedded builds depend on:
ndn-fwd-core— the forwarding rules: FIB longest-prefix-match selection, freshness predicates (absolute and wrapping-relative), conformance checks, pipeline seeds. No async, no I/O, no tracing.ndn-crypto-core— the security primitives: one Ed25519 sign/verify and one ChaCha20-Poly1305 AEAD, no-alloc, shared byte-for-byte.
The wire layer (ndn-tlv, ndn-packet, ndn-foundation-types) is likewise
no_std+alloc so the same encoder/decoder runs on both. The native engine
wraps these rules in its async, sharded machinery; the embedded forwarder wraps
the same rules in a synchronous, heapless loop.
Consequences
- Positive: a forwarding-rule bug is fixed once and both targets get the fix. The rules have one test suite, not two.
- Positive: the boundary is enforced in CI — a dependency-direction guard
keeps the
spec-classified crates closed under dependency, and the riscv32no_stdbuild compiles the seed chain on every PR, so an accidentalstdortokioedge into a seed crate breaks the build immediately. - Cost: the seed crates are constrained — no async, no allocation in the
hot predicates, no convenient
stdtypes. Writing there is more work. - Cost: some logic that could be shared isn’t worth the constraint and is duplicated deliberately; that trade-off is made case by case.
Alternatives considered
- One
stdimplementation, no embedded target. Rejected: embedded NDN (IoT, BLE meshes) is a first-class use case, not an afterthought. - Two independent implementations. Rejected: guaranteed drift between the native and embedded forwarding decisions, with per-target bugs that are miserable to reconcile.
- A
std/no_stdcfg-split inside one crate. Rejected for the core rules: the cfg soup obscures which logic is actually shared; a separate crate makes the shared surface explicit and testable in isolation.
0004 · Virtualize the clock behind a runtime seam for determinism
Status: Accepted
Context
A forwarder is full of time: PIT entries expire, nonces age out of the dead-nonce list, strategies retry after delays, faces time out, sync suppresses on jittered timers. When every one of these reads the system clock directly, the engine’s behaviour is non-deterministic — tests that exercise timeouts are flaky, and a multi-node simulation cannot be replayed. “Sleep and hope” appears in the tests, and CI pays for it in both wall-clock and trust.
Decision
Route every engine time read through a single seam: the Now trait in
ndn-runtime, which distinguishes monotonic now() (deadlines, expiry) from
wall-clock unix_nanos() (timestamps on the wire). The native TokioRuntime
reads the real clock; a virtual runtime overrides now() to return logical
time driven by an AtomicU64, so a test or simulator can advance time
explicitly and deterministically.
The forwarding path threads a packet’s arrival timestamp (ctx.arrival)
instead of re-reading the clock at each stage, so all of a packet’s
time-derived decisions are anchored to one consistent instant. Background tasks
take an injected now.
Consequences
- Positive: timeout-dependent behaviour is testable without sleeping — the test advances logical time and asserts. This is why the full suite runs in ~25 seconds.
- Positive:
ndn-simruns the realForwarderEngineagainst a virtual clock, so a simulated multi-node run is deterministic and replayable — the simulator is not a separate mock of the forwarder. - Positive: on wasm32 the same seam swaps in a
web-timeclock, so the engine builds and runs in the browser. - Cost: contributors must resist reaching for
SystemTime::now()/Instant::now()directly in engine code; the correct source isruntime.now()orctx.arrival. Direct clock reads in the forwarding path are a review red flag.
Alternatives considered
- Mock the clock only in tests (e.g. inject a fake in test builds). Rejected: it leaves production code reading the real clock, so the engine isn’t deterministic — only the test harness is — and the simulator can’t reuse it.
- A global test clock. Rejected: global mutable time breaks parallel test execution, which is exactly what nextest relies on for speed.
Status note
The seam is complete across the forwarding path, background tasks, the
Nack-path out-records, the discovery clock, and — as of the A+ polish pass —
the FaceState activity timestamps that the idle-face reaper compares against.
FaceState::new/touch now take a now_ns sourced from runtime.unix_nanos()
at every call site, so face expiry is deterministic under a virtual runtime.
The one remaining direct SystemTime::now() in the engine is unix_time_ms(),
which stamps the process start time in ForwarderStatus — a genuine
wall-clock value that should not be virtualized.
0005 · Retire the audit-witness scripts in favour of nextest
Status: Accepted
Context
Early ndn-rs development accumulated a testbed/tests/audit/ directory of ~290
shell “witness” scripts, indexed by a status ledger (EXPECTED_FAILURES.md).
Each script tied a named audit finding to a check. Two problems emerged:
- Most scripts were one of two low-value shapes. ~45 were GREP-PROOF — they
asserted that some source text was present or absent (e.g. “the string
experimentalno longer appears near the BLAKE3 constant”). That tests the code’s spelling, not its behaviour, and silently rots: after the monorepo split many grepped paths that no longer exist and passed vacuously through2>/dev/null. Another ~215 were thin wrappers aroundcargo test -p <crate>, fully redundant with the Rust suite they invoked. - None of them ran in CI, and the whole thing was slow and did not inspire confidence — precisely the opposite of what a test suite is for.
Decision
Make cargo nextest the single source of truth for in-repo behaviour, and
retire the witness system:
- Delete the GREP-PROOF and cargo-test-wrapper scripts. The Rust tests they wrapped stay; the source-text assertions are discarded as untrustworthy.
- Preserve the history:
EXPECTED_FAILURES.mdis frozen verbatim as the audit ledger (every finding, severity, and resolution remains on record), and all recorded transcripts — including live interop pcaps — are kept undertestbed/transcripts/. - Keep the genuinely-unique scripts — those that test against real external
peers (Dockerized NFD/ndnd/NDNCERT/C++ PSync) or spawn sibling-repo
binaries across process/socket boundaries — as an explicitly opt-in
testbed/interop/suite, since nextest cannot cover them. - Add property-based tests and fuzzing for the wire surface (the invariants the GREP-PROOFs gestured at but could not actually check).
Consequences
- Positive: the default test signal is fast (~25s for ~1800 tests), parallel, isolated, and runs on every PR. A green run means something.
- Positive: behaviour is tested by exercising behaviour, not by grepping source; the tests survive refactors that move code around.
- Positive: the audit findings are not lost — they are archived where they belong (a ledger and transcripts), not masquerading as a live suite.
- Cost: the external-interop scripts still carry pre-split path rot and must
be revalidated before their class is wired into a scheduled job. This is
called out in
testbed/interop/README.md.
Alternatives considered
- Fix the 290 scripts in place. Rejected: the GREP-PROOF shape is unfixable — it tests the wrong thing by construction — and fixing the cargo-test wrappers just re-creates the Rust suite in bash.
- Delete everything, including the ledger. Rejected: the audit findings and interop transcripts are real, hard-won evidence; discarding the provenance would lose genuine value along with the dead scripts.
0006 · The radio foundation boundary
Status: Accepted
Context
The named-data radio work spans many crates: ndn-frame-io (the L2 frame I/O
substrate), ndn-signals-core (the cross-layer signal plane), a userspace
Wi-Fi driver that implements four USB chipsets, a Wi-Fi Aware (NAN) stack, a
cognitive rate/power planner, a discrete-event radio simulator, and — coming —
a trusted-time protocol and non-RF bearers (IR, optical/VLC). Without a written
boundary, each leaf crate has to guess what belongs in the shared substrate
versus its own code, and the substrate’s closed types (the FrameFormat enum)
become a per-consumer bolt-on point (Raw80211 was the second such addition).
The codebase already made the core commitment — ndn-frame-io’s own header
says “backend-agnostic link-layer frame I/O; device-specific drivers live with
their face crate and implement FrameIo against this surface” — but the
principle wasn’t stated as a boundary contract. This ADR makes it one.
Decision
Hardware specificity lives in exactly two places: backend implementations below a trait, and honest numbers advertised through capability descriptors. Everything above consumes the numbers, never the hardware.
Concretely, the boundary is:
The foundation (ndn-frame-io, ndn-signals-core) owns | Leaf crates own |
|---|---|
The FrameIo device trait (async inject/recv) | The backend impl FrameIo per device (chipset registers, USB rings, RXWI parse) |
| radiotap parse + TX-header build | Chipset-specific init, TXAGC/EDCCA calibration |
The FrameFormat set + the Raw80211 passthrough escape hatch | Frame internals above the substrate (e.g. NAN management-frame bodies) |
The single-stream 20 MHz MCS→rate table (mcs_for_rssi, mcs_phy_rate_bps) | Calibrated/MIMO/wide-channel rate models, if ever needed, as their own thing |
The signal taxonomy (SignalView/SignalStore, LinkSignals, units) | Concrete signal sources (GNSS, RTC, sensors) pushing into a SignalStore |
CapturedFrame’s optional-hint shape (addr, group, rssi, mcs, …) | Interpretation of those hints for a specific protocol |
Two rules make the boundary hold:
- Extend by escape hatch, not by enum growth. A new medium or
management-frame protocol uses
Raw80211(substrate prepends radiotap and injects the frame verbatim; the consumer owns the whole frame) rather than adding aFrameFormatvariant. New variants are reserved for genuinely new body framing the substrate must build/parse itself. - Capabilities are advertised numbers, not feature flags. A backend that can stamp receive time to ±100 ns, or transmit with bounded delay, says so through a descriptor with an honest number; generic logic upstream (rate selection, and — see below — time combining) reads the number and adapts. This is how “dynamically adaptive to capabilities” is realized without the protocol ever naming a chipset feature like TSFT or EDCCA-ignore.
Consequences
- Positive: the Wi-Fi work is the first implementation of each seam, not a
load-bearing assumption. ndn-sim already proves this — it consumes the rate
table and drives
FrameIoon the determinism seam without forking the substrate; the four chipset drivers each implement one trait. - Positive: leaf authors have a decision rule (“own your frame internals
above
Raw80211; advertise your hardware’s numbers; don’t touch the core enum”), so the substrate stops accreting per-consumer variants. - Positive: it de-risks the incoming trusted-time and non-RF-bearer work.
A per-frame timing observation belongs on
CapturedFrameas one more optional hint — a stamp carrying its value, an explicit clock-domain id (a TSF counter, a PHC,CLOCK_MONOTONIC, and a PIO cycle counter are different timelines — a stamp without a domain is a bug generator), a latch point, and an honest precision. That follows the exactOption<hint>patternrssi_dbm/mcs_indexalready use. The protocol logic (time combining, false-ticker rejection, ranging) then lives in a bearer-agnostic crate that consumes stamps by precision, not by medium. - Cost: the capability-descriptor discipline requires shaping each new trait against at least three genuinely different backends before it is frozen (e.g. hardware TSF stamp, software fallback stamp, PIO cycle stamp) — a trait shaped around one backend is that backend’s API in a costume.
- Cost: the escape hatch can be over-used.
Raw80211is correct when the consumer owns the whole frame; it is a smell if a consumer uses it to avoid contributing framing the substrate should own for reuse.
Alternatives considered
- A
FrameFormatvariant per medium/protocol. Rejected: it makes the core enum a shared mutable bottleneck every consumer must PR into, and couples the substrate’s release cadence to every leaf protocol. - A lowest-common-denominator frame API with no capability descriptors. Rejected: it would erase exactly the hardware advantages (hardware timestamps, bounded-delay TX, CSI) that the radio work exists to exploit. The point is to surface capability, honestly, not to hide it.
0007 · The named-time crate boundary
Status: Accepted
Context
Named-time is a non-standard ndn-rs extension: a subsystem that carries
trusted, uncertainty-bounded time over the same faces, security model, and
cognitive control pattern as the rest of the stack (the design lives in the
named-time document). Building it raised four boundary questions that a
future contributor would otherwise have to reverse-engineer from the code.
Decision
1. The pure core is a no_std crate; concrete sources are a separate
extension. ndn-time ([scope] = spec, no_std, no-alloc) holds the
taxonomy and the math: TimeInterval, ClockCapability/Holdover,
Measured/MeasurementProvenance, LinkStamp/ClockDomainId/LatchPoint,
the Marzullo combiner, and the soft→hard ratchet. ndn-time-sources
([scope] = extension, in ndn-ext) will hold the I/O backends (GNSS/RTC/NTP
shims, the peer-derived source). This mirrors the existing
ndn-signals-core / ndn-signal-sources split, and it means the entire
protocol core is unit-testable with a simulated clock and runs on a
microcontroller.
2. The stamp types live in ndn-time, not ndn-frame-io. The design sketch
was in tension here — it said “ndn-time owns ClockDomainId” and “the stamp
types land in ndn-frame-io.” The resolution follows from the layers:
ndn-time must be no_std, but ndn-frame-io is std+tokio (raw sockets,
async FrameIo). The only valid dependency direction is therefore
ndn-frame-io → ndn-time. So LinkStamp/ClockDomainId/LatchPoint are
defined in ndn-time, and when Cut 1 lands, ndn-frame-io gains a dependency
on ndn-time and CapturedFrame gets an Option<LinkStamp> field — the stamp
type here, the field there. Both crates are spec, so the dependency
respects the dep-direction guard.
3. Provenance is a type, not a convention (principle P6). A measurement is a
Measured<T> carrying both its noise (sigma_ns) and its adversary exposure
(MeasurementProvenance: distance-bounded? replay-protected? authenticated?).
This is the named-time analogue of SafeData (ADR 0002): just as unvalidated
data cannot reach forwarding, an unbounded/unauthenticated/replayable
measurement cannot be treated by the combiner as equal to a
bounded/authenticated/fresh one. Admission (provenance::admits) reasons over
the exposure as a lattice with threat-diversity (distinct keys for Sybil,
distinct paths or a real distance bound for relay), never a checkmark count.
4. Marzullo is robustness; the trust schema is admission. The combiner
rejects a minority of false tickers but is defeated by a fabricated majority,
so it must never be the security boundary. Admission — which keys may speak for
time — happens upstream in the LVS trust schema (ndn-security); the combiner
assumes its inputs are already admitted. The crate documents and tests this
boundary (a test asserts a fabricated majority wins Marzullo, precisely so the
property can’t be silently regressed into a false sense of safety).
Consequences
- Positive: the whole time core is pure,
no_std, no-alloc, and hardware- free — 38 tests including property tests, and it compiles for riscv32no_std. The security-critical logic (provenance lattice, ratchet fail-closed/append-only) is unit-testable without a radio. - Positive: the boundary with the radio foundation (ADR 0006) is clean:
LinkStampis one moreCapturedFrameoptional hint, and it carries an explicit clock domain so cross-domain stamps are never silently subtracted. - Landed: Cut 1 (
CapturedFrame.stamp: Option<LinkStamp>) is wired.CapturedFramelives inndn-radio-hal(a later refactor moved the radio contract there), so it isndn-radio-halthat gained thendn-timedependency; the AF_PACKET receive path builds aMacDonestamp from radiotap TSFT (keyed by the NIC’s ifindex as its clock domain), and the loopback bus is honestlyNone. In practice this was non-breaking: no sibling constructsCapturedFrame(they only read its fields), so all consumers built unchanged. - Cost:
ndn-radio-haldepending onndn-timereads slightly “backwards” (a link-layer contract crate depending on a time crate). It is acceptable becausendn-timeis a tinyno_stdprimitives-and-math crate with no I/O, consumed only for the stamp vocabulary.
Alternatives considered
- Put the stamp types in
ndn-frame-io. Rejected: it would forcendn-timeto depend on astd+tokio crate, breaking itsno_stdguarantee and its embedded target — the opposite of the layering the stack is built on. - Fold provenance into
sigmaas a single “quality” scalar. Rejected: it collapses distinct threat exposures (relay vs Sybil vs replay) whose failure modes differ, which is exactly the mistake (“signed = trusted”) the threat model exists to prevent. - One
ndn-timecrate that also does the I/O. Rejected: it would dragstd/async into the pure core and make the combiner and ratchet un-testable on a microcontroller, against the sans-IO seed-crate pattern (ADR 0003).
v0.1.0 release boundary
This page is the release-candidate boundary for v0.1.0. It describes what should become stable when the tag is cut; it is not evidence that the tag has already shipped.
v0.1.0 is scoped to the spec-aligned core plus the operator tooling needed to build, run, and verify that core. Extension and research work stays available in the workspace, but does not inherit the same stability promise unless called out below.
Stability promise
| Scope | v0.1.0 status | Promise |
|---|---|---|
spec | In release boundary | Wire behavior and public APIs should follow the cited NDN specs and remain SemVer-stable across v0.1.x. |
tooling | In release boundary where it supports the core | CLIs keep sensible exit codes, help text, and machine-readable output stable across v0.1.x. |
extension | Out of stable boundary by default | Useful engineering surfaces; may change while the core settles. |
draft / research | Out of stable boundary | Exploratory code. Buildable does not mean stable. |
Candidate contents
Develop tier
For application authors. The stable promise is the umbrella crate
ndn-rs-prelude with library name ndn:
Consumer::fetchandConsumer::fetch_object.Producer::publish_objectandResponder.Subscriberas a read-only multi-publisher stream.KeyChain,SigningInfo, and the packet/name builders needed by ordinary applications.IpcConnectionfor an external forwarder andInProcConnectionfor embedded-engine tests.
Extend tier
For protocol, strategy, and face authors:
Strategywithregister_strategy!and scheduled events.RoutingProtocol/RoutingHandlewith typed status.Transport + LinkService = Face, including the NDNLPv2LpLinkService.DiscoveryProtocol,MgmtModule,NotificationStream,TrustPolicy,ValidationPolicy,Signer, andVerifier.
Instrument tier
For researchers and measurement tooling. The tier is opt-in via
experimental-instrument and intentionally looser than Develop or
Extend:
- Engine table accessors for FIB, RIB, PIT, CS, strategy table, measurements, routing, and discovery context.
TapFace,CallbackFace, and custom in-process face kinds.ContextEnricherand tracing target taxonomy.
Forwarder and core tooling
ndn-fwdstandalone forwarder.ndn-toolsbinaries:ndn-peek,ndn-put,ndn-ping,ndn-sec,ndn-ctl,ndn-traffic, andndn-iperf.enroll-ndncertfor enrollment flows.ndn-bench,ndn-otel-bridge, and the audit/testbed scripts as operator and verification tooling.
Out of stable boundary
These areas may be built, documented, or demoed in the repository, but should not be treated as v0.1-stable unless promoted by a later release note:
- Browser and SharedWorker engine topology, WebRTC/WebBLE, and browser-specific persistence.
- Embedded/mobile/FFI surfaces.
- In-network compute, network coding, CCLF, ABE, simulation, and research substrates.
- Dashboard UX and TrustContext/onboarding flows currently under active development.
Release blockers
The release-audit and interop blockers have been cleared in the 2026-05-28 readiness pass:
- The tracker-driven audit harness
RESULTS_DIR=/private/tmp/ndn-audit-results bash testbed/tests/audit/run_all.shreported 54 PASS / 0 FAIL / 0 SKIP, with 0 divergences. Transcript:testbed/tests/audit/transcripts/release_audit_run_all_after.txt. - The cross-implementation interop harness from the
interopimage reported 8 passed, 0 failed, 0 skipped. - The release docs and
testbed/EXPECTED_FAILURES.mdnow agree on the closed blocker set.
Remaining pre-tag checks are the normal repository-wide verification floor below, plus any maintainer review needed for the already-dirty worktree.
Recently closed blockers:
A.15KeyLocator rules now passa15_keylocator_rules.sh.C.09SafeBag portability now passes against referencendnsec.C.12management command signing and fresh dataset reads now pass Rust and Docker NFD witnesses.C.13NDNCERT enrollment now passes the live upstream CA witness.D.02/localhopscope now passes remote-drop/local-Unix-accept live interop; the witness waits for the local producer route before fetching.E.04management datasets now pass version/segment/FinalBlockId live interop with retry for dataset availability.G.03,G.04, andG.06now pass PSync, NLSR, and AutoConfig live interop witnesses.N.05Nack without reason now passesn05_nack_no_reason.sh.N.02andN.09now include live UDP shared-medium witnesses.- The interop image now fails its build if
ndnpeek,ndnpoke,ndncat, or the ndn-rs peer tools are absent.
Verification floor
Before tagging, run at least:
cargo build
cargo clippy -- -D warnings
mdbook build docs/wiki
Run scoped tests or audit witnesses for the crates touched by release blocker fixes. A full workspace test run is intentionally not the default development loop for this repository.
Post-v0.1 candidates
- Develop-tier publish into a sync group.
- Custom ContentStore example and non-default implementation witness.
- Structured packet-trace export for
TapFace. - Promotion criteria for selected extension crates once their APIs and threat models settle.