mk_codec/string_layer/header.rs
1//! 5-bit-symbol-aligned string-layer header (single-string + chunked variants).
2//!
3//! Per `design/SPEC_mk_v0_1.md` §2.5 and closure Q-5, mk1's string-layer
4//! header lives at the bech32 5-bit symbol layer rather than the byte
5//! layer. Encoders emit either a 2-symbol [`StringLayerHeader::SingleString`]
6//! header (`version + type=0x00`) or an 8-symbol [`StringLayerHeader::Chunked`]
7//! header (`version + type=0x01 + chunk_set_id + total_chunks + chunk_index`).
8//!
9//! All field widths are exactly 5 bits unless otherwise noted. The
10//! `chunk_set_id` is the only multi-symbol field — 20 bits = 4 symbols.
11
12use crate::consts::MAX_CHUNKS;
13use crate::error::{Error, Result};
14
15/// Type-byte values for the 5-bit `type` field (closure Q-5).
16const TYPE_SINGLE: u8 = 0x00;
17const TYPE_CHUNKED: u8 = 0x01;
18
19/// Number of 5-bit symbols in the single-string header (`version + type`).
20pub const SINGLE_HEADER_SYMBOLS: usize = 2;
21
22/// Number of 5-bit symbols in the chunked header
23/// (`version + type + 4·chunk_set_id + total_chunks + chunk_index`).
24pub const CHUNKED_HEADER_SYMBOLS: usize = 8;
25
26/// Maximum allowed value of `chunk_set_id` (20-bit field).
27pub const MAX_CHUNK_SET_ID: u32 = (1 << 20) - 1;
28
29/// Format-version field value emitted in v0.1.
30pub const VERSION_V0_1: u8 = 0x00;
31
32/// String-layer header for one mk1 chunk.
33#[non_exhaustive]
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum StringLayerHeader {
36 /// Card fits in one mk1 string; no chunking. Carries no chunk-set
37 /// identifier or index because the format is unambiguous.
38 SingleString {
39 /// 5-bit format version (`0` in v0.1).
40 version: u8,
41 },
42 /// One chunk in a multi-chunk encoding. All chunks of one card share
43 /// the same `version`, `chunk_set_id`, and `total_chunks`; only
44 /// `chunk_index` varies.
45 Chunked {
46 /// 5-bit format version (`0` in v0.1).
47 version: u8,
48 /// 20-bit per-encoding random tag for reassembly mismatch
49 /// detection. Decoders compare across chunks; mismatch is
50 /// rejected with [`Error::ChunkSetIdMismatch`].
51 chunk_set_id: u32,
52 /// Total number of chunks in this set, in `1..=MAX_CHUNKS`.
53 total_chunks: u8,
54 /// Zero-based index of this chunk within the set.
55 chunk_index: u8,
56 },
57}
58
59impl StringLayerHeader {
60 /// Emit this header as a sequence of 5-bit symbols.
61 ///
62 /// The output length is [`SINGLE_HEADER_SYMBOLS`] (= 2) for
63 /// [`StringLayerHeader::SingleString`] and [`CHUNKED_HEADER_SYMBOLS`]
64 /// (= 8) for [`StringLayerHeader::Chunked`]. The caller prepends
65 /// these symbols to `bytes_to_5bit(fragment)` to form a chunk's
66 /// data part before BCH checksumming.
67 pub fn to_5bit_symbols(self) -> Vec<u8> {
68 match self {
69 StringLayerHeader::SingleString { version } => {
70 vec![version & 0x1F, TYPE_SINGLE]
71 }
72 StringLayerHeader::Chunked {
73 version,
74 chunk_set_id,
75 total_chunks,
76 chunk_index,
77 } => {
78 // chunk_set_id is 20 bits; pack as four 5-bit symbols
79 // big-endian (bits 19..15, 14..10, 9..5, 4..0).
80 let csid = chunk_set_id & MAX_CHUNK_SET_ID;
81 // total_chunks is the user-facing 1..=32 count. The 5-bit
82 // wire field can only hold 0..=31, so we encode `count - 1`
83 // here and decode back via `wire + 1` in `from_5bit_symbols`.
84 // (`design/SPEC_mk_v0_1.md` §2.5 documents the range as
85 // `1..=32`; the off-by-one wire encoding is the only way
86 // to honour both the 5-bit field width and the closure-
87 // locked 32-chunk capacity.)
88 let total_chunks_wire = (total_chunks - 1) & 0x1F;
89 vec![
90 version & 0x1F,
91 TYPE_CHUNKED,
92 ((csid >> 15) & 0x1F) as u8,
93 ((csid >> 10) & 0x1F) as u8,
94 ((csid >> 5) & 0x1F) as u8,
95 (csid & 0x1F) as u8,
96 total_chunks_wire,
97 chunk_index & 0x1F,
98 ]
99 }
100 }
101 }
102
103 /// Parse a header off the front of a 5-bit-symbol stream.
104 ///
105 /// Returns the parsed header and the number of symbols consumed
106 /// (2 for `SingleString`, 8 for `Chunked`). The caller slices off
107 /// the remainder as the fragment-payload symbols.
108 ///
109 /// # Errors
110 ///
111 /// - [`Error::UnexpectedEnd`] if `symbols` is shorter than the
112 /// minimum 2-symbol single-string header.
113 /// - [`Error::UnsupportedVersion`] if the version field is non-zero
114 /// in v0.1.
115 /// - [`Error::UnsupportedCardType`] if the type field is not in
116 /// `{0x00, 0x01}` (the reserved range `0x02..=0x1F` is rejected).
117 /// - [`Error::ChunkedHeaderMalformed`] if a chunked header has
118 /// `total_chunks == 0`, `total_chunks > MAX_CHUNKS`, or
119 /// `chunk_index >= total_chunks`.
120 pub fn from_5bit_symbols(symbols: &[u8]) -> Result<(Self, usize)> {
121 if symbols.len() < SINGLE_HEADER_SYMBOLS {
122 return Err(Error::UnexpectedEnd);
123 }
124 let version = symbols[0] & 0x1F;
125 if version != VERSION_V0_1 {
126 return Err(Error::UnsupportedVersion(version));
127 }
128 let type_byte = symbols[1] & 0x1F;
129 match type_byte {
130 TYPE_SINGLE => Ok((
131 StringLayerHeader::SingleString { version },
132 SINGLE_HEADER_SYMBOLS,
133 )),
134 TYPE_CHUNKED => {
135 if symbols.len() < CHUNKED_HEADER_SYMBOLS {
136 return Err(Error::UnexpectedEnd);
137 }
138 let csid: u32 = ((symbols[2] as u32 & 0x1F) << 15)
139 | ((symbols[3] as u32 & 0x1F) << 10)
140 | ((symbols[4] as u32 & 0x1F) << 5)
141 | (symbols[5] as u32 & 0x1F);
142 // total_chunks is encoded as `count - 1` on the wire (5-bit
143 // field; closure-locked semantic range 1..=32). Decode back
144 // by adding 1; validity range is automatically 1..=32 since
145 // the 5-bit field caps at 31.
146 let total_chunks = (symbols[6] & 0x1F) + 1;
147 let chunk_index = symbols[7] & 0x1F;
148
149 // Defensive: even though the off-by-one decoding makes
150 // total_chunks always land in 1..=32, surface a malformed
151 // error if that invariant is ever broken (e.g., by a
152 // future encoder bug emitting a wire value > 31, which
153 // would have been masked by the & 0x1F above).
154 if total_chunks == 0 || total_chunks > MAX_CHUNKS {
155 return Err(Error::ChunkedHeaderMalformed(format!(
156 "total_chunks = {total_chunks} (must be in 1..={MAX_CHUNKS})"
157 )));
158 }
159 if chunk_index >= total_chunks {
160 return Err(Error::ChunkedHeaderMalformed(format!(
161 "chunk_index = {chunk_index} >= total_chunks = {total_chunks}"
162 )));
163 }
164 Ok((
165 StringLayerHeader::Chunked {
166 version,
167 chunk_set_id: csid,
168 total_chunks,
169 chunk_index,
170 },
171 CHUNKED_HEADER_SYMBOLS,
172 ))
173 }
174 other => Err(Error::UnsupportedCardType(other)),
175 }
176 }
177
178 /// Returns `true` if this header is the `Chunked` variant.
179 pub fn is_chunked(self) -> bool {
180 matches!(self, StringLayerHeader::Chunked { .. })
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn single_string_round_trip() {
190 let h = StringLayerHeader::SingleString { version: 0 };
191 let symbols = h.to_5bit_symbols();
192 assert_eq!(symbols.len(), SINGLE_HEADER_SYMBOLS);
193 let (parsed, consumed) = StringLayerHeader::from_5bit_symbols(&symbols).unwrap();
194 assert_eq!(parsed, h);
195 assert_eq!(consumed, SINGLE_HEADER_SYMBOLS);
196 }
197
198 #[test]
199 fn chunked_round_trip() {
200 let h = StringLayerHeader::Chunked {
201 version: 0,
202 chunk_set_id: 0xABCDE,
203 total_chunks: 5,
204 chunk_index: 3,
205 };
206 let symbols = h.to_5bit_symbols();
207 assert_eq!(symbols.len(), CHUNKED_HEADER_SYMBOLS);
208 let (parsed, consumed) = StringLayerHeader::from_5bit_symbols(&symbols).unwrap();
209 assert_eq!(parsed, h);
210 assert_eq!(consumed, CHUNKED_HEADER_SYMBOLS);
211 }
212
213 #[test]
214 fn chunked_round_trip_max_csid() {
215 // Top-of-range chunk_set_id (all 20 bits set) packs and unpacks correctly.
216 let h = StringLayerHeader::Chunked {
217 version: 0,
218 chunk_set_id: MAX_CHUNK_SET_ID,
219 total_chunks: MAX_CHUNKS,
220 chunk_index: MAX_CHUNKS - 1,
221 };
222 let (parsed, _) = StringLayerHeader::from_5bit_symbols(&h.to_5bit_symbols()).unwrap();
223 assert_eq!(parsed, h);
224 }
225
226 #[test]
227 fn chunked_round_trip_zero_csid() {
228 // Bottom-of-range chunk_set_id (zero) packs and unpacks correctly.
229 let h = StringLayerHeader::Chunked {
230 version: 0,
231 chunk_set_id: 0,
232 total_chunks: 1,
233 chunk_index: 0,
234 };
235 let (parsed, _) = StringLayerHeader::from_5bit_symbols(&h.to_5bit_symbols()).unwrap();
236 assert_eq!(parsed, h);
237 }
238
239 #[test]
240 fn parse_rejects_truncated_input() {
241 // Empty and 1-symbol inputs cannot encode a full single-string header.
242 assert!(matches!(
243 StringLayerHeader::from_5bit_symbols(&[]),
244 Err(Error::UnexpectedEnd)
245 ));
246 assert!(matches!(
247 StringLayerHeader::from_5bit_symbols(&[0]),
248 Err(Error::UnexpectedEnd)
249 ));
250 // Truncated chunked header (type=0x01 declared, but fewer than 8 symbols).
251 let symbols = vec![0u8, TYPE_CHUNKED, 0, 0, 0];
252 assert!(matches!(
253 StringLayerHeader::from_5bit_symbols(&symbols),
254 Err(Error::UnexpectedEnd)
255 ));
256 }
257
258 #[test]
259 fn parse_rejects_unsupported_version() {
260 let symbols = vec![1u8, TYPE_SINGLE];
261 assert!(matches!(
262 StringLayerHeader::from_5bit_symbols(&symbols),
263 Err(Error::UnsupportedVersion(1))
264 ));
265 }
266
267 #[test]
268 fn parse_rejects_reserved_card_type() {
269 // Reserved type byte 0x02..=0x1F MUST be rejected.
270 for ct in 0x02u8..=0x1F {
271 let symbols = vec![0u8, ct];
272 let r = StringLayerHeader::from_5bit_symbols(&symbols);
273 assert!(
274 matches!(r, Err(Error::UnsupportedCardType(c)) if c == ct),
275 "card type 0x{ct:02x} not rejected"
276 );
277 }
278 }
279
280 #[test]
281 fn wire_total_chunks_zero_decodes_to_one() {
282 // The 5-bit `total_chunks` field is encoded as `count - 1` per the
283 // off-by-one note in `to_5bit_symbols`, so wire value 0 represents
284 // a single-chunk encoding. (`SingleString` is wire-defined for
285 // forward compatibility per SPEC §2.4 but unreachable for v0.1
286 // encoders — a `Chunked(total=1)` is a defined-but-rare shape
287 // produced only by hand-constructed test inputs at the header layer.)
288 let h = StringLayerHeader::Chunked {
289 version: 0,
290 chunk_set_id: 0,
291 total_chunks: 1,
292 chunk_index: 0,
293 };
294 let symbols = h.to_5bit_symbols();
295 assert_eq!(symbols[6], 0, "wire encoding of total_chunks=1 must be 0");
296 let (parsed, _) = StringLayerHeader::from_5bit_symbols(&symbols).unwrap();
297 assert_eq!(parsed, h);
298 }
299
300 #[test]
301 fn parse_rejects_chunk_index_at_or_above_total_chunks() {
302 let h = StringLayerHeader::Chunked {
303 version: 0,
304 chunk_set_id: 0,
305 total_chunks: 3,
306 chunk_index: 0,
307 };
308 let mut symbols = h.to_5bit_symbols();
309 symbols[7] = 3; // chunk_index >= total_chunks (=3)
310 assert!(matches!(
311 StringLayerHeader::from_5bit_symbols(&symbols),
312 Err(Error::ChunkedHeaderMalformed(_))
313 ));
314 }
315
316 #[test]
317 fn is_chunked_discriminator() {
318 assert!(!StringLayerHeader::SingleString { version: 0 }.is_chunked());
319 assert!(
320 StringLayerHeader::Chunked {
321 version: 0,
322 chunk_set_id: 0,
323 total_chunks: 1,
324 chunk_index: 0,
325 }
326 .is_chunked()
327 );
328 }
329}