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/// Minimum accepted short-code length for the explicit compat opt-in (6 symbols, ~29.7 bits).
22pub const SHORT_COMPAT_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    /// **Security note:** codes shorter than [`SECURE_MIN_HUMAN_LENGTH`] symbols
70    /// have reduced entropy and **require** active rate limiting to be safe. An
71    /// unprotected 6-symbol code over 31 symbols has only ~29.7 bits of entropy.
72    /// Suppress this warning with `#[allow(deprecated)]` at the call site only
73    /// after confirming that rate limiting is in place.
74    ///
75    /// # Errors
76    /// Returns [`PolicyError::ZeroLength`] if `length` is zero, or a TTL error
77    /// if `ttl` is zero. Lengths at or above the minimum are also accepted.
78    #[deprecated(
79        note = "codes shorter than SECURE_MIN_HUMAN_LENGTH have reduced entropy;                 ensure rate limiting is active and suppress with #[allow(deprecated)]                 at the call site to acknowledge the tradeoff"
80    )]
81    pub fn short_compat(
82        alphabet: Alphabet,
83        length: usize,
84        ttl: Duration,
85    ) -> Result<Self, PolicyError> {
86        if length == 0 {
87            return Err(PolicyError::ZeroLength);
88        }
89        Self::build(alphabet, length, ttl)
90    }
91
92    /// Short-code compatibility policy: unambiguous alphabet, 6 symbols,
93    /// caller-chosen TTL. Equivalent to `short_compat(Alphabet::unambiguous(), 6, ttl)`.
94    ///
95    /// Use this when migrating from an existing system that issued 6-symbol codes.
96    /// Prefer [`CodePolicy::default_human`] (8 symbols, ~39.6 bits) for new deployments.
97    ///
98    /// # Errors
99    /// Returns a [`PolicyError`] if the TTL is zero.
100    #[deprecated(
101        note = "6-symbol codes have only ~29.7 bits of entropy;                 use default_human() for new deployments or ensure rate limiting                 is active and suppress with #[allow(deprecated)]"
102    )]
103    #[allow(deprecated)] // calls short_compat which is also deprecated
104    pub fn six_symbol(ttl: Duration) -> Result<Self, PolicyError> {
105        #[allow(deprecated)]
106        Self::short_compat(Alphabet::unambiguous(), SHORT_COMPAT_LENGTH, ttl)
107    }
108
109    fn build(alphabet: Alphabet, length: usize, ttl: Duration) -> Result<Self, PolicyError> {
110        if ttl.is_zero() {
111            // Reuse ZeroLength? No — be explicit; a zero TTL is its own bug.
112            // We model it as a policy error without inventing a new variant by
113            // treating it as an invalid shape. Keep a dedicated check here.
114            return Err(PolicyError::ZeroLength);
115        }
116        let max_raw_len = DEFAULT_MAX_RAW_LEN.max(length);
117        Ok(Self {
118            alphabet,
119            length,
120            max_raw_len,
121            ttl,
122        })
123    }
124
125    /// The alphabet used for generation and accepted in normalized input.
126    #[must_use]
127    pub fn alphabet(&self) -> &Alphabet {
128        &self.alphabet
129    }
130
131    /// The exact normalized code length.
132    #[must_use]
133    pub fn length(&self) -> usize {
134        self.length
135    }
136
137    /// The maximum accepted raw input length before normalization.
138    #[must_use]
139    pub fn max_raw_len(&self) -> usize {
140        self.max_raw_len
141    }
142
143    /// The code time-to-live.
144    #[must_use]
145    pub fn ttl(&self) -> Duration {
146        self.ttl
147    }
148
149    /// Approximate entropy in bits for this policy: `length * log2(alphabet)`.
150    /// Intended for docs/diagnostics, not a security decision input.
151    #[must_use]
152    pub fn approx_entropy_bits(&self) -> f64 {
153        (self.length as f64) * (self.alphabet.len() as f64).log2()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    const HOUR: Duration = Duration::from_secs(3600);
162
163    #[test]
164    fn default_human_is_8_and_unambiguous() {
165        let p = CodePolicy::default_human(HOUR).unwrap();
166        assert_eq!(p.length(), 8);
167        assert_eq!(p.alphabet().len(), 31);
168        assert!((p.approx_entropy_bits() - 39.6).abs() < 0.2);
169    }
170
171    #[test]
172    fn new_rejects_below_minimum() {
173        let err = CodePolicy::new(Alphabet::unambiguous(), 6, HOUR).unwrap_err();
174        assert_eq!(err, PolicyError::LengthBelowMinimum { got: 6, min: 8 });
175    }
176
177    #[test]
178    #[allow(deprecated)]
179    fn short_compat_allows_six() {
180        #[allow(deprecated)]
181        let p = CodePolicy::six_symbol(HOUR).unwrap();
182        assert_eq!(p.length(), 6);
183        assert!((p.approx_entropy_bits() - 29.7).abs() < 0.2);
184    }
185
186    #[test]
187    #[allow(deprecated)]
188    fn zero_length_and_zero_ttl_rejected() {
189        assert_eq!(
190            CodePolicy::short_compat(Alphabet::unambiguous(), 0, HOUR),
191            Err(PolicyError::ZeroLength)
192        );
193        assert_eq!(
194            CodePolicy::default_human(Duration::ZERO),
195            Err(PolicyError::ZeroLength)
196        );
197    }
198}