Skip to main content

ojak/
contexts.rs

1//! JSON-LD contexts ojak does not ship, fetched to check a Linked Data
2//! Signature as Mastodon checks one.
3//!
4//! An `RsaSignature2017` signs the RDF a document means, and what it means
5//! depends on every context it names. Mastodon turns a signed activity into
6//! RDF with the contexts it preloads and fetches any other
7//! (`JsonLdHelper#load_jsonld_context`): a GET asking for
8//! `application/ld+json`, taken only when it answers `200` with that type,
9//! read up to a megabyte, and kept in its cache for 30 days. [`fetch`] makes
10//! that request, [`fetch_cached`] keeps what it got in a [`KvStore`], and
11//! [`resolve`] finds what a document is missing and adds it to a
12//! [`Registry`].
13//!
14//! Nothing here runs unless an application asks: reading a document never
15//! fetches a context (see *ojak-jsonld*'s `contexts/README.md` for why), and
16//! the inbox fetches only to check the signature on an activity it has to
17//! take on one, when the application has given it a loader
18//! (`Builder::remote_contexts`). The fetch is the request-forgery surface
19//! that boundary exists to avoid, so it is bounded on every side: the
20//! guarded client refuses private addresses unless the application allows
21//! them and follows at most its configured redirects, a context is at most
22//! [`MAX_CONTEXT_BYTES`], and a document may cause at most
23//! [`Limits::max_remote`] fetches within [`Limits::timeout`] in all.
24
25use crate::client::{Client, RequestError};
26use crate::kv::{KvError, KvStore};
27use http::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue};
28use ojak_jsonld::Registry;
29use serde_json::Value;
30use std::fmt;
31use std::future::Future;
32use std::time::Duration;
33use url::Url;
34
35/// What a context is asked for as, and the only type it is taken as.
36pub const MEDIA_TYPE: &str = "application/ld+json";
37
38/// How long a fetched context is kept: Mastodon's `expires_in: 30.days`.
39pub const CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
40
41/// The largest context taken: `Request#body_with_limit`'s default.
42pub const MAX_CONTEXT_BYTES: usize = 1024 * 1024;
43
44/// Bounds on what one document may cause to be fetched.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Limits {
47    /// How many contexts one document may cause to be loaded, counting
48    /// those its contexts name in turn, which is also how deeply they may
49    /// nest. Every context the fediverse names but ojak does not ship is
50    /// one server's own, and an activity names at most a couple.
51    pub max_remote: usize,
52    /// How long loading them may take, all together. Each request is also
53    /// held to the client's own timeouts.
54    pub timeout: Duration,
55    /// The bounds on expanding the document, as *ojak-jsonld* applies them.
56    pub json_ld: ojak_jsonld::Limits,
57}
58
59impl Default for Limits {
60    fn default() -> Self {
61        Self {
62            max_remote: 8,
63            // `Request::TIMEOUT[:read_deadline]`.
64            timeout: Duration::from_secs(30),
65            json_ld: ojak_jsonld::Limits::default(),
66        }
67    }
68}
69
70/// Why a context could not be loaded, or a document's contexts resolved.
71#[derive(Debug)]
72pub enum Error {
73    /// The IRI is not an `http` or `https` URL.
74    InvalidIri(String),
75    /// The request was refused or failed: a private address, a response
76    /// past [`MAX_CONTEXT_BYTES`], a network failure.
77    Request(String, RequestError),
78    /// The server answered other than `200`.
79    Status(String, u16),
80    /// The server answered with a type other than [`MEDIA_TYPE`].
81    MediaType(String, Option<String>),
82    /// What came back is not a JSON object with an `@context` member.
83    NotAContext(String),
84    /// The application's loader failed.
85    Load(String, String),
86    /// The key-value store failed. [`fetch_cached`] reports a failing store
87    /// to its caller rather than failing with this.
88    Kv(KvError),
89    /// The document named more contexts to load than [`Limits::max_remote`].
90    TooMany(usize),
91    /// Loading took longer than [`Limits::timeout`].
92    TimedOut,
93    /// The document cannot be expanded.
94    JsonLd(ojak_jsonld::Error),
95}
96
97impl fmt::Display for Error {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::InvalidIri(iri) => write!(f, "not a context IRI ojak will fetch: {iri}"),
101            Self::Request(iri, error) => write!(f, "context {iri}: {error}"),
102            Self::Status(iri, status) => write!(f, "context {iri}: answered {status}"),
103            Self::MediaType(iri, Some(kind)) => {
104                write!(f, "context {iri}: served as {kind}, not {MEDIA_TYPE}")
105            }
106            Self::MediaType(iri, None) => write!(f, "context {iri}: served with no type"),
107            Self::NotAContext(iri) => write!(f, "context {iri}: not a JSON-LD context"),
108            Self::Load(iri, why) => write!(f, "context {iri}: {why}"),
109            Self::Kv(error) => write!(f, "context cache: {error}"),
110            Self::TooMany(limit) => {
111                write!(
112                    f,
113                    "names more than {limit} contexts ojak would have to fetch"
114                )
115            }
116            Self::TimedOut => f.write_str("fetching its contexts took too long"),
117            Self::JsonLd(error) => write!(f, "JSON-LD: {error}"),
118        }
119    }
120}
121
122impl std::error::Error for Error {}
123
124/// Fetch the context `iri` names, as `JsonLdHelper#load_jsonld_context`
125/// requests one: a GET asking for [`MEDIA_TYPE`], taken only when it
126/// answers `200` with that type, its body at most [`MAX_CONTEXT_BYTES`] and
127/// at most the client's own limit. What comes back is the body, which
128/// Mastodon keeps as it came.
129///
130/// # Errors
131///
132/// When the IRI is not one to fetch, the client refuses it or fails, or the
133/// response is not a context by those rules.
134pub async fn fetch(client: &Client, iri: &str) -> Result<String, Error> {
135    let url = Url::parse(iri).map_err(|_| Error::InvalidIri(iri.to_owned()))?;
136    if !matches!(url.scheme(), "http" | "https") {
137        return Err(Error::InvalidIri(iri.to_owned()));
138    }
139    let mut headers = HeaderMap::new();
140    headers.insert(ACCEPT, HeaderValue::from_static(MEDIA_TYPE));
141    let response = client
142        .get(&url, headers)
143        .await
144        .map_err(|error| Error::Request(iri.to_owned(), error))?;
145    if response.status != 200 {
146        return Err(Error::Status(iri.to_owned(), response.status));
147    }
148    // `res.mime_type`: the type without its parameters.
149    let kind = response
150        .headers
151        .get(CONTENT_TYPE)
152        .and_then(|value| value.to_str().ok())
153        .map(|value| {
154            value
155                .split(';')
156                .next()
157                .unwrap_or_default()
158                .trim()
159                .to_ascii_lowercase()
160        });
161    if kind.as_deref() != Some(MEDIA_TYPE) {
162        return Err(Error::MediaType(iri.to_owned(), kind));
163    }
164    if response.body.len() > MAX_CONTEXT_BYTES {
165        return Err(Error::Request(iri.to_owned(), RequestError::TooLarge));
166    }
167    String::from_utf8(response.body).map_err(|_| Error::NotAContext(iri.to_owned()))
168}
169
170/// [`fetch`], kept in `kv` for `ttl` (Mastodon keeps it for [`CACHE_TTL`])
171/// under `["jsonld", "context", iri]`, as `Rails.cache` keeps it under
172/// `jsonld:context:<iri>`. Only a context that was taken is kept.
173///
174/// The store is a cache, and a failing one is treated as `Rails.cache`
175/// treats a Redis it cannot reach: a read that fails is a miss, and a write
176/// that fails still returns what was fetched. `on_store_error` is told of
177/// each failure, for the application to log.
178///
179/// # Errors
180///
181/// As [`fetch`].
182pub async fn fetch_cached<K: KvStore>(
183    kv: &K,
184    client: &Client,
185    iri: &str,
186    ttl: Duration,
187    mut on_store_error: impl FnMut(KvError),
188) -> Result<String, Error> {
189    let key = ["jsonld", "context", iri];
190    match kv.get(&key).await {
191        Ok(Some(Value::String(body))) => return Ok(body),
192        Ok(_) => {}
193        Err(error) => on_store_error(error),
194    }
195    let body = fetch(client, iri).await?;
196    if let Err(error) = kv.set(&key, Value::String(body.clone()), Some(ttl)).await {
197        on_store_error(error);
198    }
199    Ok(body)
200}
201
202/// `registry` with every context `document` names that it does not hold,
203/// each loaded with `load` (the body [`fetch`] would return): what a Linked
204/// Data Signature on `document` has to be checked over. `None` when
205/// `registry` already holds every one, so a caller keeps using the registry,
206/// and any cache, it already has.
207///
208/// A context a loaded one names is loaded too. Every load counts against
209/// [`Limits::max_remote`], and all of them together against
210/// [`Limits::timeout`].
211///
212/// # Errors
213///
214/// When a context cannot be loaded or is not a context, when there are more
215/// than the limits allow, or when the document cannot be expanded.
216pub async fn resolve<F, Fut, E>(
217    registry: &Registry,
218    document: &Value,
219    limits: Limits,
220    load: F,
221) -> Result<Option<Registry>, Error>
222where
223    F: FnMut(String) -> Fut,
224    Fut: Future<Output = Result<String, E>>,
225    E: fmt::Display,
226{
227    tokio::time::timeout(
228        limits.timeout,
229        resolve_within(registry, document, limits, load),
230    )
231    .await
232    .unwrap_or(Err(Error::TimedOut))
233}
234
235async fn resolve_within<F, Fut, E>(
236    registry: &Registry,
237    document: &Value,
238    limits: Limits,
239    mut load: F,
240) -> Result<Option<Registry>, Error>
241where
242    F: FnMut(String) -> Fut,
243    Fut: Future<Output = Result<String, E>>,
244    E: fmt::Display,
245{
246    let mut extended: Option<Registry> = None;
247    let mut loaded = 0;
248    loop {
249        let current = extended.as_ref().unwrap_or(registry);
250        let missing = ojak_jsonld::rdf::unresolved_contexts(current, document, limits.json_ld)
251            .map_err(Error::JsonLd)?;
252        if missing.is_empty() {
253            return Ok(extended);
254        }
255        let mut next = current.clone();
256        for iri in missing {
257            if next.knows(&iri) {
258                continue;
259            }
260            loaded += 1;
261            if loaded > limits.max_remote {
262                return Err(Error::TooMany(limits.max_remote));
263            }
264            let body = load(iri.clone())
265                .await
266                .map_err(|error| Error::Load(iri.clone(), error.to_string()))?;
267            let parsed: Value =
268                serde_json::from_str(&body).map_err(|_| Error::NotAContext(iri.clone()))?;
269            if !parsed
270                .as_object()
271                .is_some_and(|members| members.contains_key("@context"))
272            {
273                return Err(Error::NotAContext(iri));
274            }
275            next = next.with(iri, parsed);
276        }
277        extended = Some(next);
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use serde_json::json;
285    use std::cell::RefCell;
286
287    fn signed_with(context: Value) -> Value {
288        json!({
289            "@context": context,
290            "id": "https://a.example/notes/1",
291            "type": "Note",
292            "content": "hello",
293        })
294    }
295
296    #[tokio::test]
297    async fn nothing_is_loaded_for_a_document_over_bundled_contexts() {
298        let registry = Registry::bundled();
299        let document = signed_with(json!("https://www.w3.org/ns/activitystreams"));
300        let resolved = resolve(&registry, &document, Limits::default(), |iri| async move {
301            Err::<String, _>(format!("asked for {iri}"))
302        })
303        .await
304        .unwrap();
305        assert!(resolved.is_none());
306    }
307
308    #[tokio::test]
309    async fn a_context_and_the_one_it_names_are_loaded() {
310        let registry = Registry::bundled();
311        let document = signed_with(json!([
312            "https://www.w3.org/ns/activitystreams",
313            "https://a.example/ns"
314        ]));
315        let asked = RefCell::new(Vec::new());
316        let resolved = resolve(&registry, &document, Limits::default(), |iri| {
317            asked.borrow_mut().push(iri.clone());
318            async move {
319                Ok::<_, String>(match iri.as_str() {
320                    "https://a.example/ns" => json!({"@context": [
321                        "https://a.example/more",
322                        {"mood": "https://a.example/ns#mood"}
323                    ]})
324                    .to_string(),
325                    _ => {
326                        json!({"@context": {"weather": "https://a.example/ns#weather"}}).to_string()
327                    }
328                })
329            }
330        })
331        .await
332        .unwrap()
333        .expect("contexts were added");
334        assert_eq!(
335            *asked.borrow(),
336            ["https://a.example/ns", "https://a.example/more"]
337        );
338        let canonical = ojak_jsonld::rdf::canonize(
339            &resolved,
340            &json!({
341                "@context": ["https://www.w3.org/ns/activitystreams", "https://a.example/ns"],
342                "id": "https://a.example/notes/1",
343                "mood": "sunny",
344                "weather": "fair",
345            }),
346        )
347        .unwrap();
348        assert!(canonical.contains("<https://a.example/ns#mood> \"sunny\""));
349        assert!(canonical.contains("<https://a.example/ns#weather> \"fair\""));
350    }
351
352    #[tokio::test]
353    async fn too_many_contexts_are_refused() {
354        let registry = Registry::bundled();
355        let names: Vec<Value> = (0..3)
356            .map(|n| Value::from(format!("https://a.example/ns/{n}")))
357            .collect();
358        let document = signed_with(Value::Array(names));
359        let limits = Limits {
360            max_remote: 2,
361            ..Limits::default()
362        };
363        let result = resolve(&registry, &document, limits, |_| async {
364            Ok::<_, String>(json!({"@context": {}}).to_string())
365        })
366        .await;
367        assert!(matches!(result, Err(Error::TooMany(2))), "{result:?}");
368    }
369
370    #[tokio::test]
371    async fn a_context_that_names_itself_in_a_ring_is_bounded() {
372        let registry = Registry::bundled();
373        let document = signed_with(json!("https://a.example/0"));
374        // Each names the next, forever.
375        let result = resolve(&registry, &document, Limits::default(), |iri| async move {
376            let n: u32 = iri.rsplit('/').next().unwrap().parse().unwrap();
377            Ok::<_, String>(json!({"@context": format!("https://a.example/{}", n + 1)}).to_string())
378        })
379        .await;
380        assert!(matches!(result, Err(Error::TooMany(8))), "{result:?}");
381    }
382
383    #[tokio::test]
384    async fn what_is_not_a_context_is_refused() {
385        let registry = Registry::bundled();
386        let document = signed_with(json!("https://a.example/ns"));
387        for body in ["not json", "[1, 2]", "{\"terms\": {}}"] {
388            let result = resolve(&registry, &document, Limits::default(), |_| async move {
389                Ok::<_, String>(body.to_owned())
390            })
391            .await;
392            assert!(
393                matches!(result, Err(Error::NotAContext(_))),
394                "{body}: {result:?}"
395            );
396        }
397        let result = resolve(&registry, &document, Limits::default(), |_| async {
398            Err::<String, _>("gone")
399        })
400        .await;
401        assert!(matches!(result, Err(Error::Load(_, _))), "{result:?}");
402    }
403
404    #[tokio::test]
405    async fn loading_is_held_to_a_deadline() {
406        let registry = Registry::bundled();
407        let document = signed_with(json!("https://a.example/ns"));
408        let limits = Limits {
409            timeout: Duration::from_millis(20),
410            ..Limits::default()
411        };
412        let result = resolve(&registry, &document, limits, |_| async {
413            tokio::time::sleep(Duration::from_secs(5)).await;
414            Ok::<_, String>(json!({"@context": {}}).to_string())
415        })
416        .await;
417        assert!(matches!(result, Err(Error::TimedOut)), "{result:?}");
418    }
419}