Skip to main content

ms_codec/
tag.rs

1//! Tag type — 4-byte codex32-alphabet validated type tag.
2
3use crate::consts::TAG_ENTR;
4use crate::error::{Error, Result};
5
6/// codex32 alphabet (BIP-173 lowercase bech32 charset).
7const CODEX32_ALPHABET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
8
9/// 4-byte type tag. Field is private to enforce validated construction via
10/// `try_new` (alphabet-checked) or `from_raw_bytes` (tooling-only, unvalidated).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct Tag([u8; 4]);
13
14impl Tag {
15    /// The v0.1 emit-tag for BIP-39 entropy.
16    pub const ENTR: Tag = Tag(TAG_ENTR);
17
18    /// Construct a Tag from raw 4-byte input WITHOUT alphabet validation.
19    /// Reserved for tooling (e.g., `inspect()`) that needs to surface whatever
20    /// bytes were observed on the wire, including alphabet violators. Encoder
21    /// + decoder paths MUST go through `try_new` instead.
22    pub fn from_raw_bytes(b: [u8; 4]) -> Self {
23        Tag(b)
24    }
25
26    /// Construct a Tag from a 4-character string slice. Returns
27    /// `Error::TagInvalidAlphabet` if any character is outside the codex32 alphabet.
28    pub fn try_new(s: &str) -> Result<Self> {
29        let bytes = s.as_bytes();
30        if bytes.len() != 4 {
31            // Length mismatch: the partial-input bytes carry no useful diagnostic
32            // information (the tag wasn't even the right shape). Return an empty
33            // 4-byte sentinel to keep the error variant payload simple.
34            return Err(Error::TagInvalidAlphabet { got: [0; 4] });
35        }
36        let mut out = [0u8; 4];
37        for (i, b) in bytes.iter().enumerate() {
38            if !CODEX32_ALPHABET.contains(b) {
39                return Err(Error::TagInvalidAlphabet {
40                    got: [bytes[0], bytes[1], bytes[2], bytes[3]],
41                });
42            }
43            out[i] = *b;
44        }
45        Ok(Tag(out))
46    }
47
48    /// Borrow the underlying 4 bytes.
49    pub fn as_bytes(&self) -> &[u8; 4] {
50        &self.0
51    }
52
53    /// View the tag as a string slice. Always succeeds for `try_new`-constructed
54    /// tags (codex32 alphabet is ASCII); for `from_raw_bytes`-constructed tags
55    /// containing non-UTF-8 bytes, returns "<non-utf8>".
56    pub fn as_str(&self) -> &str {
57        std::str::from_utf8(&self.0).unwrap_or("<non-utf8>")
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn entr_const_matches_string() {
67        assert_eq!(Tag::ENTR.as_str(), "entr");
68    }
69
70    #[test]
71    fn try_new_accepts_alphabet_chars() {
72        // All four lowercase reserved tags should parse.
73        for s in ["entr", "seed", "xprv", "mnem", "prvk"] {
74            let t = Tag::try_new(s).expect(s);
75            assert_eq!(t.as_str(), s);
76        }
77    }
78
79    #[test]
80    fn try_new_rejects_uppercase() {
81        // codex32 alphabet is lowercase; uppercase bytes are rejected.
82        assert!(matches!(
83            Tag::try_new("ENTR"),
84            Err(Error::TagInvalidAlphabet { .. })
85        ));
86    }
87
88    #[test]
89    fn try_new_rejects_out_of_alphabet_chars() {
90        // 'b' and 'i' and 'o' are NOT in the codex32 alphabet (excluded for OCR safety).
91        for s in ["beer", "iron", "oboe"] {
92            assert!(
93                matches!(Tag::try_new(s), Err(Error::TagInvalidAlphabet { .. })),
94                "expected reject for {:?}",
95                s
96            );
97        }
98    }
99
100    #[test]
101    fn try_new_rejects_wrong_length() {
102        for s in ["", "a", "ab", "abc", "abcde"] {
103            assert!(
104                matches!(Tag::try_new(s), Err(Error::TagInvalidAlphabet { .. })),
105                "expected reject for {:?}",
106                s
107            );
108        }
109    }
110
111    #[test]
112    fn from_raw_bytes_skips_validation() {
113        // Tooling-only construction path; uppercase bytes preserved.
114        let t = Tag::from_raw_bytes(*b"ENTR");
115        assert_eq!(t.as_bytes(), b"ENTR");
116    }
117}