From 8a28d52bf1181a36d887eb87beb587e2c499ee23 Mon Sep 17 00:00:00 2001 From: James Ray Date: Thu, 13 Aug 2026 10:13:31 -0500 Subject: [PATCH 1/4] feat!: unify typed scheme validation Route pacs.008 through zero-copy Document extraction and typed 001.13 validation while retaining version-neutral rules for older versions. Preserve raw scheme findings, move XML extraction behind private CLI helpers, prepare workspace 0.4.0, and document the scheme-rule abstraction boundary. BREAKING CHANGE: remove the public schemes::xml_scan module. --- ARCHITECTURE.md | 47 +- CHANGELOG.md | 35 ++ CLAUDE.md | 24 +- Cargo.lock | 15 +- Cargo.toml | 14 +- README.md | 11 +- crates/mx20022-cli/Cargo.toml | 2 +- crates/mx20022-cli/src/commands/mod.rs | 1 + crates/mx20022-cli/src/commands/validate.rs | 44 +- .../mx20022-cli/src/commands/xml_extract.rs | 75 +++ crates/mx20022-cli/tests/cli_integration.rs | 160 ++++++- crates/mx20022-codegen/Cargo.toml | 2 +- crates/mx20022-model/Cargo.toml | 2 +- crates/mx20022-parse/Cargo.toml | 2 +- crates/mx20022-parse/src/de.rs | 191 ++++++++ crates/mx20022-parse/src/envelope.rs | 112 +++-- crates/mx20022-translate/Cargo.toml | 2 +- crates/mx20022-validate/Cargo.toml | 5 +- crates/mx20022-validate/src/schemes/cbpr.rs | 367 ++++----------- crates/mx20022-validate/src/schemes/common.rs | 184 +------- crates/mx20022-validate/src/schemes/fednow.rs | 380 +++++---------- crates/mx20022-validate/src/schemes/mod.rs | 278 +++++++++-- .../mx20022-validate/src/schemes/pacs008.rs | 333 +++++++++++++ crates/mx20022-validate/src/schemes/raw.rs | 68 +++ crates/mx20022-validate/src/schemes/sepa.rs | 401 +++++----------- .../mx20022-validate/src/schemes/xml_scan.rs | 283 ----------- crates/mx20022-validate/tests/schemes.rs | 445 +++++++++++++++--- crates/mx20022/Cargo.toml | 2 +- crates/mx20022/src/lib.rs | 11 +- .../fixtures/inherited_namespace_pacs008.xml | 63 +++ 30 files changed, 2082 insertions(+), 1477 deletions(-) create mode 100644 crates/mx20022-cli/src/commands/xml_extract.rs create mode 100644 crates/mx20022-validate/src/schemes/pacs008.rs create mode 100644 crates/mx20022-validate/src/schemes/raw.rs delete mode 100644 crates/mx20022-validate/src/schemes/xml_scan.rs create mode 100644 crates/mx20022/tests/fixtures/inherited_namespace_pacs008.xml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ad205e5..955998a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -330,7 +330,7 @@ pub struct ConstraintViolation { ``` src/ ├── lib.rs # Re-exports: de, ser, envelope, error -├── de.rs # from_str, from_reader +├── de.rs # document_xml, from_str, from_reader ├── ser.rs # to_string, to_string_with_declaration ├── envelope.rs # detect_message_type, parse_namespace, MessageId └── error.rs # ParseError enum @@ -340,6 +340,7 @@ src/ | Function | Signature | Description | |---|---|---| +| `de::document_xml` | `(xml: &str) → Result<&str, ParseError>` | Return a zero-copy payload `Document`, bare or below any transport wrapper | | `de::from_str` | `(xml: &str) → Result` | Deserialize XML to typed struct | | `de::from_reader` | `(r: R) → Result` | Streaming deserialization | | `ser::to_string` | `(v: &T) → Result` | Serialize to XML | @@ -400,7 +401,7 @@ src/ ├── fednow.rs # FedNow rules (USD, $500K limit, UETR, etc.) ├── sepa.rs # SEPA SCT rules (EUR, IBAN required, charset) ├── cbpr.rs # CBPR+ rules (BAH required, 4 BICs mandatory) - └── xml_scan.rs # Lightweight XML string extraction helpers + └── raw.rs # Private envelope, size, and control checks ``` **Validation architecture**: @@ -419,6 +420,8 @@ Layer 2 — Business Rules (field-level) Layer 3 — Scheme Rules (message-level) ├── SchemeValidator trait with validate() and validate_typed() +├── validate(): unwrap Document, route namespace, deserialize, delegate +├── validate_typed(): sole home for supported field rules ├── FedNow: 15+ rules (currency, settlement, amounts, UETR, size limits) ├── SEPA: 12+ rules (EUR, IBAN both sides, charset, amount bounds) ├── CBPR+: 12+ rules (BAH, 4 BICs, UETR, charge bearer, UTF-8) @@ -433,16 +436,23 @@ pub trait Rule: Send + Sync { } ``` -**Scheme validator trait** (dual-path): +**Scheme validator trait** (parse-and-delegate plus typed entry point): ```rust pub trait SchemeValidator: Send + Sync { fn name(&self) -> &'static str; fn supported_messages(&self) -> &[&str]; - fn validate(&self, xml: &str, message_type: &str) -> ValidationResult; // XML string path - fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option; // Typed path + fn validate(&self, xml: &str, message_type: &str) -> ValidationResult; // Parse and delegate + fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option; } ``` +For supported `pacs.008`, the `Document` namespace is authoritative. Version +`pacs.008.001.13` delegates to typed rules; other versions receive raw findings, +the same version-neutral field rules, and `SCHEME_UNTYPED_VERSION`. Parse +failures produce one `SCHEME_PARSE` finding at `/Document`, alongside any +independent raw findings for the caller's supported short message type. The +former public `schemes::xml_scan` API was removed in 0.4.0. + **Validation output**: ```rust pub struct ValidationResult { pub errors: Vec } @@ -692,7 +702,7 @@ use mx20022::validate::schemes::{fednow::FedNowValidator, SchemeValidator}; let registry = RuleRegistry::with_defaults(); let errors = registry.validate_field("GB82WEST12345698765432", "/iban", &["IBAN_CHECK"]); -// Scheme validation (XML path) +// Scheme validation (parse-and-delegate path) let fednow = FedNowValidator::new(); let result = fednow.validate(&xml, "pacs.008.001.13"); @@ -743,6 +753,12 @@ let raw = ActiveCurrencyCode(arbitrary_string); // No validation — use with c ## 6. Design Patterns +### Design Decisions + +| Area | Decision | Rationale | +|---|---|---| +| `pacs.008` scheme validation | Keep `pacs008::Facts` as the shared version-neutral extraction boundary for typed `pacs.008.001.13` and older-version paths. Keep FedNow, SEPA, and CBPR+ rule evaluation explicit; do not replace it with an optional-policy configuration engine. | Scheme policies differ in requiredness, accepted values, bounds, aggregation, diagnostics, and finding order. Extract helpers only when their mechanics are genuinely identical. | + ### Code Generation over Hand-Writing The single most important architectural decision. ISO 20022 defines 800+ message types. Hand-writing Rust types would be unmaintainable. Instead, a three-stage pipeline (XSD → IR → Rust) auto-generates all types from the official schemas. Adding a new message type means pointing the generator at an XSD file. @@ -775,13 +791,19 @@ This enables: `xs:choice` elements (mutually exclusive alternatives) map naturally to Rust enums. However, quick-xml's serde integration requires a `$value`-renamed wrapper for enum fields in structs. `ChoiceWrapper` provides this transparently with `Deref`/`DerefMut` for ergonomic access. -### Dual Validation Paths +### Parse-and-Delegate Scheme Validation Scheme validators implement both: -- `validate(&str, &str) → ValidationResult` — operates on raw XML via string scanning -- `validate_typed(&dyn Any, &str) → Option` — operates on deserialized types +- `validate(&str, &str) → ValidationResult` — unwraps the payload `Document` + below any transport wrapper, routes by its namespace, and applies typed or + version-neutral field rules +- `validate_typed(&dyn Any, &str) → Option` — applies the same + field rules directly to an already-deserialized document -The typed path is preferred (compile-time field access, no fragile string scanning). The XML path exists for backward compatibility and for validating messages that haven't been deserialized. +Raw XML pre-passes are deliberately narrow: byte-size limits, envelope-header +presence, and disallowed controls. They do not implement field rules. This +keeps both public entry points behaviorally aligned without invoking generated +`Validatable` constraints as part of scheme validation. ### Error Composition @@ -918,7 +940,7 @@ testdata/ | Item | Description | |---|---| | **Ergonomic API layer** | Prelude module with type aliases, convenience `parse()`/`to_xml()` at top level | -| **Typed validation pipeline** | Migrate scheme validators from XML string scanning to `Validatable` trait path | +| **Additional typed scheme versions** | Add generated-document routing beyond `pacs.008.001.13` | | **WASM bindings** | Separate `mx20022-wasm` crate via wasm-bindgen, target < 2MB bundle | | **Python bindings** | Separate `mx20022-python` crate via PyO3/maturin, type stubs for mypy | @@ -933,8 +955,7 @@ testdata/ | Gap | Status | Resolution | |---|---|---| -| Scheme validators use XML string scanning (fragile) | Typed path added, XML path retained for compat | v0.5: migrate to typed-only | +| Generated typed scheme routing covers only `pacs.008.001.13` | Other `pacs.008` versions receive raw findings, version-neutral field checks, and `SCHEME_UNTYPED_VERSION` | Add typed routes as models stabilize | | Builder API is flat, not nested-closure style | Acceptable trade-off for codegen complexity | Possible `with_defaults()` convenience | | Expert module paths required (no prelude) | DX friction | v0.5: prelude with type aliases | -| SEPA validator uses f64 for amount comparison | Precision risk | v0.5: integer-cent arithmetic | | No Display for ValidationResult | Usability gap | v0.5: Display impls | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd4c96..a76866f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `mx20022_parse::de::document_xml`, which returns the complete payload + `Document` as a zero-copy XML slice whether it is bare or nested below an + arbitrary transport wrapper. Descendant `Document` elements inside that + payload are not treated as additional candidates. + +### Changed + +- Bumped all seven workspace crates and their internal dependency requirements + to 0.4.0. This source change is not a published release. +- Scheme validation now unwraps `Document`, routes `pacs.008` by its + authoritative namespace, deserializes `pacs.008.001.13`, and delegates all + field rules to `validate_typed`. Other `pacs.008` versions retain raw checks, + apply the same version-neutral field rules, and report + `SCHEME_UNTYPED_VERSION`. +- FedNow and SEPA EndToEndId length checks now operate on typed values and count + Unicode scalar values. Parse or required-field failures report one + `SCHEME_PARSE` finding at `/Document`; independent raw findings are preserved + alongside extraction, namespace-routing, and deserialization failures. +- CLI generic IBAN, BIC, LEI, currency, and date checks use command-private XML + extraction helpers; scheme validation invokes `SchemeValidator::validate` + once. + +### Removed + +- Removed the public `mx20022_validate::schemes::xml_scan` module and the + scanner-based scheme field-rule path. This is a deliberate breaking change + for 0.4.0. +- On typed `pacs.008.001.13` parse failures, required or invalid generated + fields now consolidate into `SCHEME_PARSE` rather than the scanner-era + `CBPR_CHRGBR_REQUIRED`, `CBPR_CHRGBR_VALUE`, `CBPR_E2E_REQUIRED`, or + `SEPA_CHRGBR_REQUIRED` IDs. The version-neutral path for older `pacs.008` + versions still emits these scheme IDs when applicable. + ## [0.3.2] - 2026-08-12 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 4042d23..9540470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,13 @@ -## Context Database — Your Memory +## Agent Project Context -Location: `docs/agent_context.db` (SQLite, schema v1). **Query before reasoning. Write as you learn.** +When `.sddv4/repository.json` exists or the operator invokes SDDv4, use the +installed `sddv4` skill and service-backed project as the sole context +authority. Do not query or create `docs/agent_context.db` except for the +skill's explicit read-only legacy migration. -```sql --- Bootstrap: run this first, then follow the protocol rows it returns. -SELECT title, content FROM knowledge WHERE category = 'protocol' ORDER BY title; -``` +For an unbound repository without an SDDv4 request, do not invent a project or +context store. A status request remains read-only; setup or concrete work may +initialize through the skill. ## Project: mx20022 @@ -60,9 +62,9 @@ mx20022/ ## Key Commands ```bash -# Database queries (use sqlite3) -sqlite3 docs/agent_context.db "SELECT id, title, status FROM items WHERE status = 'in_progress';" - -# Find next available work -sqlite3 docs/agent_context.db "SELECT i.id, i.title, i.priority, it.name FROM items i JOIN iterations it ON i.iteration_id = it.id WHERE i.status = 'not_started' AND NOT EXISTS (SELECT 1 FROM item_dependencies d JOIN items dep ON d.depends_on = dep.id WHERE d.item_id = i.id AND dep.status != 'complete') ORDER BY it.id, i.priority, i.id LIMIT 10;" +sddv4 context +cargo fmt --all -- --check +cargo check --workspace --all-features +cargo clippy --workspace --all-features -- -D warnings +cargo test --workspace --all-features ``` diff --git a/Cargo.lock b/Cargo.lock index 8c30766..5124716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,7 +412,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mx20022" -version = "0.3.2" +version = "0.4.0" dependencies = [ "mx20022-model", "mx20022-parse", @@ -422,7 +422,7 @@ dependencies = [ [[package]] name = "mx20022-cli" -version = "0.3.2" +version = "0.4.0" dependencies = [ "clap", "mx20022-codegen", @@ -434,7 +434,7 @@ dependencies = [ [[package]] name = "mx20022-codegen" -version = "0.3.2" +version = "0.4.0" dependencies = [ "indexmap", "insta", @@ -448,14 +448,14 @@ dependencies = [ [[package]] name = "mx20022-model" -version = "0.3.2" +version = "0.4.0" dependencies = [ "serde", ] [[package]] name = "mx20022-parse" -version = "0.3.2" +version = "0.4.0" dependencies = [ "criterion", "mx20022-model", @@ -466,7 +466,7 @@ dependencies = [ [[package]] name = "mx20022-translate" -version = "0.3.2" +version = "0.4.0" dependencies = [ "criterion", "mx20022-model", @@ -478,11 +478,12 @@ dependencies = [ [[package]] name = "mx20022-validate" -version = "0.3.2" +version = "0.4.0" dependencies = [ "criterion", "mx20022-model", "mx20022-parse", + "quick-xml", "regex", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 107f0ed..0134f20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,13 +44,13 @@ pretty_assertions = "1" insta = { version = "1", features = ["yaml"] } criterion = { version = "0.5", features = ["html_reports"] } # Internal crates -mx20022 = { path = "crates/mx20022", version = "0.3.2" } -mx20022-model = { path = "crates/mx20022-model", version = "0.3.2" } -mx20022-parse = { path = "crates/mx20022-parse", version = "0.3.2" } -mx20022-validate = { path = "crates/mx20022-validate", version = "0.3.2" } -mx20022-translate = { path = "crates/mx20022-translate", version = "0.3.2" } -mx20022-codegen = { path = "crates/mx20022-codegen", version = "0.3.2" } -mx20022-cli = { path = "crates/mx20022-cli", version = "0.3.2" } +mx20022 = { path = "crates/mx20022", version = "0.4.0" } +mx20022-model = { path = "crates/mx20022-model", version = "0.4.0" } +mx20022-parse = { path = "crates/mx20022-parse", version = "0.4.0" } +mx20022-validate = { path = "crates/mx20022-validate", version = "0.4.0" } +mx20022-translate = { path = "crates/mx20022-translate", version = "0.4.0" } +mx20022-codegen = { path = "crates/mx20022-codegen", version = "0.4.0" } +mx20022-cli = { path = "crates/mx20022-cli", version = "0.4.0" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 95b6e4f..e66e5b0 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,14 @@ use mx20022::parse::{de, envelope}; let xml = std::fs::read_to_string("message.xml")?; -// Detect the message type from the XML namespace +// Detect on the complete stream so wrapper-inherited namespaces remain in +// scope. The Document namespace still wins over a preceding AppHdr namespace. let msg_id = envelope::detect_message_type(&xml)?; println!("Message type: {}", msg_id.dotted()); // e.g. "pacs.008.001.13" -// Deserialize to a strongly-typed struct -let doc: Document = de::from_str(&xml)?; +// Then unwrap the payload Document without copying and deserialize it. +let document_xml = de::document_xml(&xml)?; +let doc: Document = de::from_str(document_xml)?; let hdr = &doc.fi_to_fi_cstmr_cdt_trf.grp_hdr; println!("Message ID: {}", hdr.msg_id.0); println!("Transactions: {}", hdr.nb_of_txs.0); @@ -100,6 +102,9 @@ println!("Transactions: {}", hdr.nb_of_txs.0); use mx20022::validate::schemes::{fednow::FedNowValidator, SchemeValidator}; let validator = FedNowValidator::new(); // $500K default limit +// validate() accepts a bare or transport-wrapped payload Document. For +// pacs.008.001.13 it parses the Document and delegates to typed field rules; +// older pacs.008 versions use the same version-neutral field rules. let result = validator.validate(&xml, "pacs.008.001.13"); if result.is_valid() { diff --git a/crates/mx20022-cli/Cargo.toml b/crates/mx20022-cli/Cargo.toml index feb3719..a1cf396 100644 --- a/crates/mx20022-cli/Cargo.toml +++ b/crates/mx20022-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-cli" -version = "0.3.2" +version = "0.4.0" description = "CLI tool for inspecting, validating, and translating ISO 20022 and SWIFT MT financial messages" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022-cli/src/commands/mod.rs b/crates/mx20022-cli/src/commands/mod.rs index d0695f6..9745417 100644 --- a/crates/mx20022-cli/src/commands/mod.rs +++ b/crates/mx20022-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod codegen; pub mod inspect; pub mod translate; pub mod validate; +mod xml_extract; /// Maximum file size accepted by any command (10 MB). pub(crate) const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; diff --git a/crates/mx20022-cli/src/commands/validate.rs b/crates/mx20022-cli/src/commands/validate.rs index 28dfa4d..36afc9f 100644 --- a/crates/mx20022-cli/src/commands/validate.rs +++ b/crates/mx20022-cli/src/commands/validate.rs @@ -7,21 +7,18 @@ //! When `--scheme` is provided (one of `fednow`, `sepa`, `cbpr`), scheme- //! specific rules are applied in addition to the generic rule checks. //! -//! Because the CLI does not statically know which message version it is -//! processing, field extraction uses lightweight XML scanning rather than -//! fully-typed deserialization. This keeps the command free of feature-gated -//! model types while still exercising the rule engine. +//! Generic checks use command-private XML extraction because the CLI does not +//! statically know every message version. Scheme validation is different: +//! `SchemeValidator::validate` unwraps the payment `Document`, routes by its +//! namespace, and delegates supported versions to typed field rules. use std::path::Path; +use super::xml_extract::{extract_all_attributes, extract_all_elements, extract_element}; use super::MAX_FILE_SIZE; use mx20022_parse::envelope::detect_message_type; use mx20022_validate::schemes::{ - cbpr::CbprPlusValidator, - fednow::FedNowValidator, - sepa::SepaValidator, - xml_scan::{extract_all_attributes, extract_all_elements, extract_element}, - SchemeValidator, + cbpr::CbprPlusValidator, fednow::FedNowValidator, sepa::SepaValidator, SchemeValidator, }; use mx20022_validate::{RuleRegistry, Severity, ValidationResult}; @@ -122,8 +119,27 @@ pub fn run(file: &Path, scheme: Option<&str>) -> Result<(), ValidateError> { }); } let xml = std::fs::read_to_string(file)?; + let scheme_validator = resolve_scheme(scheme)?; + + // Namespace detection reads the complete stream so a Document can inherit + // its namespace from a transport wrapper. The detector still gives the + // Document namespace precedence over AppHdr metadata. + let msg_id = match detect_message_type(&xml) { + Ok(message_id) => message_id, + Err(error) => { + let Some(validator) = scheme_validator.as_deref() else { + return Err(error.into()); + }; - let msg_id = detect_message_type(&xml)?; + // All built-in scheme validators support pacs.008. When namespace + // detection itself fails, route through that supported adapter so + // it can report independent raw findings alongside SCHEME_PARSE. + println!("Validating: {} (message type unresolved)", file.display()); + println!("Scheme: {}", scheme.unwrap_or_else(|| validator.name())); + println!(); + return print_result(&validator.validate(&xml, "pacs.008")); + } + }; println!("Validating: {} ({})", file.display(), msg_id.dotted()); if let Some(s) = scheme { println!("Scheme: {s}"); @@ -210,13 +226,17 @@ pub fn run(file: &Path, scheme: Option<&str>) -> Result<(), ValidateError> { } // --- Scheme-specific validation ---------------------------------------- - let scheme_validator = resolve_scheme(scheme)?; if let Some(validator) = scheme_validator { let scheme_result = validator.validate(&xml, &msg_id.dotted()); result.merge(scheme_result); } - // --- Print results ----------------------------------------------------- + print_result(&result) +} + +/// Print validation findings and convert Error-severity results into the +/// command's non-zero outcome. +fn print_result(result: &ValidationResult) -> Result<(), ValidateError> { let error_count = result.error_count(); let warning_count = result.warning_count(); diff --git a/crates/mx20022-cli/src/commands/xml_extract.rs b/crates/mx20022-cli/src/commands/xml_extract.rs new file mode 100644 index 0000000..7dbdc7d --- /dev/null +++ b/crates/mx20022-cli/src/commands/xml_extract.rs @@ -0,0 +1,75 @@ +//! Private generic field extraction for the version-agnostic CLI checks. + +pub(super) fn extract_element<'a>(xml: &'a str, tag: &str) -> Option<&'a str> { + let close = format!(""); + let prefix_bare = format!("<{tag}>"); + let prefix_attr = format!("<{tag} "); + let tag_start = xml + .find(prefix_bare.as_str()) + .or_else(|| xml.find(prefix_attr.as_str()))?; + let opening_end = xml[tag_start..].find('>')?; + let content_start = tag_start + opening_end + 1; + let content_end = xml[content_start..].find(&close)?; + Some(xml[content_start..content_start + content_end].trim()) +} + +pub(super) fn extract_all_elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { + let close = format!(""); + let prefix_bare = format!("<{tag}>"); + let prefix_attr = format!("<{tag} "); + let mut results = Vec::new(); + let mut remaining = xml; + + loop { + let bare = remaining.find(prefix_bare.as_str()); + let attributed = remaining.find(prefix_attr.as_str()); + let tag_start = match (bare, attributed) { + (None, None) => break, + (Some(position), None) | (None, Some(position)) => position, + (Some(bare), Some(attributed)) => bare.min(attributed), + }; + let Some(opening_end) = remaining[tag_start..].find('>') else { + break; + }; + let content_start = tag_start + opening_end + 1; + let tail = &remaining[content_start..]; + let Some(content_end) = tail.find(&close) else { + break; + }; + results.push(tail[..content_end].trim()); + remaining = &tail[content_end + close.len()..]; + } + + results +} + +pub(super) fn extract_all_attributes<'a>(xml: &'a str, attribute: &str) -> Vec<&'a str> { + let needle = format!("{attribute}=\""); + let mut results = Vec::new(); + let mut remaining = xml; + + while let Some(position) = remaining.find(&needle) { + let value_start = position + needle.len(); + let tail = &remaining[value_start..]; + let Some(value_end) = tail.find('"') else { + break; + }; + results.push(tail[..value_end].trim()); + remaining = &tail[value_end + 1..]; + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_generic_elements_and_attributes() { + let xml = r#"onetwo1"#; + assert_eq!(extract_element(xml, "IBAN"), Some("one")); + assert_eq!(extract_all_elements(xml, "IBAN"), vec!["one", "two"]); + assert_eq!(extract_all_attributes(xml, "Ccy"), vec!["EUR"]); + } +} diff --git a/crates/mx20022-cli/tests/cli_integration.rs b/crates/mx20022-cli/tests/cli_integration.rs index 5f1f48b..17457d7 100644 --- a/crates/mx20022-cli/tests/cli_integration.rs +++ b/crates/mx20022-cli/tests/cli_integration.rs @@ -4,7 +4,7 @@ //! so that real argument parsing, I/O, and exit-code behaviour is exercised. use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Output}; fn bin_path() -> PathBuf { // The integration-test binary is always compiled to the same target tree as @@ -320,6 +320,21 @@ fn scheme_testdata(rel: &str) -> PathBuf { .unwrap_or_else(|_| panic!("scheme testdata path not found: {rel}")) } +fn run_scheme_xml(name: &str, xml: &str, scheme: &str) -> Output { + let path = std::env::temp_dir().join(format!("mx20022-cli-{}-{name}.xml", std::process::id())); + std::fs::write(&path, xml).expect("temporary XML fixture should be writable"); + let output = Command::new(bin_path()) + .args(["validate", &path.to_string_lossy(), "--scheme", scheme]) + .output() + .expect("failed to run mx20022-cli"); + let _ = std::fs::remove_file(path); + output +} + +fn without_xml_declaration(xml: &str) -> &str { + xml.find("?>").map_or(xml, |end| xml[end + 2..].trim()) +} + #[test] fn validate_with_scheme_fednow_valid_exits_zero() { let output = Command::new(bin_path()) @@ -378,6 +393,83 @@ fn validate_with_scheme_cbpr_valid_exits_zero() { String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout) ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("pacs.008.001.13"), + "CBPR envelope should route by its Document namespace, stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +fn validate_with_scheme_accepts_supported_transport_wrappers() { + let fixture = std::fs::read_to_string(scheme_testdata("cbpr/valid_pacs008.xml")).unwrap(); + let payload = without_xml_declaration(&fixture) + .replacen("", "", 1) + .replacen("", "", 1); + + for (name, open, close) in [ + ("envelope", "", ""), + ("request-payload", "", ""), + ( + "bizmsg-payload", + "", + "", + ), + ] { + let xml = format!("{open}{payload}{close}"); + let output = run_scheme_xml(name, &xml, "cbpr"); + assert!( + output.status.success(), + "{name} wrapper should validate, stderr: {}\nstdout: {}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("pacs.008.001.13"), + "{name} wrapper should route the Document namespace" + ); + } +} + +#[test] +fn validate_with_scheme_ignores_document_inside_supplementary_data() { + let fixture = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")).unwrap(); + let xml = without_xml_declaration(&fixture).replacen( + "", + "opaque", + 1, + ); + let output = run_scheme_xml("nested-document", &xml, "fednow"); + assert!( + output.status.success(), + "supplementary Document content should not be an ambiguous payload, stderr: {}\nstdout: {}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +fn validate_with_scheme_resolves_namespace_inherited_from_envelope() { + let fixture = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")).unwrap(); + let document = without_xml_declaration(&fixture).replacen( + " xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13\"", + "", + 1, + ); + let xml = format!( + "{document}" + ); + let output = run_scheme_xml("inherited-document-namespace", &xml, "fednow"); + assert!( + output.status.success(), + "inherited Document namespace should validate, stderr: {}\nstdout: {}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("pacs.008.001.13"), + "CLI should announce the inherited Document namespace" + ); } #[test] @@ -404,6 +496,55 @@ fn validate_with_scheme_fednow_invalid_catches_error() { ); } +#[test] +fn validate_with_scheme_fednow_keeps_size_error_on_schema_failure() { + let fixture = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")).unwrap(); + let document = without_xml_declaration(&fixture).replace(" SLEV\n", ""); + let padding = format!("", "x".repeat(65 * 1024)); + let xml = format!( + "oversized-invalid{padding}{document}" + ); + let output = run_scheme_xml("fednow-oversized-schema-invalid", &xml, "fednow"); + + assert!( + !output.status.success(), + "oversized schema-invalid FedNow input must exit non-zero" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("FEDNOW_MSG_SIZE"), "stdout: {stdout}"); + assert!(stdout.contains("SCHEME_PARSE"), "stdout: {stdout}"); +} + +#[test] +fn validate_with_scheme_fednow_keeps_size_error_on_early_parse_failures() { + let padding = format!("", "x".repeat(65 * 1024)); + let cases = [ + ( + "fednow-oversized-malformed", + format!( + "oversized-malformed{padding}" + ), + ), + ( + "fednow-oversized-undeclared-prefix", + format!( + "oversized-prefix{padding}" + ), + ), + ]; + + for (name, xml) in cases { + let output = run_scheme_xml(name, &xml, "fednow"); + assert!(!output.status.success(), "{name} must exit non-zero"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("FEDNOW_MSG_SIZE"), + "{name} stdout: {stdout}" + ); + assert!(stdout.contains("SCHEME_PARSE"), "{name} stdout: {stdout}"); + } +} + #[test] fn validate_rejects_oversized_file() { let big = std::env::temp_dir().join("mx20022_test_oversized.xml"); @@ -443,6 +584,23 @@ fn validate_with_scheme_sepa_invalid_catches_error() { ); } +#[test] +fn validate_with_scheme_sepa_older_version_keeps_currency_error() { + let fixture = std::fs::read_to_string(scheme_testdata("sepa/invalid_usd.xml")).unwrap(); + let xml = fixture.replace("pacs.008.001.13", "pacs.008.001.08"); + let output = run_scheme_xml("sepa-001-08-invalid-usd", &xml, "sepa"); + assert!( + !output.status.success(), + "older-version SEPA currency violations must remain errors" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("SEPA_CURRENCY"), "stdout: {stdout}"); + assert!( + stdout.contains("SCHEME_UNTYPED_VERSION"), + "stdout: {stdout}" + ); +} + #[test] fn validate_with_scheme_cbpr_invalid_catches_error() { let out = Command::new(bin_path()) diff --git a/crates/mx20022-codegen/Cargo.toml b/crates/mx20022-codegen/Cargo.toml index 774c6d3..79bb8b1 100644 --- a/crates/mx20022-codegen/Cargo.toml +++ b/crates/mx20022-codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-codegen" -version = "0.3.2" +version = "0.4.0" description = "XSD-to-Rust code generator for ISO 20022 message types with constraint validation" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022-model/Cargo.toml b/crates/mx20022-model/Cargo.toml index bcf71f2..fbfb503 100644 --- a/crates/mx20022-model/Cargo.toml +++ b/crates/mx20022-model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-model" -version = "0.3.2" +version = "0.4.0" description = "Strongly-typed ISO 20022 message models generated from official XSD schemas (pacs, pain, camt, head)" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022-parse/Cargo.toml b/crates/mx20022-parse/Cargo.toml index e3f928c..9d348ca 100644 --- a/crates/mx20022-parse/Cargo.toml +++ b/crates/mx20022-parse/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-parse" -version = "0.3.2" +version = "0.4.0" description = "XML parsing and serialization for ISO 20022 MX financial messages via quick-xml and serde" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022-parse/src/de.rs b/crates/mx20022-parse/src/de.rs index 903b4df..c359308 100644 --- a/crates/mx20022-parse/src/de.rs +++ b/crates/mx20022-parse/src/de.rs @@ -12,10 +12,134 @@ //! let _hdr: BusinessApplicationHeaderV04 = from_str(xml).unwrap(); //! ``` +use quick_xml::events::Event; +use quick_xml::Reader; use serde::de::DeserializeOwned; use crate::{envelope::detect_message_type, ParseError}; +fn xml_position(position: u64) -> Result { + usize::try_from(position).map_err(|_| { + ParseError::InvalidEnvelope("XML position exceeds addressable input size".to_owned()) + }) +} + +/// Return the complete ISO 20022 `Document` element from raw XML. +/// +/// The returned slice borrows directly from `xml`. A `Document` may be the +/// root element or appear below a transport wrapper. Namespace prefixes are +/// ignored when matching element names. Once the payload `Document` begins, +/// nested elements also named `Document` are treated as payload content rather +/// than additional candidates. +/// +/// # Errors +/// +/// Returns [`ParseError::InvalidEnvelope`] when the XML is malformed, contains +/// no `Document`, or contains more than one top-level payload candidate. +pub fn document_xml(xml: &str) -> Result<&str, ParseError> { + let mut reader = Reader::from_str(xml); + let mut depth = 0usize; + let mut root_seen = false; + let mut document_start: Option<(usize, usize)> = None; + let mut document_range: Option<(usize, usize)> = None; + + loop { + let event_start = xml_position(reader.buffer_position())?; + let event = reader + .read_event() + .map_err(|error| ParseError::InvalidEnvelope(format!("malformed XML: {error}")))?; + + match event { + Event::Start(element) => { + let local_name = element.local_name(); + let local_name = local_name.as_ref(); + + if depth == 0 { + if root_seen { + return Err(ParseError::InvalidEnvelope( + "XML contains more than one root element".to_owned(), + )); + } + root_seen = true; + } + + if local_name == b"Document" && document_start.is_none() { + if document_range.is_some() { + return Err(ParseError::InvalidEnvelope( + "XML contains multiple Document elements".to_owned(), + )); + } + document_start = Some((event_start, depth)); + } + + depth += 1; + } + Event::Empty(element) => { + let local_name = element.local_name(); + let local_name = local_name.as_ref(); + + if depth == 0 { + if root_seen { + return Err(ParseError::InvalidEnvelope( + "XML contains more than one root element".to_owned(), + )); + } + root_seen = true; + } + + if local_name == b"Document" && document_start.is_none() { + if document_range.is_some() { + return Err(ParseError::InvalidEnvelope( + "XML contains multiple Document elements".to_owned(), + )); + } + document_range = Some((event_start, xml_position(reader.buffer_position())?)); + } + } + Event::End(element) => { + if depth == 0 { + return Err(ParseError::InvalidEnvelope( + "XML contains an unmatched closing element".to_owned(), + )); + } + + let local_name = element.local_name(); + let local_name = local_name.as_ref(); + depth -= 1; + + if local_name == b"Document" { + if let Some((start, document_depth)) = document_start { + if depth == document_depth { + document_range = Some((start, xml_position(reader.buffer_position())?)); + document_start = None; + } + } + } + } + Event::Text(text) if depth == 0 => { + if !text.as_ref().iter().all(u8::is_ascii_whitespace) { + return Err(ParseError::InvalidEnvelope( + "non-whitespace content appears outside the root element".to_owned(), + )); + } + } + Event::Eof => break, + _ => {} + } + } + + if depth != 0 || document_start.is_some() { + return Err(ParseError::InvalidEnvelope( + "Document element is not closed".to_owned(), + )); + } + + let (start, end) = document_range.ok_or_else(|| { + ParseError::InvalidEnvelope("expected one Document payload element".to_owned()) + })?; + Ok(&xml[start..end]) +} + /// Deserialize an ISO 20022 XML message from a string slice. /// /// The root element name must match the serde rename of the target type. @@ -95,6 +219,73 @@ mod tests { assert!(result.is_err(), "malformed XML must return an error"); } + #[test] + fn document_xml_returns_bare_document_without_copying() { + let xml = r#""#; + let document = document_xml(xml).unwrap(); + assert_eq!(document, xml); + assert_eq!(document.as_ptr(), xml.as_ptr()); + } + + #[test] + fn document_xml_unwraps_envelope_document() { + let xml = r#""#; + assert_eq!( + document_xml(xml).unwrap(), + r#""# + ); + } + + #[test] + fn document_xml_unwraps_arbitrary_transport_wrappers() { + for xml in [ + "", + "", + "", + ] { + assert_eq!(document_xml(xml).unwrap(), ""); + } + } + + #[test] + fn document_xml_ignores_nested_document_content() { + let xml = ""; + assert_eq!( + document_xml(xml).unwrap(), + "" + ); + } + + #[test] + fn document_xml_matches_prefixed_local_names() { + let xml = r#""#; + assert_eq!( + document_xml(xml).unwrap(), + r#""# + ); + } + + #[test] + fn document_xml_rejects_missing_document() { + let error = document_xml("").unwrap_err(); + assert!(matches!(error, ParseError::InvalidEnvelope(_))); + } + + #[test] + fn document_xml_rejects_malformed_nesting() { + let error = document_xml("").unwrap_err(); + assert!(matches!(error, ParseError::InvalidEnvelope(_))); + } + + #[test] + fn document_xml_rejects_multiple_candidates() { + let error = + document_xml("") + .unwrap_err(); + assert!(matches!(error, ParseError::InvalidEnvelope(_))); + assert!(error.to_string().contains("multiple Document")); + } + #[test] fn from_reader_invalid_xml_returns_error() { let xml = b"<<>>"; diff --git a/crates/mx20022-parse/src/envelope.rs b/crates/mx20022-parse/src/envelope.rs index 1800cc2..2266594 100644 --- a/crates/mx20022-parse/src/envelope.rs +++ b/crates/mx20022-parse/src/envelope.rs @@ -14,6 +14,10 @@ //! - variant → `"001"` //! - version → `"13"` +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; +use quick_xml::reader::NsReader; + use crate::ParseError; /// Message type identifier extracted from an ISO 20022 XML namespace URI. @@ -73,11 +77,12 @@ pub fn parse_namespace(ns: &str) -> Result { }) } -/// Extract the ISO 20022 message type from the root element's `xmlns` attribute. +/// Extract the authoritative ISO 20022 message type from XML namespace data. /// -/// Scans the raw XML for the first occurrence of an `xmlns` attribute (or -/// `xmlns=` on the root element) whose value matches the ISO 20022 namespace -/// pattern. +/// When the stream contains a `Document` element, its resolved namespace wins +/// over namespaces used by transport metadata such as `AppHdr`. This also +/// handles namespaces inherited from wrapper elements. For messages without a +/// `Document`, the first ISO 20022 element namespace is returned. /// /// # Errors /// @@ -96,47 +101,54 @@ pub fn parse_namespace(ns: &str) -> Result { /// assert_eq!(id.version, "13"); /// ``` pub fn detect_message_type(xml: &str) -> Result { - // Walk through all xmlns="..." occurrences and return the first that parses. - let mut search = xml; - while let Some(pos) = search.find("xmlns") { - let after_xmlns = &search[pos + 5..]; - - // Skip optional namespace prefix (xmlns:foo=) or plain xmlns= - let after_eq = if let Some(p) = after_xmlns.find('=') { - // Make sure there is no whitespace or '>' between "xmlns" and '=' - let between = &after_xmlns[..p]; - if between.contains('>') || between.contains('<') { - search = &search[pos + 5..]; - continue; - } - &after_xmlns[p + 1..] - } else { - search = &search[pos + 5..]; - continue; - }; - - // Skip leading whitespace then expect a quote - let after_eq = after_eq.trim_start(); - let Some(quote_char @ ('"' | '\'')) = after_eq.chars().next() else { - search = &search[pos + 5..]; - continue; - }; - - let after_open_quote = &after_eq[1..]; - if let Some(close_pos) = after_open_quote.find(quote_char) { - let ns_value = &after_open_quote[..close_pos]; - if ns_value.starts_with(NS_PREFIX) { - return parse_namespace(ns_value); + let mut reader = NsReader::from_str(xml); + let mut first_iso_namespace = None; + + loop { + let (namespace, event) = reader.read_resolved_event().map_err(|error| { + ParseError::InvalidEnvelope(format!("malformed XML envelope: {error}")) + })?; + + match event { + Event::Start(element) | Event::Empty(element) => { + let is_document = element.local_name().as_ref() == b"Document"; + match namespace { + ResolveResult::Bound(namespace) => { + let namespace = + std::str::from_utf8(namespace.as_ref()).map_err(|error| { + ParseError::InvalidEnvelope(format!( + "element namespace is not UTF-8: {error}" + )) + })?; + if is_document { + return parse_namespace(namespace); + } + if first_iso_namespace.is_none() && namespace.starts_with(NS_PREFIX) { + first_iso_namespace = Some(parse_namespace(namespace)?); + } + } + ResolveResult::Unbound if is_document => { + return Err(ParseError::InvalidEnvelope( + "Document element has no namespace".to_owned(), + )); + } + ResolveResult::Unknown(prefix) if is_document => { + return Err(ParseError::InvalidEnvelope(format!( + "Document namespace prefix is not declared: {}", + String::from_utf8_lossy(&prefix) + ))); + } + ResolveResult::Unbound | ResolveResult::Unknown(_) => {} + } } + Event::Eof => break, + _ => {} } - - // Advance past this xmlns occurrence and keep looking - search = &search[pos + 5..]; } - Err(ParseError::InvalidEnvelope( - "no ISO 20022 namespace found in XML document".to_owned(), - )) + first_iso_namespace.ok_or_else(|| { + ParseError::InvalidEnvelope("no ISO 20022 namespace found in XML document".to_owned()) + }) } #[cfg(test)] @@ -211,4 +223,22 @@ mod tests { assert_eq!(id.msg_id, "002"); assert_eq!(id.version, "14"); } + + #[test] + fn detect_message_type_prefers_document_over_apphdr() { + let xml = r#""#; + assert_eq!( + detect_message_type(xml).unwrap().dotted(), + "pacs.008.001.13" + ); + } + + #[test] + fn detect_message_type_resolves_inherited_document_namespace() { + let xml = r#""#; + assert_eq!( + detect_message_type(xml).unwrap().dotted(), + "pacs.008.001.13" + ); + } } diff --git a/crates/mx20022-translate/Cargo.toml b/crates/mx20022-translate/Cargo.toml index cfee0cc..09a14eb 100644 --- a/crates/mx20022-translate/Cargo.toml +++ b/crates/mx20022-translate/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-translate" -version = "0.3.2" +version = "0.4.0" description = "Bidirectional SWIFT MT↔ISO 20022 MX translation: MT103↔pacs.008, MT202↔pacs.009, MT940↔camt.053" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022-validate/Cargo.toml b/crates/mx20022-validate/Cargo.toml index 8b35106..a06e8a7 100644 --- a/crates/mx20022-validate/Cargo.toml +++ b/crates/mx20022-validate/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022-validate" -version = "0.3.2" +version = "0.4.0" description = "ISO 20022 message validation: XSD constraints, IBAN/BIC/LEI rules, FedNow, SEPA, and CBPR+ scheme checks" edition.workspace = true rust-version.workspace = true @@ -13,11 +13,12 @@ categories = ["encoding", "finance"] [dependencies] mx20022-model = { workspace = true, features = ["all"] } +mx20022-parse.workspace = true +quick-xml.workspace = true thiserror.workspace = true regex.workspace = true [dev-dependencies] -mx20022-parse = { workspace = true } criterion.workspace = true [[bench]] diff --git a/crates/mx20022-validate/src/schemes/cbpr.rs b/crates/mx20022-validate/src/schemes/cbpr.rs index 7ab3f98..3f80087 100644 --- a/crates/mx20022-validate/src/schemes/cbpr.rs +++ b/crates/mx20022-validate/src/schemes/cbpr.rs @@ -16,7 +16,6 @@ use std::any::Any; -use super::xml_scan::{extract_all_elements, extract_element, has_element}; use super::SchemeValidator; use crate::error::{Severity, ValidationError, ValidationResult}; @@ -47,9 +46,6 @@ impl Default for CbprPlusValidator { } } -/// Valid `ChrgBr` values under CBPR+. -const VALID_CHRGBR: &[&str] = &["CRED", "DEBT", "SHAR", "SLEV"]; - impl SchemeValidator for CbprPlusValidator { fn name(&self) -> &'static str { "CBPR+" @@ -62,116 +58,95 @@ impl SchemeValidator for CbprPlusValidator { } fn validate(&self, xml: &str, message_type: &str) -> ValidationResult { - let short_type = super::short_message_type(message_type); - - if !self.supported_messages().contains(&short_type.as_str()) { - return ValidationResult::default(); - } - - let mut errors: Vec = Vec::new(); + super::run_xml( + self, + xml, + message_type, + |xml, _| Self::validate_raw(xml), + Self::validate_pacs008, + ) + } - // --- UTF-8 control character check ---------------------------------- - // The XML string is already valid UTF-8 (Rust strings), so we only need - // to look for disallowed control characters. - check_control_characters(xml, &mut errors); + fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option { + use mx20022_model::generated::pacs::pacs_008_001_13; - // --- Business Application Header ------------------------------------ - if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") { - errors.push(ValidationError::new( - "/AppHdr", - Severity::Error, - "CBPR_BAH_REQUIRED", - "CBPR+ requires a Business Application Header (AppHdr / BizMsgIdr)", - )); + let short_type = super::short_message_type(message_type); + if !self.supported_messages().contains(&short_type.as_str()) { + return None; } - // Field-level checks are pacs.008-specific. if short_type != "pacs.008" { - return ValidationResult::new(errors); + return None; } - // --- Mandatory BICs ------------------------------------------------- - let bic_fields: &[(&str, &str, &str)] = &[ - ( - "InstgAgt", - "CBPR_INSTG_AGT_BIC", - "/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI", - ), - ( - "InstdAgt", - "CBPR_INSTD_AGT_BIC", - "/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI", - ), - ( - "DbtrAgt", - "CBPR_DBTR_AGT_BIC", - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI", - ), - ( - "CdtrAgt", - "CBPR_CDTR_AGT_BIC", - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI", - ), - ]; - for (parent, rule_id, path) in bic_fields { - check_bic_in_parent(xml, parent, path, rule_id, &mut errors); - } + let doc = msg.downcast_ref::()?; - // --- Debtor and creditor names required ----------------------------- - check_name_required(xml, "Dbtr", "CBPR_DBTR_NM_REQUIRED", &mut errors); - check_name_required(xml, "Cdtr", "CBPR_CDTR_NM_REQUIRED", &mut errors); + Some(Self::validate_pacs008(&super::pacs008::Facts::from(doc))) + } +} - // --- UETR required -------------------------------------------------- - if !has_element(xml, "UETR") { +impl CbprPlusValidator { + /// Validate rules that require the original XML envelope. + fn validate_raw(xml: &str) -> ValidationResult { + let mut errors = Vec::new(); + + if let Some((offset, character)) = super::raw::first_disallowed_control(xml) { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR", + "/Document", Severity::Error, - "CBPR_UETR_REQUIRED", - "CBPR+ requires a UETR in PmtId", + "CBPR_CONTROL_CHAR", + format!( + "Disallowed control character U+{:04X} at byte offset {offset}", + character as u32 + ), )); } - // --- End-to-end ID required ----------------------------------------- - if !has_element(xml, "EndToEndId") { + let (has_app_header, has_business_message_id) = super::raw::header_presence(xml); + if !has_app_header && !has_business_message_id { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", + "/AppHdr", Severity::Error, - "CBPR_E2E_REQUIRED", - "CBPR+ requires an EndToEndId in PmtId", + "CBPR_BAH_REQUIRED", + "CBPR+ requires a Business Application Header (AppHdr / BizMsgIdr)", )); } - // --- ChrgBr required and must be valid ------------------------------ - if let Some(chrg_br) = extract_element(xml, "ChrgBr") { - if !VALID_CHRGBR.contains(&chrg_br) { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", - Severity::Error, - "CBPR_CHRGBR_VALUE", - format!("ChrgBr must be one of CRED, DEBT, SHAR, SLEV; got \"{chrg_br}\""), - )); - } - } else { + ValidationResult::new(errors) + } + + /// Validate version-neutral pacs.008 facts under CBPR+ rules. + fn validate_pacs008(facts: &super::pacs008::Facts) -> ValidationResult { + let mut errors: Vec = Vec::new(); + + // --- Instructing agent BIC required (GrpHdr level) ------------------ + if facts.instg_agent_bic.is_none() { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", + "/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI", Severity::Error, - "CBPR_CHRGBR_REQUIRED", - "CBPR+ requires ChrgBr (one of CRED, DEBT, SHAR, SLEV)", + "CBPR_INSTG_AGT_BIC", + "InstgAgt/FinInstnId/BICFI is required for CBPR+", )); } - // --- Interbank settlement date required ----------------------------- - if !has_element(xml, "IntrBkSttlmDt") { + // --- Instructed agent BIC required (GrpHdr level) ------------------- + if facts.instd_agent_bic.is_none() { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt", + "/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI", Severity::Error, - "CBPR_STTLM_DT_REQUIRED", - "CBPR+ requires IntrBkSttlmDt", + "CBPR_INSTD_AGT_BIC", + "InstdAgt/FinInstnId/BICFI is required for CBPR+", )); } - // --- BIC padding check (8-char BICs should be 11) ------------------ - for bic in extract_all_elements(xml, "BICFI") { + // --- BIC padding check for GrpHdr-level agents --- + for bic in [ + facts.instg_agent_bic.as_deref(), + facts.instd_agent_bic.as_deref(), + ] + .into_iter() + .flatten() + { if bic.len() == 8 { errors.push(ValidationError::new( "//BICFI", @@ -184,81 +159,9 @@ impl SchemeValidator for CbprPlusValidator { } } - ValidationResult::new(errors) - } - - fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option { - use mx20022_model::generated::pacs::pacs_008_001_13; - - let short_type = super::short_message_type(message_type); - if !self.supported_messages().contains(&short_type.as_str()) { - return None; - } - - if short_type != "pacs.008" { - return None; - } - - let doc = msg.downcast_ref::()?; - - Some(self.validate_pacs008_typed(doc)) - } -} - -impl CbprPlusValidator { - /// Typed validation for pacs.008 messages under CBPR+ rules. - #[allow(clippy::unused_self)] - fn validate_pacs008_typed( - &self, - doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document, - ) -> ValidationResult { - let mut errors: Vec = Vec::new(); - let msg = &doc.fi_to_fi_cstmr_cdt_trf; - - // --- Instructing agent BIC required (GrpHdr level) ------------------ - check_bic_typed( - msg.grp_hdr.instg_agt.as_ref(), - "InstgAgt", - "/Document/FIToFICstmrCdtTrf/GrpHdr/InstgAgt/FinInstnId/BICFI", - "CBPR_INSTG_AGT_BIC", - &mut errors, - ); - - // --- Instructed agent BIC required (GrpHdr level) ------------------- - check_bic_typed( - msg.grp_hdr.instd_agt.as_ref(), - "InstdAgt", - "/Document/FIToFICstmrCdtTrf/GrpHdr/InstdAgt/FinInstnId/BICFI", - "CBPR_INSTD_AGT_BIC", - &mut errors, - ); - - // --- BIC padding check for GrpHdr-level agents --- - for agent in [ - msg.grp_hdr.instg_agt.as_ref(), - msg.grp_hdr.instd_agt.as_ref(), - ] - .into_iter() - .flatten() - { - if let Some(bic) = &agent.fin_instn_id.bicfi { - if bic.0.len() == 8 { - errors.push(ValidationError::new( - "//BICFI", - Severity::Warning, - "CBPR_BIC_PADDING", - format!( - "CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)", - bic.0 - ), - )); - } - } - } - - for tx in &msg.cdt_trf_tx_inf { + for tx in &facts.transactions { // --- Debtor agent BIC required ---------------------------------- - if tx.dbtr_agt.fin_instn_id.bicfi.is_none() { + if tx.debtor_agent_bic.is_none() { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/DbtrAgt/FinInstnId/BICFI", Severity::Error, @@ -268,7 +171,7 @@ impl CbprPlusValidator { } // --- Creditor agent BIC required -------------------------------- - if tx.cdtr_agt.fin_instn_id.bicfi.is_none() { + if tx.creditor_agent_bic.is_none() { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/CdtrAgt/FinInstnId/BICFI", Severity::Error, @@ -279,27 +182,26 @@ impl CbprPlusValidator { // --- BIC padding check (8-char BICs should be 11) --------------- for bic in [ - tx.dbtr_agt.fin_instn_id.bicfi.as_ref(), - tx.cdtr_agt.fin_instn_id.bicfi.as_ref(), + tx.debtor_agent_bic.as_deref(), + tx.creditor_agent_bic.as_deref(), ] .into_iter() .flatten() { - if bic.0.len() == 8 { + if bic.len() == 8 { errors.push(ValidationError::new( "//BICFI", Severity::Warning, "CBPR_BIC_PADDING", format!( - "CBPR+ recommends 11-character BICs; \"{}\" is 8 characters (pad with XXX)", - bic.0 + "CBPR+ recommends 11-character BICs; \"{bic}\" is 8 characters (pad with XXX)" ), )); } } // --- Debtor name required --------------------------------------- - if tx.dbtr.nm.is_none() { + if tx.debtor_name.is_none() { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm", Severity::Error, @@ -309,7 +211,7 @@ impl CbprPlusValidator { } // --- Creditor name required ------------------------------------- - if tx.cdtr.nm.is_none() { + if tx.creditor_name.is_none() { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm", Severity::Error, @@ -319,7 +221,7 @@ impl CbprPlusValidator { } // --- UETR required ---------------------------------------------- - if tx.pmt_id.uetr.is_none() { + if tx.uetr.is_none() { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR", Severity::Error, @@ -328,15 +230,37 @@ impl CbprPlusValidator { )); } - // --- End-to-end ID is a required field (always present) ---------- - // (Max35Text is required on PaymentIdentification13, nothing to - // check beyond XSD.) + // --- End-to-end ID required ------------------------------------- + if tx.end_to_end_id.is_none() { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", + Severity::Error, + "CBPR_E2E_REQUIRED", + "CBPR+ requires an EndToEndId in PmtId", + )); + } - // ChrgBr validity is enforced by the ChargeBearerType1Code enum — all - // variants (Cred, Debt, Shar, Slev) are valid for CBPR+. + // --- Charges bearer required and schema-valid ------------------ + match tx.charge_bearer.as_deref() { + Some("CRED" | "DEBT" | "SHAR" | "SLEV") => {} + Some(charge_bearer) => errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", + Severity::Error, + "CBPR_CHRGBR_VALUE", + format!( + "ChrgBr must be one of CRED, DEBT, SHAR, SLEV; got \"{charge_bearer}\"" + ), + )), + None => errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", + Severity::Error, + "CBPR_CHRGBR_REQUIRED", + "CBPR+ requires ChrgBr (one of CRED, DEBT, SHAR, SLEV)", + )), + } // --- Interbank settlement date required ------------------------- - if tx.intr_bk_sttlm_dt.is_none() { + if !tx.has_settlement_date { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmDt", Severity::Error, @@ -346,92 +270,10 @@ impl CbprPlusValidator { } } - // Note: AppHdr check and UTF-8 control character check require raw - // XML context and are not covered by the typed path. - ValidationResult::new(errors) } } -/// Check that a `BICFI` element exists inside a parent block (XML scan). -fn check_bic_in_parent( - xml: &str, - parent_tag: &str, - path: &str, - rule_id: &str, - errors: &mut Vec, -) { - super::common::check_bic_in_parent(xml, parent_tag, path, rule_id, "CBPR+", errors); -} - -/// Check that a `Nm` element exists inside a parent block (XML scan). -fn check_name_required( - xml: &str, - parent_tag: &str, - rule_id: &str, - errors: &mut Vec, -) { - let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}"); - super::common::check_name_in_parent( - xml, parent_tag, None, &path, rule_id, "CBPR+", errors, true, - ); -} - -/// Check that a BIC is present in an optional agent struct (typed). -fn check_bic_typed( - agent: Option< - &mx20022_model::generated::pacs::pacs_008_001_13::BranchAndFinancialInstitutionIdentification8, - >, - parent_name: &str, - path: &str, - rule_id: &str, - errors: &mut Vec, -) { - match agent { - None => { - errors.push(ValidationError::new( - path, - Severity::Error, - rule_id, - format!("{parent_name}/FinInstnId/BICFI is required for CBPR+ but the parent element is missing"), - )); - } - Some(agt) if agt.fin_instn_id.bicfi.is_none() => { - errors.push(ValidationError::new( - path, - Severity::Error, - rule_id, - format!("{parent_name}/FinInstnId/BICFI is required for CBPR+"), - )); - } - Some(_) => {} - } -} - -/// Check for disallowed control characters in the XML string. -/// -/// CBPR+ requires UTF-8 encoding with no control characters except: -/// - LF (0x0A) -/// - CR (0x0D) -/// - TAB (0x09) -fn check_control_characters(xml: &str, errors: &mut Vec) { - for (i, c) in xml.char_indices() { - if c.is_control() && !matches!(c, '\n' | '\r' | '\t') { - errors.push(ValidationError::new( - "/Document", - Severity::Error, - "CBPR_CONTROL_CHAR", - format!( - "Disallowed control character U+{:04X} at byte offset {i}", - c as u32 - ), - )); - // Report only the first occurrence to avoid noise. - break; - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -456,16 +298,15 @@ mod tests { #[test] fn control_character_produces_error() { - let mut errors = Vec::new(); - check_control_characters("hello\x01world", &mut errors); - assert_eq!(errors.len(), 1); - assert_eq!(errors[0].rule_id, "CBPR_CONTROL_CHAR"); + let result = CbprPlusValidator::validate_raw("hello\x01world"); + assert_eq!(result.errors[0].rule_id, "CBPR_CONTROL_CHAR"); } #[test] fn allowed_whitespace_is_fine() { - let mut errors = Vec::new(); - check_control_characters("hello\nworld\r\n\t", &mut errors); - assert!(errors.is_empty()); + let result = CbprPlusValidator::validate_raw( + "idhello\nworld\r\n\t", + ); + assert!(result.errors.is_empty()); } } diff --git a/crates/mx20022-validate/src/schemes/common.rs b/crates/mx20022-validate/src/schemes/common.rs index 7eed89b..d3b79f1 100644 --- a/crates/mx20022-validate/src/schemes/common.rs +++ b/crates/mx20022-validate/src/schemes/common.rs @@ -1,153 +1,27 @@ -//! Shared helpers for scheme validators. -//! -//! This module consolidates utility functions used across multiple scheme -//! validators (`FedNow`, SEPA, `CBPR+`), reducing code duplication and ensuring -//! consistent behaviour for common operations like amount parsing and -//! parent-block extraction. - -use super::xml_scan::{extract_element, has_element}; -use crate::error::{Severity, ValidationError}; - -/// Extract the content between `...`. -/// -/// Returns `None` and pushes an error if: -/// - `require_parent` is `true` and the parent tag is missing. -/// - The parent tag exists but is unclosed (malformed XML). -/// -/// Returns `None` silently if the parent tag is absent and `require_parent` -/// is `false`. -pub(crate) fn extract_parent_block<'a>( - xml: &'a str, - parent_tag: &str, - path_prefix: &str, - rule_id: &str, - errors: &mut Vec, - require_parent: bool, -) -> Option<&'a str> { - let open = format!("<{parent_tag}>"); - let close = format!(""); - let Some(start) = xml.find(&open) else { - if require_parent { - errors.push(ValidationError::new( - path_prefix, - Severity::Error, - rule_id, - format!("{parent_tag} element is missing"), - )); - } - return None; - }; - let after = start + open.len(); - let Some(end) = xml[after..].find(&close) else { - errors.push(ValidationError::new( - path_prefix, - Severity::Error, - rule_id, - format!("{parent_tag} element is unclosed; XML structure invalid"), - )); - return None; - }; - Some(&xml[after..after + end]) -} - -/// Check that a `` element exists inside a parent block and optionally -/// enforce a maximum length. -/// -/// If `max_len` is `Some(n)`, the name length is checked against `n`. -#[allow(clippy::too_many_arguments)] -pub(crate) fn check_name_in_parent( - xml: &str, - parent_tag: &str, - max_len: Option, - path_prefix: &str, - rule_id: &str, - scheme_name: &str, - errors: &mut Vec, - require_name: bool, -) { - let Some(block) = extract_parent_block( - xml, - parent_tag, - path_prefix, - rule_id, - errors, - true, // parent is always required when checking names - ) else { - return; - }; - - match extract_element(block, "Nm") { - None if require_name => { - errors.push(ValidationError::new( - format!("{path_prefix}/Nm"), - Severity::Error, - rule_id, - format!("{parent_tag}/Nm is required for {scheme_name}"), - )); - } - Some(nm) if max_len.is_some_and(|max| nm.chars().count() > max) => { - let max = max_len.unwrap(); - errors.push(ValidationError::new( - format!("{path_prefix}/Nm"), - Severity::Error, - rule_id, - format!( - "{parent_tag}/Nm must be at most {max} characters; got {} characters", - nm.chars().count() - ), - )); - } - _ => {} - } -} - -/// Check that a `BICFI` element exists inside a parent block. -pub(crate) fn check_bic_in_parent( - xml: &str, - parent_tag: &str, - path: &str, - rule_id: &str, - scheme_name: &str, - errors: &mut Vec, -) { - let Some(block) = extract_parent_block(xml, parent_tag, path, rule_id, errors, true) else { - return; - }; - if !has_element(block, "BICFI") { - errors.push(ValidationError::new( - path, - Severity::Error, - rule_id, - format!("{parent_tag}/FinInstnId/BICFI is required for {scheme_name}"), - )); - } -} +//! Amount parsers shared by typed scheme validators. /// Parse a decimal amount string like `"1000.50"` into integer cents (`100050`). /// /// # Contract /// /// The input **must** contain exactly one `.` followed by exactly **2** decimal -/// digits (e.g. `"100.50"`, `"0.01"`). This matches the format enforced by the -/// XSD `ActiveCurrencyAndAmount` type that all callers validate against before -/// reaching this function. +/// digits (e.g. `"100.50"`, `"0.01"`). Scheme validation deliberately does +/// not run generated XSD constraints, so this parser rejects invalid lengths +/// and arithmetic overflow itself. /// /// Returns `None` for non-conforming input: /// - No decimal point (e.g. `"100"`) /// - Integer or fractional part fails `u64` parsing (e.g. `"abc.50"`, `"100.ab"`) /// -/// A `debug_assert!` fires in test/dev builds if the fractional part is not -/// exactly 2 digits, catching misuse early without penalising release builds. pub(crate) fn parse_amount_cents(s: &str) -> Option { let dot = s.find('.')?; let integer: u64 = s[..dot].parse().ok()?; let frac_str = &s[dot + 1..]; - debug_assert!( - frac_str.len() == 2, - "parse_amount_cents expects exactly 2 decimal digits, got {frac_str:?}" - ); + if frac_str.len() != 2 { + return None; + } let frac: u64 = frac_str.parse().ok()?; - Some(integer * 100 + frac) + integer.checked_mul(100)?.checked_add(frac) } /// Like [`parse_amount_cents`], but accepts 0–2 decimal digits. @@ -167,10 +41,10 @@ pub(crate) fn parse_amount_cents_lenient(s: &str) -> Option { 2 => frac_str.parse().ok()?, _ => return None, }; - Some(integer * 100 + frac) + integer.checked_mul(100)?.checked_add(frac) } else { let integer: u64 = s.parse().ok()?; - Some(integer * 100) + integer.checked_mul(100) } } @@ -208,6 +82,13 @@ mod tests { assert_eq!(parse_amount_cents("100.ab"), None); } + #[test] + fn parse_amount_cents_rejects_wrong_fraction_length_and_overflow() { + assert_eq!(parse_amount_cents("100.5"), None); + assert_eq!(parse_amount_cents("100.500"), None); + assert_eq!(parse_amount_cents("184467440737095517.00"), None); + } + #[test] fn lenient_no_dot() { assert_eq!(parse_amount_cents_lenient("1000"), Some(100_000)); @@ -229,34 +110,7 @@ mod tests { } #[test] - fn extract_parent_block_missing_optional() { - let mut errors = Vec::new(); - let result = extract_parent_block("", "Dbtr", "/path", "RULE", &mut errors, false); - assert!(result.is_none()); - assert!(errors.is_empty(), "should not push error when not required"); - } - - #[test] - fn extract_parent_block_missing_required() { - let mut errors = Vec::new(); - let result = extract_parent_block("", "Dbtr", "/path", "RULE", &mut errors, true); - assert!(result.is_none()); - assert_eq!(errors.len(), 1); - } - - #[test] - fn extract_parent_block_unclosed() { - let mut errors = Vec::new(); - let result = extract_parent_block( - "Alice", - "Dbtr", - "/path", - "RULE", - &mut errors, - false, - ); - assert!(result.is_none()); - assert_eq!(errors.len(), 1); - assert!(errors[0].message.contains("unclosed")); + fn lenient_rejects_overflow() { + assert_eq!(parse_amount_cents_lenient("184467440737095517"), None); } } diff --git a/crates/mx20022-validate/src/schemes/fednow.rs b/crates/mx20022-validate/src/schemes/fednow.rs index 415f05b..51585e4 100644 --- a/crates/mx20022-validate/src/schemes/fednow.rs +++ b/crates/mx20022-validate/src/schemes/fednow.rs @@ -18,7 +18,6 @@ use std::sync::OnceLock; use regex::Regex; -use super::xml_scan::{extract_attribute, extract_element, has_element, xml_byte_size}; use super::SchemeValidator; use crate::error::{Severity, ValidationError, ValidationResult}; @@ -97,17 +96,36 @@ impl SchemeValidator for FedNowValidator { } fn validate(&self, xml: &str, message_type: &str) -> ValidationResult { - let short_type = super::short_message_type(message_type); + super::run_xml(self, xml, message_type, Self::validate_raw, |facts| { + self.validate_pacs008(facts) + }) + } + + fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option { + use mx20022_model::generated::pacs::pacs_008_001_13; + let short_type = super::short_message_type(message_type); if !self.supported_messages().contains(&short_type.as_str()) { - return ValidationResult::default(); + return None; } - let mut errors: Vec = Vec::new(); + // Only pacs.008 has typed field-level checks. + if short_type != "pacs.008" { + return None; + } + + let doc = msg.downcast_ref::()?; + + Some(self.validate_pacs008(&super::pacs008::Facts::from(doc))) + } +} - // --- Message size --------------------------------------------------- - let size = xml_byte_size(xml); - let size_limit: usize = if short_type == "pacs.028" { +impl FedNowValidator { + /// Validate rules that require the original XML envelope. + fn validate_raw(xml: &str, short_type: &str) -> ValidationResult { + let mut errors = Vec::new(); + let size = super::raw::byte_len(xml); + let size_limit = if short_type == "pacs.028" { 32 * 1024 } else { 64 * 1024 @@ -123,165 +141,21 @@ impl SchemeValidator for FedNowValidator { )); } - // The remaining checks are pacs.008-specific field rules. - if short_type != "pacs.008" { - return ValidationResult::new(errors); - } - - // --- NbOfTxs must be "1" ------------------------------------------- - if let Some(nb) = extract_element(xml, "NbOfTxs") { - if nb != "1" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", - Severity::Error, - "FEDNOW_SINGLE_TX", - format!( - "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{nb}\"" - ), - )); - } - } - - // --- Settlement method must be CLRG --------------------------------- - if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") { - if sttlm_mtd != "CLRG" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", - Severity::Error, - "FEDNOW_STTLM_MTD", - format!("FedNow requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""), - )); - } - } - - // --- ChrgBr must be SLEV -------------------------------------------- - if let Some(chrg_br) = extract_element(xml, "ChrgBr") { - if chrg_br != "SLEV" { + if short_type == "pacs.008" { + let (has_app_header, has_business_message_id) = super::raw::header_presence(xml); + if !has_app_header && !has_business_message_id { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", - Severity::Error, - "FEDNOW_CHRGBR", - format!("FedNow requires ChrgBr = \"SLEV\", got \"{chrg_br}\""), + "/AppHdr", + Severity::Warning, + "FEDNOW_APPHDR_MISSING", + "Business Application Header (AppHdr) is recommended for FedNow messages", )); } } - // --- Currency must be USD ------------------------------------------- - if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") { - if ccy != "USD" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", - Severity::Error, - "FEDNOW_CURRENCY", - format!("FedNow only accepts USD transactions; found currency \"{ccy}\""), - )); - } - } - - // --- Amount range --------------------------------------------------- - if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") { - self.validate_amount( - amt_str, - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", - &mut errors, - ); - } - - // --- UETR is required and must be UUID v4 --------------------------- - if let Some(uetr) = extract_element(xml, "UETR") { - if !is_valid_uetr(uetr) { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR", - Severity::Error, - "FEDNOW_UETR_FORMAT", - format!("UETR must be a valid UUID v4; got \"{uetr}\""), - )); - } - } else { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR", - Severity::Error, - "FEDNOW_UETR_REQUIRED", - "FedNow requires a UETR (UUID v4) in PmtId", - )); - } - - // --- End-to-end ID is required and max 35 chars --------------------- - if let Some(e2e) = extract_element(xml, "EndToEndId") { - if e2e.chars().count() > 35 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", - Severity::Error, - "FEDNOW_E2E_LENGTH", - format!( - "EndToEndId must be at most 35 characters; got {} characters", - e2e.chars().count() - ), - )); - } - } else { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", - Severity::Error, - "FEDNOW_E2E_REQUIRED", - "FedNow requires an EndToEndId in PmtId", - )); - } - - // --- Debtor name max 140 chars -------------------------------------- - // We check the first inside . A simple heuristic: scan for - // the Dbtr block and extract the Nm within it. - check_name_length(xml, "Dbtr", &mut errors, "FEDNOW_DBTR_NM_LENGTH"); - check_name_length(xml, "Cdtr", &mut errors, "FEDNOW_CDTR_NM_LENGTH"); - - // --- RmtInf/Ustrd max 140 chars per element ------------------------- - for ustrd in super::xml_scan::extract_all_elements(xml, "Ustrd") { - if ustrd.chars().count() > 140 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd", - Severity::Error, - "FEDNOW_USTRD_LENGTH", - format!( - "Ustrd element must be at most 140 characters; got {} characters", - ustrd.chars().count() - ), - )); - } - } - - // --- AppHdr presence is a soft check (not required but common) ------ - if !has_element(xml, "AppHdr") && !has_element(xml, "BizMsgIdr") { - errors.push(ValidationError::new( - "/AppHdr", - Severity::Warning, - "FEDNOW_APPHDR_MISSING", - "Business Application Header (AppHdr) is recommended for FedNow messages", - )); - } - ValidationResult::new(errors) } - fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option { - use mx20022_model::generated::pacs::pacs_008_001_13; - - let short_type = super::short_message_type(message_type); - if !self.supported_messages().contains(&short_type.as_str()) { - return None; - } - - // Only pacs.008 has typed field-level checks. - if short_type != "pacs.008" { - return None; - } - - let doc = msg.downcast_ref::()?; - - Some(self.validate_pacs008_typed(doc)) - } -} - -impl FedNowValidator { fn validate_amount(&self, amt_str: &str, path: &str, errors: &mut Vec) { let decimal_ok = amt_str .find('.') @@ -293,6 +167,7 @@ impl FedNowValidator { "FEDNOW_AMOUNT_DECIMALS", format!("FedNow amounts must have exactly 2 decimal places; got \"{amt_str}\""), )); + return; } match parse_amount_cents(amt_str) { Some(cents) => { @@ -328,83 +203,81 @@ impl FedNowValidator { } } - /// Typed validation for pacs.008 messages. - fn validate_pacs008_typed( - &self, - doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document, - ) -> ValidationResult { - use mx20022_model::generated::pacs::pacs_008_001_13::{ - ChargeBearerType1Code, SettlementMethod1Code, - }; - + /// Validate version-neutral pacs.008 facts. + fn validate_pacs008(&self, facts: &super::pacs008::Facts) -> ValidationResult { let mut errors: Vec = Vec::new(); - let msg = &doc.fi_to_fi_cstmr_cdt_trf; // --- NbOfTxs must be "1" ------------------------------------------- - if msg.grp_hdr.nb_of_txs.0 != "1" { - errors.push(ValidationError::new( + if let Some(nb_of_txs) = &facts.nb_of_txs { + if nb_of_txs != "1" { + errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", Severity::Error, "FEDNOW_SINGLE_TX", format!( - "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{}\"", - msg.grp_hdr.nb_of_txs.0 + "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{nb_of_txs}\"" ), )); + } } // --- Settlement method must be CLRG --------------------------------- - if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", - Severity::Error, - "FEDNOW_STTLM_MTD", - format!( - "FedNow requires SttlmMtd = \"CLRG\", got {:?}", - msg.grp_hdr.sttlm_inf.sttlm_mtd - ), - )); + if let Some(settlement_method) = &facts.settlement_method { + if settlement_method != "CLRG" { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", + Severity::Error, + "FEDNOW_STTLM_MTD", + format!("FedNow requires SttlmMtd = \"CLRG\", got \"{settlement_method}\""), + )); + } } // Validate each credit transfer transaction. - for tx in &msg.cdt_trf_tx_inf { + for tx in &facts.transactions { // --- ChrgBr must be SLEV ---------------------------------------- - if tx.chrg_br != ChargeBearerType1Code::Slev { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", - Severity::Error, - "FEDNOW_CHRGBR", - format!("FedNow requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br), - )); + if let Some(charge_bearer) = &tx.charge_bearer { + if charge_bearer != "SLEV" { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", + Severity::Error, + "FEDNOW_CHRGBR", + format!("FedNow requires ChrgBr = \"SLEV\", got \"{charge_bearer}\""), + )); + } } // --- Currency must be USD --------------------------------------- - let ccy = &tx.intr_bk_sttlm_amt.ccy.0; - if ccy != "USD" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", - Severity::Error, - "FEDNOW_CURRENCY", - format!("FedNow only accepts USD transactions; found currency \"{ccy}\""), - )); + if let Some(currency) = &tx.currency { + if currency != "USD" { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", + Severity::Error, + "FEDNOW_CURRENCY", + format!( + "FedNow only accepts USD transactions; found currency \"{currency}\"" + ), + )); + } } // --- Amount range ----------------------------------------------- - let amt_str = &tx.intr_bk_sttlm_amt.value.0; - self.validate_amount( - amt_str, - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", - &mut errors, - ); + if let Some(amount) = &tx.amount { + self.validate_amount( + amount, + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", + &mut errors, + ); + } // --- UETR is required and must be UUID v4 ----------------------- - match &tx.pmt_id.uetr { - Some(uetr) if !is_valid_uetr(&uetr.0) => { + match &tx.uetr { + Some(uetr) if !is_valid_uetr(uetr) => { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/UETR", Severity::Error, "FEDNOW_UETR_FORMAT", - format!("UETR must be a valid UUID v4; got \"{}\"", uetr.0), + format!("UETR must be a valid UUID v4; got \"{uetr}\""), )); } None => { @@ -418,70 +291,83 @@ impl FedNowValidator { Some(_) => {} // Valid UETR } - // --- End-to-end ID is required (length covered by XSD) ---------- - // Max35Text is the type — XSD validation handles the 35-char limit. - // We only need to check it's not empty/whitespace for FedNow. - if tx.pmt_id.end_to_end_id.0.trim().is_empty() { - errors.push(ValidationError::new( + // --- End-to-end ID is required and max 35 Unicode scalars ------- + match &tx.end_to_end_id { + None => errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", Severity::Error, "FEDNOW_E2E_REQUIRED", - "FedNow requires a non-empty EndToEndId in PmtId", - )); + "FedNow requires an EndToEndId in PmtId", + )), + Some(end_to_end_id) => { + if end_to_end_id.trim().is_empty() { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", + Severity::Error, + "FEDNOW_E2E_REQUIRED", + "FedNow requires a non-empty EndToEndId in PmtId", + )); + } + let end_to_end_length = end_to_end_id.chars().count(); + if end_to_end_length > 35 { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", + Severity::Error, + "FEDNOW_E2E_LENGTH", + format!( + "EndToEndId must be at most 35 characters; got {end_to_end_length} characters" + ), + )); + } + } } // --- Debtor name max 140 chars ---------------------------------- - if let Some(nm) = &tx.dbtr.nm { - if nm.0.chars().count() > 140 { + if let Some(name) = &tx.debtor_name { + if name.chars().count() > 140 { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm", Severity::Error, "FEDNOW_DBTR_NM_LENGTH", format!( "Dbtr/Nm must be at most 140 characters; got {} characters", - nm.0.chars().count() + name.chars().count() ), )); } } // --- Creditor name max 140 chars -------------------------------- - if let Some(nm) = &tx.cdtr.nm { - if nm.0.chars().count() > 140 { + if let Some(name) = &tx.creditor_name { + if name.chars().count() > 140 { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm", Severity::Error, "FEDNOW_CDTR_NM_LENGTH", format!( "Cdtr/Nm must be at most 140 characters; got {} characters", - nm.0.chars().count() + name.chars().count() ), )); } } // --- RmtInf/Ustrd max 140 chars per element --------------------- - if let Some(rmt_inf) = &tx.rmt_inf { - for ustrd in &rmt_inf.ustrd { - if ustrd.0.chars().count() > 140 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd", - Severity::Error, - "FEDNOW_USTRD_LENGTH", - format!( - "Ustrd element must be at most 140 characters; got {} characters", - ustrd.0.chars().count() - ), - )); - } + for unstructured in &tx.unstructured_remittance { + if unstructured.chars().count() > 140 { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd", + Severity::Error, + "FEDNOW_USTRD_LENGTH", + format!( + "Ustrd element must be at most 140 characters; got {} characters", + unstructured.chars().count() + ), + )); } } } - // Note: AppHdr check and message size check require raw XML and are - // not covered by the typed path. Those remain in the XML-based - // `validate` method. - ValidationResult::new(errors) } } @@ -489,26 +375,6 @@ impl FedNowValidator { // Amount parsing functions are defined in `super::common`. use super::common::parse_amount_cents; -/// Check that the `` child inside `` does not exceed 140 chars. -fn check_name_length( - xml: &str, - parent_tag: &str, - errors: &mut Vec, - rule_id: &str, -) { - let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}"); - super::common::check_name_in_parent( - xml, - parent_tag, - Some(140), - &path, - rule_id, - "FedNow", - errors, - false, // FedNow doesn't require names, only limits length - ); -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/mx20022-validate/src/schemes/mod.rs b/crates/mx20022-validate/src/schemes/mod.rs index 629f538..c8bd5c9 100644 --- a/crates/mx20022-validate/src/schemes/mod.rs +++ b/crates/mx20022-validate/src/schemes/mod.rs @@ -6,16 +6,13 @@ //! //! # Design //! -//! Scheme validators support two validation paths: -//! -//! 1. **XML-based** ([`SchemeValidator::validate`]) — operates on raw XML -//! strings using lightweight string scanning ([`xml_scan`]). -//! 2. **Typed** ([`SchemeValidator::validate_typed`]) — operates on -//! deserialized message structs via `std::any::Any` downcasting. -//! -//! The typed path is preferred when the caller has already deserialized the -//! message. It avoids fragile XML string scanning and catches field-level -//! issues at compile time (within the validator implementation). +//! [`SchemeValidator::validate`] is a parse-and-delegate adapter. For +//! `pacs.008.001.13`, it extracts the `Document`, detects the authoritative +//! message namespace, deserializes the generated model, and delegates all +//! field rules to [`SchemeValidator::validate_typed`]. Older `pacs.008` +//! versions use the same version-neutral rule inputs and add an untyped-version +//! warning. Raw XML is retained only for envelope, byte-size, and +//! control-character checks. //! //! # Error Paths //! @@ -25,14 +22,9 @@ //! | Style | Example | When | //! |---|---|---| //! | Absolute | `/Document/FIToFICstmrCdtTrf/GrpHdr/MsgId` | Typed path (field known) | -//! | Abbreviated | `//BICFI` | XML scan (element found anywhere) | +//! | Abbreviated | `//BICFI` | Rule applies to repeated typed fields | //! | Root element | `/AppHdr` | Envelope-level checks | //! -//! **New validators should prefer absolute paths** when the field location is -//! known (always the case for typed validators). Abbreviated `//Element` -//! paths are acceptable for XML-scan checks that match elements regardless -//! of position. -//! //! # Usage //! //! ```rust @@ -49,17 +41,26 @@ pub mod cbpr; pub(crate) mod common; pub mod fednow; +mod pacs008; +mod raw; pub mod sepa; -pub mod xml_scan; -use std::any::Any; +use std::{any::Any, borrow::Cow}; -use crate::error::ValidationResult; +use mx20022_model::generated::pacs::pacs_008_001_13; +use mx20022_parse::envelope::parse_namespace; +use mx20022_parse::ParseError; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; +use quick_xml::reader::NsReader; + +use crate::error::{Severity, ValidationError, ValidationResult}; /// A scheme-specific validator for ISO 20022 payment messages. /// -/// Provides two validation paths: XML-based ([`validate`](SchemeValidator::validate)) -/// and typed ([`validate_typed`](SchemeValidator::validate_typed)). +/// Provides a parse-and-delegate XML path +/// ([`validate`](SchemeValidator::validate)) and a typed path +/// ([`validate_typed`](SchemeValidator::validate_typed)). /// /// # Contract /// @@ -68,6 +69,9 @@ use crate::error::ValidationResult; /// [`supported_messages`](SchemeValidator::supported_messages). /// - `validate_typed` returns `None` for unsupported message types or /// failed downcasts, and `Some(result)` for actual validation. +/// - Malformed supported `pacs.008.001.13` XML produces one `SCHEME_PARSE` +/// finding. Independent raw findings for the caller's supported short type +/// are preserved; field findings are suppressed. /// - Neither method should panic; callers may provide malformed XML or /// unrecognised types. /// - Implementations should be `Send + Sync` so they can be stored in @@ -85,17 +89,10 @@ pub trait SchemeValidator: Send + Sync { /// Validate raw XML content against this scheme's rules. /// - /// # Migration - /// - /// This method operates on raw XML strings using fragile string scanning. - /// **New callers should use [`validate_typed`](SchemeValidator::validate_typed)** - /// which operates on deserialized message structs and catches field-level - /// issues at compile time. - /// - /// The XML-based path remains available for cases where raw XML is the - /// only input (e.g. CLI validation without prior deserialization), or for - /// checks that inherently require raw XML (message size, `AppHdr` envelope, - /// control characters). + /// Supported `pacs.008.001.13` messages are deserialized and delegated to + /// [`validate_typed`](SchemeValidator::validate_typed). Other `pacs.008` + /// versions receive raw findings, version-neutral field findings, and + /// `SCHEME_UNTYPED_VERSION`. /// /// `message_type` is the full ISO 20022 message type detected from the /// XML namespace (e.g. `"pacs.008.001.13"`). The validator is responsible @@ -130,3 +127,220 @@ pub fn short_message_type(message_type: &str) -> String { .collect::>() .join(".") } + +/// Central parse-and-delegate adapter used by the built-in schemes. +fn run_xml( + validator: &V, + xml: &str, + caller_message_type: &str, + raw_prepass: R, + untyped_rules: U, +) -> ValidationResult +where + V: SchemeValidator + ?Sized, + R: FnOnce(&str, &str) -> ValidationResult, + U: FnOnce(&pacs008::Facts) -> ValidationResult, +{ + let caller_short_type = short_message_type(caller_message_type); + if !validator + .supported_messages() + .contains(&caller_short_type.as_str()) + { + return ValidationResult::default(); + } + + if caller_short_type != "pacs.008" { + return raw_prepass(xml, &caller_short_type); + } + + let mut result = raw_prepass(xml, &caller_short_type); + let document_xml = match mx20022_parse::de::document_xml(xml) { + Ok(document) => document, + Err(error) => return with_scheme_parse(result, &error), + }; + let authoritative_type = match document_message_type(xml) { + Ok(message_type) => message_type, + Err(error) => return with_scheme_parse(result, &error), + }; + let authoritative_short_type = short_message_type(&authoritative_type); + if authoritative_short_type != "pacs.008" { + return with_scheme_parse( + result, + &ParseError::InvalidEnvelope(format!( + "Document namespace identifies {authoritative_type}, not pacs.008" + )), + ); + } + + if authoritative_type != "pacs.008.001.13" { + let facts = match pacs008::Facts::from_xml(document_xml) { + Ok(facts) => facts, + Err(error) => return with_scheme_parse(result, &error), + }; + result.merge(untyped_rules(&facts)); + result.errors.push(ValidationError::new( + "/Document", + Severity::Warning, + "SCHEME_UNTYPED_VERSION", + format!( + "{} applies version-neutral field checks to {authoritative_type}; typed deserialization requires pacs.008.001.13", + validator.name() + ), + )); + return result; + } + + let typed_xml = match document_for_typed_validation(document_xml) { + Ok(document) => document, + Err(error) => return with_scheme_parse(result, &error), + }; + let document: pacs_008_001_13::Document = match mx20022_parse::de::from_str(&typed_xml) { + Ok(document) => document, + Err(error) => return with_scheme_parse(result, &error), + }; + + let Some(typed_result) = validator.validate_typed(&document, &authoritative_type) else { + return with_scheme_parse( + result, + &ParseError::InvalidEnvelope(format!( + "{} could not route pacs.008.001.13 to typed validation", + validator.name() + )), + ); + }; + result.merge(typed_result); + result +} + +/// Build the XML view consumed by the generated type. +/// +/// ISO 20022 supplementary envelopes accept `##any`, while the generated +/// model represents that payload as a string. `quick-xml` cannot deserialize +/// nested elements into that string. Scheme rules do not inspect supplementary +/// data, so remove only the opaque contents of `SplmtryData/Envlp`, allocating +/// when such content exists and otherwise retaining the borrowed Document. +fn document_for_typed_validation(document: &str) -> Result, ParseError> { + let mut reader = quick_xml::Reader::from_str(document); + let mut stack = Vec::>::new(); + let mut active_envelope: Option<(usize, usize)> = None; + let mut opaque_ranges = Vec::<(usize, usize)>::new(); + + loop { + let event_start = usize::try_from(reader.buffer_position()).map_err(|_| { + ParseError::InvalidEnvelope( + "Document position exceeds addressable input size".to_owned(), + ) + })?; + let event = reader.read_event().map_err(|error| { + ParseError::InvalidEnvelope(format!("malformed Document XML: {error}")) + })?; + + match event { + Event::Start(element) => { + let local_name = element.local_name().as_ref().to_vec(); + if active_envelope.is_none() + && local_name == b"Envlp" + && stack.last().is_some_and(|parent| parent == b"SplmtryData") + { + let content_start = + usize::try_from(reader.buffer_position()).map_err(|_| { + ParseError::InvalidEnvelope( + "Document position exceeds addressable input size".to_owned(), + ) + })?; + active_envelope = Some((stack.len(), content_start)); + } + stack.push(local_name); + } + Event::End(_) => { + let Some(depth) = stack.len().checked_sub(1) else { + return Err(ParseError::InvalidEnvelope( + "Document contains an unmatched closing element".to_owned(), + )); + }; + if let Some((envelope_depth, content_start)) = active_envelope { + if envelope_depth == depth { + opaque_ranges.push((content_start, event_start)); + active_envelope = None; + } + } + stack.pop(); + } + Event::Eof => break, + _ => {} + } + } + + if opaque_ranges.is_empty() { + return Ok(Cow::Borrowed(document)); + } + + let mut view = String::with_capacity(document.len()); + let mut cursor = 0usize; + for (start, end) in opaque_ranges { + view.push_str(&document[cursor..start]); + view.push_str("supplementary-data-omitted-for-scheme-validation"); + cursor = end; + } + view.push_str(&document[cursor..]); + Ok(Cow::Owned(view)) +} + +/// Read the namespace bound to the `Document` element in the original XML. +/// +/// Reading the full stream preserves namespace declarations inherited from a +/// `BizMsgEnvlp` ancestor when `Document` itself is prefixed. +fn document_message_type(xml: &str) -> Result { + let mut reader = NsReader::from_str(xml); + + loop { + let (namespace, event) = reader.read_resolved_event().map_err(|error| { + ParseError::InvalidEnvelope(format!("malformed Document XML: {error}")) + })?; + match event { + Event::Start(element) | Event::Empty(element) => { + if element.local_name().as_ref() != b"Document" { + continue; + } + return match namespace { + ResolveResult::Bound(namespace) => { + let namespace = + std::str::from_utf8(namespace.as_ref()).map_err(|error| { + ParseError::InvalidEnvelope(format!( + "Document namespace is not UTF-8: {error}" + )) + })?; + parse_namespace(namespace).map(|message_id| message_id.dotted()) + } + ResolveResult::Unbound => Err(ParseError::InvalidEnvelope( + "Document root has no namespace".to_owned(), + )), + ResolveResult::Unknown(prefix) => Err(ParseError::InvalidEnvelope(format!( + "Document namespace prefix is not declared: {}", + String::from_utf8_lossy(&prefix) + ))), + }; + } + Event::Eof => { + return Err(ParseError::InvalidEnvelope( + "Document element is missing".to_owned(), + )); + } + _ => {} + } + } +} + +fn scheme_parse_error(error: &ParseError) -> ValidationResult { + ValidationResult::new(vec![ValidationError::new( + "/Document", + Severity::Error, + "SCHEME_PARSE", + error.to_string(), + )]) +} + +fn with_scheme_parse(mut raw_result: ValidationResult, error: &ParseError) -> ValidationResult { + raw_result.merge(scheme_parse_error(error)); + raw_result +} diff --git a/crates/mx20022-validate/src/schemes/pacs008.rs b/crates/mx20022-validate/src/schemes/pacs008.rs new file mode 100644 index 0000000..1199195 --- /dev/null +++ b/crates/mx20022-validate/src/schemes/pacs008.rs @@ -0,0 +1,333 @@ +//! Version-neutral pacs.008 facts used by scheme field rules. + +use mx20022_model::generated::pacs::pacs_008_001_13::{ + AccountIdentification4Choice, ChargeBearerType1Code, Document, SettlementMethod1Code, +}; +use mx20022_parse::ParseError; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; + +#[derive(Debug, Default)] +pub(crate) struct Facts { + pub(crate) nb_of_txs: Option, + pub(crate) settlement_method: Option, + pub(crate) instg_agent_bic: Option, + pub(crate) instd_agent_bic: Option, + pub(crate) transactions: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct Transaction { + pub(crate) charge_bearer: Option, + pub(crate) currency: Option, + pub(crate) amount: Option, + pub(crate) uetr: Option, + pub(crate) end_to_end_id: Option, + pub(crate) debtor_name: Option, + pub(crate) creditor_name: Option, + pub(crate) debtor_agent_bic: Option, + pub(crate) creditor_agent_bic: Option, + pub(crate) has_settlement_date: bool, + pub(crate) has_debtor_iban: bool, + pub(crate) has_creditor_iban: bool, + pub(crate) unstructured_remittance: Vec, +} + +#[derive(Debug)] +struct Node { + name: Vec, + text: String, + currency: Option, +} + +impl Facts { + pub(crate) fn from_xml(document: &str) -> Result { + let mut reader = Reader::from_str(document); + let mut facts = Self::default(); + let mut stack = Vec::::new(); + let mut current_transaction = None; + + loop { + let event = reader.read_event().map_err(|error| { + ParseError::InvalidEnvelope(format!("malformed pacs.008 Document: {error}")) + })?; + match event { + Event::Start(element) => { + let name = element.local_name().as_ref().to_vec(); + if name == b"CdtTrfTxInf" && !inside_supplementary_data(&stack) { + facts.transactions.push(Transaction::default()); + current_transaction = facts.transactions.len().checked_sub(1); + } + stack.push(Node { + currency: currency_attribute(&element)?, + name, + text: String::new(), + }); + } + Event::Empty(element) => { + let name = element.local_name().as_ref().to_vec(); + if name == b"CdtTrfTxInf" && !inside_supplementary_data(&stack) { + facts.transactions.push(Transaction::default()); + } + } + Event::Text(text) => { + if let Some(node) = stack.last_mut() { + let decoded = text.decode().map_err(|error| { + ParseError::InvalidEnvelope(format!( + "pacs.008 text is not decodable: {error}" + )) + })?; + let unescaped = quick_xml::escape::unescape(&decoded).map_err(|error| { + ParseError::InvalidEnvelope(format!( + "pacs.008 text contains an invalid entity: {error}" + )) + })?; + node.text.push_str(&unescaped); + } + } + Event::CData(text) => { + if let Some(node) = stack.last_mut() { + let decoded = text.decode().map_err(|error| { + ParseError::InvalidEnvelope(format!( + "pacs.008 CDATA is not decodable: {error}" + )) + })?; + node.text.push_str(&decoded); + } + } + Event::End(_) => { + let node = stack.pop().ok_or_else(|| { + ParseError::InvalidEnvelope( + "pacs.008 Document contains an unmatched closing element".to_owned(), + ) + })?; + let ends_transaction = node.name == b"CdtTrfTxInf"; + if !inside_supplementary_data(&stack) { + process_node(&mut facts, current_transaction, &stack, node); + } + if ends_transaction { + current_transaction = None; + } + } + Event::Eof => break, + _ => {} + } + } + + Ok(facts) + } +} + +impl From<&Document> for Facts { + fn from(document: &Document) -> Self { + let message = &document.fi_to_fi_cstmr_cdt_trf; + Self { + nb_of_txs: Some(message.grp_hdr.nb_of_txs.0.clone()), + settlement_method: Some(settlement_method(&message.grp_hdr.sttlm_inf.sttlm_mtd)), + instg_agent_bic: message + .grp_hdr + .instg_agt + .as_ref() + .and_then(|agent| agent.fin_instn_id.bicfi.as_ref()) + .map(|bic| bic.0.clone()), + instd_agent_bic: message + .grp_hdr + .instd_agt + .as_ref() + .and_then(|agent| agent.fin_instn_id.bicfi.as_ref()) + .map(|bic| bic.0.clone()), + transactions: message + .cdt_trf_tx_inf + .iter() + .map(|transaction| Transaction { + charge_bearer: Some(charge_bearer(&transaction.chrg_br)), + currency: Some(transaction.intr_bk_sttlm_amt.ccy.0.clone()), + amount: Some(transaction.intr_bk_sttlm_amt.value.0.clone()), + uetr: transaction + .pmt_id + .uetr + .as_ref() + .map(|value| value.0.clone()), + end_to_end_id: Some(transaction.pmt_id.end_to_end_id.0.clone()), + debtor_name: transaction.dbtr.nm.as_ref().map(|name| name.0.clone()), + creditor_name: transaction.cdtr.nm.as_ref().map(|name| name.0.clone()), + debtor_agent_bic: transaction + .dbtr_agt + .fin_instn_id + .bicfi + .as_ref() + .map(|bic| bic.0.clone()), + creditor_agent_bic: transaction + .cdtr_agt + .fin_instn_id + .bicfi + .as_ref() + .map(|bic| bic.0.clone()), + has_settlement_date: transaction.intr_bk_sttlm_dt.is_some(), + has_debtor_iban: has_iban(transaction.dbtr_acct.as_ref()), + has_creditor_iban: has_iban(transaction.cdtr_acct.as_ref()), + unstructured_remittance: transaction.rmt_inf.as_ref().map_or_else( + Vec::new, + |remittance| { + remittance + .ustrd + .iter() + .map(|value| value.0.clone()) + .collect() + }, + ), + }) + .collect(), + } + } +} + +fn inside_supplementary_data(stack: &[Node]) -> bool { + stack.iter().any(|node| node.name == b"SplmtryData") +} + +fn currency_attribute(element: &BytesStart<'_>) -> Result, ParseError> { + for attribute in element.attributes() { + let attribute = attribute.map_err(|error| { + ParseError::InvalidEnvelope(format!("invalid pacs.008 attribute: {error}")) + })?; + if attribute.key.local_name().as_ref() == b"Ccy" { + let value = attribute + .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, element.decoder()) + .map_err(|error| { + ParseError::InvalidEnvelope(format!("invalid pacs.008 currency: {error}")) + })?; + return Ok(Some(value.into_owned())); + } + } + Ok(None) +} + +fn process_node( + facts: &mut Facts, + current_transaction: Option, + ancestors: &[Node], + node: Node, +) { + let text = node.text.trim().to_owned(); + let name = node.name.as_slice(); + + if current_transaction.is_none() { + match name { + b"NbOfTxs" if has_ancestor(ancestors, b"GrpHdr") => { + facts.nb_of_txs.get_or_insert(text); + } + b"SttlmMtd" if has_ancestor(ancestors, b"GrpHdr") => { + facts.settlement_method.get_or_insert(text); + } + b"BICFI" if has_ancestor(ancestors, b"InstgAgt") => { + facts.instg_agent_bic.get_or_insert(text); + } + b"BICFI" if has_ancestor(ancestors, b"InstdAgt") => { + facts.instd_agent_bic.get_or_insert(text); + } + _ => {} + } + return; + } + + let transaction = &mut facts.transactions[current_transaction.expect("checked above")]; + match name { + b"ChrgBr" => { + transaction.charge_bearer.get_or_insert(text); + } + b"IntrBkSttlmAmt" => { + transaction.currency = node.currency; + transaction.amount.get_or_insert(text); + } + b"UETR" => { + transaction.uetr.get_or_insert(text); + } + b"EndToEndId" => { + transaction.end_to_end_id.get_or_insert(text); + } + b"Nm" if nearest_party(ancestors) == Some(b"Dbtr".as_slice()) => { + transaction.debtor_name.get_or_insert(text); + } + b"Nm" if nearest_party(ancestors) == Some(b"Cdtr".as_slice()) => { + transaction.creditor_name.get_or_insert(text); + } + b"BICFI" if has_ancestor(ancestors, b"DbtrAgt") => { + transaction.debtor_agent_bic.get_or_insert(text); + } + b"BICFI" if has_ancestor(ancestors, b"CdtrAgt") => { + transaction.creditor_agent_bic.get_or_insert(text); + } + b"IntrBkSttlmDt" => { + transaction.has_settlement_date = true; + } + b"IBAN" if has_ancestor(ancestors, b"DbtrAcct") => { + transaction.has_debtor_iban = true; + } + b"IBAN" if has_ancestor(ancestors, b"CdtrAcct") => { + transaction.has_creditor_iban = true; + } + b"Ustrd" => { + transaction.unstructured_remittance.push(text); + } + _ => {} + } +} + +fn has_ancestor(ancestors: &[Node], name: &[u8]) -> bool { + ancestors.iter().any(|node| node.name == name) +} + +fn nearest_party(ancestors: &[Node]) -> Option<&[u8]> { + ancestors + .iter() + .rev() + .map(|node| node.name.as_slice()) + .find(|name| matches!(*name, b"Dbtr" | b"Cdtr")) +} + +fn has_iban( + account: Option<&mx20022_model::generated::pacs::pacs_008_001_13::CashAccount40>, +) -> bool { + account.as_ref().is_some_and(|account| { + account + .id + .as_ref() + .is_some_and(|choice| matches!(choice.inner, AccountIdentification4Choice::IBAN(_))) + }) +} + +fn settlement_method(value: &SettlementMethod1Code) -> String { + match value { + SettlementMethod1Code::Inda => "INDA", + SettlementMethod1Code::Inga => "INGA", + SettlementMethod1Code::Cove => "COVE", + SettlementMethod1Code::Clrg => "CLRG", + } + .to_owned() +} + +fn charge_bearer(value: &ChargeBearerType1Code) -> String { + match value { + ChargeBearerType1Code::Debt => "DEBT", + ChargeBearerType1Code::Cred => "CRED", + ChargeBearerType1Code::Shar => "SHAR", + ChargeBearerType1Code::Slev => "SLEV", + } + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_transaction_without_reading_supplementary_payloads() { + let xml = r#"1CLRGe2e1.00Alice9.00"#; + let facts = Facts::from_xml(xml).unwrap(); + assert_eq!(facts.nb_of_txs.as_deref(), Some("1")); + assert_eq!(facts.transactions.len(), 1); + assert_eq!(facts.transactions[0].currency.as_deref(), Some("EUR")); + assert_eq!(facts.transactions[0].debtor_name.as_deref(), Some("Alice")); + } +} diff --git a/crates/mx20022-validate/src/schemes/raw.rs b/crates/mx20022-validate/src/schemes/raw.rs new file mode 100644 index 0000000..663ecdd --- /dev/null +++ b/crates/mx20022-validate/src/schemes/raw.rs @@ -0,0 +1,68 @@ +//! Raw XML checks that cannot be expressed on a deserialized message body. + +use quick_xml::events::Event; +use quick_xml::Reader; + +/// Return the UTF-8 byte length of an XML string. +pub(crate) fn byte_len(xml: &str) -> usize { + xml.len() +} + +/// Detect `AppHdr` and `BizMsgIdr` elements by local name. +/// +/// Prefixes are ignored, so namespace-qualified headers are recognized. A +/// malformed stream yields the presence observed before the parser error so +/// the adapter can preserve independent header findings with `SCHEME_PARSE`. +pub(crate) fn header_presence(xml: &str) -> (bool, bool) { + let mut reader = Reader::from_str(xml); + let mut app_header = false; + let mut business_message_id = false; + + loop { + match reader.read_event() { + Ok(Event::Start(element) | Event::Empty(element)) => { + let local_name = element.local_name(); + match local_name.as_ref() { + b"AppHdr" => app_header = true, + b"BizMsgIdr" => business_message_id = true, + _ => {} + } + if app_header && business_message_id { + break; + } + } + Ok(Event::Eof) | Err(_) => break, + Ok(_) => {} + } + } + + (app_header, business_message_id) +} + +/// Return the first disallowed control character and its UTF-8 byte offset. +pub(crate) fn first_disallowed_control(xml: &str) -> Option<(usize, char)> { + xml.char_indices() + .find(|(_, character)| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn byte_len_counts_utf8_bytes() { + assert_eq!(byte_len("é"), 2); + } + + #[test] + fn header_presence_uses_local_names() { + let xml = r#"id"#; + assert_eq!(header_presence(xml), (true, true)); + } + + #[test] + fn first_control_reports_byte_offset() { + assert_eq!(first_disallowed_control("é\u{1}"), Some((2, '\u{1}'))); + assert_eq!(first_disallowed_control("ok\n\t\r"), None); + } +} diff --git a/crates/mx20022-validate/src/schemes/sepa.rs b/crates/mx20022-validate/src/schemes/sepa.rs index f8bf88f..d9d3e74 100644 --- a/crates/mx20022-validate/src/schemes/sepa.rs +++ b/crates/mx20022-validate/src/schemes/sepa.rs @@ -15,7 +15,6 @@ use std::any::Any; -use super::xml_scan::{extract_all_elements, extract_attribute, extract_element}; use super::SchemeValidator; use crate::error::{Severity, ValidationError, ValidationResult}; @@ -65,9 +64,6 @@ pub fn is_sepa_charset(s: &str) -> bool { s.chars().all(is_sepa_char) } -/// Tags whose text content must conform to the SEPA character set. -const CHARSET_TAGS: &[&str] = &["Nm", "Ustrd", "StrtNm", "TwnNm"]; - impl SchemeValidator for SepaValidator { fn name(&self) -> &'static str { "SEPA" @@ -78,160 +74,13 @@ impl SchemeValidator for SepaValidator { } fn validate(&self, xml: &str, message_type: &str) -> ValidationResult { - let short_type = super::short_message_type(message_type); - - if !self.supported_messages().contains(&short_type.as_str()) { - return ValidationResult::default(); - } - - let mut errors: Vec = Vec::new(); - - // The field-level checks are pacs.008-specific. - if short_type != "pacs.008" { - return ValidationResult::new(errors); - } - - // --- Currency must be EUR ------------------------------------------- - if let Some(ccy) = extract_attribute(xml, "IntrBkSttlmAmt", "Ccy") { - if ccy != "EUR" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", - Severity::Error, - "SEPA_CURRENCY", - format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""), - )); - } - } - - // --- ChrgBr must be SLEV ------------------------------------------- - if let Some(chrg_br) = extract_element(xml, "ChrgBr") { - if chrg_br != "SLEV" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", - Severity::Error, - "SEPA_CHRGBR", - format!("SEPA SCT requires ChrgBr = \"SLEV\", got \"{chrg_br}\""), - )); - } - } else { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", - Severity::Error, - "SEPA_CHRGBR_REQUIRED", - "SEPA SCT requires ChrgBr = \"SLEV\"", - )); - } - - // --- Settlement method must be CLRG --------------------------------- - if let Some(sttlm_mtd) = extract_element(xml, "SttlmMtd") { - if sttlm_mtd != "CLRG" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", - Severity::Error, - "SEPA_STTLM_MTD", - format!("SEPA requires SttlmMtd = \"CLRG\", got \"{sttlm_mtd}\""), - )); - } - } - - // --- NbOfTxs must be "1" ------------------------------------------- - if let Some(nb) = extract_element(xml, "NbOfTxs") { - if nb != "1" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", - Severity::Error, - "SEPA_SINGLE_TX", - format!( - "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{nb}\"" - ), - )); - } - } - - // --- Debtor name required, max 70 chars ----------------------------- - check_name(xml, "Dbtr", 70, &mut errors, "SEPA_DBTR_NM"); - // --- Creditor name required, max 70 chars --------------------------- - check_name(xml, "Cdtr", 70, &mut errors, "SEPA_CDTR_NM"); - - // --- End-to-end ID max 35 chars ------------------------------------- - if let Some(e2e) = extract_element(xml, "EndToEndId") { - if e2e.chars().count() > 35 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", - Severity::Error, - "SEPA_E2E_LENGTH", - format!( - "EndToEndId must be at most 35 characters; got {} characters", - e2e.chars().count() - ), - )); - } - } - - // --- Ustrd total length max 140 chars -------------------------------- - let ustrd_total: usize = extract_all_elements(xml, "Ustrd") - .iter() - .map(|s| s.chars().count()) - .sum(); - if ustrd_total > 140 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd", - Severity::Error, - "SEPA_USTRD_LENGTH", - format!( - "RmtInf/Ustrd total length must not exceed 140 characters; got {ustrd_total}" - ), - )); - } - - // --- Amount range --------------------------------------------------- - if let Some(amt_str) = extract_element(xml, "IntrBkSttlmAmt") { - Self::validate_sepa_amount( - amt_str, - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", - &mut errors, - ); - } - - // --- IBAN required for debtor and creditor -------------------------- - // Presence check only — format is validated elsewhere. - let ibans = extract_all_elements(xml, "IBAN"); - if ibans.is_empty() { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf", - Severity::Error, - "SEPA_IBAN_REQUIRED", - "SEPA requires IBAN for both debtor and creditor accounts; none found", - )); - } else if ibans.len() < 2 { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf", - Severity::Warning, - "SEPA_IBAN_BOTH", - "SEPA requires IBAN for both debtor and creditor; only one found", - )); - } - - // --- SEPA character set check --------------------------------------- - for tag in CHARSET_TAGS { - for value in extract_all_elements(xml, tag) { - if !is_sepa_charset(value) { - // Report just the offending characters. - let bad: String = value.chars().filter(|&c| !is_sepa_char(c)).collect(); - errors.push(ValidationError::new( - format!("//{tag}"), - Severity::Error, - "SEPA_CHARSET", - format!( - "Field <{tag}> contains characters outside the SEPA restricted \ - Latin character set: {bad:?}" - ), - )); - } - } - } - - ValidationResult::new(errors) + super::run_xml( + self, + xml, + message_type, + |_, _| ValidationResult::default(), + |facts| self.validate_pacs008(facts), + ) } fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option { @@ -248,7 +97,7 @@ impl SchemeValidator for SepaValidator { let doc = msg.downcast_ref::()?; - Some(self.validate_pacs008_typed(doc)) + Some(self.validate_pacs008(&super::pacs008::Facts::from(doc))) } } @@ -310,69 +159,71 @@ impl SepaValidator { } } - /// Typed validation for pacs.008 messages under SEPA SCT rules. + /// Validate version-neutral pacs.008 facts under SEPA SCT rules. #[allow(clippy::unused_self)] - fn validate_pacs008_typed( - &self, - doc: &mx20022_model::generated::pacs::pacs_008_001_13::Document, - ) -> ValidationResult { - use mx20022_model::generated::pacs::pacs_008_001_13::{ - AccountIdentification4Choice, ChargeBearerType1Code, SettlementMethod1Code, - }; - + fn validate_pacs008(&self, facts: &super::pacs008::Facts) -> ValidationResult { let mut errors: Vec = Vec::new(); - let msg = &doc.fi_to_fi_cstmr_cdt_trf; // --- Settlement method must be CLRG --------------------------------- - if msg.grp_hdr.sttlm_inf.sttlm_mtd != SettlementMethod1Code::Clrg { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", - Severity::Error, - "SEPA_STTLM_MTD", - format!( - "SEPA requires SttlmMtd = \"CLRG\", got {:?}", - msg.grp_hdr.sttlm_inf.sttlm_mtd - ), - )); + if let Some(settlement_method) = &facts.settlement_method { + if settlement_method != "CLRG" { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/GrpHdr/SttlmInf/SttlmMtd", + Severity::Error, + "SEPA_STTLM_MTD", + format!("SEPA requires SttlmMtd = \"CLRG\", got \"{settlement_method}\""), + )); + } } // --- NbOfTxs must be "1" ------------------------------------------- - if msg.grp_hdr.nb_of_txs.0 != "1" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", - Severity::Error, - "SEPA_SINGLE_TX", - format!( - "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{}\"", - msg.grp_hdr.nb_of_txs.0 - ), - )); - } - - for tx in &msg.cdt_trf_tx_inf { - // --- Currency must be EUR --------------------------------------- - let ccy = &tx.intr_bk_sttlm_amt.ccy.0; - if ccy != "EUR" { + if let Some(nb_of_txs) = &facts.nb_of_txs { + if nb_of_txs != "1" { errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", + "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", Severity::Error, - "SEPA_CURRENCY", - format!("SEPA only accepts EUR transactions; found currency \"{ccy}\""), + "SEPA_SINGLE_TX", + format!( + "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{nb_of_txs}\"" + ), )); } + } + + for tx in &facts.transactions { + // --- Currency must be EUR --------------------------------------- + if let Some(currency) = &tx.currency { + if currency != "EUR" { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt/@Ccy", + Severity::Error, + "SEPA_CURRENCY", + format!( + "SEPA only accepts EUR transactions; found currency \"{currency}\"" + ), + )); + } + } // --- ChrgBr must be SLEV ---------------------------------------- - if tx.chrg_br != ChargeBearerType1Code::Slev { - errors.push(ValidationError::new( + match tx.charge_bearer.as_deref() { + Some("SLEV") => {} + Some(charge_bearer) => errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", Severity::Error, "SEPA_CHRGBR", - format!("SEPA SCT requires ChrgBr = \"SLEV\", got {:?}", tx.chrg_br), - )); + format!("SEPA SCT requires ChrgBr = \"SLEV\", got \"{charge_bearer}\""), + )), + None => errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/ChrgBr", + Severity::Error, + "SEPA_CHRGBR_REQUIRED", + "SEPA SCT requires ChrgBr = \"SLEV\"", + )), } // --- Debtor name required, max 70 chars ------------------------- - match &tx.dbtr.nm { + match &tx.debtor_name { None => { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm", @@ -381,14 +232,14 @@ impl SepaValidator { "Dbtr/Nm is required for SEPA", )); } - Some(nm) if nm.0.chars().count() > 70 => { + Some(name) if name.chars().count() > 70 => { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Dbtr/Nm", Severity::Error, "SEPA_DBTR_NM", format!( "Dbtr/Nm must be at most 70 characters; got {} characters", - nm.0.chars().count() + name.chars().count() ), )); } @@ -396,7 +247,7 @@ impl SepaValidator { } // --- Creditor name required, max 70 chars ----------------------- - match &tx.cdtr.nm { + match &tx.creditor_name { None => { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm", @@ -405,27 +256,42 @@ impl SepaValidator { "Cdtr/Nm is required for SEPA", )); } - Some(nm) if nm.0.chars().count() > 70 => { + Some(name) if name.chars().count() > 70 => { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/Cdtr/Nm", Severity::Error, "SEPA_CDTR_NM", format!( "Cdtr/Nm must be at most 70 characters; got {} characters", - nm.0.chars().count() + name.chars().count() ), )); } Some(_) => {} } - // --- End-to-end ID max 35 chars --------------------------------- - // Max35Text enforces 35 via XSD. Typed path trusts XSD validation - // for length but checks that it's present (it's a required field). + // --- End-to-end ID max 35 Unicode scalars ----------------------- + if let Some(end_to_end_id) = &tx.end_to_end_id { + let end_to_end_length = end_to_end_id.chars().count(); + if end_to_end_length > 35 { + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtId/EndToEndId", + Severity::Error, + "SEPA_E2E_LENGTH", + format!( + "EndToEndId must be at most 35 characters; got {end_to_end_length} characters" + ), + )); + } + } // --- Ustrd total length max 140 chars --------------------------- - if let Some(rmt_inf) = &tx.rmt_inf { - let ustrd_total: usize = rmt_inf.ustrd.iter().map(|u| u.0.chars().count()).sum(); + if !tx.unstructured_remittance.is_empty() { + let ustrd_total: usize = tx + .unstructured_remittance + .iter() + .map(|value| value.chars().count()) + .sum(); if ustrd_total > 140 { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/RmtInf/Ustrd", @@ -438,9 +304,12 @@ impl SepaValidator { } // SEPA character set check on Ustrd. - for ustrd in &rmt_inf.ustrd { - if !is_sepa_charset(&ustrd.0) { - let bad: String = ustrd.0.chars().filter(|&c| !is_sepa_char(c)).collect(); + for unstructured in &tx.unstructured_remittance { + if !is_sepa_charset(unstructured) { + let bad: String = unstructured + .chars() + .filter(|&character| !is_sepa_char(character)) + .collect(); errors.push(ValidationError::new( "//Ustrd", Severity::Error, @@ -455,40 +324,31 @@ impl SepaValidator { } // --- Amount range ----------------------------------------------- - let amt_str: &str = &tx.intr_bk_sttlm_amt.value.0; - Self::validate_sepa_amount( - amt_str, - "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", - &mut errors, - ); + if let Some(amount) = &tx.amount { + Self::validate_sepa_amount( + amount, + "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/IntrBkSttlmAmt", + &mut errors, + ); + } // --- SEPA character set check on names -------------------------- - if let Some(nm) = &tx.dbtr.nm { - Self::check_sepa_name(&nm.0, &mut errors); + if let Some(name) = &tx.debtor_name { + Self::check_sepa_name(name, &mut errors); } - if let Some(nm) = &tx.cdtr.nm { - Self::check_sepa_name(&nm.0, &mut errors); + if let Some(name) = &tx.creditor_name { + Self::check_sepa_name(name, &mut errors); } // --- IBAN required for debtor and creditor accounts --- - let has_dbtr_iban = tx.dbtr_acct.as_ref().is_some_and(|acct| { - acct.id.as_ref().is_some_and(|choice| { - matches!(choice.inner, AccountIdentification4Choice::IBAN(_)) - }) - }); - let has_cdtr_iban = tx.cdtr_acct.as_ref().is_some_and(|acct| { - acct.id.as_ref().is_some_and(|choice| { - matches!(choice.inner, AccountIdentification4Choice::IBAN(_)) - }) - }); - if !has_dbtr_iban && !has_cdtr_iban { + if !tx.has_debtor_iban && !tx.has_creditor_iban { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf", Severity::Error, "SEPA_IBAN_REQUIRED", "SEPA requires IBAN for both debtor and creditor accounts; none found", )); - } else if !has_dbtr_iban || !has_cdtr_iban { + } else if !tx.has_debtor_iban || !tx.has_creditor_iban { errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/CdtTrfTxInf", Severity::Warning, @@ -502,28 +362,6 @@ impl SepaValidator { } } -/// Validate that the `` child of `` is present and within -/// `max_len` characters. -fn check_name( - xml: &str, - parent_tag: &str, - max_len: usize, - errors: &mut Vec, - rule_id: &str, -) { - let path = format!("/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/{parent_tag}"); - super::common::check_name_in_parent( - xml, - parent_tag, - Some(max_len), - &path, - rule_id, - "SEPA", - errors, - true, - ); -} - #[cfg(test)] mod tests { use super::*; @@ -566,25 +404,10 @@ mod tests { assert!(!is_sepa_charset("Алиса")); // Cyrillic } - /// Build a minimal pacs.008 XML with the given amount for SEPA validation. - fn sepa_xml_with_amount(amount: &str) -> String { - format!( - r#" - - 1CLRG - - {amount} - SLEV - Alice - Bob - BANKDEFF - BANKDEFF - DE89370400440532013000 - DE89370400440532013000 - - -"# - ) + fn validate_amount(amount: &str) -> ValidationResult { + let mut errors = Vec::new(); + SepaValidator::validate_sepa_amount(amount, "/Document/Amount", &mut errors); + ValidationResult::new(errors) } fn has_error(result: &ValidationResult, code: &str) -> bool { @@ -593,9 +416,7 @@ mod tests { #[test] fn sepa_amount_at_max_boundary() { - let v = SepaValidator::new(); - let xml = sepa_xml_with_amount("999999999.99"); - let result = v.validate(&xml, "pacs.008.001.13"); + let result = validate_amount("999999999.99"); assert!( !has_error(&result, "SEPA_AMOUNT_MAX"), "999999999.99 should be within SEPA max; errors: {:?}", @@ -605,17 +426,13 @@ mod tests { #[test] fn sepa_amount_just_under_max() { - let v = SepaValidator::new(); - let xml = sepa_xml_with_amount("999999999.98"); - let result = v.validate(&xml, "pacs.008.001.13"); + let result = validate_amount("999999999.98"); assert!(!has_error(&result, "SEPA_AMOUNT_MAX")); } #[test] fn sepa_amount_exceeds_max() { - let v = SepaValidator::new(); - let xml = sepa_xml_with_amount("1000000000.00"); - let result = v.validate(&xml, "pacs.008.001.13"); + let result = validate_amount("1000000000.00"); assert!( has_error(&result, "SEPA_AMOUNT_MAX"), "1000000000.00 should exceed SEPA max" @@ -624,9 +441,7 @@ mod tests { #[test] fn sepa_amount_at_min_boundary() { - let v = SepaValidator::new(); - let xml = sepa_xml_with_amount("0.01"); - let result = v.validate(&xml, "pacs.008.001.13"); + let result = validate_amount("0.01"); assert!( !has_error(&result, "SEPA_AMOUNT_MIN"), "0.01 should be within SEPA min" @@ -635,9 +450,7 @@ mod tests { #[test] fn sepa_amount_below_min() { - let v = SepaValidator::new(); - let xml = sepa_xml_with_amount("0.00"); - let result = v.validate(&xml, "pacs.008.001.13"); + let result = validate_amount("0.00"); assert!( has_error(&result, "SEPA_AMOUNT_MIN"), "0.00 should be below SEPA min" diff --git a/crates/mx20022-validate/src/schemes/xml_scan.rs b/crates/mx20022-validate/src/schemes/xml_scan.rs deleted file mode 100644 index eaa9404..0000000 --- a/crates/mx20022-validate/src/schemes/xml_scan.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! Lightweight XML element and attribute extraction utilities. -//! -//! These functions use simple string scanning rather than full XML parsing. -//! They are intentionally limited: they handle the flat, predictable element -//! structures found in ISO 20022 messages but do not handle CDATA, comments, -//! or namespace-qualified attributes. -//! -//! # Migration to typed validation -//! -//! **New code should prefer the typed validation path** -//! ([`SchemeValidator::validate_typed`](super::SchemeValidator::validate_typed)) -//! which accesses fields directly on deserialized message structs. The -//! `xml_scan` utilities remain available for cases where raw XML is the only -//! input (e.g. CLI validation without deserialization). -//! -//! # Limitations -//! -//! - Element extraction is first-match only (use [`extract_all_elements`] for -//! repeated elements). -//! - Namespace prefixes on tags are not handled — match against the local name -//! only. -//! - Attribute extraction searches the whole document, not a specific element. - -/// Extract the text content of the first `value` in `xml`. -/// -/// Returns `None` if the tag is absent or has no closing counterpart. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::extract_element; -/// -/// let xml = "1"; -/// assert_eq!(extract_element(xml, "NbOfTxs"), Some("1")); -/// assert_eq!(extract_element(xml, "Missing"), None); -/// ``` -pub fn extract_element<'a>(xml: &'a str, tag: &str) -> Option<&'a str> { - // Match both `` and `` by searching for the tag prefix - // and then finding the `>` that ends the opening tag. - let close = format!(""); - let prefix_bare = format!("<{tag}>"); - let prefix_attr = format!("<{tag} "); - - // Find the opening tag (bare or with attributes). - let tag_start = xml - .find(prefix_bare.as_str()) - .or_else(|| xml.find(prefix_attr.as_str()))?; - - // Find the `>` that closes the opening tag. - let gt = xml[tag_start..].find('>')?; - let content_start = tag_start + gt + 1; - - let end = xml[content_start..].find(&close)?; - Some(xml[content_start..content_start + end].trim()) -} - -/// Extract the text content of every `value` in `xml`. -/// -/// Returns an empty `Vec` if the tag is absent. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::extract_all_elements; -/// -/// let xml = "AliceBob"; -/// let names = extract_all_elements(xml, "Nm"); -/// assert_eq!(names, vec!["Alice", "Bob"]); -/// ``` -pub fn extract_all_elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { - let close = format!(""); - let prefix_bare = format!("<{tag}>"); - let prefix_attr = format!("<{tag} "); - let mut results = Vec::new(); - let mut remaining = xml; - loop { - // Find the next opening tag (bare or with attributes). - let pos_bare = remaining.find(prefix_bare.as_str()); - let pos_attr = remaining.find(prefix_attr.as_str()); - let tag_start = match (pos_bare, pos_attr) { - (None, None) => break, - (Some(a), None) => a, - (None, Some(b)) => b, - (Some(a), Some(b)) => a.min(b), - }; - // Advance past the `>` that ends the opening tag. - let Some(gt) = remaining[tag_start..].find('>') else { - break; - }; - let content_start = tag_start + gt + 1; - let tail = &remaining[content_start..]; - if let Some(end_pos) = tail.find(&close) { - results.push(tail[..end_pos].trim()); - remaining = &tail[end_pos + close.len()..]; - } else { - break; - } - } - results -} - -/// Extract the value of a named attribute on a specific element tag. -/// -/// Finds the first occurrence of `` and returns -/// `value`. Handles both self-closing (`/>`) and regular (`>`) forms. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::extract_attribute; -/// -/// let xml = r#"1000.00"#; -/// assert_eq!( -/// extract_attribute(xml, "IntrBkSttlmAmt", "Ccy"), -/// Some("USD") -/// ); -/// ``` -pub fn extract_attribute<'a>(xml: &'a str, tag: &str, attr: &str) -> Option<&'a str> { - // Find `` (or `/>`) of the opening tag. - let gt_pos = tag_content.find('>')?; - let attrs_str = &tag_content[..gt_pos]; - // Find `attr="` within the attribute region. - let attr_prefix = format!("{attr}=\""); - let attr_start = attrs_str.find(&attr_prefix)?; - let value_start = attr_start + attr_prefix.len(); - let value_end = attrs_str[value_start..].find('"')?; - Some(&attrs_str[value_start..value_start + value_end]) -} - -/// Extract all values of a named XML attribute (e.g. `Ccy="USD"`) from `xml`. -/// -/// Scans for every occurrence of `attr_name="value"` and returns the values. -/// Handles both regular (`>`) and self-closing (`/>`) element forms. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::extract_all_attributes; -/// -/// let xml = r#"12"#; -/// assert_eq!(extract_all_attributes(xml, "Ccy"), vec!["USD", "EUR"]); -/// ``` -pub fn extract_all_attributes<'a>(xml: &'a str, attr_name: &str) -> Vec<&'a str> { - let needle = format!("{attr_name}=\""); - let mut results = Vec::new(); - let mut remaining = xml; - while let Some(pos) = remaining.find(&needle) { - let after_eq = pos + needle.len(); - let tail = &remaining[after_eq..]; - if let Some(end_pos) = tail.find('"') { - results.push(tail[..end_pos].trim()); - remaining = &tail[end_pos + 1..]; - } else { - break; - } - } - results -} - -/// Return `true` if the given tag appears anywhere in `xml`. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::has_element; -/// -/// let xml = "some-uuid"; -/// assert!(has_element(xml, "UETR")); -/// assert!(!has_element(xml, "AppHdr")); -/// ``` -pub fn has_element(xml: &str, tag: &str) -> bool { - let bare = format!("<{tag}>"); - let self_close = format!("<{tag}/>"); - let with_attr = format!("<{tag} "); - xml.contains(&bare) || xml.contains(&self_close) || xml.contains(&with_attr) -} - -/// Return the UTF-8 byte length of the XML string. -/// -/// # Examples -/// -/// ``` -/// use mx20022_validate::schemes::xml_scan::xml_byte_size; -/// -/// assert_eq!(xml_byte_size("hello"), 5); -/// ``` -pub fn xml_byte_size(xml: &str) -> usize { - xml.len() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_element_simple() { - let xml = "ABC123"; - assert_eq!(extract_element(xml, "MsgId"), Some("ABC123")); - } - - #[test] - fn extract_element_trims_whitespace() { - let xml = " ABC123 "; - assert_eq!(extract_element(xml, "MsgId"), Some("ABC123")); - } - - #[test] - fn extract_element_missing_returns_none() { - let xml = "ABC123"; - assert_eq!(extract_element(xml, "NbOfTxs"), None); - } - - #[test] - fn extract_element_returns_first_match() { - let xml = "AliceBob"; - assert_eq!(extract_element(xml, "Nm"), Some("Alice")); - } - - #[test] - fn extract_all_elements_multiple() { - let xml = "AliceBobCarol"; - let result = extract_all_elements(xml, "Nm"); - assert_eq!(result, vec!["Alice", "Bob", "Carol"]); - } - - #[test] - fn extract_all_elements_none_returns_empty() { - let xml = "123"; - let result = extract_all_elements(xml, "Nm"); - assert!(result.is_empty()); - } - - #[test] - fn extract_attribute_finds_value() { - let xml = r#"100.00"#; - assert_eq!(extract_attribute(xml, "Amount", "Ccy"), Some("USD")); - } - - #[test] - fn extract_attribute_self_closing() { - let xml = r#""#; - assert_eq!(extract_attribute(xml, "Amt", "Ccy"), Some("EUR")); - } - - #[test] - fn extract_attribute_missing_returns_none() { - let xml = r#"100.00"#; - assert_eq!(extract_attribute(xml, "Amount", "Ccy"), None); - } - - #[test] - fn has_element_present() { - let xml = "uuid-here"; - assert!(has_element(xml, "UETR")); - } - - #[test] - fn has_element_self_closing() { - let xml = ""; - assert!(has_element(xml, "Empty")); - } - - #[test] - fn has_element_absent() { - let xml = "123"; - assert!(!has_element(xml, "AppHdr")); - } - - #[test] - fn xml_byte_size_ascii() { - assert_eq!(xml_byte_size("hello"), 5); - } - - #[test] - fn xml_byte_size_utf8_multibyte() { - // "é" is 2 bytes in UTF-8 - assert_eq!(xml_byte_size("é"), 2); - } -} diff --git a/crates/mx20022-validate/tests/schemes.rs b/crates/mx20022-validate/tests/schemes.rs index 738ce28..1700774 100644 --- a/crates/mx20022-validate/tests/schemes.rs +++ b/crates/mx20022-validate/tests/schemes.rs @@ -33,6 +33,17 @@ fn has_warning_with_rule(result: &mx20022_validate::ValidationResult, rule_id: & .any(|e| e.rule_id == rule_id && e.severity == Severity::Warning) } +fn assert_only_rule(result: &mx20022_validate::ValidationResult, rule_id: &str) { + assert_eq!( + result.errors.len(), + 1, + "expected exactly one {rule_id} finding, got: {:?}", + result.errors + ); + assert_eq!(result.errors[0].rule_id, rule_id); + assert_eq!(result.errors[0].path, "/Document"); +} + // --------------------------------------------------------------------------- // FedNow tests // --------------------------------------------------------------------------- @@ -550,20 +561,10 @@ use mx20022_model::generated::pacs::pacs_008_001_13; /// If the XML is wrapped in an envelope (e.g. ``), extracts the /// inner `` element first. fn parse_pacs008(xml: &str) -> pacs_008_001_13::Document { - let doc_xml = extract_document_fragment(xml); + let doc_xml = mx20022_parse::de::document_xml(xml).expect("fixture must contain Document"); mx20022_parse::de::from_str(doc_xml).expect("fixture must deserialize into pacs.008 Document") } -/// Extract the `...` fragment from possibly-enveloped XML. -fn extract_document_fragment(xml: &str) -> &str { - if let Some(start) = xml.find("") { - return &xml[start..end + "".len()]; - } - } - xml -} - #[test] fn fednow_typed_valid_pacs008() { let xml = read_fixture("fednow/valid_pacs008.xml"); @@ -885,6 +886,14 @@ fn fednow_rejects_zero_amount() { ); } +#[test] +fn fednow_rejects_invalid_decimal_count_without_panicking() { + let xml = fednow_base_xml().replace("{amount}", "100.5"); + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + assert!(has_error_with_rule(&result, "FEDNOW_AMOUNT_DECIMALS")); + assert!(!has_error_with_rule(&result, "FEDNOW_AMOUNT_FORMAT")); +} + #[test] fn fednow_rejects_long_e2e_id() { let long_e2e = "A".repeat(36); // 36 > 35 max @@ -902,30 +911,12 @@ fn fednow_rejects_long_e2e_id() { #[test] fn sepa_rejects_missing_debtor_name() { - let xml = r#" - - - - SEPA-NO-DBTR-NM - 2024-01-01T09:00:00Z - 1 - CLRG - - - E2E-001 - 100.00 - SLEV - - BANKDEFF - - DE89370400440532013000 - Bob - FR7630006000011234567890189 - - -"#; + let mut doc = parse_pacs008(&read_fixture("sepa/valid_pacs008.xml")); + doc.fi_to_fi_cstmr_cdt_trf.cdt_trf_tx_inf[0].dbtr.nm = None; let v = SepaValidator::new(); - let result = v.validate(xml, "pacs.008.001.13"); + let result = v + .validate_typed(&doc, "pacs.008.001.13") + .expect("SEPA supports typed pacs.008.001.13"); assert!( has_error_with_rule(&result, "SEPA_DBTR_NM"), "Expected SEPA_DBTR_NM for missing debtor name; got: {:?}", @@ -935,30 +926,12 @@ fn sepa_rejects_missing_debtor_name() { #[test] fn sepa_rejects_missing_creditor_name() { - let xml = r#" - - - - SEPA-NO-CDTR-NM - 2024-01-01T09:00:00Z - 1 - CLRG - - - E2E-001 - 100.00 - SLEV - Alice - DE89370400440532013000 - - BANKDEFF - - FR7630006000011234567890189 - - -"#; + let mut doc = parse_pacs008(&read_fixture("sepa/valid_pacs008.xml")); + doc.fi_to_fi_cstmr_cdt_trf.cdt_trf_tx_inf[0].cdtr.nm = None; let v = SepaValidator::new(); - let result = v.validate(xml, "pacs.008.001.13"); + let result = v + .validate_typed(&doc, "pacs.008.001.13") + .expect("SEPA supports typed pacs.008.001.13"); assert!( has_error_with_rule(&result, "SEPA_CDTR_NM"), "Expected SEPA_CDTR_NM for missing creditor name; got: {:?}", @@ -967,7 +940,7 @@ fn sepa_rejects_missing_creditor_name() { } #[test] -fn cbpr_rejects_missing_chrgbr() { +fn cbpr_missing_required_chrgbr_returns_scheme_parse() { let xml = r#" BAH-001 @@ -998,15 +971,11 @@ fn cbpr_rejects_missing_chrgbr() { "#; let v = CbprPlusValidator::new(); let result = v.validate(xml, "pacs.008.001.13"); - assert!( - has_error_with_rule(&result, "CBPR_CHRGBR_REQUIRED"), - "Expected CBPR_CHRGBR_REQUIRED; got: {:?}", - result.errors - ); + assert_only_rule(&result, "SCHEME_PARSE"); } #[test] -fn cbpr_rejects_invalid_chrgbr_value() { +fn cbpr_schema_invalid_chrgbr_returns_scheme_parse() { let xml = r#" BAH-001 @@ -1038,17 +1007,12 @@ fn cbpr_rejects_invalid_chrgbr_value() { "#; let v = CbprPlusValidator::new(); let result = v.validate(xml, "pacs.008.001.13"); - assert!( - has_error_with_rule(&result, "CBPR_CHRGBR_VALUE"), - "Expected CBPR_CHRGBR_VALUE for ChrgBr=XXXX; got: {:?}", - result.errors - ); + assert_only_rule(&result, "SCHEME_PARSE"); } #[test] -fn cbpr_rejects_unclosed_parent_tag() { - // The tag is intentionally unclosed — should produce an error - // instead of silently passing (verifies fix from item 1). +fn cbpr_malformed_nesting_returns_scheme_parse() { + // The tag is intentionally unclosed. let xml = r#" BAH-001 @@ -1078,14 +1042,341 @@ fn cbpr_rejects_unclosed_parent_tag() { "#; let v = CbprPlusValidator::new(); let result = v.validate(xml, "pacs.008.001.13"); - // Should detect unclosed Dbtr tag rather than silently passing. - let has_unclosed_error = result + assert_only_rule(&result, "SCHEME_PARSE"); +} + +#[test] +fn xml_and_typed_rule_ids_match_for_multi_transaction_document() { + let fixture = read_fixture("fednow/valid_pacs008.xml"); + let document = mx20022_parse::de::document_xml(&fixture).unwrap(); + let tx_start = document.find("").unwrap(); + let tx_end = + document[tx_start..].find("").unwrap() + tx_start + "".len(); + let transaction = &document[tx_start..tx_end]; + let multi_document = document + .replacen("1", "2", 1) + .replacen( + "", + &format!("{transaction}"), + 1, + ); + let envelope = format!( + "multi{multi_document}" + ); + + let validator = FedNowValidator::new(); + let xml_result = validator.validate(&envelope, "pacs.008"); + let document = parse_pacs008(&envelope); + let typed_result = validator + .validate_typed(&document, "pacs.008.001.13") + .unwrap(); + + let mut xml_rule_ids: Vec<_> = xml_result .errors .iter() - .any(|e| e.message.contains("unclosed") || e.rule_id.contains("DBTR_NM")); + .map(|error| error.rule_id.as_str()) + .collect(); + let mut typed_rule_ids: Vec<_> = typed_result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + xml_rule_ids.sort_unstable(); + typed_rule_ids.sort_unstable(); + assert_eq!(xml_rule_ids, typed_rule_ids); +} + +#[test] +fn sepa_rejects_36_scalar_end_to_end_id_through_xml_adapter() { + let xml = + read_fixture("sepa/valid_pacs008.xml").replace("E2E-SEPA-20240101-001", &"é".repeat(36)); + let result = SepaValidator::new().validate(&xml, "pacs.008.001.13"); + assert!(has_error_with_rule(&result, "SEPA_E2E_LENGTH")); +} + +#[test] +fn missing_required_001_13_field_returns_only_scheme_parse() { + let document = read_fixture("fednow/valid_pacs008.xml") + .replace("", "") + .replace(" SLEV\n", ""); + let xml = format!( + "missing-required{document}" + ); + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + assert_only_rule(&result, "SCHEME_PARSE"); +} + +#[test] +fn malformed_namespaced_document_preserves_raw_header_warning() { + let xml = r#""#; + let result = FedNowValidator::new().validate(xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!(rule_ids, vec!["FEDNOW_APPHDR_MISSING", "SCHEME_PARSE"]); +} + +#[test] +fn pacs_008_001_08_returns_raw_findings_and_untyped_warning_only() { + let xml = r#""#; + let result = FedNowValidator::new().validate(xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!( + rule_ids, + vec!["FEDNOW_APPHDR_MISSING", "SCHEME_UNTYPED_VERSION"] + ); +} + +#[test] +fn older_pacs_008_versions_retain_scheme_field_errors() { + let sepa_xml = + read_fixture("sepa/invalid_usd.xml").replace("pacs.008.001.13", "pacs.008.001.08"); + let sepa = SepaValidator::new().validate(&sepa_xml, "pacs.008.001.13"); + assert!(has_error_with_rule(&sepa, "SEPA_CURRENCY")); + assert!(has_warning_with_rule(&sepa, "SCHEME_UNTYPED_VERSION")); + + let fednow_xml = + read_fixture("fednow/invalid_eur.xml").replace("pacs.008.001.13", "pacs.008.001.08"); + let fednow = FedNowValidator::new().validate(&fednow_xml, "pacs.008.001.13"); + assert!(has_error_with_rule(&fednow, "FEDNOW_CURRENCY")); + assert!(has_warning_with_rule(&fednow, "SCHEME_UNTYPED_VERSION")); + + let cbpr_xml = + read_fixture("cbpr/invalid_missing_uetr.xml").replace("pacs.008.001.13", "pacs.008.001.08"); + let cbpr = CbprPlusValidator::new().validate(&cbpr_xml, "pacs.008.001.13"); + assert!(has_error_with_rule(&cbpr, "CBPR_UETR_REQUIRED")); + assert!(has_warning_with_rule(&cbpr, "SCHEME_UNTYPED_VERSION")); +} + +#[test] +fn older_pacs_008_versions_preserve_scanner_era_rule_ids() { + let cbpr_xml = + read_fixture("cbpr/valid_pacs008.xml").replace("pacs.008.001.13", "pacs.008.001.08"); + for (xml, rule_id) in [ + ( + cbpr_xml.replace("SHAR", ""), + "CBPR_CHRGBR_REQUIRED", + ), + ( + cbpr_xml.replace("SHAR", "XXXX"), + "CBPR_CHRGBR_VALUE", + ), + ( + cbpr_xml.replace("E2E-CBPR-20240101-001", ""), + "CBPR_E2E_REQUIRED", + ), + ] { + let result = CbprPlusValidator::new().validate(&xml, "pacs.008.001.13"); + assert!( + has_error_with_rule(&result, rule_id), + "{rule_id}: {result:?}" + ); + } + + let sepa_xml = read_fixture("sepa/valid_pacs008.xml") + .replace("pacs.008.001.13", "pacs.008.001.08") + .replace("SLEV", ""); + let sepa = SepaValidator::new().validate(&sepa_xml, "pacs.008.001.13"); assert!( - has_unclosed_error, - "Expected an error about unclosed Dbtr or missing Dbtr/Nm; got: {:?}", + has_error_with_rule(&sepa, "SEPA_CHRGBR_REQUIRED"), + "{sepa:?}" + ); +} + +#[test] +fn document_namespace_overrides_caller_version() { + let xml = read_fixture("fednow/invalid_eur.xml"); + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.08"); + assert!(has_error_with_rule(&result, "FEDNOW_CURRENCY")); + assert!(!has_warning_with_rule(&result, "SCHEME_UNTYPED_VERSION")); +} + +#[test] +fn prefixed_document_uses_namespace_inherited_from_envelope() { + let document = read_fixture("fednow/valid_pacs008.xml") + .replace("", "") + .replace( + "", + "", + ) + .replace("", ""); + let xml = format!( + r#"id{document}"# + ); + + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.08"); + assert!( + result.errors.is_empty(), + "inherited Document namespace should route and deserialize: {:?}", + result.errors + ); +} + +#[test] +fn supported_transport_wrappers_route_the_payment_document() { + let fixture = read_fixture("cbpr/valid_pacs008.xml"); + let payload = fixture + .replace("", "") + .replacen("", "", 1) + .replacen("", "", 1); + + for (open, close) in [ + ("", ""), + ("", ""), + ("", ""), + ] { + let xml = format!("{open}{payload}{close}"); + let result = CbprPlusValidator::new().validate(&xml, "pacs.008.001.13"); + assert!( + result.is_valid(), + "supported wrapper should validate: {:?}", + result.errors + ); + assert!(!has_error_with_rule(&result, "SCHEME_PARSE")); + } +} + +#[test] +fn supplementary_document_is_not_a_second_payload_candidate() { + let xml = read_fixture("fednow/valid_pacs008.xml").replacen( + "", + "opaque", + 1, + ); + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + assert!( + result.is_valid(), + "opaque supplementary XML should not block scheme validation: {:?}", result.errors ); + assert!(!has_error_with_rule(&result, "SCHEME_PARSE")); +} + +#[test] +fn fednow_raw_size_check_applies_to_non_pacs_008_supported_type() { + let xml = format!("{}", "x".repeat(65 * 1024)); + let result = FedNowValidator::new().validate(&xml, "pacs.002.001.14"); + assert!(has_error_with_rule(&result, "FEDNOW_MSG_SIZE")); +} + +#[test] +fn fednow_size_finding_survives_schema_parse_failure() { + let document = read_fixture("fednow/valid_pacs008.xml") + .replace("", "") + .replace(" SLEV\n", ""); + let padding = format!("", "x".repeat(65 * 1024)); + let xml = format!( + "oversized-invalid{padding}{document}" + ); + + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!(rule_ids, vec!["FEDNOW_MSG_SIZE", "SCHEME_PARSE"]); +} + +#[test] +fn fednow_size_finding_survives_document_extraction_failure() { + let padding = format!("", "x".repeat(65 * 1024)); + let xml = format!( + "oversized-malformed{padding}" + ); + + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!(rule_ids, vec!["FEDNOW_MSG_SIZE", "SCHEME_PARSE"]); +} + +#[test] +fn fednow_size_finding_survives_document_namespace_failure() { + let padding = format!("", "x".repeat(65 * 1024)); + let xml = format!( + "oversized-prefix{padding}" + ); + + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!(rule_ids, vec!["FEDNOW_MSG_SIZE", "SCHEME_PARSE"]); +} + +#[test] +fn fednow_size_finding_survives_duplicate_and_wrong_type_failures() { + let padding = format!("", "x".repeat(65 * 1024)); + let cases = [ + format!( + "duplicate{padding}" + ), + format!( + "wrong-type{padding}" + ), + ]; + + for xml in cases { + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!(rule_ids, vec!["FEDNOW_MSG_SIZE", "SCHEME_PARSE"]); + } +} + +#[test] +fn cbpr_header_and_control_findings_survive_document_extraction_failure() { + let xml = "\u{1}"; + let result = CbprPlusValidator::new().validate(xml, "pacs.008.001.13"); + let mut rule_ids: Vec<_> = result + .errors + .iter() + .map(|error| error.rule_id.as_str()) + .collect(); + rule_ids.sort_unstable(); + assert_eq!( + rule_ids, + vec!["CBPR_BAH_REQUIRED", "CBPR_CONTROL_CHAR", "SCHEME_PARSE"] + ); +} + +#[test] +fn fednow_raw_header_warning_remains_on_routable_pacs_008() { + let xml = read_fixture("fednow/valid_pacs008.xml"); + let result = FedNowValidator::new().validate(&xml, "pacs.008.001.13"); + assert!(has_warning_with_rule(&result, "FEDNOW_APPHDR_MISSING")); +} + +#[test] +fn cbpr_raw_header_and_control_checks_apply_to_supported_untyped_message() { + let missing_header = CbprPlusValidator::new().validate("", "pacs.009.001.10"); + assert!(has_error_with_rule(&missing_header, "CBPR_BAH_REQUIRED")); + + let control = CbprPlusValidator::new().validate( + "id\u{1}", + "pacs.009.001.10", + ); + assert!(has_error_with_rule(&control, "CBPR_CONTROL_CHAR")); + assert!(!has_error_with_rule(&control, "CBPR_BAH_REQUIRED")); } diff --git a/crates/mx20022/Cargo.toml b/crates/mx20022/Cargo.toml index b90b82a..761212b 100644 --- a/crates/mx20022/Cargo.toml +++ b/crates/mx20022/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mx20022" -version = "0.3.2" +version = "0.4.0" description = "Production-grade ISO 20022 toolkit: strongly-typed message models, FedNow/SEPA/CBPR+ validation, and bidirectional SWIFT MT↔MX translation" edition.workspace = true rust-version.workspace = true diff --git a/crates/mx20022/src/lib.rs b/crates/mx20022/src/lib.rs index 785aa22..4ac0f97 100644 --- a/crates/mx20022/src/lib.rs +++ b/crates/mx20022/src/lib.rs @@ -19,14 +19,19 @@ //! # Quick start: parse an ISO 20022 XML message //! //! ```rust -//! use mx20022::prelude::*; //! use mx20022::model::generated::pacs::pacs_008_001_13::Document; +//! use mx20022::parse::{de, envelope}; //! -//! let xml = r#""#; -//! let id = detect_message_type(xml).unwrap(); +//! // This fixture deliberately declares the payment namespace on its wrapper. +//! let xml = include_str!("../tests/fixtures/inherited_namespace_pacs008.xml"); +//! let id = envelope::detect_message_type(xml).unwrap(); //! assert_eq!(id.family, "pacs"); //! assert_eq!(id.msg_id, "008"); //! assert_eq!(id.version, "13"); +//! +//! let document_xml = de::document_xml(xml).unwrap(); +//! let document: Document = de::from_str(document_xml).unwrap(); +//! assert_eq!(document.fi_to_fi_cstmr_cdt_trf.grp_hdr.msg_id.0, "README-INHERITED-NS"); //! ``` //! //! # Quick start: translate MT103 to pacs.008 diff --git a/crates/mx20022/tests/fixtures/inherited_namespace_pacs008.xml b/crates/mx20022/tests/fixtures/inherited_namespace_pacs008.xml new file mode 100644 index 0000000..d8650c8 --- /dev/null +++ b/crates/mx20022/tests/fixtures/inherited_namespace_pacs008.xml @@ -0,0 +1,63 @@ + + + + + + README-INHERITED-NS + 2024-01-01T12:00:00Z + 1 + + CLRG + + + + FRNYUS33XXX + + + + + CHASUS33XXX + + + + + + E2E-README-001 + 97ed4827-7b6f-4491-a06f-b548d5a7512d + + 1000.00 + 2024-01-01 + SLEV + + Alice Smith + + + + GB82WEST12345698765432 + + + + + AAAAGB2LXXX + + + + + BBBBUS33XXX + + + + Bob Jones + + + + DE89370400440532013000 + + + + Executable README routing example + + + + + From 4fafb282f064cdccc094661f2d811554c0768750 Mon Sep 17 00:00:00 2001 From: James Ray Date: Thu, 13 Aug 2026 11:06:03 -0500 Subject: [PATCH 2/4] fix: reject malformed XML and mismatched FedNow counts --- CHANGELOG.md | 10 ++++ crates/mx20022-cli/tests/cli_integration.rs | 53 ++++++++++++++++ crates/mx20022-parse/src/envelope.rs | 60 +++++++++++++++++-- crates/mx20022-validate/src/schemes/fednow.rs | 15 ++--- crates/mx20022-validate/tests/schemes.rs | 54 ++++++++++++++++- 5 files changed, 177 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76866f..832aef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 extraction helpers; scheme validation invokes `SchemeValidator::validate` once. +### Fixed + +- `mx20022-cli validate` now reads the complete XML stream before reporting a + valid message type, so malformed or truncated XML without `--scheme` prints + a parse diagnostic and exits non-zero instead of reporting success. +- `FEDNOW_SINGLE_TX` now checks both the declared `NbOfTxs` and the actual + number of `CdtTrfTxInf` elements. A message declaring one transaction while + carrying multiple transactions is rejected on typed and older-version + validation paths. + ### Removed - Removed the public `mx20022_validate::schemes::xml_scan` module and the diff --git a/crates/mx20022-cli/tests/cli_integration.rs b/crates/mx20022-cli/tests/cli_integration.rs index 17457d7..fa25743 100644 --- a/crates/mx20022-cli/tests/cli_integration.rs +++ b/crates/mx20022-cli/tests/cli_integration.rs @@ -191,6 +191,36 @@ fn validate_invalid_bic_exits_nonzero() { ); } +#[test] +fn validate_malformed_xml_without_scheme_exits_nonzero() { + let path = + std::env::temp_dir().join(format!("mx20022-cli-{}-malformed.xml", std::process::id())); + std::fs::write( + &path, + r#""#, + ) + .expect("temporary XML fixture should be writable"); + + let output = Command::new(bin_path()) + .args(["validate", &path.to_string_lossy()]) + .output() + .expect("failed to run mx20022-cli"); + let _ = std::fs::remove_file(path); + + assert!( + !output.status.success(), + "malformed XML must exit non-zero, stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stdout.contains("OK"), "malformed XML must not print OK"); + assert!( + stderr.contains("parse error") && stderr.contains("malformed XML"), + "expected a malformed-XML diagnostic, stderr: {stderr}" + ); +} + #[test] fn validate_head_xml_exits_zero() { let output = Command::new(bin_path()) @@ -496,6 +526,29 @@ fn validate_with_scheme_fednow_invalid_catches_error() { ); } +#[test] +fn validate_with_scheme_fednow_counts_actual_transactions() { + let xml = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")).unwrap(); + let tx_start = xml.find("").unwrap(); + let tx_end = + xml[tx_start..].find("").unwrap() + tx_start + "".len(); + let transaction = &xml[tx_start..tx_end]; + let multi_xml = xml.replacen( + "", + &format!("{transaction}"), + 1, + ); + + let output = run_scheme_xml("fednow-actual-multi-tx", &multi_xml, "fednow"); + assert!( + !output.status.success(), + "FedNow input with two actual transactions must exit non-zero" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("FEDNOW_SINGLE_TX"), "stdout: {stdout}"); + assert!(stdout.contains("found 2"), "stdout: {stdout}"); +} + #[test] fn validate_with_scheme_fednow_keeps_size_error_on_schema_failure() { let fixture = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")).unwrap(); diff --git a/crates/mx20022-parse/src/envelope.rs b/crates/mx20022-parse/src/envelope.rs index 2266594..b70706d 100644 --- a/crates/mx20022-parse/src/envelope.rs +++ b/crates/mx20022-parse/src/envelope.rs @@ -87,7 +87,8 @@ pub fn parse_namespace(ns: &str) -> Result { /// # Errors /// /// Returns [`ParseError::InvalidEnvelope`] if no matching namespace is found or -/// the namespace is malformed. +/// the namespace or XML stream is malformed. The complete stream is read before +/// a detected message type is returned. /// /// # Examples /// @@ -103,14 +104,27 @@ pub fn parse_namespace(ns: &str) -> Result { pub fn detect_message_type(xml: &str) -> Result { let mut reader = NsReader::from_str(xml); let mut first_iso_namespace = None; + let mut document_message_id = None; + let mut depth = 0usize; + let mut root_seen = false; loop { let (namespace, event) = reader.read_resolved_event().map_err(|error| { ParseError::InvalidEnvelope(format!("malformed XML envelope: {error}")) })?; + let opens_element = matches!(&event, Event::Start(_)); match event { Event::Start(element) | Event::Empty(element) => { + if depth == 0 { + if root_seen { + return Err(ParseError::InvalidEnvelope( + "XML contains more than one root element".to_owned(), + )); + } + root_seen = true; + } + let is_document = element.local_name().as_ref() == b"Document"; match namespace { ResolveResult::Bound(namespace) => { @@ -120,19 +134,21 @@ pub fn detect_message_type(xml: &str) -> Result { "element namespace is not UTF-8: {error}" )) })?; - if is_document { - return parse_namespace(namespace); + if is_document && document_message_id.is_none() { + document_message_id = Some(parse_namespace(namespace)?); } if first_iso_namespace.is_none() && namespace.starts_with(NS_PREFIX) { first_iso_namespace = Some(parse_namespace(namespace)?); } } - ResolveResult::Unbound if is_document => { + ResolveResult::Unbound if is_document && document_message_id.is_none() => { return Err(ParseError::InvalidEnvelope( "Document element has no namespace".to_owned(), )); } - ResolveResult::Unknown(prefix) if is_document => { + ResolveResult::Unknown(prefix) + if is_document && document_message_id.is_none() => + { return Err(ParseError::InvalidEnvelope(format!( "Document namespace prefix is not declared: {}", String::from_utf8_lossy(&prefix) @@ -140,13 +156,36 @@ pub fn detect_message_type(xml: &str) -> Result { } ResolveResult::Unbound | ResolveResult::Unknown(_) => {} } + if opens_element { + depth += 1; + } + } + Event::End(_) => { + depth = depth.checked_sub(1).ok_or_else(|| { + ParseError::InvalidEnvelope( + "malformed XML envelope: unmatched closing element".to_owned(), + ) + })?; + } + Event::Text(text) if depth == 0 => { + if !text.as_ref().iter().all(u8::is_ascii_whitespace) { + return Err(ParseError::InvalidEnvelope( + "non-whitespace content appears outside the root element".to_owned(), + )); + } } Event::Eof => break, _ => {} } } - first_iso_namespace.ok_or_else(|| { + if depth != 0 { + return Err(ParseError::InvalidEnvelope( + "malformed XML envelope: root element is not closed".to_owned(), + )); + } + + document_message_id.or(first_iso_namespace).ok_or_else(|| { ParseError::InvalidEnvelope("no ISO 20022 namespace found in XML document".to_owned()) }) } @@ -241,4 +280,13 @@ mod tests { "pacs.008.001.13" ); } + + #[test] + fn detect_message_type_rejects_truncated_document() { + let xml = r#""#; + assert!( + detect_message_type(xml).is_err(), + "namespace detection must not accept malformed trailing XML" + ); + } } diff --git a/crates/mx20022-validate/src/schemes/fednow.rs b/crates/mx20022-validate/src/schemes/fednow.rs index 51585e4..0aa2b93 100644 --- a/crates/mx20022-validate/src/schemes/fednow.rs +++ b/crates/mx20022-validate/src/schemes/fednow.rs @@ -6,7 +6,8 @@ //! - Only USD transactions are accepted. //! - Settlement method must be `CLRG`. //! - Charges bearer must be `SLEV`. -//! - A single transaction per group (`NbOfTxs = "1"`). +//! - A single transaction per group (`NbOfTxs = "1"` and exactly one +//! `CdtTrfTxInf`). //! - UETR is mandatory (UUID v4 format). //! - End-to-end ID is mandatory (≤ 35 characters). //! - Amount in `[0.01, 500_000.00]` USD (the upper bound is configurable up to @@ -207,18 +208,18 @@ impl FedNowValidator { fn validate_pacs008(&self, facts: &super::pacs008::Facts) -> ValidationResult { let mut errors: Vec = Vec::new(); - // --- NbOfTxs must be "1" ------------------------------------------- - if let Some(nb_of_txs) = &facts.nb_of_txs { - if nb_of_txs != "1" { - errors.push(ValidationError::new( + // --- Declared and actual transaction count must both be one --------- + let actual_transactions = facts.transactions.len(); + if facts.nb_of_txs.as_deref() != Some("1") || actual_transactions != 1 { + let declared_transactions = facts.nb_of_txs.as_deref().unwrap_or(""); + errors.push(ValidationError::new( "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", Severity::Error, "FEDNOW_SINGLE_TX", format!( - "FedNow requires exactly one transaction per group (NbOfTxs = \"1\"), got \"{nb_of_txs}\"" + "FedNow requires exactly one transaction per group (NbOfTxs = \"1\" and one CdtTrfTxInf); declared \"{declared_transactions}\", found {actual_transactions} CdtTrfTxInf element(s)" ), )); - } } // --- Settlement method must be CLRG --------------------------------- diff --git a/crates/mx20022-validate/tests/schemes.rs b/crates/mx20022-validate/tests/schemes.rs index 1700774..cccae95 100644 --- a/crates/mx20022-validate/tests/schemes.rs +++ b/crates/mx20022-validate/tests/schemes.rs @@ -133,6 +133,52 @@ fn fednow_multi_transaction() { ); } +#[test] +fn fednow_declared_single_with_two_transactions_fails() { + let xml = read_fixture("fednow/valid_pacs008.xml"); + let tx_start = xml.find("").unwrap(); + let tx_end = + xml[tx_start..].find("").unwrap() + tx_start + "".len(); + let transaction = xml[tx_start..tx_end].to_owned(); + let multi_xml = xml.replacen( + "", + &format!("{transaction}"), + 1, + ); + + let validator = FedNowValidator::new(); + let xml_result = validator.validate(&multi_xml, "pacs.008.001.13"); + assert!( + has_error_with_rule(&xml_result, "FEDNOW_SINGLE_TX"), + "declaring one while carrying two transactions must fail: {:?}", + xml_result.errors + ); + let single_tx_finding = xml_result + .errors + .iter() + .find(|error| error.rule_id == "FEDNOW_SINGLE_TX") + .unwrap(); + assert!(single_tx_finding.message.contains("found 2")); + + let document = parse_pacs008(&multi_xml); + let typed_result = validator + .validate_typed(&document, "pacs.008.001.13") + .expect("FedNow supports typed pacs.008.001.13"); + assert!( + has_error_with_rule(&typed_result, "FEDNOW_SINGLE_TX"), + "typed validation must count the two actual transactions: {:?}", + typed_result.errors + ); + + let older_xml = multi_xml.replace("pacs.008.001.13", "pacs.008.001.08"); + let older_result = validator.validate(&older_xml, "pacs.008.001.08"); + assert!( + has_error_with_rule(&older_result, "FEDNOW_SINGLE_TX"), + "older-version validation must count actual transactions: {:?}", + older_result.errors + ); +} + #[test] fn fednow_missing_uetr() { let xml = r#" @@ -1120,7 +1166,7 @@ fn malformed_namespaced_document_preserves_raw_header_warning() { } #[test] -fn pacs_008_001_08_returns_raw_findings_and_untyped_warning_only() { +fn pacs_008_001_08_applies_version_neutral_transaction_count() { let xml = r#""#; let result = FedNowValidator::new().validate(xml, "pacs.008.001.13"); let mut rule_ids: Vec<_> = result @@ -1131,7 +1177,11 @@ fn pacs_008_001_08_returns_raw_findings_and_untyped_warning_only() { rule_ids.sort_unstable(); assert_eq!( rule_ids, - vec!["FEDNOW_APPHDR_MISSING", "SCHEME_UNTYPED_VERSION"] + vec![ + "FEDNOW_APPHDR_MISSING", + "FEDNOW_SINGLE_TX", + "SCHEME_UNTYPED_VERSION" + ] ); } From 46e19025782f487b771d9e2a89944e97b5acf248 Mon Sep 17 00:00:00 2001 From: James Ray Date: Thu, 13 Aug 2026 11:11:17 -0500 Subject: [PATCH 3/4] docs: satisfy rustdoc link lint --- crates/mx20022-validate/src/schemes/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mx20022-validate/src/schemes/mod.rs b/crates/mx20022-validate/src/schemes/mod.rs index c8bd5c9..cd765c4 100644 --- a/crates/mx20022-validate/src/schemes/mod.rs +++ b/crates/mx20022-validate/src/schemes/mod.rs @@ -16,7 +16,7 @@ //! //! # Error Paths //! -//! Error paths in [`ValidationError`](crate::error::ValidationError) follow +//! Error paths in [`ValidationError`] follow //! XPath-like conventions: //! //! | Style | Example | When | From ea9624ab47b0cff22504cac2e245a6e5aa5345fb Mon Sep 17 00:00:00 2001 From: James Ray Date: Thu, 13 Aug 2026 12:04:00 -0500 Subject: [PATCH 4/4] fix: close scheme validation bypasses --- CHANGELOG.md | 6 ++ crates/mx20022-cli/tests/cli_integration.rs | 24 +++++ .../mx20022-validate/src/schemes/pacs008.rs | 49 +++++++++- crates/mx20022-validate/src/schemes/sepa.rs | 68 +++++++------ crates/mx20022-validate/tests/schemes.rs | 95 +++++++++++++++++++ 5 files changed, 203 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832aef9..e207e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 number of `CdtTrfTxInf` elements. A message declaring one transaction while carrying multiple transactions is rejected on typed and older-version validation paths. +- Nested `CdtTrfTxInf` elements inside `SplmtryData` no longer terminate the + enclosing transaction during older-version pacs.008 fact extraction, so + subsequent scheme value rules cannot be bypassed. +- `SEPA_SINGLE_TX` now checks both declared and actual transaction counts, and + SEPA restricted-Latin checks again cover all `Nm`, `StrtNm`, and `TwnNm` + fields in addition to `Ustrd` on typed and older-version validation paths. ### Removed diff --git a/crates/mx20022-cli/tests/cli_integration.rs b/crates/mx20022-cli/tests/cli_integration.rs index fa25743..cfd0a55 100644 --- a/crates/mx20022-cli/tests/cli_integration.rs +++ b/crates/mx20022-cli/tests/cli_integration.rs @@ -549,6 +549,30 @@ fn validate_with_scheme_fednow_counts_actual_transactions() { assert!(stdout.contains("found 2"), "stdout: {stdout}"); } +#[test] +fn validate_with_scheme_fednow_keeps_fields_after_nested_supplementary_transaction() { + let xml = std::fs::read_to_string(scheme_testdata("fednow/valid_pacs008.xml")) + .unwrap() + .replace("pacs.008.001.13", "pacs.008.001.08") + .replace("Ccy=\"USD\">1000.00", "Ccy=\"EUR\">750000.00") + .replace("SLEV", "SHAR") + .replacen( + " opaque\n , pub(crate) instd_agent_bic: Option, pub(crate) transactions: Vec, + pub(crate) additional_charset_fields: Vec, +} + +#[derive(Debug)] +pub(crate) struct TaggedText { + pub(crate) tag: &'static str, + pub(crate) value: String, } #[derive(Debug, Default)] @@ -102,10 +109,11 @@ impl Facts { ) })?; let ends_transaction = node.name == b"CdtTrfTxInf"; - if !inside_supplementary_data(&stack) { + let is_supplementary = inside_supplementary_data(&stack); + if !is_supplementary { process_node(&mut facts, current_transaction, &stack, node); } - if ends_transaction { + if ends_transaction && !is_supplementary { current_transaction = None; } } @@ -116,6 +124,21 @@ impl Facts { Ok(facts) } + + /// Build typed facts and retain the additional tag-based charset fields. + /// + /// `Nm` occurs in many generated party, agent, and remittance types. Using + /// the generated serializer keeps this tag-based rule complete as the + /// schema evolves without duplicating that type graph here. Debtor and + /// creditor names plus `Ustrd` are already mapped into each transaction. + pub(crate) fn from_typed_with_additional_charset_fields( + document: &Document, + ) -> Result { + let mut facts = Self::from(document); + let serialized = mx20022_parse::ser::to_string(document)?; + facts.additional_charset_fields = Self::from_xml(&serialized)?.additional_charset_fields; + Ok(facts) + } } impl From<&Document> for Facts { @@ -136,6 +159,7 @@ impl From<&Document> for Facts { .as_ref() .and_then(|agent| agent.fin_instn_id.bicfi.as_ref()) .map(|bic| bic.0.clone()), + additional_charset_fields: Vec::new(), transactions: message .cdt_trf_tx_inf .iter() @@ -212,6 +236,13 @@ fn process_node( let text = node.text.trim().to_owned(); let name = node.name.as_slice(); + if let Some(tag) = additional_charset_tag(name, ancestors) { + facts.additional_charset_fields.push(TaggedText { + tag, + value: text.clone(), + }); + } + if current_transaction.is_none() { match name { b"NbOfTxs" if has_ancestor(ancestors, b"GrpHdr") => { @@ -274,6 +305,15 @@ fn process_node( } } +fn additional_charset_tag(name: &[u8], ancestors: &[Node]) -> Option<&'static str> { + match name { + b"Nm" if nearest_party(ancestors).is_none() => Some("Nm"), + b"StrtNm" => Some("StrtNm"), + b"TwnNm" => Some("TwnNm"), + _ => None, + } +} + fn has_ancestor(ancestors: &[Node], name: &[u8]) -> bool { ancestors.iter().any(|node| node.name == name) } @@ -323,11 +363,14 @@ mod tests { #[test] fn parses_each_transaction_without_reading_supplementary_payloads() { - let xml = r#"1CLRGe2e1.00Alice9.00"#; + let xml = r#"1CLRGe2e9.00Банкулица750000.00SHARAlice"#; let facts = Facts::from_xml(xml).unwrap(); assert_eq!(facts.nb_of_txs.as_deref(), Some("1")); assert_eq!(facts.transactions.len(), 1); assert_eq!(facts.transactions[0].currency.as_deref(), Some("EUR")); + assert_eq!(facts.transactions[0].amount.as_deref(), Some("750000.00")); + assert_eq!(facts.transactions[0].charge_bearer.as_deref(), Some("SHAR")); assert_eq!(facts.transactions[0].debtor_name.as_deref(), Some("Alice")); + assert!(facts.additional_charset_fields.is_empty()); } } diff --git a/crates/mx20022-validate/src/schemes/sepa.rs b/crates/mx20022-validate/src/schemes/sepa.rs index d9d3e74..1a6d402 100644 --- a/crates/mx20022-validate/src/schemes/sepa.rs +++ b/crates/mx20022-validate/src/schemes/sepa.rs @@ -97,7 +97,12 @@ impl SchemeValidator for SepaValidator { let doc = msg.downcast_ref::()?; - Some(self.validate_pacs008(&super::pacs008::Facts::from(doc))) + let facts = match super::pacs008::Facts::from_typed_with_additional_charset_fields(doc) { + Ok(facts) => facts, + Err(error) => return Some(super::scheme_parse_error(&error)), + }; + + Some(self.validate_pacs008(&facts)) } } @@ -143,16 +148,16 @@ impl SepaValidator { } } - /// Check that a name string conforms to the SEPA restricted Latin character set. - fn check_sepa_name(name: &str, errors: &mut Vec) { - if !is_sepa_charset(name) { - let bad: String = name.chars().filter(|&c| !is_sepa_char(c)).collect(); + /// Check that a field conforms to the SEPA restricted Latin character set. + fn check_sepa_text(tag: &str, value: &str, errors: &mut Vec) { + if !is_sepa_charset(value) { + let bad: String = value.chars().filter(|&c| !is_sepa_char(c)).collect(); errors.push(ValidationError::new( - "//Nm", + format!("//{tag}"), Severity::Error, "SEPA_CHARSET", format!( - "Field contains characters outside the SEPA restricted \ + "Field <{tag}> contains characters outside the SEPA restricted \ Latin character set: {bad:?}" ), )); @@ -176,18 +181,18 @@ impl SepaValidator { } } - // --- NbOfTxs must be "1" ------------------------------------------- - if let Some(nb_of_txs) = &facts.nb_of_txs { - if nb_of_txs != "1" { - errors.push(ValidationError::new( - "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", - Severity::Error, - "SEPA_SINGLE_TX", - format!( - "SEPA requires one transaction per group (NbOfTxs = \"1\"), got \"{nb_of_txs}\"" - ), - )); - } + // --- Declared and actual transaction count must both be one --------- + let actual_transactions = facts.transactions.len(); + if facts.nb_of_txs.as_deref() != Some("1") || actual_transactions != 1 { + let declared_transactions = facts.nb_of_txs.as_deref().unwrap_or(""); + errors.push(ValidationError::new( + "/Document/FIToFICstmrCdtTrf/GrpHdr/NbOfTxs", + Severity::Error, + "SEPA_SINGLE_TX", + format!( + "SEPA requires exactly one transaction per group (NbOfTxs = \"1\" and one CdtTrfTxInf); declared \"{declared_transactions}\", found {actual_transactions} CdtTrfTxInf element(s)" + ), + )); } for tx in &facts.transactions { @@ -305,21 +310,7 @@ impl SepaValidator { // SEPA character set check on Ustrd. for unstructured in &tx.unstructured_remittance { - if !is_sepa_charset(unstructured) { - let bad: String = unstructured - .chars() - .filter(|&character| !is_sepa_char(character)) - .collect(); - errors.push(ValidationError::new( - "//Ustrd", - Severity::Error, - "SEPA_CHARSET", - format!( - "Field contains characters outside the SEPA restricted \ - Latin character set: {bad:?}" - ), - )); - } + Self::check_sepa_text("Ustrd", unstructured, &mut errors); } } @@ -334,10 +325,10 @@ impl SepaValidator { // --- SEPA character set check on names -------------------------- if let Some(name) = &tx.debtor_name { - Self::check_sepa_name(name, &mut errors); + Self::check_sepa_text("Nm", name, &mut errors); } if let Some(name) = &tx.creditor_name { - Self::check_sepa_name(name, &mut errors); + Self::check_sepa_text("Nm", name, &mut errors); } // --- IBAN required for debtor and creditor accounts --- @@ -358,6 +349,11 @@ impl SepaValidator { } } + // --- SEPA character set check on other names and addresses --------- + for field in &facts.additional_charset_fields { + Self::check_sepa_text(field.tag, &field.value, &mut errors); + } + ValidationResult::new(errors) } } diff --git a/crates/mx20022-validate/tests/schemes.rs b/crates/mx20022-validate/tests/schemes.rs index cccae95..967c957 100644 --- a/crates/mx20022-validate/tests/schemes.rs +++ b/crates/mx20022-validate/tests/schemes.rs @@ -314,6 +314,101 @@ fn sepa_invalid_charset() { ); } +#[test] +fn sepa_counts_actual_transactions_and_reports_missing_declared_count() { + let xml = read_fixture("sepa/valid_pacs008.xml"); + let tx_start = xml.find("").unwrap(); + let tx_end = + xml[tx_start..].find("").unwrap() + tx_start + "".len(); + let transaction = xml[tx_start..tx_end].to_owned(); + let multi_xml = xml.replacen( + "", + &format!("{transaction}"), + 1, + ); + + let validator = SepaValidator::new(); + for (version, message_type, candidate) in [ + ("typed", "pacs.008.001.13", multi_xml.clone()), + ( + "untyped", + "pacs.008.001.08", + multi_xml.replace("pacs.008.001.13", "pacs.008.001.08"), + ), + ] { + let result = validator.validate(&candidate, message_type); + let finding = result + .errors + .iter() + .find(|error| error.rule_id == "SEPA_SINGLE_TX") + .unwrap_or_else(|| { + panic!("{version} validation must count two actual transactions: {result:?}") + }); + assert!(finding.message.contains("found 2"), "{finding:?}"); + } + + let missing_count = xml + .replace(" 1\n", "") + .replace("pacs.008.001.13", "pacs.008.001.08"); + let result = validator.validate(&missing_count, "pacs.008.001.08"); + let finding = result + .errors + .iter() + .find(|error| error.rule_id == "SEPA_SINGLE_TX") + .unwrap_or_else(|| panic!("missing NbOfTxs must fail: {result:?}")); + assert!(finding.message.contains(""), "{finding:?}"); +} + +#[test] +fn sepa_checks_agent_ultimate_party_and_address_charset_fields() { + let xml = read_fixture("sepa/valid_pacs008.xml") + .replacen( + " DEUTDEDBXXX", + " DEUTDEDBXXX\n Банк", + 1, + ) + .replacen( + " \n Hans Muller\n ", + " Владелец\n \n Hans Muller\n улицаМосква\n ", + 1, + ); + + let validator = SepaValidator::new(); + for (version, message_type, candidate) in [ + ("typed", "pacs.008.001.13", xml.clone()), + ( + "untyped", + "pacs.008.001.08", + xml.replace("pacs.008.001.13", "pacs.008.001.08"), + ), + ] { + let result = validator.validate(&candidate, message_type); + assert!( + !has_error_with_rule(&result, "SCHEME_PARSE"), + "{version} fixture must reach field rules: {result:?}" + ); + let charset_paths: Vec<_> = result + .errors + .iter() + .filter(|error| error.rule_id == "SEPA_CHARSET") + .map(|error| error.path.as_str()) + .collect(); + assert_eq!( + charset_paths.iter().filter(|path| **path == "//Nm").count(), + 2, + "{version} must reject agent and ultimate-party names: {result:?}" + ); + assert!( + charset_paths.contains(&"//StrtNm"), + "{version} must reject street names: {result:?}" + ); + assert!( + charset_paths.contains(&"//TwnNm"), + "{version} must reject town names: {result:?}" + ); + } +} + #[test] fn sepa_amount_too_high() { let xml = r#"