Skip to main content

codlet_core/code/
generate.rs

1//! Code generation (RFC-003 §4, FR-1) and input validation (FR-2).
2//!
3//! Generation uses rejection sampling to avoid modulo bias and fails closed on
4//! RNG error — never substituting a deterministic or partial value (INV-3).
5
6use super::normalize::normalize;
7use super::policy::CodePolicy;
8use crate::error::{CodeInputError, RandomError};
9use crate::rng::RandomSource;
10use crate::secret::PlainCode;
11
12/// Generate a fresh plaintext code under `policy`, drawing randomness from
13/// `rng`.
14///
15/// The algorithm (RFC-003 §4): for each position, read one random byte; accept
16/// it only if below the alphabet's [unbiased ceiling], then map
17/// `alphabet[byte % len]`; otherwise discard and redraw.
18///
19/// [unbiased ceiling]: super::alphabet::Alphabet::unbiased_ceiling
20///
21/// # Errors
22/// Returns [`RandomError`] if the RNG fails at any point. On error no code is
23/// produced; there is no fallback (INV-3).
24pub fn generate_code<R: RandomSource>(
25    policy: &CodePolicy,
26    rng: &mut R,
27) -> Result<PlainCode, RandomError> {
28    let alphabet = policy.alphabet();
29    let ceiling = alphabet.unbiased_ceiling();
30    let mut out = String::with_capacity(policy.length());
31
32    while out.len() < policy.length() {
33        let mut buf = [0u8; 1];
34        // Propagate RNG failure immediately — fail closed.
35        rng.fill_bytes(&mut buf)?;
36        let b = buf[0];
37        if (b as usize) < ceiling {
38            out.push(alphabet.symbol_for_byte(b) as char);
39        }
40        // else: above ceiling, discard and redraw (rejection sampling).
41    }
42
43    Ok(PlainCode::new(out))
44}
45
46/// Validate and normalize raw user-supplied code input under `policy`.
47///
48/// Runs before any storage lookup so garbage never reaches the database
49/// (RFC-003 FR-2). Returns the canonical normalized string on success.
50///
51/// # Errors
52/// Returns [`CodeInputError`] for empty input, raw input over the policy
53/// maximum, a normalized length mismatch, or characters outside the policy
54/// alphabet. All variants are intended to collapse to one generic public
55/// message (INV-8); the distinction is for internal diagnostics only.
56pub fn validate_code_input(raw: &str, policy: &CodePolicy) -> Result<String, CodeInputError> {
57    if raw.is_empty() {
58        return Err(CodeInputError::Empty);
59    }
60    if raw.len() > policy.max_raw_len() {
61        return Err(CodeInputError::TooLongRaw);
62    }
63    let normalized = normalize(raw);
64    if normalized.is_empty() {
65        return Err(CodeInputError::Empty);
66    }
67    // Length is counted in characters; the accepted alphabet is ASCII so a
68    // char count equals the byte count for valid input.
69    if normalized.chars().count() != policy.length() {
70        return Err(CodeInputError::WrongLength);
71    }
72    let alphabet = policy.alphabet();
73    if !normalized.bytes().all(|b| alphabet.contains(b)) {
74        return Err(CodeInputError::UnsupportedCharacters);
75    }
76    Ok(normalized)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::code::alphabet::Alphabet;
83    use crate::rng::{AlwaysFailRandom, FixedBytesRandom, SystemRandom};
84    use core::time::Duration;
85
86    fn human() -> CodePolicy {
87        CodePolicy::default_human(Duration::from_secs(3600)).unwrap()
88    }
89
90    #[test]
91    fn generated_code_matches_policy_length_and_alphabet() {
92        let policy = human();
93        let mut rng = SystemRandom::new();
94        let code = generate_code(&policy, &mut rng).unwrap();
95        assert_eq!(code.expose().chars().count(), policy.length());
96        let alpha = policy.alphabet();
97        assert!(code.expose().bytes().all(|b| alpha.contains(b)));
98    }
99
100    #[test]
101    fn rng_failure_fails_closed() {
102        // Acceptance (RFC-003 §11.5): RNG that always errors yields no code.
103        let policy = human();
104        let mut rng = AlwaysFailRandom;
105        assert_eq!(generate_code(&policy, &mut rng), Err(RandomError));
106    }
107
108    #[test]
109    fn rejection_sampling_discards_bytes_at_or_above_ceiling() {
110        // Alphabet len 31 → ceiling 248. Feed 248 (rejected) then 0 (accepted →
111        // first symbol). A biased modulo-only generator would have used 248.
112        #[allow(deprecated)]
113        let policy = CodePolicy::six_symbol(Duration::from_secs(3600)).unwrap();
114        let alpha = Alphabet::unambiguous();
115        assert_eq!(alpha.unbiased_ceiling(), 248);
116        // Sequence: 248 rejected, then 0,0,0,0,0,0 accepted → six of symbol[0].
117        let mut rng = FixedBytesRandom::new(vec![248, 0]);
118        let code = generate_code(&policy, &mut rng).unwrap();
119        let first = alpha.symbols()[0] as char;
120        assert_eq!(code.expose(), &first.to_string().repeat(6));
121    }
122
123    #[test]
124    fn validate_accepts_normalizes_and_rejects() {
125        let policy = human(); // length 8
126        // Build a valid 8-char code from the alphabet with separators/lowercase.
127        assert_eq!(
128            validate_code_input("abcd-2345", &policy).unwrap(),
129            "ABCD2345"
130        );
131        assert_eq!(validate_code_input("", &policy), Err(CodeInputError::Empty));
132        assert_eq!(
133            validate_code_input("ABCD234", &policy),
134            Err(CodeInputError::WrongLength)
135        );
136        // '0' is not in the alphabet → unsupported (length is right at 8).
137        assert_eq!(
138            validate_code_input("ABCD2340", &policy),
139            Err(CodeInputError::UnsupportedCharacters)
140        );
141        // Over the raw max.
142        let long = "A".repeat(policy.max_raw_len() + 1);
143        assert_eq!(
144            validate_code_input(&long, &policy),
145            Err(CodeInputError::TooLongRaw)
146        );
147    }
148}