Skip to main content

jsonld_context_processing/algorithm/
mod.rs

1//! # Two implementations, one algorithm: keep `sync.rs` in step
2//!
3//! This module, together with `define.rs` and `iri.rs`, holds the `async`
4//! implementation of the [context processing algorithm][1] — the one that can
5//! call the [`Loader`] to fetch remote contexts and `@import`ed documents.
6//! `sync.rs`, in this same directory, holds a second, hand-maintained
7//! implementation of the *same* algorithm with `async`, `.await` and the boxed
8//! `Recursion` stripped out and recursion made direct. It is used as a fast path whenever
9//! `requires_loader` proves the input contains no remote `@context` IRI and no
10//! `@import`, which is the common case in real payloads.
11//!
12//! Which of the two runs is invisible to callers: for any input both must
13//! produce the same active context and the same error. **A change to the
14//! algorithm here therefore has to be mirrored in `sync.rs`, and a change there
15//! mirrored back here.** Nothing enforces this — the two are separate function
16//! bodies with no shared code, so a fix applied to only one silently makes the
17//! result depend on whether the input happened to reference a remote context.
18//! The mirrored pairs are:
19//!
20//! | this module | `sync.rs` |
21//! |---|---|
22//! | `process_context` (`mod.rs`) | `process_context_sync` |
23//! | `define` (`define.rs`) | `define_sync` |
24//! | `expand_iri_with` (`iri.rs`) | `expand_iri_with_sync` |
25//!
26//! `sync.rs` does guard the one class of divergence it can detect — reaching a
27//! code path that would need the loader — by returning
28//! [`Error::LoadingDocumentFailed`]. That protects against `requires_loader`
29//! drifting out of step with the algorithm body; it says nothing about the two
30//! implementations otherwise agreeing.
31//!
32//! [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
33
34use std::{hash::Hash, sync::Arc};
35
36use crate::{Error, Options, Process, Processed, ProcessingCache, ProcessingResult, ProcessingStack, WarningHandler, cache::cache_key};
37use iri_rs::{Iri, IriBuf};
38use jsonld_core::{Context, Dereference, Environment, ExtractContext, Loader, ProcessingMode, Term};
39use jsonld_syntax::{self as syntax, Nullable, native_stack::Recursion};
40use rdfx::vocabulary::VocabularyMut;
41
42mod define;
43mod iri;
44mod merged;
45mod sync;
46
47pub use define::*;
48pub use iri::*;
49pub use merged::*;
50use sync::{process_context_sync, requires_loader};
51use syntax::context::definition::KeyOrKeywordRef;
52
53/// Checks an `@vocab` value against the JSON-LD 1.0 rule.
54///
55/// 1.0 required the value to be an absolute IRI or a blank node identifier;
56/// document-relative resolution only arrived in 1.1. A bare `_:` prefix counts,
57/// as it is what makes `@vocab` map properties to blank nodes.
58pub(crate) fn is_valid_vocab_json_ld_1_0(value: &syntax::context::definition::Vocab) -> bool {
59    value.as_str().starts_with("_:") || Iri::new(value.as_str()).is_ok()
60}
61
62impl Process for syntax::context::Context {
63    async fn process_full<N, L, W>(
64        &self,
65        vocabulary: &mut N,
66        active_context: &Context<N::Iri, N::BlankId>,
67        loader: &L,
68        base_url: Option<N::Iri>,
69        options: Options,
70        mut warnings: W,
71    ) -> Result<Processed<'_, N::Iri, N::BlankId>, Error<L::Error>>
72    where
73        N: VocabularyMut,
74        N::Iri: Clone + Eq + Hash,
75        N::BlankId: Clone + PartialEq,
76        L: Loader,
77        W: WarningHandler<N>,
78    {
79        // Fast path: no remote `@context` IRIs and no `@import` anywhere in
80        // the tree means the entire algorithm runs synchronously — no
81        // boxed `Recursion` per level, no future state machine.
82        if !requires_loader(self) {
83            return process_context_sync(
84                Environment {
85                    vocabulary,
86                    loader,
87                    warnings: &mut warnings,
88                },
89                active_context,
90                self,
91                ProcessingStack::default(),
92                base_url,
93                options,
94                0,
95            );
96        }
97
98        process_context(
99            Environment {
100                vocabulary,
101                loader,
102                warnings: &mut warnings,
103            },
104            active_context,
105            self,
106            ProcessingStack::default(),
107            base_url,
108            options,
109            true,
110        )
111        .await
112    }
113
114    async fn process_full_with_cache<N, L, W>(
115        &self,
116        vocabulary: &mut N,
117        active_context: &Context<N::Iri, N::BlankId>,
118        loader: &L,
119        base_url: Option<N::Iri>,
120        options: Options,
121        mut warnings: W,
122        cache: &ProcessingCache<N::Iri, N::BlankId>,
123    ) -> Result<Processed<'_, N::Iri, N::BlankId>, Error<L::Error>>
124    where
125        N: VocabularyMut,
126        N::Iri: Clone + Eq + Hash,
127        N::BlankId: Clone + Eq + Hash,
128        L: Loader,
129        W: WarningHandler<N>,
130    {
131        let key = cache_key(active_context, self, base_url.as_ref(), options);
132
133        if let Some(cached) = cache.get(key, active_context, self, base_url.as_ref(), options) {
134            return Ok(Processed::new(self, (*cached).clone()));
135        }
136
137        // Retained for the cache entry: `base_url` is moved into processing.
138        let entry_active = active_context.clone();
139        let entry_base_url = base_url.clone();
140
141        let processed = if requires_loader(self) {
142            process_context(
143                Environment {
144                    vocabulary,
145                    loader,
146                    warnings: &mut warnings,
147                },
148                active_context,
149                self,
150                ProcessingStack::default(),
151                base_url,
152                options,
153                true,
154            )
155            .await?
156        } else {
157            process_context_sync(
158                Environment {
159                    vocabulary,
160                    loader,
161                    warnings: &mut warnings,
162                },
163                active_context,
164                self,
165                ProcessingStack::default(),
166                base_url,
167                options,
168                0,
169            )?
170        };
171
172        cache.insert(key, entry_active, self.clone(), entry_base_url, options, Arc::new(processed.processed.clone()));
173        Ok(processed)
174    }
175}
176
177/// Resolves `iri_ref` against `base_iri` and interns the result in `vocabulary`.
178///
179/// With no base IRI, `iri_ref` must already be an absolute IRI. Returns `None`
180/// when the reference cannot be turned into one either way.
181fn resolve_iri<V, I>(vocabulary: &mut V, iri_ref: iri_rs::IriRef<&str>, base_iri: Option<&I>) -> Option<I>
182where
183    V: rdfx::vocabulary::IriVocabularyMut<Iri = I>,
184{
185    if let Some(base_iri) = base_iri {
186        let base = vocabulary.iri(base_iri)?;
187        let resolved = iri_ref.resolved(&base).ok()?;
188        let iri_buf = IriBuf::try_from(resolved).ok()?;
189        Some(vocabulary.insert_owned(iri_buf))
190    } else {
191        let iri = Iri::try_from(iri_ref).ok()?;
192        Some(vocabulary.insert(iri))
193    }
194}
195
196/// Runs the [context processing algorithm][1], layering `local_context` on top
197/// of `active_context`.
198///
199/// Recurses for each remote `@context` IRI, fetching it through the loader, and
200/// for each term definition's scoped `@context` via `define`. `remote_contexts`
201/// carries the chain of remote context URLs entered so far, which bounds that
202/// recursion; a top-level call starts from an empty [`ProcessingStack`].
203///
204/// `validate_scoped_context` is the algorithm's input of the same name: `true`
205/// for a document's own `@context` and for contexts applied while expanding,
206/// `false` only when Create Term Definition validates a scoped `@context`
207/// (step 21.3). Only then is a context already in `remote_contexts` skipped
208/// (step 5.2.2); anywhere else a repeat is processed again, because JSON-LD
209/// processes the entries of a `@context` array in order.
210///
211/// Every mutation is applied to a clone of `active_context`, so the caller's
212/// context is left untouched.
213///
214/// This is the `async` half of a mirrored pair; see the module documentation
215/// before changing it.
216///
217/// [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
218async fn process_context<'l: 'a, 'a, N, L, W>(
219    mut env: Environment<'a, N, L, W>,
220    active_context: &'a Context<N::Iri, N::BlankId>,
221    local_context: &'l syntax::context::Context,
222    mut remote_contexts: ProcessingStack<N::Iri>,
223    base_url: Option<N::Iri>,
224    mut options: Options,
225    validate_scoped_context: bool,
226) -> ProcessingResult<'l, N::Iri, N::BlankId, L::Error>
227where
228    N: VocabularyMut,
229    N::Iri: Clone + Eq + Hash,
230    N::BlankId: Clone + PartialEq,
231    L: Loader,
232    W: WarningHandler<N>,
233{
234    // 1) Initialize result to the result of cloning active context.
235    let mut result = active_context.clone();
236    result.set_processing_mode(options.processing_mode);
237
238    // 2) If `local_context` is an object containing the member @propagate,
239    // its value MUST be boolean true or false, set `propagate` to that value.
240    if let syntax::context::Context::One(syntax::ContextEntry::Definition(def)) = local_context
241        && let Some(propagate) = def.propagate
242    {
243        if options.processing_mode == ProcessingMode::JsonLd1_0 {
244            return Err(Error::InvalidContextEntry);
245        }
246
247        options.propagate = propagate;
248    }
249
250    // 3) If propagate is false, and result does not have a previous context,
251    // set previous context in result to active context.
252    if !options.propagate && result.previous_context().is_none() {
253        result.set_previous_context(active_context.clone());
254    }
255
256    // 4) If local context is not an array, set it to an array containing only local context.
257    // 5) For each item context in local context:
258    for context in local_context {
259        match context {
260            // 5.1) If context is null:
261            syntax::ContextEntry::Null => {
262                // If `override_protected` is false and `active_context` contains any protected term
263                // definitions, an invalid context nullification has been detected and processing
264                // is aborted.
265                if !options.override_protected && result.has_protected_items() {
266                    return Err(Error::InvalidContextNullification);
267                }
268                // Otherwise, initialize result as a newly-initialized active context, setting
269                // previous_context in result to the previous value of result if propagate is
270                // false. Continue with the next context.
271                let previous_result = result;
272
273                // Initialize `result` as a newly-initialized active context, setting both
274                // `base_iri` and `original_base_url` to the value of `original_base_url` in
275                // active context, ...
276                result = Context::new(active_context.original_base_url().cloned());
277                result.set_processing_mode(options.processing_mode);
278
279                // ... and, if `propagate` is `false`, `previous_context` in `result` to the
280                // previous value of `result`.
281                if !options.propagate {
282                    result.set_previous_context(previous_result);
283                }
284            }
285
286            // 5.2) If context is a string,
287            syntax::ContextEntry::IriRef(iri_ref) => {
288                // Initialize `context` to the result of resolving context against base URL.
289                // If base URL is not a valid IRI, then context MUST be a valid IRI, otherwise
290                // a loading document failed error has been detected and processing is aborted.
291                let context_iri = resolve_iri(env.vocabulary, iri_ref.as_ref(), base_url.as_ref()).ok_or(Error::LoadingDocumentFailed)?;
292
293                // 5.2.2) If validate scoped context is false, and remote contexts already
294                // includes context do not process context further and continue to any next
295                // context in local context.
296                //
297                // JSON-LD 1.0 had no such allowance: a context already in remote contexts is a
298                // recursive context inclusion error, whatever the caller.
299                if remote_contexts.cycle(&context_iri) {
300                    if options.processing_mode == ProcessingMode::JsonLd1_0 {
301                        return Err(Error::RecursiveContextInclusion);
302                    }
303                    if !validate_scoped_context {
304                        continue;
305                    }
306                }
307
308                // 5.2.3) If the number of entries in the `remote_contexts` array exceeds a
309                // processor defined limit, a context overflow error has been detected and
310                // processing is aborted; otherwise, add context to remote contexts.
311                if remote_contexts.len() >= crate::MAX_REMOTE_CONTEXTS {
312                    return Err(Error::ContextOverflow);
313                }
314                remote_contexts.enter(context_iri.clone());
315
316                // 5.2.4) If context was previously dereferenced, then the processor MUST NOT do a
317                // further dereference, and context is set to the previously established internal
318                // representation: set `context_document` to the previously dereferenced document,
319                // and set loaded context to the value of the @context entry from the document in
320                // context document.
321                //
322                // 5.2.5) Otherwise, set `context document` to the RemoteDocument obtained by
323                // dereferencing context using the LoadDocumentCallback, passing context for url,
324                // and http://www.w3.org/ns/json-ld#context for profile and for requestProfile.
325                //
326                // If context cannot be dereferenced, or the document from context document cannot
327                // be transformed into the internal representation , a loading remote context
328                // failed error has been detected and processing is aborted.
329                // If the document has no top-level map with an @context entry, an invalid remote
330                // context has been detected and processing is aborted.
331                // Set loaded context to the value of that entry.
332                let loaded_context = if let Some(previously) = remote_contexts.dereferenced(&context_iri) {
333                    previously
334                } else {
335                    let loaded = Arc::new(
336                        env.loader
337                            .dereference(env.vocabulary, context_iri.clone())
338                            .await?
339                            .into_document()
340                            .into_ld_context()
341                            .map_err(Error::ContextExtractionFailed)?,
342                    );
343                    remote_contexts.remember_dereferenced(context_iri.clone(), Arc::clone(&loaded));
344                    loaded
345                };
346
347                // 5.2.6) Set result to the result of recursively calling this algorithm, passing
348                // result for active context, loaded context for local context, the documentUrl of
349                // context document for base URL, a copy of remote contexts, and validate scoped
350                // context.
351                let new_options = Options {
352                    processing_mode: options.processing_mode,
353                    override_protected: false,
354                    propagate: true,
355                    vocab: options.vocab,
356                };
357
358                let r = Recursion::new(process_context(
359                    Environment {
360                        vocabulary: env.vocabulary,
361                        loader: env.loader,
362                        warnings: env.warnings,
363                    },
364                    &result,
365                    &loaded_context,
366                    remote_contexts.clone(),
367                    Some(context_iri),
368                    new_options,
369                    validate_scoped_context,
370                ))
371                .await?;
372
373                result = r.into_processed();
374            }
375
376            // 5.4) Context definition.
377            syntax::ContextEntry::Definition(context) => {
378                // 5.5) If context has a @version entry:
379                if context.version.is_some() {
380                    // 5.5.2) If processing mode is set to json-ld-1.0, a processing mode conflict
381                    // error has been detected.
382                    if options.processing_mode == ProcessingMode::JsonLd1_0 {
383                        return Err(Error::ProcessingModeConflict);
384                    }
385                }
386
387                // 5.6) If context has an @import entry:
388                let import_context = match &context.import {
389                    Some(import_value) => {
390                        // 5.6.1) If processing mode is json-ld-1.0, an invalid context entry error
391                        // has been detected.
392                        if options.processing_mode == ProcessingMode::JsonLd1_0 {
393                            return Err(Error::InvalidContextEntry);
394                        }
395
396                        // 5.6.3) Initialize import to the result of resolving the value of
397                        // @import.
398                        let import = resolve_iri(env.vocabulary, import_value.as_ref(), base_url.as_ref()).ok_or(Error::InvalidImportValue)?;
399
400                        // 5.6.4) Dereference import.
401                        let import_context = env
402                            .loader
403                            .dereference(env.vocabulary, import.clone())
404                            .await?
405                            .into_document()
406                            .into_ld_context()
407                            .map_err(Error::ContextExtractionFailed)?;
408
409                        // If the dereferenced document has no top-level map with an @context
410                        // entry, or if the value of @context is not a context definition
411                        // (i.e., it is not an map), an invalid remote context has been
412                        // detected and processing is aborted; otherwise, set import context
413                        // to the value of that entry.
414                        match &import_context {
415                            syntax::context::Context::One(syntax::ContextEntry::Definition(import_context_def)) => {
416                                // If `import_context` has a @import entry, an invalid context entry
417                                // error has been detected and processing is aborted.
418                                if import_context_def.import.is_some() {
419                                    return Err(Error::InvalidContextEntry);
420                                }
421                            }
422                            _ => {
423                                return Err(Error::InvalidRemoteContext);
424                            }
425                        }
426
427                        // Set `context` to the result of merging context into
428                        // `import_context`, replacing common entries with those from
429                        // `context`.
430                        Some(import_context)
431                    }
432                    None => None,
433                };
434
435                let context = Merged::new(context, import_context);
436
437                // 5.7) If context has a @base entry and remote contexts is empty, i.e.,
438                // the currently being processed context is not a remote context:
439                if remote_contexts.is_empty() {
440                    // Initialize value to the value associated with the @base entry.
441                    if let Some(value) = context.base() {
442                        match value {
443                            syntax::Nullable::Null => {
444                                // If value is null, remove the base IRI of result.
445                                result.set_base_iri(None);
446                            }
447                            syntax::Nullable::Some(iri_ref) => {
448                                if let Ok(iri) = Iri::try_from(iri_ref.as_ref()) {
449                                    result.set_base_iri(Some(env.vocabulary.insert(iri)));
450                                } else {
451                                    let resolved = resolve_iri(env.vocabulary, iri_ref.as_ref(), result.base_iri()).ok_or(Error::InvalidBaseIri)?;
452                                    result.set_base_iri(Some(resolved));
453                                }
454                            }
455                        }
456                    }
457                }
458
459                // 5.8) If context has a @vocab entry:
460                // Initialize value to the value associated with the @vocab entry.
461                if let Some(value) = context.vocab() {
462                    match value {
463                        syntax::Nullable::Null => {
464                            // If value is null, remove any vocabulary mapping from result.
465                            result.set_vocabulary(None);
466                        }
467                        syntax::Nullable::Some(value) => {
468                            // Otherwise, if value is an IRI or blank node identifier, the
469                            // vocabulary mapping of result is set to the result of IRI
470                            // expanding value using true for document relative. If it is not
471                            // an IRI, or a blank node identifier, an invalid vocab mapping
472                            // error has been detected and processing is aborted.
473                            // NOTE: The use of blank node identifiers to value for @vocab is
474                            // obsolete, and may be removed in a future version of JSON-LD.
475                            //
476                            // Document-relative expansion of `@vocab` is a JSON-LD 1.1 addition:
477                            // 1.0 required an absolute IRI or blank node identifier, and rejects
478                            // `""` and `/relative` (`expand#t0115`, `expand#t0116`). A bare `_:`
479                            // prefix stays valid in 1.0 (`expand#t0075`).
480                            let json_ld_1_0 = options.processing_mode == ProcessingMode::JsonLd1_0;
481                            if json_ld_1_0 && !is_valid_vocab_json_ld_1_0(value) {
482                                return Err(Error::InvalidVocabMapping);
483                            }
484
485                            match expand_iri_simple(&mut env, &result, Nullable::Some(value.into()), !json_ld_1_0, Some(options.vocab))? {
486                                Some(arc) if matches!(arc.as_ref(), Term::Id(_)) => {
487                                    let term = std::sync::Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone());
488                                    result.set_vocabulary(Some(term));
489                                }
490                                _ => return Err(Error::InvalidVocabMapping),
491                            }
492                        }
493                    }
494                }
495
496                // 5.9) If context has a @language entry:
497                if let Some(value) = context.language() {
498                    match value {
499                        Nullable::Null => {
500                            // 5.9.2) If value is null, remove any default language from result.
501                            result.set_default_language(None);
502                        }
503                        Nullable::Some(tag) => {
504                            result.set_default_language(Some(tag.to_owned()));
505                        }
506                    }
507                }
508
509                // 5.10) If context has a @direction entry:
510                if let Some(value) = context.direction() {
511                    // 5.10.1) If processing mode is json-ld-1.0, an invalid context entry error
512                    // has been detected and processing is aborted.
513                    if options.processing_mode == ProcessingMode::JsonLd1_0 {
514                        return Err(Error::InvalidContextEntry);
515                    }
516
517                    match value {
518                        Nullable::Null => {
519                            // 5.10.3) If value is null, remove any base direction from result.
520                            result.set_default_base_direction(None);
521                        }
522                        Nullable::Some(dir) => {
523                            result.set_default_base_direction(Some(dir));
524                        }
525                    }
526                }
527
528                // 5.12) Create a map `defined` to keep track of whether or not a term
529                // has already been defined or is currently being defined during recursion.
530                let mut defined = DefinedTerms::new();
531                let protected = context.protected().unwrap_or(false);
532
533                // 5.13) For each key-value pair in context where key is not
534                // @base, @direction, @import, @language, @propagate, @protected, @version,
535                // or @vocab,
536                // invoke the Create Term Definition algorithm passing result for
537                // active context, context for local context, key, defined, base URL,
538                // and the value of the @protected entry from context, if any, for protected.
539                // (and the value of override protected)
540                if context.type_().is_some() {
541                    define(
542                        Environment {
543                            vocabulary: env.vocabulary,
544                            loader: env.loader,
545                            warnings: env.warnings,
546                        },
547                        &mut result,
548                        &context,
549                        KeyOrKeywordRef::Keyword(syntax::Keyword::Type),
550                        &mut defined,
551                        remote_contexts.clone(),
552                        base_url.clone(),
553                        protected,
554                        options,
555                    )
556                    .await?;
557                }
558
559                for (key, _binding) in context.bindings() {
560                    define(
561                        Environment {
562                            vocabulary: env.vocabulary,
563                            loader: env.loader,
564                            warnings: env.warnings,
565                        },
566                        &mut result,
567                        &context,
568                        key.into(),
569                        &mut defined,
570                        remote_contexts.clone(),
571                        base_url.clone(),
572                        protected,
573                        options,
574                    )
575                    .await?;
576                }
577            }
578        }
579    }
580
581    Ok(Processed::new(local_context, result))
582}