Skip to main content

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}