use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pairing {
pub url: Option<String>,
pub token: String,
}
const SCHEME: &str = "ecr://pair?";
impl Pairing {
pub fn token_only(token: impl Into<String>) -> Self {
Self {
url: None,
token: token.into(),
}
}
pub fn new(url: impl Into<String>, token: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
token: token.into(),
}
}
pub fn parse(text: &str) -> Option<Self> {
let text = text.trim();
if text.is_empty() {
return None;
}
let Some(query) = text.strip_prefix(SCHEME) else {
return (!text.contains(char::is_whitespace)).then(|| Self::token_only(text));
};
let mut url = None;
let mut token = None;
for field in query.split('&') {
let Some((key, value)) = field.split_once('=') else {
continue;
};
match key {
"url" => url = decode(value),
"token" => token = decode(value),
_ => {}
}
}
let token = token.filter(|t| !t.is_empty())?;
Some(Self {
url: url.filter(|u| !u.is_empty()),
token,
})
}
}
impl fmt::Display for Pairing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(url) = &self.url else {
return f.write_str(&self.token);
};
write!(
f,
"{SCHEME}url={}&token={}",
encode(url),
encode(&self.token)
)
}
}
fn encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn decode(text: &str) -> Option<String> {
let mut out = Vec::with_capacity(text.len());
let mut bytes = text.bytes();
while let Some(byte) = bytes.next() {
if byte != b'%' {
out.push(byte);
continue;
}
let hex = [bytes.next()?, bytes.next()?];
let hex = std::str::from_utf8(&hex).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?);
}
String::from_utf8(out).ok()
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "8f1c2d3e4f5a6b7c";
#[test]
fn a_pairing_survives_a_round_trip() {
let pairing = Pairing::new("http://box:8383", TOKEN);
assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
}
#[test]
fn the_url_is_encoded_so_its_punctuation_cannot_end_a_field() {
let encoded = Pairing::new("http://box:8383/", TOKEN).to_string();
assert!(!encoded.contains("//box"), "{encoded}");
assert!(encoded.starts_with("ecr://pair?url="));
}
#[test]
fn a_url_containing_an_ampersand_still_round_trips() {
let pairing = Pairing::new("http://box:8383/?a=1&b=2", TOKEN);
assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
}
#[test]
fn a_bare_token_is_still_a_pairing() {
assert_eq!(Pairing::parse(TOKEN), Some(Pairing::token_only(TOKEN)));
}
#[test]
fn a_token_only_pairing_is_written_the_old_way() {
assert_eq!(Pairing::token_only(TOKEN).to_string(), TOKEN);
}
#[test]
fn a_code_without_a_token_is_not_a_pairing() {
assert_eq!(Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox"), None);
assert_eq!(Pairing::parse("ecr://pair?token="), None);
}
#[test]
fn something_that_is_not_a_code_at_all_is_refused() {
assert_eq!(Pairing::parse(""), None);
assert_eq!(Pairing::parse(" "), None);
assert_eq!(Pairing::parse("some words here"), None);
}
#[test]
fn an_unknown_field_is_ignored_rather_than_failing() {
let parsed = Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox&token=abc&name=phone");
assert_eq!(parsed, Some(Pairing::new("http://box", "abc")));
}
#[test]
fn a_truncated_escape_is_refused_rather_than_guessed() {
assert_eq!(Pairing::parse("ecr://pair?token=ab%"), None);
assert_eq!(Pairing::parse("ecr://pair?token=ab%2"), None);
}
}