actpub_webfinger/
account.rs1use std::fmt;
4use std::str::FromStr;
5
6use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::error::Error;
11
12const RESOURCE_QUERY: &AsciiSet = &CONTROLS
17 .add(b' ')
18 .add(b'"')
19 .add(b'<')
20 .add(b'>')
21 .add(b'`')
22 .add(b'#')
23 .add(b'?')
24 .add(b'{')
25 .add(b'}')
26 .add(b'/')
27 .add(b'&')
28 .add(b'=')
29 .add(b'+')
30 .add(b'%');
31
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(try_from = "String", into = "String")]
51pub struct Account {
52 user: String,
53 host: String,
54}
55
56impl Account {
57 pub fn new(user: impl Into<String>, host: impl Into<String>) -> Result<Self, Error> {
72 let user = user.into();
73 let host_raw = host.into();
74 if user.is_empty() || host_raw.is_empty() {
75 return Err(Error::InvalidAcct("empty user or host".into()));
76 }
77 let host_ascii = idna::domain_to_ascii(&host_raw)
78 .map_err(|e| Error::InvalidAcct(format!("invalid IDN host `{host_raw}`: {e}")))?;
79 if host_ascii.is_empty() {
80 return Err(Error::InvalidAcct(format!(
81 "host `{host_raw}` maps to an empty IDNA label"
82 )));
83 }
84 Ok(Self {
85 user,
86 host: host_ascii,
87 })
88 }
89
90 pub fn parse(input: &str) -> Result<Self, Error> {
103 let body = input
104 .strip_prefix("acct:")
105 .or_else(|| input.strip_prefix('@'))
106 .unwrap_or(input);
107
108 let (user, host) = body
109 .split_once('@')
110 .ok_or_else(|| Error::InvalidAcct(format!("missing `@`: {input}")))?;
111
112 if user.is_empty() || host.is_empty() {
113 return Err(Error::InvalidAcct(format!("empty user or host: {input}")));
114 }
115 if user.contains('@') || host.contains('@') {
116 return Err(Error::InvalidAcct(format!(
117 "unexpected additional `@`: {input}"
118 )));
119 }
120
121 Self::new(user, host)
122 }
123
124 #[must_use]
126 pub fn user(&self) -> &str {
127 &self.user
128 }
129
130 #[must_use]
132 pub fn host(&self) -> &str {
133 &self.host
134 }
135
136 #[must_use]
138 pub fn to_resource(&self) -> String {
139 format!("acct:{}@{}", self.user, self.host)
140 }
141
142 pub fn webfinger_url(&self) -> Result<Url, Error> {
149 self.webfinger_url_with_scheme("https")
150 }
151
152 pub fn webfinger_url_with_scheme(&self, scheme: &str) -> Result<Url, Error> {
163 let resource = self.to_resource();
164 let encoded = percent_encode(&resource);
165 let raw = format!(
166 "{scheme}://{host}{path}?resource={encoded}",
167 host = self.host,
168 path = crate::WELL_KNOWN_PATH,
169 );
170 Ok(Url::parse(&raw)?)
171 }
172}
173
174impl fmt::Display for Account {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 write!(f, "acct:{}@{}", self.user, self.host)
177 }
178}
179
180impl FromStr for Account {
181 type Err = Error;
182
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 Self::parse(s)
185 }
186}
187
188impl TryFrom<String> for Account {
189 type Error = Error;
190
191 fn try_from(value: String) -> Result<Self, Self::Error> {
192 Self::parse(&value)
193 }
194}
195
196impl From<Account> for String {
197 fn from(a: Account) -> Self {
198 a.to_string()
199 }
200}
201
202fn percent_encode(input: &str) -> String {
208 utf8_percent_encode(input, RESOURCE_QUERY).to_string()
209}
210
211#[cfg(test)]
212mod tests {
213 use pretty_assertions::assert_eq;
214
215 use super::*;
216
217 #[test]
218 fn parses_acct_prefix() {
219 let a = Account::parse("acct:alice@example.com").unwrap();
220 assert_eq!(a.user(), "alice");
221 assert_eq!(a.host(), "example.com");
222 }
223
224 #[test]
225 fn parses_at_prefix() {
226 let a = Account::parse("@alice@example.com").unwrap();
227 assert_eq!(a.host(), "example.com");
228 }
229
230 #[test]
231 fn parses_bare_form() {
232 let a = Account::parse("alice@example.com").unwrap();
233 assert_eq!(a.to_resource(), "acct:alice@example.com");
234 }
235
236 #[test]
237 fn normalises_host_to_lowercase() {
238 let a = Account::parse("acct:Alice@EXAMPLE.COM").unwrap();
239 assert_eq!(a.host(), "example.com");
240 assert_eq!(a.user(), "Alice");
242 }
243
244 #[test]
245 fn idna_normalises_unicode_host_to_punycode() {
246 let a = Account::parse("acct:alice@例え.jp").unwrap();
248 assert_eq!(a.host(), "xn--r8jz45g.jp");
249 }
250
251 #[test]
252 fn idna_rejects_invalid_unicode_labels() {
253 assert!(Account::new("alice", "\u{FDD0}.jp").is_err());
255 }
256
257 #[test]
258 fn rejects_missing_at() {
259 assert!(Account::parse("acct:alice").is_err());
260 }
261
262 #[test]
263 fn rejects_empty_components() {
264 assert!(Account::parse("acct:@example.com").is_err());
265 assert!(Account::parse("acct:alice@").is_err());
266 }
267
268 #[test]
269 fn rejects_extra_at() {
270 assert!(Account::parse("acct:alice@evil@example.com").is_err());
271 }
272
273 #[test]
274 fn builds_webfinger_url() {
275 let a = Account::parse("acct:alice@example.com").unwrap();
276 let url = a.webfinger_url().unwrap();
277 assert_eq!(
278 url.as_str(),
279 "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
280 );
281 }
282
283 #[test]
284 fn roundtrips_through_serde() {
285 let a = Account::parse("acct:alice@example.com").unwrap();
286 let json = serde_json::to_string(&a).unwrap();
287 assert_eq!(json, r#""acct:alice@example.com""#);
288 let back: Account = serde_json::from_str(&json).unwrap();
289 assert_eq!(back, a);
290 }
291}