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 #[must_use]
56 pub fn validate(value: &str) -> bool {
57 // Must have at least 1 digit and no more than MAX_LUHN_DIGITS.
58 if value.is_empty() || value.len() > MAX_LUHN_DIGITS {
59 return false;
60 }
61
62 // Must contain only digits
63 if !value.chars().all(|c| c.is_ascii_digit()) {
64 return false;
65 }
66
67 let mut sum = 0;
68 let mut is_second = false;
69
70 // Process digits from right to left
71 for ch in value.chars().rev() {
72 let digit = ch.to_digit(10).expect("pre-filtered to numeric chars only") as usize;
73
74 let processed = if is_second {
75 let doubled = digit * 2;
76 if doubled > 9 { doubled - 9 } else { doubled }
77 } else {
78 digit
79 };
80
81 sum += processed;
82 is_second = !is_second;
83 }
84
85 sum % 10 == 0
86 }
87
88 /// Get a human-readable description of why validation failed.
89 #[must_use]
90 pub const fn error_message() -> &'static str {
91 "Invalid checksum (Luhn algorithm)"
92 }
93}
94
95/// MOD-97 algorithm validator for IBANs and similar identifiers.
96///
97/// The MOD-97 algorithm is used to validate International Bank Account Numbers (IBANs)
98/// and other financial identifiers.
99///
100/// # Algorithm Steps
101/// 1. Move the first 4 characters to the end
102/// 2. Replace letters with numbers (A=10, B=11, ..., Z=35)
103/// 3. Calculate the remainder of the number modulo 97
104/// 4. The remainder should be 1 for valid IBANs
105pub struct Mod97Validator;
106
107impl Mod97Validator {
108 /// Validate a string using the MOD-97 algorithm.
109 ///
110 /// # Arguments
111 ///
112 /// * `value` - The string to validate (typically an IBAN or similar identifier)
113 ///
114 /// # Returns
115 ///
116 /// `true` if the value passes MOD-97 validation, `false` otherwise
117 ///
118 /// # Example
119 ///
120 /// ```
121 /// use fraiseql_core::validation::checksum::Mod97Validator;
122 ///
123 /// assert!(Mod97Validator::validate("GB82WEST12345698765432")); // Valid IBAN
124 /// assert!(!Mod97Validator::validate("GB82WEST12345698765433")); // Invalid
125 /// ```
126 #[must_use]
127 pub fn validate(value: &str) -> bool {
128 // Quick length pre-check: IBANs are 4–34 characters (ISO 13616).
129 if value.len() < 4 || value.len() > MAX_MOD97_BYTES {
130 return false;
131 }
132
133 let value_upper = value.to_uppercase();
134
135 // Must contain only alphanumeric characters
136 if !value_upper.chars().all(|c| c.is_ascii_alphanumeric()) {
137 return false;
138 }
139
140 // Rearrange: move first 4 characters to the end
141 let rearranged = format!("{}{}", &value_upper[4..], &value_upper[..4]);
142
143 // Convert to numeric string: A=10, B=11, ..., Z=35
144 let mut numeric = String::new();
145 for ch in rearranged.chars() {
146 if ch.is_ascii_digit() {
147 numeric.push(ch);
148 } else if ch.is_ascii_uppercase() {
149 // A=10, B=11, ..., Z=35
150 numeric.push_str(&(10 + (ch as usize - 'A' as usize)).to_string());
151 } else {
152 return false;
153 }
154 }
155
156 // Calculate mod 97
157 let remainder = Self::mod97(&numeric);
158 remainder == 1
159 }
160
161 /// Calculate mod 97 of a numeric string.
162 ///
163 /// Uses the standard modulo operator by processing the number in chunks
164 /// to avoid overflow on very large numbers.
165 fn mod97(numeric: &str) -> u32 {
166 let mut remainder = 0u32;
167
168 for digit_char in numeric.chars() {
169 if let Some(digit) = digit_char.to_digit(10) {
170 remainder = (remainder * 10 + digit) % 97;
171 }
172 }
173
174 remainder
175 }
176
177 /// Get a human-readable description of why validation failed.
178 #[must_use]
179 pub const fn error_message() -> &'static str {
180 "Invalid checksum (MOD-97 algorithm)"
181 }
182}