Skip to main content

ojak/
webfinger.rs

1//! Finding an actor by its handle, through WebFinger (RFC 7033).
2//!
3//! A handle, `@alice@social.example`, is an [`Address`]: a user and a host.
4//! [`Fetcher::webfinger`] asks the host's WebFinger endpoint for it, through
5//! the guarded client, and reads the actors its `self` links name. The host
6//! answers only for itself: an actor it names on another origin is a claim an
7//! application that shows the handle should check against the actor, as it
8//! would any other.
9
10use crate::fetch::{FetchError, Fetcher};
11use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
12use serde_json::Value;
13use std::fmt;
14use std::future::Future;
15use url::Url;
16
17/// What a WebFinger query asks for.
18pub const JRD_ACCEPT: &str = "application/jrd+json, application/json";
19
20/// What a host-meta query asks for.
21pub const XRD_ACCEPT: &str = "application/xrd+xml, application/xml, text/xml";
22
23/// A handle: a user at a host.
24#[derive(Clone, Debug, Eq, Hash, PartialEq)]
25pub struct Address {
26    user: String,
27    host: String,
28}
29
30impl Address {
31    /// Read a handle as people write it, `@alice@social.example`,
32    /// `alice@social.example`, or `acct:alice@social.example`. The user is
33    /// what RFC 3986 allows unencoded in a URI's userinfo, and
34    /// percent-encodings; the host a domain name, lower-cased, or an IP
35    /// literal, with a port if it has one. A host in Unicode is not read: an
36    /// international domain is written in its ASCII form, as the host
37    /// serves it.
38    #[must_use]
39    pub fn parse(handle: &str) -> Option<Self> {
40        let handle = handle.trim();
41        let handle = handle
42            .strip_prefix("acct:")
43            .or_else(|| handle.strip_prefix('@'))
44            .unwrap_or(handle);
45        let (user, host) = handle.rsplit_once('@')?;
46        let host = host.to_ascii_lowercase();
47        let valid_user = !user.is_empty()
48            && user.bytes().all(|byte| {
49                byte.is_ascii_alphanumeric()
50                    || matches!(
51                        byte,
52                        b'-' | b'.'
53                            | b'_'
54                            | b'~'
55                            | b'!'
56                            | b'$'
57                            | b'&'
58                            | b'\''
59                            | b'('
60                            | b')'
61                            | b'*'
62                            | b'+'
63                            | b','
64                            | b';'
65                            | b'='
66                            | b'%'
67                    )
68            });
69        if !valid_user || !is_host(&host) {
70            return None;
71        }
72        Some(Self {
73            user: user.to_owned(),
74            host,
75        })
76    }
77
78    /// The user, before the `@`.
79    #[must_use]
80    pub fn user(&self) -> &str {
81        &self.user
82    }
83
84    /// The host, with its port if it has one.
85    #[must_use]
86    pub fn host(&self) -> &str {
87        &self.host
88    }
89
90    /// The `acct:` URI WebFinger is asked about.
91    #[must_use]
92    pub fn acct(&self) -> String {
93        format!("acct:{}@{}", self.user, self.host)
94    }
95
96    /// The URL of the host's WebFinger query for this address, over `https`,
97    /// or over `http` for an onion service, as Mastodon asks one.
98    ///
99    /// # Panics
100    ///
101    /// Never: the host has been checked to be one.
102    #[must_use]
103    pub fn webfinger_url(&self) -> Url {
104        /// What a query parameter's value may not hold unencoded.
105        const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC
106            .remove(b'-')
107            .remove(b'.')
108            .remove(b'_')
109            .remove(b'~')
110            .remove(b':')
111            .remove(b'@');
112        let acct = self.acct();
113        let resource = utf8_percent_encode(&acct, QUERY_VALUE);
114        let scheme = if self.is_onion() { "http" } else { "https" };
115        Url::parse(&format!(
116            "{scheme}://{}/.well-known/webfinger?resource={resource}",
117            self.host
118        ))
119        .expect("a checked host makes a URL")
120    }
121}
122
123impl Address {
124    /// The URL of the host's host-meta document (RFC 6415), over `https`,
125    /// or over `http` for an onion service, as Mastodon asks it.
126    ///
127    /// # Panics
128    ///
129    /// Never: the host has been checked to be one.
130    #[must_use]
131    pub fn host_meta_url(&self) -> Url {
132        let scheme = if self.is_onion() { "http" } else { "https" };
133        Url::parse(&format!("{scheme}://{}/.well-known/host-meta", self.host))
134            .expect("a checked host makes a URL")
135    }
136
137    fn is_onion(&self) -> bool {
138        self.host
139            .split(':')
140            .next()
141            .is_some_and(|name| name.ends_with(".onion"))
142    }
143}
144
145/// The WebFinger query template a host-meta document (an XRD) gives in its
146/// `lrdd` link, as Mastodon's `Webfinger#url_from_template` reads it: the
147/// first `Link` element, in the document's default namespace, whose `rel`
148/// is `lrdd`.
149///
150/// # Errors
151///
152/// When the document is not XML, has no default namespace, or has no such
153/// link with a `template`.
154pub fn lrdd_template(xrd: &str) -> Result<String, String> {
155    let document =
156        roxmltree::Document::parse(xrd).map_err(|error| format!("invalid XML: {error}"))?;
157    let namespace = document
158        .root_element()
159        .default_namespace()
160        .ok_or("invalid XML: no default namespace")?;
161    document
162        .descendants()
163        .find(|node| {
164            node.is_element()
165                && node.tag_name().name() == "Link"
166                && node.tag_name().namespace() == Some(namespace)
167                && node.attribute("rel") == Some("lrdd")
168        })
169        .ok_or("host-meta without link to Webfinger")?
170        .attribute("template")
171        .map(str::to_owned)
172        .ok_or_else(|| "host-meta link to Webfinger without a template".to_owned())
173}
174
175/// An `acct:` URI or a bare handle split at its `@`s as Ruby's
176/// `split('@')` splits it, keeping the first two parts, as
177/// [`confirm_with`] reads a `subject`. A part that is missing is empty.
178fn split_acct(acct: &str) -> (String, String) {
179    let acct = acct.strip_prefix("acct:").unwrap_or(acct);
180    let mut parts = acct.split('@');
181    (
182        parts.next().unwrap_or_default().to_owned(),
183        parts.next().unwrap_or_default().to_owned(),
184    )
185}
186
187impl fmt::Display for Address {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        write!(f, "{}@{}", self.user, self.host)
190    }
191}
192
193/// Whether `host` is a lower-case domain name or an IP literal, with a
194/// numeric port if it has one.
195fn is_host(host: &str) -> bool {
196    let (name, port) = if let Some(literal) = host.strip_prefix('[') {
197        let Some((address, rest)) = literal.split_once(']') else {
198            return false;
199        };
200        if address.parse::<std::net::Ipv6Addr>().is_err() {
201            return false;
202        }
203        match rest {
204            "" => return true,
205            rest => (None, rest.strip_prefix(':')),
206        }
207    } else {
208        match host.split_once(':') {
209            Some((name, port)) => (Some(name), Some(port)),
210            None => (Some(host), None),
211        }
212    };
213    let port_ok = port.is_none_or(|port| port.parse::<u16>().is_ok());
214    let name_ok = name.is_none_or(|name| {
215        !name.is_empty()
216            && name.split('.').all(|label| {
217                !label.is_empty()
218                    && !label.starts_with('-')
219                    && !label.ends_with('-')
220                    && label.bytes().all(|byte| {
221                        byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'
222                    })
223            })
224    });
225    port_ok && name_ok
226}
227
228/// An actor a WebFinger answer names.
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct Named {
231    /// The actor's `id`.
232    pub id: Url,
233    /// Its ActivityStreams type, when the link says: Lemmy answers for a
234    /// name that is both a user and a community with a `self` link for each,
235    /// telling them apart this way.
236    pub kind: Option<String>,
237}
238
239/// What a WebFinger answer says of an address.
240#[derive(Clone, Debug, Eq, PartialEq)]
241pub struct Found {
242    /// The `subject`: the canonical `acct:` URI, when the host gives one.
243    pub subject: Option<String>,
244    /// The actors its `self` links name, in the order it gave them.
245    pub actors: Vec<Named>,
246}
247
248impl Found {
249    /// Read a JRD: the `self` links with an ActivityStreams type and an
250    /// `http`, `https` or `ap` `href`. A link that is not one, or whose
251    /// `properties` are missing or `null`, is read as far as it goes.
252    #[must_use]
253    pub fn from_jrd(jrd: &Value) -> Self {
254        let links = jrd
255            .get("links")
256            .and_then(Value::as_array)
257            .map(Vec::as_slice)
258            .unwrap_or_default();
259        let actors = links
260            .iter()
261            .filter(|link| link.get("rel").and_then(Value::as_str) == Some("self"))
262            .filter(|link| {
263                link.get("type")
264                    .and_then(Value::as_str)
265                    .is_some_and(crate::fetch::is_activity_content_type)
266            })
267            .filter_map(|link| {
268                let id = Url::parse(link.get("href")?.as_str()?).ok()?;
269                if !matches!(id.scheme(), "http" | "https" | "ap") {
270                    return None;
271                }
272                let kind = link
273                    .get("properties")
274                    .and_then(|properties| {
275                        properties.get("https://www.w3.org/ns/activitystreams#type")
276                    })
277                    .and_then(Value::as_str)
278                    .map(str::to_owned);
279                Some(Named { id, kind })
280            })
281            .collect();
282        Self {
283            subject: jrd
284                .get("subject")
285                .and_then(Value::as_str)
286                .map(str::to_owned),
287            actors,
288        }
289    }
290
291    /// The first actor named, or the first of type `kind` when there is one.
292    #[must_use]
293    pub fn actor(&self, kind: Option<&str>) -> Option<&Url> {
294        kind.and_then(|kind| {
295            self.actors
296                .iter()
297                .find(|named| named.kind.as_deref() == Some(kind))
298        })
299        .or_else(|| self.actors.first())
300        .map(|named| &named.id)
301    }
302}
303
304/// A handle resolved through WebFinger: the handle its host gave as the
305/// `subject`, and the actor the answer's first ActivityPub `self` link names.
306#[derive(Clone, Debug, Eq, PartialEq)]
307pub struct Resolved {
308    /// The canonical handle, as the host spells it.
309    pub address: Address,
310    /// The actor it names.
311    pub actor: Url,
312    /// The whole answer the actor was read from.
313    pub found: Found,
314}
315
316/// Why a handle could not be resolved.
317#[derive(Debug)]
318pub enum ResolveError {
319    /// A host could not be asked, or did not answer with a JRD. A `410
320    /// Gone` is here, with its status: the handle is gone from its host.
321    Fetch(FetchError),
322    /// The answer for `asked` has no `subject`.
323    NoSubject { asked: String },
324    /// The answer for `asked` names no ActivityPub actor.
325    NoActor { asked: String },
326    /// The `subject` of the answer for `asked` is not a handle.
327    BadSubject { asked: String, subject: String },
328    /// The handle the answer for `asked` gave as canonical names yet
329    /// another one, `stopped_at`.
330    TooManyRedirects { asked: String, stopped_at: String },
331    /// The handle, resolved, names another actor than the one it was
332    /// expected to.
333    NotLoopingBack { handle: String, actor: String },
334}
335
336impl ResolveError {
337    /// The status a host answered with, if it answered with one other than
338    /// success; a `410` says the handle is gone.
339    #[must_use]
340    pub fn status(&self) -> Option<u16> {
341        match self {
342            Self::Fetch(error) => error.status(),
343            _ => None,
344        }
345    }
346}
347
348impl fmt::Display for ResolveError {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        match self {
351            Self::Fetch(error) => error.fmt(f),
352            Self::NoSubject { asked } => write!(f, "Missing subject in response for {asked}"),
353            Self::NoActor { asked } => write!(f, "Missing self link in response for {asked}"),
354            Self::BadSubject { asked, subject } => write!(
355                f,
356                "Webfinger response for {asked} has a subject that is not a handle: {subject}"
357            ),
358            Self::TooManyRedirects { asked, stopped_at } => write!(
359                f,
360                "Too many webfinger redirects for URI {asked} (stopped at {stopped_at})"
361            ),
362            Self::NotLoopingBack { handle, actor } => write!(
363                f,
364                "Webfinger response for {handle} does not loop back to {actor}"
365            ),
366        }
367    }
368}
369
370impl std::error::Error for ResolveError {
371    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
372        match self {
373            Self::Fetch(error) => Some(error),
374            _ => None,
375        }
376    }
377}
378
379impl From<FetchError> for ResolveError {
380    fn from(error: FetchError) -> Self {
381        Self::Fetch(error)
382    }
383}
384
385/// Resolve `address` through WebFinger as Mastodon's `ResolveAccountService`
386/// does, asking each host with `lookup`: an answer has to have a `subject`
387/// and an ActivityPub `self` link. When the `subject` is the handle asked
388/// about, compared without regard to case, that is the answer; when it is
389/// another, that handle is asked about in turn, once, and its answer has to
390/// name itself. An application that builds the query URL itself — over
391/// plain `http` for an onion service, say — passes a `lookup` that calls
392/// [`Fetcher::webfinger_at`]; [`Fetcher::resolve`] asks with
393/// [`Fetcher::webfinger`].
394///
395/// The actor is only what the host claims: look it up afterwards to
396/// establish it like any other.
397///
398/// # Errors
399///
400/// When a host cannot be asked or its answer is missing either part, the
401/// `subject` is not a handle, or a redirect leads to yet another handle.
402pub async fn resolve_with<F, Fut>(address: &Address, lookup: F) -> Result<Resolved, ResolveError>
403where
404    F: FnMut(&Address) -> Fut,
405    Fut: Future<Output = Result<Found, FetchError>>,
406{
407    resolve_reading(address, Reading::Strict, lookup).await
408}
409
410/// How a `subject` is read as a handle.
411#[derive(Clone, Copy)]
412enum Reading {
413    /// `ResolveAccountService#split_acct`: split at its `@`s, it has to have
414    /// exactly two parts.
415    Strict,
416    /// `ProcessAccountService#split_acct`: the first two of its parts,
417    /// whatever follows them, as [`split_acct`] reads it.
418    FirstTwoParts,
419}
420
421async fn resolve_reading<F, Fut>(
422    address: &Address,
423    reading: Reading,
424    mut lookup: F,
425) -> Result<Resolved, ResolveError>
426where
427    F: FnMut(&Address) -> Fut,
428    Fut: Future<Output = Result<Found, FetchError>>,
429{
430    let same = |a: &Address, b: &Address| {
431        a.user.eq_ignore_ascii_case(&b.user) && a.host.eq_ignore_ascii_case(&b.host)
432    };
433    let first = read_answer(address, lookup(address).await?, reading)?;
434    if same(&first.address, address) {
435        return Ok(first);
436    }
437    // The handle does not match, so it may have been redirected.
438    let canonical = first.address;
439    let second = read_answer(&canonical, lookup(&canonical).await?, reading)?;
440    if !same(&second.address, &canonical) {
441        return Err(ResolveError::TooManyRedirects {
442            asked: address.to_string(),
443            stopped_at: second.address.to_string(),
444        });
445    }
446    Ok(second)
447}
448
449/// Confirm that `address` names `actor`, as Mastodon's
450/// `ProcessAccountService#check_webfinger!` does before it believes an
451/// actor's handle: [`resolve_with`], and the actor the canonical handle's
452/// answer names has to be `actor`. Returns what it resolved to, whose
453/// `address` is the handle to show.
454///
455/// A `subject` is read as `check_webfinger!` reads it, by its first two
456/// parts split at `@`, so `acct:alice@social.example@elsewhere` is
457/// `alice@social.example`; [`resolve_with`] refuses it, as
458/// `ResolveAccountService` does.
459///
460/// # Errors
461///
462/// As [`resolve_with`], and when the handle names another actor.
463pub async fn confirm_with<F, Fut>(
464    address: &Address,
465    actor: &str,
466    lookup: F,
467) -> Result<Resolved, ResolveError>
468where
469    F: FnMut(&Address) -> Fut,
470    Fut: Future<Output = Result<Found, FetchError>>,
471{
472    let resolved = resolve_reading(address, Reading::FirstTwoParts, lookup).await?;
473    if resolved.actor.as_str() != actor {
474        return Err(ResolveError::NotLoopingBack {
475            handle: resolved.address.to_string(),
476            actor: actor.to_owned(),
477        });
478    }
479    Ok(resolved)
480}
481
482/// `Webfinger::Response`'s checks of the answer for `asked`, and the handle
483/// its `subject` names, which is an `acct:` URI or a bare `user@host`.
484fn read_answer(asked: &Address, found: Found, reading: Reading) -> Result<Resolved, ResolveError> {
485    let asked = asked.acct();
486    let subject = found
487        .subject
488        .clone()
489        .filter(|subject| !subject.trim().is_empty())
490        .ok_or_else(|| ResolveError::NoSubject {
491            asked: asked.clone(),
492        })?;
493    let actor = found
494        .actors
495        .first()
496        .map(|named| named.id.clone())
497        .ok_or_else(|| ResolveError::NoActor {
498            asked: asked.clone(),
499        })?;
500    let address = match reading {
501        Reading::Strict => Some(subject.trim())
502            .filter(|subject| !subject.starts_with('@'))
503            .and_then(Address::parse),
504        Reading::FirstTwoParts => {
505            let (user, host) = split_acct(subject.trim());
506            Some(())
507                .filter(|()| !user.is_empty() && !host.is_empty())
508                .and_then(|()| Address::parse(&format!("{user}@{host}")))
509        }
510    }
511    .ok_or(ResolveError::BadSubject { asked, subject })?;
512    Ok(Resolved {
513        address,
514        actor,
515        found,
516    })
517}
518
519impl Fetcher {
520    /// Ask `address`'s host what it says of it, as Mastodon's `Webfinger`
521    /// does: at [`Address::webfinger_url`], and, when that answers `404`, at
522    /// the query the host's host-meta `lrdd` template names, whose own `404`
523    /// is not followed further.
524    ///
525    /// # Errors
526    ///
527    /// As [`Fetcher::webfinger_at`], and when host-meta does not answer
528    /// `200` or names no query.
529    pub async fn webfinger(&self, address: &Address) -> Result<Found, FetchError> {
530        let plain = |mut url: Url| {
531            if self.plain_http_webfinger() {
532                // A scheme change between two special schemes always succeeds.
533                let _ = url.set_scheme("http");
534            }
535            url
536        };
537        match self.webfinger_at(&plain(address.webfinger_url())).await {
538            // `Webfinger#body_from_host_meta`: a host that does not answer at
539            // the standard place may say where it does, once.
540            Err(FetchError::Status(404)) => {
541                let url = self
542                    .host_meta_query(address, plain(address.host_meta_url()))
543                    .await?;
544                self.webfinger_at(&url).await
545            }
546            answer => answer,
547        }
548    }
549
550    /// The WebFinger query for `address` that the host-meta document at
551    /// `url` names. A failure is [`FetchError::Invalid`], never a status, so
552    /// that a host-meta `410` is not taken for the handle being gone.
553    async fn host_meta_query(&self, address: &Address, url: Url) -> Result<Url, FetchError> {
554        let acct = address.acct();
555        let response = self.get(&url, XRD_ACCEPT, None).await?;
556        if response.status != 200 {
557            return Err(FetchError::Invalid(format!(
558                "Request for {acct} returned HTTP {}",
559                response.status
560            )));
561        }
562        let xrd = String::from_utf8_lossy(&response.body);
563        let template = lrdd_template(&xrd)
564            .map_err(|error| FetchError::Invalid(format!("{error} for {acct}")))?;
565        Url::parse(&template.replace("{uri}", &acct))
566            .map_err(|error| FetchError::Invalid(format!("Invalid URI for {acct}: {error}")))
567    }
568
569    /// Confirm that `address` names `actor`, as [`confirm_with`] does,
570    /// asking each host with [`Fetcher::webfinger`].
571    ///
572    /// # Errors
573    ///
574    /// As [`confirm_with`].
575    pub async fn confirm(&self, address: &Address, actor: &str) -> Result<Resolved, ResolveError> {
576        confirm_with(address, actor, |address| {
577            let address = address.clone();
578            async move { self.webfinger(&address).await }
579        })
580        .await
581    }
582
583    /// Resolve `address` to the handle its host says is canonical and the
584    /// actor that names, as [`resolve_with`] does, asking each host with
585    /// [`Fetcher::webfinger`].
586    ///
587    /// # Errors
588    ///
589    /// As [`resolve_with`].
590    pub async fn resolve(&self, address: &Address) -> Result<Resolved, ResolveError> {
591        resolve_with(address, |address| {
592            let address = address.clone();
593            async move { self.webfinger(&address).await }
594        })
595        .await
596    }
597
598    /// Ask the WebFinger query `url` what it says, unsigned, as WebFinger
599    /// is asked; [`Address::webfinger_url`] is the query for an address.
600    ///
601    /// # Errors
602    ///
603    /// When the request fails or is refused, the answer is not a `200`, as
604    /// Mastodon requires, or it is not a JSON object.
605    pub async fn webfinger_at(&self, url: &Url) -> Result<Found, FetchError> {
606        let response = self.get(url, JRD_ACCEPT, None).await?;
607        if response.status != 200 {
608            return Err(FetchError::Status(response.status));
609        }
610        let jrd: Value = serde_json::from_slice(&response.body)
611            .map_err(|error| FetchError::Invalid(error.to_string()))?;
612        if !jrd.is_object() {
613            return Err(FetchError::Invalid("not an object".into()));
614        }
615        Ok(Found::from_jrd(&jrd))
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use serde_json::json;
623
624    #[test]
625    fn a_handle_is_read_however_it_is_written() {
626        let address = Address::parse("@alice@social.example").unwrap();
627        for handle in [
628            "alice@social.example",
629            "acct:alice@social.example",
630            " @alice@Social.Example ",
631        ] {
632            assert_eq!(Address::parse(handle).as_ref(), Some(&address), "{handle}");
633        }
634        assert_eq!(address.user(), "alice");
635        assert_eq!(address.acct(), "acct:alice@social.example");
636        assert_eq!(address.to_string(), "alice@social.example");
637        assert_eq!(
638            Address::parse("bob@localhost:3000").unwrap().host(),
639            "localhost:3000"
640        );
641        assert_eq!(
642            Address::parse("bob@[::1]:3000").unwrap().host(),
643            "[::1]:3000"
644        );
645        assert_eq!(Address::parse("bob@127.0.0.1").unwrap().host(), "127.0.0.1");
646    }
647
648    #[test]
649    fn what_is_not_a_handle() {
650        for handle in [
651            "",
652            "alice",
653            "@alice",
654            "@social.example",
655            "alice@",
656            "al ice@social.example",
657            "alice@social example",
658            "alice@social.example/path",
659            "alice@social.example?x",
660            "alice@social.example#x",
661            "alice@evil.example\\@social.example",
662            "alice@bücher.example",
663            "alice@-social.example",
664            "alice@social..example",
665            "alice@social.example:https",
666            "alice@[not-an-address]",
667            "alice/x@social.example",
668        ] {
669            assert_eq!(Address::parse(handle), None, "{handle:?}");
670        }
671    }
672
673    #[test]
674    fn the_resource_is_encoded_in_the_query() {
675        let address = Address::parse("al+ice@social.example").unwrap();
676        assert_eq!(
677            address.webfinger_url().as_str(),
678            "https://social.example/.well-known/webfinger?resource=acct:al%2Bice@social.example"
679        );
680    }
681
682    #[test]
683    fn a_jrd_names_its_actors_by_their_self_links() {
684        let found = Found::from_jrd(&json!({
685            "subject": "acct:lemmy@lemmy.example",
686            "links": [
687                {"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://lemmy.example/u/lemmy"},
688                {
689                    "rel": "self",
690                    "type": "application/activity+json",
691                    "href": "https://lemmy.example/u/lemmy",
692                    "properties": {"https://www.w3.org/ns/activitystreams#type": "Person"}
693                },
694                {
695                    "rel": "self",
696                    "type": "application/activity+json",
697                    "href": "https://lemmy.example/c/lemmy",
698                    "properties": {"https://www.w3.org/ns/activitystreams#type": "Group"}
699                },
700                {"rel": "self", "type": "application/activity+json", "href": "https://elsewhere.example/x", "properties": null},
701                {"rel": "self", "type": "text/html", "href": "https://lemmy.example/html"},
702                {"rel": "self", "type": "application/activity+json", "href": "javascript:alert(1)"}
703            ]
704        }));
705        assert_eq!(found.subject.as_deref(), Some("acct:lemmy@lemmy.example"));
706        assert_eq!(found.actors.len(), 3);
707        assert_eq!(found.actors[2].kind, None);
708        assert_eq!(
709            found.actor(None).map(Url::as_str),
710            Some("https://lemmy.example/u/lemmy")
711        );
712        assert_eq!(
713            found.actor(Some("Group")).map(Url::as_str),
714            Some("https://lemmy.example/c/lemmy")
715        );
716        assert_eq!(
717            found.actor(Some("Service")).map(Url::as_str),
718            Some("https://lemmy.example/u/lemmy")
719        );
720        assert!(
721            Found::from_jrd(&json!({"subject": "acct:x@y"}))
722                .actors
723                .is_empty()
724        );
725    }
726
727    /// Answers by handle, as hosts would give them.
728    async fn resolve_from(
729        asked: &str,
730        answers: &[(&str, Value)],
731    ) -> Result<Resolved, ResolveError> {
732        let answers: std::collections::HashMap<String, Found> = answers
733            .iter()
734            .map(|(handle, jrd)| ((*handle).to_owned(), Found::from_jrd(jrd)))
735            .collect();
736        resolve_with(&Address::parse(asked).unwrap(), |address| {
737            let found = answers
738                .get(&address.to_string())
739                .cloned()
740                .ok_or(FetchError::Status(404));
741            async move { found }
742        })
743        .await
744    }
745
746    fn jrd(subject: &str, actor: &str) -> Value {
747        json!({
748            "subject": subject,
749            "links": [{"rel": "self", "type": "application/activity+json", "href": actor}],
750        })
751    }
752
753    #[tokio::test]
754    async fn a_handle_resolves_to_the_actor_its_host_names() {
755        let resolved = resolve_from(
756            "alice@social.example",
757            &[(
758                "alice@social.example",
759                jrd(
760                    "acct:Alice@social.example",
761                    "https://social.example/users/alice",
762                ),
763            )],
764        )
765        .await
766        .unwrap();
767        assert_eq!(resolved.address.user(), "Alice", "as the host spells it");
768        assert_eq!(
769            resolved.actor.as_str(),
770            "https://social.example/users/alice"
771        );
772    }
773
774    #[tokio::test]
775    async fn one_redirect_is_followed_and_has_to_name_itself() {
776        let redirected = jrd("acct:alice@social.example", "https://example.com/ignored");
777        let resolved = resolve_from(
778            "alice@example.com",
779            &[
780                ("alice@example.com", redirected.clone()),
781                (
782                    "alice@social.example",
783                    jrd(
784                        "acct:alice@social.example",
785                        "https://social.example/users/alice",
786                    ),
787                ),
788            ],
789        )
790        .await
791        .unwrap();
792        assert_eq!(resolved.address.to_string(), "alice@social.example");
793        assert_eq!(
794            resolved.actor.as_str(),
795            "https://social.example/users/alice"
796        );
797
798        let error = resolve_from(
799            "alice@example.com",
800            &[
801                ("alice@example.com", redirected),
802                (
803                    "alice@social.example",
804                    jrd("acct:alice@third.example", "https://third.example/alice"),
805                ),
806            ],
807        )
808        .await
809        .unwrap_err();
810        assert!(
811            matches!(error, ResolveError::TooManyRedirects { .. }),
812            "{error}"
813        );
814    }
815
816    #[tokio::test]
817    async fn an_answer_missing_a_part_or_gone_is_an_error() {
818        let error = resolve_from(
819            "alice@social.example",
820            &[("alice@social.example", json!({"links": []}))],
821        )
822        .await
823        .unwrap_err();
824        assert!(matches!(error, ResolveError::NoSubject { .. }));
825        let error = resolve_from(
826            "alice@social.example",
827            &[(
828                "alice@social.example",
829                json!({"subject": "acct:alice@social.example"}),
830            )],
831        )
832        .await
833        .unwrap_err();
834        assert!(matches!(error, ResolveError::NoActor { .. }));
835        let error = resolve_from(
836            "alice@social.example",
837            &[(
838                "alice@social.example",
839                jrd("acct:a@b@social.example", "https://social.example/a"),
840            )],
841        )
842        .await
843        .unwrap_err();
844        assert!(matches!(error, ResolveError::BadSubject { .. }));
845        let error = resolve_from("alice@social.example", &[]).await.unwrap_err();
846        assert_eq!(error.status(), Some(404));
847    }
848
849    #[test]
850    fn an_onion_service_is_asked_over_http() {
851        let url = Address::parse("alice@abcdef.onion")
852            .unwrap()
853            .webfinger_url();
854        assert_eq!(url.scheme(), "http");
855        let url = Address::parse("alice@social.example")
856            .unwrap()
857            .webfinger_url();
858        assert_eq!(url.scheme(), "https");
859    }
860
861    #[test]
862    fn an_acct_is_split_like_ruby_splits_it() {
863        assert_eq!(
864            split_acct("acct:alice@example.com"),
865            ("alice".into(), "example.com".into())
866        );
867        assert_eq!(split_acct("alice"), ("alice".into(), String::new()));
868        assert_eq!(
869            split_acct("a@b@c"),
870            ("a".into(), "b".into()),
871            "only the first two parts count"
872        );
873    }
874
875    #[tokio::test]
876    async fn a_confirmed_handle_has_to_name_the_actor() {
877        let answers = [(
878            "alice@social.example",
879            jrd(
880                "acct:alice@social.example",
881                "https://social.example/users/alice",
882            ),
883        )];
884        let lookup = |address: &Address| {
885            let found = answers
886                .iter()
887                .find(|(handle, _)| *handle == address.to_string())
888                .map(|(_, jrd)| Found::from_jrd(jrd))
889                .ok_or(FetchError::Status(404));
890            async move { found }
891        };
892        let address = Address::parse("alice@social.example").unwrap();
893        assert!(
894            confirm_with(&address, "https://social.example/users/alice", lookup)
895                .await
896                .is_ok()
897        );
898        let error = confirm_with(&address, "https://social.example/users/mallory", lookup)
899            .await
900            .unwrap_err();
901        assert!(
902            matches!(error, ResolveError::NotLoopingBack { .. }),
903            "{error}"
904        );
905    }
906
907    #[test]
908    fn host_meta_names_the_query_in_its_lrdd_link() {
909        let xrd = r#"<?xml version="1.0"?>
910<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
911  <Link rel="other" template="https://x.example/no"/>
912  <Link rel="lrdd" type="application/xrd+xml" template="https://x.example/wf?resource={uri}"/>
913</XRD>"#;
914        assert_eq!(
915            lrdd_template(xrd).unwrap(),
916            "https://x.example/wf?resource={uri}"
917        );
918        assert!(
919            lrdd_template("<XRD><Link rel=\"lrdd\" template=\"t\"/></XRD>").is_err(),
920            "no default namespace"
921        );
922        assert!(lrdd_template("<XRD xmlns=\"urn:x\"/>").is_err());
923        assert!(lrdd_template("not xml").is_err());
924        assert_eq!(
925            Address::parse("alice@abcdef.onion")
926                .unwrap()
927                .host_meta_url()
928                .as_str(),
929            "http://abcdef.onion/.well-known/host-meta"
930        );
931    }
932
933    #[tokio::test]
934    async fn confirming_reads_a_subject_by_its_first_two_parts() {
935        let answers = [(
936            "alice@social.example",
937            jrd(
938                "acct:alice@social.example@elsewhere.example",
939                "https://social.example/users/alice",
940            ),
941        )];
942        let lookup = |address: &Address| {
943            let found = answers
944                .iter()
945                .find(|(handle, _)| *handle == address.to_string())
946                .map(|(_, jrd)| Found::from_jrd(jrd))
947                .ok_or(FetchError::Status(404));
948            async move { found }
949        };
950        let address = Address::parse("alice@social.example").unwrap();
951        let confirmed = confirm_with(&address, "https://social.example/users/alice", lookup)
952            .await
953            .unwrap();
954        assert_eq!(confirmed.address.to_string(), "alice@social.example");
955        let error = resolve_with(&address, lookup).await.unwrap_err();
956        assert!(matches!(error, ResolveError::BadSubject { .. }), "{error}");
957    }
958}