1use 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
17pub const JRD_ACCEPT: &str = "application/jrd+json, application/json";
19
20pub const XRD_ACCEPT: &str = "application/xrd+xml, application/xml, text/xml";
22
23#[derive(Clone, Debug, Eq, Hash, PartialEq)]
25pub struct Address {
26 user: String,
27 host: String,
28}
29
30impl Address {
31 #[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 #[must_use]
80 pub fn user(&self) -> &str {
81 &self.user
82 }
83
84 #[must_use]
86 pub fn host(&self) -> &str {
87 &self.host
88 }
89
90 #[must_use]
92 pub fn acct(&self) -> String {
93 format!("acct:{}@{}", self.user, self.host)
94 }
95
96 #[must_use]
103 pub fn webfinger_url(&self) -> Url {
104 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 #[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
145pub 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
175fn 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
193fn 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#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct Named {
231 pub id: Url,
233 pub kind: Option<String>,
237}
238
239#[derive(Clone, Debug, Eq, PartialEq)]
241pub struct Found {
242 pub subject: Option<String>,
244 pub actors: Vec<Named>,
246}
247
248impl Found {
249 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
307pub struct Resolved {
308 pub address: Address,
310 pub actor: Url,
312 pub found: Found,
314}
315
316#[derive(Debug)]
318pub enum ResolveError {
319 Fetch(FetchError),
322 NoSubject { asked: String },
324 NoActor { asked: String },
326 BadSubject { asked: String, subject: String },
328 TooManyRedirects { asked: String, stopped_at: String },
331 NotLoopingBack { handle: String, actor: String },
334}
335
336impl ResolveError {
337 #[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
385pub 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#[derive(Clone, Copy)]
412enum Reading {
413 Strict,
416 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 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
449pub 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
482fn 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 pub async fn webfinger(&self, address: &Address) -> Result<Found, FetchError> {
530 let plain = |mut url: Url| {
531 if self.plain_http_webfinger() {
532 let _ = url.set_scheme("http");
534 }
535 url
536 };
537 match self.webfinger_at(&plain(address.webfinger_url())).await {
538 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 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 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 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 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 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}