umsh_node_mgmt/
device.rs

1//! The device side of an exchange: tokens, retained responses, and
2//! cursors.
3//!
4//! The engine performs no I/O and runs no ULCP command. It reads an
5//! arriving request, decides whether the request needs to be executed at
6//! all, and hands the caller the embedded frame together with where in a
7//! continued read to resume. The caller dispatches that frame through
8//! whatever machinery serves its local link and reports what came back;
9//! the engine wraps it, hands it out, and retains it against a
10//! retransmission.
11//!
12//! Authorization is not the engine's concern: a request reaches it only
13//! after its source has been checked against the administrator list.
14
15use umsh_ulcp::frame::{self, Cmd, Frame};
16use umsh_ulcp::status::Status;
17
18use crate::envelope::{Envelope, EnvelopeError, Token};
19use crate::fragment::continuable;
20
21/// An Ed25519 public key naming a node.
22pub type PublicKey = [u8; 32];
23
24/// Retained entries the reference engine keeps, one per administrator.
25///
26/// The spec requires only the most recently active administrator's, and
27/// bounds nothing above that. Four covers a device managed by a handful
28/// of people at once and costs about a kilobyte.
29pub const CACHE_ENTRIES: usize = 4;
30
31/// Why an arriving payload produced nothing.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum DropReason {
34    /// The payload was too short to hold a token, so there is nothing to
35    /// correlate a response with and no way to report the problem.
36    NoToken,
37    /// The output buffer could not hold the response. The caller sized
38    /// it below the transport's own payload limit.
39    NoRoom,
40}
41
42/// What the caller must do with an arriving payload.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Ingress<'p> {
45    /// Send nothing and account for it.
46    Drop(DropReason),
47    /// `out[..len]` is a complete response payload: a retained response
48    /// being retransmitted, or an error the engine answered on its own.
49    Respond { len: usize },
50    /// Dispatch the frame, then call [`DeviceEngine::complete`].
51    Dispatch(Dispatch<'p>),
52}
53
54/// A request the engine wants executed.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct Dispatch<'p> {
57    /// Exactly one ULCP frame, in the grammar of the local bindings. Its
58    /// TID is zero and receivers ignore it.
59    pub frame: &'p [u8],
60    /// Where in the logical trailing content — the value of a
61    /// `CMD_PROP_IS`, or the entry list of a `CMD_PROP_ARE` — this
62    /// response resumes. Zero for a request carrying no cursor.
63    pub resume: u32,
64    /// Octets available for the reply frame, the envelope's worst case
65    /// already deducted.
66    pub budget: usize,
67    /// A reset-class command, which is answered by no response payload
68    /// at all. The caller executes it and completes with
69    /// [`Produced::no_response`].
70    pub resets: bool,
71}
72
73impl Dispatch<'_> {
74    /// The parsed command, absent when this build does not define it.
75    /// Such a frame is the caller's to answer `STATUS_INVALID_COMMAND`;
76    /// the engine only needed enough of it to apply the cursor rules.
77    pub fn command(&self) -> Option<Cmd> {
78        Frame::parse(self.frame).ok().and_then(|f| f.command())
79    }
80}
81
82/// What dispatching a frame produced.
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub struct Produced<'a> {
85    /// The reply frame, or empty for a reset-class command.
86    pub frame: &'a [u8],
87    /// Octets of trailing content this frame carries, which is where the
88    /// next fragment resumes. Meaningless unless `remaining` is nonzero.
89    pub produced: u32,
90    /// Octets of trailing content this frame does not carry. Zero ends
91    /// the read; anything else issues a cursor.
92    pub remaining: u32,
93}
94
95impl<'a> Produced<'a> {
96    /// A reply that says everything it has to say.
97    pub const fn complete(frame: &'a [u8]) -> Self {
98        Self {
99            frame,
100            produced: 0,
101            remaining: 0,
102        }
103    }
104
105    /// A leading fragment of a read that does not fit one payload.
106    pub const fn fragment(frame: &'a [u8], produced: u32, remaining: u32) -> Self {
107        Self {
108            frame,
109            produced,
110            remaining,
111        }
112    }
113
114    /// A reset-class command: executed, and answered by nothing.
115    pub const fn no_response() -> Self {
116        Self {
117            frame: &[],
118            produced: 0,
119            remaining: 0,
120        }
121    }
122}
123
124/// Why a response could not be assembled.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum CompleteError {
127    /// [`DeviceEngine::complete`] was called without a dispatch in
128    /// flight.
129    NotDispatched,
130    /// The reply frame exceeds the budget the dispatch handed out, or
131    /// the read ran past what a cursor can address.
132    TooLarge,
133}
134
135/// A cursor as this engine issues them.
136///
137/// ```text
138/// +-------+------------+-----+--------+
139/// | NONCE | GENERATION | TAG | OFFSET |
140/// +-------+------------+-----+--------+
141///    2 B       2 B       2 B    2 B
142/// ```
143///
144/// Opaque to the administrator, which returns it byte for byte. Each
145/// field answers one of the ways a cursor can go stale: NONCE a reboot,
146/// GENERATION a device-domain change out from under the position, and
147/// TAG a cursor presented with a read other than the one it was issued
148/// for. OFFSET caps a continued read at 64 KiB, which is orders of
149/// magnitude past anything a device holds.
150const CURSOR_LEN: usize = 8;
151
152fn encode_cursor(nonce: u16, generation: u16, tag: u16, offset: u16) -> [u8; CURSOR_LEN] {
153    let mut out = [0u8; CURSOR_LEN];
154    out[0..2].copy_from_slice(&nonce.to_be_bytes());
155    out[2..4].copy_from_slice(&generation.to_be_bytes());
156    out[4..6].copy_from_slice(&tag.to_be_bytes());
157    out[6..8].copy_from_slice(&offset.to_be_bytes());
158    out
159}
160
161/// A 16-bit FNV-1a of the request frame, binding a cursor to the read it
162/// was issued for. A continuation repeats the frame verbatim — the
163/// cursor rides in the envelope, not the frame — so an honest
164/// continuation always matches.
165fn request_tag(frame: &[u8]) -> u16 {
166    let mut hash: u32 = 0x811C_9DC5;
167    for &byte in frame {
168        hash ^= u32::from(byte);
169        hash = hash.wrapping_mul(0x0100_0193);
170    }
171    // Fold to 16 bits rather than truncate, so every input byte reaches
172    // the result.
173    ((hash >> 16) ^ hash) as u16
174}
175
176/// Whether this command initiates a reset, and so is answered by no
177/// response payload.
178///
179/// `CMD_RESTORE` qualifies because a device that has a saved snapshot
180/// resets into it; one that has none answers normally, but the
181/// administrator cannot know which in advance, so the binding treats the
182/// command as reset-class either way and the administrator confirms
183/// delivery with a MAC acknowledgment. `CMD_REBOOT` qualifies for the
184/// same reason: a board restarts and says nothing, while one that cannot
185/// answers `STATUS_UNIMPLEMENTED`.
186const fn reset_class(cmd: Cmd) -> bool {
187    matches!(
188        cmd,
189        Cmd::Reset | Cmd::Restore | Cmd::FactoryReset | Cmd::Reboot
190    )
191}
192
193/// One administrator's most recent exchange.
194#[derive(Clone, Copy)]
195struct Entry<const PAYLOAD: usize> {
196    key: PublicKey,
197    token: Token,
198    payload: [u8; PAYLOAD],
199    len: usize,
200    /// When this administrator was last heard from, for eviction.
201    last_ms: u64,
202    occupied: bool,
203}
204
205// `[u8; PAYLOAD]` does not derive `Default` for an arbitrary const.
206impl<const PAYLOAD: usize> Default for Entry<PAYLOAD> {
207    fn default() -> Self {
208        Self {
209            key: [0; 32],
210            token: [0; 2],
211            payload: [0; PAYLOAD],
212            len: 0,
213            last_ms: 0,
214            occupied: false,
215        }
216    }
217}
218
219/// The exchange between [`DeviceEngine::begin`] and
220/// [`DeviceEngine::complete`].
221#[derive(Clone, Copy)]
222struct InFlight {
223    key: PublicKey,
224    token: Token,
225    generation: u16,
226    tag: u16,
227    resume: u32,
228    budget: usize,
229    now_ms: u64,
230}
231
232/// The device half of the Node Management binding.
233///
234/// `PAYLOAD` is the largest response payload the transport can carry,
235/// which is also the size of each retained entry. `ENTRIES` is how many
236/// administrators keep a retained response.
237pub struct DeviceEngine<const PAYLOAD: usize, const ENTRIES: usize = CACHE_ENTRIES> {
238    nonce: u16,
239    entries: [Entry<PAYLOAD>; ENTRIES],
240    in_flight: Option<InFlight>,
241}
242
243impl<const PAYLOAD: usize, const ENTRIES: usize> DeviceEngine<PAYLOAD, ENTRIES> {
244    /// Build an engine. `nonce` is drawn once per boot from the
245    /// cryptographic RNG: it is what stops a cursor issued before a
246    /// reboot from being honored after one, when the generation counter
247    /// has started over.
248    pub fn new(nonce: u16) -> Self {
249        Self {
250            nonce,
251            entries: [Entry::default(); ENTRIES],
252            in_flight: None,
253        }
254    }
255
256    /// Read an arriving Node Management Request payload, the payload type
257    /// byte already stripped.
258    ///
259    /// `generation` is the device-domain version: every change to it
260    /// invalidates the cursors issued before it. `now_ms` orders the
261    /// retained entries for eviction.
262    pub fn begin<'p>(
263        &mut self,
264        from: &PublicKey,
265        payload: &'p [u8],
266        generation: u16,
267        now_ms: u64,
268        out: &mut [u8],
269    ) -> Ingress<'p> {
270        self.in_flight = None;
271
272        // The token leads the payload, so it survives anything the
273        // option block does. Every envelope that has one is answered;
274        // only a payload too short to hold one is dropped, since a
275        // response nothing can be correlated with is no answer at all.
276        let &[token0, token1, ..] = payload else {
277            return Ingress::Drop(DropReason::NoToken);
278        };
279        let token = [token0, token1];
280
281        let request = match Envelope::parse(payload) {
282            Ok(request) => request,
283            Err(EnvelopeError::UnknownCritical(_)) => {
284                return self.answer(from, token, Status::UNIMPLEMENTED, now_ms, out);
285            }
286            Err(EnvelopeError::InvalidOptionValue(crate::envelope::OPT_CURSOR)) => {
287                return self.answer(from, token, Status::CURSOR_INVALID, now_ms, out);
288            }
289            Err(_) => return self.answer(from, token, Status::PARSE_ERROR, now_ms, out),
290        };
291
292        // A retransmission is answered from the retained response
293        // without executing anything — before the request is even looked
294        // at, since the point is not to look at it again.
295        if let Some(entry) = self.retained(from, token) {
296            entry.last_ms = now_ms;
297            let len = entry.len;
298            if out.len() < len {
299                return Ingress::Drop(DropReason::NoRoom);
300            }
301            out[..len].copy_from_slice(&entry.payload[..len]);
302            return Ingress::Respond { len };
303        }
304
305        // A frame that does not parse is answered rather than dropped:
306        // the administrator learns its request was heard and malformed.
307        let Ok(parsed) = Frame::parse(request.frame) else {
308            return self.answer(from, token, Status::PARSE_ERROR, now_ms, out);
309        };
310        let cmd = parsed.command();
311
312        let mut resume = 0;
313        if let Some(cursor) = request.cursor {
314            // A cursor on something that is not a read at all, including
315            // a command this build does not define.
316            if !cmd.is_some_and(continuable) {
317                return self.answer(from, token, Status::INVALID_ARGUMENT, now_ms, out);
318            }
319            match self.parse_cursor(cursor, generation, request.frame) {
320                Some(offset) => resume = offset,
321                None => return self.answer(from, token, Status::CURSOR_INVALID, now_ms, out),
322            }
323        }
324
325        let budget = PAYLOAD
326            .min(out.len())
327            .saturating_sub(crate::envelope::OVERHEAD_MAX);
328        self.in_flight = Some(InFlight {
329            key: *from,
330            token,
331            generation,
332            // Binds any cursor this exchange issues to the read that
333            // issued it.
334            tag: request_tag(request.frame),
335            resume,
336            budget,
337            now_ms,
338        });
339        Ingress::Dispatch(Dispatch {
340            frame: request.frame,
341            resume,
342            // Measured against the worst envelope rather than this
343            // request's, so a reply that turns out to need a cursor
344            // still fits the payload it was sized for.
345            budget,
346            resets: cmd.is_some_and(reset_class),
347        })
348    }
349
350    /// Wrap what the dispatch produced, retain it, and write the
351    /// response payload into `out`.
352    ///
353    /// Returns the payload's length, or `None` for a reset-class command,
354    /// which is answered by no payload at all.
355    pub fn complete(
356        &mut self,
357        produced: Produced<'_>,
358        out: &mut [u8],
359    ) -> Result<Option<usize>, CompleteError> {
360        let in_flight = self.in_flight.take().ok_or(CompleteError::NotDispatched)?;
361
362        if produced.frame.is_empty() {
363            // A reset takes the retained entries with it: after one, a
364            // retransmitted request is executed again, with the same
365            // result.
366            self.entries = [Entry::default(); ENTRIES];
367            return Ok(None);
368        }
369
370        if produced.frame.len() > in_flight.budget {
371            return Err(CompleteError::TooLarge);
372        }
373
374        let mut response = Envelope::new(in_flight.token, produced.frame);
375        let cursor;
376        if produced.remaining > 0 {
377            let offset = in_flight
378                .resume
379                .checked_add(produced.produced)
380                .and_then(|offset| u16::try_from(offset).ok())
381                .ok_or(CompleteError::TooLarge)?;
382            cursor = encode_cursor(self.nonce, in_flight.generation, in_flight.tag, offset);
383            response = response
384                .with_cursor(&cursor)
385                .with_remaining(produced.remaining);
386        }
387
388        let len = response.encode(out).map_err(|_| CompleteError::TooLarge)?;
389        self.retain(
390            &in_flight.key,
391            in_flight.token,
392            &out[..len],
393            in_flight.now_ms,
394        );
395        Ok(Some(len))
396    }
397
398    /// Forget every retained response, as a reset does.
399    pub fn forget_retained(&mut self) {
400        self.entries = [Entry::default(); ENTRIES];
401    }
402
403    /// Answer a request the engine can decide on its own, and retain the
404    /// answer like any other.
405    fn answer(
406        &mut self,
407        from: &PublicKey,
408        token: Token,
409        status: Status,
410        now_ms: u64,
411        out: &mut [u8],
412    ) -> Ingress<'static> {
413        let mut buf = [0u8; 8];
414        let Ok(len) = frame::last_status(&mut buf, frame::TID_UNSOLICITED, status) else {
415            return Ingress::Drop(DropReason::NoRoom);
416        };
417        let Ok(len) = Envelope::new(token, &buf[..len]).encode(out) else {
418            return Ingress::Drop(DropReason::NoRoom);
419        };
420        self.retain(from, token, &out[..len], now_ms);
421        Ingress::Respond { len }
422    }
423
424    /// The offset a cursor names, or `None` if it cannot be honored.
425    fn parse_cursor(&self, cursor: &[u8], generation: u16, frame: &[u8]) -> Option<u32> {
426        let cursor: [u8; CURSOR_LEN] = cursor.try_into().ok()?;
427        let nonce = u16::from_be_bytes([cursor[0], cursor[1]]);
428        let issued_at = u16::from_be_bytes([cursor[2], cursor[3]]);
429        let tag = u16::from_be_bytes([cursor[4], cursor[5]]);
430        let offset = u16::from_be_bytes([cursor[6], cursor[7]]);
431        if nonce != self.nonce || issued_at != generation || tag != request_tag(frame) {
432            return None;
433        }
434        Some(u32::from(offset))
435    }
436
437    fn retained(&mut self, key: &PublicKey, token: Token) -> Option<&mut Entry<PAYLOAD>> {
438        self.entries
439            .iter_mut()
440            .find(|entry| entry.occupied && &entry.key == key && entry.token == token)
441    }
442
443    fn retain(&mut self, key: &PublicKey, token: Token, payload: &[u8], now_ms: u64) {
444        if payload.len() > PAYLOAD {
445            // Unretainable, so a retransmission would be executed again.
446            // Nothing this crate builds gets here; a caller that
447            // overruns its own budget does.
448            return;
449        }
450        let slot = self
451            .entries
452            .iter_mut()
453            .position(|entry| entry.occupied && &entry.key == key)
454            .or_else(|| self.entries.iter().position(|entry| !entry.occupied))
455            .or_else(|| {
456                // Evict the least recently active administrator, which
457                // by construction is never the one we are answering.
458                self.entries
459                    .iter()
460                    .enumerate()
461                    .min_by_key(|(_, entry)| entry.last_ms)
462                    .map(|(index, _)| index)
463            });
464        let Some(slot) = slot else { return };
465        let entry = &mut self.entries[slot];
466        entry.key = *key;
467        entry.token = token;
468        entry.payload[..payload.len()].copy_from_slice(payload);
469        entry.len = payload.len();
470        entry.last_ms = now_ms;
471        entry.occupied = true;
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use umsh_ulcp::ids::prop;
479
480    const PAYLOAD: usize = 180;
481    type Engine = DeviceEngine<PAYLOAD, 2>;
482
483    const ALICE: PublicKey = [0xAA; 32];
484    const BOB: PublicKey = [0xBB; 32];
485
486    fn request(token: Token, frame: &[u8], out: &mut [u8]) -> usize {
487        Envelope::new(token, frame).encode(out).expect("encode")
488    }
489
490    fn get(key: u32, buf: &mut [u8]) -> usize {
491        frame::prop_get(buf, 0, key).expect("encode")
492    }
493
494    /// The status a `PROP_LAST_STATUS` response reports.
495    fn reported_status(payload: &[u8]) -> Status {
496        let envelope = Envelope::parse(payload).expect("envelope");
497        let parsed = Frame::parse(envelope.frame).expect("frame");
498        assert_eq!(parsed.command(), Some(Cmd::PropIs));
499        let (key, consumed) = umsh_ulcp::pui::decode(parsed.payload).expect("key");
500        assert_eq!(key, prop::LAST_STATUS);
501        let (status, _) = umsh_ulcp::pui::decode(&parsed.payload[consumed..]).expect("status");
502        Status(status)
503    }
504
505    #[test]
506    fn a_plain_request_is_dispatched_and_its_reply_comes_back_whole() {
507        let mut engine = Engine::new(0x1234);
508        let mut frame_buf = [0u8; 8];
509        let frame_len = get(prop::CAPS, &mut frame_buf);
510        let frame = &frame_buf[..frame_len];
511        let mut payload = [0u8; PAYLOAD];
512        let len = request([1, 2], frame, &mut payload);
513
514        let mut out = [0u8; PAYLOAD];
515        let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], 7, 0, &mut out)
516        else {
517            panic!("expected a dispatch");
518        };
519        assert_eq!(dispatch.frame, frame);
520        assert_eq!(dispatch.resume, 0);
521        assert!(!dispatch.resets);
522        assert_eq!(dispatch.budget, PAYLOAD - crate::envelope::OVERHEAD_MAX);
523
524        let mut reply_buf = [0u8; 16];
525        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[1, 2, 3]).unwrap();
526        let len = engine
527            .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
528            .expect("complete")
529            .expect("a response");
530
531        let response = Envelope::parse(&out[..len]).expect("envelope");
532        assert_eq!(response.token, [1, 2]);
533        assert_eq!(response.cursor, None);
534        assert_eq!(response.frame, &reply_buf[..reply_len]);
535    }
536
537    #[test]
538    fn completing_without_a_dispatch_is_an_error() {
539        let mut engine = Engine::new(1);
540        let mut out = [0u8; PAYLOAD];
541        assert_eq!(
542            engine.complete(Produced::complete(&[0x80, 0x06]), &mut out),
543            Err(CompleteError::NotDispatched)
544        );
545    }
546
547    #[test]
548    fn a_repeated_token_is_answered_from_the_retained_response() {
549        let mut engine = Engine::new(1);
550        let mut frame_buf = [0u8; 8];
551        let frame_len = get(prop::CAPS, &mut frame_buf);
552        let frame = &frame_buf[..frame_len];
553        let mut payload = [0u8; PAYLOAD];
554        let len = request([9, 9], frame, &mut payload);
555
556        let mut out = [0u8; PAYLOAD];
557        assert!(matches!(
558            engine.begin(&ALICE, &payload[..len], 0, 0, &mut out),
559            Ingress::Dispatch(_)
560        ));
561        let mut reply_buf = [0u8; 16];
562        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[7]).unwrap();
563        let first = engine
564            .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
565            .unwrap()
566            .unwrap();
567        let first = out[..first].to_vec();
568
569        // The identical request again: answered, not executed.
570        let mut again = [0u8; PAYLOAD];
571        match engine.begin(&ALICE, &payload[..len], 0, 1_000, &mut again) {
572            Ingress::Respond { len } => assert_eq!(&again[..len], &first[..]),
573            other => panic!("expected the retained response, got {other:?}"),
574        }
575
576        // A different administrator sending the same token has its own
577        // exchange, and gets dispatched.
578        assert!(matches!(
579            engine.begin(&BOB, &payload[..len], 0, 2_000, &mut again),
580            Ingress::Dispatch(_)
581        ));
582    }
583
584    #[test]
585    fn a_new_token_from_the_same_administrator_replaces_the_retained_entry() {
586        let mut engine = Engine::new(1);
587        let mut frame_buf = [0u8; 8];
588        let frame_len = get(prop::CAPS, &mut frame_buf);
589        let frame = &frame_buf[..frame_len];
590        let mut out = [0u8; PAYLOAD];
591        let mut reply_buf = [0u8; 16];
592        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[7]).unwrap();
593
594        for token in [[1, 1], [2, 2]] {
595            let mut payload = [0u8; PAYLOAD];
596            let len = request(token, frame, &mut payload);
597            assert!(matches!(
598                engine.begin(&ALICE, &payload[..len], 0, 0, &mut out),
599                Ingress::Dispatch(_)
600            ));
601            engine
602                .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
603                .unwrap();
604        }
605
606        // Only the most recent exchange is retained, and one
607        // administrator never occupies two slots.
608        let mut payload = [0u8; PAYLOAD];
609        let len = request([1, 1], frame, &mut payload);
610        assert!(matches!(
611            engine.begin(&ALICE, &payload[..len], 0, 0, &mut out),
612            Ingress::Dispatch(_)
613        ));
614        assert_eq!(engine.entries.iter().filter(|e| e.occupied).count(), 1);
615    }
616
617    #[test]
618    fn the_least_recently_active_administrator_is_evicted() {
619        let mut engine = Engine::new(1);
620        let mut frame_buf = [0u8; 8];
621        let frame_len = get(prop::CAPS, &mut frame_buf);
622        let frame = &frame_buf[..frame_len];
623        let mut out = [0u8; PAYLOAD];
624        let mut reply_buf = [0u8; 16];
625        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[7]).unwrap();
626        let carol: PublicKey = [0xCC; 32];
627
628        // Two slots, three administrators; Alice is the stalest.
629        for (key, now) in [(&ALICE, 0u64), (&BOB, 10), (&carol, 20)] {
630            let mut payload = [0u8; PAYLOAD];
631            let len = request([1, 1], frame, &mut payload);
632            assert!(matches!(
633                engine.begin(key, &payload[..len], 0, now, &mut out),
634                Ingress::Dispatch(_)
635            ));
636            engine
637                .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
638                .unwrap();
639        }
640
641        let mut payload = [0u8; PAYLOAD];
642        let len = request([1, 1], frame, &mut payload);
643        // Bob and Carol are still answered from their retained entries.
644        assert!(matches!(
645            engine.begin(&BOB, &payload[..len], 0, 30, &mut out),
646            Ingress::Respond { .. }
647        ));
648        // Alice's is gone, so her retransmission is executed again.
649        assert!(matches!(
650            engine.begin(&ALICE, &payload[..len], 0, 40, &mut out),
651            Ingress::Dispatch(_)
652        ));
653    }
654
655    #[test]
656    fn a_frame_that_does_not_parse_is_answered_parse_error() {
657        let mut engine = Engine::new(1);
658        let mut out = [0u8; PAYLOAD];
659
660        for frame in [&[][..], &[0x00, 0x02][..], &[0x80][..]] {
661            let mut payload = [0u8; PAYLOAD];
662            let len = request([3, 3], frame, &mut payload);
663            let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
664            else {
665                panic!("expected an answer for {frame:?}");
666            };
667            assert_eq!(reported_status(&out[..len]), Status::PARSE_ERROR);
668            // Even the error is retained: a retransmission must not be
669            // re-derived, only re-sent.
670            engine.forget_retained();
671        }
672    }
673
674    #[test]
675    fn a_payload_too_short_for_a_token_is_dropped() {
676        let mut engine = Engine::new(1);
677        let mut out = [0u8; PAYLOAD];
678        for payload in [&[][..], &[0x01][..]] {
679            assert_eq!(
680                engine.begin(&ALICE, payload, 0, 0, &mut out),
681                Ingress::Drop(DropReason::NoToken)
682            );
683        }
684    }
685
686    #[test]
687    fn a_malformed_option_block_is_answered_because_the_token_precedes_it() {
688        let mut engine = Engine::new(1);
689        let mut out = [0u8; PAYLOAD];
690        // A length nibble promising more than the payload holds.
691        let Ingress::Respond { len } = engine.begin(&ALICE, &[4, 5, 0x1F, 0x00], 0, 0, &mut out)
692        else {
693            panic!("expected an answer");
694        };
695        assert_eq!(reported_status(&out[..len]), Status::PARSE_ERROR);
696        assert_eq!(Envelope::parse(&out[..len]).unwrap().token, [4, 5]);
697    }
698
699    #[test]
700    fn a_cursor_of_an_impossible_width_is_answered_cursor_invalid() {
701        let mut payload = [0u8; PAYLOAD];
702        payload[0] = 1;
703        payload[1] = 2;
704        let len = {
705            let mut enc = umsh_core::options::OptionEncoder::new(&mut payload[2..]);
706            enc.put(crate::envelope::OPT_CURSOR, &[0u8; 9]).unwrap();
707            enc.end_marker().unwrap();
708            2 + enc.finish()
709        };
710
711        let mut engine = Engine::new(1);
712        let mut out = [0u8; PAYLOAD];
713        let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out) else {
714            panic!("expected an answer");
715        };
716        assert_eq!(reported_status(&out[..len]), Status::CURSOR_INVALID);
717    }
718
719    #[test]
720    fn an_unknown_critical_option_is_answered_unimplemented() {
721        let mut payload = [0u8; PAYLOAD];
722        payload[0] = 5;
723        payload[1] = 6;
724        let len = {
725            let mut enc = umsh_core::options::OptionEncoder::new(&mut payload[2..]);
726            enc.put(7, &[0]).unwrap();
727            enc.end_marker().unwrap();
728            2 + enc.finish()
729        };
730
731        let mut engine = Engine::new(1);
732        let mut out = [0u8; PAYLOAD];
733        let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out) else {
734            panic!("expected an answer");
735        };
736        assert_eq!(reported_status(&out[..len]), Status::UNIMPLEMENTED);
737        assert_eq!(Envelope::parse(&out[..len]).unwrap().token, [5, 6]);
738    }
739
740    /// Drive one fragment of a continued read, returning where it
741    /// resumed and the length of the response written into `out`.
742    fn fragment(
743        engine: &mut Engine,
744        token: Token,
745        frame: &[u8],
746        cursor: Option<&[u8]>,
747        generation: u16,
748        // Octets this fragment carries, and octets left after it.
749        (produced, remaining): (u32, u32),
750        out: &mut [u8],
751    ) -> (u32, usize) {
752        let mut payload = [0u8; PAYLOAD];
753        let mut envelope = Envelope::new(token, frame);
754        if let Some(cursor) = cursor {
755            envelope = envelope.with_cursor(cursor);
756        }
757        let len = envelope.encode(&mut payload).expect("encode");
758        let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], generation, 0, out)
759        else {
760            panic!("expected a dispatch");
761        };
762        let mut reply_buf = [0u8; 32];
763        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[0; 4]).unwrap();
764        let len = engine
765            .complete(
766                Produced::fragment(&reply_buf[..reply_len], produced, remaining),
767                out,
768            )
769            .expect("complete")
770            .expect("a response");
771        (dispatch.resume, len)
772    }
773
774    #[test]
775    fn a_cursor_carries_the_position_from_one_exchange_to_the_next() {
776        let mut engine = Engine::new(0xBEEF);
777        let mut frame_buf = [0u8; 8];
778        let frame_len = get(prop::CAPS, &mut frame_buf);
779        let frame = &frame_buf[..frame_len];
780        let mut out = [0u8; PAYLOAD];
781
782        let (resume, len) = fragment(&mut engine, [1, 0], frame, None, 3, (40, 90), &mut out);
783        assert_eq!(resume, 0);
784        let first = Envelope::parse(&out[..len]).unwrap();
785        assert_eq!(first.remaining, Some(90));
786        let cursor = first.cursor.expect("a cursor").to_vec();
787
788        let (resume, len) = fragment(
789            &mut engine,
790            [2, 0],
791            frame,
792            Some(&cursor),
793            3,
794            (40, 50),
795            &mut out,
796        );
797        assert_eq!(resume, 40);
798        let second = Envelope::parse(&out[..len]).unwrap();
799        let cursor = second.cursor.expect("a cursor").to_vec();
800
801        // The last fragment ends the read by carrying no cursor.
802        let mut payload = [0u8; PAYLOAD];
803        let len = Envelope::new([3, 0], frame)
804            .with_cursor(&cursor)
805            .encode(&mut payload)
806            .unwrap();
807        let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], 3, 0, &mut out)
808        else {
809            panic!("expected a dispatch");
810        };
811        assert_eq!(dispatch.resume, 80);
812        let mut reply_buf = [0u8; 32];
813        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[0; 4]).unwrap();
814        let len = engine
815            .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
816            .unwrap()
817            .unwrap();
818        assert_eq!(Envelope::parse(&out[..len]).unwrap().cursor, None);
819    }
820
821    #[test]
822    fn a_cursor_is_refused_once_the_device_domain_moves() {
823        let mut engine = Engine::new(1);
824        let mut frame_buf = [0u8; 8];
825        let frame_len = get(prop::CAPS, &mut frame_buf);
826        let frame = &frame_buf[..frame_len];
827        let mut out = [0u8; PAYLOAD];
828
829        let (_, len) = fragment(&mut engine, [1, 0], frame, None, 5, (10, 10), &mut out);
830        let cursor = Envelope::parse(&out[..len])
831            .unwrap()
832            .cursor
833            .unwrap()
834            .to_vec();
835
836        let mut payload = [0u8; PAYLOAD];
837        let len = Envelope::new([2, 0], frame)
838            .with_cursor(&cursor)
839            .encode(&mut payload)
840            .unwrap();
841        let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 6, 0, &mut out) else {
842            panic!("a cursor issued at generation 5 must not be honored at 6");
843        };
844        assert_eq!(reported_status(&out[..len]), Status::CURSOR_INVALID);
845    }
846
847    #[test]
848    fn a_cursor_is_refused_for_a_read_other_than_the_one_it_began() {
849        let mut engine = Engine::new(1);
850        let mut frame_buf = [0u8; 8];
851        let frame_len = get(prop::CAPS, &mut frame_buf);
852        let frame = &frame_buf[..frame_len];
853        let mut out = [0u8; PAYLOAD];
854        let (_, len) = fragment(&mut engine, [1, 0], frame, None, 0, (10, 10), &mut out);
855        let cursor = Envelope::parse(&out[..len])
856            .unwrap()
857            .cursor
858            .unwrap()
859            .to_vec();
860
861        // Same command, different property.
862        let mut other_buf = [0u8; 8];
863        let other_len = get(prop::PROTOCOL_VERSION, &mut other_buf);
864        let other = &other_buf[..other_len];
865        let mut payload = [0u8; PAYLOAD];
866        let len = Envelope::new([2, 0], other)
867            .with_cursor(&cursor)
868            .encode(&mut payload)
869            .unwrap();
870        let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out) else {
871            panic!("expected a refusal");
872        };
873        assert_eq!(reported_status(&out[..len]), Status::CURSOR_INVALID);
874    }
875
876    #[test]
877    fn a_cursor_from_before_a_reboot_is_refused() {
878        let mut engine = Engine::new(0x1111);
879        let mut frame_buf = [0u8; 8];
880        let frame_len = get(prop::CAPS, &mut frame_buf);
881        let frame = &frame_buf[..frame_len];
882        let mut out = [0u8; PAYLOAD];
883        let (_, len) = fragment(&mut engine, [1, 0], frame, None, 0, (10, 10), &mut out);
884        let cursor = Envelope::parse(&out[..len])
885            .unwrap()
886            .cursor
887            .unwrap()
888            .to_vec();
889
890        // Same generation — a counter that started over — but a new
891        // per-boot nonce.
892        let mut rebooted = Engine::new(0x2222);
893        let mut payload = [0u8; PAYLOAD];
894        let len = Envelope::new([2, 0], frame)
895            .with_cursor(&cursor)
896            .encode(&mut payload)
897            .unwrap();
898        let Ingress::Respond { len } = rebooted.begin(&ALICE, &payload[..len], 0, 0, &mut out)
899        else {
900            panic!("expected a refusal");
901        };
902        assert_eq!(reported_status(&out[..len]), Status::CURSOR_INVALID);
903    }
904
905    #[test]
906    fn a_malformed_cursor_is_refused_rather_than_read_from_the_beginning() {
907        let mut engine = Engine::new(1);
908        let mut frame_buf = [0u8; 8];
909        let frame_len = get(prop::CAPS, &mut frame_buf);
910        let frame = &frame_buf[..frame_len];
911        let mut out = [0u8; PAYLOAD];
912
913        for cursor in [&[0u8][..], &[0u8; 7][..], &[0xFF; 8][..]] {
914            let mut payload = [0u8; PAYLOAD];
915            let len = Envelope::new([1, 0], frame)
916                .with_cursor(cursor)
917                .encode(&mut payload)
918                .unwrap();
919            let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
920            else {
921                panic!("expected a refusal for {cursor:?}");
922            };
923            assert_eq!(reported_status(&out[..len]), Status::CURSOR_INVALID);
924            engine.forget_retained();
925        }
926    }
927
928    #[test]
929    fn a_cursor_on_a_request_that_is_not_a_read_is_an_invalid_argument() {
930        let mut engine = Engine::new(1);
931        let mut out = [0u8; PAYLOAD];
932        let mut frame_buf = [0u8; 8];
933
934        for len in [
935            frame::prop_set(&mut frame_buf, 0, prop::CAPS, &[1]).unwrap(),
936            frame::save(&mut frame_buf, 0).unwrap(),
937        ] {
938            let mut payload = [0u8; PAYLOAD];
939            let len = Envelope::new([1, 0], &frame_buf[..len])
940                .with_cursor(&[0; 8])
941                .encode(&mut payload)
942                .unwrap();
943            let Ingress::Respond { len } = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
944            else {
945                panic!("expected a refusal");
946            };
947            assert_eq!(reported_status(&out[..len]), Status::INVALID_ARGUMENT);
948            engine.forget_retained();
949        }
950    }
951
952    #[test]
953    fn a_multi_get_continues_the_same_way_a_get_does() {
954        let mut engine = Engine::new(1);
955        let mut frame_buf = [0u8; 16];
956        let len =
957            frame::prop_multi_get(&mut frame_buf, 0, &[prop::CAPS, prop::DEV_ADMINS]).unwrap();
958        let frame = &frame_buf[..len];
959        let mut out = [0u8; PAYLOAD];
960
961        let (_, len) = fragment(&mut engine, [1, 0], frame, None, 0, (60, 20), &mut out);
962        let cursor = Envelope::parse(&out[..len])
963            .unwrap()
964            .cursor
965            .unwrap()
966            .to_vec();
967        let (resume, _) = fragment(
968            &mut engine,
969            [2, 0],
970            frame,
971            Some(&cursor),
972            0,
973            (20, 0),
974            &mut out,
975        );
976        assert_eq!(resume, 60);
977    }
978
979    #[test]
980    fn a_reset_is_answered_by_nothing_and_forgets_what_was_retained() {
981        let mut engine = Engine::new(1);
982        let mut out = [0u8; PAYLOAD];
983        let mut frame_buf = [0u8; 8];
984
985        // Something to retain first.
986        let get_len = get(prop::CAPS, &mut frame_buf);
987        let mut payload = [0u8; PAYLOAD];
988        let len = request([1, 1], &frame_buf[..get_len], &mut payload);
989        assert!(matches!(
990            engine.begin(&ALICE, &payload[..len], 0, 0, &mut out),
991            Ingress::Dispatch(_)
992        ));
993        let mut reply_buf = [0u8; 16];
994        let reply_len = frame::prop_is(&mut reply_buf, 0, prop::CAPS, &[7]).unwrap();
995        engine
996            .complete(Produced::complete(&reply_buf[..reply_len]), &mut out)
997            .unwrap();
998
999        let mut frame_buf = [0u8; 8];
1000        for len in [
1001            frame::reset(&mut frame_buf, 0).unwrap(),
1002            frame::restore(&mut frame_buf, 0).unwrap(),
1003            frame::factory_reset(&mut frame_buf, 0).unwrap(),
1004            frame::reboot(&mut frame_buf, 0).unwrap(),
1005        ] {
1006            let mut payload = [0u8; PAYLOAD];
1007            let len = request([2, 2], &frame_buf[..len], &mut payload);
1008            let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
1009            else {
1010                panic!("expected a dispatch");
1011            };
1012            assert!(dispatch.resets);
1013            assert_eq!(engine.complete(Produced::no_response(), &mut out), Ok(None));
1014        }
1015
1016        // The earlier exchange is no longer retained, so its
1017        // retransmission is executed again.
1018        let mut payload = [0u8; PAYLOAD];
1019        let len = request([1, 1], &frame_buf[..get_len], &mut payload);
1020        assert!(matches!(
1021            engine.begin(&ALICE, &payload[..len], 0, 0, &mut out),
1022            Ingress::Dispatch(_)
1023        ));
1024    }
1025
1026    #[test]
1027    fn a_reply_that_overruns_the_budget_is_refused_rather_than_truncated() {
1028        let mut engine = Engine::new(1);
1029        let mut frame_buf = [0u8; 8];
1030        let frame_len = get(prop::CAPS, &mut frame_buf);
1031        let frame = &frame_buf[..frame_len];
1032        let mut payload = [0u8; PAYLOAD];
1033        let len = request([1, 2], frame, &mut payload);
1034
1035        let mut out = [0u8; PAYLOAD];
1036        let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
1037        else {
1038            panic!("expected a dispatch");
1039        };
1040
1041        let oversized = [0u8; PAYLOAD];
1042        assert_eq!(
1043            engine.complete(
1044                Produced::complete(&oversized[..dispatch.budget + 1]),
1045                &mut out[..PAYLOAD]
1046            ),
1047            Err(CompleteError::TooLarge)
1048        );
1049    }
1050
1051    #[test]
1052    fn a_reply_exactly_at_the_budget_fits_even_when_it_acquires_a_cursor() {
1053        let mut engine = Engine::new(1);
1054        let mut frame_buf = [0u8; 8];
1055        let frame_len = get(prop::CAPS, &mut frame_buf);
1056        let frame = &frame_buf[..frame_len];
1057        let mut payload = [0u8; PAYLOAD];
1058        let len = request([1, 2], frame, &mut payload);
1059
1060        let mut out = [0u8; PAYLOAD];
1061        let Ingress::Dispatch(dispatch) = engine.begin(&ALICE, &payload[..len], 0, 0, &mut out)
1062        else {
1063            panic!("expected a dispatch");
1064        };
1065        let full = [0u8; PAYLOAD];
1066        let len = engine
1067            .complete(
1068                Produced::fragment(&full[..dispatch.budget], 100, 200),
1069                &mut out,
1070            )
1071            .expect("the budget already allows for the worst envelope")
1072            .expect("a response");
1073        assert!(len <= PAYLOAD);
1074    }
1075}