taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

iban.rs (11133B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025, 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{
     18     fmt::{Debug, Display},
     19     ops::Deref,
     20     str::FromStr,
     21 };
     22 
     23 use compact_str::CompactString;
     24 pub use registry::Country;
     25 use registry::{IbanC, PatternErr, check_pattern, rng_pattern};
     26 use serde_with::{DeserializeFromStr, SerializeDisplay};
     27 
     28 use super::utils::InlineStr;
     29 
     30 mod registry;
     31 
     32 const MAX_IBAN_SIZE: usize = 34;
     33 const MAX_BIC_SIZE: usize = 11;
     34 
     35 /// Parse an IBAN, panic if malformed
     36 pub fn iban(iban: impl AsRef<str>) -> IBAN {
     37     iban.as_ref().parse().expect("invalid IBAN")
     38 }
     39 
     40 /// Parse an BIC, panic if malformed
     41 pub fn bic(bic: impl AsRef<str>) -> BIC {
     42     bic.as_ref().parse().expect("invalid BIC")
     43 }
     44 
     45 #[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
     46 /// International Bank Account Number (IBAN)
     47 pub struct IBAN {
     48     country: Country,
     49     encoded: InlineStr<MAX_IBAN_SIZE>,
     50 }
     51 
     52 impl IBAN {
     53     /// Compute IBAN checksum
     54     fn checksum(s: &[u8]) -> u8 {
     55         (s.iter().cycle().skip(4).take(s.len()).fold(0u32, |sum, b| {
     56             if b.is_ascii_digit() {
     57                 (sum * 10 + (b - b'0') as u32) % 97
     58             } else {
     59                 (sum * 100 + (b - b'A' + 10) as u32) % 97
     60             }
     61         })) as u8
     62     }
     63 
     64     fn from_raw_parts(country: Country, bban: &[u8]) -> Self {
     65         // Create an iban with an empty digit check
     66         let mut encoded = InlineStr::try_from_iter(
     67             country
     68                 .iso_bytes()
     69                 .iter()
     70                 .copied()
     71                 .chain(*b"00")
     72                 .chain(bban.iter().copied()),
     73         )
     74         .unwrap();
     75         // Compute check digit
     76         let checksum = 98 - Self::checksum(encoded.deref());
     77 
     78         // And insert it
     79         unsafe {
     80             // SAFETY: we only insert ASCII digits
     81             let buf = encoded.deref_mut();
     82             buf[3] = checksum % 10 + b'0';
     83             buf[2] = checksum / 10 + b'0';
     84         }
     85 
     86         Self { country, encoded }
     87     }
     88 
     89     pub fn from_parts(country: Country, bban: &str) -> Self {
     90         check_pattern(bban.as_bytes(), country.bban_pattern()).unwrap(); // TODO  return Result
     91         Self::from_raw_parts(country, bban.as_bytes())
     92     }
     93 
     94     pub fn random(country: Country) -> Self {
     95         let mut bban = [0u8; MAX_IBAN_SIZE - 4];
     96         rng_pattern(&mut bban, country.bban_pattern());
     97         Self::from_raw_parts(country, &bban[..country.bban_len()])
     98     }
     99 
    100     pub fn country(&self) -> Country {
    101         self.country
    102     }
    103 
    104     pub fn bban(&self) -> &str {
    105         // SAFETY len >= 5
    106         unsafe { self.as_ref().get_unchecked(4..) }
    107     }
    108 
    109     pub fn bank_id(&self) -> &str {
    110         &self.bban()[self.country.bank_id()]
    111     }
    112 
    113     pub fn branch_id(&self) -> &str {
    114         &self.bban()[self.country.branch_id()]
    115     }
    116 }
    117 
    118 impl AsRef<str> for IBAN {
    119     fn as_ref(&self) -> &str {
    120         self.encoded.as_ref()
    121     }
    122 }
    123 
    124 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    125 pub enum IbanErrKind {
    126     #[error("contains illegal characters (only 0-9A-Z allowed)")]
    127     Invalid,
    128     #[error("contains invalid characters")]
    129     Malformed,
    130     #[error("unknown country {0}")]
    131     UnknownCountry(String),
    132     #[error("too long expected max {MAX_IBAN_SIZE} chars got {0}")]
    133     Overflow(usize),
    134     #[error("too short expected min 4 chars got {0}")]
    135     Underflow(usize),
    136     #[error("wrong size expected {0} chars got {1}")]
    137     Size(u8, usize),
    138     #[error("checksum expected 1 got {0}")]
    139     Checksum(u8),
    140 }
    141 
    142 #[derive(Debug, thiserror::Error)]
    143 #[error("iban '{iban}' {kind}")]
    144 pub struct ParseIbanErr {
    145     iban: CompactString,
    146     pub kind: IbanErrKind,
    147 }
    148 
    149 impl FromStr for IBAN {
    150     type Err = ParseIbanErr;
    151 
    152     fn from_str(s: &str) -> Result<Self, Self::Err> {
    153         let bytes: &[u8] = s.as_bytes();
    154         if !bytes
    155             .iter()
    156             .all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric())
    157         {
    158             Err(IbanErrKind::Invalid)
    159         } else if let Some(encoded) = InlineStr::try_from_iter(
    160             bytes
    161                 .iter()
    162                 .filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())),
    163         ) {
    164             if encoded.len() < 4 {
    165                 Err(IbanErrKind::Underflow(encoded.len()))
    166             } else if !IbanC::A.check(&encoded[0..2]) || !IbanC::N.check(&encoded[2..4]) {
    167                 Err(IbanErrKind::Malformed)
    168             } else if let Some(country) = Country::from_iso(&encoded.as_ref()[..2]) {
    169                 if let Err(e) = check_pattern(&encoded[4..], country.bban_pattern()) {
    170                     Err(match e {
    171                         PatternErr::Len(expected, got) => IbanErrKind::Size(expected, got),
    172                         PatternErr::Malformed => IbanErrKind::Malformed,
    173                     })
    174                 } else {
    175                     let checksum = Self::checksum(&encoded);
    176                     if checksum != 1 {
    177                         Err(IbanErrKind::Checksum(checksum))
    178                     } else {
    179                         Ok(Self { country, encoded })
    180                     }
    181                 }
    182             } else {
    183                 Err(IbanErrKind::UnknownCountry(
    184                     encoded.as_ref()[..2].to_owned(),
    185                 ))
    186             }
    187         } else {
    188             Err(IbanErrKind::Overflow(bytes.len()))
    189         }
    190         .map_err(|kind| ParseIbanErr {
    191             iban: s.into(),
    192             kind,
    193         })
    194     }
    195 }
    196 
    197 impl Display for IBAN {
    198     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    199         Display::fmt(&self.as_ref(), f)
    200     }
    201 }
    202 
    203 impl Debug for IBAN {
    204     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    205         Display::fmt(&self, f)
    206     }
    207 }
    208 
    209 /// Bank Identifier Code (BIC)
    210 #[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
    211 pub struct BIC(InlineStr<MAX_BIC_SIZE>);
    212 
    213 impl BIC {
    214     pub fn bank_code(&self) -> &str {
    215         // SAFETY len >= 8
    216         unsafe { self.as_ref().get_unchecked(0..4) }
    217     }
    218 
    219     pub fn country_code(&self) -> &str {
    220         // SAFETY len >= 8
    221         unsafe { self.as_ref().get_unchecked(4..6) }
    222     }
    223 
    224     pub fn location_code(&self) -> &str {
    225         // SAFETY len >= 8
    226         unsafe { self.as_ref().get_unchecked(6..8) }
    227     }
    228 
    229     pub fn branch_code(&self) -> Option<&str> {
    230         // SAFETY len >= 8
    231         let s = unsafe { self.as_ref().get_unchecked(8..) };
    232         (!s.is_empty()).then_some(s)
    233     }
    234 }
    235 
    236 impl AsRef<str> for BIC {
    237     fn as_ref(&self) -> &str {
    238         self.0.as_ref()
    239     }
    240 }
    241 
    242 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    243 pub enum BicErrKind {
    244     #[error("contains illegal characters (only 0-9A-Z allowed)")]
    245     Invalid,
    246     #[error("invalid check digit")]
    247     BankCode,
    248     #[error("invalid country code")]
    249     CountryCode,
    250     #[error("bad size expected 8 or {MAX_BIC_SIZE} chars got {0}")]
    251     Size(usize),
    252 }
    253 
    254 #[derive(Debug, thiserror::Error)]
    255 #[error("bic '{bic}' {kind}")]
    256 pub struct ParseBicErr {
    257     bic: CompactString,
    258     pub kind: BicErrKind,
    259 }
    260 
    261 impl FromStr for BIC {
    262     type Err = ParseBicErr;
    263 
    264     fn from_str(s: &str) -> Result<Self, Self::Err> {
    265         let bytes: &[u8] = s.as_bytes();
    266         let len = bytes.len();
    267         if len != 8 && len != MAX_BIC_SIZE {
    268             Err(BicErrKind::Size(len))
    269         } else if !bytes[0..4].iter().all(u8::is_ascii_alphabetic) {
    270             Err(BicErrKind::BankCode)
    271         } else if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) {
    272             Err(BicErrKind::CountryCode)
    273         } else if !bytes[6..].iter().all(u8::is_ascii_alphanumeric) {
    274             Err(BicErrKind::Invalid)
    275         } else {
    276             Ok(Self(
    277                 InlineStr::try_from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase()))
    278                     .unwrap(),
    279             ))
    280         }
    281         .map_err(|kind| ParseBicErr {
    282             bic: s.into(),
    283             kind,
    284         })
    285     }
    286 }
    287 
    288 impl Display for BIC {
    289     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    290         Display::fmt(&self.as_ref(), f)
    291     }
    292 }
    293 
    294 impl Debug for BIC {
    295     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    296         Display::fmt(&self, f)
    297     }
    298 }
    299 
    300 #[test]
    301 fn parse_iban() {
    302     use registry::VALID_IBAN;
    303     for (valid, bban) in VALID_IBAN {
    304         // Parsing
    305         let iban = IBAN::from_str(valid).unwrap();
    306         assert_eq!(iban.to_string(), valid);
    307         // Roundtrip
    308         let from_parts = IBAN::from_parts(iban.country(), iban.bban());
    309         assert_eq!(from_parts.to_string(), valid);
    310 
    311         // BBAN
    312         if let Some(bban) = bban {
    313             assert_eq!(bban, iban.bban());
    314         }
    315 
    316         // Random
    317         let rand = IBAN::random(iban.country());
    318         let parsed = IBAN::from_str(rand.as_ref()).unwrap();
    319         assert_eq!(rand, parsed);
    320     }
    321 
    322     for (invalid, err) in [
    323         ("FR1420041@10050500013M02606", IbanErrKind::Invalid),
    324         ("", IbanErrKind::Underflow(0)),
    325         ("12345678901234567890123456", IbanErrKind::Malformed),
    326         ("FR", IbanErrKind::Underflow(2)),
    327         ("FRANCE123456", IbanErrKind::Malformed),
    328         ("DE44500105175407324932", IbanErrKind::Checksum(28)),
    329     ] {
    330         let iban = IBAN::from_str(invalid).unwrap_err();
    331         assert_eq!(iban.kind, err);
    332     }
    333 }
    334 
    335 #[test]
    336 fn parse_bic() {
    337     for (valid, parts) in [
    338         ("DEUTDEFF", ("DEUT", "DE", "FF", None)), // Deutsche Bank, Germany
    339         ("NEDSZAJJ", ("NEDS", "ZA", "JJ", None)), // Nedbank, South Africa // codespell:ignore
    340         ("BARCGB22", ("BARC", "GB", "22", None)), // Barclays, UK
    341         ("CHASUS33XXX", ("CHAS", "US", "33", Some("XXX"))), // JP Morgan Chase, USA (branch)
    342         ("BNPAFRPP", ("BNPA", "FR", "PP", None)), // BNP Paribas, France
    343         ("INGBNL2A", ("INGB", "NL", "2A", None)), // ING Bank, Netherlands
    344     ] {
    345         let bic = BIC::from_str(valid).unwrap();
    346         assert_eq!(
    347             (
    348                 bic.bank_code(),
    349                 bic.country_code(),
    350                 bic.location_code(),
    351                 bic.branch_code()
    352             ),
    353             parts
    354         );
    355         assert_eq!(bic.to_string(), valid);
    356     }
    357 
    358     for (invalid, err) in [
    359         ("DEU", BicErrKind::Size(3)),
    360         ("DEUTDEFFA1BC", BicErrKind::Size(12)),
    361         ("D3UTDEFF", BicErrKind::BankCode),
    362         ("DEUTD3FF", BicErrKind::CountryCode),
    363         ("DEUTDEFF@@1", BicErrKind::Invalid),
    364     ] {
    365         let bic = BIC::from_str(invalid).unwrap_err();
    366         assert_eq!(bic.kind, err, "{invalid}");
    367     }
    368 }