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 `Box::pin`
8//! 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, Environment, ExtractContext, Loader, ProcessingMode, Term};
39use jsonld_syntax::{self as syntax, Nullable};
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 // `Box::pin` per recursion, 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 )
110 .await
111 }
112
113 async fn process_full_with_cache<N, L, W>(
114 &self,
115 vocabulary: &mut N,
116 active_context: &Context<N::Iri, N::BlankId>,
117 loader: &L,
118 base_url: Option<N::Iri>,
119 options: Options,
120 mut warnings: W,
121 cache: &ProcessingCache<N::Iri, N::BlankId>,
122 ) -> Result<Processed<'_, N::Iri, N::BlankId>, Error<L::Error>>
123 where
124 N: VocabularyMut,
125 N::Iri: Clone + Eq + Hash,
126 N::BlankId: Clone + Eq + Hash,
127 L: Loader,
128 W: WarningHandler<N>,
129 {
130 let key = cache_key(active_context, self, base_url.as_ref(), options);
131
132 if let Some(cached) = cache.get(key, active_context, self, base_url.as_ref(), options) {
133 return Ok(Processed::new(self, (*cached).clone()));
134 }
135
136 // Retained for the cache entry: `base_url` is moved into processing.
137 let entry_active = active_context.clone();
138 let entry_base_url = base_url.clone();
139
140 let processed = if requires_loader(self) {
141 process_context(
142 Environment {
143 vocabulary,
144 loader,
145 warnings: &mut warnings,
146 },
147 active_context,
148 self,
149 ProcessingStack::default(),
150 base_url,
151 options,
152 )
153 .await?
154 } else {
155 process_context_sync(
156 Environment {
157 vocabulary,
158 loader,
159 warnings: &mut warnings,
160 },
161 active_context,
162 self,
163 ProcessingStack::default(),
164 base_url,
165 options,
166 0,
167 )?
168 };
169
170 cache.insert(key, entry_active, self.clone(), entry_base_url, options, Arc::new(processed.processed.clone()));
171 Ok(processed)
172 }
173}
174
175/// Resolves `iri_ref` against `base_iri` and interns the result in `vocabulary`.
176///
177/// With no base IRI, `iri_ref` must already be an absolute IRI. Returns `None`
178/// when the reference cannot be turned into one either way.
179fn resolve_iri<V, I>(vocabulary: &mut V, iri_ref: iri_rs::IriRef<&str>, base_iri: Option<&I>) -> Option<I>
180where
181 V: rdfx::vocabulary::IriVocabularyMut<Iri = I>,
182{
183 if let Some(base_iri) = base_iri {
184 let base = vocabulary.iri(base_iri)?;
185 let resolved = iri_ref.resolved(&base).ok()?;
186 let iri_buf = IriBuf::try_from(resolved).ok()?;
187 Some(vocabulary.insert_owned(iri_buf))
188 } else {
189 let iri = Iri::try_from(iri_ref).ok()?;
190 Some(vocabulary.insert(iri))
191 }
192}
193
194/// Runs the [context processing algorithm][1], layering `local_context` on top
195/// of `active_context`.
196///
197/// Recurses for each remote `@context` IRI, fetching it through the loader, and
198/// for each term definition's scoped `@context` via `define`. `remote_contexts`
199/// carries the chain of remote context URLs entered so far, which bounds that
200/// recursion; a top-level call starts from an empty [`ProcessingStack`].
201///
202/// Every mutation is applied to a clone of `active_context`, so the caller's
203/// context is left untouched.
204///
205/// This is the `async` half of a mirrored pair; see the module documentation
206/// before changing it.
207///
208/// [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
209async fn process_context<'l: 'a, 'a, N, L, W>(
210 mut env: Environment<'a, N, L, W>,
211 active_context: &'a Context<N::Iri, N::BlankId>,
212 local_context: &'l syntax::context::Context,
213 mut remote_contexts: ProcessingStack<N::Iri>,
214 base_url: Option<N::Iri>,
215 mut options: Options,
216) -> ProcessingResult<'l, N::Iri, N::BlankId, L::Error>
217where
218 N: VocabularyMut,
219 N::Iri: Clone + Eq + Hash,
220 N::BlankId: Clone + PartialEq,
221 L: Loader,
222 W: WarningHandler<N>,
223{
224 // 1) Initialize result to the result of cloning active context.
225 let mut result = active_context.clone();
226 result.set_processing_mode(options.processing_mode);
227
228 // 2) If `local_context` is an object containing the member @propagate,
229 // its value MUST be boolean true or false, set `propagate` to that value.
230 if let syntax::context::Context::One(syntax::ContextEntry::Definition(def)) = local_context
231 && let Some(propagate) = def.propagate
232 {
233 if options.processing_mode == ProcessingMode::JsonLd1_0 {
234 return Err(Error::InvalidContextEntry);
235 }
236
237 options.propagate = propagate;
238 }
239
240 // 3) If propagate is false, and result does not have a previous context,
241 // set previous context in result to active context.
242 if !options.propagate && result.previous_context().is_none() {
243 result.set_previous_context(active_context.clone());
244 }
245
246 // 4) If local context is not an array, set it to an array containing only local context.
247 // 5) For each item context in local context:
248 for context in local_context {
249 match context {
250 // 5.1) If context is null:
251 syntax::ContextEntry::Null => {
252 // If `override_protected` is false and `active_context` contains any protected term
253 // definitions, an invalid context nullification has been detected and processing
254 // is aborted.
255 if !options.override_protected && result.has_protected_items() {
256 return Err(Error::InvalidContextNullification);
257 }
258 // Otherwise, initialize result as a newly-initialized active context, setting
259 // previous_context in result to the previous value of result if propagate is
260 // false. Continue with the next context.
261 let previous_result = result;
262
263 // Initialize `result` as a newly-initialized active context, setting both
264 // `base_iri` and `original_base_url` to the value of `original_base_url` in
265 // active context, ...
266 result = Context::new(active_context.original_base_url().cloned());
267 result.set_processing_mode(options.processing_mode);
268
269 // ... and, if `propagate` is `false`, `previous_context` in `result` to the
270 // previous value of `result`.
271 if !options.propagate {
272 result.set_previous_context(previous_result);
273 }
274 }
275
276 // 5.2) If context is a string,
277 syntax::ContextEntry::IriRef(iri_ref) => {
278 // Initialize `context` to the result of resolving context against base URL.
279 // If base URL is not a valid IRI, then context MUST be a valid IRI, otherwise
280 // a loading document failed error has been detected and processing is aborted.
281 let context_iri = resolve_iri(env.vocabulary, iri_ref.as_ref(), base_url.as_ref()).ok_or(Error::LoadingDocumentFailed)?;
282
283 // If the number of entries in the `remote_contexts` array exceeds a processor
284 // defined limit, a context overflow error has been detected and processing is
285 // aborted; otherwise, add context to remote contexts.
286 //
287 // If context was previously dereferenced, then the processor MUST NOT do a further
288 // dereference, and context is set to the previously established internal
289 // representation: set `context_document` to the previously dereferenced document,
290 // and set loaded context to the value of the @context entry from the document in
291 // context document.
292 //
293 // Otherwise, set `context document` to the RemoteDocument obtained by dereferencing
294 // context using the LoadDocumentCallback, passing context for url, and
295 // http://www.w3.org/ns/json-ld#context for profile and for requestProfile.
296 //
297 // If context cannot be dereferenced, or the document from context document cannot
298 // be transformed into the internal representation , a loading remote context
299 // failed error has been detected and processing is aborted.
300 // If the document has no top-level map with an @context entry, an invalid remote
301 // context has been detected and processing is aborted.
302 // Set loaded context to the value of that entry.
303 // In JSON-LD 1.1 a context already in `remote_contexts` is simply
304 // not processed again — scoped contexts legitimately reload
305 // contexts, and only the entry limit (context overflow) applies.
306 // JSON-LD 1.0 had no such allowance: a repeat is a recursive
307 // context inclusion error.
308 if remote_contexts.len() >= crate::MAX_REMOTE_CONTEXTS {
309 return Err(Error::ContextOverflow);
310 }
311
312 let fresh = remote_contexts.push(context_iri.clone());
313 if !fresh && options.processing_mode == ProcessingMode::JsonLd1_0 {
314 return Err(Error::RecursiveContextInclusion);
315 }
316
317 if fresh {
318 let loaded_context = env
319 .loader
320 .load_with(env.vocabulary, context_iri.clone())
321 .await?
322 .into_document()
323 .into_ld_context()
324 .map_err(Error::ContextExtractionFailed)?;
325
326 // Set result to the result of recursively calling this algorithm, passing result
327 // for active context, loaded context for local context, the documentUrl of context
328 // document for base URL, and a copy of remote contexts.
329 let new_options = Options {
330 processing_mode: options.processing_mode,
331 override_protected: false,
332 propagate: true,
333 vocab: options.vocab,
334 };
335
336 let r = Box::pin(process_context(
337 Environment {
338 vocabulary: env.vocabulary,
339 loader: env.loader,
340 warnings: env.warnings,
341 },
342 &result,
343 &loaded_context,
344 remote_contexts.clone(),
345 Some(context_iri),
346 new_options,
347 ))
348 .await?;
349
350 result = r.into_processed();
351 }
352 }
353
354 // 5.4) Context definition.
355 syntax::ContextEntry::Definition(context) => {
356 // 5.5) If context has a @version entry:
357 if context.version.is_some() {
358 // 5.5.2) If processing mode is set to json-ld-1.0, a processing mode conflict
359 // error has been detected.
360 if options.processing_mode == ProcessingMode::JsonLd1_0 {
361 return Err(Error::ProcessingModeConflict);
362 }
363 }
364
365 // 5.6) If context has an @import entry:
366 let import_context = match &context.import {
367 Some(import_value) => {
368 // 5.6.1) If processing mode is json-ld-1.0, an invalid context entry error
369 // has been detected.
370 if options.processing_mode == ProcessingMode::JsonLd1_0 {
371 return Err(Error::InvalidContextEntry);
372 }
373
374 // 5.6.3) Initialize import to the result of resolving the value of
375 // @import.
376 let import = resolve_iri(env.vocabulary, import_value.as_ref(), base_url.as_ref()).ok_or(Error::InvalidImportValue)?;
377
378 // 5.6.4) Dereference import.
379 let import_context = env
380 .loader
381 .load_with(env.vocabulary, import.clone())
382 .await?
383 .into_document()
384 .into_ld_context()
385 .map_err(Error::ContextExtractionFailed)?;
386
387 // If the dereferenced document has no top-level map with an @context
388 // entry, or if the value of @context is not a context definition
389 // (i.e., it is not an map), an invalid remote context has been
390 // detected and processing is aborted; otherwise, set import context
391 // to the value of that entry.
392 match &import_context {
393 syntax::context::Context::One(syntax::ContextEntry::Definition(import_context_def)) => {
394 // If `import_context` has a @import entry, an invalid context entry
395 // error has been detected and processing is aborted.
396 if import_context_def.import.is_some() {
397 return Err(Error::InvalidContextEntry);
398 }
399 }
400 _ => {
401 return Err(Error::InvalidRemoteContext);
402 }
403 }
404
405 // Set `context` to the result of merging context into
406 // `import_context`, replacing common entries with those from
407 // `context`.
408 Some(import_context)
409 }
410 None => None,
411 };
412
413 let context = Merged::new(context, import_context);
414
415 // 5.7) If context has a @base entry and remote contexts is empty, i.e.,
416 // the currently being processed context is not a remote context:
417 if remote_contexts.is_empty() {
418 // Initialize value to the value associated with the @base entry.
419 if let Some(value) = context.base() {
420 match value {
421 syntax::Nullable::Null => {
422 // If value is null, remove the base IRI of result.
423 result.set_base_iri(None);
424 }
425 syntax::Nullable::Some(iri_ref) => {
426 if let Ok(iri) = Iri::try_from(iri_ref.as_ref()) {
427 result.set_base_iri(Some(env.vocabulary.insert(iri)));
428 } else {
429 let resolved = resolve_iri(env.vocabulary, iri_ref.as_ref(), result.base_iri()).ok_or(Error::InvalidBaseIri)?;
430 result.set_base_iri(Some(resolved));
431 }
432 }
433 }
434 }
435 }
436
437 // 5.8) If context has a @vocab entry:
438 // Initialize value to the value associated with the @vocab entry.
439 if let Some(value) = context.vocab() {
440 match value {
441 syntax::Nullable::Null => {
442 // If value is null, remove any vocabulary mapping from result.
443 result.set_vocabulary(None);
444 }
445 syntax::Nullable::Some(value) => {
446 // Otherwise, if value is an IRI or blank node identifier, the
447 // vocabulary mapping of result is set to the result of IRI
448 // expanding value using true for document relative. If it is not
449 // an IRI, or a blank node identifier, an invalid vocab mapping
450 // error has been detected and processing is aborted.
451 // NOTE: The use of blank node identifiers to value for @vocab is
452 // obsolete, and may be removed in a future version of JSON-LD.
453 //
454 // Document-relative expansion of `@vocab` is a JSON-LD 1.1 addition:
455 // 1.0 required an absolute IRI or blank node identifier, and rejects
456 // `""` and `/relative` (`expand#t0115`, `expand#t0116`). A bare `_:`
457 // prefix stays valid in 1.0 (`expand#t0075`).
458 let json_ld_1_0 = options.processing_mode == ProcessingMode::JsonLd1_0;
459 if json_ld_1_0 && !is_valid_vocab_json_ld_1_0(value) {
460 return Err(Error::InvalidVocabMapping);
461 }
462
463 match expand_iri_simple(&mut env, &result, Nullable::Some(value.into()), !json_ld_1_0, Some(options.vocab))? {
464 Some(arc) if matches!(arc.as_ref(), Term::Id(_)) => {
465 let term = std::sync::Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone());
466 result.set_vocabulary(Some(term));
467 }
468 _ => return Err(Error::InvalidVocabMapping),
469 }
470 }
471 }
472 }
473
474 // 5.9) If context has a @language entry:
475 if let Some(value) = context.language() {
476 match value {
477 Nullable::Null => {
478 // 5.9.2) If value is null, remove any default language from result.
479 result.set_default_language(None);
480 }
481 Nullable::Some(tag) => {
482 result.set_default_language(Some(tag.to_owned()));
483 }
484 }
485 }
486
487 // 5.10) If context has a @direction entry:
488 if let Some(value) = context.direction() {
489 // 5.10.1) If processing mode is json-ld-1.0, an invalid context entry error
490 // has been detected and processing is aborted.
491 if options.processing_mode == ProcessingMode::JsonLd1_0 {
492 return Err(Error::InvalidContextEntry);
493 }
494
495 match value {
496 Nullable::Null => {
497 // 5.10.3) If value is null, remove any base direction from result.
498 result.set_default_base_direction(None);
499 }
500 Nullable::Some(dir) => {
501 result.set_default_base_direction(Some(dir));
502 }
503 }
504 }
505
506 // 5.12) Create a map `defined` to keep track of whether or not a term
507 // has already been defined or is currently being defined during recursion.
508 let mut defined = DefinedTerms::new();
509 let protected = context.protected().unwrap_or(false);
510
511 // 5.13) For each key-value pair in context where key is not
512 // @base, @direction, @import, @language, @propagate, @protected, @version,
513 // or @vocab,
514 // invoke the Create Term Definition algorithm passing result for
515 // active context, context for local context, key, defined, base URL,
516 // and the value of the @protected entry from context, if any, for protected.
517 // (and the value of override protected)
518 if context.type_().is_some() {
519 define(
520 Environment {
521 vocabulary: env.vocabulary,
522 loader: env.loader,
523 warnings: env.warnings,
524 },
525 &mut result,
526 &context,
527 KeyOrKeywordRef::Keyword(syntax::Keyword::Type),
528 &mut defined,
529 remote_contexts.clone(),
530 base_url.clone(),
531 protected,
532 options,
533 )
534 .await?;
535 }
536
537 for (key, _binding) in context.bindings() {
538 define(
539 Environment {
540 vocabulary: env.vocabulary,
541 loader: env.loader,
542 warnings: env.warnings,
543 },
544 &mut result,
545 &context,
546 key.into(),
547 &mut defined,
548 remote_contexts.clone(),
549 base_url.clone(),
550 protected,
551 options,
552 )
553 .await?;
554 }
555 }
556 }
557 }
558
559 Ok(Processed::new(local_context, result))
560}