Skip to main content

cesr/core/indexer/
builder.rs

1use alloc::borrow::Cow;
2#[cfg(feature = "alloc")]
3#[allow(
4    unused_imports,
5    reason = "alloc prelude items; subset used per cfg/feature combination"
6)]
7use alloc::{borrow::ToOwned, format, vec, vec::Vec};
8use core::num::NonZeroUsize;
9
10use base64::{Engine, engine::general_purpose as b64};
11
12use super::code::{IndexMode, IndexedSigCode, hardage};
13use super::error::{IndexerParseError, IndexerValidationError};
14use super::indexer::Indexer;
15use super::xizage::XizageSize;
16use crate::b64::{decode_int, encode_binary};
17
18// ── Type states ────────────────────────────────────────────────────────
19
20/// Initial type-state for [`IndexerBuilder`]: no code or index set yet.
21#[derive(Debug)]
22pub struct IStart;
23
24/// Type-state for [`IndexerBuilder`] after a code has been selected.
25#[derive(Debug)]
26pub struct IWithCode {
27    code: IndexedSigCode,
28}
29
30/// Type-state for [`IndexerBuilder`] after a code and index have been set.
31#[derive(Debug)]
32pub struct IWithIndex {
33    code: IndexedSigCode,
34    index: u32,
35    ondex: Option<u32>,
36}
37
38// ── Builder ────────────────────────────────────────────────────────────
39
40/// Type-state builder for constructing and parsing [`Indexer`] primitives.
41#[derive(Debug)]
42pub struct IndexerBuilder<S> {
43    state: S,
44}
45
46impl Default for IndexerBuilder<IStart> {
47    fn default() -> Self {
48        Self { state: IStart }
49    }
50}
51
52impl IndexerBuilder<IStart> {
53    /// Creates a new `IndexerBuilder` in the initial state.
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Sets the indexed signature code, advancing the builder to the next state.
60    #[must_use]
61    pub const fn with_code(self, code: IndexedSigCode) -> IndexerBuilder<IWithCode> {
62        IndexerBuilder {
63            state: IWithCode { code },
64        }
65    }
66
67    /// Parse an [`Indexer`] from a qualified Base64 (qb64) stream.
68    ///
69    /// Returns the parsed `Indexer` and the number of bytes consumed from the
70    /// input.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`IndexerParseError`] if the stream is empty, too short, contains
75    /// invalid Base64, or has an unrecognized code.
76    #[allow(
77        clippy::too_many_lines,
78        reason = "sequential parsing steps that are clearer together"
79    )]
80    pub fn from_qb64(self, stream: &[u8]) -> Result<(Indexer<'static>, usize), IndexerParseError> {
81        let &first_byte = stream.first().ok_or(IndexerParseError::EmptyStream)?;
82
83        let first_char = char::from(first_byte);
84        let hard_size = hardage(first_char)
85            .ok_or_else(|| IndexerParseError::UnknownCode(format!("{first_char}")))?;
86
87        if stream.len() < hard_size {
88            return Err(IndexerParseError::StreamTooShort {
89                need: hard_size,
90                got: stream.len(),
91            });
92        }
93
94        let hard = core::str::from_utf8(&stream[..hard_size])
95            .map_err(|_| IndexerParseError::InvalidBase64)?;
96        let code = IndexedSigCode::from_hard(hard).map_err(IndexerParseError::from)?;
97
98        let xizage = code.get_xizage();
99        let hs = usize::from(xizage.hs);
100        let ss = usize::from(xizage.ss);
101        let os = usize::from(xizage.os);
102        let ls = usize::from(xizage.ls);
103        let cs = hs + ss;
104        let ms = ss - os;
105
106        if stream.len() < cs {
107            return Err(IndexerParseError::StreamTooShort {
108                need: cs,
109                got: stream.len(),
110            });
111        }
112
113        let index_str = core::str::from_utf8(&stream[hs..hs + ms])
114            .map_err(|_| IndexerParseError::InvalidBase64)?;
115        let index: u32 = decode_int(index_str).map_err(IndexerParseError::from)?;
116
117        let ondex = match code.mode() {
118            IndexMode::CurrentOnly => {
119                if os > 0 {
120                    let ondex_str = core::str::from_utf8(&stream[hs + ms..hs + ms + os])
121                        .map_err(|_| IndexerParseError::InvalidBase64)?;
122                    let ondex_val: u32 = decode_int(ondex_str).map_err(IndexerParseError::from)?;
123                    if ondex_val != 0 {
124                        return Err(IndexerParseError::OndexNotZeroForCurrentOnly(ondex_val));
125                    }
126                }
127                None
128            }
129            IndexMode::Both => {
130                if os > 0 {
131                    let ondex_str = core::str::from_utf8(&stream[hs + ms..hs + ms + os])
132                        .map_err(|_| IndexerParseError::InvalidBase64)?;
133                    let ondex_val: u32 = decode_int(ondex_str).map_err(IndexerParseError::from)?;
134                    Some(ondex_val)
135                } else {
136                    Some(index)
137                }
138            }
139        };
140
141        let fs = match xizage.fs {
142            XizageSize::Fixed(n) => usize::from(n),
143            XizageSize::Variable => {
144                #[allow(
145                    clippy::as_conversions,
146                    reason = "u32 to usize is a safe widening cast"
147                )]
148                let idx = index as usize;
149                compute_full_size(idx, cs)?
150            }
151        };
152
153        if stream.len() < fs {
154            return Err(IndexerParseError::StreamTooShort {
155                need: fs,
156                got: stream.len(),
157            });
158        }
159
160        let ps = cs % 4;
161        let payload = &stream[cs..fs];
162        let mut temp = Vec::with_capacity(ps + payload.len());
163        temp.extend(core::iter::repeat_n(b'A', ps));
164        temp.extend_from_slice(payload);
165        let decoded = b64::URL_SAFE_NO_PAD
166            .decode(&temp)
167            .map_err(|_| IndexerParseError::InvalidBase64)?;
168        let skip = if ps != 0 { ps } else { ls };
169        let raw = decoded[skip..].to_vec();
170
171        Ok((Indexer::new(code, index, ondex, Cow::Owned(raw)), fs))
172    }
173
174    /// Parse an [`Indexer`] from a qualified binary (qb2) stream.
175    ///
176    /// Returns the parsed `Indexer` and the number of binary bytes consumed
177    /// from the input.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`IndexerParseError`] if the stream is empty, too short, contains
182    /// invalid data, or has an unrecognized code.
183    pub fn from_qb2(self, stream: &[u8]) -> Result<(Indexer<'static>, usize), IndexerParseError> {
184        let &first_byte = stream.first().ok_or(IndexerParseError::EmptyStream)?;
185
186        let first_sextet = first_byte >> 2;
187        let hs: usize = match first_sextet {
188            0..=51 => 1,
189            52..=56 => 2,
190            _ => {
191                return Err(IndexerParseError::UnknownCode(format!(
192                    "binary lead byte 0x{first_byte:02x}",
193                )));
194            }
195        };
196
197        let bhs = (hs * 3).div_ceil(4);
198        if stream.len() < bhs {
199            return Err(IndexerParseError::StreamTooShort {
200                need: bhs,
201                got: stream.len(),
202            });
203        }
204
205        let char_len = NonZeroUsize::new(hs)
206            .ok_or_else(|| IndexerParseError::UnknownCode("zero hard size".to_owned()))?;
207        let hard_b64 = encode_binary(&stream[..bhs], char_len).map_err(IndexerParseError::from)?;
208        let hard = &hard_b64[..hs];
209        let code = IndexedSigCode::from_hard(hard).map_err(IndexerParseError::from)?;
210
211        let xizage = code.get_xizage();
212        let ss = usize::from(xizage.ss);
213        let cs = hs + ss;
214
215        let bcs = (cs * 3).div_ceil(4);
216        if stream.len() < bcs {
217            return Err(IndexerParseError::StreamTooShort {
218                need: bcs,
219                got: stream.len(),
220            });
221        }
222
223        let cs_nz = NonZeroUsize::new(cs)
224            .ok_or_else(|| IndexerParseError::UnknownCode("zero code size".to_owned()))?;
225        let both_b64 = encode_binary(&stream[..bcs], cs_nz).map_err(IndexerParseError::from)?;
226
227        let soft = &both_b64[hs..cs];
228        let os = usize::from(xizage.os);
229        let ms = ss - os;
230
231        let index: u32 = decode_int(&soft[..ms]).map_err(IndexerParseError::from)?;
232
233        let fs: usize = match xizage.fs {
234            XizageSize::Fixed(n) => usize::from(n),
235            XizageSize::Variable => {
236                #[allow(
237                    clippy::as_conversions,
238                    reason = "u32 to usize is a safe widening cast"
239                )]
240                let idx = index as usize;
241                compute_full_size(idx, cs)?
242            }
243        };
244
245        let bfs = fs * 3 / 4;
246        if stream.len() < bfs {
247            return Err(IndexerParseError::StreamTooShort {
248                need: bfs,
249                got: stream.len(),
250            });
251        }
252
253        let qb64 = b64::URL_SAFE_NO_PAD.encode(&stream[..bfs]);
254        let (indexer, _) = Self::new().from_qb64(qb64.as_bytes())?;
255
256        Ok((indexer, bfs))
257    }
258}
259
260/// Checked full char size `fs = index * 4 + cs`. `index` is attacker-controlled,
261/// so the arithmetic is checked (mirrors matter's `compute_full_size`).
262#[inline]
263fn compute_full_size(index: usize, cs: usize) -> Result<usize, IndexerParseError> {
264    index
265        .checked_mul(4)
266        .and_then(|quad| quad.checked_add(cs))
267        .ok_or(IndexerParseError::SizeOverflow)
268}
269
270impl IndexedSigCode {
271    /// Full qb64 character size of the indexed primitive at the head of `stream`,
272    /// without decoding raw bytes.
273    ///
274    /// # Errors
275    /// `IndexerParseError` on unknown code, short stream, bad UTF-8, or size overflow.
276    pub fn frame_size(stream: &[u8]) -> Result<usize, IndexerParseError> {
277        let &first = stream.first().ok_or(IndexerParseError::EmptyStream)?;
278        let hard_size = hardage(char::from(first))
279            .ok_or_else(|| IndexerParseError::UnknownCode(format!("{}", char::from(first))))?;
280        if stream.len() < hard_size {
281            return Err(IndexerParseError::StreamTooShort {
282                need: hard_size,
283                got: stream.len(),
284            });
285        }
286        let hard = core::str::from_utf8(&stream[..hard_size])
287            .map_err(|_| IndexerParseError::InvalidBase64)?;
288        Self::from_hard(hard)
289            .map_err(IndexerParseError::from)?
290            .frame_size_of(stream)
291    }
292
293    /// `frame_size` for an already-known code — shared with `from_qb64` so there
294    /// is exactly one size implementation.
295    pub(crate) fn frame_size_of(self, stream: &[u8]) -> Result<usize, IndexerParseError> {
296        let xizage = self.get_xizage();
297        let hs = usize::from(xizage.hs);
298        let ss = usize::from(xizage.ss);
299        let os = usize::from(xizage.os);
300        let cs = hs + ss;
301        let ms = ss - os;
302        match xizage.fs {
303            XizageSize::Fixed(n) => Ok(usize::from(n)),
304            XizageSize::Variable => {
305                if stream.len() < cs {
306                    return Err(IndexerParseError::StreamTooShort {
307                        need: cs,
308                        got: stream.len(),
309                    });
310                }
311                let index_str = core::str::from_utf8(&stream[hs..hs + ms])
312                    .map_err(|_| IndexerParseError::InvalidBase64)?;
313                let index: usize = decode_int(index_str).map_err(IndexerParseError::from)?;
314                compute_full_size(index, cs)
315            }
316        }
317    }
318}
319
320impl IndexerBuilder<IWithCode> {
321    /// Sets the signer index.
322    ///
323    /// For `Both` codes the ondex is automatically set equal to the index.
324    /// For `CurrentOnly` codes the ondex is set to `None`.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`IndexerValidationError::IndexTooLarge`] if `index` exceeds the
329    /// code's maximum.
330    pub const fn with_index(
331        self,
332        index: u32,
333    ) -> Result<IndexerBuilder<IWithIndex>, IndexerValidationError> {
334        if index > self.state.code.max_index() {
335            return Err(IndexerValidationError::IndexTooLarge {
336                code: self.state.code,
337                index,
338                max: self.state.code.max_index(),
339            });
340        }
341        let ondex = match self.state.code.mode() {
342            IndexMode::Both => Some(index),
343            IndexMode::CurrentOnly => None,
344        };
345        Ok(IndexerBuilder {
346            state: IWithIndex {
347                code: self.state.code,
348                index,
349                ondex,
350            },
351        })
352    }
353
354    /// Sets both the signer index and the explicit ondex.
355    ///
356    /// Only valid for `Both` codes. Returns an error if the code is
357    /// `CurrentOnly`, or if either index exceeds its maximum.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`IndexerValidationError`] if the code is `CurrentOnly`, or if
362    /// either index or ondex exceeds the code's maximum capacity.
363    pub fn with_indices(
364        self,
365        index: u32,
366        ondex: u32,
367    ) -> Result<IndexerBuilder<IWithIndex>, IndexerValidationError> {
368        // Reject CurrentOnly codes — they have no ondex field.
369        if self.state.code.mode() == IndexMode::CurrentOnly {
370            return Err(IndexerValidationError::OndexOnCurrentOnly(self.state.code));
371        }
372        // Validate index.
373        if index > self.state.code.max_index() {
374            return Err(IndexerValidationError::IndexTooLarge {
375                code: self.state.code,
376                index,
377                max: self.state.code.max_index(),
378            });
379        }
380        // Validate ondex.
381        if let Some(max_ondex) = self.state.code.max_ondex()
382            && ondex > max_ondex
383        {
384            return Err(IndexerValidationError::OndexTooLarge {
385                code: self.state.code,
386                ondex,
387                max: max_ondex,
388            });
389        }
390        // When os=0 the wire format has no space for a separate ondex, so
391        // ondex must equal index (matching keripy's InvalidVarIndexError).
392        if self.state.code.get_xizage().os() == 0 && ondex != index {
393            return Err(IndexerValidationError::OndexMustEqualIndex {
394                code: self.state.code,
395                index,
396                ondex,
397            });
398        }
399        Ok(IndexerBuilder {
400            state: IWithIndex {
401                code: self.state.code,
402                index,
403                ondex: Some(ondex),
404            },
405        })
406    }
407}
408
409impl IndexerBuilder<IWithIndex> {
410    /// Terminal step: validates the raw byte length and returns the finished
411    /// [`Indexer`].
412    ///
413    /// # Errors
414    ///
415    /// Returns [`IndexerValidationError::UnexpectedRawSize`] if the raw byte slice
416    /// length does not match the code's expected size.
417    pub fn with_raw<'a>(
418        self,
419        raw: impl Into<Cow<'a, [u8]>>,
420    ) -> Result<Indexer<'a>, IndexerValidationError> {
421        let raw_bytes = raw.into();
422        let expected = self.state.code.raw_size();
423        if raw_bytes.len() != expected {
424            return Err(IndexerValidationError::UnexpectedRawSize {
425                code: self.state.code,
426                expected,
427                got: raw_bytes.len(),
428            });
429        }
430        Ok(Indexer::new(
431            self.state.code,
432            self.state.index,
433            self.state.ondex,
434            raw_bytes,
435        ))
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use alloc::string::String;
442
443    use rstest::rstest;
444
445    use super::*;
446
447    // ── Happy paths ────────────────────────────────────────────────────
448
449    /// Ed25519 (Both, small): index=0, ondex auto-set to 0.
450    #[test]
451    fn ed25519_both_index_0() {
452        let indexer = IndexerBuilder::new()
453            .with_code(IndexedSigCode::Ed25519)
454            .with_index(0)
455            .unwrap()
456            .with_raw(&[0u8; 64])
457            .unwrap();
458        assert_eq!(indexer.code(), IndexedSigCode::Ed25519);
459        assert_eq!(indexer.index(), 0);
460        assert_eq!(indexer.ondex(), Some(0));
461        assert_eq!(indexer.raw().len(), 64);
462    }
463
464    /// `Ed25519Crt` (`CurrentOnly`, small): ondex is `None`.
465    #[test]
466    fn ed25519crt_current_only() {
467        let indexer = IndexerBuilder::new()
468            .with_code(IndexedSigCode::Ed25519Crt)
469            .with_index(5)
470            .unwrap()
471            .with_raw(&[0u8; 64])
472            .unwrap();
473        assert_eq!(indexer.code(), IndexedSigCode::Ed25519Crt);
474        assert_eq!(indexer.index(), 5);
475        assert_eq!(indexer.ondex(), None);
476    }
477
478    /// `Ed448` has `raw_size` 114.
479    #[test]
480    fn ed448_raw_size() {
481        let indexer = IndexerBuilder::new()
482            .with_code(IndexedSigCode::Ed448)
483            .with_index(4)
484            .unwrap()
485            .with_raw(&[0u8; 114])
486            .unwrap();
487        assert_eq!(indexer.raw().len(), 114);
488    }
489
490    /// Big code with large index.
491    #[test]
492    fn big_code_large_index() {
493        let indexer = IndexerBuilder::new()
494            .with_code(IndexedSigCode::Ed25519Big)
495            .with_index(4000)
496            .unwrap()
497            .with_raw(&[0u8; 64])
498            .unwrap();
499        assert_eq!(indexer.index(), 4000);
500        assert_eq!(indexer.ondex(), Some(4000));
501    }
502
503    /// Max valid index for small Ed25519: 63.
504    #[test]
505    fn max_valid_index_small() {
506        let result = IndexerBuilder::new()
507            .with_code(IndexedSigCode::Ed25519)
508            .with_index(63);
509        assert!(result.is_ok());
510    }
511
512    /// Explicit ondex via `with_indices` on a Big Both code.
513    #[test]
514    fn explicit_ondex_big() {
515        let indexer = IndexerBuilder::new()
516            .with_code(IndexedSigCode::Ed448Big)
517            .with_indices(5, 10)
518            .unwrap()
519            .with_raw(&[0u8; 114])
520            .unwrap();
521        assert_eq!(indexer.index(), 5);
522        assert_eq!(indexer.ondex(), Some(10));
523    }
524
525    /// `with_indices` on a small Both code (`Ed25519`, `os`=0) where ondex == index.
526    #[test]
527    fn with_indices_small_both_same() {
528        let indexer = IndexerBuilder::new()
529            .with_code(IndexedSigCode::Ed25519)
530            .with_indices(7, 7)
531            .unwrap()
532            .with_raw(&[0u8; 64])
533            .unwrap();
534        assert_eq!(indexer.index(), 7);
535        assert_eq!(indexer.ondex(), Some(7));
536    }
537
538    // ── Validation errors ──────────────────────────────────────────────
539
540    /// Index too large for small code (max is 63).
541    #[test]
542    fn index_too_large() {
543        let result = IndexerBuilder::new()
544            .with_code(IndexedSigCode::Ed25519)
545            .with_index(64);
546        assert!(result.is_err());
547    }
548
549    /// Wrong raw size.
550    #[test]
551    fn wrong_raw_size() {
552        let result = IndexerBuilder::new()
553            .with_code(IndexedSigCode::Ed25519)
554            .with_index(0)
555            .unwrap()
556            .with_raw(&[0u8; 32]); // Ed25519 sig is 64 bytes
557        assert!(result.is_err());
558    }
559
560    /// `with_indices` on `CurrentOnly` code is rejected.
561    #[test]
562    fn ondex_on_current_only() {
563        let result = IndexerBuilder::new()
564            .with_code(IndexedSigCode::Ed25519Crt)
565            .with_indices(0, 0);
566        assert!(result.is_err());
567    }
568
569    /// Ondex too large for big code.
570    #[test]
571    fn ondex_too_large() {
572        // Ed448Big: max_ondex = 64^3 - 1 = 262_143
573        let result = IndexerBuilder::new()
574            .with_code(IndexedSigCode::Ed448Big)
575            .with_indices(0, 262_144);
576        assert!(result.is_err());
577    }
578
579    /// Index too large when using `with_indices`.
580    #[test]
581    fn index_too_large_with_indices() {
582        let result = IndexerBuilder::new()
583            .with_code(IndexedSigCode::Ed25519Big)
584            .with_indices(4096, 0); // max_index for Ed25519Big is 4095
585        assert!(result.is_err());
586    }
587
588    // ── Error variant checks ───────────────────────────────────────────
589
590    #[test]
591    fn index_too_large_error_variant() {
592        let err = IndexerBuilder::new()
593            .with_code(IndexedSigCode::Ed25519)
594            .with_index(64)
595            .err()
596            .unwrap();
597        assert_eq!(
598            err,
599            IndexerValidationError::IndexTooLarge {
600                code: IndexedSigCode::Ed25519,
601                index: 64,
602                max: 63,
603            }
604        );
605    }
606
607    #[test]
608    fn wrong_raw_size_error_variant() {
609        let err = IndexerBuilder::new()
610            .with_code(IndexedSigCode::Ed448)
611            .with_index(0)
612            .unwrap()
613            .with_raw(&[0u8; 64])
614            .err()
615            .unwrap();
616        assert_eq!(
617            err,
618            IndexerValidationError::UnexpectedRawSize {
619                code: IndexedSigCode::Ed448,
620                expected: 114,
621                got: 64,
622            }
623        );
624    }
625
626    #[test]
627    fn ondex_on_current_only_error_variant() {
628        let err = IndexerBuilder::new()
629            .with_code(IndexedSigCode::ECDSA256k1Crt)
630            .with_indices(0, 0)
631            .err()
632            .unwrap();
633        assert_eq!(
634            err,
635            IndexerValidationError::OndexOnCurrentOnly(IndexedSigCode::ECDSA256k1Crt)
636        );
637    }
638
639    #[test]
640    fn ondex_too_large_error_variant() {
641        let err = IndexerBuilder::new()
642            .with_code(IndexedSigCode::Ed25519Big)
643            .with_indices(0, 4096) // max_ondex for Ed25519Big (os=2) is 4095
644            .err()
645            .unwrap();
646        assert_eq!(
647            err,
648            IndexerValidationError::OndexTooLarge {
649                code: IndexedSigCode::Ed25519Big,
650                ondex: 4096,
651                max: 4095,
652            }
653        );
654    }
655
656    // ── Boundary conditions ────────────────────────────────────────────
657
658    /// Max valid index for big Ed25519: 4095.
659    #[test]
660    fn max_valid_index_big() {
661        let result = IndexerBuilder::new()
662            .with_code(IndexedSigCode::Ed25519Big)
663            .with_index(4095);
664        assert!(result.is_ok());
665    }
666
667    /// Max valid ondex for big `Ed448`: `262_143`.
668    #[test]
669    fn max_valid_ondex_big() {
670        let result = IndexerBuilder::new()
671            .with_code(IndexedSigCode::Ed448Big)
672            .with_indices(0, 262_143);
673        assert!(result.is_ok());
674    }
675
676    /// All ECDSA codes work through the builder.
677    #[test]
678    fn ecdsa_codes() {
679        for code in [
680            IndexedSigCode::ECDSA256k1,
681            IndexedSigCode::ECDSA256r1,
682            IndexedSigCode::ECDSA256k1Big,
683            IndexedSigCode::ECDSA256r1Big,
684        ] {
685            let indexer = IndexerBuilder::new()
686                .with_code(code)
687                .with_index(0)
688                .unwrap()
689                .with_raw(&[0u8; 64])
690                .unwrap();
691            assert_eq!(indexer.code(), code);
692            assert_eq!(indexer.ondex(), Some(0));
693        }
694    }
695
696    /// All `CurrentOnly` codes produce `ondex`=`None`.
697    #[test]
698    fn all_current_only_ondex_none() {
699        for code in [
700            IndexedSigCode::Ed25519Crt,
701            IndexedSigCode::ECDSA256k1Crt,
702            IndexedSigCode::ECDSA256r1Crt,
703            IndexedSigCode::Ed448Crt,
704            IndexedSigCode::Ed25519BigCrt,
705            IndexedSigCode::ECDSA256k1BigCrt,
706            IndexedSigCode::ECDSA256r1BigCrt,
707            IndexedSigCode::Ed448BigCrt,
708        ] {
709            let raw_size = code.raw_size();
710            let raw = vec![0u8; raw_size];
711            let indexer = IndexerBuilder::new()
712                .with_code(code)
713                .with_index(0)
714                .unwrap()
715                .with_raw(&raw)
716                .unwrap();
717            assert_eq!(
718                indexer.ondex(),
719                None,
720                "code {code:?} should have ondex=None"
721            );
722        }
723    }
724
725    // ── compute_full_size overflow probe ──────────────────────────────
726
727    #[test]
728    fn indexer_compute_full_size_rejects_overflow() {
729        // `index` is decoded from the attacker-controlled soft field; the
730        // arithmetic `index * 4 + cs` is checked, so overflow must be a typed
731        // Err, never a panic (debug) or a silently-wrapped (truncated) frame.
732        assert_eq!(compute_full_size(1, 4).unwrap(), 8);
733        assert!(compute_full_size(usize::MAX / 4, 4).is_err());
734        assert!(compute_full_size(usize::MAX, 0).is_err());
735    }
736
737    // ── frame_size tests ──────────────────────────────────────────────
738
739    #[test]
740    fn indexer_frame_size_fixed_and_truncated() {
741        // 'A' = Ed25519 indexed sig, fixed fs = 88 (verified)
742        let full = String::from("A") + &"A".repeat(87);
743        assert_eq!(IndexedSigCode::frame_size(full.as_bytes()).unwrap(), 88);
744        assert!(IndexedSigCode::frame_size(b"").is_err());
745        assert!(IndexedSigCode::frame_size(b"9").is_err()); // '9' -> hardage None
746    }
747
748    // ── from_qb64 tests ───────────────────────────────────────────────
749
750    /// Known cesride test vector: Ed25519, index=0.
751    #[test]
752    fn from_qb64_cesride_vector() {
753        let qb64 = "AACdI8OSQkMJ9r-xigjEByEjIua7LHH3AOJ22PQKqljMhuhcgh9nGRcKnsz5KvKd7K_H9-1298F4Id1DxvIoEmCQ";
754        let (indexer, consumed) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
755        assert_eq!(consumed, 88);
756        assert_eq!(indexer.code(), IndexedSigCode::Ed25519);
757        assert_eq!(indexer.index(), 0);
758        assert_eq!(indexer.ondex(), Some(0));
759        // Roundtrip.
760        assert_eq!(indexer.to_qb64(), qb64);
761    }
762
763    /// Construct a qb64 with index=5, then parse it.
764    #[test]
765    fn from_qb64_with_index() {
766        let original = IndexerBuilder::new()
767            .with_code(IndexedSigCode::Ed25519)
768            .with_index(5)
769            .unwrap()
770            .with_raw(&[0xAB; 64])
771            .unwrap();
772        let qb64 = original.to_qb64();
773        let (parsed, consumed) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
774        assert_eq!(consumed, 88);
775        assert_eq!(parsed.code(), IndexedSigCode::Ed25519);
776        assert_eq!(parsed.index(), 5);
777        assert_eq!(parsed.ondex(), Some(5));
778        assert_eq!(parsed.raw(), original.raw());
779    }
780
781    /// Big Ed25519 variant with index=100.
782    #[test]
783    fn from_qb64_big_variant() {
784        let original = IndexerBuilder::new()
785            .with_code(IndexedSigCode::Ed25519Big)
786            .with_index(100)
787            .unwrap()
788            .with_raw(&[0xCD; 64])
789            .unwrap();
790        let qb64 = original.to_qb64();
791        let (parsed, _) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
792        assert_eq!(parsed.code(), IndexedSigCode::Ed25519Big);
793        assert_eq!(parsed.index(), 100);
794        assert_eq!(parsed.ondex(), Some(100));
795    }
796
797    /// Ed448 with separate ondex values.
798    #[test]
799    fn from_qb64_ed448_separate_ondex() {
800        let original = IndexerBuilder::new()
801            .with_code(IndexedSigCode::Ed448)
802            .with_indices(2, 5)
803            .unwrap()
804            .with_raw(&[0xEE; 114])
805            .unwrap();
806        let qb64 = original.to_qb64();
807        let (parsed, _) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
808        assert_eq!(parsed.index(), 2);
809        assert_eq!(parsed.ondex(), Some(5));
810    }
811
812    /// Empty stream returns `EmptyStream` error.
813    #[test]
814    fn from_qb64_empty_stream() {
815        let result = IndexerBuilder::new().from_qb64(b"");
816        assert!(result.is_err());
817    }
818
819    /// Stream too short returns `StreamTooShort` error.
820    #[test]
821    fn from_qb64_stream_too_short() {
822        let result = IndexerBuilder::new().from_qb64(b"A");
823        assert!(result.is_err());
824    }
825
826    /// Invalid code returns an error.
827    #[test]
828    fn from_qb64_unknown_code() {
829        let result = IndexerBuilder::new().from_qb64(b"#invalid");
830        assert!(result.is_err());
831    }
832
833    /// Stream longer than needed: consumed should be exactly the full size.
834    #[test]
835    fn from_qb64_consumes_exact_bytes() {
836        let original = IndexerBuilder::new()
837            .with_code(IndexedSigCode::Ed25519)
838            .with_index(0)
839            .unwrap()
840            .with_raw(&[0u8; 64])
841            .unwrap();
842        let mut stream = original.to_qb64().into_bytes();
843        stream.extend_from_slice(b"EXTRA_STUFF");
844        let (_, consumed) = IndexerBuilder::new().from_qb64(&stream).unwrap();
845        assert_eq!(consumed, 88);
846    }
847
848    /// `CurrentOnly` code parsed from qb64 has `ondex`=`None`.
849    #[test]
850    fn from_qb64_current_only_ondex_none() {
851        let original = IndexerBuilder::new()
852            .with_code(IndexedSigCode::Ed25519Crt)
853            .with_index(3)
854            .unwrap()
855            .with_raw(&[0u8; 64])
856            .unwrap();
857        let qb64 = original.to_qb64();
858        let (parsed, _) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
859        assert_eq!(parsed.code(), IndexedSigCode::Ed25519Crt);
860        assert_eq!(parsed.index(), 3);
861        assert_eq!(parsed.ondex(), None);
862    }
863
864    /// Roundtrip all 16 codes through qb64 encode/decode.
865    #[rstest]
866    #[case(IndexedSigCode::Ed25519)]
867    #[case(IndexedSigCode::Ed25519Crt)]
868    #[case(IndexedSigCode::ECDSA256k1)]
869    #[case(IndexedSigCode::ECDSA256k1Crt)]
870    #[case(IndexedSigCode::ECDSA256r1)]
871    #[case(IndexedSigCode::ECDSA256r1Crt)]
872    #[case(IndexedSigCode::Ed448)]
873    #[case(IndexedSigCode::Ed448Crt)]
874    #[case(IndexedSigCode::Ed25519Big)]
875    #[case(IndexedSigCode::Ed25519BigCrt)]
876    #[case(IndexedSigCode::ECDSA256k1Big)]
877    #[case(IndexedSigCode::ECDSA256k1BigCrt)]
878    #[case(IndexedSigCode::ECDSA256r1Big)]
879    #[case(IndexedSigCode::ECDSA256r1BigCrt)]
880    #[case(IndexedSigCode::Ed448Big)]
881    #[case(IndexedSigCode::Ed448BigCrt)]
882    fn from_qb64_roundtrip_all_codes(#[case] code: IndexedSigCode) {
883        let raw = vec![0xAB_u8; code.raw_size()];
884        let original = IndexerBuilder::new()
885            .with_code(code)
886            .with_index(0)
887            .unwrap()
888            .with_raw(&raw)
889            .unwrap();
890        let qb64 = original.to_qb64();
891        let (parsed, _) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
892        assert_eq!(parsed.code(), original.code());
893        assert_eq!(parsed.raw(), original.raw());
894        assert_eq!(parsed.to_qb64(), qb64);
895    }
896
897    /// `Ed448Big` with large distinct index and ondex roundtrips correctly.
898    #[test]
899    fn from_qb64_ed448_big_distinct_indices() {
900        let original = IndexerBuilder::new()
901            .with_code(IndexedSigCode::Ed448Big)
902            .with_indices(1000, 500)
903            .unwrap()
904            .with_raw(&[0xFF; 114])
905            .unwrap();
906        let qb64 = original.to_qb64();
907        let (parsed, consumed) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
908        assert_eq!(consumed, 160);
909        assert_eq!(parsed.index(), 1000);
910        assert_eq!(parsed.ondex(), Some(500));
911        assert_eq!(parsed.raw(), original.raw());
912    }
913
914    // ── from_qb2 tests ────────────────────────────────────────────────
915
916    /// Ed25519 roundtrip through qb2.
917    #[test]
918    fn from_qb2_roundtrip() {
919        let original = IndexerBuilder::new()
920            .with_code(IndexedSigCode::Ed25519)
921            .with_index(0)
922            .unwrap()
923            .with_raw(&[0xAB; 64])
924            .unwrap();
925        let qb2 = original.to_qb2();
926        let (parsed, consumed) = IndexerBuilder::new().from_qb2(&qb2).unwrap();
927        assert_eq!(consumed, 66); // 88 * 3 / 4
928        assert_eq!(parsed.code(), original.code());
929        assert_eq!(parsed.index(), original.index());
930        assert_eq!(parsed.raw(), original.raw());
931    }
932
933    /// `from_qb2` empty stream returns error.
934    #[test]
935    fn from_qb2_empty_stream() {
936        let result = IndexerBuilder::new().from_qb2(b"");
937        assert!(result.is_err());
938    }
939
940    /// Roundtrip all 16 codes through qb2 encode/decode.
941    #[rstest]
942    #[case(IndexedSigCode::Ed25519)]
943    #[case(IndexedSigCode::Ed25519Crt)]
944    #[case(IndexedSigCode::ECDSA256k1)]
945    #[case(IndexedSigCode::ECDSA256k1Crt)]
946    #[case(IndexedSigCode::ECDSA256r1)]
947    #[case(IndexedSigCode::ECDSA256r1Crt)]
948    #[case(IndexedSigCode::Ed448)]
949    #[case(IndexedSigCode::Ed448Crt)]
950    #[case(IndexedSigCode::Ed25519Big)]
951    #[case(IndexedSigCode::Ed25519BigCrt)]
952    #[case(IndexedSigCode::ECDSA256k1Big)]
953    #[case(IndexedSigCode::ECDSA256k1BigCrt)]
954    #[case(IndexedSigCode::ECDSA256r1Big)]
955    #[case(IndexedSigCode::ECDSA256r1BigCrt)]
956    #[case(IndexedSigCode::Ed448Big)]
957    #[case(IndexedSigCode::Ed448BigCrt)]
958    fn from_qb2_roundtrip_all_codes(#[case] code: IndexedSigCode) {
959        let raw = vec![0xCD_u8; code.raw_size()];
960        let original = IndexerBuilder::new()
961            .with_code(code)
962            .with_index(0)
963            .unwrap()
964            .with_raw(&raw)
965            .unwrap();
966        let qb2 = original.to_qb2();
967        let qb64_len = original.to_qb64().len();
968        let expected_bfs = qb64_len * 3 / 4;
969        let (parsed, consumed) = IndexerBuilder::new().from_qb2(&qb2).unwrap();
970        assert_eq!(
971            consumed, expected_bfs,
972            "consumed mismatch for code {code:?}"
973        );
974        assert_eq!(parsed.code(), original.code());
975        assert_eq!(parsed.raw(), original.raw());
976    }
977
978    /// `from_qb2` with extra trailing bytes only consumes expected amount.
979    #[test]
980    fn from_qb2_consumes_exact_bytes() {
981        let original = IndexerBuilder::new()
982            .with_code(IndexedSigCode::Ed25519)
983            .with_index(0)
984            .unwrap()
985            .with_raw(&[0u8; 64])
986            .unwrap();
987        let mut qb2 = original.to_qb2();
988        qb2.extend_from_slice(&[0xFF; 20]);
989        let (_, consumed) = IndexerBuilder::new().from_qb2(&qb2).unwrap();
990        assert_eq!(consumed, 66); // 88 * 3/4
991    }
992
993    /// `from_qb2` on a big code roundtrips correctly.
994    #[test]
995    fn from_qb2_big_code() {
996        let original = IndexerBuilder::new()
997            .with_code(IndexedSigCode::Ed25519Big)
998            .with_index(100)
999            .unwrap()
1000            .with_raw(&[0xAB; 64])
1001            .unwrap();
1002        let qb2 = original.to_qb2();
1003        let (parsed, consumed) = IndexerBuilder::new().from_qb2(&qb2).unwrap();
1004        assert_eq!(consumed, 69); // 92 * 3 / 4
1005        assert_eq!(parsed.code(), IndexedSigCode::Ed25519Big);
1006        assert_eq!(parsed.index(), 100);
1007    }
1008
1009    /// Cross-format roundtrip: encode to qb64, decode from qb64,
1010    /// re-encode to qb2, decode from qb2, compare.
1011    #[test]
1012    fn cross_format_roundtrip() {
1013        let original = IndexerBuilder::new()
1014            .with_code(IndexedSigCode::Ed448)
1015            .with_indices(7, 3)
1016            .unwrap()
1017            .with_raw(&[0x42; 114])
1018            .unwrap();
1019
1020        // qb64 roundtrip.
1021        let qb64 = original.to_qb64();
1022        let (from_b64, _) = IndexerBuilder::new().from_qb64(qb64.as_bytes()).unwrap();
1023        assert_eq!(from_b64.raw(), original.raw());
1024        assert_eq!(from_b64.index(), 7);
1025        assert_eq!(from_b64.ondex(), Some(3));
1026
1027        // qb2 roundtrip.
1028        let qb2 = original.to_qb2();
1029        let (from_b2, _) = IndexerBuilder::new().from_qb2(&qb2).unwrap();
1030        assert_eq!(from_b2.raw(), original.raw());
1031        assert_eq!(from_b2.index(), 7);
1032        assert_eq!(from_b2.ondex(), Some(3));
1033
1034        // Both parse results should be identical.
1035        assert_eq!(from_b64, from_b2);
1036    }
1037}