umsh_journal_store/
proto.rs

1//! Full-protocol snapshot journal records (spec §Saved State).
2//!
3//! Persists the opaque snapshot payload produced by
4//! `Session::encode_snapshot` — the journal knows nothing about its
5//! contents. Deliberately separate from the BLE bond/PIN journal
6//! ([`ble`](crate::ble)): the two have different lifecycles (`CMD_CLEAR`
7//! erases this journal but never touches bonds or the pairing PIN) and
8//! different record sizes. The record machinery — two-page rotation,
9//! CRC over the body, a trailing commit word written last, newest
10//! generation wins — is the shared [`record`](crate::record) engine.
11//!
12//! The same record format also carries the device identity and the
13//! device node's frame-counter map ([`counter`](crate::counter)), each
14//! in a journal of its own; which pages each journal owns is the
15//! firmware's memory-map decision.
16
17use crate::record::{self, CommitError, RecordWriter};
18
19/// A device-identity record payload: the Ed25519 private key followed
20/// by its public key (stored so boot does not repeat the derivation).
21pub const IDENTITY_PAYLOAD_LEN: usize = 64;
22
23pub fn encode_identity(secret: &[u8; 32], public: &[u8; 32]) -> [u8; IDENTITY_PAYLOAD_LEN] {
24    let mut payload = [0u8; IDENTITY_PAYLOAD_LEN];
25    payload[..32].copy_from_slice(secret);
26    payload[32..].copy_from_slice(public);
27    payload
28}
29
30/// Split a persisted identity payload into (secret, public); anything
31/// but the exact expected length is treated as no identity.
32pub fn decode_identity(payload: &[u8]) -> Option<([u8; 32], [u8; 32])> {
33    if payload.len() != IDENTITY_PAYLOAD_LEN {
34        return None;
35    }
36    Some((
37        payload[..32].try_into().expect("length checked"),
38        payload[32..].try_into().expect("length checked"),
39    ))
40}
41
42/// Two records per page; the snapshot payload is bounded by
43/// `umsh_ulcp_device::SNAPSHOT_MAX` (1792) with headroom.
44pub const SLOT_SIZE: usize = 2048;
45pub const COMMIT_OFFSET: usize = SLOT_SIZE - 4;
46const CRC_OFFSET: usize = COMMIT_OFFSET - 4;
47const MAGIC: [u8; 4] = *b"UPRS";
48const KIND_SNAPSHOT: u8 = 0;
49const KIND_CLEARED: u8 = 1;
50const HEADER_LEN: usize = 4 + 4 + 1 + 2;
51/// Largest payload a record can carry.
52pub const MAX_PAYLOAD: usize = CRC_OFFSET - HEADER_LEN;
53
54/// What a journal record asserts about the saved protocol state.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum Record {
57    /// A saved snapshot with its opaque payload.
58    Snapshot(heapless::Vec<u8, MAX_PAYLOAD>),
59    /// A committed `CMD_CLEAR`: nothing is saved, and any older
60    /// snapshot records still physically present are void. Erasing
61    /// pages is never the clear transaction — a single committed
62    /// tombstone is, so an interrupted clear can never resurrect an
63    /// older snapshot from a surviving page.
64    Cleared,
65}
66
67/// Borrowed form of [`Record`] for the write path: persist callers pass
68/// their payload by reference so the record machinery never buffers a
69/// second copy (the write path's task futures hold these across awaits,
70/// so every avoided `MAX_PAYLOAD` copy is RAM off a task pool).
71#[derive(Clone, Copy, Debug)]
72pub enum RecordRef<'a> {
73    Snapshot(&'a [u8]),
74    Cleared,
75}
76
77/// One journal record with its monotonically increasing generation;
78/// the newest valid record is authoritative.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct Stored {
81    pub generation: u32,
82    pub record: Record,
83}
84
85/// Encode one record body into a slot image. The payload must fit
86/// `MAX_PAYLOAD`; the commit word stays erased (0xFF) —
87/// `write_committed_record` writes zeros there only after the body
88/// lands.
89pub fn encode_record(generation: u32, record: RecordRef<'_>) -> [u8; SLOT_SIZE] {
90    let mut bytes = [0xFFu8; SLOT_SIZE];
91    bytes[..4].copy_from_slice(&MAGIC);
92    bytes[4..8].copy_from_slice(&generation.to_le_bytes());
93    let (kind, payload): (u8, &[u8]) = match record {
94        RecordRef::Snapshot(payload) => (KIND_SNAPSHOT, payload),
95        RecordRef::Cleared => (KIND_CLEARED, &[]),
96    };
97    bytes[8] = kind;
98    bytes[9..11].copy_from_slice(&(payload.len() as u16).to_le_bytes());
99    bytes[HEADER_LEN..HEADER_LEN + payload.len()].copy_from_slice(payload);
100    let crc = record::crc32(&bytes[..CRC_OFFSET]);
101    bytes[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
102    bytes
103}
104
105impl Stored {
106    /// Owned-record slot image, kept for the host tests; the production
107    /// write path encodes through [`RecordRef`] without owning the
108    /// payload.
109    #[cfg(test)]
110    pub fn encode(&self) -> [u8; SLOT_SIZE] {
111        let record = match &self.record {
112            Record::Snapshot(payload) => RecordRef::Snapshot(payload),
113            Record::Cleared => RecordRef::Cleared,
114        };
115        encode_record(self.generation, record)
116    }
117
118    pub fn decode(bytes: &[u8; SLOT_SIZE]) -> Option<Self> {
119        let generation = probe_record(bytes)?;
120        let len = usize::from(u16::from_le_bytes(bytes[9..11].try_into().ok()?));
121        let record = match bytes[8] {
122            KIND_SNAPSHOT => {
123                let mut payload = heapless::Vec::new();
124                payload
125                    .extend_from_slice(&bytes[HEADER_LEN..HEADER_LEN + len])
126                    .ok()?;
127                Record::Snapshot(payload)
128            }
129            // `probe_record` admits no other kind.
130            _ => Record::Cleared,
131        };
132        Some(Self { generation, record })
133    }
134}
135
136/// Validate one slot image — magic, commit word, CRC, kind, length —
137/// and return its generation, without touching the payload.
138///
139/// Mount scans run on this: a scan's working set stays one slot buffer
140/// plus the eight-byte candidate, and the winning slot is decoded
141/// exactly once after the scan settles. Passing whole [`Stored`] values
142/// through a scan costs a `MAX_PAYLOAD` copy per step, and on the
143/// embedded boot path those copies land on the one stack every task
144/// shares.
145pub fn probe_record(bytes: &[u8; SLOT_SIZE]) -> Option<u32> {
146    if bytes[..4] != MAGIC {
147        return None;
148    }
149    if bytes[COMMIT_OFFSET..] != [0; 4] {
150        return None;
151    }
152    let crc = u32::from_le_bytes(bytes[CRC_OFFSET..COMMIT_OFFSET].try_into().ok()?);
153    if crc != record::crc32(&bytes[..CRC_OFFSET]) {
154        return None;
155    }
156    let len = usize::from(u16::from_le_bytes(bytes[9..11].try_into().ok()?));
157    if len > MAX_PAYLOAD {
158        return None;
159    }
160    match bytes[8] {
161        KIND_SNAPSHOT => {}
162        KIND_CLEARED if len == 0 => {}
163        _ => return None,
164    }
165    Some(u32::from_le_bytes(bytes[4..8].try_into().ok()?))
166}
167
168/// Borrow a validated slot's committed payload in place: its generation,
169/// and the payload bytes when the record is a snapshot rather than a
170/// tombstone.
171///
172/// The mount path reads the winning slot through this rather than
173/// [`Stored::decode`] so the payload is copied exactly once, into
174/// whatever the caller is returning, instead of landing first in a
175/// `MAX_PAYLOAD` record on the way there.
176pub fn payload_bytes(bytes: &[u8; SLOT_SIZE]) -> Option<(u32, Option<&[u8]>)> {
177    let generation = probe_record(bytes)?;
178    let len = usize::from(u16::from_le_bytes(bytes[9..11].try_into().ok()?));
179    let payload = match bytes[8] {
180        KIND_SNAPSHOT => Some(&bytes[HEADER_LEN..HEADER_LEN + len]),
181        // `probe_record` admits no kind but a tombstone here.
182        _ => None,
183    };
184    Some((generation, payload))
185}
186
187/// Consider one journal slot while mounting: keep the newest committed
188/// slot's address and generation. The payload stays in flash; the
189/// caller decodes the winner once the scan settles.
190pub fn consider_slot(current: &mut Option<(u32, u32)>, address: u32, bytes: &[u8; SLOT_SIZE]) {
191    let Some(generation) = probe_record(bytes) else {
192        return;
193    };
194    if current.is_none_or(|(_, newest)| record::generation_is_newer(generation, newest)) {
195        *current = Some((address, generation));
196    }
197}
198
199/// Consider one journal slot while looking for the newest committed
200/// record strictly older than `newer_than`.
201///
202/// This is how a boot walks back after the layer above rejects a
203/// record's *payload*. [`consider_slot`] recovers from a corrupt or
204/// uncommitted record, but a record whose CRC is fine and whose contents
205/// the session refuses would otherwise take the device to a bare boot
206/// while a readable older generation sits in the journal.
207///
208/// Re-scanning rather than retaining a runner-up during the first mount
209/// keeps that path's "never buffers a second copy" discipline intact;
210/// the cost is paid only on a boot that is already going wrong.
211pub fn consider_older_slot(
212    current: &mut Option<(u32, u32)>,
213    address: u32,
214    bytes: &[u8; SLOT_SIZE],
215    newer_than: u32,
216) {
217    let Some(generation) = probe_record(bytes) else {
218        return;
219    };
220    if !record::generation_is_newer(newer_than, generation) {
221        return;
222    }
223    if current.is_none_or(|(_, newest)| record::generation_is_newer(generation, newest)) {
224        *current = Some((address, generation));
225    }
226}
227
228/// Write one committed record. Failure leaves any previously committed
229/// record untouched: the body lands first and the commit word last, so
230/// a mount never selects a partial write.
231pub async fn write_record<W: RecordWriter>(
232    writer: &mut W,
233    target: u32,
234    generation: u32,
235    record: RecordRef<'_>,
236) -> Result<(), CommitError<W::Error>> {
237    let bytes = encode_record(generation, record);
238    crate::record::write_committed_record(writer, target, &bytes).await
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::record::{PAGE_SIZE, PageEraser};
245    use core::future::Future;
246    use core::task::{Context, Poll, Waker};
247
248    /// Test-only journal addresses (production addresses are the
249    /// firmware's memory-map decision).
250    const PAGE0: u32 = 0x000E_8000;
251    const PAGE1: u32 = PAGE0 + PAGE_SIZE;
252
253    fn block_on<F: Future>(future: F) -> F::Output {
254        let mut future = core::pin::pin!(future);
255        let waker = Waker::noop();
256        let mut context = Context::from_waker(&waker);
257        loop {
258            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
259                return output;
260            }
261        }
262    }
263
264    /// Mock flash that persists into a byte map and can cut power after
265    /// a byte budget.
266    struct MockFlash {
267        bytes: std::collections::BTreeMap<u32, u8>,
268        budget: Option<usize>,
269    }
270
271    impl MockFlash {
272        fn new() -> Self {
273            Self {
274                bytes: std::collections::BTreeMap::new(),
275                budget: None,
276            }
277        }
278
279        fn slot(&self, address: u32) -> [u8; SLOT_SIZE] {
280            let mut out = [0xFFu8; SLOT_SIZE];
281            for (offset, byte) in out.iter_mut().enumerate() {
282                if let Some(value) = self.bytes.get(&(address + offset as u32)) {
283                    *byte = *value;
284                }
285            }
286            out
287        }
288
289        fn mount(&self) -> Option<(u32, Stored)> {
290            let mut latest = None;
291            for page in [PAGE0, PAGE1] {
292                let mut address = page;
293                while address < page + PAGE_SIZE {
294                    consider_slot(&mut latest, address, &self.slot(address));
295                    address += SLOT_SIZE as u32;
296                }
297            }
298            let (slot, _) = latest?;
299            Stored::decode(&self.slot(slot)).map(|stored| (slot, stored))
300        }
301
302        /// The snapshot payload a boot would restore: the newest valid
303        /// record when it is a snapshot, nothing when it is a
304        /// tombstone.
305        fn mounted_snapshot(&self) -> Option<Stored> {
306            match self.mount() {
307                Some((_, stored)) if matches!(stored.record, Record::Snapshot(_)) => Some(stored),
308                _ => None,
309            }
310        }
311    }
312
313    impl RecordWriter for MockFlash {
314        type Error = ();
315
316        async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
317            for (offset, byte) in bytes.iter().enumerate() {
318                if let Some(budget) = &mut self.budget {
319                    if *budget == 0 {
320                        return Err(());
321                    }
322                    *budget -= 1;
323                }
324                self.bytes.insert(address + offset as u32, *byte);
325            }
326            Ok(())
327        }
328    }
329
330    impl PageEraser for MockFlash {
331        type Error = ();
332
333        async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
334            self.bytes
335                .retain(|address, _| *address < start || *address >= end);
336            Ok(())
337        }
338    }
339
340    fn record(generation: u32, fill: u8, len: usize) -> Stored {
341        let mut payload = heapless::Vec::new();
342        payload.resize(len, fill).unwrap();
343        Stored {
344            generation,
345            record: Record::Snapshot(payload),
346        }
347    }
348
349    fn tombstone(generation: u32) -> Stored {
350        Stored {
351            generation,
352            record: Record::Cleared,
353        }
354    }
355
356    /// Test-side wrapper keeping the owned-`Stored` write shape the
357    /// tests were written against (the production path takes a
358    /// [`RecordRef`]; this local definition shadows the glob import).
359    async fn write_record<W: RecordWriter>(
360        writer: &mut W,
361        target: u32,
362        stored: &Stored,
363    ) -> Result<(), CommitError<W::Error>> {
364        let record = match &stored.record {
365            Record::Snapshot(payload) => RecordRef::Snapshot(payload),
366            Record::Cleared => RecordRef::Cleared,
367        };
368        super::write_record(writer, target, stored.generation, record).await
369    }
370
371    #[test]
372    fn committed_record_round_trips_and_newest_generation_wins() {
373        let mut flash = MockFlash::new();
374        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 900))).unwrap();
375        block_on(write_record(
376            &mut flash,
377            PAGE0 + SLOT_SIZE as u32,
378            &record(2, 0xBB, 3),
379        ))
380        .unwrap();
381        let (address, mounted) = flash.mount().unwrap();
382        assert_eq!(address, PAGE0 + SLOT_SIZE as u32);
383        assert_eq!(mounted, record(2, 0xBB, 3));
384    }
385
386    #[test]
387    fn uncommitted_corrupt_and_oversize_records_are_ignored() {
388        let mut flash = MockFlash::new();
389        // Body without commit word.
390        let bytes = record(1, 0xAA, 16).encode();
391        block_on(RecordWriter::write_record(
392            &mut flash,
393            PAGE0,
394            &bytes[..COMMIT_OFFSET],
395        ))
396        .unwrap();
397        assert!(flash.mount().is_none());
398
399        // Committed but corrupted body.
400        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 16))).unwrap();
401        flash.bytes.insert(PAGE0 + 12, 0x00);
402        assert!(flash.mount().is_none());
403
404        // A length field beyond capacity, and an unknown record kind.
405        for (offset, bad) in [(9u32, (MAX_PAYLOAD as u16 + 1).to_le_bytes()), (8, [2, 0])] {
406            let mut bytes = record(1, 0xAA, 16).encode();
407            let at = offset as usize;
408            bytes[at..at + 2].copy_from_slice(&bad);
409            let crc = crate::record::crc32(&bytes[..CRC_OFFSET]);
410            bytes[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
411            let mut flash = MockFlash::new();
412            block_on(RecordWriter::write_record(
413                &mut flash,
414                PAGE1,
415                &bytes[..COMMIT_OFFSET],
416            ))
417            .unwrap();
418            block_on(RecordWriter::write_record(
419                &mut flash,
420                PAGE1 + COMMIT_OFFSET as u32,
421                &[0; 4],
422            ))
423            .unwrap();
424            assert!(flash.mount().is_none());
425        }
426    }
427
428    /// A committed tombstone is the clear transaction: it voids every
429    /// older snapshot on either page without any erase, and an
430    /// interrupted tombstone write leaves the previous snapshot
431    /// authoritative.
432    #[test]
433    fn tombstone_clears_and_survives_interruption() {
434        // Old snapshots on both pages, newest on PAGE1.
435        let mut flash = MockFlash::new();
436        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
437        block_on(write_record(&mut flash, PAGE1, &record(2, 0xBB, 40))).unwrap();
438        assert_eq!(flash.mounted_snapshot().unwrap(), record(2, 0xBB, 40));
439
440        // Cut the tombstone write at every distinct byte boundary: the
441        // newest snapshot must remain authoritative — never the older
442        // one. Every header byte, the CRC and commit regions, and
443        // samples of the 0xFF-filled body (where all cuts are
444        // physically identical: writing 0xFF to erased flash changes
445        // nothing).
446        let total = COMMIT_OFFSET + 4;
447        for cut in (0..=HEADER_LEN + 1)
448            .chain((HEADER_LEN..CRC_OFFSET).step_by(89))
449            .chain(CRC_OFFSET - 1..total)
450        {
451            let mut flash = flash_with_two_snapshots();
452            flash.budget = Some(cut);
453            let target = PAGE0 + SLOT_SIZE as u32;
454            assert!(block_on(write_record(&mut flash, target, &tombstone(3))).is_err());
455            flash.budget = None;
456            assert_eq!(
457                flash.mounted_snapshot().expect("snapshot must survive"),
458                record(2, 0xBB, 40),
459                "cut at {cut} lost or replaced the committed snapshot"
460            );
461        }
462
463        // The committed tombstone mounts as no snapshot, with both
464        // older snapshot records still physically present.
465        let mut flash = flash_with_two_snapshots();
466        block_on(write_record(
467            &mut flash,
468            PAGE0 + SLOT_SIZE as u32,
469            &tombstone(3),
470        ))
471        .unwrap();
472        assert!(flash.mounted_snapshot().is_none());
473        assert_eq!(flash.mount().unwrap().1, tombstone(3));
474
475        // Clearing again is idempotent, and a later save supersedes.
476        block_on(write_record(
477            &mut flash,
478            PAGE1 + SLOT_SIZE as u32,
479            &tombstone(4),
480        ))
481        .unwrap();
482        assert!(flash.mounted_snapshot().is_none());
483        block_on(write_record(&mut flash, PAGE0, &record(5, 0xCC, 8))).unwrap();
484        assert_eq!(flash.mounted_snapshot().unwrap(), record(5, 0xCC, 8));
485    }
486
487    fn flash_with_two_snapshots() -> MockFlash {
488        let mut flash = MockFlash::new();
489        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
490        block_on(write_record(&mut flash, PAGE1, &record(2, 0xBB, 40))).unwrap();
491        flash
492    }
493
494    /// A payload the layer above refuses does not have to mean a bare
495    /// boot: the walk-back finds the newest committed record older than
496    /// the rejected one, skipping tombstones' generation entirely and
497    /// stopping when nothing older remains.
498    #[test]
499    fn walk_back_finds_successively_older_committed_records() {
500        let mut flash = MockFlash::new();
501        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
502        block_on(write_record(
503            &mut flash,
504            PAGE0 + SLOT_SIZE as u32,
505            &record(2, 0xBB, 40),
506        ))
507        .unwrap();
508        block_on(write_record(&mut flash, PAGE1, &record(3, 0xCC, 40))).unwrap();
509
510        let older = |newer_than: u32| {
511            let mut latest = None;
512            for page in [PAGE0, PAGE1] {
513                let mut address = page;
514                while address < page + PAGE_SIZE {
515                    consider_older_slot(&mut latest, address, &flash.slot(address), newer_than);
516                    address += SLOT_SIZE as u32;
517                }
518            }
519            let (slot, _) = latest?;
520            Stored::decode(&flash.slot(slot))
521        };
522
523        assert_eq!(flash.mount().unwrap().1, record(3, 0xCC, 40));
524        assert_eq!(older(3), Some(record(2, 0xBB, 40)));
525        assert_eq!(older(2), Some(record(1, 0xAA, 40)));
526        assert_eq!(older(1), None);
527    }
528
529    #[test]
530    fn identity_payload_round_trips() {
531        let payload = encode_identity(&[0x11; 32], &[0x22; 32]);
532        assert_eq!(decode_identity(&payload), Some(([0x11; 32], [0x22; 32])));
533        assert_eq!(decode_identity(&payload[..63]), None);
534        assert_eq!(decode_identity(&[]), None);
535    }
536
537    /// Generation comparison survives wraparound: a record numbered 0
538    /// supersedes one numbered u32::MAX.
539    #[test]
540    fn generation_wraparound_selects_the_newer_record() {
541        let mut flash = MockFlash::new();
542        block_on(write_record(&mut flash, PAGE0, &record(u32::MAX, 0xAA, 8))).unwrap();
543        block_on(write_record(
544            &mut flash,
545            PAGE0 + SLOT_SIZE as u32,
546            &tombstone(0),
547        ))
548        .unwrap();
549        assert!(flash.mounted_snapshot().is_none());
550        assert_eq!(flash.mount().unwrap().1, tombstone(0));
551    }
552
553    /// Cut the write at every byte boundary: a mount afterwards always
554    /// yields the previously committed record, never a mixture.
555    #[test]
556    fn power_cut_at_every_byte_never_replaces_the_committed_record() {
557        let old = record(7, 0x11, 700);
558        let new = record(8, 0x22, 700);
559        // Total bytes a full record write issues (body + commit word).
560        let total = COMMIT_OFFSET + 4;
561        for cut in 0..total {
562            let mut flash = MockFlash::new();
563            block_on(write_record(&mut flash, PAGE0, &old)).unwrap();
564            flash.budget = Some(cut);
565            let result = block_on(write_record(&mut flash, PAGE0 + SLOT_SIZE as u32, &new));
566            assert!(result.is_err(), "cut at {cut} must fail the write");
567            flash.budget = None;
568            let (_, mounted) = flash.mount().expect("old record must survive");
569            assert_eq!(mounted, old, "cut at {cut} corrupted the mount");
570        }
571        // And the complete write wins.
572        let mut flash = MockFlash::new();
573        block_on(write_record(&mut flash, PAGE0, &old)).unwrap();
574        block_on(write_record(&mut flash, PAGE0 + SLOT_SIZE as u32, &new)).unwrap();
575        assert_eq!(flash.mount().unwrap().1, new);
576    }
577}