commit 181a7ebcb751e05562bdb2cb8d65e316abc62797
parent 39069b2097c0429e4c256736993a4fa38308b029
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:38:00 +0200
ebics: HAA & HKD & HEV parsing
Diffstat:
10 files changed, 863 insertions(+), 423 deletions(-)
diff --git a/src/ebics/administrative.rs b/src/ebics/administrative.rs
@@ -0,0 +1,229 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 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
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::fmt::Display;
+
+use compact_str::CompactString;
+use taler_common::types::{
+ amount::Currency,
+ iban::{BIC, IBAN},
+};
+use taler_enum_meta::EnumMeta;
+
+use crate::{
+ EbicsResponse,
+ config::EbicsHostCfg,
+ ebics::{ebics_code::EbicsReturnCode, order::Order},
+ xml::{self, Xml, XmlAccess as _},
+ xml_build,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VersionNumber {
+ pub number: CompactString,
+ pub schema: CompactString,
+}
+
+impl Display for VersionNumber {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let Self { number, schema } = self;
+ write!(f, "{number}:{schema}")
+ }
+}
+
+pub struct HKD {
+ pub partner: PartnerInfo,
+ pub users: Box<[UserInfo]>,
+}
+pub struct PartnerInfo {
+ pub name: Option<CompactString>,
+ pub accounts: Box<[AccountInfo]>,
+ pub orders: Box<[OrderInfo]>,
+}
+pub struct OrderInfo {
+ pub order: Order,
+ pub description: String,
+}
+pub struct AccountInfo {
+ pub currency: Currency,
+ pub iban: IBAN,
+ pub bic: BIC,
+}
+pub struct UserInfo {
+ pub id: CompactString,
+ pub status: UserStatus,
+ pub permissions: Box<[Order]>,
+}
+
+pub struct HAA {
+ pub orders: Box<[Order]>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, EnumMeta)]
+#[enum_meta(Description)]
+pub enum UserStatus {
+ /// "Subscriber is permitted access"
+ Ready,
+ /// "Subscriber is established, pending access permission"
+ New,
+ /// "Subscriber has sent INI file, but no HIA file yet"
+ INI,
+ /// "Subscriber has sent HIA order, but no INI file yet"
+ HIA,
+ /// "Subscriber has sent both HIA order and INI file"
+ Initialised,
+ /// "Suspended after several failed attempts, new initialisation via INI and HIA possible"
+ SuspendedFailedAttempts,
+ /// "Suspended after SPR order, new initialisation via INI and HIA possible"
+ SuspendedSPR,
+ /// "Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank"
+ SuspendedBank,
+}
+
+pub fn hev_msg(cfg: &EbicsHostCfg) -> String {
+ xml_build!(
+ "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") {
+ "HostId": &cfg.host_id
+ }
+ )
+}
+
+pub fn parse_hev(xml: &str) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> {
+ Xml::parse_str(xml, "ebicsHEVResponse", |root| {
+ Ok(EbicsResponse {
+ technical_code: root.one("SystemReturnCode").one("ReturnCode").parse()?,
+ bank_code: EbicsReturnCode::EBICS_OK,
+ content: root
+ .many("VersionNumber")
+ .map(|n| {
+ Ok(VersionNumber {
+ number: n.parse()?,
+ schema: n.attr("ProtocolVersion")?.into(),
+ })
+ })
+ .collect::<xml::Result<_>>()?,
+ })
+ })
+}
+
+fn ebics_order(n: Xml, ty: &str) -> xml::Result<Order> {
+ let msg = n.opt("MsgName")?;
+ Ok(Order::V3 {
+ ty: ty.into(),
+ service: n.opt("ServiceName").parse()?,
+ scope: n.opt("Scope").parse()?,
+ option: n.opt("ServiceOption").parse()?,
+ container: n.opt("Container").parse_attr("containerType")?,
+ message: msg.parse()?,
+ version: msg.parse_opt_attr("version")?,
+ })
+}
+
+pub fn parse_hkd(xml: &str) -> xml::Result<HKD> {
+ fn order(n: Xml) -> xml::Result<Order> {
+ let ty = n.one("AdminOrderType")?.text();
+ Ok(n.opt("Service")?
+ .map(|s| ebics_order(s, ty))
+ .transpose()?
+ .unwrap_or_else(|| Order::V3 {
+ ty: ty.into(),
+ service: None,
+ scope: None,
+ message: None,
+ version: None,
+ container: None,
+ option: None,
+ }))
+ }
+ Xml::parse_str(xml, "HKDResponseOrderData", |root| {
+ let partner = root.one("PartnerInfo")?;
+
+ Ok(HKD {
+ partner: PartnerInfo {
+ name: partner.one("AddressInfo").opt("Name").parse()?,
+ accounts: partner
+ .many("AccountInfo")
+ .map(|account| {
+ let currency = account.parse_attr("Currency")?;
+ let iban = account
+ .many("AccountNumber")
+ .find(|nb| nb.opt_attr("international") == Some("true"))
+ .unwrap()
+ .parse()?;
+ let bic = account
+ .many("BankCode")
+ .find(|nb| nb.opt_attr("international") == Some("true"))
+ .unwrap()
+ .parse()?;
+ Ok(AccountInfo {
+ currency,
+ iban,
+ bic,
+ })
+ })
+ .collect::<xml::Result<_>>()?,
+ orders: partner
+ .many("OrderInfo")
+ .map(|n| {
+ Ok(OrderInfo {
+ order: order(n)?,
+ description: n.one("Description").parse()?,
+ })
+ })
+ .collect::<xml::Result<_>>()?,
+ },
+ users: root
+ .many("UserInfo")
+ .map(|n| {
+ let id = n.one("UserID")?;
+ Ok(UserInfo {
+ id: id.parse()?,
+ status: match id.attr("Status")? {
+ "1" => UserStatus::Ready,
+ "2" => UserStatus::New,
+ "3" => UserStatus::INI,
+ "4" => UserStatus::HIA,
+ "5" => UserStatus::Initialised,
+ "6" => UserStatus::SuspendedFailedAttempts,
+ // 7 is not applicable per spec
+ "8" => UserStatus::SuspendedSPR,
+ "9" => UserStatus::SuspendedBank,
+ s => return Err(id.parse_err(format_args!("Unknown user status {s}"))),
+ },
+ permissions: n
+ .many("Permission")
+ .map(|p| order(p))
+ .collect::<xml::Result<_>>()?,
+ })
+ })
+ .collect::<xml::Result<_>>()?,
+ })
+ })
+}
+
+pub fn parse_haa(xml: &str) -> xml::Result<HAA> {
+ Xml::parse_str(xml, "HAAResponseOrderData", |root| {
+ Ok(HAA {
+ orders: root
+ .many("Service")
+ .map(|s| ebics_order(s, "BTD"))
+ .collect::<xml::Result<_>>()?,
+ })
+ })
+}
diff --git a/src/ebics_code.rs b/src/ebics/ebics_code.rs
diff --git a/src/ebics/key_management.rs b/src/ebics/key_management.rs
@@ -0,0 +1,314 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 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
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::io::Write as _;
+
+use anyhow::bail;
+use aws_lc_rs::{
+ encoding::{AsDer, Pkcs8V1Der},
+ rsa::PublicEncryptingKey,
+};
+use base64::{Engine as _, prelude::BASE64_STANDARD};
+use flate2::{Compression, write::ZlibEncoder};
+use reqwest::Client;
+use taler_enum_meta::EnumMeta;
+use tracing::info;
+
+use crate::{
+ EbicsResponse,
+ common::{DataEncryptionInfo, EbicsLogger, decrypt_and_decompress_payload},
+ config::{EbicsHostCfg, EbicsKeysCfg},
+ crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private},
+ ebics::ebics_code::EbicsReturnCode,
+ keys::{self, BankPubKeysFile, ClientPriKeysFile},
+ post_to_bank,
+ xml::{self, Xml, XmlAccess as _, XmlWriter},
+ xml_build, xml_el,
+ xml_sign::sign_ebics,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)]
+#[enum_meta(Str)]
+#[allow(clippy::upper_case_acronyms)]
+pub enum Order {
+ INI,
+ HIA,
+ HPB,
+}
+
+/** Perform an EBICS public key management [order] using [client] and update on disk state */
+pub async fn submit_client_keys(
+ keys_cfg: &EbicsKeysCfg,
+ host_cfg: &EbicsHostCfg,
+ client: &mut ClientPriKeysFile,
+ http: &Client,
+ ebics_logger: &EbicsLogger,
+ order: Order,
+) -> anyhow::Result<()> {
+ if order == Order::HPB {
+ bail!("Only INI & HIA are supported for client keys");
+ }
+ let res = key_management(host_cfg, client, http, ebics_logger, order).await?;
+
+ if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE
+ || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE
+ {
+ bail!(
+ "{order} status code {}: either your IDs are incorrect, or you already have keys registered with this bank",
+ res.technical_code
+ )
+ }
+ res.ok_or_fail(order.as_ref())?;
+ match order {
+ Order::INI => client.submitted_ini = true,
+ Order::HIA => client.submitted_hia = true,
+ Order::HPB => unreachable!("Only INI & HIA are supported for client keys"),
+ }
+ keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?;
+ // TODO better error: Could not update the $order state on disk
+ Ok(())
+}
+
+pub async fn hpb(
+ http: &Client,
+ cfg: &EbicsHostCfg,
+ logger: &EbicsLogger,
+ client: &ClientPriKeysFile,
+) -> anyhow::Result<BankPubKeysFile> {
+ let order = Order::HPB;
+ let res = key_management(cfg, client, http, logger, order).await?;
+ if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED {
+ bail!(
+ "{order} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank",
+ res.technical_code
+ )
+ }
+ let order_data = res
+ .ok_or_fail(order.as_ref())?
+ .expect("{order}: missing order data");
+
+ fn rsa_pub_key(xml: Xml) -> xml::Result<PublicEncryptingKey> {
+ xml.one("X509Data")
+ .one("X509Certificate")
+ .decode(rsa_private_from_b64_x509_certificate)
+ }
+
+ Ok(Xml::parse_str(
+ &order_data,
+ "HPBResponseOrderData",
+ |root| {
+ let auth_pub = root.one("AuthenticationPubKeyInfo")?;
+ let version = auth_pub.one("AuthenticationVersion")?.text();
+ assert_eq!(
+ version, "X002",
+ "Expected authentication version X002 got unsupported {version}"
+ );
+ let auth_pub = rsa_pub_key(auth_pub)?;
+
+ let enc_pub = root.one("EncryptionPubKeyInfo")?;
+ let version = enc_pub.one("EncryptionVersion")?.text();
+ assert_eq!(
+ version, "E002",
+ "Expected encryption version E002 got unsupported {version}"
+ );
+ let enc_pub = rsa_pub_key(enc_pub)?;
+
+ Ok(BankPubKeysFile {
+ bank_authentication_public_key: auth_pub,
+ bank_encryption_public_key: enc_pub,
+ accepted: false,
+ })
+ },
+ )?)
+}
+
+pub async fn key_management(
+ cfg: &EbicsHostCfg,
+ client: &ClientPriKeysFile,
+ http: &Client,
+ _ebics_logger: &EbicsLogger,
+ order: Order,
+) -> anyhow::Result<EbicsResponse<Option<String>>> {
+ let EbicsHostCfg {
+ host_id,
+ user_id,
+ partner_id,
+ ..
+ } = cfg;
+ info!("Doing key request {order}");
+ //val txLog = ebicsLogger.tx(order.name)
+ // TODO is this still necessary ?
+
+ let (name, security_medium) = match order {
+ Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"),
+ Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"),
+ };
+
+ fn xml_order_data(
+ cfg: &EbicsHostCfg,
+ name: &str,
+ schema: &str,
+ build: impl FnOnce(&mut XmlWriter),
+ ) -> String {
+ let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") {
+ @ build,
+ "PartnerID": &cfg.partner_id,
+ "UserID": &cfg.user_id
+ });
+ // Deflate TODO write inside the compressor directly
+ let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
+ encoder.write_all(xml.as_bytes()).unwrap();
+ let compressed = encoder.finish().unwrap();
+ BASE64_STANDARD.encode(&compressed)
+ }
+
+ fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K)
+ where
+ K: AsDer<Pkcs8V1Der<'static>>,
+ {
+ let der = key.as_der().unwrap();
+ let b64 = BASE64_STANDARD.encode(der.as_ref());
+ let lines = b64
+ .as_bytes()
+ .chunks(64)
+ .map(|c| std::str::from_utf8(c).unwrap())
+ .collect::<Vec<_>>()
+ .join("\n");
+ let pem =
+ format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n");
+ let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap();
+ let der = cert.der();
+ let b64 = BASE64_STANDARD.encode(der.as_ref());
+
+ xml_el!(w, "ds:X509Data" {
+ "ds:X509Certificate": b64
+ });
+ }
+ let data = match order {
+ Order::INI => Some(xml_order_data(
+ cfg,
+ "SignaturePubKeyOrderData",
+ "http://www.ebics.org/S002",
+ |w| {
+ xml_el!(w, "SignaturePubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.signature_private_key),
+ "SignatureVersion": "A006"
+ });
+ },
+ )),
+ Order::HIA => Some(xml_order_data(
+ cfg,
+ "HIARequestOrderData",
+ "urn:org:ebics:H005",
+ |w| {
+ xml_el!(w, "AuthenticationPubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.authentication_private_key),
+ "AuthenticationVersion": "X002"
+ },
+ "EncryptionPubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.encryption_private_key),
+ "EncryptionVersion": "E002"
+ });
+ },
+ )),
+ Order::HPB => None,
+ };
+ let sign = order == Order::HPB;
+ let msg = xml_build!(
+ name
+ ("xmlns": "urn:org:ebics:H005")
+ ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#")
+ ("Version": "H005")
+ ("Revision": "1")
+ {
+ "header" ("authenticate": "true") {
+ "static" {
+ "HostID": host_id,
+ @ |w: &mut XmlWriter| {
+ if order == Order::HPB {
+ let nonce: u128 = rand::random();
+ xml_el!(w,
+ "Nonce": format_args!("{:032x}", nonce),
+ "Timestamp": jiff::Timestamp::now()
+ );
+ }
+ },
+ "PartnerID": partner_id,
+ "UserID": user_id,
+ "OrderDetails" {
+ "AdminOrderType": order
+ },
+ "SecurityMedium": security_medium
+ },
+ "mutable"
+ },
+ @ |w: &mut XmlWriter| {
+ if sign {
+ xml_el!(w, "AuthSignature");
+ }
+ },
+ "body" {
+ @ |w: &mut XmlWriter| {
+ if let Some(data) = data {
+ xml_el!(w, "DataTransfer" {
+ "OrderData": data
+ });
+ }
+ }
+ }
+ }
+ );
+ let signed = if sign {
+ sign_ebics(msg, &client.authentication_private_key)
+ } else {
+ msg
+ };
+ let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?;
+ Ok(Xml::parse_str(
+ &res,
+ "ebicsKeyManagementResponse",
+ |root| {
+ let body = root.one("body")?;
+ Ok(EbicsResponse {
+ technical_code: root
+ .one_signed("header")
+ .one("mutable")
+ .one("ReturnCode")
+ .parse()?,
+ bank_code: body.one_signed("ReturnCode").parse()?,
+ content: if let Some(data) = body.opt("DataTransfer")? {
+ let info = data.one_signed("DataEncryptionInfo")?;
+ let info = DataEncryptionInfo {
+ transaction_key: info.one("TransactionKey").b64()?,
+ bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?,
+ };
+ let chunk = data.one("OrderData").b64()?;
+ let decoded = decrypt_and_decompress_payload(
+ &client.encryption_private_key,
+ info,
+ vec![chunk],
+ );
+ Some(String::from_utf8(decoded).unwrap())
+ } else {
+ None
+ },
+ })
+ },
+ )?)
+}
diff --git a/src/ebics/mod.rs b/src/ebics/mod.rs
@@ -0,0 +1,23 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 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
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+pub mod administrative;
+pub mod ebics_code;
+pub mod key_management;
+pub mod order;
diff --git a/src/ebics/order.rs b/src/ebics/order.rs
@@ -0,0 +1,207 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 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
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use compact_str::CompactString;
+use taler_enum_meta::EnumMeta;
+
+pub enum Order {
+ V2 {
+ ty: CompactString,
+ attribute: CompactString,
+ },
+ V3 {
+ ty: CompactString,
+ service: Option<CompactString>,
+ scope: Option<CompactString>,
+ message: Option<CompactString>,
+ version: Option<CompactString>,
+ container: Option<CompactString>,
+ option: Option<CompactString>,
+ },
+}
+
+impl Order {
+ pub const WSS_PARAMS: Self = Self::V3 {
+ ty: CompactString::const_new("BTD"),
+ service: Some(CompactString::const_new("OTH")),
+ scope: Some(CompactString::const_new("DE")),
+ message: Some(CompactString::const_new("wssparam")),
+ version: None,
+ container: None,
+ option: None,
+ };
+ pub const HAC: Self = Self::V3 {
+ ty: CompactString::const_new("HAC"),
+ service: None,
+ scope: None,
+ message: None,
+ version: None,
+ container: None,
+ option: None,
+ };
+ pub const HKD: Self = Self::V3 {
+ ty: CompactString::const_new("HKD"),
+ service: None,
+ scope: None,
+ message: None,
+ version: None,
+ container: None,
+ option: None,
+ };
+ pub const HAA: Self = Self::V3 {
+ ty: CompactString::const_new("HAA"),
+ service: None,
+ scope: None,
+ message: None,
+ version: None,
+ container: None,
+ option: None,
+ };
+
+ pub fn description(&self, mut f: std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::V2 { ty, attribute } => write!(f, "{ty}-{attribute}"),
+ Self::V3 {
+ ty,
+ service,
+ scope,
+ message,
+ version,
+ container,
+ option,
+ } => {
+ write!(f, "{ty}")?;
+ for part in [service, scope, container, option].into_iter().flatten() {
+ write!(f, "-{part}")?;
+ }
+ if let Some(message) = message {
+ write!(f, "-{message}")?;
+ if let Some(version) = version {
+ write!(f, ".{version}")?;
+ }
+ }
+ Ok(())
+ }
+ }
+ }
+
+ pub fn doc(&self) -> Option<OrderDoc> {
+ match self {
+ Self::V2 { ty, .. } => match ty.as_str() {
+ "HAC" => Some(OrderDoc::acknowledgement),
+ "Z01" => Some(OrderDoc::status),
+ "Z52" => Some(OrderDoc::report),
+ "Z53" => Some(OrderDoc::statement),
+ "Z54" => Some(OrderDoc::notification),
+ _ => None,
+ },
+ Self::V3 { ty, message, .. } => match ty.as_str() {
+ "HAC" => Some(OrderDoc::acknowledgement),
+ "BTD" => match message.as_deref() {
+ Some("pain.002") => Some(OrderDoc::status),
+ Some("camt.052") => Some(OrderDoc::report),
+ Some("camt.053") => Some(OrderDoc::statement),
+ Some("camt.054") => Some(OrderDoc::notification),
+ _ => None,
+ },
+ _ => None,
+ },
+ }
+ }
+
+ /** Check if EBICS order is a downloadable one */
+ pub fn is_downloadable(&self) -> bool {
+ matches!(
+ self.doc(),
+ Some(OrderDoc::acknowledgement)
+ | Some(OrderDoc::status)
+ | Some(OrderDoc::report)
+ | Some(OrderDoc::statement)
+ | Some(OrderDoc::notification)
+ )
+ }
+
+ /** Check if EBICS order is an uploadable one */
+ pub fn is_upload(&self) -> bool {
+ matches!(self, Self::V3 { ty, .. } if ty == "BTU")
+ }
+
+ /** Check if two EBICS order match ignoring the message version */
+ pub fn matches(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::V2 { ty: ty1, .. }, Self::V2 { ty: ty2, .. }) => ty1 == ty2,
+ (
+ Self::V3 {
+ ty: ty1,
+ service: service1,
+ scope: scope1,
+ message: message1,
+ container: container1,
+ option: option1,
+ ..
+ },
+ Self::V3 {
+ ty: ty2,
+ service: service2,
+ scope: scope2,
+ message: message2,
+ container: container2,
+ option: option2,
+ ..
+ },
+ ) => {
+ ty1 == ty2
+ && service1 == service2
+ && scope1 == scope2
+ && message1 == message2
+ && container1 == container2
+ && option1 == option2
+ }
+ _ => false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)]
+#[enum_meta(Str, Description)]
+#[allow(non_camel_case_types)]
+pub enum OrderDoc {
+ /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002
+ acknowledgement,
+ /// Payment status - CustomerPaymentStatusReport pain.002
+ status,
+ /// Account intraday reports - BankToCustomerAccountReport camt.052
+ report,
+ /// Account statements - BankToCustomerStatement camt.053
+ statement,
+ /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054
+ notification,
+}
+
+impl OrderDoc {
+ pub fn short_description(&self) -> &'static str {
+ match self {
+ Self::acknowledgement => "EBICS acknowledgement",
+ Self::status => "Payment status",
+ Self::report => "Account intraday reports",
+ Self::statement => "Account statements",
+ Self::notification => "Debit & credit notifications",
+ }
+ }
+}
diff --git a/src/iso20022/hac.rs b/src/iso20022/hac.rs
@@ -77,7 +77,6 @@ pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> {
root.one("CstmrPmtStsRpt")?
.many("OrgnlPmtInfAndSts")
.map(|n| {
- let action: HacAction = n.one("OrgnlPmtInfId").parse()?;
let mut timestamp = None;
let mut order_id = None;
let info = n.one("StsRsnInf")?;
@@ -94,13 +93,11 @@ pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> {
_ => {}
}
}
- let code = info.opt("Rsn").one("Cd").parse()?;
- let info: String = info.many("AddtlInf").map(|n| n.text()).collect();
Ok(CustomerAck {
- action,
+ action: n.one("OrgnlPmtInfId").parse()?,
order_id,
- code,
- info: info.into_boxed_str(),
+ code: info.opt("Rsn").one("Cd").parse()?,
+ info: info.many("AddtlInf").map(|n| n.text()).collect(),
timestamp: timestamp.unwrap(),
})
})
diff --git a/src/iso20022/mod.rs b/src/iso20022/mod.rs
@@ -76,7 +76,10 @@ pub enum ChargeBearer {
pub mod test {
use tracing::info;
- use crate::iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002};
+ use crate::{
+ ebics::administrative::{parse_haa, parse_hkd},
+ iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002},
+ };
#[test]
pub fn sample() {
@@ -107,7 +110,7 @@ pub mod test {
} else if name.contains("pain002") {
parse_pain002(&xml).unwrap();
} else if name.contains("pain001") {
- // TODO
+ // Ignore
} else {
panic!("Unsupported file type {name}")
}
@@ -161,15 +164,15 @@ pub mod test {
if path.contains("HAC") {
parse_hac(&xml).unwrap();
} else if path.contains("HKD") {
- // TODO
+ parse_hkd(&xml).unwrap();
} else if path.contains("HAA") {
- // TODO
+ parse_haa(&xml).unwrap();
} else if path.contains("camt") {
parse_camt(&xml).unwrap();
} else if path.contains("pain.002") {
parse_pain002(&xml).unwrap();
} else if path.contains("pain.001") {
- // TODO
+ // Ignore
} else {
panic!("Unsupported file type {path}")
}
diff --git a/src/key_management.rs b/src/key_management.rs
@@ -1,327 +0,0 @@
-/*
-* This file is part of LibEuFin.
-* Copyright (C) 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
-* published by the Free Software Foundation; either version 3, or
-* (at your option) any later version.
-
-* LibEuFin 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 LibEuFin; see the file COPYING. If not, see
-* <http://www.gnu.org/licenses/>
-*/
-
-use std::io::Write as _;
-
-use anyhow::bail;
-use aws_lc_rs::{
- encoding::{AsDer, Pkcs8V1Der},
- rsa::PublicEncryptingKey,
-};
-use base64::{Engine as _, prelude::BASE64_STANDARD};
-use flate2::{Compression, write::ZlibEncoder};
-use reqwest::Client;
-use tracing::info;
-
-use crate::{
- EbicsResponse,
- common::{DataEncryptionInfo, EbicsLogger, decrypt_and_decompress_payload},
- config::{EbicsHostCfg, EbicsKeysCfg},
- crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private},
- ebics_code::EbicsReturnCode,
- keys::{self, BankPubKeysFile, ClientPriKeysFile},
- post_to_bank,
- xml::{self, Xml, XmlAccess as _, XmlWriter},
- xml_build, xml_el,
- xml_sign::sign_ebics,
-};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[allow(clippy::upper_case_acronyms)]
-pub enum Order {
- INI,
- HIA,
- HPB,
-}
-
-impl Order {
- pub fn name(&self) -> &'static str {
- match self {
- Order::INI => "INI",
- Order::HIA => "HIA",
- Order::HPB => "HPB",
- }
- }
-}
-
-/** Perform an EBICS public key management [order] using [client] and update on disk state */
-pub async fn submit_client_keys(
- keys_cfg: &EbicsKeysCfg,
- host_cfg: &EbicsHostCfg,
- client: &mut ClientPriKeysFile,
- http: &Client,
- ebics_logger: &EbicsLogger,
- order: Order,
-) -> anyhow::Result<()> {
- if order == Order::HPB {
- bail!("Only INI & HIA are supported for client keys");
- }
- let res = key_management(host_cfg, client, http, ebics_logger, order).await?;
-
- if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE
- || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE
- {
- bail!(
- "{} status code {}: either your IDs are incorrect, or you already have keys registered with this bank",
- order.name(),
- res.technical_code
- )
- }
- res.ok_or_fail(order.name())?;
- match order {
- Order::INI => client.submitted_ini = true,
- Order::HIA => client.submitted_hia = true,
- Order::HPB => unreachable!("Only INI & HIA are supported for client keys"),
- }
- keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?;
- // TODO better error: Could not update the $order state on disk
- Ok(())
-}
-
-pub async fn hpb(
- http: &Client,
- cfg: &EbicsHostCfg,
- logger: &EbicsLogger,
- client: &ClientPriKeysFile,
-) -> anyhow::Result<BankPubKeysFile> {
- let order = Order::HPB;
- let res = key_management(cfg, client, http, logger, order).await?;
- if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED {
- bail!(
- "{} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank",
- order.name(),
- res.technical_code
- )
- }
- let order_data = res
- .ok_or_fail(order.name())?
- .expect("{order}: missing order data");
-
- fn rsa_pub_key(xml: Xml) -> xml::Result<PublicEncryptingKey> {
- xml.one("X509Data")
- .one("X509Certificate")
- .decode(rsa_private_from_b64_x509_certificate)
- }
-
- Ok(Xml::parse_str(
- &order_data,
- "HPBResponseOrderData",
- |root| {
- let auth_pub = root.one("AuthenticationPubKeyInfo")?;
- let version = auth_pub.one("AuthenticationVersion")?.text();
- assert_eq!(
- version, "X002",
- "Expected authentication version X002 got unsupported {version}"
- );
- let auth_pub = rsa_pub_key(auth_pub)?;
-
- let enc_pub = root.one("EncryptionPubKeyInfo")?;
- let version = enc_pub.one("EncryptionVersion")?.text();
- assert_eq!(
- version, "E002",
- "Expected encryption version E002 got unsupported {version}"
- );
- let enc_pub = rsa_pub_key(enc_pub)?;
-
- Ok(BankPubKeysFile {
- bank_authentication_public_key: auth_pub,
- bank_encryption_public_key: enc_pub,
- accepted: false,
- })
- },
- )?)
-}
-
-pub async fn key_management(
- cfg: &EbicsHostCfg,
- client: &ClientPriKeysFile,
- http: &Client,
- _ebics_logger: &EbicsLogger,
- order: Order,
-) -> anyhow::Result<EbicsResponse<Option<String>>> {
- let EbicsHostCfg {
- host_id,
- user_id,
- partner_id,
- ..
- } = cfg;
- info!("Doing key request {}", order.name());
- //val txLog = ebicsLogger.tx(order.name)
- // TODO is this still necessary ?
-
- let (name, security_medium) = match order {
- Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"),
- Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"),
- };
-
- fn xml_order_data(
- cfg: &EbicsHostCfg,
- name: &str,
- schema: &str,
- build: impl FnOnce(&mut XmlWriter),
- ) -> String {
- let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") {
- @ build,
- "PartnerID": &cfg.partner_id,
- "UserID": &cfg.user_id
- });
- // Deflate TODO write inside the compressor directly
- let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
- encoder.write_all(xml.as_bytes()).unwrap();
- let compressed = encoder.finish().unwrap();
- BASE64_STANDARD.encode(&compressed)
- }
-
- fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K)
- where
- K: AsDer<Pkcs8V1Der<'static>>,
- {
- let der = key.as_der().unwrap();
- let b64 = BASE64_STANDARD.encode(der.as_ref());
- let lines = b64
- .as_bytes()
- .chunks(64)
- .map(|c| std::str::from_utf8(c).unwrap())
- .collect::<Vec<_>>()
- .join("\n");
- let pem =
- format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n");
- let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap();
- let der = cert.der();
- let b64 = BASE64_STANDARD.encode(der.as_ref());
-
- xml_el!(w, "ds:X509Data" {
- "ds:X509Certificate": b64
- });
- }
- let data = match order {
- Order::INI => Some(xml_order_data(
- cfg,
- "SignaturePubKeyOrderData",
- "http://www.ebics.org/S002",
- |w| {
- xml_el!(w, "SignaturePubKeyInfo" {
- @ |w| rsa_key_xml(w, &client.signature_private_key),
- "SignatureVersion": "A006"
- });
- },
- )),
- Order::HIA => Some(xml_order_data(
- cfg,
- "HIARequestOrderData",
- "urn:org:ebics:H005",
- |w| {
- xml_el!(w, "AuthenticationPubKeyInfo" {
- @ |w| rsa_key_xml(w, &client.authentication_private_key),
- "AuthenticationVersion": "X002"
- },
- "EncryptionPubKeyInfo" {
- @ |w| rsa_key_xml(w, &client.encryption_private_key),
- "EncryptionVersion": "E002"
- });
- },
- )),
- Order::HPB => None,
- };
- let sign = order == Order::HPB;
- let msg = xml_build!(
- name
- ("xmlns": "urn:org:ebics:H005")
- ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#")
- ("Version": "H005")
- ("Revision": "1")
- {
- "header" ("authenticate": "true") {
- "static" {
- "HostID": host_id,
- @ |w: &mut XmlWriter| {
- if order == Order::HPB {
- let nonce: u128 = rand::random();
- xml_el!(w,
- "Nonce": format_args!("{:032x}", nonce),
- "Timestamp": jiff::Timestamp::now()
- );
- }
- },
- "PartnerID": partner_id,
- "UserID": user_id,
- "OrderDetails" {
- "AdminOrderType": order.name()
- },
- "SecurityMedium": security_medium
- },
- "mutable"
- },
- @ |w: &mut XmlWriter| {
- if sign {
- xml_el!(w, "AuthSignature");
- }
- },
- "body" {
- @ |w: &mut XmlWriter| {
- if let Some(data) = data {
- xml_el!(w, "DataTransfer" {
- "OrderData": data
- });
- }
- }
- }
- }
- );
- let signed = if sign {
- sign_ebics(msg, &client.authentication_private_key)
- } else {
- msg
- };
- let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?;
- Ok(Xml::parse_str(
- &res,
- "ebicsKeyManagementResponse",
- |root| {
- let technical_code = root
- .one_signed("header")
- .one("mutable")
- .one("ReturnCode")
- .parse()?;
- let body = root.one("body")?;
- let bank_code = body.one_signed("ReturnCode").parse()?;
- let content = if let Some(data) = body.opt("DataTransfer")? {
- let info = data.one_signed("DataEncryptionInfo")?;
- let info = DataEncryptionInfo {
- transaction_key: info.one("TransactionKey").b64()?,
- bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?,
- };
- let chunk = data.one("OrderData").b64()?;
- let decoded = decrypt_and_decompress_payload(
- &client.encryption_private_key,
- info,
- vec![chunk],
- );
- Some(String::from_utf8(decoded).unwrap())
- } else {
- None
- };
- Ok(EbicsResponse {
- technical_code,
- bank_code,
- content,
- })
- },
- )?)
-}
diff --git a/src/lib.rs b/src/lib.rs
@@ -17,7 +17,7 @@
* <http://www.gnu.org/licenses/>
*/
-use std::{fmt::Display, path::Path};
+use std::path::Path;
use anyhow::bail;
use compact_str::CompactString;
@@ -33,12 +33,13 @@ use tracing::{debug, info};
use crate::{
common::EbicsLogger,
config::{EbicsHostCfg, NexusCfg},
- ebics_code::EbicsReturnCode,
- key_management::{Order, hpb, submit_client_keys},
- keys::{load_bank_keys, load_client_keys, persist_client_keys},
- xml::XmlAccess as _,
+ ebics::{
+ administrative::{VersionNumber, hev_msg, parse_hev},
+ ebics_code::EbicsReturnCode,
+ key_management::{Order, hpb, submit_client_keys},
+ },
+ keys::{ClientPriKeysFile, load_bank_keys, load_client_keys, persist_client_keys},
};
-use crate::{keys::ClientPriKeysFile, xml::Xml};
pub mod api;
pub mod common;
@@ -46,9 +47,8 @@ pub mod config;
pub mod crypto;
pub mod db;
pub mod dialect;
-pub mod ebics_code;
+pub mod ebics;
pub mod iso20022;
-pub mod key_management;
pub mod keys;
pub mod model;
pub mod testbench;
@@ -115,11 +115,11 @@ pub async fn ebics_setup(
.join(", ")
);
if !versions.contains(&VersionNumber {
- number: "03.00".to_owned(),
- schema: "H005".to_owned(),
+ number: "03.00".into(),
+ schema: "H005".into(),
}) && versions.contains(&VersionNumber {
- number: "03.02".to_owned(),
- schema: "H005".to_owned(),
+ number: "03.02".into(),
+ schema: "H005".into(),
}) {
bail!("EBICS 3 is not supported by your bank");
}
@@ -141,31 +141,12 @@ pub async fn ebics_setup(
Ok(())
}
-pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<VersionNumber>> {
+pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Box<[VersionNumber]>> {
let phase = "HEV";
info!(target: "ebics", "Doing administrative request {phase}");
- let msg = xml_build!(
- "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") {
- "HostID": &cfg.host_id
- }
- );
+ let msg = hev_msg(cfg);
let res = post_to_bank(cfg.base_url.as_str(), http, msg).await?;
- Xml::parse_str(&res, "ebicsHEVResponse", |root| {
- let technical_code = root.one("SystemReturnCode").one("ReturnCode").parse()?;
- let versions: Vec<_> = root
- .many("VersionNumber")
- .map(|n| VersionNumber {
- number: n.text().to_owned(),
- schema: n.attr("ProtocolVersion").expect("TODO").to_owned(),
- })
- .collect();
- Ok(EbicsResponse {
- technical_code,
- bank_code: EbicsReturnCode::EBICS_OK,
- content: versions,
- })
- })?
- .ok_or_fail(phase)
+ parse_hev(&res)?.ok_or_fail(phase)
}
#[derive(Debug, thiserror::Error)]
@@ -207,16 +188,3 @@ impl<T> EbicsResponse<T> {
}
}
}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct VersionNumber {
- pub number: String,
- pub schema: String,
-}
-
-impl Display for VersionNumber {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- let Self { number, schema } = self;
- write!(f, "{number}:{schema}")
- }
-}
diff --git a/src/xml.rs b/src/xml.rs
@@ -273,104 +273,130 @@ impl<'xml> Xml<'xml> {
pub fn text(self) -> &'xml str {
self.node.text().unwrap_or_default()
}
+
+ pub fn attr(self, name: &str) -> Result<&'xml str> {
+ self.node
+ .attribute(name)
+ .ok_or_else(|| XmlError::MissingAttr(self.path(None), name.into()))
+ }
+
+ pub fn opt_attr(self, name: &str) -> Option<&'xml str> {
+ self.node.attribute(name)
+ }
}
pub trait XmlAccess<'xml>: Sized {
- type TryOut<T>;
- type OptOut<T>;
+ type Out<T>;
+ type Opt<T>;
- fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>>;
- fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>)
- -> Result<Self::OptOut<T>>;
+ fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>>;
+ fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>>;
- fn one(self, tag: &'xml str) -> Result<Self::TryOut<Xml<'xml>>> {
+ fn one(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> {
self.lift(|n| n.one_inner(tag, false))
}
- fn one_signed(self, tag: &'xml str) -> Result<Self::TryOut<Xml<'xml>>> {
+ fn one_signed(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> {
self.lift(|n| n.one_inner(tag, true))
}
- fn opt(self, tag: &'xml str) -> Result<Self::OptOut<Xml<'xml>>> {
+ fn opt(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> {
self.opt_lift(|n| n.opt_inner(tag, false))
}
- fn opt_signed(self, tag: &'xml str) -> Result<Self::OptOut<Xml<'xml>>> {
+ fn opt_signed(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> {
self.opt_lift(|n| n.opt_inner(tag, true))
}
- fn attr(self, name: &str) -> Result<Self::TryOut<&'xml str>> {
- self.lift(|n| {
- n.node
- .attribute(name)
- .ok_or_else(|| XmlError::MissingAttr(n.path(None), name.into()))
+ fn parse_attr<T: FromStr>(self, name: &str) -> Result<Self::Out<T>>
+ where
+ T::Err: Display,
+ {
+ self.lift(|n| n.attr(name)?.parse().map_err(|e| n.parse_err(e)))
+ }
+
+ fn parse_opt_attr<T: FromStr>(self, name: &str) -> Result<Self::Opt<T>>
+ where
+ T::Err: Display,
+ {
+ self.opt_lift(|n| {
+ n.opt_attr(name)
+ .map(|it| it.parse().map_err(|e| n.parse_err(e)))
+ .transpose()
})
}
fn decode<T, E: Display>(
self,
lambda: impl FnOnce(&str) -> std::result::Result<T, E>,
- ) -> Result<Self::TryOut<T>> {
+ ) -> Result<Self::Out<T>> {
// TODO error not a node text ?
self.lift(|n| lambda(n.text()).map_err(|e| n.parse_err(e)))
}
- fn parse<T: FromStr>(self) -> Result<Self::TryOut<T>>
+ fn parse<T: FromStr>(self) -> Result<Self::Out<T>>
where
T::Err: Display,
{
self.decode(T::from_str)
}
- fn b64(self) -> Result<Self::TryOut<Vec<u8>>> {
+ fn b64(self) -> Result<Self::Out<Vec<u8>>> {
self.decode(|it| BASE64_STANDARD.decode(it))
}
}
impl<'xml> XmlAccess<'xml> for Xml<'xml> {
- type TryOut<T> = T;
- type OptOut<T> = Option<T>;
+ type Out<T> = T;
+ type Opt<T> = Option<T>;
- fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> {
+ fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> {
f(self)
}
- fn opt_lift<T>(
- self,
- f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>,
- ) -> Result<Self::OptOut<T>> {
+ fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> {
self.lift(f)
}
}
+impl<'xml> XmlAccess<'xml> for Option<Xml<'xml>> {
+ type Out<T> = Option<T>;
+ type Opt<T> = Option<T>;
+
+ fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> {
+ self.map(|it| it.lift(f)).transpose()
+ }
+
+ fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> {
+ match self {
+ Some(xml) => xml.opt_lift(f),
+ None => Ok(None),
+ }
+ }
+}
+
impl<'xml> XmlAccess<'xml> for Result<Xml<'xml>> {
- type TryOut<T> = T;
- type OptOut<T> = Option<T>;
+ type Out<T> = T;
+ type Opt<T> = Option<T>;
- fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> {
+ fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> {
self?.lift(f)
}
- fn opt_lift<T>(
- self,
- f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>,
- ) -> Result<Self::OptOut<T>> {
+ fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> {
self.lift(f)
}
}
impl<'xml> XmlAccess<'xml> for Result<Option<Xml<'xml>>> {
- type TryOut<T> = Option<T>;
- type OptOut<T> = Option<T>;
+ type Out<T> = Option<T>;
+ type Opt<T> = Option<T>;
- fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> {
+ fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> {
self?.map(|it| it.lift(f)).transpose()
}
- fn opt_lift<T>(
- self,
- f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>,
- ) -> Result<Self::OptOut<T>> {
+ fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> {
match self? {
Some(xml) => xml.opt_lift(f),
None => Ok(None),