md_codec/chunk.rs
1//! Chunk header per SPEC v0.30 §2.2.
2//!
3//! Encodes the 37-bit chunked wire-format header. First-symbol layout
4//! MSB-first: `[v3][v2][v1][v0][chunked]` (4-bit version + 1-bit chunked-flag).
5//! Remainder: 20-bit chunk-set-id + 6-bit count-minus-1 + 6-bit index.
6//! Total = 4 + 1 + 20 + 6 + 6 = 37 bits.
7//!
8//! v0.34.0: also hosts [`decode_with_correction`] — the BCH-error-correcting
9//! decode entry point. Per chunk: parse → polymod-residue → (if non-zero)
10//! call [`crate::bch_decode::decode_regular_errors`] → apply corrections →
11//! re-encode → forward to [`reassemble`]. Atomic per plan §1 D28: any chunk
12//! exceeding the BCH `t = 4` capacity fails the whole call without partial
13//! output.
14
15use crate::bitstream::{BitReader, BitWriter};
16use crate::codex32::REGULAR_CODE_SYMBOLS_MAX;
17use crate::error::Error;
18use crate::header::Header;
19
20/// Wire header for a single chunk in a chunked v0.30 payload.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ChunkHeader {
23 /// Wire-format version (4 bits). v0.30 = 4.
24 pub version: u8,
25 /// 20-bit chunk-set identifier shared by all chunks in a set.
26 pub chunk_set_id: u32,
27 /// Total number of chunks in the set; valid range `1..=64`.
28 pub count: u8,
29 /// Zero-based index of this chunk within the set; must be `< count`.
30 pub index: u8,
31}
32
33impl ChunkHeader {
34 /// Encode the chunk header into `w` as 37 bits.
35 ///
36 /// Returns an error if `count`, `index`, or `chunk_set_id` are out of range.
37 pub fn write(&self, w: &mut BitWriter) -> Result<(), Error> {
38 if !(1..=64).contains(&(self.count as u32)) {
39 return Err(Error::ChunkCountOutOfRange { count: self.count });
40 }
41 if self.index >= self.count {
42 return Err(Error::ChunkIndexOutOfRange {
43 index: self.index,
44 count: self.count,
45 });
46 }
47 if self.chunk_set_id >= (1 << 20) {
48 return Err(Error::ChunkSetIdOutOfRange {
49 id: self.chunk_set_id,
50 });
51 }
52 w.write_bits(u64::from(self.version & 0b1111), 4);
53 w.write_bits(1, 1); // chunked = 1
54 w.write_bits(u64::from(self.chunk_set_id), 20);
55 w.write_bits((self.count - 1) as u64, 6); // count-1 offset
56 w.write_bits(u64::from(self.index), 6);
57 Ok(())
58 }
59
60 /// Decode a chunk header (37 bits) from `r`.
61 ///
62 /// Returns [`Error::WireVersionMismatch`] if the 4-bit version field
63 /// is not `WF_REDESIGN_VERSION` per SPEC §2.5 (e.g., v0.x chunked
64 /// payloads where version=0 in the first 3 wire bits become version=0
65 /// or version=1 under the v0.30 4-bit read depending on prior bits).
66 /// Returns [`Error::ChunkHeaderChunkedFlagMissing`] if the chunked-flag
67 /// bit is not set after the version check passes.
68 pub fn read(r: &mut BitReader) -> Result<Self, Error> {
69 let version = r.read_bits(4)? as u8;
70 if version != Header::WF_REDESIGN_VERSION {
71 return Err(Error::WireVersionMismatch { got: version });
72 }
73 let chunked = r.read_bits(1)? != 0;
74 if !chunked {
75 return Err(Error::ChunkHeaderChunkedFlagMissing);
76 }
77 let chunk_set_id = r.read_bits(20)? as u32;
78 let count = (r.read_bits(6)? + 1) as u8;
79 let index = r.read_bits(6)? as u8;
80 Ok(Self {
81 version,
82 chunk_set_id,
83 count,
84 index,
85 })
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::header::Header;
93
94 #[test]
95 fn chunk_header_round_trip() {
96 let h = ChunkHeader {
97 version: Header::WF_REDESIGN_VERSION,
98 chunk_set_id: 0xABCDE,
99 count: 3,
100 index: 1,
101 };
102 let mut w = BitWriter::new();
103 h.write(&mut w).unwrap();
104 // 4 + 1 + 20 + 6 + 6 = 37 bits
105 assert_eq!(w.bit_len(), 37);
106 let bytes = w.into_bytes();
107 let mut r = BitReader::new(&bytes);
108 assert_eq!(ChunkHeader::read(&mut r).unwrap(), h);
109 }
110
111 #[test]
112 fn chunk_header_count_64_round_trip() {
113 let h = ChunkHeader {
114 version: Header::WF_REDESIGN_VERSION,
115 chunk_set_id: 0,
116 count: 64,
117 index: 63,
118 };
119 let mut w = BitWriter::new();
120 h.write(&mut w).unwrap();
121 let bytes = w.into_bytes();
122 let mut r = BitReader::new(&bytes);
123 assert_eq!(ChunkHeader::read(&mut r).unwrap(), h);
124 }
125
126 #[test]
127 fn chunk_header_count_zero_rejected() {
128 let h = ChunkHeader {
129 version: Header::WF_REDESIGN_VERSION,
130 chunk_set_id: 0,
131 count: 0,
132 index: 0,
133 };
134 let mut w = BitWriter::new();
135 assert!(matches!(
136 h.write(&mut w),
137 Err(Error::ChunkCountOutOfRange { count: 0 })
138 ));
139 }
140
141 /// SPEC v0.30 §2.5 v0.x rejection for chunk-header path. A wire crafted
142 /// with version=0 and chunked-flag=1 (the v0.30-layout interpretation of
143 /// what a v0.x chunked first-symbol becomes when reordered) must be
144 /// rejected with `WireVersionMismatch { got: 0 }`.
145 #[test]
146 fn chunk_header_rejects_v0x_version() {
147 // Construct first 5 bits MSB-first: [v3=0][v2=0][v1=0][v0=0][chunked=1]
148 // = 0b00001 (numeric 1)
149 // Pad with 32 zero bits (chunk_set_id + count-1 + index) to reach
150 // the full 37-bit chunk header length. 37 bits packed MSB-first into
151 // 5 bytes (with 3 trailing zero bits beyond the bit limit).
152 // Easier: use BitWriter to build the wire deterministically.
153 let mut w = BitWriter::new();
154 w.write_bits(0, 4); // version = 0 (v0.x)
155 w.write_bits(1, 1); // chunked = 1
156 w.write_bits(0, 20); // chunk_set_id
157 w.write_bits(0, 6); // count-1
158 w.write_bits(0, 6); // index
159 assert_eq!(w.bit_len(), 37);
160 let bytes = w.into_bytes();
161 let mut r = BitReader::new(&bytes);
162 assert!(matches!(
163 ChunkHeader::read(&mut r),
164 Err(Error::WireVersionMismatch { got: 0 })
165 ));
166 }
167}
168
169use crate::identity::Md1EncodingId;
170
171/// Derive the 20-bit chunk-set-id from a [`Md1EncodingId`] by taking the
172/// top 20 bits of the underlying 16-byte hash, MSB-first.
173///
174/// The chunk-set-id groups chunks belonging to the same encoded payload.
175/// Returned value is in the range `0..=0xFFFFF`.
176pub fn derive_chunk_set_id(id: &Md1EncodingId) -> u32 {
177 // First 20 bits of Md1EncodingId[0..16], MSB-first.
178 let bytes = id.as_bytes();
179 ((bytes[0] as u32) << 12) | ((bytes[1] as u32) << 4) | ((bytes[2] as u32) >> 4)
180}
181
182#[cfg(test)]
183mod chunk_set_id_tests {
184 use super::*;
185
186 #[test]
187 fn derive_chunk_set_id_deterministic() {
188 let mut bytes = [0u8; 16];
189 bytes[0] = 0xab;
190 bytes[1] = 0xcd;
191 bytes[2] = 0xe1;
192 bytes[3] = 0x23;
193 let id = Md1EncodingId::new(bytes);
194 let csid_a = derive_chunk_set_id(&id);
195 let csid_b = derive_chunk_set_id(&id);
196 assert_eq!(csid_a, csid_b);
197 }
198
199 #[test]
200 fn derive_chunk_set_id_msb_first_extraction() {
201 // bytes[0]=0xAB, [1]=0xCD, [2]=0xEF: top 20 bits = 0xABCDE
202 let mut bytes = [0u8; 16];
203 bytes[0] = 0xAB;
204 bytes[1] = 0xCD;
205 bytes[2] = 0xEF;
206 let id = Md1EncodingId::new(bytes);
207 assert_eq!(derive_chunk_set_id(&id), 0xABCDE);
208 }
209}
210
211use crate::encode::Descriptor;
212
213/// Per-chunk payload *sizing* budget (in payload bits) that [`split`] uses to
214/// choose the chunk count: `count = ceil(padded_payload_bits / 320)`. It is 64
215/// data symbols (64 × 5 = 320 bits), deliberately BELOW the codex32 regular
216/// single-string data cap of 80 symbols / 400 bits (enforced by
217/// [`crate::codex32::wrap_payload`]), so each chunk's 37-bit header fits
218/// alongside the fragment inside one regular-code codeword.
219///
220/// NOTE: this is the chunk-*sizing* budget, NOT the single-string threshold.
221/// A payload that fits ≤ 400 bits is emitted as ONE string; only a payload
222/// exceeding the 400-bit single-string cap (or an explicit `--force-chunked`)
223/// is split — and once split, chunks are sized by this 320-bit budget.
224pub const SINGLE_STRING_PAYLOAD_BIT_LIMIT: usize = 64 * 5;
225
226/// Split a [`Descriptor`] into N codex32 md1 strings, each carrying a chunk
227/// header and a slice of the canonical payload.
228///
229/// Algorithm:
230/// 1. Encode the full payload (`encode_payload`).
231/// 2. Compute [`crate::identity::Md1EncodingId`]; derive `ChunkSetId`.
232/// 3. Choose chunk count N such that each chunk fits in codex32 long form
233/// after adding the 37-bit chunk header.
234/// 4. Split the payload into N approximately-equal byte-boundary slices.
235/// 5. For each chunk i: prepend chunk header (37 bits), wrap via codex32 with
236/// the chunked-flag bit set, emit md1 string.
237///
238/// Note: `bytes_per_chunk` could be 0 if `payload_bytes` were empty, but the
239/// encoder validates `n ≥ 1` so the payload is always non-empty.
240pub fn split(d: &Descriptor) -> Result<Vec<String>, Error> {
241 use crate::bitstream::BitWriter;
242 use crate::encode::encode_payload;
243 use crate::identity::compute_md1_encoding_id;
244
245 let (payload_bytes, _payload_bits) = encode_payload(d)?;
246
247 // Compute ChunkSetId from full-encoding hash.
248 let md1_id = compute_md1_encoding_id(d)?;
249 let chunk_set_id = derive_chunk_set_id(&md1_id);
250
251 // Choose chunk count from payload byte count (≤7 bits of trailing
252 // codex32-padding are tolerated by the reassembled-stream TLV-rollback).
253 let payload_bit_count_for_sizing = payload_bytes.len() * 8;
254 let chunks_needed = payload_bit_count_for_sizing.div_ceil(SINGLE_STRING_PAYLOAD_BIT_LIMIT);
255 if chunks_needed > 64 {
256 return Err(Error::ChunkCountExceedsMax {
257 needed: chunks_needed,
258 });
259 }
260 let count: u8 = if chunks_needed == 0 {
261 1
262 } else {
263 chunks_needed as u8
264 };
265
266 // Split payload into `count` byte-boundary slices.
267 let bytes_per_chunk = payload_bytes.len().div_ceil(count as usize);
268
269 let mut chunks = Vec::with_capacity(count as usize);
270 for index in 0..count {
271 let start_byte = (index as usize) * bytes_per_chunk;
272 let end_byte = ((index as usize + 1) * bytes_per_chunk).min(payload_bytes.len());
273 let chunk_payload_bytes = &payload_bytes[start_byte..end_byte];
274
275 // Build per-chunk wire: 37-bit chunk header + chunk-payload bytes
276 // (full 8 bits per byte, no further fractional content). Chunk's
277 // exact bit count = 37 + 8 × |chunk_payload_bytes|.
278 let header = ChunkHeader {
279 version: Header::WF_REDESIGN_VERSION,
280 chunk_set_id,
281 count,
282 index,
283 };
284 let mut w = BitWriter::new();
285 header.write(&mut w)?;
286 for byte in chunk_payload_bytes {
287 w.write_bits(u64::from(*byte), 8);
288 }
289 let chunk_bit_count = 37 + 8 * chunk_payload_bytes.len();
290 let bytes = w.into_bytes();
291 let s = crate::codex32::wrap_payload(&bytes, chunk_bit_count)?;
292 chunks.push(s);
293 }
294 Ok(chunks)
295}
296
297/// Reassemble a [`Descriptor`] from N md1 codex32 strings (strict:
298/// byte-identical to pre-P0 behavior). Delegates to
299/// [`reassemble_with_opts`] with the default (strict) options.
300///
301/// Algorithm:
302/// 1. Unwrap each string via the codex32 layer (verifies BCH per chunk).
303/// 2. Parse the 37-bit chunk header from each.
304/// 3. Validate consistency: same version, chunk_set_id, count.
305/// 4. Sort by index; verify `0..count-1` with no gaps.
306/// 5. Concatenate per-chunk payload bytes.
307/// 6. Decode the reassembled payload via
308/// [`crate::decode::decode_payload`].
309/// 7. Verify the reassembled payload's derived chunk-set-id matches the
310/// chunk-set-id present in every chunk header (cross-chunk integrity).
311pub fn reassemble(strings: &[&str]) -> Result<Descriptor, Error> {
312 reassemble_with_opts(strings, crate::decode::DecodeOpts::default())
313}
314
315/// Reassemble a [`Descriptor`] from N md1 codex32 strings, honoring
316/// `opts` (P0 partial-decode; see [`crate::decode::DecodeOpts`] for the
317/// contract). Same algorithm as [`reassemble`], except step 6 decodes via
318/// [`crate::decode::decode_payload_with_opts`] instead of the strict
319/// primitive.
320///
321/// INVARIANT (funds-load-bearing): `opts.allow_unresolved_origin` relaxes
322/// ONLY the origin-gate outcome of the step-6 decode call. Every check
323/// ABOVE that call (per-chunk BCH via `unwrap_string`, chunk-header
324/// consistency, index-gap) and the derived-chunk-set-id / content-id
325/// check BELOW it (step 7) stay enforced UNCONDITIONALLY regardless of
326/// `opts` — a chunk set with a doctored chunk-set-id still rejects with
327/// `Error::ChunkSetIdMismatch` even when `allow_unresolved_origin: true`.
328pub fn reassemble_with_opts(
329 strings: &[&str],
330 opts: crate::decode::DecodeOpts,
331) -> Result<Descriptor, Error> {
332 use crate::bitstream::BitReader;
333 use crate::codex32::unwrap_string;
334 use crate::decode::decode_payload_with_opts;
335 use crate::identity::compute_md1_encoding_id;
336
337 if strings.is_empty() {
338 return Err(Error::ChunkSetEmpty);
339 }
340
341 // Unwrap each, parse 37-bit chunk header, then read whole payload bytes.
342 // Use the symbol-aligned bit count returned by `unwrap_string` (NOT
343 // `bytes.len() * 8`, which would over-estimate by up to 7 bits and break
344 // round-trip for chunks where symbol-padding plus byte-padding crosses a
345 // byte boundary — e.g. N=3, N=8, etc.).
346 let mut parsed: Vec<(ChunkHeader, Vec<u8>)> = Vec::with_capacity(strings.len());
347 for s in strings {
348 let (bytes, symbol_aligned_bit_count) = unwrap_string(s)?;
349 let mut r = BitReader::with_bit_limit(&bytes, symbol_aligned_bit_count);
350 let header = ChunkHeader::read(&mut r)?;
351 // Per encoder contract: chunk wire is exactly 37 + 8N bits. The
352 // symbol-aligned bit count is `ceil((37+8N)/5) * 5`, which is in
353 // [37+8N, 37+8N+4]. So `(symbol_aligned_bit_count - 37) / 8`
354 // (floor) recovers exactly N.
355 let payload_byte_count = (symbol_aligned_bit_count - 37) / 8;
356 let mut chunk_payload_bytes = Vec::with_capacity(payload_byte_count);
357 for _ in 0..payload_byte_count {
358 let v = r.read_bits(8)? as u8;
359 chunk_payload_bytes.push(v);
360 }
361 // Trailing ≤4 symbol-padding bits remain in r; discard.
362 parsed.push((header, chunk_payload_bytes));
363 }
364
365 // Validate consistency.
366 let (h0, _) = &parsed[0];
367 let expected_count = h0.count;
368 let expected_csid = h0.chunk_set_id;
369 let expected_version = h0.version;
370 for (h, _) in &parsed {
371 if h.count != expected_count
372 || h.chunk_set_id != expected_csid
373 || h.version != expected_version
374 {
375 return Err(Error::ChunkSetInconsistent);
376 }
377 }
378 if parsed.len() != expected_count as usize {
379 return Err(Error::ChunkSetIncomplete {
380 got: parsed.len(),
381 expected: expected_count as usize,
382 });
383 }
384
385 // Sort by index; verify 0..count-1 with no gaps.
386 parsed.sort_by_key(|(h, _)| h.index);
387 for (i, (h, _)) in parsed.iter().enumerate() {
388 if h.index as usize != i {
389 return Err(Error::ChunkIndexGap {
390 expected: i as u8,
391 got: h.index,
392 });
393 }
394 }
395
396 // Concatenate chunk payload bytes.
397 let mut full_bytes = Vec::new();
398 for (_, chunk_bytes) in &parsed {
399 full_bytes.extend_from_slice(chunk_bytes);
400 }
401
402 // Decode payload, honoring `opts` (P0.2). bit_len = bytes.len() * 8;
403 // TLV-rollback handles trailing padding.
404 let descriptor = decode_payload_with_opts(&full_bytes, full_bytes.len() * 8, opts)?;
405
406 // Cross-chunk integrity check — UNCONDITIONAL regardless of `opts`
407 // (the content-id oracle; P0.2 funds-load-bearing invariant).
408 let md1_id = compute_md1_encoding_id(&descriptor)?;
409 let derived_csid = derive_chunk_set_id(&md1_id);
410 if derived_csid != expected_csid {
411 return Err(Error::ChunkSetIdMismatch {
412 expected: expected_csid,
413 derived: derived_csid,
414 });
415 }
416
417 Ok(descriptor)
418}
419
420// ---------------------------------------------------------------------------
421// v0.34.0: BCH-error-correcting decode (plan §1 D22 + §2.B.1).
422// ---------------------------------------------------------------------------
423
424/// Per-correction report emitted by [`decode_with_correction`]. One entry
425/// per repaired character. `position` is 0-indexed into the codex32
426/// data-part (i.e. the characters following the `md1` HRP + separator);
427/// `was` is the original (corrupted) char from the input; `now` is the
428/// corrected char.
429///
430/// Atomic per plan §1 D28: when [`decode_with_correction`] succeeds the
431/// returned vector aggregates corrections across all chunks; chunks that
432/// were already valid contribute nothing.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct CorrectionDetail {
435 /// 0-indexed position of the chunk in the caller's `&[&str]` slice.
436 pub chunk_index: usize,
437 /// 0-indexed position of the corrected character within the chunk's
438 /// data-part (post-HRP-and-separator).
439 pub position: usize,
440 /// The original (corrupted) character at this position.
441 pub was: char,
442 /// The corrected character at this position.
443 pub now: char,
444}
445
446/// Local codex32 alphabet (BIP 173 lowercase). Each char = one 5-bit
447/// symbol. Duplicated from `codex32.rs` (which keeps it private) so this
448/// module doesn't widen the codex32 public surface; the mapping is
449/// constant per BIP 173.
450const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
451
452/// BIP 173 separator character between HRP and data-part for md1 strings.
453const HRP_PREFIX: &str = "md1";
454
455/// Parse a single md1 chunk into its 5-bit data-part symbol vector.
456/// Returns the data-with-checksum symbols (i.e. all symbols after `md1`).
457/// Visual separators (whitespace + `-`) are stripped per codex32 convention.
458fn parse_chunk_symbols(chunk: &str, chunk_index: usize) -> Result<Vec<u8>, Error> {
459 // BIP-173: reject mixed-case (per chunk). The correction path rejects too —
460 // case is lowercased before symbol mapping, so a case-flip is a zero-symbol-
461 // error event never in the BCH channel; a wholesale mixed-case string is a
462 // malformed encoding, not noise to correct. (Mirrors mk-codec's correcting
463 // decode, which rejects MixedCase before correction.)
464 if crate::codex32::is_mixed_case(chunk) {
465 return Err(Error::Codex32DecodeError(format!(
466 "chunk {chunk_index}: string mixes upper and lower case (BIP-173 forbids mixed case)"
467 )));
468 }
469 let lower = chunk.to_ascii_lowercase();
470 if !lower.starts_with(HRP_PREFIX) {
471 return Err(Error::Codex32DecodeError(format!(
472 "chunk {chunk_index}: string does not start with HRP md1"
473 )));
474 }
475 let rest = &lower[HRP_PREFIX.len()..];
476 let mut symbols: Vec<u8> = Vec::with_capacity(rest.len());
477 for c in rest.chars() {
478 if c.is_whitespace() || c == '-' {
479 continue;
480 }
481 let lc = c as u8;
482 let sym = CODEX32_ALPHABET
483 .iter()
484 .position(|&b| b == lc)
485 .ok_or_else(|| {
486 Error::Codex32DecodeError(format!(
487 "chunk {chunk_index}: character {c:?} not in codex32 alphabet"
488 ))
489 })? as u8;
490 symbols.push(sym);
491 }
492 Ok(symbols)
493}
494
495/// Re-encode a 5-bit data-part symbol vector as a complete md1 string.
496fn encode_chunk_string(data_with_checksum: &[u8]) -> String {
497 let mut out = String::with_capacity(HRP_PREFIX.len() + data_with_checksum.len());
498 out.push_str(HRP_PREFIX);
499 for &v in data_with_checksum {
500 out.push(CODEX32_ALPHABET[(v & 0x1F) as usize] as char);
501 }
502 out
503}
504
505/// BCH-error-correcting decode for a chunk-set of md1 strings.
506///
507/// Per plan §1 Q1 lock — full-decode semantics: this is the single entry
508/// point that callers needing both "did anything get repaired?" AND "the
509/// fully-decoded descriptor" should use.
510///
511/// Algorithm:
512/// 1. For each chunk, parse the data-part into 5-bit symbols and compute
513/// the BCH polymod residue (`hrp_expand("md") || data_with_checksum`)
514/// XOR'd against [`crate::bch::MD_REGULAR_CONST`].
515/// 2. Residue `== 0` ⇒ chunk passes through unchanged.
516/// 3. Residue `!= 0` ⇒ invoke
517/// [`crate::bch_decode::decode_regular_errors`]. If `None`, return
518/// `Err(Error::TooManyErrors { chunk_index, bound: 8 })` per plan §2.B.4
519/// D29 error-mapping table.
520/// 4. Apply corrections to the chunk's symbol vector, re-encode as a
521/// fresh md1 string, and record one [`CorrectionDetail`] per repaired
522/// character.
523/// 5. After ALL chunks have been processed (any single uncorrectable
524/// chunk aborts atomically per plan §1 D28), forward the corrected
525/// chunk strings to [`reassemble`] to produce the [`Descriptor`].
526///
527/// On success returns `(Descriptor, Vec<CorrectionDetail>)`. The
528/// correction-detail vector is in (`chunk_index` ascending,
529/// `position` ascending within chunk) order; an empty vector means every
530/// input chunk was already a valid codeword.
531pub fn decode_with_correction(
532 strings: &[&str],
533) -> Result<(Descriptor, Vec<CorrectionDetail>), Error> {
534 if strings.is_empty() {
535 return Err(Error::ChunkSetEmpty);
536 }
537
538 let mut corrected_strings: Vec<String> = Vec::with_capacity(strings.len());
539 // Track the post-correction 5-bit symbol vector of the first string so the
540 // single-string detection pre-pass below can inspect bit 0 of the first
541 // symbol (the chunked-flag per SPEC v0.30 §2.3) without re-parsing the
542 // wrapped string.
543 let mut first_corrected_symbols: Option<Vec<u8>> = None;
544 let mut all_details: Vec<CorrectionDetail> = Vec::new();
545
546 for (chunk_index, chunk) in strings.iter().enumerate() {
547 let symbols = parse_chunk_symbols(chunk, chunk_index)?;
548
549 // cycle-4 M4: reject any chunk longer than the codex32 regular code's
550 // 93-symbol codeword BEFORE the residue/correction logic. β has order
551 // 93, so degrees d and d+93 alias in chien_search for an over-93-symbol
552 // word — the correcting decoder would otherwise mis-correct at an
553 // aliased root. This precedes the residue==0 pass-through, so a clean
554 // over-length md1 is rejected on `repair` too (the correct domain gate;
555 // composes with H6's encode cap). Fail-closed.
556 if symbols.len() > REGULAR_CODE_SYMBOLS_MAX {
557 return Err(Error::ChunkSymbolCountOutOfRange {
558 chunk_index,
559 symbols: symbols.len(),
560 max: REGULAR_CODE_SYMBOLS_MAX,
561 });
562 }
563
564 // Polymod residue against md1's target constant.
565 let mut input = crate::bch::hrp_expand("md");
566 input.extend_from_slice(&symbols);
567 let residue = crate::bch::polymod_run(&input) ^ crate::bch::MD_REGULAR_CONST;
568
569 if residue == 0 {
570 // Already valid — pass through unchanged.
571 corrected_strings.push((*chunk).to_string());
572 if chunk_index == 0 {
573 first_corrected_symbols = Some(symbols);
574 }
575 continue;
576 }
577
578 // Attempt BCH correction.
579 let (positions, magnitudes) =
580 crate::bch_decode::decode_regular_errors(residue, symbols.len()).ok_or(
581 Error::TooManyErrors {
582 chunk_index,
583 bound: 8,
584 },
585 )?;
586
587 // Apply corrections; record (was, now) chars per position.
588 let mut corrected = symbols.clone();
589 let mut details: Vec<CorrectionDetail> = Vec::with_capacity(positions.len());
590 for (&pos, &mag) in positions.iter().zip(&magnitudes) {
591 if pos >= corrected.len() {
592 // Defensive: chien_search bounded pos to [0, L); but a
593 // pathological 5+-error pattern could in principle skirt
594 // that. Treat as uncorrectable per Q2 absorption rules.
595 return Err(Error::TooManyErrors {
596 chunk_index,
597 bound: 8,
598 });
599 }
600 let was_byte = corrected[pos];
601 let now_byte = was_byte ^ mag;
602 let was = CODEX32_ALPHABET[(was_byte & 0x1F) as usize] as char;
603 let now = CODEX32_ALPHABET[(now_byte & 0x1F) as usize] as char;
604 details.push(CorrectionDetail {
605 chunk_index,
606 position: pos,
607 was,
608 now,
609 });
610 corrected[pos] = now_byte;
611 }
612
613 // Defensive re-verify (catches pathological 5+-error patterns
614 // that happen to produce a degree-≤4 locator with 4 valid roots).
615 let mut verify_input = crate::bch::hrp_expand("md");
616 verify_input.extend_from_slice(&corrected);
617 let verify_residue = crate::bch::polymod_run(&verify_input) ^ crate::bch::MD_REGULAR_CONST;
618 if verify_residue != 0 {
619 return Err(Error::TooManyErrors {
620 chunk_index,
621 bound: 8,
622 });
623 }
624
625 corrected_strings.push(encode_chunk_string(&corrected));
626 if chunk_index == 0 {
627 first_corrected_symbols = Some(corrected);
628 }
629 all_details.extend(details);
630 }
631
632 // v0.35.0: single-string auto-dispatch per SPEC v0.30 §2.3. The first
633 // 5-bit symbol of the corrected payload carries the chunked-flag in
634 // bit 0 (0 = single-payload, 1 = chunked). When the sole input string
635 // decodes (post-BCH correction) as non-chunked, route it through the
636 // single-payload decode path rather than `reassemble`. When it
637 // decodes as chunked, fall through to the existing `reassemble`
638 // path — which naturally surfaces `ChunkSetIncomplete { got: 1,
639 // expected: count }` for any `count > 1` (the "chunked-bit set but
640 // only one chunk supplied" ambiguity edge per plan §2.D.1) while
641 // preserving the legitimate count==1 chunked-of-1 case shipped in
642 // v0.34.0.
643 if strings.len() == 1 {
644 // `first_corrected_symbols` is populated by the loop above (both
645 // the residue==0 pass-through and the correction-applied paths
646 // populate it for `chunk_index == 0`).
647 let symbols = first_corrected_symbols
648 .as_ref()
649 .expect("loop populates first_corrected_symbols when strings.len() >= 1");
650 let chunked_flag = symbols.first().map(|s| s & 0x01).unwrap_or(1);
651 if chunked_flag == 0 {
652 // Non-chunked: decode via the single-payload path. The
653 // corrected string passes BCH-verify (proven by the defensive
654 // re-verify above; or by residue == 0 in the pass-through
655 // branch), so `decode_md1_string` will not re-fail at the
656 // codex32 layer.
657 let descriptor = crate::decode::decode_md1_string(&corrected_strings[0])?;
658 return Ok((descriptor, all_details));
659 }
660 // chunked_flag == 1: fall through to `reassemble` below.
661 }
662
663 // Hand corrected strings to the existing reassembly path.
664 let corrected_refs: Vec<&str> = corrected_strings.iter().map(|s| s.as_str()).collect();
665 let descriptor = reassemble(&corrected_refs)?;
666 Ok((descriptor, all_details))
667}