1use crate::record::{crc32, generation_is_newer};
8
9pub const MAX_BONDS: usize = 4;
10pub const SLOT_SIZE: usize = 256;
11pub const COMMIT_OFFSET: usize = SLOT_SIZE - 4;
12const CRC_OFFSET: usize = COMMIT_OFFSET - 4;
13const MAGIC: [u8; 4] = *b"UBLS";
14const VERSION: u8 = 3;
18const BOND_SIZE: usize = 44;
19const LOCAL_IRK_OFFSET: usize = 16;
20const BONDS_OFFSET: usize = 32;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct StoredBond {
24 pub address_kind: u8,
25 pub address: [u8; 6],
26 pub irk: Option<[u8; 16]>,
27 pub ltk: [u8; 16],
28 pub security_level: u8,
29 pub is_bonded: bool,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum BondUpsert {
35 Unchanged,
37 Updated,
39 Inserted { evicted: Option<StoredBond> },
42}
43
44pub fn upsert_bond(
50 bonds: &mut heapless::Vec<StoredBond, MAX_BONDS>,
51 bond: StoredBond,
52) -> BondUpsert {
53 if let Some(index) = bonds.iter().position(|existing| {
54 existing.address_kind == bond.address_kind && existing.address == bond.address
55 }) {
56 if bonds[index] == bond && index == bonds.len() - 1 {
57 return BondUpsert::Unchanged;
58 }
59 bonds.remove(index);
60 let _ = bonds.push(bond);
61 return BondUpsert::Updated;
62 }
63 let evicted = if bonds.len() == MAX_BONDS {
64 Some(bonds.remove(0))
65 } else {
66 None
67 };
68 let _ = bonds.push(bond);
69 BondUpsert::Inserted { evicted }
70}
71
72pub fn touch_bond(
77 bonds: &mut heapless::Vec<StoredBond, MAX_BONDS>,
78 address_kind: u8,
79 address: [u8; 6],
80) -> bool {
81 let Some(index) = bonds
82 .iter()
83 .position(|existing| existing.address_kind == address_kind && existing.address == address)
84 else {
85 return false;
86 };
87 if index == bonds.len() - 1 {
88 return false;
89 }
90 let bond = bonds.remove(index);
91 let _ = bonds.push(bond);
92 true
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct Snapshot {
97 pub generation: u32,
98 pub pin: Option<u32>,
99 pub local_irk: Option<[u8; 16]>,
100 pub bonds: heapless::Vec<StoredBond, MAX_BONDS>,
101}
102
103impl Snapshot {
104 pub const fn empty() -> Self {
105 Self {
106 generation: 0,
107 pin: None,
108 local_irk: None,
109 bonds: heapless::Vec::new(),
110 }
111 }
112
113 pub fn encode(&self) -> [u8; SLOT_SIZE] {
114 let mut out = [0xff; SLOT_SIZE];
115 out[..4].copy_from_slice(&MAGIC);
116 out[4] = VERSION;
117 out[5] = self.bonds.len() as u8;
118 out[6] = u8::from(self.pin.is_some());
119 out[7] = u8::from(self.local_irk.is_some());
120 out[8..12].copy_from_slice(&self.generation.to_le_bytes());
121 out[12..16].copy_from_slice(&self.pin.unwrap_or(u32::MAX).to_le_bytes());
122 out[LOCAL_IRK_OFFSET..BONDS_OFFSET].copy_from_slice(&self.local_irk.unwrap_or([0; 16]));
123 for (index, bond) in self.bonds.iter().enumerate() {
124 let start = BONDS_OFFSET + index * BOND_SIZE;
125 out[start] = bond.address_kind;
126 out[start + 1..start + 7].copy_from_slice(&bond.address);
127 out[start + 7] = u8::from(bond.irk.is_some());
128 out[start + 8..start + 24].copy_from_slice(&bond.irk.unwrap_or([0; 16]));
129 out[start + 24..start + 40].copy_from_slice(&bond.ltk);
130 out[start + 40] = bond.security_level;
131 out[start + 41] = u8::from(bond.is_bonded);
132 }
133 let crc = crc32(&out[..CRC_OFFSET]);
134 out[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
135 out
136 }
137
138 pub fn decode(bytes: &[u8; SLOT_SIZE]) -> Option<Self> {
139 if bytes[COMMIT_OFFSET..] != [0, 0, 0, 0]
140 || bytes[..4] != MAGIC
141 || bytes[4] != VERSION
142 || usize::from(bytes[5]) > MAX_BONDS
143 || crc32(&bytes[..CRC_OFFSET])
144 != u32::from_le_bytes(bytes[CRC_OFFSET..COMMIT_OFFSET].try_into().ok()?)
145 {
146 return None;
147 }
148 let pin = match (bytes[6], u32::from_le_bytes(bytes[12..16].try_into().ok()?)) {
149 (0, _) => None,
150 (1, value @ 0..=999_999) => Some(value),
151 _ => return None,
152 };
153 let local_irk = match bytes[7] {
154 0 => None,
155 1 => {
156 let value: [u8; 16] = bytes[LOCAL_IRK_OFFSET..BONDS_OFFSET].try_into().ok()?;
157 if value == [0; 16] {
158 return None;
159 }
160 Some(value)
161 }
162 _ => return None,
163 };
164 let mut bonds = heapless::Vec::new();
165 for index in 0..usize::from(bytes[5]) {
166 let start = BONDS_OFFSET + index * BOND_SIZE;
167 let irk = match bytes[start + 7] {
168 0 => None,
169 1 => Some(bytes[start + 8..start + 24].try_into().ok()?),
170 _ => return None,
171 };
172 let security_level = bytes[start + 40];
173 if security_level > 2 || bytes[start + 41] > 1 {
174 return None;
175 }
176 bonds
177 .push(StoredBond {
178 address_kind: bytes[start],
179 address: bytes[start + 1..start + 7].try_into().ok()?,
180 irk,
181 ltk: bytes[start + 24..start + 40].try_into().ok()?,
182 security_level,
183 is_bonded: bytes[start + 41] == 1,
184 })
185 .ok()?;
186 }
187 Some(Self {
188 generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?),
189 pin,
190 local_irk,
191 bonds,
192 })
193 }
194}
195
196#[cfg(test)]
199pub fn latest_snapshot<'a>(
200 records: impl IntoIterator<Item = (u32, &'a [u8; SLOT_SIZE])>,
201) -> Option<(u32, Snapshot)> {
202 let mut latest: Option<(u32, Snapshot)> = None;
203 for (address, bytes) in records {
204 latest = consider_snapshot(latest, address, bytes);
205 }
206 latest
207}
208
209pub fn consider_snapshot(
211 current: Option<(u32, Snapshot)>,
212 address: u32,
213 bytes: &[u8; SLOT_SIZE],
214) -> Option<(u32, Snapshot)> {
215 let Some(candidate) = Snapshot::decode(bytes) else {
216 return current;
217 };
218 if current
219 .as_ref()
220 .is_none_or(|(_, snapshot)| generation_is_newer(candidate.generation, snapshot.generation))
221 {
222 Some((address, candidate))
223 } else {
224 current
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::record::{
232 CommitError, PAGE_SIZE, PageEraser, RecordWriter, erase_journal_page,
233 write_committed_record,
234 };
235 use core::future::Future;
236 use core::task::{Context, Poll, Waker};
237
238 const PAGE0: u32 = 0x000E_4000;
241 const PAGE1: u32 = PAGE0 + PAGE_SIZE;
242
243 fn block_on<F: Future>(future: F) -> F::Output {
244 let mut future = core::pin::pin!(future);
245 let mut context = Context::from_waker(Waker::noop());
246 loop {
247 match future.as_mut().poll(&mut context) {
248 Poll::Ready(output) => return output,
249 Poll::Pending => std::thread::yield_now(),
250 }
251 }
252 }
253
254 #[derive(Default)]
255 struct MockWriter {
256 fail_call: Option<usize>,
257 calls: std::vec::Vec<(u32, std::vec::Vec<u8>)>,
258 erase_failure: bool,
259 erases: std::vec::Vec<(u32, u32)>,
260 }
261
262 impl RecordWriter for MockWriter {
263 type Error = usize;
264
265 async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
266 let call = self.calls.len();
267 self.calls.push((address, bytes.to_vec()));
268 if self.fail_call == Some(call) {
269 Err(call)
270 } else {
271 Ok(())
272 }
273 }
274 }
275
276 impl PageEraser for MockWriter {
277 type Error = ();
278
279 async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
280 self.erases.push((start, end));
281 if self.erase_failure { Err(()) } else { Ok(()) }
282 }
283 }
284
285 fn sample() -> Snapshot {
286 let mut snapshot = Snapshot {
287 generation: 42,
288 pin: Some(123_456),
289 local_irk: Some([9; 16]),
290 bonds: heapless::Vec::new(),
291 };
292 snapshot
293 .bonds
294 .push(StoredBond {
295 address_kind: 1,
296 address: [1, 2, 3, 4, 5, 6],
297 irk: Some([7; 16]),
298 ltk: [8; 16],
299 security_level: 2,
300 is_bonded: true,
301 })
302 .unwrap();
303 snapshot
304 }
305
306 #[test]
307 fn committed_snapshot_round_trips() {
308 let snapshot = sample();
309 let mut encoded = snapshot.encode();
310 encoded[COMMIT_OFFSET..].fill(0);
311 assert_eq!(Snapshot::decode(&encoded), Some(snapshot));
312 }
313
314 #[test]
315 fn uncommitted_or_corrupt_snapshot_is_ignored() {
316 let snapshot = sample();
317 let encoded = snapshot.encode();
318 assert_eq!(Snapshot::decode(&encoded), None);
319 let mut corrupt = encoded;
320 corrupt[COMMIT_OFFSET..].fill(0);
321 corrupt[24] ^= 1;
322 assert_eq!(Snapshot::decode(&corrupt), None);
323 }
324
325 #[test]
326 fn journal_selects_newest_valid_record_across_wraparound() {
327 let mut old = sample();
328 old.generation = u32::MAX;
329 let mut old_bytes = old.encode();
330 old_bytes[COMMIT_OFFSET..].fill(0);
331
332 let mut new = sample();
333 new.generation = 0;
334 new.pin = Some(654_321);
335 let mut new_bytes = new.encode();
336 new_bytes[COMMIT_OFFSET..].fill(0);
337
338 assert_eq!(
339 latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &new_bytes)]),
340 Some((PAGE1, new))
341 );
342 }
343
344 #[test]
345 fn interrupted_new_record_never_replaces_committed_old_record() {
346 let mut old = sample();
347 old.generation = 7;
348 let mut old_bytes = old.encode();
349 old_bytes[COMMIT_OFFSET..].fill(0);
350
351 let mut new = sample();
352 new.generation = 8;
353 new.pin = Some(654_321);
354 let encoded_new = new.encode();
355
356 for body_bytes in 0..=COMMIT_OFFSET {
360 let mut interrupted = [0xff; SLOT_SIZE];
361 interrupted[..body_bytes].copy_from_slice(&encoded_new[..body_bytes]);
362 assert_eq!(
363 latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &interrupted)]),
364 Some((PAGE0, old.clone())),
365 "body power cut after {body_bytes} bytes"
366 );
367 }
368 for commit_bytes in 0..4 {
369 let mut interrupted = encoded_new;
370 interrupted[COMMIT_OFFSET..COMMIT_OFFSET + commit_bytes].fill(0);
371 assert_eq!(
372 latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &interrupted)]),
373 Some((PAGE0, old.clone())),
374 "commit power cut after {commit_bytes} bytes"
375 );
376 }
377
378 let mut committed = encoded_new;
379 committed[COMMIT_OFFSET..].fill(0);
380 assert_eq!(
381 latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &committed)]),
382 Some((PAGE1, new))
383 );
384 }
385
386 #[test]
387 fn record_writer_faults_distinguish_body_and_commit_failures() {
388 let bytes = sample().encode();
389
390 let mut body_failure = MockWriter {
391 fail_call: Some(0),
392 ..Default::default()
393 };
394 assert_eq!(
395 block_on(write_committed_record(&mut body_failure, PAGE0, &bytes)),
396 Err(CommitError::Body(0))
397 );
398 assert_eq!(body_failure.calls.len(), 1);
399
400 let mut commit_failure = MockWriter {
401 fail_call: Some(1),
402 ..Default::default()
403 };
404 assert_eq!(
405 block_on(write_committed_record(&mut commit_failure, PAGE0, &bytes)),
406 Err(CommitError::Commit(1))
407 );
408 assert_eq!(commit_failure.calls.len(), 2);
409 assert_eq!(commit_failure.calls[0].0, PAGE0);
410 assert_eq!(commit_failure.calls[0].1, bytes[..COMMIT_OFFSET]);
411 assert_eq!(
412 commit_failure.calls[1],
413 (PAGE0 + COMMIT_OFFSET as u32, std::vec![0; 4])
414 );
415 }
416
417 #[test]
418 fn successful_record_write_commits_marker_last() {
419 let bytes = sample().encode();
420 let mut writer = MockWriter::default();
421 assert_eq!(
422 block_on(write_committed_record(&mut writer, PAGE1, &bytes)),
423 Ok(())
424 );
425 assert_eq!(writer.calls.len(), 2);
426 assert_eq!(writer.calls[0].0, PAGE1);
427 assert_eq!(writer.calls[0].1, bytes[..COMMIT_OFFSET]);
428 assert_eq!(
429 writer.calls[1],
430 (PAGE1 + COMMIT_OFFSET as u32, std::vec![0; 4])
431 );
432 }
433
434 #[test]
435 fn journal_page_erase_propagates_failure_and_uses_exact_bounds() {
436 let mut failing = MockWriter {
437 erase_failure: true,
438 ..Default::default()
439 };
440 assert_eq!(block_on(erase_journal_page(&mut failing, PAGE1)), Err(()));
441 assert_eq!(failing.erases, std::vec![(PAGE1, PAGE1 + PAGE_SIZE)]);
442
443 let mut successful = MockWriter::default();
444 assert_eq!(block_on(erase_journal_page(&mut successful, PAGE0)), Ok(()));
445 assert_eq!(successful.erases, std::vec![(PAGE0, PAGE0 + PAGE_SIZE)]);
446 }
447
448 fn bond(id: u8) -> StoredBond {
449 StoredBond {
450 address_kind: 0,
451 address: [id, 0, 0, 0, 0, 0],
452 irk: None,
453 ltk: [id; 16],
454 security_level: 2,
455 is_bonded: true,
456 }
457 }
458
459 fn addresses(bonds: &heapless::Vec<StoredBond, MAX_BONDS>) -> std::vec::Vec<u8> {
460 bonds.iter().map(|b| b.address[0]).collect()
461 }
462
463 #[test]
464 fn upsert_appends_new_bonds_at_mru_end_until_full() {
465 let mut bonds = heapless::Vec::new();
466 for id in 1..=4 {
467 assert_eq!(
468 upsert_bond(&mut bonds, bond(id)),
469 BondUpsert::Inserted { evicted: None }
470 );
471 }
472 assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
473 }
474
475 #[test]
476 fn upsert_evicts_lru_entry_when_full() {
477 let mut bonds = heapless::Vec::new();
478 for id in 1..=4 {
479 upsert_bond(&mut bonds, bond(id));
480 }
481 assert_eq!(
483 upsert_bond(&mut bonds, bond(5)),
484 BondUpsert::Inserted {
485 evicted: Some(bond(1))
486 }
487 );
488 assert_eq!(addresses(&bonds), std::vec![2, 3, 4, 5]);
489 }
490
491 #[test]
492 fn upsert_of_existing_bond_moves_it_to_mru_end() {
493 let mut bonds = heapless::Vec::new();
494 for id in 1..=4 {
495 upsert_bond(&mut bonds, bond(id));
496 }
497 assert_eq!(upsert_bond(&mut bonds, bond(2)), BondUpsert::Updated);
500 assert_eq!(addresses(&bonds), std::vec![1, 3, 4, 2]);
501 }
502
503 #[test]
504 fn upsert_of_bond_already_at_mru_end_with_same_content_is_unchanged() {
505 let mut bonds = heapless::Vec::new();
506 for id in 1..=4 {
507 upsert_bond(&mut bonds, bond(id));
508 }
509 assert_eq!(upsert_bond(&mut bonds, bond(4)), BondUpsert::Unchanged);
510 assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
511 }
512
513 #[test]
514 fn touch_moves_known_bond_to_mru_end_and_reports_change() {
515 let mut bonds = heapless::Vec::new();
516 for id in 1..=4 {
517 upsert_bond(&mut bonds, bond(id));
518 }
519 assert!(touch_bond(&mut bonds, 0, [1, 0, 0, 0, 0, 0]));
520 assert_eq!(addresses(&bonds), std::vec![2, 3, 4, 1]);
521 }
522
523 #[test]
524 fn touch_of_bond_already_at_mru_end_is_a_no_op() {
525 let mut bonds = heapless::Vec::new();
526 for id in 1..=4 {
527 upsert_bond(&mut bonds, bond(id));
528 }
529 assert!(!touch_bond(&mut bonds, 0, [4, 0, 0, 0, 0, 0]));
530 assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
531 }
532
533 #[test]
534 fn touch_of_unknown_bond_is_a_no_op() {
535 let mut bonds = heapless::Vec::new();
536 upsert_bond(&mut bonds, bond(1));
537 assert!(!touch_bond(&mut bonds, 0, [9, 0, 0, 0, 0, 0]));
538 assert_eq!(addresses(&bonds), std::vec![1]);
539 }
540}