Merge branch 'master' into next

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Johnathan Corgan
2026-07-22 03:46:15 +00:00
6 changed files with 471 additions and 39 deletions
+14
View File
@@ -209,6 +209,20 @@ with v0.4.x or earlier peers.
when `pending_new_session().is_some()`, applying the same
smaller-NodeAddr resolution rule. Mirrored to the FSP rekey msg1
path for symmetry.
- `SessionDatagram` hop-limit handling now follows IP semantics. Delivery to
the addressed node is no longer TTL-gated, and a forwarder decrements before
deciding rather than after, so a datagram that would leave with a TTL of zero
is dropped instead of transmitted. Previously the TTL check ran ahead of the
local-delivery test, so a datagram addressed to this node that arrived with
TTL 0 was dropped, and a forwarder receiving a transit datagram at TTL 1
transmitted it at TTL 0 for the next hop to discard, wasting one transmission
per expiring datagram. The reachable radius is unchanged, because the two
behaviors compensated exactly: a path of `h` links still delivers for any
source TTL of `h` or more. During a rolling upgrade, an unupgraded forwarder
feeding an upgraded destination delivers one hop further than either version
does on its own; no version mix delivers less far. The `TtlExhausted` reject
counter now charges at the node that makes the decision rather than at the
hop after it.
## [0.4.1] - 2026-07-19
+20 -15
View File
@@ -1,9 +1,11 @@
//! SessionDatagram forwarding handler.
//!
//! Handles incoming SessionDatagram (0x00) link messages: decodes the
//! envelope, enforces hop limits, performs coordinate cache warming from
//! plaintext session-layer headers, routes to the next hop or delivers
//! locally, and generates error signals on routing failure.
//! envelope, performs coordinate cache warming from plaintext session-layer
//! headers, pre-resolves the next hop for forwardable transit datagrams, and
//! drives the routing core's outcome — local delivery when the datagram is
//! addressed to this node, the transit hop-limit drop, the forward, or the
//! error signal generated on routing failure.
use crate::NodeAddr;
use crate::node::reject::ForwardingReject;
@@ -44,19 +46,21 @@ impl Node {
let my_addr = *self.node_addr();
// Coordinate cache warming from plaintext session-layer headers. Gated
// on a non-exhausted TTL so a datagram the core will drop as
// TTL-exhausted does not warm the cache, matching the pre-refactor
// ordering (warming ran only after the TTL early-return).
if datagram_ref.ttl != 0 {
self.try_warm_coord_cache_ref(&datagram_ref);
}
// Coordinate cache warming from plaintext session-layer headers. Runs
// ahead of both the delivery and the TTL decisions the core makes: the
// coords a peer put on the wire are equally valid whichever way those
// go, and the only arrivals this newly warms from are those with an
// exhausted TTL, whose every insert is already achievable at TTL 1.
self.try_warm_coord_cache_ref(&datagram_ref);
// Pre-resolve the next hop only for genuine transit packets (TTL > 0
// and not locally destined) so `find_next_hop`'s coord-cache LRU-touch
// side effect keeps the same scope it had inline. Warming above has
// already run, so the resolution observes freshly cached coords.
let next_hop = if datagram_ref.ttl != 0 && datagram_ref.dest_addr != my_addr {
// Pre-resolve the next hop only for datagrams the core can actually
// forward: not locally destined, and carrying a TTL that survives the
// decrement (`ttl > 1` — the shell-side mirror of the core's
// would-leave-zero drop). This keeps `find_next_hop`'s coord-cache
// LRU-touch side effect scoped to genuine forwards, as it was when the
// TTL test ran inline ahead of it. Warming above has already run, so
// the resolution observes freshly cached coords.
let next_hop = if datagram_ref.dest_addr != my_addr && datagram_ref.ttl > 1 {
self.resolve_next_hop(&datagram_ref.dest_addr)
} else {
None
@@ -93,6 +97,7 @@ impl Node {
debug!(
src = %datagram_ref.src_addr,
dest = %datagram_ref.dest_addr,
ttl = datagram_ref.ttl,
"SessionDatagram TTL exhausted, dropping"
);
}
+323 -10
View File
@@ -42,22 +42,114 @@ async fn test_forwarding_hop_limit_exhausted() {
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
// No panic, no send (node has no peers)
let fwd = &node.metrics().forwarding;
assert_eq!(
fwd.ttl_exhausted_packets.get(),
1,
"transit ttl=0 should be charged to TtlExhausted"
);
assert_eq!(
fwd.drop_no_route_packets.get(),
0,
"transit ttl=0 should never reach the routing step"
);
}
#[tokio::test]
async fn test_forwarding_hop_limit_one_drops_at_transit() {
// ttl=1 means after decrement it becomes 0 — the datagram can
// still be delivered this hop but would be dropped at the next.
// decrement_ttl returns true (1 > 0), so the handler proceeds.
async fn test_forwarding_ttl_one_local_delivery_is_not_gated() {
// dest == self, so this is local delivery, not transit: the TTL gate
// does not apply and the datagram is handed to the session layer.
let mut node = make_node();
let from = make_node_addr(0xAA);
let my_addr = *node.node_addr();
let src = make_node_addr(0x01);
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
let encoded = dg.encode();
// Should succeed — ttl=1 decrements to 0 but packet is still processed
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
let fwd = &node.metrics().forwarding;
assert_eq!(fwd.delivered_packets.get(), 1, "ttl=1 should be delivered");
assert_eq!(fwd.ttl_exhausted_packets.get(), 0);
}
/// The shell's half of the acceptance: a datagram addressed to this node with
/// ttl=0 reaches the session layer and is charged to `delivered`, not to the
/// `TtlExhausted` reject. The shell has its own TTL-shaped gates ahead of the
/// core, so the core-level test alone would not pin this.
#[tokio::test]
async fn test_forwarding_ttl_zero_local_delivery_is_not_gated() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let my_addr = *node.node_addr();
let src = make_node_addr(0x01);
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(0);
let encoded = dg.encode();
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
let fwd = &node.metrics().forwarding;
assert_eq!(
fwd.delivered_packets.get(),
1,
"ttl=0 addressed to this node must still be delivered locally"
);
assert_eq!(
fwd.ttl_exhausted_packets.get(),
0,
"local delivery must not be charged to the TtlExhausted reject"
);
assert_eq!(fwd.drop_no_route_packets.get(), 0);
}
/// The shell's half of the transit boundary: ttl=1 is charged to
/// `TtlExhausted` and never reaches the routing step, and ttl=2 clears the
/// gate — visible here as the no-route charge this peerless node produces
/// once the datagram gets that far.
#[tokio::test]
async fn test_forwarding_ttl_one_transit_dropped_before_routing() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let src = make_node_addr(0x01);
let dest = make_node_addr(0x02);
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
let encoded = dg.encode();
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
let fwd = &node.metrics().forwarding;
assert_eq!(
fwd.ttl_exhausted_packets.get(),
1,
"transit ttl=1 must be dropped as TTL-exhausted, not forwarded"
);
assert_eq!(
fwd.drop_no_route_packets.get(),
0,
"transit ttl=1 must not reach the routing step"
);
assert_eq!(fwd.forwarded_packets.get(), 0);
assert_eq!(fwd.delivered_packets.get(), 0);
}
#[tokio::test]
async fn test_forwarding_ttl_two_transit_clears_the_gate() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let src = make_node_addr(0x01);
let dest = make_node_addr(0x02);
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(2);
let encoded = dg.encode();
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
let fwd = &node.metrics().forwarding;
assert_eq!(
fwd.ttl_exhausted_packets.get(),
0,
"transit ttl=2 must clear the TTL gate"
);
assert_eq!(
fwd.drop_no_route_packets.get(),
1,
"transit ttl=2 should have reached the routing step and found no route"
);
}
// --- Local delivery ---
@@ -274,6 +366,104 @@ async fn test_coord_cache_warming_encrypted_msg_no_coords() {
);
}
/// Cache warming is not gated on the TTL, case 1 of 2: a datagram addressed
/// to this node that arrives already exhausted is delivered, and its
/// plaintext coordinates still reach the cache.
///
/// The warming call sits ahead of the routing decision precisely so that it
/// is unconditional. The other warming tests all run at the default TTL of 64
/// and so cannot see a TTL-shaped gate around it; this one runs at zero.
/// `SessionSetup` is used rather than a CP-flagged encrypted message because
/// the local-delivery path caches coords from the latter itself, which would
/// mask a suppressed warming call.
#[tokio::test]
async fn test_coord_cache_warming_ttl_zero_local_delivery() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let my_addr = *node.node_addr();
let src_addr = make_node_addr(0x01);
let root_addr = make_node_addr(0xF0);
let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![my_addr, root_addr]).unwrap();
let setup = SessionSetup::new(src_coords, dest_coords);
let dg = SessionDatagram::new(src_addr, my_addr, setup.encode()).with_ttl(0);
let encoded = dg.encode();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
assert_eq!(
node.metrics().forwarding.delivered_packets.get(),
1,
"ttl=0 addressed to this node must be delivered, not dropped"
);
let cached = node.coord_cache().get(&src_addr, now_ms);
assert!(
cached.is_some(),
"warming must not be gated on the TTL: a delivered ttl=0 datagram \
still carries usable coords"
);
assert_eq!(cached.unwrap().root_id(), &root_addr);
}
/// Cache warming is not gated on the TTL, case 2 of 2: a transit datagram
/// that is dropped for hop limit still contributes its plaintext coordinates.
///
/// This is the case the gate suppressed most visibly — the datagram never
/// reaches any other code that could cache coords, so the assertion below is
/// only satisfiable by the unconditional warming call. The `TtlExhausted`
/// charge is asserted alongside it to show the drop did happen, so the test
/// cannot be satisfied by the datagram merely surviving the TTL gate.
#[tokio::test]
async fn test_coord_cache_warming_ttl_zero_transit_drop() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let src_addr = make_node_addr(0x01);
let dest_addr = make_node_addr(0x02);
let root_addr = make_node_addr(0xF0);
let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest_addr, root_addr]).unwrap();
let setup = SessionSetup::new(src_coords, dest_coords);
let dg = SessionDatagram::new(src_addr, dest_addr, setup.encode()).with_ttl(0);
let encoded = dg.encode();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
assert!(node.coord_cache().get(&dest_addr, now_ms).is_none());
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
assert_eq!(
node.metrics().forwarding.ttl_exhausted_packets.get(),
1,
"the transit datagram should have been dropped for hop limit"
);
assert!(
node.coord_cache().get(&src_addr, now_ms).is_some(),
"a transit datagram dropped for hop limit must still warm the cache \
with its source coords"
);
assert!(
node.coord_cache().get(&dest_addr, now_ms).is_some(),
"a transit datagram dropped for hop limit must still warm the cache \
with its destination coords"
);
}
// ============================================================================
// Integration Tests
// ============================================================================
@@ -394,9 +584,9 @@ async fn test_forwarding_multi_hop() {
#[tokio::test]
async fn test_forwarding_hop_limit_prevents_infinite_loops() {
// 3-node chain: 0 -- 1 -- 2
// Send a datagram with ttl=1. It should be forwarded by node 1
// (decrement to 0) and delivered at node 2 (local delivery). If node 2
// tried to forward further, the 0 ttl would prevent it.
// Send a datagram with ttl=2. Node 1 forwards it as transit (2 -> 1) and
// node 2 delivers it locally, which is not TTL-gated. Had node 2 been
// transit instead, the arriving ttl=1 would have stopped it there.
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
verify_tree_convergence(&nodes);
@@ -411,7 +601,7 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
node2_addr,
vec![0x10, 0x00, 0x04, 0x00, 1, 2, 3, 4],
)
.with_ttl(2); // Enough for 0->1 (decrement to 1) and 1->2 (decrement to 0, local delivery)
.with_ttl(2); // Node 1 forwards with ttl=1; node 2 is the destination
let encoded = dg.encode();
@@ -426,7 +616,130 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
process_available_packets(&mut nodes).await;
}
// No panic, no infinite loop
// The datagram must actually have traversed the chain rather than dying
// somewhere en route: node 1 forwarded it and node 2 delivered it. These
// hold on both sides of the hop-limit change — the per-hop decrement is
// pinned by `test_forwarding_ttl_decrement_is_one_per_hop` below — but
// without them the test asserts nothing and would pass on a chain that
// dropped the datagram at the first hop.
assert_eq!(
nodes[1].node.metrics().forwarding.forwarded_packets.get(),
1,
"node 1 should have forwarded the transit datagram"
);
assert_eq!(
nodes[2].node.metrics().forwarding.delivered_packets.get(),
1,
"node 2 should have delivered the datagram addressed to it"
);
assert_eq!(
nodes[2]
.node
.metrics()
.forwarding
.ttl_exhausted_packets
.get(),
0,
"the destination must not charge a TTL drop for a datagram it delivers"
);
cleanup_nodes(&mut nodes).await;
}
/// Acceptance for the hop limit as the *composed* system sees it: the shell's
/// TTL-shaped predicates in `node::dataplane::forwarding` driving the
/// authoritative rule in `Router::route`, across real links.
///
/// The decision is split across those two files on this branch, and the
/// core-level tests exercise only one side of that seam. This is the coverage
/// that runs both together over a hop.
///
/// The TTL a node puts on the wire is not directly observable, so it is
/// bracketed from both sides by where the datagram comes to rest on a live
/// 3-node chain (0 -- 1 -- 2). Both injections are handed to node 0 as
/// transit — an external source, addressed to node 2 — so nodes 0 and 1 are
/// both forwarders and only node 2 is the addressed destination.
///
/// - ttl=2 must reach node 1 as ttl=1 and stop there, because forwarding it
/// again would put it on the wire at zero. Emitting ttl=2 unchanged would
/// instead show up as a delivery at node 2.
/// - ttl=3 must survive both forwarders and be delivered at node 2, which
/// receives it at ttl=1 — delivery is not TTL-gated. Decrementing by more
/// than one per hop would have stopped it at node 1.
#[tokio::test]
async fn test_forwarding_ttl_decrement_is_one_per_hop() {
/// `(forwarded, ttl_exhausted, delivered)` for one node.
fn counts(node: &TestNode) -> (u64, u64, u64) {
let fwd = &node.node.metrics().forwarding;
(
fwd.forwarded_packets.get(),
fwd.ttl_exhausted_packets.get(),
fwd.delivered_packets.get(),
)
}
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
verify_tree_convergence(&nodes);
populate_all_coord_caches(&mut nodes);
let node0_addr = *nodes[0].node.node_addr();
let node2_addr = *nodes[2].node.node_addr();
let external_src = make_node_addr(0xEE);
/// Hand a transit datagram to node 0 and let the chain settle.
async fn inject(nodes: &mut [TestNode], from: &NodeAddr, dg: SessionDatagram) {
let encoded = dg.encode();
nodes[0]
.node
.handle_session_datagram(from, &encoded[1..], false)
.await;
for _ in 0..3 {
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(nodes).await;
}
}
let transit = |ttl: u8| {
SessionDatagram::new(external_src, node2_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(ttl)
};
// ttl=2 in: node 0 emits 1, node 1 has nothing left to emit.
inject(&mut nodes, &node0_addr, transit(2)).await;
assert_eq!(
counts(&nodes[0]),
(1, 0, 0),
"node 0 should have forwarded the ttl=2 datagram at ttl=1"
);
assert_eq!(
counts(&nodes[1]),
(0, 1, 0),
"node 1 should have received ttl=1 and dropped it rather than sending at ttl=0"
);
assert_eq!(
counts(&nodes[2]),
(0, 0, 0),
"node 2 must never see a datagram that started two hops away at ttl=2"
);
// ttl=3 in: node 0 emits 2, node 1 emits 1, node 2 delivers at ttl=1.
inject(&mut nodes, &node0_addr, transit(3)).await;
assert_eq!(
counts(&nodes[0]),
(2, 0, 0),
"node 0 should have forwarded the ttl=3 datagram too"
);
assert_eq!(
counts(&nodes[1]),
(1, 1, 0),
"node 1 should have forwarded the ttl=2 it received, and dropped nothing new"
);
assert_eq!(
counts(&nodes[2]),
(0, 0, 1),
"node 2 should have delivered the datagram that arrived at ttl=1"
);
cleanup_nodes(&mut nodes).await;
}
+31 -12
View File
@@ -64,7 +64,10 @@ pub(crate) struct NextHop {
/// Why a datagram was dropped without forwarding or delivering.
pub(crate) enum DropReason {
/// Received TTL was already exhausted (0) — cannot decrement further.
/// The datagram would leave this node with a TTL of zero, so it is not
/// transmittable. Covers both the already-exhausted arrival (ttl=0) and
/// the last-hop arrival (ttl=1). Transit only: delivery to the addressed
/// node is never TTL-gated.
TtlExhausted,
}
@@ -100,14 +103,20 @@ pub(crate) enum RouteAction {
}
impl Router {
/// Decide the fate of an inbound SessionDatagram: drop (TTL), local
/// delivery, transit forward, or no-route. Pure over the datagram, the
/// Decide the fate of an inbound SessionDatagram: local delivery, drop
/// (TTL), transit forward, or no-route. Pure over the datagram, the
/// shell-resolved next hop, and the [`RoutingView`] reads.
///
/// The shell pre-resolves `next_hop` only for genuine transit packets
/// (TTL > 0 and dest not local), so `find_next_hop`'s LRU-touch side
/// effect keeps the same scope it has today. `route` still re-checks TTL
/// and local delivery authoritatively.
/// Follows IP hop-limit semantics: the TTL governs forwarding, not
/// delivery to the addressed host, so the local-delivery test precedes
/// the TTL gate; and the decrement precedes the drop decision, so a
/// datagram that would leave with a TTL of zero is not transmitted.
///
/// The shell pre-resolves `next_hop` only for datagrams this can actually
/// forward (dest not local and TTL surviving the decrement), so
/// `find_next_hop`'s LRU-touch side effect stays scoped to genuine
/// forwards. `route` still re-checks local delivery and the TTL
/// authoritatively.
pub(crate) fn route(
&mut self,
dg: &SessionDatagramRef<'_>,
@@ -116,14 +125,24 @@ impl Router {
next_hop: Option<NextHop>,
rv: &impl RoutingView,
) -> RouteOutcome {
if dg.ttl == 0 {
// Delivery to the addressed node is *not* TTL-gated — under IP
// semantics the TTL governs forwarding, not delivery to the addressed
// host — so this test precedes the TTL gate below.
if dg.dest_addr == *my_addr {
return RouteOutcome::DeliverLocal;
}
// TTL enforcement on the transit path: decrement first, then drop if
// the datagram would leave with a TTL of zero. `saturating_sub` folds
// the already-exhausted arrival (ttl=0) into the same test as the
// last-hop arrival (ttl=1); neither is transmitted.
let forwarded_ttl = dg.ttl.saturating_sub(1);
if forwarded_ttl == 0 {
return RouteOutcome::Drop {
reason: DropReason::TtlExhausted,
};
}
if dg.dest_addr == *my_addr {
return RouteOutcome::DeliverLocal;
}
let nh = match next_hop {
Some(nh) => nh,
None => return RouteOutcome::NoRoute,
@@ -133,7 +152,7 @@ impl Router {
// the outgoing link. This is the single owned copy + encode the shell
// performed inline today.
let mut datagram = SessionDatagram::new(dg.src_addr, dg.dest_addr, dg.payload.to_vec());
datagram.ttl = dg.ttl - 1;
datagram.ttl = forwarded_ttl;
datagram.path_mtu = dg.path_mtu.min(nh.link_mtu);
let outgoing_ce = incoming_ce || rv.is_congested(&nh.addr);
let bytes = datagram.encode();
+1 -1
View File
@@ -6,7 +6,7 @@
//!
//! - `core.rs` — the `RoutingView` read-seam trait, the `NextHop` /
//! `RouteOutcome` types, `Router::route`, the pure transit-forward
//! decision (TTL, local-vs-forward, path-MTU min-fold, ECN CE), and the
//! decision (local-vs-forward, transit TTL, path-MTU min-fold, ECN CE), and the
//! pure candidate assembly + hop-selection / route-classification helpers
//! (`Candidate`, `RouteClass`, `routing_candidates`, `select_best_candidate`,
//! `classify_forward`). The assembly reads raw per-peer data through the
+82 -1
View File
@@ -15,13 +15,22 @@ fn decode_forward(bytes: &[u8]) -> SessionDatagramRef<'_> {
SessionDatagramRef::decode(&bytes[1..]).expect("forwarded datagram re-decodes")
}
/// A transit datagram that arrived already exhausted is dropped and charged
/// to `TtlExhausted`. A next hop is supplied so the drop is evidence of the
/// TTL gate rather than of an absent route.
#[test]
fn ttl_zero_drops_as_exhausted() {
let mut router = Router::new();
let my_addr = make_node_addr(0x10);
let dg = make_datagram_ref(0, make_node_addr(0x20));
let rv = MockRoutingView::new(false);
let out = router.route(&dg, &my_addr, false, None, &rv);
let out = router.route(
&dg,
&my_addr,
false,
Some(make_next_hop(make_node_addr(0x30), 1400)),
&rv,
);
assert!(matches!(
out,
RouteOutcome::Drop {
@@ -30,6 +39,78 @@ fn ttl_zero_drops_as_exhausted() {
));
}
/// Acceptance: a datagram addressed to this node with ttl=0 is delivered
/// locally. The TTL governs forwarding, not delivery to the addressed host,
/// so the gate sits after the local-delivery test — and `TtlExhausted` is
/// not charged for a delivered datagram.
#[test]
fn ttl_zero_to_self_delivers_local() {
let mut router = Router::new();
let my_addr = make_node_addr(0x10);
let dg = make_datagram_ref(0, my_addr);
let rv = MockRoutingView::new(false);
let out = router.route(&dg, &my_addr, false, None, &rv);
assert!(
matches!(out, RouteOutcome::DeliverLocal),
"ttl=0 addressed to this node must still be delivered locally"
);
}
/// Acceptance: a transit datagram arriving with ttl=1 would leave with ttl=0,
/// so it is dropped here rather than transmitted. A next hop is supplied, so
/// a `Forward` outcome would mean it had been put on the wire at ttl=0.
#[test]
fn ttl_one_transit_drops_before_forwarding() {
let mut router = Router::new();
let my_addr = make_node_addr(0x10);
let dg = make_datagram_ref(1, make_node_addr(0x20));
let rv = MockRoutingView::new(false);
let out = router.route(
&dg,
&my_addr,
false,
Some(make_next_hop(make_node_addr(0x30), 1400)),
&rv,
);
assert!(
matches!(
out,
RouteOutcome::Drop {
reason: DropReason::TtlExhausted
}
),
"transit ttl=1 must be dropped as TTL-exhausted, not forwarded at ttl=0"
);
}
/// Acceptance: the other side of the same boundary — a transit datagram
/// arriving with ttl=2 clears the gate and leaves with ttl=1.
#[test]
fn ttl_two_transit_forwards_at_one() {
let mut router = Router::new();
let my_addr = make_node_addr(0x10);
let nh_addr = make_node_addr(0x30);
let dg = make_datagram_ref(2, make_node_addr(0x20));
let rv = MockRoutingView::new(false);
let out = router.route(
&dg,
&my_addr,
false,
Some(make_next_hop(nh_addr, 1400)),
&rv,
);
match out {
RouteOutcome::Forward { bytes, .. } => {
assert_eq!(
decode_forward(&bytes).ttl,
1,
"transit ttl=2 must leave with ttl=1"
);
}
_ => panic!("expected Forward"),
}
}
#[test]
fn destination_is_self_delivers_local() {
let mut router = Router::new();