Skip to main content

ojak_jsonld/
registry.rs

1//! The set of JSON-LD contexts ojak can resolve.
2//!
3//! There is no document loader here, and that is the point. Context
4//! resolution runs on inbound, attacker-controlled documents, before any
5//! signature has been checked, so a loader that fetches the IRIs those
6//! documents name is a request-forgery primitive reachable by anyone who can
7//! post to an inbox. This crate resolves what it ships and what its caller
8//! adds with [`Registry::with`], and nothing else.
9//!
10//! A caller that does fetch — `ojak::contexts`, which checks a Linked Data
11//! Signature over a context the signer named as Mastodon does — asks
12//! [`crate::rdf::unresolved_contexts`] what is missing, fetches it under its
13//! own rules, and adds it to a copy of the registry; the copy shares the
14//! bundled documents rather than parsing or cloning them again.
15//!
16//! See `contexts/README.md` for where each document came from and why.
17
18use alloc::{
19    borrow::ToOwned,
20    collections::BTreeMap,
21    string::{String, ToString},
22    sync::Arc,
23    vec::Vec,
24};
25use serde_json::Value;
26
27/// A bundled context: the IRI documents refer to it by, and its JSON text.
28struct Bundled {
29    iri: &'static str,
30    /// Extra IRIs that name the same document. Servers are inconsistent about
31    /// the scheme and the trailing slash, and a near-miss would otherwise drop
32    /// every term the document defines.
33    aliases: &'static [&'static str],
34    document: &'static str,
35}
36
37const BUNDLED: &[Bundled] = &[
38    Bundled {
39        iri: "https://www.w3.org/ns/activitystreams",
40        aliases: &[
41            "http://www.w3.org/ns/activitystreams",
42            "https://www.w3.org/ns/activitystreams#",
43        ],
44        document: include_str!("../contexts/activitystreams.jsonld"),
45    },
46    Bundled {
47        iri: "https://w3id.org/security/v1",
48        aliases: &["http://w3id.org/security/v1"],
49        document: include_str!("../contexts/security-v1.jsonld"),
50    },
51    // What a Linked Data Signature's options are read against. Its home,
52    // web-payments.org, no longer answers; Mastodon ships a copy.
53    Bundled {
54        iri: "https://w3id.org/identity/v1",
55        aliases: &["http://w3id.org/identity/v1"],
56        document: include_str!("../contexts/identity-v1.jsonld"),
57    },
58    Bundled {
59        iri: "https://w3id.org/security/data-integrity/v1",
60        aliases: &[],
61        document: include_str!("../contexts/security-data-integrity-v1.jsonld"),
62    },
63    Bundled {
64        iri: "https://w3id.org/security/data-integrity/v2",
65        // Where w3id.org redirects to.
66        aliases: &["https://www.w3.org/2025/credentials/vcdi/context/v2.jsonld"],
67        document: include_str!("../contexts/security-data-integrity-v2.jsonld"),
68    },
69    Bundled {
70        iri: "https://w3id.org/security/multikey/v1",
71        aliases: &[],
72        document: include_str!("../contexts/security-multikey-v1.jsonld"),
73    },
74    Bundled {
75        iri: "https://www.w3.org/ns/did/v1",
76        aliases: &[],
77        document: include_str!("../contexts/did-v1.jsonld"),
78    },
79    Bundled {
80        iri: "https://www.w3.org/ns/cid/v1",
81        aliases: &[],
82        document: include_str!("../contexts/cid-v1.jsonld"),
83    },
84    Bundled {
85        iri: "https://gotosocial.org/ns",
86        aliases: &["https://gotosocial.org/ns#"],
87        document: include_str!("../contexts/gotosocial.jsonld"),
88    },
89    Bundled {
90        iri: "https://litepub.social/litepub/context.jsonld",
91        aliases: &["http://litepub.social/litepub/context.jsonld"],
92        document: include_str!("../contexts/litepub.jsonld"),
93    },
94    Bundled {
95        iri: "https://purl.archive.org/socialweb/webfinger",
96        aliases: &[],
97        document: include_str!("../contexts/webfinger.jsonld"),
98    },
99    Bundled {
100        iri: "https://w3id.org/fep/ef61",
101        aliases: &[],
102        document: include_str!("../contexts/fep-ef61.jsonld"),
103    },
104    Bundled {
105        iri: "https://w3id.org/fep/7aa9",
106        aliases: &[],
107        document: include_str!("../contexts/fep-7aa9.jsonld"),
108    },
109    Bundled {
110        iri: "https://w3id.org/fep/22cd",
111        aliases: &[],
112        document: include_str!("../contexts/fep-22cd.jsonld"),
113    },
114    // Served as `application/json` with no JSON-LD `Link` header, so a
115    // conforming loader would not treat it as a context even after fetching it.
116    Bundled {
117        iri: "https://join-lemmy.org/context.json",
118        aliases: &[],
119        document: include_str!("../contexts/join-lemmy.jsonld"),
120    },
121    // Has never resolved; Mastodon inlines these terms instead. Bundled because
122    // some implementations put the bare namespace IRI in `@context` anyway.
123    Bundled {
124        iri: "http://joinmastodon.org/ns",
125        aliases: &["https://joinmastodon.org/ns", "http://joinmastodon.org/ns#"],
126        document: include_str!("../contexts/joinmastodon.jsonld"),
127    },
128];
129
130/// The contexts available to a processing run.
131///
132/// Parsing the bundled documents costs a few hundred microseconds, so build one
133/// registry and keep it rather than building one per document. A clone
134/// shares those documents, so a clone given a few more with
135/// [`Registry::with`] costs only what was added.
136#[derive(Clone, Debug)]
137pub struct Registry {
138    /// What the registry was built with, shared between its clones.
139    base: Arc<BTreeMap<String, Value>>,
140    /// What [`Registry::with`] added since, which takes precedence.
141    added: BTreeMap<String, Value>,
142}
143
144impl Default for Registry {
145    fn default() -> Self {
146        Self::bundled()
147    }
148}
149
150impl Registry {
151    /// Every context ojak ships, and nothing else.
152    ///
153    /// # Panics
154    ///
155    /// Never, for the bundled documents: they are parsed by a test.
156    #[must_use]
157    pub fn bundled() -> Self {
158        let mut documents = BTreeMap::new();
159        for entry in BUNDLED {
160            let parsed: Value = serde_json::from_str(entry.document)
161                .expect("bundled context document is valid JSON");
162            for iri in core::iter::once(entry.iri).chain(entry.aliases.iter().copied()) {
163                documents.insert(iri.to_owned(), parsed.clone());
164            }
165        }
166        Self {
167            base: Arc::new(documents),
168            added: BTreeMap::new(),
169        }
170    }
171
172    /// An empty registry, resolving nothing.
173    ///
174    /// Useful for asking what a document means on its own terms: with no
175    /// registry every `@context` IRI goes unresolved and only inline term
176    /// definitions apply.
177    #[must_use]
178    pub fn empty() -> Self {
179        Self {
180            base: Arc::new(BTreeMap::new()),
181            added: BTreeMap::new(),
182        }
183    }
184
185    /// Add a context of the caller's own.
186    ///
187    /// The value is the whole context *document* — an object with an
188    /// `@context` member — matching what the IRI would have served.
189    #[must_use]
190    pub fn with(mut self, iri: impl Into<String>, document: Value) -> Self {
191        self.added.insert(iri.into(), document);
192        self
193    }
194
195    /// Whether `iri` names a document this registry holds.
196    #[must_use]
197    pub fn knows(&self, iri: &str) -> bool {
198        self.added.contains_key(iri) || self.base.contains_key(iri)
199    }
200
201    /// The `@context` member of the document registered under `iri`.
202    pub(crate) fn resolve(&self, iri: &str) -> Option<&Value> {
203        self.added
204            .get(iri)
205            .or_else(|| self.base.get(iri))
206            .and_then(|doc| doc.get("@context"))
207    }
208
209    /// Every IRI this registry answers to, including aliases.
210    #[must_use]
211    pub fn known_iris(&self) -> Vec<String> {
212        let mut iris: Vec<String> = self
213            .base
214            .keys()
215            .filter(|iri| !self.added.contains_key(*iri))
216            .chain(self.added.keys())
217            .map(ToString::to_string)
218            .collect();
219        iris.sort();
220        iris
221    }
222}