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}