• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 16131346449

08 Jul 2025 01:01AM UTC coverage: 85.317% (-0.01%) from 85.329%
16131346449

Pull #852

github

web-flow
Merge 0eb74b9e5 into 25acd561b
Pull Request #852: Fragment parameter fixes

93 of 105 new or added lines in 2 files covered. (88.57%)

4 existing lines in 1 file now uncovered.

7455 of 8738 relevant lines covered (85.32%)

535.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.41
/payjoin/src/core/uri/url_ext.rs
1
use std::collections::BTreeMap;
2
use std::str::FromStr;
3

4
use bitcoin::bech32::Hrp;
5
use bitcoin::consensus::encode::Decodable;
6
use bitcoin::consensus::Encodable;
7
use url::Url;
8

9
use super::error::BadEndpointError;
10
use crate::hpke::HpkePublicKey;
11
use crate::ohttp::OhttpKeys;
12

13
/// Parse and set fragment parameters from `&pj=` URI parameter URLs
14
pub(crate) trait UrlExt {
15
    fn receiver_pubkey(&self) -> Result<HpkePublicKey, ParseReceiverPubkeyParamError>;
16
    fn set_receiver_pubkey(&mut self, exp: HpkePublicKey);
17
    fn ohttp(&self) -> Result<OhttpKeys, ParseOhttpKeysParamError>;
18
    fn set_ohttp(&mut self, ohttp: OhttpKeys);
19
    fn exp(&self) -> Result<std::time::SystemTime, ParseExpParamError>;
20
    fn set_exp(&mut self, exp: std::time::SystemTime);
21
}
22

23
impl UrlExt for Url {
24
    /// Retrieve the receiver's public key from the URL fragment
25
    fn receiver_pubkey(&self) -> Result<HpkePublicKey, ParseReceiverPubkeyParamError> {
18✔
26
        let value = get_param(self, "RK1")
18✔
27
            .map_err(ParseReceiverPubkeyParamError::InvalidFragment)?
18✔
28
            .ok_or(ParseReceiverPubkeyParamError::MissingPubkey)?;
17✔
29

30
        let (hrp, bytes) = crate::bech32::nochecksum::decode(value)
15✔
31
            .map_err(ParseReceiverPubkeyParamError::DecodeBech32)?;
15✔
32

33
        let rk_hrp: Hrp = Hrp::parse("RK").unwrap();
14✔
34
        if hrp != rk_hrp {
14✔
35
            return Err(ParseReceiverPubkeyParamError::InvalidHrp(hrp));
1✔
36
        }
13✔
37

38
        HpkePublicKey::from_compressed_bytes(&bytes[..])
13✔
39
            .map_err(ParseReceiverPubkeyParamError::InvalidPubkey)
13✔
40
    }
18✔
41

42
    /// Set the receiver's public key in the URL fragment
43
    fn set_receiver_pubkey(&mut self, pubkey: HpkePublicKey) {
21✔
44
        let rk_hrp: Hrp = Hrp::parse("RK").unwrap();
21✔
45

46
        set_param(
21✔
47
            self,
21✔
48
            &crate::bech32::nochecksum::encode(rk_hrp, &pubkey.to_compressed_bytes())
21✔
49
                .expect("encoding compressed pubkey bytes should never fail"),
21✔
50
        )
51
    }
21✔
52

53
    /// Retrieve the ohttp parameter from the URL fragment
54
    fn ohttp(&self) -> Result<OhttpKeys, ParseOhttpKeysParamError> {
29✔
55
        let value = get_param(self, "OH1")
29✔
56
            .map_err(ParseOhttpKeysParamError::InvalidFragment)?
29✔
57
            .ok_or(ParseOhttpKeysParamError::MissingOhttpKeys)?;
28✔
58
        OhttpKeys::from_str(value).map_err(ParseOhttpKeysParamError::InvalidOhttpKeys)
26✔
59
    }
29✔
60

61
    /// Set the ohttp parameter in the URL fragment
62
    fn set_ohttp(&mut self, ohttp: OhttpKeys) { set_param(self, &ohttp.to_string()) }
22✔
63

64
    /// Retrieve the exp parameter from the URL fragment
65
    fn exp(&self) -> Result<std::time::SystemTime, ParseExpParamError> {
18✔
66
        let value = get_param(self, "EX1")
18✔
67
            .map_err(ParseExpParamError::InvalidFragment)?
18✔
68
            .ok_or(ParseExpParamError::MissingExp)?;
17✔
69

70
        let (hrp, bytes) =
15✔
71
            crate::bech32::nochecksum::decode(value).map_err(ParseExpParamError::DecodeBech32)?;
16✔
72

73
        let ex_hrp: Hrp = Hrp::parse("EX").unwrap();
15✔
74
        if hrp != ex_hrp {
15✔
75
            return Err(ParseExpParamError::InvalidHrp(hrp));
1✔
76
        }
14✔
77

78
        u32::consensus_decode(&mut &bytes[..])
14✔
79
            .map(|timestamp| {
14✔
80
                std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp as u64)
13✔
81
            })
13✔
82
            .map_err(ParseExpParamError::InvalidExp)
14✔
83
    }
18✔
84

85
    /// Set the exp parameter in the URL fragment
86
    fn set_exp(&mut self, exp: std::time::SystemTime) {
26✔
87
        let t = match exp.duration_since(std::time::UNIX_EPOCH) {
26✔
88
            Ok(duration) => duration.as_secs().try_into().unwrap(), // TODO Result type instead of Option & unwrap
26✔
89
            Err(_) => 0u32,
×
90
        };
91

92
        let mut buf = [0u8; 4];
26✔
93
        t.consensus_encode(&mut &mut buf[..]).unwrap(); // TODO no unwrap
26✔
94

95
        let ex_hrp: Hrp = Hrp::parse("EX").unwrap();
26✔
96

97
        let exp_str = crate::bech32::nochecksum::encode(ex_hrp, &buf)
26✔
98
            .expect("encoding u32 timestamp should never fail");
26✔
99

100
        set_param(self, &exp_str)
26✔
101
    }
26✔
102
}
103

104
pub fn parse_with_fragment(endpoint: &str) -> Result<Url, BadEndpointError> {
57✔
105
    let url = Url::parse(endpoint).map_err(BadEndpointError::UrlParse)?;
57✔
106

107
    if let Some(fragment) = url.fragment() {
56✔
108
        if fragment.chars().any(|c| c.is_lowercase()) {
1,234✔
109
            return Err(BadEndpointError::LowercaseFragment);
4✔
110
        }
13✔
111
    };
39✔
112
    Ok(url)
52✔
113
}
57✔
114

115
#[derive(Debug)]
116
pub(crate) enum ParseFragmentError {
117
    InvalidChar(char),
118
    AmbiguousDelimiter,
119
}
120

121
impl std::error::Error for ParseFragmentError {
NEW
122
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None }
×
123
}
124

125
impl std::fmt::Display for ParseFragmentError {
NEW
126
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
127
        use ParseFragmentError::*;
128

NEW
129
        match &self {
×
NEW
130
            InvalidChar(c) => write!(f, "invalid character: {c} (must be uppercase)"),
×
NEW
131
            AmbiguousDelimiter => write!(f, "ambiguous fragment delimiter (both + and - found)"),
×
132
        }
UNCOV
133
    }
×
134
}
135

136
fn check_fragment_delimiter(fragment: &str) -> Result<char, ParseFragmentError> {
131✔
137
    // For backwards compatibility, also accept `+` as a
138
    // fragment parameter delimiter. This was previously
139
    // specified, but may be interpreted as ` ` by some
140
    // URI parsoing libraries. Therefore if `-` is missing,
141
    // assume the URI was generated following the older
142
    // version of the spec.
143

144
    let has_dash = fragment.contains('-');
131✔
145
    let has_plus = fragment.contains('+');
131✔
146

147
    // Even though fragment is a &str, it should be ascii so bytes() correspond
148
    // to chars(), except that it's easier to check that they are in range
149
    for c in fragment.bytes() {
9,024✔
150
        // These character ranges are more permissive than uppercase bech32, but
151
        // also more restrictive than bech32 in general since lowercase is not
152
        // allowed
153
        if !(b'0'..b'9' + 1).contains(&c)
9,024✔
154
            && !(b'A'..b'Z' + 1).contains(&c)
6,516✔
155
            && c != b'-'
114✔
156
            && c != b'+'
7✔
157
        {
158
            return Err(ParseFragmentError::InvalidChar(c.into()));
3✔
159
        }
9,021✔
160
    }
161

162
    match (has_dash, has_plus) {
128✔
NEW
163
        (true, true) => Err(ParseFragmentError::AmbiguousDelimiter),
×
164
        (false, true) => Ok('+'),
4✔
165
        _ => Ok('-'),
124✔
166
    }
167
}
131✔
168

169
fn get_param<'a>(url: &'a Url, prefix: &str) -> Result<Option<&'a str>, ParseFragmentError> {
65✔
170
    if let Some(fragment) = url.fragment() {
65✔
171
        let delim = check_fragment_delimiter(fragment)?;
62✔
172

173
        // The spec says these MUST be ordered lexicographically.
174
        // However, this was a late spec change, and only matters
175
        // for privacy reasons (fingerprinting implementations).
176
        // To maintain compatibility, we don't care about the order
177
        // of the parameters.
178
        for param in fragment.split(delim) {
109✔
179
            if param.starts_with(prefix) {
109✔
180
                return Ok(Some(param));
57✔
181
            }
52✔
182
        }
183
    }
3✔
184
    Ok(None)
5✔
185
}
65✔
186

187
/// Set a URL fragment parameter, inserting it or replacing it depending on
188
/// whether a parameter with the same bech32 HRP is already present.
189
///
190
/// Parameters are sorted lexicographically by prefix.
191
fn set_param(url: &mut Url, new_param: &str) {
69✔
192
    let fragment = url.fragment().unwrap_or("");
69✔
193
    let delim = check_fragment_delimiter(fragment)
69✔
194
        .expect("set_param must be called on a URL with a valid fragment");
69✔
195

196
    // In case of an invalid fragment parameter the following will still attempt
197
    // to retain the existing data
198
    let mut params = fragment
69✔
199
        .split(delim)
69✔
200
        .filter(|param| !param.is_empty())
93✔
201
        .map(|param| {
69✔
202
            let key = param.split('1').next().unwrap_or(param);
69✔
203
            (key, param)
69✔
204
        })
69✔
205
        .collect::<BTreeMap<&str, &str>>();
69✔
206

207
    // TODO: change param to Option(&str) to allow deletion?
208
    let key = new_param.split('1').next().unwrap_or(new_param);
69✔
209
    params.insert(key, new_param);
69✔
210

211
    if params.is_empty() {
69✔
NEW
212
        url.set_fragment(None)
×
213
    } else {
69✔
214
        // Can we avoid intermediate allocation of Vec, intersperse() exists but not in MSRV
69✔
215
        let fragment = params.values().copied().collect::<Vec<_>>().join("-");
69✔
216
        url.set_fragment(Some(&fragment));
69✔
217
    }
69✔
218
}
69✔
219

220
#[derive(Debug)]
221
pub(crate) enum ParseOhttpKeysParamError {
222
    MissingOhttpKeys,
223
    InvalidOhttpKeys(crate::ohttp::ParseOhttpKeysError),
224
    InvalidFragment(ParseFragmentError),
225
}
226

227
impl std::fmt::Display for ParseOhttpKeysParamError {
UNCOV
228
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
229
        use ParseOhttpKeysParamError::*;
230

231
        match &self {
×
232
            MissingOhttpKeys => write!(f, "ohttp keys are missing"),
×
233
            InvalidOhttpKeys(o) => write!(f, "invalid ohttp keys: {o}"),
×
NEW
234
            InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"),
×
235
        }
236
    }
×
237
}
238

239
#[derive(Debug)]
240
pub(crate) enum ParseExpParamError {
241
    MissingExp,
242
    InvalidHrp(bitcoin::bech32::Hrp),
243
    DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError),
244
    InvalidExp(bitcoin::consensus::encode::Error),
245
    InvalidFragment(ParseFragmentError),
246
}
247

248
impl std::fmt::Display for ParseExpParamError {
UNCOV
249
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
250
        use ParseExpParamError::*;
251

252
        match &self {
×
253
            MissingExp => write!(f, "exp is missing"),
×
254
            InvalidHrp(h) => write!(f, "incorrect hrp for exp: {h}"),
×
255
            DecodeBech32(d) => write!(f, "exp is not valid bech32: {d}"),
×
256
            InvalidExp(i) =>
×
257
                write!(f, "exp param does not contain a bitcoin consensus encoded u32: {i}"),
×
NEW
258
            InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"),
×
259
        }
260
    }
×
261
}
262

263
#[derive(Debug)]
264
pub(crate) enum ParseReceiverPubkeyParamError {
265
    MissingPubkey,
266
    InvalidHrp(bitcoin::bech32::Hrp),
267
    DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError),
268
    InvalidPubkey(crate::hpke::HpkeError),
269
    InvalidFragment(ParseFragmentError),
270
}
271

272
impl std::fmt::Display for ParseReceiverPubkeyParamError {
273
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1✔
274
        use ParseReceiverPubkeyParamError::*;
275

276
        match &self {
1✔
277
            MissingPubkey => write!(f, "receiver public key is missing"),
1✔
278
            InvalidHrp(h) => write!(f, "incorrect hrp for receiver key: {h}"),
×
279
            DecodeBech32(e) => write!(f, "receiver public is not valid base64: {e}"),
×
280
            InvalidPubkey(e) =>
×
281
                write!(f, "receiver public key does not represent a valid pubkey: {e}"),
×
NEW
282
            InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"),
×
283
        }
284
    }
1✔
285
}
286

287
impl std::error::Error for ParseReceiverPubkeyParamError {
UNCOV
288
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
×
289
        use ParseReceiverPubkeyParamError::*;
290

291
        match &self {
×
292
            MissingPubkey => None,
×
293
            InvalidHrp(_) => None,
×
294
            DecodeBech32(error) => Some(error),
×
295
            InvalidPubkey(error) => Some(error),
×
NEW
296
            InvalidFragment(error) => Some(error),
×
297
        }
298
    }
×
299
}
300

301
#[cfg(test)]
302
mod tests {
303
    use payjoin_test_utils::{BoxError, EXAMPLE_URL};
304

305
    use super::*;
306
    use crate::{Uri, UriExt};
307

308
    #[test]
309
    fn test_ohttp_get_set() {
1✔
310
        let mut url = EXAMPLE_URL.clone();
1✔
311

312
        let serialized = "OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC";
1✔
313
        let ohttp_keys = OhttpKeys::from_str(serialized).unwrap();
1✔
314
        url.set_ohttp(ohttp_keys.clone());
1✔
315

316
        assert_eq!(url.fragment(), Some(serialized));
1✔
317
        assert_eq!(
1✔
318
            url.ohttp().expect("Ohttp keys have been set but are missing on get"),
1✔
319
            ohttp_keys
320
        );
321
    }
1✔
322

323
    #[test]
324
    fn test_errors_when_parsing_ohttp() {
1✔
325
        let missing_ohttp_url = EXAMPLE_URL.clone();
1✔
326
        assert!(matches!(
1✔
327
            missing_ohttp_url.ohttp(),
1✔
328
            Err(ParseOhttpKeysParamError::MissingOhttpKeys)
329
        ));
330

331
        let invalid_ohttp_url =
1✔
332
            Url::parse("https://example.com?pj=https://test-payjoin-url#OH1invalid_bech_32")
1✔
333
                .unwrap();
1✔
334
        assert!(matches!(
1✔
335
            invalid_ohttp_url.ohttp(),
1✔
336
            Err(ParseOhttpKeysParamError::InvalidFragment(_))
337
        ));
338
    }
1✔
339

340
    #[test]
341
    fn test_exp_get_set() {
1✔
342
        let mut url = EXAMPLE_URL.clone();
1✔
343

344
        let exp_time =
1✔
345
            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1720547781);
1✔
346
        url.set_exp(exp_time);
1✔
347
        assert_eq!(url.fragment(), Some("EX1C4UC6ES"));
1✔
348

349
        assert_eq!(url.exp().expect("Expiry has been set but is missing on get"), exp_time);
1✔
350
    }
1✔
351

352
    #[test]
353
    fn test_errors_when_parsing_exp() {
1✔
354
        let missing_exp_url = EXAMPLE_URL.clone();
1✔
355
        assert!(matches!(missing_exp_url.exp(), Err(ParseExpParamError::MissingExp)));
1✔
356

357
        let invalid_fragment_exp_url =
1✔
358
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX1invalid_bech_32")
1✔
359
                .unwrap();
1✔
360
        assert!(matches!(
1✔
361
            invalid_fragment_exp_url.exp(),
1✔
362
            Err(ParseExpParamError::InvalidFragment(_))
363
        ));
364

365
        let invalid_bech32_exp_url =
1✔
366
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX1INVALIDBECH32").unwrap();
1✔
367
        assert!(matches!(invalid_bech32_exp_url.exp(), Err(ParseExpParamError::DecodeBech32(_))));
1✔
368

369
        // Since the HRP is everything to the left of the right-most separator, the invalid url in
370
        // this test would have it's HRP being parsed as EX101 instead of the expected EX1
371
        let invalid_hrp_exp_url =
1✔
372
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX1010").unwrap();
1✔
373
        assert!(matches!(invalid_hrp_exp_url.exp(), Err(ParseExpParamError::InvalidHrp(_))));
1✔
374

375
        // Not enough data to decode into a u32
376
        let invalid_timestamp_exp_url =
1✔
377
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX10").unwrap();
1✔
378
        assert!(matches!(invalid_timestamp_exp_url.exp(), Err(ParseExpParamError::InvalidExp(_))));
1✔
379
    }
1✔
380

381
    #[test]
382
    fn test_errors_when_parsing_receiver_pubkey() {
1✔
383
        let missing_receiver_pubkey_url = EXAMPLE_URL.clone();
1✔
384
        assert!(matches!(
1✔
385
            missing_receiver_pubkey_url.receiver_pubkey(),
1✔
386
            Err(ParseReceiverPubkeyParamError::MissingPubkey)
387
        ));
388

389
        let invalid_fragment_receiver_pubkey_url =
1✔
390
            Url::parse("http://example.com?pj=https://test-payjoin-url#RK1invalid_bech_32")
1✔
391
                .unwrap();
1✔
392
        assert!(matches!(
1✔
393
            invalid_fragment_receiver_pubkey_url.receiver_pubkey(),
1✔
394
            Err(ParseReceiverPubkeyParamError::InvalidFragment(_))
395
        ));
396

397
        let invalid_bech32_receiver_pubkey_url =
1✔
398
            Url::parse("http://example.com?pj=https://test-payjoin-url#RK1INVALIDBECH32").unwrap();
1✔
399
        assert!(matches!(
1✔
400
            invalid_bech32_receiver_pubkey_url.receiver_pubkey(),
1✔
401
            Err(ParseReceiverPubkeyParamError::DecodeBech32(_))
402
        ));
403

404
        // Since the HRP is everything to the left of the right-most separator, the invalid url in
405
        // this test would have it's HRP being parsed as RK101 instead of the expected RK1
406
        let invalid_hrp_receiver_pubkey_url =
1✔
407
            Url::parse("http://example.com?pj=https://test-payjoin-url#RK101").unwrap();
1✔
408
        assert!(matches!(
1✔
409
            invalid_hrp_receiver_pubkey_url.receiver_pubkey(),
1✔
410
            Err(ParseReceiverPubkeyParamError::InvalidHrp(_))
411
        ));
412

413
        // Not enough data to decode into a u32
414
        let invalid_receiver_pubkey_url =
1✔
415
            Url::parse("http://example.com?pj=https://test-payjoin-url#RK10").unwrap();
1✔
416
        assert!(matches!(
1✔
417
            invalid_receiver_pubkey_url.receiver_pubkey(),
1✔
418
            Err(ParseReceiverPubkeyParamError::InvalidPubkey(_))
419
        ));
420
    }
1✔
421

422
    #[test]
423
    fn test_valid_v2_url_fragment_on_bip21() {
1✔
424
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
425
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
426
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC";
1✔
427
        let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
428
        assert!(pjuri.extras.endpoint().ohttp().is_ok());
1✔
429
        assert_eq!(format!("{pjuri}"), uri);
1✔
430

431
        let reordered = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
432
                   &pj=HTTPS://EXAMPLE.COM/\
1✔
433
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC\
1✔
434
                   &pjos=0";
1✔
435
        let pjuri =
1✔
436
            Uri::try_from(reordered).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
437
        assert!(pjuri.extras.endpoint().ohttp().is_ok());
1✔
438
        assert_eq!(format!("{pjuri}"), uri);
1✔
439
    }
1✔
440

441
    #[test]
442
    fn test_failed_url_fragment() -> Result<(), BoxError> {
1✔
443
        let expected_error = "LowercaseFragment";
1✔
444
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
445
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
446
                   %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc";
1✔
447
        assert!(Uri::try_from(uri).is_err(), "Expected url fragment failure, but it succeeded");
1✔
448
        if let Err(bitcoin_uri::de::Error::Extras(error)) = Uri::try_from(uri) {
1✔
449
            assert!(
1✔
450
                error.to_string().contains(expected_error),
1✔
451
                "Error should indicate '{expected_error}' but was: {error}"
×
452
            );
453
        }
×
454
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
455
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
456
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQc";
1✔
457
        assert!(Uri::try_from(uri).is_err(), "Expected url fragment failure, but it succeeded");
1✔
458
        if let Err(bitcoin_uri::de::Error::Extras(error)) = Uri::try_from(uri) {
1✔
459
            assert!(
1✔
460
                error.to_string().contains(expected_error),
1✔
461
                "Error should indicate '{expected_error}' but was: {error}"
×
462
            );
463
        }
×
464
        Ok(())
1✔
465
    }
1✔
466

467
    #[test]
468
    fn test_fragment_delimeter_backwards_compatibility() {
1✔
469
        // ensure + is still accepted as a delimiter
470
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
471
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
472
                   %23EX1C4UC6ES+OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC";
1✔
473
        let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
474

475
        let mut endpoint = pjuri.extras.endpoint().clone();
1✔
476
        assert!(endpoint.ohttp().is_ok());
1✔
477
        assert!(endpoint.exp().is_ok());
1✔
478

479
        // Before setting the delimiter should be preserved
480
        assert_eq!(
1✔
481
            endpoint.fragment(),
1✔
482
            Some("EX1C4UC6ES+OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC")
483
        );
484

485
        // Upon setting any value, the delimiter should be normalized to `-`
486
        endpoint.set_exp(pjuri.extras.endpoint.exp().unwrap());
1✔
487
        assert_eq!(
1✔
488
            endpoint.fragment(),
1✔
489
            Some("EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC")
490
        );
491
    }
1✔
492

493
    #[test]
494
    fn test_fragment_lexicographical_order() {
1✔
495
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
496
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
497
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES";
1✔
498
        let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
499

500
        let mut endpoint = pjuri.extras.endpoint().clone();
1✔
501
        assert!(endpoint.ohttp().is_ok());
1✔
502
        assert!(endpoint.exp().is_ok());
1✔
503

504
        assert_eq!(
1✔
505
            endpoint.fragment(),
1✔
506
            Some("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES")
507
        );
508

509
        // Upon setting any value, the order should be normalized to lexicographical
510
        endpoint.set_exp(pjuri.extras.endpoint.exp().unwrap());
1✔
511
        assert_eq!(
1✔
512
            endpoint.fragment(),
1✔
513
            Some("EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC")
514
        );
515
    }
1✔
516
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc