use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use url::Url;
const X_CALLBACK_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'!')
.remove(b'~')
.remove(b'*')
.remove(b'\'')
.remove(b'(')
.remove(b')');
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XCallbackParams {
pub x_source: Option<String>,
pub x_success: Option<String>,
pub x_error: Option<String>,
pub x_cancel: Option<String>,
}
impl XCallbackParams {
#[must_use]
pub fn from_url(url: &Url) -> Self {
let Some(query) = url.query() else {
return Self::default();
};
let x_success = Self::raw_query_value(query, "x-success")
.or_else(|| Self::raw_query_value(query, "callback"))
.map(Self::decode_once);
Self {
x_source: Self::raw_query_value(query, "x-source").map(Self::decode_once),
x_success,
x_error: Self::raw_query_value(query, "x-error").map(Self::decode_once),
x_cancel: Self::raw_query_value(query, "x-cancel").map(Self::decode_once),
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.x_source.is_none()
&& self.x_success.is_none()
&& self.x_error.is_none()
&& self.x_cancel.is_none()
}
pub fn append_to_url(&self, url: &mut Url) {
if self.is_empty() {
return;
}
let mut query = url.query().unwrap_or_default().to_owned();
for (key, value) in [
("x-source", self.x_source.as_deref()),
("x-success", self.x_success.as_deref()),
("x-error", self.x_error.as_deref()),
("x-cancel", self.x_cancel.as_deref()),
] {
let Some(value) = value else {
continue;
};
if !query.is_empty() {
query.push('&');
}
query.push_str(key);
query.push('=');
query.push_str(&utf8_percent_encode(value, X_CALLBACK_ENCODE_SET).to_string());
}
url.set_query(Some(&query));
}
fn raw_query_value<'a>(query: &'a str, key: &str) -> Option<&'a str> {
query.split('&').find_map(|pair| {
let (pair_key, value) = pair.split_once('=').unwrap_or((pair, ""));
(pair_key == key).then_some(value)
})
}
fn decode_once(value: &str) -> String {
if !Self::has_valid_percent_triplets(value) {
return value.to_owned();
}
percent_decode_str(value)
.decode_utf8()
.map_or_else(|_| value.to_owned(), std::borrow::Cow::into_owned)
}
fn has_valid_percent_triplets(value: &str) -> bool {
value.split('%').skip(1).all(|escape| {
matches!(
escape.as_bytes(),
[first, second, ..]
if first.is_ascii_hexdigit() && second.is_ascii_hexdigit()
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_all_callback_params_exactly_once() {
let url = Url::parse(
"pubkyauth://signin?caps=%2F%3Arw&x-source=Bitkit%20Wallet&x-success=bitkit%3A%2F%2Fwallet%2Fcallback%3Fnonce%3Dabc%252F123%26state%3Dready&x-error=bitkit%3A%2F%2Fwallet%2Ferror%3Fnonce%3Dabc%26reason%3Ddenied&x-cancel=bitkit%3A%2F%2Fwallet%2Fcancel%3Fnonce%3Dabc",
)
.unwrap();
assert_eq!(
XCallbackParams::from_url(&url),
XCallbackParams {
x_source: Some("Bitkit Wallet".into()),
x_success: Some("bitkit://wallet/callback?nonce=abc%2F123&state=ready".into()),
x_error: Some("bitkit://wallet/error?nonce=abc&reason=denied".into()),
x_cancel: Some("bitkit://wallet/cancel?nonce=abc".into()),
}
);
}
#[test]
fn preserves_literal_plus_and_decodes_percent_encoded_space() {
let url = Url::parse(
"pubkyauth://signin?x-source=Bitkit+Wallet%20Mobile&x-success=bitkit%3A%2F%2Fcallback%3Fnonce%3Da%2Bb",
)
.unwrap();
let callbacks = XCallbackParams::from_url(&url);
assert_eq!(callbacks.x_source.as_deref(), Some("Bitkit+Wallet Mobile"));
assert_eq!(
callbacks.x_success.as_deref(),
Some("bitkit://callback?nonce=a+b")
);
}
#[test]
fn x_success_takes_precedence_over_legacy_callback_even_when_empty() {
let url =
Url::parse("pubkyauth://signin?x-success=&callback=bitkit%3A%2F%2Flegacy").unwrap();
assert_eq!(
XCallbackParams::from_url(&url).x_success.as_deref(),
Some("")
);
}
#[test]
fn uses_legacy_callback_as_success_fallback() {
let url = Url::parse("pubkyauth://signin?callback=bitkit%3A%2F%2Flegacy").unwrap();
assert_eq!(
XCallbackParams::from_url(&url).x_success.as_deref(),
Some("bitkit://legacy")
);
}
#[test]
fn appends_callbacks_with_ring_compatible_encoding_and_order() {
let mut url = Url::parse("pubkyauth://signin?caps=%2F%3Arw").unwrap();
let callbacks = XCallbackParams {
x_source: Some("Bitkit Wallet".into()),
x_success: Some("bitkit://callback?nonce=a+b&state=ready%20now".into()),
x_error: Some("bitkit://error".into()),
x_cancel: Some("bitkit://cancel".into()),
};
callbacks.append_to_url(&mut url);
assert_eq!(
url.as_str(),
"pubkyauth://signin?caps=%2F%3Arw&x-source=Bitkit%20Wallet&x-success=bitkit%3A%2F%2Fcallback%3Fnonce%3Da%2Bb%26state%3Dready%2520now&x-error=bitkit%3A%2F%2Ferror&x-cancel=bitkit%3A%2F%2Fcancel"
);
assert_eq!(XCallbackParams::from_url(&url), callbacks);
}
#[test]
fn canonicalizes_legacy_callback_when_appending() {
let legacy = Url::parse("pubkyauth://signin?callback=bitkit%3A%2F%2Flegacy").unwrap();
let callbacks = XCallbackParams::from_url(&legacy);
let mut canonical = Url::parse("pubkyauth://signin").unwrap();
callbacks.append_to_url(&mut canonical);
assert_eq!(
canonical.as_str(),
"pubkyauth://signin?x-success=bitkit%3A%2F%2Flegacy"
);
}
#[test]
fn malformed_percent_encoding_is_left_unchanged() {
let url = Url::parse("pubkyauth://signin?x-success=bitkit%3A%2F%2Fcallback%ZZ").unwrap();
assert_eq!(
XCallbackParams::from_url(&url).x_success.as_deref(),
Some("bitkit%3A%2F%2Fcallback%ZZ")
);
}
}