From fd4fc854f9c7b4a76b5b0866aa5b16a07f1e65f5 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 20 Oct 2026 19:27:41 -0700 Subject: [PATCH] add safe byte codecs for the typed protocol payloads --- CHANGELOG.md | 6 ++ cli/Cargo.lock | 2 +- lib/protocol/Cargo.toml | 2 +- lib/protocol/src/frame.rs | 157 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1da5df..019ccef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Releases up through `v0.4.0` were workspace-wide and tagged `vX.Y.Z`. From `0.4. ## [Unreleased] +## [tinyboot-protocol 0.5.1] - 2026-10-20 + +### Added + +- `InfoData`, `EraseData` and `VerifyData` gain `from_bytes` and `to_bytes`, so a host reads and writes them from plain byte slices without going through the `Data` union. `EraseData` and `VerifyData` gain `SIZE` like `InfoData`. + ## [tinyboot-core 0.6.1] - 2026-10-20 ### Added diff --git a/cli/Cargo.lock b/cli/Cargo.lock index b73b108..0e03949 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -531,7 +531,7 @@ dependencies = [ [[package]] name = "tinyboot-protocol" -version = "0.5.0" +version = "0.5.1" dependencies = [ "bitflags 2.11.1", "embedded-io", diff --git a/lib/protocol/Cargo.toml b/lib/protocol/Cargo.toml index 0b9a1ab..2901ad9 100644 --- a/lib/protocol/Cargo.toml +++ b/lib/protocol/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tinyboot-protocol" description = "Protocol implementation for tinyboot bootloader" -version = "0.5.0" +version = "0.5.1" edition.workspace = true repository.workspace = true license.workspace = true diff --git a/lib/protocol/src/frame.rs b/lib/protocol/src/frame.rs index 6ed0a89..069b195 100644 --- a/lib/protocol/src/frame.rs +++ b/lib/protocol/src/frame.rs @@ -40,6 +40,38 @@ pub struct InfoData { impl InfoData { /// Encoded size in bytes. pub const SIZE: u16 = core::mem::size_of::() as u16; + + /// Decode from the start of `bytes` (little-endian fields in declaration + /// order); `None` when `bytes` is shorter than [`SIZE`](Self::SIZE). + /// Bytes past `SIZE` are ignored, so a longer reply from a newer device + /// still decodes. + pub fn from_bytes(bytes: &[u8]) -> Option { + let b = bytes.get(..Self::SIZE as usize)?; + Some(Self { + capacity: u32::from_le_bytes([b[0], b[1], b[2], b[3]]), + erase_size: u16::from_le_bytes([b[4], b[5]]), + boot_version: u16::from_le_bytes([b[6], b[7]]), + app_version: u16::from_le_bytes([b[8], b[9]]), + mode: b[10], + boot_baud: b[11], + compat: u32::from_le_bytes([b[12], b[13], b[14], b[15]]), + max_payload: u16::from_le_bytes([b[16], b[17]]), + }) + } + + /// Encode as little-endian fields in declaration order. + pub fn to_bytes(&self) -> [u8; Self::SIZE as usize] { + let mut b = [0; Self::SIZE as usize]; + b[0..4].copy_from_slice(&{ self.capacity }.to_le_bytes()); + b[4..6].copy_from_slice(&{ self.erase_size }.to_le_bytes()); + b[6..8].copy_from_slice(&{ self.boot_version }.to_le_bytes()); + b[8..10].copy_from_slice(&{ self.app_version }.to_le_bytes()); + b[10] = self.mode; + b[11] = self.boot_baud; + b[12..16].copy_from_slice(&{ self.compat }.to_le_bytes()); + b[16..18].copy_from_slice(&{ self.max_payload }.to_le_bytes()); + b + } } /// Typed Erase request data. @@ -50,6 +82,27 @@ pub struct EraseData { pub byte_count: u16, } +impl EraseData { + /// Encoded size in bytes. + pub const SIZE: u16 = core::mem::size_of::() as u16; + + /// Decode from the start of `bytes` (little-endian fields in declaration + /// order); `None` when `bytes` is shorter than [`SIZE`](Self::SIZE). + /// Bytes past `SIZE` are ignored, so a longer reply from a newer device + /// still decodes. + pub fn from_bytes(bytes: &[u8]) -> Option { + let b = bytes.get(..Self::SIZE as usize)?; + Some(Self { + byte_count: u16::from_le_bytes([b[0], b[1]]), + }) + } + + /// Encode as little-endian fields in declaration order. + pub fn to_bytes(&self) -> [u8; Self::SIZE as usize] { + self.byte_count.to_le_bytes() + } +} + /// Typed Verify response data. #[repr(C)] #[derive(Clone, Copy)] @@ -58,6 +111,27 @@ pub struct VerifyData { pub crc: u16, } +impl VerifyData { + /// Encoded size in bytes. + pub const SIZE: u16 = core::mem::size_of::() as u16; + + /// Decode from the start of `bytes` (little-endian fields in declaration + /// order); `None` when `bytes` is shorter than [`SIZE`](Self::SIZE). + /// Bytes past `SIZE` are ignored, so a longer reply from a newer device + /// still decodes. + pub fn from_bytes(bytes: &[u8]) -> Option { + let b = bytes.get(..Self::SIZE as usize)?; + Some(Self { + crc: u16::from_le_bytes([b[0], b[1]]), + }) + } + + /// Encode as little-endian fields in declaration order. + pub fn to_bytes(&self) -> [u8; Self::SIZE as usize] { + self.crc.to_le_bytes() + } +} + /// Per-command flags occupying addr byte 3. /// /// Each command can define its own bitflags type as a union variant. @@ -448,6 +522,89 @@ mod tests { assert_eq!(InfoData::SIZE, 18); } + fn union_bytes(d: &Data) -> [u8; L] { + let mut b = [0; L]; + b.copy_from_slice(unsafe { &d.raw[..L] }); + b + } + + fn info() -> InfoData { + InfoData { + capacity: 0x0403_0201, + erase_size: 0x0605, + boot_version: 0x0807, + app_version: 0x0A09, + mode: 0x0B, + boot_baud: 0x0C, + compat: 0x100F_0E0D, + max_payload: 0x1211, + } + } + + fn assert_info_eq(a: &InfoData, b: &InfoData) { + assert_eq!({ a.capacity }, { b.capacity }); + assert_eq!({ a.erase_size }, { b.erase_size }); + assert_eq!({ a.boot_version }, { b.boot_version }); + assert_eq!({ a.app_version }, { b.app_version }); + assert_eq!(a.mode, b.mode); + assert_eq!(a.boot_baud, b.boot_baud); + assert_eq!({ a.compat }, { b.compat }); + assert_eq!({ a.max_payload }, { b.max_payload }); + } + + #[test] + fn info_bytes_match_union() { + let d = Data { info: info() }; + let raw = union_bytes::<{ InfoData::SIZE as usize }>(&d); + assert_info_eq(&InfoData::from_bytes(&raw).unwrap(), &info()); + assert_eq!(info().to_bytes(), raw); + } + + #[test] + fn info_from_bytes_length() { + let raw = info().to_bytes(); + assert!(InfoData::from_bytes(&raw[..raw.len() - 1]).is_none()); + let mut long = [0xEE; InfoData::SIZE as usize + 3]; + long[..raw.len()].copy_from_slice(&raw); + assert_info_eq(&InfoData::from_bytes(&long).unwrap(), &info()); + } + + #[test] + fn erase_bytes_match_union() { + let d = Data { + erase: EraseData { byte_count: 0x0201 }, + }; + let raw = union_bytes::<{ EraseData::SIZE as usize }>(&d); + assert_eq!(EraseData::from_bytes(&raw).unwrap().byte_count, 0x0201); + assert_eq!(EraseData { byte_count: 0x0201 }.to_bytes(), raw); + } + + #[test] + fn erase_from_bytes_length() { + assert_eq!(EraseData::SIZE, 2); + assert!(EraseData::from_bytes(&[0x01]).is_none()); + let long = [0x01, 0x02, 0xEE, 0xEE, 0xEE]; + assert_eq!(EraseData::from_bytes(&long).unwrap().byte_count, 0x0201); + } + + #[test] + fn verify_bytes_match_union() { + let d = Data { + verify: VerifyData { crc: 0x0201 }, + }; + let raw = union_bytes::<{ VerifyData::SIZE as usize }>(&d); + assert_eq!(VerifyData::from_bytes(&raw).unwrap().crc, 0x0201); + assert_eq!(VerifyData { crc: 0x0201 }.to_bytes(), raw); + } + + #[test] + fn verify_from_bytes_length() { + assert_eq!(VerifyData::SIZE, 2); + assert!(VerifyData::from_bytes(&[0x01]).is_none()); + let long = [0x01, 0x02, 0xEE, 0xEE, 0xEE]; + assert_eq!(VerifyData::from_bytes(&long).unwrap().crc, 0x0201); + } + #[test] fn large_payload_round_trip() { let data = [0x3C; 240];