///|
#derive(Eq)
priv struct Term {
iri : String
coercion : String?
prefix : Bool
is_protected : Bool
}
///|
priv struct Context {
terms : Map[String, Term]
mut vocab : String?
}
///|
fn Context::new() -> Context {
{ terms: Map([]), vocab: None, }
}
///|
fn Context::copy(self : Context) -> Context {
{ terms: self.terms.copy(), vocab: self.vocab, }
}
///|
fn check_reference(uri : String, stem~ : Bool = false) -> Unit raise {
let parsed = address(uri)
let parts = parsed.fragment.split("/").collect()
if parts.length() != 5 ||
parts[0].to_owned() != "" ||
parts[1].to_owned() != "module" ||
(parts[3].to_owned() != "value" && parts[3].to_owned() != "type") ||
(stem && parts[4].to_owned() != "") {
raise Invalid(
"metadata.invalid_predicate_uri", uri, "Use a declaration URI or declaration stem",
)
}
let module_name = @ir.Path::parse(
parts[2]
.to_owned()
.split("%2F")
.map(StringView::to_owned)
.collect()
.join("/"),
) catch {
_ =>
raise Invalid(
"metadata.invalid_predicate_uri", uri, "Invalid module path",
)
}
if module_name
.to_canonical()
.split("/")
.map(StringView::to_owned)
.collect()
.join("%2F") !=
parts[2].to_owned() {
raise Invalid(
"metadata.invalid_predicate_uri", uri, "Noncanonical module encoding",
)
}
if !stem {
let local_name = @ir.Name::parse(parts[4].to_owned()) catch {
_ =>
raise Invalid(
"metadata.invalid_predicate_uri", uri, "Use a canonical local name; camelCase requires an explicit alias",
)
}
if local_name.to_canonical() != parts[4].to_owned() {
raise Invalid(
"metadata.invalid_predicate_uri", uri, "Use a canonical local name; camelCase requires an explicit alias",
)
}
}
}
///|
fn Context::expand(self : Context, key : String) -> (String, String?) raise {
if self.terms.get(key) is Some(term) && !term.prefix {
return (term.iri, term.coercion)
}
if key.has_prefix("morphir://ir/") {
check_reference(key)
return (key, None)
}
let parts = key.split(":").collect()
if parts.length() == 2 &&
self.terms.get(parts[0].to_owned()) is Some(term) &&
term.prefix {
let iri = term.iri + parts[1].to_owned()
check_reference(iri)
return (iri, None)
}
if !key.contains(":") && self.vocab is Some(stem) {
let iri = stem + key
check_reference(iri)
return (iri, None)
}
raise Invalid(
"metadata.unresolved_term", key, "Supply an explicit alias, declared prefix or @vocab",
)
}
///|
fn bool_true(value : @ion.IonValue, owner : String) -> Unit raise {
if !value.annotations().is_empty() || !(value.kind() is Bool(true)) {
raise Invalid(
"metadata.invalid_context_form", owner, "This context flag only admits true",
)
}
}
///|
fn apply_inline(
context : Context,
value : @ion.IonValue,
imports : Bool,
) -> Unit raise {
let record = fields(value, "@context")
let names = record.keys().collect()
names.sort()
for name in names {
if name.has_prefix("@") && name != "@vocab" {
raise Invalid(
"metadata.unsupported_context_keyword", name, "Remove unsupported context keyword",
)
}
}
// Prefixes are declared first; compact targets can refer forward to them.
let pending = []
for name in names {
if name == "@vocab" {
let stem = text(record[name], name)
check_reference(stem, stem=true)
if imports && context.vocab is Some(old) && old != stem {
raise Invalid(
"metadata.context_vocab_collision", name, "Imported contexts disagree on @vocab",
)
}
context.vocab = Some(stem)
continue
}
if name.is_empty() || name.has_prefix("@") {
raise Invalid("metadata.invalid_context_form", name, "Invalid term name")
}
let value = record[name]
let term = match value.kind() {
String(_) =>
{
iri: text(value, name),
coercion: None,
prefix: false,
is_protected: false,
}
Struct(_) => {
let definition = fields(value, name)
for key in definition.keys() {
if !["@id", "@type", "@prefix", "@protected"].contains(key) {
raise Invalid(
"metadata.unsupported_context_keyword", key, "Remove unsupported term-definition keyword",
)
}
}
let coercion = definition.get("@type").map(v => text(v, name))
if coercion is Some(c) && c != "@json" && c != "@id" {
raise Invalid(
"metadata.invalid_context_form", name, "Only @json and @id coercions are admitted",
)
}
if definition.get("@prefix") is Some(v) {
bool_true(v, name)
}
if definition.get("@protected") is Some(v) {
bool_true(v, name)
}
{
iri: text(required(definition, "@id", name), name),
coercion,
prefix: definition.contains("@prefix"),
is_protected: definition.contains("@protected"),
}
}
_ =>
raise Invalid(
"metadata.invalid_context_form", name, "Term definitions must be strings or objects",
)
}
if term.prefix {
if term.coercion is Some(_) {
raise Invalid(
"metadata.invalid_context_form", name, "Prefixes cannot carry a coercion",
)
}
check_reference(term.iri, stem=true)
set_term(context, name, term, imports)
} else {
pending.push((name, term))
}
}
for (name, term) in pending {
if !term.iri.has_prefix("morphir://ir/") && !term.iri.contains(":") {
raise Invalid(
"metadata.invalid_context_form", name, "A term target must be an absolute declaration URI or compact IRI",
)
}
let iri = if term.iri.has_prefix("morphir://ir/") {
term.iri
} else {
context.expand(term.iri).0
}
check_reference(iri)
set_term(context, name, { ..term, iri, }, imports)
}
}
///|
fn set_term(
context : Context,
name : String,
term : Term,
imports : Bool,
) -> Unit raise {
if context.terms.get(name) is Some(old) && old != term {
if old.is_protected {
raise Invalid(
"metadata.protected_term_redefinition", name, "Retain the protected binding",
)
}
if imports {
raise Invalid(
"metadata.context_term_collision", name, "Imported contexts disagree; a trailing override cannot cure the collision",
)
}
}
context.terms[name] = term
}
///|
fn Context::merge_import(self : Context, other : Context) -> Unit raise {
if other.vocab is Some(v) {
if self.vocab is Some(old) && old != v {
raise Invalid(
"metadata.context_vocab_collision", "@vocab", "Imported contexts disagree",
)
}
self.vocab = Some(v)
}
for name, term in other.terms {
set_term(self, name, term, true)
}
}
///|
fn resolve_context(
value : @ion.IonValue,
inherited : Context,
capabilities : Capabilities,
base : String,
seen : Map[String, Bool],
active : Map[String, Bool],
depth~ : Int32 = 0,
) -> Context raise {
if !value.annotations().is_empty() {
raise Invalid(
"metadata.invalid_context_form", base, "Context must be unannotated JSON data",
)
}
if depth > 32 {
raise Invalid(
"metadata.context_limit", base, "Context import depth exceeds 32",
)
}
fn reference(resource_ref : String) -> Context raise {
let content_prefix = "morphir://context/sha256/"
let content_addressed = resource_ref.has_prefix(content_prefix)
if content_addressed {
if !digest(resource_ref[content_prefix.length():].to_owned()) {
raise Invalid(
"metadata.invalid_context_reference", resource_ref, "Invalid content digest",
)
}
} else {
if resource_ref.contains(":") ||
resource_ref.has_prefix("/") ||
resource_ref.contains("\\") ||
!resource_ref.has_suffix(".jsonld") {
raise Invalid(
"metadata.remote_context_forbidden", resource_ref, "Use a confined relative .jsonld path or content-addressed context",
)
}
if base == "" {
raise Invalid(
"metadata.relative_import_without_base", resource_ref, "Content-addressed contexts have no relative file base",
)
}
}
guard capabilities.load_context is Some(load) else {
raise Invalid(
"metadata.context_resource_unavailable", resource_ref, "Configure an explicit trusted context loader",
)
}
// Host loader owns confinement and symlink resolution, which the portable
// core cannot perform. It must reject escape from the accepted root.
let resource = load(resource_ref, base)
if active.contains(resource.url) {
raise Invalid(
"metadata.context_import_cycle",
resource.url,
"Remove the cyclic import",
)
}
if seen.contains(resource.url) {
raise Invalid(
"metadata.duplicate_context_import",
resource.url,
"Import each resource once per transitive closure",
)
}
if seen.length() >= 256 {
raise Invalid(
"metadata.context_limit", resource_ref, "Context closure exceeds 256 resources",
)
}
if resource.bytes.length() > 1048576 {
raise Invalid(
"metadata.context_limit", resource_ref, "Context exceeds 1 MiB",
)
}
let hash = @crypto.bytes_to_hex_string(@crypto.sha256(resource.bytes))
if !digest(resource.sha256) ||
hash != resource.sha256 ||
(
content_addressed &&
hash != resource_ref[content_prefix.length():].to_owned()
) {
raise Invalid(
"metadata.context_digest_mismatch", resource_ref, "Use exact inventoried context bytes",
)
}
let source = @utf8.decode(resource.bytes[:]) catch {
_ =>
raise Invalid(
"metadata.invalid_context_form", resource_ref, "Context must be UTF-8 JSON",
)
}
ignore(@json.parse(source)) catch {
_ =>
raise Invalid(
"metadata.invalid_context_form", resource_ref, "Context must be JSON, not general Ion",
)
}
let document = fields(
@ion_text.read_ion(source[:], fuel=Limited(@ion.Budget(128))),
resource_ref,
)
if document.length() != 1 || !document.contains("@context") {
raise Invalid(
"metadata.invalid_context_form", resource_ref, "Context document needs exactly one @context member",
)
}
seen[resource.url] = true
active[resource.url] = true
let resolved = resolve_context(
document["@context"],
Context::new(),
capabilities,
if content_addressed {
""
} else {
resource.url
},
seen,
active,
depth=depth + 1,
)
active.remove(resource.url)
resolved
}
let output = inherited.copy()
match value.kind() {
Struct(_) => apply_inline(output, value, false)
String(_) => {
let imported = reference(text(value, "@context"))
if imported.vocab is Some(v) {
output.vocab = Some(v)
}
for name, term in imported.terms {
set_term(output, name, term, false)
}
}
List(items) => {
if items.is_empty() || !(items[0].kind() is String(_)) {
raise Invalid(
"metadata.invalid_context_form", "@context", "An array needs one or more reference strings, then at most one object",
)
}
let imports = Context::new()
let mut trailing : @ion.IonValue? = None
for item in items {
match item.kind() {
String(_) if trailing is None =>
imports.merge_import(reference(text(item, "@context")))
Struct(_) if trailing is None => trailing = Some(item)
_ =>
raise Invalid(
"metadata.invalid_context_form", "@context", "No repeated object, nested array or reference after an object",
)
}
}
if imports.vocab is Some(v) {
output.vocab = Some(v)
}
for name, term in imports.terms {
set_term(output, name, term, false)
}
if trailing is Some(value) {
apply_inline(output, value, false)
}
}
_ =>
raise Invalid(
"metadata.invalid_context_form", "@context", "Use an object, reference string or bounded array",
)
}
output
}