Skip to main content

iban_check/
validator.rs

1use crate::countries::country_iban_length;
2use crate::error::ValidationError;
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7/// Maximum input length to prevent DoS via memory exhaustion.
8/// Longest IBAN is 32 chars (LC). With whitespace, allow generous margin.
9const MAX_INPUT_LENGTH: usize = 256;
10
11/// Validates an IBAN string.
12///
13/// Whitespace characters are ignored. The input is case-insensitive.
14///
15/// # Examples
16///
17/// ```
18/// use iban_check::validate;
19///
20/// assert!(validate("DE89370400440532013000").is_ok());
21/// assert!(validate("DE89 3704 0044 0532 0130 00").is_ok());
22/// assert!(validate("invalid").is_err());
23/// ```
24pub fn validate(iban: &str) -> Result<(), ValidationError> {
25    validate_cow(iban).map(|_| ())
26}
27
28/// Validates an IBAN and returns the sanitized, normalized string as a `Cow<str>`.
29///
30/// This internal function is shared by [`validate`] and [`Iban::new`] to avoid
31/// redundant sanitization. On the fast path (already uppercase, no whitespace),
32/// this performs zero allocations.
33fn validate_cow(iban: &str) -> Result<Cow<'_, str>, ValidationError> {
34    // Security: reject unreasonably long inputs before any allocation
35    if iban.len() > MAX_INPUT_LENGTH {
36        return Err(ValidationError::InvalidLength {
37            expected: MAX_INPUT_LENGTH,
38            found: iban.len(),
39        });
40    }
41
42    // Check if sanitization is needed (whitespace removal or case conversion)
43    let needs_sanitization = iban
44        .chars()
45        .any(|c| c.is_whitespace() || c.is_ascii_lowercase());
46
47    let cow: Cow<'_, str> = if needs_sanitization {
48        let normalized: String = iban
49            .chars()
50            .filter(|c| !c.is_whitespace())
51            .map(|c| c.to_ascii_uppercase())
52            .collect();
53        Cow::Owned(normalized)
54    } else {
55        Cow::Borrowed(iban)
56    };
57
58    let s = cow.as_ref();
59
60    // Empty check
61    if s.is_empty() {
62        return Err(ValidationError::Empty);
63    }
64
65    // Minimum length check (need at least country code + check digits + 1 BBAN char = 5)
66    if s.len() < 5 {
67        return Err(ValidationError::InvalidLength {
68            expected: 5,
69            found: s.len(),
70        });
71    }
72
73    // Extract country code and look up expected length
74    let country_code = &s[0..2];
75    let expected_length = country_iban_length(country_code)
76        .ok_or(ValidationError::InvalidCountryCode)?;
77
78    // Length check
79    if s.len() != expected_length {
80        return Err(ValidationError::InvalidLength {
81            expected: expected_length,
82            found: s.len(),
83        });
84    }
85
86    let bytes = s.as_bytes();
87
88    // Character validity check
89    for (i, &b) in bytes.iter().enumerate() {
90        if !b.is_ascii_alphanumeric() {
91            return Err(ValidationError::InvalidCharacter {
92                character: b as char,
93                position: i,
94            });
95        }
96    }
97
98    // Mod-97-10 checksum without physical rearrangement
99    // Process BBAN first (chars 4..end), then prefix (chars 0..4)
100    let mut remainder = 0u32;
101
102    for &b in bytes[4..].iter().chain(&bytes[0..4]) {
103        if b.is_ascii_digit() {
104            let digit = (b - b'0') as u32;
105            remainder = (remainder * 10 + digit) % 97;
106        } else {
107            // b.is_ascii_uppercase() is guaranteed by the alphanumeric check above
108            let value = (b - b'A' + 10) as u32;
109            let tens = value / 10;
110            let ones = value % 10;
111            remainder = (remainder * 10 + tens) % 97;
112            remainder = (remainder * 10 + ones) % 97;
113        }
114    }
115
116    if remainder != 1 {
117        return Err(ValidationError::InvalidChecksum);
118    }
119
120    Ok(cow)
121}
122
123/// Parsed and validated IBAN.
124///
125/// This type guarantees that the underlying string is a valid IBAN.
126/// It can only be constructed through validation.
127#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128pub struct Iban(String);
129
130impl Iban {
131    /// Attempts to parse and validate an IBAN string.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use iban_check::Iban;
137    ///
138    /// let iban = Iban::new("DE89370400440532013000").unwrap();
139    /// ```
140    pub fn new(iban: &str) -> Result<Self, ValidationError> {
141        validate_cow(iban).map(|cow| Iban(cow.into_owned()))
142    }
143
144    /// Returns the country code (first two characters).
145    pub fn country_code(&self) -> &str {
146        // Safe: Iban is only constructible through validation which ensures len >= 5
147        &self.0[0..2]
148    }
149
150    /// Returns the check digits (characters 2-4).
151    pub fn check_digits(&self) -> &str {
152        // Safe: Iban is only constructible through validation which ensures len >= 5
153        &self.0[2..4]
154    }
155
156    /// Returns the BBAN (Basic Bank Account Number) portion.
157    pub fn bban(&self) -> &str {
158        // Safe: Iban is only constructible through validation which ensures len >= 5
159        &self.0[4..]
160    }
161
162    /// Returns the full IBAN string.
163    pub fn as_str(&self) -> &str {
164        &self.0
165    }
166}
167
168impl fmt::Display for Iban {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        write!(f, "{}", self.0)
171    }
172}
173
174impl FromStr for Iban {
175    type Err = ValidationError;
176
177    fn from_str(s: &str) -> Result<Self, Self::Err> {
178        Self::new(s)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // 7.1 Valid IBANs
187    #[test]
188    fn test_valid_ibans() {
189        assert!(validate("DE89370400440532013000").is_ok()); // Germany
190        assert!(validate("GB82WEST12345698765432").is_ok()); // United Kingdom
191        assert!(validate("FR1420041010050500013M02606").is_ok()); // France
192        assert!(validate("AL35202111090000000001234567").is_ok()); // Albania
193        assert!(validate("NO9386011117947").is_ok()); // Norway (shortest)
194    }
195
196    // 7.2 Invalid Checksums
197    #[test]
198    fn test_invalid_checksum() {
199        assert_eq!(
200            validate("DE89370400440532013001"),
201            Err(ValidationError::InvalidChecksum)
202        );
203        assert_eq!(
204            validate("GB82WEST12345698765433"),
205            Err(ValidationError::InvalidChecksum)
206        );
207    }
208
209    // 7.3 Wrong Length for Known Country
210    #[test]
211    fn test_wrong_length() {
212        assert_eq!(
213            validate("DE8937040044053201300"),
214            Err(ValidationError::InvalidLength {
215                expected: 22,
216                found: 21,
217            })
218        );
219        assert_eq!(
220            validate("GB82WEST1234569876543"),
221            Err(ValidationError::InvalidLength {
222                expected: 22,
223                found: 21,
224            })
225        );
226        assert_eq!(
227            validate("FR1420041010050500013M0260"),
228            Err(ValidationError::InvalidLength {
229                expected: 27,
230                found: 26,
231            })
232        );
233    }
234
235    // 7.4 Unknown Country Code
236    #[test]
237    fn test_unknown_country() {
238        assert_eq!(
239            validate("XX89370400440532013000"),
240            Err(ValidationError::InvalidCountryCode)
241        );
242        assert_eq!(
243            validate("ZZ1420041010050500013M02606"),
244            Err(ValidationError::InvalidCountryCode)
245        );
246    }
247
248    // 7.5 Invalid Characters
249    #[test]
250    fn test_with_whitespace() {
251        // Should be sanitized
252        assert!(validate("DE89 3704 0044 0532 0130 00").is_ok());
253    }
254
255    #[test]
256    fn test_invalid_characters() {
257        assert_eq!(
258            validate("DE89.37040044053201300"),
259            Err(ValidationError::InvalidCharacter {
260                character: '.',
261                position: 4,
262            })
263        );
264    }
265
266    #[test]
267    fn test_invalid_characters_later() {
268        assert_eq!(
269            validate("GB82WEST1234569876543!"),
270            Err(ValidationError::InvalidCharacter {
271                character: '!',
272                position: 21,
273            })
274        );
275    }
276
277    // 7.6 Case Insensitivity
278    #[test]
279    fn test_lowercase() {
280        assert!(validate("de89370400440532013000").is_ok());
281    }
282
283    #[test]
284    fn test_mixed_case() {
285        assert!(validate("De89370400440532013000").is_ok());
286        assert!(validate("gB82WEST12345698765432").is_ok());
287    }
288
289    // 7.7 Whitespace Handling
290    #[test]
291    fn test_leading_whitespace() {
292        assert!(validate(" DE89370400440532013000").is_ok());
293    }
294
295    #[test]
296    fn test_trailing_whitespace() {
297        assert!(validate("DE89370400440532013000\n").is_ok());
298    }
299
300    #[test]
301    fn test_tabs_and_newlines() {
302        assert!(validate("DE89\t3704\n0044 0532\t0130\n00").is_ok());
303    }
304
305    // 7.8 Empty Input
306    #[test]
307    fn test_empty() {
308        assert_eq!(validate(""), Err(ValidationError::Empty));
309    }
310
311    #[test]
312    fn test_whitespace_only() {
313        assert_eq!(validate("   "), Err(ValidationError::Empty));
314    }
315
316    #[test]
317    fn test_tabs_only() {
318        assert_eq!(validate("\t\n"), Err(ValidationError::Empty));
319    }
320
321    // 7.9 Edge Cases
322    #[test]
323    fn test_single_char() {
324        assert_eq!(
325            validate("D"),
326            Err(ValidationError::InvalidLength {
327                expected: 5,
328                found: 1,
329            })
330        );
331    }
332
333    #[test]
334    fn test_three_chars() {
335        assert_eq!(
336            validate("DE8"),
337            Err(ValidationError::InvalidLength {
338                expected: 5,
339                found: 3,
340            })
341        );
342    }
343
344    #[test]
345    fn test_country_code_only() {
346        assert_eq!(
347            validate("DE"),
348            Err(ValidationError::InvalidLength {
349                expected: 5,
350                found: 2,
351            })
352        );
353    }
354
355    #[test]
356    fn test_four_chars() {
357        assert_eq!(
358            validate("DE89"),
359            Err(ValidationError::InvalidLength {
360                expected: 5,
361                found: 4,
362            })
363        );
364    }
365
366    // Iban type tests
367    #[test]
368    fn test_iban_new_valid() {
369        let iban = Iban::new("DE89370400440532013000").unwrap();
370        assert_eq!(iban.as_str(), "DE89370400440532013000");
371        assert_eq!(iban.country_code(), "DE");
372        assert_eq!(iban.check_digits(), "89");
373        assert_eq!(iban.bban(), "370400440532013000");
374    }
375
376    #[test]
377    fn test_iban_new_invalid() {
378        assert!(Iban::new("DE89").is_err());
379        assert!(Iban::new("XX89370400440532013000").is_err());
380        assert!(Iban::new("DE89370400440532013001").is_err());
381    }
382
383    #[test]
384    fn test_iban_from_str() {
385        let iban: Iban = "DE89370400440532013000".parse().unwrap();
386        assert_eq!(iban.as_str(), "DE89370400440532013000");
387    }
388
389    #[test]
390    fn test_iban_display() {
391        let iban = Iban::new("DE89370400440532013000").unwrap();
392        assert_eq!(format!("{}", iban), "DE89370400440532013000");
393    }
394
395    #[test]
396    fn test_iban_equality() {
397        let iban1 = Iban::new("DE89370400440532013000").unwrap();
398        let iban2 = Iban::new("de89370400440532013000").unwrap();
399        assert_eq!(iban1, iban2);
400    }
401
402    // New tests for security and performance fixes
403
404    #[test]
405    fn test_input_too_long() {
406        let long_input = "A".repeat(257);
407        assert_eq!(
408            validate(&long_input),
409            Err(ValidationError::InvalidLength {
410                expected: MAX_INPUT_LENGTH,
411                found: 257,
412            })
413        );
414    }
415
416    #[test]
417    fn test_input_at_max_length_with_whitespace() {
418        // 22 chars IBAN + lots of whitespace = still ok if total <= 256
419        // The IBAN itself must be valid (checksum correct)
420        let iban = "DE89370400440532013000";
421        let with_ws = format!("{} {}", iban, " ".repeat(200));
422        assert!(validate(&with_ws).is_ok());
423    }
424
425    #[test]
426    fn test_clone_validation_error() {
427        let err = ValidationError::InvalidChecksum;
428        let cloned = err.clone();
429        assert_eq!(err, cloned);
430    }
431
432    #[test]
433    fn test_iban_clone() {
434        let iban1 = Iban::new("DE89370400440532013000").unwrap();
435        let iban2 = iban1.clone();
436        assert_eq!(iban1, iban2);
437    }
438
439    #[test]
440    fn test_all_countries_basic() {
441        // Spot-check a few countries to ensure the data is loaded
442        assert!(validate("DE89370400440532013000").is_ok());
443        assert!(validate("GB82WEST12345698765432").is_ok());
444        assert!(validate("FR1420041010050500013M02606").is_ok());
445        assert!(validate("CH9300762011623852957").is_ok());
446        assert!(validate("NL91ABNA0417164300").is_ok());
447        assert!(validate("BE71096123456769").is_ok());
448    }
449}