Skip to main content

iac_forge/
hex.rs

1//! Hex encoding + decoding helpers.
2//!
3//! Every pleme-io crate that round-trips raw bytes through a canonical
4//! sexpr form (`substrate-forge::program::ProgramSource::Wasm`,
5//! `weights-forge::storage::StorageKind::Inline`, etc.) needs the same
6//! lowercase hex encode/decode pair. This module is the single copy —
7//! consumers should `use iac_forge::hex;` rather than reimplementing.
8//!
9//! The encoding is lowercase (fixed) so content-hash comparisons across
10//! emitters stay stable: `deadbeef` ≠ `DEADBEEF` as strings even though
11//! they decode to the same bytes.
12
13use crate::sexpr::SExprError;
14
15/// Encode a byte slice as a lowercase hex string.
16///
17/// The output length is exactly `bytes.len() * 2`. The encoding is
18/// deterministic: identical byte slices always produce the identical
19/// string, regardless of platform.
20#[must_use]
21pub fn encode(bytes: &[u8]) -> String {
22    let mut out = String::with_capacity(bytes.len() * 2);
23    for b in bytes {
24        out.push(nibble(b >> 4));
25        out.push(nibble(b & 0xF));
26    }
27    out
28}
29
30/// Decode a lowercase (or mixed-case) hex string into bytes.
31///
32/// Returns `SExprError::Parse` on:
33/// - odd-length input (can't chunk into byte pairs),
34/// - non-ASCII bytes,
35/// - non-hex digits.
36///
37/// The empty string decodes to an empty `Vec<u8>` — valid degenerate.
38pub fn decode(s: &str) -> Result<Vec<u8>, SExprError> {
39    if s.len() % 2 != 0 {
40        return Err(SExprError::Parse("hex string must have even length".into()));
41    }
42    let mut out = Vec::with_capacity(s.len() / 2);
43    for chunk in s.as_bytes().chunks(2) {
44        let pair =
45            std::str::from_utf8(chunk).map_err(|_| SExprError::Parse("non-ascii in hex".into()))?;
46        let byte =
47            u8::from_str_radix(pair, 16).map_err(|e| SExprError::Parse(format!("bad hex: {e}")))?;
48        out.push(byte);
49    }
50    Ok(out)
51}
52
53/// Map a 0..=15 nibble to its lowercase hex character. Also used by
54/// `sexpr::ContentHash::to_hex`.
55pub(crate) fn nibble(n: u8) -> char {
56    match n {
57        0..=9 => (b'0' + n) as char,
58        10..=15 => (b'a' + (n - 10)) as char,
59        _ => unreachable!("nibble must be 0..=15"),
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn encode_empty_is_empty() {
69        assert_eq!(encode(&[]), "");
70    }
71
72    #[test]
73    fn encode_is_lowercase() {
74        // Pinned: a future uppercase refactor would break every
75        // content-hash string comparison across crates.
76        assert_eq!(encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
77    }
78
79    #[test]
80    fn encode_length_is_twice_input() {
81        for n in 0..16 {
82            let bytes: Vec<u8> = (0..n).collect();
83            assert_eq!(encode(&bytes).len(), n as usize * 2);
84        }
85    }
86
87    #[test]
88    fn decode_empty_is_empty_vec() {
89        assert!(decode("").unwrap().is_empty());
90    }
91
92    #[test]
93    fn decode_rejects_odd_length() {
94        let err = decode("abc").unwrap_err();
95        assert!(format!("{err:?}").contains("even length"));
96    }
97
98    #[test]
99    fn decode_rejects_non_ascii() {
100        // Non-ASCII branch: chunk into 2-byte windows such that the
101        // window breaks up a multi-byte UTF-8 char, leaving the lead
102        // byte with no continuation in its chunk. "aña" = 4 bytes:
103        // [0x61, 0xc3, 0xb1, 0x61]. Chunks [0x61, 0xc3] and
104        // [0xb1, 0x61] — the first chunk ends mid-"ñ" and fails
105        // str::from_utf8, hitting the "non-ascii" branch (rather than
106        // the "bad hex" branch that a valid-UTF-8 chunk would hit).
107        let err = decode("aña").unwrap_err();
108        assert!(
109            format!("{err:?}").contains("non-ascii"),
110            "unexpected: {err:?}"
111        );
112    }
113
114    #[test]
115    fn decode_rejects_non_hex_digits() {
116        let err = decode("ag").unwrap_err();
117        assert!(format!("{err:?}").contains("bad hex"));
118    }
119
120    #[test]
121    fn decode_accepts_uppercase_mixed_case() {
122        // `u8::from_str_radix(_, 16)` accepts both cases even though
123        // the encoder only emits lowercase — pin the asymmetry so
124        // round-tripping an externally-produced uppercase string
125        // continues to work.
126        assert_eq!(decode("DEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
127        assert_eq!(decode("DeadBeef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
128    }
129
130    #[test]
131    fn roundtrip_every_byte_value() {
132        // Round-trip all 256 single-byte values individually.
133        for b in 0u8..=255u8 {
134            let enc = encode(&[b]);
135            assert_eq!(enc.len(), 2);
136            let dec = decode(&enc).unwrap();
137            assert_eq!(dec, vec![b], "failed for byte 0x{b:02x}");
138        }
139    }
140
141    #[test]
142    fn roundtrip_larger_buffer() {
143        let original: Vec<u8> = (0..=255).chain((0..=255).rev()).collect();
144        let enc = encode(&original);
145        assert_eq!(enc.len(), original.len() * 2);
146        let dec = decode(&enc).unwrap();
147        assert_eq!(dec, original);
148    }
149
150    #[test]
151    fn nibble_boundaries() {
152        assert_eq!(nibble(0), '0');
153        assert_eq!(nibble(9), '9');
154        assert_eq!(nibble(10), 'a');
155        assert_eq!(nibble(15), 'f');
156    }
157
158    #[test]
159    #[should_panic(expected = "nibble must be 0..=15")]
160    fn nibble_panics_above_range() {
161        // unreachable!() must panic — encode() gates inputs via bit-
162        // masks, but an internal misuse should surface loudly instead
163        // of producing garbage characters.
164        let _ = nibble(16);
165    }
166}