1const KEY_CONTEXT: &str = "beck stdlib keyed digest v1";
35
36pub fn of(text: &str) -> String {
38 blake3::hash(text.as_bytes()).to_hex().to_string()
39}
40
41pub fn keyed(key: &str, message: &str) -> String {
45 let derived = blake3::derive_key(KEY_CONTEXT, key.as_bytes());
46 blake3::keyed_hash(&derived, message.as_bytes())
47 .to_hex()
48 .to_string()
49}
50
51pub fn same(a: &str, b: &str) -> bool {
56 let (a, b) = (a.as_bytes(), b.as_bytes());
57 if a.len() != b.len() {
58 return false;
59 }
60 let mut diff = 0u8;
61 for (x, y) in a.iter().zip(b) {
62 diff |= x ^ y;
63 }
64 diff == 0
65}
66
67pub fn hex_encode(text: &str) -> String {
69 let mut out = String::with_capacity(text.len() * 2);
70 for b in text.as_bytes() {
71 out.push(char::from(HEX[usize::from(b >> 4)]));
72 out.push(char::from(HEX[usize::from(b & 0x0f)]));
73 }
74 out
75}
76
77const HEX: &[u8; 16] = b"0123456789abcdef";
78
79pub fn hex_decode(text: &str) -> Result<String, String> {
85 if !text.len().is_multiple_of(2) {
86 return Err(format!(
87 "hex has two digits per byte, and `{}` has an odd number of them",
88 text.len()
89 ));
90 }
91 let digits = text.as_bytes();
92 let mut bytes = Vec::with_capacity(digits.len() / 2);
93 for pair in digits.chunks(2) {
94 let hi = digit(pair[0])?;
95 let lo = digit(pair[1])?;
96 bytes.push(hi << 4 | lo);
97 }
98 utf8(bytes)
99}
100
101fn digit(c: u8) -> Result<u8, String> {
102 match c {
103 b'0'..=b'9' => Ok(c - b'0'),
104 b'a'..=b'f' => Ok(c - b'a' + 10),
105 b'A'..=b'F' => Ok(c - b'A' + 10),
106 _ => Err(format!(
107 "`{}` is not a hex digit",
108 char::from(c).escape_default()
109 )),
110 }
111}
112
113pub fn base64_encode(text: &str) -> String {
120 base64_encode_bytes(text.as_bytes())
121}
122
123pub fn base64_encode_bytes(bytes: &[u8]) -> String {
129 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
130 for chunk in bytes.chunks(3) {
131 let b = [
132 chunk[0],
133 chunk.get(1).copied().unwrap_or(0),
134 chunk.get(2).copied().unwrap_or(0),
135 ];
136 let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
137 let digits = [n >> 18, n >> 12 & 63, n >> 6 & 63, n & 63];
138 for d in digits.iter().take(chunk.len() + 1) {
139 out.push(char::from(B64[*d as usize]));
140 }
141 }
142 out
143}
144
145const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
146
147pub fn base64_decode(text: &str) -> Result<String, String> {
153 utf8(base64_decode_bytes(text)?)
154}
155
156pub fn base64_decode_bytes(text: &str) -> Result<Vec<u8>, String> {
162 let text = text.trim_end_matches('=');
163 let mut acc: u32 = 0;
164 let mut bits = 0u32;
165 let mut bytes = Vec::with_capacity(text.len() / 4 * 3);
166 for c in text.bytes() {
167 let six = sextet(c)?;
168 acc = acc << 6 | u32::from(six);
169 bits += 6;
170 if bits >= 8 {
171 bits -= 8;
172 bytes.push((acc >> bits) as u8);
173 }
174 }
175 if bits >= 6 {
177 return Err(format!(
178 "`{}` ends in a group of one character, which encodes no byte",
179 text.escape_default()
180 ));
181 }
182 Ok(bytes)
183}
184
185fn sextet(c: u8) -> Result<u8, String> {
186 Ok(match c {
187 b'A'..=b'Z' => c - b'A',
188 b'a'..=b'z' => c - b'a' + 26,
189 b'0'..=b'9' => c - b'0' + 52,
190 b'-' | b'+' => 62,
191 b'_' | b'/' => 63,
192 _ => {
193 return Err(format!(
194 "`{}` is not a base64 character",
195 char::from(c).escape_default()
196 ));
197 }
198 })
199}
200
201fn utf8(bytes: Vec<u8>) -> Result<String, String> {
202 String::from_utf8(bytes).map_err(|_| "the decoded bytes are not UTF-8".to_string())
203}
204
205pub fn uuid_normalise(text: &str) -> Result<String, String> {
212 let trimmed = text.trim();
213 let inner = trimmed
214 .strip_prefix('{')
215 .and_then(|s| s.strip_suffix('}'))
216 .unwrap_or(trimmed);
217 let inner = inner.strip_prefix("urn:uuid:").unwrap_or(inner);
218 let digits: String = inner.chars().filter(|c| *c != '-').collect();
219 if digits.chars().count() != 32 || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
220 return Err(format!(
221 "`{}` is not a UUID: 32 hex digits are wanted, in the 8-4-4-4-12 form",
222 text.escape_default()
223 ));
224 }
225 if inner.contains('-') && !hyphenated(inner) {
228 return Err(format!(
229 "`{}` is not a UUID: the groups are 8-4-4-4-12",
230 text.escape_default()
231 ));
232 }
233 let d = digits.to_ascii_lowercase();
234 Ok(format!(
235 "{}-{}-{}-{}-{}",
236 &d[0..8],
237 &d[8..12],
238 &d[12..16],
239 &d[16..20],
240 &d[20..32]
241 ))
242}
243
244fn hyphenated(s: &str) -> bool {
245 let groups: Vec<usize> = s.split('-').map(|g| g.chars().count()).collect();
246 groups == [8, 4, 4, 4, 12]
247}
248
249pub fn uuid_version(canonical: &str) -> i64 {
255 canonical
256 .as_bytes()
257 .get(14)
258 .and_then(|c| char::from(*c).to_digit(16))
259 .map(i64::from)
260 .unwrap_or(0)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn the_digest_is_blake3_and_the_vector_is_the_specifications() {
269 assert_eq!(
272 of(""),
273 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
274 );
275 assert_eq!(
276 of("abc"),
277 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
278 );
279 }
280
281 #[test]
282 fn a_keyed_digest_depends_on_both_halves_and_on_neither_alone() {
283 let a = keyed("k1", "message");
284 assert_ne!(
285 a,
286 keyed("k2", "message"),
287 "a different key, a different mac"
288 );
289 assert_ne!(
290 a,
291 keyed("k1", "other"),
292 "a different message, a different mac"
293 );
294 assert_eq!(a, keyed("k1", "message"), "and it is a function");
295 assert_ne!(a.len(), 0);
298 assert_eq!(a.len(), 64);
299 }
300
301 #[test]
302 fn constant_time_equality_answers_the_same_question_as_equality() {
303 for (a, b) in [
304 ("", ""),
305 ("a", "a"),
306 ("ab", "ab"),
307 ("a", "b"),
308 ("a", ""),
309 ("", "b"),
310 ] {
311 assert_eq!(same(a, b), a == b, "{a:?} vs {b:?}");
312 }
313 }
314
315 #[test]
316 fn hex_round_trips_and_the_vectors_are_ascii() {
317 assert_eq!(hex_encode("hello"), "68656c6c6f");
318 assert_eq!(
319 hex_decode("68656C6C6F").unwrap(),
320 "hello",
321 "uppercase reads"
322 );
323 for s in [
324 "",
325 "hello",
326 "a longer string with punctuation!",
327 "unicode: é☃",
328 ] {
329 assert_eq!(hex_decode(&hex_encode(s)).unwrap(), s);
330 }
331 assert!(hex_decode("abc").is_err(), "odd length");
332 assert!(hex_decode("zz").is_err(), "not a digit");
333 }
334
335 #[test]
336 fn base64_matches_rfc_4648s_test_vectors() {
337 for (plain, encoded) in [
339 ("", ""),
340 ("f", "Zg"),
341 ("fo", "Zm8"),
342 ("foo", "Zm9v"),
343 ("foob", "Zm9vYg"),
344 ("fooba", "Zm9vYmE"),
345 ("foobar", "Zm9vYmFy"),
346 ] {
347 assert_eq!(base64_encode(plain), encoded, "encoding {plain:?}");
348 assert_eq!(
349 base64_decode(encoded).unwrap(),
350 plain,
351 "decoding {encoded:?}"
352 );
353 }
354 }
355
356 #[test]
357 fn base64_reads_what_other_encoders_write() {
358 assert_eq!(base64_decode("Zm9vYmE=").unwrap(), "fooba", "padded");
359 assert_eq!(base64_decode("Zm9vYmE==").unwrap(), "fooba", "over-padded");
360 assert_eq!(base64_decode("YWE+").unwrap(), "aa>");
363 assert_eq!(base64_decode("YWE-").unwrap(), "aa>");
364 assert_eq!(base64_decode("YWE/").unwrap(), "aa?");
365 assert_eq!(base64_decode("YWE_").unwrap(), "aa?");
366 assert!(
367 base64_decode("Zm9vYmFy!").is_err(),
368 "not a base64 character"
369 );
370 assert!(
371 base64_decode("Z").is_err(),
372 "a group of one encodes no byte"
373 );
374 }
375
376 #[test]
377 fn base64_round_trips_every_length_of_a_growing_string() {
378 let mut s = String::new();
379 for c in "the quick brown fox jumps over the lazy dog".chars() {
380 s.push(c);
381 assert_eq!(base64_decode(&base64_encode(&s)).unwrap(), s, "{s:?}");
382 }
383 }
384
385 #[test]
386 fn a_uuid_normalises_to_one_spelling() {
387 let canonical = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
388 for spelling in [
389 "f47ac10b-58cc-4372-a567-0e02b2c3d479",
390 "F47AC10B-58CC-4372-A567-0E02B2C3D479",
391 "f47ac10b58cc4372a5670e02b2c3d479",
392 "{f47ac10b-58cc-4372-a567-0e02b2c3d479}",
393 "urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479",
394 " f47ac10b-58cc-4372-a567-0e02b2c3d479 ",
395 ] {
396 assert_eq!(
397 uuid_normalise(spelling).as_deref(),
398 Ok(canonical),
399 "{spelling}"
400 );
401 }
402 assert_eq!(uuid_version(canonical), 4);
403 }
404
405 #[test]
406 fn what_is_not_a_uuid_says_which_way_it_is_not() {
407 for bad in [
408 "",
409 "f47ac10b-58cc-4372-a567-0e02b2c3d47", "f47ac10b-58cc-4372-a567-0e02b2c3d4799", "g47ac10b-58cc-4372-a567-0e02b2c3d479", "f47ac10b-58c-c4372-a567-0e02b2c3d479", ] {
414 assert!(uuid_normalise(bad).is_err(), "{bad:?} is not a UUID");
415 }
416 }
417}