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;
12use rand_chacha10::ChaCha20Rng;
13use rand_core10::{Rng, SeedableRng};
14
15/// Seed bytes derived from user input for deterministic fixtures.
16#[derive(Clone, Copy, Eq, PartialEq, Hash)]
17pub struct Seed(pub(crate) [u8; 32]);
18
19impl Seed {
20    /// Create a seed from raw bytes.
21    pub fn new(bytes: [u8; 32]) -> Self {
22        Self(bytes)
23    }
24
25    /// Access raw seed bytes.
26    pub fn bytes(&self) -> &[u8; 32] {
27        &self.0
28    }
29
30    /// Derive a seed from plain text.
31    ///
32    /// This hashes the provided text verbatim with BLAKE3. Unlike
33    /// [`Seed::from_env_value`], it does not trim whitespace or interpret
34    /// 64-character strings as hex.
35    pub fn from_text(text: &str) -> Self {
36        Self(*blake3::hash(text.as_bytes()).as_bytes())
37    }
38
39    /// Fill the destination buffer with deterministic bytes derived from this seed.
40    ///
41    /// This keeps RNG implementation details private while allowing callers to
42    /// derive stable byte sequences from seed material.
43    pub fn fill_bytes(&self, dest: &mut [u8]) {
44        let mut rng = ChaCha20Rng::from_seed(self.0);
45        rng.fill_bytes(dest);
46    }
47
48    /// Derive a seed from a user-provided string.
49    ///
50    /// Accepted formats:
51    /// - 64-char hex (with optional `0x` prefix)
52    /// - any other string (hashed with BLAKE3)
53    pub fn from_env_value(value: &str) -> Result<Self, String> {
54        let v = value.trim();
55        let hex = v.strip_prefix("0x").unwrap_or(v);
56
57        if hex.len() == 64 {
58            return parse_hex_32(hex).map(Self);
59        }
60
61        Ok(Self::from_text(v))
62    }
63}
64
65impl core::fmt::Debug for Seed {
66    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
67        f.write_str("Seed(**redacted**)")
68    }
69}
70
71fn parse_hex_32(hex: &str) -> Result<[u8; 32], String> {
72    fn val(c: u8) -> Option<u8> {
73        match c {
74            b'0'..=b'9' => Some(c - b'0'),
75            b'a'..=b'f' => Some(c - b'a' + 10),
76            b'A'..=b'F' => Some(c - b'A' + 10),
77            _ => None,
78        }
79    }
80
81    if hex.len() != 64 {
82        return Err(alloc::format!("expected 64 hex chars, got {}", hex.len()));
83    }
84
85    let bytes = hex.as_bytes();
86    let mut out = [0u8; 32];
87
88    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
89        let hi = val(chunk[0])
90            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[0] as char))?;
91        let lo = val(chunk[1])
92            .ok_or_else(|| alloc::format!("invalid hex char: {}", chunk[1] as char))?;
93        out[i] = (hi << 4) | lo;
94    }
95
96    Ok(out)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::{Seed, parse_hex_32};
102
103    #[test]
104    fn seed_debug_is_redacted() {
105        let seed = Seed::new([7u8; 32]);
106        assert_eq!(format!("{:?}", seed), "Seed(**redacted**)");
107    }
108
109    #[test]
110    fn parse_hex_32_rejects_wrong_length() {
111        let err = parse_hex_32("abcd").unwrap_err();
112        assert!(err.contains("expected 64 hex chars"));
113    }
114
115    #[test]
116    fn parse_hex_32_rejects_invalid_char() {
117        let mut s = "0".repeat(64);
118        s.replace_range(10..11, "g");
119
120        let err = parse_hex_32(&s).unwrap_err();
121        assert!(err.contains("invalid hex char"));
122    }
123
124    #[test]
125    fn seed_from_env_value_parses_hex_with_prefix_and_whitespace() {
126        let hex = "0x0000000000000000000000000000000000000000000000000000000000000001";
127        let seed = Seed::from_env_value(&format!("  {hex}  ")).unwrap();
128        assert_eq!(seed.bytes()[31], 1);
129        assert!(seed.bytes()[..31].iter().all(|b| *b == 0));
130    }
131
132    #[test]
133    fn seed_from_env_value_parses_uppercase_hex() {
134        let hex = "F".repeat(64);
135        let seed = Seed::from_env_value(&hex).unwrap();
136        assert!(seed.bytes().iter().all(|b| *b == 0xFF));
137    }
138
139    #[test]
140    fn string_seed_is_hashed_with_blake3() {
141        let seed = Seed::from_env_value("  deterministic-seed-value  ").unwrap();
142        let expected = blake3::hash("deterministic-seed-value".as_bytes());
143        assert_eq!(seed.bytes(), expected.as_bytes());
144    }
145
146    #[test]
147    fn from_text_hashes_verbatim_input() {
148        let text = "  deterministic-seed-value  ";
149        let seed = Seed::from_text(text);
150        let expected = blake3::hash(text.as_bytes());
151        assert_eq!(seed.bytes(), expected.as_bytes());
152        assert_ne!(seed, Seed::from_env_value(text).unwrap());
153    }
154
155    #[test]
156    fn from_text_does_not_parse_hex_shaped_strings() {
157        let text = "ab".repeat(32);
158        let seed = Seed::from_text(&text);
159        let expected = blake3::hash(text.as_bytes());
160        assert_eq!(seed.bytes(), expected.as_bytes());
161        assert_ne!(seed, Seed::from_env_value(&text).unwrap());
162    }
163
164    #[test]
165    fn parse_hex_32_lowercase_valid() {
166        let hex = "aa".repeat(32);
167        let result = parse_hex_32(&hex).unwrap();
168        assert!(result.iter().all(|b| *b == 0xAA));
169    }
170
171    #[test]
172    fn parse_hex_32_mixed_case_valid() {
173        let hex = "aAbBcCdDeEfF".repeat(5);
174        // 60 chars — pad to 64
175        let hex = format!("{hex}0000");
176        assert_eq!(hex.len(), 64);
177        assert!(parse_hex_32(&hex).is_ok());
178    }
179
180    #[test]
181    fn parse_hex_32_invalid_lo_nibble() {
182        // Valid hi nibble, invalid lo nibble at position 1
183        let mut hex = "0".repeat(64);
184        hex.replace_range(1..2, "z");
185        let err = parse_hex_32(&hex).unwrap_err();
186        assert!(err.contains("invalid hex char: z"));
187    }
188
189    #[test]
190    fn seed_equality_and_clone() {
191        let a = Seed::new([42u8; 32]);
192        let b = a;
193        assert_eq!(a, b);
194        assert_eq!(a.bytes(), b.bytes());
195    }
196
197    #[test]
198    fn seed_inequality() {
199        let a = Seed::new([1u8; 32]);
200        let b = Seed::new([2u8; 32]);
201        assert_ne!(a, b);
202    }
203
204    #[test]
205    fn seed_hash_consistent() {
206        use core::hash::{Hash, Hasher};
207        let seed = Seed::new([99u8; 32]);
208
209        let mut h1 = std::collections::hash_map::DefaultHasher::new();
210        seed.hash(&mut h1);
211        let hash1 = h1.finish();
212
213        let mut h2 = std::collections::hash_map::DefaultHasher::new();
214        seed.hash(&mut h2);
215        assert_eq!(hash1, h2.finish());
216    }
217
218    #[test]
219    fn fill_bytes_is_seed_stable() {
220        let seed = Seed::new([7u8; 32]);
221        let mut a = [0u8; 16];
222        let mut b = [0u8; 16];
223
224        seed.fill_bytes(&mut a);
225        seed.fill_bytes(&mut b);
226
227        assert_eq!(a, b);
228    }
229
230    #[test]
231    fn fill_bytes_overwrites_destination_buffer() {
232        let seed = Seed::new([7u8; 32]);
233        let mut out = [0xAA; 16];
234
235        seed.fill_bytes(&mut out);
236
237        assert_ne!(out, [0xAA; 16]);
238    }
239
240    #[test]
241    fn from_env_value_short_string_uses_blake3() {
242        let seed = Seed::from_env_value("abc").unwrap();
243        let expected = blake3::hash(b"abc");
244        assert_eq!(seed.bytes(), expected.as_bytes());
245    }
246
247    #[test]
248    fn from_env_value_63_char_non_hex_uses_blake3() {
249        // 63 chars — not 64, so falls through to blake3 hashing.
250        let input = "a".repeat(63);
251        let seed = Seed::from_env_value(&input).unwrap();
252        let expected = blake3::hash(input.as_bytes());
253        assert_eq!(seed.bytes(), expected.as_bytes());
254    }
255
256    #[test]
257    fn from_env_value_65_char_non_hex_uses_blake3() {
258        // 65 chars — not 64, so falls through to blake3 hashing.
259        let input = "a".repeat(65);
260        let seed = Seed::from_env_value(&input).unwrap();
261        let expected = blake3::hash(input.as_bytes());
262        assert_eq!(seed.bytes(), expected.as_bytes());
263    }
264
265    #[test]
266    fn from_env_value_64_char_invalid_hex_returns_error() {
267        // 64 chars but not valid hex — parse_hex_32 error path.
268        let input = "g".repeat(64);
269        assert!(Seed::from_env_value(&input).is_err());
270    }
271}