Skip to main content

mk_codec/
error.rs

1//! Error type for `mk-codec`.
2//!
3//! Variants mirror the rejection conditions enumerated in
4//! `design/SPEC_mk_v0_1.md` §4 ("Bytecode-Validity Rules") and
5//! `bip/bip-mnemonic-key.mediawiki` §"Decoder validity rules". All
6//! decoder-rejection paths in a future implementation MUST surface
7//! one of these variants. Pre-BIP-submission, every variant is
8//! required to map to at least one named negative test vector
9//! (tracked as `decoder-error-variant-parity` in
10//! `design/FOLLOWUPS.md`).
11
12use thiserror::Error;
13
14/// All errors `mk-codec` can produce.
15///
16/// Marked `#[non_exhaustive]` so that future versions can add variants
17/// without breaking external callers' exhaustive `match` arms.
18#[non_exhaustive]
19#[derive(Debug, Error)]
20pub enum Error {
21    // ── String-layer errors (codex32 plumbing, HRP, chunk-header) ───────────
22    /// HRP is not `mk` or input is not a valid bech32-shaped string.
23    #[error("invalid HRP: {0}")]
24    InvalidHrp(String),
25
26    /// Input string mixes ASCII upper- and lower-case in its data part.
27    /// BIP 173 forbids mixed case to remove an entire class of
28    /// transcription ambiguity; the rule is inherited verbatim by mk1's
29    /// codex32-derived encoding.
30    #[error("mixed case in input string")]
31    MixedCase,
32
33    /// Input string's data-part length is not a valid mk1 length:
34    /// either below the regular-code minimum (14 5-bit symbols), in the
35    /// reserved-invalid 94–95 gap between regular and long codes, or
36    /// above the long-code maximum (108). The carried `usize` is the
37    /// observed length; reported pessimistically to highlight which
38    /// boundary the caller missed.
39    #[error("invalid data-part length: {0}")]
40    InvalidStringLength(usize),
41
42    /// Input string's data part contains a character that is not in the
43    /// 32-character bech32 alphabet (`qpzry9x8gf2tvdw0s3jn54khce6mua7l`).
44    /// The offending character and its 0-indexed position within the
45    /// data part are reported so a higher-level decoder report can
46    /// surface a precise location for transcription-error feedback.
47    #[error("invalid character {ch} at position {position}")]
48    InvalidChar {
49        /// The character that was not in the bech32 alphabet.
50        ch: char,
51        /// 0-indexed position within the data part (chars after `mk1`).
52        position: usize,
53    },
54
55    /// BCH checksum could not be corrected within the per-code-variant
56    /// substitution capacity (4 for regular, 8 for long).
57    #[error("BCH uncorrectable: {0}")]
58    BchUncorrectable(String),
59
60    /// Chunk-header card-type byte is not in {0x00 SingleString, 0x01 Chunked}.
61    /// The 5-bit type field's reserved range 0x02..=0x1F MUST be rejected.
62    #[error("unsupported card type: 0x{0:02x}")]
63    UnsupportedCardType(u8),
64
65    /// 5-bit payload symbols, after BCH verification, do not byte-align
66    /// (i.e., the trailing pad bits of the final 5-bit symbol are non-zero).
67    /// Parallels md1's `MalformedPayloadPadding` rejection.
68    #[error("malformed payload padding (5-bit symbols don't byte-align)")]
69    MalformedPayloadPadding,
70
71    /// For chunked input: chunks have inconsistent `chunk_set_id` values.
72    /// Used at reassembly time to detect mixed-card-set inputs.
73    #[error("chunk_set_id mismatch across chunks")]
74    ChunkSetIdMismatch,
75
76    /// For chunked input: malformed chunked-string header (e.g., total_chunks
77    /// = 0 or > 32, chunk_index >= total_chunks, gaps or duplicates in the
78    /// index sequence at reassembly).
79    #[error("chunked-header malformed: {0}")]
80    ChunkedHeaderMalformed(String),
81
82    /// Decoder received a multi-string input whose `SingleString` and
83    /// `Chunked` header variants disagree across the supplied list:
84    /// either the first string is `SingleString` but additional strings
85    /// follow (caught early in `pipeline::decode`), or the first chunk
86    /// is `Chunked` but a later chunk in the list is `SingleString`
87    /// (caught in `chunk::reassemble_from_chunks`). Distinct from
88    /// [`Error::ChunkedHeaderMalformed`], which covers issues *within*
89    /// a declared-chunked set (bad `chunk_index`, bad `total_chunks`,
90    /// duplicates, gaps, etc.).
91    #[error("mixed string-layer header types in input list")]
92    MixedHeaderTypes,
93
94    /// For chunked input: reassembled bytecode's trailing 4-byte
95    /// `cross_chunk_hash` does not match `SHA-256(canonical_bytecode)[0..4]`.
96    #[error("cross-chunk integrity hash mismatch")]
97    CrossChunkHashMismatch,
98
99    // ── Bytecode-layer errors (after string-layer reassembly) ────────────────
100    /// Bytecode-header version != 0 in v0.1.
101    #[error("unsupported version: {0}")]
102    UnsupportedVersion(u8),
103
104    /// A reserved bit in the bytecode header was set (bits 0, 1, 3 in v0.1;
105    /// bit 2 is the fingerprint flag and is allowed).
106    #[error("reserved bits set in bytecode header")]
107    ReservedBitsSet,
108
109    /// `policy_id_stub_count == 0`. The spec requires ≥ 1.
110    #[error("policy_id_stub_count must be >= 1")]
111    InvalidPolicyIdStubCount,
112
113    /// Origin-path indicator byte is outside the standard table or in the
114    /// reserved range. (Per SPEC §3.5: 0x00, 0x08-0x10, 0x16, 0x18-0xFD,
115    /// 0xFF are reserved; 0x16 is reserved pending md1 dictionary update,
116    /// see FOLLOWUPS `md-path-dictionary-0x16-gap`.)
117    #[error("invalid path indicator byte: 0x{0:02x}")]
118    InvalidPathIndicator(u8),
119
120    /// Explicit path declared `component_count > MAX_PATH_COMPONENTS`
121    /// (closure Q-3 lock: max 10, was 32 in the pre-closure draft).
122    #[error("path too deep: {0} components (max 10)")]
123    PathTooDeep(u8),
124
125    /// A path component's encoded value is invalid (e.g., out of BIP 32
126    /// range, or hardened-bit set in an invalid position).
127    #[error("invalid path component: {0}")]
128    InvalidPathComponent(String),
129
130    /// xpub `version` field doesn't match a known network's xpub prefix.
131    #[error("invalid xpub version: 0x{0:08x}")]
132    InvalidXpubVersion(u32),
133
134    /// xpub `public_key` bytes do not parse as a valid compressed
135    /// secp256k1 point. Realistically unreachable for inputs that
136    /// pass BCH verification; surfaces hand-constructed inputs.
137    #[error("invalid xpub public key: {0}")]
138    InvalidXpubPublicKey(String),
139
140    /// Decoder hit end-of-stream mid-field.
141    #[error("unexpected end of bytecode")]
142    UnexpectedEnd,
143
144    /// Decoder finished consuming all expected fields but bytes remain.
145    #[error("trailing bytes after xpub")]
146    TrailingBytes,
147
148    /// Canonical bytecode + cross-chunk hash exceeds the v0.1 capacity
149    /// of `MAX_CHUNKS * CHUNKED_FRAGMENT_LONG_BYTES − CROSS_CHUNK_HASH_BYTES`
150    /// (= 32 × 53 − 4 = 1692 bytes). Reachable only through pathological
151    /// hand-constructed inputs; typical mk1 cards land well below this
152    /// ceiling per `design/SPEC_mk_v0_1.md` §2.4.
153    #[error(
154        "card payload too large: bytecode_len = {bytecode_len} > max_supported = {max_supported}"
155    )]
156    CardPayloadTooLarge {
157        /// Observed canonical-bytecode length in bytes.
158        bytecode_len: usize,
159        /// Maximum bytecode length the v0.1 chunking layer can carry.
160        max_supported: usize,
161    },
162}
163
164/// `Result` alias used throughout `mk-codec`.
165pub type Result<T> = core::result::Result<T, Error>;
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// Each variant carries enough information for its rendered Display
172    /// to be diagnostic. Sanity-check the format strings render
173    /// correctly for every parameterized variant.
174    #[test]
175    fn parameterized_variants_render() {
176        let cases: Vec<(Error, &str)> = vec![
177            (Error::InvalidHrp("mq".into()), "invalid HRP: mq"),
178            (
179                Error::BchUncorrectable(
180                    "5 substitutions exceed long-code 4-correction limit".into(),
181                ),
182                "BCH uncorrectable: 5 substitutions exceed long-code 4-correction limit",
183            ),
184            (
185                Error::UnsupportedCardType(0x05),
186                "unsupported card type: 0x05",
187            ),
188            (
189                Error::ChunkedHeaderMalformed("total_chunks = 0".into()),
190                "chunked-header malformed: total_chunks = 0",
191            ),
192            (
193                Error::InvalidXpubPublicKey("malformed compressed point".into()),
194                "invalid xpub public key: malformed compressed point",
195            ),
196            (Error::UnsupportedVersion(1), "unsupported version: 1"),
197            (
198                Error::InvalidPathIndicator(0x16),
199                "invalid path indicator byte: 0x16",
200            ),
201            (
202                Error::PathTooDeep(11),
203                "path too deep: 11 components (max 10)",
204            ),
205            (
206                Error::InvalidPathComponent("LEB128 overflow at component 3".into()),
207                "invalid path component: LEB128 overflow at component 3",
208            ),
209            (
210                Error::InvalidXpubVersion(0xDEADBEEF),
211                "invalid xpub version: 0xdeadbeef",
212            ),
213        ];
214        for (err, expected) in cases {
215            assert_eq!(format!("{err}"), expected);
216        }
217    }
218
219    // ── String-layer rejection coverage (per plan §3.2.4) ──────────────
220    //
221    // Phase 5 landed the string-layer code paths that produce
222    // `CrossChunkHashMismatch`, `MalformedPayloadPadding`,
223    // `ChunkSetIdMismatch`, and `ChunkedHeaderMalformed`. The detailed
224    // reject scenarios live in `crate::string_layer::pipeline::tests`
225    // and `crate::string_layer::chunk::tests`; the smoke checks here
226    // assert that each variant is reachable through the public
227    // `crate::decode` API rather than just the lower-level layer
228    // helpers (the scaffolds documented in the plan §3.2.4 forward-
229    // reference these tests).
230    //
231    // (Phase 4 retired the proposed `FingerprintFlagMismatch` variant:
232    // structurally undetectable in the decoder under the closure-locked
233    // wire format, since no length prefix lets the decoder distinguish
234    // "flag set, fp present" from "flag unset, fp omitted." SPEC §4
235    // rule 3 was reframed as an encoder-side invariant; see commit
236    // log for Phase 4 review fixup.)
237
238    /// Unparameterized variants render their static message verbatim.
239    #[test]
240    fn static_variants_render() {
241        assert_eq!(
242            format!("{}", Error::ReservedBitsSet),
243            "reserved bits set in bytecode header",
244        );
245        assert_eq!(
246            format!("{}", Error::CrossChunkHashMismatch),
247            "cross-chunk integrity hash mismatch",
248        );
249        assert_eq!(
250            format!("{}", Error::ChunkSetIdMismatch),
251            "chunk_set_id mismatch across chunks",
252        );
253        assert_eq!(
254            format!("{}", Error::MixedHeaderTypes),
255            "mixed string-layer header types in input list",
256        );
257        assert_eq!(
258            format!("{}", Error::MalformedPayloadPadding),
259            "malformed payload padding (5-bit symbols don't byte-align)",
260        );
261        assert_eq!(
262            format!("{}", Error::InvalidPolicyIdStubCount),
263            "policy_id_stub_count must be >= 1",
264        );
265        assert_eq!(
266            format!("{}", Error::UnexpectedEnd),
267            "unexpected end of bytecode",
268        );
269        assert_eq!(
270            format!("{}", Error::TrailingBytes),
271            "trailing bytes after xpub",
272        );
273    }
274}