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        if bytes[..4] != MAGIC {
120            return None;
121        }
122        if bytes[COMMIT_OFFSET..] != [0; 4] {
123            return None;
124        }
125        let crc = u32::from_le_bytes(bytes[CRC_OFFSET..COMMIT_OFFSET].try_into().ok()?);
126        if crc != record::crc32(&bytes[..CRC_OFFSET]) {
127            return None;
128        }
129        let generation = u32::from_le_bytes(bytes[4..8].try_into().ok()?);
130        let len = usize::from(u16::from_le_bytes(bytes[9..11].try_into().ok()?));
131        if len > MAX_PAYLOAD {
132            return None;
133        }
134        let record = match bytes[8] {
135            KIND_SNAPSHOT => {
136                let mut payload = heapless::Vec::new();
137                payload
138                    .extend_from_slice(&bytes[HEADER_LEN..HEADER_LEN + len])
139                    .ok()?;
140                Record::Snapshot(payload)
141            }
142            KIND_CLEARED if len == 0 => Record::Cleared,
143            _ => return None,
144        };
145        Some(Self { generation, record })
146    }
147}
148
149/// Consider one journal slot while mounting.
150pub fn consider_record(
151    current: Option<(u32, Stored)>,
152    address: u32,
153    bytes: &[u8; SLOT_SIZE],
154) -> Option<(u32, Stored)> {
155    let Some(candidate) = Stored::decode(bytes) else {
156        return current;
157    };
158    if current.as_ref().is_none_or(|(_, stored)| {
159        record::generation_is_newer(candidate.generation, stored.generation)
160    }) {
161        Some((address, candidate))
162    } else {
163        current
164    }
165}
166
167/// Consider one journal slot while looking for the newest committed
168/// record strictly older than `newer_than`.
169///
170/// This is how a boot walks back after the layer above rejects a
171/// record's *payload*. [`consider_record`] recovers from a corrupt or
172/// uncommitted record, but a record whose CRC is fine and whose contents
173/// the session refuses would otherwise take the device to a bare boot
174/// while a readable older generation sits in the journal.
175///
176/// Re-scanning rather than retaining a runner-up during the first mount
177/// keeps that path's "never buffers a second copy" discipline intact;
178/// the cost is paid only on a boot that is already going wrong.
179pub fn consider_older_record(
180    current: Option<(u32, Stored)>,
181    address: u32,
182    bytes: &[u8; SLOT_SIZE],
183    newer_than: u32,
184) -> Option<(u32, Stored)> {
185    let Some(candidate) = Stored::decode(bytes) else {
186        return current;
187    };
188    if !record::generation_is_newer(newer_than, candidate.generation) {
189        return current;
190    }
191    if current.as_ref().is_none_or(|(_, stored)| {
192        record::generation_is_newer(candidate.generation, stored.generation)
193    }) {
194        Some((address, candidate))
195    } else {
196        current
197    }
198}
199
200/// Write one committed record. Failure leaves any previously committed
201/// record untouched: the body lands first and the commit word last, so
202/// a mount never selects a partial write.
203pub async fn write_record<W: RecordWriter>(
204    writer: &mut W,
205    target: u32,
206    generation: u32,
207    record: RecordRef<'_>,
208) -> Result<(), CommitError<W::Error>> {
209    let bytes = encode_record(generation, record);
210    crate::record::write_committed_record(writer, target, &bytes).await
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::record::{PAGE_SIZE, PageEraser};
217    use core::future::Future;
218    use core::task::{Context, Poll, Waker};
219
220    /// Test-only journal addresses (production addresses are the
221    /// firmware's memory-map decision).
222    const PAGE0: u32 = 0x000E_8000;
223    const PAGE1: u32 = PAGE0 + PAGE_SIZE;
224
225    fn block_on<F: Future>(future: F) -> F::Output {
226        let mut future = core::pin::pin!(future);
227        let waker = Waker::noop();
228        let mut context = Context::from_waker(&waker);
229        loop {
230            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
231                return output;
232            }
233        }
234    }
235
236    /// Mock flash that persists into a byte map and can cut power after
237    /// a byte budget.
238    struct MockFlash {
239        bytes: std::collections::BTreeMap<u32, u8>,
240        budget: Option<usize>,
241    }
242
243    impl MockFlash {
244        fn new() -> Self {
245            Self {
246                bytes: std::collections::BTreeMap::new(),
247                budget: None,
248            }
249        }
250
251        fn slot(&self, address: u32) -> [u8; SLOT_SIZE] {
252            let mut out = [0xFFu8; SLOT_SIZE];
253            for (offset, byte) in out.iter_mut().enumerate() {
254                if let Some(value) = self.bytes.get(&(address + offset as u32)) {
255                    *byte = *value;
256                }
257            }
258            out
259        }
260
261        fn mount(&self) -> Option<(u32, Stored)> {
262            let mut latest = None;
263            for page in [PAGE0, PAGE1] {
264                let mut address = page;
265                while address < page + PAGE_SIZE {
266                    latest = consider_record(latest, address, &self.slot(address));
267                    address += SLOT_SIZE as u32;
268                }
269            }
270            latest
271        }
272
273        /// The snapshot payload a boot would restore: the newest valid
274        /// record when it is a snapshot, nothing when it is a
275        /// tombstone.
276        fn mounted_snapshot(&self) -> Option<Stored> {
277            match self.mount() {
278                Some((_, stored)) if matches!(stored.record, Record::Snapshot(_)) => Some(stored),
279                _ => None,
280            }
281        }
282    }
283
284    impl RecordWriter for MockFlash {
285        type Error = ();
286
287        async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
288            for (offset, byte) in bytes.iter().enumerate() {
289                if let Some(budget) = &mut self.budget {
290                    if *budget == 0 {
291                        return Err(());
292                    }
293                    *budget -= 1;
294                }
295                self.bytes.insert(address + offset as u32, *byte);
296            }
297            Ok(())
298        }
299    }
300
301    impl PageEraser for MockFlash {
302        type Error = ();
303
304        async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
305            self.bytes
306                .retain(|address, _| *address < start || *address >= end);
307            Ok(())
308        }
309    }
310
311    fn record(generation: u32, fill: u8, len: usize) -> Stored {
312        let mut payload = heapless::Vec::new();
313        payload.resize(len, fill).unwrap();
314        Stored {
315            generation,
316            record: Record::Snapshot(payload),
317        }
318    }
319
320    fn tombstone(generation: u32) -> Stored {
321        Stored {
322            generation,
323            record: Record::Cleared,
324        }
325    }
326
327    /// Test-side wrapper keeping the owned-`Stored` write shape the
328    /// tests were written against (the production path takes a
329    /// [`RecordRef`]; this local definition shadows the glob import).
330    async fn write_record<W: RecordWriter>(
331        writer: &mut W,
332        target: u32,
333        stored: &Stored,
334    ) -> Result<(), CommitError<W::Error>> {
335        let record = match &stored.record {
336            Record::Snapshot(payload) => RecordRef::Snapshot(payload),
337            Record::Cleared => RecordRef::Cleared,
338        };
339        super::write_record(writer, target, stored.generation, record).await
340    }
341
342    #[test]
343    fn committed_record_round_trips_and_newest_generation_wins() {
344        let mut flash = MockFlash::new();
345        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 900))).unwrap();
346        block_on(write_record(
347            &mut flash,
348            PAGE0 + SLOT_SIZE as u32,
349            &record(2, 0xBB, 3),
350        ))
351        .unwrap();
352        let (address, mounted) = flash.mount().unwrap();
353        assert_eq!(address, PAGE0 + SLOT_SIZE as u32);
354        assert_eq!(mounted, record(2, 0xBB, 3));
355    }
356
357    #[test]
358    fn uncommitted_corrupt_and_oversize_records_are_ignored() {
359        let mut flash = MockFlash::new();
360        // Body without commit word.
361        let bytes = record(1, 0xAA, 16).encode();
362        block_on(RecordWriter::write_record(
363            &mut flash,
364            PAGE0,
365            &bytes[..COMMIT_OFFSET],
366        ))
367        .unwrap();
368        assert!(flash.mount().is_none());
369
370        // Committed but corrupted body.
371        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 16))).unwrap();
372        flash.bytes.insert(PAGE0 + 12, 0x00);
373        assert!(flash.mount().is_none());
374
375        // A length field beyond capacity, and an unknown record kind.
376        for (offset, bad) in [(9u32, (MAX_PAYLOAD as u16 + 1).to_le_bytes()), (8, [2, 0])] {
377            let mut bytes = record(1, 0xAA, 16).encode();
378            let at = offset as usize;
379            bytes[at..at + 2].copy_from_slice(&bad);
380            let crc = crate::record::crc32(&bytes[..CRC_OFFSET]);
381            bytes[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
382            let mut flash = MockFlash::new();
383            block_on(RecordWriter::write_record(
384                &mut flash,
385                PAGE1,
386                &bytes[..COMMIT_OFFSET],
387            ))
388            .unwrap();
389            block_on(RecordWriter::write_record(
390                &mut flash,
391                PAGE1 + COMMIT_OFFSET as u32,
392                &[0; 4],
393            ))
394            .unwrap();
395            assert!(flash.mount().is_none());
396        }
397    }
398
399    /// A committed tombstone is the clear transaction: it voids every
400    /// older snapshot on either page without any erase, and an
401    /// interrupted tombstone write leaves the previous snapshot
402    /// authoritative.
403    #[test]
404    fn tombstone_clears_and_survives_interruption() {
405        // Old snapshots on both pages, newest on PAGE1.
406        let mut flash = MockFlash::new();
407        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
408        block_on(write_record(&mut flash, PAGE1, &record(2, 0xBB, 40))).unwrap();
409        assert_eq!(flash.mounted_snapshot().unwrap(), record(2, 0xBB, 40));
410
411        // Cut the tombstone write at every distinct byte boundary: the
412        // newest snapshot must remain authoritative — never the older
413        // one. Every header byte, the CRC and commit regions, and
414        // samples of the 0xFF-filled body (where all cuts are
415        // physically identical: writing 0xFF to erased flash changes
416        // nothing).
417        let total = COMMIT_OFFSET + 4;
418        for cut in (0..=HEADER_LEN + 1)
419            .chain((HEADER_LEN..CRC_OFFSET).step_by(89))
420            .chain(CRC_OFFSET - 1..total)
421        {
422            let mut flash = flash_with_two_snapshots();
423            flash.budget = Some(cut);
424            let target = PAGE0 + SLOT_SIZE as u32;
425            assert!(block_on(write_record(&mut flash, target, &tombstone(3))).is_err());
426            flash.budget = None;
427            assert_eq!(
428                flash.mounted_snapshot().expect("snapshot must survive"),
429                record(2, 0xBB, 40),
430                "cut at {cut} lost or replaced the committed snapshot"
431            );
432        }
433
434        // The committed tombstone mounts as no snapshot, with both
435        // older snapshot records still physically present.
436        let mut flash = flash_with_two_snapshots();
437        block_on(write_record(
438            &mut flash,
439            PAGE0 + SLOT_SIZE as u32,
440            &tombstone(3),
441        ))
442        .unwrap();
443        assert!(flash.mounted_snapshot().is_none());
444        assert_eq!(flash.mount().unwrap().1, tombstone(3));
445
446        // Clearing again is idempotent, and a later save supersedes.
447        block_on(write_record(
448            &mut flash,
449            PAGE1 + SLOT_SIZE as u32,
450            &tombstone(4),
451        ))
452        .unwrap();
453        assert!(flash.mounted_snapshot().is_none());
454        block_on(write_record(&mut flash, PAGE0, &record(5, 0xCC, 8))).unwrap();
455        assert_eq!(flash.mounted_snapshot().unwrap(), record(5, 0xCC, 8));
456    }
457
458    fn flash_with_two_snapshots() -> MockFlash {
459        let mut flash = MockFlash::new();
460        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
461        block_on(write_record(&mut flash, PAGE1, &record(2, 0xBB, 40))).unwrap();
462        flash
463    }
464
465    /// A payload the layer above refuses does not have to mean a bare
466    /// boot: the walk-back finds the newest committed record older than
467    /// the rejected one, skipping tombstones' generation entirely and
468    /// stopping when nothing older remains.
469    #[test]
470    fn walk_back_finds_successively_older_committed_records() {
471        let mut flash = MockFlash::new();
472        block_on(write_record(&mut flash, PAGE0, &record(1, 0xAA, 40))).unwrap();
473        block_on(write_record(
474            &mut flash,
475            PAGE0 + SLOT_SIZE as u32,
476            &record(2, 0xBB, 40),
477        ))
478        .unwrap();
479        block_on(write_record(&mut flash, PAGE1, &record(3, 0xCC, 40))).unwrap();
480
481        let older = |newer_than: u32| {
482            let mut latest = None;
483            for page in [PAGE0, PAGE1] {
484                let mut address = page;
485                while address < page + PAGE_SIZE {
486                    latest =
487                        consider_older_record(latest, address, &flash.slot(address), newer_than);
488                    address += SLOT_SIZE as u32;
489                }
490            }
491            latest.map(|(_, stored)| stored)
492        };
493
494        assert_eq!(flash.mount().unwrap().1, record(3, 0xCC, 40));
495        assert_eq!(older(3), Some(record(2, 0xBB, 40)));
496        assert_eq!(older(2), Some(record(1, 0xAA, 40)));
497        assert_eq!(older(1), None);
498    }
499
500    #[test]
501    fn identity_payload_round_trips() {
502        let payload = encode_identity(&[0x11; 32], &[0x22; 32]);
503        assert_eq!(decode_identity(&payload), Some(([0x11; 32], [0x22; 32])));
504        assert_eq!(decode_identity(&payload[..63]), None);
505        assert_eq!(decode_identity(&[]), None);
506    }
507
508    /// Generation comparison survives wraparound: a record numbered 0
509    /// supersedes one numbered u32::MAX.
510    #[test]
511    fn generation_wraparound_selects_the_newer_record() {
512        let mut flash = MockFlash::new();
513        block_on(write_record(&mut flash, PAGE0, &record(u32::MAX, 0xAA, 8))).unwrap();
514        block_on(write_record(
515            &mut flash,
516            PAGE0 + SLOT_SIZE as u32,
517            &tombstone(0),
518        ))
519        .unwrap();
520        assert!(flash.mounted_snapshot().is_none());
521        assert_eq!(flash.mount().unwrap().1, tombstone(0));
522    }
523
524    /// Cut the write at every byte boundary: a mount afterwards always
525    /// yields the previously committed record, never a mixture.
526    #[test]
527    fn power_cut_at_every_byte_never_replaces_the_committed_record() {
528        let old = record(7, 0x11, 700);
529        let new = record(8, 0x22, 700);
530        // Total bytes a full record write issues (body + commit word).
531        let total = COMMIT_OFFSET + 4;
532        for cut in 0..total {
533            let mut flash = MockFlash::new();
534            block_on(write_record(&mut flash, PAGE0, &old)).unwrap();
535            flash.budget = Some(cut);
536            let result = block_on(write_record(&mut flash, PAGE0 + SLOT_SIZE as u32, &new));
537            assert!(result.is_err(), "cut at {cut} must fail the write");
538            flash.budget = None;
539            let (_, mounted) = flash.mount().expect("old record must survive");
540            assert_eq!(mounted, old, "cut at {cut} corrupted the mount");
541        }
542        // And the complete write wins.
543        let mut flash = MockFlash::new();
544        block_on(write_record(&mut flash, PAGE0, &old)).unwrap();
545        block_on(write_record(&mut flash, PAGE0 + SLOT_SIZE as u32, &new)).unwrap();
546        assert_eq!(flash.mount().unwrap().1, new);
547    }
548}