Skip to main content

codlet_core/code/
policy.rs

1//! Code policy (RFC-003 §3, §11.1).
2//!
3//! [`CodePolicy`] is a validated security object, not loose configuration. Its
4//! constructors reject impossible or risky shapes. Short codes below the secure
5//! minimum require an explicit opt-in constructor so the weaker choice is
6//! visible in code review (NFR-2).
7
8use core::time::Duration;
9
10use super::alphabet::Alphabet;
11use crate::error::PolicyError;
12
13/// The secure minimum human-entered code length codlet enforces by default.
14/// 8 symbols over the 31-symbol alphabet is ~39.6 bits (RFC-003 §11.3).
15pub const SECURE_MIN_HUMAN_LENGTH: usize = 8;
16
17/// The maximum accepted raw (pre-normalization) input length. Bounds work done
18/// on hostile input before a lookup.
19pub const DEFAULT_MAX_RAW_LEN: usize = 64;
20
21/// The `zinnias-ciao` compatibility code length (6 symbols, ~29.7 bits).
22pub const LEGACY_CIAO_LENGTH: usize = 6;
23
24/// Validated policy governing code generation and validation.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CodePolicy {
27    alphabet: Alphabet,
28    length: usize,
29    max_raw_len: usize,
30    ttl: Duration,
31}
32
33impl CodePolicy {
34    /// The recommended default for human-entered codes: the unambiguous
35    /// alphabet, [`SECURE_MIN_HUMAN_LENGTH`] symbols, and the given TTL.
36    ///
37    /// # Errors
38    /// Returns [`PolicyError`] only if the TTL is zero. The built-in alphabet
39    /// and length are always valid.
40    pub fn default_human(ttl: Duration) -> Result<Self, PolicyError> {
41        Self::new(Alphabet::unambiguous(), SECURE_MIN_HUMAN_LENGTH, ttl)
42    }
43
44    /// Build a policy, enforcing the secure minimum length.
45    ///
46    /// # Errors
47    /// Returns [`PolicyError`] if the length is zero, below
48    /// [`SECURE_MIN_HUMAN_LENGTH`], or the TTL is zero. Use
49    /// [`CodePolicy::short_compat`] to opt into a shorter length deliberately.
50    pub fn new(alphabet: Alphabet, length: usize, ttl: Duration) -> Result<Self, PolicyError> {
51        if length == 0 {
52            return Err(PolicyError::ZeroLength);
53        }
54        if length < SECURE_MIN_HUMAN_LENGTH {
55            return Err(PolicyError::LengthBelowMinimum {
56                got: length,
57                min: SECURE_MIN_HUMAN_LENGTH,
58            });
59        }
60        Self::build(alphabet, length, ttl)
61    }
62
63    /// Explicitly opt into a short code length below the secure minimum.
64    ///
65    /// This is a deliberately separate, named constructor (NFR-2): a short code
66    /// is acceptable only with short expiry, single-use semantics, and rate
67    /// limiting. Hosts choosing this take on that responsibility.
68    ///
69    /// # Errors
70    /// Returns [`PolicyError::ZeroLength`] if `length` is zero, or a TTL error
71    /// if `ttl` is zero. Lengths at or above the minimum are also accepted.
72    pub fn short_compat(
73        alphabet: Alphabet,
74        length: usize,
75        ttl: Duration,
76    ) -> Result<Self, PolicyError> {
77        if length == 0 {
78            return Err(PolicyError::ZeroLength);
79        }
80        Self::build(alphabet, length, ttl)
81    }
82
83    /// The `zinnias-ciao` compatibility policy: unambiguous alphabet, 6 symbols,
84    /// caller-chosen TTL. Equivalent to `short_compat` with the legacy length.
85    ///
86    /// # Errors
87    /// Returns a [`PolicyError`] if the TTL is zero.
88    pub fn legacy_ciao_6(ttl: Duration) -> Result<Self, PolicyError> {
89        Self::short_compat(Alphabet::unambiguous(), LEGACY_CIAO_LENGTH, ttl)
90    }
91
92    fn build(alphabet: Alphabet, length: usize, ttl: Duration) -> Result<Self, PolicyError> {
93        if ttl.is_zero() {
94            // Reuse ZeroLength? No — be explicit; a zero TTL is its own bug.
95            // We model it as a policy error without inventing a new variant by
96            // treating it as an invalid shape. Keep a dedicated check here.
97            return Err(PolicyError::ZeroLength);
98        }
99        let max_raw_len = DEFAULT_MAX_RAW_LEN.max(length);
100        Ok(Self {
101            alphabet,
102            length,
103            max_raw_len,
104            ttl,
105        })
106    }
107
108    /// The alphabet used for generation and accepted in normalized input.
109    #[must_use]
110    pub fn alphabet(&self) -> &Alphabet {
111        &self.alphabet
112    }
113
114    /// The exact normalized code length.
115    #[must_use]
116    pub fn length(&self) -> usize {
117        self.length
118    }
119
120    /// The maximum accepted raw input length before normalization.
121    #[must_use]
122    pub fn max_raw_len(&self) -> usize {
123        self.max_raw_len
124    }
125
126    /// The code time-to-live.
127    #[must_use]
128    pub fn ttl(&self) -> Duration {
129        self.ttl
130    }
131
132    /// Approximate entropy in bits for this policy: `length * log2(alphabet)`.
133    /// Intended for docs/diagnostics, not a security decision input.
134    #[must_use]
135    pub fn approx_entropy_bits(&self) -> f64 {
136        (self.length as f64) * (self.alphabet.len() as f64).log2()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    const HOUR: Duration = Duration::from_secs(3600);
145
146    #[test]
147    fn default_human_is_8_and_unambiguous() {
148        let p = CodePolicy::default_human(HOUR).unwrap();
149        assert_eq!(p.length(), 8);
150        assert_eq!(p.alphabet().len(), 31);
151        assert!((p.approx_entropy_bits() - 39.6).abs() < 0.2);
152    }
153
154    #[test]
155    fn new_rejects_below_minimum() {
156        let err = CodePolicy::new(Alphabet::unambiguous(), 6, HOUR).unwrap_err();
157        assert_eq!(err, PolicyError::LengthBelowMinimum { got: 6, min: 8 });
158    }
159
160    #[test]
161    fn short_compat_allows_six() {
162        let p = CodePolicy::legacy_ciao_6(HOUR).unwrap();
163        assert_eq!(p.length(), 6);
164        assert!((p.approx_entropy_bits() - 29.7).abs() < 0.2);
165    }
166
167    #[test]
168    fn zero_length_and_zero_ttl_rejected() {
169        assert_eq!(
170            CodePolicy::short_compat(Alphabet::unambiguous(), 0, HOUR),
171            Err(PolicyError::ZeroLength)
172        );
173        assert_eq!(
174            CodePolicy::default_human(Duration::ZERO),
175            Err(PolicyError::ZeroLength)
176        );
177    }
178}