Skip to main content

morphir_core/metadata/
context.rs

1//! Pure expansion of the bounded authored metadata context grammar.
2
3use super::{Fact, GraphName, ObjectTerm};
4use crate::node_address::{NodeRoot, NodeUri};
5use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
6use serde_json::{Map, Value};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11const MAX_CONTEXT_IMPORT_DEPTH: usize = 128;
12
13/// Object interpretation requested by an authored term definition.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Coercion {
16    /// Ordinary literal data, including URI-looking strings.
17    #[default]
18    None,
19    /// A string containing a canonical node address.
20    NodeId,
21    /// One opaque structured data value whose datatype comes from the schema closure.
22    Json,
23}
24
25/// A context grammar, resource, or expansion error.
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
27pub enum ContextError {
28    /// The authored shape is outside the bounded context grammar.
29    #[error("invalid context form")]
30    InvalidForm,
31    /// A JSON-LD keyword is outside the accepted subset.
32    #[error("unsupported context keyword: {0}")]
33    UnsupportedKeyword(String),
34    /// Two imports assign different meanings to the same term.
35    #[error("context term collision: {0}")]
36    TermCollision(String),
37    /// Two imports assign different default vocabularies.
38    #[error("context vocabulary collision")]
39    VocabCollision,
40    /// A narrower scope changed a protected inherited definition.
41    #[error("protected term redefinition: {0}")]
42    ProtectedTermRedefinition(String),
43    /// One resource appeared twice in a transitive import closure.
44    #[error("duplicate context import: {0}")]
45    DuplicateImport(String),
46    /// An import recurs into an active resource.
47    #[error("context import cycle: {0}")]
48    ImportCycle(String),
49    /// The finite context import depth budget was exceeded.
50    #[error("context import depth exceeds {0}")]
51    ImportDepthExceeded(usize),
52    /// A local reference leaves the supplied root.
53    #[error("context path escapes the supplied root")]
54    PathEscape,
55    /// The caller did not supply this resource.
56    #[error("context resource unavailable: {0}")]
57    ResourceUnavailable(String),
58    /// An explicit nonlocal resource has not been trusted by the caller.
59    #[error("context resource untrusted: {0}")]
60    ResourceUntrusted(String),
61    /// The bytes do not match a content-addressed reference.
62    #[error("context digest mismatch: {0}")]
63    DigestMismatch(String),
64    /// HTTP and other remote schemes are not context references.
65    #[error("remote context forbidden: {0}")]
66    RemoteForbidden(String),
67    /// A digest-addressed context cannot resolve a local child.
68    #[error("relative import without a file base")]
69    RelativeImportWithoutBase,
70    /// A target is not a canonical semantic node address.
71    #[error("invalid context target: {0}")]
72    InvalidTarget(String),
73    /// No binding, prefix, or default vocabulary names the key.
74    #[error("unbound context key: {0}")]
75    UnboundKey(String),
76    /// A node-link value is not a canonical node URI string.
77    #[error("invalid node-link value")]
78    InvalidNodeLink,
79    /// The schema closure did not supply the `@json` datatype.
80    #[error("@json coercion requires a declared datatype")]
81    MissingJsonDatatype,
82    /// A non-JSON fact object needs a recognized expanded value or node form.
83    #[error("fact object must be @value or @id; structured data requires @json")]
84    InvalidFactObject,
85    /// A supplied context file is malformed or has the wrong envelope.
86    #[error("invalid context resource: {0}")]
87    InvalidResource(String),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct Binding {
92    target: String,
93    prefix: bool,
94    coercion: Coercion,
95    protected: bool,
96}
97
98/// Effective bindings at one metadata container scope.
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
100pub struct EffectiveContext {
101    terms: BTreeMap<String, Binding>,
102    vocab: Option<String>,
103}
104
105/// A compact key expanded to a semantic URI and its object coercion.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExpandedKey {
108    uri: NodeUri,
109    coercion: Coercion,
110}
111
112impl ExpandedKey {
113    /// The canonical semantic declaration address.
114    pub fn uri(&self) -> &NodeUri {
115        &self.uri
116    }
117
118    /// The authored value coercion, without schema validation.
119    pub fn coercion(&self) -> Coercion {
120        self.coercion
121    }
122}
123
124impl EffectiveContext {
125    /// A self-contained authored spelling of the effective bindings.
126    /// Imported aliases are expanded to their canonical targets, so a writer
127    /// can carry the same scope without access to the import workspace.
128    pub fn to_inline_value(&self) -> Value {
129        let mut inline = Map::new();
130        if let Some(vocab) = &self.vocab {
131            inline.insert("@vocab".to_owned(), Value::String(vocab.clone()));
132        }
133        for (name, binding) in &self.terms {
134            let value =
135                if !binding.prefix && !binding.protected && binding.coercion == Coercion::None {
136                    Value::String(binding.target.clone())
137                } else {
138                    let mut definition = Map::new();
139                    definition.insert("@id".to_owned(), Value::String(binding.target.clone()));
140                    if binding.prefix {
141                        definition.insert("@prefix".to_owned(), Value::Bool(true));
142                    }
143                    if binding.protected {
144                        definition.insert("@protected".to_owned(), Value::Bool(true));
145                    }
146                    match binding.coercion {
147                        Coercion::None => {}
148                        Coercion::NodeId => {
149                            definition.insert("@type".to_owned(), Value::String("@id".to_owned()));
150                        }
151                        Coercion::Json => {
152                            definition
153                                .insert("@type".to_owned(), Value::String("@json".to_owned()));
154                        }
155                    }
156                    Value::Object(definition)
157                };
158            inline.insert(name.clone(), value);
159        }
160        Value::Object(inline)
161    }
162
163    /// Expand an authored key. An exact alias precedes a prefix and `@vocab`.
164    pub fn expand_key(&self, key: &str) -> Result<ExpandedKey, ContextError> {
165        if let Some(binding) = self.terms.get(key) {
166            return Ok(ExpandedKey {
167                uri: parse_uri(&binding.target)?,
168                coercion: binding.coercion,
169            });
170        }
171        if key.starts_with("morphir://ir/") {
172            return Ok(ExpandedKey {
173                uri: parse_uri(key)?,
174                coercion: Coercion::None,
175            });
176        }
177        if let Some((prefix, suffix)) = key.split_once(':')
178            && let Some(binding) = self.terms.get(prefix).filter(|binding| binding.prefix)
179        {
180            return Ok(ExpandedKey {
181                uri: parse_uri(&format!("{}{suffix}", binding.target))?,
182                coercion: Coercion::None,
183            });
184        }
185        if let Some(vocab) = &self.vocab {
186            return Ok(ExpandedKey {
187                uri: parse_uri(&format!("{vocab}{key}"))?,
188                coercion: Coercion::None,
189            });
190        }
191        Err(ContextError::UnboundKey(key.to_owned()))
192    }
193
194    /// The active default declaration stem, if one was supplied.
195    pub fn vocab(&self) -> Option<&str> {
196        self.vocab.as_deref()
197    }
198}
199
200#[derive(Debug, Clone)]
201struct LoadedResource {
202    bytes: Vec<u8>,
203    trusted: bool,
204}
205
206/// Explicit, already-loaded context bytes. Resolution never reads a file or fetches a URL.
207#[derive(Debug, Clone)]
208pub struct ContextResources {
209    root: String,
210    loaded: BTreeMap<String, LoadedResource>,
211}
212
213impl ContextResources {
214    /// Set the local path root used for lexical confinement of relative imports.
215    ///
216    /// Absolute roots remain absolute; `.` and `./` spellings normalize without
217    /// filesystem access. The caller must supply already-loaded bytes and must
218    /// apply any filesystem symlink, byte-size, and total-resource budgets before
219    /// constructing this map. Resolution itself caps import nesting at 128.
220    pub fn new(root: impl Into<String>) -> Self {
221        let root = root.into();
222        Self {
223            root: normalize_lexical_path(&root).unwrap_or(root),
224            loaded: BTreeMap::new(),
225        }
226    }
227
228    /// Supply bytes under a local path inside the root.
229    pub fn insert_local(&mut self, path: impl Into<String>, bytes: Vec<u8>) {
230        let path = path.into();
231        let identity = normalize_lexical_path(&path).unwrap_or(path);
232        self.loaded.insert(
233            identity,
234            LoadedResource {
235                bytes,
236                trusted: true,
237            },
238        );
239    }
240
241    /// Supply bytes for one digest reference and the caller's trust decision.
242    pub fn insert_verified(&mut self, reference: &str, bytes: Vec<u8>, trusted: bool) {
243        self.loaded
244            .insert(reference.to_owned(), LoadedResource { bytes, trusted });
245    }
246}
247
248/// Resolve an authored `@context` value over a parent and supplied resource closure.
249///
250/// The `base_file` identifies the containing local context file, if any. A
251/// digest-addressed resource has no file base. Reuse the same parent for sibling
252/// attribute and annotation scopes so their local bindings remain isolated.
253///
254/// ```
255/// use morphir_core::metadata::{ContextResources, Coercion, resolve_context};
256/// use serde_json::json;
257/// let resources = ContextResources::new("contexts");
258/// let context = resolve_context(None, &json!({
259///     "@vocab": "morphir://ir/pkg/acme/metadata?format=4.0.0#/module/naming/value/",
260///     "replacement": {"@id": "morphir://ir/pkg/acme/metadata?format=4.0.0#/module/naming/value/replacement", "@type": "@id"}
261/// }), &resources, None).unwrap();
262/// assert_eq!(context.expand_key("replacement").unwrap().coercion(), Coercion::NodeId);
263/// ```
264pub fn resolve_context(
265    parent: Option<&EffectiveContext>,
266    authored: &Value,
267    resources: &ContextResources,
268    base_file: Option<&str>,
269) -> Result<EffectiveContext, ContextError> {
270    let mut seen = BTreeSet::new();
271    let mut active = BTreeSet::new();
272    resolve_value(
273        parent.cloned().unwrap_or_default(),
274        authored,
275        resources,
276        base_file
277            .map(ContextBase::Local)
278            .unwrap_or(ContextBase::Workspace),
279        &mut seen,
280        &mut active,
281    )
282}
283
284/// Replace authored context references with their effective inline bindings
285/// using only resources the caller has already acquired and verified. This is
286/// an in-memory validation view; callers retain the original document bytes
287/// for archive identity and roundtrip.
288pub fn inline_document_contexts(
289    document: &Value,
290    resources: &ContextResources,
291    source_file: Option<&str>,
292) -> Result<Value, ContextError> {
293    let mut result = document.clone();
294    let parent = if let Some(context) = result
295        .get_mut("$meta")
296        .and_then(|meta| meta.get_mut("@context"))
297    {
298        let effective = resolve_context(None, context, resources, source_file)?;
299        *context = effective.to_inline_value();
300        effective
301    } else {
302        EffectiveContext::default()
303    };
304    inline_node_contexts(&mut result, &parent, resources, source_file)?;
305    Ok(result)
306}
307
308fn inline_node_contexts(
309    value: &mut Value,
310    parent: &EffectiveContext,
311    resources: &ContextResources,
312    source_file: Option<&str>,
313) -> Result<(), ContextError> {
314    match value {
315        Value::Object(object) => {
316            for (name, child) in object {
317                if matches!(
318                    name.as_str(),
319                    "$meta" | "@context" | "facts" | "@graph" | "assertionSources" | "extensions"
320                ) {
321                    continue;
322                }
323                if matches!(name.as_str(), "attributes" | "annotations")
324                    && let Some(context) = child.get_mut("@context")
325                {
326                    let effective = resolve_context(Some(parent), context, resources, source_file)?;
327                    *context = effective.to_inline_value();
328                }
329                inline_node_contexts(child, parent, resources, source_file)?;
330            }
331        }
332        Value::Array(items) => {
333            for child in items {
334                inline_node_contexts(child, parent, resources, source_file)?;
335            }
336        }
337        _ => {}
338    }
339    Ok(())
340}
341
342#[derive(Clone, Copy)]
343enum ContextBase<'a> {
344    Workspace,
345    Local(&'a str),
346    ContentAddressed,
347}
348
349/// Convert one authored object using context coercion. The caller supplies the
350/// validated schema datatype for `@json` and validates the resulting data.
351pub fn expand_object(
352    coercion: Coercion,
353    value: Value,
354    json_datatype: Option<NodeUri>,
355) -> Result<ObjectTerm, ContextError> {
356    match coercion {
357        Coercion::None => Ok(ObjectTerm::value(value)),
358        Coercion::NodeId => value
359            .as_str()
360            .and_then(|s| NodeUri::parse(s).ok())
361            .map(ObjectTerm::NodeRef)
362            .ok_or(ContextError::InvalidNodeLink),
363        Coercion::Json => json_datatype
364            .map(|uri| ObjectTerm::typed_json(value, uri))
365            .ok_or(ContextError::MissingJsonDatatype),
366    }
367}
368
369/// Expand authored fact properties into default-graph terms without claiming
370/// declaration or data validation. The caller supplies the `@json` datatype
371/// from its verified predicate closure, then validates each resulting object.
372/// A bare array repeats objects; an `@json` array is one structured object.
373pub fn expand_properties<'a>(
374    subject: &NodeUri,
375    properties: impl IntoIterator<Item = (&'a str, &'a Value)>,
376    context: &EffectiveContext,
377    json_datatype: impl Fn(&NodeUri) -> Option<NodeUri>,
378) -> Result<Vec<Fact>, ContextError> {
379    let mut facts = Vec::new();
380    for (key, authored) in properties {
381        let (predicate, objects) = expand_property_objects(key, authored, context, &json_datatype)?;
382        facts.extend(objects.into_iter().map(|object| {
383            Fact::new(
384                subject.clone(),
385                predicate.uri().clone(),
386                object,
387                GraphName::Default,
388            )
389        }));
390    }
391    Ok(facts)
392}
393
394/// Shared object expansion for a carrier's authored property. The V4 parser
395/// also uses this path when matching detailed-source selectors so expanded
396/// forms cannot acquire a different identity there.
397pub(crate) fn expand_property_objects(
398    key: &str,
399    authored: &Value,
400    context: &EffectiveContext,
401    json_datatype: &impl Fn(&NodeUri) -> Option<NodeUri>,
402) -> Result<(ExpandedKey, Vec<ObjectTerm>), ContextError> {
403    let predicate = context.expand_key(key)?;
404    let datatype = || json_datatype(predicate.uri());
405    let objects = if predicate.coercion() == Coercion::Json {
406        vec![expand_object(Coercion::Json, authored.clone(), datatype())?]
407    } else {
408        let values: Vec<&Value> = match authored {
409            Value::Array(items) => items.iter().collect(),
410            _ => vec![authored],
411        };
412        values
413            .into_iter()
414            .filter(|value| !value.is_null())
415            .map(|value| expand_fact_object(predicate.coercion(), value, datatype()))
416            .collect::<Result<Vec<_>, _>>()?
417    };
418    Ok((predicate, objects))
419}
420
421fn expand_fact_object(
422    coercion: Coercion,
423    authored: &Value,
424    json_datatype: Option<NodeUri>,
425) -> Result<ObjectTerm, ContextError> {
426    match authored {
427        Value::Array(_) => Err(ContextError::InvalidFactObject),
428        Value::Object(members) if members.len() == 1 && members.contains_key("@id") => {
429            expand_object(Coercion::NodeId, members["@id"].clone(), None)
430        }
431        Value::Object(members) if members.len() == 1 && members.contains_key("@value") => {
432            match &members["@value"] {
433                Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
434                    Ok(ObjectTerm::value(members["@value"].clone()))
435                }
436                _ => Err(ContextError::InvalidFactObject),
437            }
438        }
439        Value::Object(members)
440            if members.len() == 2
441                && members.get("@type") == Some(&Value::String("@json".to_owned()))
442                && members.contains_key("@value") =>
443        {
444            expand_object(Coercion::Json, members["@value"].clone(), json_datatype)
445        }
446        Value::Object(_) => Err(ContextError::InvalidFactObject),
447        _ => expand_object(coercion, authored.clone(), json_datatype),
448    }
449}
450
451fn resolve_value(
452    mut parent: EffectiveContext,
453    authored: &Value,
454    resources: &ContextResources,
455    base: ContextBase<'_>,
456    seen: &mut BTreeSet<String>,
457    active: &mut BTreeSet<String>,
458) -> Result<EffectiveContext, ContextError> {
459    match authored {
460        Value::Object(object) => {
461            apply_inline(&mut parent, object)?;
462            Ok(parent)
463        }
464        Value::String(reference) => {
465            let imported = resolve_import(reference, resources, base, seen, active)?;
466            apply_imports(&mut parent, imported)?;
467            Ok(parent)
468        }
469        Value::Array(items) if !items.is_empty() => {
470            if !items[0].is_string() {
471                return Err(ContextError::InvalidForm);
472            }
473            let mut imports = EffectiveContext::default();
474            let mut inline = None;
475            for (index, item) in items.iter().enumerate() {
476                match item {
477                    Value::String(reference) if inline.is_none() => {
478                        let imported = resolve_import(reference, resources, base, seen, active)?;
479                        merge_import(&mut imports, imported)?;
480                    }
481                    Value::Object(object) if index + 1 == items.len() && inline.is_none() => {
482                        inline = Some(object)
483                    }
484                    _ => return Err(ContextError::InvalidForm),
485                }
486            }
487            apply_imports(&mut parent, imports)?;
488            if let Some(object) = inline {
489                apply_inline(&mut parent, object)?;
490            }
491            Ok(parent)
492        }
493        _ => Err(ContextError::InvalidForm),
494    }
495}
496
497fn resolve_import(
498    reference: &str,
499    resources: &ContextResources,
500    base: ContextBase<'_>,
501    seen: &mut BTreeSet<String>,
502    active: &mut BTreeSet<String>,
503) -> Result<EffectiveContext, ContextError> {
504    let hashed = reference.starts_with("morphir://context/sha256/");
505    let identity = if hashed {
506        let digest = reference
507            .strip_prefix("morphir://context/sha256/")
508            .expect("hashed reference has the checked prefix");
509        if digest.len() != 64
510            || !digest
511                .bytes()
512                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
513        {
514            return Err(ContextError::InvalidForm);
515        }
516        reference.to_owned()
517    } else {
518        if reference.contains("://") {
519            return Err(ContextError::RemoteForbidden(reference.to_owned()));
520        }
521        if reference.starts_with('/') {
522            return Err(ContextError::PathEscape);
523        }
524        if !reference.ends_with(".jsonld") {
525            return Err(ContextError::InvalidForm);
526        }
527        let candidate = match base {
528            ContextBase::Workspace if reference.starts_with(&format!("{}/", resources.root)) => {
529                reference.to_owned()
530            }
531            ContextBase::Workspace => format!("{}/{}", resources.root, reference),
532            ContextBase::Local(file) => format!(
533                "{}/{}",
534                file.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("."),
535                reference
536            ),
537            ContextBase::ContentAddressed => return Err(ContextError::RelativeImportWithoutBase),
538        };
539        normalize_path(&candidate, &resources.root)?
540    };
541    if active.contains(&identity) {
542        return Err(ContextError::ImportCycle(identity));
543    }
544    if active.len() >= MAX_CONTEXT_IMPORT_DEPTH {
545        return Err(ContextError::ImportDepthExceeded(MAX_CONTEXT_IMPORT_DEPTH));
546    }
547    if !seen.insert(identity.clone()) {
548        return Err(ContextError::DuplicateImport(identity));
549    }
550    let loaded = resources
551        .loaded
552        .get(&identity)
553        .ok_or_else(|| ContextError::ResourceUnavailable(identity.clone()))?;
554    if hashed {
555        if !loaded.trusted {
556            return Err(ContextError::ResourceUntrusted(identity));
557        }
558        let actual = Sha256::digest(&loaded.bytes)
559            .iter()
560            .map(|byte| format!("{byte:02x}"))
561            .collect::<String>();
562        if !reference.ends_with(&actual) {
563            return Err(ContextError::DigestMismatch(reference.to_owned()));
564        }
565    }
566    let document: Value = serde_json::from_slice::<UniqueValue>(&loaded.bytes)
567        .map(|value| value.0)
568        .map_err(|_| ContextError::InvalidResource(identity.clone()))?;
569    let envelope = document
570        .as_object()
571        .filter(|object| object.len() == 1)
572        .and_then(|object| object.get("@context"))
573        .ok_or_else(|| ContextError::InvalidResource(identity.clone()))?;
574    active.insert(identity.clone());
575    let result = resolve_value(
576        EffectiveContext::default(),
577        envelope,
578        resources,
579        if hashed {
580            ContextBase::ContentAddressed
581        } else {
582            ContextBase::Local(&identity)
583        },
584        seen,
585        active,
586    );
587    active.remove(&identity);
588    result
589}
590
591fn normalize_path(path: &str, root: &str) -> Result<String, ContextError> {
592    let normalized = normalize_lexical_path(path)?;
593    let within_root = match root {
594        "." => !normalized.starts_with('/'),
595        "/" => normalized.starts_with('/'),
596        _ => normalized == root || normalized.starts_with(&format!("{root}/")),
597    };
598    if within_root {
599        Ok(normalized)
600    } else {
601        Err(ContextError::PathEscape)
602    }
603}
604
605fn normalize_lexical_path(path: &str) -> Result<String, ContextError> {
606    let absolute = path.starts_with('/');
607    let mut parts = Vec::new();
608    for part in path.split('/') {
609        match part {
610            "" | "." => {}
611            ".." => {
612                if parts.pop().is_none() {
613                    return Err(ContextError::PathEscape);
614                }
615            }
616            _ => parts.push(part),
617        }
618    }
619    let joined = parts.join("/");
620    if absolute {
621        Ok(format!("/{joined}"))
622    } else if joined.is_empty() {
623        Ok(".".to_owned())
624    } else {
625        Ok(joined)
626    }
627}
628
629fn merge_import(
630    target: &mut EffectiveContext,
631    imported: EffectiveContext,
632) -> Result<(), ContextError> {
633    for (key, binding) in imported.terms {
634        if target.terms.get(&key).is_some_and(|old| old != &binding) {
635            return Err(ContextError::TermCollision(key));
636        }
637        target.terms.insert(key, binding);
638    }
639    if let Some(vocab) = imported.vocab {
640        if target.vocab.as_ref().is_some_and(|old| old != &vocab) {
641            return Err(ContextError::VocabCollision);
642        }
643        target.vocab = Some(vocab);
644    }
645    Ok(())
646}
647
648fn apply_imports(
649    target: &mut EffectiveContext,
650    imported: EffectiveContext,
651) -> Result<(), ContextError> {
652    for (key, binding) in imported.terms {
653        if target
654            .terms
655            .get(&key)
656            .is_some_and(|old| old.protected && old != &binding)
657        {
658            return Err(ContextError::ProtectedTermRedefinition(key));
659        }
660        target.terms.insert(key, binding);
661    }
662    if imported.vocab.is_some() {
663        target.vocab = imported.vocab;
664    }
665    Ok(())
666}
667
668fn apply_inline(
669    target: &mut EffectiveContext,
670    object: &Map<String, Value>,
671) -> Result<(), ContextError> {
672    if let Some(key) = object
673        .keys()
674        .find(|key| key.starts_with('@') && key.as_str() != "@vocab")
675    {
676        return Err(ContextError::UnsupportedKeyword(key.clone()));
677    }
678    if let Some(vocab) = object.get("@vocab") {
679        let stem = vocab.as_str().ok_or(ContextError::InvalidForm)?;
680        validate_stem(stem)?;
681        target.vocab = Some(stem.to_owned());
682    }
683    // Prefixes are installed first, so term definitions can refer to a prefix
684    // declared later in the same JSON object.
685    for (key, value) in object.iter().filter(|(key, _)| !key.starts_with('@')) {
686        if value.as_object().and_then(|obj| obj.get("@prefix")) == Some(&Value::Bool(true)) {
687            install_binding(target, key, value)?;
688        }
689    }
690    for (key, value) in object.iter().filter(|(key, value)| {
691        !key.starts_with('@')
692            && value.as_object().and_then(|obj| obj.get("@prefix")) != Some(&Value::Bool(true))
693    }) {
694        install_binding(target, key, value)?;
695    }
696    Ok(())
697}
698
699fn install_binding(
700    target: &mut EffectiveContext,
701    key: &str,
702    value: &Value,
703) -> Result<(), ContextError> {
704    if key.is_empty()
705        || key.chars().any(char::is_whitespace)
706        || key.split_once(':').is_some_and(|(prefix, suffix)| {
707            prefix.is_empty() || suffix.is_empty() || suffix.contains(':')
708        })
709    {
710        return Err(ContextError::InvalidForm);
711    }
712    let (raw, prefix, coercion, protected) = match value {
713        Value::String(raw) => (raw.as_str(), false, Coercion::None, false),
714        Value::Object(object) => {
715            if let Some(member) = object.keys().find(|member| {
716                !matches!(member.as_str(), "@id" | "@type" | "@prefix" | "@protected")
717            }) {
718                return Err(if member.starts_with('@') {
719                    ContextError::UnsupportedKeyword(member.clone())
720                } else {
721                    ContextError::InvalidForm
722                });
723            }
724            let raw = object
725                .get("@id")
726                .and_then(Value::as_str)
727                .ok_or(ContextError::InvalidForm)?;
728            let prefix = object
729                .get("@prefix")
730                .map(Value::as_bool)
731                .transpose_bool()?
732                .unwrap_or(false);
733            if object.contains_key("@prefix") && !prefix {
734                return Err(ContextError::InvalidForm);
735            }
736            let protected = object
737                .get("@protected")
738                .map(Value::as_bool)
739                .transpose_bool()?
740                .unwrap_or(false);
741            let coercion = match object.get("@type") {
742                None => Coercion::None,
743                Some(Value::String(value)) if value == "@id" => Coercion::NodeId,
744                Some(Value::String(value)) if value == "@json" => Coercion::Json,
745                _ => return Err(ContextError::InvalidForm),
746            };
747            (raw, prefix, coercion, protected)
748        }
749        _ => return Err(ContextError::InvalidForm),
750    };
751    let expanded = if raw.starts_with("morphir://ir/") {
752        raw.to_owned()
753    } else if let Some((head, tail)) = raw.split_once(':') {
754        target
755            .terms
756            .get(head)
757            .filter(|binding| binding.prefix)
758            .map(|binding| format!("{}{tail}", binding.target))
759            .ok_or_else(|| ContextError::InvalidTarget(raw.to_owned()))?
760    } else {
761        return Err(ContextError::InvalidTarget(raw.to_owned()));
762    };
763    if prefix {
764        if coercion != Coercion::None {
765            return Err(ContextError::InvalidForm);
766        }
767        validate_stem(&expanded)?;
768    } else {
769        parse_uri(&expanded)?;
770    }
771    let binding = Binding {
772        target: expanded,
773        prefix,
774        coercion,
775        protected,
776    };
777    if target
778        .terms
779        .get(key)
780        .is_some_and(|old| old.protected && old != &binding)
781    {
782        return Err(ContextError::ProtectedTermRedefinition(key.to_owned()));
783    }
784    target.terms.insert(key.to_owned(), binding);
785    Ok(())
786}
787
788fn parse_uri(raw: &str) -> Result<NodeUri, ContextError> {
789    let uri = NodeUri::parse(raw).map_err(|_| ContextError::InvalidTarget(raw.to_owned()))?;
790    if !matches!(uri.root(), NodeRoot::Type { .. } | NodeRoot::Value { .. })
791        || !uri.steps().is_empty()
792    {
793        return Err(ContextError::InvalidTarget(raw.to_owned()));
794    }
795    Ok(uri)
796}
797
798fn validate_stem(stem: &str) -> Result<(), ContextError> {
799    if !stem.ends_with('/') {
800        return Err(ContextError::InvalidTarget(stem.to_owned()));
801    }
802    parse_uri(&format!("{stem}sample"))
803        .map_err(|_| ContextError::InvalidTarget(stem.to_owned()))?;
804    Ok(())
805}
806
807// A parsed `Value` cannot reveal duplicate object members. Context resources
808// enter as bytes, so reject duplicates recursively before constructing one.
809struct UniqueValue(Value);
810
811impl<'de> Deserialize<'de> for UniqueValue {
812    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
813        deserializer.deserialize_any(UniqueValueVisitor)
814    }
815}
816
817struct UniqueValueVisitor;
818
819impl<'de> Visitor<'de> for UniqueValueVisitor {
820    type Value = UniqueValue;
821
822    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
823        formatter.write_str("a context JSON value without duplicate members")
824    }
825
826    fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
827        Ok(UniqueValue(Value::Bool(value)))
828    }
829
830    fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
831        Ok(UniqueValue(Value::Number(value.into())))
832    }
833
834    fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
835        Ok(UniqueValue(Value::Number(value.into())))
836    }
837
838    fn visit_f64<E: serde::de::Error>(self, value: f64) -> Result<Self::Value, E> {
839        serde_json::Number::from_f64(value)
840            .map(|number| UniqueValue(Value::Number(number)))
841            .ok_or_else(|| E::custom("non-finite JSON number"))
842    }
843
844    fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
845        Ok(UniqueValue(Value::String(value.to_owned())))
846    }
847
848    fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
849        Ok(UniqueValue(Value::String(value)))
850    }
851
852    fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
853        Ok(UniqueValue(Value::Null))
854    }
855
856    fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
857        Ok(UniqueValue(Value::Null))
858    }
859
860    fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
861        let mut values = Vec::new();
862        while let Some(value) = seq.next_element::<UniqueValue>()? {
863            values.push(value.0);
864        }
865        Ok(UniqueValue(Value::Array(values)))
866    }
867
868    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
869        let mut values = Map::new();
870        while let Some(key) = map.next_key::<String>()? {
871            if values.contains_key(&key) {
872                return Err(serde::de::Error::custom(format!(
873                    "duplicate context member: {key}"
874                )));
875            }
876            values.insert(key, map.next_value::<UniqueValue>()?.0);
877        }
878        if values.len() == 1
879            && let Some(Value::String(lexeme)) = values.get("$serde_json::private::Number")
880        {
881            return lexeme
882                .parse::<serde_json::Number>()
883                .map(|number| UniqueValue(Value::Number(number)))
884                .map_err(serde::de::Error::custom);
885        }
886        Ok(UniqueValue(Value::Object(values)))
887    }
888}
889
890trait BoolValue {
891    fn transpose_bool(self) -> Result<Option<bool>, ContextError>;
892}
893impl BoolValue for Option<Option<bool>> {
894    fn transpose_bool(self) -> Result<Option<bool>, ContextError> {
895        self.map(|value| value.ok_or(ContextError::InvalidForm))
896            .transpose()
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::UniqueValue;
903    use serde_json::Value;
904
905    #[test]
906    fn imported_json_numbers_keep_their_numeric_kind_and_lexeme() {
907        for source in ["1", "1.25", "18446744073709551616"] {
908            let value = serde_json::from_slice::<UniqueValue>(source.as_bytes())
909                .unwrap()
910                .0;
911            assert!(matches!(value, Value::Number(_)));
912            assert_eq!(value.to_string(), source);
913        }
914    }
915}