commit 1f36e0e09b4dec44fe3c87e908df2e8c8b130642
parent 044fe02269259b1b723d972e22b89cd681146b98
Author: Antoine A <>
Date: Fri, 31 Jan 2025 18:17:49 +0100
common: IBAN & BIC parser
Diffstat:
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,