//! PICOBOOT — flashing an RP2040/RP2350 from a page, without touching mass storage. //! //! # Why this exists //! //! A Pico in BOOTSEL mode is famous for mounting as a drive you drag a `.uf2` onto. That mass //! storage interface is genuinely closed to a browser: WebUSB may not claim protected device //! classes, and MSC is one. It is easy — and wrong — to stop there and conclude a page can never //! flash a Pico. //! //! The bootrom exposes a **second** interface alongside the drive: PICOBOOT, vendor-defined class //! `0xFF` with two bulk endpoints. Vendor class is exactly what WebUSB *is* allowed to claim, and //! PICOBOOT is what `picotool` speaks. So the door is open; it is simply a different door. //! //! This module is that protocol, sans-IO like the rest of the crate: it builds the 32-byte command //! packets and parses the 16-byte status replies, owning no USB handle, no clock and no retries. A //! driver moves the bytes — `nusb` on a workstation, WebUSB in a tab — and neither can drift from //! the other, because there is only one implementation. //! //! # Verified, not recalled //! //! Every constant here was read from primary sources rather than memory: `PICOBOOT_MAGIC`, the //! command ids and their `0x80` IN-direction bit, the packed `picoboot_cmd`/`picoboot_cmd_status` //! layouts and all eighteen status codes come from the Pico SDK's `boot/picoboot.h`; the USB ids //! from picotool's `picoboot_connection.h`; `XIP_BASE` from the SDK address maps for both chips. //! The UF2 magics and family ids were verified earlier against real forge output. //! //! # Scope //! //! The command set needed to put an image on flash — exclusive access, EXIT_XIP, erase, write, //! reboot — plus the UF2 parsing that turns a forge artefact into addressed payloads. `PC_EXEC`, //! `PC_VECTORIZE_FLASH`, OTP and `PC_GET_INFO` have verified opcodes but no argument builders, //! because their layouts were not read from a primary source here. Documented, not stubbed. //! //! **No RP2040 has been flashed with this.** The packets match the specification; first contact //! with real silicon is the validation step. /// Raspberry Pi's USB vendor id. pub const VENDOR_ID: u16 = 0x2e8a; /// Product id of an RP2040 in BOOTSEL mode. pub const PID_RP2040_BOOTSEL: u16 = 0x0003; /// Product id of an RP2350 in BOOTSEL mode. pub const PID_RP2350_BOOTSEL: u16 = 0x000f; /// First word of every command packet. pub const MAGIC: u32 = 0x431f_d10b; /// Vendor control request that resets the PICOBOOT interface. pub const IF_RESET: u8 = 0x41; /// Vendor control request that reads back a [`Status`]. pub const IF_CMD_STATUS: u8 = 0x42; /// A command packet is exactly this long. pub const CMD_LEN: usize = 32; /// A status reply is exactly this long. pub const STATUS_LEN: usize = 16; /// Where execute-in-place flash is mapped, on both RP2040 and RP2350. pub const XIP_BASE: u32 = 0x1000_0000; /// The smallest erasable unit of the QSPI flash. pub const FLASH_SECTOR_SIZE: u32 = 4096; /// The largest writable page. pub const FLASH_PAGE_SIZE: u32 = 256; /// A failure building or reading a PICOBOOT exchange. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PicobootError { /// A status reply was not [`STATUS_LEN`] bytes. ShortStatus(usize), /// The device reported a non-zero status code. Failed { /// The raw code. code: u32, /// Its documented meaning. reason: &'static str, }, /// The status reply's token did not match the command's. TokenMismatch { /// The token that was sent. sent: u32, /// The token that came back. got: u32, }, /// A UF2 image was not a whole number of 512-byte blocks. Uf2NotBlockAligned(usize), /// A UF2 block's magics were wrong. Uf2BadMagic { /// Index of the offending block. block: usize, }, /// A UF2 block claimed a payload longer than a block can hold. Uf2PayloadTooLong { /// Index of the offending block. block: usize, /// The size it claimed. claimed: u32, }, /// The image contained no flashable blocks. Uf2Empty, } impl core::fmt::Display for PicobootError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { PicobootError::ShortStatus(n) => write!(f, "status reply was {n} bytes, want 16"), PicobootError::Failed { code, reason } => { write!(f, "device returned {code} ({reason})") } PicobootError::TokenMismatch { sent, got } => { write!(f, "status token {got:#x} does not match command {sent:#x}") } PicobootError::Uf2NotBlockAligned(n) => { write!(f, "UF2 is {n} bytes, not a multiple of 512") } PicobootError::Uf2BadMagic { block } => write!(f, "UF2 block {block} has bad magic"), PicobootError::Uf2PayloadTooLong { block, claimed } => { write!(f, "UF2 block {block} claims a {claimed}-byte payload") } PicobootError::Uf2Empty => write!(f, "UF2 contained no payload blocks"), } } } impl std::error::Error for PicobootError {} /// A PICOBOOT command id. The top bit marks a device-to-host transfer, which is why `Read` and /// `GetInfo` sit above `0x80` — that bit is part of the id, not a flag alongside it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Command { /// Take (or release) exclusive access, locking out the mass-storage interface. ExclusiveAccess, /// Reboot. RP2040 only; RP2350 uses `Reboot2`. Reboot, /// Erase a flash range. Must be sector aligned. FlashErase, /// Read memory (device to host). Read, /// Write memory. Does **not** erase first. Write, /// Leave execute-in-place so flash commands are reliable. ExitXip, /// Re-enter execute-in-place. EnterCmdXip, /// Reboot, RP2350 form. Reboot2, /// Query the bootrom (device to host). RP2350 only. GetInfo, } impl Command { /// The wire id. #[must_use] pub const fn id(self) -> u8 { match self { Command::ExclusiveAccess => 0x01, Command::Reboot => 0x02, Command::FlashErase => 0x03, Command::Read => 0x84, Command::Write => 0x05, Command::ExitXip => 0x06, Command::EnterCmdXip => 0x07, Command::Reboot2 => 0x0a, Command::GetInfo => 0x8b, } } /// Whether the data stage flows device to host — the `0x80` bit of the id. #[must_use] pub const fn is_in_transfer(self) -> bool { self.id() & 0x80 != 0 } } /// How much of the mass-storage interface to lock out while flashing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Exclusive { /// Leave mass storage alone. No = 0, /// Disable mass-storage writes; aborts any UF2 download in flight. Yes = 1, /// Also mark the media absent, ejecting the drive. AndEject = 2, } /// A built 32-byte command packet. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Packet { /// The command it carries. pub command: Command, /// The token to match against the status reply. pub token: u32, /// Bytes of the data stage that follows, or 0. pub transfer_length: u32, bytes: [u8; CMD_LEN], } impl Packet { /// The wire bytes. #[must_use] pub const fn bytes(&self) -> &[u8; CMD_LEN] { &self.bytes } fn build(command: Command, token: u32, args: &[u8], transfer_length: u32) -> Packet { debug_assert!(args.len() <= 16, "the args union is 16 bytes"); let mut b = [0u8; CMD_LEN]; b[0..4].copy_from_slice(&MAGIC.to_le_bytes()); b[4..8].copy_from_slice(&token.to_le_bytes()); b[8] = command.id(); b[9] = args.len() as u8; // b[10..12] is the reserved u16 and must stay zero. b[12..16].copy_from_slice(&transfer_length.to_le_bytes()); b[16..16 + args.len()].copy_from_slice(args); Packet { command, token, transfer_length, bytes: b, } } } fn range_args(addr: u32, size: u32) -> [u8; 8] { let mut a = [0u8; 8]; a[0..4].copy_from_slice(&addr.to_le_bytes()); a[4..8].copy_from_slice(&size.to_le_bytes()); a } /// `PC_EXCLUSIVE_ACCESS` — take the device away from its own mass-storage interface. #[must_use] pub fn exclusive_access(token: u32, mode: Exclusive) -> Packet { Packet::build(Command::ExclusiveAccess, token, &[mode as u8], 0) } /// `PC_EXIT_XIP` — required before flash writes are reliable. Carries no arguments. #[must_use] pub fn exit_xip(token: u32) -> Packet { Packet::build(Command::ExitXip, token, &[], 0) } /// `PC_ENTER_CMD_XIP` — put execute-in-place back. #[must_use] pub fn enter_cmd_xip(token: u32) -> Packet { Packet::build(Command::EnterCmdXip, token, &[], 0) } /// `PC_FLASH_ERASE` — erase `size` bytes at `addr`. Both must be sector aligned; the device /// answers `BadAlignment` rather than guessing, so [`erase_plan`] exists to get it right first. #[must_use] pub fn flash_erase(token: u32, addr: u32, size: u32) -> Packet { Packet::build(Command::FlashErase, token, &range_args(addr, size), 0) } /// `PC_WRITE` — the packet announcing a write; `size` bytes then follow on the OUT endpoint. /// Does not erase, so the range must already have been erased. #[must_use] pub fn write(token: u32, addr: u32, size: u32) -> Packet { Packet::build(Command::Write, token, &range_args(addr, size), size) } /// `PC_READ` — the packet announcing a read; `size` bytes then arrive on the IN endpoint. #[must_use] pub fn read(token: u32, addr: u32, size: u32) -> Packet { Packet::build(Command::Read, token, &range_args(addr, size), size) } /// `PC_REBOOT` (RP2040): jump to `pc` with `sp`, after `delay_ms`. A zeroed pc/sp asks the bootrom /// to restart the flash image, which is what a flasher wants once it is done. #[must_use] pub fn reboot(token: u32, pc: u32, sp: u32, delay_ms: u32) -> Packet { let mut a = [0u8; 12]; a[0..4].copy_from_slice(&pc.to_le_bytes()); a[4..8].copy_from_slice(&sp.to_le_bytes()); a[8..12].copy_from_slice(&delay_ms.to_le_bytes()); Packet::build(Command::Reboot, token, &a, 0) } /// The documented meaning of a status code. #[must_use] pub fn status_reason(code: u32) -> &'static str { match code { 0 => "ok", 1 => "unknown command", 2 => "invalid command length", 3 => "invalid transfer length", 4 => "invalid address", 5 => "bad alignment", 6 => "interleaved write", 7 => "rebooting", 8 => "unknown error", 9 => "invalid state", 10 => "not permitted", 11 => "invalid argument", 12 => "buffer too small", 13 => "precondition not met", 14 => "modified data", 15 => "invalid data", 16 => "not found", 17 => "unsupported modification", _ => "undocumented status", } } /// A parsed 16-byte status reply. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Status { /// Echo of the command's token. pub token: u32, /// The result code. pub code: u32, /// The command this refers to. pub cmd_id: u8, /// Whether the device is still working on it. pub in_progress: bool, } impl Status { /// Parse a status reply. pub fn parse(bytes: &[u8]) -> Result { if bytes.len() < STATUS_LEN { return Err(PicobootError::ShortStatus(bytes.len())); } Ok(Status { token: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), code: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), cmd_id: bytes[8], in_progress: bytes[9] != 0, }) } /// Whether the command succeeded. #[must_use] pub const fn is_ok(self) -> bool { self.code == 0 } /// Check this status against the packet it should answer. pub fn check(self, sent: &Packet) -> Result { if self.token != sent.token { return Err(PicobootError::TokenMismatch { sent: sent.token, got: self.token, }); } if !self.is_ok() { return Err(PicobootError::Failed { code: self.code, reason: status_reason(self.code), }); } Ok(self) } } /// Which chip a BOOTSEL product id belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Chip { /// RP2040. Rp2040, /// RP2350. Rp2350, } impl Chip { /// Identify a device from its USB ids, or `None` if it is not a Pico in BOOTSEL mode. #[must_use] pub const fn from_usb(vid: u16, pid: u16) -> Option { if vid != VENDOR_ID { return None; } match pid { PID_RP2040_BOOTSEL => Some(Chip::Rp2040), PID_RP2350_BOOTSEL => Some(Chip::Rp2350), _ => None, } } /// Identify a chip from a board registry's part string. /// /// Gating on the FLASH METHOD alone is not enough: `FlashMethod::Uf2` also covers parts whose /// UF2 bootloader is not the RP2 bootrom — a SAMD51 mounts a drive too, and has no PICOBOOT /// interface behind it. Offering a PICOBOOT button for one of those is a click that can only /// fail, so the caller asks this and refuses when it answers `None`. #[must_use] pub fn from_part_name(part: &str) -> Option { let p = part.to_ascii_lowercase(); if p.contains("rp2040") { Some(Chip::Rp2040) } else if p.contains("rp2350") { Some(Chip::Rp2350) } else { None } } /// Every chip this module can drive. pub const ALL: [Chip; 2] = [Chip::Rp2040, Chip::Rp2350]; /// The BOOTSEL product id this chip presents. #[must_use] pub const fn bootsel_pid(self) -> u16 { match self { Chip::Rp2040 => PID_RP2040_BOOTSEL, Chip::Rp2350 => PID_RP2350_BOOTSEL, } } /// The name to show a user. #[must_use] pub const fn name(self) -> &'static str { match self { Chip::Rp2040 => "RP2040", Chip::Rp2350 => "RP2350", } } } // --- UF2, the container the forge actually emits --------------------------------------------- /// A UF2 block is always this long, payload or not. pub const UF2_BLOCK_LEN: usize = 512; /// First magic word (`"UF2\n"`). pub const UF2_MAGIC_START0: u32 = 0x0A32_4655; /// Second magic word. pub const UF2_MAGIC_START1: u32 = 0x9E5D_5157; /// Trailing magic word. pub const UF2_MAGIC_END: u32 = 0x0AB1_6F30; /// Family id for RP2040. pub const UF2_FAMILY_RP2040: u32 = 0xE48B_FF56; /// Family id for RP2350 in Arm secure mode. pub const UF2_FAMILY_RP2350_ARM_S: u32 = 0xE48B_FF59; /// Flag bit meaning the block is not part of the image and must not be written. const UF2_FLAG_NOT_MAIN_FLASH: u32 = 0x0000_0001; /// One addressed payload extracted from a UF2 file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Uf2Chunk { /// Where this payload belongs. pub addr: u32, /// The payload itself. pub data: Vec, } /// Parse a UF2 image into the payloads it wants written. /// /// Blocks flagged `NOT_MAIN_FLASH` are skipped rather than written — that flag exists precisely to /// carry metadata a flasher must not put on the device. pub fn parse_uf2(image: &[u8]) -> Result, PicobootError> { // `is_empty` stays explicit: 0 IS a multiple of 512, so folding it into the // divisibility check would accept an empty file as a valid zero-block image. if image.is_empty() || !image.len().is_multiple_of(UF2_BLOCK_LEN) { return Err(PicobootError::Uf2NotBlockAligned(image.len())); } let word = |b: &[u8], at: usize| u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); let mut out = Vec::with_capacity(image.len() / UF2_BLOCK_LEN); for (i, block) in image.as_chunks::().0.iter().enumerate() { if word(block, 0) != UF2_MAGIC_START0 || word(block, 4) != UF2_MAGIC_START1 || word(block, UF2_BLOCK_LEN - 4) != UF2_MAGIC_END { return Err(PicobootError::Uf2BadMagic { block: i }); } let flags = word(block, 8); let addr = word(block, 12); let payload_size = word(block, 16); if flags & UF2_FLAG_NOT_MAIN_FLASH != 0 { continue; } // The payload lives at offset 32 and the trailing magic is the last word, so this is the // most a block can carry. A larger claim is a malformed file, not a big block. if payload_size as usize > UF2_BLOCK_LEN - 32 - 4 { return Err(PicobootError::Uf2PayloadTooLong { block: i, claimed: payload_size, }); } out.push(Uf2Chunk { addr, data: block[32..32 + payload_size as usize].to_vec(), }); } if out.is_empty() { return Err(PicobootError::Uf2Empty); } Ok(out) } /// Merge consecutive chunks that are physically adjacent into single runs. /// /// A UF2 carries 256 bytes per block, so a 1 MB image is 4096 separate writes if taken literally. /// Coalescing them turns that into a handful, which is the difference between a flash that takes /// seconds and one that takes minutes. Non-adjacent chunks start a new run, so a sparse image is /// still written correctly rather than being padded across its gaps. #[must_use] pub fn coalesce(chunks: &[Uf2Chunk]) -> Vec { let mut runs: Vec = Vec::new(); for c in chunks { match runs.last_mut() { Some(run) if run.addr + run.data.len() as u32 == c.addr => { run.data.extend_from_slice(&c.data); } _ => runs.push(c.clone()), } } runs } /// The sector-aligned erase each run needs before it can be written. /// /// `PC_FLASH_ERASE` rejects an unaligned address or size outright, and `PC_WRITE` does not erase, /// so a flasher that skips this writes into un-erased flash and silently corrupts the image. The /// range is widened down to the sector containing the start and up to the sector containing the /// end. #[must_use] pub fn erase_plan(runs: &[Uf2Chunk]) -> Vec<(u32, u32)> { let mut plan: Vec<(u32, u32)> = Vec::new(); for r in runs { let start = r.addr & !(FLASH_SECTOR_SIZE - 1); let end_byte = r.addr + r.data.len() as u32; let end = end_byte.div_ceil(FLASH_SECTOR_SIZE) * FLASH_SECTOR_SIZE; match plan.last_mut() { // Two runs inside one sector must not be erased twice — the second erase would wipe // the first run's freshly written bytes. Some(last) if last.0 + last.1 >= start => { let new_end = end.max(last.0 + last.1); last.1 = new_end - last.0; } _ => plan.push((start, end - start)), } } plan } #[cfg(test)] mod tests { use super::*; /// Build a syntactically valid UF2 block. fn uf2_block(addr: u32, payload: &[u8], flags: u32, block_no: u32, total: u32) -> Vec { let mut b = vec![0u8; UF2_BLOCK_LEN]; b[0..4].copy_from_slice(&UF2_MAGIC_START0.to_le_bytes()); b[4..8].copy_from_slice(&UF2_MAGIC_START1.to_le_bytes()); b[8..12].copy_from_slice(&flags.to_le_bytes()); b[12..16].copy_from_slice(&addr.to_le_bytes()); b[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes()); b[20..24].copy_from_slice(&block_no.to_le_bytes()); b[24..28].copy_from_slice(&total.to_le_bytes()); b[28..32].copy_from_slice(&UF2_FAMILY_RP2040.to_le_bytes()); b[32..32 + payload.len()].copy_from_slice(payload); b[UF2_BLOCK_LEN - 4..].copy_from_slice(&UF2_MAGIC_END.to_le_bytes()); b } #[test] fn the_command_packet_matches_the_sdk_layout() { let p = exclusive_access(0xdead_beef, Exclusive::AndEject); let b = p.bytes(); assert_eq!(b.len(), 32, "picoboot_cmd is 32 bytes"); assert_eq!(&b[0..4], &0x431f_d10bu32.to_le_bytes(), "PICOBOOT_MAGIC"); assert_eq!(&b[4..8], &0xdead_beefu32.to_le_bytes(), "dToken"); assert_eq!(b[8], 0x01, "PC_EXCLUSIVE_ACCESS"); assert_eq!(b[9], 1, "bCmdSize is the arg byte count"); assert_eq!(&b[10..12], &[0, 0], "the reserved u16 must be zero"); assert_eq!(&b[12..16], &0u32.to_le_bytes(), "no data stage"); assert_eq!(b[16], 2, "EXCLUSIVE_AND_EJECT"); } #[test] fn the_opcodes_are_the_sdk_values_and_the_in_bit_is_part_of_them() { assert_eq!(Command::ExclusiveAccess.id(), 0x01); assert_eq!(Command::Reboot.id(), 0x02); assert_eq!(Command::FlashErase.id(), 0x03); assert_eq!(Command::Read.id(), 0x84); assert_eq!(Command::Write.id(), 0x05); assert_eq!(Command::ExitXip.id(), 0x06); assert_eq!(Command::EnterCmdXip.id(), 0x07); assert_eq!(Command::Reboot2.id(), 0x0a); assert_eq!(Command::GetInfo.id(), 0x8b); // The direction is encoded IN the id, not beside it. assert!(Command::Read.is_in_transfer() && Command::GetInfo.is_in_transfer()); assert!(!Command::Write.is_in_transfer() && !Command::FlashErase.is_in_transfer()); } #[test] fn a_range_command_carries_addr_then_size_and_write_announces_its_data_stage() { let p = write(7, XIP_BASE + 0x1000, 256); let b = p.bytes(); assert_eq!(b[8], 0x05); assert_eq!(b[9], 8, "range_cmd is two u32s"); assert_eq!(&b[16..20], &(XIP_BASE + 0x1000).to_le_bytes(), "dAddr"); assert_eq!(&b[20..24], &256u32.to_le_bytes(), "dSize"); assert_eq!( &b[12..16], &256u32.to_le_bytes(), "a write announces the bytes that follow" ); // An argument-less command says so, rather than sending stale bytes. let x = exit_xip(7); assert_eq!(x.bytes()[9], 0, "bCmdSize == 0"); assert!(x.bytes()[16..].iter().all(|&v| v == 0)); } #[test] fn a_status_reply_is_checked_against_its_command() { let sent = flash_erase(0x1234, XIP_BASE, FLASH_SECTOR_SIZE); let mut reply = [0u8; STATUS_LEN]; reply[0..4].copy_from_slice(&0x1234u32.to_le_bytes()); reply[8] = 0x03; let s = Status::parse(&reply).unwrap().check(&sent).unwrap(); assert!(s.is_ok() && !s.in_progress); // A failure is reported with its documented meaning, not just a number. reply[4..8].copy_from_slice(&5u32.to_le_bytes()); let err = Status::parse(&reply).unwrap().check(&sent).unwrap_err(); assert_eq!( err, PicobootError::Failed { code: 5, reason: "bad alignment" } ); // A reply for a DIFFERENT command must not be read as this one's success. reply[4..8].copy_from_slice(&0u32.to_le_bytes()); reply[0..4].copy_from_slice(&0x9999u32.to_le_bytes()); assert!(matches!( Status::parse(&reply).unwrap().check(&sent), Err(PicobootError::TokenMismatch { .. }) )); // A truncated reply is an error, never a silent success. assert_eq!(Status::parse(&[0u8; 8]), Err(PicobootError::ShortStatus(8))); } #[test] fn bootsel_devices_are_identified_and_nothing_else_is() { assert_eq!(Chip::from_usb(0x2e8a, 0x0003), Some(Chip::Rp2040)); assert_eq!(Chip::from_usb(0x2e8a, 0x000f), Some(Chip::Rp2350)); // A Pico running user code (stdio USB) is NOT in BOOTSEL and must not be offered. assert_eq!(Chip::from_usb(0x2e8a, 0x000a), None); assert_eq!( Chip::from_usb(0x2e8a, 0x0004), None, "picoprobe is not a target" ); assert_eq!( Chip::from_usb(0x1234, 0x0003), None, "vendor must match too" ); } #[test] fn only_rp2_parts_are_claimed_so_a_samd_uf2_board_is_not_offered() { assert_eq!(Chip::from_part_name("RP2040"), Some(Chip::Rp2040)); assert_eq!(Chip::from_part_name("rp2350"), Some(Chip::Rp2350)); // A SAMD51 mounts a UF2 drive too and has no PICOBOOT behind it. Claiming it would put // a button in front of a board this protocol cannot reach. assert_eq!(Chip::from_part_name("ATSAMD51P19"), None); assert_eq!(Chip::from_part_name("nRF52833"), None); // The BOOTSEL ids round-trip with the USB identification. for c in Chip::ALL { assert_eq!(Chip::from_usb(VENDOR_ID, c.bootsel_pid()), Some(c)); } } #[test] fn a_uf2_parses_into_addressed_payloads() { let mut img = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 2); img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2)); let chunks = parse_uf2(&img).unwrap(); assert_eq!(chunks.len(), 2); assert_eq!(chunks[0].addr, XIP_BASE); assert_eq!(chunks[0].data, vec![0xAA; 256]); assert_eq!(chunks[1].addr, XIP_BASE + 256); } #[test] fn a_not_main_flash_block_is_skipped_not_written() { // That flag exists to carry metadata; writing it would put non-image bytes on the device. let mut img = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 2); img.extend(uf2_block(XIP_BASE + 256, &[0xBB; 256], 0, 1, 2)); let chunks = parse_uf2(&img).unwrap(); assert_eq!(chunks.len(), 1, "the flagged block must not be flashed"); assert_eq!(chunks[0].addr, XIP_BASE + 256); } #[test] fn malformed_uf2_images_are_rejected_rather_than_half_flashed() { assert_eq!(parse_uf2(&[]), Err(PicobootError::Uf2NotBlockAligned(0))); assert_eq!( parse_uf2(&[0u8; 700]), Err(PicobootError::Uf2NotBlockAligned(700)) ); assert_eq!( parse_uf2(&[0u8; 512]), Err(PicobootError::Uf2BadMagic { block: 0 }) ); // A payload larger than a block can hold is a malformed file, and reading it would run // past the block into the next one's header. let mut bad = uf2_block(XIP_BASE, &[0xAA; 256], 0, 0, 1); bad[16..20].copy_from_slice(&500u32.to_le_bytes()); assert_eq!( parse_uf2(&bad), Err(PicobootError::Uf2PayloadTooLong { block: 0, claimed: 500 }) ); // Every block flagged out leaves nothing to write, which is worth saying explicitly. let empty = uf2_block(XIP_BASE, &[0xAA; 256], UF2_FLAG_NOT_MAIN_FLASH, 0, 1); assert_eq!(parse_uf2(&empty), Err(PicobootError::Uf2Empty)); } #[test] fn adjacent_chunks_coalesce_and_gaps_do_not() { let chunks = vec![ Uf2Chunk { addr: 0x1000_0000, data: vec![1; 256], }, Uf2Chunk { addr: 0x1000_0100, data: vec![2; 256], }, // A gap: this must start a new run rather than be padded onto the previous one. Uf2Chunk { addr: 0x1001_0000, data: vec![3; 256], }, ]; let runs = coalesce(&chunks); assert_eq!(runs.len(), 2, "two contiguous, one detached"); assert_eq!(runs[0].addr, 0x1000_0000); assert_eq!(runs[0].data.len(), 512, "the adjacent pair merged"); assert_eq!(runs[1].addr, 0x1001_0000); assert_eq!(runs[1].data.len(), 256); } #[test] fn the_erase_plan_is_sector_aligned_and_never_erases_written_bytes_twice() { // A run that starts mid-sector must erase from the sector boundary... let runs = vec![Uf2Chunk { addr: XIP_BASE + 100, data: vec![0; 10], }]; assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE)]); // ...and one byte past a sector must erase two. let runs = vec![Uf2Chunk { addr: XIP_BASE, data: vec![0; FLASH_SECTOR_SIZE as usize + 1], }]; assert_eq!(erase_plan(&runs), vec![(XIP_BASE, FLASH_SECTOR_SIZE * 2)]); // Two runs sharing a sector must yield ONE erase. Two would wipe the first run's bytes // after they had already been written. let runs = vec![ Uf2Chunk { addr: XIP_BASE, data: vec![0; 256], }, Uf2Chunk { addr: XIP_BASE + 1024, data: vec![0; 256], }, ]; let plan = erase_plan(&runs); assert_eq!(plan.len(), 1, "one shared sector, one erase"); assert_eq!(plan[0], (XIP_BASE, FLASH_SECTOR_SIZE)); // Every plan entry is sector aligned in both address and size. for (addr, size) in erase_plan(&runs) { assert_eq!(addr % FLASH_SECTOR_SIZE, 0); assert_eq!(size % FLASH_SECTOR_SIZE, 0); } } }