diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index d243933c4..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 ``` @@ -20,13 +21,23 @@ 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 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 +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 The [runtime contract](runtime-contract.md) defines cases in `v1/runtime.json`: @@ -34,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 @@ -49,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 a3a48eced..6b54fdc02 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 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 | |---|---|---| @@ -52,7 +70,7 @@ No existing test has been deleted in these first three 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 new file mode 100644 index 000000000..80fae01f0 --- /dev/null +++ b/conformance/stack-encrypt/read-contract.md @@ -0,0 +1,126 @@ +# Record read profile + +`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. + +## 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. +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 +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 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. + +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, 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. + +## 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 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 +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. + +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/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..7cdaad2a9 --- /dev/null +++ b/conformance/stack-encrypt/v1/reads.json @@ -0,0 +1,1132 @@ +{ + "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": true, + "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" + } + } + }, + { + "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 eb96f1735..df11cd397 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 @@ -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. @@ -121,9 +121,34 @@ 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 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. 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, 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 +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..2b500104e 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,22 @@ func DeterministicGuest() ([]byte, error) { } return wasm, 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) + } + p, err := cph.plan(plan) + if err != nil { + return nil, err + } + return cph.client.openTree(ctx, cph.keyset, cph.context, p, tree) +} + +// 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 new file mode 100644 index 000000000..44e26526a --- /dev/null +++ b/languages/golang/encrypt/read_conformance_test.go @@ -0,0 +1,483 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "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"` + 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"` + Rows *[]map[string]conformanceInput `json:"rows"` + } `json:"expected"` +} + +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 = contextName + } + return p +} + +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[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]}} + } + batch[i] = fields + } + return batch +} + +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") + } + plain := vcvalue.Plain{V: int32(34)} + if c.Mutation.Kind == "row_plain" { + batch[i] = plain + return + } + fields := readMap(t, batch[i], "row") + 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, contextName) + case "tenant_no_payload": + fields[contextName] = map[string]any{} + case "tenant_number": + readMap(t, fields[contextName], "context outputs")["passthrough"] = plain + default: + outputs := readMap(t, fields["value"], "value outputs") + 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) + } + 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 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 := encrypt.DeterministicGuest() + 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) { + 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, 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, 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, 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) + } + 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 { + 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(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 != nil { + reader = cipher.Context(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") + } + } + } + }) + } +} + +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 + } + // 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 { + 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 _, 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 + 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 new file mode 100644 index 000000000..031377b92 --- /dev/null +++ b/packages/stack-encrypt/tests/read_conformance.rs @@ -0,0 +1,358 @@ +//! 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, + #[serde(default)] + context_field_name: String, + rows: Vec, + mutation: Mutation, + #[serde(default, deserialize_with = "deserialize_expected_context")] + expected_context: Option, + check_error: Option, + expected: Expected, + // Go additionally runs representable cases through its normal record adapter. + #[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 { + 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, context_name: &str) -> 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); + } + // 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 { + fields.push(("context_field", frame(context_name.to_owned()))); + fields.push(( + context_name, + framed(&json!({"context":[context_name],"outputs":["passthrough"],"type":"string"})), + )); + } + fields.push(("value", framed(&spec))); + record::plan(object(fields).risky_ref()).unwrap() +} +fn rows(input: &[Row], context_name: Option<&str>) -> ValueFrame { + frame( + input + .iter() + .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::>(), + ) +} +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, context_name: &str) { + 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 != 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 { + "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}"), + } + } + } +} + +#[test] +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 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 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}")); + } + } + 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); + } + } + 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" + ); +} + +#[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}" + ); + } +} 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 -- \