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}