umsh_ulcp_runtime/
wifi_journal.rs

1//! Full-page WiFi snapshots sharing the existing two-page journal reservation.
2//! Mount accepts legacy half-page records. The first new commit goes to the
3//! opposite page, so an interrupted upgrade cannot erase the saved configuration.
4use crate::journal::{JournalFlash, SharedFlash, journal_write_target};
5use embassy_sync::blocking_mutex::raw::RawMutex;
6use umsh_journal_store::{
7    proto,
8    record::{self, PAGE_SIZE},
9};
10
11pub const SLOT_SIZE: usize = PAGE_SIZE as usize;
12/// Largest payload a full-page record carries.
13pub const MAX_PAYLOAD: usize = proto::max_payload(SLOT_SIZE);
14pub type BootPayload = heapless::Vec<u8, MAX_PAYLOAD>;
15
16pub struct WifiStore<M: RawMutex + 'static, F: JournalFlash + 'static> {
17    flash: &'static SharedFlash<M, F>,
18    page0: u32,
19    generation: u32,
20    slot: Option<u32>,
21    walked_back_to: u32,
22}
23
24#[cfg(test)]
25mod tests {
26    use super::*;
27    use embassy_sync::{blocking_mutex::raw::NoopRawMutex, mutex::Mutex};
28    use record::{PageEraser, RecordReader, RecordWriter};
29    use std::{boxed::Box, vec, vec::Vec};
30
31    struct Flash {
32        bytes: Vec<u8>,
33        budget: usize,
34    }
35    impl RecordReader for Flash {
36        type Error = ();
37        fn read_record(&mut self, address: u32, out: &mut [u8]) -> Result<(), ()> {
38            out.copy_from_slice(&self.bytes[address as usize..address as usize + out.len()]);
39            Ok(())
40        }
41    }
42    impl RecordWriter for Flash {
43        type Error = ();
44        async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), ()> {
45            for (i, byte) in bytes.iter().enumerate() {
46                if self.budget == 0 {
47                    return Err(());
48                }
49                self.budget -= 1;
50                self.bytes[address as usize + i] &= byte;
51            }
52            Ok(())
53        }
54    }
55    impl PageEraser for Flash {
56        type Error = ();
57        async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), ()> {
58            self.bytes[start as usize..end as usize].fill(255);
59            Ok(())
60        }
61    }
62    fn run<T>(future: impl core::future::Future<Output = T>) -> T {
63        let mut future = core::pin::pin!(future);
64        let mut cx = core::task::Context::from_waker(core::task::Waker::noop());
65        match future.as_mut().poll(&mut cx) {
66            core::task::Poll::Ready(value) => value,
67            _ => panic!("mock flash must not suspend"),
68        }
69    }
70
71    #[test]
72    fn legacy_upgrade_and_interrupted_commits_preserve_one_complete_snapshot() {
73        let old = b"legacy configuration";
74        let new = [0x42; 2560];
75        for cut in (0..24).chain((24..4092).step_by(137)).chain(4092..=4096) {
76            let mut bytes = vec![255; 8192];
77            let mut record = proto::encode_record(7, proto::RecordRef::Snapshot(old));
78            record[proto::COMMIT_OFFSET..].fill(0);
79            // Exercise a legacy record in the second half of a page.
80            bytes[2048..4096].copy_from_slice(&record);
81            let flash = Box::leak(Box::new(Mutex::<NoopRawMutex, _>::new(Flash {
82                bytes,
83                budget: cut,
84            })));
85            let (mut store, mounted) = run(WifiStore::mount(flash, 0));
86            assert_eq!(mounted.as_deref(), Some(old.as_slice()));
87            let saved = run(store.persist(&new));
88            let (_, mounted) = run(WifiStore::mount(flash, 0));
89            assert_eq!(
90                mounted.as_deref(),
91                Some(if saved.is_ok() { &new } else { old.as_slice() })
92            );
93        }
94    }
95
96    #[test]
97    fn clear_is_committed_and_an_invalid_payload_can_walk_back() {
98        let flash = Box::leak(Box::new(Mutex::<NoopRawMutex, _>::new(Flash {
99            bytes: vec![255; 8192],
100            budget: usize::MAX,
101        })));
102        let (mut store, _) = run(WifiStore::mount(flash, 0));
103        run(store.persist(b"first")).unwrap();
104        run(store.persist(b"invalid protocol payload")).unwrap();
105        let (mut store, _) = run(WifiStore::mount(flash, 0));
106        let mut out = [0; 64];
107        let len = run(store.older_snapshot(&mut out)).unwrap();
108        assert_eq!(&out[..len], b"first");
109        run(store.clear()).unwrap();
110        assert!(run(WifiStore::mount(flash, 0)).1.is_none());
111    }
112}
113
114/// A large record occupies the whole page. Only when it is invalid do
115/// we examine the legacy slots within it; payloads cannot masquerade as
116/// independently committed legacy records inside a valid large record.
117fn newest<F: JournalFlash>(
118    flash: &mut F,
119    page0: u32,
120    before: Option<u32>,
121    buf: &mut [u8; SLOT_SIZE],
122) -> Option<(u32, usize, u32)> {
123    let mut winner: Option<(u32, usize, u32)> = None;
124    for page in [page0, page0 + PAGE_SIZE] {
125        if flash.read_record(page, buf).is_err() {
126            continue;
127        }
128        let large = proto::probe_record(buf).is_some();
129        let width = if large { SLOT_SIZE } else { proto::SLOT_SIZE };
130        for offset in (0..SLOT_SIZE).step_by(width) {
131            let Some(generation) = proto::probe_record(&buf[offset..offset + width]) else {
132                continue;
133            };
134            if before.is_some_and(|before| !record::generation_is_newer(before, generation)) {
135                continue;
136            }
137            if winner.is_none_or(|(_, _, old)| record::generation_is_newer(generation, old)) {
138                winner = Some((page + offset as u32, width, generation));
139            }
140        }
141    }
142    winner
143}
144
145impl<M: RawMutex + 'static, F: JournalFlash + 'static> WifiStore<M, F> {
146    /// Largest payload this store persists: a full-page record's.
147    pub const MAX_PAYLOAD: usize = MAX_PAYLOAD;
148
149    pub async fn mount(
150        flash: &'static SharedFlash<M, F>,
151        page0: u32,
152    ) -> (Self, Option<BootPayload>) {
153        let mut locked = flash.lock().await;
154        let mut buf = [0; SLOT_SIZE];
155        let found = newest(&mut *locked, page0, None, &mut buf);
156        let payload = found.and_then(|(address, size, _)| {
157            locked.read_record(address, &mut buf[..size]).ok()?;
158            let (_, payload) = proto::payload_bytes(&buf[..size])?;
159            BootPayload::from_slice(payload?).ok()
160        });
161        let generation = found.map_or(0, |(_, _, generation)| generation);
162        (
163            Self {
164                flash,
165                page0,
166                generation,
167                slot: found.map(|(address, _, _)| address),
168                walked_back_to: generation,
169            },
170            payload,
171        )
172    }
173
174    pub async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
175        let mut flash = self.flash.lock().await;
176        let mut buf = [0; SLOT_SIZE];
177        let (address, size, generation) =
178            newest(&mut *flash, self.page0, Some(self.walked_back_to), &mut buf)?;
179        self.walked_back_to = generation;
180        flash.read_record(address, &mut buf[..size]).ok()?;
181        let (_, payload) = proto::payload_bytes(&buf[..size])?;
182        let payload = payload?;
183        out.get_mut(..payload.len())?.copy_from_slice(payload);
184        Some(payload.len())
185    }
186
187    pub async fn persist(&mut self, payload: &[u8]) -> Result<(), ()> {
188        if payload.len() > Self::MAX_PAYLOAD {
189            return Err(());
190        }
191        self.write(proto::RecordRef::Snapshot(payload)).await
192    }
193
194    pub async fn clear(&mut self) -> Result<(), ()> {
195        self.write(proto::RecordRef::Cleared).await
196    }
197
198    async fn write(&mut self, record: proto::RecordRef<'_>) -> Result<(), ()> {
199        let mut flash = self.flash.lock().await;
200        // Normalize a legacy slot to its page before selecting the other page.
201        let current_page = self
202            .slot
203            .map(|address| self.page0 + ((address - self.page0) / PAGE_SIZE) * PAGE_SIZE);
204        let target = journal_write_target(&mut *flash, current_page, self.page0, SLOT_SIZE).await?;
205        let generation = self.generation.wrapping_add(1);
206        let bytes = proto::encode_record_sized::<SLOT_SIZE>(generation, record);
207        record::write_committed_record(&mut *flash, target, &bytes)
208            .await
209            .map_err(|_| ())?;
210        self.generation = generation;
211        self.slot = Some(target);
212        Ok(())
213    }
214}