umsh_mac/
test_support.rs

1//! Std-only simulated radio network and dummy platform components for tests.
2//!
3//! This module is intended for development, simulation, and examples. The
4//! simulated network types are useful outside unit tests, but the dummy crypto
5//! and RNG implementations are deliberately insecure and must not be used for
6//! real deployments.
7
8use core::convert::Infallible;
9use std::{
10    cell::{Cell, RefCell},
11    collections::VecDeque,
12    rc::Rc,
13    vec::Vec,
14};
15
16use core::task::{Context, Poll};
17use embedded_hal_async::delay::DelayNs;
18use rand::{Rng, TryCryptoRng, TryRng};
19use umsh_core::PublicKey;
20use umsh_crypto::{
21    AesCipher, AesProvider, CryptoEngine, NodeIdentity, Sha256Provider, SharedSecret,
22};
23use umsh_hal::{Clock, CounterStore, KeyValueStore, Radio, RxInfo, Snr, TxError, TxOptions};
24
25use crate::{
26    DEFAULT_ACKS, DEFAULT_CHANNELS, DEFAULT_DUP, DEFAULT_FRAME, DEFAULT_IDENTITIES, DEFAULT_PEERS,
27    DEFAULT_TX, Mac, OperatingPolicy, Platform, RepeaterConfig,
28};
29
30const DEFAULT_RSSI: i16 = -40;
31const DEFAULT_SNR: Snr = Snr::from_decibels(10);
32
33/// Convenience alias for a `Mac` instantiated with the simulated test components.
34pub type TestMac<
35    const IDENTITIES: usize = DEFAULT_IDENTITIES,
36    const PEERS: usize = DEFAULT_PEERS,
37    const CHANNELS: usize = DEFAULT_CHANNELS,
38    const ACKS: usize = DEFAULT_ACKS,
39    const TX: usize = DEFAULT_TX,
40    const FRAME: usize = DEFAULT_FRAME,
41    const DUP: usize = DEFAULT_DUP,
42> = Mac<TestPlatform, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
43
44/// Convenience alias for a `Mac` instantiated with the modeled simulated components.
45pub type ModeledTestMac<
46    const IDENTITIES: usize = DEFAULT_IDENTITIES,
47    const PEERS: usize = DEFAULT_PEERS,
48    const CHANNELS: usize = DEFAULT_CHANNELS,
49    const ACKS: usize = DEFAULT_ACKS,
50    const TX: usize = DEFAULT_TX,
51    const FRAME: usize = DEFAULT_FRAME,
52    const DUP: usize = DEFAULT_DUP,
53> = Mac<ModeledTestPlatform, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
54
55/// Platform bundle for the simulated test components.
56pub struct TestPlatform;
57
58impl Platform for TestPlatform {
59    type Identity = DummyIdentity;
60    type Aes = DummyAes;
61    type Sha = DummySha;
62    type Radio = SimulatedRadio;
63    type Delay = DummyDelay;
64    type Clock = DummyClock;
65    type Rng = DummyRng;
66    type CounterStore = DummyCounterStore;
67    type KeyValueStore = DummyKeyValueStore;
68}
69
70/// Platform bundle for the modeled simulated components.
71pub struct ModeledTestPlatform;
72
73impl Platform for ModeledTestPlatform {
74    type Identity = DummyIdentity;
75    type Aes = DummyAes;
76    type Sha = DummySha;
77    type Radio = ModeledRadio;
78    type Delay = DummyDelay;
79    type Clock = DummyClock;
80    type Rng = DummyRng;
81    type CounterStore = DummyCounterStore;
82    type KeyValueStore = DummyKeyValueStore;
83}
84
85/// Create a test MAC coordinator using the simulated components.
86pub fn make_test_mac<
87    const IDENTITIES: usize,
88    const PEERS: usize,
89    const CHANNELS: usize,
90    const ACKS: usize,
91    const TX: usize,
92    const FRAME: usize,
93    const DUP: usize,
94>(
95    radio: SimulatedRadio,
96    clock: DummyClock,
97) -> TestMac<IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP> {
98    Mac::new(
99        radio,
100        CryptoEngine::new(DummyAes, DummySha),
101        clock,
102        DummyRng::default(),
103        DummyCounterStore,
104        RepeaterConfig::default(),
105        OperatingPolicy::default(),
106    )
107}
108
109/// Create a test MAC coordinator using the modeled simulated components.
110pub fn make_modeled_test_mac<
111    const IDENTITIES: usize,
112    const PEERS: usize,
113    const CHANNELS: usize,
114    const ACKS: usize,
115    const TX: usize,
116    const FRAME: usize,
117    const DUP: usize,
118>(
119    radio: ModeledRadio,
120    clock: DummyClock,
121) -> ModeledTestMac<IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP> {
122    Mac::new(
123        radio,
124        CryptoEngine::new(DummyAes, DummySha),
125        clock,
126        DummyRng::default(),
127        DummyCounterStore,
128        RepeaterConfig::default(),
129        OperatingPolicy::default(),
130    )
131}
132
133/// Shared simulated radio topology and frame queues.
134#[derive(Clone)]
135pub struct SimulatedNetwork {
136    inner: Rc<RefCell<NetworkState>>,
137}
138
139struct NetworkState {
140    inboxes: Vec<VecDeque<QueuedFrame>>,
141    links: Vec<Vec<LinkProfile>>,
142}
143
144struct QueuedFrame {
145    data: Vec<u8>,
146    rssi: i16,
147    snr: Snr,
148}
149
150#[derive(Clone, Copy)]
151struct LinkProfile {
152    connected: bool,
153    rssi: i16,
154    snr: Snr,
155}
156
157impl Default for LinkProfile {
158    fn default() -> Self {
159        Self {
160            connected: false,
161            rssi: DEFAULT_RSSI,
162            snr: DEFAULT_SNR,
163        }
164    }
165}
166
167impl Default for SimulatedNetwork {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173impl SimulatedNetwork {
174    /// Create an empty simulated network.
175    pub fn new() -> Self {
176        Self {
177            inner: Rc::new(RefCell::new(NetworkState {
178                inboxes: Vec::new(),
179                links: Vec::new(),
180            })),
181        }
182    }
183
184    /// Add a radio with default limits.
185    pub fn add_radio(&self) -> SimulatedRadio {
186        self.add_radio_with_config(256, 10)
187    }
188
189    /// Add a radio with explicit frame-size and airtime limits.
190    pub fn add_radio_with_config(&self, max_frame_size: usize, t_frame_ms: u32) -> SimulatedRadio {
191        let mut state = self.inner.borrow_mut();
192        let id = state.inboxes.len();
193        for row in &mut state.links {
194            row.push(LinkProfile::default());
195        }
196        state.inboxes.push(VecDeque::new());
197        state.links.push(vec![LinkProfile::default(); id + 1]);
198        SimulatedRadio {
199            network: self.clone(),
200            id,
201            max_frame_size,
202            t_frame_ms,
203        }
204    }
205
206    /// Connect `from` to `to` with default signal values.
207    pub fn connect(&self, from: usize, to: usize) {
208        self.set_link(from, to, true, DEFAULT_RSSI, DEFAULT_SNR);
209    }
210
211    /// Connect two radios in both directions.
212    pub fn connect_bidirectional(&self, a: usize, b: usize) {
213        self.connect(a, b);
214        self.connect(b, a);
215    }
216
217    /// Remove the directed link from `from` to `to`.
218    pub fn disconnect(&self, from: usize, to: usize) {
219        self.set_link(from, to, false, DEFAULT_RSSI, DEFAULT_SNR);
220    }
221
222    /// Configure one directed link with explicit signal values.
223    pub fn set_link(&self, from: usize, to: usize, connected: bool, rssi: i16, snr: Snr) {
224        let mut state = self.inner.borrow_mut();
225        let Some(row) = state.links.get_mut(from) else {
226            panic!("unknown simulated radio id {from}");
227        };
228        let Some(link) = row.get_mut(to) else {
229            panic!("unknown simulated radio id {to}");
230        };
231        *link = LinkProfile {
232            connected,
233            rssi,
234            snr,
235        };
236    }
237
238    /// Inject a frame directly into one radio's receive queue.
239    pub fn inject_frame(&self, to: usize, frame: &[u8]) {
240        self.inject_frame_with_info(to, frame, DEFAULT_RSSI, DEFAULT_SNR);
241    }
242
243    /// Inject a frame directly into one radio's receive queue with explicit metadata.
244    pub fn inject_frame_with_info(&self, to: usize, frame: &[u8], rssi: i16, snr: Snr) {
245        let mut state = self.inner.borrow_mut();
246        let Some(queue) = state.inboxes.get_mut(to) else {
247            panic!("unknown simulated radio id {to}");
248        };
249        queue.push_back(QueuedFrame {
250            data: frame.to_vec(),
251            rssi,
252            snr,
253        });
254    }
255
256    fn transmit(&self, from: usize, frame: &[u8]) {
257        let mut state = self.inner.borrow_mut();
258        let Some(row) = state.links.get(from) else {
259            panic!("unknown simulated radio id {from}");
260        };
261        let deliveries: Vec<(usize, i16, Snr)> = row
262            .iter()
263            .enumerate()
264            .filter_map(|(to, link)| link.connected.then_some((to, link.rssi, link.snr)))
265            .collect();
266        for (to, rssi, snr) in deliveries {
267            state.inboxes[to].push_back(QueuedFrame {
268                data: frame.to_vec(),
269                rssi,
270                snr,
271            });
272        }
273    }
274
275    fn receive(&self, id: usize, buf: &mut [u8]) -> RxInfo {
276        let mut state = self.inner.borrow_mut();
277        let Some(queue) = state.inboxes.get_mut(id) else {
278            panic!("unknown simulated radio id {id}");
279        };
280        let Some(frame) = queue.pop_front() else {
281            return RxInfo {
282                len: 0,
283                rssi: 0,
284                snr: Snr::from_decibels(0),
285                lqi: None,
286            };
287        };
288        let len = frame.data.len().min(buf.len());
289        buf[..len].copy_from_slice(&frame.data[..len]);
290        RxInfo {
291            len,
292            rssi: frame.rssi,
293            snr: frame.snr,
294            lqi: None,
295        }
296    }
297}
298
299/// Radio implementation backed by a [`SimulatedNetwork`].
300#[derive(Clone)]
301pub struct SimulatedRadio {
302    network: SimulatedNetwork,
303    id: usize,
304    max_frame_size: usize,
305    t_frame_ms: u32,
306}
307
308impl SimulatedRadio {
309    /// Return the radio's stable identifier within the simulated network.
310    pub fn id(&self) -> usize {
311        self.id
312    }
313}
314
315impl Radio for SimulatedRadio {
316    type Error = ();
317
318    async fn transmit(
319        &mut self,
320        data: &[u8],
321        _options: TxOptions,
322    ) -> Result<(), TxError<Self::Error>> {
323        self.network.transmit(self.id, data);
324        Ok(())
325    }
326
327    fn poll_receive(
328        &mut self,
329        _cx: &mut Context<'_>,
330        buf: &mut [u8],
331    ) -> Poll<Result<RxInfo, Self::Error>> {
332        let rx = self.network.receive(self.id, buf);
333        if rx.len == 0 {
334            Poll::Pending
335        } else {
336            Poll::Ready(Ok(rx))
337        }
338    }
339
340    fn max_frame_size(&self) -> usize {
341        self.max_frame_size
342    }
343
344    fn t_frame_ms(&self) -> u32 {
345        self.t_frame_ms
346    }
347}
348
349/// Link model used by [`ModeledNetwork`] to derive receive metadata and loss.
350#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub struct ModeledLinkProfile {
352    pub connected: bool,
353    pub base_rssi: i16,
354    pub base_snr: Snr,
355    pub rssi_jitter_dbm: i16,
356    pub snr_jitter_centibels: i16,
357    pub propagation_delay_ms: u32,
358    pub drop_per_thousand: u16,
359}
360
361impl Default for ModeledLinkProfile {
362    fn default() -> Self {
363        Self {
364            connected: false,
365            base_rssi: DEFAULT_RSSI,
366            base_snr: DEFAULT_SNR,
367            rssi_jitter_dbm: 2,
368            snr_jitter_centibels: 10,
369            propagation_delay_ms: 0,
370            drop_per_thousand: 0,
371        }
372    }
373}
374
375impl ModeledLinkProfile {
376    /// Return a connected profile with the default modeled signal characteristics.
377    pub fn connected() -> Self {
378        Self {
379            connected: true,
380            ..Self::default()
381        }
382    }
383}
384
385/// Shared simulated network with scheduled delivery, jitter, packet loss, and coarse collision modeling.
386#[derive(Clone)]
387pub struct ModeledNetwork {
388    inner: Rc<RefCell<ModeledNetworkState>>,
389    clock: DummyClock,
390}
391
392struct ModeledNetworkState {
393    inboxes: Vec<VecDeque<QueuedFrame>>,
394    links: Vec<Vec<ModeledLinkProfile>>,
395    in_flight: Vec<InFlightTransmission>,
396    scheduled: Vec<ScheduledDelivery>,
397    rng: ModeledRng,
398}
399
400struct InFlightTransmission {
401    from: usize,
402    start_ms: u64,
403    end_ms: u64,
404}
405
406struct ScheduledDelivery {
407    to: usize,
408    available_at_ms: u64,
409    start_ms: u64,
410    end_ms: u64,
411    data: Vec<u8>,
412    rssi: i16,
413    snr: Snr,
414    collided: bool,
415}
416
417impl ModeledNetwork {
418    /// Create an empty modeled network with a shared clock starting at 0 ms.
419    pub fn new() -> Self {
420        Self::with_clock(DummyClock::new(0))
421    }
422
423    /// Create an empty modeled network using a caller-supplied shared clock.
424    pub fn with_clock(clock: DummyClock) -> Self {
425        Self {
426            inner: Rc::new(RefCell::new(ModeledNetworkState {
427                inboxes: Vec::new(),
428                links: Vec::new(),
429                in_flight: Vec::new(),
430                scheduled: Vec::new(),
431                rng: ModeledRng::new(0x554d_5348),
432            })),
433            clock,
434        }
435    }
436
437    /// Return the shared modeled clock.
438    pub fn clock(&self) -> DummyClock {
439        self.clock.clone()
440    }
441
442    /// Advance the shared simulation time.
443    pub fn advance_ms(&self, delta_ms: u64) {
444        self.clock.advance_ms(delta_ms);
445        self.promote_due_frames();
446    }
447
448    /// Override the deterministic RNG seed used for loss/jitter sampling.
449    pub fn reseed(&self, seed: u64) {
450        self.inner.borrow_mut().rng = ModeledRng::new(seed);
451    }
452
453    /// Add a modeled radio with default limits.
454    pub fn add_radio(&self) -> ModeledRadio {
455        self.add_radio_with_config(256, 100)
456    }
457
458    /// Add a modeled radio with explicit frame-size and airtime limits.
459    pub fn add_radio_with_config(&self, max_frame_size: usize, t_frame_ms: u32) -> ModeledRadio {
460        let mut state = self.inner.borrow_mut();
461        let id = state.inboxes.len();
462        for row in &mut state.links {
463            row.push(ModeledLinkProfile::default());
464        }
465        state.inboxes.push(VecDeque::new());
466        state
467            .links
468            .push(vec![ModeledLinkProfile::default(); id + 1]);
469        ModeledRadio {
470            network: self.clone(),
471            id,
472            max_frame_size,
473            t_frame_ms,
474        }
475    }
476
477    /// Connect `from` to `to` using the default modeled link profile.
478    pub fn connect(&self, from: usize, to: usize) {
479        self.set_link_profile(from, to, ModeledLinkProfile::connected());
480    }
481
482    /// Connect two radios in both directions using the default modeled link profile.
483    pub fn connect_bidirectional(&self, a: usize, b: usize) {
484        self.connect(a, b);
485        self.connect(b, a);
486    }
487
488    /// Remove the directed link from `from` to `to`.
489    pub fn disconnect(&self, from: usize, to: usize) {
490        self.set_link_profile(from, to, ModeledLinkProfile::default());
491    }
492
493    /// Configure one directed link with an explicit modeled profile.
494    pub fn set_link_profile(&self, from: usize, to: usize, profile: ModeledLinkProfile) {
495        let mut state = self.inner.borrow_mut();
496        let Some(row) = state.links.get_mut(from) else {
497            panic!("unknown modeled radio id {from}");
498        };
499        let Some(link) = row.get_mut(to) else {
500            panic!("unknown modeled radio id {to}");
501        };
502        *link = profile;
503    }
504
505    /// Return whether any future deliveries are still pending.
506    pub fn has_pending_deliveries(&self) -> bool {
507        !self.inner.borrow().scheduled.is_empty()
508    }
509
510    fn promote_due_frames(&self) {
511        let now_ms = self.clock.now_ms();
512        let mut state = self.inner.borrow_mut();
513        state.in_flight.retain(|tx| tx.end_ms > now_ms);
514        let mut index = 0usize;
515        while index < state.scheduled.len() {
516            if state.scheduled[index].available_at_ms > now_ms {
517                index += 1;
518                continue;
519            }
520            let delivery = state.scheduled.swap_remove(index);
521            if delivery.collided {
522                continue;
523            }
524            state.inboxes[delivery.to].push_back(QueuedFrame {
525                data: delivery.data,
526                rssi: delivery.rssi,
527                snr: delivery.snr,
528            });
529        }
530    }
531
532    fn channel_busy(&self, from: usize, now_ms: u64) -> bool {
533        let state = self.inner.borrow();
534        state.in_flight.iter().any(|tx| {
535            tx.from != from
536                && tx.start_ms <= now_ms
537                && now_ms < tx.end_ms
538                && state
539                    .links
540                    .get(tx.from)
541                    .and_then(|row| row.get(from))
542                    .map(|profile| profile.connected)
543                    .unwrap_or(false)
544        })
545    }
546
547    fn transmit(
548        &self,
549        from: usize,
550        frame: &[u8],
551        t_frame_ms: u32,
552        options: TxOptions,
553    ) -> Result<(), TxError<()>> {
554        self.promote_due_frames();
555        let now_ms = self.clock.now_ms();
556        if !matches!(options.cad, umsh_hal::CadPolicy::Skip) && self.channel_busy(from, now_ms) {
557            return Err(TxError::CadTimeout);
558        }
559
560        let mut state = self.inner.borrow_mut();
561        let Some(row) = state.links.get(from) else {
562            panic!("unknown modeled radio id {from}");
563        };
564        let start_ms = now_ms;
565        let end_ms = now_ms.saturating_add(u64::from(t_frame_ms));
566        let deliveries: Vec<(usize, ModeledLinkProfile)> = row
567            .iter()
568            .enumerate()
569            .filter_map(|(to, link)| link.connected.then_some((to, *link)))
570            .collect();
571
572        for (to, profile) in deliveries {
573            if profile.drop_per_thousand > 0
574                && state.rng.random_u16(1000) < profile.drop_per_thousand
575            {
576                continue;
577            }
578
579            let rssi_jitter = if profile.rssi_jitter_dbm > 0 {
580                state
581                    .rng
582                    .random_i16_inclusive(-profile.rssi_jitter_dbm, profile.rssi_jitter_dbm)
583            } else {
584                0
585            };
586            let snr_jitter = if profile.snr_jitter_centibels > 0 {
587                state.rng.random_i16_inclusive(
588                    -profile.snr_jitter_centibels,
589                    profile.snr_jitter_centibels,
590                )
591            } else {
592                0
593            };
594            let propagation_delay_ms = u64::from(profile.propagation_delay_ms);
595            let available_at_ms = end_ms.saturating_add(propagation_delay_ms);
596            let mut delivery = ScheduledDelivery {
597                to,
598                available_at_ms,
599                start_ms,
600                end_ms,
601                data: frame.to_vec(),
602                rssi: profile.base_rssi.saturating_add(rssi_jitter),
603                snr: Snr::from_centibels(
604                    profile.base_snr.as_centibels().saturating_add(snr_jitter),
605                ),
606                collided: false,
607            };
608
609            for existing in &mut state.scheduled {
610                if existing.to != to {
611                    continue;
612                }
613                if existing.start_ms < end_ms && start_ms < existing.end_ms {
614                    existing.collided = true;
615                    delivery.collided = true;
616                }
617            }
618
619            state.scheduled.push(delivery);
620        }
621
622        state.in_flight.push(InFlightTransmission {
623            from,
624            start_ms,
625            end_ms,
626        });
627        Ok(())
628    }
629
630    fn receive(&self, id: usize, buf: &mut [u8]) -> RxInfo {
631        self.promote_due_frames();
632        let mut state = self.inner.borrow_mut();
633        let Some(queue) = state.inboxes.get_mut(id) else {
634            panic!("unknown modeled radio id {id}");
635        };
636        let Some(frame) = queue.pop_front() else {
637            return RxInfo {
638                len: 0,
639                rssi: 0,
640                snr: Snr::from_decibels(0),
641                lqi: None,
642            };
643        };
644        let len = frame.data.len().min(buf.len());
645        buf[..len].copy_from_slice(&frame.data[..len]);
646        RxInfo {
647            len,
648            rssi: frame.rssi,
649            snr: frame.snr,
650            lqi: None,
651        }
652    }
653}
654
655impl Default for ModeledNetwork {
656    fn default() -> Self {
657        Self::new()
658    }
659}
660
661/// Radio implementation backed by a [`ModeledNetwork`].
662#[derive(Clone)]
663pub struct ModeledRadio {
664    network: ModeledNetwork,
665    id: usize,
666    max_frame_size: usize,
667    t_frame_ms: u32,
668}
669
670#[derive(Clone, Copy)]
671struct ModeledRng(u64);
672
673impl ModeledRng {
674    fn new(seed: u64) -> Self {
675        Self(seed.max(1))
676    }
677
678    fn next_u32(&mut self) -> u32 {
679        let mut x = self.0;
680        x ^= x << 13;
681        x ^= x >> 7;
682        x ^= x << 17;
683        self.0 = x.max(1);
684        x as u32
685    }
686
687    fn random_u16(&mut self, upper_exclusive: u16) -> u16 {
688        if upper_exclusive == 0 {
689            0
690        } else {
691            (self.next_u32() % u32::from(upper_exclusive)) as u16
692        }
693    }
694
695    fn random_i16_inclusive(&mut self, min: i16, max: i16) -> i16 {
696        if min >= max {
697            min
698        } else {
699            let span = (i32::from(max) - i32::from(min) + 1) as u32;
700            (i32::from(min) + (self.next_u32() % span) as i32) as i16
701        }
702    }
703}
704
705impl ModeledRadio {
706    /// Return the radio's stable identifier within the modeled network.
707    pub fn id(&self) -> usize {
708        self.id
709    }
710}
711
712impl Radio for ModeledRadio {
713    type Error = ();
714
715    async fn transmit(
716        &mut self,
717        data: &[u8],
718        options: TxOptions,
719    ) -> Result<(), TxError<Self::Error>> {
720        self.network
721            .transmit(self.id, data, self.t_frame_ms, options)
722    }
723
724    fn poll_receive(
725        &mut self,
726        _cx: &mut Context<'_>,
727        buf: &mut [u8],
728    ) -> Poll<Result<RxInfo, Self::Error>> {
729        let rx = self.network.receive(self.id, buf);
730        if rx.len == 0 {
731            Poll::Pending
732        } else {
733            Poll::Ready(Ok(rx))
734        }
735    }
736
737    fn max_frame_size(&self) -> usize {
738        self.max_frame_size
739    }
740
741    fn t_frame_ms(&self) -> u32 {
742        self.t_frame_ms
743    }
744}
745
746/// Minimal `NodeIdentity` implementation used by tests and simulations.
747///
748/// This type is not suitable for production use.
749#[derive(Clone)]
750pub struct DummyIdentity {
751    public_key: PublicKey,
752}
753
754impl DummyIdentity {
755    /// Construct a dummy identity from fixed public-key bytes.
756    ///
757    /// When the `software-crypto` feature is enabled the supplied bytes
758    /// are treated as a *seed* and run through Ed25519 key derivation so
759    /// that the resulting public key is always a valid point on the
760    /// curve. This keeps test code source-compatible (callers still pass
761    /// distinguishable byte patterns like `[0xAB; 32]`) while letting
762    /// the MAC layer enforce real curve validation in `add_peer`.
763    ///
764    /// Without `software-crypto` the bytes are used verbatim — useful
765    /// for `no_std` test scenarios where Ed25519 derivation is not
766    /// available, and where the validator is also compiled out.
767    pub fn new(bytes: [u8; 32]) -> Self {
768        #[cfg(feature = "software-crypto")]
769        let public_key = {
770            use umsh_crypto::NodeIdentity;
771            *umsh_crypto::software::SoftwareIdentity::from_secret_bytes(&bytes).public_key()
772        };
773        #[cfg(not(feature = "software-crypto"))]
774        let public_key = PublicKey(bytes);
775        Self { public_key }
776    }
777
778    /// Construct a dummy identity from a raw `PublicKey` without
779    /// running it through Ed25519 derivation. Intended for callers that
780    /// need exact control over the test public-key bytes (e.g. to
781    /// construct a key that the validator should reject).
782    pub fn from_public_key(public_key: PublicKey) -> Self {
783        Self { public_key }
784    }
785}
786
787impl NodeIdentity for DummyIdentity {
788    type Error = ();
789
790    fn public_key(&self) -> &PublicKey {
791        &self.public_key
792    }
793
794    async fn sign(&self, _message: &[u8]) -> Result<[u8; 64], Self::Error> {
795        Ok([0u8; 64])
796    }
797
798    async fn agree(&self, peer: &PublicKey) -> Result<SharedSecret, Self::Error> {
799        let mut out = [0u8; 32];
800        for (index, byte) in out.iter_mut().enumerate() {
801            *byte = self.public_key.0[index] ^ peer.0[index];
802        }
803        Ok(SharedSecret(out))
804    }
805}
806
807/// Dummy XOR-based cipher used by tests.
808///
809/// This type is intentionally insecure.
810pub struct DummyCipher {
811    key: [u8; 16],
812}
813
814impl AesCipher for DummyCipher {
815    fn encrypt_block(&self, block: &mut [u8; 16]) {
816        for (byte, key) in block.iter_mut().zip(self.key.iter()) {
817            *byte ^= *key;
818        }
819    }
820
821    fn decrypt_block(&self, block: &mut [u8; 16]) {
822        self.encrypt_block(block);
823    }
824}
825
826/// Dummy AES provider used by tests.
827///
828/// This type is intentionally insecure.
829#[derive(Clone, Copy)]
830pub struct DummyAes;
831
832impl AesProvider for DummyAes {
833    type Cipher = DummyCipher;
834
835    fn new_cipher(&self, key: &[u8; 16]) -> Self::Cipher {
836        DummyCipher { key: *key }
837    }
838}
839
840/// Dummy SHA/HMAC provider used by tests.
841///
842/// This type is intentionally insecure.
843#[derive(Clone, Copy)]
844pub struct DummySha;
845
846impl Sha256Provider for DummySha {
847    fn hash(&self, data: &[&[u8]]) -> [u8; 32] {
848        let mut out = [0u8; 32];
849        for chunk in data {
850            for (index, byte) in chunk.iter().enumerate() {
851                out[index % 32] ^= *byte;
852            }
853        }
854        out
855    }
856
857    fn hmac(&self, key: &[u8], data: &[&[u8]]) -> [u8; 32] {
858        let mut out = [0u8; 32];
859        for (index, byte) in key.iter().enumerate() {
860            out[index % 32] ^= *byte;
861        }
862        for chunk in data {
863            for (index, byte) in chunk.iter().enumerate() {
864                out[index % 32] ^= *byte;
865            }
866        }
867        out
868    }
869}
870
871/// Mutable monotonic test clock.
872#[derive(Clone, Default)]
873pub struct DummyClock {
874    now_ms: Rc<Cell<u64>>,
875}
876
877impl DummyClock {
878    /// Create the clock starting at `now_ms`.
879    pub fn new(now_ms: u64) -> Self {
880        Self {
881            now_ms: Rc::new(Cell::new(now_ms)),
882        }
883    }
884
885    /// Advance the clock by `delta_ms`.
886    pub fn advance_ms(&self, delta_ms: u64) {
887        self.now_ms.set(self.now_ms.get().saturating_add(delta_ms));
888    }
889
890    /// Set the current clock value.
891    pub fn set_ms(&self, now_ms: u64) {
892        self.now_ms.set(now_ms);
893    }
894}
895
896impl Clock for DummyClock {
897    fn now_ms(&self) -> u64 {
898        self.now_ms.get()
899    }
900}
901
902/// No-op async delay used only to satisfy the platform bundle in tests.
903#[derive(Clone, Copy, Default)]
904pub struct DummyDelay;
905
906impl DelayNs for DummyDelay {
907    async fn delay_ns(&mut self, _ns: u32) {}
908}
909
910/// Deterministic byte-filling RNG used by tests.
911///
912/// This type is intentionally predictable.
913#[derive(Clone, Default)]
914pub struct DummyRng(pub u8);
915
916impl TryRng for DummyRng {
917    type Error = Infallible;
918
919    fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
920        let mut bytes = [0u8; 4];
921        self.fill_bytes(&mut bytes);
922        Ok(u32::from_le_bytes(bytes))
923    }
924
925    fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
926        let mut bytes = [0u8; 8];
927        self.fill_bytes(&mut bytes);
928        Ok(u64::from_le_bytes(bytes))
929    }
930
931    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Infallible> {
932        for byte in dest.iter_mut() {
933            *byte = self.0;
934            self.0 = self.0.wrapping_add(1);
935        }
936        Ok(())
937    }
938}
939
940impl TryCryptoRng for DummyRng {}
941
942/// No-op counter store used by tests.
943#[derive(Clone, Copy, Default)]
944pub struct DummyCounterStore;
945
946impl CounterStore for DummyCounterStore {
947    type Error = ();
948
949    async fn load(&self, _context: &[u8]) -> Result<u32, Self::Error> {
950        Ok(0)
951    }
952
953    async fn store(&self, _context: &[u8], _value: u32) -> Result<(), Self::Error> {
954        Ok(())
955    }
956
957    async fn flush(&self) -> Result<(), Self::Error> {
958        Ok(())
959    }
960}
961
962/// No-op key-value store used only to satisfy the platform bundle in tests.
963#[derive(Clone, Copy, Default)]
964pub struct DummyKeyValueStore;
965
966impl KeyValueStore for DummyKeyValueStore {
967    type Error = ();
968
969    async fn load(&self, _key: &[u8], _buf: &mut [u8]) -> Result<Option<usize>, Self::Error> {
970        Ok(None)
971    }
972
973    async fn store(&self, _key: &[u8], _value: &[u8]) -> Result<(), Self::Error> {
974        Ok(())
975    }
976
977    async fn delete(&self, _key: &[u8]) -> Result<(), Self::Error> {
978        Ok(())
979    }
980}