Skip to main content

bones_core/event/
hash_text.rs

1//! Text encoding/decoding helpers for BLAKE3 hashes.
2
3pub const BLAKE3_PREFIX: &str = "blake3:";
4
5const BASE64_URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6
7/// Encode a BLAKE3 digest as `blake3:<base64url-no-pad>`.
8#[must_use]
9pub fn encode_blake3_hash(hash: &blake3::Hash) -> String {
10    format!(
11        "{BLAKE3_PREFIX}{}",
12        encode_base64_url_no_pad(hash.as_bytes())
13    )
14}
15
16/// Decode `blake3:<...>` text into 32 raw bytes.
17///
18/// Accepts:
19/// - Legacy hex payload (`64` hex chars)
20/// - Base64url no-pad payload (`43` chars for 32-byte digests)
21#[must_use]
22pub fn decode_blake3_hash(raw: &str) -> Option<[u8; 32]> {
23    let payload = raw.strip_prefix(BLAKE3_PREFIX)?;
24
25    if payload.len() == 64 && payload.chars().all(|c| c.is_ascii_hexdigit()) {
26        let bytes = decode_hex(payload)?;
27        return bytes.try_into().ok();
28    }
29
30    let bytes = decode_base64_url_no_pad(payload)?;
31    bytes.try_into().ok()
32}
33
34#[must_use]
35pub fn is_valid_blake3_hash(raw: &str) -> bool {
36    decode_blake3_hash(raw).is_some()
37}
38
39fn encode_base64_url_no_pad(bytes: &[u8]) -> String {
40    let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
41    let mut idx = 0usize;
42
43    while idx + 3 <= bytes.len() {
44        let b0 = bytes[idx];
45        let b1 = bytes[idx + 1];
46        let b2 = bytes[idx + 2];
47        out.push(char::from(BASE64_URL[usize::from(b0 >> 2)]));
48        out.push(char::from(
49            BASE64_URL[usize::from(((b0 & 0b0000_0011) << 4) | (b1 >> 4))],
50        ));
51        out.push(char::from(
52            BASE64_URL[usize::from(((b1 & 0b0000_1111) << 2) | (b2 >> 6))],
53        ));
54        out.push(char::from(BASE64_URL[usize::from(b2 & 0b0011_1111)]));
55        idx += 3;
56    }
57
58    let remainder = bytes.len() - idx;
59    if remainder == 1 {
60        let b0 = bytes[idx];
61        out.push(char::from(BASE64_URL[usize::from(b0 >> 2)]));
62        out.push(char::from(BASE64_URL[usize::from((b0 & 0b0000_0011) << 4)]));
63    } else if remainder == 2 {
64        let b0 = bytes[idx];
65        let b1 = bytes[idx + 1];
66        out.push(char::from(BASE64_URL[usize::from(b0 >> 2)]));
67        out.push(char::from(
68            BASE64_URL[usize::from(((b0 & 0b0000_0011) << 4) | (b1 >> 4))],
69        ));
70        out.push(char::from(BASE64_URL[usize::from((b1 & 0b0000_1111) << 2)]));
71    }
72
73    out
74}
75
76fn decode_base64_url_no_pad(raw: &str) -> Option<Vec<u8>> {
77    let input = raw.as_bytes();
78    if input.len() % 4 == 1 {
79        return None;
80    }
81
82    let mut out = Vec::with_capacity(input.len() * 3 / 4 + 2);
83    let mut cursor = 0usize;
84
85    while cursor + 4 <= input.len() {
86        let a = decode_base64_url_digit(*input.get(cursor)?)?;
87        let b = decode_base64_url_digit(*input.get(cursor + 1)?)?;
88        let c = decode_base64_url_digit(*input.get(cursor + 2)?)?;
89        let d = decode_base64_url_digit(*input.get(cursor + 3)?)?;
90        out.push((a << 2) | (b >> 4));
91        out.push(((b & 0b0000_1111) << 4) | (c >> 2));
92        out.push(((c & 0b0000_0011) << 6) | d);
93        cursor += 4;
94    }
95
96    let remainder = input.len() - cursor;
97    if remainder == 2 {
98        let a = decode_base64_url_digit(*input.get(cursor)?)?;
99        let b = decode_base64_url_digit(*input.get(cursor + 1)?)?;
100        out.push((a << 2) | (b >> 4));
101    } else if remainder == 3 {
102        let a = decode_base64_url_digit(*input.get(cursor)?)?;
103        let b = decode_base64_url_digit(*input.get(cursor + 1)?)?;
104        let c = decode_base64_url_digit(*input.get(cursor + 2)?)?;
105        out.push((a << 2) | (b >> 4));
106        out.push(((b & 0b0000_1111) << 4) | (c >> 2));
107    } else if remainder != 0 {
108        return None;
109    }
110
111    Some(out)
112}
113
114const fn decode_base64_url_digit(raw: u8) -> Option<u8> {
115    match raw {
116        b'A'..=b'Z' => Some(raw - b'A'),
117        b'a'..=b'z' => Some(raw - b'a' + 26),
118        b'0'..=b'9' => Some(raw - b'0' + 52),
119        b'-' => Some(62),
120        b'_' => Some(63),
121        _ => None,
122    }
123}
124
125fn decode_hex(raw: &str) -> Option<Vec<u8>> {
126    if !raw.len().is_multiple_of(2) {
127        return None;
128    }
129    let mut out = Vec::with_capacity(raw.len() / 2);
130    let bytes = raw.as_bytes();
131    let mut i = 0usize;
132    while i < bytes.len() {
133        let hi = decode_hex_nibble(*bytes.get(i)?)?;
134        let lo = decode_hex_nibble(*bytes.get(i + 1)?)?;
135        out.push((hi << 4) | lo);
136        i += 2;
137    }
138    Some(out)
139}
140
141const fn decode_hex_nibble(raw: u8) -> Option<u8> {
142    match raw {
143        b'0'..=b'9' => Some(raw - b'0'),
144        b'a'..=b'f' => Some(raw - b'a' + 10),
145        b'A'..=b'F' => Some(raw - b'A' + 10),
146        _ => None,
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn base64_roundtrip_for_blake3_digest() {
156        let digest = blake3::hash(b"hello-world");
157        let encoded = encode_blake3_hash(&digest);
158        let decoded = decode_blake3_hash(&encoded).expect("decode");
159        assert_eq!(decoded, *digest.as_bytes());
160    }
161
162    #[test]
163    fn accepts_legacy_hex_payload() {
164        let digest = blake3::hash(b"legacy");
165        let legacy = format!("blake3:{}", digest.to_hex());
166        let decoded = decode_blake3_hash(&legacy).expect("decode");
167        assert_eq!(decoded, *digest.as_bytes());
168    }
169
170    #[test]
171    fn rejects_invalid_payloads() {
172        assert!(decode_blake3_hash("blake3:").is_none());
173        assert!(decode_blake3_hash("blake3:abc").is_none());
174        assert!(decode_blake3_hash("blake3:xyz!").is_none());
175        assert!(decode_blake3_hash("sha256:abcd").is_none());
176    }
177}