Skip to main content

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}