fraiseql_core/validation/checksum.rs
1//! Checksum validation algorithms for credit cards, IBANs, and other structured identifiers.
2//!
3//! This module provides validators for common checksum algorithms used in banking and
4//! payment systems.
5
6/// Maximum number of digits accepted by the Luhn validator.
7///
8/// Real-world Luhn-validated identifiers (credit cards, account numbers) top out
9/// at 19 digits. A generous cap of 25 prevents O(n) iteration over attacker-
10/// supplied megabyte strings while remaining compatible with every known use case.
11const MAX_LUHN_DIGITS: usize = 25;
12
13/// Maximum byte length accepted by the MOD-97 validator.
14///
15/// The longest IBAN defined by ISO 13616 is 34 characters (e.g. Malta). Any
16/// input longer than this limit cannot be a valid IBAN and is rejected early.
17const MAX_MOD97_BYTES: usize = 34;
18
19/// Luhn algorithm validator for credit cards and similar identifiers.
20///
21/// The Luhn algorithm (also called mod-10) is used to validate credit card numbers
22/// and other identification numbers.
23///
24/// # Algorithm Steps
25/// 1. From right to left, double every second digit
26/// 2. If doubling results in a two-digit number, subtract 9
27/// 3. Sum all digits
28/// 4. The sum modulo 10 should equal 0
29pub struct LuhnValidator;
30
31impl LuhnValidator {
32 /// Validate a string using the Luhn algorithm.
33 ///
34 /// # Arguments
35 ///
36 /// * `value` - The string to validate (must contain only digits)
37 ///
38 /// # Returns
39 ///
40 /// `true` if the value passes Luhn validation, `false` otherwise
41 ///
42 /// # Example
43 ///
44 /// ```
45 /// use fraiseql_core::validation::checksum::LuhnValidator;
46 ///
47 /// assert!(LuhnValidator::validate("4532015112830366")); // Valid Visa
48 /// assert!(!LuhnValidator::validate("4532015112830367")); // Invalid
49 /// ```
50 ///
51 /// # Panics
52 ///
53 /// Cannot panic in practice — the `expect` on `to_digit(10)` is guarded
54 /// by a preceding `all(|c| c.is_ascii_digit())` check that returns `false` first.
55 pub fn validate(value: &str) -> bool {
56 // Must have at least 1 digit and no more than MAX_LUHN_DIGITS.
57 if value.is_empty() || value.len() > MAX_LUHN_DIGITS {
58 return false;
59 }
60
61 // Must contain only digits
62 if !value.chars().all(|c| c.is_ascii_digit()) {
63 return false;
64 }
65
66 let mut sum = 0;
67 let mut is_second = false;
68
69 // Process digits from right to left
70 for ch in value.chars().rev() {
71 let digit = ch.to_digit(10).expect("pre-filtered to numeric chars only") as usize;
72
73 let processed = if is_second {
74 let doubled = digit * 2;
75 if doubled > 9 { doubled - 9 } else { doubled }
76 } else {
77 digit
78 };
79
80 sum += processed;
81 is_second = !is_second;
82 }
83
84 sum % 10 == 0
85 }
86
87 /// Get a human-readable description of why validation failed.
88 pub const fn error_message() -> &'static str {
89 "Invalid checksum (Luhn algorithm)"
90 }
91}
92
93/// MOD-97 algorithm validator for IBANs and similar identifiers.
94///
95/// The MOD-97 algorithm is used to validate International Bank Account Numbers (IBANs)
96/// and other financial identifiers.
97///
98/// # Algorithm Steps
99/// 1. Move the first 4 characters to the end
100/// 2. Replace letters with numbers (A=10, B=11, ..., Z=35)
101/// 3. Calculate the remainder of the number modulo 97
102/// 4. The remainder should be 1 for valid IBANs
103pub struct Mod97Validator;
104
105impl Mod97Validator {
106 /// Validate a string using the MOD-97 algorithm.
107 ///
108 /// # Arguments
109 ///
110 /// * `value` - The string to validate (typically an IBAN or similar identifier)
111 ///
112 /// # Returns
113 ///
114 /// `true` if the value passes MOD-97 validation, `false` otherwise
115 ///
116 /// # Example
117 ///
118 /// ```
119 /// use fraiseql_core::validation::checksum::Mod97Validator;
120 ///
121 /// assert!(Mod97Validator::validate("GB82WEST12345698765432")); // Valid IBAN
122 /// assert!(!Mod97Validator::validate("GB82WEST12345698765433")); // Invalid
123 /// ```
124 pub fn validate(value: &str) -> bool {
125 // Quick length pre-check: IBANs are 4–34 characters (ISO 13616).
126 if value.len() < 4 || value.len() > MAX_MOD97_BYTES {
127 return false;
128 }
129
130 let value_upper = value.to_uppercase();
131
132 // Must contain only alphanumeric characters
133 if !value_upper.chars().all(|c| c.is_ascii_alphanumeric()) {
134 return false;
135 }
136
137 // Rearrange: move first 4 characters to the end
138 let rearranged = format!("{}{}", &value_upper[4..], &value_upper[..4]);
139
140 // Convert to numeric string: A=10, B=11, ..., Z=35
141 let mut numeric = String::new();
142 for ch in rearranged.chars() {
143 if ch.is_ascii_digit() {
144 numeric.push(ch);
145 } else if ch.is_ascii_uppercase() {
146 // A=10, B=11, ..., Z=35
147 numeric.push_str(&(10 + (ch as usize - 'A' as usize)).to_string());
148 } else {
149 return false;
150 }
151 }
152
153 // Calculate mod 97
154 let remainder = Self::mod97(&numeric);
155 remainder == 1
156 }
157
158 /// Calculate mod 97 of a numeric string.
159 ///
160 /// Uses the standard modulo operator by processing the number in chunks
161 /// to avoid overflow on very large numbers.
162 fn mod97(numeric: &str) -> u32 {
163 let mut remainder = 0u32;
164
165 for digit_char in numeric.chars() {
166 if let Some(digit) = digit_char.to_digit(10) {
167 remainder = (remainder * 10 + digit) % 97;
168 }
169 }
170
171 remainder
172 }
173
174 /// Get a human-readable description of why validation failed.
175 pub const fn error_message() -> &'static str {
176 "Invalid checksum (MOD-97 algorithm)"
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 // Luhn tests
185 #[test]
186 fn test_luhn_valid_visa() {
187 assert!(LuhnValidator::validate("4532015112830366"));
188 }
189
190 #[test]
191 fn test_luhn_valid_another_visa() {
192 assert!(LuhnValidator::validate("4111111111111111"));
193 }
194
195 #[test]
196 fn test_luhn_invalid_checksum() {
197 assert!(!LuhnValidator::validate("4532015112830367"));
198 }
199
200 #[test]
201 fn test_luhn_invalid_non_digits() {
202 assert!(!LuhnValidator::validate("4532-0151-1283-0366"));
203 }
204
205 #[test]
206 fn test_luhn_empty_string() {
207 assert!(!LuhnValidator::validate(""));
208 }
209
210 #[test]
211 fn test_luhn_single_digit() {
212 assert!(LuhnValidator::validate("0"));
213 }
214
215 #[test]
216 fn test_luhn_all_zeros() {
217 assert!(LuhnValidator::validate("0000000000000000"));
218 }
219
220 // MOD-97 tests
221 #[test]
222 fn test_mod97_valid_iban_gb() {
223 assert!(Mod97Validator::validate("GB82WEST12345698765432"));
224 }
225
226 #[test]
227 fn test_mod97_valid_iban_de() {
228 assert!(Mod97Validator::validate("DE89370400440532013000"));
229 }
230
231 #[test]
232 fn test_mod97_invalid_checksum() {
233 assert!(!Mod97Validator::validate("GB82WEST12345698765433"));
234 }
235
236 #[test]
237 fn test_mod97_invalid_too_short() {
238 assert!(!Mod97Validator::validate("GB8"));
239 }
240
241 #[test]
242 fn test_mod97_invalid_special_chars() {
243 assert!(!Mod97Validator::validate("GB82-WEST-1234"));
244 }
245
246 #[test]
247 fn test_mod97_lowercase_conversion() {
248 // Should handle lowercase by converting to uppercase
249 assert!(Mod97Validator::validate("gb82west12345698765432"));
250 }
251
252 #[test]
253 fn test_mod97_error_message() {
254 assert_eq!(Mod97Validator::error_message(), "Invalid checksum (MOD-97 algorithm)");
255 }
256
257 #[test]
258 fn test_luhn_error_message() {
259 assert_eq!(LuhnValidator::error_message(), "Invalid checksum (Luhn algorithm)");
260 }
261
262 // ── Length-guard tests ────────────────────────────────────────────────────
263
264 #[test]
265 fn test_luhn_exactly_25_digits_accepted() {
266 // 25 digits that pass Luhn (craft a valid one: 24 zeros + check digit 0)
267 let value = "0".repeat(25);
268 // All-zeros passes Luhn (sum = 0, 0 % 10 == 0)
269 assert!(LuhnValidator::validate(&value));
270 }
271
272 #[test]
273 fn test_luhn_26_digits_rejected_by_length_guard() {
274 let value = "0".repeat(26);
275 assert!(!LuhnValidator::validate(&value), "26-digit string must be rejected");
276 }
277
278 #[test]
279 fn test_mod97_exactly_34_chars_accepted_structure() {
280 // GB IBAN is 22 chars; build a syntactically valid 34-char string
281 // (all A's — will fail mod-97 checksum but must NOT be rejected by length guard)
282 let value = "A".repeat(34);
283 // Will be false (checksum fails) but must not panic; length guard allows it through
284 let _ = Mod97Validator::validate(&value);
285 // Just verify 34-char input is not immediately rejected (returns false for checksum, not
286 // length) We can't check internal path from outside, so we verify no panic occurs
287 // and the function runs.
288 }
289
290 #[test]
291 fn test_mod97_35_chars_rejected_by_length_guard() {
292 let value = "A".repeat(35);
293 assert!(
294 !Mod97Validator::validate(&value),
295 "35-char string must be rejected by length guard"
296 );
297 }
298
299 #[test]
300 fn test_mod97_valid_iban_within_length_limit() {
301 // Verify an actual valid IBAN still passes after adding the length guard.
302 assert!(Mod97Validator::validate("GB82WEST12345698765432")); // 22 chars — well within limit
303 }
304}