bones_core/event/
hash_text.rs1pub const BLAKE3_PREFIX: &str = "blake3:";
4
5const BASE64_URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6
7#[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#[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}