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