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