From 41e0ab61b59681be5f594953e831b1ad3353317d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 31 Oct 2026 23:26:58 +1100 Subject: [PATCH 1/4] test(encrypt): add read conformance --- conformance/stack-encrypt/README.md | 11 +- conformance/stack-encrypt/coverage.md | 20 +- conformance/stack-encrypt/read-contract.md | 105 ++ conformance/stack-encrypt/runtime-contract.md | 2 + conformance/stack-encrypt/v1/reads.json | 1107 +++++++++++++++++ .../2026-10-31-stack-encrypt-conformance.md | 31 +- languages/golang/encrypt/export_test.go | 33 + .../golang/encrypt/read_conformance_test.go | 261 ++++ .../stack-encrypt/tests/read_conformance.rs | 248 ++++ .../__tests__/stack-conformance-ci.test.mjs | 3 +- scripts/stack-conformance.sh | 4 +- 11 files changed, 1816 insertions(+), 9 deletions(-) create mode 100644 conformance/stack-encrypt/read-contract.md create mode 100644 conformance/stack-encrypt/v1/reads.json create mode 100644 languages/golang/encrypt/read_conformance_test.go create mode 100644 packages/stack-encrypt/tests/read_conformance.rs diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index d243933c4..3abb99362 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -20,13 +20,22 @@ native Rust against the ciphertexts Go has just produced. It never regenerates expectations. CI also runs the Go cases on its other supported host platforms. The same task runs the shared declaration acceptance/rejection corpus in both languages, with native observations of normalization. It also runs the shared -runtime cases described below. +runtime and read cases described below. The Rust runner is `packages/stack-encrypt/tests/conformance.rs`; the Go runner is `languages/golang/encrypt/conformance_test.go`. Go calls the record interface used by generated code. Testing public SDK declarations is a separate layer. Python and TypeScript runners are pending; their absence is not a passing result. +## `record-reads-v1` contract + +The [read contract](read-contract.md) defines 26 cases in `v1/reads.json` for +malformed stored records, authenticated read-type failures and context preflight. +The native runner observes retrieval stages. Go exercises the same cases through +both an uninitialised guest and an active guest, plus the normal record adapter +for representable cases. The runners are `tests/read_conformance.rs` in the Rust +crate and `encrypt/read_conformance_test.go` in the Go module. + ## `record-runtime-v1` contract The [runtime contract](runtime-contract.md) defines cases in `v1/runtime.json`: diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index a3a48eced..82c4ddc4e 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -39,12 +39,30 @@ batches; Go provider-call instrumentation remains open. | Native source validation tests | `late-wrong-type`, `late-missing`, `unknown-field` | Repeated keys, unsupported frame shapes and more output kinds | | Existing shared value-kind/operator fixtures | Runtime query cases cover selected conversion edges | Full type/operator matrices, generated SDK calls and verification | +## Read checkpoint + +`v1/reads.json` adds 26 cases for stored-shape validation, authenticated types +and context preflight. The [read contract](read-contract.md) distinguishes native +shape/pending stages and the guest's earlier `check_record` diagnostics. Native +data-key counters and Go's uninitialised guest verify preflight boundaries. + +| Existing evidence | Shared case IDs | Retained assertions | +|---|---|---| +| Native `a_stored_record_that_does_not_fit` | `missing-field`, `missing-ciphertext`, `field-not-map`, `plaintext-under-c`, `row-not-map`, `late-missing-field` | Repeated keys, other passthrough fields and output kinds | +| Native typed read/null tests | `authenticated-int32-as-*`, `authenticated-null-as-required`, `authenticated-null-as-sql`, `sql-absent-*` | Wider types and declaration combinations | +| Go `context_field_test.go`, native context tests | `context-unrestricted`, `context-expected`, `context-mismatch`, `context-late-mismatch`, `context-drop-tenant`, `context-tenant-*` | Generated APIs, queries, extension composition and stored-context tampering | +| Native and guest context-source validation | `static-context-with-expectation` | Other source combinations and empty-batch behavior | + +These are overlap mappings, not grounds for deleting whole mixed-purpose tests. +Repeated-key trees cannot be emitted by Go's map-based tree encoder; a future +raw-byte profile must cover that boundary explicitly. + ## Consolidation policy Shared semantic expectations should be maintained once and executed by each language runner. Conformance can replace duplicate case tables and fixture maintenance. It does not eliminate the need to execute the Rust and Go paths. -No existing test has been deleted in these first three slices. +No existing test has been deleted in these first four slices. | Existing evidence | Relationship to conformance | Consolidation action | |---|---|---| diff --git a/conformance/stack-encrypt/read-contract.md b/conformance/stack-encrypt/read-contract.md new file mode 100644 index 000000000..6853d5294 --- /dev/null +++ b/conformance/stack-encrypt/read-contract.md @@ -0,0 +1,105 @@ +# Record read profile + +`record-reads-v1` adds 26 shared cases in `v1/reads.json` for stored record +shapes, authenticated type checks and context preflight. It fixes the resolver +to `NoTargets`. It is experimental and covers native Rust and Go WASI. + +## Inputs and observations + +Each case has separate `write` and `read` declarations, each naming the `kind` +and `null` policy of `value`. Outputs are ciphertext and equality. `rows` uses +the runtime profile's typed scalar representation. Runners first encrypt these +rows successfully, then apply the named `mutation` to its zero-based `row`. +This supplies real ciphertext, including for negative reads. + +Without `context_field`, the context is `["conformance", "value"]`. With it, +`tenant` is a string passthrough supplying the row context; `value` is its +field identity. An optional `expected_context` constrains the stored context. +Its value must be a nonempty plain label. + +`expected` describes the native opener's stage, complete diagnostic code and +payload on refusal, typed ordered rows on success, and data-key retrieval +call count. Runners must reject unknown stages, mutations, capabilities, +profiles and input types. Cases have unique, nonempty IDs. + +| Stage | Native observation | Retrieval calls | +|---|---|---| +| `shape` | `record::decrypt` refuses before returning a pending operation | 0 | +| `preflight` | The pending opener refuses before requesting keys | 0 | +| `authenticated` | Shape checking passes; opening the authenticated value finds a type mismatch | 1 | +| `success` | Opened rows match all expected values, types and ordering | 1 when ciphertext is present; 0 for the all-absent SQL-null case | + +Counters are reset after fixture encryption. Reads must never generate data +keys. These counts observe native data-key calls, not index-key initialization +or Go's logical provider calls. + +`check_error` independently describes `record::check_record`, which needs no +cipher. An absent `check_error` requires acceptance. The Go guest runs this +check before looking up its cipher state. Both an uninitialised guest and an +active guest execute each case: preflight errors must have the specified +diagnostic in either; accepted shapes must reach `ErrState` without a cipher. +Consequently, a concealed type mismatch must pass preflight and fail only when +the active guest opens it. + +The Go runner uses a test-only entry point to the existing `se_decrypt_record` +export. This permits shapes the host's `record.Sealed` type cannot express, +including a passthrough directly under `c`. It uses the binding's existing +tree codec, options and transport-buffer cleanup. Cases marked `adapter` also +run the normal `Cipher.Open` path; they retain their original stored shape. +This does not certify generated SDK mappings. + +## Required behavior + +- Missing required fields, missing ciphertext outputs, non-map rows or field + outputs, and plaintext passthroughs under ciphertext are refused before keys. +- A malformed later row rejects the batch before retrieval. +- Reads project the fields and outputs the plan opens. Extra stored fields, + extra outputs and missing equality outputs do not prevent reading ciphertext. + Writes still reject unknown source fields, as the runtime profile requires. +- Reading int32 ciphertext as int64, uint64 or string fails after retrieval. + Reads do not apply the query path's lossless numeric conversion. +- A sealed null cannot open through a required declaration. It can open through + a SQL-null declaration, with retrieval because ciphertext is present. +- An absent SQL-null field opens as null without retrieval. A required or + sealed-null read declaration refuses the same missing field. +- Context-field reads work without an expected label or with a matching label. + A mismatch, including one in a later row, refuses the whole batch before keys. +- Missing context fields, missing passthrough payloads and non-string context + values fail before retrieval. +- A static-context plan cannot also take a call context. Native `check_record` + and the guest report `dynamic_record` with reason `refused`; the native pending + opener reports `plan_two_context_sources`, naming the plan and the call. + The corpus records both existing diagnostics explicitly. + +Context-mismatch diagnostic payloads expose only stored descriptor length and +part count, not its value. Go diagnostic numeric metadata is compared as JSON +numbers; plaintext comparisons retain actual host types. + +## Mutation vocabulary + +| Mutation | Change to the selected row | +|---|---| +| `none` | Preserve it | +| `drop_value` / `drop_c` / `drop_eq` | Remove the named field or output | +| `field_plain` / `c_plain` / `row_plain` | Replace that node with an int32 passthrough | +| `extra_field` / `extra_output` | Add an `extra` int32 passthrough at that level | +| `drop_tenant` | Remove the context field | +| `tenant_no_payload` | Replace its output map with an empty map | +| `tenant_number` | Replace its passthrough string with an int32 | + +## Scope and consolidation + +The existing native record tests retain repeated keys, invalid leaf framing, +other tree forms, context extensions and wider type/output combinations. Go's +tree encoder accepts maps with unique keys, so repeated-key transport cases +need a separate raw-byte profile rather than being silently skipped here. + +The existing Go context-field suite retains generated SDK calls, query context +selection, extension behavior and stored-context tampering. The deterministic +provider's wrong-context refusal remains covered there and by the scalar +profile. This profile observes preflight context checks, not every context +composition or cryptographic tampering case. + +No existing tests are removed. Python and TypeScript runners, EQL/JSON read +shapes, provider failures, version compatibility and runtime artifact exchange +remain follow-up work. diff --git a/conformance/stack-encrypt/runtime-contract.md b/conformance/stack-encrypt/runtime-contract.md index 77e673ebf..f638a9fdc 100644 --- a/conformance/stack-encrypt/runtime-contract.md +++ b/conformance/stack-encrypt/runtime-contract.md @@ -79,6 +79,8 @@ guest's lack of network requests is not proof of logical key-call counts. The cases cover runtime writes, corresponding reads, and equality queries. They do not cover malformed stored records, authenticated type mismatches on read, context composition/preflight, provider failures, or other operators. +The separate [read profile](read-contract.md) adds stored-shape, authenticated +type and context-preflight cases. Fresh cross-language artifact exchange remains the scalar profile's coverage. Existing `value_kinds.json` and `query_operators.json` fixtures remain the diff --git a/conformance/stack-encrypt/v1/reads.json b/conformance/stack-encrypt/v1/reads.json new file mode 100644 index 000000000..30709696c --- /dev/null +++ b/conformance/stack-encrypt/v1/reads.json @@ -0,0 +1,1107 @@ +{ + "version": 1, + "profile": "record-reads-v1", + "required_capabilities": [ + "records", + "read-shapes", + "authenticated-types", + "context-preflight" + ], + "cases": [ + { + "id": "missing-field", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "drop_value", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + { + "id": "missing-ciphertext", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "drop_c", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "no_ciphertext_node" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "no_ciphertext_node" + } + } + }, + { + "id": "field-not-map", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "field_plain", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "outputs_not_map" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "outputs_not_map" + } + } + }, + { + "id": "plaintext-under-c", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "c_plain", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "passthrough" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "passthrough" + } + } + }, + { + "id": "row-not-map", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "row_plain", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "reason": "not_an_object" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "reason": "not_an_object" + } + } + }, + { + "id": "late-missing-field", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "1" + } + }, + { + "value": { + "type": "int32", + "text": "2" + } + } + ], + "mutation": { + "kind": "drop_value", + "row": 1 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + { + "id": "read-none", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ] + } + }, + { + "id": "read-drop-eq", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "drop_eq", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ] + } + }, + { + "id": "read-extra-output", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "extra_output", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ] + } + }, + { + "id": "read-extra-field", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "extra_field", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ] + } + }, + { + "id": "authenticated-int32-as-int64", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int64", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "authenticated", + "retrievals": 1, + "error": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "field": "value", + "expected": "int64" + } + } + } + }, + { + "id": "authenticated-int32-as-string", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "string", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "authenticated", + "retrievals": 1, + "error": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "field": "value", + "expected": "string" + } + } + } + }, + { + "id": "authenticated-int32-as-uint64", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "uint64", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "authenticated", + "retrievals": 1, + "error": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "field": "value", + "expected": "uint64" + } + } + } + }, + { + "id": "authenticated-null-as-required", + "write": { + "kind": "int32", + "null": "sealed" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "authenticated", + "retrievals": 1, + "error": { + "code": "stack_encrypt::plan_field_type", + "fields": { + "field": "value", + "expected": "int32" + } + } + } + }, + { + "id": "authenticated-null-as-sql", + "write": { + "kind": "int32", + "null": "sealed" + }, + "read": { + "kind": "int32", + "null": "sql" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ] + } + }, + { + "id": "sql-absent-read", + "write": { + "kind": "int32", + "null": "sql" + }, + "read": { + "kind": "int32", + "null": "sql" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 0, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ] + } + }, + { + "id": "sql-absent-as-required", + "write": { + "kind": "int32", + "null": "sql" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + { + "id": "sql-absent-as-sealed", + "write": { + "kind": "int32", + "null": "sql" + }, + "read": { + "kind": "int32", + "null": "sealed" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "value", + "reason": "field_missing" + } + } + }, + { + "id": "context-unrestricted", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ] + } + }, + { + "id": "context-expected", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ] + }, + "expected_context": "tenants/acme" + }, + { + "id": "context-mismatch", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": true, + "expected": { + "stage": "preflight", + "retrievals": 0, + "error": { + "code": "stack_encrypt::context_mismatch", + "fields": { + "stored_len": 12, + "stored_parts": 2 + } + } + }, + "expected_context": "tenants/other", + "check_error": { + "code": "stack_encrypt::context_mismatch", + "fields": { + "stored_len": 12, + "stored_parts": 2 + } + } + }, + { + "id": "context-late-mismatch", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "1" + } + }, + { + "tenant": { + "type": "string", + "text": "tenants/other" + }, + "value": { + "type": "int32", + "text": "2" + } + } + ], + "mutation": { + "kind": "none", + "row": 1 + }, + "adapter": true, + "expected": { + "stage": "preflight", + "retrievals": 0, + "error": { + "code": "stack_encrypt::context_mismatch", + "fields": { + "stored_len": 13, + "stored_parts": 2 + } + } + }, + "expected_context": "tenants/acme", + "check_error": { + "code": "stack_encrypt::context_mismatch", + "fields": { + "stored_len": 13, + "stored_parts": 2 + } + } + }, + { + "id": "context-drop-tenant", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "drop_tenant", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "field_missing" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "field_missing" + } + } + }, + { + "id": "context-tenant-no-payload", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "tenant_no_payload", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "no_passthrough_node" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "no_passthrough_node" + } + } + }, + { + "id": "context-tenant-number", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": true, + "rows": [ + { + "tenant": { + "type": "string", + "text": "tenants/acme" + }, + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "tenant_number", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "shape", + "retrievals": 0, + "error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "field_type" + } + } + }, + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "field": "tenant", + "reason": "field_type" + } + } + }, + { + "id": "static-context-with-expectation", + "write": { + "kind": "int32", + "null": "" + }, + "read": { + "kind": "int32", + "null": "" + }, + "context_field": false, + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + } + ], + "mutation": { + "kind": "none", + "row": 0 + }, + "adapter": false, + "expected": { + "stage": "preflight", + "retrievals": 0, + "error": { + "code": "stack_encrypt::plan_two_context_sources", + "fields": { + "first": "the plan", + "second": "the call" + } + } + }, + "expected_context": "tenants/acme", + "check_error": { + "code": "stack_encrypt::dynamic_record", + "fields": { + "reason": "refused" + } + } + } + ] +} diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index eb96f1735..9ee96a665 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -1,6 +1,6 @@ # Stack Encrypt cross-language conformance -Date: 2026-10-31. Status: scalar, declaration and initial runtime slices implemented; the complete contract is not frozen. +Date: 2026-10-31. Status: scalar, declaration, initial runtime and read slices implemented; the complete contract is not frozen. This work is stacked on [PR #1404](https://github.com/cipherstash/stack/pull/1404), at `b684a217e2da629c762efa70fac958ec7e21646a`. It follows the accepted @@ -121,9 +121,32 @@ Local validation passes for all three profiles, fresh scalar ciphertext exchange, Rust clippy/formatting, Go lint, Biome and the CI wiring checks. The deterministic guest is reused from the unchanged parent engine. -The next runtime extension should cover malformed stored records, authenticated -read-type failures, and context composition/preflight. Existing wider value-kind, -query-operator, generated SDK and EQL fixtures remain in place. +The read checkpoint below adds malformed stored records, authenticated read-type +failures and context preflight. Existing wider value-kind, query-operator, +generated SDK and EQL fixtures remain in place. + +## Read checkpoint + +The read slice is on `feat/stack-encrypt-read-conformance`, stacked on draft +[PR #1461](https://github.com/cipherstash/stack/pull/1461). Its 26 shared cases +cover malformed stored shapes, authenticated type failures, null read-policy +changes and context preflight, including late failures in batches. + +Native Rust distinguishes shape rejection, pending preflight and authenticated +type refusal using data-key counters. Go checks each case against an uninitialised +guest and an active guest; representable cases additionally use `Cipher.Open`. +A test-only entry point supplies shapes the record adapter cannot represent. +The [read contract](../../conformance/stack-encrypt/read-contract.md) records +exact diagnostics and the remaining gaps. No production API or old test changes. + +Local validation passes for all four profiles (275 shared cases), fresh scalar +artifact exchange, Rust clippy/formatting, Go lint, Biome and 34 CI wiring checks. +The deterministic guest is reused from the unchanged parent engine. + +Next, enroll the existing wider value-kind and operator fixtures under named +requirements, then extend context composition and EQL/JSON operation profiles. +Repeated-key and malformed-leaf cases need an explicit raw-byte transport path. +Python and TypeScript consumers remain pending. ## Follow-up slices diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 47778d388..822abf4af 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/record" "github.com/cipherstash/vitaminc/bindings/go/vcffi" ) @@ -199,3 +200,35 @@ func DeterministicGuest() ([]byte, error) { } return wasm, err } + +// OpenTreeForTest accepts stored tree shapes that record.Sealed cannot express. +// It uses the normal guest export, transport codec and keyset options. The +// returned plaintext transport buffer is wiped after decoding, as in Open. +func (cph *Cipher) OpenTreeForTest(ctx context.Context, tree any, plan *record.Plan, expected string) (any, error) { + encodedTree, err := marshalCipherText(tree) + if err != nil { + return nil, err + } + encodedPlan, err := vcffi.Marshal(plan.Wire()) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(cph.keyset, expected)) + if err != nil { + return nil, err + } + out, err := cph.client.callKMS(ctx, func(ctx context.Context, inst *instance) ([]byte, error) { + return inst.call(ctx, inst.decryptRecord, buf(encodedTree), buf(encodedPlan), buf(opts)) + }) + if err != nil { + return nil, err + } + defer wipe(out) + return vcffi.Unmarshal(out) +} + +// OpenTreeForTest asks an uninitialised guest to run its normal read preflight. +// A valid request reaches ErrState; an invalid one must fail before that point. +func (k *Checker) OpenTreeForTest(ctx context.Context, tree any, plan *record.Plan, expected string) (any, error) { + return k.c.DefaultKeyset().OpenTreeForTest(ctx, tree, plan, expected) +} diff --git a/languages/golang/encrypt/read_conformance_test.go b/languages/golang/encrypt/read_conformance_test.go new file mode 100644 index 000000000..9bf0774e7 --- /dev/null +++ b/languages/golang/encrypt/read_conformance_test.go @@ -0,0 +1,261 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +type readDeclaration struct { + Kind string `json:"kind"` + Null string `json:"null"` +} +type readCase struct { + ID string `json:"id"` + Write readDeclaration `json:"write"` + Read readDeclaration `json:"read"` + ContextField bool `json:"context_field"` + Rows []map[string]conformanceInput `json:"rows"` + Mutation struct { + Kind string `json:"kind"` + Row int `json:"row"` + } `json:"mutation"` + ExpectedContext string `json:"expected_context"` + Adapter bool `json:"adapter"` + CheckError *declarationError `json:"check_error"` + Expected struct { + Stage string `json:"stage"` + Retrievals int `json:"retrievals"` + Error *declarationError `json:"error"` + Rows *[]map[string]conformanceInput `json:"rows"` + } `json:"expected"` +} + +func readPlan(d readDeclaration, contextField bool) *record.Plan { + p := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(d.Kind), Null: record.Null(d.Null), Outputs: []record.Output{record.Ciphertext, record.Equality}}}} + if contextField { + p.Context = nil + p.ContextField = "tenant" + } + return p +} + +func readTree(stored []record.Sealed, contextField bool) []any { + batch := make([]any, len(stored)) + for i, row := range stored { + fields := map[string]any{} + if contextField { + fields["tenant"] = map[string]any{"passthrough": vcvalue.Plain{V: row["tenant"].Context}} + } + if field, ok := row["value"]; ok { + fields["value"] = map[string]any{"c": encrypt.Ciphertext(field.Ciphertext), "eq": vcvalue.Plain{V: field.Terms[record.Equality]}} + } + batch[i] = fields + } + return batch +} + +func mutateRead(t *testing.T, batch []any, c readCase) { + t.Helper() + i := c.Mutation.Row + if i < 0 || i >= len(batch) { + t.Fatal("mutation row out of range") + } + plain := vcvalue.Plain{V: int32(34)} + if c.Mutation.Kind == "row_plain" { + batch[i] = plain + return + } + fields := batch[i].(map[string]any) + switch c.Mutation.Kind { + case "none": + case "drop_value": + delete(fields, "value") + case "field_plain": + fields["value"] = plain + case "extra_field": + fields["extra"] = plain + case "drop_tenant": + delete(fields, "tenant") + case "tenant_no_payload": + fields["tenant"] = map[string]any{} + case "tenant_number": + fields["tenant"].(map[string]any)["passthrough"] = plain + default: + outputs := fields["value"].(map[string]any) + switch c.Mutation.Kind { + case "drop_c": + delete(outputs, "c") + case "drop_eq": + delete(outputs, "eq") + case "c_plain": + outputs["c"] = plain + case "extra_output": + outputs["extra"] = plain + default: + t.Fatalf("unknown mutation %q", c.Mutation.Kind) + } + } +} + +func readRefusal(t *testing.T, err error, want *declarationError) { + t.Helper() + sentinel := encrypt.ErrEncoding + if want.Code == "stack_encrypt::context_mismatch" { + sentinel = encrypt.ErrContextMismatch + } + var diagnostic *encrypt.Diagnostic + if !errors.Is(err, sentinel) || !errors.As(err, &diagnostic) { + t.Fatalf("expected %s diagnostic, got %v", want.Code, err) + } + // Diagnostic counters are JSON numbers; transport decodes them as uint64. + actualFields, marshalErr := json.Marshal(diagnostic.Fields) + if marshalErr != nil { + t.Fatal(marshalErr) + } + expectedFields, marshalErr := json.Marshal(want.Fields) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if diagnostic.Code != want.Code || !bytes.Equal(actualFields, expectedFields) { + t.Fatalf("got %s %#v; want %s %#v", diagnostic.Code, diagnostic.Fields, want.Code, want.Fields) + } +} + +func openedRows(t *testing.T, output any) []record.Source { + t.Helper() + batch, ok := output.([]any) + if !ok { + t.Fatalf("unexpected opened batch %T", output) + } + result := make([]record.Source, len(batch)) + for i, v := range batch { + fields, ok := v.(vcvalue.Object) + if !ok { + t.Fatalf("unexpected opened row %T", v) + } + result[i] = record.Source{} + for _, f := range fields { + if _, ok := result[i][f.Key]; ok { + t.Fatal("duplicate opened field") + } + result[i][f.Key] = f.Value + } + } + return result +} + +func TestReadConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + RequiredCapabilities []string `json:"required_capabilities"` + Cases []readCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "reads.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-reads-v1" || len(corpus.Cases) == 0 { + t.Fatal("unsupported or empty read corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "read-shapes", "authenticated-types", "context-preflight"}) { + t.Fatal("unsupported required capabilities") + } + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + wasm, err := os.ReadFile("testdata/stack_encrypt_guest_deterministic.wasm") + if err != nil { + t.Fatal(err) + } + checker, err := encrypt.NewCheckerOver(ctx, wasm) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = checker.Close() }) + cipher := client.DefaultKeyset() + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatal("empty or repeated ID") + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + switch c.Expected.Stage { + case "success", "shape", "preflight", "authenticated": + default: + t.Fatal("unknown stage") + } + write, read := readPlan(c.Write, c.ContextField), readPlan(c.Read, c.ContextField) + stored, err := cipher.Seal(ctx, write, runtimeRows(t, c.Rows)) + if err != nil { + t.Fatal(err) + } + tree := readTree(stored, c.ContextField) + mutateRead(t, tree, c) + // Without a cipher, preflight must reject malformed shapes/contexts. + // Valid shapes (including concealed type mismatches) must reach ErrState. + _, err = checker.OpenTreeForTest(ctx, tree, read, c.ExpectedContext) + if c.CheckError != nil { + readRefusal(t, err, c.CheckError) + } else if !errors.Is(err, encrypt.ErrState) { + t.Fatalf("expected state boundary after preflight, got %v", err) + } + out, err := cipher.OpenTreeForTest(ctx, tree, read, c.ExpectedContext) + want := c.Expected.Error + // The guest runs check_record before constructing the pending opener. + if c.CheckError != nil { + want = c.CheckError + } + if c.Expected.Stage == "success" { + if want != nil || c.Expected.Rows == nil { + t.Fatal("ambiguous success outcome") + } + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(openedRows(t, out), runtimeRows(t, *c.Expected.Rows)) { + t.Fatal("typed ordered read differs") + } + } else { + if want == nil || c.Expected.Rows != nil { + t.Fatal("missing or ambiguous refusal") + } + readRefusal(t, err, want) + } + if c.Adapter { + if c.Mutation.Kind != "none" { + t.Fatal("adapter cases must retain the stored shape") + } + reader := cipher + if c.ExpectedContext != "" { + reader = cipher.Context(c.ExpectedContext) + } + opened, err := reader.Open(ctx, read, stored) + if want != nil { + readRefusal(t, err, want) + } else { + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(opened, runtimeRows(t, *c.Expected.Rows)) { + t.Fatal("record adapter read differs") + } + } + } + }) + } +} diff --git a/packages/stack-encrypt/tests/read_conformance.rs b/packages/stack-encrypt/tests/read_conformance.rs new file mode 100644 index 000000000..33267c733 --- /dev/null +++ b/packages/stack-encrypt/tests/read_conformance.rs @@ -0,0 +1,248 @@ +//! Stored-shape, authenticated-type and context preflight conformance. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{value, Input}, + frames::{frame, object}, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use stack_encrypt::dynamic::{record, Scope, ValueFrame}; +use stack_encrypt::{CipherText, ErrorPayload, Label, StackCipherText}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::atomic::Ordering, +}; + +type Row = BTreeMap; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + required_capabilities: Vec, + cases: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Declaration { + kind: String, + null: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + write: Declaration, + read: Declaration, + context_field: bool, + rows: Vec, + mutation: Mutation, + expected_context: Option, + check_error: Option, + expected: Expected, + // Go additionally runs representable cases through its normal record adapter. + #[serde(rename = "adapter")] + _adapter: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Mutation { + kind: String, + row: usize, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Refusal { + code: String, + fields: Map, +} +#[derive(Deserialize, PartialEq, Debug)] +#[serde(rename_all = "snake_case")] +enum Stage { + Success, + Shape, + Preflight, + Authenticated, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Expected { + stage: Stage, + retrievals: usize, + error: Option, + rows: Option>, +} + +fn refusal(error: &impl ErrorPayload, want: &Refusal) { + assert_eq!(error.code().unwrap().to_string(), want.code); + assert_eq!(error.payload(), want.fields); +} +fn plan(decl: &Declaration, context_field: bool) -> record::Plan { + assert!(["", "sql", "sealed"].contains(&decl.null.as_str())); + let context = if context_field { + vec!["value"] + } else { + vec!["conformance", "value"] + }; + let mut spec = json!({"context":context,"outputs":["c","eq"],"type":decl.kind}); + if !decl.null.is_empty() { + spec["null"] = json!(decl.null); + } + let mut plan = json!({"value":spec}); + if context_field { + plan["context_field"] = json!("tenant"); + plan["tenant"] = json!({"context":["tenant"],"outputs":["passthrough"],"type":"string"}); + } + record::plan(&vitaminc_aead_frame::serde::to_vec(&plan).unwrap()).unwrap() +} +fn rows(input: &[Row]) -> ValueFrame { + frame( + input + .iter() + .map(|row| object(row.iter().map(|(k, v)| (k, value(v))).collect())) + .collect::>(), + ) +} +fn map(tree: &mut StackCipherText) -> &mut Vec<(String, StackCipherText)> { + let CipherText::Map(fields) = tree else { + panic!("map") + }; + fields +} +fn slot<'a>(fields: &'a mut [(String, StackCipherText)], key: &str) -> &'a mut StackCipherText { + &mut fields.iter_mut().find(|(k, _)| k == key).unwrap().1 +} +fn plain() -> StackCipherText { + CipherText::Passthrough(common::passthrough(34_i32)) +} +// Ciphertext deliberately has no Clone implementation. Copy public test storage. +fn copy_tree(tree: &StackCipherText) -> StackCipherText { + use stack_encrypt::{PassthroughFrame, SealedValue}; + let leaf = |v: &SealedValue| SealedValue::from_bytes(&v.to_bytes()).unwrap(); + match tree { + CipherText::Single(v) => CipherText::Single(leaf(v)), + CipherText::EmptySequence(v) => CipherText::EmptySequence(leaf(v)), + CipherText::EmptyMap(v) => CipherText::EmptyMap(leaf(v)), + CipherText::Sequence(v) => CipherText::Sequence(v.iter().map(copy_tree).collect()), + CipherText::Map(v) => { + CipherText::Map(v.iter().map(|(k, v)| (k.clone(), copy_tree(v))).collect()) + } + CipherText::Passthrough(v) => CipherText::Passthrough(PassthroughFrame(v.0.clone())), + } +} + +fn mutate(tree: &mut StackCipherText, mutation: &Mutation) { + let CipherText::Sequence(batch) = tree else { + panic!("batch") + }; + let row = &mut batch[mutation.row]; + if mutation.kind == "row_plain" { + *row = plain(); + return; + } + let fields = map(row); + match mutation.kind.as_str() { + "none" => {} + "drop_value" => fields.retain(|(k, _)| k != "value"), + "field_plain" => *slot(fields, "value") = plain(), + "extra_field" => fields.push(("extra".into(), plain())), + "drop_tenant" => fields.retain(|(k, _)| k != "tenant"), + "tenant_no_payload" => map(slot(fields, "tenant")).clear(), + "tenant_number" => *slot(map(slot(fields, "tenant")), "passthrough") = plain(), + kind => { + let outputs = map(slot(fields, "value")); + match kind { + "drop_c" => outputs.retain(|(k, _)| k != "c"), + "drop_eq" => outputs.retain(|(k, _)| k != "eq"), + "c_plain" => *slot(outputs, "c") = plain(), + "extra_output" => outputs.push(("extra".into(), plain())), + other => panic!("unknown mutation {other}"), + } + } + } +} + +#[tokio::test] +async fn shared_record_read_profile() { + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/reads.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-reads-v1"); + assert_eq!( + corpus.required_capabilities, + [ + "records", + "read-shapes", + "authenticated-types", + "context-preflight" + ] + ); + assert!(!corpus.cases.is_empty()); + let mut ids = BTreeSet::new(); + for case in corpus.cases { + assert!(!case.id.is_empty() && ids.insert(case.id.clone())); + eprintln!("read conformance: {}", case.id); + let write = plan(&case.write, case.context_field); + let read = plan(&case.read, case.context_field); + let expected = case + .expected_context + .as_deref() + .map(|s| Label::parse(s).unwrap()); + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let mut stored = record::encrypt(&keyset, rows(&case.rows), &write) + .unwrap() + .await + .unwrap(); + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); + mutate(&mut stored, &case.mutation); + let checked = record::check_record(copy_tree(&stored), &read, expected.as_ref()); + if let Some(want) = &case.check_error { + refusal(&checked.unwrap_err(), want); + } else { + checked.unwrap(); + } + let result = record::decrypt(Scope::Keyset(keyset), stored, &read, expected); + match case.expected.stage { + Stage::Shape => { + assert!(case.expected.rows.is_none()); + refusal( + &result.err().expect("shape rejection before pending"), + case.expected.error.as_ref().unwrap(), + ); + } + Stage::Success => { + assert!(case.expected.error.is_none()); + let opened = result.unwrap().await.unwrap(); + assert_eq!( + opened.risky_ref(), + rows(case.expected.rows.as_ref().unwrap()).risky_ref() + ); + } + Stage::Preflight | Stage::Authenticated => { + assert!(case.expected.rows.is_none()); + let err = result.unwrap().await.unwrap_err(); + refusal(&err, case.expected.error.as_ref().unwrap()); + assert_eq!( + case.expected.retrievals, + usize::from(case.expected.stage == Stage::Authenticated) + ); + } + } + assert_eq!( + generates.load(Ordering::SeqCst), + 0, + "reads never generate data keys" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + case.expected.retrievals, + "retrieval stage" + ); + } +} diff --git a/scripts/__tests__/stack-conformance-ci.test.mjs b/scripts/__tests__/stack-conformance-ci.test.mjs index d684a5bd6..e3006df7b 100644 --- a/scripts/__tests__/stack-conformance-ci.test.mjs +++ b/scripts/__tests__/stack-conformance-ci.test.mjs @@ -20,8 +20,9 @@ describe('shared Stack Encrypt conformance is required in CI', () => { expect(script).toMatch(/cargo nextest run[^\n]*--test conformance/) expect(script).toMatch(/cargo nextest run[^\n]*--test declarations/) expect(script).toMatch(/cargo nextest run[^\n]*--test runtime_conformance/) + expect(script).toMatch(/cargo nextest run[^\n]*--test read_conformance/) expect(script).toContain( - "go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance)$' ./encrypt", + "go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance|TestReadConformance)$' ./encrypt", ) }) diff --git a/scripts/stack-conformance.sh b/scripts/stack-conformance.sh index ca45ad5fe..670b9085c 100644 --- a/scripts/stack-conformance.sh +++ b/scripts/stack-conformance.sh @@ -18,11 +18,11 @@ fi artifacts=$(mktemp -d) trap 'rm -rf "$artifacts"' EXIT -mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance +mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance --test read_conformance ( cd languages/golang STACK_CONFORMANCE_OUTPUT="$artifacts/go.json" CGO_ENABLED=0 \ - go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance)$' ./encrypt + go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance|TestReadConformance)$' ./encrypt ) test -s "$artifacts/go.json" STACK_CONFORMANCE_INPUT="$artifacts/go.json" mise x --env test -- \ From 0d4b1ed56a1e9004f84488837d92044b97b0b461 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 15:57:31 +1100 Subject: [PATCH 2/4] fix(conformance): share validated read paths --- conformance/stack-encrypt/README.md | 14 +- conformance/stack-encrypt/coverage.md | 4 +- conformance/stack-encrypt/read-contract.md | 30 +- conformance/stack-encrypt/v1/reads.json | 5 +- .../2026-10-31-stack-encrypt-conformance.md | 12 +- languages/golang/encrypt/export_test.go | 35 +- .../golang/encrypt/read_conformance_test.go | 305 ++++++++++++++---- languages/golang/encrypt/records.go | 26 +- .../stack-encrypt/tests/read_conformance.rs | 207 ++++++++---- 9 files changed, 466 insertions(+), 172 deletions(-) diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 3abb99362..abd176bd8 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -11,6 +11,7 @@ From the repository root, with the pinned mise tools installed: ```sh mise run wasm:guest:build:deterministic +mise run wasm:guest:build mise run test:conformance ``` @@ -29,11 +30,12 @@ Python and TypeScript runners are pending; their absence is not a passing result ## `record-reads-v1` contract -The [read contract](read-contract.md) defines 26 cases in `v1/reads.json` for +The [read contract](read-contract.md) defines cases in `v1/reads.json` for malformed stored records, authenticated read-type failures and context preflight. The native runner observes retrieval stages. Go exercises the same cases through -both an uninitialised guest and an active guest, plus the normal record adapter -for representable cases. The runners are `tests/read_conformance.rs` in the Rust +an uninitialised guest and a configured cipher, plus the normal record adapter +for representable cases. The contract records the static-context combination +that the host refuses before calling the guest. The runners are `tests/read_conformance.rs` in the Rust crate and `encrypt/read_conformance_test.go` in the Go module. ## `record-runtime-v1` contract @@ -43,7 +45,7 @@ missing and explicit null values, SQL-null omission, sealed-null storage and equality, typed batch round trips, write validation and lossless query conversions. The runners are `packages/stack-encrypt/tests/runtime_conformance.rs` and `languages/golang/encrypt/runtime_conformance_test.go`. Native Rust also checks -data-key call counts; Go records where its host adapter refuses inputs earlier. +data-key call counts; Go host source validation preserves shared structured diagnostics. ## `record-declarations-v1` contract @@ -58,7 +60,9 @@ The runners are `packages/stack-encrypt/tests/declarations.rs` and without a cipher. Valid-frame declaration diagnostics agree exactly; malformed frames document the different native-parser and WASI-boundary classifications. Native Rust also checks normalization observations. Go's checker does not expose -the parsed plan, so it asserts acceptance and diagnostics. +the parsed plan, so it asserts acceptance and diagnostics against both release +and deterministic guests. Representable cases also exercise `Checker.Check` +through explicit host plan fixtures in `v1/go-declarations.json`. ## `record-scalars-v1` contract diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 82c4ddc4e..6b54fdc02 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -41,7 +41,7 @@ batches; Go provider-call instrumentation remains open. ## Read checkpoint -`v1/reads.json` adds 26 cases for stored-shape validation, authenticated types +`v1/reads.json` adds cases for stored-shape validation, authenticated types and context preflight. The [read contract](read-contract.md) distinguishes native shape/pending stages and the guest's earlier `check_record` diagnostics. Native data-key counters and Go's uninitialised guest verify preflight boundaries. @@ -70,7 +70,7 @@ No existing test has been deleted in these first four slices. | Rust kind/index admission tests | The corpus has the complete 21-kind/five-index acceptance matrix | Share case data; retain direct tests that compare declaration admission with actual term execution | | Rust `value_kinds.rs`, Go `value_kinds_test.go` | Already share `value_kinds.json`; cover more types and generated SDK code than the initial scalar profile | Enroll that existing fixture under named requirements instead of copying it; keep generated-code assertions | | Rust `query_operators.rs`, Go `query_test.go` | Already share operator vectors, including verification and target limits | Reuse their fixture and cases when adding an operator profile; preserve host/generated query mapping tests | -| Go checker and host validation tests in `guest_test.go` | Exercise host rejection and diagnostic forwarding through public checker methods | Keep adapter-specific assertions; raw-frame conformance bypasses host validation deliberately | +| Go checker and host validation tests in `guest_test.go` | Exercise host rejection and diagnostic forwarding through public checker methods | Keep adapter-specific assertions; representable declaration cases also use `Checker.Check`, while raw frames observe the guest boundary | | Native scalar/record round trips and cross-opening | Some behavior overlaps, but existing tests cover more types and API paths | Replace only exact duplicates after matching requirements and execution paths | Keep Rust compile-fail, ownership/zeroization, batching, fuzz/property, and diff --git a/conformance/stack-encrypt/read-contract.md b/conformance/stack-encrypt/read-contract.md index 6853d5294..219e332a7 100644 --- a/conformance/stack-encrypt/read-contract.md +++ b/conformance/stack-encrypt/read-contract.md @@ -1,6 +1,6 @@ # Record read profile -`record-reads-v1` adds 26 shared cases in `v1/reads.json` for stored record +`record-reads-v1` adds shared cases in `v1/reads.json` for stored record shapes, authenticated type checks and context preflight. It fixes the resolver to `NoTargets`. It is experimental and covers native Rust and Go WASI. @@ -35,8 +35,9 @@ or Go's logical provider calls. `check_error` independently describes `record::check_record`, which needs no cipher. An absent `check_error` requires acceptance. The Go guest runs this -check before looking up its cipher state. Both an uninitialised guest and an -active guest execute each case: preflight errors must have the specified +check before looking up its cipher state. Both an uninitialised guest and a +configured cipher execute each case (subject to the host context refusal +described below): guest preflight errors must have the specified diagnostic in either; accepted shapes must reach `ErrState` without a cipher. Consequently, a concealed type mismatch must pass preflight and fail only when the active guest opens it. @@ -44,7 +45,9 @@ the active guest opens it. The Go runner uses a test-only entry point to the existing `se_decrypt_record` export. This permits shapes the host's `record.Sealed` type cannot express, including a passthrough directly under `c`. It uses the binding's existing -tree codec, options and transport-buffer cleanup. Cases marked `adapter` also +tree codec, Cipher validation/context/extension, production decoding and +transport-buffer cleanup. Only the uninitialised checker deliberately bypasses +host plan checks to observe the guest boundary. Cases marked `adapter` also run the normal `Cipher.Open` path; they retain their original stored shape. This does not certify generated SDK mappings. @@ -69,7 +72,11 @@ This does not certify generated SDK mappings. - A static-context plan cannot also take a call context. Native `check_record` and the guest report `dynamic_record` with reason `refused`; the native pending opener reports `plan_two_context_sources`, naming the plan and the call. - The corpus records both existing diagnostics explicitly. + The corpus records both engine diagnostics explicitly. Go's configured + `Cipher.Context(...).Open` refuses this combination earlier in `Cipher.plan` + with `ErrEncoding`. The case also exercises that host refusal through both + `Open` and the validated tree hook; it does not require its English wording + or the absence of a diagnostic. Context-mismatch diagnostic payloads expose only stored descriptor length and part count, not its value. Go diagnostic numeric metadata is compared as JSON @@ -103,3 +110,16 @@ composition or cryptographic tampering case. No existing tests are removed. Python and TypeScript runners, EQL/JSON read shapes, provider failures, version compatibility and runtime artifact exchange remain follow-up work. + +Opened row order follows the input batch. Within a row the engine emits fields +in declaration order. Both runners +construct declarations with the context field first, followed by recoverable +fields. Native expected frames use that order; Go's shared production decoder validates it +before converting objects to maps. `context_field_name` may override the default +`tenant` name when `context_field` is true; a `zone` case prevents lexical object +ordering from masquerading as declaration ordering. + +Both runners require `context_field`, `adapter`, and `mutation.row` (including +explicit false/zero values), reject empty supplied `expected_context`, and reject +contradictory stage, check-error and retrieval-count expectations. The Rust runner +collects per-case failures so later cases still execute after an earlier failure. diff --git a/conformance/stack-encrypt/v1/reads.json b/conformance/stack-encrypt/v1/reads.json index 30709696c..b10df692e 100644 --- a/conformance/stack-encrypt/v1/reads.json +++ b/conformance/stack-encrypt/v1/reads.json @@ -1083,7 +1083,7 @@ "kind": "none", "row": 0 }, - "adapter": false, + "adapter": true, "expected": { "stage": "preflight", "retrievals": 0, @@ -1102,6 +1102,7 @@ "reason": "refused" } } - } + }, + {"id": "context-field-after-value-lexically", "write": {"kind": "int32", "null": ""}, "read": {"kind": "int32", "null": ""}, "context_field": true, "rows": [{"value": {"type": "int32", "text": "34"}, "zone": {"type": "string", "text": "tenants/acme"}}], "mutation": {"kind": "none", "row": 0}, "adapter": true, "expected": {"stage": "success", "retrievals": 1, "rows": [{"value": {"type": "int32", "text": "34"}, "zone": {"type": "string", "text": "tenants/acme"}}]}, "context_field_name": "zone"} ] } diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 9ee96a665..df11cd397 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -113,7 +113,7 @@ The initial runtime slice is on `feat/stack-encrypt-runtime-conformance`, stacke on draft [PR #1460](https://github.com/cipherstash/stack/pull/1460). Its shared cases cover missing/null storage, typed batch round trips, source refusals, and lossless equality query conversions. Native Rust observes data-key call counts. -Go explicitly records host validation where it precedes guest diagnostics. +Go host source validation preserves the shared structured diagnostic fields. The [runtime contract](../../conformance/stack-encrypt/runtime-contract.md) describes the corpus and limits. No production API or existing test is removed. @@ -128,7 +128,7 @@ generated SDK and EQL fixtures remain in place. ## Read checkpoint The read slice is on `feat/stack-encrypt-read-conformance`, stacked on draft -[PR #1461](https://github.com/cipherstash/stack/pull/1461). Its 26 shared cases +[PR #1461](https://github.com/cipherstash/stack/pull/1461). Its shared cases cover malformed stored shapes, authenticated type failures, null read-policy changes and context preflight, including late failures in batches. @@ -137,10 +137,12 @@ type refusal using data-key counters. Go checks each case against an uninitialis guest and an active guest; representable cases additionally use `Cipher.Open`. A test-only entry point supplies shapes the record adapter cannot represent. The [read contract](../../conformance/stack-encrypt/read-contract.md) records -exact diagnostics and the remaining gaps. No production API or old test changes. +exact diagnostics and the remaining gaps. The tree hook shares the production +open/decode path, including field-order validation before conversion to maps. +No public API or existing test is removed. -Local validation passes for all four profiles (275 shared cases), fresh scalar -artifact exchange, Rust clippy/formatting, Go lint, Biome and 34 CI wiring checks. +Local validation passes for all four profiles, fresh scalar +artifact exchange, Rust clippy/formatting, Go lint, Biome and the CI wiring checks. The deterministic guest is reused from the unchanged parent engine. Next, enroll the existing wider value-kind and operator fixtures under named diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 822abf4af..2b500104e 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -201,34 +201,21 @@ func DeterministicGuest() ([]byte, error) { return wasm, err } -// OpenTreeForTest accepts stored tree shapes that record.Sealed cannot express. -// It uses the normal guest export, transport codec and keyset options. The -// returned plaintext transport buffer is wiped after decoding, as in Open. -func (cph *Cipher) OpenTreeForTest(ctx context.Context, tree any, plan *record.Plan, expected string) (any, error) { - encodedTree, err := marshalCipherText(tree) - if err != nil { - return nil, err - } - encodedPlan, err := vcffi.Marshal(plan.Wire()) - if err != nil { - return nil, err +// OpenTreeForTest accepts stored shapes that record.Sealed cannot express, +// while preserving Cipher validation, extension, context and production decoding. +func (cph *Cipher) OpenTreeForTest(ctx context.Context, tree []any, plan *record.Plan) ([]record.Source, error) { + if cph == nil { + return nil, fmt.Errorf("%w: Open on a nil *Cipher", ErrEncoding) } - opts, err := vcffi.Marshal(options(cph.keyset, expected)) - if err != nil { - return nil, err - } - out, err := cph.client.callKMS(ctx, func(ctx context.Context, inst *instance) ([]byte, error) { - return inst.call(ctx, inst.decryptRecord, buf(encodedTree), buf(encodedPlan), buf(opts)) - }) + p, err := cph.plan(plan) if err != nil { return nil, err } - defer wipe(out) - return vcffi.Unmarshal(out) + return cph.client.openTree(ctx, cph.keyset, cph.context, p, tree) } -// OpenTreeForTest asks an uninitialised guest to run its normal read preflight. -// A valid request reaches ErrState; an invalid one must fail before that point. -func (k *Checker) OpenTreeForTest(ctx context.Context, tree any, plan *record.Plan, expected string) (any, error) { - return k.c.DefaultKeyset().OpenTreeForTest(ctx, tree, plan, expected) +// OpenTreeForTest deliberately bypasses host plan validation to observe the +// uninitialised guest's preflight, including plans the host refuses earlier. +func (k *Checker) OpenTreeForTest(ctx context.Context, tree []any, plan *record.Plan, expected string) ([]record.Source, error) { + return k.c.openTree(ctx, k.c.DefaultKeyset().keyset, expected, plan, tree) } diff --git a/languages/golang/encrypt/read_conformance_test.go b/languages/golang/encrypt/read_conformance_test.go index 9bf0774e7..894bc8958 100644 --- a/languages/golang/encrypt/read_conformance_test.go +++ b/languages/golang/encrypt/read_conformance_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -29,10 +30,11 @@ type readCase struct { Kind string `json:"kind"` Row int `json:"row"` } `json:"mutation"` - ExpectedContext string `json:"expected_context"` - Adapter bool `json:"adapter"` - CheckError *declarationError `json:"check_error"` - Expected struct { + ContextFieldName string `json:"context_field_name"` + ExpectedContext *string `json:"expected_context"` + Adapter bool `json:"adapter"` + CheckError *declarationError `json:"check_error"` + Expected struct { Stage string `json:"stage"` Retrievals int `json:"retrievals"` Error *declarationError `json:"error"` @@ -40,21 +42,21 @@ type readCase struct { } `json:"expected"` } -func readPlan(d readDeclaration, contextField bool) *record.Plan { +func readPlan(d readDeclaration, contextField bool, contextName string) *record.Plan { p := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(d.Kind), Null: record.Null(d.Null), Outputs: []record.Output{record.Ciphertext, record.Equality}}}} if contextField { p.Context = nil - p.ContextField = "tenant" + p.ContextField = contextName } return p } -func readTree(stored []record.Sealed, contextField bool) []any { +func readTree(stored []record.Sealed, contextField bool, contextName string) []any { batch := make([]any, len(stored)) for i, row := range stored { fields := map[string]any{} if contextField { - fields["tenant"] = map[string]any{"passthrough": vcvalue.Plain{V: row["tenant"].Context}} + fields[contextName] = map[string]any{"passthrough": vcvalue.Plain{V: row[contextName].Context}} } if field, ok := row["value"]; ok { fields["value"] = map[string]any{"c": encrypt.Ciphertext(field.Ciphertext), "eq": vcvalue.Plain{V: field.Terms[record.Equality]}} @@ -66,6 +68,15 @@ func readTree(stored []record.Sealed, contextField bool) []any { func mutateRead(t *testing.T, batch []any, c readCase) { t.Helper() + switch c.Mutation.Kind { + case "none", "row_plain", "drop_value", "field_plain", "extra_field", "drop_tenant", "tenant_no_payload", "tenant_number", "drop_c", "drop_eq", "c_plain", "extra_output": + default: + t.Fatalf("unknown mutation %q", c.Mutation.Kind) + } + contextName := c.ContextFieldName + if contextName == "" { + contextName = "tenant" + } i := c.Mutation.Row if i < 0 || i >= len(batch) { t.Fatal("mutation row out of range") @@ -75,7 +86,7 @@ func mutateRead(t *testing.T, batch []any, c readCase) { batch[i] = plain return } - fields := batch[i].(map[string]any) + fields := readMap(t, batch[i], "row") switch c.Mutation.Kind { case "none": case "drop_value": @@ -85,13 +96,13 @@ func mutateRead(t *testing.T, batch []any, c readCase) { case "extra_field": fields["extra"] = plain case "drop_tenant": - delete(fields, "tenant") + delete(fields, contextName) case "tenant_no_payload": - fields["tenant"] = map[string]any{} + fields[contextName] = map[string]any{} case "tenant_number": - fields["tenant"].(map[string]any)["passthrough"] = plain + readMap(t, fields[contextName], "context outputs")["passthrough"] = plain default: - outputs := fields["value"].(map[string]any) + outputs := readMap(t, fields["value"], "value outputs") switch c.Mutation.Kind { case "drop_c": delete(outputs, "c") @@ -117,43 +128,11 @@ func readRefusal(t *testing.T, err error, want *declarationError) { if !errors.Is(err, sentinel) || !errors.As(err, &diagnostic) { t.Fatalf("expected %s diagnostic, got %v", want.Code, err) } - // Diagnostic counters are JSON numbers; transport decodes them as uint64. - actualFields, marshalErr := json.Marshal(diagnostic.Fields) - if marshalErr != nil { - t.Fatal(marshalErr) - } - expectedFields, marshalErr := json.Marshal(want.Fields) - if marshalErr != nil { - t.Fatal(marshalErr) - } - if diagnostic.Code != want.Code || !bytes.Equal(actualFields, expectedFields) { + if diagnostic.Code != want.Code || !sameDiagnosticFields(t, diagnostic.Fields, want.Fields) { t.Fatalf("got %s %#v; want %s %#v", diagnostic.Code, diagnostic.Fields, want.Code, want.Fields) } } -func openedRows(t *testing.T, output any) []record.Source { - t.Helper() - batch, ok := output.([]any) - if !ok { - t.Fatalf("unexpected opened batch %T", output) - } - result := make([]record.Source, len(batch)) - for i, v := range batch { - fields, ok := v.(vcvalue.Object) - if !ok { - t.Fatalf("unexpected opened row %T", v) - } - result[i] = record.Source{} - for _, f := range fields { - if _, ok := result[i][f.Key]; ok { - t.Fatal("duplicate opened field") - } - result[i][f.Key] = f.Value - } - } - return result -} - func TestReadConformance(t *testing.T) { var corpus struct { Version int `json:"version"` @@ -177,7 +156,7 @@ func TestReadConformance(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = client.Close() }) - wasm, err := os.ReadFile("testdata/stack_encrypt_guest_deterministic.wasm") + wasm, err := encrypt.DeterministicGuest() if err != nil { t.Fatal(err) } @@ -194,27 +173,51 @@ func TestReadConformance(t *testing.T) { } seen[c.ID] = true t.Run(c.ID, func(t *testing.T) { - switch c.Expected.Stage { - case "success", "shape", "preflight", "authenticated": - default: - t.Fatal("unknown stage") + if err := validateReadCase(c); err != nil { + t.Fatal(err) + } + name := c.ContextFieldName + if name == "" { + name = "tenant" + } + expectedContext := "" + if c.ExpectedContext != nil { + expectedContext = *c.ExpectedContext } - write, read := readPlan(c.Write, c.ContextField), readPlan(c.Read, c.ContextField) + write, read := readPlan(c.Write, c.ContextField, name), readPlan(c.Read, c.ContextField, name) stored, err := cipher.Seal(ctx, write, runtimeRows(t, c.Rows)) if err != nil { t.Fatal(err) } - tree := readTree(stored, c.ContextField) + tree := readTree(stored, c.ContextField, name) mutateRead(t, tree, c) // Without a cipher, preflight must reject malformed shapes/contexts. // Valid shapes (including concealed type mismatches) must reach ErrState. - _, err = checker.OpenTreeForTest(ctx, tree, read, c.ExpectedContext) + _, err = checker.OpenTreeForTest(ctx, tree, read, expectedContext) if c.CheckError != nil { readRefusal(t, err, c.CheckError) } else if !errors.Is(err, encrypt.ErrState) { t.Fatalf("expected state boundary after preflight, got %v", err) } - out, err := cipher.OpenTreeForTest(ctx, tree, read, c.ExpectedContext) + reader := cipher + if c.ExpectedContext != nil { + reader = cipher.Context(expectedContext) + } + out, err := reader.OpenTreeForTest(ctx, tree, read) + // The raw checker above observes the guest; a configured Cipher + // refuses this combination on the host before reaching it. + if c.ExpectedContext != nil && !c.ContextField { + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("expected host context refusal, got %v", err) + } + if !c.Adapter { + t.Fatal("host context refusal must exercise Open too") + } + if _, err := reader.Open(ctx, read, stored); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("Open: expected host context refusal, got %v", err) + } + return + } want := c.Expected.Error // The guest runs check_record before constructing the pending opener. if c.CheckError != nil { @@ -227,7 +230,7 @@ func TestReadConformance(t *testing.T) { if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(openedRows(t, out), runtimeRows(t, *c.Expected.Rows)) { + if !reflect.DeepEqual(out, runtimeRows(t, *c.Expected.Rows)) { t.Fatal("typed ordered read differs") } } else { @@ -241,8 +244,8 @@ func TestReadConformance(t *testing.T) { t.Fatal("adapter cases must retain the stored shape") } reader := cipher - if c.ExpectedContext != "" { - reader = cipher.Context(c.ExpectedContext) + if c.ExpectedContext != nil { + reader = cipher.Context(expectedContext) } opened, err := reader.Open(ctx, read, stored) if want != nil { @@ -259,3 +262,189 @@ func TestReadConformance(t *testing.T) { }) } } + +func readMap(t *testing.T, value any, name string) map[string]any { + t.Helper() + fields, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s: expected map, got %T", name, value) + } + return fields +} + +// Standard JSON decoding rejects unknown fields, but not missing required +// fields. Keep the same required/null rules as Rust's Deserialize structs. +func (c *readCase) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + require := func(fields map[string]json.RawMessage, names ...string) error { + for _, name := range names { + if len(fields[name]) == 0 || bytes.Equal(bytes.TrimSpace(fields[name]), []byte("null")) { + return fmt.Errorf("missing or null %s", name) + } + } + return nil + } + if err := require(fields, "id", "write", "read", "context_field", "rows", "mutation", "adapter", "expected"); err != nil { + return err + } + for key, names := range map[string][]string{"write": {"kind", "null"}, "read": {"kind", "null"}, "mutation": {"kind", "row"}, "expected": {"stage", "retrievals"}} { + var nested map[string]json.RawMessage + if err := json.Unmarshal(fields[key], &nested); err != nil { + return err + } + if err := require(nested, names...); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + } + type plain readCase + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + d.UseNumber() + return d.Decode((*plain)(c)) +} + +func validateReadCase(c readCase) error { + if c.ExpectedContext != nil { + if _, err := record.ParseContext(*c.ExpectedContext); err != nil { + return fmt.Errorf("expected_context: %w", err) + } + } + if c.ContextFieldName != "" { + if !c.ContextField { + return errors.New("context_field_name without context field") + } + if err := record.CheckSegment(c.ContextFieldName); err != nil { + return err + } + } + for _, d := range []readDeclaration{c.Write, c.Read} { + if d.Null != "" && d.Null != "sql" && d.Null != "sealed" { + return errors.New("unknown null policy") + } + if !record.Kind(d.Kind).Known() || d.Kind == "" { + return errors.New("unknown kind") + } + } + switch c.Expected.Stage { + case "success": + if c.CheckError != nil || c.Expected.Error != nil || c.Expected.Rows == nil || c.Expected.Retrievals < 0 || c.Expected.Retrievals > 1 { + return errors.New("inconsistent success") + } + case "authenticated": + if c.CheckError != nil || c.Expected.Retrievals != 1 { + return errors.New("inconsistent authenticated stage") + } + case "shape", "preflight": + if c.Expected.Retrievals != 0 || c.CheckError == nil { + return errors.New("inconsistent preflight stage") + } + default: + return errors.New("unknown stage") + } + if c.Expected.Stage != "success" && (c.Expected.Error == nil || c.Expected.Rows != nil) { + return errors.New("inconsistent refusal") + } + return nil +} + +func TestReadCorpusSchema(t *testing.T) { + const valid = `{"id":"schema","write":{"kind":"int32","null":""},"read":{"kind":"int32","null":""},"context_field":false,"rows":[],"mutation":{"kind":"none","row":0},"adapter":false,"expected":{"stage":"success","retrievals":0,"rows":[]}}` + check := func(data []byte) error { + var c readCase + if err := json.Unmarshal(data, &c); err != nil { + return err + } + return validateReadCase(c) + } + if err := check([]byte(valid)); err != nil { + t.Fatal(err) + } + for _, path := range [][]string{{"context_field"}, {"adapter"}, {"mutation", "row"}, {"expected", "retrievals"}, {"write", "kind"}} { + t.Run("missing-"+path[len(path)-1], func(t *testing.T) { + var data map[string]any + if err := json.Unmarshal([]byte(valid), &data); err != nil { + t.Fatal(err) + } + if len(path) == 1 { + delete(data, path[0]) + } else { + delete(data[path[0]].(map[string]any), path[1]) + } + encoded, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := check(encoded); err == nil { + t.Fatal("accepted missing required field") + } + }) + } + for name, mutate := range map[string]func(*readCase){ + "empty-context": func(c *readCase) { empty := ""; c.ExpectedContext = &empty }, + "authenticated-preflight-error": func(c *readCase) { + c.Expected.Stage = "authenticated" + c.Expected.Retrievals = 1 + c.CheckError = &declarationError{} + c.Expected.Error = &declarationError{} + c.Expected.Rows = nil + }, + "preflight-retrieval": func(c *readCase) { + c.Expected.Stage = "preflight" + c.Expected.Retrievals = 1 + c.CheckError = &declarationError{} + c.Expected.Error = &declarationError{} + c.Expected.Rows = nil + }, + "success-check-error": func(c *readCase) { c.CheckError = &declarationError{} }, + } { + t.Run(name, func(t *testing.T) { + var c readCase + if err := json.Unmarshal([]byte(valid), &c); err != nil { + t.Fatal(err) + } + mutate(&c) + if err := validateReadCase(c); err == nil { + t.Fatal("accepted inconsistent expectation") + } + }) + } +} + +func TestReadTreePreservesCipherScope(t *testing.T) { + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte{}) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cipher := client.DefaultKeyset().Extend("region", int32(7)).Context("tenants/acme") + plan := readPlan(readDeclaration{Kind: "int32"}, true, "tenant") + rows := []record.Source{{"tenant": "tenants/acme", "value": int32(34)}} + stored, err := cipher.Seal(ctx, plan, rows) + if err != nil { + t.Fatal(err) + } + tree := readTree(stored, true, "tenant") + opened, err := cipher.OpenTreeForTest(ctx, tree, plan) + if err != nil || !reflect.DeepEqual(opened, rows) { + t.Fatalf("extended context roundtrip: %v", err) + } + if _, err := cipher.Context("tenants/other").OpenTreeForTest(ctx, tree, plan); !errors.Is(err, encrypt.ErrContextMismatch) { + t.Fatalf("context mismatch: %v", err) + } + if _, err := cipher.Context("invalid/(label)").OpenTreeForTest(ctx, tree, plan); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("invalid cipher: %v", err) + } + if _, err := cipher.OpenTreeForTest(ctx, tree, nil); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("invalid plan: %v", err) + } + if _, err := cipher.OpenTreeForTest(ctx, []any{struct{}{}}, plan); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("marshal error lost ErrEncoding: %v", err) + } +} diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index 446a46d4d..efdecd116 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -406,6 +406,11 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, expected string, } trees[i] = tree } + return c.openTree(ctx, sel, expected, p, trees) +} + +// openTree owns the shared transport, opening and typed-row validation path. +func (c *Client) openTree(ctx context.Context, sel KeysetSelector, expected string, p *record.Plan, trees []any) ([]record.Source, error) { encodedTree, err := marshalCipherText(trees) if err != nil { return nil, fmt.Errorf("%w: %v", ErrEncoding, err) @@ -431,8 +436,17 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, expected string, return nil, fmt.Errorf("%w: %v", ErrInternal, err) } items, ok := decoded.([]any) - if !ok || len(items) != len(records) { - return nil, fmt.Errorf("%w: %d records came back as %T", ErrInternal, len(records), decoded) + if !ok || len(items) != len(trees) { + return nil, fmt.Errorf("%w: %d records came back as %T", ErrInternal, len(trees), decoded) + } + order := make([]string, 0, len(p.Fields)+1) + if p.ContextField != "" { + order = append(order, p.ContextField) + } + for _, f := range p.Fields { + if f.HasCiphertext() { + order = append(order, f.Name) + } } sources := make([]record.Source, len(items)) for i, item := range items { @@ -440,6 +454,14 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, expected string, if !ok { return nil, fmt.Errorf("%w: record %d decrypted as %T", ErrInternal, i, item) } + if len(obj) != len(order) { + return nil, fmt.Errorf("%w: record %d has %d fields, expected %d", ErrInternal, i, len(obj), len(order)) + } + for j, f := range obj { + if f.Key != order[j] { + return nil, fmt.Errorf("%w: record %d field %d is %q, expected %q", ErrInternal, i, j, f.Key, order[j]) + } + } src := make(record.Source, len(obj)) for _, f := range obj { src[f.Key] = f.Value diff --git a/packages/stack-encrypt/tests/read_conformance.rs b/packages/stack-encrypt/tests/read_conformance.rs index 33267c733..e8beaa208 100644 --- a/packages/stack-encrypt/tests/read_conformance.rs +++ b/packages/stack-encrypt/tests/read_conformance.rs @@ -37,6 +37,8 @@ struct Case { write: Declaration, read: Declaration, context_field: bool, + #[serde(default)] + context_field_name: String, rows: Vec, mutation: Mutation, expected_context: Option, @@ -79,7 +81,7 @@ fn refusal(error: &impl ErrorPayload, want: &Refusal) { assert_eq!(error.code().unwrap().to_string(), want.code); assert_eq!(error.payload(), want.fields); } -fn plan(decl: &Declaration, context_field: bool) -> record::Plan { +fn plan(decl: &Declaration, context_field: bool, context_name: &str) -> record::Plan { assert!(["", "sql", "sealed"].contains(&decl.null.as_str())); let context = if context_field { vec!["value"] @@ -90,18 +92,32 @@ fn plan(decl: &Declaration, context_field: bool) -> record::Plan { if !decl.null.is_empty() { spec["null"] = json!(decl.null); } - let mut plan = json!({"value":spec}); + // Match Go Plan.Wire's declaration order explicitly, regardless of the + // serde_json map backend or the context field's lexical position. + let framed = |v: &Value| { + ValueFrame::from_bytes(&vitaminc_aead_frame::serde::to_vec(v).unwrap()).unwrap() + }; + let mut fields = Vec::new(); if context_field { - plan["context_field"] = json!("tenant"); - plan["tenant"] = json!({"context":["tenant"],"outputs":["passthrough"],"type":"string"}); + fields.push(("context_field", frame(context_name.to_owned()))); + fields.push(( + context_name, + framed(&json!({"context":[context_name],"outputs":["passthrough"],"type":"string"})), + )); } - record::plan(&vitaminc_aead_frame::serde::to_vec(&plan).unwrap()).unwrap() + fields.push(("value", framed(&spec))); + record::plan(object(fields).risky_ref()).unwrap() } -fn rows(input: &[Row]) -> ValueFrame { +fn rows(input: &[Row], context_name: Option<&str>) -> ValueFrame { frame( input .iter() - .map(|row| object(row.iter().map(|(k, v)| (k, value(v))).collect())) + .map(|row| { + let mut entries: Vec<_> = row.iter().collect(); + entries + .sort_by_key(|(name, _)| (Some(name.as_str()) != context_name, name.as_str())); + object(entries.into_iter().map(|(k, v)| (k, value(v))).collect()) + }) .collect::>(), ) } @@ -133,7 +149,7 @@ fn copy_tree(tree: &StackCipherText) -> StackCipherText { } } -fn mutate(tree: &mut StackCipherText, mutation: &Mutation) { +fn mutate(tree: &mut StackCipherText, mutation: &Mutation, context_name: &str) { let CipherText::Sequence(batch) = tree else { panic!("batch") }; @@ -148,9 +164,9 @@ fn mutate(tree: &mut StackCipherText, mutation: &Mutation) { "drop_value" => fields.retain(|(k, _)| k != "value"), "field_plain" => *slot(fields, "value") = plain(), "extra_field" => fields.push(("extra".into(), plain())), - "drop_tenant" => fields.retain(|(k, _)| k != "tenant"), - "tenant_no_payload" => map(slot(fields, "tenant")).clear(), - "tenant_number" => *slot(map(slot(fields, "tenant")), "passthrough") = plain(), + "drop_tenant" => fields.retain(|(k, _)| k != context_name), + "tenant_no_payload" => map(slot(fields, context_name)).clear(), + "tenant_number" => *slot(map(slot(fields, context_name)), "passthrough") = plain(), kind => { let outputs = map(slot(fields, "value")); match kind { @@ -164,8 +180,8 @@ fn mutate(tree: &mut StackCipherText, mutation: &Mutation) { } } -#[tokio::test] -async fn shared_record_read_profile() { +#[test] +fn shared_record_read_profile() { let corpus: Corpus = serde_json::from_str(include_str!( "../../../conformance/stack-encrypt/v1/reads.json" )) @@ -182,67 +198,120 @@ async fn shared_record_read_profile() { ] ); assert!(!corpus.cases.is_empty()); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut failures = Vec::new(); let mut ids = BTreeSet::new(); for case in corpus.cases { assert!(!case.id.is_empty() && ids.insert(case.id.clone())); eprintln!("read conformance: {}", case.id); - let write = plan(&case.write, case.context_field); - let read = plan(&case.read, case.context_field); - let expected = case - .expected_context - .as_deref() - .map(|s| Label::parse(s).unwrap()); - let (cipher, generates, retrieves) = common::counting_cipher().await; - let keyset = cipher.default_keyset(); - let mut stored = record::encrypt(&keyset, rows(&case.rows), &write) - .unwrap() - .await - .unwrap(); - generates.store(0, Ordering::SeqCst); - retrieves.store(0, Ordering::SeqCst); - mutate(&mut stored, &case.mutation); - let checked = record::check_record(copy_tree(&stored), &read, expected.as_ref()); - if let Some(want) = &case.check_error { - refusal(&checked.unwrap_err(), want); - } else { - checked.unwrap(); + let id = case.id.clone(); + if let Err(error) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + rt.block_on(check_case(case)) + })) { + let message = error + .downcast_ref::() + .map(String::as_str) + .or_else(|| error.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic"); + failures.push(format!("{id}: {message}")); } - let result = record::decrypt(Scope::Keyset(keyset), stored, &read, expected); - match case.expected.stage { - Stage::Shape => { - assert!(case.expected.rows.is_none()); - refusal( - &result.err().expect("shape rejection before pending"), - case.expected.error.as_ref().unwrap(), - ); - } - Stage::Success => { - assert!(case.expected.error.is_none()); - let opened = result.unwrap().await.unwrap(); - assert_eq!( - opened.risky_ref(), - rows(case.expected.rows.as_ref().unwrap()).risky_ref() - ); - } - Stage::Preflight | Stage::Authenticated => { - assert!(case.expected.rows.is_none()); - let err = result.unwrap().await.unwrap_err(); - refusal(&err, case.expected.error.as_ref().unwrap()); - assert_eq!( - case.expected.retrievals, - usize::from(case.expected.stage == Stage::Authenticated) - ); - } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +async fn check_case(case: Case) { + let context_name = if case.context_field_name.is_empty() { + "tenant" + } else { + assert!(case.context_field); + Label::new([case.context_field_name.as_str()]).unwrap(); + &case.context_field_name + }; + match case.expected.stage { + Stage::Success => { + assert!( + case.check_error.is_none() + && case.expected.error.is_none() + && case.expected.rows.is_some() + ); + assert!(case.expected.retrievals <= 1); + } + Stage::Authenticated => { + assert!(case.check_error.is_none()); + assert_eq!(case.expected.retrievals, 1); + } + Stage::Shape | Stage::Preflight => { + assert!(case.check_error.is_some()); + assert_eq!(case.expected.retrievals, 0); } - assert_eq!( - generates.load(Ordering::SeqCst), - 0, - "reads never generate data keys" - ); - assert_eq!( - retrieves.load(Ordering::SeqCst), - case.expected.retrievals, - "retrieval stage" - ); } + let write = plan(&case.write, case.context_field, context_name); + let read = plan(&case.read, case.context_field, context_name); + let expected = case + .expected_context + .as_deref() + .map(|s| Label::parse(s).unwrap()); + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let mut stored = record::encrypt( + &keyset, + rows(&case.rows, case.context_field.then_some(context_name)), + &write, + ) + .unwrap() + .await + .unwrap(); + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); + mutate(&mut stored, &case.mutation, context_name); + let checked = record::check_record(copy_tree(&stored), &read, expected.as_ref()); + if let Some(want) = &case.check_error { + refusal(&checked.unwrap_err(), want); + } else { + checked.unwrap(); + } + let result = record::decrypt(Scope::Keyset(keyset), stored, &read, expected); + match case.expected.stage { + Stage::Shape => { + assert!(case.expected.rows.is_none()); + refusal( + &result.err().expect("shape rejection before pending"), + case.expected.error.as_ref().unwrap(), + ); + } + Stage::Success => { + assert!(case.expected.error.is_none()); + let opened = result.unwrap().await.unwrap(); + assert_eq!( + opened.risky_ref(), + rows( + case.expected.rows.as_ref().unwrap(), + case.context_field.then_some(context_name) + ) + .risky_ref() + ); + } + Stage::Preflight | Stage::Authenticated => { + assert!(case.expected.rows.is_none()); + let err = result.unwrap().await.unwrap_err(); + refusal(&err, case.expected.error.as_ref().unwrap()); + assert_eq!( + case.expected.retrievals, + usize::from(case.expected.stage == Stage::Authenticated) + ); + } + } + assert_eq!( + generates.load(Ordering::SeqCst), + 0, + "reads never generate data keys" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + case.expected.retrievals, + "retrieval stage" + ); } From ea2be854ac1f797e94276592ba7f1ad1c60f8184 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 16:03:32 +1100 Subject: [PATCH 3/4] style(conformance): format read fixtures --- conformance/stack-encrypt/v1/reads.json | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/conformance/stack-encrypt/v1/reads.json b/conformance/stack-encrypt/v1/reads.json index b10df692e..7cdaad2a9 100644 --- a/conformance/stack-encrypt/v1/reads.json +++ b/conformance/stack-encrypt/v1/reads.json @@ -1103,6 +1103,30 @@ } } }, - {"id": "context-field-after-value-lexically", "write": {"kind": "int32", "null": ""}, "read": {"kind": "int32", "null": ""}, "context_field": true, "rows": [{"value": {"type": "int32", "text": "34"}, "zone": {"type": "string", "text": "tenants/acme"}}], "mutation": {"kind": "none", "row": 0}, "adapter": true, "expected": {"stage": "success", "retrievals": 1, "rows": [{"value": {"type": "int32", "text": "34"}, "zone": {"type": "string", "text": "tenants/acme"}}]}, "context_field_name": "zone"} + { + "id": "context-field-after-value-lexically", + "write": { "kind": "int32", "null": "" }, + "read": { "kind": "int32", "null": "" }, + "context_field": true, + "rows": [ + { + "value": { "type": "int32", "text": "34" }, + "zone": { "type": "string", "text": "tenants/acme" } + } + ], + "mutation": { "kind": "none", "row": 0 }, + "adapter": true, + "expected": { + "stage": "success", + "retrievals": 1, + "rows": [ + { + "value": { "type": "int32", "text": "34" }, + "zone": { "type": "string", "text": "tenants/acme" } + } + ] + }, + "context_field_name": "zone" + } ] } From ef88da8d97ef240caadbb2a7264881fd20596581 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 18:16:17 +1100 Subject: [PATCH 4/4] test(conformance): reject null expected context --- conformance/stack-encrypt/read-contract.md | 3 +- .../golang/encrypt/read_conformance_test.go | 33 +++++++++++++++ .../stack-encrypt/tests/read_conformance.rs | 41 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/conformance/stack-encrypt/read-contract.md b/conformance/stack-encrypt/read-contract.md index 219e332a7..80fae01f0 100644 --- a/conformance/stack-encrypt/read-contract.md +++ b/conformance/stack-encrypt/read-contract.md @@ -15,7 +15,8 @@ This supplies real ciphertext, including for negative reads. Without `context_field`, the context is `["conformance", "value"]`. With it, `tenant` is a string passthrough supplying the row context; `value` is its field identity. An optional `expected_context` constrains the stored context. -Its value must be a nonempty plain label. +Omit it for an unrestricted read; when present it must be a nonempty plain +label string. Explicit JSON null is invalid, not another spelling of omission. `expected` describes the native opener's stage, complete diagnostic code and payload on refusal, typed ordered rows on success, and data-key retrieval diff --git a/languages/golang/encrypt/read_conformance_test.go b/languages/golang/encrypt/read_conformance_test.go index 894bc8958..44e26526a 100644 --- a/languages/golang/encrypt/read_conformance_test.go +++ b/languages/golang/encrypt/read_conformance_test.go @@ -290,6 +290,12 @@ func (c *readCase) UnmarshalJSON(data []byte) error { if err := require(fields, "id", "write", "read", "context_field", "rows", "mutation", "adapter", "expected"); err != nil { return err } + // A supplied null must not decode to nil and become an unrestricted read. + if _, present := fields["expected_context"]; present { + if err := require(fields, "expected_context"); err != nil { + return err + } + } for key, names := range map[string][]string{"write": {"kind", "null"}, "read": {"kind", "null"}, "mutation": {"kind", "row"}, "expected": {"stage", "retrievals"}} { var nested map[string]json.RawMessage if err := json.Unmarshal(fields[key], &nested); err != nil { @@ -362,6 +368,33 @@ func TestReadCorpusSchema(t *testing.T) { if err := check([]byte(valid)); err != nil { t.Fatal(err) } + for _, test := range []struct { + name string + value string + valid bool + }{ + {"null", "null", false}, + {"empty", `""`, false}, + {"number", "42", false}, + {"array", "[]", false}, + {"object", "{}", false}, + {"label", `"tenants/acme"`, true}, + } { + t.Run("expected-context-"+test.name, func(t *testing.T) { + var data map[string]json.RawMessage + if err := json.Unmarshal([]byte(valid), &data); err != nil { + t.Fatal(err) + } + data["expected_context"] = json.RawMessage(test.value) + encoded, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := check(encoded); (err == nil) != test.valid { + t.Fatalf("expected_context=%s: valid=%t, error=%v", test.value, test.valid, err) + } + }) + } for _, path := range [][]string{{"context_field"}, {"adapter"}, {"mutation", "row"}, {"expected", "retrievals"}, {"write", "kind"}} { t.Run("missing-"+path[len(path)-1], func(t *testing.T) { var data map[string]any diff --git a/packages/stack-encrypt/tests/read_conformance.rs b/packages/stack-encrypt/tests/read_conformance.rs index e8beaa208..031377b92 100644 --- a/packages/stack-encrypt/tests/read_conformance.rs +++ b/packages/stack-encrypt/tests/read_conformance.rs @@ -41,6 +41,7 @@ struct Case { context_field_name: String, rows: Vec, mutation: Mutation, + #[serde(default, deserialize_with = "deserialize_expected_context")] expected_context: Option, check_error: Option, expected: Expected, @@ -48,6 +49,17 @@ struct Case { #[serde(rename = "adapter")] _adapter: bool, } + +// Only omission means unrestricted. Deserialize a supplied value as a label, +// rather than letting Option treat an explicit null as absence. +fn deserialize_expected_context<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let context = String::deserialize(deserializer)?; + Label::parse(&context).map_err(serde::de::Error::custom)?; + Ok(Some(context)) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Mutation { @@ -315,3 +327,32 @@ async fn check_case(case: Case) { "retrieval stage" ); } + +#[test] +fn expected_context_must_be_a_nonempty_label_when_present() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/reads.json" + )) + .unwrap(); + let mut case = corpus["cases"][0].clone(); + case.as_object_mut().unwrap().remove("expected_context"); + assert!(serde_json::from_value::(case.clone()) + .unwrap() + .expected_context + .is_none()); + case["expected_context"] = json!("tenants/acme"); + assert_eq!( + serde_json::from_value::(case.clone()) + .unwrap() + .expected_context + .as_deref(), + Some("tenants/acme") + ); + for invalid in [Value::Null, json!(""), json!(42), json!([]), json!({})] { + case["expected_context"] = invalid.clone(); + assert!( + serde_json::from_value::(case.clone()).is_err(), + "accepted expected_context: {invalid}" + ); + } +}