Skip to main content

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}