1use crate::record::{self, CommitError, RecordWriter};
18
19pub 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
30pub 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
42pub 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;
51pub const MAX_PAYLOAD: usize = CRC_OFFSET - HEADER_LEN;
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum Record {
57 Snapshot(heapless::Vec<u8, MAX_PAYLOAD>),
59 Cleared,
65}
66
67#[derive(Clone, Copy, Debug)]
72pub enum RecordRef<'a> {
73 Snapshot(&'a [u8]),
74 Cleared,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct Stored {
81 pub generation: u32,
82 pub record: Record,
83}
84
85pub 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 #[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
149pub 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
167pub 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
200pub 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 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 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 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 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 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 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 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 #[test]
404 fn tombstone_clears_and_survives_interruption() {
405 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 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 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 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 #[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 #[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 #[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 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 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}