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;
153#[cfg(feature = "software-crypto")]
154mod pfs;
155mod receive;
156mod ticket;
157mod transport;
158
159pub use app_error::{AppEncodeError, AppParseError};
160pub use app_payload::{
161    expect_payload_type, parse_mac_command_payload, parse_node_identity_payload, split_payload_type,
162};
163#[cfg(feature = "software-crypto")]
164pub use channel::Channel;
165pub use host::{Host, HostError};
166pub use identity::{NodeCapabilities, NodeIdentityPayload, NodeRole};
167pub use identity_responder::{
168    IdentityRequestContext, NodeIdentityProfile, RespondDecision, default_respond_policy,
169    never_respond_policy,
170};
171pub use mac::{MacBackend, MacBackendError};
172pub use mac_command::OwnedMacCommand;
173pub use mac_command::{CommandId, MacCommand};
174#[cfg(feature = "software-crypto")]
175pub use node::BoundChannel;
176#[cfg(feature = "software-crypto")]
177pub use node::PfsStatus;
178pub use node::{LocalNode, NodeError, PfsFailure, PongMetadata, Subscription};
179pub use peer::{PING_MIC_SIZE, PeerConnection};
180pub use receive::{ChannelInfoRef, PacketFamily, ReceivedPacketRef, RouteHops, RxMetadata, Snr};
181pub use ticket::{SendProgressTicket, SendToken};
182pub use transport::Transport;
183
184#[cfg(test)]
185mod tests {
186    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
187    use std::{
188        cell::RefCell,
189        collections::VecDeque,
190        future::Future,
191        num::NonZeroU8,
192        pin::pin,
193        rc::Rc,
194        task::{Context, Poll, RawWaker, RawWakerVTable, Waker},
195    };
196    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
197    use umsh_core::{NodeHint, PublicKey};
198    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
199    use umsh_crypto::NodeIdentity;
200    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
201    use umsh_crypto::software::SoftwareIdentity;
202    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
203    use umsh_hal::Snr;
204    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
205    use umsh_mac::MacEventRef;
206    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
207    use umsh_mac::{CapacityError, LocalIdentityId, PeerId, SendError, SendOptions, SendReceipt};
208
209    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
210    use crate::ReceivedPacketRef;
211    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
212    use crate::{MacBackend, MacBackendError, OwnedMacCommand, SendToken};
213    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
214    use umsh_core::ChannelId;
215    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
216    use umsh_text::OwnedTextMessage;
217    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
218    #[test]
219    fn peer_receive_handlers_precede_node_receive_handlers() {
220        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
221
222        let mac = FakeMac::new(Vec::new());
223        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
224        let membership = Rc::new(RefCell::new(NodeMembership::new()));
225        let state = Rc::new(RefCell::new(LocalNodeState::new()));
226        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
227        let peer = PublicKey([0x41; 32]);
228        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
229
230        let call_order = Rc::new(RefCell::new(Vec::new()));
231        let peer_call_order = call_order.clone();
232        let _peer_subscription = peer_connection.on_receive(move |_| {
233            peer_call_order.borrow_mut().push("peer");
234            true
235        });
236        let node_call_order = call_order.clone();
237        let _node_subscription = node.on_receive(move |_| {
238            node_call_order.borrow_mut().push("node");
239            true
240        });
241
242        assert!(node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
243        assert_eq!(call_order.borrow().as_slice(), ["peer"]);
244    }
245
246    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
247    fn test_node(mac: FakeMac) -> crate::node::LocalNode<FakeMac> {
248        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
249
250        LocalNode::new(
251            LocalIdentityId(1),
252            mac,
253            Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new())),
254            Rc::new(RefCell::new(NodeMembership::new())),
255            Rc::new(RefCell::new(LocalNodeState::new())),
256        )
257    }
258
259    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
260    #[test]
261    fn leaving_a_channel_unregisters_its_key_from_the_mac() {
262        let mac = FakeMac::new(Vec::new());
263        let node = test_node(mac.clone());
264        let channel = crate::Channel::private(umsh_core::ChannelKey([0x11; 32]), "trail");
265
266        block_on_ready(node.join(&channel)).unwrap();
267        assert!(mac.holds_channel(channel.key()));
268
269        block_on_ready(node.leave(&channel)).unwrap();
270        assert!(!mac.holds_channel(channel.key()));
271        assert!(node.bound_channel(&channel).is_none());
272    }
273
274    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
275    #[test]
276    fn rejoining_after_leave_re_registers_the_key_with_the_mac() {
277        let mac = FakeMac::new(Vec::new());
278        let node = test_node(mac.clone());
279        let channel = crate::Channel::private(umsh_core::ChannelKey([0x22; 32]), "camp");
280
281        let first = block_on_ready(node.join(&channel)).unwrap();
282        block_on_ready(node.leave(&channel)).unwrap();
283        let second = block_on_ready(node.join(&channel)).unwrap();
284
285        // The key is back in the MAC, the fresh handle is live, and the stale
286        // one from before the leave is not.
287        assert!(mac.holds_channel(channel.key()));
288        assert!(second.is_active());
289        assert!(!first.is_active());
290    }
291
292    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
293    #[test]
294    fn leaving_a_channel_that_was_never_joined_is_a_no_op() {
295        let mac = FakeMac::new(Vec::new());
296        let node = test_node(mac.clone());
297        let joined = crate::Channel::private(umsh_core::ChannelKey([0x33; 32]), "joined");
298        let stranger = crate::Channel::private(umsh_core::ChannelKey([0x44; 32]), "stranger");
299
300        block_on_ready(node.join(&joined)).unwrap();
301        block_on_ready(node.leave(&stranger)).unwrap();
302
303        assert!(mac.holds_channel(joined.key()));
304        assert!(node.bound_channel(&joined).is_some());
305    }
306
307    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
308    #[test]
309    fn receive_callbacks_can_observe_rx_metadata() {
310        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
311
312        let mac = FakeMac::new(Vec::new());
313        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
314        let membership = Rc::new(RefCell::new(NodeMembership::new()));
315        let state = Rc::new(RefCell::new(LocalNodeState::new()));
316        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
317        let peer = PublicKey([0x44; 32]);
318
319        let observed = Rc::new(RefCell::new(None));
320        let observed_for_callback = observed.clone();
321        let _subscription = node.on_receive(move |packet| {
322            *observed_for_callback.borrow_mut() = Some((
323                packet.rssi(),
324                packet.snr(),
325                packet.lqi(),
326                packet.received_at_ms(),
327            ));
328            true
329        });
330
331        let payload = encode_text_payload("metadata");
332        let packet = test_unicast_packet_with_rx(
333            peer,
334            &payload,
335            umsh_mac::RxMetadata::new(
336                Some(-73),
337                Some(Snr::from_centibels(123)),
338                NonZeroU8::new(200),
339                Some(123_456),
340            ),
341        );
342
343        assert!(node.dispatch_received_packet(&packet));
344        assert_eq!(
345            *observed.borrow(),
346            Some((
347                Some(-73),
348                Some(Snr::from_centibels(123)),
349                NonZeroU8::new(200),
350                Some(123_456),
351            ))
352        );
353    }
354
355    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
356    #[test]
357    fn subscription_guard_unregisters_on_drop() {
358        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
359
360        let mac = FakeMac::new(Vec::new());
361        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
362        let membership = Rc::new(RefCell::new(NodeMembership::new()));
363        let state = Rc::new(RefCell::new(LocalNodeState::new()));
364        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
365        let peer = PublicKey([0x33; 32]);
366
367        let hits = Rc::new(RefCell::new(0u32));
368        {
369            let hits = hits.clone();
370            let _subscription = node.on_receive(move |_| {
371                *hits.borrow_mut() += 1;
372                true
373            });
374            assert!(node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
375        }
376
377        assert_eq!(*hits.borrow(), 1);
378        assert!(!node.dispatch_received_packet(&test_unicast_packet(peer, &[0x01, 0x02])));
379        assert_eq!(*hits.borrow(), 1);
380    }
381
382    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
383    #[test]
384    fn callbacks_observe_control_side_events_and_peer_ack_state() {
385        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
386
387        let mac = FakeMac::new(Vec::new());
388        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
389        let membership = Rc::new(RefCell::new(NodeMembership::new()));
390        let state = Rc::new(RefCell::new(LocalNodeState::new()));
391        let node = LocalNode::new(LocalIdentityId(1), mac, dispatcher, membership, state);
392
393        let peer = PublicKey([0x42; 32]);
394        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
395        let node_discovery = Rc::new(RefCell::new(Vec::new()));
396        let beacons = Rc::new(RefCell::new(Vec::new()));
397        let commands = Rc::new(RefCell::new(Vec::new()));
398        let peer_acks = Rc::new(RefCell::new(Vec::new()));
399        let peer_timeouts = Rc::new(RefCell::new(Vec::new()));
400
401        let discovery_log = node_discovery.clone();
402        let _discovered_subscription = node.on_node_discovered(move |key, name| {
403            discovery_log
404                .borrow_mut()
405                .push((key, name.map(str::to_string)));
406        });
407        let beacon_log = beacons.clone();
408        let _beacon_subscription = node.on_beacon(move |from_hint, from_key| {
409            beacon_log.borrow_mut().push((from_hint, from_key));
410        });
411        let command_log = commands.clone();
412        let _command_subscription = node.on_mac_command(move |from, command| {
413            command_log.borrow_mut().push((from, command.clone()));
414        });
415        let peer_ack_log = peer_acks.clone();
416        let _ack_subscription = peer_connection.on_ack_received(move |token| {
417            peer_ack_log.borrow_mut().push(token);
418        });
419        let peer_timeout_log = peer_timeouts.clone();
420        let _timeout_subscription = peer_connection.on_ack_timeout(move |token| {
421            peer_timeout_log.borrow_mut().push(token);
422        });
423
424        let token = SendToken::new(LocalIdentityId(1), SendReceipt(12));
425        let timeout_token = SendToken::new(LocalIdentityId(1), SendReceipt(13));
426        let hint = NodeHint([1, 2, 3]);
427        let command = OwnedMacCommand::EchoRequest {
428            data: vec![9, 8, 7],
429        };
430
431        node.dispatch_node_discovered(peer, Some("alice"));
432        node.dispatch_beacon(hint, Some(peer));
433        node.dispatch_mac_command(peer, &command);
434        node.dispatch_ack_received(peer, token);
435        node.dispatch_ack_timeout(peer, timeout_token);
436
437        assert_eq!(
438            node_discovery.borrow().as_slice(),
439            &[(peer, Some(String::from("alice")))]
440        );
441        assert_eq!(beacons.borrow().as_slice(), &[(hint, Some(peer))]);
442        assert_eq!(commands.borrow().as_slice(), &[(peer, command)]);
443        assert_eq!(peer_acks.borrow().as_slice(), &[token]);
444        assert_eq!(peer_timeouts.borrow().as_slice(), &[timeout_token]);
445    }
446
447    /// A ping must travel the way the traffic it is measuring would, so the
448    /// caller's options carry through untouched. The one exception is the ack
449    /// request: the echo response already acknowledges the ping, so asking
450    /// for a MAC ack too would put a second frame on the air for nothing.
451    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
452    #[test]
453    fn ping_honours_caller_options_but_never_requests_a_mac_ack() {
454        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
455        use umsh_core::MicSize;
456
457        let mac = FakeMac::new(vec![[7u8; 32], [9u8; 32]]);
458        let node = LocalNode::new(
459            LocalIdentityId(1),
460            mac.clone(),
461            Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new())),
462            Rc::new(RefCell::new(NodeMembership::new())),
463            Rc::new(RefCell::new(LocalNodeState::new())),
464        );
465        let peer_connection = block_on_ready(node.peer(PublicKey([0x55; 32]))).unwrap();
466
467        let options = SendOptions::default()
468            .with_mic_size(MicSize::Mic4)
469            .with_ack_requested(true)
470            .with_trace_route()
471            .with_flood_hops(3)
472            .with_region_code([0x78, 0x53]);
473        block_on_ready(peer_connection.ping(6, &options, 1_000)).unwrap();
474
475        let sent = mac.take_unicasts().pop().expect("ping send");
476        assert_eq!(sent.options.mic_size, MicSize::Mic4);
477        assert!(sent.options.trace_route);
478        assert_eq!(sent.options.flood_hops, Some(3));
479        assert_eq!(sent.options.region_code, Some([0x78, 0x53]));
480        assert!(!sent.options.ack_requested, "the echo response is the ack");
481
482        // `no_flood` is a distinct state from an unset budget and must also
483        // survive, rather than collapsing back to the wide default.
484        block_on_ready(peer_connection.ping(0, &SendOptions::default().no_flood(), 1_000)).unwrap();
485        let sent = mac.take_unicasts().pop().expect("ping send");
486        assert_eq!(sent.options.flood_hops, None);
487    }
488
489    /// The MIC size pings are normally sent with. A ping frame is otherwise
490    /// nearly half authenticator.
491    #[test]
492    fn ping_mic_size_is_eight_bytes() {
493        assert_eq!(crate::PING_MIC_SIZE, umsh_core::MicSize::Mic8);
494        assert_eq!(crate::PING_MIC_SIZE.byte_len(), 8);
495    }
496
497    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
498    #[test]
499    fn pfs_routed_send_tracks_ack_against_ephemeral_identity() {
500        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
501
502        let mac = FakeMac::new(vec![[7u8; 32], [9u8; 32]]);
503        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
504        let membership = Rc::new(RefCell::new(NodeMembership::new()));
505        let state = Rc::new(RefCell::new(LocalNodeState::new()));
506        let node = LocalNode::new(
507            LocalIdentityId(1),
508            mac.clone(),
509            dispatcher.clone(),
510            membership,
511            state,
512        );
513
514        let peer = PublicKey([0x55; 32]);
515        let peer_connection = block_on_ready(node.peer(peer)).unwrap();
516        let options = SendOptions::default().with_ack_requested(true);
517
518        block_on_ready(node.request_pfs(&peer, 60, &options)).unwrap();
519        let request = mac.take_unicasts().pop().expect("request send");
520        let request_command = parse_owned_mac_command(&request.payload);
521        let request_ephemeral = match request_command {
522            OwnedMacCommand::PfsSessionRequest { ephemeral_key, .. } => ephemeral_key,
523            other => panic!("unexpected request payload: {other:?}"),
524        };
525
526        block_on_ready(node.handle_pfs_command(
527            &peer,
528            &OwnedMacCommand::PfsSessionResponse {
529                ephemeral_key: PublicKey([0x44; 32]),
530                duration_minutes: 60,
531            },
532            &options,
533        ))
534        .unwrap();
535
536        let payload = encode_text_payload("hello over pfs");
537        let ticket = block_on_ready(peer_connection.send(&payload, &options)).unwrap();
538        let sent = mac.take_unicasts().pop().expect("pfs-routed send");
539        assert_eq!(sent.from, LocalIdentityId(10));
540        assert_eq!(sent.to, PublicKey([0x44; 32]));
541
542        let pairwise_from_pfs = PublicKey([0x44; 32]);
543        let _ = request_ephemeral; // Keeps the request path explicit in the test setup.
544        dispatcher.borrow_mut().dispatch_ticket_state(
545            sent.from,
546            &MacEventRef::AckReceived {
547                peer: pairwise_from_pfs,
548                receipt: SendReceipt(42),
549            },
550        );
551        assert!(ticket.was_acked());
552        assert!(ticket.is_finished());
553    }
554
555    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
556    #[test]
557    fn pfs_session_manager_request_and_teardown() {
558        use crate::pfs::PfsSessionManager;
559
560        let mac = FakeMac::new(vec![[3u8; 32]]);
561        let peer_long_term = PublicKey([0x55; 32]);
562        let options = SendOptions::default().with_ack_requested(true);
563
564        let mut pfs = PfsSessionManager::new();
565        block_on_ready(pfs.request_session(
566            &mac,
567            LocalIdentityId(1),
568            &peer_long_term,
569            60,
570            &options,
571        ))
572        .unwrap();
573
574        let sent = mac.take_unicasts();
575        assert_eq!(sent.len(), 1);
576        assert_eq!(sent[0].from, LocalIdentityId(1));
577        assert_eq!(sent[0].to, peer_long_term);
578        assert_eq!(
579            parse_owned_mac_command(&sent[0].payload),
580            OwnedMacCommand::PfsSessionRequest {
581                ephemeral_key: *SoftwareIdentity::from_secret_bytes(&[3u8; 32]).public_key(),
582                duration_minutes: 60,
583            }
584        );
585
586        assert!(
587            block_on_ready(pfs.end_session(
588                &mac,
589                LocalIdentityId(1),
590                &peer_long_term,
591                true,
592                &options,
593            ))
594            .unwrap()
595        );
596        let sent = mac.take_unicasts();
597        assert_eq!(sent.len(), 1);
598        assert_eq!(
599            parse_owned_mac_command(&sent[0].payload),
600            OwnedMacCommand::EndPfsSession
601        );
602    }
603
604    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
605    #[test]
606    fn pfs_end_session_errors_when_missing() {
607        use crate::pfs::PfsSessionManager;
608
609        let mac = FakeMac::new(Vec::new());
610        let options = SendOptions::default();
611        let mut pfs = PfsSessionManager::new();
612
613        let error = block_on_ready(pfs.end_session(
614            &mac,
615            LocalIdentityId(1),
616            &PublicKey([0x77; 32]),
617            true,
618            &options,
619        ))
620        .unwrap_err();
621        assert!(matches!(error, crate::NodeError::PfsSessionMissing));
622    }
623
624    #[cfg(feature = "unsafe-advanced")]
625    fn encode_text_payload(text: &str) -> Vec<u8> {
626        let message = OwnedTextMessage::basic(text);
627        let mut body = [0u8; 512];
628        let len = umsh_text::text_message::encode(&message.as_borrowed(), &mut body).unwrap();
629        let mut payload = Vec::with_capacity(len + 1);
630        payload.push(umsh_core::PayloadType::TextMessage as u8);
631        payload.extend_from_slice(&body[..len]);
632        payload
633    }
634
635    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
636    fn test_unicast_packet<'a>(from: PublicKey, payload: &'a [u8]) -> ReceivedPacketRef<'a> {
637        test_unicast_packet_with_rx(from, payload, umsh_mac::RxMetadata::default())
638    }
639
640    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
641    fn test_unicast_packet_with_rx<'a>(
642        from: PublicKey,
643        payload: &'a [u8],
644        rx: umsh_mac::RxMetadata,
645    ) -> ReceivedPacketRef<'a> {
646        let wire = Box::leak(payload.to_vec().into_boxed_slice());
647        let header = umsh_core::PacketHeader {
648            fcf: umsh_core::Fcf::new(umsh_core::PacketType::Unicast, false, false),
649            options_range: 0..0,
650            flood_hops: None,
651            dst: None,
652            channel: None,
653            source: umsh_core::SourceAddrRef::Hint(from.hint()),
654            sec_info: None,
655            body_range: 0..wire.len(),
656            mic_range: wire.len()..wire.len(),
657            total_len: wire.len(),
658        };
659        ReceivedPacketRef::new(
660            wire,
661            wire,
662            header,
663            umsh_core::ParsedOptions::default(),
664            Some(from),
665            Some(from.hint()),
666            true,
667            None,
668            rx,
669        )
670    }
671
672    /// A received broadcast frame, as a solicitation would arrive: optionally
673    /// carrying an FHOPS byte and a Route option. Only the ranges matter, so
674    /// the wire is just the route bytes.
675    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
676    fn test_broadcast_packet(
677        from: PublicKey,
678        flood_hops: Option<u8>,
679        route: Option<&'static [u8]>,
680    ) -> ReceivedPacketRef<'static> {
681        let route_len = route.map_or(0, <[u8]>::len);
682        let wire: &'static [u8] = Box::leak(
683            route
684                .map_or_else(Vec::new, <[u8]>::to_vec)
685                .into_boxed_slice(),
686        );
687        let mut options = umsh_core::ParsedOptions::default();
688        if route.is_some() {
689            options.source_route = Some(0..route_len);
690        }
691        let header = umsh_core::PacketHeader {
692            fcf: umsh_core::Fcf::new(umsh_core::PacketType::Broadcast, false, false),
693            options_range: 0..route_len,
694            flood_hops: flood_hops.map(umsh_core::FloodHops),
695            dst: None,
696            channel: None,
697            source: umsh_core::SourceAddrRef::Hint(from.hint()),
698            sec_info: None,
699            body_range: route_len..wire.len(),
700            mic_range: wire.len()..wire.len(),
701            total_len: wire.len(),
702        };
703        ReceivedPacketRef::new(
704            wire,
705            &wire[route_len..],
706            header,
707            options,
708            Some(from),
709            Some(from.hint()),
710            false,
711            None,
712            umsh_mac::RxMetadata::default(),
713        )
714    }
715
716    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
717    #[derive(Clone, Default)]
718    struct FakeMac {
719        state: Rc<RefCell<FakeMacState>>,
720    }
721
722    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
723    #[derive(Default)]
724    struct FakeMacState {
725        random_blocks: VecDeque<[u8; 32]>,
726        next_peer_id: u8,
727        next_ephemeral_id: u8,
728        now_ms: u64,
729        unicasts: Vec<SentUnicast>,
730        removed_ephemerals: Vec<LocalIdentityId>,
731        peers: Vec<(PublicKey, PeerId)>,
732        channels: Vec<umsh_core::ChannelKey>,
733    }
734
735    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
736    #[derive(Clone, Debug, PartialEq, Eq)]
737    struct SentUnicast {
738        from: LocalIdentityId,
739        to: PublicKey,
740        payload: Vec<u8>,
741        options: SendOptions,
742    }
743
744    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
745    impl FakeMac {
746        fn new(random_blocks: Vec<[u8; 32]>) -> Self {
747            Self {
748                state: Rc::new(RefCell::new(FakeMacState {
749                    random_blocks: random_blocks.into(),
750                    next_peer_id: 0,
751                    next_ephemeral_id: 10,
752                    now_ms: 1_000,
753                    ..FakeMacState::default()
754                })),
755            }
756        }
757
758        fn take_unicasts(&self) -> Vec<SentUnicast> {
759            core::mem::take(&mut self.state.borrow_mut().unicasts)
760        }
761
762        fn holds_channel(&self, key: &umsh_core::ChannelKey) -> bool {
763            self.state.borrow().channels.iter().any(|k| k.0 == key.0)
764        }
765    }
766
767    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
768    impl MacBackend for FakeMac {
769        type SendError = SendError;
770        type CapacityError = CapacityError;
771        type RunError = core::convert::Infallible;
772
773        async fn next_event(
774            &self,
775            _on_event: impl FnMut(LocalIdentityId, umsh_mac::MacEventRef<'_>),
776        ) -> Result<(), Self::RunError> {
777            // FakeMac is used to drive LocalNode directly in tests, never as a
778            // Host run loop, so this stub is never invoked.
779            Ok(())
780        }
781
782        async fn add_peer(
783            &self,
784            key: PublicKey,
785        ) -> Result<PeerId, MacBackendError<Self::SendError, Self::CapacityError>> {
786            let mut state = self.state.borrow_mut();
787            if let Some((_, existing)) = state
788                .peers
789                .iter()
790                .find(|(existing_key, _)| *existing_key == key)
791            {
792                return Ok(*existing);
793            }
794            let peer_id = PeerId(state.next_peer_id);
795            state.next_peer_id = state.next_peer_id.wrapping_add(1);
796            state.peers.push((key, peer_id));
797            Ok(peer_id)
798        }
799
800        async fn add_private_channel(
801            &self,
802            key: umsh_core::ChannelKey,
803        ) -> Result<(), MacBackendError<Self::SendError, Self::CapacityError>> {
804            let mut state = self.state.borrow_mut();
805            if !state.channels.iter().any(|k| k.0 == key.0) {
806                state.channels.push(key);
807            }
808            Ok(())
809        }
810
811        async fn add_named_channel(
812            &self,
813            _name: &str,
814        ) -> Result<(), MacBackendError<Self::SendError, Self::CapacityError>> {
815            Ok(())
816        }
817
818        async fn remove_channel(&self, key: &umsh_core::ChannelKey) -> bool {
819            let mut state = self.state.borrow_mut();
820            let before = state.channels.len();
821            state.channels.retain(|k| k.0 != key.0);
822            state.channels.len() != before
823        }
824
825        async fn send_broadcast(
826            &self,
827            _from: LocalIdentityId,
828            _payload: &[u8],
829            _options: &SendOptions,
830        ) -> Result<SendReceipt, MacBackendError<Self::SendError, Self::CapacityError>> {
831            Ok(SendReceipt(99))
832        }
833
834        async fn send_multicast(
835            &self,
836            _from: LocalIdentityId,
837            _channel: &ChannelId,
838            _payload: &[u8],
839            _options: &SendOptions,
840        ) -> Result<SendReceipt, MacBackendError<Self::SendError, Self::CapacityError>> {
841            Ok(SendReceipt(99))
842        }
843
844        async fn send_unicast(
845            &self,
846            from: LocalIdentityId,
847            dst: &PublicKey,
848            payload: &[u8],
849            options: &SendOptions,
850        ) -> Result<Option<SendReceipt>, MacBackendError<Self::SendError, Self::CapacityError>>
851        {
852            self.state.borrow_mut().unicasts.push(SentUnicast {
853                from,
854                to: *dst,
855                payload: payload.to_vec(),
856                options: options.clone(),
857            });
858            Ok(Some(SendReceipt(42)))
859        }
860
861        async fn send_blind_unicast(
862            &self,
863            from: LocalIdentityId,
864            dst: &PublicKey,
865            _channel: &ChannelId,
866            payload: &[u8],
867            options: &SendOptions,
868        ) -> Result<Option<SendReceipt>, MacBackendError<Self::SendError, Self::CapacityError>>
869        {
870            self.send_unicast(from, dst, payload, options).await
871        }
872
873        async fn fill_random(&self, dest: &mut [u8]) {
874            let mut state = self.state.borrow_mut();
875            let next = state.random_blocks.pop_front().expect("test rng exhausted");
876            dest.copy_from_slice(&next[..dest.len()]);
877        }
878
879        async fn now_ms(&self) -> u64 {
880            self.state.borrow().now_ms
881        }
882
883        async fn register_ephemeral(
884            &self,
885            _parent: LocalIdentityId,
886            _identity: SoftwareIdentity,
887        ) -> Result<LocalIdentityId, MacBackendError<Self::SendError, Self::CapacityError>>
888        {
889            let mut state = self.state.borrow_mut();
890            let id = LocalIdentityId(state.next_ephemeral_id);
891            state.next_ephemeral_id = state.next_ephemeral_id.wrapping_add(1);
892            Ok(id)
893        }
894
895        async fn remove_ephemeral(&self, id: LocalIdentityId) -> bool {
896            self.state.borrow_mut().removed_ephemerals.push(id);
897            true
898        }
899    }
900
901    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
902    fn parse_owned_mac_command(payload: &[u8]) -> OwnedMacCommand {
903        OwnedMacCommand::from(
904            crate::parse_mac_command_payload(umsh_core::PacketType::Unicast, payload).unwrap(),
905        )
906    }
907
908    #[cfg(feature = "unsafe-advanced")]
909    fn block_on_ready<F: Future>(future: F) -> F::Output {
910        fn raw_waker() -> RawWaker {
911            fn clone(_: *const ()) -> RawWaker {
912                raw_waker()
913            }
914            fn wake(_: *const ()) {}
915            fn wake_by_ref(_: *const ()) {}
916            fn drop(_: *const ()) {}
917
918            RawWaker::new(
919                core::ptr::null(),
920                &RawWakerVTable::new(clone, wake, wake_by_ref, drop),
921            )
922        }
923
924        let waker = unsafe { Waker::from_raw(raw_waker()) };
925        let mut context = Context::from_waker(&waker);
926        let mut future = pin!(future);
927        match future.as_mut().poll(&mut context) {
928            Poll::Ready(value) => value,
929            Poll::Pending => panic!("test future unexpectedly returned Poll::Pending"),
930        }
931    }
932
933    // --- Identity Request responder ---
934
935    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
936    fn responder_node(mac: &FakeMac) -> crate::LocalNode<FakeMac> {
937        use crate::node::{LocalNode, LocalNodeState, NodeMembership};
938        let dispatcher = Rc::new(RefCell::new(crate::dispatch::EventDispatcher::new()));
939        let membership = Rc::new(RefCell::new(NodeMembership::new()));
940        let state = Rc::new(RefCell::new(LocalNodeState::new()));
941        LocalNode::new(
942            LocalIdentityId(1),
943            mac.clone(),
944            dispatcher,
945            membership,
946            state,
947        )
948    }
949
950    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
951    fn test_profile(public_key: PublicKey) -> crate::NodeIdentityProfile {
952        crate::NodeIdentityProfile::new(
953            public_key,
954            crate::NodeRole::Repeater,
955            crate::NodeCapabilities::REPEATER | crate::NodeCapabilities::TEXT_MESSAGES,
956        )
957        .with_name("repeater-1")
958    }
959
960    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
961    #[test]
962    fn responder_answers_selected_request_with_unicast_identity() {
963        let mac = FakeMac::new(Vec::new());
964        let node = responder_node(&mac);
965        let our_key = PublicKey([0x11; 32]);
966        let requester = PublicKey([0x41; 32]);
967        node.enable_identity_responder_default(test_profile(our_key));
968
969        // Broadcast-style request that filters on our hint and carries a nonce.
970        let options = crate::mac_command::IdentityRequestBuilder::new()
971            .nonce(0xCAFE_F00D)
972            .unwrap()
973            .filter_hint(&our_key.hint())
974            .unwrap()
975            .build();
976        let packet = test_unicast_packet(requester, &[]);
977
978        let plan = node
979            .evaluate_identity_request(&packet, requester, &options)
980            .expect("responder should produce a reply plan");
981        block_on_ready(node.send_identity_response(plan));
982
983        let unicasts = mac.take_unicasts();
984        assert_eq!(unicasts.len(), 1, "exactly one unicast reply");
985        let reply = &unicasts[0];
986        assert_eq!(reply.to, requester, "reply is addressed to the requester");
987        // test_unicast_packet is source_authenticated → requester already has
988        // our key → hint source suffices.
989        assert!(!reply.options.full_source);
990        // A targeted request gets an immediate reply; only broadcast and
991        // multicast solicitations are jittered.
992        assert_eq!(reply.options.tx_delay_ms, None);
993
994        assert_eq!(reply.payload[0], umsh_core::PayloadType::NodeIdentity as u8);
995        let identity = crate::NodeIdentityPayload::from_bytes(&reply.payload[1..]).unwrap();
996        assert_eq!(identity.role, crate::NodeRole::Repeater);
997        assert_eq!(identity.name.as_deref(), Some("repeater-1"));
998        assert_eq!(identity.nonce, Some(0xCAFE_F00D), "request nonce echoed");
999        assert!(identity.signature.is_none(), "responses are unsigned");
1000    }
1001
1002    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1003    #[test]
1004    fn responder_answers_broadcast_solicitation_with_delayed_full_source_reply() {
1005        let mac = FakeMac::new(vec![[0x12; 32]]);
1006        let node = responder_node(&mac);
1007        let our_key = PublicKey([0x11; 32]);
1008        let requester = PublicKey([0x41; 32]);
1009        node.enable_identity_responder_default(test_profile(our_key));
1010
1011        let options = crate::mac_command::IdentityRequestBuilder::new()
1012            .nonce(0x0000_BEEF)
1013            .unwrap()
1014            .filter_role(crate::NodeRole::Repeater)
1015            .unwrap()
1016            .build();
1017        let packet = test_broadcast_packet(requester, None, None);
1018
1019        let plan = node
1020            .evaluate_identity_request(&packet, requester, &options)
1021            .expect("selected broadcast solicitation produces a plan");
1022        block_on_ready(node.send_identity_response(plan));
1023
1024        let unicasts = mac.take_unicasts();
1025        assert_eq!(unicasts.len(), 1);
1026        let reply = &unicasts[0];
1027        assert_eq!(reply.to, requester);
1028        // A broadcast is unauthenticated, so the requester may lack our key.
1029        assert!(reply.options.full_source);
1030        // The reply is held for a random slice of the 30-second window so the
1031        // selected nodes do not all answer the same frame at once.
1032        let delay = reply
1033            .options
1034            .tx_delay_ms
1035            .expect("broadcast replies are jittered");
1036        assert!((500..=30_000).contains(&delay));
1037        // No FILTER_NODE_HINT narrowed the solicitation, so the reply stays
1038        // inside the one hop the request was allowed: no FHOPS field.
1039        assert_eq!(reply.options.flood_hops, None);
1040    }
1041
1042    /// A `FILTER_NODE_HINT` names one answering node, so the solicitation may
1043    /// be flood routed and the reply keeps its normal flood budget.
1044    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1045    #[test]
1046    fn responder_answers_flood_routed_hint_filtered_solicitation() {
1047        let mac = FakeMac::new(vec![[0x12; 32]]);
1048        let node = responder_node(&mac);
1049        let our_key = PublicKey([0x11; 32]);
1050        let requester = PublicKey([0x41; 32]);
1051        node.enable_identity_responder_default(test_profile(our_key));
1052
1053        let options = crate::mac_command::IdentityRequestBuilder::new()
1054            .filter_hint(&our_key.hint())
1055            .unwrap()
1056            .build();
1057        // FHOPS_REM=2, FHOPS_ACC=1: repeated once, two hops of budget left.
1058        let repeated = test_broadcast_packet(requester, Some(0x21), None);
1059
1060        let plan = node
1061            .evaluate_identity_request(&repeated, requester, &options)
1062            .expect("a hint-filtered solicitation may be flood routed");
1063        block_on_ready(node.send_identity_response(plan));
1064
1065        let unicasts = mac.take_unicasts();
1066        assert_eq!(unicasts.len(), 1);
1067        assert!(
1068            unicasts[0].options.flood_hops.is_some(),
1069            "the reply may be flooded back to a requester that named us"
1070        );
1071    }
1072
1073    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1074    #[test]
1075    fn responder_drops_repeated_or_routed_broadcast_solicitations() {
1076        let mac = FakeMac::new(Vec::new());
1077        let node = responder_node(&mac);
1078        let our_key = PublicKey([0x11; 32]);
1079        let requester = PublicKey([0x41; 32]);
1080        node.enable_identity_responder_default(test_profile(our_key));
1081        let options = crate::mac_command::IdentityRequestBuilder::new()
1082            .filter_role(crate::NodeRole::Repeater)
1083            .unwrap()
1084            .build();
1085
1086        // A nonzero FHOPS byte marks a request that was flood routed, and no
1087        // FILTER_NODE_HINT narrows this one to a single answering node.
1088        let repeated = test_broadcast_packet(requester, Some(0x21), None);
1089        assert!(
1090            node.evaluate_identity_request(&repeated, requester, &options)
1091                .is_none()
1092        );
1093
1094        // A non-empty Route option is a steered request. That holds whatever
1095        // the filters say, so check it with a hint filter too.
1096        let routed = test_broadcast_packet(requester, None, Some(&[0xAB, 0xCD]));
1097        assert!(
1098            node.evaluate_identity_request(&routed, requester, &options)
1099                .is_none()
1100        );
1101        let hint_options = crate::mac_command::IdentityRequestBuilder::new()
1102            .filter_hint(&our_key.hint())
1103            .unwrap()
1104            .build();
1105        assert!(
1106            node.evaluate_identity_request(&routed, requester, &hint_options)
1107                .is_none()
1108        );
1109
1110        // A zeroed FHOPS byte and a present-but-empty Route option are fine.
1111        let clean = test_broadcast_packet(requester, Some(0x00), Some(&[]));
1112        assert!(
1113            node.evaluate_identity_request(&clean, requester, &options)
1114                .is_some()
1115        );
1116    }
1117
1118    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1119    #[test]
1120    fn responder_ignores_request_that_filters_exclude() {
1121        let mac = FakeMac::new(Vec::new());
1122        let node = responder_node(&mac);
1123        let our_key = PublicKey([0x11; 32]);
1124        node.enable_identity_responder_default(test_profile(our_key));
1125
1126        // Filter targets a different hint → we are not selected.
1127        let other_hint = PublicKey([0x99; 32]).hint();
1128        let options = crate::mac_command::IdentityRequestBuilder::new()
1129            .filter_hint(&other_hint)
1130            .unwrap()
1131            .build();
1132        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1133
1134        assert!(
1135            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options)
1136                .is_none()
1137        );
1138    }
1139
1140    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1141    #[test]
1142    fn responder_respects_ignore_policy() {
1143        let mac = FakeMac::new(Vec::new());
1144        let node = responder_node(&mac);
1145        let our_key = PublicKey([0x11; 32]);
1146        node.enable_identity_responder(test_profile(our_key), |_ctx| {
1147            crate::RespondDecision::Ignore
1148        });
1149
1150        // No filters → selects everyone, so only the policy can decline.
1151        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1152        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1153
1154        assert!(
1155            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options)
1156                .is_none()
1157        );
1158    }
1159
1160    /// Advertisements are built from the installed profile, so a node that
1161    /// has opted out of being discovered must still be able to read it back.
1162    /// Silencing the responder with a policy rather than uninstalling it is
1163    /// what keeps the two independent.
1164    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1165    #[test]
1166    fn never_respond_policy_keeps_the_profile_readable() {
1167        let mac = FakeMac::new(Vec::new());
1168        let node = responder_node(&mac);
1169        let our_key = PublicKey([0x11; 32]);
1170        node.enable_identity_responder(test_profile(our_key), crate::never_respond_policy);
1171
1172        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1173        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1174        assert!(
1175            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options)
1176                .is_none(),
1177            "a silenced responder answers nothing"
1178        );
1179        assert_eq!(
1180            node.with_identity_profile(|profile| profile.public_key),
1181            Some(our_key),
1182            "yet the profile an advertisement is built from is still there"
1183        );
1184    }
1185
1186    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1187    #[test]
1188    fn responder_disabled_yields_no_plan() {
1189        let mac = FakeMac::new(Vec::new());
1190        let node = responder_node(&mac);
1191        let options = crate::mac_command::IdentityRequestBuilder::new().build();
1192        let packet = test_unicast_packet(PublicKey([0x41; 32]), &[]);
1193        assert!(
1194            node.evaluate_identity_request(&packet, PublicKey([0x41; 32]), &options)
1195                .is_none()
1196        );
1197    }
1198
1199    #[cfg(all(feature = "software-crypto", feature = "unsafe-advanced"))]
1200    #[test]
1201    fn default_policy_full_source_tracks_authentication() {
1202        use crate::identity_responder::{IdentityRequestContext, default_respond_policy};
1203        use crate::mac_command::IdentityRequestFilters;
1204
1205        let base = |source_authenticated: bool| IdentityRequestContext {
1206            from_key: PublicKey([0x41; 32]),
1207            from_hint: None,
1208            source_authenticated,
1209            has_full_source: false,
1210            channel: None,
1211            family: crate::PacketFamily::Unicast,
1212            filters: IdentityRequestFilters::new(&[]),
1213            rssi: None,
1214            snr: None,
1215        };
1216
1217        // Authenticated request → sender already holds our key → hint reply.
1218        assert_eq!(
1219            default_respond_policy(&base(true)),
1220            crate::RespondDecision::Respond { full_source: false }
1221        );
1222        // Unauthenticated request → sender may lack our key → include it.
1223        assert_eq!(
1224            default_respond_policy(&base(false)),
1225            crate::RespondDecision::Respond { full_source: true }
1226        );
1227    }
1228}