hal: add a hal for accessing flash memories

Signed-off-by: Chris Frantz <cfrantz@google.com>
diff --git a/hal/blocking/flash/BUILD.bazel b/hal/blocking/flash/BUILD.bazel
new file mode 100644
index 0000000..6962a9c
--- /dev/null
+++ b/hal/blocking/flash/BUILD.bazel
@@ -0,0 +1,42 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "driver",
+    srcs = [
+        "driver.rs",
+    ],
+    crate_name = "hal_flash_driver",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//util/types",
+        "@rust_crates//:zerocopy",
+    ],
+)
+
+rust_library(
+    name = "flash",
+    srcs = [
+        "flash.rs",
+    ],
+    crate_name = "hal_flash",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        ":driver",
+        "//util/io",
+        "//util/types",
+    ],
+)
+
+rust_test(
+    name = "flash_test",
+    crate = ":flash",
+    rustc_flags = [
+        "-C",
+        "debug-assertions",
+    ],
+)
diff --git a/hal/blocking/flash/README.md b/hal/blocking/flash/README.md
new file mode 100644
index 0000000..55823ea
--- /dev/null
+++ b/hal/blocking/flash/README.md
@@ -0,0 +1,55 @@
+# Blocking Flash HAL
+
+This HAL provides abstractions for interacting with flash memory. It defines traits for low-level drivers and high-level synchronous interfaces, along with a helper implementation to bridge them.
+
+## Key Abstractions
+
+### `FlashAddress`
+
+A transparent wrapper around a 32-bit offset (`u32`) representing a location in flash memory. It supports basic arithmetic operations (`Add`, `AddAssign`, `BitAnd`, `BitAndAssign`) to facilitate address calculations.
+
+Platform-specific extension traits (like `EarlgreyFlashAddress` in `earlgrey_util`) may use the offset bits to encode additional information (e.g., distinguishing between DATA and INFO partitions using the MSB).
+
+### `FlashDriver` Trait
+
+Defines the low-level interface for flash hardware. It is designed to support both synchronous and asynchronous hardware controllers using a start-poll-complete execution model for erase and program operations:
+
+1.  **Start**: Initiated via `start_erase` or `start_program`.
+2.  **Poll**: Check completion status via `is_busy`, or wait for a hardware interrupt.
+3.  **Complete**: Finalize the operation and retrieve any execution errors via `complete_op`.
+
+Read operations (`read`) are synchronous for simplicity.
+
+Drivers also define hardware-specific constraints as constants:
+*   `ERASABLE_SIZES_BITMAP`: A bitmap where bit `i` is set if erasing blocks of size `2^i` is supported.
+*   `PROGRAM_WINDOW_SIZE`: The maximum size of a single write, and the boundary alignment constraint for writes.
+*   `MAX_READ_SIZE`: The maximum size of a single read.
+*   `READ_ALIGNMENT` / `PROGRAM_ALIGNMENT`: Address and size alignment requirements.
+
+### `Flash` Trait
+
+Provides a simplified, synchronous, blocking interface suitable for application use. It abstracts away the low-level start-poll-complete flow and hardware constraints (alignment, windows).
+
+### `BlockingFlash`
+
+A concrete implementation of the `Flash` trait that wraps a `FlashDriver` and a `Blocking` mechanism. It implements the blocking behavior and handles driver constraints automatically:
+
+```mermaid
+graph TD
+    App[Application] -- "Flash::program()" --> BF[BlockingFlash]
+    BF -- "1. start_program()" --> Driver[FlashDriver]
+    BF -- "2. wait_for_notification()" --> Blocking[Blocking impl]
+    Blocking -- "wakes up (IRQ/Poll)" --> BF
+    BF -- "3. complete_op()" --> Driver
+```
+
+#### Read Alignment Handling
+
+If a read request is not aligned to `TDriver::READ_ALIGNMENT`, `BlockingFlash::read` will:
+1.  Read the aligned block containing the unaligned start address into a temporary buffer.
+2.  Copy the requested bytes from the temporary buffer.
+3.  Read the remaining data in aligned chunks directly into the destination buffer.
+
+#### Program Window Handling
+
+Hardware flash controllers often cannot program data that crosses a write window boundary (typically 64 bytes). `BlockingFlash::program` automatically detects these boundaries and splits a large or unaligned write into multiple smaller writes that fit within the program windows.
diff --git a/hal/blocking/flash/driver.rs b/hal/blocking/flash/driver.rs
new file mode 100644
index 0000000..378152b
--- /dev/null
+++ b/hal/blocking/flash/driver.rs
@@ -0,0 +1,159 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Low-level flash driver interface.
+
+#![no_std]
+
+use core::num::NonZero;
+
+use util_types::PowerOf2Usize;
+use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
+
+/// Low-level flash driver interface.
+///
+/// This trait defines the interface for interacting with flash hardware at a low level.
+/// It supports an asynchronous-style execution model using a start-poll-complete pattern:
+/// 1. Start the operation (`start_erase`, `start_program`).
+/// 2. Poll status (`is_busy`) or wait for interrupt.
+/// 3. Finalize and check errors (`complete_op`).
+///
+/// Read operations (`read`) are assumed to be synchronous and blocking for simplicity.
+pub trait FlashDriver {
+    /// The error type returned by driver operations.
+    type Error;
+
+    /// The default page size in bytes.
+    const PAGE_SIZE: usize;
+
+    /// The maximum size of a single program operation (write window).
+    /// Program operations cannot span across boundaries aligned to this size.
+    const PROGRAM_WINDOW_SIZE: usize;
+
+    /// The maximum size of a single read operation.
+    const MAX_READ_SIZE: usize;
+
+    /// The alignment required for read operations (addresses and lengths).
+    const READ_ALIGNMENT: usize;
+
+    /// The alignment required for program operations (addresses and lengths).
+    const PROGRAM_ALIGNMENT: usize;
+
+    /// Returns the total size of the flash in bytes.
+    fn size(&self) -> NonZero<usize>;
+
+    /// Returns a bitmap of supported erase block sizes.
+    ///
+    /// Each bit `i` represents a supported erase block size of `2^i` bytes.
+    /// For example, if bit 11 is set, then 2048-byte erases are supported.
+    fn erasable_sizes_bitmap(&mut self) -> Result<u32, Self::Error>;
+
+    /// Reads data from flash synchronously.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The address to start reading from. Must be aligned to `READ_ALIGNMENT`.
+    /// * `buf`: The buffer to read data into. Must have size <= `MAX_READ_SIZE` and be aligned to `READ_ALIGNMENT`.
+    fn read(&mut self, start_addr: FlashAddress, buf: &mut [u8]) -> Result<(), Self::Error>;
+
+    /// Starts an erase operation asynchronously.
+    ///
+    /// The operation is not guaranteed to be complete until `complete_op` is called
+    /// and returns `Ok`.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The start address of the block to erase. Must be aligned to the block size.
+    /// * `size`: The size of the block to erase. Must have its corresponding bit set in `ERASABLE_SIZES_BITMAP`.
+    fn start_erase(
+        &mut self,
+        start_addr: FlashAddress,
+        size: PowerOf2Usize,
+    ) -> Result<(), Self::Error>;
+
+    /// Starts a program operation asynchronously.
+    ///
+    /// The operation is not guaranteed to be complete until `complete_op` is called
+    /// and returns `Ok`.
+    ///
+    /// The programmed region must not cross a `PROGRAM_WINDOW_SIZE` boundary.
+    ///
+    /// # Arguments
+    /// * `start_address`: The address to start programming at. Must be aligned to `PROGRAM_ALIGNMENT`.
+    /// * `data`: The data to program. Must have size <= `PROGRAM_WINDOW_SIZE` and be aligned to `PROGRAM_ALIGNMENT`.
+    fn start_program(
+        &mut self,
+        start_address: FlashAddress,
+        data: &[u8],
+    ) -> Result<(), Self::Error>;
+
+    /// Returns whether the driver is currently busy with an operation.
+    fn is_busy(&mut self) -> bool;
+
+    /// Completes a pending erase or program operation and returns the result.
+    ///
+    /// This should be called after `is_busy` returns false to check for errors and
+    /// reset the driver state.
+    fn complete_op(&mut self) -> Result<(), Self::Error>;
+}
+
+/// Represents an address in flash memory.
+///
+/// A flash address consists of an offset within the flash address space.
+#[derive(Default, Clone, Copy, PartialEq, Eq, IntoBytes, Immutable, FromBytes, KnownLayout)]
+#[repr(transparent)]
+pub struct FlashAddress {
+    offset: u32,
+}
+
+impl core::fmt::Display for FlashAddress {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "0x{:08x}", self.offset)
+    }
+}
+
+impl core::fmt::Debug for FlashAddress {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        core::fmt::Display::fmt(self, f)
+    }
+}
+
+impl FlashAddress {
+    /// Creates a new `FlashAddress`.
+    pub const fn new(offset: u32) -> Self {
+        Self { offset }
+    }
+
+    /// Returns the offset.
+    pub fn offset(&self) -> u32 {
+        self.offset
+    }
+}
+
+impl core::ops::Add<usize> for FlashAddress {
+    type Output = Self;
+    fn add(self, other: usize) -> Self {
+        Self {
+            offset: self.offset + other as u32,
+        }
+    }
+}
+
+impl core::ops::AddAssign<usize> for FlashAddress {
+    fn add_assign(&mut self, other: usize) {
+        self.offset += other as u32;
+    }
+}
+
+impl core::ops::BitAnd<usize> for FlashAddress {
+    type Output = Self;
+    fn bitand(self, other: usize) -> Self {
+        Self {
+            offset: self.offset & other as u32,
+        }
+    }
+}
+
+impl core::ops::BitAndAssign<usize> for FlashAddress {
+    fn bitand_assign(&mut self, other: usize) {
+        self.offset &= other as u32;
+    }
+}
diff --git a/hal/blocking/flash/flash.rs b/hal/blocking/flash/flash.rs
new file mode 100644
index 0000000..072e83c
--- /dev/null
+++ b/hal/blocking/flash/flash.rs
@@ -0,0 +1,430 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! High-level blocking flash interface.
+
+#![cfg_attr(not(test), no_std)]
+
+use core::{cmp::min, num::NonZero};
+pub use hal_flash_driver::FlashAddress;
+use hal_flash_driver::FlashDriver;
+use util_io::RandomRead;
+use util_types::{Blocking, PowerOf2Usize};
+
+/// High-level flash interface.
+///
+/// This trait provides a simplified, synchronous, blocking interface for flash operations.
+/// It abstracts away the asynchronous execution model and hardware alignment/window
+/// constraints of the underlying driver.
+pub trait Flash {
+    /// The error type returned by flash operations.
+    type Error;
+
+    /// Returns the geometry of the flash.
+    ///
+    /// # Returns
+    /// A tuple containing:
+    /// 1. The total size of the flash in bytes.
+    /// 2. The default/smallest page size (erase block size) in bytes.
+    /// 3. A bitmap of all supported erase block sizes.
+    fn geometry(&mut self) -> Result<(NonZero<usize>, PowerOf2Usize, u32), Self::Error>;
+
+    /// Reads data from flash into the provided buffer.
+    ///
+    /// This method handles unaligned read addresses by performing partial reads
+    /// into a temporary buffer if necessary.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The address to start reading from.
+    /// * `buf`: The buffer to read data into.
+    fn read(&mut self, start_addr: FlashAddress, buf: &mut [u8]) -> Result<(), Self::Error>;
+
+    /// Erases a block of flash.
+    ///
+    /// This is a blocking operation that waits for the hardware erase to complete.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The start address of the block to erase. Must be aligned to `size`.
+    /// * `size`: The size of the block to erase. Must be one of the supported sizes in `geometry().2`.
+    fn erase(&mut self, start_addr: FlashAddress, size: PowerOf2Usize) -> Result<(), Self::Error>;
+
+    /// Programs data into flash.
+    ///
+    /// This is a blocking operation that waits for the hardware program to complete.
+    /// It automatically handles programming data that spans across hardware program
+    /// window boundaries by splitting it into multiple aligned writes.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The address to start programming at.
+    /// * `data`: The data to program.
+    fn program(&mut self, start_addr: FlashAddress, data: &[u8]) -> Result<(), Self::Error>;
+
+    /// Returns a `RandomRead` implementation for this flash.
+    fn random_reader(&mut self) -> impl RandomRead<Error = Self::Error>
+    where
+        Self: Sized,
+    {
+        FlashRandomReader(self)
+    }
+}
+
+impl<F: Flash> Flash for &mut F {
+    type Error = F::Error;
+    #[inline(always)]
+    fn geometry(&mut self) -> Result<(NonZero<usize>, PowerOf2Usize, u32), Self::Error> {
+        (**self).geometry()
+    }
+    #[inline(always)]
+    fn read(&mut self, start_addr: FlashAddress, buf: &mut [u8]) -> Result<(), Self::Error> {
+        (**self).read(start_addr, buf)
+    }
+    #[inline(always)]
+    fn program(&mut self, start_addr: FlashAddress, data: &[u8]) -> Result<(), Self::Error> {
+        (**self).program(start_addr, data)
+    }
+    #[inline(always)]
+    fn erase(&mut self, start_addr: FlashAddress, size: PowerOf2Usize) -> Result<(), Self::Error> {
+        (**self).erase(start_addr, size)
+    }
+}
+
+/// A trait that can be used to constrain the page-size of the flash.
+///
+/// If you just need to read the page size at runtime, use `Flash::geometry()` instead.
+pub trait FlashPageSize {
+    /// The size of a flash page in bytes.
+    const PAGE_SIZE: usize;
+}
+
+/// A blocking flash implementation that wraps a `FlashDriver`.
+///
+/// This struct implements the high-level `Flash` trait by wrapping a low-level
+/// `FlashDriver` and using a `Blocking` mechanism (e.g., waiting for an interrupt
+/// or polling) to block the calling thread until asynchronous driver operations
+/// complete.
+///
+/// It also handles address alignment for reads and program window constraints for writes.
+pub struct BlockingFlash<TDriver: FlashDriver, TBlocking: Blocking> {
+    /// The underlying flash driver.
+    pub driver: TDriver,
+    /// The blocking mechanism used to wait for operations.
+    pub blocking: TBlocking,
+}
+
+impl<TDriver: FlashDriver, TBlocking: Blocking> FlashPageSize
+    for BlockingFlash<TDriver, TBlocking>
+{
+    /// The default page size.
+    const PAGE_SIZE: usize = TDriver::PAGE_SIZE;
+}
+
+impl<TDriver: FlashDriver, TBlocking: Blocking> Flash for BlockingFlash<TDriver, TBlocking> {
+    type Error = TDriver::Error;
+    fn geometry(&mut self) -> Result<(NonZero<usize>, PowerOf2Usize, u32), Self::Error> {
+        let bitmap = self.driver.erasable_sizes_bitmap()?;
+        let page_size = PowerOf2Usize::new(1 << (bitmap.trailing_zeros())).unwrap();
+        Ok((self.driver.size(), page_size, bitmap))
+    }
+    /// Reads data from flash.
+    ///
+    /// Handles unaligned `start_addr` by reading the aligned block containing it
+    /// into a temporary buffer first, copying the relevant bytes, and then reading
+    /// the remaining data in aligned chunks.
+    fn read(&mut self, start_addr: FlashAddress, mut buf: &mut [u8]) -> Result<(), Self::Error> {
+        let mut addr = start_addr;
+        let align_skip_len = (addr.offset() & (TDriver::READ_ALIGNMENT as u32 - 1)) as usize;
+        if (align_skip_len) != 0 {
+            // Read prefix up to alignment boundary
+            assert!(TDriver::READ_ALIGNMENT <= 16);
+            let mut tmp = [0_u8; 16];
+            let prefix_count = min(TDriver::READ_ALIGNMENT - align_skip_len, buf.len());
+            self.driver
+                .read(addr & !(TDriver::READ_ALIGNMENT - 1), &mut tmp)?;
+            buf[..prefix_count].copy_from_slice(&tmp[align_skip_len..][..prefix_count]);
+            buf = &mut buf[prefix_count..];
+            addr += prefix_count;
+        }
+        // Read remaining aligned chunks
+        for buf_chunk in buf.chunks_mut(TDriver::MAX_READ_SIZE) {
+            self.driver.read(addr, buf_chunk)?;
+            addr += buf_chunk.len();
+        }
+        Ok(())
+    }
+    /// Erases a block of flash.
+    ///
+    /// Starts the asynchronous erase operation and blocks the thread using `self.blocking`
+    /// until the operation completes.
+    fn erase(&mut self, start_addr: FlashAddress, size: PowerOf2Usize) -> Result<(), Self::Error> {
+        self.driver.start_erase(start_addr, size)?;
+        self.blocking.wait_for_notification();
+        self.driver.complete_op()
+    }
+    /// Programs data into flash.
+    ///
+    /// Splits the data into chunks that fit within the hardware's `PROGRAM_WINDOW_SIZE`
+    /// and do not cross window boundaries. Each chunk is programmed asynchronously,
+    /// and the thread blocks until it completes before starting the next chunk.
+    fn program(&mut self, start_addr: FlashAddress, mut data: &[u8]) -> Result<(), Self::Error> {
+        assert!(
+            TDriver::PROGRAM_WINDOW_SIZE.count_ones() == 1,
+            "TDriver::PROGRAM_WINDOW_SIZE must be a power of 2"
+        );
+        let window_mask = TDriver::PROGRAM_WINDOW_SIZE - 1;
+        let mut addr = start_addr;
+        while !data.is_empty() {
+            // Calculate bytes remaining in the current program window
+            let chunk = &data[..min(
+                data.len(),
+                TDriver::PROGRAM_WINDOW_SIZE - ((addr.offset() & window_mask as u32) as usize),
+            )];
+            self.driver.start_program(addr, chunk)?;
+            self.blocking.wait_for_notification();
+            self.driver.complete_op()?;
+            data = &data[chunk.len()..];
+            addr += chunk.len();
+        }
+        Ok(())
+    }
+}
+
+struct FlashRandomReader<'a, F: Flash>(&'a mut F);
+impl<F: Flash> RandomRead for FlashRandomReader<'_, F> {
+    type Error = F::Error;
+    fn read(&mut self, start_addr: usize, buf: &mut [u8]) -> Result<(), Self::Error> {
+        self.0.read(FlashAddress::new(start_addr as u32), buf)
+    }
+    fn size(&mut self) -> Result<usize, Self::Error> {
+        Ok(self.0.geometry()?.0.get())
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    pub struct FakeBlocking();
+    impl Blocking for FakeBlocking {
+        fn wait_for_notification(&self) {}
+    }
+
+    #[derive(Debug, Clone, Copy)]
+    pub struct FakeDriverError;
+    #[derive(Clone)]
+    pub struct FakeFlashDriver {
+        pub data: Vec<u8>,
+        pub check_err_result: Result<(), FakeDriverError>,
+    }
+    impl FakeFlashDriver {
+        pub fn new(data: Vec<u8>) -> Self {
+            Self {
+                data,
+                check_err_result: Ok(()),
+            }
+        }
+    }
+    impl FlashDriver for FakeFlashDriver {
+        type Error = FakeDriverError;
+        const PAGE_SIZE: usize = 2048;
+        const PROGRAM_WINDOW_SIZE: usize = 64;
+        const MAX_READ_SIZE: usize = 4096;
+        const READ_ALIGNMENT: usize = 4;
+        const PROGRAM_ALIGNMENT: usize = 8;
+
+        fn erasable_sizes_bitmap(&mut self) -> Result<u32, Self::Error> {
+            Ok(1 << 11)
+        }
+        fn size(&self) -> NonZero<usize> {
+            NonZero::new(self.data.len()).unwrap()
+        }
+        fn read(&mut self, start_addr: FlashAddress, buf: &mut [u8]) -> Result<(), Self::Error> {
+            let start_addr = start_addr.offset() as usize;
+            assert!(start_addr.checked_add(buf.len()).unwrap() <= self.data.len());
+            assert!(buf.len() <= Self::MAX_READ_SIZE);
+            assert!(start_addr % Self::READ_ALIGNMENT == 0);
+            buf.copy_from_slice(&self.data[start_addr..][..buf.len()]);
+            Ok(())
+        }
+        fn start_erase(
+            &mut self,
+            start_addr: FlashAddress,
+            size: PowerOf2Usize,
+        ) -> Result<(), Self::Error> {
+            let start_addr = start_addr.offset() as usize;
+            assert_eq!(size.get(), 2048);
+            assert!(start_addr.checked_add(size.get()).unwrap() <= self.data.len());
+            assert!(start_addr % size.get() == 0);
+            self.data[start_addr..][..size.get()].fill(0xff);
+            Ok(())
+        }
+        fn start_program(
+            &mut self,
+            start_addr: FlashAddress,
+            data: &[u8],
+        ) -> Result<(), Self::Error> {
+            let start_addr = start_addr.offset() as usize;
+            assert!(start_addr.checked_add(data.len()).unwrap() <= self.data.len());
+            assert!(
+                data.len() <= Self::PROGRAM_WINDOW_SIZE,
+                "Program window violation"
+            );
+            let end_addr = start_addr.wrapping_add(data.len());
+            assert!(
+                start_addr / Self::PROGRAM_WINDOW_SIZE
+                    == (end_addr - 1) / Self::PROGRAM_WINDOW_SIZE,
+                "Program window violation"
+            );
+            for (dest, src) in self.data[start_addr..end_addr].iter_mut().zip(data) {
+                *dest &= *src;
+            }
+            Ok(())
+        }
+        fn is_busy(&mut self) -> bool {
+            false
+        }
+        fn complete_op(&mut self) -> Result<(), Self::Error> {
+            self.check_err_result
+        }
+    }
+
+    #[test]
+    #[should_panic(expected = "Program window violation")]
+    pub fn test_fake_flash_program_window_violation_0() {
+        let mut flash_driver = FakeFlashDriver::new((0..255).collect());
+        flash_driver
+            .start_program(FlashAddress::new(0x3c), &[0x42; 5])
+            .unwrap();
+    }
+
+    #[test]
+    #[should_panic(expected = "Program window violation")]
+    pub fn test_fake_flash_program_window_violation_1() {
+        let mut flash_driver = FakeFlashDriver::new((0..255).collect());
+        flash_driver
+            .start_program(FlashAddress::new(0x0), &[0; 68])
+            .unwrap();
+    }
+
+    #[test]
+    pub fn test_fake_flash_full_program_window() {
+        let mut flash_driver = FakeFlashDriver::new((0..255).collect());
+        flash_driver
+            .start_program(FlashAddress::new(0x40), &[0; 0x40])
+            .unwrap();
+        assert_eq!(flash_driver.data[0x40..0x80], [0; 0x40]);
+    }
+
+    #[test]
+    pub fn test_size() {
+        let flash_driver = FakeFlashDriver::new((0..255).collect());
+        let mut flash = BlockingFlash {
+            driver: flash_driver,
+            blocking: FakeBlocking(),
+        };
+
+        assert_eq!(flash.geometry().unwrap().0.get(), 255);
+        let mut reader = flash.random_reader();
+        assert_eq!(reader.size().unwrap(), 255);
+    }
+
+    #[test]
+    pub fn test_read() {
+        let flash_driver = FakeFlashDriver::new((0..255).collect());
+
+        let mut flash = BlockingFlash {
+            driver: flash_driver,
+            blocking: FakeBlocking(),
+        };
+
+        let mut buf = [0_u8; 4];
+        flash.read(FlashAddress::new(0), &mut buf).unwrap();
+        assert_eq!(buf, [0_u8, 1, 2, 3]);
+
+        let mut buf = [0_u8; 4];
+        flash.read(FlashAddress::new(1), &mut buf).unwrap();
+        assert_eq!(buf, [1, 2, 3, 4]);
+
+        let mut buf = [0_u8; 4];
+        flash.read(FlashAddress::new(2), &mut buf).unwrap();
+        assert_eq!(buf, [2, 3, 4, 5]);
+
+        {
+            let mut reader = flash.random_reader();
+            let mut buf = [0_u8; 4];
+            reader.read(2, &mut buf).unwrap();
+            assert_eq!(buf, [2, 3, 4, 5]);
+        }
+
+        let mut buf = [0_u8; 4];
+        flash.read(FlashAddress::new(3), &mut buf).unwrap();
+        assert_eq!(buf, [3, 4, 5, 6]);
+
+        let mut buf = [0_u8; 6];
+        flash.read(FlashAddress::new(3), &mut buf).unwrap();
+        assert_eq!(buf, [3, 4, 5, 6, 7, 8]);
+
+        for i in 0..32 {
+            let mut buf = [0_u8; 32];
+            flash.read(FlashAddress::new(0), &mut buf[..i]).unwrap();
+            assert_eq!(&buf[..i], &flash.driver.data[..i]);
+        }
+
+        for i in 0..32 {
+            let mut buf = [0_u8; 32];
+            flash
+                .read(FlashAddress::new(32 - i as u32), &mut buf[..i])
+                .unwrap();
+            assert_eq!(&buf[..i], &flash.driver.data[32 - i..32]);
+        }
+    }
+
+    #[test]
+    pub fn test_erase() {
+        let mut flash = BlockingFlash {
+            driver: FakeFlashDriver::new(vec![0x42; 0x4000]),
+            blocking: FakeBlocking(),
+        };
+        flash
+            .erase(FlashAddress::new(0x0800), PowerOf2Usize::new(2048).unwrap())
+            .unwrap();
+        assert_eq!(flash.driver.data[0x0000..0x0800], [0x42; 0x0800]);
+        assert_eq!(flash.driver.data[0x0800..0x1000], [0xff; 0x0800]);
+        assert_eq!(flash.driver.data[0x1000..0x4000], [0x42; 0x3000]);
+
+        flash
+            .erase(FlashAddress::new(0x3000), PowerOf2Usize::new(2048).unwrap())
+            .unwrap();
+        assert_eq!(flash.driver.data[0x0000..0x0800], [0x42; 0x0800]);
+        assert_eq!(flash.driver.data[0x0800..0x1000], [0xff; 0x0800]);
+        assert_eq!(flash.driver.data[0x1000..0x3000], [0x42; 0x2000]);
+        assert_eq!(flash.driver.data[0x3000..0x3800], [0xff; 0x0800]);
+        assert_eq!(flash.driver.data[0x3800..0x4000], [0x42; 0x0800]);
+    }
+
+    #[test]
+    pub fn test_program() {
+        let mut flash = BlockingFlash {
+            driver: FakeFlashDriver::new(vec![0xff; 8192]),
+            blocking: FakeBlocking(),
+        };
+
+        flash
+            .program(
+                FlashAddress::new(0x3c),
+                &[0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
+            )
+            .unwrap();
+        assert_eq!(
+            flash.driver.data[0x38..0x44],
+            [0xff, 0xff, 0xff, 0xff, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0xff, 0xff]
+        );
+
+        flash
+            .program(FlashAddress::new(0x40), &[0x24, 0x25])
+            .unwrap();
+        assert_eq!(
+            flash.driver.data[0x38..0x44],
+            [0xff, 0xff, 0xff, 0xff, 0x10, 0x11, 0x12, 0x13, 0x04, 0x05, 0xff, 0xff]
+        );
+    }
+}
diff --git a/util/io/BUILD.bazel b/util/io/BUILD.bazel
new file mode 100644
index 0000000..9c68076
--- /dev/null
+++ b/util/io/BUILD.bazel
@@ -0,0 +1,23 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "io",
+    srcs = [
+        "io.rs",
+    ],
+    crate_name = "util_io",
+    edition = "2024",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//util/error",
+        "@pigweed//pw_status/rust:pw_status",
+    ],
+)
+
+rust_test(
+    name = "io_test",
+    crate = ":io",
+)
diff --git a/util/io/io.rs b/util/io/io.rs
new file mode 100644
index 0000000..1b834b8
--- /dev/null
+++ b/util/io/io.rs
@@ -0,0 +1,78 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Input/Output traits and utilities.
+
+#![no_std]
+
+use util_error::{ErrorCode, ErrorModule};
+
+/// The generic IO error module.
+pub const IO_GENERIC: ErrorModule = ErrorModule::new(0x494F); // ascii: IO
+/// The read operation is out of bounds.
+pub const IO_GENERIC_READ_OUT_OF_BOUNDS: ErrorCode =
+    IO_GENERIC.from_pw(1, pw_status::Error::OutOfRange);
+
+/// Trait for random access read operations.
+pub trait RandomRead {
+    type Error;
+    /// Reads data from the source into the destination buffer.
+    ///
+    /// # Arguments
+    /// * `start_addr`: The offset from which to start reading.
+    /// * `dst`: The buffer to read data into.
+    fn read(&mut self, start_addr: usize, dst: &mut [u8]) -> Result<(), Self::Error>;
+
+    /// Returns the total size of the readable data in bytes.
+    fn size(&mut self) -> Result<usize, Self::Error>;
+}
+
+impl RandomRead for &[u8] {
+    type Error = ErrorCode;
+    fn read(&mut self, start_addr: usize, dst: &mut [u8]) -> Result<(), Self::Error> {
+        // Explicit wrapping add. Overflows are expected to
+        // be detected in the indexing operation
+        let end_addr = start_addr.wrapping_add(dst.len());
+        let src = self
+            .get(start_addr..end_addr)
+            .ok_or(IO_GENERIC_READ_OUT_OF_BOUNDS)?;
+        dst.copy_from_slice(src);
+        Ok(())
+    }
+    fn size(&mut self) -> Result<usize, Self::Error> {
+        Ok(self.len())
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn should_read() {
+        let mut src: &[u8] = &[1, 2, 3, 4, 5, 6, 7, 8, 9];
+        let mut dst: [u8; 3] = [0; 3];
+        assert!(src.read(6, &mut dst).is_ok());
+        assert_eq!(&dst, &[7, 8, 9]);
+        assert_eq!(RandomRead::size(&mut src).unwrap(), 9);
+    }
+
+    #[test]
+    fn should_fail() {
+        let mut src: &[u8] = &[1, 2, 3, 4, 5, 6, 7, 8, 9];
+        let mut dst: [u8; 3] = [0; 3];
+        assert!(src.read(7, &mut dst).is_err());
+    }
+
+    #[test]
+    fn invalid_start_address_should_not_panic() {
+        let mut src: &[u8] = &[1, 2, 3, 4, 5, 6, 7, 8];
+        let mut dst: [u8; 4] = [0; 4];
+        // Set `start_addr` so that adding `dst.len()` causes it to
+        // wrap around and become smaller than `src.len()`
+        let start_addr: usize = usize::MAX - (dst.len() - 1);
+        // Should not panic
+        let result = src.read(start_addr, &mut dst);
+        assert!(result.is_err());
+    }
+}