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