Skip to main content

actpub_webfinger/
account.rs

1//! Parsing and formatting of `acct:` URIs (RFC 7565).
2
3use 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
12/// Characters that must be percent-encoded inside the `resource=` query
13/// parameter value. This follows RFC 3986 `query` component rules but keeps
14/// the `:` and `@` intact since they appear in every `acct:user@host` URI
15/// and Fediverse servers universally expect them unencoded.
16const 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/// A Fediverse account identifier of the form `acct:user@host`.
33///
34/// See [RFC 7565](https://www.rfc-editor.org/rfc/rfc7565) for the canonical
35/// definition of the `acct:` URI scheme.
36///
37/// # Examples
38///
39/// ```
40/// # use actpub_webfinger::Account;
41/// let a = Account::parse("acct:alice@example.com").unwrap();
42/// assert_eq!(a.user(), "alice");
43/// assert_eq!(a.host(), "example.com");
44/// assert_eq!(a.to_string(), "acct:alice@example.com");
45///
46/// // Leading `@` is tolerated:
47/// assert_eq!(Account::parse("@alice@example.com").unwrap(), a);
48/// ```
49#[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    /// Constructs an [`Account`] from its components.
58    ///
59    /// Both must be non-empty. The host is normalised using IDNA 2008
60    /// (Unicode → ASCII Punycode, lowercased) per
61    /// [RFC 7565 §7][rfc7565-7], so internationalised domain names are
62    /// accepted and stored in their canonical Punycode form.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::InvalidAcct`] if `user` or `host` is empty, or if
67    /// `host` contains characters that IDNA cannot map to a valid DNS
68    /// label.
69    ///
70    /// [rfc7565-7]: https://www.rfc-editor.org/rfc/rfc7565#section-7
71    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    /// Parses a string into an [`Account`].
91    ///
92    /// Accepts the following forms:
93    ///
94    /// - `acct:user@host`
95    /// - `@user@host`
96    /// - `user@host`
97    ///
98    /// # Errors
99    ///
100    /// Returns [`Error::InvalidAcct`] if the string does not match any of
101    /// the supported forms or if any component is empty.
102    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    /// Returns the local-part (username).
125    #[must_use]
126    pub fn user(&self) -> &str {
127        &self.user
128    }
129
130    /// Returns the host component (always lowercase).
131    #[must_use]
132    pub fn host(&self) -> &str {
133        &self.host
134    }
135
136    /// Returns the resource URI in canonical `acct:` form.
137    #[must_use]
138    pub fn to_resource(&self) -> String {
139        format!("acct:{}@{}", self.user, self.host)
140    }
141
142    /// Builds the `https://{host}/.well-known/webfinger?resource=…` URL for
143    /// this account.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`Error::InvalidUrl`] if the host is not a valid authority.
148    pub fn webfinger_url(&self) -> Result<Url, Error> {
149        self.webfinger_url_with_scheme("https")
150    }
151
152    /// Builds the `{scheme}://{host}/.well-known/webfinger?resource=…` URL
153    /// for this account, allowing the caller to override the scheme.
154    ///
155    /// Production code should always use [`Self::webfinger_url`] to ensure
156    /// `https`. The override exists to support test fixtures, local
157    /// development, and Tor hidden-service endpoints.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`Error::InvalidUrl`] if the resulting URL is malformed.
162    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
202/// Percent-encodes the `resource=` query-string value.
203///
204/// Uses the RFC 3986 `query` component set but leaves `:` and `@` intact,
205/// since these appear in every `acct:user@host` URI and Fediverse servers
206/// universally accept (and in practice require) them unencoded.
207fn 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        // But preserves user case per RFC 7565 §7.
241        assert_eq!(a.user(), "Alice");
242    }
243
244    #[test]
245    fn idna_normalises_unicode_host_to_punycode() {
246        // Unicode domain label should be converted to ASCII Punycode.
247        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        // Contains a label that is not valid per IDNA.
254        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}