diff --git a/conformance/differential/README.md b/conformance/differential/README.md index 83310bb..739a26c 100644 --- a/conformance/differential/README.md +++ b/conformance/differential/README.md @@ -45,6 +45,8 @@ differential/ verdict/ -- the assembled five-field verdict artifact (build_verdict / verdict.Build) vcn_*: the OCI VCN slice, derived from ../../fixtures/vcn-v1 (see docs/INTEGRATION-CONTRACT.md) receipt/ -- the receipt verifier (verify_receipt / receipt.Verify) + schema_identity/ -- the D1.3 schema identity contract (bind_schema_identity / schemaidentity.BindSchemaIdentity); + a vector also holds the real bytes of a schema source file (see below) ``` One group per primitive, landed incrementally — matching how F6 @@ -246,3 +248,23 @@ verifier to reject it. Documents are decoded so an integer stays an integer and `1.0` stays a float (Go: `Decoder.UseNumber`), because the receipt's version is a bare integer. + +### `schema_identity/` + +A vector directory holds `input.json`, `expected.json` and the schema source file that `input.json` names. The source file is **not JSON**: its exact bytes are the thing under test (line endings, a final newline or none), so `schema_identity/.gitattributes` marks `*.yaml` as `-text` and git leaves them alone. + +```text +input.json: + source string file name of the schema source in this directory + claimed_digest string|null the digest claimed for the source (null = no claim) + namespace, kind string|null spec.identity components (null = absent) + identity_from_source bool? the Rust test checks namespace/kind against the file's own spec.identity + +expected.json: + accepted bool + schema_name, schema_digest when accepted + error: {code, rule, path} when rejected + why string context, not compared +``` + +Both languages must accept or reject identically and name the same code, rule and path. Contract: `docs/RECEIPT-SCHEMA.md`, "Schema identity contract". diff --git a/conformance/differential/schema_identity/.gitattributes b/conformance/differential/schema_identity/.gitattributes new file mode 100644 index 0000000..08f776a --- /dev/null +++ b/conformance/differential/schema_identity/.gitattributes @@ -0,0 +1,2 @@ +# The source files are fixtures whose exact bytes (line endings, a final newline or none) are the thing under test; git must not touch them. +*.yaml -text diff --git a/conformance/differential/schema_identity/claimed_digest_empty/expected.json b/conformance/differential/schema_identity/claimed_digest_empty/expected.json new file mode 100644 index 0000000..1cb8592 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_empty/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "an empty claim is a claim, and a wrong one", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_empty/input.json b/conformance/differential/schema_identity/claimed_digest_empty/input.json new file mode 100644 index 0000000..30b5e40 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_empty/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_empty/schema.yaml b/conformance/differential/schema_identity/claimed_digest_empty/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_empty/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_last_nibble_off/expected.json b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/expected.json new file mode 100644 index 0000000..8a0de00 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "a one-character difference", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_last_nibble_off/input.json b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/input.json new file mode 100644 index 0000000..b441144 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a330", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_last_nibble_off/schema.yaml b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_last_nibble_off/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_missing/expected.json b/conformance/differential/schema_identity/claimed_digest_missing/expected.json new file mode 100644 index 0000000..5941df2 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_missing/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "no claimed digest: fail closed", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISSING", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_missing/input.json b/conformance/differential/schema_identity/claimed_digest_missing/input.json new file mode 100644 index 0000000..bb1bb25 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_missing/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": null, + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_missing/schema.yaml b/conformance/differential/schema_identity/claimed_digest_missing/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_missing/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/expected.json b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/expected.json new file mode 100644 index 0000000..2a2b0ef --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "the CRLF source claiming the digest of its LF form: a normalized digest never stands in", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/input.json b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/input.json new file mode 100644 index 0000000..f838fee --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/schema.yaml b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/schema.yaml new file mode 100644 index 0000000..facbda7 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_a_normalized_copy/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/expected.json b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/expected.json new file mode 100644 index 0000000..b568e78 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "the 314-byte source with the digest of the 313-byte one", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/input.json b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/input.json new file mode 100644 index 0000000..d568a2d --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:4fff76af7c6313eae102fc1f967b9d607db0f26c51f6f5f8eb11d7c0988b07f7", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/schema.yaml b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_of_the_other_variant/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_uppercase_hex/expected.json b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/expected.json new file mode 100644 index 0000000..6e0dbd4 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "the digest form is lowercase hex", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_uppercase_hex/input.json b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/input.json new file mode 100644 index 0000000..0dce041 --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:A36E841DB3D5F108A7D5E400F80A636D095BC5C9DDC670142B938D73D0D1A338", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_uppercase_hex/schema.yaml b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_uppercase_hex/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/claimed_digest_without_prefix/expected.json b/conformance/differential/schema_identity/claimed_digest_without_prefix/expected.json new file mode 100644 index 0000000..34a580c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_without_prefix/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "the sha256: prefix is part of the value", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/claimed_digest_without_prefix/input.json b/conformance/differential/schema_identity/claimed_digest_without_prefix/input.json new file mode 100644 index 0000000..1ff056a --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_without_prefix/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/claimed_digest_without_prefix/schema.yaml b/conformance/differential/schema_identity/claimed_digest_without_prefix/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/claimed_digest_without_prefix/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/crlf_line_endings/expected.json b/conformance/differential/schema_identity/crlf_line_endings/expected.json new file mode 100644 index 0000000..db72b25 --- /dev/null +++ b/conformance/differential/schema_identity/crlf_line_endings/expected.json @@ -0,0 +1,6 @@ +{ + "accepted": true, + "why": "line endings are part of the byte identity; no CRLF to LF normalization", + "schema_name": "cic:storage:StorageResource", + "schema_digest": "sha256:b0e97e6fcb7d35585a48514acc92e2ac0145ed02f3207be811bf95d18b27e0d2" +} diff --git a/conformance/differential/schema_identity/crlf_line_endings/input.json b/conformance/differential/schema_identity/crlf_line_endings/input.json new file mode 100644 index 0000000..05de982 --- /dev/null +++ b/conformance/differential/schema_identity/crlf_line_endings/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:b0e97e6fcb7d35585a48514acc92e2ac0145ed02f3207be811bf95d18b27e0d2", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/crlf_line_endings/schema.yaml b/conformance/differential/schema_identity/crlf_line_endings/schema.yaml new file mode 100644 index 0000000..facbda7 --- /dev/null +++ b/conformance/differential/schema_identity/crlf_line_endings/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/digest_is_checked_before_identity/expected.json b/conformance/differential/schema_identity/digest_is_checked_before_identity/expected.json new file mode 100644 index 0000000..c521de8 --- /dev/null +++ b/conformance/differential/schema_identity/digest_is_checked_before_identity/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "both are wrong: the digest binding is the first reason", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/digest_is_checked_before_identity/input.json b/conformance/differential/schema_identity/digest_is_checked_before_identity/input.json new file mode 100644 index 0000000..566cf2f --- /dev/null +++ b/conformance/differential/schema_identity/digest_is_checked_before_identity/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:4fff76af7c6313eae102fc1f967b9d607db0f26c51f6f5f8eb11d7c0988b07f7", + "namespace": "", + "kind": "" +} diff --git a/conformance/differential/schema_identity/digest_is_checked_before_identity/schema.yaml b/conformance/differential/schema_identity/digest_is_checked_before_identity/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/digest_is_checked_before_identity/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/golden_with_final_lf/expected.json b/conformance/differential/schema_identity/golden_with_final_lf/expected.json new file mode 100644 index 0000000..276690d --- /dev/null +++ b/conformance/differential/schema_identity/golden_with_final_lf/expected.json @@ -0,0 +1,6 @@ +{ + "accepted": true, + "why": "the 314-byte golden source, one final LF", + "schema_name": "cic:storage:StorageResource", + "schema_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338" +} diff --git a/conformance/differential/schema_identity/golden_with_final_lf/input.json b/conformance/differential/schema_identity/golden_with_final_lf/input.json new file mode 100644 index 0000000..ab6544b --- /dev/null +++ b/conformance/differential/schema_identity/golden_with_final_lf/input.json @@ -0,0 +1,7 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "StorageResource", + "identity_from_source": true +} diff --git a/conformance/differential/schema_identity/golden_with_final_lf/schema.yaml b/conformance/differential/schema_identity/golden_with_final_lf/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/golden_with_final_lf/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/golden_without_final_lf/expected.json b/conformance/differential/schema_identity/golden_without_final_lf/expected.json new file mode 100644 index 0000000..6a941eb --- /dev/null +++ b/conformance/differential/schema_identity/golden_without_final_lf/expected.json @@ -0,0 +1,6 @@ +{ + "accepted": true, + "why": "the same file without its final LF is another identity (313 bytes)", + "schema_name": "cic:storage:StorageResource", + "schema_digest": "sha256:4fff76af7c6313eae102fc1f967b9d607db0f26c51f6f5f8eb11d7c0988b07f7" +} diff --git a/conformance/differential/schema_identity/golden_without_final_lf/input.json b/conformance/differential/schema_identity/golden_without_final_lf/input.json new file mode 100644 index 0000000..b177de0 --- /dev/null +++ b/conformance/differential/schema_identity/golden_without_final_lf/input.json @@ -0,0 +1,7 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:4fff76af7c6313eae102fc1f967b9d607db0f26c51f6f5f8eb11d7c0988b07f7", + "namespace": "cic:storage", + "kind": "StorageResource", + "identity_from_source": true +} diff --git a/conformance/differential/schema_identity/golden_without_final_lf/schema.yaml b/conformance/differential/schema_identity/golden_without_final_lf/schema.yaml new file mode 100644 index 0000000..aaac9a9 --- /dev/null +++ b/conformance/differential/schema_identity/golden_without_final_lf/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true \ No newline at end of file diff --git a/conformance/differential/schema_identity/kind_contains_colon/expected.json b/conformance/differential/schema_identity/kind_contains_colon/expected.json new file mode 100644 index 0000000..5703872 --- /dev/null +++ b/conformance/differential/schema_identity/kind_contains_colon/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "a colon in the kind would make the last separator ambiguous", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-KIND", + "path": "$.spec.identity.kind" + } +} diff --git a/conformance/differential/schema_identity/kind_contains_colon/input.json b/conformance/differential/schema_identity/kind_contains_colon/input.json new file mode 100644 index 0000000..6621428 --- /dev/null +++ b/conformance/differential/schema_identity/kind_contains_colon/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "Storage:Resource" +} diff --git a/conformance/differential/schema_identity/kind_contains_colon/schema.yaml b/conformance/differential/schema_identity/kind_contains_colon/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/kind_contains_colon/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/kind_empty/expected.json b/conformance/differential/schema_identity/kind_empty/expected.json new file mode 100644 index 0000000..12b8bcd --- /dev/null +++ b/conformance/differential/schema_identity/kind_empty/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "empty kind", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-KIND", + "path": "$.spec.identity.kind" + } +} diff --git a/conformance/differential/schema_identity/kind_empty/input.json b/conformance/differential/schema_identity/kind_empty/input.json new file mode 100644 index 0000000..e05081c --- /dev/null +++ b/conformance/differential/schema_identity/kind_empty/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "" +} diff --git a/conformance/differential/schema_identity/kind_empty/schema.yaml b/conformance/differential/schema_identity/kind_empty/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/kind_empty/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/kind_missing/expected.json b/conformance/differential/schema_identity/kind_missing/expected.json new file mode 100644 index 0000000..8ec3f4f --- /dev/null +++ b/conformance/differential/schema_identity/kind_missing/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "absent kind", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-KIND", + "path": "$.spec.identity.kind" + } +} diff --git a/conformance/differential/schema_identity/kind_missing/input.json b/conformance/differential/schema_identity/kind_missing/input.json new file mode 100644 index 0000000..f673f68 --- /dev/null +++ b/conformance/differential/schema_identity/kind_missing/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": null +} diff --git a/conformance/differential/schema_identity/kind_missing/schema.yaml b/conformance/differential/schema_identity/kind_missing/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/kind_missing/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/name_is_used_as_authored/expected.json b/conformance/differential/schema_identity/name_is_used_as_authored/expected.json new file mode 100644 index 0000000..ca04c5f --- /dev/null +++ b/conformance/differential/schema_identity/name_is_used_as_authored/expected.json @@ -0,0 +1,6 @@ +{ + "accepted": true, + "why": "no trimming, no case folding: the name is the two strings joined", + "schema_name": " Cic:Storage:storageResource ", + "schema_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338" +} diff --git a/conformance/differential/schema_identity/name_is_used_as_authored/input.json b/conformance/differential/schema_identity/name_is_used_as_authored/input.json new file mode 100644 index 0000000..e220ba0 --- /dev/null +++ b/conformance/differential/schema_identity/name_is_used_as_authored/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": " Cic:Storage", + "kind": "storageResource " +} diff --git a/conformance/differential/schema_identity/name_is_used_as_authored/schema.yaml b/conformance/differential/schema_identity/name_is_used_as_authored/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/name_is_used_as_authored/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_double_colon/expected.json b/conformance/differential/schema_identity/namespace_double_colon/expected.json new file mode 100644 index 0000000..69423ca --- /dev/null +++ b/conformance/differential/schema_identity/namespace_double_colon/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "empty inner component", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_double_colon/input.json b/conformance/differential/schema_identity/namespace_double_colon/input.json new file mode 100644 index 0000000..48dfeb6 --- /dev/null +++ b/conformance/differential/schema_identity/namespace_double_colon/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic::storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/namespace_double_colon/schema.yaml b/conformance/differential/schema_identity/namespace_double_colon/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_double_colon/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_empty/expected.json b/conformance/differential/schema_identity/namespace_empty/expected.json new file mode 100644 index 0000000..d80898d --- /dev/null +++ b/conformance/differential/schema_identity/namespace_empty/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "empty namespace", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_empty/input.json b/conformance/differential/schema_identity/namespace_empty/input.json new file mode 100644 index 0000000..afa2a1a --- /dev/null +++ b/conformance/differential/schema_identity/namespace_empty/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/namespace_empty/schema.yaml b/conformance/differential/schema_identity/namespace_empty/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_empty/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_is_checked_before_kind/expected.json b/conformance/differential/schema_identity/namespace_is_checked_before_kind/expected.json new file mode 100644 index 0000000..ccaa3fd --- /dev/null +++ b/conformance/differential/schema_identity/namespace_is_checked_before_kind/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "both are wrong: the namespace is the first reason", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_is_checked_before_kind/input.json b/conformance/differential/schema_identity/namespace_is_checked_before_kind/input.json new file mode 100644 index 0000000..4465ce1 --- /dev/null +++ b/conformance/differential/schema_identity/namespace_is_checked_before_kind/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "", + "kind": "" +} diff --git a/conformance/differential/schema_identity/namespace_is_checked_before_kind/schema.yaml b/conformance/differential/schema_identity/namespace_is_checked_before_kind/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_is_checked_before_kind/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_leading_colon/expected.json b/conformance/differential/schema_identity/namespace_leading_colon/expected.json new file mode 100644 index 0000000..462c6db --- /dev/null +++ b/conformance/differential/schema_identity/namespace_leading_colon/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "empty first component", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_leading_colon/input.json b/conformance/differential/schema_identity/namespace_leading_colon/input.json new file mode 100644 index 0000000..6b75c37 --- /dev/null +++ b/conformance/differential/schema_identity/namespace_leading_colon/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": ":storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/namespace_leading_colon/schema.yaml b/conformance/differential/schema_identity/namespace_leading_colon/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_leading_colon/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_missing/expected.json b/conformance/differential/schema_identity/namespace_missing/expected.json new file mode 100644 index 0000000..38660bd --- /dev/null +++ b/conformance/differential/schema_identity/namespace_missing/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "absent namespace", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_missing/input.json b/conformance/differential/schema_identity/namespace_missing/input.json new file mode 100644 index 0000000..0bd5e58 --- /dev/null +++ b/conformance/differential/schema_identity/namespace_missing/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": null, + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/namespace_missing/schema.yaml b/conformance/differential/schema_identity/namespace_missing/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_missing/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/namespace_trailing_colon/expected.json b/conformance/differential/schema_identity/namespace_trailing_colon/expected.json new file mode 100644 index 0000000..c054a9f --- /dev/null +++ b/conformance/differential/schema_identity/namespace_trailing_colon/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "empty last component", + "error": { + "code": "E_INVALID_SCHEMA_IDENTITY", + "rule": "R-SCHEMA-NAME-NAMESPACE", + "path": "$.spec.identity.namespace" + } +} diff --git a/conformance/differential/schema_identity/namespace_trailing_colon/input.json b/conformance/differential/schema_identity/namespace_trailing_colon/input.json new file mode 100644 index 0000000..4b20d3a --- /dev/null +++ b/conformance/differential/schema_identity/namespace_trailing_colon/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/namespace_trailing_colon/schema.yaml b/conformance/differential/schema_identity/namespace_trailing_colon/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/namespace_trailing_colon/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/single_component_namespace/expected.json b/conformance/differential/schema_identity/single_component_namespace/expected.json new file mode 100644 index 0000000..97ae7f8 --- /dev/null +++ b/conformance/differential/schema_identity/single_component_namespace/expected.json @@ -0,0 +1,6 @@ +{ + "accepted": true, + "why": "a namespace of one component is well formed", + "schema_name": "storage:StorageResource", + "schema_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338" +} diff --git a/conformance/differential/schema_identity/single_component_namespace/input.json b/conformance/differential/schema_identity/single_component_namespace/input.json new file mode 100644 index 0000000..2d5595d --- /dev/null +++ b/conformance/differential/schema_identity/single_component_namespace/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/single_component_namespace/schema.yaml b/conformance/differential/schema_identity/single_component_namespace/schema.yaml new file mode 100644 index 0000000..036231c --- /dev/null +++ b/conformance/differential/schema_identity/single_component_namespace/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.0 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/conformance/differential/schema_identity/source_bytes_altered/expected.json b/conformance/differential/schema_identity/source_bytes_altered/expected.json new file mode 100644 index 0000000..ffc2a1b --- /dev/null +++ b/conformance/differential/schema_identity/source_bytes_altered/expected.json @@ -0,0 +1,9 @@ +{ + "accepted": false, + "why": "one byte of the source differs from the pinned bytes", + "error": { + "code": "E_SCHEMA_SOURCE_BINDING", + "rule": "R-SCHEMA-DIGEST-MISMATCH", + "path": "$.schema_digest" + } +} diff --git a/conformance/differential/schema_identity/source_bytes_altered/input.json b/conformance/differential/schema_identity/source_bytes_altered/input.json new file mode 100644 index 0000000..f838fee --- /dev/null +++ b/conformance/differential/schema_identity/source_bytes_altered/input.json @@ -0,0 +1,6 @@ +{ + "source": "schema.yaml", + "claimed_digest": "sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338", + "namespace": "cic:storage", + "kind": "StorageResource" +} diff --git a/conformance/differential/schema_identity/source_bytes_altered/schema.yaml b/conformance/differential/schema_identity/source_bytes_altered/schema.yaml new file mode 100644 index 0000000..33364b6 --- /dev/null +++ b/conformance/differential/schema_identity/source_bytes_altered/schema.yaml @@ -0,0 +1,13 @@ +spec: + identity: + atomic_ref: "cic:core:Identity@v0.2.0" + kind: StorageResource + namespace: "cic:storage" + version: v1.2.1 + config_surface: + aggregate_ref: "cic:core:ConfigSurface@v0.2.0" + nodes: + - name: type + shape_type: scalar + scalar_type: string + mandatory: true diff --git a/docs/MATERIALIZATION-SPEC.md b/docs/MATERIALIZATION-SPEC.md index 2811996..c6aa2d2 100644 --- a/docs/MATERIALIZATION-SPEC.md +++ b/docs/MATERIALIZATION-SPEC.md @@ -1585,12 +1585,37 @@ to catch. ```text schema logical identity / canonical name (e.g. cic:storage:StorageResource) schema version (e.g. v1.2.0) - canonical schema digest (content hash, not a tag) + schema digest (content hash, not a tag) ``` All three vary together **per schema**, independently of the grammar it's written against — a schema can bump its own version (new field, narrowed conformance, ...) without the grammar changing at all, and vice versa. + + **Decided (owner decision, `cic-primitive-engine#63`): what the name + and the digest are.** The earlier wording "canonical schema digest" + named a canonical form without defining an algorithm that makes a + YAML schema into one; it is replaced by a digest of the **source + bytes**: + - `schema_digest = "sha256:" + lowercase_hex(SHA256(raw_schema_file_bytes))`, + over the complete file as pinned and published by its source + authority. No parsing, normalization or re-serialization comes + first: comments, whitespace, key order, line endings and a final + newline (or its absence) are part of the identity. It is the + immutable identity of the *content*, not a claim that two + differently written documents mean the same. It is not the section A + digest of a value (A7 spells both the same way; the input differs). + A digest of other bytes never stands in for the source's; a missing + or mismatching binding fails closed. + - `schema_name = identity.namespace + ":" + identity.kind`, both from + the pinned schema's `spec.identity`, used as authored (no trimming, + case folding or escaping). Both are non-empty; the namespace may hold + colon-separated components (`cic:storage`), none of them empty; the + kind holds no colon. An identity that does not fit refuses the + receipt; no name is invented. + + The wire-level statement, the check order, the rejection codes and the + golden vectors are in `RECEIPT-SCHEMA.md`, "Schema identity contract". 4. **Validator/engine identity** — which *implementation* materialized this data, and its own version: e.g. `cic-primitive-engine-rust v0.1.0` vs `cic-primitive-engine-go v0.1.0`. Required specifically diff --git a/docs/RECEIPT-SCHEMA.md b/docs/RECEIPT-SCHEMA.md index de0817b..9206f28 100644 --- a/docs/RECEIPT-SCHEMA.md +++ b/docs/RECEIPT-SCHEMA.md @@ -53,7 +53,9 @@ schema_name string D1.3 -- logical/canonical name, e.g. "cic:storage: StorageResource" schema_version string D1.3 -- e.g. "v1.2.0" -schema_digest string (sha256:…) D1.3 -- canonical schema digest +schema_digest string (sha256:…) D1.3 -- digest of the raw schema + source bytes; see "Schema + identity contract" engine_identity string D1.4 -- which implementation, e.g. "cic-primitive- @@ -259,6 +261,42 @@ materialized tree doesn't already satisfy. If a real audit workflow later needs that, it is a new, concretely-motivated decision for then — not one to pre-empt now on spec alone. +## Schema identity contract + +Two questions D1.3 left open are decided (owner decision, `cic-primitive-engine#63`): which bytes `schema_digest` is the digest of, and how `schema_name` is formed. The code that fixes them is `engine/src/schema_identity.rs` and its Go peer `go/schemaidentity`; the corpus is `conformance/differential/schema_identity/`. + +**`schema_digest`** is `"sha256:"` and the lowercase hex of the SHA-256 of the **complete raw bytes of the pinned schema source file**, as its source authority published it: + +- not a Git blob or object id, and not the digest of a parsed, normalized, re-serialized or canonicalized document: comments, whitespace, mapping order, line endings and the presence of a final newline are part of the identity (the 314-byte golden file and the same file without its final LF, 313 bytes, have different digests; so does a CRLF copy); +- it is immutable *content* identity, not semantic equivalence between differently written documents, and it is not the section A digest of a value (the spelling `sha256:` + hex is A7's; the input is different); +- it is the digest of the **pinned source**. A producer that preprocesses the schema must not put the digest of the preprocessed bytes in the field. A claim that is absent, or that is not exactly the digest of the source bytes (wrong value, uppercase hex, no `sha256:` prefix, empty), is refused. + +**`schema_name`** is `identity.namespace + ":" + identity.kind`, both read from the pinned schema's `spec.identity` and used exactly as authored: + +- both are non-empty strings; the namespace is colon-separated components, none empty (`cic:storage` is two; a leading, trailing or doubled colon is an empty component); the kind holds no colon, so the last separator of the name is the one the rule put there; +- no trimming, case folding, escaping or other normalization: `" Cic:Storage"` and `"storageResource "` give `" Cic:Storage:storageResource "`; +- a missing or invalid component refuses the receipt; the producer does not invent an identifier. Example: namespace `cic:storage` and kind `StorageResource` give `cic:storage:StorageResource`. + +**Check order** (both languages name the same first reason): the digest binding, then the namespace, then the kind. + +| Rule | Code | Raised when | +|---|---|---| +| `R-SCHEMA-DIGEST-MISSING` | `E_SCHEMA_SOURCE_BINDING` | no digest is claimed for the source | +| `R-SCHEMA-DIGEST-MISMATCH` | `E_SCHEMA_SOURCE_BINDING` | the claim is not the digest of the source bytes | +| `R-SCHEMA-NAME-NAMESPACE` | `E_INVALID_SCHEMA_IDENTITY` | the namespace is absent, empty, or has an empty component | +| `R-SCHEMA-NAME-KIND` | `E_INVALID_SCHEMA_IDENTITY` | the kind is absent, empty, or holds a colon | + +**What this does and does not establish.** It defines what two fields mean and refuses a claim that disagrees with the source bytes it is given. It does not authenticate where those bytes came from, and it does not make the receipt verifier (`RECEIPT-VERIFIER.md`) check anything new: `verify_receipt` still checks `schema_name` for non-emptiness and `schema_digest` for form only, and still has no schema parser. The origin of the source and of the primitive release is a separate gate, open until a verified release record exists; nothing here authorizes a production receipt. + +**Golden vectors** (the two files below are real bytes in the corpus, not strings in a document; their digests were recomputed from the files with `sha256sum` and `openssl dgst`, apart from the code under test): + +| Source | Bytes | `schema_digest` | +|---|---|---| +| `golden_with_final_lf/schema.yaml` | 314 | `sha256:a36e841db3d5f108a7d5e400f80a636d095bc5c9ddc670142b938d73d0d1a338` | +| `golden_without_final_lf/schema.yaml` | 313 | `sha256:4fff76af7c6313eae102fc1f967b9d607db0f26c51f6f5f8eb11d7c0988b07f7` | + +The corpus holds 5 accepted and 18 rejected vectors (a repeated claim of another variant's digest, a normalized copy's digest, one altered source byte, a one-character digest difference, uppercase hex, no prefix, an empty claim, every identity component absent or empty, the colon cases, and the two check-order cases). Each language also flips every one of the 314 bytes of the golden source and requires the mismatch rule. + ## Not decided here - `conformance_plan_digest`/`observation_digest` — F5 already confirmed diff --git a/engine/src/error.rs b/engine/src/error.rs index b8c165a..e88c475 100644 --- a/engine/src/error.rs +++ b/engine/src/error.rs @@ -160,6 +160,19 @@ pub mod code { /// object key (`R-RECEIPT-PATH-BINDING`). Refused, never partially /// accepted. Raised by `receipt::verify_receipt`. pub const RECEIPT_UNBOUND: &str = "E_RECEIPT_UNBOUND"; + + /// A schema's logical name cannot be formed: `identity.namespace` or + /// `identity.kind` is absent, empty, or breaks the colon rules + /// (`R-SCHEMA-NAME-NAMESPACE`, `R-SCHEMA-NAME-KIND`; D1.3, + /// `docs/RECEIPT-SCHEMA.md`). Raised by `schema_identity::schema_name`. + pub const INVALID_SCHEMA_IDENTITY: &str = "E_INVALID_SCHEMA_IDENTITY"; + + /// The digest claimed for a schema source is absent, or is not the + /// digest of the source bytes (`R-SCHEMA-DIGEST-MISSING`, + /// `R-SCHEMA-DIGEST-MISMATCH`; D1.3). Refused, never replaced by the + /// digest of other bytes. Raised by + /// `schema_identity::bind_schema_identity`. + pub const SCHEMA_SOURCE_BINDING: &str = "E_SCHEMA_SOURCE_BINDING"; } /// The single error type this crate raises. diff --git a/engine/src/lib.rs b/engine/src/lib.rs index ab1eda2..f663ff0 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -227,6 +227,7 @@ pub mod materialized; pub mod plan; pub mod reader; pub mod receipt; +pub mod schema_identity; pub mod verdict; pub use canonical::{digest, to_canonical_json}; @@ -249,6 +250,7 @@ pub use plan::{ pub use receipt::{ verify_receipt, DefaultEvidence, DerivedEvidence, PathBinding, Receipt, RECEIPT_SCHEMA_VERSION, }; +pub use schema_identity::{bind_schema_identity, schema_digest, schema_name, SchemaIdentity}; pub use verdict::{ build_verdict, evaluate_verdict, verdict_canonical_bytes, verdict_value, ConformanceVerdict, VERDICT_SCHEMA_VERSION, diff --git a/engine/src/schema_identity.rs b/engine/src/schema_identity.rs new file mode 100644 index 0000000..0836ba2 --- /dev/null +++ b/engine/src/schema_identity.rs @@ -0,0 +1,194 @@ +//! Domain schema identity (D1.3): the `schema_name` and the `schema_digest` of a receipt, and the binding of a claimed digest to the source bytes +//! (`docs/RECEIPT-SCHEMA.md`, "Schema identity contract"; `docs/MATERIALIZATION-SPEC.md`, D1.3). The Go peer is `go/schemaidentity`. +//! +//! This module defines what two receipt fields **mean**. It does not read a schema, authenticate where one came from, or verify a receipt: +//! +//! - `schema_digest` is the digest of the **raw bytes of the pinned schema source file**: `"sha256:"` and the lowercase hex of the SHA-256 of +//! exactly the bytes the source authority published. Nothing is parsed, normalized or re-serialized first, so a comment, the key order, the +//! line endings and a missing final newline are all part of the identity. It is the immutable identity of the *content*, not a claim that two +//! differently written YAML documents are equal. It is not the section A digest of a value, though it is spelled the same way (A7). +//! - `schema_name` is `identity.namespace + ":" + identity.kind`, both read from the pinned schema's `spec.identity` and used as authored. +//! +//! The caller passes the identity components; this crate has no schema reader (`lib.rs`, "Division of labor"). A missing component is +//! `None`, an empty one is `Some("")`, and both are refused. +//! +//! The order of the checks is part of the contract (both languages must name the same first reason): the digest binding, then the namespace, +//! then the kind. + +use crate::canonical; +use crate::error::{code, Error, Result, Stage}; + +/// The two identity fields of a receipt, as this contract defines them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SchemaIdentity { + pub schema_name: String, + pub schema_digest: String, +} + +/// `"sha256:"` and the lowercase hex SHA-256 of `source`, taken over the bytes as they are. +#[must_use] +pub fn schema_digest(source: &[u8]) -> String { + canonical::digest(source) +} + +/// `namespace + ":" + kind`, exactly as given, if both are well formed. +/// +/// The namespace is non-empty and made of non-empty components separated by `:` (`cic:storage`); the kind is non-empty and holds no `:`, so the +/// last separator of the name is always the one this function put there. Nothing is trimmed, case-folded or escaped. +/// +/// # Errors +/// `E_INVALID_SCHEMA_IDENTITY`, rule `R-SCHEMA-NAME-NAMESPACE` or `R-SCHEMA-NAME-KIND`. +pub fn schema_name(namespace: Option<&str>, kind: Option<&str>) -> Result { + let Some(namespace) = namespace else { + return Err(invalid_identity( + "R-SCHEMA-NAME-NAMESPACE", + "$.spec.identity.namespace", + "the namespace is absent", + )); + }; + if namespace.is_empty() || namespace.split(':').any(str::is_empty) { + return Err(invalid_identity( + "R-SCHEMA-NAME-NAMESPACE", + "$.spec.identity.namespace", + "the namespace is empty or has an empty component", + )); + } + let Some(kind) = kind else { + return Err(invalid_identity( + "R-SCHEMA-NAME-KIND", + "$.spec.identity.kind", + "the kind is absent", + )); + }; + if kind.is_empty() || kind.contains(':') { + return Err(invalid_identity( + "R-SCHEMA-NAME-KIND", + "$.spec.identity.kind", + "the kind is empty or contains a colon", + )); + } + Ok(format!("{namespace}:{kind}")) +} + +/// Binds `claimed_digest` to the source bytes it must describe and returns the identity of the schema. +/// +/// A claim that is absent, or that is not exactly the digest of `source`, is refused: a digest of other bytes (a preprocessed copy, a +/// re-serialized one) never stands in for the source. Consistency only: where `source` came from is not established here. +/// +/// # Errors +/// `E_SCHEMA_SOURCE_BINDING` (rule `R-SCHEMA-DIGEST-MISSING` or `R-SCHEMA-DIGEST-MISMATCH`), else the errors of [`schema_name`]. +pub fn bind_schema_identity( + source: &[u8], + claimed_digest: Option<&str>, + namespace: Option<&str>, + kind: Option<&str>, +) -> Result { + let Some(claimed) = claimed_digest else { + return Err(binding( + "R-SCHEMA-DIGEST-MISSING", + "no digest is claimed for the schema source", + )); + }; + let actual = schema_digest(source); + if claimed != actual { + return Err(binding( + "R-SCHEMA-DIGEST-MISMATCH", + "the claimed digest is not the digest of the source bytes", + )); + } + Ok(SchemaIdentity { + schema_name: schema_name(namespace, kind)?, + schema_digest: actual, + }) +} + +fn invalid_identity(rule: &'static str, path: &str, detail: &str) -> Error { + Error::new( + code::INVALID_SCHEMA_IDENTITY, + rule, + Stage::Validate, + path, + detail, + ) +} + +fn binding(rule: &'static str, detail: &str) -> Error { + Error::new( + code::SCHEMA_SOURCE_BINDING, + rule, + Stage::Validate, + "$.schema_digest", + detail, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_is_over_the_raw_bytes() { + // the SHA-256 of the empty input and of "abc" (FIPS 180-4 examples) + assert_eq!( + schema_digest(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + schema_digest(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_ne!(schema_digest(b"a: 1\n"), schema_digest(b"a: 1")); + assert_ne!(schema_digest(b"a: 1\n"), schema_digest(b"a: 1\r\n")); + } + + #[test] + fn name_is_composed_exactly() { + assert_eq!( + schema_name(Some("cic:storage"), Some("StorageResource")).unwrap(), + "cic:storage:StorageResource" + ); + assert_eq!( + schema_name(Some(" Cic:Storage"), Some("kind ")).unwrap(), + " Cic:Storage:kind " + ); + } + + #[test] + fn name_refusals_name_the_component() { + for (ns, kind, rule) in [ + (None, Some("K"), "R-SCHEMA-NAME-NAMESPACE"), + (Some(""), Some("K"), "R-SCHEMA-NAME-NAMESPACE"), + (Some(":a"), Some("K"), "R-SCHEMA-NAME-NAMESPACE"), + (Some("a:"), Some("K"), "R-SCHEMA-NAME-NAMESPACE"), + (Some("a::b"), Some("K"), "R-SCHEMA-NAME-NAMESPACE"), + (Some("a"), None, "R-SCHEMA-NAME-KIND"), + (Some("a"), Some(""), "R-SCHEMA-NAME-KIND"), + (Some("a"), Some("K:x"), "R-SCHEMA-NAME-KIND"), + ] { + let e = schema_name(ns, kind).unwrap_err(); + assert_eq!( + (e.code, e.rule), + (code::INVALID_SCHEMA_IDENTITY, rule), + "{ns:?} {kind:?}" + ); + } + } + + #[test] + fn binding_checks_the_digest_first() { + let src = b"spec: {}\n"; + let good = schema_digest(src); + let e = bind_schema_identity(src, None, None, None).unwrap_err(); + assert_eq!(e.rule, "R-SCHEMA-DIGEST-MISSING"); + let e = bind_schema_identity(src, Some("sha256:00"), None, None).unwrap_err(); + assert_eq!( + (e.code, e.rule), + (code::SCHEMA_SOURCE_BINDING, "R-SCHEMA-DIGEST-MISMATCH") + ); + let e = bind_schema_identity(src, Some(&good), None, Some("K")).unwrap_err(); + assert_eq!(e.rule, "R-SCHEMA-NAME-NAMESPACE"); + let id = bind_schema_identity(src, Some(&good), Some("a:b"), Some("K")).unwrap(); + assert_eq!(id.schema_name, "a:b:K"); + assert_eq!(id.schema_digest, good); + } +} diff --git a/engine/tests/differential.rs b/engine/tests/differential.rs index b5d7519..8e62801 100644 --- a/engine/tests/differential.rs +++ b/engine/tests/differential.rs @@ -8,6 +8,7 @@ use std::fs; use std::path::{Path, PathBuf}; +use cic_primitive_engine::{bind_schema_identity, schema_digest}; use cic_primitive_engine::{ build_verdict, classify_field_value, conformance_plan_digest, evaluate, evaluate_verdict, materialized_object_digest, observation_digest, reader, to_canonical_json, @@ -1029,3 +1030,152 @@ fn receipt_vectors_detect_deliberate_divergence() { } assert!(checked >= 12, "too few mutations were exercised: {checked}"); } + +// --------------------------------------------------------------------------- +// `conformance/differential/schema_identity/` -- the D1.3 schema identity +// contract (`docs/RECEIPT-SCHEMA.md`, "Schema identity contract"). Each vector +// holds the real bytes of a schema source file next to its input.json; the Go +// peer (go/schemaidentity) reads the identical files. +// --------------------------------------------------------------------------- + +fn opt_str(v: &Value, key: &str) -> Option { + match map_get(v, key) { + Some(Value::Str(s)) => Some(s.clone()), + Some(Value::Null) | None => None, + _ => panic!("expected string or null field {key:?}"), + } +} + +/// `Ok((schema_name, schema_digest))` for an accepted vector, else the rejection's `(code, rule, path)`. +fn run_schema_identity_vector( + name: &str, + input: &Value, +) -> std::result::Result<(String, String), (String, String, String)> { + let source = fs::read( + corpus_root() + .join("schema_identity") + .join(name) + .join(str_field(input, "source")), + ) + .unwrap_or_else(|e| panic!("{name}: source: {e}")); + let claimed = opt_str(input, "claimed_digest"); + let namespace = opt_str(input, "namespace"); + let kind = opt_str(input, "kind"); + bind_schema_identity( + &source, + claimed.as_deref(), + namespace.as_deref(), + kind.as_deref(), + ) + .map(|id| (id.schema_name, id.schema_digest)) + .map_err(|e| (e.code.to_string(), e.rule.to_string(), e.path)) +} + +#[test] +fn schema_identity_vectors() { + let vectors = load_vectors("schema_identity"); + assert!( + !vectors.is_empty(), + "differential/schema_identity has no vectors -- an empty corpus trivially passes" + ); + let mut failures = Vec::new(); + let (mut accepted, mut rejected) = (0, 0); + for v in &vectors { + let got = run_schema_identity_vector(&v.name, &v.input); + if bool_field(&v.expected, "accepted") { + accepted += 1; + let want = ( + str_field(&v.expected, "schema_name").to_string(), + str_field(&v.expected, "schema_digest").to_string(), + ); + if got != Ok(want.clone()) { + failures.push(format!("{}: want {want:?}, got {got:?}", v.name)); + } + } else { + rejected += 1; + let want = map_get(&v.expected, "error").expect("a rejection vector names its error"); + let want = ( + str_field(want, "code").to_string(), + str_field(want, "rule").to_string(), + str_field(want, "path").to_string(), + ); + if got != Err(want.clone()) { + failures.push(format!("{}: want rejection {want:?}, got {got:?}", v.name)); + } + } + } + assert!( + accepted >= 5 && rejected >= 18, + "corpus too thin: {accepted} accepted, {rejected} rejected" + ); + assert!( + failures.is_empty(), + "{} of {} schema identity vectors failed:\n {}", + failures.len(), + vectors.len(), + failures.join("\n ") + ); +} + +/// The vectors marked `identity_from_source` state the namespace and the kind that the file itself declares under `spec.identity`; this +/// reads the file with the engine's own reader and checks they agree, so the corpus cannot drift from the bytes it hashes. (The Go peer has +/// no YAML reader; it checks the digests and the name composition on the same files.) +#[test] +fn schema_identity_vectors_state_what_their_source_declares() { + let mut checked = 0; + for v in load_vectors("schema_identity") { + if !matches!( + map_get(&v.input, "identity_from_source"), + Some(Value::Bool(true)) + ) { + continue; + } + let bytes = fs::read( + corpus_root() + .join("schema_identity") + .join(&v.name) + .join(str_field(&v.input, "source")), + ) + .unwrap(); + let doc = reader::parse(&bytes, Stage::Read, "$", "schema.yaml").unwrap(); + let identity = map_get(map_get(&doc, "spec").unwrap(), "identity").unwrap(); + assert_eq!( + str_field(identity, "namespace"), + str_field(&v.input, "namespace"), + "{}", + v.name + ); + assert_eq!( + str_field(identity, "kind"), + str_field(&v.input, "kind"), + "{}", + v.name + ); + checked += 1; + } + assert!(checked >= 2, "too few source-declared vectors: {checked}"); +} + +/// The harness must be able to fail: the golden source with a different byte, or with the final LF removed, must stop matching the golden +/// digest, and the digest function must not be insensitive to any one byte. +#[test] +fn schema_identity_vectors_detect_deliberate_divergence() { + let dir = corpus_root() + .join("schema_identity") + .join("golden_with_final_lf"); + let source = fs::read(dir.join("schema.yaml")).unwrap(); + assert_eq!(source.len(), 314); + let golden = schema_digest(&source); + let mut checked = 0; + for i in 0..source.len() { + let mut altered = source.clone(); + altered[i] ^= 1; + assert_ne!(schema_digest(&altered), golden, "byte {i}"); + let e = bind_schema_identity(&altered, Some(&golden), Some("cic:storage"), Some("K")) + .unwrap_err(); + assert_eq!(e.rule, "R-SCHEMA-DIGEST-MISMATCH", "byte {i}"); + checked += 1; + } + assert_eq!(checked, 314); + assert_ne!(schema_digest(&source[..313]), golden); +} diff --git a/go/schemaidentity/differential_test.go b/go/schemaidentity/differential_test.go new file mode 100644 index 0000000..9b19510 --- /dev/null +++ b/go/schemaidentity/differential_test.go @@ -0,0 +1,141 @@ +package schemaidentity + +// Runs the schema_identity group of the language-independent differential corpus in conformance/differential/ -- the Go peer of +// engine/tests/differential.rs's schema_identity_vectors and schema_identity_vectors_detect_deliberate_divergence, reading the identical +// files, the schema source bytes included. A null in input.json is a nil pointer (the component or the claim is absent). + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" +) + +func corpusRoot() string { + // go test runs in this package's directory (go/schemaidentity/). + return filepath.Join("..", "..", "conformance", "differential", "schema_identity") +} + +type vectorInput struct { + Source string `json:"source"` + ClaimedDigest *string `json:"claimed_digest"` + Namespace *string `json:"namespace"` + Kind *string `json:"kind"` +} + +type vectorError struct { + Code string `json:"code"` + Rule string `json:"rule"` + Path string `json:"path"` +} + +type vectorExpected struct { + Accepted bool `json:"accepted"` + SchemaName string `json:"schema_name"` + SchemaDigest string `json:"schema_digest"` + Error *vectorError `json:"error"` +} + +func readJSON(t *testing.T, path string, v interface{}) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + if err := json.Unmarshal(b, v); err != nil { + t.Fatalf("%s: %v", path, err) + } +} + +func vectorDirs(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(corpusRoot()) + if err != nil { + t.Fatal(err) + } + var dirs []string + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e.Name()) + } + } + sort.Strings(dirs) + return dirs +} + +func TestSchemaIdentityVectors(t *testing.T) { + dirs := vectorDirs(t) + if len(dirs) == 0 { + t.Fatal("differential/schema_identity has no vectors -- an empty corpus trivially passes") + } + accepted, rejected := 0, 0 + for _, name := range dirs { + var in vectorInput + var want vectorExpected + readJSON(t, filepath.Join(corpusRoot(), name, "input.json"), &in) + readJSON(t, filepath.Join(corpusRoot(), name, "expected.json"), &want) + source, err := os.ReadFile(filepath.Join(corpusRoot(), name, in.Source)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + got, err := BindSchemaIdentity(source, in.ClaimedDigest, in.Namespace, in.Kind) + if want.Accepted { + accepted++ + if err != nil { + t.Errorf("%s: want accepted, got %v", name, err) + } else if got.SchemaName != want.SchemaName || got.SchemaDigest != want.SchemaDigest { + t.Errorf("%s: want (%q, %q), got (%q, %q)", name, want.SchemaName, want.SchemaDigest, got.SchemaName, got.SchemaDigest) + } + continue + } + rejected++ + e, ok := err.(*Error) + if !ok { + t.Errorf("%s: want rejection %+v, got %v", name, *want.Error, err) + continue + } + if e.Code != want.Error.Code || e.Rule != want.Error.Rule || e.Path != want.Error.Path { + t.Errorf("%s: want %+v, got {%s %s %s}", name, *want.Error, e.Code, e.Rule, e.Path) + } + } + if accepted < 5 || rejected < 18 { + t.Errorf("corpus too thin: %d accepted, %d rejected", accepted, rejected) + } +} + +func TestSchemaIdentityVectorsDetectDeliberateDivergence(t *testing.T) { + source, err := os.ReadFile(filepath.Join(corpusRoot(), "golden_with_final_lf", "schema.yaml")) + if err != nil { + t.Fatal(err) + } + if len(source) != 314 { + t.Fatalf("the golden source is %d bytes, want 314", len(source)) + } + golden := SchemaDigest(source) + ns, kind := "cic:storage", "K" + for i := range source { + altered := append([]byte(nil), source...) + altered[i] ^= 1 + if SchemaDigest(altered) == golden { + t.Fatalf("byte %d: the digest does not depend on it", i) + } + _, err := BindSchemaIdentity(altered, &golden, &ns, &kind) + if e, ok := err.(*Error); !ok || e.Rule != "R-SCHEMA-DIGEST-MISMATCH" { + t.Fatalf("byte %d: want the mismatch rule, got %v", i, err) + } + } + if SchemaDigest(source[:313]) == golden { + t.Fatal("the digest ignores the final LF") + } +} + +func TestSchemaDigestKnownAnswers(t *testing.T) { + // FIPS 180-4 examples, independent of the corpus + if got := SchemaDigest(nil); got != "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("empty: %s", got) + } + if got := SchemaDigest([]byte("abc")); got != "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { + t.Errorf("abc: %s", got) + } +} diff --git a/go/schemaidentity/schemaidentity.go b/go/schemaidentity/schemaidentity.go new file mode 100644 index 0000000..c768eef --- /dev/null +++ b/go/schemaidentity/schemaidentity.go @@ -0,0 +1,100 @@ +// Package schemaidentity defines the domain schema identity of a receipt (D1.3, docs/RECEIPT-SCHEMA.md "Schema identity contract") -- the Go peer +// of this repo's engine/src/schema_identity.rs. +// +// It defines what two receipt fields mean; it does not read a schema, authenticate where one came from, or verify a receipt. +// +// - SchemaDigest is "sha256:" and the lowercase hex of the SHA-256 of the raw bytes of the pinned schema source file. Nothing is parsed, +// normalized or re-serialized first, so comments, key order, line endings and a missing final newline are all part of the identity. It is +// spelled like the A7 digest of a section A value but is not one. +// - SchemaName is identity.namespace + ":" + identity.kind, both read from the pinned schema's spec.identity and used as authored. +// +// The caller passes the identity components (this module has no schema reader). A missing component is nil, an empty one is a pointer to "", +// and both are refused. The order of the checks is part of the contract: the digest binding, then the namespace, then the kind. +package schemaidentity + +import ( + "fmt" + "strings" + + "github.com/CentralInfraCore/cic-primitive-engine/go/canonical" +) + +// Rejection codes and rules, spelled as in the Rust peer. +const ( + CodeInvalidSchemaIdentity = "E_INVALID_SCHEMA_IDENTITY" + CodeSchemaSourceBinding = "E_SCHEMA_SOURCE_BINDING" +) + +// Error is a rejection with the code/rule/path fields the Rust peer's Error carries. +type Error struct { + Code string + Rule string + Path string + Detail string +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s (%s) at %s: %s", e.Code, e.Rule, e.Path, e.Detail) +} + +// Identity is the pair of receipt fields this contract defines. +type Identity struct { + SchemaName string + SchemaDigest string +} + +// SchemaDigest is "sha256:" and the lowercase hex SHA-256 of source, taken over the bytes as they are. +func SchemaDigest(source []byte) string { return canonical.Digest(source) } + +// SchemaName is namespace + ":" + kind, exactly as given, if both are well formed. The namespace is non-empty and made of non-empty +// components separated by ":"; the kind is non-empty and holds no ":". Nothing is trimmed, case-folded or escaped. +func SchemaName(namespace, kind *string) (string, error) { + if namespace == nil { + return "", invalidIdentity("R-SCHEMA-NAME-NAMESPACE", "$.spec.identity.namespace", "the namespace is absent") + } + if *namespace == "" || hasEmptyComponent(*namespace) { + return "", invalidIdentity("R-SCHEMA-NAME-NAMESPACE", "$.spec.identity.namespace", "the namespace is empty or has an empty component") + } + if kind == nil { + return "", invalidIdentity("R-SCHEMA-NAME-KIND", "$.spec.identity.kind", "the kind is absent") + } + if *kind == "" || strings.Contains(*kind, ":") { + return "", invalidIdentity("R-SCHEMA-NAME-KIND", "$.spec.identity.kind", "the kind is empty or contains a colon") + } + return *namespace + ":" + *kind, nil +} + +func hasEmptyComponent(s string) bool { + for _, part := range strings.Split(s, ":") { + if part == "" { + return true + } + } + return false +} + +// BindSchemaIdentity binds claimedDigest to the source bytes it must describe and returns the identity of the schema. A claim that is absent +// (nil), or that is not exactly the digest of source, is refused: the digest of other bytes never stands in for the source. Consistency only: +// where source came from is not established here. +func BindSchemaIdentity(source []byte, claimedDigest, namespace, kind *string) (Identity, error) { + if claimedDigest == nil { + return Identity{}, binding("R-SCHEMA-DIGEST-MISSING", "no digest is claimed for the schema source") + } + actual := SchemaDigest(source) + if *claimedDigest != actual { + return Identity{}, binding("R-SCHEMA-DIGEST-MISMATCH", "the claimed digest is not the digest of the source bytes") + } + name, err := SchemaName(namespace, kind) + if err != nil { + return Identity{}, err + } + return Identity{SchemaName: name, SchemaDigest: actual}, nil +} + +func invalidIdentity(rule, path, detail string) *Error { + return &Error{Code: CodeInvalidSchemaIdentity, Rule: rule, Path: path, Detail: detail} +} + +func binding(rule, detail string) *Error { + return &Error{Code: CodeSchemaSourceBinding, Rule: rule, Path: "$.schema_digest", Detail: detail} +}