commit 5bd14489396c44904c155fc431b5bdc992720489
parent ef883e8324477468eefd42936e2513452cd96838
Author: Antoine A <>
Date: Wed, 18 Mar 2026 13:52:55 +0100
common: make payto parsing more lenient
Diffstat:
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