1use std::fmt;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Pairing {
24 pub url: Option<String>,
27 pub token: String,
28}
29
30const SCHEME: &str = "ecr://pair?";
31
32impl Pairing {
33 pub fn token_only(token: impl Into<String>) -> Self {
34 Self {
35 url: None,
36 token: token.into(),
37 }
38 }
39
40 pub fn new(url: impl Into<String>, token: impl Into<String>) -> Self {
41 Self {
42 url: Some(url.into()),
43 token: token.into(),
44 }
45 }
46
47 pub fn parse(text: &str) -> Option<Self> {
50 let text = text.trim();
51 if text.is_empty() {
52 return None;
53 }
54
55 let Some(query) = text.strip_prefix(SCHEME) else {
56 return (!text.contains(char::is_whitespace)).then(|| Self::token_only(text));
58 };
59
60 let mut url = None;
61 let mut token = None;
62 for field in query.split('&') {
63 let Some((key, value)) = field.split_once('=') else {
64 continue;
65 };
66 match key {
67 "url" => url = decode(value),
68 "token" => token = decode(value),
69 _ => {}
70 }
71 }
72
73 let token = token.filter(|t| !t.is_empty())?;
74 Some(Self {
75 url: url.filter(|u| !u.is_empty()),
76 token,
77 })
78 }
79}
80
81impl fmt::Display for Pairing {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 let Some(url) = &self.url else {
87 return f.write_str(&self.token);
88 };
89 write!(
90 f,
91 "{SCHEME}url={}&token={}",
92 encode(url),
93 encode(&self.token)
94 )
95 }
96}
97
98fn encode(text: &str) -> String {
102 let mut out = String::with_capacity(text.len());
103 for byte in text.bytes() {
104 match byte {
105 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
106 out.push(byte as char)
107 }
108 _ => out.push_str(&format!("%{byte:02X}")),
109 }
110 }
111 out
112}
113
114fn decode(text: &str) -> Option<String> {
115 let mut out = Vec::with_capacity(text.len());
116 let mut bytes = text.bytes();
117
118 while let Some(byte) = bytes.next() {
119 if byte != b'%' {
120 out.push(byte);
121 continue;
122 }
123 let hex = [bytes.next()?, bytes.next()?];
124 let hex = std::str::from_utf8(&hex).ok()?;
125 out.push(u8::from_str_radix(hex, 16).ok()?);
126 }
127
128 String::from_utf8(out).ok()
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 const TOKEN: &str = "8f1c2d3e4f5a6b7c";
136
137 #[test]
138 fn a_pairing_survives_a_round_trip() {
139 let pairing = Pairing::new("http://box:8383", TOKEN);
140
141 assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
142 }
143
144 #[test]
145 fn the_url_is_encoded_so_its_punctuation_cannot_end_a_field() {
146 let encoded = Pairing::new("http://box:8383/", TOKEN).to_string();
147
148 assert!(!encoded.contains("//box"), "{encoded}");
149 assert!(encoded.starts_with("ecr://pair?url="));
150 }
151
152 #[test]
153 fn a_url_containing_an_ampersand_still_round_trips() {
154 let pairing = Pairing::new("http://box:8383/?a=1&b=2", TOKEN);
157
158 assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
159 }
160
161 #[test]
162 fn a_bare_token_is_still_a_pairing() {
163 assert_eq!(Pairing::parse(TOKEN), Some(Pairing::token_only(TOKEN)));
165 }
166
167 #[test]
168 fn a_token_only_pairing_is_written_the_old_way() {
169 assert_eq!(Pairing::token_only(TOKEN).to_string(), TOKEN);
170 }
171
172 #[test]
173 fn a_code_without_a_token_is_not_a_pairing() {
174 assert_eq!(Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox"), None);
175 assert_eq!(Pairing::parse("ecr://pair?token="), None);
176 }
177
178 #[test]
179 fn something_that_is_not_a_code_at_all_is_refused() {
180 assert_eq!(Pairing::parse(""), None);
181 assert_eq!(Pairing::parse(" "), None);
182 assert_eq!(Pairing::parse("some words here"), None);
183 }
184
185 #[test]
186 fn an_unknown_field_is_ignored_rather_than_failing() {
187 let parsed = Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox&token=abc&name=phone");
188
189 assert_eq!(parsed, Some(Pairing::new("http://box", "abc")));
190 }
191
192 #[test]
193 fn a_truncated_escape_is_refused_rather_than_guessed() {
194 assert_eq!(Pairing::parse("ecr://pair?token=ab%"), None);
195 assert_eq!(Pairing::parse("ecr://pair?token=ab%2"), None);
196 }
197}