Merge branch 'master' into next

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Johnathan Corgan
2026-08-11 07:21:10 +00:00
5 changed files with 826 additions and 5 deletions
+81 -4
View File
@@ -138,15 +138,92 @@ jobs:
run: cargo install cargo-zigbuild --version 0.19.8 --locked
- name: Install zig (required by cargo-zigbuild)
shell: bash
run: |
set -euo pipefail
ZIG_VERSION="0.13.0"
# Each arch carries the expected SHA-256 of its upstream tarball,
# taken from ziglang.org's own https://ziglang.org/download/index.json,
# field .["<version>"]["<arch>-linux"].shasum:
# jq -r '.["0.13.0"]["x86_64-linux"].shasum' index.json
# Upstream publishes no .sha256 sidecar and no SHA256SUMS, so
# index.json is the only checksum document offered, and it lists only
# recent releases: once a version ages out of it the pin can no longer
# be re-derived upstream. Bumping ZIG_VERSION means replacing every
# hash below, and adding an arch means adding its hash here too.
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ZIG_ARCH="x86_64" ;;
aarch64|arm64) ZIG_ARCH="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
x86_64|amd64)
ZIG_ARCH="x86_64"
ZIG_SHA256="d45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea"
;;
aarch64|arm64)
ZIG_ARCH="aarch64"
ZIG_SHA256="041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt
if [ -z "${ZIG_SHA256:-}" ]; then
echo "No SHA-256 pinned for zig ${ZIG_VERSION} on ${ZIG_ARCH}."
echo "Add one to the case above, from https://ziglang.org/download/index.json:"
echo " jq -r '.[\"${ZIG_VERSION}\"][\"${ZIG_ARCH}-linux\"].shasum'"
exit 1
fi
NAME="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz"
URL="https://ziglang.org/download/${ZIG_VERSION}/${NAME}"
# Stage outside the checkout so a failed attempt cannot leave a stray
# tarball in the working tree.
ZIG_TMP="$(mktemp -d)"
trap 'rm -rf "$ZIG_TMP"' EXIT
TARBALL="${ZIG_TMP}/${NAME}"
# Download to a file and check it before anything consumes it: piping
# curl straight into tar let a truncated transfer reach the extractor,
# which is how this step failed. curl's own --retry does not cover a
# short read (exit 18), and a checksum mismatch needs a fresh download
# anyway, so the retry is an explicit bounded loop.
verified=""
previous=""
for attempt in 1 2 3; do
rm -f "$TARBALL"
if curl -fsSL -o "$TARBALL" "$URL" && [ -s "$TARBALL" ]; then
actual="$(sha256sum < "$TARBALL" | cut -d' ' -f1)"
if [ "$actual" = "$ZIG_SHA256" ]; then
echo "zig tarball matches its pinned SHA-256 (${actual})"
verified=yes
break
fi
echo "zig tarball failed its checksum on attempt ${attempt}:"
echo " expected ${ZIG_SHA256}"
echo " actual ${actual}"
echo " size $(wc -c < "$TARBALL") bytes"
if [ "$actual" = "$previous" ]; then
echo "Two attempts fetched byte-identical content, so retrying is not"
echo "going to help: the pin is stale, upstream re-published, or the"
echo "source is serving the same bad file every time."
break
fi
previous="$actual"
else
echo "zig tarball download failed on attempt ${attempt}"
fi
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 10))
fi
done
if [ -z "$verified" ]; then
echo "zig ${ZIG_VERSION} (${ZIG_ARCH}) did not download with its pinned"
echo "SHA-256 after ${attempt} attempt(s). Refusing to extract a tarball"
echo "that does not match the pin; failing the build."
exit 1
fi
sudo tar xJ -C /opt -f "$TARBALL"
sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
zig version
+176
View File
@@ -114,6 +114,57 @@ with v0.4.x or earlier peers.
additionally wired into the Noise XX handshake cluster
(msg1/msg2/msg3) and the rekey-initiator outbound sites on `next`.
- OpenWrt 802.11s open-mesh backhaul: router-to-router radio links with FIPS
providing all encryption, authentication and routing over bare L2 neighbor
links. The mesh runs open with `mesh_fwding 0`, since SAE would duplicate the
Noise layer and force ath10k raw mode, and FIPS's spanning tree is the
routing layer. `fips-mesh-setup` is an opt-in UCI helper creating a per-radio
mesh point (`radio0` to `fips-mesh0`, `radio1` to `fips-mesh1`, with a
free-index fallback and a collision guard); radio setup stays opt-in because
a package must not commandeer radios on install. A dual-band router gets one
instance per radio, and FIPS treats the two paths as failover rather than
multipath: cross-connection resolution keeps one active link per peer and the
second band stands by, re-establishing after keepalive timeout. The shipped
`fips.yaml` carries the `mesh0`/`mesh1` Ethernet-transport entries commented
out, so a stock install that never creates them logs no per-boot
interface-missing warning; the helper uncomments the matching block when it
creates the interface and re-comments it on remove (#123).
- OpenWrt open `!FIPS` access SSID, stacked on the mesh backhaul above: every
FIPS router broadcasts the same open SSID, forming one standard ESS that
phones and laptops save once and roam between natively, with the Noise
handshake as the only security layer. The leading `!` sorts it to the top of
alphabetically ordered network pickers, and the encryption type must be
uniform across routers or clients treat the ESS as different saved networks.
`fips-ap-setup` is an opt-in UCI helper creating the `fips-ap0` open AP on an
isolated network with a static ULA /64 and RA-only odhcpd addressing —
stateless SLAAC with no DHCP, the minimum that satisfies Android's
provisioning check — behind a locked-down `fips_ap` firewall zone with no
path to `br-lan` or the WAN, reaching only ICMPv6, mDNS and the FIPS
transports. There is no internet by design, so phones keep cellular as their
default route (#126).
- Android-ready core: the daemon's desktop transports and TUN operations are
gated by `target_os` rather than by Cargo features, so a plain `cargo build`
compiles for every target with no flags and Android self-excludes the raw
Ethernet transport as Windows already did. `Node::enable_app_owned_tun()`
gives an embedder that owns the TUN file descriptor — an Android
`VpnService`, for instance — a channel pair for exchanging IPv6 packet bytes
with FIPS instead of FIPS creating a system TUN device, and `start()` then
performs no system-TUN or `CAP_NET_ADMIN` operations. Packets entering this
way bypass `handle_tun_packet`, so the embedder must push only
`fd00::/8`-destined packets and clamp TCP MSS on outbound SYNs. Desktop
builds are unchanged and no Cargo features are introduced.
- A bounded graceful-shutdown drain phase, controlled by the new
`node.drain_timeout_secs` (default 2s). On the shutdown signal the node
broadcasts Disconnect to all peers and then keeps serving for that window,
exiting early once all peers are gone, so in-flight traffic settles and peers
observe the disconnect before the transports close, where previously teardown
was immediate. The published node state gains a `Draining` variant visible
via control queries during the window. The immediate stop path used by
non-daemon callers is unchanged.
- FreeBSD support for the daemon, `fipsctl`, and `fipstop`: native TUN
datapath (TUNSIFHEAD address-family framing, kernel-assigned `tunN`
device name as with `utun` on macOS), clean service teardown,
@@ -165,6 +216,30 @@ with v0.4.x or earlier peers.
### Changed
- Node health is determined at start completion instead of unconditionally
reaching a single running state. **Zero transports up is now fatal**: the
node tears down cleanly and the daemon exits with an error, where it
previously came up and served nothing. Any configured optional child that
failed to start — a transport beyond the first, Nostr, mDNS, TUN, DNS, or a
worker pool — leaves the node degraded but serving, with a warning naming
what failed, and all configured children up is full health. A child the node
was never asked to run does not count against it. The published node state
gains `Degraded` and `Failed`, both visible via control queries, with
degraded operational and failed not. Exit detection for the DNS task, the two
TUN threads, mDNS and Nostr also re-evaluates health at runtime, so a child
that dies after a healthy start now shows as degraded; transports and worker
pools expose no runtime-exit signal yet and are unchanged.
- A connected UDP socket that cannot open now names the syscall that failed and
the address it was operating on, the local address for `bind` and the peer
address for `connect`. Both paths previously returned a bare OS error that
the caller wrapped identically, so a field report of `Address already in use`
could not be attributed to either, and the two have entirely different
causes: on Linux a UDP `connect(2)` to a 4-tuple another socket already holds
returns `EADDRINUSE`, which is not the same fault as `bind` refusing the
local address. A node at roughly 245 peers was emitting this three times a
second across nine peers with no way to diagnose it.
- Connected UDP peer drains now batch macOS receives with `recvmsg_x(2)`,
matching the wildcard UDP receive path instead of issuing one `recv(2)`
syscall per queued datagram.
@@ -227,6 +302,53 @@ with v0.4.x or earlier peers.
folded into the new tables with a one-time deprecation warning; migrate your
`fips.yaml` to the new keys.
- Config validation now rejects two `node.rekey` settings that appear to
disable the trigger and in fact fire it continuously. `after_messages` of
zero makes the message-count arm true on every poll, because the trigger
compares the counter with greater-or-equal. `after_secs` at or below the
per-session jitter bound is the same trap on the timer arm: each session
offsets the interval by a random value within plus or minus that bound, so a
smaller interval saturates to zero on a negative draw and rekeys on sight,
for roughly half of sessions. Both are checked whether or not rekey is
enabled, so switching it on later cannot surface the error at a surprising
moment, and neither gains an upper bound — a very large value remains the
supported way to disable one arm. A config carrying either setting now fails
to load instead of starting a node that rekeys constantly.
- Peer bloom filters are computed for every recipient in one prefix and suffix
union sweep rather than rebuilt per recipient. Announcing to R peers
previously did R full map builds and R by T merges; at 240 peers that was
20.6 ms per tick, roughly half the tick body, with a median per-interval
maximum of 34.5 ms. The result is exactly equal rather than approximately:
merging is a bytewise OR, so regrouping the unions cannot change it. The
trade-off, measured rather than assumed, is that the sweep does its full work
regardless of how many peers are ready, so a tick announcing to one or two
peers now costs about twice what it did; break-even is around three ready
peers. Cadence, the debounce, the sequence rule and the fill-ratio cap are
unchanged.
- Each peer's npub is derived once at construction instead of once per tick.
The per-tick stats snapshot ran a bech32 encode for every tracked peer, and a
second one for the common peer with no hosts-file entry and no alias, since
the display-name fallback bottoms out in the same encode: 14.1 ms per tick at
240 peers. The display name itself is deliberately not cached, because the
alias map and the host map both mutate at runtime.
- The peer-retry tick no longer awaits the Nostr advert refetch. It ran inline
on the 1-second rx-loop tick, awaiting a fetch with a 2-second timeout for
each due peer and discarding the result; with up to sixteen due peers the
timeouts stacked, and field profiling measured single 2.00 s stalls as the
common case and a worst tick of 12.4 s against a 1 s period, delaying every
other rx-loop arm by as much as 4.2 s. The refetch is now spawned, so a dial
uses the advert cached at that moment and the refreshed one lands for that
peer's next retry.
- The `Adopted NAT traversal socket` log line now carries the transport id and
the local address alongside the peer npub. Without the local address an
operator cannot join a host socket table against adoption events, and without
the transport id several peers sharing one adopted transport are
indistinguishable from several separate adopted transports.
- Inbound msg1 is classified before it is rate limited, and rekey or restart
msg1 arriving on an established link now draws on its own token bucket
instead of competing with stranger admission for a single shared one. On a
@@ -257,6 +379,38 @@ with v0.4.x or earlier peers.
### Fixed
- Nostr NAT traversal signals are now sent only to relays the client pool
actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox
relays, the relays its advert nominates for signaling, and our own DM relays,
but the pool is built once at startup from the configured relays and the send
is rejected outright, before anything is contacted, if any single URL in that
list is outside it. One unconfigured relay anywhere in the merge therefore
killed the whole attempt, including the sends to relays both sides shared. On
a public node in open mode this made discovery non-functional: 309 traversal
attempts, 290 explicit failures, zero successes, every failure on `relay not
found`. Configured peers were unaffected, since they run a matching relay
set. Comparison is on the normalized relay URL rather than the raw string, so
a configured relay spelled with a trailing slash or different host case is
not discarded. Two smaller fixes ride along: the responder resolves its
relays before binding a socket and running STUN, rather than spending a STUN
round trip and holding an offer slot only to find it has nowhere to answer,
and it gained the empty-relay-list guard the initiator already had.
- A failed log write can no longer panic the thread or task that logged. The
subscriber was built with the default internal-error reporting, which sends a
failed write to `eprintln!`, and that macro panics when stderr has also
failed. The shipped supervisor configurations make that a single condition
rather than two: the macOS plist points both standard streams at one
unrotated file, and the systemd units route both to journald, so one full
disk fails both sinks together. In the daemon a crypto worker was the case
that mattered — it logs a warning on send backpressure, and a worker that
dies takes its share of the peer space with it permanently, while the panic
message is discarded along the same broken path. In `fips-gateway`, which
built its subscriber the same way, the casualty is a spawned task: the DNS
resolver, the control accept loop or the pool tick, none of which is observed
until shutdown, so the process would keep running and reporting healthy with
mesh name resolution or lease expiry and NAT cleanup silently stopped.
- macOS: `peers.allow`, `peers.deny`, and the `hosts` file are now read
from `/usr/local/etc/fips/`, matching the install layout the macOS
packaging ships (`packaging/macos/`). The default-path constants were
@@ -380,6 +534,28 @@ with v0.4.x or earlier peers.
counter now charges at the node that makes the decision rather than at the
hop after it.
### Security
- The FSP session address is now bound to the peer key the Noise handshake
authenticated, on both the initial and the rekey path. The responder recorded
a session under the source address carried in the datagram without ever
checking that address against the static key it had just authenticated, so a
peer could complete a genuine handshake while claiming another node's
address, and the identity cache, the session map and the address the IPv6
shim reconstructs on delivery would all attribute its traffic to the node it
named. The address is now derived from the authenticated key at the point it
first becomes available in msg3, and a mismatch drops the half-open session
without recording either the identity or the session. The rekey responder
needed its own check: it returns before that code is reached and never read
the peer's static key at all, so a rekey could complete under an established
session with a different key than the one that opened it. It now requires the
key to be unchanged and abandons the rekey while leaving the existing session
intact, rather than tearing the session down, which would have handed an
attacker a way to kill established sessions. Both comparisons are on x-only
keys, because a stored key may carry a synthesized parity while the handshake
learns the true point. The two rejections are counted separately in the
session reject statistics.
## [0.4.1] - 2026-07-19
### Changed
+10 -1
View File
@@ -67,7 +67,16 @@ async fn main() {
)
.from_env_lossy();
fmt().with_env_filter(filter).with_target(true).init();
// As in the daemon: a failed log write must not panic whoever logged. The
// default reports write failures with `eprintln!`, which panics when stderr
// fails too, and both units send stdout and stderr to journald. Here the
// casualty is a spawned task — the DNS resolver or the pool tick — whose
// handle nothing observes until shutdown.
fmt()
.with_env_filter(filter)
.with_target(true)
.log_internal_errors(false)
.init();
info!("fips-gateway {} starting", version::short_version());
+7
View File
@@ -101,10 +101,17 @@ async fn run_daemon(
// ANSI color only when stdout is a terminal — under a supervisor
// (daemon(8), systemd) escape codes would litter the log file.
//
// Never let a failed log write panic the thread that logged. The default
// is to report a write failure with `eprintln!`, which itself panics when
// stderr fails too — and the shipped supervisor configs point stdout and
// stderr at the same place, so one full disk satisfies both. A worker
// thread killed that way takes its share of the peer space with it.
fmt()
.with_env_filter(filter)
.with_target(true)
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
.log_internal_errors(false)
.init();
info!("FIPS {} starting", version::short_version());
+552
View File
@@ -49,6 +49,10 @@ fn test_routing_unknown_destination() {
// === Bloom filter priority ===
/// Scope note: this covers the bloom *ordering* path, not the bloom predicate.
/// The chosen peer here is also the greedy tree winner, so the assertion holds
/// whichever branch produced it. See the `Seam: NodeRoutingView adapter`
/// section at the end of this file for the discriminating tests.
#[test]
fn test_routing_bloom_filter_hit() {
let mut node = make_node();
@@ -102,6 +106,9 @@ fn test_routing_bloom_filter_hit() {
assert_ne!(result.unwrap().node_addr(), &peer2_addr);
}
/// Scope note: as above, this pins the tie-break ordering rather than the bloom
/// predicate — the inline comment below already notes that the self-distance
/// check is what does the filtering here.
#[test]
fn test_routing_bloom_filter_multiple_hits_tiebreak() {
let mut node = make_node();
@@ -207,6 +214,13 @@ fn test_routing_tree_fallback() {
///
/// Post-fix behavior: same scenario falls through to greedy tree routing
/// and returns the tree-routing-selected next hop.
///
/// Scope note: despite the name, this does **not** discriminate the bloom
/// predicate. Under a `NodeRoutingView::peer_may_reach` that returns `true`
/// unconditionally the healthy and broken runs return the same peer by
/// different branches, and `find_next_hop` exposes no branch information, so
/// no assertion here can tell them apart. The test that does discriminate it
/// is `test_seam_bloom_hit_overrides_tree_tiebreak`.
#[test]
fn test_routing_bloom_hit_not_closer_falls_through_to_tree() {
let mut node = make_node();
@@ -908,6 +922,14 @@ async fn test_routing_stops_after_peer_removal() {
///
/// Chain: 0 -- 1 -- 2 -- 3. Only node 0 has node 3's coords cached.
/// Nodes 1 and 2 route using bloom filters only.
///
/// Scope note: despite the name, what this actually pins is that transit
/// requires cached coordinates at each hop and that the last hop short-circuits
/// on direct peering. It does not discriminate the bloom predicate: the node-1
/// and node-2 assertions return from `find_next_hop` before the routing view is
/// ever constructed, and node 0 has exactly one peer, so its assertion cannot
/// separate any two candidates. See the `Seam: NodeRoutingView adapter` section
/// at the end of this file for the tests that do.
#[tokio::test]
async fn test_routing_bloom_only_transit() {
let edges = vec![(0, 1), (1, 2), (2, 3)];
@@ -1442,3 +1464,533 @@ fn test_parent_loss_selfroot_invalidates_coord_cache() {
"stale old-root entry must be invalidated after self-root"
);
}
// === Seam: NodeRoutingView adapter ===
//
// The tests above exercise the routing *decision*; the ones below exercise the
// *adapter* that feeds it. `NodeRoutingView` (src/node/mod.rs) is the only
// thing connecting the node's live peer map to the sans-IO routing core, and
// the core's own tests in src/proto/routing/tests/ drive a mock view, so
// nothing else in the suite observes the real adapter.
//
// Why no pre-existing test discriminates it: in every other fixture in this
// file the bloom winner and the greedy-tree winner are the same peer, which
// makes branch 3 and branch 4 of `find_next_hop` indistinguishable — the route
// is unchanged whichever branch produced it. The fixtures below break that
// collinearity deliberately, so the answer each one asserts is a peer the tree
// fallback would *not* have chosen. A broken adapter changes the answer rather
// than merely changing the path to it.
//
// Every assertion pins a peer identity rather than `is_some()`, and every route
// goes through the real `Node::find_next_hop`, which constructs the real
// `NodeRoutingView` internally and cannot be handed a mock.
fn seam_now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn seam_add_peer(node: &mut Node, link_num: u64, transport_id: TransportId) -> NodeAddr {
let link_id = LinkId::new(link_num);
let identity = seed_completed_connection(node, link_id, transport_id, 1000);
let addr = *identity.node_addr();
node.promote_connection(link_id, identity, 2000).unwrap();
addr
}
/// Put `dest` into `peer`'s inbound bloom filter.
fn seam_set_filter(node: &mut Node, peer: &NodeAddr, dest: &NodeAddr) {
let mut filter = BloomFilter::new();
filter.insert(dest);
node.get_peer_mut(peer)
.unwrap()
.update_filter(filter, 1, 3000);
}
/// Set a peer's link cost inputs directly. `etx * (1.0 + srtt_ms / 100.0)`.
fn seam_set_cost(node: &mut Node, peer: &NodeAddr, etx: f64, rtt_us: i64) {
let mmp = node.get_peer_mut(peer).unwrap().mmp_mut().unwrap();
mmp.metrics.etx = etx;
mmp.metrics.srtt.update(rtt_us);
}
/// Two peers *equidistant* from `dest`, both strictly closer than we are.
///
/// Tree (we are root): my ── near ── dest ── far
///
/// `far` is also a direct peer of ours: a mesh link that is not a tree edge.
/// Distances to dest are my = 2, near = 1, far = 1, so both peers clear the
/// self-distance check at the same distance and the same link cost. The only
/// thing separating them is the address tie-break, which is what lets a test
/// make one of the adapter's predicates pick the peer the tree would have
/// rejected — and then the two branches finally disagree.
///
/// Peer addresses come from `Identity::generate()` and are random, so callers
/// assign roles at runtime with `near.min(far)` / `near.max(far)` rather than
/// assuming which topological role drew the smaller key.
///
/// Returns `(near, far, dest)`.
fn seam_two_equidistant_peers(node: &mut Node) -> (NodeAddr, NodeAddr, NodeAddr) {
let transport_id = TransportId::new(1);
let my_addr = *node.node_addr();
let near = seam_add_peer(node, 1, transport_id);
let far = seam_add_peer(node, 2, transport_id);
let dest = make_node_addr(99);
let near_coords = TreeCoordinate::from_addrs(vec![near, my_addr]).unwrap();
node.tree_state_mut()
.update_peer(ParentDeclaration::new(near, my_addr, 1, 1000), near_coords);
let far_coords = TreeCoordinate::from_addrs(vec![far, dest, near, my_addr]).unwrap();
node.tree_state_mut()
.update_peer(ParentDeclaration::new(far, dest, 3, 1000), far_coords);
let dest_coords = TreeCoordinate::from_addrs(vec![dest, near, my_addr]).unwrap();
node.coord_cache_mut()
.insert(dest, dest_coords, seam_now_ms());
(near, far, dest)
}
/// Three peers at *distinct* distances from `dest`, strung along one tree path.
///
/// Tree (we are root): my ── rung3 ── rung2 ── rung1 ── dest
///
/// All three rungs are also direct peers of ours. Distances to dest are
/// my = 4, rung1 = 1, rung2 = 2, rung3 = 3. Putting the bloom hits on
/// `{rung2, rung3}` makes the winner depend on the coordinate *values* the
/// adapter hands back rather than merely on whether it hands back any: rung2
/// wins on distance from inside the bloom set, while the tree fallback would
/// have answered rung1. Link costs are all 1.0 and the three distances differ,
/// so the address tie-break never engages and the answer does not depend on
/// which peer drew the smaller random key.
///
/// Returns `(rung1, rung2, rung3, dest)`.
fn seam_distance_ladder(node: &mut Node) -> (NodeAddr, NodeAddr, NodeAddr, NodeAddr) {
let transport_id = TransportId::new(1);
let my_addr = *node.node_addr();
let rung1 = seam_add_peer(node, 1, transport_id);
let rung2 = seam_add_peer(node, 2, transport_id);
let rung3 = seam_add_peer(node, 3, transport_id);
let dest = make_node_addr(99);
let rung3_coords = TreeCoordinate::from_addrs(vec![rung3, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(rung3, my_addr, 1, 1000),
rung3_coords,
);
let rung2_coords = TreeCoordinate::from_addrs(vec![rung2, rung3, my_addr]).unwrap();
node.tree_state_mut()
.update_peer(ParentDeclaration::new(rung2, rung3, 2, 1000), rung2_coords);
let rung1_coords = TreeCoordinate::from_addrs(vec![rung1, rung2, rung3, my_addr]).unwrap();
node.tree_state_mut()
.update_peer(ParentDeclaration::new(rung1, rung2, 3, 1000), rung1_coords);
let dest_coords = TreeCoordinate::from_addrs(vec![dest, rung1, rung2, rung3, my_addr]).unwrap();
node.coord_cache_mut()
.insert(dest, dest_coords, seam_now_ms());
(rung1, rung2, rung3, dest)
}
/// Guard on the two seam fixtures rather than on the seam itself.
///
/// Every seam test below asserts an answer that only holds while the fixture's
/// distances are what its comment claims. If tree-coordinate or distance
/// semantics ever change so that the fixtures stop discriminating, those tests
/// would go on passing while observing nothing — the exact blindness they exist
/// to remove. This one fails first and says which fixture collapsed.
#[test]
fn test_seam_fixture_distances_are_as_documented() {
let mut node = make_node();
let my_addr = *node.node_addr();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let dest_coords = TreeCoordinate::from_addrs(vec![dest, near, my_addr]).unwrap();
let ts = node.tree_state();
assert_eq!(ts.my_coords().distance_to(&dest_coords), 2, "my distance");
assert_eq!(
ts.peer_coords(&near).unwrap().distance_to(&dest_coords),
1,
"near distance"
);
assert_eq!(
ts.peer_coords(&far).unwrap().distance_to(&dest_coords),
1,
"far distance"
);
// Both peers equidistant and equally cheap, so greedy tree routing resolves
// the tie on address. The equidistant-fixture tests below each assert the
// *other* peer; this is the answer they are contradicting.
let tree_pick = ts.find_next_hop(&dest_coords, &BTreeSet::new()).unwrap();
assert_eq!(
tree_pick,
near.min(far),
"greedy tree tie-break is min addr"
);
let mut node = make_node();
let my_addr = *node.node_addr();
let (rung1, rung2, rung3, dest) = seam_distance_ladder(&mut node);
let dest_coords = TreeCoordinate::from_addrs(vec![dest, rung1, rung2, rung3, my_addr]).unwrap();
let ts = node.tree_state();
assert_eq!(ts.my_coords().distance_to(&dest_coords), 4, "my distance");
assert_eq!(
ts.peer_coords(&rung1).unwrap().distance_to(&dest_coords),
1,
"rung1 distance"
);
assert_eq!(
ts.peer_coords(&rung2).unwrap().distance_to(&dest_coords),
2,
"rung2 distance"
);
assert_eq!(
ts.peer_coords(&rung3).unwrap().distance_to(&dest_coords),
3,
"rung3 distance"
);
let tree_pick = ts.find_next_hop(&dest_coords, &BTreeSet::new()).unwrap();
assert_eq!(tree_pick, rung1, "greedy tree picks the nearest rung");
}
/// `NodeRoutingView::peer_may_reach` decides the bloom candidate set.
///
/// The bloom hit is placed on the peer the greedy tree tie-break would have
/// *rejected*, so the only path to the asserted answer is branch 3 of
/// `find_next_hop` selecting it on that hit. Widening `peer_may_reach` to
/// `true` — bloom filtering off, the break that motivated this whole section —
/// or inverting it collapses the answer back to the tree pick.
#[test]
fn test_seam_bloom_hit_overrides_tree_tiebreak() {
let mut node = make_node();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let tree_pick = near.min(far);
let bloom_pick = near.max(far);
// Fixture preconditions, so a red below reads as a seam regression rather
// than as the fixture having collapsed.
assert_eq!(node.peers.len(), 2, "fixture: two peers");
assert!(node.tree_state().peer_coords(&near).is_some());
assert!(node.tree_state().peer_coords(&far).is_some());
seam_set_filter(&mut node, &bloom_pick, &dest);
assert!(!node.get_peer(&tree_pick).unwrap().may_reach(&dest));
let hop = node.find_next_hop(&dest).expect("route exists");
assert_eq!(
hop.node_addr(),
&bloom_pick,
"the bloom candidate must beat the greedy tree tie-break winner {tree_pick:?}"
);
}
/// `NodeRoutingView::peer_can_send` keeps a down link out of the candidate set.
///
/// Both peers hold a bloom hit, so the address tie-break would hand the route
/// to the low-address peer; that peer is the one marked reconnecting. Widening
/// `peer_can_send` to `true` lets it back in and hands a down link to the
/// forwarder.
#[test]
fn test_seam_unsendable_bloom_candidate_is_skipped() {
let mut node = make_node();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let low = near.min(far);
let high = near.max(far);
seam_set_filter(&mut node, &low, &dest);
seam_set_filter(&mut node, &high, &dest);
node.get_peer_mut(&low).unwrap().mark_reconnecting();
assert_eq!(node.peers.len(), 2, "fixture: two peers");
assert!(
!node.get_peer(&low).unwrap().can_send(),
"fixture: low is down"
);
assert!(
node.get_peer(&high).unwrap().can_send(),
"fixture: high is up"
);
let hop = node.find_next_hop(&dest).expect("route exists");
assert!(hop.can_send(), "a down link must never be returned");
assert_eq!(
hop.node_addr(),
&high,
"the sendable peer must win despite losing the address tie-break"
);
}
/// `NodeRoutingView::peer_link_cost` must carry the ETX factor.
///
/// SRTT is equal on both peers, so ETX is the only thing that can order them,
/// and the cheap peer is the one that loses the address tie-break. This reds
/// for a flattened cost, a negated cost, and — the shape a rewrite of the
/// adapter actually produces — a cost that reads only the latency half of
/// `etx * (1.0 + srtt_ms / 100.0)`.
#[test]
fn test_seam_link_cost_etx_orders_bloom_candidates() {
let mut node = make_node();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let low = near.min(far);
let high = near.max(far);
seam_set_filter(&mut node, &low, &dest);
seam_set_filter(&mut node, &high, &dest);
seam_set_cost(&mut node, &low, 3.0, 1_000);
seam_set_cost(&mut node, &high, 1.0, 1_000);
let cost_low = node.get_peer(&low).unwrap().link_cost();
let cost_high = node.get_peer(&high).unwrap().link_cost();
assert!(
cost_high < cost_low,
"fixture: ETX alone must make high cheaper ({cost_high} vs {cost_low})"
);
let hop = node.find_next_hop(&dest).expect("route exists");
assert_eq!(
hop.node_addr(),
&high,
"the lower-ETX link must win despite losing the address tie-break"
);
}
/// `NodeRoutingView::peer_link_cost` must carry the SRTT factor.
///
/// The mirror of the ETX test: ETX is equal on both peers, so latency is the
/// only thing that can order them. This reds for a flattened cost, a negated
/// cost, and a cost that reads only the ETX half.
#[test]
fn test_seam_link_cost_srtt_orders_bloom_candidates() {
let mut node = make_node();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let low = near.min(far);
let high = near.max(far);
seam_set_filter(&mut node, &low, &dest);
seam_set_filter(&mut node, &high, &dest);
seam_set_cost(&mut node, &low, 1.0, 50_000); // 50 ms -> cost 1.5
seam_set_cost(&mut node, &high, 1.0, 1_000); // 1 ms -> cost 1.01
let cost_low = node.get_peer(&low).unwrap().link_cost();
let cost_high = node.get_peer(&high).unwrap().link_cost();
assert!(
cost_high < cost_low,
"fixture: SRTT alone must make high cheaper ({cost_high} vs {cost_low})"
);
let hop = node.find_next_hop(&dest).expect("route exists");
assert_eq!(
hop.node_addr(),
&high,
"the lower-latency link must win despite losing the address tie-break"
);
}
/// `NodeRoutingView::peer_coords` must return each peer's *own* coordinates.
///
/// The ladder puts the two bloom candidates at *different* distances, so the
/// winner is decided by the coordinate values the adapter returns and not
/// merely by whether it returns any: rung2 (distance 2) beats rung3
/// (distance 3) from inside the bloom set, while the tree fallback would have
/// answered rung1 (distance 1). Dropping the lookup to `None`, or sourcing it
/// from the wrong object (our own coordinates rather than the peer's), moves
/// the answer to rung1.
#[test]
fn test_seam_peer_coords_distance_orders_bloom_candidates() {
let mut node = make_node();
let (rung1, rung2, rung3, dest) = seam_distance_ladder(&mut node);
// rung1 is the greedy tree winner and deliberately carries no bloom hit.
seam_set_filter(&mut node, &rung2, &dest);
seam_set_filter(&mut node, &rung3, &dest);
assert_eq!(node.peers.len(), 3, "fixture: three peers");
assert!(
!node.get_peer(&rung1).unwrap().may_reach(&dest),
"fixture: the tree winner is not a bloom candidate"
);
let hop = node.find_next_hop(&dest).expect("route exists");
assert_eq!(
hop.node_addr(),
&rung2,
"the nearer bloom candidate must win: rung1 {rung1:?} is the tree \
answer, rung3 {rung3:?} is the farther candidate"
);
}
/// A peer with no tree coordinates is infinitely far and can never be chosen.
///
/// A peer that has completed a handshake but whose tree declaration has not yet
/// arrived is an ordinary runtime state, and it is the state the core maps to
/// `usize::MAX` in its self-distance check. This exercises the adapter's `None`
/// arm and reds if the adapter ever fabricates coordinates for a peer the tree
/// does not know — a fallback to some other peer's coordinates would make this
/// peer the sole viable bloom candidate and hand it the route.
#[test]
fn test_seam_peer_without_tree_coords_is_never_selected() {
let mut node = make_node();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
let tree_pick = near.min(far);
// A third peer: in the peer map, sendable, holding a bloom hit for dest,
// and absent from tree state.
let ghost = seam_add_peer(&mut node, 3, TransportId::new(1));
seam_set_filter(&mut node, &ghost, &dest);
assert!(node.get_peer(&ghost).unwrap().can_send());
assert!(node.get_peer(&ghost).unwrap().may_reach(&dest));
assert!(
node.tree_state().peer_coords(&ghost).is_none(),
"fixture: ghost has no tree coordinates"
);
let hop = node.find_next_hop(&dest).expect("route exists");
assert_ne!(
hop.node_addr(),
&ghost,
"a peer with no tree coordinates must never be selected"
);
assert_eq!(
hop.node_addr(),
&tree_pick,
"with no viable bloom candidate the tree fallback answers"
);
}
/// Every `NodeRoutingView` read must report the live node state it adapts.
///
/// The tests above cover the reads that change a routing decision. This one
/// covers the residue: reads whose corruption has no behavioural consequence
/// through `find_next_hop` (a peer enumerated twice cannot displace itself,
/// since the core's dominance test is a strict-improvement check) and reads
/// `find_next_hop` never performs at all (`is_congested` and `cached_coords`,
/// which are reached from the datagram forwarding path).
///
/// Assertions are against values this test *set*, not against a second call to
/// the same production getter the adapter itself calls: a mirror comparison of
/// the form `view.x(p) == peer.x()` reduces to `f(x) == f(x)` and is blind to
/// anything the two sides share. The parity loop at the end is the secondary
/// check, not the primary one.
#[test]
fn test_seam_routing_view_reads_match_live_peer_state() {
use crate::proto::routing::RoutingView;
let mut node = make_node();
let my_addr = *node.node_addr();
let (near, far, dest) = seam_two_equidistant_peers(&mut node);
// Make the two peers differ on every predicate, in opposite directions, so
// no constant in either direction can satisfy the assertions below.
seam_set_filter(&mut node, &near, &dest);
node.get_peer_mut(&far).unwrap().mark_reconnecting();
// Both cost factors off the multiplicative identity: with etx pinned at 1.0
// a cost that reads only the latency half is indistinguishable from the
// real one, and this assertion would be blind to it.
seam_set_cost(&mut node, &near, 2.0, 50_000); // 2.0 * (1.0 + 0.5) -> 3.0
// A short-TTL entry, so the expiry arm of `cached_coords` is exercised and
// not only the present/absent arms.
let stale = make_node_addr(201);
let stale_coords = TreeCoordinate::from_addrs(vec![stale, near, my_addr]).unwrap();
node.coord_cache_mut()
.insert_with_ttl(stale, stale_coords, 1_000_000, 10);
let unknown = make_node_addr(202);
let near_coords = TreeCoordinate::from_addrs(vec![near, my_addr]).unwrap();
let far_coords = TreeCoordinate::from_addrs(vec![far, dest, near, my_addr]).unwrap();
let view = NodeRoutingView {
coord_cache: node.coord_cache(),
peers: &node.peers,
tree_state: node.tree_state(),
congested: true,
};
// Enumeration: every peer exactly once, and no peer the map does not hold.
// A visitor that skips *some* peers is order-nondeterministic through
// `find_next_hop`; one that repeats a peer is invisible there entirely.
let mut handles = Vec::new();
view.for_each_peer(|peer| handles.push(peer));
assert_eq!(handles.len(), 2, "each peer visited exactly once");
let visited: HashSet<NodeAddr> = handles.iter().map(|p| view.peer_addr(*p)).collect();
assert_eq!(
visited,
node.peers.keys().copied().collect::<HashSet<_>>(),
"enumeration must cover exactly the live peer map"
);
let near_h = *handles
.iter()
.find(|p| view.peer_addr(**p) == near)
.unwrap();
let far_h = *handles.iter().find(|p| view.peer_addr(**p) == far).unwrap();
// peer_may_reach: near holds a filter containing dest; far holds none.
assert!(view.peer_may_reach(near_h, &dest));
assert!(!view.peer_may_reach(far_h, &dest));
assert!(
!view.peer_may_reach(near_h, &unknown),
"the filter is per-destination"
);
// peer_can_send: far was marked reconnecting.
assert!(view.peer_can_send(near_h));
assert!(!view.peer_can_send(far_h));
// peer_link_cost: etx 2.0 * (1.0 + 50ms/100) for near; far has no RTT
// sample, so it takes the optimistic 1.0 default. Neither factor is at the
// identity, so a cost reading only one half of the product is caught.
assert_eq!(view.peer_link_cost(near_h), 3.0);
assert_eq!(view.peer_link_cost(far_h), 1.0);
// peer_coords: each peer's own coordinates, as the fixture installed them.
assert_eq!(view.peer_coords(near_h), Some(&near_coords));
assert_eq!(view.peer_coords(far_h), Some(&far_coords));
// is_congested, both directions: a constant in either direction is wrong,
// and the false-negative direction silently drops the ECN CE mark.
assert!(view.is_congested(&near));
let calm = NodeRoutingView {
coord_cache: node.coord_cache(),
peers: &node.peers,
tree_state: node.tree_state(),
congested: false,
};
assert!(!calm.is_congested(&near));
// cached_coords: present, absent, and expired. The expiry arm is what
// decides PathBroken-vs-CoordsRequired for a route that has gone away.
let now_ms = seam_now_ms();
assert_eq!(
view.cached_coords(&dest, now_ms).as_ref(),
node.coord_cache().get(&dest, now_ms)
);
assert!(view.cached_coords(&unknown, now_ms).is_none());
assert!(
view.cached_coords(&stale, 1_000_005).is_some(),
"within TTL"
);
assert!(view.cached_coords(&stale, 1_000_100).is_none(), "past TTL");
// Secondary parity sweep over the live peer map.
for peer in &handles {
let addr = view.peer_addr(*peer);
let live = node.peers.get(&addr).unwrap();
assert_eq!(view.peer_may_reach(*peer, &dest), live.may_reach(&dest));
assert_eq!(view.peer_can_send(*peer), live.can_send());
assert_eq!(view.peer_link_cost(*peer), live.link_cost());
assert_eq!(
view.peer_coords(*peer),
node.tree_state().peer_coords(&addr)
);
}
}