Skip to main content

ms_codec/
inspect.rs

1//! Structural inspection of an ms1 string for debugging / future ms-cli.
2
3use crate::consts::MNEM_PREFIX;
4use crate::envelope;
5use crate::error::Result;
6use crate::tag::Tag;
7use codex32::Codex32String;
8
9/// Payload kind as decoded by `inspect()`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum InspectKind {
12    /// `entr` — raw BIP-39 entropy (0x00 prefix byte, v0.1).
13    Entr,
14    /// `mnem` — BIP-39 mnemonic entropy with language tag (0x02 prefix byte, v0.2).
15    Mnem,
16    /// Any other prefix byte — future or invalid.
17    Unknown,
18}
19
20impl InspectKind {
21    /// Kebab-case name for text/JSON output.
22    pub fn as_str(self) -> &'static str {
23        match self {
24            InspectKind::Entr => "entr",
25            InspectKind::Mnem => "mnem",
26            InspectKind::Unknown => "unknown",
27        }
28    }
29}
30
31/// Structural dump of a parsed ms1 string. `#[non_exhaustive]` per SPEC §10
32/// — v0.2+ may add fields (share-index detail, threshold-layer hints,
33/// derivation metadata).
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub struct InspectReport {
37    /// Expected "ms" in v0.1.
38    pub hrp: String,
39    /// Expected 0 in v0.1.
40    pub threshold: u8,
41    /// The parsed type tag (id field).
42    pub tag: Tag,
43    /// Expected 's' in v0.1.
44    pub share_index: char,
45    /// 0x00 in v0.1 (reserved); becomes type discriminator in v0.2+.
46    pub prefix_byte: u8,
47    /// Payload bytes after the prefix byte.
48    pub payload_bytes: Vec<u8>,
49    /// BCH verification result. True if the upstream codex32 parser accepted.
50    pub checksum_valid: bool,
51    /// Payload kind derived from the prefix byte.
52    pub kind: InspectKind,
53    /// For `kind == Mnem`: the language byte (index into `MNEM_LANGUAGE_NAMES`).
54    /// `None` for all other kinds.
55    pub language: Option<u8>,
56}
57
58/// Inspect an ms1 string. Less strict than `decode()`: returns a report even
59/// for strings that would fail decoder validity rules (e.g., wrong threshold,
60/// reserved-not-emitted tag, non-zero prefix byte) — caller can examine the
61/// fields to diagnose what's wrong. Still requires a valid BIP-93 parse.
62pub fn inspect(s: &str) -> Result<InspectReport> {
63    // `?` leverages From<codex32::Error> for Error.
64    let c = Codex32String::from_string(s.to_string())?;
65    // Canonical lowercase wire copy (BIP-173 uppercase QR form folds here;
66    // codex32 already rejected mixed case). Lowercasing loses no diagnostic
67    // information — codex32 enforces whole-string uniform case, and the
68    // "surface the raw observation" intent is about non-table tag VALUES.
69    let s_owned = envelope::wire_string(&c);
70    let fields = envelope::extract_wire_fields(&s_owned)?;
71
72    // For tag construction in inspect we accept whatever bytes were on the wire
73    // (alphabet-valid or not) — surfacing the raw observation is the point.
74    let tag = match std::str::from_utf8(&fields.id_bytes) {
75        Ok(t) => Tag::try_new(t).unwrap_or_else(|_| Tag::from_raw_bytes(fields.id_bytes)),
76        Err(_) => Tag::from_raw_bytes(fields.id_bytes),
77    };
78
79    let payload_with_prefix = c.parts().data();
80    let (prefix_byte, payload_bytes) = if payload_with_prefix.is_empty() {
81        (0u8, Vec::new())
82    } else {
83        (payload_with_prefix[0], payload_with_prefix[1..].to_vec())
84    };
85
86    // Classify the payload kind and extract the language byte for mnem payloads.
87    let (kind, language) = match prefix_byte {
88        0x00 => (InspectKind::Entr, None),
89        MNEM_PREFIX => {
90            // payload_bytes = [lang_byte, entropy...]; language is the first byte.
91            let lang = payload_bytes.first().copied();
92            (InspectKind::Mnem, lang)
93        }
94        _ => (InspectKind::Unknown, None),
95    };
96
97    Ok(InspectReport {
98        hrp: fields.hrp.to_string(),
99        threshold: fields.threshold_byte - b'0', // ASCII to digit
100        tag,
101        share_index: fields.share_index_byte as char,
102        prefix_byte,
103        payload_bytes,
104        checksum_valid: true, // if from_string accepted, BCH was valid
105        kind,
106        language,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::{encode, payload::Payload};
114
115    #[test]
116    fn inspect_v01_entr_returns_expected_fields() {
117        let entropy = vec![0xAAu8; 16];
118        let s = encode::encode(Tag::ENTR, &Payload::Entr(entropy.clone())).unwrap();
119        let r = inspect(&s).unwrap();
120        assert_eq!(r.hrp, "ms");
121        assert_eq!(r.threshold, 0);
122        assert_eq!(r.tag, Tag::ENTR);
123        assert_eq!(r.share_index, 's');
124        assert_eq!(r.prefix_byte, 0x00);
125        assert_eq!(r.payload_bytes, entropy);
126        assert!(r.checksum_valid);
127    }
128
129    #[test]
130    fn inspect_returns_report_for_decoder_rejects() {
131        // A non-zero-prefix string: decode() rejects, inspect() returns the report.
132        let mut data = vec![0x01u8];
133        data.extend_from_slice(&[0xAAu8; 16]);
134        let c = Codex32String::from_seed("ms", 0, "entr", codex32::Fe::S, &data).unwrap();
135        let r = inspect(&c.to_string()).unwrap();
136        assert_eq!(r.prefix_byte, 0x01); // would fail decode rule 8, inspect surfaces it
137    }
138}