Skip to main content

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}