From 36509bde64ffe7b32d3fddc2eaf985f5e8b91408 Mon Sep 17 00:00:00 2001 From: Andrei <92177534+andrei-21@users.noreply.github.com> Date: Wed, 29 May 2024 11:20:34 +0100 Subject: [PATCH] Parse phone number prefix (#1092) --- parser/src/domain.rs | 2 +- parser/src/lib.rs | 34 +++++++++++- parser/src/phone_number.rs | 17 ++++++ src/errors.rs | 10 ++++ src/lib.rs | 26 ++++++++- src/lipalightninglib.udl | 10 ++++ src/phone_number.rs | 106 +++++++++++++++++++++++++++++++++++-- 7 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 parser/src/phone_number.rs diff --git a/parser/src/domain.rs b/parser/src/domain.rs index d7ba2c0e9..a1f1b7675 100644 --- a/parser/src/domain.rs +++ b/parser/src/domain.rs @@ -44,7 +44,7 @@ fn unicode_label(s: &str) -> IResult<&str, Label> { } fn label(s: &str) -> IResult<&str, Label> { - let r: IResult<&str, &str> = tag_no_case("xn--")(s); + let r: IResult<_, _> = tag_no_case("xn--")(s); match r { Ok((s, _tag)) => punycode_label(s), Err(_) => unicode_label(s), diff --git a/parser/src/lib.rs b/parser/src/lib.rs index 9c024dd43..2eefaf875 100644 --- a/parser/src/lib.rs +++ b/parser/src/lib.rs @@ -1,7 +1,9 @@ mod domain; mod lightning_address; +mod phone_number; -use lightning_address::lightning_address; +use crate::lightning_address::lightning_address; +use crate::phone_number::phone_number; use nom::character::complete::space0; use nom::error::Error; use nom::sequence::delimited; @@ -36,11 +38,41 @@ pub fn parse_lightning_address(address: &str) -> Result<(), ParseError> { } } +pub fn parse_phone_number(number: &str) -> Result { + let r = delimited(space0, phone_number, space0)(number).finish(); + match r { + Ok(("", digits)) if digits.is_empty() => Err(ParseError::Incomplete), + Ok(("", digits)) => Ok(digits), + Ok((rem, _digits)) => Err(ParseError::ExcessSuffix(number.len() - rem.len())), + Err(Error { input: "", .. }) => Err(ParseError::Incomplete), + Err(Error { input, .. }) => { + Err(ParseError::UnexpectedCharacter(number.len() - input.len())) + } + } +} + #[cfg(test)] mod tests { use super::parse_lightning_address as p; + use super::parse_phone_number as pn; use super::*; + #[test] + fn test_parse_phone_number() { + assert_eq!(pn(""), Err(ParseError::Incomplete)); + assert_eq!(pn(" "), Err(ParseError::Incomplete)); + assert_eq!(pn(" +"), Err(ParseError::Incomplete)); + assert_eq!(pn(" +1"), Ok("1".to_string())); + assert_eq!(pn(" +12"), Ok("12".to_string())); + assert_eq!(pn(" +12 "), Ok("12".to_string())); + assert_eq!(pn(" +123"), Ok("123".to_string())); + assert_eq!(pn(" +12 3"), Ok("123".to_string())); + + assert_eq!(pn("+123~"), Err(ParseError::ExcessSuffix(4))); + assert_eq!(pn("+12+"), Err(ParseError::ExcessSuffix(3))); + assert_eq!(pn("+12~"), Err(ParseError::ExcessSuffix(3))); + } + #[test] fn test_parse_lightning_address() { assert_eq!(p(""), Err(ParseError::Incomplete)); diff --git a/parser/src/phone_number.rs b/parser/src/phone_number.rs new file mode 100644 index 000000000..f3c2197a4 --- /dev/null +++ b/parser/src/phone_number.rs @@ -0,0 +1,17 @@ +use nom::bytes::complete::take_while; +use nom::character::complete::char as nom_char; +use nom::sequence::preceded; +use nom::IResult; + +fn is_digit_or_symbol(c: char) -> bool { + c.is_ascii_digit() || c.is_whitespace() || ".-/()[]".contains(c) +} + +pub(crate) fn phone_number(s: &str) -> IResult<&str, String> { + let (s, digits_and_symbols) = preceded(nom_char('+'), take_while(is_digit_or_symbol))(s)?; + let digits = digits_and_symbols + .chars() + .filter(char::is_ascii_digit) + .collect::(); + Ok((s, digits)) +} diff --git a/src/errors.rs b/src/errors.rs index ed68f8dca..af19b4110 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -333,6 +333,16 @@ impl NotificationHandlingErrorCode { } } +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum ParsePhoneNumberPrefixError { + #[error("Incomplete")] + Incomplete, + #[error("InvalidCharacter at {at}")] + InvalidCharacter { at: u32 }, + #[error("UnsupportedCountry")] + UnsupportedCountry, +} + #[derive(Debug, PartialEq, Eq, thiserror::Error)] pub enum ParsePhoneNumberError { #[error("ParsingError")] diff --git a/src/lib.rs b/src/lib.rs index 43896dd37..55479acb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,8 @@ use crate::errors::{ pub use crate::errors::{ DecodeDataError, Error as LnError, LnUrlPayError, LnUrlPayErrorCode, LnUrlPayResult, MnemonicError, NotificationHandlingError, NotificationHandlingErrorCode, ParsePhoneNumberError, - PayError, PayErrorCode, PayResult, Result, RuntimeErrorCode, SimpleError, UnsupportedDataType, + ParsePhoneNumberPrefixError, PayError, PayErrorCode, PayResult, Result, RuntimeErrorCode, + SimpleError, UnsupportedDataType, }; use crate::event::LipaEventListener; pub use crate::exchange_rate_provider::ExchangeRate; @@ -79,6 +80,7 @@ pub use crate::payment::{ IncomingPaymentInfo, OutgoingPaymentInfo, PaymentInfo, PaymentState, Recipient, }; pub use crate::phone_number::PhoneNumber; +use crate::phone_number::PhoneNumberPrefixParser; pub use crate::recovery::recover_lightning_node; pub use crate::secret::{generate_secret, mnemonic_to_secret, words_by_prefix, Secret}; pub use crate::swap::{ @@ -270,6 +272,7 @@ pub struct LightningNode { analytics_interceptor: Arc, environment: Environment, allowed_countries_country_iso_3166_1_alpha_2: Vec, + phone_number_prefix_parser: PhoneNumberPrefixParser, } /// Contains the fee information for the options to resolve on-chain funds from channel closes. @@ -441,6 +444,9 @@ impl LightningNode { register_webhook_url(&rt, &sdk, &auth, &environment)?; + let phone_number_prefix_parser = + PhoneNumberPrefixParser::new(&config.phone_number_allowed_countries_iso_3166_1_alpha_2); + Ok(LightningNode { user_preferences, sdk, @@ -455,6 +461,7 @@ impl LightningNode { environment, allowed_countries_country_iso_3166_1_alpha_2: config .phone_number_allowed_countries_iso_3166_1_alpha_2, + phone_number_prefix_parser, }) } @@ -617,7 +624,20 @@ impl LightningNode { )) } - /// Parse a phone number, check against the list of allowed countries (set in [`Config`]). + /// Parse a phone number prefix, check against the list of allowed countries + /// (set in [`Config::phone_number_allowed_countries_iso_3166_1_alpha_2`]). + /// The parser is not strict, it parses some invalid prefixes as valid. + /// + /// Requires network: **no** + pub fn parse_phone_number_prefix( + &self, + phone_number_prefix: String, + ) -> std::result::Result<(), ParsePhoneNumberPrefixError> { + self.phone_number_prefix_parser.parse(&phone_number_prefix) + } + + /// Parse a phone number, check against the list of allowed countries + /// (set in [`Config::phone_number_allowed_countries_iso_3166_1_alpha_2`]). /// /// Returns a possible lightning address, which can be checked for existence /// with [`LightningNode::decode_data`]. @@ -2662,6 +2682,8 @@ impl From for ParseError { /// Try to parse the provided string as a lightning address, return [`ParseError`] /// precisely indicating why parsing failed. +/// +/// Requires network: **no** pub fn parse_lightning_address(address: &str) -> std::result::Result<(), ParseError> { parser::parse_lightning_address(address).map_err(ParseError::from) } diff --git a/src/lipalightninglib.udl b/src/lipalightninglib.udl index 04c1e4d9b..504f3b30b 100644 --- a/src/lipalightninglib.udl +++ b/src/lipalightninglib.udl @@ -24,6 +24,9 @@ interface LightningNode { [Throws=DecodeDataError] DecodedData decode_data(string data); + [Throws=ParsePhoneNumberPrefixError] + void parse_phone_number_prefix(string phone_number); + [Throws=ParsePhoneNumberError] string parse_phone_number_to_lightning_address(string phone_number); @@ -739,6 +742,13 @@ interface DecodeDataError { Unrecognized(string msg); }; +[Error] +interface ParsePhoneNumberPrefixError { + Incomplete(); + InvalidCharacter(u32 at); + UnsupportedCountry(); +}; + [Error] interface ParsePhoneNumberError { ParsingError(); diff --git a/src/phone_number.rs b/src/phone_number.rs index 1ccfe60ac..3320b35ca 100644 --- a/src/phone_number.rs +++ b/src/phone_number.rs @@ -1,6 +1,7 @@ -use crate::errors::ParsePhoneNumberError; +use crate::errors::{ParsePhoneNumberError, ParsePhoneNumberPrefixError}; use perro::ensure; use phonenumber::country::Id as CountryCode; +use phonenumber::metadata::DATABASE; use phonenumber::ParseError; #[derive(PartialEq, Debug)] @@ -10,7 +11,7 @@ pub struct PhoneNumber { } impl PhoneNumber { - pub(crate) fn parse(number: &str) -> Result { + pub(crate) fn parse(number: &str) -> Result { let number = match phonenumber::parse(None, number) { Ok(number) => number, Err(ParseError::InvalidCountryCode) => { @@ -25,7 +26,7 @@ impl PhoneNumber { .country() .id() .ok_or(ParsePhoneNumberError::InvalidCountryCode)?; - Ok(PhoneNumber { e164, country_code }) + Ok(Self { e164, country_code }) } pub(crate) fn to_lightning_address(&self, domain: &str) -> String { @@ -45,12 +46,111 @@ pub(crate) fn lightning_address_to_phone_number(address: &str, domain: &str) -> None } +pub(crate) struct PhoneNumberPrefixParser { + allowed_country_codes: Vec, +} + +impl PhoneNumberPrefixParser { + pub fn new(allowed_countries_iso_3166_1_alpha_2: &[String]) -> Self { + // Stricly speaking *ISO 3166-1 alpha-2* is not the same as *CLDR country IDs* + // and such conversion is not correct, but for the most contries such codes match. + let allowed_country_codes = allowed_countries_iso_3166_1_alpha_2 + .iter() + .flat_map(|id| DATABASE.by_id(id)) + .map(|m| m.country_code().to_string()) + .collect::>(); + Self { + allowed_country_codes, + } + } + + pub fn parse(&self, prefix: &str) -> Result<(), ParsePhoneNumberPrefixError> { + match parser::parse_phone_number(prefix) { + Ok(digits) => { + if self + .allowed_country_codes + .iter() + .any(|c| digits.starts_with(c)) + { + Ok(()) + } else if self + .allowed_country_codes + .iter() + .any(|c| c.starts_with(&digits)) + { + Err(ParsePhoneNumberPrefixError::Incomplete) + } else { + Err(ParsePhoneNumberPrefixError::UnsupportedCountry) + } + } + Err(parser::ParseError::Incomplete) => Err(ParsePhoneNumberPrefixError::Incomplete), + Err( + parser::ParseError::UnexpectedCharacter(index) + | parser::ParseError::ExcessSuffix(index), + ) => Err(ParsePhoneNumberPrefixError::InvalidCharacter { at: index as u32 }), + } + } +} + #[cfg(test)] mod tests { use super::*; static LIPA_DOMAIN: &str = "@lipa.swiss"; + #[test] + fn test_parse_phone_number_prefix() { + let ch = PhoneNumberPrefixParser::new(&["CH".to_string()]); + assert_eq!(ch.parse(""), Err(ParsePhoneNumberPrefixError::Incomplete)); + assert_eq!(ch.parse("+"), Err(ParsePhoneNumberPrefixError::Incomplete)); + assert_eq!( + ch.parse("+3"), + Err(ParsePhoneNumberPrefixError::UnsupportedCountry) + ); + assert_eq!(ch.parse("+4"), Err(ParsePhoneNumberPrefixError::Incomplete)); + assert_eq!( + ch.parse("+4 "), + Err(ParsePhoneNumberPrefixError::Incomplete) + ); + assert_eq!( + ch.parse("+44"), + Err(ParsePhoneNumberPrefixError::UnsupportedCountry) + ); + assert_eq!(ch.parse("+41"), Ok(())); + assert_eq!(ch.parse("+41 ("), Ok(())); + assert_eq!(ch.parse("+41 (935"), Ok(())); + assert_eq!( + ch.parse("+41a"), + Err(ParsePhoneNumberPrefixError::InvalidCharacter { at: 3 }) + ); + + let us = PhoneNumberPrefixParser::new(&["US".to_string()]); + assert_eq!(us.parse("+"), Err(ParsePhoneNumberPrefixError::Incomplete)); + assert_eq!(us.parse("+1"), Ok(())); + assert_eq!(us.parse("+12"), Ok(())); + + let us_and_ch = PhoneNumberPrefixParser::new(&["US".to_string(), "CH".to_string()]); + assert_eq!( + us_and_ch.parse("+"), + Err(ParsePhoneNumberPrefixError::Incomplete) + ); + assert_eq!(us_and_ch.parse("+1"), Ok(())); + assert_eq!(us_and_ch.parse("+12"), Ok(())); + assert_eq!( + us_and_ch.parse("+3"), + Err(ParsePhoneNumberPrefixError::UnsupportedCountry) + ); + assert_eq!( + us_and_ch.parse("+4"), + Err(ParsePhoneNumberPrefixError::Incomplete) + ); + assert_eq!( + us_and_ch.parse("+44"), + Err(ParsePhoneNumberPrefixError::UnsupportedCountry) + ); + assert_eq!(us_and_ch.parse("+41"), Ok(())); + } + #[test] fn test_parse_phone_number() { let expected = PhoneNumber {