taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

subject.rs (21824B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2024-2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{
     18     fmt::{Debug, Display, Write as _},
     19     str::FromStr,
     20 };
     21 
     22 use aws_lc_rs::digest::{SHA256, digest};
     23 use compact_str::CompactString;
     24 use taler_common::{
     25     api::{EddsaPublicKey, ShortHashCode},
     26     db::IncomingType,
     27     encoding::base32::{Base32Error, CROCKFORD_ALPHABET},
     28     types::url,
     29 };
     30 use url::Url;
     31 
     32 #[derive(Debug, Clone, PartialEq, Eq)]
     33 pub enum IncomingSubject {
     34     Key(IncomingKey),
     35     AdminBalanceAdjust,
     36 }
     37 
     38 #[derive(Debug, Clone, PartialEq, Eq)]
     39 pub struct IncomingKey {
     40     pub ty: IncomingType,
     41     pub key: EddsaPublicKey,
     42 }
     43 
     44 impl IncomingKey {
     45     pub fn reserve(key: EddsaPublicKey) -> Self {
     46         Self {
     47             ty: IncomingType::reserve,
     48             key,
     49         }
     50     }
     51 
     52     pub fn kyc(key: EddsaPublicKey) -> Self {
     53         Self {
     54             ty: IncomingType::kyc,
     55             key,
     56         }
     57     }
     58 
     59     pub fn map(key: EddsaPublicKey) -> Self {
     60         Self {
     61             ty: IncomingType::map,
     62             key,
     63         }
     64     }
     65 }
     66 
     67 #[derive(Debug, PartialEq, Eq)]
     68 pub struct OutgoingSubject {
     69     pub wtid: ShortHashCode,
     70     pub exchange_base_url: Url,
     71     pub metadata: Option<CompactString>,
     72 }
     73 
     74 impl OutgoingSubject {
     75     /// Generate a random outgoing subject for https://exchange.test.com
     76     pub fn rand() -> Self {
     77         Self {
     78             wtid: ShortHashCode::rand(),
     79             exchange_base_url: url("https://exchange.test.com"),
     80             metadata: None,
     81         }
     82     }
     83 }
     84 
     85 /** Base32 quality by proximity to spec and error probability */
     86 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
     87 enum Base32Quality {
     88     /// Both mixed casing and mixed characters, that's weird
     89     Mixed,
     90     /// Standard but use lowercase, maybe the client shown lowercase in the UI
     91     Standard,
     92     /// Uppercase but mixed characters, its common when making typos
     93     Upper,
     94     /// Both uppercase and use the standard alphabet as it should
     95     UpperStandard,
     96 }
     97 
     98 impl Base32Quality {
     99     pub fn measure(s: &str) -> Self {
    100         let mut uppercase = true;
    101         let mut standard = true;
    102         for b in s.bytes() {
    103             uppercase &= b.is_ascii_uppercase() | b.is_ascii_digit();
    104             standard &= CROCKFORD_ALPHABET.contains(&b)
    105                 | CROCKFORD_ALPHABET.contains(&b.to_ascii_uppercase())
    106         }
    107         match (uppercase, standard) {
    108             (true, true) => Base32Quality::UpperStandard,
    109             (true, false) => Base32Quality::Upper,
    110             (false, true) => Base32Quality::Standard,
    111             (false, false) => Base32Quality::Mixed,
    112         }
    113     }
    114 }
    115 
    116 #[derive(Debug)]
    117 pub struct Candidate {
    118     subject: IncomingSubject,
    119     quality: Base32Quality,
    120 }
    121 
    122 #[derive(Debug, PartialEq, Eq)]
    123 pub enum IncomingSubjectResult {
    124     Success(IncomingSubject),
    125     Ambiguous,
    126 }
    127 
    128 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    129 pub enum IncomingSubjectErr {
    130     #[error("found multiple public keys")]
    131     Ambiguous,
    132     #[error("missing reserve public key")]
    133     Missing,
    134 }
    135 
    136 #[derive(Debug, thiserror::Error)]
    137 pub enum OutgoingSubjectErr {
    138     #[error("missing parts")]
    139     MissingParts,
    140     #[error("malformed wtid: {0}")]
    141     Wtid(#[from] Base32Error<32>),
    142     #[error("malformed exchange url: {0}")]
    143     Url(#[from] url::ParseError),
    144 }
    145 
    146 /// Parse a talerable outgoing transfer subject
    147 pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> {
    148     let mut parts = subject.split(' ');
    149     let first = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?;
    150     let second = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?;
    151     Ok(if let Some(third) = parts.next() {
    152         OutgoingSubject {
    153             wtid: second.parse()?,
    154             exchange_base_url: third.parse()?,
    155             metadata: Some(first.into()),
    156         }
    157     } else {
    158         OutgoingSubject {
    159             wtid: first.parse()?,
    160             exchange_base_url: second.parse()?,
    161             metadata: None,
    162         }
    163     })
    164 }
    165 
    166 /// Format an outgoing subject
    167 pub fn fmt_out_subject(
    168     wtid: &ShortHashCode,
    169     url: impl AsRef<str>,
    170     metadata: Option<&str>,
    171 ) -> String {
    172     let mut buf = String::new();
    173     if let Some(metadata) = metadata {
    174         buf.push_str(metadata);
    175         buf.push(' ');
    176     }
    177     write!(&mut buf, "{wtid} {}", url.as_ref()).unwrap();
    178     buf
    179 }
    180 
    181 /// Format an incoming subject
    182 pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> impl Display {
    183     std::fmt::from_fn(move |f| match ty {
    184         IncomingType::reserve => write!(f, "{key}"),
    185         IncomingType::kyc => write!(f, "KYC:{key}"),
    186         IncomingType::map => write!(f, "MAP:{key}"),
    187     })
    188 }
    189 
    190 /**
    191  * Extract the public key from an unstructured incoming transfer subject.
    192  *
    193  * When a user enters the transfer object in an unstructured way, for ex in
    194  * their banking UI, they may mistakenly enter separators such as ' \n-+' and
    195  * make typos.
    196  * To parse them while ignoring user errors, we reconstruct valid keys from key
    197  * parts, resolving ambiguities where possible.
    198  **/
    199 pub fn parse_incoming_unstructured(subject: &str) -> Result<IncomingSubject, IncomingSubjectErr> {
    200     // We expect subject to be less than 4GB
    201     assert!(subject.len() <= u32::MAX as usize);
    202 
    203     const KEY_SIZE: usize = 52;
    204     const PREFIXED_SIZE: usize = KEY_SIZE + 3;
    205     const ADMIN_BALANCE_ADJUST: &str = "ADMINBALANCEADJUST";
    206 
    207     /** Parse an incoming subject */
    208     #[inline]
    209     fn parse_single(str: &str) -> Option<Candidate> {
    210         if str == ADMIN_BALANCE_ADJUST {
    211             return Some(Candidate {
    212                 subject: IncomingSubject::AdminBalanceAdjust,
    213                 quality: Base32Quality::UpperStandard,
    214             });
    215         }
    216         // Check key type
    217         let (ty, raw) = match str.len() {
    218             KEY_SIZE => (IncomingType::reserve, str),
    219             PREFIXED_SIZE => {
    220                 if let Some(key) = str.strip_prefix("KYC") {
    221                     (IncomingType::kyc, key)
    222                 } else if let Some(key) = str.strip_prefix("MAP") {
    223                     (IncomingType::map, key)
    224                 } else {
    225                     return None;
    226                 }
    227             }
    228             _ => return None,
    229         };
    230 
    231         // Check key validity
    232         let key = EddsaPublicKey::from_str(raw).ok()?;
    233 
    234         let quality = Base32Quality::measure(raw);
    235         Some(Candidate {
    236             subject: IncomingSubject::Key(IncomingKey { ty, key }),
    237             quality,
    238         })
    239     }
    240 
    241     // Find and concatenate valid parts of a keys
    242     let (parts, concatenated) = {
    243         let mut parts = Vec::with_capacity(4);
    244         let mut concatenated = String::with_capacity(subject.len().min(PREFIXED_SIZE + 10));
    245         parts.push(0u32);
    246         for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) {
    247             if !part.is_empty() {
    248                 // SAFETY: part are all valid ASCII alphanumeric
    249                 concatenated.push_str(unsafe { std::str::from_utf8_unchecked(part) });
    250                 parts.push(concatenated.len() as u32);
    251             }
    252         }
    253         (parts, concatenated)
    254     };
    255 
    256     // Find best candidates
    257     let mut best: Option<(IncomingType, EddsaPublicKey, Base32Quality)> = None;
    258     // For each part as a starting point
    259     for (i, &start) in parts.iter().enumerate() {
    260         // Use progressively longer concatenation
    261         for &end in parts[i..].iter().skip(1) {
    262             let len = (end - start) as usize;
    263             // Until they are to long to be a key
    264             if len > PREFIXED_SIZE {
    265                 break;
    266             } else if len != KEY_SIZE && len != PREFIXED_SIZE && len != ADMIN_BALANCE_ADJUST.len() {
    267                 continue;
    268             }
    269 
    270             // Parse the concatenated parts
    271             // SAFETY: we now end.end <= concatenated.len
    272             let slice = unsafe { &concatenated.get_unchecked(start as usize..end as usize) };
    273             if let Some(new) = parse_single(slice) {
    274                 let (nty, nkey) = match new.subject {
    275                     IncomingSubject::AdminBalanceAdjust => {
    276                         return Ok(IncomingSubject::AdminBalanceAdjust);
    277                     }
    278                     IncomingSubject::Key(IncomingKey { ty, key }) => (ty, key),
    279                 };
    280                 // On success update best candidate
    281                 match best {
    282                     Some((bty, bkey, bquality)) => {
    283                         if new.quality > bquality // We prefer high quality keys
    284                                 || matches!( // We prefer prefixed keys over reserve keys
    285                                     (bty, nty),
    286                                     (IncomingType::reserve, IncomingType::kyc | IncomingType::map)
    287                                 )
    288                         {
    289                             best = Some((nty, nkey, new.quality))
    290                         } else if bkey != nkey // If keys are different
    291                                 && bquality == new.quality // Of same quality
    292                                 && !matches!( // And prefixing is different
    293                                     (bty, nty),
    294                                     (IncomingType::kyc | IncomingType::map, IncomingType::reserve)
    295                                 )
    296                         {
    297                             return Err(IncomingSubjectErr::Ambiguous);
    298                         }
    299                     }
    300                     None => best = Some((nty, nkey, new.quality)),
    301                 }
    302             }
    303         }
    304     }
    305 
    306     if let Some((ty, key, _)) = best {
    307         Ok(IncomingSubject::Key(IncomingKey { ty, key }))
    308     } else {
    309         Err(IncomingSubjectErr::Missing)
    310     }
    311 }
    312 
    313 // Modulo 10 Recursive
    314 fn mod10_recursive(bytes: &[u8]) -> u8 {
    315     const LOOKUP_TABLE: [u8; 10] = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5];
    316     // Modulo 10 Recursive calculation
    317     let mut carry = 0u8;
    318     for &b in bytes {
    319         // ASCII '0'-'9' is 0x30-0x39. Subtracting b'0' (48) gives the integer.
    320         let digit = b - b'0';
    321         carry = LOOKUP_TABLE[((carry + digit) % 10) as usize];
    322     }
    323     carry
    324 }
    325 
    326 /// Encode a public key as a QR-Bill reference
    327 pub fn subject_fmt_qr_bill(key_bytes: &[u8]) -> String {
    328     // High-Entropy Hash (SHA-256) to ensure even distribution
    329     let hash = digest(&SHA256, key_bytes);
    330 
    331     // Compute hash % 10^26
    332     let hash_mod = hash.as_ref().chunks(3).fold(0u128, |rem, chunk| {
    333         chunk.iter().fold(rem, |r, &b| r * 256 + b as u128) % 10u128.pow(26)
    334     });
    335 
    336     // Format to 26 digits with leading zeros
    337     let reference_base = format!("{:0>26}", hash_mod);
    338 
    339     // Modulo 10 Recursive calculation
    340     let carry = mod10_recursive(reference_base.as_bytes());
    341     let checksum = (10 - carry) % 10;
    342 
    343     // Combine base (26) + checksum (1) = 27 characters
    344     format!("{}{}", reference_base, checksum)
    345 }
    346 
    347 /// Check if a string is a valid QR-Bill reference
    348 pub fn subject_is_qr_bill(reference: &str) -> bool {
    349     // Quick length and numeric check
    350     if reference.len() != 27 || !reference.chars().all(|c| c.is_ascii_digit()) {
    351         return false;
    352     }
    353 
    354     // If the check digit is correct, the final carry will be 0
    355     mod10_recursive(reference.as_bytes()) == 0
    356 }
    357 
    358 #[cfg(test)]
    359 mod test {
    360     use std::str::FromStr as _;
    361 
    362     use taler_common::{
    363         api::{EddsaPublicKey, ShortHashCode},
    364         db::IncomingType,
    365         types::url,
    366     };
    367 
    368     use crate::subject::{
    369         Base32Quality, IncomingKey, IncomingSubject, IncomingSubjectErr, OutgoingSubject,
    370         fmt_out_subject, mod10_recursive, parse_incoming_unstructured, parse_outgoing,
    371         subject_fmt_qr_bill, subject_is_qr_bill,
    372     };
    373 
    374     #[test]
    375     fn qrbill() {
    376         let reference = "210000000003139471430009017";
    377 
    378         let input = "21000000000313947143000901";
    379         let carry = mod10_recursive(input.as_bytes());
    380         let checksum = (10 - carry) % 10;
    381         assert_eq!(checksum, 7);
    382 
    383         assert_eq!(mod10_recursive(reference.as_bytes()), 0);
    384         assert!(subject_is_qr_bill(reference));
    385         assert!(!subject_is_qr_bill(input));
    386         assert!(!subject_is_qr_bill(""));
    387         assert!(!subject_is_qr_bill("210000000003139471430009019"));
    388         assert!(!subject_is_qr_bill("21000000000313947143000901A"));
    389 
    390         let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0";
    391         let key = EddsaPublicKey::from_str(key).unwrap();
    392         assert_eq!(
    393             subject_fmt_qr_bill(key.as_ref()),
    394             "442862674560948379842733643"
    395         );
    396     }
    397 
    398     #[test]
    399     fn quality() {
    400         assert_eq!(
    401             Base32Quality::measure("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
    402             Base32Quality::UpperStandard
    403         );
    404         assert_eq!(
    405             Base32Quality::measure("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0UPHYV0CYDE6XBB0YMPFXCEG0"),
    406             Base32Quality::Upper
    407         );
    408         assert_eq!(
    409             Base32Quality::measure("4mZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
    410             Base32Quality::Standard
    411         );
    412         assert_eq!(
    413             Base32Quality::measure("4mZT6RS3RVB3B0E2RDMYW0YRA3Y0UPHYV0CYDE6XBB0YMPFXCEG0"),
    414             Base32Quality::Mixed
    415         );
    416     }
    417 
    418     #[test]
    419     /** Test parsing logic */
    420     fn incoming_parse() {
    421         let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0";
    422         let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG";
    423 
    424         // Common checks
    425         for ty in [IncomingType::reserve, IncomingType::kyc, IncomingType::map] {
    426             let prefix = match ty {
    427                 IncomingType::reserve => "",
    428                 IncomingType::kyc => "KYC",
    429                 IncomingType::map => "MAP",
    430             };
    431             let standard = &format!("{prefix}{key}");
    432             let (standard_l, standard_r) = standard.split_at(standard.len() / 2);
    433             let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0");
    434             let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2);
    435             let other_standard = &format!("{prefix}{other}");
    436             let other_mixed =
    437                 &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60");
    438             let key = EddsaPublicKey::from_str(key).unwrap();
    439             let result = Ok(IncomingSubject::Key(IncomingKey { ty, key }));
    440 
    441             // Check succeed if standard or mixed
    442             for case in [standard, mixed] {
    443                 for test in [
    444                     format!("noise {case} noise"),
    445                     format!("{case} noise to the right"),
    446                     format!("noise to the left {case}"),
    447                     format!("    {case}     "),
    448                     format!("noise\n{case}\nnoise"),
    449                     format!("Test+{case}"),
    450                 ] {
    451                     assert_eq!(parse_incoming_unstructured(&test), result);
    452                 }
    453             }
    454 
    455             // Check succeed if standard or mixed and split
    456             for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] {
    457                 for case in [
    458                     format!("left {l}{r} right"),
    459                     format!("left {l} {r} right"),
    460                     format!("left {l}-{r} right"),
    461                     format!("left {l}+{r} right"),
    462                     format!("left {l}\n{r} right"),
    463                     format!("left {l}-+\n{r} right"),
    464                     format!("left {l} - {r} right"),
    465                     format!("left {l} + {r} right"),
    466                     format!("left {l} \n {r} right"),
    467                     format!("left {l} - + \n {r} right"),
    468                 ] {
    469                     assert_eq!(parse_incoming_unstructured(&case), result);
    470                 }
    471             }
    472 
    473             // Check concat parts
    474             for chunk_size in 1..standard.len() {
    475                 let chunked: String = standard
    476                     .as_bytes()
    477                     .chunks(chunk_size)
    478                     .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
    479                     .collect();
    480                 for case in [chunked.clone(), format!("left {chunked} right")] {
    481                     assert_eq!(parse_incoming_unstructured(&case), result);
    482                 }
    483             }
    484 
    485             // Check failed when multiple key
    486             for case in [
    487                 format!("{standard} {other_standard}"),
    488                 format!("{mixed} {other_mixed}"),
    489             ] {
    490                 assert_eq!(
    491                     parse_incoming_unstructured(&case),
    492                     Err(IncomingSubjectErr::Ambiguous)
    493                 );
    494             }
    495 
    496             // Check accept redundant key
    497             for case in [
    498                 format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key
    499                 format!("{standard} {other_mixed}"),              // Prefer high quality
    500             ] {
    501                 assert_eq!(parse_incoming_unstructured(&case), result);
    502             }
    503 
    504             // Check prefer prefixed over simple ones
    505             for case in [
    506                 format!("{standard_l}-{standard_r} {mixed_l}-{mixed_r}"),
    507                 format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}"),
    508             ] {
    509                 let res = parse_incoming_unstructured(&case);
    510                 if !(ty == IncomingType::reserve
    511                     && matches!(res, Err(IncomingSubjectErr::Ambiguous)))
    512                 {
    513                     assert_eq!(res, result);
    514                 }
    515             }
    516 
    517             // Check failure if malformed or missing
    518             for case in [
    519                 "does not contain any reserve", // Check fail if none
    520                 &standard[1..],                 // Check fail if missing char
    521                                                 // "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key TODO aws-lc does not check
    522             ] {
    523                 assert_eq!(
    524                     parse_incoming_unstructured(case),
    525                     Err(IncomingSubjectErr::Missing)
    526                 );
    527             }
    528 
    529             if ty == IncomingType::kyc || ty == IncomingType::map {
    530                 // Prefer prefixed over unprefixed
    531                 for case in [format!("{other} {standard}"), format!("{other} {mixed}")] {
    532                     assert_eq!(parse_incoming_unstructured(&case), result);
    533                 }
    534             }
    535         }
    536     }
    537 
    538     #[test]
    539     /** Test parsing logic using real cases */
    540     fn real() {
    541         // Good reserve case
    542         for (subject, key) in [
    543             (
    544                 "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
    545                 "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
    546             ),
    547             (
    548                 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
    549                 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
    550             ),
    551             (
    552                 "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
    553                 "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
    554             ),
    555             (
    556                 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG",
    557                 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG",
    558             ),
    559         ] {
    560             assert_eq!(
    561                 Ok(IncomingSubject::Key(IncomingKey::reserve(
    562                     EddsaPublicKey::from_str(key).unwrap(),
    563                 ))),
    564                 parse_incoming_unstructured(subject)
    565             )
    566         }
    567         // Good kyc case
    568         for (subject, key) in [(
    569             "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70",
    570             "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70",
    571         )] {
    572             assert_eq!(
    573                 Ok(IncomingSubject::Key(IncomingKey::kyc(
    574                     EddsaPublicKey::from_str(key).unwrap(),
    575                 ))),
    576                 parse_incoming_unstructured(subject)
    577             )
    578         }
    579     }
    580 
    581     #[test]
    582     fn outgoing() {
    583         let wtid = ShortHashCode::rand();
    584 
    585         // Without metadata
    586         let subject = format!("{wtid} http://exchange.example.com/");
    587         let parsed = parse_outgoing(&subject).unwrap();
    588         assert_eq!(
    589             parsed,
    590             OutgoingSubject {
    591                 wtid,
    592                 exchange_base_url: url("http://exchange.example.com/"),
    593                 metadata: None
    594             }
    595         );
    596         assert_eq!(
    597             subject,
    598             fmt_out_subject(
    599                 &parsed.wtid,
    600                 &parsed.exchange_base_url,
    601                 parsed.metadata.as_deref()
    602             )
    603         );
    604 
    605         // With metadata
    606         let subject = format!("Accounting:id.4 {wtid} http://exchange.example.com/");
    607         let parsed = parse_outgoing(&subject).unwrap();
    608         assert_eq!(
    609             parsed,
    610             OutgoingSubject {
    611                 wtid,
    612                 exchange_base_url: url("http://exchange.example.com/"),
    613                 metadata: Some("Accounting:id.4".into())
    614             }
    615         );
    616         assert_eq!(
    617             subject,
    618             fmt_out_subject(
    619                 &parsed.wtid,
    620                 &parsed.exchange_base_url,
    621                 parsed.metadata.as_deref()
    622             )
    623         );
    624     }
    625 }