mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Follow IP semantics for the session datagram hop limit on the refactored path
The same correction as on the maintenance line, re-expressed rather than merged, because the sans-IO relocation moved this logic into a synchronous core that returns an outcome instead of acting inline. Delivery to the addressed node is no longer gated on the hop limit, and the decrement happens before the drop decision, so a datagram that would leave with zero is not sent. The reachable radius does not change: the old behavior refused to deliver at zero but was willing to send at zero, and the new behavior is the mirror of that, so the two cancel. The shell above the core needed changing too, and that was the real work. It carried two hop-limit predicates of its own, both justified by comments citing the ordering that existed before the refactor. The first gated coordinate cache warming, which this fix requires to be unconditional; left alone it would have suppressed warming twice over, once for a datagram addressed here and now delivered, and once for a transit datagram dropped for hop limit whose plaintext coordinates are still perfectly usable. The second gated next hop resolution, which carries a cache touch side effect, and now mirrors the core's would-leave-zero rule instead of the old one. Neither divergence would have surfaced as a test failure. That last point is worth stating plainly, because it nearly went the other way. Restoring the warming gate on top of the corrected core passes the entire suite without a single failure. The tests could not see the seam between the core rule and the shell's copy of it. So this adds coverage that can: a three node chain pinning the decrement across a real hop, which is the only thing exercising the shell predicates composed with the core, and two warming tests at a zero hop limit covering both the delivered and the dropped case. Each was checked by putting the specific defect it targets back and confirming the test fails. One existing test that claimed to cover hop limit behavior asserted nothing whatsoever. It asserts now, but those assertions do not discriminate this change, since the case it exercises behaves identically under both rules. They guard against the test going vacuous again, not against this defect, and the comment says so.
This commit is contained in:
@@ -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
@@ -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
@@ -60,7 +60,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,
|
||||
}
|
||||
|
||||
@@ -96,14 +99,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<'_>,
|
||||
@@ -112,14 +121,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,
|
||||
@@ -129,7 +148,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user