finger_protocol/
webfinger.rs1use std::collections::BTreeMap;
39
40use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
41use serde::{Deserialize, Serialize};
42
43pub const MEDIA_TYPE: &str = "application/jrd+json";
45
46pub const WELL_KNOWN_PATH: &str = "/.well-known/webfinger";
48
49const RESOURCE: &AsciiSet = &NON_ALPHANUMERIC
52 .remove(b'-')
53 .remove(b'.')
54 .remove(b'_')
55 .remove(b'~');
56
57pub fn acct(user: &str, host: &str) -> String {
64 format!("acct:{user}@{host}")
65}
66
67pub fn request_url(host: &str, resource: &str, rels: &[&str]) -> String {
73 let mut url = format!(
74 "https://{host}{WELL_KNOWN_PATH}?resource={}",
75 utf8_percent_encode(resource, RESOURCE)
76 );
77 for rel in rels {
78 url.push_str("&rel=");
79 url.push_str(&utf8_percent_encode(rel, RESOURCE).to_string());
80 }
81 url
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Jrd {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub subject: Option<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub aliases: Vec<String>,
94 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96 pub properties: BTreeMap<String, Option<String>>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub links: Vec<Link>,
100}
101
102impl Jrd {
103 pub fn link(&self, rel: &str) -> Option<&Link> {
105 self.links.iter().find(|link| link.rel == rel)
106 }
107
108 pub fn links_with(&self, rel: &str) -> impl Iterator<Item = &Link> {
110 self.links.iter().filter(move |link| link.rel == rel)
111 }
112}
113
114#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Link {
117 pub rel: String,
119 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
121 pub media_type: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub href: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub template: Option<String>,
129 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131 pub titles: BTreeMap<String, String>,
132 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134 pub properties: BTreeMap<String, Option<String>>,
135}
136
137pub fn parse(json: &str) -> Result<Jrd, serde_json::Error> {
139 serde_json::from_str(json)
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 const CAROL: &str = r#"{
148 "subject" : "acct:carol@example.com",
149 "aliases" : ["https://example.com/~carol/"],
150 "properties" : { "http://example.com/ns/role" : "employee", "http://x/nil" : null },
151 "links" : [
152 { "rel" : "http://webfinger.example/rel/profile-page",
153 "href" : "https://www.example.com/~carol/" },
154 { "rel" : "self", "type" : "application/activity+json",
155 "href" : "https://example.com/users/carol",
156 "titles" : { "en-us" : "Carol" } }
157 ]
158 }"#;
159
160 #[test]
161 fn an_account_uri_is_built_per_rfc_7565() {
162 assert_eq!(acct("bob", "example.com"), "acct:bob@example.com");
163 }
164
165 #[test]
166 fn the_resource_is_encoded_the_way_the_rfc_shows() {
167 let url = request_url("example.com", &acct("carol", "example.com"), &[]);
168 assert_eq!(
169 url,
170 "https://example.com/.well-known/webfinger?resource=acct%3Acarol%40example.com"
171 );
172 }
173
174 #[test]
175 fn rel_filters_append_in_order() {
176 let url = request_url("example.com", "acct:a@b", &["self", "http://x/y"]);
177 assert!(url.ends_with("&rel=self&rel=http%3A%2F%2Fx%2Fy"), "got {url}");
178 }
179
180 #[test]
181 fn a_jrd_round_trips_its_subject_aliases_and_links() {
182 let jrd = parse(CAROL).unwrap();
183 assert_eq!(jrd.subject.as_deref(), Some("acct:carol@example.com"));
184 assert_eq!(jrd.aliases, vec!["https://example.com/~carol/"]);
185 assert_eq!(jrd.links.len(), 2);
186 }
187
188 #[test]
189 fn a_link_is_found_by_relation_with_its_media_type() {
190 let jrd = parse(CAROL).unwrap();
191 let this = jrd.link("self").unwrap();
192 assert_eq!(this.media_type.as_deref(), Some("application/activity+json"));
193 assert_eq!(this.href.as_deref(), Some("https://example.com/users/carol"));
194 assert_eq!(this.titles.get("en-us").map(String::as_str), Some("Carol"));
195 }
196
197 #[test]
198 fn a_null_property_survives_as_a_present_key() {
199 let jrd = parse(CAROL).unwrap();
200 assert_eq!(jrd.properties.get("http://x/nil"), Some(&None));
201 assert_eq!(
202 jrd.properties.get("http://example.com/ns/role"),
203 Some(&Some("employee".to_string()))
204 );
205 }
206
207 #[test]
208 fn a_minimal_jrd_needs_only_links() {
209 let jrd = parse(r#"{"links":[{"rel":"self"}]}"#).unwrap();
210 assert_eq!(jrd.subject, None);
211 assert!(jrd.aliases.is_empty());
212 assert_eq!(jrd.links[0].rel, "self");
213 }
214
215 #[test]
216 fn a_link_without_a_rel_is_rejected() {
217 assert!(parse(r#"{"links":[{"href":"https://x/"}]}"#).is_err());
218 }
219}