diff --git a/src/node/dataplane/forwarding.rs b/src/node/dataplane/forwarding.rs index 859339a..ed662e5 100644 --- a/src/node/dataplane/forwarding.rs +++ b/src/node/dataplane/forwarding.rs @@ -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" ); } diff --git a/src/node/tests/forwarding.rs b/src/node/tests/forwarding.rs index a682a21..53baff5 100644 --- a/src/node/tests/forwarding.rs +++ b/src/node/tests/forwarding.rs @@ -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; } diff --git a/src/proto/routing/core.rs b/src/proto/routing/core.rs index ce48bec..efe334e 100644 --- a/src/proto/routing/core.rs +++ b/src/proto/routing/core.rs @@ -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, 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(); diff --git a/src/proto/routing/mod.rs b/src/proto/routing/mod.rs index d8d9183..96b1773 100644 --- a/src/proto/routing/mod.rs +++ b/src/proto/routing/mod.rs @@ -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 diff --git a/src/proto/routing/tests/core.rs b/src/proto/routing/tests/core.rs index 1f6a0a3..a4f075c 100644 --- a/src/proto/routing/tests/core.rs +++ b/src/proto/routing/tests/core.rs @@ -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();