taler-rust

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

commit 1f36e0e09b4dec44fe3c87e908df2e8c8b130642
parent 044fe02269259b1b723d972e22b89cd681146b98
Author: Antoine A <>
Date:   Fri, 31 Jan 2025 18:17:49 +0100

common: IBAN & BIC parser

Diffstat:
Mcommon/taler-common/src/types.rs | 4+++-
Mcommon/taler-common/src/types/amount.rs | 20+++++---------------
Acommon/taler-common/src/types/iban.rs | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-common/src/types/payto.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Acommon/taler-common/src/types/utils.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-magnet-bank/src/dev.rs | 4++--
Mtaler-magnet-bank/src/lib.rs | 5+++--
7 files changed, 416 insertions(+), 22 deletions(-)

diff --git a/common/taler-common/src/types.rs b/common/taler-common/src/types.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024 Taler Systems SA + Copyright (C) 2024-2025 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -16,8 +16,10 @@ pub mod amount; pub mod base32; +pub mod iban; pub mod payto; pub mod timestamp; +mod utils; use url::Url; diff --git a/common/taler-common/src/types/amount.rs b/common/taler-common/src/types/amount.rs @@ -22,6 +22,8 @@ use std::{ str::FromStr, }; +use super::utils::InlineStr; + /** Number of characters we use to represent currency names */ // We use the same value than the exchange -1 because we use a byte for the len instead of 0 termination pub const CURRENCY_LEN: usize = 11; @@ -37,18 +39,11 @@ pub const FRAC_BASE: u32 = 10u32.pow(FRAC_BASE_NB_DIGITS as u32); #[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] /// Inlined ISO 4217 currency string -pub struct Currency { - /// Len of currency string in buf - len: u8, - /// Buffer of currency bytes, left adjusted and zero padded - // TODO use std::ascii::Char when stable - buf: [u8; CURRENCY_LEN], -} +pub struct Currency(InlineStr<CURRENCY_LEN>); impl AsRef<str> for Currency { fn as_ref(&self) -> &str { - // SAFETY: len <= CURRENCY_LEN && buf[..len] are all ASCII uppercase - unsafe { std::str::from_utf8_unchecked(self.buf.get_unchecked(..self.len as usize)) } + self.0.as_ref() } } @@ -82,12 +77,7 @@ impl FromStr for Currency { } else if !bytes.iter().all(|c| c.is_ascii_uppercase()) { Err(CurrencyErrorKind::Invalid) } else { - let mut buf = [0; CURRENCY_LEN]; - buf[..len].copy_from_slice(bytes); - Ok(Self { - len: len as u8, - buf, - }) + Ok(Self(InlineStr::copy_from_slice(bytes))) } .map_err(|kind| ParseCurrencyError { currency: s.to_owned(), diff --git a/common/taler-common/src/types/iban.rs b/common/taler-common/src/types/iban.rs @@ -0,0 +1,288 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; + +use super::utils::InlineStr; + +const MAX_IBAN_SIZE: usize = 34; +const MAX_BIC_SIZE: usize = 11; + +#[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] +/// International Bank Account Number (IBAN) +pub struct IBAN(InlineStr<MAX_IBAN_SIZE>); + +impl IBAN { + /// Compute IBAN checksum + fn iban_checksum(s: &str) -> u32 { + s.as_bytes() + .iter() + .cycle() + .skip(4) + .take(s.len()) + .fold(0u32, |mut checksum, b| { + if b.is_ascii_digit() { + checksum = checksum * 10 + (b - b'0') as u32; + } else { + checksum = checksum * 100 + (b - b'A' + 10) as u32; + } + if checksum > 9_999_999 { + checksum %= 97 + } + checksum + }) + % 97 + } +} + +impl AsRef<str> for IBAN { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum IbanErrorKind { + #[error("contains illegal characters (only 0-9A-Z allowed)")] + Invalid, + #[error("contains invalid contry code")] + CountryCode, + #[error("contains invalid check digit")] + CheckDigit, + #[error("too long (max {MAX_IBAN_SIZE} chars)")] + Big, + #[error("checksum expected 1 got {0}")] + Checksum(u32), +} + +#[derive(Debug, thiserror::Error)] +#[error("iban '{iban}' {kind}")] +pub struct ParseIbanError { + iban: String, + pub kind: IbanErrorKind, +} + +impl FromStr for IBAN { + type Err = ParseIbanError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + (|| { + let bytes: &[u8] = s.as_bytes(); + if !bytes + .iter() + .all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric()) + { + return Err(IbanErrorKind::Invalid); + } + let Some(inlined) = InlineStr::from_iter( + bytes + .iter() + .filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())), + ) else { + return Err(IbanErrorKind::Big); + }; + let str = inlined.as_ref(); + let bytes = str.as_bytes(); + if bytes.len() < 2 || !bytes[0..2].iter().all(u8::is_ascii_uppercase) { + return Err(IbanErrorKind::CountryCode); + } else if bytes.len() < 4 || !bytes[2..4].iter().all(u8::is_ascii_digit) { + return Err(IbanErrorKind::CheckDigit); + } + let checksum = Self::iban_checksum(str); + if checksum != 1 { + Err(IbanErrorKind::Checksum(checksum)) + } else { + Ok(Self(inlined)) + } + })() + .map_err(|kind| ParseIbanError { + iban: s.to_owned(), + kind, + }) + } +} + +impl Debug for IBAN { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.as_ref(), f) + } +} + +impl Display for IBAN { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.as_ref(), f) + } +} + +/// Bank Identifier Code (BIC) +#[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] +pub struct BIC(InlineStr<MAX_BIC_SIZE>); + +impl BIC { + pub fn bank_code(&self) -> &str { + // SAFETY len >= 8 + unsafe { self.as_ref().get_unchecked(0..4) } + } + + pub fn country_code(&self) -> &str { + // SAFETY len >= 8 + unsafe { self.as_ref().get_unchecked(4..6) } + } + + pub fn location_code(&self) -> &str { + // SAFETY len >= 8 + unsafe { self.as_ref().get_unchecked(6..8) } + } + + pub fn branch_code(&self) -> Option<&str> { + // SAFETY len >= 8 + let s = unsafe { self.as_ref().get_unchecked(8..) }; + (!s.is_empty()).then_some(s) + } +} + +impl AsRef<str> for BIC { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum BicErrorKind { + #[error("contains illegal characters (only 0-9A-Z allowed)")] + Invalid, + #[error("invalid check digit")] + BankCode, + #[error("invalid contry code")] + CountryCode, + #[error("bad size expected or {MAX_BIC_SIZE} chars for {0}")] + Size(usize), +} + +#[derive(Debug, thiserror::Error)] +#[error("iban '{bic}' {kind}")] +pub struct ParseBicError { + bic: String, + pub kind: BicErrorKind, +} + +impl FromStr for BIC { + type Err = ParseBicError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let bytes: &[u8] = s.as_bytes(); + let len = bytes.len(); + if len != 8 && len != MAX_BIC_SIZE { + Err(BicErrorKind::Size(len)) + } else if !bytes[0..4].iter().all(u8::is_ascii_alphabetic) { + Err(BicErrorKind::BankCode) + } else if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) { + Err(BicErrorKind::CountryCode) + } else if !bytes[6..].iter().all(u8::is_ascii_alphanumeric) { + Err(BicErrorKind::Invalid) + } else { + Ok(Self( + InlineStr::from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase())) + .unwrap(), + )) + } + .map_err(|kind| ParseBicError { + bic: s.to_owned(), + kind, + }) + } +} + +impl Debug for BIC { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.as_ref(), f) + } +} + +impl Display for BIC { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.as_ref(), f) + } +} + +#[test] +fn parse_iban() { + for valid in [ + "DE44500105175407324931", // Germany + "GB82WEST12345698765432", // United Kingdom + "FR1420041010050500013M02606", // France + "ES9121000418450200051332", // Spain + "NL91ABNA0417164300", // Netherlands + "IT60X0542811101000000123456", // Italy + "BE68539007547034", // Belgium + "SE4550000000058398257466", // Sweden + "PL61109010140000071219812874", // Poland + "NO9386011117947", // Norway + ] { + let iban = IBAN::from_str(&valid).unwrap(); + assert_eq!(iban.to_string(), valid); + } + + for (invalid, err) in [ + ("FR1420041@10050500013M02606", IbanErrorKind::Invalid), + ("", IbanErrorKind::CountryCode), + ("12345678901234567890123456", IbanErrorKind::CountryCode), + ("FR", IbanErrorKind::CheckDigit), + ("FRANCE123456", IbanErrorKind::CheckDigit), + ("DE44500105175407324932", IbanErrorKind::Checksum(28)), + ] { + let iban = IBAN::from_str(&invalid).unwrap_err(); + assert_eq!(iban.kind, err); + } +} + +#[test] +fn parse_bic() { + for (valid, parts) in [ + ("DEUTDEFF", ("DEUT", "DE", "FF", None)), // Deutsche Bank, Germany + ("NEDSZAJJ", ("NEDS", "ZA", "JJ", None)), // Nedbank, South Africa + ("BARCGB22", ("BARC", "GB", "22", None)), // Barclays, UK + ("CHASUS33XXX", ("CHAS", "US", "33", Some("XXX"))), // JP Morgan Chase, USA (branch) + ("BNPAFRPP", ("BNPA", "FR", "PP", None)), // BNP Paribas, France + ("INGBNL2A", ("INGB", "NL", "2A", None)), // ING Bank, Netherlands + ] { + let bic = BIC::from_str(&valid).unwrap(); + assert_eq!( + ( + bic.bank_code(), + bic.country_code(), + bic.location_code(), + bic.branch_code() + ), + parts + ); + assert_eq!(bic.to_string(), valid); + } + + for (invalid, err) in [ + ("DEU", BicErrorKind::Size(3)), + ("DEUTDEFFA1BC", BicErrorKind::Size(12)), + ("D3UTDEFF", BicErrorKind::BankCode), + ("DEUTD3FF", BicErrorKind::CountryCode), + ("DEUTDEFF@@1", BicErrorKind::Invalid), + ] { + let bic = BIC::from_str(&invalid).unwrap_err(); + assert_eq!(bic.kind, err, "{invalid}"); + } +} diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -21,6 +21,8 @@ use std::{ }; use url::Url; +use super::iban::{ParseBicError, ParseIbanError, BIC, IBAN}; + /// Parse a payto URI, panic if malformed pub fn payto(url: impl AsRef<str>) -> Payto { url.as_ref().parse().expect("invalid payto") @@ -97,9 +99,56 @@ impl FromStr for Payto { } } -/// RFC 8905 payto URI +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IbanPayto { + pub iban: IBAN, + pub bic: Option<BIC>, +} + +#[derive(Debug, thiserror::Error)] +pub enum IbanPaytoErr { + #[error("missing IBAN in path")] + MissingIban, + #[error(transparent)] + IBAN(#[from] ParseIbanError), + #[error(transparent)] + BIC(#[from] ParseBicError), +} + +const IBAN: &str = "iban"; + +impl TryFrom<&Payto> for IbanPayto { + type Error = PaytoErr; + + fn try_from(value: &Payto) -> Result<Self, Self::Error> { + let url = value.as_ref(); + if url.domain() != Some(IBAN) { + return Err(PaytoErr::UnsupportedKind( + IBAN, + url.domain().unwrap_or_default().to_owned(), + )); + } + let Some(mut segments) = url.path_segments() else { + return Err(PaytoErr::custom(IbanPaytoErr::MissingIban)); + }; + let Some(first) = segments.next() else { + return Err(PaytoErr::custom(IbanPaytoErr::MissingIban)); + }; + let (iban, bic) = match segments.next() { + Some(second) => ( + second.parse().map_err(PaytoErr::custom)?, + Some(second.parse().map_err(PaytoErr::custom)?), + ), + None => (first.parse().map_err(PaytoErr::custom)?, None), + }; + + Ok(Self { iban, bic }) + } +} + +/// Full payto query #[derive(Debug, Clone, Deserialize)] -pub struct FullPayto { +pub struct FullQuery { #[serde(rename = "receiver-name")] pub receiver_name: String, } diff --git a/common/taler-common/src/types/utils.rs b/common/taler-common/src/types/utils.rs @@ -0,0 +1,64 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +#[derive(Clone, PartialEq, Eq)] +pub struct InlineStr<const LEN: usize> { + /// Len of ascii string in buf + len: u8, + /// Buffer of ascii bytes, left adjusted and zero padded + // TODO use std::ascii::Char when stable + buf: [u8; LEN], +} + +impl<const LEN: usize> InlineStr<LEN> { + /// Create an inlined string from a slice + #[inline] + pub fn copy_from_slice(slice: &[u8]) -> Self { + let len = slice.len(); + let mut buf = [0; LEN]; + buf[..len].copy_from_slice(slice); + debug_assert!(buf.is_ascii()); + Self { + len: len as u8, + buf, + } + } + + /// Create an inlined string from a string + /// Return none if too long + #[inline] + pub fn from_iter<T: IntoIterator<Item = u8>>(iter: T) -> Option<Self> { + let mut len = 0; + let mut buf = [0; LEN]; + for byte in iter { + *buf.get_mut(len)? = byte; + len += 1; + } + debug_assert!(buf.is_ascii()); + Some(Self { + len: len as u8, + buf, + }) + } +} + +impl<const LEN: usize> AsRef<str> for InlineStr<LEN> { + #[inline] + fn as_ref(&self) -> &str { + // SAFETY: len <= LEN && buf[..len] are all ASCII uppercase + unsafe { std::str::from_utf8_unchecked(self.buf.get_unchecked(..self.len as usize)) } + } +} diff --git a/taler-magnet-bank/src/dev.rs b/taler-magnet-bank/src/dev.rs @@ -20,7 +20,7 @@ use taler_common::{ config::Config, types::{ amount::Amount, - payto::{FullPayto, Payto}, + payto::{FullQuery, Payto}, }, }; use tracing::info; @@ -113,7 +113,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { amount, subject, } => { - let full: FullPayto = creditor.query()?; + let full: FullQuery = creditor.query()?; let debtor = MagnetPayto::try_from(&debtor)?; let creditor = MagnetPayto::try_from(&creditor)?; let debtor = client.account(&debtor.number).await?; diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use taler_common::types::payto::{FullPayto, Payto, PaytoErr}; +use taler_common::types::payto::{FullQuery, Payto, PaytoErr}; pub mod adapter; pub mod config; @@ -50,6 +50,7 @@ impl std::fmt::Display for MagnetPayto { self.as_payto().fmt(f) } } + #[derive(Debug, thiserror::Error)] pub enum MagnetPaytoErr { #[error("missing Magnet Bank account number in path")] @@ -78,7 +79,7 @@ impl TryFrom<&Payto> for MagnetPayto { if segments.next().is_some() { return Err(PaytoErr::TooLong(MAGNET_BANK)); } - let full: FullPayto = value.query()?; + let full: FullQuery = value.query()?; Ok(Self { number: account.to_owned(), name: full.receiver_name,