Skip to main content

actpub_webfinger/
jrd.rs

1//! JSON Resource Descriptor (JRD) types as defined in
2//! [RFC 7033 §4.4](https://datatracker.ietf.org/doc/html/rfc7033#section-4.4).
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::rels;
10
11/// A `WebFinger` JSON Resource Descriptor (JRD).
12///
13/// JRDs are emitted by the `/.well-known/webfinger` endpoint to describe a
14/// resource identified by the `subject` field. Each JRD may declare
15/// [`aliases`](Self::aliases) for the same resource, scalar
16/// [`properties`](Self::properties) drawn from arbitrary URI schemes, and
17/// a list of [`links`](Self::links) to related resources.
18#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Jrd {
21    /// The URI of the resource described by this JRD.
22    pub subject: String,
23
24    /// Alternative URIs that also identify the subject.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub aliases: Vec<String>,
27
28    /// Scalar properties keyed by URI. Per RFC 7033 a property value may be
29    /// either a string or JSON `null`.
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub properties: BTreeMap<String, Option<String>>,
32
33    /// Links describing related resources.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub links: Vec<JrdLink>,
36}
37
38impl Jrd {
39    /// Returns a new [`JrdBuilder`] initialised with the given subject.
40    pub fn builder(subject: impl Into<String>) -> JrdBuilder {
41        JrdBuilder {
42            inner: Self {
43                subject: subject.into(),
44                ..Self::default()
45            },
46        }
47    }
48
49    /// Finds the first link with the given [`rel`](JrdLink::rel).
50    #[must_use]
51    pub fn find_link(&self, rel: &str) -> Option<&JrdLink> {
52        self.links.iter().find(|l| l.rel == rel)
53    }
54
55    /// Returns the `ActivityPub` actor link for this subject.
56    ///
57    /// The canonical form is `rel="self"` with
58    /// `type="application/activity+json"`. If no such link exists but one
59    /// with the JSON-LD profile media type does, that is returned instead.
60    ///
61    /// Media-type matching is performed against the bare
62    /// `type/subtype` prefix so a parameter-carrying header like
63    /// `application/ld+json; profile="…"` still matches, while
64    /// unrelated subtypes that happen to share a string prefix
65    /// (e.g. `application/ld+jsonx`) do not.
66    #[must_use]
67    pub fn activitypub_actor(&self) -> Option<&JrdLink> {
68        self.links
69            .iter()
70            .find(|l| {
71                l.rel == rels::SELF
72                    && matches!(
73                        l.media_type.as_deref(),
74                        Some(mt) if bare_media_type(mt).eq_ignore_ascii_case(rels::MEDIA_TYPE_ACTIVITYPUB)
75                    )
76            })
77            .or_else(|| {
78                self.links.iter().find(|l| {
79                    l.rel == rels::SELF
80                        && matches!(
81                            l.media_type.as_deref(),
82                            Some(mt) if bare_media_type(mt).eq_ignore_ascii_case("application/ld+json")
83                        )
84                })
85            })
86    }
87}
88
89/// Builder for [`Jrd`] produced by [`Jrd::builder`].
90#[derive(Debug)]
91pub struct JrdBuilder {
92    inner: Jrd,
93}
94
95impl JrdBuilder {
96    /// Appends an alias URI.
97    #[must_use]
98    pub fn alias(mut self, alias: impl Into<String>) -> Self {
99        self.inner.aliases.push(alias.into());
100        self
101    }
102
103    /// Appends a property.
104    #[must_use]
105    pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
106        self.inner.properties.insert(key.into(), value);
107        self
108    }
109
110    /// Appends a link.
111    #[must_use]
112    pub fn link(mut self, link: JrdLink) -> Self {
113        self.inner.links.push(link);
114        self
115    }
116
117    /// Finalises the [`Jrd`].
118    #[must_use]
119    pub fn build(self) -> Jrd {
120        self.inner
121    }
122}
123
124/// A link entry inside a [`Jrd`].
125///
126/// Per [RFC 7033 §4.4.4][rel], the `href` and `template` members are
127/// mutually exclusive: only one MUST be present in a given link. This
128/// invariant is checked at runtime by [`JrdLink::validate`] and asserted
129/// by [`JrdLinkBuilder`] in debug builds.
130///
131/// [rel]: https://datatracker.ietf.org/doc/html/rfc7033#section-4.4.4
132#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
133#[non_exhaustive]
134pub struct JrdLink {
135    /// Link relation (IANA registered name or URI).
136    pub rel: String,
137
138    /// Media type of the resource referenced by [`href`](Self::href).
139    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
140    pub media_type: Option<String>,
141
142    /// URI of the related resource.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub href: Option<Url>,
145
146    /// Localised titles keyed by BCP-47 language tag.
147    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148    pub titles: BTreeMap<String, String>,
149
150    /// Link-specific properties.
151    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152    pub properties: BTreeMap<String, Option<String>>,
153
154    /// URI template for links that synthesise a URI from parameters.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub template: Option<String>,
157}
158
159impl JrdLink {
160    /// Returns a new [`JrdLinkBuilder`].
161    pub fn builder(rel: impl Into<String>) -> JrdLinkBuilder {
162        JrdLinkBuilder {
163            inner: Self {
164                rel: rel.into(),
165                ..Self::default()
166            },
167        }
168    }
169
170    /// Checks that this link satisfies the RFC 7033 §4.4.4 invariants.
171    ///
172    /// Currently enforces mutual exclusion between [`href`](Self::href)
173    /// and [`template`](Self::template).
174    ///
175    /// # Errors
176    ///
177    /// Returns `Err` if both `href` and `template` are set.
178    pub const fn validate(&self) -> Result<(), &'static str> {
179        if self.href.is_some() && self.template.is_some() {
180            return Err("JRD link must not have both `href` and `template`");
181        }
182        Ok(())
183    }
184}
185
186/// Builder for [`JrdLink`] produced by [`JrdLink::builder`].
187#[derive(Debug)]
188pub struct JrdLinkBuilder {
189    inner: JrdLink,
190}
191
192impl JrdLinkBuilder {
193    /// Sets the MIME type (`type` property).
194    #[must_use]
195    pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
196        self.inner.media_type = Some(media_type.into());
197        self
198    }
199
200    /// Sets the `href` URL, clearing any previously-set `template`.
201    ///
202    /// Per RFC 7033 §4.4.4, the two fields are mutually exclusive, so
203    /// this setter atomically clears the other.
204    #[must_use]
205    pub fn href(mut self, href: Url) -> Self {
206        self.inner.href = Some(href);
207        self.inner.template = None;
208        self
209    }
210
211    /// Sets a localised title.
212    #[must_use]
213    pub fn title(mut self, lang: impl Into<String>, title: impl Into<String>) -> Self {
214        self.inner.titles.insert(lang.into(), title.into());
215        self
216    }
217
218    /// Sets a property.
219    #[must_use]
220    pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
221        self.inner.properties.insert(key.into(), value);
222        self
223    }
224
225    /// Sets the URI template, clearing any previously-set `href`.
226    ///
227    /// Per RFC 7033 §4.4.4, the two fields are mutually exclusive, so
228    /// this setter atomically clears the other.
229    #[must_use]
230    pub fn template(mut self, template: impl Into<String>) -> Self {
231        self.inner.template = Some(template.into());
232        self.inner.href = None;
233        self
234    }
235
236    /// Finalises the [`JrdLink`].
237    #[must_use]
238    pub fn build(self) -> JrdLink {
239        self.inner
240    }
241}
242
243/// Extracts the bare `type/subtype` prefix of a media-type string,
244/// stripping any RFC 6838 parameters.
245///
246/// `application/ld+json; profile="…"` → `"application/ld+json"`; a
247/// string without a `;` is returned as-is after trimming. Used by
248/// [`Jrd::activitypub_actor`] so a parameterised `type=` link still
249/// matches the canonical `WebFinger` media-type names without being
250/// fooled by unrelated subtypes that happen to share a string
251/// prefix (`application/ld+jsonx`).
252fn bare_media_type(mt: &str) -> &str {
253    mt.split(';').next().unwrap_or(mt).trim()
254}
255
256#[cfg(test)]
257mod tests {
258    use pretty_assertions::assert_eq;
259    use serde_json::json;
260
261    use super::*;
262
263    #[test]
264    fn jrd_serializes_only_set_fields() {
265        let jrd = Jrd::builder("acct:alice@example.com").build();
266        let v = serde_json::to_value(&jrd).unwrap();
267        assert_eq!(v, json!({ "subject": "acct:alice@example.com" }));
268    }
269
270    #[test]
271    fn mastodon_style_jrd_roundtrips() {
272        let raw = json!({
273            "subject": "acct:Gargron@mastodon.social",
274            "aliases": [
275                "https://mastodon.social/@Gargron",
276                "https://mastodon.social/users/Gargron"
277            ],
278            "links": [
279                {
280                    "rel": "http://webfinger.net/rel/profile-page",
281                    "type": "text/html",
282                    "href": "https://mastodon.social/@Gargron"
283                },
284                {
285                    "rel": "self",
286                    "type": "application/activity+json",
287                    "href": "https://mastodon.social/users/Gargron"
288                },
289                {
290                    "rel": "http://ostatus.org/schema/1.0/subscribe",
291                    "template": "https://mastodon.social/authorize_interaction?uri={uri}"
292                }
293            ]
294        });
295
296        let jrd: Jrd = serde_json::from_value(raw.clone()).unwrap();
297        assert_eq!(jrd.subject, "acct:Gargron@mastodon.social");
298        assert_eq!(jrd.aliases.len(), 2);
299        assert_eq!(jrd.links.len(), 3);
300
301        let actor = jrd.activitypub_actor().expect("has actor link");
302        assert_eq!(
303            actor.href.as_ref().map(Url::as_str),
304            Some("https://mastodon.social/users/Gargron")
305        );
306
307        let subscribe = jrd.find_link(rels::OSTATUS_SUBSCRIBE).unwrap();
308        assert!(subscribe.template.is_some());
309        assert!(subscribe.href.is_none());
310
311        let back = serde_json::to_value(&jrd).unwrap();
312        assert_eq!(back, raw);
313    }
314
315    #[test]
316    fn builder_round_trips_through_serde() {
317        let jrd = Jrd::builder("acct:alice@example.com")
318            .alias("https://example.com/@alice")
319            .link(
320                JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
321                    .href("https://example.com/users/alice".parse().unwrap())
322                    .media_type("application/activity+json")
323                    .build(),
324            )
325            .build();
326
327        let actor = jrd.activitypub_actor().unwrap();
328        assert_eq!(actor.rel, "self");
329        let json = serde_json::to_value(&jrd).unwrap();
330        let back: Jrd = serde_json::from_value(json).unwrap();
331        assert_eq!(back, jrd);
332    }
333
334    #[test]
335    fn property_with_null_value_roundtrips() {
336        // RFC 7033 §4.4.3 permits JSON `null` as a property value to
337        // indicate "known-absent" (as opposed to "unknown"), and this
338        // distinction must survive a roundtrip.
339        let raw = json!({
340            "subject": "acct:alice@example.com",
341            "properties": { "http://example/schema/foo": null }
342        });
343        let jrd: Jrd = serde_json::from_value(raw.clone()).expect("deserialise");
344        assert_eq!(
345            jrd.properties.get("http://example/schema/foo"),
346            Some(&None),
347            "null property must deserialise to Some(None), not None",
348        );
349        let back = serde_json::to_value(&jrd).expect("serialise");
350        assert_eq!(back, raw);
351    }
352
353    #[test]
354    fn jrd_link_validate_accepts_href_only() {
355        let link = JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
356            .href("https://example.com/a".parse().expect("valid URL"))
357            .build();
358        assert!(link.validate().is_ok());
359    }
360
361    #[test]
362    fn jrd_link_validate_accepts_template_only() {
363        let link = JrdLink::builder(rels::OSTATUS_SUBSCRIBE)
364            .template("https://example.com/subscribe?uri={uri}")
365            .build();
366        assert!(link.validate().is_ok());
367    }
368
369    #[test]
370    fn jrd_link_validate_rejects_both_href_and_template() {
371        // Construct an invalid link directly (the builder cannot produce
372        // this state) to verify the validator catches it.
373        let mut link = JrdLink::builder(rels::SELF).build();
374        link.href = Some("https://example.com/a".parse().expect("valid URL"));
375        link.template = Some("https://example.com/t?u={u}".to_owned());
376        assert!(
377            link.validate().is_err(),
378            "RFC 7033 §4.4.4 forbids both `href` and `template` on a single link",
379        );
380    }
381
382    #[test]
383    fn jrd_link_builder_href_after_template_clears_template() {
384        // The builder's exclusivity guarantee: setting `href` after
385        // `template` must drop the template to maintain the RFC 7033
386        // invariant; this keeps the resulting link valid by construction.
387        let link = JrdLink::builder(rels::SELF)
388            .template("https://example.com/t?u={u}")
389            .href("https://example.com/a".parse().expect("valid URL"))
390            .build();
391        assert!(link.template.is_none(), "template must be cleared");
392        assert!(link.href.is_some(), "href must be retained");
393        assert!(link.validate().is_ok());
394    }
395
396    #[test]
397    fn activitypub_actor_falls_back_to_ld_json_profile() {
398        // Some implementations (notably Lemmy older versions) emit the
399        // actor link using the full JSON-LD media type instead of the
400        // shorthand. The helper must find it either way.
401        let jrd: Jrd = serde_json::from_value(json!({
402            "subject": "acct:alice@example.com",
403            "links": [{
404                "rel": "self",
405                "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
406                "href": "https://example.com/users/alice"
407            }]
408        }))
409        .expect("JSON-LD profile JRD must parse");
410
411        let actor = jrd
412            .activitypub_actor()
413            .expect("should fall back to ld+json profile");
414        assert_eq!(
415            actor.href.as_ref().map(Url::as_str),
416            Some("https://example.com/users/alice"),
417        );
418    }
419
420    #[test]
421    fn find_link_returns_none_for_missing_rel() {
422        let jrd = Jrd::builder("acct:alice@example.com").build();
423        assert!(jrd.find_link("http://example.com/rel/missing").is_none());
424    }
425
426    #[test]
427    fn activitypub_actor_rejects_media_types_that_only_share_the_ld_json_prefix() {
428        // P1-N4 (sixth-round audit) regression: the earlier
429        // `starts_with("application/ld+json")` test matched bogus
430        // subtypes like `application/ld+jsonx` and
431        // `application/ld+jsonsomething`. The bare-media-type
432        // helper strips parameters before comparing the bare
433        // `type/subtype`, so only legitimate AS2.0 JSON-LD
434        // responses are recognised.
435        let jrd: Jrd = serde_json::from_value(json!({
436            "subject": "acct:alice@example.com",
437            "links": [{
438                "rel": "self",
439                // Attacker-supplied media type that a prefix match
440                // would have accepted as JSON-LD.
441                "type": "application/ld+jsonx",
442                "href": "https://example.com/attacker"
443            }]
444        }))
445        .expect("JRD must parse");
446        assert!(
447            jrd.activitypub_actor().is_none(),
448            "prefix-only media-type impersonation must NOT be accepted as the AP actor link",
449        );
450    }
451
452    #[test]
453    fn activitypub_actor_is_case_insensitive_on_media_type() {
454        // RFC 6838 makes media types case-insensitive; a peer
455        // emitting `APPLICATION/ACTIVITY+JSON` is still a valid
456        // AP actor link.
457        let jrd: Jrd = serde_json::from_value(json!({
458            "subject": "acct:alice@example.com",
459            "links": [{
460                "rel": "self",
461                "type": "Application/Activity+JSON",
462                "href": "https://example.com/users/alice"
463            }]
464        }))
465        .expect("JRD must parse");
466        let actor = jrd
467            .activitypub_actor()
468            .expect("case-insensitive media-type must be recognised");
469        assert_eq!(
470            actor.href.as_ref().map(Url::as_str),
471            Some("https://example.com/users/alice"),
472        );
473    }
474}