Skip to main content

polyc_crypto/
hex.rs

1//! Lowercase-hex encoding shared by every signed-artifact module.
2//!
3//! Signatures, public keys, and payload digests travel as lowercase hex in
4//! this crate's canonical JSON payloads. One encoder/decoder pair keeps the
5//! four signed-artifact modules (`approval`, `mandate`, `handoff`, `grant`)
6//! byte-identical instead of each carrying a private copy.
7
8/// Encode `bytes` as lowercase hex.
9#[must_use]
10pub fn lower(bytes: &[u8]) -> String {
11    use std::fmt::Write as _;
12    let mut s = String::with_capacity(bytes.len() * 2);
13    for b in bytes {
14        let _ = write!(&mut s, "{b:02x}");
15    }
16    s
17}
18
19/// Decode a hex string into bytes.
20///
21/// Fail-closed: `None` on odd length or any non-hex character — callers treat
22/// an undecodable field as an unverifiable artifact, never a partial one.
23#[must_use]
24pub fn decode(s: &str) -> Option<Vec<u8>> {
25    if !s.len().is_multiple_of(2) {
26        return None;
27    }
28    (0..s.len())
29        .step_by(2)
30        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
31        .collect()
32}
33
34#[cfg(test)]
35mod tests {
36    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
37
38    use super::*;
39
40    #[test]
41    fn round_trips_and_fails_closed() {
42        assert_eq!(lower(&[0x00, 0xab, 0xff]), "00abff");
43        assert_eq!(decode("00abff"), Some(vec![0x00, 0xab, 0xff]));
44        assert_eq!(decode(""), Some(Vec::new()));
45        assert_eq!(decode("abc"), None, "odd length fails closed");
46        assert_eq!(decode("zz"), None, "non-hex fails closed");
47    }
48}