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

payjoin / rust-payjoin / 12324387724

13 Dec 2024 11:08PM UTC coverage: 67.045% (-0.04%) from 67.083%
12324387724

Pull #441

github

web-flow
Merge 4ea00db32 into 0891a5bf0
Pull Request #441: Propagate errors when retrieving exp parameter

32 of 60 new or added lines in 2 files covered. (53.33%)

2 existing lines in 1 file now uncovered.

3131 of 4670 relevant lines covered (67.04%)

986.08 hits per line

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

80.0
/payjoin/src/uri/url_ext.rs
1
use std::str::FromStr;
2

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

8
use crate::hpke::HpkePublicKey;
9
use crate::ohttp::OhttpKeys;
10

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

21
impl UrlExt for Url {
22
    /// Retrieve the receiver's public key from the URL fragment
23
    fn receiver_pubkey(&self) -> Result<HpkePublicKey, ParseReceiverPubkeyParamError> {
3✔
24
        let value = get_param(self, "RK1", |v| Some(v.to_owned()))
3✔
25
            .ok_or(ParseReceiverPubkeyParamError::MissingPubkey)?;
3✔
26

27
        let (hrp, bytes) = crate::bech32::nochecksum::decode(&value)
3✔
28
            .map_err(ParseReceiverPubkeyParamError::DecodeBech32)?;
3✔
29

30
        let rk_hrp: Hrp = Hrp::parse("RK").unwrap();
3✔
31
        if hrp != rk_hrp {
3✔
32
            return Err(ParseReceiverPubkeyParamError::InvalidHrp(hrp));
×
33
        }
3✔
34

3✔
35
        HpkePublicKey::from_compressed_bytes(&bytes[..])
3✔
36
            .map_err(ParseReceiverPubkeyParamError::InvalidPubkey)
3✔
37
    }
3✔
38

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

5✔
43
        set_param(
5✔
44
            self,
5✔
45
            "RK1",
5✔
46
            &crate::bech32::nochecksum::encode(rk_hrp, &pubkey.to_compressed_bytes())
5✔
47
                .expect("encoding compressed pubkey bytes should never fail"),
5✔
48
        )
5✔
49
    }
5✔
50

51
    /// Retrieve the ohttp parameter from the URL fragment
52
    fn ohttp(&self) -> Result<OhttpKeys, ParseOhttpKeysParamError> {
16✔
53
        let value = get_param(self, "OH1", |v| Some(v.to_owned()))
16✔
54
            .ok_or(ParseOhttpKeysParamError::MissingOhttpKeys)?;
16✔
55
        OhttpKeys::from_str(&value).map_err(ParseOhttpKeysParamError::InvalidOhttpKeys)
15✔
56
    }
16✔
57

58
    /// Set the ohttp parameter in the URL fragment
59
    fn set_ohttp(&mut self, ohttp: OhttpKeys) { set_param(self, "OH1", &ohttp.to_string()) }
6✔
60

61
    /// Retrieve the exp parameter from the URL fragment
62
    fn exp(&self) -> Result<std::time::SystemTime, ParseExpParamError> {
9✔
63
        let value =
8✔
64
            get_param(self, "EX1", |v| Some(v.to_owned())).ok_or(ParseExpParamError::MissingExp)?;
9✔
65

66
        let (hrp, bytes) =
7✔
67
            crate::bech32::nochecksum::decode(&value).map_err(ParseExpParamError::DecodeBech32)?;
8✔
68

69
        let ex_hrp: Hrp = Hrp::parse("EX").unwrap();
7✔
70
        if hrp != ex_hrp {
7✔
71
            return Err(ParseExpParamError::InvalidHrp(hrp));
1✔
72
        }
6✔
73

6✔
74
        u32::consensus_decode(&mut &bytes[..])
6✔
75
            .map(|timestamp| {
6✔
76
                std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp as u64)
5✔
77
            })
6✔
78
            .map_err(ParseExpParamError::InvalidExp)
6✔
79
    }
9✔
80

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

88
        let mut buf = [0u8; 4];
6✔
89
        t.consensus_encode(&mut &mut buf[..]).unwrap(); // TODO no unwrap
6✔
90

6✔
91
        let ex_hrp: Hrp = Hrp::parse("EX").unwrap();
6✔
92

6✔
93
        let exp_str = crate::bech32::nochecksum::encode(ex_hrp, &buf)
6✔
94
            .expect("encoding u32 timestamp should never fail");
6✔
95

6✔
96
        set_param(self, "EX1", &exp_str)
6✔
97
    }
6✔
98
}
99

100
fn get_param<F, T>(url: &Url, prefix: &str, parse: F) -> Option<T>
28✔
101
where
28✔
102
    F: Fn(&str) -> Option<T>,
28✔
103
{
28✔
104
    if let Some(fragment) = url.fragment() {
28✔
105
        for param in fragment.split('+') {
45✔
106
            if param.starts_with(prefix) {
45✔
107
                return parse(param);
26✔
108
            }
19✔
109
        }
110
    }
2✔
111
    None
2✔
112
}
28✔
113

114
fn set_param(url: &mut Url, prefix: &str, param: &str) {
17✔
115
    let fragment = url.fragment().unwrap_or("");
17✔
116
    let mut fragment = fragment.to_string();
17✔
117
    if let Some(start) = fragment.find(prefix) {
17✔
118
        let end = fragment[start..].find('+').map_or(fragment.len(), |i| start + i);
×
119
        fragment.replace_range(start..end, "");
×
120
        if fragment.ends_with('+') {
×
121
            fragment.pop();
×
122
        }
×
123
    }
17✔
124

125
    if !fragment.is_empty() {
17✔
126
        fragment.push('+');
10✔
127
    }
10✔
128
    fragment.push_str(param);
17✔
129

17✔
130
    url.set_fragment(if fragment.is_empty() { None } else { Some(&fragment) });
17✔
131
}
17✔
132

133
#[cfg(feature = "v2")]
134
#[derive(Debug)]
135
pub(crate) enum ParseOhttpKeysParamError {
136
    MissingOhttpKeys,
137
    InvalidOhttpKeys(crate::ohttp::ParseOhttpKeysError),
138
}
139

140
#[cfg(feature = "v2")]
141
impl std::fmt::Display for ParseOhttpKeysParamError {
NEW
142
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
143
        use ParseOhttpKeysParamError::*;
144

NEW
145
        match &self {
×
NEW
146
            MissingOhttpKeys => write!(f, "ohttp keys are missing"),
×
NEW
147
            InvalidOhttpKeys(o) => write!(f, "invalid ohttp keys: {}", o),
×
148
        }
NEW
149
    }
×
150
}
151

152
#[cfg(feature = "v2")]
153
#[derive(Debug)]
154
pub(crate) enum ParseExpParamError {
155
    MissingExp,
156
    InvalidHrp(bitcoin::bech32::Hrp),
157
    DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError),
158
    InvalidExp(bitcoin::consensus::encode::Error),
159
}
160

161
#[cfg(feature = "v2")]
162
impl std::fmt::Display for ParseExpParamError {
NEW
163
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
164
        use ParseExpParamError::*;
165

NEW
166
        match &self {
×
NEW
167
            MissingExp => write!(f, "exp is missing"),
×
NEW
168
            InvalidHrp(h) => write!(f, "incorrect hrp for exp: {}", h),
×
NEW
169
            DecodeBech32(d) => write!(f, "exp is not valid bech32: {}", d),
×
NEW
170
            InvalidExp(i) =>
×
NEW
171
                write!(f, "exp param does not contain a bitcoin consensus encoded u32: {}", i),
×
172
        }
NEW
173
    }
×
174
}
175

176
#[cfg(feature = "v2")]
177
#[derive(Debug)]
178
pub(crate) enum ParseReceiverPubkeyParamError {
179
    MissingPubkey,
180
    InvalidHrp(bitcoin::bech32::Hrp),
181
    DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError),
182
    InvalidPubkey(crate::hpke::HpkeError),
183
}
184

185
#[cfg(feature = "v2")]
186
impl std::fmt::Display for ParseReceiverPubkeyParamError {
NEW
187
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
×
188
        use ParseReceiverPubkeyParamError::*;
189

NEW
190
        match &self {
×
NEW
191
            MissingPubkey => write!(f, "receiver public key is missing"),
×
NEW
192
            InvalidHrp(h) => write!(f, "incorrect hrp for receiver key: {}", h),
×
NEW
193
            DecodeBech32(e) => write!(f, "receiver public is not valid base64: {}", e),
×
NEW
194
            InvalidPubkey(e) =>
×
NEW
195
                write!(f, "receiver public key does not represent a valid pubkey: {}", e),
×
196
        }
NEW
197
    }
×
198
}
199

200
#[cfg(feature = "v2")]
201
impl std::error::Error for ParseReceiverPubkeyParamError {
NEW
202
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
×
203
        use ParseReceiverPubkeyParamError::*;
204

NEW
205
        match &self {
×
NEW
206
            MissingPubkey => None,
×
NEW
207
            InvalidHrp(_) => None,
×
NEW
208
            DecodeBech32(error) => Some(error),
×
NEW
209
            InvalidPubkey(error) => Some(error),
×
210
        }
NEW
211
    }
×
212
}
213

214
#[cfg(test)]
215
mod tests {
216
    use super::*;
217
    use crate::{Uri, UriExt};
218

219
    #[test]
220
    fn test_ohttp_get_set() {
1✔
221
        let mut url = Url::parse("https://example.com").unwrap();
1✔
222

1✔
223
        let serialized = "OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC";
1✔
224
        let ohttp_keys = OhttpKeys::from_str(serialized).unwrap();
1✔
225
        url.set_ohttp(ohttp_keys.clone());
1✔
226

1✔
227
        assert_eq!(url.fragment(), Some(serialized));
1✔
228
        assert_eq!(url.ohttp().unwrap(), ohttp_keys);
1✔
229
    }
1✔
230

231
    #[test]
232
    fn test_errors_when_parsing_ohttp() {
1✔
233
        let missing_ohttp_url = Url::parse("https://example.com").unwrap();
1✔
234
        assert!(matches!(
1✔
235
            missing_ohttp_url.ohttp(),
1✔
236
            Err(ParseOhttpKeysParamError::MissingOhttpKeys)
237
        ));
238

239
        let invalid_ohttp_url =
1✔
240
            Url::parse("https://example.com?pj=https://test-payjoin-url#OH1invalid_bech_32")
1✔
241
                .unwrap();
1✔
242
        assert!(matches!(
1✔
243
            invalid_ohttp_url.ohttp(),
1✔
244
            Err(ParseOhttpKeysParamError::InvalidOhttpKeys(_))
245
        ));
246
    }
1✔
247

248
    #[test]
249
    fn test_exp_get_set() {
1✔
250
        let mut url = Url::parse("https://example.com").unwrap();
1✔
251

1✔
252
        let exp_time =
1✔
253
            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1720547781);
1✔
254
        url.set_exp(exp_time);
1✔
255
        assert_eq!(url.fragment(), Some("EX1C4UC6ES"));
1✔
256

257
        assert_eq!(url.exp().unwrap(), exp_time);
1✔
258
    }
1✔
259

260
    #[test]
261
    fn test_errors_when_parsing_exp() {
1✔
262
        let missing_exp_url = Url::parse("http://example.com").unwrap();
1✔
263
        assert!(matches!(missing_exp_url.exp(), Err(ParseExpParamError::MissingExp)));
1✔
264

265
        let invalid_bech32_exp_url =
1✔
266
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX1invalid_bech_32")
1✔
267
                .unwrap();
1✔
268
        assert!(matches!(invalid_bech32_exp_url.exp(), Err(ParseExpParamError::DecodeBech32(_))));
1✔
269

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

276
        // Not enough data to decode into a u32
277
        let invalid_timestamp_exp_url =
1✔
278
            Url::parse("http://example.com?pj=https://test-payjoin-url#EX10").unwrap();
1✔
279
        assert!(matches!(invalid_timestamp_exp_url.exp(), Err(ParseExpParamError::InvalidExp(_))))
1✔
280
    }
1✔
281

282
    #[test]
283
    fn test_valid_v2_url_fragment_on_bip21() {
1✔
284
        let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
285
                   &pjos=0&pj=HTTPS://EXAMPLE.COM/\
1✔
286
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC";
1✔
287
        let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
288
        assert!(pjuri.extras.endpoint().ohttp().is_ok());
1✔
289
        assert_eq!(format!("{}", pjuri), uri);
1✔
290

291
        let reordered = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\
1✔
292
                   &pj=HTTPS://EXAMPLE.COM/\
1✔
293
                   %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC\
1✔
294
                   &pjos=0";
1✔
295
        let pjuri =
1✔
296
            Uri::try_from(reordered).unwrap().assume_checked().check_pj_supported().unwrap();
1✔
297
        assert!(pjuri.extras.endpoint().ohttp().is_ok());
1✔
298
        assert_eq!(format!("{}", pjuri), uri);
1✔
299
    }
1✔
300
}
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