Skip to main content

kanonak_codec/
lib.rs

1//! kanonak-codec — the generic, ontology-independent codec runtime (Rust port).
2//!
3//! Given a `CodecSchema` (the per-package metadata a generated SDK embeds) and a
4//! set of typed nodes, it builds the canonical input model and content-addresses
5//! it via `kanonak-canonical` (the same content-form the Python/TypeScript
6//! references and the `kanonak hash` CLI produce). It also (de)serializes the
7//! normalized-JSON wire form. Self-contained: carriers come from the schema's
8//! datatype URIs, and the resolved foundation URIs are embedded by the generator,
9//! so hashing needs no runtime ontology resolution.
10//!
11//! A node is a plain JSON object (`serde_json::Map<String, serde_json::Value>`) —
12//! the `$`-envelope plus alias-collapsed local-name fields. A generated typed
13//! model serializes to one. Note: `serde_json::Value` (the node field model) is
14//! distinct from `kanonak_canonical::Value` (the canonical-input value enum).
15
16use kanonak_canonical::{
17    canonical_form as canonical_form_pkg, canonical_hash as canonical_hash_pkg, carrier_of,
18    is_readable_by, parse_coordinate, versionless_key, CanonError, CoordinateVersion, Package,
19    Statement, Subject, Value,
20};
21use serde_json::{Map, Value as Json};
22
23mod typed;
24pub use typed::{to_node, KanonakNode, KanonakResource, Ref};
25
26/// The reserved `$`-envelope keys, which never become statements/predicates.
27/// `$name` (0.2.0) carries an embedded value's authored dict-key — hash-relevant.
28/// `$types` (0.4.0, runtime#10) carries a multi-typed node's FULL type set.
29const ENVELOPE_KEYS: [&str; 7] = [
30    "$type",
31    "$types",
32    "$id",
33    "$name",
34    "$contentHash",
35    "$version",
36    "$extra",
37];
38
39/// A node is a JSON object.
40pub type Node = Map<String, Json>;
41
42/// Errors raised by the codec runtime. Fails loudly — no fallbacks.
43///
44/// `#[non_exhaustive]`: a later 1.x release may add a variant, so a `match`
45/// outside this crate needs a wildcard arm. Branch on the message's bracketed
46/// kind (`[unknown-type]`, `[fragment-reference]`, ...) rather than the variant.
47#[derive(Debug)]
48#[non_exhaustive]
49pub enum CodecError {
50    /// A node, schema, or package context was malformed.
51    Malformed(String),
52    /// The underlying canonical library rejected a lexical/value.
53    Canon(CanonError),
54}
55
56impl std::fmt::Display for CodecError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            CodecError::Malformed(m) => write!(f, "{}", m),
60            CodecError::Canon(e) => write!(f, "{}", e.0),
61        }
62    }
63}
64
65impl std::error::Error for CodecError {}
66
67impl From<CanonError> for CodecError {
68    fn from(e: CanonError) -> Self {
69        CodecError::Canon(e)
70    }
71}
72
73fn err<T>(msg: impl Into<String>) -> Result<T, CodecError> {
74    Err(CodecError::Malformed(msg.into()))
75}
76
77/// The raw lexical token of a scalar — the input the canonical form normalizes.
78/// bool -> "true"/"false"; string -> the string; number -> its plain decimal
79/// string (serde_json's `Number::to_string()` gives "5" / "1.5", never
80/// scientific notation). The canonical crate re-normalizes from there.
81fn lexical(value: &Json) -> String {
82    match value {
83        Json::Bool(b) => {
84            if *b {
85                "true".to_string()
86            } else {
87                "false".to_string()
88            }
89        }
90        Json::String(s) => s.clone(),
91        Json::Number(n) => n.to_string(),
92        other => other.to_string(),
93    }
94}
95
96/// The schema's `classes` map. A class entry may carry an optional
97/// `ancestors` list (0.6.1, runtime#28): every superclass, transitively, by
98/// durable VERSIONED URI — what lets [`type_matches`] accept a subclass node
99/// for a superclass check. Additive and canonicalization-INERT: a schema
100/// without it is still valid, and absent, a type match is by the class itself
101/// only — stricter, never looser.
102fn classes_of(schema: &Json) -> Result<&Map<String, Json>, CodecError> {
103    schema
104        .get("classes")
105        .and_then(|c| c.as_object())
106        .ok_or_else(|| CodecError::Malformed("schema is missing 'classes'".into()))
107}
108
109/// A coordinate's versionless key and version, or `None` when the string is
110/// not a STRICTLY well-formed coordinate or carries no version (never errors).
111fn versioned_coordinate(uri: &str) -> Option<(String, CoordinateVersion)> {
112    let c = parse_coordinate(uri).ok()?;
113    let version = c.version?;
114    Some((format!("{}/{}/{}", c.publisher, c.package, c.name), version))
115}
116
117/// The versionless identity of a coordinate (versioned or not), or `None`
118/// when the string is not one.
119fn identity_of(uri: &str) -> Option<String> {
120    versionless_key(uri).ok()
121}
122
123fn version_order(v: CoordinateVersion) -> (u64, u64, u64) {
124    (v.major, v.minor, v.patch)
125}
126
127fn format_version(v: CoordinateVersion) -> String {
128    format!("{}.{}.{}", v.major, v.minor, v.patch)
129}
130
131/// THE class lookup (runtime#28). A node is typed with the version of the
132/// class its producer's import resolved to; a codec generated from a later
133/// COMPATIBLE version of the same package must still read it. So: the exact
134/// versioned key first, then the class with the same publisher, package and
135/// name whose version can read the written one — `is_readable_by` from
136/// `kanonak-canonical`, the protocol's single compatibility rule, pinned in
137/// every port. Returns the class's schema key and entry. When nothing
138/// matches, the error says why, ending in a bracketed kind the conformance
139/// vectors pin: `[unknown-type]`, `[newer-version]` (the data may use terms
140/// this schema lacks), `[other-major]`, or `[other-minor-line]` (below 1.0.0
141/// the minor is the incompatible line).
142fn class_for<'a>(
143    classes: &'a Map<String, Json>,
144    type_uri: &str,
145) -> Result<(&'a str, &'a Json), String> {
146    if let Some((key, cls)) = classes.get_key_value(type_uri) {
147        return Ok((key.as_str(), cls));
148    }
149    let unknown = || format!("no schema for type {} [unknown-type]", type_uri);
150    let (key, written) = match versioned_coordinate(type_uri) {
151        Some(found) => found,
152        None => return Err(unknown()),
153    };
154
155    let mut readable: Option<(&'a str, &'a Json, CoordinateVersion)> = None;
156    let mut nearest: Option<CoordinateVersion> = None;
157    for (uri, cls) in classes.iter() {
158        let version = match versioned_coordinate(uri) {
159            Some((k, v)) if k == key => v,
160            _ => continue,
161        };
162        if is_readable_by(written, version) {
163            if readable.map_or(true, |(_, _, best)| {
164                version_order(version) > version_order(best)
165            }) {
166                readable = Some((uri.as_str(), cls, version));
167            }
168        } else if nearest.map_or(true, |n| version_order(version) > version_order(n)) {
169            nearest = Some(version);
170        }
171    }
172    if let Some((uri, cls, _)) = readable {
173        return Ok((uri, cls));
174    }
175    match nearest {
176        Some(r) => Err(incompatible_version(type_uri, written, r)),
177        None => Err(unknown()),
178    }
179}
180
181fn incompatible_version(type_uri: &str, w: CoordinateVersion, r: CoordinateVersion) -> String {
182    let (ws, rs) = (format_version(w), format_version(r));
183    if w.major != r.major {
184        return format!(
185            "{} is written at {}, a different major version than this codec's schema ({}) \
186             [other-major]",
187            type_uri, ws, rs
188        );
189    }
190    if w.major == 0 && w.minor != r.minor {
191        return format!(
192            "{} is written at {}; below 1.0.0 a different minor is a different version line \
193             than this codec's schema ({}) [other-minor-line]",
194            type_uri, ws, rs
195        );
196    }
197    format!(
198        "{} is written at {}, newer than this codec's schema ({}); upgrade the codec to read it \
199         [newer-version]",
200        type_uri, ws, rs
201    )
202}
203
204/// The class to HASH a node or embedded value with: the exact versioned class
205/// only. A content hash is computed over the producer's predicate and type
206/// URIs, versions included, so a node written at an earlier compatible
207/// version cannot be re-hashed with a later schema — its predicates would
208/// carry the later version and the hash would differ. When only a compatible
209/// class exists, say so (`[hash-needs-exact-version]`) rather than produce a
210/// different hash or the bare "no schema" error.
211fn hash_class_for<'a>(
212    schema: &'a Json,
213    type_uri: &str,
214    what: &str,
215) -> Result<&'a Json, CodecError> {
216    let classes = classes_of(schema)?;
217    if let Some(cls) = classes.get(type_uri) {
218        return Ok(cls);
219    }
220    match class_for(classes, type_uri) {
221        Ok((found, _)) => err(format!(
222            "Cannot hash {} {}: this codec's schema has it at {}, and a content hash is \
223             computed with the producer's schema version — hashing needs the exact class \
224             [hash-needs-exact-version]",
225            what, type_uri, found
226        )),
227        Err(_) => err(format!(
228            "no schema for {} {} [unknown-type]",
229            what, type_uri
230        )),
231    }
232}
233
234/// Whether a node is an instance of the class `class_uri` — what a generated
235/// type guard asks (runtime#28). Each of the node's types (`$types`, else its
236/// `$type`) is resolved through the same compatible lookup [`deserialize`]
237/// uses, so a node written at an earlier compatible version satisfies the
238/// later version's guard; it matches when that class IS `class_uri` or has it
239/// among its `ancestors`. Classes compare by versionless identity. A type this
240/// schema cannot read (unknown, newer, another major) never matches. Errors
241/// when `class_uri` is not a class coordinate or the schema is malformed.
242/// For a typed instance, [`KanonakNode::type_matches`] asks the same question
243/// of its envelope.
244pub fn type_matches(node: &Node, class_uri: &str, schema: &Json) -> Result<bool, CodecError> {
245    let members: Vec<&str> = match node.get("$types") {
246        None | Some(Json::Null) => node
247            .get("$type")
248            .and_then(|t| t.as_str())
249            .filter(|t| !t.is_empty())
250            .into_iter()
251            .collect(),
252        Some(Json::Array(items)) => items
253            .iter()
254            .map(|t| t.as_str())
255            .collect::<Option<Vec<&str>>>()
256            .ok_or_else(|| {
257                CodecError::Malformed("type_matches: $types must be a list of type URIs".into())
258            })?,
259        Some(_) => return err("type_matches: $types must be a list of type URIs"),
260    };
261    type_matches_members(&members, class_uri, schema)
262}
263
264/// The one type-match walk, over a node's type members (`$types`, else
265/// `$type`), shared by the dictionary and typed surfaces.
266pub(crate) fn type_matches_members(
267    members: &[&str],
268    class_uri: &str,
269    schema: &Json,
270) -> Result<bool, CodecError> {
271    let target = identity_of(class_uri).ok_or_else(|| {
272        CodecError::Malformed(format!(
273            "type_matches: '{}' is not a class coordinate",
274            class_uri
275        ))
276    })?;
277    let classes = classes_of(schema)?;
278
279    for &member in members {
280        let (key, cls) = match class_for(classes, member) {
281            Ok(found) => found,
282            Err(_) => continue,
283        };
284        let type_uri = cls
285            .get("typeUri")
286            .and_then(|t| t.as_str())
287            .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'typeUri'", key)))?;
288        if identity_of(type_uri).as_deref() == Some(target.as_str()) {
289            return Ok(true);
290        }
291        match cls.get("ancestors") {
292            None | Some(Json::Null) => {}
293            Some(Json::Array(ancestors)) => {
294                for ancestor in ancestors {
295                    let ancestor = ancestor.as_str().ok_or_else(|| {
296                        CodecError::Malformed(format!(
297                            "class {}: 'ancestors' must be a list of class URIs",
298                            key
299                        ))
300                    })?;
301                    if identity_of(ancestor).as_deref() == Some(target.as_str()) {
302                        return Ok(true);
303                    }
304                }
305            }
306            Some(_) => {
307                return err(format!(
308                    "class {}: 'ancestors' must be a list of class URIs",
309                    key
310                ))
311            }
312        }
313    }
314    Ok(false)
315}
316
317/// An enumeration member found by [`enum_member`]. `#[non_exhaustive]`: read
318/// its fields; a later 1.x release may add one.
319#[derive(Debug, Clone, PartialEq)]
320#[non_exhaustive]
321pub struct EnumMemberMatch {
322    /// The enumeration class's durable URI (its key in the schema's `enums`).
323    pub enum_type: String,
324    /// The member's durable URI as this schema keys it.
325    pub uri: String,
326    /// The member's schema entry (`{"label": ...}`).
327    pub member: Json,
328}
329
330/// The enumeration member a `{"$ref": …}` names (runtime#28): the exact
331/// versioned key first, then the same member at a version that can read the
332/// written one — so a member referenced at an earlier compatible version of
333/// the package resolves against a later schema. `None` when this schema has
334/// no such member, which, as for `enums` itself, means "not mine", never
335/// "invalid".
336pub fn enum_member(schema: &Json, reference: &str) -> Option<EnumMemberMatch> {
337    let enums = schema.get("enums").and_then(|e| e.as_object())?;
338    for (enum_type, e) in enums.iter() {
339        if let Some(member) = e.get("members").and_then(|m| m.get(reference)) {
340            return Some(EnumMemberMatch {
341                enum_type: enum_type.clone(),
342                uri: reference.to_string(),
343                member: member.clone(),
344            });
345        }
346    }
347    let (key, written) = versioned_coordinate(reference)?;
348    let mut best: Option<(&str, &str, &Json, CoordinateVersion)> = None;
349    for (enum_type, e) in enums.iter() {
350        let members = match e.get("members").and_then(|m| m.as_object()) {
351            Some(members) => members,
352            None => continue,
353        };
354        for (uri, member) in members.iter() {
355            let version = match versioned_coordinate(uri) {
356                Some((k, v)) if k == key => v,
357                _ => continue,
358            };
359            if !is_readable_by(written, version) {
360                continue;
361            }
362            if best.map_or(true, |(_, _, _, b)| {
363                version_order(version) > version_order(b)
364            }) {
365                best = Some((enum_type.as_str(), uri.as_str(), member, version));
366            }
367        }
368    }
369    best.map(|(enum_type, uri, member, _)| EnumMemberMatch {
370        enum_type: enum_type.to_string(),
371        uri: uri.to_string(),
372        member: member.clone(),
373    })
374}
375
376/// Validate a node-or-embedded's `$types` envelope (0.4.0, runtime#10) and
377/// return the validated set, or `None` when the node is single-typed.
378/// Invariants: sorted by UTF-8 bytes, at least two members, no duplicates, and
379/// `$type` (the dispatch key, chosen by the schema layer's primary rule) is a
380/// member. Enforced wherever the envelope is touched — serialize, deserialize,
381/// and canonicalization — so a producer fails at emit time and a reader never
382/// masks a nondeterministic emitter by silently repairing the set.
383fn validated_types(
384    map: &Map<String, Json>,
385    where_: &str,
386) -> Result<Option<Vec<String>>, CodecError> {
387    let raw = match map.get("$types") {
388        None | Some(Json::Null) => return Ok(None),
389        Some(v) => v,
390    };
391    let items = match raw.as_array() {
392        Some(items) => items,
393        None => {
394            return err(format!(
395                "{}: $types must be a list of non-empty type URIs",
396                where_
397            ))
398        }
399    };
400    let mut types: Vec<String> = Vec::with_capacity(items.len());
401    for item in items {
402        match item.as_str() {
403            Some(s) if !s.is_empty() => types.push(s.to_string()),
404            _ => {
405                return err(format!(
406                    "{}: $types must be a list of non-empty type URIs",
407                    where_
408                ))
409            }
410        }
411    }
412    if types.len() < 2 {
413        return err(format!(
414            "{}: $types with {} member(s) is forbidden — a single-typed node carries \
415             only $type (a second encoding of the same content would be hash-ambiguous)",
416            where_,
417            types.len()
418        ));
419    }
420    for pair in types.windows(2) {
421        match pair[0].as_bytes().cmp(pair[1].as_bytes()) {
422            std::cmp::Ordering::Equal => {
423                return err(format!(
424                    "{}: $types carries duplicate member {}",
425                    where_, pair[1]
426                ))
427            }
428            std::cmp::Ordering::Greater => {
429                return err(format!(
430                    "{}: $types is not sorted by UTF-8 bytes ({} sorts after {}) — \
431                     ordering is the producer's job, never the reader's",
432                    where_, pair[0], pair[1]
433                ))
434            }
435            std::cmp::Ordering::Less => {}
436        }
437    }
438    match map.get("$type").and_then(|t| t.as_str()) {
439        Some(primary) if types.iter().any(|t| t == primary) => Ok(Some(types)),
440        other => err(format!(
441            "{}: $type ({:?}) must be present and a member of $types",
442            where_, other
443        )),
444    }
445}
446
447/// Recursively validate every `$types` envelope in a wire value (the node
448/// itself and any embedded node at any depth). Shared by [`serialize`] (the
449/// producer fails at emit time) and [`deserialize`] (the strict reader rejects
450/// rather than repairs).
451fn assert_types_envelopes(value: &Json, where_: &str) -> Result<(), CodecError> {
452    match value {
453        Json::Array(items) => {
454            for (i, item) in items.iter().enumerate() {
455                assert_types_envelopes(item, &format!("{}[{}]", where_, i))?;
456            }
457            Ok(())
458        }
459        Json::Object(map) => assert_types_envelopes_map(map, where_),
460        _ => Ok(()),
461    }
462}
463
464fn assert_types_envelopes_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
465    if map.contains_key("$types") {
466        validated_types(map, where_)?;
467    }
468    for (key, value) in map.iter() {
469        if key != "$types" {
470            assert_types_envelopes(value, &format!("{}.{}", where_, key))?;
471        }
472    }
473    Ok(())
474}
475
476/// Every reference value (`{"$ref": uri}`) in a wire value, at any depth, must
477/// address a NAMED resource: `publisher/package[@version]/name`, no URI fragment
478/// (runtime#6). Fragments address embedded resources for navigation and
479/// deep-linking; they are not part of the reference graph, and accepting one
480/// here would establish that capability by accident. Shared by [`serialize`],
481/// [`deserialize`] and canonicalization, so all three reject the same input.
482fn assert_reference_values(value: &Json, where_: &str) -> Result<(), CodecError> {
483    match value {
484        Json::Array(items) => {
485            for (i, item) in items.iter().enumerate() {
486                assert_reference_values(item, &format!("{}[{}]", where_, i))?;
487            }
488            Ok(())
489        }
490        Json::Object(map) => assert_reference_values_map(map, where_),
491        _ => Ok(()),
492    }
493}
494
495fn assert_reference_values_map(map: &Map<String, Json>, where_: &str) -> Result<(), CodecError> {
496    if let Some(Json::String(uri)) = map.get("$ref") {
497        assert_named_reference(uri, where_)?;
498    }
499    for (key, value) in map.iter() {
500        assert_reference_values(value, &format!("{}.{}", where_, key))?;
501    }
502    Ok(())
503}
504
505fn assert_named_reference(uri: &str, where_: &str) -> Result<(), CodecError> {
506    if uri.contains('#') {
507        return err(format!(
508            "{}: reference value '{}' contains a URI fragment. A reference addresses a named \
509             resource (publisher/package[@version]/name); fragments are a navigation convention, \
510             not part of the reference graph [fragment-reference]",
511            where_, uri
512        ));
513    }
514    Ok(())
515}
516
517/// Build a single canonical `Value` for one (non-list) field datum, per its
518/// schema prop.
519fn build_value(prop: &Json, raw: &Json, schema: &Json) -> Result<Value, CodecError> {
520    let kind = prop
521        .get("kind")
522        .and_then(|k| k.as_str())
523        .ok_or_else(|| CodecError::Malformed("schema prop is missing 'kind'".into()))?;
524    if kind == "object" {
525        // A node: a reference (`{"$ref"}`) or an embedded resource.
526        if let Some(reference) = raw.get("$ref").and_then(|r| r.as_str()) {
527            return Ok(Value::Reference(reference.to_string()));
528        }
529        if let Some(map) = raw.as_object() {
530            return embedded_value(prop, map, schema);
531        }
532        return err(format!(
533            "Object property expects a reference ({{\"$ref\": ...}}) or an embedded \
534             node (a map), got {}",
535            raw
536        ));
537    }
538    let datatype = prop
539        .get("datatype")
540        .and_then(|d| d.as_str())
541        .ok_or_else(|| CodecError::Malformed("datatype prop is missing 'datatype'".into()))?;
542    match carrier_of(datatype) {
543        None => Ok(Value::Raw(lexical(raw))),
544        Some(carrier) => Ok(Value::Typed {
545            carrier,
546            lexical: lexical(raw),
547        }),
548    }
549}
550
551/// Canonicalize an embedded value (0.2.0): a map with no `$id`, an optional
552/// `$name` (the authored dict-key — hash-relevant), an optional `$type`, and
553/// schema-mapped fields. An explicit `$type` emits a type statement inside the
554/// embedded (hash-relevant even when it equals the range-derived type); without
555/// it, fields map via the containing property's `range` and NO type statement is
556/// emitted — range-derived typing is inference only.
557fn embedded_value(
558    prop: &Json,
559    map: &Map<String, Json>,
560    schema: &Json,
561) -> Result<Value, CodecError> {
562    if map.contains_key("$id") {
563        return err(
564            "An embedded value must not carry $id — to point at a named resource, \
565             pass a reference ({\"$ref\": ...}).",
566        );
567    }
568    let types = validated_types(map, "Embedded value")?;
569    let explicit_type = map.get("$type").and_then(|t| t.as_str());
570    let cls_uri = match explicit_type.or_else(|| prop.get("range").and_then(|r| r.as_str())) {
571        Some(uri) => uri,
572        None => {
573            return err(
574                "Cannot map embedded value: it carries no $type and the property \
575                 declares no range.",
576            )
577        }
578    };
579    let cls = hash_class_for(schema, cls_uri, "embedded type")?;
580    let props = cls
581        .get("props")
582        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
583
584    let mut statements = field_statements(map, cls_uri, props, schema)?;
585    if let Some(members) = types {
586        // A multi-typed embedded ($types implies an explicit $type): one type
587        // statement per member, in $types (UTF-8 sorted) order — all hash-relevant.
588        let type_predicate = schema
589            .get("typePredicate")
590            .and_then(|p| p.as_str())
591            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
592        for member in members {
593            statements.push(Statement {
594                predicate: type_predicate.to_string(),
595                value: Value::Reference(member),
596            });
597        }
598    } else if let Some(type_uri) = explicit_type {
599        let type_predicate = schema
600            .get("typePredicate")
601            .and_then(|p| p.as_str())
602            .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
603        statements.push(Statement {
604            predicate: type_predicate.to_string(),
605            value: Value::Reference(type_uri.to_string()),
606        });
607    }
608    let name = map
609        .get("$name")
610        .and_then(|n| n.as_str())
611        .filter(|s| !s.is_empty())
612        .map(|s| s.to_string());
613    Ok(Value::Embedded { name, statements })
614}
615
616/// The statements for one node-or-embedded's modeled fields + its `$extra` —
617/// everything except the type triple (subjects always carry one; embeddeds only
618/// when explicitly typed).
619fn field_statements(
620    source: &Map<String, Json>,
621    class_uri: &str,
622    props: &Json,
623    schema: &Json,
624) -> Result<Vec<Statement>, CodecError> {
625    let mut out: Vec<Statement> = Vec::new();
626
627    for (key, raw) in source.iter() {
628        if ENVELOPE_KEYS.contains(&key.as_str()) || raw.is_null() {
629            continue;
630        }
631        match props.get(key) {
632            None => return undeclared_statement(class_uri, &format!("\"{}\"", key)),
633            Some(prop) => {
634                let predicate =
635                    prop.get("predicate")
636                        .and_then(|p| p.as_str())
637                        .ok_or_else(|| {
638                            CodecError::Malformed(format!("prop {} is missing 'predicate'", key))
639                        })?;
640                let value = match raw.as_array() {
641                    Some(items) => {
642                        // An empty list contributes NO statement — absent and empty
643                        // are identical at the canonical layer (the wire serialize
644                        // still preserves the empty list).
645                        if items.is_empty() {
646                            continue;
647                        }
648                        let mut list = Vec::with_capacity(items.len());
649                        for item in items {
650                            list.push(build_value(prop, item, schema)?);
651                        }
652                        Value::List(list)
653                    }
654                    None => build_value(prop, raw, schema)?,
655                };
656                out.push(Statement {
657                    predicate: predicate.to_string(),
658                    value,
659                });
660            }
661        }
662    }
663
664    // Open-world extras ride through serialize and deserialize, but this codec
665    // can't type them, so it refuses to hash them rather than hash them as text.
666    if let Some(extra) = source.get("$extra") {
667        let extra = extra
668            .as_object()
669            .ok_or_else(|| CodecError::Malformed("$extra must be an object".into()))?;
670        for (predicate, raw) in extra.iter() {
671            if raw.is_null() {
672                continue;
673            }
674            return undeclared_statement(class_uri, &format!("$extra statement {}", predicate));
675        }
676    }
677    Ok(out)
678}
679
680/// A codec holds one package's schema, so it can't know the range of a
681/// predicate another package declares on its classes (open-world augmentation).
682/// Hashing such a statement as untyped text would give a hash that differs from
683/// `kanonak hash`'s without a word, so hashing refuses it, naming it
684/// (`[hash-undeclared-statement]`). Serializing and deserializing carry it.
685fn undeclared_statement<T>(class_uri: &str, what: &str) -> Result<T, CodecError> {
686    err(format!(
687        "Cannot hash {}'s {}: this codec's schema doesn't declare it, so it can't type its value. \
688         Hash it with the SDK, over the graph [hash-undeclared-statement]",
689        class_uri, what
690    ))
691}
692
693/// The statements for one subject node: the rdf:type triple(s), then its fields.
694fn statements(node: &Node, schema: &Json) -> Result<Vec<Statement>, CodecError> {
695    let types = {
696        let id = node
697            .get("$id")
698            .and_then(|i| i.as_str())
699            .unwrap_or("(no $id)");
700        let where_ = format!("Node {}", id);
701        let types = validated_types(node, &where_)?;
702        assert_reference_values_map(node, &where_)?;
703        types
704    };
705    let type_uri = node
706        .get("$type")
707        .and_then(|t| t.as_str())
708        .filter(|s| !s.is_empty())
709        .ok_or_else(|| CodecError::Malformed("node is missing $type".into()))?;
710
711    let cls = hash_class_for(schema, type_uri, "type")?;
712    let props = cls
713        .get("props")
714        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", type_uri)))?;
715
716    let type_predicate = schema
717        .get("typePredicate")
718        .and_then(|p| p.as_str())
719        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
720
721    // The rdf:type triple(s) every subject carries: one per $types member for a
722    // multi-typed node (in $types' UTF-8 sorted order), else the single $type.
723    let members = types.unwrap_or_else(|| vec![type_uri.to_string()]);
724    let mut out: Vec<Statement> = members
725        .into_iter()
726        .map(|member| Statement {
727            predicate: type_predicate.to_string(),
728            value: Value::Reference(member),
729        })
730        .collect();
731    out.extend(field_statements(node, type_uri, props, schema)?);
732    Ok(out)
733}
734
735/// Build the canonical input model: a subject per node + the synthesized
736/// package-wrapper subject (raw label + `Package` type), exactly the subject set
737/// `kanonak hash` produces for the equivalent authored package.
738pub fn build_package(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<Package, CodecError> {
739    let mut subjects: Vec<Subject> = Vec::with_capacity(nodes.len() + 1);
740    for node in nodes {
741        let id = node
742            .get("$id")
743            .and_then(|i| i.as_str())
744            .filter(|s| !s.is_empty())
745            .ok_or_else(|| CodecError::Malformed("node is missing $id".into()))?;
746        subjects.push(Subject {
747            uri: id.to_string(),
748            statements: statements(node, schema)?,
749        });
750    }
751
752    let publisher = pkg
753        .get("publisher")
754        .and_then(|p| p.as_str())
755        .ok_or_else(|| CodecError::Malformed("pkg is missing 'publisher'".into()))?;
756    let package_name = pkg
757        .get("packageName")
758        .and_then(|p| p.as_str())
759        .ok_or_else(|| CodecError::Malformed("pkg is missing 'packageName'".into()))?;
760    let version = pkg
761        .get("version")
762        .and_then(|p| p.as_str())
763        .ok_or_else(|| CodecError::Malformed("pkg is missing 'version'".into()))?;
764
765    let pkg_uri = format!(
766        "{}/{}@{}/{}",
767        publisher, package_name, version, package_name
768    );
769
770    let type_predicate = schema
771        .get("typePredicate")
772        .and_then(|p| p.as_str())
773        .ok_or_else(|| CodecError::Malformed("schema is missing 'typePredicate'".into()))?;
774    let label_predicate = schema
775        .get("labelPredicate")
776        .and_then(|p| p.as_str())
777        .ok_or_else(|| CodecError::Malformed("schema is missing 'labelPredicate'".into()))?;
778    let package_type_uri = schema
779        .get("packageTypeUri")
780        .and_then(|p| p.as_str())
781        .ok_or_else(|| CodecError::Malformed("schema is missing 'packageTypeUri'".into()))?;
782
783    let mut pkg_statements: Vec<Statement> = Vec::new();
784    if let Some(label) = pkg.get("label") {
785        if !label.is_null() {
786            let label = label
787                .as_str()
788                .ok_or_else(|| CodecError::Malformed("pkg label must be a string".into()))?;
789            pkg_statements.push(Statement {
790                predicate: label_predicate.to_string(),
791                value: Value::Raw(label.to_string()),
792            });
793        }
794    }
795    pkg_statements.push(Statement {
796        predicate: type_predicate.to_string(),
797        value: Value::Reference(package_type_uri.to_string()),
798    });
799    subjects.push(Subject {
800        uri: pkg_uri,
801        statements: pkg_statements,
802    });
803
804    Ok(Package { subjects })
805}
806
807/// The canonical form (the `{subjects:[...]}` JSON) of a package from nodes.
808pub fn canonical_form(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
809    Ok(canonical_form_pkg(&build_package(nodes, schema, pkg)?)?)
810}
811
812/// The `sha256:` content hash of a package from nodes — matches `kanonak hash`.
813pub fn content_hash(nodes: &[Node], schema: &Json, pkg: &Json) -> Result<String, CodecError> {
814    Ok(canonical_hash_pkg(&build_package(nodes, schema, pkg)?)?)
815}
816
817/// Serialize a typed node to its normalized-JSON wire form. `$extra` entries ride
818/// as sibling fields after the modeled ones; a modeled field wins a name
819/// collision (`[JsonExtensionData]` semantics). No `$extra` key on the wire.
820/// Fallible since 0.4.0: an invalid `$types` envelope (at any depth) is a
821/// producer bug and fails at emit time, as does a fragment reference (runtime#6).
822pub fn serialize(node: &Node) -> Result<Node, CodecError> {
823    let where_ = node
824        .get("$id")
825        .or_else(|| node.get("$type"))
826        .and_then(|v| v.as_str())
827        .unwrap_or("(node)");
828    assert_types_envelopes_map(node, &format!("serialize {}", where_))?;
829    assert_reference_values_map(node, &format!("serialize {}", where_))?;
830    let mut out = Map::new();
831    for (key, value) in node.iter() {
832        if key == "$extra" || value.is_null() {
833            continue;
834        }
835        out.insert(key.clone(), value.clone());
836    }
837    if let Some(extra) = node.get("$extra").and_then(|e| e.as_object()) {
838        for (key, value) in extra.iter() {
839            if !value.is_null() && !out.contains_key(key) {
840                out.insert(key.clone(), value.clone());
841            }
842        }
843    }
844    Ok(out)
845}
846
847/// Parse normalized JSON into a typed node. `$`-envelope keys and fields modeled
848/// on the node's `$type` stay top-level; every other key is collected into
849/// `$extra` so a strongly-typed consumer round-trips it losslessly. Requires
850/// `$type` (the one field that cannot be inferred) and a class in the schema
851/// that can read it: the exact class, or the same class at a compatible later
852/// version (runtime#28 — see [`type_matches`] for the same rule). The node
853/// keeps the `$type` it was WRITTEN with; the producer's bytes are the
854/// producer's.
855pub fn deserialize(json_obj: &Node, schema: &Json) -> Result<Node, CodecError> {
856    let type_uri = json_obj
857        .get("$type")
858        .and_then(|t| t.as_str())
859        .ok_or_else(|| CodecError::Malformed("Cannot deserialize: missing string $type".into()))?;
860
861    // Reader-side $types validation, at every depth: an unsorted / singleton /
862    // duplicate / non-member set is REJECTED, never silently repaired —
863    // determinism belongs to the producer, and a lenient reader would mask a
864    // nondeterministic emitter.
865    {
866        let where_ = json_obj
867            .get("$id")
868            .and_then(|i| i.as_str())
869            .unwrap_or(type_uri);
870        assert_types_envelopes_map(json_obj, &format!("deserialize {}", where_))?;
871        assert_reference_values_map(json_obj, &format!("deserialize {}", where_))?;
872    }
873
874    let (cls_uri, cls) = class_for(classes_of(schema)?, type_uri)
875        .map_err(|e| CodecError::Malformed(format!("Cannot deserialize: {}", e)))?;
876    let props = cls
877        .get("props")
878        .ok_or_else(|| CodecError::Malformed(format!("class {} is missing 'props'", cls_uri)))?;
879
880    let mut node = Map::new();
881    node.insert("$type".to_string(), Json::String(type_uri.to_string()));
882    let mut extra = Map::new();
883    for (key, value) in json_obj.iter() {
884        if key == "$type" {
885            continue;
886        }
887        if key.starts_with('$') || props.get(key).is_some() {
888            node.insert(key.clone(), value.clone());
889        } else {
890            extra.insert(key.clone(), value.clone());
891        }
892    }
893    if !extra.is_empty() {
894        node.insert("$extra".to_string(), Json::Object(extra));
895    }
896    Ok(node)
897}