Skip to main content

uselesskey_core_seed/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(not(feature = "std"), no_std)]
3//! Seed parsing and redaction primitives for uselesskey.
4//!
5//! Provides the [`Seed`] type that wraps 32 bytes of entropy used for
6//! deterministic fixture derivation. Implements `Debug` with redaction
7//! to prevent accidental leakage of seed material in logs.
8
9extern crate alloc;
10
11use alloc::string::String;
12
13/// Seed bytes derived from user input for deterministic fixtures.
14#[derive(Clone, Copy, Eq, PartialEq, Hash)]
15pub struct Seed(pub(crate) [u8; 32]);
16
17impl Seed {
18    /// Create a seed from raw bytes.
19    pub fn new(bytes: [u8; 32]) -> Self {
20        Self(bytes)
21    }
22
23    /// Access raw seed bytes.
24    pub fn bytes(&self) -> &[u8; 32] {
25        &self.0
26    }
27
28    /// Derive a seed from a user-provided string.
29    ///
30    /// Accepted formats:
31    /// - 64-char hex (with optional `0x` prefix)
32    /// - any other string (hashed with BLAKE3)
33    pub fn from_env_value(value: &str) -> Result<Self, String> {
34        let v = value.trim();
35        let hex = v.strip_prefix("0x").unwrap_or(v);
36
37        if hex.len() == 64 {
38            return parse_hex_32(hex).map(Self);
39        }
40
41        Ok(Self(*blake3::hash(v.as_bytes()).as_bytes()))
42    }
43}
44
45impl core::fmt::Debug for Seed {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        f.write_str("Seed(**redacted**)")
48    }
49}
50
51fn parse_hex_32(hex: &str) -> Result<[u8; 32], String> {
52    fn val(c: u8) -> Option<u8> {
53        match c {
54            b'0'..=b'9' => Some(c - b'0'),
55            b'a'..=b'f' => Some(c - b'a' + 10),
56            b'A'..=b'F' => Some(c - b'A' + 10),
57            _ => None,
58        }
59    }
60
61    if hex.len() != 64 {
62        return Err(alloc::format!("expected 64 hex chars, got {}", hex.len()));
63    }
64
65    let bytes = hex.as_bytes();
66    let mut out = [0u8; 32];
67
68    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
69        let hi = val(chunk[0])
70            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[0] as char))?;
71        let lo = val(chunk[1])
72            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[1] as char))?;
73        out[i] = (hi << 4) | lo;
74    }
75
76    Ok(out)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::{Seed, parse_hex_32};
82
83    #[test]
84    fn seed_debug_is_redacted() {
85        let seed = Seed::new([7u8; 32]);
86        assert_eq!(format!("{:?}", seed), "Seed(**redacted**)");
87    }
88
89    #[test]
90    fn parse_hex_32_rejects_wrong_length() {
91        let err = parse_hex_32("abcd").unwrap_err();
92        assert!(err.contains("expected 64 hex chars"));
93    }
94
95    #[test]
96    fn parse_hex_32_rejects_invalid_char() {
97        let mut s = "0".repeat(64);
98        s.replace_range(10..11, "g");
99
100        let err = parse_hex_32(&s).unwrap_err();
101        assert!(err.contains("invalid hex char"));
102    }
103
104    #[test]
105    fn seed_from_env_value_parses_hex_with_prefix_and_whitespace() {
106        let hex = "0x0000000000000000000000000000000000000000000000000000000000000001";
107        let seed = Seed::from_env_value(&format!("  {hex}  ")).unwrap();
108        assert_eq!(seed.bytes()[31], 1);
109        assert!(seed.bytes()[..31].iter().all(|b| *b == 0));
110    }
111
112    #[test]
113    fn seed_from_env_value_parses_uppercase_hex() {
114        let hex = "F".repeat(64);
115        let seed = Seed::from_env_value(&hex).unwrap();
116        assert!(seed.bytes().iter().all(|b| *b == 0xFF));
117    }
118
119    #[test]
120    fn string_seed_is_hashed_with_blake3() {
121        let seed = Seed::from_env_value("  deterministic-seed-value  ").unwrap();
122        let expected = blake3::hash("deterministic-seed-value".as_bytes());
123        assert_eq!(seed.bytes(), expected.as_bytes());
124    }
125
126    #[test]
127    fn parse_hex_32_lowercase_valid() {
128        let hex = "aa".repeat(32);
129        let result = parse_hex_32(&hex).unwrap();
130        assert!(result.iter().all(|b| *b == 0xAA));
131    }
132
133    #[test]
134    fn parse_hex_32_mixed_case_valid() {
135        let hex = "aAbBcCdDeEfF".repeat(5);
136        // 60 chars — pad to 64
137        let hex = format!("{hex}0000");
138        assert_eq!(hex.len(), 64);
139        assert!(parse_hex_32(&hex).is_ok());
140    }
141
142    #[test]
143    fn parse_hex_32_invalid_lo_nibble() {
144        // Valid hi nibble, invalid lo nibble at position 1
145        let mut hex = "0".repeat(64);
146        hex.replace_range(1..2, "z");
147        let err = parse_hex_32(&hex).unwrap_err();
148        assert!(err.contains("invalid hex char: z"));
149    }
150
151    #[test]
152    fn seed_equality_and_clone() {
153        let a = Seed::new([42u8; 32]);
154        let b = a;
155        assert_eq!(a, b);
156        assert_eq!(a.bytes(), b.bytes());
157    }
158
159    #[test]
160    fn seed_inequality() {
161        let a = Seed::new([1u8; 32]);
162        let b = Seed::new([2u8; 32]);
163        assert_ne!(a, b);
164    }
165
166    #[test]
167    fn seed_hash_consistent() {
168        use core::hash::{Hash, Hasher};
169        let seed = Seed::new([99u8; 32]);
170
171        let mut h1 = std::collections::hash_map::DefaultHasher::new();
172        seed.hash(&mut h1);
173        let hash1 = h1.finish();
174
175        let mut h2 = std::collections::hash_map::DefaultHasher::new();
176        seed.hash(&mut h2);
177        assert_eq!(hash1, h2.finish());
178    }
179
180    #[test]
181    fn from_env_value_short_string_uses_blake3() {
182        let seed = Seed::from_env_value("abc").unwrap();
183        let expected = blake3::hash(b"abc");
184        assert_eq!(seed.bytes(), expected.as_bytes());
185    }
186
187    #[test]
188    fn from_env_value_63_char_non_hex_uses_blake3() {
189        // 63 chars — not 64, so falls through to blake3 hashing.
190        let input = "a".repeat(63);
191        let seed = Seed::from_env_value(&input).unwrap();
192        let expected = blake3::hash(input.as_bytes());
193        assert_eq!(seed.bytes(), expected.as_bytes());
194    }
195
196    #[test]
197    fn from_env_value_65_char_non_hex_uses_blake3() {
198        // 65 chars — not 64, so falls through to blake3 hashing.
199        let input = "a".repeat(65);
200        let seed = Seed::from_env_value(&input).unwrap();
201        let expected = blake3::hash(input.as_bytes());
202        assert_eq!(seed.bytes(), expected.as_bytes());
203    }
204
205    #[test]
206    fn from_env_value_64_char_invalid_hex_returns_error() {
207        // 64 chars but not valid hex — parse_hex_32 error path.
208        let input = "g".repeat(64);
209        assert!(Seed::from_env_value(&input).is_err());
210    }
211}