umsh_node/
lib.rs

1#![allow(async_fn_in_trait)]
2#![cfg_attr(not(feature = "std"), no_std)]
3
4//! Application-facing node layer built on top of [`umsh-mac`](umsh_mac).
5//!
6//! > Note: This reference implementation is a work in progress and was developed
7//! > with the assistance of an LLM. It should be considered experimental.
8//!
9//! `umsh-node` sits between the radio-facing MAC coordinator in `umsh-mac` and the
10//! application. Where `umsh-mac` thinks in raw frames, keys, replay windows, and transmit
11//! queues, `umsh-node` provides composable abstractions for sending and receiving messages,
12//! tracking in-flight sends, and managing channel membership.
13//!
14//! The receive boundary is intentionally low-level: raw subscriptions get a
15//! [`ReceivedPacketRef`] that stays close to the accepted on-wire packet. Payload-specific
16//! helpers such as those in the `umsh-text` crate live one layer up and are built on top of
17//! those raw packet callbacks.
18//!
19//! This crate requires `alloc` (heap allocation for `String`, `Vec`, etc.). It is
20//! otherwise `no_std` compatible.
21//!
22//! # Architecture overview
23//!
24//! ```text
25//! ┌──────────────────────────────────────────────────────────────┐
26//! │  Application                                                 │
27//! │  Host · LocalNode · PeerConnection · BoundChannel            │
28//! ┌──────────────────────┴───────────────────────────────────────┐
29//! │  Host                                                        │
30//! │    ├── drives the shared MAC/runtime event loop              │
31//! │    └── owns multiple LocalNode handles                       │
32//! └──────────────────────┬───────────────────────────────────────┘
33//!                        │
34//! ┌──────────────────────┴───────────────────────────────────────┐
35//! │  LocalNode<M>                                                │
36//! │    ├── sends down through MacBackend                         │
37//! │    ├── owns per-identity PFS state                           │
38//! │    └── dispatches node/peer callback subscriptions           │
39//! └──────────────────────┬───────────────────────────────────────┘
40//!                        │  MacBackend trait
41//! ┌──────────────────────┴───────────────────────────────────────┐
42//! │  MacHandle → Mac<P>  (no_std, heapless)                      │
43//! └──────────────────────────────────────────────────────────────┘
44//! ```
45//!
46//! # Key types
47//!
48//! - [`Host`] — preferred multi-identity driver. Owns the shared MAC event loop and routes
49//!   inbound traffic to the right [`LocalNode`].
50//! - [`LocalNode`] — per-identity application handle. Implements [`Transport`] (unicast /
51//!   broadcast), owns PFS state, and exposes raw packet plus control-side subscriptions.
52//! - [`BoundChannel`] — a channel bound to a `LocalNode`. Implements [`Transport`]
53//!   (blind unicast / multicast). Available with the `software-crypto` feature.
54//! - [`PeerConnection`] — relationship with one remote peer, generic over transport context,
55//!   with peer-scoped callback subscriptions.
56//! - [`Transport`] — shared send interface (`send` / `send_all`).
57//! - [`SendProgressTicket`] — lightweight polling handle for observing in-flight send
58//!   progress (`was_transmitted`, `was_acked`, `is_finished`).
59//! - [`Subscription`] — owned callback registration that auto-unsubscribes on drop.
60//! - [`ReceivedPacketRef`] — borrowed receive view passed into low-level `on_receive(...)`
61//!   handlers and wrappers, including local RX observations such as RSSI, SNR, LQI, and
62//!   receive timestamp.
63//! - [`MacBackend`] — pluggable MAC backend trait for testability.
64//!
65//! # Control payload types
66//!
67//! [`umsh_text::OwnedTextMessage`], [`NodeIdentityPayload`], and [`OwnedMacCommand`] are
68//! optional heap-allocated conveniences for callers that need
69//! to retain parsed payloads across task boundaries. Most receive-side code should prefer the
70//! borrowed views from the payload crates and [`ReceivedPacketRef`].
71//!
72//! # MAC abstraction
73//!
74//! [`MacBackend`] exposes the public send/configure surface of the MAC coordinator.
75//! Safe PFS session management is available with `software-crypto` and builds on
76//! that public surface directly.
77//!
78//! [`MacHandle`](umsh_mac::MacHandle) implements `MacBackend`, and test code can provide
79//! a fake implementation to drive the node layer deterministically.
80//!
81//! # Typical usage
82//!
83//! For most applications, register callbacks and then let [`Host::run`] own the shared
84//! MAC event loop:
85//!
86//! ```rust,ignore
87//! let mut host = Host::new(mac_handle);
88//! let node = host.add_node(identity_id);
89//! let peer = node.peer(peer_key)?;
90//! let chat = umsh_text::UnicastTextChatWrapper::from_peer(&peer);
91//!
92//! let _messages = chat.on_text(|packet, text| {
93//!     println!(
94//!         "peer says: {} (hops={})",
95//!         text.body,
96//!         packet.flood_hops().map(|h| h.remaining()).unwrap_or(0),
97//!     );
98//! });
99//!
100//! let _ticket = chat.send_text("hello", &SendOptions::default()).await?;
101//! host.run().await?;
102//! ```
103//!
104//! If you need to multiplex UMSH progress with another async source such as user input, use
105//! [`Host::pump_once`] as a single wake-driven step. It already waits on radio activity and
106//! protocol deadlines; you should not add a manual poll/sleep loop around it.
107//!
108//! ```rust,ignore
109//! loop {
110//!     tokio::select! {
111//!         line = stdin.next_line() => { /* handle input */ }
112//!         result = host.pump_once() => result?,
113//!     }
114//! }
115//! ```
116//!
117//! If you need protocol fidelity instead of a payload wrapper, subscribe directly on the node
118//! or peer and inspect the raw packet view:
119//!
120//! ```rust,ignore
121//! let _raw = peer.on_receive(|packet| {
122//!     if packet.packet_family() == umsh::mac::PacketFamily::Unicast {
123//!         println!(
124//!             "from={:?} encrypted={} mic_len={}",
125//!             packet.from_key(),
126//!             packet.encrypted(),
127//!             packet.mic_len(),
128//!         );
129//!     }
130//!     false
131//! });
132//! ```
133
134#[cfg(not(feature = "alloc"))]
135compile_error!("umsh-node currently requires the alloc feature");
136
137extern crate alloc;
138
139mod app_error;
140mod app_payload;
141mod app_util;
142#[cfg(feature = "software-crypto")]
143mod channel;
144mod dispatch;
145mod host;
146mod identity;
147pub mod identity_responder;
148pub mod location;
149mod mac;
150pub mod mac_command;
151mod node;
152mod peer;
153pub mod peer_repeaters;
154#[cfg(feature = "software-crypto")]
155mod pfs;
156mod receive;
157mod ticket;
158mod transport;
159
160pub use app_error::{AppEncodeError, AppParseError};
161pub use app_payload::{
162    expect_payload_type, parse_mac_command_payload, parse_node_identity_payload, split_payload_type,
163};
164#[cfg(feature = "software-crypto")]
165pub use channel::Channel;
166pub use host::{Host, HostError};
167pub use identity::{NodeCapabilities, NodeIdentityPayload, NodeRole};
168pub use identity_responder::{
169    IdentityRequestContext, NodeIdentityProfile, RespondDecision, default_respond_policy,
170    never_respond_policy,
171};
172pub use mac::{MacBackend, MacBackendError};
173pub use mac_command::OwnedMacCommand;
174pub use mac_command::{CommandId, MacCommand};
175#[cfg(feature = "software-crypto")]
176pub use node::BoundChannel;
177#[cfg(feature = "software-crypto")]
178pub use node::PfsStatus;
179pub use node::{LocalNode, NodeError, PfsFailure, PongMetadata, Subscription};
180pub use peer::{PING_MIC_SIZE, PeerConnection};
181pub use peer_repeaters::{
182    MAX_PEER_REPEATERS, MergedPeerRepeater, PeerRepeaterRecord, PeerRepeaterTable,
183};
184pub use receive::{ChannelInfoRef, PacketFamily, ReceivedPacketRef, RouteHops, RxMetadata, Snr};
185pub use ticket::{SendProgressTicket, SendToken};
186pub use transport::Transport;
187
188#[cfg(test)]
189mod tests {
190    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
191    use std::{
192        cell::RefCell,
193        collections::VecDeque,
194        future::Future,
195        num::NonZeroU8,
196        pin::pin,
197        rc::Rc,
198        task::{Context, Poll, RawWaker, RawWakerVTable, Waker},
199    };
200    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
201    use umsh_core::{NodeHint, PublicKey};
202    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
203    use umsh_crypto::NodeIdentity;
204    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
205    use umsh_crypto::software::SoftwareIdentity;
206    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
207    use umsh_hal::Snr;
208    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
209    use umsh_mac::MacEventRef;
210    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
211    use umsh_mac::{CapacityError, LocalIdentityId, PeerId, SendError, SendOptions, SendReceipt};
212
213    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
214    use crate::ReceivedPacketRef;
215    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
216    use crate::{MacBackend, MacBackendError, OwnedMacCommand, SendToken};
217    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
218    use umsh_core::ChannelId;
219    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
220    use umsh_text::OwnedTextMessage;
221    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
222    #[test]
223    fn peer_receive_handlers_precede_node_receive_handlers() {
224        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
225
226        let mac = FakeMac::new(Vec::new());
227        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
228        let membership = Rc::new(RefCell::new(NodeMembership::new()));
229        let state = Rc::new(RefCell::new(LocalNodeState::new()));
230        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
231        let peer = PublicKey([0x41; 32]);
232        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
233
234        let call_order = Rc::new(RefCell::new(Vec::new()));
235        let peer_call_order = call_order.clone();
236        let _peer_subscription = peer_connection.on_receive(move |_| {
237            peer_call_order.borrow_mut().push("peer");
238            true
239        });
240        let node_call_order = call_order.clone();
241        let _node_subscription = node.on_receive(move |_| {
242            node_call_order.borrow_mut().push("node");
243            true
244        });
245
246        assert!(node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
247        assert_eq!(call_order.borrow().as_slice(), ["peer"]);
248    }
249
250    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
251    fn test_node(mac: FakeMac) -> crate::node::LocalNode<FakeMac> {
252        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
253
254        LocalNode::new(
255            LocalIdentityId(1),
256            mac,
257            Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new())),
258            Rc::new(RefCell::new(NodeMembership::new())),
259            Rc::new(RefCell::new(LocalNodeState::new())),
260        )
261    }
262
263    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
264    #[test]
265    fn leaving_a_channel_unregisters_its_key_from_the_mac() {
266        let mac = FakeMac::new(Vec::new());
267        let node = test_node(mac.clone());
268        let channel = crate::Channel::private(umsh_core::ChannelKey([0x11; 32]), "trail");
269
270        block_on_ready(node.join(&channel)).unwrap();
271        assert!(mac.holds_channel(channel.key()));
272
273        block_on_ready(node.leave(&channel)).unwrap();
274        assert!(!mac.holds_channel(channel.key()));
275        assert!(node.bound_channel(&channel).is_none());
276    }
277
278    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
279    #[test]
280    fn rejoining_after_leave_re_registers_the_key_with_the_mac() {
281        let mac = FakeMac::new(Vec::new());
282        let node = test_node(mac.clone());
283        let channel = crate::Channel::private(umsh_core::ChannelKey([0x22; 32]), "camp");
284
285        let first = block_on_ready(node.join(&channel)).unwrap();
286        block_on_ready(node.leave(&channel)).unwrap();
287        let second = block_on_ready(node.join(&channel)).unwrap();
288
289        // The key is back in the MAC, the fresh handle is live, and the stale
290        // one from before the leave is not.
291        assert!(mac.holds_channel(channel.key()));
292        assert!(second.is_active());
293        assert!(!first.is_active());
294    }
295
296    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
297    #[test]
298    fn leaving_a_channel_that_was_never_joined_is_a_no_op() {
299        let mac = FakeMac::new(Vec::new());
300        let node = test_node(mac.clone());
301        let joined = crate::Channel::private(umsh_core::ChannelKey([0x33; 32]), "joined");
302        let stranger = crate::Channel::private(umsh_core::ChannelKey([0x44; 32]), "stranger");
303
304        block_on_ready(node.join(&joined)).unwrap();
305        block_on_ready(node.leave(&stranger)).unwrap();
306
307        assert!(mac.holds_channel(joined.key()));
308        assert!(node.bound_channel(&joined).is_some());
309    }
310
311    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
312    #[test]
313    fn receive_callbacks_can_observe_rx_metadata() {
314        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
315
316        let mac = FakeMac::new(Vec::new());
317        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
318        let membership = Rc::new(RefCell::new(NodeMembership::new()));
319        let state = Rc::new(RefCell::new(LocalNodeState::new()));
320        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
321        let peer = PublicKey([0x44; 32]);
322
323        let observed = Rc::new(RefCell::new(None));
324        let observed_for_callback = observed.clone();
325        let _subscription = node.on_receive(move |packet| {
326            *observed_for_callback.borrow_mut() = Some((
327                packet.rssi(),
328                packet.snr(),
329                packet.lqi(),
330                packet.received_at_ms(),
331            ));
332            true
333        });
334
335        let payload = encode_text_payload("metadata");
336        let packet = test_unicast_packet_with_rx(
337            peer,
338            &payload,
339            umsh_mac::RxMetadata::new(
340                Some(-73),
341                Some(Snr::from_centibels(123)),
342                NonZeroU8::new(200),
343                Some(123_456),
344            ),
345        );
346
347        assert!(node.dispatch_received_packet(&packet));
348        assert_eq!(
349            *observed.borrow(),
350            Some((
351                Some(-73),
352                Some(Snr::from_centibels(123)),
353                NonZeroU8::new(200),
354                Some(123_456),
355            ))
356        );
357    }
358
359    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
360    #[test]
361    fn subscription_guard_unregisters_on_drop() {
362        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
363
364        let mac = FakeMac::new(Vec::new());
365        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
366        let membership = Rc::new(RefCell::new(NodeMembership::new()));
367        let state = Rc::new(RefCell::new(LocalNodeState::new()));
368        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
369        let peer = PublicKey([0x33; 32]);
370
371        let hits = Rc::new(RefCell::new(0u32));
372        {
373            let hits = hits.clone();
374            let _subscription = node.on_receive(move |_| {
375                *hits.borrow_mut() += 1;
376                true
377            });
378            assert!(node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
379        }
380
381        assert_eq!(*hits.borrow(), 1);
382        assert!(!node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
383        assert_eq!(*hits.borrow(), 1);
384    }
385
386    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
387    #[test]
388    fn callbacks_observe_control_side_events_and_peer_ack_state() {
389        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
390
391        let mac = FakeMac::new(Vec::new());
392        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
393        let membership = Rc::new(RefCell::new(NodeMembership::new()));
394        let state = Rc::new(RefCell::new(LocalNodeState::new()));
395        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
396
397        let peer = PublicKey([0x42; 32]);
398        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
399        let node_discovery = Rc::new(RefCell::new(Vec::new()));
400        let beacons = Rc::new(RefCell::new(Vec::new()));
401        let commands = Rc::new(RefCell::new(Vec::new()));
402        let peer_acks = Rc::new(RefCell::new(Vec::new()));
403        let peer_timeouts = Rc::new(RefCell::new(Vec::new()));
404
405        let discovery_log = node_discovery.clone();
406        let _discovered_subscription = node.on_node_discovered(move |key, name| {
407            discovery_log
408                .borrow_mut()
409                .push((key, name.map(str::to_string)));
410        });
411        let beacon_log = beacons.clone();
412        let _beacon_subscription = node.on_beacon(move |from_hint, from_key| {
413            beacon_log.borrow_mut().push((from_hint, from_key));
414        });
415        let command_log = commands.clone();
416        let _command_subscription = node.on_mac_command(move |from, command| {
417            command_log.borrow_mut().push((from, command.clone()));
418        });
419        let peer_ack_log = peer_acks.clone();
420        let _ack_subscription = peer_connection.on_ack_received(move |token| {
421            peer_ack_log.borrow_mut().push(token);
422        });
423        let peer_timeout_log = peer_timeouts.clone();
424        let _timeout_subscription = peer_connection.on_ack_timeout(move |token| {
425            peer_timeout_log.borrow_mut().push(token);
426        });
427
428        let token = SendToken::new(LocalIdentityId(1), SendReceipt(12));
429        let timeout_token = SendToken::new(LocalIdentityId(1), SendReceipt(13));
430        let hint = NodeHint([1, 2, 3]);
431        let command = OwnedMacCommand::EchoRequest {
432            data: vec![9, 8, 7],
433        };
434
435        node.dispatch_node_discovered(peer, Some("alice"));
436        node.dispatch_beacon(hint, Some(peer));
437        node.dispatch_mac_command(peer, &command);
438        node.dispatch_ack_received(peer, token);
439        node.dispatch_ack_timeout(peer, timeout_token);
440
441        assert_eq!(
442            node_discovery.borrow().as_slice(),
443            &[(peer, Some(String::from("alice")))]
444        );
445        assert_eq!(beacons.borrow().as_slice(), &[(hint, Some(peer))]);
446        assert_eq!(commands.borrow().as_slice(), &[(peer, command)]);
447        assert_eq!(peer_acks.borrow().as_slice(), &[token]);
448        assert_eq!(peer_timeouts.borrow().as_slice(), &[timeout_token]);
449    }
450
451    /// A ping must travel the way the traffic it is measuring would, so the
452    /// caller's options carry through untouched. The one exception is the ack
453    /// request: the echo response already acknowledges the ping, so asking
454    /// for a MAC ack too would put a second frame on the air for nothing.
455    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
456    #[test]
457    fn ping_honours_caller_options_but_never_requests_a_mac_ack() {
458        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
459        use umsh_core::MicSize;
460
461        let mac = FakeMac::new(vec![[7u8; 32], [9u8; 32]]);
462        let node = LocalNode::new(
463            LocalIdentityId(1),
464            mac.clone(),
465            Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new())),
466            Rc::new(RefCell::new(NodeMembership::new())),
467            Rc::new(RefCell::new(LocalNodeState::new())),
468        );
469        let peer_connection = block_on_ready(node.peer(PublicKey([0x55; 32]))).unwrap();
470
471        let options = SendOptions::default()
472            .with_mic_size(MicSize::Mic4)
473            .with_ack_requested(true)
474            .with_trace_route()
475            .with_flood_hops(3)
476            .with_region_code([0x78, 0x53]);
477        block_on_ready(peer_connection.ping(6, &options, 1_000)).unwrap();
478
479        let sent = mac.take_unicasts().pop().expect("ping send");
480        assert_eq!(sent.options.mic_size, MicSize::Mic4);
481        assert!(sent.options.trace_route);
482        assert_eq!(sent.options.flood_hops, Some(3));
483        assert_eq!(sent.options.region_code, Some([0x78, 0x53]));
484        assert!(!sent.options.ack_requested, "the echo response is the ack");
485
486        // `no_flood` is a distinct state from an unset budget and must also
487        // survive, rather than collapsing back to the wide default.
488        block_on_ready(peer_connection.ping(0, &SendOptions::default().no_flood(), 1_000)).unwrap();
489        let sent = mac.take_unicasts().pop().expect("ping send");
490        assert_eq!(sent.options.flood_hops, None);
491    }
492
493    /// The MIC size pings are normally sent with. A ping frame is otherwise
494    /// nearly half authenticator.
495    #[test]
496    fn ping_mic_size_is_eight_bytes() {
497        assert_eq!(crate::PING_MIC_SIZE, umsh_core::MicSize::Mic8);
498        assert_eq!(crate::PING_MIC_SIZE.byte_len(), 8);
499    }
500
501    /// How much echo data fits is a property of the frame the ping is sealed
502    /// into—MIC size, source form, options—and the MAC builder is the only
503    /// thing that knows all of it. A ping asks for the size it was told to and
504    /// lets an oversize request fail at the builder, rather than being quietly
505    /// shortened to a length that measures a different frame than the caller
506    /// asked about.
507    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
508    #[test]
509    fn ping_data_is_not_capped_by_the_node_layer() {
510        let mac = FakeMac::new(vec![[7u8; 32]]);
511        let node = test_node(mac.clone());
512        let peer_connection = block_on_ready(node.peer(PublicKey([0x55; 32]))).unwrap();
513
514        block_on_ready(peer_connection.ping(120, &SendOptions::default(), 1_000)).unwrap();
515
516        let sent = mac.take_unicasts().pop().expect("ping send");
517        match parse_owned_mac_command(&sent.payload) {
518            OwnedMacCommand::EchoRequest { data } => {
519                assert_eq!(data.len(), 2 + 120, "2-byte nonce plus the requested fill");
520            }
521            other => panic!("unexpected ping payload: {other:?}"),
522        }
523    }
524
525    /// A peer reached through a channel pings over that channel. The ping is
526    /// measuring the path the channel's traffic takes, so it has to be carried
527    /// the same way—and the reply that comes back on the channel matches the
528    /// pending ping just as a unicast reply would.
529    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
530    #[test]
531    fn ping_over_a_bound_channel_sends_a_blind_unicast() {
532        let mac = FakeMac::new(vec![[0x3c; 32]]);
533        let node = test_node(mac.clone());
534        let channel = crate::Channel::private(umsh_core::ChannelKey([0x66; 32]), "trail");
535        let bound = block_on_ready(node.join(&channel)).unwrap();
536
537        let peer = PublicKey([0x55; 32]);
538        let pongs = Rc::new(RefCell::new(Vec::new()));
539        let pong_log = pongs.clone();
540        let peer_connection = bound.peer(peer);
541        let _pong_subscription = peer_connection.on_pong(move |rtt_ms| {
542            pong_log.borrow_mut().push(rtt_ms);
543        });
544
545        block_on_ready(peer_connection.ping(4, &SendOptions::default(), 1_000)).unwrap();
546
547        let sent = mac.take_unicasts().pop().expect("ping send");
548        assert_eq!(
549            sent.channel,
550            Some(*channel.channel_id()),
551            "a channel-bound ping goes out blind on that channel"
552        );
553        assert!(!sent.options.ack_requested, "the echo response is the ack");
554        let nonce = match parse_owned_mac_command(&sent.payload) {
555            OwnedMacCommand::EchoRequest { data } => {
556                assert_eq!(data.len(), 2 + 4);
557                [data[0], data[1]]
558            }
559            other => panic!("unexpected ping payload: {other:?}"),
560        };
561
562        let packet = test_channel_packet(
563            peer,
564            umsh_core::PacketType::BlindUnicast,
565            channel.key(),
566            *channel.channel_id(),
567            &nonce,
568        );
569        node.match_pong(peer, &nonce, &packet, 1_250);
570
571        assert_eq!(pongs.borrow().as_slice(), &[250]);
572    }
573
574    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
575    #[test]
576    fn pfs_routed_send_tracks_ack_against_ephemeral_identity() {
577        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
578
579        let mac = FakeMac::new(vec![[7u8; 32], [9u8; 32]]);
580        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
581        let membership = Rc::new(RefCell::new(NodeMembership::new()));
582        let state = Rc::new(RefCell::new(LocalNodeState::new()));
583        let node = LocalNode::new(
584            LocalIdentityId(1),
585            mac.clone(),
586            dispatcher.clone(),
587            membership,
588            state,
589        );
590
591        let peer = PublicKey([0x55; 32]);
592        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
593        let options = SendOptions::default().with_ack_requested(true);
594
595        block_on_ready(node.request_pfs(&peer, 60, &options)).unwrap();
596        let request = mac.take_unicasts().pop().expect("request send");
597        let request_command = parse_owned_mac_command(&request.payload);
598        let request_ephemeral = match request_command {
599            OwnedMacCommand::PfsSessionRequest { ephemeral_key, .. } => ephemeral_key,
600            other => panic!("unexpected request payload: {other:?}"),
601        };
602
603        block_on_ready(node.handle_pfs_command(
604            &peer,
605            None,
606            &OwnedMacCommand::PfsSessionResponse {
607                ephemeral_key: PublicKey([0x44; 32]),
608                duration_minutes: 60,
609            },
610            &options,
611        ))
612        .unwrap();
613
614        let payload = encode_text_payload("hello over pfs");
615        let ticket = block_on_ready(peer_connection.send(&payload, &options)).unwrap();
616        let sent = mac.take_unicasts().pop().expect("pfs-routed send");
617        assert_eq!(sent.from, LocalIdentityId(10));
618        assert_eq!(sent.to, PublicKey([0x44; 32]));
619
620        let pairwise_from_pfs = PublicKey([0x44; 32]);
621        let _ = request_ephemeral; // Keeps the request path explicit in the test setup.
622        dispatcher.borrow_mut().dispatch_ticket_state(
623            sent.from,
624            &MacEventRef::AckReceived {
625                peer: pairwise_from_pfs,
626                receipt: SendReceipt(42),
627            },
628        );
629        assert!(ticket.was_acked());
630        assert!(ticket.is_finished());
631    }
632
633    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
634    #[test]
635    fn pfs_session_manager_request_and_teardown() {
636        use crate::pfs::PfsSessionManager;
637
638        let mac = FakeMac::new(vec![[3u8; 32]]);
639        let peer_long_term = PublicKey([0x55; 32]);
640        let options = SendOptions::default().with_ack_requested(true);
641
642        let mut pfs = PfsSessionManager::new();
643        block_on_ready(pfs.request_session(
644            &mac,
645            LocalIdentityId(1),
646            &peer_long_term,
647            60,
648            &options,
649        ))
650        .unwrap();
651
652        let sent = mac.take_unicasts();
653        assert_eq!(sent.len(), 1);
654        assert_eq!(sent[0].from, LocalIdentityId(1));
655        assert_eq!(sent[0].to, peer_long_term);
656        assert_eq!(
657            parse_owned_mac_command(&sent[0].payload),
658            OwnedMacCommand::PfsSessionRequest {
659                ephemeral_key: *SoftwareIdentity::from_secret_bytes(&[3u8; 32]).public_key(),
660                duration_minutes: 60,
661            }
662        );
663
664        assert!(
665            block_on_ready(pfs.end_session(
666                &mac,
667                LocalIdentityId(1),
668                &peer_long_term,
669                true,
670                &options,
671            ))
672            .unwrap()
673        );
674        let sent = mac.take_unicasts();
675        assert_eq!(sent.len(), 1);
676        assert_eq!(
677            parse_owned_mac_command(&sent[0].payload),
678            OwnedMacCommand::EndPfsSession
679        );
680    }
681
682    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
683    #[test]
684    fn pfs_end_session_errors_when_missing() {
685        use crate::pfs::PfsSessionManager;
686
687        let mac = FakeMac::new(Vec::new());
688        let options = SendOptions::default();
689        let mut pfs = PfsSessionManager::new();
690
691        let error = block_on_ready(pfs.end_session(
692            &mac,
693            LocalIdentityId(1),
694            &PublicKey([0x77; 32]),
695            true,
696            &options,
697        ))
698        .unwrap_err();
699        assert!(matches!(error, crate::NodeError::PfsSessionMissing));
700    }
701
702    #[cfg(feature = "unsafe-advanced")]
703    fn encode_text_payload(text: &str) -> Vec<u8> {
704        let message = OwnedTextMessage::basic(text);
705        let mut body = [0u8; 512];
706        let len = umsh_text::text_message::encode(&message.as_borrowed(), &mut body).unwrap();
707        let mut payload = Vec::with_capacity(len + 1);
708        payload.push(umsh_core::PayloadType::TextMessage as u8);
709        payload.extend_from_slice(&body[..len]);
710        payload
711    }
712
713    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
714    fn test_unicast_packet<'a>(from: PublicKey, payload: &'a [u8]) -> ReceivedPacketRef<'a> {
715        test_unicast_packet_with_rx(from, payload, umsh_mac::RxMetadata::default())
716    }
717
718    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
719    fn test_unicast_packet_with_rx<'a>(
720        from: PublicKey,
721        payload: &'a [u8],
722        rx: umsh_mac::RxMetadata,
723    ) -> ReceivedPacketRef<'a> {
724        let wire = Box::leak(payload.to_vec().into_boxed_slice());
725        let header = umsh_core::PacketHeader {
726            fcf: umsh_core::Fcf::new(umsh_core::PacketType::Unicast, false, false),
727            options_range: 0..0,
728            flood_hops: None,
729            dst: None,
730            channel: None,
731            source: umsh_core::SourceAddrRef::Hint(from.hint()),
732            sec_info: None,
733            body_range: 0..wire.len(),
734            mic_range: wire.len()..wire.len(),
735            total_len: wire.len(),
736        };
737        ReceivedPacketRef::new(
738            wire,
739            wire,
740            header,
741            umsh_core::ParsedOptions::default(),
742            Some(from),
743            Some(from.hint()),
744            true,
745            None,
746            rx,
747        )
748    }
749
750    /// A received frame that arrived inside a channel—a blind unicast, or
751    /// the multicast a solicitation can ride.
752    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
753    fn test_channel_packet<'a>(
754        from: PublicKey,
755        packet_type: umsh_core::PacketType,
756        channel_key: &'a umsh_core::ChannelKey,
757        channel_id: umsh_core::ChannelId,
758        payload: &'a [u8],
759    ) -> ReceivedPacketRef<'a> {
760        let wire = Box::leak(payload.to_vec().into_boxed_slice());
761        let header = umsh_core::PacketHeader {
762            fcf: umsh_core::Fcf::new(packet_type, false, false),
763            options_range: 0..0,
764            flood_hops: None,
765            dst: None,
766            channel: Some(channel_id),
767            source: umsh_core::SourceAddrRef::Hint(from.hint()),
768            sec_info: None,
769            body_range: 0..wire.len(),
770            mic_range: wire.len()..wire.len(),
771            total_len: wire.len(),
772        };
773        ReceivedPacketRef::new(
774            wire,
775            wire,
776            header,
777            umsh_core::ParsedOptions::default(),
778            Some(from),
779            Some(from.hint()),
780            true,
781            Some(crate::ChannelInfoRef {
782                id: channel_id,
783                key: channel_key,
784            }),
785            umsh_mac::RxMetadata::default(),
786        )
787    }
788
789    /// A received broadcast frame, as a solicitation would arrive: optionally
790    /// carrying an FHOPS byte and a Route option. Only the ranges matter, so
791    /// the wire is just the route bytes.
792    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
793    fn test_broadcast_packet(
794        from: PublicKey,
795        flood_hops: Option<u8>,
796        route: Option<&'static [u8]>,
797    ) -> ReceivedPacketRef<'static> {
798        test_broadcast_packet_with_trace(from, flood_hops, route, None)
799    }
800
801    /// As above, plus an accumulated trace route — what a steered
802    /// solicitation looks like once repeaters have prepended themselves to it.
803    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
804    fn test_broadcast_packet_with_trace(
805        from: PublicKey,
806        flood_hops: Option<u8>,
807        route: Option<&'static [u8]>,
808        trace: Option<&'static [u8]>,
809    ) -> ReceivedPacketRef<'static> {
810        let route_len = route.map_or(0, <[u8]>::len);
811        let trace_len = trace.map_or(0, <[u8]>::len);
812        let mut bytes = route.map_or_else(Vec::new, <[u8]>::to_vec);
813        bytes.extend_from_slice(trace.unwrap_or(&[]));
814        let wire: &'static [u8] = Box::leak(bytes.into_boxed_slice());
815        let mut options = umsh_core::ParsedOptions::default();
816        if route.is_some() {
817            options.source_route = Some(0..route_len);
818        }
819        if trace.is_some() {
820            options.trace_route = Some(route_len..route_len + trace_len);
821        }
822        let header = umsh_core::PacketHeader {
823            fcf: umsh_core::Fcf::new(umsh_core::PacketType::Broadcast, false, false),
824            options_range: 0..route_len + trace_len,
825            flood_hops: flood_hops.map(umsh_core::FloodHops),
826            dst: None,
827            channel: None,
828            source: umsh_core::SourceAddrRef::Hint(from.hint()),
829            sec_info: None,
830            body_range: route_len + trace_len..wire.len(),
831            mic_range: wire.len()..wire.len(),
832            total_len: wire.len(),
833        };
834        ReceivedPacketRef::new(
835            wire,
836            &wire[route_len + trace_len..],
837            header,
838            options,
839            Some(from),
840            Some(from.hint()),
841            false,
842            None,
843            umsh_mac::RxMetadata::default(),
844        )
845    }
846
847    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
848    #[derive(Clone, Default)]
849    struct FakeMac {
850        state: Rc<RefCell<FakeMacState>>,
851    }
852
853    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
854    #[derive(Default)]
855    struct FakeMacState {
856        random_blocks: VecDeque<[u8; 32]>,
857        next_peer_id: u8,
858        next_ephemeral_id: u8,
859        now_ms: u64,
860        unicasts: Vec<SentUnicast>,
861        removed_ephemerals: Vec<LocalIdentityId>,
862        peers: Vec<(PublicKey, PeerId)>,
863        channels: Vec<umsh_core::ChannelKey>,
864        observations: Vec<umsh_mac::TransmitterObservation>,
865    }
866
867    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
868    #[derive(Clone, Debug, PartialEq, Eq)]
869    struct SentUnicast {
870        from: LocalIdentityId,
871        to: PublicKey,
872        payload: Vec<u8>,
873        options: SendOptions,
874        /// The channel a blind unicast went out on; `None` for plain unicast.
875        channel: Option<ChannelId>,
876    }
877
878    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
879    impl FakeMac {
880        fn new(random_blocks: Vec<[u8; 32]>) -> Self {
881            Self {
882                state: Rc::new(RefCell::new(FakeMacState {
883                    random_blocks: random_blocks.into(),
884                    next_peer_id: 0,
885                    next_ephemeral_id: 10,
886                    now_ms: 1_000,
887                    ..FakeMacState::default()
888                })),
889            }
890        }
891
892        fn take_unicasts(&self) -> Vec<SentUnicast> {
893            core::mem::take(&mut self.state.borrow_mut().unicasts)
894        }
895
896        fn holds_channel(&self, key: &umsh_core::ChannelKey) -> bool {
897            self.state.borrow().channels.iter().any(|k| k.0 == key.0)
898        }
899
900        fn observe(&self, hint: umsh_core::RouterHint, rssi_dbm: i16, snr: umsh_hal::Snr) {
901            let mut state = self.state.borrow_mut();
902            let last_seen_ms = state.now_ms;
903            state.observations.push(umsh_mac::TransmitterObservation {
904                hint,
905                rssi_dbm,
906                snr,
907                last_seen_ms,
908            });
909        }
910    }
911
912    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
913    impl MacBackend for FakeMac {
914        type SendError = SendError;
915        type CapacityError = CapacityError;
916        type RunError = core::convert::Infallible;
917
918        async fn next_event(
919            &self,
920            _on_event: impl FnMut(LocalIdentityId, umsh_mac::MacEventRef<'_>),
921        ) -> Result<(), Self::RunError> {
922            // FakeMac is used to drive LocalNode directly in tests, never as a
923            // Host run loop, so this stub is never invoked.
924            Ok(())
925        }
926
927        async fn add_peer(
928            &self,
929            key: PublicKey,
930        ) -> Result<PeerId, MacBackendError<Self::SendError, Self::CapacityError>> {
931            let mut state = self.state.borrow_mut();
932            if let Some((_, existing)) = state
933                .peers
934                .iter()
935                .find(|(existing_key, _)| *existing_key == key)
936            {
937                return Ok(*existing);
938            }
939            let peer_id = PeerId(state.next_peer_id);
940            state.next_peer_id = state.next_peer_id.wrapping_add(1);
941            state.peers.push((key, peer_id));
942            Ok(peer_id)
943        }
944
945        async fn add_private_channel(
946            &self,
947            key: umsh_core::ChannelKey,
948        ) -> Result<(), MacBackendError<Self::SendError, Self::CapacityError>> {
949            let mut state = self.state.borrow_mut();
950            if !state.channels.iter().any(|k| k.0 == key.0) {
951                state.channels.push(key);
952            }
953            Ok(())
954        }
955
956        async fn add_named_channel(
957            &self,
958            _name: &str,
959        ) -> Result<(), MacBackendError<Self::SendError, Self::CapacityError>> {
960            Ok(())
961        }
962
963        async fn remove_channel(&self, key: &umsh_core::ChannelKey) -> bool {
964            let mut state = self.state.borrow_mut();
965            let before = state.channels.len();
966            state.channels.retain(|k| k.0 != key.0);
967            state.channels.len() != before
968        }
969
970        async fn send_broadcast(
971            &self,
972            _from: LocalIdentityId,
973            _payload: &[u8],
974            _options: &SendOptions,
975        ) -> Result<SendReceipt, MacBackendError<Self::SendError, Self::CapacityError>> {
976            Ok(SendReceipt(99))
977        }
978
979        async fn send_multicast(
980            &self,
981            _from: LocalIdentityId,
982            _channel: &ChannelId,
983            _payload: &[u8],
984            _options: &SendOptions,
985        ) -> Result<SendReceipt, MacBackendError<Self::SendError, Self::CapacityError>> {
986            Ok(SendReceipt(99))
987        }
988
989        async fn send_unicast(
990            &self,
991            from: LocalIdentityId,
992            dst: &PublicKey,
993            payload: &[u8],
994            options: &SendOptions,
995        ) -> Result<Option<SendReceipt>, MacBackendError<Self::SendError, Self::CapacityError>>
996        {
997            self.state.borrow_mut().unicasts.push(SentUnicast {
998                from,
999                to: *dst,
1000                payload: payload.to_vec(),
1001                options: options.clone(),
1002                channel: None,
1003            });
1004            Ok(Some(SendReceipt(42)))
1005        }
1006
1007        async fn send_blind_unicast(
1008            &self,
1009            from: LocalIdentityId,
1010            dst: &PublicKey,
1011            channel: &ChannelId,
1012            payload: &[u8],
1013            options: &SendOptions,
1014        ) -> Result<Option<SendReceipt>, MacBackendError<Self::SendError, Self::CapacityError>>
1015        {
1016            self.state.borrow_mut().unicasts.push(SentUnicast {
1017                from,
1018                to: *dst,
1019                payload: payload.to_vec(),
1020                options: options.clone(),
1021                channel: Some(*channel),
1022            });
1023            Ok(Some(SendReceipt(42)))
1024        }
1025
1026        async fn fill_random(&self, dest: &mut [u8]) {
1027            let mut state = self.state.borrow_mut();
1028            let next = state.random_blocks.pop_front().expect("test rng exhausted");
1029            dest.copy_from_slice(&next[..dest.len()]);
1030        }
1031
1032        async fn now_ms(&self) -> u64 {
1033            self.state.borrow().now_ms
1034        }
1035
1036        async fn register_ephemeral(
1037            &self,
1038            _parent: LocalIdentityId,
1039            _identity: SoftwareIdentity,
1040        ) -> Result<LocalIdentityId, MacBackendError<Self::SendError, Self::CapacityError>>
1041        {
1042            let mut state = self.state.borrow_mut();
1043            let id = LocalIdentityId(state.next_ephemeral_id);
1044            state.next_ephemeral_id = state.next_ephemeral_id.wrapping_add(1);
1045            Ok(id)
1046        }
1047
1048        async fn remove_ephemeral(&self, id: LocalIdentityId) -> bool {
1049            self.state.borrow_mut().removed_ephemerals.push(id);
1050            true
1051        }
1052
1053        async fn for_each_transmitter_observation(
1054            &self,
1055            f: &mut dyn FnMut(umsh_mac::TransmitterObservation),
1056        ) {
1057            let observations = self.state.borrow().observations.clone();
1058            for observation in observations {
1059                f(observation);
1060            }
1061        }
1062    }
1063
1064    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1065    fn parse_owned_mac_command(payload: &[u8]) -> OwnedMacCommand {
1066        OwnedMacCommand::from(
1067            crate::parse_mac_command_payload(umsh_core::PacketType::Unicast, payload).unwrap(),
1068        )
1069    }
1070
1071    #[cfg(feature = "unsafe-advanced")]
1072    fn block_on_ready<F: Future>(future: F) -> F::Output {
1073        fn raw_waker() -> RawWaker {
1074            fn clone(_: *const ()) -> RawWaker {
1075                raw_waker()
1076            }
1077            fn wake(_: *const ()) {}
1078            fn wake_by_ref(_: *const ()) {}
1079            fn drop(_: *const ()) {}
1080
1081            RawWaker::new(
1082                core::ptr::null(),
1083                &RawWakerVTable::new(clone, wake, wake_by_ref, drop),
1084            )
1085        }
1086
1087        let waker = unsafe { Waker::from_raw(raw_waker()) };
1088        let mut context = Context::from_waker(&waker);
1089        let mut future = pin!(future);
1090        match future.as_mut().poll(&mut context) {
1091            Poll::Ready(value) => value,
1092            Poll::Pending => panic!("test future unexpectedly returned Poll::Pending"),
1093        }
1094    }
1095
1096    // --- Identity Request responder ---
1097
1098    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1099    fn responder_node(mac: &FakeMac) -> crate::LocalNode<FakeMac> {
1100        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
1101        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
1102        let membership = Rc::new(RefCell::new(NodeMembership::new()));
1103        let state = Rc::new(RefCell::new(LocalNodeState::new()));
1104        LocalNode::new(
1105            LocalIdentityId(1),
1106            mac.clone(),
1107            dispatcher,
1108            membership,
1109            state,
1110        )
1111    }
1112
1113    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1114    fn test_profile(public_key: PublicKey) -> crate::NodeIdentityProfile {
1115        crate::NodeIdentityProfile::new(
1116            public_key,
1117            crate::NodeRole::Repeater,
1118            crate::NodeCapabilities::REPEATER | crate::NodeCapabilities::TEXT_MESSAGES,
1119        )
1120        .with_name("repeater-1")
1121    }
1122
1123    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1124    #[test]
1125    fn responder_answers_selected_request_with_unicast_identity() {
1126        let mac = FakeMac::new(Vec::new());
1127        let node = responder_node(&mac);
1128        let our_key = PublicKey([0x11; 32]);
1129        let requester = PublicKey([0x41; 32]);
1130        node.enable_identity_responder_default(test_profile(our_key));
1131
1132        // Broadcast-style request that filters on our hint and carries a nonce.
1133        let options = crate::mac_command::IdentityRequestBuilder::new()
1134            .nonce(0xCAFE_F00D)
1135            .unwrap()
1136            .filter_hint(&our_key.hint())
1137            .unwrap()
1138            .build();
1139        let packet = test_unicast_packet(requester, &[]);
1140
1141        let plan = node
1142            .evaluate_identity_request(&packet, requester, &options, 0)
1143            .expect("responder should produce a reply plan");
1144        block_on_ready(node.send_identity_response(plan));
1145
1146        let unicasts = mac.take_unicasts();
1147        assert_eq!(unicasts.len(), 1, "exactly one unicast reply");
1148        let reply = &unicasts[0];
1149        assert_eq!(reply.to, requester, "reply is addressed to the requester");
1150        // test_unicast_packet is source_authenticated → requester already has
1151        // our key → hint source suffices.
1152        assert!(!reply.options.full_source);
1153        // A targeted request gets an immediate reply; only broadcast and
1154        // multicast solicitations are jittered.
1155        assert_eq!(reply.options.tx_delay_ms, None);
1156
1157        assert_eq!(reply.payload[0], umsh_core::PayloadType::NodeIdentity as u8);
1158        let identity = crate::NodeIdentityPayload::from_bytes(&reply.payload[1..]).unwrap();
1159        assert_eq!(identity.role, crate::NodeRole::Repeater);
1160        assert_eq!(identity.name.as_deref(), Some("repeater-1"));
1161        assert_eq!(identity.nonce, Some(0xCAFE_F00D), "request nonce echoed");
1162        assert!(identity.signature.is_none(), "responses are unsigned");
1163    }
1164
1165    /// A blind request concealed both endpoints behind the channel key. The
1166    /// identity reply follows it back onto that channel rather than naming the
1167    /// pair in the clear.
1168    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1169    #[test]
1170    fn identity_response_follows_a_blind_request_onto_its_channel() {
1171        let mac = FakeMac::new(Vec::new());
1172        let node = responder_node(&mac);
1173        let our_key = PublicKey([0x11; 32]);
1174        let requester = PublicKey([0x41; 32]);
1175        node.enable_identity_responder_default(test_profile(our_key));
1176
1177        let channel_key = umsh_core::ChannelKey([0x5A; 32]);
1178        let channel_id = umsh_core::ChannelId([0xC1, 0xD2]);
1179        let options = crate::mac_command::IdentityRequestBuilder::new()
1180            .filter_hint(&our_key.hint())
1181            .unwrap()
1182            .build();
1183        let packet = test_channel_packet(
1184            requester,
1185            umsh_core::PacketType::BlindUnicast,
1186            &channel_key,
1187            channel_id,
1188            &[],
1189        );
1190
1191        let plan = node
1192            .evaluate_identity_request(&packet, requester, &options, 0)
1193            .expect("responder should produce a reply plan");
1194        block_on_ready(node.send_identity_response(plan));
1195
1196        let sent = mac.take_unicasts();
1197        assert_eq!(sent.len(), 1);
1198        assert_eq!(
1199            sent[0].channel,
1200            Some(channel_id),
1201            "a blind request must not be answered off its channel"
1202        );
1203    }
1204
1205    /// A multicast solicitation carries a channel too, but every node it
1206    /// selects answers the same frame; targeted unicast replies are what keep
1207    /// one solicitation from filling the channel with them.
1208    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1209    #[test]
1210    fn identity_response_to_a_multicast_solicitation_stays_unicast() {
1211        let mac = FakeMac::new(vec![[0x12; 32]]);
1212        let node = responder_node(&mac);
1213        let our_key = PublicKey([0x11; 32]);
1214        let requester = PublicKey([0x41; 32]);
1215        node.enable_identity_responder_default(test_profile(our_key));
1216
1217        let channel_key = umsh_core::ChannelKey([0x5A; 32]);
1218        let channel_id = umsh_core::ChannelId([0xC1, 0xD2]);
1219        let options = crate::mac_command::IdentityRequestBuilder::new()
1220            .filter_hint(&our_key.hint())
1221            .unwrap()
1222            .build();
1223        let packet = test_channel_packet(
1224            requester,
1225            umsh_core::PacketType::Multicast,
1226            &channel_key,
1227            channel_id,
1228            &[],
1229        );
1230
1231        let plan = node
1232            .evaluate_identity_request(&packet, requester, &options, 0)
1233            .expect("responder should produce a reply plan");
1234        block_on_ready(node.send_identity_response(plan));
1235
1236        let sent = mac.take_unicasts();
1237        assert_eq!(sent.len(), 1);
1238        assert_eq!(sent[0].channel, None);
1239    }
1240
1241    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1242    #[test]
1243    fn responder_answers_broadcast_solicitation_with_delayed_full_source_reply() {
1244        let mac = FakeMac::new(vec![[0x12; 32]]);
1245        let node = responder_node(&mac);
1246        let our_key = PublicKey([0x11; 32]);
1247        let requester = PublicKey([0x41; 32]);
1248        node.enable_identity_responder_default(test_profile(our_key));
1249
1250        let options = crate::mac_command::IdentityRequestBuilder::new()
1251            .nonce(0x0000_BEEF)
1252            .unwrap()
1253            .filter_role(crate::NodeRole::Repeater)
1254            .unwrap()
1255            .build();
1256        let packet = test_broadcast_packet(requester, None, None);
1257
1258        let plan = node
1259            .evaluate_identity_request(&packet, requester, &options, 0)
1260            .expect("selected broadcast solicitation produces a plan");
1261        block_on_ready(node.send_identity_response(plan));
1262
1263        let unicasts = mac.take_unicasts();
1264        assert_eq!(unicasts.len(), 1);
1265        let reply = &unicasts[0];
1266        assert_eq!(reply.to, requester);
1267        // A broadcast is unauthenticated, so the requester may lack our key.
1268        assert!(reply.options.full_source);
1269        // The reply is held for a random slice of the 30-second window so the
1270        // selected nodes do not all answer the same frame at once.
1271        let delay = reply
1272            .options
1273            .tx_delay_ms
1274            .expect("broadcast replies are jittered");
1275        assert!((500..=30_000).contains(&delay));
1276        // No FILTER_NODE_HINT narrowed the solicitation, so the reply stays
1277        // inside the one hop the request was allowed: no FHOPS field.
1278        assert_eq!(reply.options.flood_hops, None);
1279    }
1280
1281    /// A `FILTER_NODE_HINT` names one answering node, so the solicitation may
1282    /// be flood routed and the reply keeps its normal flood budget.
1283    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1284    #[test]
1285    fn responder_answers_flood_routed_hint_filtered_solicitation() {
1286        let mac = FakeMac::new(vec![[0x12; 32]]);
1287        let node = responder_node(&mac);
1288        let our_key = PublicKey([0x11; 32]);
1289        let requester = PublicKey([0x41; 32]);
1290        node.enable_identity_responder_default(test_profile(our_key));
1291
1292        let options = crate::mac_command::IdentityRequestBuilder::new()
1293            .filter_hint(&our_key.hint())
1294            .unwrap()
1295            .build();
1296        // FHOPS_REM=2, FHOPS_ACC=1: repeated once, two hops of budget left.
1297        let repeated = test_broadcast_packet(requester, Some(0x21), None);
1298
1299        let plan = node
1300            .evaluate_identity_request(&repeated, requester, &options, 0)
1301            .expect("a hint-filtered solicitation may be flood routed");
1302        block_on_ready(node.send_identity_response(plan));
1303
1304        let unicasts = mac.take_unicasts();
1305        assert_eq!(unicasts.len(), 1);
1306        assert!(
1307            unicasts[0].options.flood_hops.is_some(),
1308            "the reply may be flooded back to a requester that named us"
1309        );
1310        assert_eq!(
1311            unicasts[0].options.tx_delay_ms, None,
1312            "a whole hint names one node, so the reply goes out at once"
1313        );
1314    }
1315
1316    /// A one-byte prefix keeps the hold: it selects a 256th of everything the
1317    /// request reaches, and a hint-filtered request may be flood routed, so
1318    /// that fraction is of the whole mesh rather than one neighborhood.
1319    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1320    #[test]
1321    fn responder_holds_a_reply_to_a_one_byte_hint_prefix() {
1322        let mac = FakeMac::new(vec![[0x12; 32]]);
1323        let node = responder_node(&mac);
1324        let our_key = PublicKey([0x11; 32]);
1325        let requester = PublicKey([0x41; 32]);
1326        node.enable_identity_responder_default(test_profile(our_key));
1327
1328        let options = crate::mac_command::IdentityRequestBuilder::new()
1329            .filter_hint_prefix(&our_key.hint().0[..1])
1330            .unwrap()
1331            .build();
1332        let packet = test_broadcast_packet(requester, None, None);
1333
1334        let plan = node
1335            .evaluate_identity_request(&packet, requester, &options, 0)
1336            .expect("a one-byte prefix still selects the nodes it covers");
1337        block_on_ready(node.send_identity_response(plan));
1338
1339        let unicasts = mac.take_unicasts();
1340        assert_eq!(unicasts.len(), 1);
1341        let delay = unicasts[0]
1342            .options
1343            .tx_delay_ms
1344            .expect("a prefix this short may select a crowd, which is held");
1345        assert!((500..=30_000).contains(&delay));
1346    }
1347
1348    /// The shape that identifies an intermediate hop: the requester knows only
1349    /// the two-byte router hint a route named it by, steers the ask to the hop
1350    /// before it so it arrives with an empty Route option, and gets an answer
1351    /// back down the trace the request accumulated on the way.
1352    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1353    #[test]
1354    fn responder_answers_a_router_hint_prefix_and_replies_down_the_trace() {
1355        let mac = FakeMac::new(vec![[0x12; 32]]);
1356        let node = responder_node(&mac);
1357        let our_key = PublicKey([0x11; 32]);
1358        let requester = PublicKey([0x41; 32]);
1359        node.enable_identity_responder_default(test_profile(our_key));
1360
1361        // Two bytes of our own hint — everything a route reveals about us.
1362        let router_hint = &our_key.hint().0[..2];
1363        let options = crate::mac_command::IdentityRequestBuilder::new()
1364            .filter_hint_prefix(router_hint)
1365            .unwrap()
1366            .build();
1367        // Steered here, so the Route option arrived emptied, and traced, so
1368        // the repeater that carried it prepended its own hint.
1369        let steered =
1370            test_broadcast_packet_with_trace(requester, Some(0x00), Some(&[]), Some(&[0x12, 0x34]));
1371
1372        let plan = node
1373            .evaluate_identity_request(&steered, requester, &options, 0)
1374            .expect("a router-hint prefix selects the node it names");
1375        block_on_ready(node.send_identity_response(plan));
1376
1377        let unicasts = mac.take_unicasts();
1378        assert_eq!(unicasts.len(), 1);
1379        assert_eq!(
1380            unicasts[0].options.source_route.as_deref(),
1381            Some([umsh_core::RouterHint([0x12, 0x34])].as_slice()),
1382            "the reply retraces the path the question came by"
1383        );
1384        assert_eq!(
1385            unicasts[0].options.tx_delay_ms, None,
1386            "two bytes name one node, so there is no crowd of replies to spread"
1387        );
1388
1389        // A hint that is a prefix of somebody else's is not a prefix of ours.
1390        let stranger = crate::mac_command::IdentityRequestBuilder::new()
1391            .filter_hint_prefix(&[router_hint[0], router_hint[1] ^ 0xFF])
1392            .unwrap()
1393            .build();
1394        assert!(
1395            node.evaluate_identity_request(&steered, requester, &stranger, 0)
1396                .is_none(),
1397            "a prefix naming another router selects nobody here"
1398        );
1399    }
1400
1401    /// One solicitation gets one reply, however many copies of it arrive.
1402    ///
1403    /// A request is unauthenticated and carries no frame counter, so nothing
1404    /// below the node layer can recognize a repeat of one; a repeater that
1405    /// carried a copy it should have left alone used to buy the requester an
1406    /// extra reply from every node in earshot.
1407    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1408    #[test]
1409    fn responder_answers_one_solicitation_once() {
1410        let mac = FakeMac::new(vec![[0x12; 32], [0x13; 32], [0x14; 32]]);
1411        let node = responder_node(&mac);
1412        let our_key = PublicKey([0x11; 32]);
1413        let requester = PublicKey([0x41; 32]);
1414        node.enable_identity_responder_default(test_profile(our_key));
1415
1416        let options = crate::mac_command::IdentityRequestBuilder::new()
1417            .nonce(0x0000_BEEF)
1418            .unwrap()
1419            .filter_role(crate::NodeRole::Repeater)
1420            .unwrap()
1421            .build();
1422        let packet = test_broadcast_packet(requester, None, None);
1423
1424        let plan = node
1425            .evaluate_identity_request(&packet, requester, &options, 1_000)
1426            .expect("the first copy is answered");
1427        block_on_ready(node.send_identity_response(plan));
1428
1429        // A second copy arriving while the first reply is still held in the
1430        // transmit queue, and a third once it has aired.
1431        assert!(
1432            node.evaluate_identity_request(&packet, requester, &options, 3_000)
1433                .is_none(),
1434            "a copy arriving mid-hold must not queue a second reply"
1435        );
1436        assert!(
1437            node.evaluate_identity_request(&packet, requester, &options, 45_000)
1438                .is_none(),
1439            "nor one arriving after the first reply aired"
1440        );
1441        assert_eq!(mac.take_unicasts().len(), 1, "exactly one reply");
1442
1443        // A fresh nonce is the requester asking again, and is answered.
1444        let asked_again = crate::mac_command::IdentityRequestBuilder::new()
1445            .nonce(0x0000_F00D)
1446            .unwrap()
1447            .filter_role(crate::NodeRole::Repeater)
1448            .unwrap()
1449            .build();
1450        let plan = node
1451            .evaluate_identity_request(&packet, requester, &asked_again, 46_000)
1452            .expect("a new solicitation is a new question");
1453        block_on_ready(node.send_identity_response(plan));
1454        assert_eq!(mac.take_unicasts().len(), 1);
1455
1456        // So is the same nonce once the suppression window has passed.
1457        let plan = node
1458            .evaluate_identity_request(&packet, requester, &options, 200_000)
1459            .expect("suppression is a window, not a permanent refusal");
1460        block_on_ready(node.send_identity_response(plan));
1461        assert_eq!(mac.take_unicasts().len(), 1);
1462    }
1463
1464    /// Suppression names a solicitation, not a peer: two requesters asking at
1465    /// once both get an answer.
1466    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1467    #[test]
1468    fn responder_answers_every_requester_that_asks() {
1469        let mac = FakeMac::new(vec![[0x12; 32], [0x13; 32]]);
1470        let node = responder_node(&mac);
1471        let our_key = PublicKey([0x11; 32]);
1472        node.enable_identity_responder_default(test_profile(our_key));
1473
1474        let options = crate::mac_command::IdentityRequestBuilder::new()
1475            .nonce(0x0000_BEEF)
1476            .unwrap()
1477            .filter_role(crate::NodeRole::Repeater)
1478            .unwrap()
1479            .build();
1480
1481        // Same nonce, different askers — a nonce is only unique to its sender.
1482        for requester in [PublicKey([0x41; 32]), PublicKey([0x42; 32])] {
1483            let packet = test_broadcast_packet(requester, None, None);
1484            let plan = node
1485                .evaluate_identity_request(&packet, requester, &options, 1_000)
1486                .expect("each requester is owed an answer");
1487            block_on_ready(node.send_identity_response(plan));
1488        }
1489        assert_eq!(mac.take_unicasts().len(), 2);
1490    }
1491
1492    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1493    #[test]
1494    fn responder_drops_repeated_or_routed_broadcast_solicitations() {
1495        let mac = FakeMac::new(Vec::new());
1496        let node = responder_node(&mac);
1497        let our_key = PublicKey([0x11; 32]);
1498        let requester = PublicKey([0x41; 32]);
1499        node.enable_identity_responder_default(test_profile(our_key));
1500        let options = crate::mac_command::IdentityRequestBuilder::new()
1501            .filter_role(crate::NodeRole::Repeater)
1502            .unwrap()
1503            .build();
1504
1505        // A nonzero FHOPS byte marks a request that was flood routed, and no
1506        // FILTER_NODE_HINT narrows this one to a single answering node.
1507        let repeated = test_broadcast_packet(requester, Some(0x21), None);
1508        assert!(
1509            node.evaluate_identity_request(&repeated, requester, &options, 0)
1510                .is_none()
1511        );
1512
1513        // A non-empty Route option is a steered request. That holds whatever
1514        // the filters say, so check it with a hint filter too.
1515        let routed = test_broadcast_packet(requester, None, Some(&[0xAB, 0xCD]));
1516        assert!(
1517            node.evaluate_identity_request(&routed, requester, &options, 0)
1518                .is_none()
1519        );
1520        let hint_options = crate::mac_command::IdentityRequestBuilder::new()
1521            .filter_hint(&our_key.hint())
1522            .unwrap()
1523            .build();
1524        assert!(
1525            node.evaluate_identity_request(&routed, requester, &hint_options, 0)
1526                .is_none()
1527        );
1528
1529        // A zeroed FHOPS byte and a present-but-empty Route option are fine.
1530        let clean = test_broadcast_packet(requester, Some(0x00), Some(&[]));
1531        assert!(
1532            node.evaluate_identity_request(&clean, requester, &options, 0)
1533                .is_some()
1534        );
1535    }
1536
1537    /// A steered solicitation arrives with its Route option emptied by the
1538    /// repeaters that spent it and a trace route they filled in on the way.
1539    /// That trace is the requester's only path home — the reply carries no
1540    /// flood budget, and receiving a broadcast teaches the MAC no route — so
1541    /// the reply must go back down it, copied verbatim rather than reversed.
1542    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1543    #[test]
1544    fn responder_routes_its_reply_back_down_the_requests_trace() {
1545        let mac = FakeMac::new(vec![[0x00; 32]]);
1546        let node = responder_node(&mac);
1547        let our_key = PublicKey([0x11; 32]);
1548        let requester = PublicKey([0x41; 32]);
1549        node.enable_identity_responder_default(test_profile(our_key));
1550        let options = crate::mac_command::IdentityRequestBuilder::new()
1551            .filter_role(crate::NodeRole::Repeater)
1552            .unwrap()
1553            .build();
1554
1555        // Two repeaters carried it: each consumed its own hint from the Route
1556        // option, leaving it empty, and prepended itself to the trace.
1557        let steered = test_broadcast_packet_with_trace(
1558            requester,
1559            Some(0x00),
1560            Some(&[]),
1561            Some(&[0x12, 0x34, 0xAB, 0xCD]),
1562        );
1563        let plan = node
1564            .evaluate_identity_request(&steered, requester, &options, 0)
1565            .expect("an emptied route is an arrived request, and we answer it");
1566        block_on_ready(node.send_identity_response(plan));
1567
1568        let unicasts = mac.take_unicasts();
1569        assert_eq!(unicasts.len(), 1);
1570        let reply = &unicasts[0];
1571        let route = reply
1572            .options
1573            .source_route
1574            .as_ref()
1575            .expect("the reply is steered back");
1576        // Same order as the trace: repeaters prepend, so an accumulated trace
1577        // already reads as the path back.
1578        assert_eq!(
1579            route.as_slice(),
1580            &[
1581                umsh_core::RouterHint([0x12, 0x34]),
1582                umsh_core::RouterHint([0xAB, 0xCD])
1583            ]
1584        );
1585        // And still no flood budget: routing it home must not also license
1586        // every repeater that hears it to flood it onward.
1587        assert_eq!(reply.options.flood_hops, None);
1588    }
1589
1590    /// The ordinary in-range case is unchanged: no trace in, no route out,
1591    /// and above all no empty Route option on the wire.
1592    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1593    #[test]
1594    fn responder_leaves_an_untraced_reply_unrouted() {
1595        let mac = FakeMac::new(vec![[0x00; 32]]);
1596        let node = responder_node(&mac);
1597        let our_key = PublicKey([0x11; 32]);
1598        let requester = PublicKey([0x41; 32]);
1599        node.enable_identity_responder_default(test_profile(our_key));
1600        let options = crate::mac_command::IdentityRequestBuilder::new()
1601            .filter_role(crate::NodeRole::Repeater)
1602            .unwrap()
1603            .build();
1604
1605        let direct = test_broadcast_packet(requester, None, None);
1606        let plan = node
1607            .evaluate_identity_request(&direct, requester, &options, 0)
1608            .expect("a zero-hop solicitation is answered");
1609        block_on_ready(node.send_identity_response(plan));
1610
1611        let unicasts = mac.take_unicasts();
1612        assert_eq!(unicasts.len(), 1);
1613        assert!(unicasts[0].options.source_route.is_none());
1614        assert_eq!(unicasts[0].options.flood_hops, None);
1615    }
1616
1617    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1618    #[test]
1619    fn responder_ignores_request_that_filters_exclude() {
1620        let mac = FakeMac::new(Vec::new());
1621        let node = responder_node(&mac);
1622        let our_key = PublicKey([0x11; 32]);
1623        node.enable_identity_responder_default(test_profile(our_key));
1624
1625        // Filter targets a different hint → we are not selected.
1626        let other_hint = PublicKey([0x99; 32]).hint();
1627        let options = crate::mac_command::IdentityRequestBuilder::new()
1628            .filter_hint(&other_hint)
1629            .unwrap()
1630            .build();
1631        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1632
1633        assert!(
1634            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options, 0)
1635                .is_none()
1636        );
1637    }
1638
1639    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1640    #[test]
1641    fn responder_respects_ignore_policy() {
1642        let mac = FakeMac::new(Vec::new());
1643        let node = responder_node(&mac);
1644        let our_key = PublicKey([0x11; 32]);
1645        node.enable_identity_responder(test_profile(our_key), |_ctx| {
1646            crate::RespondDecision::Ignore
1647        });
1648
1649        // No filters → selects everyone, so only the policy can decline.
1650        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1651        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1652
1653        assert!(
1654            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options, 0)
1655                .is_none()
1656        );
1657    }
1658
1659    /// Advertisements are built from the installed profile, so a node that
1660    /// has opted out of being discovered must still be able to read it back.
1661    /// Silencing the responder with a policy rather than uninstalling it is
1662    /// what keeps the two independent.
1663    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1664    #[test]
1665    fn never_respond_policy_keeps_the_profile_readable() {
1666        let mac = FakeMac::new(Vec::new());
1667        let node = responder_node(&mac);
1668        let our_key = PublicKey([0x11; 32]);
1669        node.enable_identity_responder(test_profile(our_key), crate::never_respond_policy);
1670
1671        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1672        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1673        assert!(
1674            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options, 0)
1675                .is_none(),
1676            "a silenced responder answers nothing"
1677        );
1678        assert_eq!(
1679            node.with_identity_profile(|profile| profile.public_key),
1680            Some(our_key),
1681            "yet the profile an advertisement is built from is still there"
1682        );
1683    }
1684
1685    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1686    #[test]
1687    fn responder_disabled_yields_no_plan() {
1688        let mac = FakeMac::new(Vec::new());
1689        let node = responder_node(&mac);
1690        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1691        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1692        assert!(
1693            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options, 0)
1694                .is_none()
1695        );
1696    }
1697
1698    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1699    #[test]
1700    fn default_policy_full_source_tracks_authentication() {
1701        use crate::identity_responder::{IdentityRequestContext, default_respond_policy};
1702        use crate::mac_command::IdentityRequestFilters;
1703
1704        let base = |source_authenticated: bool| IdentityRequestContext {
1705            from_key: PublicKey([0x41; 32]),
1706            from_hint: None,
1707            source_authenticated,
1708            has_full_source: false,
1709            channel: None,
1710            family: crate::PacketFamily::Unicast,
1711            filters: IdentityRequestFilters::new(&[]),
1712            rssi: None,
1713            snr: None,
1714            trace_route: &[],
1715        };
1716
1717        // Authenticated request → sender already holds our key → hint reply.
1718        assert_eq!(
1719            default_respond_policy(&base(true)),
1720            crate::RespondDecision::Respond { full_source: false }
1721        );
1722        // Unauthenticated request → sender may lack our key → include it.
1723        assert_eq!(
1724            default_respond_policy(&base(false)),
1725            crate::RespondDecision::Respond { full_source: true }
1726        );
1727    }
1728
1729    // --- Peer Repeaters responder ---
1730
1731    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1732    fn peer_identity(name: &str, regions: &[&str]) -> crate::NodeIdentityPayload {
1733        crate::NodeIdentityPayload {
1734            role: crate::NodeRole::Repeater,
1735            capabilities: crate::NodeCapabilities::REPEATER,
1736            name: Some(String::from(name)),
1737            location: None,
1738            altitude_m: None,
1739            timestamp: None,
1740            supported_regions: Some(regions.iter().map(|text| String::from(*text)).collect()),
1741            nonce: None,
1742            signature: None,
1743        }
1744    }
1745
1746    /// The request options a requester would put on the wire.
1747    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1748    fn peer_repeaters_request(nonce: Option<u16>, cursor: Option<&[u8]>) -> Vec<u8> {
1749        let mut builder = crate::mac_command::PeerRepeatersRequestBuilder::new();
1750        if let Some(nonce) = nonce {
1751            builder = builder.nonce(nonce).unwrap();
1752        }
1753        if let Some(cursor) = cursor {
1754            builder = builder.cursor(cursor).unwrap();
1755        }
1756        builder.build()
1757    }
1758
1759    /// Pull the one response the responder sent back off the fake MAC and
1760    /// reparse it exactly as a receiver would.
1761    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1762    /// The peer-repeater listing names the neighborhood around this node. A
1763    /// request that arrived concealed on a channel is answered there, not in
1764    /// the open.
1765    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1766    #[test]
1767    fn peer_repeaters_response_follows_a_blind_request_onto_its_channel() {
1768        let mac = FakeMac::new(Vec::new());
1769        let node = responder_node(&mac);
1770        node.enable_peer_repeaters_responder();
1771        let channel_id = umsh_core::ChannelId([0xC1, 0xD2]);
1772
1773        block_on_ready(node.answer_peer_repeaters_request(
1774            PublicKey([0x41; 32]),
1775            Some(channel_id),
1776            &peer_repeaters_request(Some(7), None),
1777        ));
1778
1779        let sent = mac.take_unicasts();
1780        assert_eq!(sent.len(), 1);
1781        assert_eq!(sent[0].channel, Some(channel_id));
1782    }
1783
1784    /// Pull the one response the responder sent back off the fake MAC and
1785    /// reparse it exactly as a receiver would.
1786    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1787    fn sent_response(mac: &FakeMac) -> (PublicKey, Vec<u8>) {
1788        let mut unicasts = mac.take_unicasts();
1789        assert_eq!(unicasts.len(), 1, "exactly one response frame");
1790        let sent = unicasts.remove(0);
1791        assert_eq!(
1792            sent.payload[0],
1793            umsh_core::PayloadType::MacCommand as u8,
1794            "responses travel as MAC commands"
1795        );
1796        let body = match crate::mac_command::parse(&sent.payload[1..]).unwrap() {
1797            crate::mac_command::MacCommand::PeerRepeatersResponse { body } => body.to_vec(),
1798            other => panic!("expected a Peer Repeaters Response, got {other:?}"),
1799        };
1800        (sent.to, body)
1801    }
1802
1803    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1804    #[test]
1805    fn a_disabled_responder_answers_nothing() {
1806        let mac = FakeMac::new(Vec::new());
1807        let node = responder_node(&mac);
1808        node.observe_peer_identity(PublicKey([0xAA; 32]), &peer_identity("Ridge", &[]), 0);
1809
1810        block_on_ready(node.answer_peer_repeaters_request(
1811            PublicKey([0x41; 32]),
1812            None,
1813            &peer_repeaters_request(None, None),
1814        ));
1815        assert!(mac.take_unicasts().is_empty());
1816    }
1817
1818    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1819    #[test]
1820    fn a_response_echoes_the_nonce_and_reports_what_both_sources_know() {
1821        let mac = FakeMac::new(Vec::new());
1822        let node = responder_node(&mac);
1823        node.enable_peer_repeaters_responder();
1824
1825        let peer = PublicKey([0xAA; 32]);
1826        node.observe_peer_identity(peer, &peer_identity("Ridge", &["SJC", "0x1234"]), 0);
1827        // The same peer heard on the air: only this supplies signal.
1828        mac.observe(
1829            umsh_core::RouterHint([peer.0[0], peer.0[1]]),
1830            -95,
1831            umsh_hal::Snr::from_decibels(2),
1832        );
1833        // A hop nothing has an identity for still names itself in a trace.
1834        mac.observe(
1835            umsh_core::RouterHint([0x11, 0x22]),
1836            -70,
1837            umsh_hal::Snr::from_decibels(-4),
1838        );
1839
1840        let requester = PublicKey([0x41; 32]);
1841        block_on_ready(node.answer_peer_repeaters_request(
1842            requester,
1843            None,
1844            &peer_repeaters_request(Some(0xBEEF), None),
1845        ));
1846
1847        let (to, body) = sent_response(&mac);
1848        assert_eq!(to, requester);
1849        let view = crate::mac_command::PeerRepeatersResponseView::new(&body);
1850        assert_eq!(view.nonce(), Some(0xBEEF), "request nonce echoed");
1851        assert_eq!(view.total(), Some(2));
1852        assert_eq!(view.cursor(), None, "one page held everything");
1853
1854        let entries: Vec<_> = view.entries().collect();
1855        assert_eq!(entries.len(), 2);
1856
1857        assert_eq!(entries[0].hint(), Some(&peer.hint().0[..]));
1858        assert_eq!(entries[0].name(), Some("Ridge"));
1859        let (rssi, snr) = entries[0].rssi_snr().unwrap();
1860        assert_eq!(rssi, -95);
1861        assert_eq!(snr, umsh_hal::Snr::from_decibels(2));
1862        assert_eq!(
1863            entries[0].regions().collect::<Vec<_>>(),
1864            vec![[0x78, 0x53], [0x12, 0x34]],
1865            "the identity's region strings arrive as derived codes"
1866        );
1867
1868        assert_eq!(
1869            entries[1].hint(),
1870            Some(&[0x11, 0x22][..]),
1871            "an unclaimed observation is named by its router hint alone"
1872        );
1873        assert_eq!(entries[1].name(), None);
1874        assert_eq!(entries[1].rssi_snr().unwrap().0, -70);
1875        assert!(entries[1].regions().next().is_none());
1876    }
1877
1878    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1879    #[test]
1880    fn a_full_table_pages_and_the_cursor_resumes_where_it_stopped() {
1881        let mac = FakeMac::new(Vec::new());
1882        let node = responder_node(&mac);
1883        node.enable_peer_repeaters_responder();
1884
1885        // Long names, so the entries are large enough that one page cannot
1886        // hold the whole table.
1887        for seed in 0..crate::peer_repeaters::MAX_PEER_REPEATERS as u8 {
1888            node.observe_peer_identity(
1889                PublicKey([seed; 32]),
1890                &peer_identity(&format!("Repeater number {seed:08}"), &["Rogue Valley"]),
1891                0,
1892            );
1893        }
1894
1895        let requester = PublicKey([0x41; 32]);
1896        let mut seen: Vec<Vec<u8>> = Vec::new();
1897        let mut cursor: Option<Vec<u8>> = None;
1898        let mut pages = 0;
1899        loop {
1900            block_on_ready(node.answer_peer_repeaters_request(
1901                requester,
1902                None,
1903                &peer_repeaters_request(Some(1), cursor.as_deref()),
1904            ));
1905            let (_, body) = sent_response(&mac);
1906            let view = crate::mac_command::PeerRepeatersResponseView::new(&body);
1907            assert_eq!(
1908                view.total(),
1909                Some(crate::peer_repeaters::MAX_PEER_REPEATERS as u8),
1910                "every page reports the whole listing's size"
1911            );
1912            seen.extend(
1913                view.entries()
1914                    .filter_map(|entry| entry.hint().map(Vec::from)),
1915            );
1916            pages += 1;
1917            assert!(pages < 10, "paging should terminate");
1918            match view.cursor() {
1919                Some(next) => cursor = Some(next.to_vec()),
1920                None => break,
1921            }
1922        }
1923
1924        assert!(pages > 1, "the table did not fit one page");
1925        assert_eq!(seen.len(), crate::peer_repeaters::MAX_PEER_REPEATERS);
1926        for seed in 0..crate::peer_repeaters::MAX_PEER_REPEATERS as u8 {
1927            assert!(
1928                seen.contains(&Vec::from(&PublicKey([seed; 32]).hint().0[..])),
1929                "every repeater was listed exactly once across the pages"
1930            );
1931        }
1932        let mut sorted = seen.clone();
1933        sorted.sort();
1934        sorted.dedup();
1935        assert_eq!(
1936            sorted.len(),
1937            seen.len(),
1938            "no entry was repeated across pages"
1939        );
1940    }
1941
1942    /// A cursor names a place in a listing. If the listing has changed since,
1943    /// resuming into it would skip or repeat peers, so the walk restarts.
1944    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1945    #[test]
1946    fn a_cursor_from_a_changed_listing_restarts_the_walk() {
1947        let mac = FakeMac::new(Vec::new());
1948        let node = responder_node(&mac);
1949        node.enable_peer_repeaters_responder();
1950        for seed in 0..3u8 {
1951            node.observe_peer_identity(PublicKey([seed; 32]), &peer_identity("Peer", &[]), 0);
1952        }
1953
1954        // A cursor whose generation matches resumes; index 2 leaves one entry.
1955        let generation = 3u16.to_be_bytes();
1956        block_on_ready(node.answer_peer_repeaters_request(
1957            PublicKey([0x41; 32]),
1958            None,
1959            &peer_repeaters_request(None, Some(&[generation[0], generation[1], 2])),
1960        ));
1961        let (_, body) = sent_response(&mac);
1962        let view = crate::mac_command::PeerRepeatersResponseView::new(&body);
1963        assert_eq!(view.entries().count(), 1, "resumed at the third entry");
1964
1965        // One more identity moves the generation on, and the same cursor is
1966        // now stale.
1967        node.observe_peer_identity(PublicKey([0x77; 32]), &peer_identity("Newcomer", &[]), 0);
1968        block_on_ready(node.answer_peer_repeaters_request(
1969            PublicKey([0x41; 32]),
1970            None,
1971            &peer_repeaters_request(None, Some(&[generation[0], generation[1], 2])),
1972        ));
1973        let (_, body) = sent_response(&mac);
1974        let view = crate::mac_command::PeerRepeatersResponseView::new(&body);
1975        assert_eq!(
1976            view.entries().count(),
1977            4,
1978            "the stale cursor restarted the walk"
1979        );
1980    }
1981
1982    /// The listing is the answer even when it is empty — a repeater that
1983    /// knows of nobody says so rather than staying silent.
1984    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1985    #[test]
1986    fn an_empty_neighborhood_still_answers() {
1987        let mac = FakeMac::new(Vec::new());
1988        let node = responder_node(&mac);
1989        node.enable_peer_repeaters_responder();
1990
1991        block_on_ready(node.answer_peer_repeaters_request(
1992            PublicKey([0x41; 32]),
1993            None,
1994            &peer_repeaters_request(None, None),
1995        ));
1996        let (_, body) = sent_response(&mac);
1997        let view = crate::mac_command::PeerRepeatersResponseView::new(&body);
1998        assert_eq!(view.total(), Some(0));
1999        assert_eq!(view.entries().count(), 0);
2000        assert_eq!(view.cursor(), None);
2001    }
2002}