Skip to main content

ms_codec/
inspect.rs

1//! Structural inspection of an ms1 string for debugging / future ms-cli.
2
3use crate::codex32::Codex32String;
4use crate::consts::{MNEM_PREFIX, PREIMAGE_PREFIX};
5use crate::envelope;
6use crate::error::Result;
7use crate::tag::Tag;
8use std::fmt;
9use zeroize::Zeroizing;
10
11/// Payload kind as decoded by `inspect()`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InspectKind {
14    /// `entr` — raw BIP-39 entropy (0x00 prefix byte, v0.1).
15    Entr,
16    /// `mnem` — BIP-39 mnemonic entropy with language tag (0x02 prefix byte, v0.2).
17    Mnem,
18    /// `hash` — a hashlock preimage (0x03 prefix byte, v0.8).
19    Preimage,
20    /// Any other prefix byte — future or invalid.
21    Unknown,
22}
23
24impl InspectKind {
25    /// Kebab-case name for text/JSON output.
26    pub fn as_str(self) -> &'static str {
27        match self {
28            InspectKind::Entr => "entr",
29            InspectKind::Mnem => "mnem",
30            InspectKind::Preimage => "preimage",
31            InspectKind::Unknown => "unknown",
32        }
33    }
34}
35
36/// Structural dump of a parsed ms1 string. `#[non_exhaustive]` per SPEC §10
37/// — v0.2+ may add fields (share-index detail, threshold-layer hints,
38/// derivation metadata).
39///
40/// `Debug` is **hand-rolled** (not derived) to redact `payload_bytes`
41/// (RULE Z-DEBUG, cycle-15 Lane M): `Zeroizing<Vec<u8>>`'s own derived `Debug`
42/// is non-redacting (forwards to `Vec`), so a derived `Debug` here would leak
43/// the raw entropy bytes. The hand-roll surfaces every *structural* field
44/// verbatim and renders the secret bytes as a length-only `[REDACTED; N]`
45/// placeholder. See the `impl fmt::Debug` below.
46#[derive(Clone)]
47#[non_exhaustive]
48pub struct InspectReport {
49    /// Expected "ms" in v0.1.
50    pub hrp: String,
51    /// Expected 0 in v0.1.
52    pub threshold: u8,
53    /// The parsed type tag (id field).
54    pub tag: Tag,
55    /// Expected 's' in v0.1.
56    pub share_index: char,
57    /// 0x00 in v0.1 (reserved); becomes type discriminator in v0.2+.
58    pub prefix_byte: u8,
59    /// Payload bytes after the prefix byte. Wrapped in `Zeroizing` so the
60    /// decoded secret entropy is scrubbed on drop (cycle-15 Lane M). The
61    /// hand-rolled `Debug` (below) redacts it; `Deref<Target=Vec<u8>>` keeps
62    /// read-only consumers (`.len()`, `hex::encode(&field)`) unchanged.
63    pub payload_bytes: Zeroizing<Vec<u8>>,
64    /// BCH verification result. True if the upstream codex32 parser accepted.
65    pub checksum_valid: bool,
66    /// Payload kind derived from the prefix byte.
67    pub kind: InspectKind,
68    /// For `kind == Mnem`: the language byte (index into `MNEM_LANGUAGE_NAMES`).
69    /// `None` for all other kinds.
70    pub language: Option<u8>,
71}
72
73impl fmt::Debug for InspectReport {
74    /// Hand-rolled redacting `Debug` (RULE Z-DEBUG): surfaces every structural
75    /// field verbatim and renders the secret `payload_bytes` as a length-only
76    /// `[REDACTED; N]` placeholder so the raw entropy can never reach a debug
77    /// dump. Mirrors the no-echo precedent on `crate::error::Error` (`error.rs`).
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_struct("InspectReport")
80            .field("hrp", &self.hrp)
81            .field("threshold", &self.threshold)
82            .field("tag", &self.tag)
83            .field("share_index", &self.share_index)
84            .field("prefix_byte", &self.prefix_byte)
85            .field(
86                "payload_bytes",
87                &format_args!("[REDACTED; {} bytes]", self.payload_bytes.len()),
88            )
89            .field("checksum_valid", &self.checksum_valid)
90            .field("kind", &self.kind)
91            .field("language", &self.language)
92            .finish()
93    }
94}
95
96/// Inspect an ms1 string. Less strict than `decode()`: returns a report even
97/// for strings that would fail decoder validity rules (e.g., wrong threshold,
98/// reserved-not-emitted tag, non-zero prefix byte) — caller can examine the
99/// fields to diagnose what's wrong. Still requires a valid BIP-93 parse.
100pub fn inspect(s: &str) -> Result<InspectReport> {
101    // `?` leverages From<crate::codex32::Error> for Error.
102    let c = Codex32String::from_string(s.to_string())?;
103    // Canonical lowercase wire copy (BIP-173 uppercase QR form folds here;
104    // codex32 already rejected mixed case). Lowercasing loses no diagnostic
105    // information — codex32 enforces whole-string uniform case, and the
106    // "surface the raw observation" intent is about non-table tag VALUES.
107    let s_owned = envelope::wire_string(&c);
108    let fields = envelope::extract_wire_fields(&s_owned)?;
109
110    // For tag construction in inspect we accept whatever bytes were on the wire
111    // (alphabet-valid or not) — surfacing the raw observation is the point.
112    let tag = match std::str::from_utf8(&fields.id_bytes) {
113        Ok(t) => Tag::try_new(t).unwrap_or_else(|_| Tag::from_raw_bytes(fields.id_bytes)),
114        Err(_) => Tag::from_raw_bytes(fields.id_bytes),
115    };
116
117    let payload_with_prefix = c.parts().data();
118    let (prefix_byte, payload_bytes) = if payload_with_prefix.is_empty() {
119        (0u8, Vec::new())
120    } else {
121        (payload_with_prefix[0], payload_with_prefix[1..].to_vec())
122    };
123
124    // Classify the payload kind and extract the language byte for mnem payloads.
125    let (kind, language) = match prefix_byte {
126        0x00 => (InspectKind::Entr, None),
127        MNEM_PREFIX => {
128            // payload_bytes = [lang_byte, entropy...]; language is the first byte.
129            let lang = payload_bytes.first().copied();
130            (InspectKind::Mnem, lang)
131        }
132        PREIMAGE_PREFIX => (InspectKind::Preimage, None),
133        _ => (InspectKind::Unknown, None),
134    };
135
136    Ok(InspectReport {
137        hrp: fields.hrp.to_string(),
138        threshold: fields.threshold_byte - b'0', // ASCII to digit
139        tag,
140        share_index: fields.share_index_byte as char,
141        prefix_byte,
142        payload_bytes: Zeroizing::new(payload_bytes),
143        checksum_valid: true, // if from_string accepted, BCH was valid
144        kind,
145        language,
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::{encode, payload::Payload};
153
154    #[test]
155    fn inspect_v01_entr_returns_expected_fields() {
156        let entropy = vec![0xAAu8; 16];
157        let s = encode::encode(Tag::ENTR, &Payload::Entr(entropy.clone())).unwrap();
158        let r = inspect(&s).unwrap();
159        assert_eq!(r.hrp, "ms");
160        assert_eq!(r.threshold, 0);
161        assert_eq!(r.tag, Tag::ENTR);
162        assert_eq!(r.share_index, 's');
163        assert_eq!(r.prefix_byte, 0x00);
164        // I-1 (cycle-15 Lane M): `payload_bytes` is now `Zeroizing<Vec<u8>>`,
165        // which has no `PartialEq<Vec<u8>>` (and `Deref` doesn't bridge `==`),
166        // so deref the field rather than deriving `PartialEq` on the secret-
167        // bearing struct.
168        assert_eq!(*r.payload_bytes, entropy);
169        assert!(r.checksum_valid);
170    }
171
172    #[test]
173    fn inspect_returns_report_for_decoder_rejects() {
174        // A non-zero-prefix string: decode() rejects, inspect() returns the report.
175        let mut data = vec![0x01u8];
176        data.extend_from_slice(&[0xAAu8; 16]);
177        let c = Codex32String::from_seed("ms", 0, "entr", crate::codex32::Fe::S, &data).unwrap();
178        let r = inspect(&c.to_string()).unwrap();
179        assert_eq!(r.prefix_byte, 0x01); // would fail decode rule 8, inspect surfaces it
180    }
181}