libeufin

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

commit 5a3b66c27505e5938deb9a1b2f523861b771605c
parent e45345f475c905f56bb2448765f91d6e701cd608
Author: Antoine A <>
Date:   Tue, 29 Sep 2026 22:25:27 +0200

common: sync with latest build logic of taler-rust

Diffstat:
Dbuild-system/archive.py | 169-------------------------------------------------------------------------------
Abuild-system/configure.py | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbuild-system/configure.sh | 67-------------------------------------------------------------------
Dbuild-system/version.gradle | 42------------------------------------------
Dcontrib/ci/jobs/4-deb/version.sh | 6------
Mlibeufin-ebics/src/ws.rs | 6+++---
Mlibeufin-nexus/src/db/initiated.rs | 18+++++++++---------
Mlibeufin-nexus/src/db/payment.rs | 35++++++++++++++++++-----------------
Mtestbench/src/integration.rs | 14++++++++++----
9 files changed, 157 insertions(+), 317 deletions(-)

diff --git a/build-system/archive.py b/build-system/archive.py @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 - -# This file has been placed in the public domain. - -"""Create a source archive containing this repository and its submodules.""" - -from __future__ import annotations - -import argparse -import io -import os -import subprocess -import sys -import tarfile -from pathlib import Path - - -def run_git(repo: Path, *args: str, input_data: bytes | None = None) -> bytes: - try: - return subprocess.run( - ["git", *args], - cwd=repo, - input=input_data, - check=True, - stdout=subprocess.PIPE, - ).stdout - except subprocess.CalledProcessError as exc: - command = " ".join(("git", *args)) - raise RuntimeError(f"'{command}' failed in {repo}") from exc - - -def tracked_paths(repo: Path) -> list[Path]: - output = run_git(repo, "ls-files", "-z", "--cached") - return [ - Path(os.fsdecode(item)) for item in output.rstrip(b"\0").split(b"\0") if item - ] - - -def submodule_paths(repo: Path) -> set[Path]: - gitmodules = repo / ".gitmodules" - if not gitmodules.exists(): - return set() - result = subprocess.run( - [ - "git", - "config", - "-f", - ".gitmodules", - "--get-regexp", - r"^submodule\..*\.path$", - ], - cwd=repo, - check=False, - stdout=subprocess.PIPE, - text=True, - ) - if result.returncode not in (0, 1): - raise RuntimeError(f"could not read submodules from {gitmodules}") - return {Path(line.split(maxsplit=1)[1]) for line in result.stdout.splitlines()} - - -def export_ignored(repo: Path, paths: list[Path]) -> set[Path]: - if not paths: - return set() - query = b"\0".join(os.fsencode(path) for path in paths) + b"\0" - output = run_git( - repo, "check-attr", "-z", "--stdin", "export-ignore", input_data=query - ) - fields = output.rstrip(b"\0").split(b"\0") - ignored: set[Path] = set() - for index in range(0, len(fields), 3): - if fields[index + 2] == b"set": - ignored.add(Path(os.fsdecode(fields[index]))) - return ignored - - -def repository_entries(repo: Path) -> list[tuple[Path, Path]]: - """Return (filesystem path, archive-relative path) pairs for one repository.""" - submodules = submodule_paths(repo) - candidates: list[tuple[Path, Path]] = [] - - for relative in tracked_paths(repo): - if relative not in submodules: - candidates.append((repo / relative, relative)) - - for submodule in sorted(submodules): - submodule_dir = repo / submodule - if not (submodule_dir / ".git").exists(): - raise RuntimeError( - f"submodule '{submodule}' is not initialized; run './bootstrap' first" - ) - for source, relative in repository_entries(submodule_dir): - candidates.append((source, submodule / relative)) - - ignored = export_ignored(repo, [relative for _, relative in candidates]) - return [ - (source, relative) for source, relative in candidates if relative not in ignored - ] - - -def archive_prefix(output: Path) -> Path: - name = output.name - for suffix in (".tar.gz", ".tgz"): - if name.endswith(suffix): - prefix = name[: -len(suffix)] - if prefix: - return Path(prefix) - break - raise ValueError("output file must end in .tar.gz or .tgz") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--include", - action="append", - default=[], - metavar="FILE", - help="include an untracked or export-ignored file (may be repeated)", - ) - parser.add_argument("output", type=Path, help="output .tar.gz file") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - root = Path(run_git(Path.cwd(), "rev-parse", "--show-toplevel").decode().strip()) - output = args.output.resolve() - prefix = archive_prefix(output) - entries = repository_entries(root) - version = run_git(root, "describe", "--tags", "--always", "--abbrev=8").strip() - if not version or any(character in version for character in (b"\n", b"\r", b"\0")): - raise ValueError("program version must be a nonempty single line") - version += b"\n" - - included: list[tuple[Path, Path]] = [] - for name in args.include: - relative = Path(name) - if relative.is_absolute() or ".." in relative.parts: - raise ValueError( - f"included path must be relative to the repository: {name}" - ) - source = (root / relative).absolute() - if not source.is_file(): - raise FileNotFoundError(f"included file does not exist: {name}") - included.append((source, relative)) - - with tarfile.open(output, "w:gz") as archive: - stamp = tarfile.TarInfo(str(prefix / ".version")) - stamp.size = len(version) - stamp.mode = 0o644 - stamp.mtime = int(run_git(root, "log", "-1", "--format=%ct")) - archive.addfile(stamp, io.BytesIO(version)) - for source, relative in [*included, *entries]: - # A stale stamp in the checkout must not override the Git version. - if relative == Path(".version"): - continue - archive.add(source, arcname=prefix / relative, recursive=False) - - print(f"created {output}") - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except (OSError, RuntimeError, ValueError) as exc: - print(f"archive: {exc}", file=sys.stderr) - sys.exit(1) diff --git a/build-system/configure.py b/build-system/configure.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +"""Configure the taler-rust installation prefix and Rust toolchain.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +# Oldest Rust release that can build this tree. Keep the reason for the number +# next to it -- the point of the check is to report an unusable toolchain here, +# once, instead of as a wall of parse errors in the middle of the build. +# +# 1.85 edition 2024 / resolver "3" (Cargo.toml) +# 1.86 Vec::pop_if (common/failure-injection) +# 1.88 let chains (used throughout) +# 1.93 std::fmt::from_fn (taler-common, taler-api, taler-wise) +# +# Debian trixie ships 1.85, which is too old; trixie-backports and testing both +# ship a new enough rustc. Keep this in step with `rust-version` in Cargo.toml. +RUST_MIN_VERSION = "1.93" +BUILD_VARIABLE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*", re.DOTALL) + + +def version_tuple(version: str) -> tuple[int, int, int]: + parts = [int(part) for part in version.split(".")[:3]] + return tuple(parts + [0] * (3 - len(parts))) + + +def tool_version(path: str) -> str | None: + try: + output = subprocess.run( + [path, "--version"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ).stdout + except (OSError, subprocess.SubprocessError): + return None + found = re.search(r"\d+(?:\.\d+)+", output) + return found.group(0) if found else None + + +def find_tool(name: str) -> tuple[str, str | None]: + requested = os.environ.get(name.upper()) or name + path = shutil.which(requested) + if path is None: + print(f"configure: error: tool '{name}' not available", file=sys.stderr) + print( + f"configure: hint: install '{name}' or point {name.upper()}= at it", + file=sys.stderr, + ) + raise SystemExit(1) + return path, tool_version(path) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + usage="./configure [--prefix=DIR] [VARIABLE=VALUE]...", + description="Configure the installation prefix and Rust toolchain.", + ) + parser.add_argument("--prefix", default="/usr/local", help="installation prefix") + parser.add_argument("build_variables", nargs="*", metavar="VARIABLE=VALUE") + args = parser.parse_args() + if not args.prefix: + parser.error("installation prefix must not be empty") + for argument in args.build_variables: + if not BUILD_VARIABLE.fullmatch(argument): + parser.error(f"unrecognized argument: {argument}") + variable = argument.partition("=")[0] + print( + f"configure: WARNING: unsupported variable '{variable}' is ignored", + file=sys.stderr, + ) + return args + + +def main() -> int: + args = parse_args() + cargo_path, cargo_version = find_tool("cargo") + rustc_path, rustc_version = find_tool("rustc") + + for name, path, version in ( + ("cargo", cargo_path, cargo_version), + ("rustc", rustc_path, rustc_version), + ): + suffix = f" (version {version})" if version else "" + print(f"found {name} as {path}{suffix}") + + if rustc_version and version_tuple(rustc_version) < version_tuple(RUST_MIN_VERSION): + print( + f"configure: WARNING: rustc {rustc_version} is older than" + f" {RUST_MIN_VERSION}; this tree will not compile with it." + " On Debian stable: apt install -t trixie-backports rustc cargo", + file=sys.stderr, + ) + + config = ( + "# This makefile fragment is generated by configure.\n" + f"prefix = {args.prefix}\n" + f"cargo = {cargo_path}\n" + f"rustc = {rustc_path}\n" + ) + Path(".config.mk").write_text(config, encoding="utf-8") + print("writing .config.mk") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build-system/configure.sh b/build-system/configure.sh @@ -1,67 +0,0 @@ -#!/bin/sh - -# This file has been placed in the public domain. - -set -eu - -prefix=/usr/local - -usage() { - cat <<EOF -Usage: ./configure [--prefix=DIR] [VARIABLE=VALUE]... - -Configure the installation prefix (default: /usr/local). -Unsupported build variables are ignored with a warning. -EOF -} - -while test $# -gt 0; do - case $1 in - --prefix=*) - prefix=${1#*=} - ;; - --prefix) - if test $# -lt 2; then - echo "configure: option '--prefix' requires an argument" >&2 - exit 2 - fi - prefix=$2 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *=*) - variable=${1%%=*} - case $variable in - ""|[!A-Za-z_]*|*[!A-Za-z0-9_]*) - echo "configure: unrecognized option '$1'" >&2 - echo "Try './configure --help' for more information." >&2 - exit 2 - ;; - *) - echo "configure: WARNING: unsupported variable '$variable' is ignored" >&2 - ;; - esac - ;; - *) - echo "configure: unrecognized option '$1'" >&2 - echo "Try './configure --help' for more information." >&2 - exit 2 - ;; - esac - shift -done - -if test -z "$prefix"; then - echo "configure: installation prefix must not be empty" >&2 - exit 2 -fi - -cat >.config.mk <<EOF -# This makefile fragment is generated by configure. -prefix = $prefix -EOF - -echo "configured installation prefix: $prefix" diff --git a/build-system/version.gradle b/build-system/version.gradle @@ -1,42 +0,0 @@ -// This file is in the public domain. - -// Applied by the root project, independently of the Kotlin build plugins. -def git = { List<String> arguments -> - def result = providers.exec { - workingDir rootDir - commandLine(['git'] + arguments) - environment = System.getenv().findAll { name, value -> - !(name in ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']) - } - ignoreExitValue = true - } - if (result.result.get().exitValue != 0) { - throw new GradleException("git ${arguments.join(' ')}: ${result.standardError.asText.get().trim()}") - } - result.standardOutput.asText.get().trim() -} - -String resolvedVersion -try { - // Do not describe an enclosing repository when building a source archive. - if (!new File(rootDir, '.git').exists()) { - throw new GradleException('source tree has no Git metadata') - } - if (new File(git(['rev-parse', '--show-toplevel'])).canonicalFile != rootDir.canonicalFile) { - throw new GradleException('Git metadata belongs to another source tree') - } - resolvedVersion = git(['describe', '--tags', '--always', '--abbrev=8']) -} catch (Exception gitError) { - def stamp = new File(rootDir, '.version') - if (!stamp.isFile()) { - throw new GradleException( - "Cannot determine program version: ${gitError.message}. Source archives must contain ${stamp}", - gitError - ) - } - resolvedVersion = stamp.getText('UTF-8').trim() -} -if (!resolvedVersion || resolvedVersion.contains('\n') || resolvedVersion.contains('\r') || resolvedVersion.contains('\u0000')) { - throw new GradleException('Program version must be a nonempty single line') -} -version = resolvedVersion diff --git a/contrib/ci/jobs/4-deb/version.sh b/contrib/ci/jobs/4-deb/version.sh @@ -1,6 +0,0 @@ -#!/bin/sh -# This file is in the public domain. -# Compatibility entry point, also usable in generated job directories. -set -eu -repo_dir=$(git rev-parse --show-toplevel) -exec "$repo_dir/contrib/ci/version.sh" "$@" diff --git a/libeufin-ebics/src/ws.rs b/libeufin-ebics/src/ws.rs @@ -258,7 +258,7 @@ pub async fn listen_for_notification( #[cfg(test)] mod test { - use std::{assert_matches, fmt::Debug, fs::Permissions, os::unix::fs::PermissionsExt as _}; + use std::{fmt::Debug, fs::Permissions, os::unix::fs::PermissionsExt as _}; use axum::{ extract::{ @@ -433,9 +433,9 @@ mod test { // Check message number and type assert!(count <= 3); if count == 3 { - assert_matches!(msg, WssNotification::GeneralInfo { .. }) + assert!(matches!(msg, WssNotification::GeneralInfo { .. })) } else { - assert_matches!(msg, WssNotification::NewData { .. }) + assert!(matches!(msg, WssNotification::NewData { .. })) } }) .await diff --git a/libeufin-nexus/src/db/initiated.rs b/libeufin-nexus/src/db/initiated.rs @@ -465,7 +465,7 @@ pub async fn tx_status_update( #[cfg(test)] mod test { - use std::{assert_matches, str::FromStr as _}; + use std::str::FromStr as _; use jiff::{Span, Timestamp, civil::Date}; use libeufin_ebics::{ebics::rand_ebics_id, iso20022::model::Tx}; @@ -621,10 +621,10 @@ mod test { .unwrap(); // Create a test batch with three transactions for id in ["TX", "TX_SETTLED"] { - assert_matches!( + assert!(matches!( gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) - ); + )); } batch_initiated(db, &Timestamp::now(), "BATCH", false) .await @@ -632,19 +632,19 @@ mod test { // Create witness transactions and batch for id in ["WITNESS_1", "WITNESS_2"] { - assert_matches!( + assert!(matches!( gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) - ); + )); } batch_initiated(db, &Timestamp::now(), "BATCH_WITNESS", false) .await .unwrap(); for id in ["WITNESS_3", "WITNESS_4"] { - assert_matches!( + assert!(matches!( gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) - ); + )); } // Check everything is unsubmitted sqlx::query( @@ -859,10 +859,10 @@ mod test { let (_, db) = db_setup().await; let now = Timestamp::now(); for i in 0..6 { - assert_matches!( + assert!(matches!( gen_initiate(&db, format!("PAY{i}"), "").await, PaymentInitiationResult::Success(_) - ); + )); batch_initiated(&db, &now, &rand_ebics_id(), false) .await .unwrap(); diff --git a/libeufin-nexus/src/db/payment.rs b/libeufin-nexus/src/db/payment.rs @@ -307,8 +307,6 @@ pub async fn register_in_malformed( #[cfg(test)] mod test { - use std::assert_matches; - use jiff::Timestamp; use libeufin_ebics::{ ebics::rand_ebics_id, @@ -348,10 +346,10 @@ mod test { format!("{} https://exchange.com/", ShortHashCode::rand()), ] { let payment = gen_out_pay(subject.clone()); - assert_matches!( + assert!(matches!( gen_initiate(&db, payment.id.e2e_id.clone().unwrap(), subject).await, PaymentInitiationResult::Success(_) - ); + )); let first = register_outgoing(&db, &payment).await.unwrap(); assert_eq!( first, @@ -462,10 +460,10 @@ mod test { format!("{wtid} https://exchange.com/"), format!("{wtid} https://exchange.com/"), ] { - assert_matches!( + assert!(matches!( gen_initiate(&db, rand_ebics_id(), subject).await, PaymentInitiationResult::Success(_) - ); + )); } batch_initiated(&db, &Timestamp::now(), "BATCH", false) .await @@ -550,7 +548,8 @@ mod test { ) .await .unwrap(); - assert_matches!( + assert!( + matches!( res, IncomingBounceRegistrationResult::Success(InResult { new: true, @@ -558,7 +557,7 @@ mod test { completed: false, pending: false, ref bounce_id - }) if bounce_id.as_ref() == Some(&id), + }) if bounce_id.as_ref() == Some(&id)), "{res:?}" ); // Idempotent @@ -572,15 +571,17 @@ mod test { ) .await .unwrap(); - assert_matches!( - res, - IncomingBounceRegistrationResult::Success(InResult { - new: false, - id: _, - completed: false, - pending: false, - ref bounce_id - }) if bounce_id.as_ref() == Some(&id), + assert!( + matches!( + res, + IncomingBounceRegistrationResult::Success(InResult { + new: false, + id: _, + completed: false, + pending: false, + ref bounce_id + }) if bounce_id.as_ref() == Some(&id), + ), "{res:?}" ); diff --git a/testbench/src/integration.rs b/testbench/src/integration.rs @@ -17,7 +17,7 @@ * <http://www.gnu.org/licenses/> */ -use std::{assert_matches, time::Duration}; +use std::time::Duration; use anyhow::anyhow; use axum::http::Method; @@ -482,7 +482,9 @@ async fn conversion() { .assert_ok_json::<IncomingHistory>() .incoming_transactions .remove(0); - assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + assert!( + matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) + ); } // Cashout @@ -536,7 +538,9 @@ async fn conversion() { .assert_ok_json::<IncomingHistory>() .incoming_transactions .remove(0); - assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + assert!( + matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) + ); // Bounce ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") @@ -587,7 +591,9 @@ async fn conversion() { .assert_ok_json::<IncomingHistory>() .incoming_transactions .remove(0); - assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + assert!( + matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) + ); // Bounce ctx.posta("/accounts/exchange/taler-wire-gateway/transfer")