Skip to main content

device_envoy_core/
flash_block.rs

1//! Shared low-level flash block protocol for type-safe persistent storage.
2//!
3//! This module provides the platform-independent protocol layer for
4//! platform crates. See your platform crate's `flash_block` module for
5//! constructors, hardware wiring, and usage examples.
6
7use core::any::type_name;
8
9use crc32fast::Hasher;
10use serde::{Deserialize, Serialize};
11
12/// Magic number identifying a valid flash block: `'BLKS'`.
13pub(crate) const MAGIC: u32 = 0x424C_4B53;
14
15/// Number of bytes in the block header: magic(4) + type\_hash(4) + payload\_len(2).
16pub(crate) const HEADER_SIZE: usize = 10;
17
18/// Number of bytes used by the CRC trailer.
19pub(crate) const CRC_SIZE: usize = 4;
20
21/// Errors returned by [`save_block`], [`load_block`], and [`clear_block`].
22#[derive(Debug)]
23pub enum Error<E> {
24    /// An I/O operation on the underlying flash device failed.
25    Io(E),
26    /// Serialization or deserialization failed.
27    FormatError,
28    /// The stored data is corrupt (bad CRC or invalid length).
29    StorageCorrupted,
30}
31
32/// Operations on blocks of flash memory.
33///
34/// Platform crates implement this trait on their concrete flash block handle
35/// types.
36///
37/// Constructors and hardware wiring remain platform-specific; this trait
38/// defines the shared operation surface used by higher-level abstractions.
39///
40/// # Features
41///
42/// - Type safety: hash-based type checking prevents reading data written under a
43///   different Rust type name. Trying to read a different type returns `Ok(None)`.
44/// - Postcard serialization: compact, `no_std`-friendly binary format.
45///
46/// This example increments a persisted boot counter and clears a separate
47/// scratch block in the same helper.
48///
49/// # Example
50///
51/// ```rust,no_run
52/// use core::convert::Infallible;
53/// use device_envoy_core::flash_block::FlashBlock;
54///
55/// #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy)]
56/// struct BootCounter(u8);
57///
58/// impl BootCounter {
59///     const fn new(value: u8) -> Self {
60///         Self(value)
61///     }
62///
63///     fn increment(self) -> Self {
64///         Self((self.0 + 1) % 10)
65///     }
66/// }
67///
68/// fn update_boot_counter_and_clear_scratch(
69///     boot_counter_flash_block: &mut impl FlashBlock<Error = Infallible>,
70///     scratch_flash_block: &mut impl FlashBlock<Error = Infallible>,
71/// ) -> Result<BootCounter, Infallible> {
72///     // Load the typed value, defaulting to 0 when the block is empty.
73///     let boot_counter = boot_counter_flash_block
74///         .load()?
75///         .unwrap_or(BootCounter::new(0))
76///         .increment();
77///
78///     // Save the updated value back to flash.
79///     boot_counter_flash_block.save(&boot_counter)?;
80///
81///     // Clear the extra scratch block.
82///     scratch_flash_block.clear()?;
83///
84///     Ok(boot_counter)
85/// }
86///
87/// # struct DemoFlashBlock;
88/// # impl FlashBlock for DemoFlashBlock {
89/// #     type Error = Infallible;
90/// #     fn load<T>(&mut self) -> Result<Option<T>, Self::Error>
91/// #     where
92/// #         T: serde::Serialize + for<'de> serde::Deserialize<'de>,
93/// #     {
94/// #         Ok(None)
95/// #     }
96/// #     fn save<T>(&mut self, _value: &T) -> Result<(), Self::Error>
97/// #     where
98/// #         T: serde::Serialize + for<'de> serde::Deserialize<'de>,
99/// #     {
100/// #         Ok(())
101/// #     }
102/// #     fn clear(&mut self) -> Result<(), Self::Error> {
103/// #         Ok(())
104/// #     }
105/// # }
106/// # fn main() {
107/// #     let mut boot_counter_flash_block = DemoFlashBlock;
108/// #     let mut scratch_flash_block = DemoFlashBlock;
109/// #     let _ = update_boot_counter_and_clear_scratch(
110/// #         &mut boot_counter_flash_block,
111/// #         &mut scratch_flash_block,
112/// #     );
113/// # }
114/// ```
115#[cfg_attr(
116    feature = "wasm",
117    doc = "\nBrowser-simulated device: [`crate::wasm::FlashBlockWasm`]."
118)]
119pub trait FlashBlock {
120    /// Error returned by block operations.
121    type Error;
122
123    /// Load a typed value from this block.
124    ///
125    /// Returns `Ok(None)` when the block is empty or contains a different type.
126    ///
127    /// See the [FlashBlock trait documentation](Self) for usage examples.
128    fn load<T>(&mut self) -> Result<Option<T>, Self::Error>
129    where
130        T: Serialize + for<'de> Deserialize<'de>;
131
132    /// Save a typed value to this block.
133    ///
134    /// See the [FlashBlock trait documentation](Self) for usage examples.
135    fn save<T>(&mut self, value: &T) -> Result<(), Self::Error>
136    where
137        T: Serialize + for<'de> Deserialize<'de>;
138
139    /// Clear this block.
140    ///
141    /// See the [FlashBlock trait documentation](Self) for usage examples.
142    fn clear(&mut self) -> Result<(), Self::Error>;
143}
144
145/// Low-level read/write/erase interface for a flash device.
146///
147/// Implement this trait in the platform crate to connect the shared block
148/// protocol to the hardware driver.
149// Public for cross-crate platform plumbing; hidden from end-user docs.
150#[doc(hidden)]
151pub trait FlashDevice {
152    /// The error type returned by I/O operations.
153    type Error;
154
155    /// Read `bytes.len()` bytes starting at `offset`.
156    fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error>;
157
158    /// Write `bytes` starting at `offset`.
159    fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error>;
160
161    /// Erase flash from `from` (inclusive) to `to` (exclusive), in bytes.
162    fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error>;
163}
164
165/// Maximum payload bytes for a flash block size.
166#[must_use]
167// Public for cross-crate platform plumbing; hidden from end-user docs.
168#[doc(hidden)]
169pub const fn max_payload_size(block_size: usize) -> usize {
170    assert!(block_size > HEADER_SIZE + CRC_SIZE, "block_size too small");
171    block_size - HEADER_SIZE - CRC_SIZE
172}
173
174/// Serialize `value` and write it into the block starting at `block_offset`.
175///
176/// The block is erased before writing. On success the block contains:
177/// magic + type hash + payload length + serialized payload + CRC32.
178// Public for cross-crate platform plumbing; hidden from end-user docs.
179#[doc(hidden)]
180pub fn save_block<const BLOCK_SIZE: usize, T, F>(
181    flash: &mut F,
182    block_offset: u32,
183    value: &T,
184) -> Result<(), Error<F::Error>>
185where
186    T: Serialize + for<'de> Deserialize<'de>,
187    F: FlashDevice,
188{
189    let max_payload_size = max_payload_size(BLOCK_SIZE);
190    let mut payload_buffer = [0u8; BLOCK_SIZE];
191    let payload = postcard::to_slice(value, &mut payload_buffer[..max_payload_size])
192        .map_err(|_| Error::FormatError)?;
193    let payload_len = payload.len();
194
195    let mut block_bytes = [0xFFu8; BLOCK_SIZE];
196    block_bytes[0..4].copy_from_slice(&MAGIC.to_le_bytes());
197    block_bytes[4..8].copy_from_slice(&compute_type_hash::<T>().to_le_bytes());
198    block_bytes[8..10].copy_from_slice(&(payload_len as u16).to_le_bytes());
199    block_bytes[HEADER_SIZE..HEADER_SIZE + payload_len].copy_from_slice(payload);
200
201    let crc_offset = HEADER_SIZE + payload_len;
202    let crc = compute_crc(&block_bytes[..crc_offset]);
203    block_bytes[crc_offset..crc_offset + CRC_SIZE].copy_from_slice(&crc.to_le_bytes());
204
205    let block_size_u32 = u32::try_from(BLOCK_SIZE).expect("block size must fit in u32");
206    flash
207        .erase(block_offset, block_offset + block_size_u32)
208        .map_err(Error::Io)?;
209    flash.write(block_offset, &block_bytes).map_err(Error::Io)?;
210    Ok(())
211}
212
213/// Read the block at `block_offset`.
214///
215/// Returns `Ok(None)` when the block has no recognized magic or the stored
216/// type hash does not match `T`. Returns `Err` when the data is corrupt.
217// Public for cross-crate platform plumbing; hidden from end-user docs.
218#[doc(hidden)]
219pub fn load_block<const BLOCK_SIZE: usize, T, F>(
220    flash: &mut F,
221    block_offset: u32,
222) -> Result<Option<T>, Error<F::Error>>
223where
224    T: Serialize + for<'de> Deserialize<'de>,
225    F: FlashDevice,
226{
227    let mut block_bytes = [0u8; BLOCK_SIZE];
228    flash
229        .read(block_offset, &mut block_bytes)
230        .map_err(Error::Io)?;
231
232    let magic = u32::from_le_bytes(block_bytes[0..4].try_into().expect("4-byte slice"));
233    if magic != MAGIC {
234        return Ok(None);
235    }
236
237    let stored_type_hash = u32::from_le_bytes(block_bytes[4..8].try_into().expect("4-byte slice"));
238    if stored_type_hash != compute_type_hash::<T>() {
239        return Ok(None);
240    }
241
242    let payload_len =
243        u16::from_le_bytes(block_bytes[8..10].try_into().expect("2-byte slice")) as usize;
244    if payload_len > max_payload_size(BLOCK_SIZE) {
245        return Err(Error::StorageCorrupted);
246    }
247
248    let crc_offset = HEADER_SIZE + payload_len;
249    let stored_crc = u32::from_le_bytes(
250        block_bytes[crc_offset..crc_offset + CRC_SIZE]
251            .try_into()
252            .expect("4-byte slice"),
253    );
254    if stored_crc != compute_crc(&block_bytes[..crc_offset]) {
255        return Err(Error::StorageCorrupted);
256    }
257
258    let payload = &block_bytes[HEADER_SIZE..HEADER_SIZE + payload_len];
259    postcard::from_bytes(payload)
260        .map(Some)
261        .map_err(|_| Error::StorageCorrupted)
262}
263
264/// Erase the block at `block_offset`.
265// Public for cross-crate platform plumbing; hidden from end-user docs.
266#[doc(hidden)]
267pub fn clear_block<const BLOCK_SIZE: usize, F: FlashDevice>(
268    flash: &mut F,
269    block_offset: u32,
270) -> Result<(), Error<F::Error>> {
271    let block_size_u32 = u32::try_from(BLOCK_SIZE).expect("block size must fit in u32");
272    flash
273        .erase(block_offset, block_offset + block_size_u32)
274        .map_err(Error::Io)
275}
276
277/// FNV-1a hash of `T`'s fully-qualified type name.
278///
279/// Used as a type-safety tag stored alongside serialized data so that an attempt
280/// to load the wrong type returns `Ok(None)` rather than corrupt data.
281pub(crate) fn compute_type_hash<T>() -> u32 {
282    const FNV_OFFSET: u32 = 2_166_136_261;
283    const FNV_PRIME: u32 = 16_777_619;
284
285    let mut hash = FNV_OFFSET;
286    for byte in type_name::<T>().bytes() {
287        hash ^= u32::from(byte);
288        hash = hash.wrapping_mul(FNV_PRIME);
289    }
290    hash
291}
292
293/// CRC32 checksum.
294pub(crate) fn compute_crc(bytes: &[u8]) -> u32 {
295    let mut hasher = Hasher::new();
296    hasher.update(bytes);
297    hasher.finalize()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{
303        Error, FlashDevice, HEADER_SIZE, clear_block, load_block, max_payload_size, save_block,
304    };
305
306    const TEST_FLASH_BLOCK_SIZE: usize = 4096;
307    const TEST_FLASH_SIZE: usize = TEST_FLASH_BLOCK_SIZE * 4;
308
309    struct MemoryFlashDevice {
310        bytes: [u8; TEST_FLASH_SIZE],
311    }
312
313    impl MemoryFlashDevice {
314        fn new() -> Self {
315            Self {
316                bytes: [0xFF; TEST_FLASH_SIZE],
317            }
318        }
319    }
320
321    impl FlashDevice for MemoryFlashDevice {
322        type Error = ();
323
324        fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), ()> {
325            let offset = offset as usize;
326            bytes.copy_from_slice(&self.bytes[offset..offset + bytes.len()]);
327            Ok(())
328        }
329
330        fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), ()> {
331            let offset = offset as usize;
332            self.bytes[offset..offset + bytes.len()].copy_from_slice(bytes);
333            Ok(())
334        }
335
336        fn erase(&mut self, from: u32, to: u32) -> Result<(), ()> {
337            self.bytes[from as usize..to as usize].fill(0xFF);
338            Ok(())
339        }
340    }
341
342    #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
343    struct WifiPersistedState {
344        ssid: heapless::String<32>,
345        password: heapless::String<64>,
346        timezone_offset_minutes: i32,
347    }
348
349    #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
350    struct OtherState {
351        timezone_offset_minutes: i32,
352    }
353
354    #[test]
355    fn save_load_clear_round_trip() {
356        let mut device = MemoryFlashDevice::new();
357        let state = WifiPersistedState {
358            ssid: heapless::String::try_from("demo-net").expect("ssid fits"),
359            password: heapless::String::try_from("password123").expect("password fits"),
360            timezone_offset_minutes: -300,
361        };
362
363        save_block::<TEST_FLASH_BLOCK_SIZE, _, _>(&mut device, 0, &state).expect("save succeeds");
364        let loaded = load_block::<TEST_FLASH_BLOCK_SIZE, WifiPersistedState, _>(&mut device, 0)
365            .expect("load succeeds")
366            .expect("value exists");
367        assert_eq!(loaded, state);
368
369        clear_block::<TEST_FLASH_BLOCK_SIZE, _>(&mut device, 0).expect("clear succeeds");
370        let cleared = load_block::<TEST_FLASH_BLOCK_SIZE, WifiPersistedState, _>(&mut device, 0)
371            .expect("load succeeds");
372        assert!(cleared.is_none());
373    }
374
375    #[test]
376    fn type_mismatch_returns_none() {
377        let mut device = MemoryFlashDevice::new();
378        let other = OtherState {
379            timezone_offset_minutes: 60,
380        };
381        save_block::<TEST_FLASH_BLOCK_SIZE, _, _>(&mut device, 0, &other).expect("save succeeds");
382        let result = load_block::<TEST_FLASH_BLOCK_SIZE, WifiPersistedState, _>(&mut device, 0)
383            .expect("load succeeds");
384        assert!(result.is_none());
385    }
386
387    #[test]
388    fn corrupted_crc_returns_error() {
389        let mut device = MemoryFlashDevice::new();
390        let state = WifiPersistedState {
391            ssid: heapless::String::new(),
392            password: heapless::String::new(),
393            timezone_offset_minutes: 0,
394        };
395        save_block::<TEST_FLASH_BLOCK_SIZE, _, _>(&mut device, 0, &state).expect("save succeeds");
396        device.bytes[HEADER_SIZE + 1] ^= 0x5A;
397
398        let error = load_block::<TEST_FLASH_BLOCK_SIZE, WifiPersistedState, _>(&mut device, 0)
399            .expect_err("crc mismatch should fail");
400        assert!(matches!(error, Error::<()>::StorageCorrupted));
401    }
402
403    #[test]
404    fn max_payload_size_is_header_and_crc_aware() {
405        assert_eq!(
406            max_payload_size(TEST_FLASH_BLOCK_SIZE),
407            TEST_FLASH_BLOCK_SIZE - 14
408        );
409    }
410}