libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 5bd14489396c44904c155fc431b5bdc992720489
parent ef883e8324477468eefd42936e2513452cd96838
Author: Antoine A <>
Date:   Wed, 18 Mar 2026 13:52:55 +0100

common: make payto parsing more lenient

Diffstat:
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 6+++---
Mlibeufin-common/src/main/kotlin/TalerCommon.kt | 63++++++++++++++++++++++++++++++++-------------------------------
Mlibeufin-common/src/test/kotlin/PaytoTest.kt | 5++++-
3 files changed, 39 insertions(+), 35 deletions(-)

diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -78,9 +78,9 @@ suspend fun ApplicationCall.bankInfo(db: Database): BankInfo { */ fun BankConfig.talerWithdrawUri(id: UUID): String { val base = this.baseUrl.url - val protocol = if (base.protocol == "http") "taler+http" else "taler" + val protocol = if (base.protocol.name == "http") "taler+http" else "taler" val port = if (base.port != -1) ":${base.port}" else "" - return "${protocol}://withdraw/${base.host}${port}${base.path}taler-integration/${id}" + return "${protocol}://withdraw/${base.host}${port}${base.encodedPath}taler-integration/${id}" } fun BankConfig.withdrawConfirmUrl(id: UUID): String { diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -25,8 +25,6 @@ import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import kotlinx.serialization.json.* -import java.net.URI -import java.net.URL import java.time.Instant import java.time.Duration import java.time.temporal.ChronoUnit @@ -146,22 +144,24 @@ value class TalerTimestamp constructor(val instant: Instant) { @JvmInline @Serializable(with = BaseURL.Serializer::class) -value class BaseURL private constructor(val url: URL) { +value class BaseURL private constructor(val url: Url) { companion object { fun parse(raw: String): BaseURL { - val url = URI(raw).toURL() - if (url.protocol !in setOf("http", "https")) { - throw badRequest("only 'http' and 'https' are accepted for baseURL got '${url.protocol}'") - } else if (url.host.isNullOrBlank()) { + val url = URLBuilder(raw) + if (url.protocolOrNull == null) { + throw badRequest("missing protocol in baseURL got '${url}'") + } else if (url.protocol.name !in setOf("http", "https")) { + throw badRequest("only 'http' and 'https' are accepted for baseURL got '${url.protocol.name}'") + } else if (url.host.isEmpty()) { throw badRequest("missing host in baseURL got '${url}'") - } else if (url.query != null) { - throw badRequest("require no query in baseURL got '${url.query}'") - } else if (url.ref != null) { - throw badRequest("require no fragments in baseURL got '${url.ref}'") - } else if (!url.path.endsWith('/')) { - throw badRequest("baseURL path must end with / got '${url.path}'") + } else if (!url.parameters.isEmpty()) { + throw badRequest("require no query in baseURL got '${url.encodedParameters}'") + } else if (url.fragment.isNotEmpty()) { + throw badRequest("require no fragments in baseURL got '${url.fragment}'") + } else if (!url.encodedPath.endsWith('/')) { + throw badRequest("baseURL path must end with / got '${url.encodedPath}'") } - return BaseURL(url) + return BaseURL(url.build()) } } @@ -364,7 +364,7 @@ class TalerAmount : Comparable<TalerAmount> { @Serializable(with = Payto.Serializer::class) sealed class Payto { - abstract val parsed: URI + abstract val parsed: Url abstract val canonical: String abstract val amount: TalerAmount? abstract val message: String? @@ -421,25 +421,26 @@ sealed class Payto { } companion object { - fun parse(raw: String): Payto { + private val HEX_PATTERN: Regex = Regex("%(?![0-9a-fA-F]{2})") + fun parse(input: String): Payto { + val raw = input.replace(HEX_PATTERN, "%25") val parsed = try { - URI(raw) + Url(raw) } catch (e: Exception) { throw CommonError.Payto("expected a valid URI") } - if (parsed.scheme != "payto") throw CommonError.Payto("expect a payto URI got '${parsed.scheme}'") + if (parsed.protocol.name != "payto") throw CommonError.Payto("expect a payto URI got '${parsed.protocol.name}'") - val params = parseQueryString(parsed.query ?: "") - val amount = params["amount"]?.run { TalerAmount(this) } - val message = params["message"] - val receiverName = params["receiver-name"] + val amount = parsed.parameters["amount"]?.run { TalerAmount(this) } + val message = parsed.parameters["message"] + val receiverName = parsed.parameters["receiver-name"] return when (parsed.host) { "iban" -> { - val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() } - val (bic, rawIban) = when (splitPath.size) { - 1 -> Pair(null, splitPath[0]) - 2 -> Pair(splitPath[0], splitPath[1]) + val segments = parsed.segments + val (bic, rawIban) = when (segments.size) { + 1 -> Pair(null, segments[0]) + 2 -> Pair(segments[0], segments[1]) else -> throw CommonError.Payto("too many path segments for an IBAN payto URI") } val iban = IBAN.parse(rawIban) @@ -455,10 +456,10 @@ sealed class Payto { } "x-taler-bank" -> { - val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() } - if (splitPath.size != 2) + val segments = parsed.segments + if (segments.size != 2) throw CommonError.Payto("bad number of path segments for a x-taler-bank payto URI") - val username = splitPath[1] + val username = segments[1] XTalerBankPayto( parsed, "payto://x-taler-bank/localhost/$username", @@ -477,7 +478,7 @@ sealed class Payto { @Serializable(with = IbanPayto.Serializer::class) class IbanPayto internal constructor( - override val parsed: URI, + override val parsed: Url, override val canonical: String, override val amount: TalerAmount?, override val message: String?, @@ -541,7 +542,7 @@ class IbanPayto internal constructor( } class XTalerBankPayto internal constructor( - override val parsed: URI, + override val parsed: Url, override val canonical: String, override val amount: TalerAmount?, override val message: String?, diff --git a/libeufin-common/src/test/kotlin/PaytoTest.kt b/libeufin-common/src/test/kotlin/PaytoTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -51,6 +51,9 @@ class PaytoTest { assertNull(withoutOptionals.message) assertNull(withoutOptionals.receiverName) assertNull(withoutOptionals.amount) + val malformed = Payto.parse("payto://iban/CH0400766000103138557?receiver-name=NYM%20Technologies%SA").expectIban() + assertEquals(malformed.iban.value, "CH0400766000103138557") + assertEquals(malformed.receiverName, "NYM Technologies%SA") } @Test