uni_plugin/hex.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Hex codec for the guest/host wire boundary.
5//!
6//! Every loader that hands raw bytes across an untrusted boundary (Extism's
7//! JSON host-fn wire, Rhai's script boundary) needs the same lowercase-hex
8//! encode/decode pair. They were implemented independently and byte-identically
9//! in `uni-plugin-extism` and `uni-plugin-rhai`; they live here so the
10//! panic-safety property below is stated and tested once.
11
12use std::fmt::Write as _;
13
14/// Lowercase hex encoding for the guest/host wire boundary.
15#[must_use]
16pub fn to_hex(bytes: &[u8]) -> String {
17 let mut s = String::with_capacity(bytes.len() * 2);
18 for b in bytes {
19 let _ = write!(s, "{b:02x}");
20 }
21 s
22}
23
24/// Decode lowercase/uppercase hex; errors on odd length or non-hex digits.
25///
26/// Operates on raw bytes (`chunks_exact(2)`), NOT `&s[i..i+2]` string slicing.
27/// The guest or script controls this string, and byte-index slicing panics on a
28/// multibyte UTF-8 codepoint that happens to make the byte length even. A
29/// non-ASCII byte simply fails the hex-digit test and returns `Err`, so a
30/// hostile input cannot take down the host thread.
31///
32/// # Errors
33///
34/// Returns `Err` if `s` has odd byte length or contains a non-hex digit.
35pub fn from_hex(s: &str) -> Result<Vec<u8>, String> {
36 let bytes = s.as_bytes();
37 if !bytes.len().is_multiple_of(2) {
38 return Err("odd-length hex string".to_owned());
39 }
40 fn nibble(b: u8) -> Result<u8, String> {
41 match b {
42 b'0'..=b'9' => Ok(b - b'0'),
43 b'a'..=b'f' => Ok(b - b'a' + 10),
44 b'A'..=b'F' => Ok(b - b'A' + 10),
45 _ => Err("invalid hex digit".to_owned()),
46 }
47 }
48 bytes
49 .chunks_exact(2)
50 .map(|pair| Ok((nibble(pair[0])? << 4) | nibble(pair[1])?))
51 .collect()
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57
58 #[test]
59 fn hex_round_trips() {
60 let bytes = vec![0x00, 0x0f, 0x10, 0xff, 0xa5];
61 let hex = to_hex(&bytes);
62 assert_eq!(hex, "000f10ffa5");
63 assert_eq!(from_hex(&hex).unwrap(), bytes);
64 }
65
66 #[test]
67 fn from_hex_accepts_uppercase() {
68 assert_eq!(from_hex("A5FF").unwrap(), vec![0xa5, 0xff]);
69 }
70
71 #[test]
72 fn from_hex_errors_on_odd_length() {
73 assert!(from_hex("abc").is_err());
74 }
75
76 #[test]
77 fn from_hex_errors_on_invalid_digit() {
78 assert!(from_hex("zz").is_err());
79 }
80
81 /// A multibyte codepoint can make `s.len()` even while `&s[0..2]` is not a
82 /// char boundary. Byte-wise decoding must return `Err`, never panic.
83 #[test]
84 fn from_hex_errors_on_even_byte_multibyte_input() {
85 // "é" is 2 bytes in UTF-8, so this string has even byte length.
86 let res = from_hex("é");
87 assert!(res.is_err(), "multibyte input must error, not panic");
88 }
89}