commit 95c2f76677ca08b5ce1ce603cd2229f00ff885d1
parent 77cd5c7b937e54cb7eb4045798187a4e7943c642
Author: Antoine A <>
Date: Mon, 30 Dec 2024 12:53:20 +0100
taler: refactor subject types and module structure
Diffstat:
14 files changed, 515 insertions(+), 441 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -281,9 +281,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
-version = "1.2.5"
+version = "1.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
+checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333"
dependencies = [
"shlex",
]
@@ -1236,9 +1236,12 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "jiff"
-version = "0.1.15"
+version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db69f08d4fb10524cacdb074c10b296299d71274ddbc830a8ee65666867002e9"
+checksum = "24a46169c7a10358cdccfb179910e8a5a392fc291bdb409da9aeece5b19786d8"
+dependencies = [
+ "serde",
+]
[[package]]
name = "js-sys"
@@ -1665,9 +1668,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.37"
+version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
+checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
dependencies = [
"proc-macro2",
]
@@ -1906,9 +1909,9 @@ dependencies = [
[[package]]
name = "rustversion"
-version = "1.0.18"
+version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248"
+checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
[[package]]
name = "ryu"
@@ -1939,18 +1942,18 @@ checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba"
[[package]]
name = "serde"
-version = "1.0.216"
+version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e"
+checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.216"
+version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e"
+checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [
"proc-macro2",
"quote",
@@ -1993,9 +1996,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.11.0"
+version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817"
+checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [
"base64",
"chrono",
@@ -2011,9 +2014,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.11.0"
+version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d"
+checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
"darling",
"proc-macro2",
@@ -2358,9 +2361,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
-version = "2.0.91"
+version = "2.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
+checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
dependencies = [
"proc-macro2",
"quote",
@@ -2389,7 +2392,10 @@ name = "taler-api"
version = "0.1.0"
dependencies = [
"axum",
+ "criterion",
"dashmap",
+ "ed25519-dalek",
+ "fastrand",
"http-body-util",
"libdeflater",
"listenfd",
@@ -2398,6 +2404,7 @@ dependencies = [
"sqlx",
"taler-common",
"test-utils",
+ "thiserror 2.0.9",
"tokio",
"tracing",
"tracing-subscriber",
@@ -2410,7 +2417,6 @@ name = "taler-common"
version = "0.1.0"
dependencies = [
"criterion",
- "ed25519-dalek",
"fastrand",
"jiff",
"rand",
diff --git a/Cargo.toml b/Cargo.toml
@@ -13,3 +13,5 @@ tokio = { version = "1.42", features = ["macros"] }
axum = "0.7.9"
sqlx = { version = "0.8", default-features = false }
url = { version = "2.2", features = ["serde"] }
+criterion = { version = "0.5" }
+fastrand = "2.2.0"
+\ No newline at end of file
diff --git a/taler-api/Cargo.toml b/taler-api/Cargo.toml
@@ -17,12 +17,20 @@ sqlx = { workspace = true, features = [
] }
http-body-util = "0.1.2"
libdeflater = "1.22.0"
+ed25519-dalek = { version = "2.1.1", default-features = false }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
axum.workspace = true
url.workspace = true
+thiserror.workspace = true
taler-common = { path = "../taler-common" }
[dev-dependencies]
test-utils = { path = "../test-utils" }
+criterion.workspace = true
+fastrand.workspace = true
+
+[[bench]]
+name = "subject"
+harness = false
diff --git a/taler-api/benches/subject.rs b/taler-api/benches/subject.rs
@@ -0,0 +1,84 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2024 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 criterion::{black_box, criterion_group, criterion_main, Criterion};
+use taler_api::subject::parse_incoming_unstructured;
+
+fn parser(c: &mut Criterion) {
+ const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789::: \t\t\t\t\n\n\n\n----++++";
+ let mut rng = fastrand::Rng::with_seed(42);
+ let real_simple = [
+ "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
+ "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
+ "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
+ "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
+ "KYC:00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
+ "Taler KYC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
+ ];
+ let real_splitted = [
+ "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
+ "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
+ "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
+ "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
+ "KYC: 00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
+ "Taler KIC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
+ ];
+ let randoms: Vec<String> = (0..30)
+ .map(|_| {
+ (0..256)
+ .map(|_| *rng.choice(CHARS).unwrap() as char)
+ .collect()
+ })
+ .collect();
+ let chunks: Vec<String> = [
+ "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
+ "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
+ "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
+ "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0",
+ ]
+ .iter()
+ .flat_map(|key| {
+ (1..key.len()).map(|chunk_size| {
+ key.as_bytes()
+ .chunks(chunk_size)
+ .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
+ .collect()
+ })
+ })
+ .collect();
+
+ fn bench<A: AsRef<str>>(
+ c: &mut Criterion,
+ name: &str,
+ cases: impl IntoIterator<Item = A> + Copy,
+ ) {
+ c.bench_function(name, |b| {
+ b.iter(|| {
+ for case in cases {
+ parse_incoming_unstructured(black_box(case.as_ref()));
+ }
+ })
+ });
+ }
+
+ bench(c, "real_simple", real_simple);
+ bench(c, "real_splitted", real_splitted);
+ bench(c, "rng", &randoms);
+ bench(c, "chunks", &chunks);
+}
+
+criterion_group!(benches, parser);
+criterion_main!(benches);
diff --git a/taler-api/src/lib.rs b/taler-api/src/lib.rs
@@ -55,6 +55,7 @@ mod constants;
pub mod db;
pub mod error;
pub mod notification;
+pub mod subject;
#[derive(Debug, Clone, Copy, Default)]
#[must_use]
diff --git a/taler-api/src/subject.rs b/taler-api/src/subject.rs
@@ -0,0 +1,371 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2024 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, ops::Deref, str::FromStr};
+
+use taler_common::{
+ api_common::{EddsaPublicKey, ShortHashCode},
+ types::base32::{Base32Error, CROCKFORD_ALPHABET},
+};
+use url::Url;
+
+use crate::db::IncomingType;
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum IncomingSubject {
+ Reserve(EddsaPublicKey),
+ Kyc(EddsaPublicKey),
+}
+
+impl IncomingSubject {
+ pub fn ty(&self) -> IncomingType {
+ match self {
+ IncomingSubject::Reserve(_) => IncomingType::reserve,
+ IncomingSubject::Kyc(_) => IncomingType::kyc,
+ }
+ }
+
+ pub fn key(&self) -> &[u8] {
+ match self {
+ IncomingSubject::Kyc(key) | IncomingSubject::Reserve(key) => key.deref(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct OutgoingSubject(pub ShortHashCode, pub Url);
+
+/** Base32 quality by proximity to spec and error probability */
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum Base32Quality {
+ /// Both mixed casing and mixed characters, thats weird
+ Mixed,
+ /// Standard but use lowercase, maybe the client shown lowercase in the UI
+ Standard,
+ /// Uppercase but mixed characters, its common when making typos
+ Upper,
+ /// Both uppercase and use the standard alphabet as it should
+ UpperStandard,
+}
+
+impl Base32Quality {
+ pub fn measure(s: &str) -> Self {
+ let mut uppercase = true;
+ let mut standard = true;
+ for b in s.bytes() {
+ uppercase &= b.is_ascii_uppercase();
+ standard &= CROCKFORD_ALPHABET.contains(&b)
+ }
+ match (uppercase, standard) {
+ (true, true) => Base32Quality::UpperStandard,
+ (true, false) => Base32Quality::Upper,
+ (false, true) => Base32Quality::Standard,
+ (false, false) => Base32Quality::Mixed,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct Candidate {
+ subject: IncomingSubject,
+ quality: Base32Quality,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum IncomingSubjectResult {
+ Success(IncomingSubject),
+ Ambiguous,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum OutgoingSubjectErr {
+ #[error("missing parts")]
+ MissingParts,
+ #[error("malformed wtid: {0}")]
+ Wtid(#[from] Base32Error<32>),
+ #[error("malformed exchange url: {0}")]
+ Url(#[from] url::ParseError),
+}
+
+/**
+ * Extract the wtid and exchange url from an outgoing transfer subject.
+ */
+pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> {
+ let (wtid, base_url) = subject
+ .split_once(" ")
+ .ok_or(OutgoingSubjectErr::MissingParts)?;
+ let wtid = wtid.parse()?;
+ let base_url = base_url.parse()?;
+ Ok(OutgoingSubject(wtid, base_url))
+}
+
+/**
+ * Extract the public key from an unstructured incoming transfer subject.
+ *
+ * When a user enters the transfer object in an unstructured way, for ex in
+ * their banking UI, they may mistakenly enter separators such as ' \n-+' and
+ * make typos.
+ * To parse them while ignoring user errors, we reconstruct valid keys from key
+ * parts, resolving ambiguities where possible.
+ **/
+pub fn parse_incoming_unstructured(subject: &str) -> Option<IncomingSubjectResult> {
+ // We expect subject to be less than 65KB
+ assert!(subject.len() <= u16::MAX as usize);
+ /** Parse an incoming subject */
+ fn parse_single(str: &str) -> Option<Candidate> {
+ // Check key type
+ let (is_kyc, raw) = if let Some(key) = str.strip_prefix("KYC:") {
+ (true, key)
+ } else {
+ (false, str)
+ };
+
+ // Check key validity
+ let key = EddsaPublicKey::from_str(raw).ok()?;
+ if ed25519_dalek::VerifyingKey::from_bytes(&key).is_err() {
+ return None;
+ }
+
+ let quality = Base32Quality::measure(raw);
+ Some(Candidate {
+ subject: if is_kyc {
+ IncomingSubject::Kyc(key)
+ } else {
+ IncomingSubject::Reserve(key)
+ },
+ quality,
+ })
+ }
+
+ // Find and concatenate valid parts of a keys
+ let (parts, concatenated) = {
+ let mut parts = Vec::new();
+ let mut concatenated = String::new();
+ let mut part = None;
+ for (i, b) in subject.as_bytes().iter().enumerate() {
+ if matches!(b, b'0'..=b':' | b'A'..=b'Z' | b'a'..=b'z') {
+ if part.is_none() {
+ part = Some(i);
+ }
+ } else if let Some(from) = part {
+ let start = concatenated.len() as u16;
+ concatenated.push_str(&subject[from..i]);
+ let end = concatenated.len() as u16;
+ parts.push(start..end);
+ part = None;
+ }
+ }
+ if let Some(from) = part {
+ let start = concatenated.len() as u16;
+ concatenated.push_str(&subject[from..]);
+ let end = concatenated.len() as u16;
+ parts.push(start..end);
+ }
+ (parts, concatenated)
+ };
+
+ // Find best candidates
+ let mut best: Option<Candidate> = None;
+ // For each part as a starting point
+ for (i, start) in parts.iter().enumerate() {
+ // Use progressively longer concatenation
+ for end in parts[i..].iter() {
+ let range = start.start..end.end;
+ // Until they are to long to be a key
+ if range.len() > 56 {
+ break;
+ }
+ // If the slice is the right size for a key (56B with prefix else 54B)
+ if range.len() == 52 || range.len() == 56 {
+ // Parse the concatenated parts
+ let slice =
+ unsafe { &concatenated.get_unchecked(start.start as usize..end.end as usize) };
+ if let Some(other) = parse_single(slice) {
+ // On success update best candidate
+ match &mut best {
+ Some(best) => {
+ if other.quality > best.quality // We prefer high quality keys
+ || matches!( // We prefer prefixed keys over reserve keys
+ (&best.subject.ty(), &other.subject.ty()),
+ (IncomingType::reserve, IncomingType::kyc | IncomingType::wad)
+ )
+ {
+ *best = other
+ } else if best.subject.key() != other.subject.key() // If keys are different
+ && best.quality == other.quality // Of same quality
+ && !matches!( // And prefixing is diferent
+ (&best.subject.ty(), &other.subject.ty()),
+ (IncomingType::kyc | IncomingType::wad, IncomingType::reserve)
+ )
+ {
+ return Some(IncomingSubjectResult::Ambiguous);
+ }
+ }
+ None => best = Some(other),
+ }
+ }
+ }
+ }
+ }
+
+ best.map(|it| IncomingSubjectResult::Success(it.subject))
+}
+
+#[test]
+/** Test parsing logic */
+fn parse() {
+ let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0";
+ let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG";
+
+ // Common checkes
+ for ty in [IncomingType::reserve, IncomingType::kyc] {
+ let prefix = match ty {
+ IncomingType::reserve => "",
+ IncomingType::kyc => "KYC:",
+ IncomingType::wad => unreachable!(),
+ };
+ let standard = &format!("{prefix}{key}");
+ let (upper_l, upper_r) = standard.split_at(standard.len() / 2);
+ let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0");
+ let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2);
+ let other_standard = &format!("{prefix}{other}");
+ let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60");
+
+ let result = Some(IncomingSubjectResult::Success(match ty {
+ IncomingType::reserve => {
+ IncomingSubject::Reserve(EddsaPublicKey::from_str(key).unwrap())
+ }
+ IncomingType::kyc => IncomingSubject::Kyc(EddsaPublicKey::from_str(key).unwrap()),
+ IncomingType::wad => unreachable!(),
+ }));
+
+ // Check succeed if upper or mixed
+ for case in [standard, mixed] {
+ for test in [
+ format!("noise {case} noise"),
+ format!("{case} noise to the right"),
+ format!("noise to the left {case}"),
+ format!(" {case} "),
+ format!("noise\n{case}\nnoise"),
+ format!("Test+{case}"),
+ ] {
+ assert_eq!(parse_incoming_unstructured(&test), result);
+ }
+ }
+
+ // Check succeed if upper or mixed and split
+ for (l, r) in [(upper_l, upper_r), (mixed_l, mixed_r)] {
+ for case in [
+ format!("left {l}{r} right"),
+ format!("left {l} {r} right"),
+ format!("left {l}-{r} right"),
+ format!("left {l}+{r} right"),
+ format!("left {l}\n{r} right"),
+ format!("left {l}-+\n{r} right"),
+ format!("left {l} - {r} right"),
+ format!("left {l} + {r} right"),
+ format!("left {l} \n {r} right"),
+ format!("left {l} - + \n {r} right"),
+ ] {
+ assert_eq!(parse_incoming_unstructured(&case), result);
+ }
+ }
+
+ // Check concat parts
+ for chunk_size in 1..standard.len() {
+ let chunked: String = standard
+ .as_bytes()
+ .chunks(chunk_size)
+ .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
+ .collect();
+ for case in [chunked.clone(), format!("left {chunked} right")] {
+ assert_eq!(parse_incoming_unstructured(&case), result);
+ }
+ }
+
+ // Check failed when multiple key
+ for case in [
+ format!("{standard} {other_standard}"),
+ format!("{mixed} {other_mixed}"),
+ ] {
+ assert_eq!(
+ parse_incoming_unstructured(&case),
+ Some(IncomingSubjectResult::Ambiguous)
+ );
+ }
+
+ // Check accept redundant key
+ for case in [
+ format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key
+ format!("{standard} {other_mixed}"), // Prefer high quality
+ ] {
+ assert_eq!(parse_incoming_unstructured(&case), result);
+ }
+
+ // Check prefer prefixed over simple
+ for case in [format!("{mixed_l}-{mixed_r} {upper_l}-{upper_r}")] {
+ let res = parse_incoming_unstructured(&case);
+ if ty == IncomingType::reserve {
+ assert_eq!(res, Some(IncomingSubjectResult::Ambiguous));
+ } else {
+ assert_eq!(res, result);
+ }
+ }
+
+ // Check failure if malformed or missing
+ for case in [
+ "does not contain any reserve", // Check fail if none
+ &standard[0..standard.len() - 1], // Check fail if missing char
+ "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key
+ ] {
+ assert_eq!(parse_incoming_unstructured(&case), None);
+ }
+
+ if ty == IncomingType::kyc {
+ // Prefer prefixed over unprefixed
+ for case in [format!("{other} {standard}"), format!("{other} {mixed}")] {
+ assert_eq!(parse_incoming_unstructured(&case), result);
+ }
+ }
+ }
+}
+
+#[test]
+/** Test parsing logic using real cases */
+fn real() {
+ // Good case
+ for (subject, key) in [
+ (
+ "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
+ "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
+ ),
+ (
+ "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
+ "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
+ ),
+ (
+ "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
+ "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
+ ),
+ ] {
+ assert_eq!(
+ Some(IncomingSubjectResult::Success(IncomingSubject::Reserve(
+ EddsaPublicKey::from_str(key).unwrap(),
+ ))),
+ parse_incoming_unstructured(subject)
+ )
+ }
+}
diff --git a/taler-api/tests/api.rs b/taler-api/tests/api.rs
@@ -249,9 +249,9 @@ async fn incoming_history(pool: PgPool) {
it.incoming_transactions
.into_iter()
.map(|it| match it {
- IncomingBankTransaction::IncomingReserveTransaction { row_id, .. }
- | IncomingBankTransaction::IncomingWadTransaction { row_id, .. }
- | IncomingBankTransaction::IncomingKycAuthTransaction { row_id, .. } => {
+ IncomingBankTransaction::Reserve { row_id, .. }
+ | IncomingBankTransaction::Wad { row_id, .. }
+ | IncomingBankTransaction::Kyc { row_id, .. } => {
*row_id as i64
}
})
diff --git a/taler-api/tests/common/db.rs b/taler-api/tests/common/db.rs
@@ -282,21 +282,21 @@ pub async fn incoming_page(
|r: PgRow| {
let kind: IncomingType = r.try_get("type")?;
Ok(match kind {
- IncomingType::reserve => IncomingBankTransaction::IncomingReserveTransaction {
+ IncomingType::reserve => IncomingBankTransaction::Reserve {
row_id: r.try_get_safeu64("incoming_transaction_id")?,
date: r.try_get_timestamp("creation_time")?,
amount: r.try_get_amount("amount", currency)?,
debit_account: r.try_get_payto("debit_payto")?,
reserve_pub: r.try_get_base32("reserve_pub")?,
},
- IncomingType::kyc => IncomingBankTransaction::IncomingKycAuthTransaction {
+ IncomingType::kyc => IncomingBankTransaction::Kyc {
row_id: r.try_get_safeu64("incoming_transaction_id")?,
date: r.try_get_timestamp("creation_time")?,
amount: r.try_get_amount("amount", currency)?,
debit_account: r.try_get_payto("debit_payto")?,
account_pub: r.try_get_base32("account_pub")?,
},
- IncomingType::wad => IncomingBankTransaction::IncomingWadTransaction {
+ IncomingType::wad => IncomingBankTransaction::Wad {
row_id: r.try_get_safeu64("incoming_transaction_id")?,
date: r.try_get_timestamp("creation_time")?,
amount: r.try_get_amount("amount", currency)?,
diff --git a/taler-common/Cargo.toml b/taler-common/Cargo.toml
@@ -6,22 +6,17 @@ edition = "2021"
[dependencies]
serde_with = "3.11.0"
rand = "0.8"
-fastrand = "2.2.0"
serde_urlencoded = "0.7"
jiff = { version = "0.1", default-features = false, features = ["std"] }
-ed25519-dalek = { version = "2.1.1", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
url.workspace = true
thiserror.workspace = true
+fastrand.workspace = true
sqlx = { workspace = true, features = ["macros"] }
[dev-dependencies]
-criterion = { version = "0.5" }
-
-[[bench]]
-name = "subject"
-harness = false
+criterion.workspace = true
[[bench]]
name = "base32"
diff --git a/taler-common/benches/subject.rs b/taler-common/benches/subject.rs
@@ -1,84 +0,0 @@
-/*
- This file is part of TALER
- Copyright (C) 2024 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 criterion::{black_box, criterion_group, criterion_main, Criterion};
-use taler_common::subject::parse_incoming_unstructured;
-
-fn parser(c: &mut Criterion) {
- const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789::: \t\t\t\t\n\n\n\n----++++";
- let mut rng = fastrand::Rng::with_seed(42);
- let real_simple = [
- "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
- "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
- "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
- "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
- "KYC:00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
- "Taler KYC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
- ];
- let real_splitted = [
- "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
- "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
- "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
- "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
- "KYC: 00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
- "Taler KIC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
- ];
- let randoms: Vec<String> = (0..30)
- .map(|_| {
- (0..256)
- .map(|_| *rng.choice(CHARS).unwrap() as char)
- .collect()
- })
- .collect();
- let chunks: Vec<String> = [
- "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
- "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
- "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
- "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0",
- ]
- .iter()
- .flat_map(|key| {
- (1..key.len()).map(|chunk_size| {
- key.as_bytes()
- .chunks(chunk_size)
- .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
- .collect()
- })
- })
- .collect();
-
- fn bench<A: AsRef<str>>(
- c: &mut Criterion,
- name: &str,
- cases: impl IntoIterator<Item = A> + Copy,
- ) {
- c.bench_function(name, |b| {
- b.iter(|| {
- for case in cases {
- parse_incoming_unstructured(black_box(case.as_ref()));
- }
- })
- });
- }
-
- bench(c, "real_simple", real_simple);
- bench(c, "real_splitted", real_splitted);
- bench(c, "rng", &randoms);
- bench(c, "chunks", &chunks);
-}
-
-criterion_group!(benches, parser);
-criterion_main!(benches);
diff --git a/taler-common/src/api_params.rs b/taler-common/src/api_params.rs
@@ -68,6 +68,15 @@ pub struct Page {
pub offset: Option<i64>,
}
+impl Default for Page {
+ fn default() -> Self {
+ Self {
+ limit: 20,
+ offset: None,
+ }
+ }
+}
+
impl Page {
pub fn backward(&self) -> bool {
self.limit < 0
@@ -93,7 +102,7 @@ impl HistoryParams {
}
}
-#[derive(Debug)]
+#[derive(Debug, Default)]
pub struct History {
pub page: Page,
pub timeout_ms: Option<u64>,
diff --git a/taler-common/src/api_wire.rs b/taler-common/src/api_wire.rs
@@ -85,7 +85,7 @@ pub struct OutgoingHistory {
pub debit_account: Payto,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingBankTransaction>
pub struct OutgoingBankTransaction {
pub row_id: SafeU64,
@@ -103,12 +103,12 @@ pub struct IncomingHistory {
pub incoming_transactions: Vec<IncomingBankTransaction>,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
/// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingBankTransaction>
pub enum IncomingBankTransaction {
#[serde(rename = "RESERVE")]
- IncomingReserveTransaction {
+ Reserve {
row_id: SafeU64,
date: Timestamp,
amount: Amount,
@@ -116,7 +116,7 @@ pub enum IncomingBankTransaction {
reserve_pub: EddsaPublicKey,
},
#[serde(rename = "WAD")]
- IncomingWadTransaction {
+ Wad {
row_id: SafeU64,
date: Timestamp,
amount: Amount,
@@ -125,7 +125,7 @@ pub enum IncomingBankTransaction {
wad_id: WadId,
},
#[serde(rename = "KYCAUTH")]
- IncomingKycAuthTransaction {
+ Kyc {
row_id: SafeU64,
date: Timestamp,
amount: Amount,
diff --git a/taler-common/src/lib.rs b/taler-common/src/lib.rs
@@ -18,7 +18,6 @@ pub mod api_common;
pub mod api_params;
pub mod api_wire;
pub mod error_code;
-pub mod subject;
pub mod types;
pub mod config {
// TODO
diff --git a/taler-common/src/subject.rs b/taler-common/src/subject.rs
@@ -1,318 +0,0 @@
-/*
- This file is part of TALER
- Copyright (C) 2024 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, str::FromStr};
-
-use crate::{api_common::EddsaPublicKey, types::base32::CROCKFORD_ALPHABET};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum IncomingType {
- Reserve,
- Kyc,
- Wad,
-}
-
-#[derive(Debug, PartialEq, Eq)]
-pub struct IncomingSubject(IncomingType, EddsaPublicKey);
-
-/** Base32 quality by proximity to spec and error probability */
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-enum Base32Quality {
- /// Both mixed casing and mixed characters, thats weird
- Mixed,
- /// Standard but use lowercase, maybe the client shown lowercase in the UI
- Standard,
- /// Uppercase but mixed characters, its common when making typos
- Upper,
- /// Both uppercase and use the standard alphabet as it should
- UpperStandard,
-}
-
-impl Base32Quality {
- pub fn measure(s: &str) -> Self {
- let mut uppercase = true;
- let mut standard = true;
- for b in s.bytes() {
- uppercase &= b.is_ascii_uppercase();
- standard &= CROCKFORD_ALPHABET.contains(&b)
- }
- match (uppercase, standard) {
- (true, true) => Base32Quality::UpperStandard,
- (true, false) => Base32Quality::Upper,
- (false, true) => Base32Quality::Standard,
- (false, false) => Base32Quality::Mixed,
- }
- }
-}
-
-#[derive(Debug)]
-pub struct Candidate {
- subject: IncomingSubject,
- quality: Base32Quality,
-}
-
-#[derive(Debug, PartialEq, Eq)]
-pub enum IncomingSubjectResult {
- Success(IncomingSubject),
- Ambiguous,
-}
-
-/**
- * Extract the public key from an unstructured incoming transfer subject.
- *
- * When a user enters the transfer object in an unstructured way, for ex in
- * their banking UI, they may mistakenly enter separators such as ' \n-+' and
- * make typos.
- * To parse them while ignoring user errors, we reconstruct valid keys from key
- * parts, resolving ambiguities where possible.
- **/
-pub fn parse_incoming_unstructured(subject: &str) -> Option<IncomingSubjectResult> {
- // We expect subject to be less than 65KB
- assert!(subject.len() <= u16::MAX as usize);
- /** Parse an incoming subject */
- fn parse_single(str: &str) -> Option<Candidate> {
- // Check key type
- let (kind, raw) = if let Some(key) = str.strip_prefix("KYC:") {
- (IncomingType::Kyc, key)
- } else {
- (IncomingType::Reserve, str)
- };
-
- // Check key validity
- let key = EddsaPublicKey::from_str(raw).ok()?;
- if ed25519_dalek::VerifyingKey::from_bytes(&key).is_err() {
- return None;
- }
-
- let quality = Base32Quality::measure(raw);
- Some(Candidate {
- subject: IncomingSubject(kind, key),
- quality,
- })
- }
-
- // Find and concatenate valid parts of a keys
- let (parts, concatenated) = {
- let mut parts = Vec::new();
- let mut concatenated = String::new();
- let mut part = None;
- for (i, b) in subject.as_bytes().iter().enumerate() {
- if matches!(b, b'0'..=b':' | b'A'..=b'Z' | b'a'..=b'z') {
- if part.is_none() {
- part = Some(i);
- }
- } else if let Some(from) = part {
- let start = concatenated.len() as u16;
- concatenated.push_str(&subject[from..i]);
- let end = concatenated.len() as u16;
- parts.push(start..end);
- part = None;
- }
- }
- if let Some(from) = part {
- let start = concatenated.len() as u16;
- concatenated.push_str(&subject[from..]);
- let end = concatenated.len() as u16;
- parts.push(start..end);
- }
- (parts, concatenated)
- };
-
- // Find best candidates
- let mut best: Option<Candidate> = None;
- // For each part as a starting point
- for (i, start) in parts.iter().enumerate() {
- // Use progressively longer concatenation
- for end in parts[i..].iter() {
- let range = start.start..end.end;
- // Until they are to long to be a key
- if range.len() > 56 {
- break;
- }
- // If the slice is the right size for a key (56B with prefix else 54B)
- if range.len() == 52 || range.len() == 56 {
- // Parse the concatenated parts
- let slice =
- unsafe { &concatenated.get_unchecked(start.start as usize..end.end as usize) };
- if let Some(other) = parse_single(slice) {
- // On success update best candidate
- match &mut best {
- Some(best) => {
- if other.quality > best.quality // We prefer high quality keys
- || matches!( // We prefer prefixed keys over reserve keys
- (&best.subject.0, &other.subject.0),
- (IncomingType::Reserve, IncomingType::Kyc | IncomingType::Wad)
- )
- {
- *best = other
- } else if best.subject.1 != other.subject.1 // If keys are different
- && best.quality == other.quality // Of same quality
- && !matches!( // And prefixing is diferent
- (&best.subject.0, &other.subject.0),
- (IncomingType::Kyc | IncomingType::Wad, IncomingType::Reserve)
- )
- {
- return Some(IncomingSubjectResult::Ambiguous);
- }
- }
- None => best = Some(other),
- }
- }
- }
- }
- }
-
- best.map(|it| IncomingSubjectResult::Success(it.subject))
-}
-
-#[test]
-/** Test parsing logic */
-fn parse() {
- let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0";
- let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG";
-
- // Common checkes
- for (ty, prefix) in [(IncomingType::Reserve, ""), (IncomingType::Kyc, "KYC:")] {
- let standard = &format!("{prefix}{key}");
- let (upper_l, upper_r) = standard.split_at(standard.len() / 2);
- let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0");
- let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2);
- let other_standard = &format!("{prefix}{other}");
- let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60");
-
- let result = Some(IncomingSubjectResult::Success(IncomingSubject(
- ty,
- EddsaPublicKey::from_str(key).unwrap(),
- )));
-
- // Check succeed if upper or mixed
- for case in [standard, mixed] {
- for test in [
- format!("noise {case} noise"),
- format!("{case} noise to the right"),
- format!("noise to the left {case}"),
- format!(" {case} "),
- format!("noise\n{case}\nnoise"),
- format!("Test+{case}"),
- ] {
- assert_eq!(parse_incoming_unstructured(&test), result);
- }
- }
-
- // Check succeed if upper or mixed and split
- for (l, r) in [(upper_l, upper_r), (mixed_l, mixed_r)] {
- for case in [
- format!("left {l}{r} right"),
- format!("left {l} {r} right"),
- format!("left {l}-{r} right"),
- format!("left {l}+{r} right"),
- format!("left {l}\n{r} right"),
- format!("left {l}-+\n{r} right"),
- format!("left {l} - {r} right"),
- format!("left {l} + {r} right"),
- format!("left {l} \n {r} right"),
- format!("left {l} - + \n {r} right"),
- ] {
- assert_eq!(parse_incoming_unstructured(&case), result);
- }
- }
-
- // Check concat parts
- for chunk_size in 1..standard.len() {
- let chunked: String = standard
- .as_bytes()
- .chunks(chunk_size)
- .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
- .collect();
- for case in [chunked.clone(), format!("left {chunked} right")] {
- assert_eq!(parse_incoming_unstructured(&case), result);
- }
- }
-
- // Check failed when multiple key
- for case in [
- format!("{standard} {other_standard}"),
- format!("{mixed} {other_mixed}"),
- ] {
- assert_eq!(
- parse_incoming_unstructured(&case),
- Some(IncomingSubjectResult::Ambiguous)
- );
- }
-
- // Check accept redundant key
- for case in [
- format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key
- format!("{standard} {other_mixed}"), // Prefer high quality
- ] {
- assert_eq!(parse_incoming_unstructured(&case), result);
- }
-
- // Check prefer prefixed over simple
- for case in [format!("{mixed_l}-{mixed_r} {upper_l}-{upper_r}")] {
- let res = parse_incoming_unstructured(&case);
- if ty == IncomingType::Reserve {
- assert_eq!(res, Some(IncomingSubjectResult::Ambiguous));
- } else {
- assert_eq!(res, result);
- }
- }
-
- // Check failure if malformed or missing
- for case in [
- "does not contain any reserve", // Check fail if none
- &standard[0..standard.len() - 1], // Check fail if missing char
- "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key
- ] {
- assert_eq!(parse_incoming_unstructured(&case), None);
- }
-
- if ty == IncomingType::Kyc {
- // Prefer prefixed over unprefixed
- for case in [format!("{other} {standard}"), format!("{other} {mixed}")] {
- assert_eq!(parse_incoming_unstructured(&case), result);
- }
- }
- }
-}
-
-#[test]
-/** Test parsing logic using real cases */
-fn real() {
- // Good case
- for (subject, key) in [
- (
- "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
- "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
- ),
- (
- "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
- "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
- ),
- (
- "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
- "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
- ),
- ] {
- assert_eq!(
- Some(IncomingSubjectResult::Success(IncomingSubject(
- IncomingType::Reserve,
- EddsaPublicKey::from_str(key).unwrap(),
- ))),
- parse_incoming_unstructured(subject)
- )
- }
-}