Skip to main content

ojak_jsonld/
lib.rs

1//! JSON-LD term expansion for ActivityPub documents, over bundled contexts.
2#![no_std]
3//!
4//! ActivityPub is JSON-LD, which means a key is not a name but an abbreviation
5//! for an IRI, and the `@context` says which. Two servers can describe the same
6//! thing with different keys — `sensitive`, `as:sensitive`, or any term aliased
7//! to either — and a reader that matches on spelling reads one and drops the
8//! other. This crate resolves keys to the IRIs they stand for, and puts them
9//! back into ojak's own spelling.
10//!
11//! ~~~~
12//! use ojak_jsonld::{Registry, normalize};
13//! use serde_json::json;
14//!
15//! let registry = Registry::bundled();
16//! let document = json!({
17//!     "@context": ["https://www.w3.org/ns/activitystreams", {"s": "as:sensitive"}],
18//!     "type": "Note",
19//!     "id": "https://remote.example/notes/1",
20//!     "s": true
21//! });
22//! let normalized = normalize(&registry, &document).expect("normalize");
23//! // The alias is gone; the term ojak reads is there.
24//! assert_eq!(normalized.document()["sensitive"], json!(true));
25//! ~~~~
26//!
27//! # What this is not
28//!
29//! It is not a general JSON-LD processor, and does not try to be. It resolves
30//! only the contexts in `contexts/`, because resolution happens on inbound
31//! attacker-controlled documents and a loader that fetches what they name is a
32//! request-forgery primitive; see `contexts/README.md`. A context it does not
33//! ship is read as ActivityStreams, which every context the fediverse serves
34//! extends, so its standard terms mean what they always do and only the
35//! sender's own additions go unread; [`Processed::unresolved_contexts`] says
36//! which contexts those were. It rejects `@graph`,
37//! `@included` and `@reverse` outright, for reasons recorded on
38//! [`Error::RestructuringKeyword`]. It has no flattening, no framing, and no
39//! `@nest`, `@index` maps, `@id` maps or `@type` maps — none of which appear
40//! in ActivityPub traffic.
41//!
42//! It does turn a document into RDF and canonicalise it ([`rdf`]), because
43//! that is what a Linked Data Signature signs; see that module for how it is
44//! stricter than the reading path. A caller checking such a signature over a
45//! context it fetched, as Mastodon does, adds the fetched document to a
46//! registry ([`Registry::with`]); the fetching itself, and its rules, are the
47//! caller's.
48//!
49//! What it does cover is the JSON-LD 1.1 needed by the contexts the fediverse
50//! actually serves: term and compact-IRI expansion, `@vocab`, `@base`,
51//! `@language` and language maps, `@container` sets and lists, type coercion,
52//! and property- and type-scoped contexts. The last of those is not optional:
53//! `security/data-integrity/v1` hangs `proofValue` and `cryptosuite` off a
54//! scoped context on `DataIntegrityProof`, and those are the fields ojak
55//! verifies integrity proofs with.
56//!
57//! One simplification is worth knowing about when reading compacted output.
58//! JSON-LD compaction may split a single property across two terms, writing an
59//! untagged value under `content` and language-tagged ones under `contentMap`.
60//! Ojak picks one term for a property and writes every value under it. The
61//! result is valid JSON-LD carrying every value, and re-expands to the same
62//! thing, but it is not always the term a given server would have chosen.
63
64extern crate alloc;
65
66use alloc::{string::String, vec::Vec};
67use core::fmt;
68use serde_json::Value;
69
70mod compact;
71mod context;
72mod expand;
73pub mod rdf;
74mod registry;
75
76pub use context::{ContextCache, NoCache, ProcessedContext};
77pub use registry::Registry;
78
79use alloc::sync::Arc;
80use context::{ActiveContext, Session};
81
82/// The context ojak compacts to, and emits.
83const OJAK_CONTEXT: &str = include_str!("../contexts/ojak.jsonld");
84
85/// Ceilings on the work one document may cause.
86///
87/// A document arrives from anyone who can reach an inbox, and every one of
88/// these bounds an amount of work that is otherwise a function of what the
89/// sender wrote. The defaults are generous for real traffic: the largest
90/// Mastodon actor observed uses a depth of 5 and some 60 nodes.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub struct Limits {
93    /// How deeply values may nest. Matches Fedify's traversal limit.
94    pub max_depth: usize,
95    /// How many JSON objects one document may contain.
96    pub max_nodes: usize,
97    /// How many `@context` entries one document may cause to be processed,
98    /// counting each element of every array and each bundled document reached.
99    pub max_contexts: usize,
100    /// How many terms one active context may define.
101    pub max_terms: usize,
102}
103
104impl Default for Limits {
105    fn default() -> Self {
106        Self {
107            max_depth: 64,
108            max_nodes: 10_000,
109            max_contexts: 64,
110            max_terms: 4_096,
111        }
112    }
113}
114
115/// Why a document could not be processed.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub enum Error {
118    /// Nesting past [`Limits::max_depth`].
119    DepthExceeded,
120    /// More objects than [`Limits::max_nodes`].
121    NodeBudgetExceeded,
122    /// More context entries than [`Limits::max_contexts`].
123    ContextBudgetExceeded,
124    /// More terms in one context than [`Limits::max_terms`].
125    TermBudgetExceeded,
126    /// A context, or a term definition, that refers back to itself.
127    CyclicContext(String),
128    /// A `@context` value that is not an IRI, a term map, an array of those,
129    /// or null.
130    InvalidContext,
131    /// A term definition JSON-LD does not allow.
132    InvalidTermDefinition(String),
133    /// A document using `@graph`, `@included` or `@reverse`, or a term aliased
134    /// to one of them.
135    ///
136    /// These let a single RDF graph be written as several different trees.
137    /// Because a Linked Data Signature covers the graph rather than the tree,
138    /// a signed activity can be restructured — its `object` promoted to the top
139    /// level under `@graph`, or properties hidden behind `@included` — so that
140    /// every tree-reading implementation reads something the signer did not
141    /// say, with the signature still verifying. That is GHSA-9rfg-v8g9-9367,
142    /// disclosed across several fediverse projects in 2025 with the
143    /// recommendation that implementations reject all three; ojak does.
144    RestructuringKeyword(String),
145    /// A context the registry does not hold — one ojak does not ship, and
146    /// the caller did not add — met where guessing is not good enough:
147    /// turning a document into RDF ([`rdf`]), which a signature covers.
148    UnresolvedContext(String),
149    /// Something JSON-LD allows that ojak does not turn into RDF.
150    Unsupported(String),
151    /// Labelling the blank nodes of a dataset took more work than
152    /// [`rdf::canonicalize`] allows one dataset.
153    CanonicalizationBudgetExceeded,
154}
155
156impl fmt::Display for Error {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::DepthExceeded => f.write_str("document nests too deeply"),
160            Self::NodeBudgetExceeded => f.write_str("document has too many objects"),
161            Self::ContextBudgetExceeded => f.write_str("document processes too many contexts"),
162            Self::TermBudgetExceeded => f.write_str("context defines too many terms"),
163            Self::CyclicContext(name) => write!(f, "cyclic JSON-LD context or term: {name}"),
164            Self::InvalidContext => f.write_str("invalid @context value"),
165            Self::InvalidTermDefinition(term) => write!(f, "invalid term definition: {term}"),
166            Self::RestructuringKeyword(keyword) => {
167                write!(f, "refusing graph-restructuring keyword: {keyword}")
168            }
169            Self::UnresolvedContext(iri) => write!(f, "context not resolved: {iri}"),
170            Self::Unsupported(what) => write!(f, "not supported in RDF: {what}"),
171            Self::CanonicalizationBudgetExceeded => {
172                f.write_str("canonicalizing the dataset takes too much work")
173            }
174        }
175    }
176}
177
178impl core::error::Error for Error {}
179
180/// A processed document, and what could not be resolved while processing it.
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct Processed {
183    document: Value,
184    unresolved: Vec<String>,
185}
186
187impl Processed {
188    /// The processed document.
189    #[must_use]
190    pub fn document(&self) -> &Value {
191        &self.document
192    }
193
194    /// The processed document, by value.
195    #[must_use]
196    pub fn into_document(self) -> Value {
197        self.document
198    }
199
200    /// Context IRIs the document named that ojak does not ship.
201    ///
202    /// Not an error: their terms simply did not expand, and any key relying on
203    /// them was dropped. Worth logging when a peer's documents come back
204    /// emptier than expected — it usually means a new extension context.
205    #[must_use]
206    pub fn unresolved_contexts(&self) -> &[String] {
207        &self.unresolved
208    }
209}
210
211/// Expand a document: every key becomes the IRI it stands for.
212///
213/// The result is the JSON-LD expanded form — an array of node objects, with
214/// every value wrapped as `{"@value": …}` or `{"@id": …}`. This is the form to
215/// compare two documents in, and the form [`compact`] reads.
216pub fn expand(registry: &Registry, document: &Value) -> Result<Processed, Error> {
217    expand_with(registry, document, Limits::default())
218}
219
220/// [`expand`], with limits of the caller's choosing.
221pub fn expand_with(
222    registry: &Registry,
223    document: &Value,
224    limits: Limits,
225) -> Result<Processed, Error> {
226    let mut session = Session::new(registry, limits);
227    let expanded = expand::expand_document(&ActiveContext::default(), document, &mut session)?;
228    Ok(Processed {
229        document: expanded,
230        unresolved: session.unresolved,
231    })
232}
233
234/// Compact an expanded document against `context`.
235///
236/// `context` is a `@context` value — an IRI the registry knows, a term map, or
237/// an array of those.
238pub fn compact(registry: &Registry, expanded: &Value, context: &Value) -> Result<Processed, Error> {
239    compact_with(registry, expanded, context, Limits::default())
240}
241
242/// [`compact`], with limits of the caller's choosing.
243pub fn compact_with(
244    registry: &Registry,
245    expanded: &Value,
246    context: &Value,
247    limits: Limits,
248) -> Result<Processed, Error> {
249    let mut session = Session::new(registry, limits);
250    let active = context::process(
251        &ActiveContext::default(),
252        context,
253        &mut session,
254        &mut Vec::new(),
255    )?;
256    let compacted = compact::compact_document(&active, expanded, context, &mut session)?;
257    Ok(Processed {
258        document: compacted,
259        unresolved: session.unresolved,
260    })
261}
262
263/// Read a document however it was written, and hand it back in ojak's own
264/// spelling.
265///
266/// This is expansion followed by compaction against ojak's context: whatever
267/// aliases, prefixes and extension vocabularies the sender used are resolved to
268/// IRIs and then written back with the terms ojak's vocabulary types expect.
269/// A term from a context ojak does not ship survives as its full IRI rather
270/// than as the sender's abbreviation, so nothing is silently renamed.
271pub fn normalize(registry: &Registry, document: &Value) -> Result<Processed, Error> {
272    normalize_with(registry, document, Limits::default())
273}
274
275/// [`normalize`], with limits of the caller's choosing.
276pub fn normalize_with(
277    registry: &Registry,
278    document: &Value,
279    limits: Limits,
280) -> Result<Processed, Error> {
281    normalize_with_cache(registry, document, limits, &NoCache)
282}
283
284/// [`normalize_with`], keeping processed contexts in `cache`.
285///
286/// The result is the same as without one. What changes is the cost: a
287/// document whose `@context` has been seen before, as nearly every one a
288/// server receives has, is not made to process it again, and nor is ojak's
289/// own context, which every document is compacted against.
290pub fn normalize_with_cache(
291    registry: &Registry,
292    document: &Value,
293    limits: Limits,
294    cache: &dyn ContextCache,
295) -> Result<Processed, Error> {
296    let mut session = Session::with_cache(registry, cache, limits);
297    let expanded = expand::expand_document(&ActiveContext::default(), document, &mut session)?;
298    let unresolved = session.unresolved;
299
300    let ojak = ojak_context_processed(registry, limits, cache)?;
301    let mut session = Session::with_cache(registry, cache, limits);
302    session.contexts = ojak.charged;
303    let context = ojak
304        .source
305        .as_ref()
306        .expect("ojak's context is kept with its source");
307    let compacted = compact::compact_document(&ojak.active, &expanded, context, &mut session)?;
308    Ok(Processed {
309        document: compacted,
310        unresolved,
311    })
312}
313
314/// Ojak's own context, processed: from `cache` when it has been before.
315fn ojak_context_processed(
316    registry: &Registry,
317    limits: Limits,
318    cache: &dyn ContextCache,
319) -> Result<Arc<ProcessedContext>, Error> {
320    let key = alloc::format!(
321        "\u{0}ojak\u{0}{}\u{0}{}",
322        limits.max_contexts,
323        limits.max_terms
324    );
325    if let Some(cached) = cache.get(&key) {
326        return Ok(cached);
327    }
328    let context = ojak_context();
329    let mut session = Session::new(registry, limits);
330    let active = context::process(
331        &ActiveContext::default(),
332        &context,
333        &mut session,
334        &mut Vec::new(),
335    )?;
336    let processed = Arc::new(ProcessedContext {
337        active,
338        charged: session.contexts,
339        unresolved: session.unresolved,
340        source: Some(context),
341    });
342    cache.put(key, processed.clone());
343    Ok(processed)
344}
345
346/// The `@context` value ojak emits and compacts to.
347#[must_use]
348pub fn ojak_context() -> Value {
349    let document: Value =
350        serde_json::from_str(OJAK_CONTEXT).expect("bundled ojak context is valid JSON");
351    document
352        .get("@context")
353        .cloned()
354        .expect("bundled ojak context has an @context member")
355}