From 358fa9b0002ff0b1ca96e11464c665f57401c302 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 31 Oct 2026 23:06:17 +1100 Subject: [PATCH 1/4] test(encrypt): add runtime conformance --- conformance/stack-encrypt/README.md | 12 +- conformance/stack-encrypt/coverage.md | 18 +- conformance/stack-encrypt/runtime-contract.md | 83 ++ conformance/stack-encrypt/v1/runtime.json | 816 ++++++++++++++++++ .../2026-10-31-stack-encrypt-conformance.md | 20 +- languages/golang/encrypt/conformance_test.go | 42 +- .../encrypt/runtime_conformance_test.go | 189 ++++ .../stack-encrypt/tests/common/conformance.rs | 29 + packages/stack-encrypt/tests/common/mod.rs | 2 + packages/stack-encrypt/tests/conformance.rs | 24 +- .../tests/runtime_conformance.rs | 270 ++++++ .../__tests__/stack-conformance-ci.test.mjs | 5 +- scripts/stack-conformance.sh | 4 +- 13 files changed, 1467 insertions(+), 47 deletions(-) create mode 100644 conformance/stack-encrypt/runtime-contract.md create mode 100644 conformance/stack-encrypt/v1/runtime.json create mode 100644 languages/golang/encrypt/runtime_conformance_test.go create mode 100644 packages/stack-encrypt/tests/common/conformance.rs create mode 100644 packages/stack-encrypt/tests/runtime_conformance.rs diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 2932078b0..966a1954d 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -19,13 +19,23 @@ It runs native Rust against frozen vectors, Go against the same vectors, then 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. +languages, with native observations of normalization. It also runs the shared +runtime 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-runtime-v1` contract + +The [runtime contract](runtime-contract.md) defines 37 cases in `v1/runtime.json`: +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. + ## `record-declarations-v1` contract The [declaration grammar](declaration-grammar.md) defines the engine's current diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 53ff44d27..8d79820e1 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -23,12 +23,28 @@ Paths are relative to the repository root. New profile cases run from `v1/`. Python and future TypeScript runners have no evidence in this checkout. Their absence is a delivery gap, not a test skip that counts toward certification. +## Runtime checkpoint + +`v1/runtime.json` adds 37 shared cases for typed writes and reads, null storage, +batches, equality queries and lossless numeric conversion. See the +[runtime contract](runtime-contract.md) for exact observations and limits. +Native data-key counters cover zero-call preflight failures and single-call +batches; Go provider-call instrumentation remains open. + +| Existing evidence | Shared case IDs | Retained assertions | +|---|---|---| +| Native SQL-null record tests | `sql-missing`, `sql-null`, `sql-mixed-batch`, `sql-all-absent` | Wider declarations and malformed stored-record behavior | +| Native sealed-null padding/equality tests | `sealed-mixed-batch`, `sealed-int64`, `sealed-uint64`, `sealed-float64` | Other integer widths, bool ciphertext, dates, timestamps and decimals | +| Go generated `null_test.go` | Same sealed/SQL semantics | Pointer mapping, generated methods, EQL envelopes, reading null into nonpointer fields | +| 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 | + ## 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 two slices. +No existing test has been deleted in these first three slices. | Existing evidence | Relationship to conformance | Consolidation action | |---|---|---| diff --git a/conformance/stack-encrypt/runtime-contract.md b/conformance/stack-encrypt/runtime-contract.md new file mode 100644 index 000000000..be52d6bf1 --- /dev/null +++ b/conformance/stack-encrypt/runtime-contract.md @@ -0,0 +1,83 @@ +# Record runtime profile + +`record-runtime-v1` describes the behavior exercised by `v1/runtime.json`. +It is an experimental test contract for native Rust and Go WASI, with no EQL +resolver. Python and TypeScript runners remain pending. + +Each case uses one field, `value`, with ciphertext (`c`) and equality (`eq`) +outputs, under context `["conformance", "value"]`. `kind` declares its type; +`null` is empty for a required value, `sql`, or `sealed`. Values use the scalar +profile's `{type, text}` representation, extended with `float64` decimal text. +Runners construct typed values before calling the engine. + +## Write and read + +The `seal` operation receives an ordered `rows` array of field maps. An absent +map key is missing; `{ "type": "null", "text": "" }` is an explicit null. +These are separate inputs, including in languages that offer a nil-like value. + +Successful cases specify `expected_rows` and `expected_stored`. The former +describes opened values with their types and order. The latter states whether +each row stores the field at all. Both must have the input row count. + +- Required fields reject missing values and explicit nulls. +- SQL-null fields accept both, omit the stored field, and open as explicit null. +- Sealed-null fields require the key to exist. An explicit null stores both + ciphertext and equality, then opens as null. +- Runtime writes require the declared numeric type. A losslessly convertible + value of another width is still refused on this path. +- Unknown fields are refused; they cannot be silently dropped. +- An invalid later row rejects the whole batch before data-key generation. +- An empty batch succeeds with no data-key calls. + +Every stored equality term must match a query for its expected opened value. +`equal_lengths` additionally requires all stored ciphertexts in the case to +have equal lengths. Mixed sealed-null/value cases exercise this requirement +for int32, int64, uint64 and float64. This is not a claim about all types. +Boolean equality is not admitted; existing native null tests cover boolean +ciphertext separately. + +## Equality queries + +The `query` operation receives `input` and either `expected_value` or +`expected_error`. A successful query's term must equal the term stored by +encrypting `expected_value` through the same declaration. That value is +independent expected data, not a conversion computed by the runner. + +Cases cover signed narrowing bounds, integral and fractional float inputs, +negative-to-unsigned refusal, and exact versus rounded integer-to-float +conversion around 2^53. Text and boolean values are not parsed as numbers. +A null equality query succeeds only for sealed-null fields. + +## Refusals and observations + +`expected_error` specifies the engine diagnostic `code`, `reason`, and `field`. +Native Rust must reject before producing a pending operation. Go must expose +`ErrEncoding` and the same diagnostic when the request reaches the guest. + +Missing and unknown fields are rejected earlier by Go's record adapter. +Those cases additionally specify `go_message`, an exact adapter regression +observation, and require no engine diagnostic. This prose is not a portable +engine error contract. A future SDK should report equivalent semantics at its +earliest supported stage, without adopting Go's wording. + +The native runner instruments data-key generation and retrieval calls. A +successful batch makes one of each when it stores ciphertext, and zero when +every field is omitted. Refused writes and all queries make neither call. +The query oracle's separate encryption is counted separately. These assertions +exclude index-key initialization and do not establish Go provider-call counts. +Go runs the same semantic cases through its deterministic WASI guest; that +guest's lack of network requests is not proof of logical key-call counts. + +## Scope and remaining work + +The 37 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. +Fresh cross-language artifact exchange remains the scalar profile's coverage. + +Existing `value_kinds.json` and `query_operators.json` fixtures remain the +shared evidence for their wider type/operator matrices. Existing generated +Go null tests retain pointer mapping and EQL assertions. Native null tests +retain their wider kinds and lower-level padding assertions. No tests are +deleted by this slice; consolidation requires matching those assertions first. diff --git a/conformance/stack-encrypt/v1/runtime.json b/conformance/stack-encrypt/v1/runtime.json new file mode 100644 index 000000000..261e1bf2c --- /dev/null +++ b/conformance/stack-encrypt/v1/runtime.json @@ -0,0 +1,816 @@ +{ + "version": 1, + "profile": "record-runtime-v1", + "required_capabilities": [ + "records", + "equality", + "null-policies", + "lossless-query-conversion" + ], + "cases": [ + { + "id": "required-missing", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [{}], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_missing", + "field": "value", + "go_message": "cipherstash: malformed input: row 0: no value for field \"value\"" + } + }, + { + "id": "required-null", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "required-boundaries", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "required-wrong-width", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "int64", + "text": "34" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "sql-missing", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [{}], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [false], + "equal_lengths": false + }, + { + "id": "sql-null", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [false], + "equal_lengths": false + }, + { + "id": "sql-boundaries", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "sql-wrong-width", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + { + "value": { + "type": "int64", + "text": "34" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "sealed-missing", + "operation": "seal", + "kind": "int32", + "null": "sealed", + "rows": [{}], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_missing", + "field": "value", + "go_message": "cipherstash: malformed input: row 0: no value for field \"value\"" + } + }, + { + "id": "sealed-null", + "operation": "seal", + "kind": "int32", + "null": "sealed", + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [true], + "equal_lengths": false + }, + { + "id": "sealed-boundaries", + "operation": "seal", + "kind": "int32", + "null": "sealed", + "rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "value": { + "type": "int32", + "text": "2147483647" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "sealed-wrong-width", + "operation": "seal", + "kind": "int32", + "null": "sealed", + "rows": [ + { + "value": { + "type": "int64", + "text": "34" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "sql-mixed-batch", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + {}, + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + }, + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [false, true, false], + "equal_lengths": false + }, + { + "id": "sealed-mixed-batch", + "operation": "seal", + "kind": "int32", + "null": "sealed", + "rows": [ + { + "value": { + "type": "null", + "text": "" + } + }, + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + }, + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [true, true, true], + "equal_lengths": true + }, + { + "id": "sql-all-absent", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + {}, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "null", + "text": "" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [false, false], + "equal_lengths": false + }, + { + "id": "empty-batch", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [], + "expected_rows": [], + "expected_stored": [], + "equal_lengths": false + }, + { + "id": "late-wrong-type", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "value": { + "type": "string", + "text": "private-value" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "late-missing", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + }, + {} + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_missing", + "field": "value", + "go_message": "cipherstash: malformed input: row 1: no value for field \"value\"" + } + }, + { + "id": "unknown-field", + "operation": "seal", + "kind": "int32", + "null": "", + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + }, + "extra": { + "type": "string", + "text": "private-value" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "unknown_field", + "field": "extra", + "go_message": "cipherstash: malformed input: row 0: the plan has no field \"extra\"" + } + }, + { + "id": "sealed-int64", + "operation": "seal", + "kind": "int64", + "null": "sealed", + "rows": [ + { + "value": { + "type": "int64", + "text": "9223372036854775807" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "int64", + "text": "9223372036854775807" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "sealed-uint64", + "operation": "seal", + "kind": "uint64", + "null": "sealed", + "rows": [ + { + "value": { + "type": "uint64", + "text": "18446744073709551615" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "uint64", + "text": "18446744073709551615" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "sealed-float64", + "operation": "seal", + "kind": "float64", + "null": "sealed", + "rows": [ + { + "value": { + "type": "float64", + "text": "1.5" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_rows": [ + { + "value": { + "type": "float64", + "text": "1.5" + } + }, + { + "value": { + "type": "null", + "text": "" + } + } + ], + "expected_stored": [true, true], + "equal_lengths": true + }, + { + "id": "query-narrow-int", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "int64", + "text": "34" + }, + "expected_value": { + "type": "int32", + "text": "34" + } + }, + { + "id": "query-integral-float", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "float64", + "text": "34" + }, + "expected_value": { + "type": "int32", + "text": "34" + } + }, + { + "id": "query-int32-min", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "int64", + "text": "-2147483648" + }, + "expected_value": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "id": "query-int32-max", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "int64", + "text": "2147483647" + }, + "expected_value": { + "type": "int32", + "text": "2147483647" + } + }, + { + "id": "query-int32-underflow", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "int64", + "text": "-2147483649" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-int32-overflow", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "int64", + "text": "2147483648" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-fraction", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "float64", + "text": "34.5" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-negative-unsigned", + "operation": "query", + "kind": "uint64", + "null": "", + "input": { + "type": "int64", + "text": "-1" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-exact-integer-float", + "operation": "query", + "kind": "float64", + "null": "", + "input": { + "type": "uint64", + "text": "9007199254740992" + }, + "expected_value": { + "type": "float64", + "text": "9007199254740992" + } + }, + { + "id": "query-lossy-integer-float", + "operation": "query", + "kind": "float64", + "null": "", + "input": { + "type": "uint64", + "text": "9007199254740993" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-text-is-not-number", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "string", + "text": "34" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-bool-is-not-number", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "bool", + "text": "true" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type", + "field": "value" + } + }, + { + "id": "query-required-null", + "operation": "query", + "kind": "int32", + "null": "", + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "value" + } + }, + { + "id": "query-sql-null", + "operation": "query", + "kind": "int32", + "null": "sql", + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "null_query", + "field": "value" + } + }, + { + "id": "query-sealed-null", + "operation": "query", + "kind": "int32", + "null": "sealed", + "input": { + "type": "null", + "text": "" + }, + "expected_value": { + "type": "null", + "text": "" + } + } + ] +} diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 9cbc4c863..967d31030 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 and declaration slices implemented; the complete contract is not frozen. +Date: 2026-10-31. Status: scalar, declaration and initial runtime 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 @@ -107,6 +107,24 @@ records consolidation candidates. Shared case tables can replace duplicated expectations once coverage matches; language-specific and implementation tests remain. No existing tests have been removed. +## Runtime checkpoint + +The initial runtime slice is on `feat/stack-encrypt-runtime-conformance`, stacked +on draft [PR #1460](https://github.com/cipherstash/stack/pull/1460). Its 37 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. +The [runtime contract](../../conformance/stack-encrypt/runtime-contract.md) +describes the corpus and limits. No production API or existing test is removed. + +Local validation passes for all three profiles (249 shared cases), fresh scalar +ciphertext exchange, Rust clippy/formatting, Go lint, Biome and 34 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. + ## Follow-up slices - Extend the declaration grammar to EQL resolver capabilities and the operation diff --git a/languages/golang/encrypt/conformance_test.go b/languages/golang/encrypt/conformance_test.go index a0b465c18..1d60c84cd 100644 --- a/languages/golang/encrypt/conformance_test.go +++ b/languages/golang/encrypt/conformance_test.go @@ -33,12 +33,9 @@ type conformanceCorpus struct { } type conformanceCase struct { - ID string `json:"id"` - Kind string `json:"kind"` - Input struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"input"` + ID string `json:"id"` + Kind string `json:"kind"` + Input conformanceInput `json:"input"` ExpectedError *struct { Code string `json:"code"` Reason string `json:"reason"` @@ -75,15 +72,20 @@ func readConformanceJSON(t *testing.T, path string, out any) { } } -func conformanceValue(t *testing.T, c conformanceCase) any { +type conformanceInput struct { + Type string `json:"type"` + Text string `json:"text"` +} + +func conformanceValue(t *testing.T, input conformanceInput) any { t.Helper() - switch c.Input.Type { + switch input.Type { case "int32", "int64": bits := 64 - if c.Input.Type == "int32" { + if input.Type == "int32" { bits = 32 } - n, err := strconv.ParseInt(c.Input.Text, 10, bits) + n, err := strconv.ParseInt(input.Text, 10, bits) if err != nil { t.Fatal(err) } @@ -92,25 +94,31 @@ func conformanceValue(t *testing.T, c conformanceCase) any { } return n case "uint64": - n, err := strconv.ParseUint(c.Input.Text, 10, 64) + n, err := strconv.ParseUint(input.Text, 10, 64) + if err != nil { + t.Fatal(err) + } + return n + case "float64": + n, err := strconv.ParseFloat(input.Text, 64) if err != nil { t.Fatal(err) } return n case "bool": - if c.Input.Text != "true" && c.Input.Text != "false" { + if input.Text != "true" && input.Text != "false" { t.Fatal("bool must be true or false") } - return c.Input.Text == "true" + return input.Text == "true" case "string": - return c.Input.Text + return input.Text case "null": - if c.Input.Text != "" { + if input.Text != "" { t.Fatal("null has empty text") } return nil default: - t.Fatalf("unsupported input type %q", c.Input.Type) + t.Fatalf("unsupported input type %q", input.Type) return nil } } @@ -166,7 +174,7 @@ func TestStackConformance(t *testing.T) { plan := &record.Plan{Context: corpus.Context, Fields: []record.Field{{ Name: corpus.Field, Kind: record.Kind(c.Kind), Outputs: []record.Output{record.Ciphertext, record.Equality}, }}} - input := conformanceValue(t, c) + input := conformanceValue(t, c.Input) query, err := cipher.QueryTerm(ctx, plan, corpus.Field, "eq", input) if c.ExpectedError != nil { var diagnostic *encrypt.Diagnostic diff --git a/languages/golang/encrypt/runtime_conformance_test.go b/languages/golang/encrypt/runtime_conformance_test.go new file mode 100644 index 000000000..fd2193f85 --- /dev/null +++ b/languages/golang/encrypt/runtime_conformance_test.go @@ -0,0 +1,189 @@ +package encrypt_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +type runtimeCase struct { + ID string `json:"id"` + Operation string `json:"operation"` + Kind string `json:"kind"` + Null string `json:"null"` + Rows *[]map[string]conformanceInput `json:"rows"` + ExpectedRows *[]map[string]conformanceInput `json:"expected_rows"` + ExpectedStored *[]bool `json:"expected_stored"` + EqualLengths bool `json:"equal_lengths"` + Input *conformanceInput `json:"input"` + ExpectedValue *conformanceInput `json:"expected_value"` + ExpectedError *struct { + GoMessage string `json:"go_message"` + Code string `json:"code"` + Reason string `json:"reason"` + Field string `json:"field"` + } `json:"expected_error"` +} + +func runtimeRows(t *testing.T, input []map[string]conformanceInput) []record.Source { + t.Helper() + rows := make([]record.Source, len(input)) + for i, row := range input { + rows[i] = record.Source{} + for key, v := range row { + rows[i][key] = conformanceValue(t, v) + } + } + return rows +} + +func runtimeRefusal(t *testing.T, err error, c runtimeCase) { + t.Helper() + var diagnostic *encrypt.Diagnostic + if c.ExpectedError.GoMessage != "" { + if !errors.Is(err, encrypt.ErrEncoding) || errors.As(err, &diagnostic) || err.Error() != c.ExpectedError.GoMessage { + t.Fatalf("unexpected host refusal: %v", err) + } + return + } + if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &diagnostic) { + t.Fatalf("expected encoding diagnostic, got %v", err) + } + want := c.ExpectedError + if diagnostic.Code != want.Code || diagnostic.Reason() != want.Reason || diagnostic.Field() != want.Field { + t.Fatalf("unexpected diagnostic: %s reason=%s field=%s", diagnostic.Code, diagnostic.Reason(), diagnostic.Field()) + } +} + +func TestRuntimeConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + RequiredCapabilities []string `json:"required_capabilities"` + Cases []runtimeCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "runtime.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-runtime-v1" || len(corpus.Cases) == 0 { + t.Fatal("unsupported or empty runtime corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "equality", "null-policies", "lossless-query-conversion"}) { + t.Fatal("unsupported 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() }) + cipher := client.DefaultKeyset() + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or repeated ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + plan := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(c.Kind), Null: record.Null(c.Null), Outputs: []record.Output{record.Ciphertext, record.Equality}}}} + switch c.Operation { + case "seal": + if c.Rows == nil || c.Input != nil || c.ExpectedValue != nil { + t.Fatal("invalid seal case") + } + sealed, err := cipher.Seal(ctx, plan, runtimeRows(t, *c.Rows)) + if c.ExpectedError != nil { + if c.ExpectedRows != nil || c.ExpectedStored != nil || c.EqualLengths { + t.Fatal("ambiguous seal outcome") + } + runtimeRefusal(t, err, c) + return + } + if err != nil { + t.Fatal(err) + } + if c.ExpectedRows == nil || c.ExpectedStored == nil { + t.Fatal("missing seal outcome") + } + expected := runtimeRows(t, *c.ExpectedRows) + if len(sealed) != len(*c.Rows) || len(expected) != len(sealed) || len(*c.ExpectedStored) != len(sealed) { + t.Fatal("row count differs") + } + length := -1 + for i, row := range sealed { + stored, present := row["value"] + if present != (*c.ExpectedStored)[i] { + t.Fatalf("row %d: stored field presence differs", i) + } + if !present { + if len(row) != 0 { + t.Fatal("unexpected stored field") + } + continue + } + if len(row) != 1 || len(stored.Ciphertext) == 0 || len(stored.Terms) != 1 || len(stored.Terms[record.Equality]) == 0 { + t.Fatal("unexpected outputs") + } + if c.EqualLengths { + if length >= 0 && length != len(stored.Ciphertext) { + t.Fatal("null and value ciphertext lengths differ") + } + length = len(stored.Ciphertext) + } + term, err := cipher.QueryTerm(ctx, plan, "value", "eq", expected[i]["value"]) + if err != nil || len(term.Terms) != 1 || !bytes.Equal(term.Terms[0], stored.Terms[record.Equality]) { + t.Fatalf("stored/query equality mismatch: %v", err) + } + } + opened, err := cipher.Open(ctx, plan, sealed) + if err != nil { + t.Fatal(err) + } + // Compare rows individually: an empty batch may use a nil slice in Go. + if len(opened) != len(expected) { + t.Fatal("opened row count differs") + } + for i := range expected { + if !reflect.DeepEqual(opened[i], expected[i]) { + t.Fatalf("row %d: typed roundtrip differs", i) + } + } + case "query": + if c.Input == nil || c.Rows != nil || c.ExpectedRows != nil || c.ExpectedStored != nil || c.EqualLengths { + t.Fatal("invalid query case") + } + term, err := cipher.QueryTerm(ctx, plan, "value", "eq", conformanceValue(t, *c.Input)) + if c.ExpectedError != nil { + if c.ExpectedValue != nil { + t.Fatal("ambiguous query outcome") + } + runtimeRefusal(t, err, c) + return + } + if err != nil { + t.Fatal(err) + } + if c.ExpectedValue == nil { + t.Fatal("missing query outcome") + } + sealed, err := cipher.Seal(ctx, plan, []record.Source{{"value": conformanceValue(t, *c.ExpectedValue)}}) + if err != nil { + t.Fatal(err) + } + if len(sealed) != 1 || len(term.Terms) != 1 || !bytes.Equal(term.Terms[0], sealed[0]["value"].Terms[record.Equality]) { + t.Fatal("query differs from normalized stored value") + } + default: + t.Fatalf("unknown operation %q", c.Operation) + } + }) + } +} diff --git a/packages/stack-encrypt/tests/common/conformance.rs b/packages/stack-encrypt/tests/common/conformance.rs new file mode 100644 index 000000000..14828fd08 --- /dev/null +++ b/packages/stack-encrypt/tests/common/conformance.rs @@ -0,0 +1,29 @@ +//! Typed inputs shared by conformance profiles. +use super::frames::frame; +use serde::Deserialize; +use stack_encrypt::dynamic::ValueFrame; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Input { + #[serde(rename = "type")] + pub kind: String, + pub text: String, +} + +pub fn value(input: &Input) -> ValueFrame { + match input.kind.as_str() { + "int32" => frame(input.text.parse::().expect("i32")), + "int64" => frame(input.text.parse::().expect("i64")), + "uint64" => frame(input.text.parse::().expect("u64")), + "float64" => frame(input.text.parse::().expect("f64")), + "bool" => frame(input.text.parse::().expect("bool")), + "string" => frame(input.text.clone()), + "null" => { + assert!(input.text.is_empty()); + use vitaminc_aead::plaintext::{Physical, Raw}; + frame(Raw(None, Physical::Null)) + } + other => panic!("unsupported input kind {other}"), + } +} diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs index 812d50f27..9e4fb1b8b 100644 --- a/packages/stack-encrypt/tests/common/mod.rs +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -307,3 +307,5 @@ pub fn unhex(text: &str) -> Vec { .map(|i| u8::from_str_radix(&text[i..i + 2], 16).expect("hex byte")) .collect() } +#[cfg(feature = "dynamic")] +pub mod conformance; diff --git a/packages/stack-encrypt/tests/conformance.rs b/packages/stack-encrypt/tests/conformance.rs index c32cef931..8a1251d7c 100644 --- a/packages/stack-encrypt/tests/conformance.rs +++ b/packages/stack-encrypt/tests/conformance.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::atomic::Ordering; +use common::conformance::{value, Input}; use common::frames::{frame, object}; use common::{hex, unhex}; use miette::Diagnostic; @@ -43,14 +44,6 @@ struct Case { expected_error: Option, } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Input { - #[serde(rename = "type")] - kind: String, - text: String, -} - #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ExpectedError { @@ -79,21 +72,6 @@ fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../conformance/stack-encrypt/v1") } -fn value(input: &Input) -> ValueFrame { - match input.kind.as_str() { - "int32" => frame(input.text.parse::().expect("i32")), - "int64" => frame(input.text.parse::().expect("i64")), - "uint64" => frame(input.text.parse::().expect("u64")), - "bool" => frame(input.text.parse::().expect("bool")), - "string" => frame(input.text.clone()), - "null" => { - assert!(input.text.is_empty()); - use vitaminc_aead::plaintext::{Physical, Raw}; - frame(Raw(None, Physical::Null)) - } - other => panic!("unsupported input kind {other}"), - } -} fn plan(corpus: &Corpus, case: &Case) -> record::Plan { let mut context = corpus.context.clone(); diff --git a/packages/stack-encrypt/tests/runtime_conformance.rs b/packages/stack-encrypt/tests/runtime_conformance.rs new file mode 100644 index 000000000..d18058be7 --- /dev/null +++ b/packages/stack-encrypt/tests/runtime_conformance.rs @@ -0,0 +1,270 @@ +//! Shared runtime semantics, with native data-key call observations. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{value, Input}, + frames::{frame, object}, +}; +use miette::Diagnostic; +use serde::Deserialize; +use serde_json::json; +use stack_encrypt::dynamic::{self, record, NoTargets, OpSpec, Operand, Scope, ValueFrame}; +use stack_encrypt::{CipherText, 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)] +struct Case { + id: String, + kind: String, + null: String, + #[serde(flatten)] + operation: Operation, +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +enum Operation { + Seal { + rows: Vec, + expected_rows: Option>, + expected_stored: Option>, + #[serde(default)] + equal_lengths: bool, + expected_error: Option, + }, + Query { + input: Input, + expected_value: Option, + expected_error: Option, + }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedError { + // Go can refuse record shape before entering the guest. + #[serde(rename = "go_message")] + _go_message: Option, + code: String, + reason: String, + field: String, +} + +fn refusal(error: dynamic::Error, expected: &ExpectedError) { + assert_eq!(error.code().unwrap().to_string(), expected.code); + assert_eq!(error.reason().unwrap().as_str(), expected.reason); + assert_eq!(error.field(), Some(expected.field.as_str())); +} + +fn rows(input: &[Row]) -> ValueFrame { + frame( + input + .iter() + .map(|row| object(row.iter().map(|(key, input)| (key, value(input))).collect())) + .collect::>(), + ) +} + +// Return the actual bytes and length, rather than deriving storage from input. +fn outputs(tree: &StackCipherText) -> Option<(Vec, usize)> { + let CipherText::Map(fields) = tree else { + panic!("record map") + }; + if fields.is_empty() { + return None; + } + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].0, "value"); + let CipherText::Map(outputs) = &fields[0].1 else { + panic!("output map") + }; + assert_eq!(outputs.len(), 2); + let CipherText::Single(leaf) = &outputs.iter().find(|(k, _)| k == "c").unwrap().1 else { + panic!("ciphertext") + }; + let CipherText::Passthrough(term) = &outputs.iter().find(|(k, _)| k == "eq").unwrap().1 else { + panic!("term") + }; + Some(( + vitaminc_aead_frame::serde::from_slice::<&[u8]>(&term.0) + .unwrap() + .to_vec(), + leaf.to_bytes().len(), + )) +} + +#[tokio::test] +async fn shared_record_runtime_profile() { + let corpus: Corpus = serde_json::from_str(include_str!( + "../../../conformance/stack-encrypt/v1/runtime.json" + )) + .unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-runtime-v1"); + assert_eq!( + corpus.required_capabilities, + [ + "records", + "equality", + "null-policies", + "lossless-query-conversion" + ] + ); + 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!("runtime conformance: {}", case.id); + assert!(["", "sql", "sealed"].contains(&case.null.as_str())); + let mut spec = + json!({"context":["conformance", "value"], "outputs":["c", "eq"], "type":case.kind}); + if !case.null.is_empty() { + spec["null"] = json!(case.null); + } + let plan = + record::plan(&vitaminc_aead_frame::serde::to_vec(&json!({"value":spec})).unwrap()) + .unwrap(); + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + match case.operation { + Operation::Seal { + rows: input, + expected_rows, + expected_stored, + equal_lengths, + expected_error, + } => { + let result = record::encrypt(&keyset, rows(&input), &plan); + if let Some(expected) = expected_error { + assert!(expected_rows.is_none() && expected_stored.is_none() && !equal_lengths); + refusal(result.err().expect("preflight refusal"), &expected); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + continue; + } + let expected_rows = expected_rows.expect("opened rows"); + let expected_stored = expected_stored.expect("storage presence"); + assert_eq!(input.len(), expected_rows.len()); + assert_eq!(input.len(), expected_stored.len()); + let sealed = result.unwrap().await.unwrap(); + let CipherText::Sequence(batch) = &sealed else { + panic!("batch") + }; + assert_eq!(batch.len(), input.len()); + let stored: Vec<_> = batch.iter().map(outputs).collect(); + assert_eq!( + stored.iter().map(Option::is_some).collect::>(), + expected_stored + ); + let calls = usize::from(expected_stored.iter().any(|&present| present)); + assert_eq!( + generates.load(Ordering::SeqCst), + calls, + "batch generation calls" + ); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + if equal_lengths { + let lengths: BTreeSet<_> = stored + .iter() + .map(|v| v.as_ref().expect("stored field").1) + .collect(); + assert_eq!(lengths.len(), 1, "null and value ciphertext lengths"); + } + for (expected, stored) in expected_rows.iter().zip(&stored) { + if let Some((bytes, _)) = stored { + let term = dynamic::query_term( + &keyset, + &plan, + "value", + OpSpec::Eq, + value(&expected["value"]), + &NoTargets, + ) + .unwrap() + .await + .unwrap(); + assert_eq!(term.operand(), &Operand::One(bytes.clone())); + } + } + assert_eq!( + generates.load(Ordering::SeqCst), + calls, + "queries generate no data keys" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + 0, + "queries retrieve no data keys" + ); + let opened = record::decrypt(Scope::Keyset(keyset.clone()), sealed, &plan, None) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened.risky_ref(), + rows(&expected_rows).risky_ref(), + "typed ordered roundtrip" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + calls, + "batch retrieval calls" + ); + assert_eq!( + generates.load(Ordering::SeqCst), + calls, + "opening generates no data keys" + ); + } + Operation::Query { + input, + expected_value, + expected_error, + } => { + let result = dynamic::query_term( + &keyset, + &plan, + "value", + OpSpec::Eq, + value(&input), + &NoTargets, + ); + if let Some(expected) = expected_error { + assert!(expected_value.is_none()); + refusal(result.err().expect("preflight refusal"), &expected); + } else { + let term = result.unwrap().await.unwrap(); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + let expected = expected_value.expect("normalized value"); + let sealed = + record::encrypt(&keyset, object(vec![("value", value(&expected))]), &plan) + .unwrap() + .await + .unwrap(); + assert_eq!(term.operand(), &Operand::One(outputs(&sealed).unwrap().0)); + // This generation belongs to the independent stored-value oracle. + assert_eq!(generates.load(Ordering::SeqCst), 1); + continue; + } + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + } + } +} diff --git a/scripts/__tests__/stack-conformance-ci.test.mjs b/scripts/__tests__/stack-conformance-ci.test.mjs index 5321c75e3..d684a5bd6 100644 --- a/scripts/__tests__/stack-conformance-ci.test.mjs +++ b/scripts/__tests__/stack-conformance-ci.test.mjs @@ -5,7 +5,7 @@ import { REPO_ROOT } from './lib/repo-root.mjs' import { readWorkflow } from './lib/workflows.mjs' describe('shared Stack Encrypt conformance is required in CI', () => { - it('the conformance task runs both profiles in both languages', () => { + it('the conformance task runs all profiles in both languages', () => { const script = readFileSync( join(REPO_ROOT, 'scripts/stack-conformance.sh'), 'utf8', @@ -19,8 +19,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).toContain( - "go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance)$' ./encrypt", + "go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance)$' ./encrypt", ) }) diff --git a/scripts/stack-conformance.sh b/scripts/stack-conformance.sh index 0b80b7d1d..ca45ad5fe 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 +mise x --env test -- cargo nextest run --locked -p stack-encrypt --all-features --test conformance --test declarations --test runtime_conformance ( cd languages/golang STACK_CONFORMANCE_OUTPUT="$artifacts/go.json" CGO_ENABLED=0 \ - go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance)$' ./encrypt + go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance|TestRuntimeConformance)$' ./encrypt ) test -s "$artifacts/go.json" STACK_CONFORMANCE_INPUT="$artifacts/go.json" mise x --env test -- \ From ac9e1309e9a005883a01dbef7af816b649b48446 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 15:47:03 +1100 Subject: [PATCH 2/4] fix(conformance): align runtime observations Compare stored lengths after SQL-null omission, preserve structured source diagnostics at the Go boundary, and share output and refusal assertions. Refs #1461 --- conformance/stack-encrypt/README.md | 2 +- conformance/stack-encrypt/coverage.md | 2 +- conformance/stack-encrypt/runtime-contract.md | 19 +++-- conformance/stack-encrypt/v1/runtime.json | 16 ++-- .../2026-10-31-stack-encrypt-conformance.md | 6 +- languages/golang/encrypt/conformance_test.go | 19 +++-- languages/golang/encrypt/records.go | 13 ++- .../encrypt/runtime_conformance_test.go | 35 +++----- .../stack-encrypt/tests/common/conformance.rs | 36 +++++++++ packages/stack-encrypt/tests/conformance.rs | 37 +-------- .../tests/runtime_conformance.rs | 81 +++++++------------ 11 files changed, 127 insertions(+), 139 deletions(-) diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 966a1954d..d243933c4 100644 --- a/conformance/stack-encrypt/README.md +++ b/conformance/stack-encrypt/README.md @@ -29,7 +29,7 @@ Python and TypeScript runners are pending; their absence is not a passing result ## `record-runtime-v1` contract -The [runtime contract](runtime-contract.md) defines 37 cases in `v1/runtime.json`: +The [runtime contract](runtime-contract.md) defines cases in `v1/runtime.json`: 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 diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md index 8d79820e1..a3a48eced 100644 --- a/conformance/stack-encrypt/coverage.md +++ b/conformance/stack-encrypt/coverage.md @@ -25,7 +25,7 @@ absence is a delivery gap, not a test skip that counts toward certification. ## Runtime checkpoint -`v1/runtime.json` adds 37 shared cases for typed writes and reads, null storage, +`v1/runtime.json` adds shared cases for typed writes and reads, null storage, batches, equality queries and lossless numeric conversion. See the [runtime contract](runtime-contract.md) for exact observations and limits. Native data-key counters cover zero-call preflight failures and single-call diff --git a/conformance/stack-encrypt/runtime-contract.md b/conformance/stack-encrypt/runtime-contract.md index be52d6bf1..77e673ebf 100644 --- a/conformance/stack-encrypt/runtime-contract.md +++ b/conformance/stack-encrypt/runtime-contract.md @@ -8,7 +8,9 @@ Each case uses one field, `value`, with ciphertext (`c`) and equality (`eq`) outputs, under context `["conformance", "value"]`. `kind` declares its type; `null` is empty for a required value, `sql`, or `sealed`. Values use the scalar profile's `{type, text}` representation, extended with `float64` decimal text. -Runners construct typed values before calling the engine. +Runners construct typed values before calling the engine. This profile admits +`int32`, `int64`, `uint64`, `float64`, `bool`, and `string` as declared kinds; +both runners reject unknown kinds and null-policy spellings. ## Write and read @@ -55,11 +57,14 @@ A null equality query succeeds only for sealed-null fields. Native Rust must reject before producing a pending operation. Go must expose `ErrEncoding` and the same diagnostic when the request reaches the guest. -Missing and unknown fields are rejected earlier by Go's record adapter. -Those cases additionally specify `go_message`, an exact adapter regression -observation, and require no engine diagnostic. This prose is not a portable -engine error contract. A future SDK should report equivalent semantics at its -earliest supported stage, without adopting Go's wording. +Missing and unknown fields can be rejected earlier by Go's record adapter. +The adapter preserves the same structured source diagnostic (code, reason and +field); the corpus does not require a particular English message or rejection +at the host boundary. Forwarding to the guest later remains conformant. + +Each refused source row contains exactly one independent fault. The corpus does +not specify precedence between multiple faults or source-object traversal order. +Batch cases may place that single invalid row after valid rows to test preflight. The native runner instruments data-key generation and retrieval calls. A successful batch makes one of each when it stores ciphertext, and zero when @@ -71,7 +76,7 @@ guest's lack of network requests is not proof of logical key-call counts. ## Scope and remaining work -The 37 cases cover runtime writes, corresponding reads, and equality queries. +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. Fresh cross-language artifact exchange remains the scalar profile's coverage. diff --git a/conformance/stack-encrypt/v1/runtime.json b/conformance/stack-encrypt/v1/runtime.json index 261e1bf2c..328e41b0c 100644 --- a/conformance/stack-encrypt/v1/runtime.json +++ b/conformance/stack-encrypt/v1/runtime.json @@ -17,8 +17,7 @@ "expected_error": { "code": "stack_encrypt::dynamic_source", "reason": "field_missing", - "field": "value", - "go_message": "cipherstash: malformed input: row 0: no value for field \"value\"" + "field": "value" } }, { @@ -200,8 +199,7 @@ "expected_error": { "code": "stack_encrypt::dynamic_source", "reason": "field_missing", - "field": "value", - "go_message": "cipherstash: malformed input: row 0: no value for field \"value\"" + "field": "value" } }, { @@ -457,8 +455,7 @@ "expected_error": { "code": "stack_encrypt::dynamic_source", "reason": "field_missing", - "field": "value", - "go_message": "cipherstash: malformed input: row 1: no value for field \"value\"" + "field": "value" } }, { @@ -481,8 +478,7 @@ "expected_error": { "code": "stack_encrypt::dynamic_source", "reason": "unknown_field", - "field": "extra", - "go_message": "cipherstash: malformed input: row 0: the plan has no field \"extra\"" + "field": "extra" } }, { @@ -811,6 +807,8 @@ "type": "null", "text": "" } - } + }, + {"id": "sql-mixed-equal-lengths", "operation": "seal", "kind": "int32", "null": "sql", "rows": [{}, {"value": {"type": "int32", "text": "1"}}, {"value": {"type": "int32", "text": "2"}}], "expected_rows": [{"value": {"type": "null", "text": ""}}, {"value": {"type": "int32", "text": "1"}}, {"value": {"type": "int32", "text": "2"}}], "expected_stored": [false, true, true], "equal_lengths": true}, + {"id": "sql-empty-equal-lengths", "operation": "seal", "kind": "int32", "null": "sql", "rows": [], "expected_rows": [], "expected_stored": [], "equal_lengths": true} ] } diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md index 967d31030..eb96f1735 100644 --- a/docs/plans/2026-10-31-stack-encrypt-conformance.md +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -110,15 +110,15 @@ remain. No existing tests have been removed. ## Runtime checkpoint The initial runtime slice is on `feat/stack-encrypt-runtime-conformance`, stacked -on draft [PR #1460](https://github.com/cipherstash/stack/pull/1460). Its 37 shared +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. The [runtime contract](../../conformance/stack-encrypt/runtime-contract.md) describes the corpus and limits. No production API or existing test is removed. -Local validation passes for all three profiles (249 shared cases), fresh scalar -ciphertext exchange, Rust clippy/formatting, Go lint, Biome and 34 CI wiring +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 diff --git a/languages/golang/encrypt/conformance_test.go b/languages/golang/encrypt/conformance_test.go index 1d60c84cd..5fa883e7d 100644 --- a/languages/golang/encrypt/conformance_test.go +++ b/languages/golang/encrypt/conformance_test.go @@ -177,13 +177,7 @@ func TestStackConformance(t *testing.T) { input := conformanceValue(t, c.Input) query, err := cipher.QueryTerm(ctx, plan, corpus.Field, "eq", input) if c.ExpectedError != nil { - var diagnostic *encrypt.Diagnostic - if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &diagnostic) { - t.Fatalf("expected encoding diagnostic, got %v", err) - } - if diagnostic.Code != c.ExpectedError.Code || diagnostic.Reason() != c.ExpectedError.Reason || diagnostic.Field() != corpus.Field { - t.Fatalf("unexpected diagnostic: %s reason=%s field=%s", diagnostic.Code, diagnostic.Reason(), diagnostic.Field()) - } + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, corpus.Field) return } if c.Kind != c.Input.Type { @@ -247,3 +241,14 @@ func TestStackConformance(t *testing.T) { } } } + +func assertConformanceDiagnostic(t *testing.T, err error, code, reason, field string) { + t.Helper() + var diagnostic *encrypt.Diagnostic + if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &diagnostic) { + t.Fatalf("expected encoding diagnostic, got %v", err) + } + if diagnostic.Code != code || diagnostic.Reason() != reason || diagnostic.Field() != field { + t.Fatalf("unexpected diagnostic: %s reason=%s field=%s", diagnostic.Code, diagnostic.Reason(), diagnostic.Field()) + } +} diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index 657b7e786..446a46d4d 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -39,7 +39,7 @@ func (cph *Cipher) Seal(ctx context.Context, plan *record.Plan, rows []record.So for i, row := range rows { source[i], err = sourceRow(p, row) if err != nil { - return nil, fmt.Errorf("%w: row %d: %v", ErrEncoding, i, err) + return nil, fmt.Errorf("%w: row %d: %w", ErrEncoding, i, err) } if cph.context != "" { // The cipher names the context every row is under: a value @@ -235,7 +235,7 @@ func sourceRow(p *record.Plan, row record.Source) (vcvalue.Object, error) { if len(row) != expected { for name := range row { if p.Field(name) == nil && name != p.ContextField { - return nil, fmt.Errorf("the plan has no field %q", name) + return nil, sourceDiagnostic("unknown_field", name, fmt.Sprintf("the plan has no field %q", name)) } } } @@ -257,7 +257,7 @@ func sourceRow(p *record.Plan, row record.Source) (vcvalue.Object, error) { continue } if !ok { - return nil, fmt.Errorf("no value for field %q", f.Name) + return nil, sourceDiagnostic("field_missing", f.Name, fmt.Sprintf("no value for field %q", f.Name)) } out = append(out, vcvalue.Field{Key: f.Name, Value: v}) } @@ -507,3 +507,10 @@ func indexWord(o record.Output) string { } return string(o) } + +// The adapter reports the same source semantics as the engine, even when it +// can refuse the row before encoding it. Seal preserves this diagnostic chain. +func sourceDiagnostic(reason, field, message string) error { + return &Diagnostic{Code: "stack_encrypt::dynamic_source", Message: message, + Fields: map[string]any{"reason": reason, "field": field}} +} diff --git a/languages/golang/encrypt/runtime_conformance_test.go b/languages/golang/encrypt/runtime_conformance_test.go index fd2193f85..5dfcfddee 100644 --- a/languages/golang/encrypt/runtime_conformance_test.go +++ b/languages/golang/encrypt/runtime_conformance_test.go @@ -25,10 +25,9 @@ type runtimeCase struct { Input *conformanceInput `json:"input"` ExpectedValue *conformanceInput `json:"expected_value"` ExpectedError *struct { - GoMessage string `json:"go_message"` - Code string `json:"code"` - Reason string `json:"reason"` - Field string `json:"field"` + Code string `json:"code"` + Reason string `json:"reason"` + Field string `json:"field"` } `json:"expected_error"` } @@ -44,24 +43,6 @@ func runtimeRows(t *testing.T, input []map[string]conformanceInput) []record.Sou return rows } -func runtimeRefusal(t *testing.T, err error, c runtimeCase) { - t.Helper() - var diagnostic *encrypt.Diagnostic - if c.ExpectedError.GoMessage != "" { - if !errors.Is(err, encrypt.ErrEncoding) || errors.As(err, &diagnostic) || err.Error() != c.ExpectedError.GoMessage { - t.Fatalf("unexpected host refusal: %v", err) - } - return - } - if !errors.Is(err, encrypt.ErrEncoding) || !errors.As(err, &diagnostic) { - t.Fatalf("expected encoding diagnostic, got %v", err) - } - want := c.ExpectedError - if diagnostic.Code != want.Code || diagnostic.Reason() != want.Reason || diagnostic.Field() != want.Field { - t.Fatalf("unexpected diagnostic: %s reason=%s field=%s", diagnostic.Code, diagnostic.Reason(), diagnostic.Field()) - } -} - func TestRuntimeConformance(t *testing.T) { var corpus struct { Version int `json:"version"` @@ -93,6 +74,12 @@ func TestRuntimeConformance(t *testing.T) { } seen[c.ID] = true t.Run(c.ID, func(t *testing.T) { + if c.Null != "" && c.Null != "sql" && c.Null != "sealed" { + t.Fatalf("unknown null policy %q", c.Null) + } + if c.Kind != "int32" && c.Kind != "int64" && c.Kind != "uint64" && c.Kind != "float64" && c.Kind != "bool" && c.Kind != "string" { + t.Fatalf("unsupported runtime kind %q", c.Kind) + } plan := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(c.Kind), Null: record.Null(c.Null), Outputs: []record.Output{record.Ciphertext, record.Equality}}}} switch c.Operation { case "seal": @@ -104,7 +91,7 @@ func TestRuntimeConformance(t *testing.T) { if c.ExpectedRows != nil || c.ExpectedStored != nil || c.EqualLengths { t.Fatal("ambiguous seal outcome") } - runtimeRefusal(t, err, c) + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, c.ExpectedError.Field) return } if err != nil { @@ -165,7 +152,7 @@ func TestRuntimeConformance(t *testing.T) { if c.ExpectedValue != nil { t.Fatal("ambiguous query outcome") } - runtimeRefusal(t, err, c) + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, c.ExpectedError.Field) return } if err != nil { diff --git a/packages/stack-encrypt/tests/common/conformance.rs b/packages/stack-encrypt/tests/common/conformance.rs index 14828fd08..073483d4a 100644 --- a/packages/stack-encrypt/tests/common/conformance.rs +++ b/packages/stack-encrypt/tests/common/conformance.rs @@ -27,3 +27,39 @@ pub fn value(input: &Input) -> ValueFrame { other => panic!("unsupported input kind {other}"), } } + +/// Inspect actual stored output, shared by scalar and runtime profiles. +pub fn stored_outputs( + tree: &stack_encrypt::StackCipherText, + field: &str, +) -> Option<(Vec, usize)> { + use stack_encrypt::CipherText; + let CipherText::Map(fields) = tree else { + panic!("expected record") + }; + assert!(fields.len() <= 1 && fields.iter().all(|(name, _)| name == field)); + let (_, node) = fields.iter().find(|(name, _)| name == field)?; + let CipherText::Map(outputs) = node else { + panic!("expected outputs") + }; + assert_eq!(outputs.len(), 2); + let CipherText::Single(leaf) = &outputs.iter().find(|(name, _)| name == "c").unwrap().1 else { + panic!("expected ciphertext") + }; + let CipherText::Passthrough(term) = &outputs.iter().find(|(name, _)| name == "eq").unwrap().1 + else { + panic!("expected equality bytes") + }; + Some(( + vitaminc_aead_frame::serde::from_slice::<&[u8]>(&term.0) + .unwrap() + .to_vec(), + leaf.to_bytes().len(), + )) +} +pub fn refusal(error: &stack_encrypt::dynamic::Error, code: &str, reason: &str, field: &str) { + use miette::Diagnostic; + assert_eq!(error.code().unwrap().to_string(), code); + assert_eq!(error.reason().unwrap().as_str(), reason); + assert_eq!(error.field(), Some(field)); +} diff --git a/packages/stack-encrypt/tests/conformance.rs b/packages/stack-encrypt/tests/conformance.rs index 8a1251d7c..ade5592fb 100644 --- a/packages/stack-encrypt/tests/conformance.rs +++ b/packages/stack-encrypt/tests/conformance.rs @@ -7,10 +7,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::atomic::Ordering; -use common::conformance::{value, Input}; +use common::conformance::{refusal, stored_outputs, value, Input}; use common::frames::{frame, object}; use common::{hex, unhex}; -use miette::Diagnostic; use serde::{Deserialize, Serialize}; use serde_json::json; use stack_encrypt::dynamic::{ @@ -72,7 +71,6 @@ fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../conformance/stack-encrypt/v1") } - fn plan(corpus: &Corpus, case: &Case) -> record::Plan { let mut context = corpus.context.clone(); context.push(corpus.field.clone()); @@ -98,23 +96,6 @@ fn stored(corpus: &Corpus, artifact: &Artifact) -> StackCipherText { )]) } -fn stored_equality(tree: &StackCipherText, field: &str) -> Vec { - let CipherText::Map(fields) = tree else { - panic!("expected record") - }; - let (_, CipherText::Map(outputs)) = fields.iter().find(|(name, _)| name == field).unwrap() - else { - panic!("expected outputs") - }; - let (_, CipherText::Passthrough(term)) = outputs.iter().find(|(name, _)| name == "eq").unwrap() - else { - panic!("expected equality bytes") - }; - vitaminc_aead_frame::serde::from_slice::<&[u8]>(&term.0) - .unwrap() - .to_vec() -} - // Exercise the owned scalar API independently of dynamic record lowering. async fn scalar( keyset: &KeysetCipher<'_, common::DeterministicSource>, @@ -243,19 +224,7 @@ async fn shared_record_scalar_profile() { Err(error) => error, Ok(_) => panic!("{}: expected preflight rejection", case.id), }; - assert_eq!( - error.code().unwrap().to_string(), - expected.code, - "{}", - case.id - ); - assert_eq!( - error.reason().unwrap().as_str(), - expected.reason, - "{}", - case.id - ); - assert_eq!(error.field(), Some(corpus.field.as_str())); + refusal(&error, &expected.code, &expected.reason, &corpus.field); assert_eq!(generates.load(Ordering::SeqCst), 0); assert_eq!(retrieves.load(Ordering::SeqCst), 0); continue; @@ -267,7 +236,7 @@ async fn shared_record_scalar_profile() { .unwrap() .await .unwrap(); - let stored_term = stored_equality(&fresh, &corpus.field); + let stored_term = stored_outputs(&fresh, &corpus.field).unwrap().0; let opened = record::decrypt(Scope::Keyset(keyset.clone()), fresh, &plan, None) .unwrap() .await diff --git a/packages/stack-encrypt/tests/runtime_conformance.rs b/packages/stack-encrypt/tests/runtime_conformance.rs index d18058be7..252251a79 100644 --- a/packages/stack-encrypt/tests/runtime_conformance.rs +++ b/packages/stack-encrypt/tests/runtime_conformance.rs @@ -3,14 +3,13 @@ mod common; use common::{ - conformance::{value, Input}, + conformance::{refusal, stored_outputs, value, Input}, frames::{frame, object}, }; -use miette::Diagnostic; use serde::Deserialize; use serde_json::json; use stack_encrypt::dynamic::{self, record, NoTargets, OpSpec, Operand, Scope, ValueFrame}; -use stack_encrypt::{CipherText, StackCipherText}; +use stack_encrypt::CipherText; use std::{ collections::{BTreeMap, BTreeSet}, sync::atomic::Ordering, @@ -57,20 +56,11 @@ enum Operation { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ExpectedError { - // Go can refuse record shape before entering the guest. - #[serde(rename = "go_message")] - _go_message: Option, code: String, reason: String, field: String, } -fn refusal(error: dynamic::Error, expected: &ExpectedError) { - assert_eq!(error.code().unwrap().to_string(), expected.code); - assert_eq!(error.reason().unwrap().as_str(), expected.reason); - assert_eq!(error.field(), Some(expected.field.as_str())); -} - fn rows(input: &[Row]) -> ValueFrame { frame( input @@ -80,34 +70,6 @@ fn rows(input: &[Row]) -> ValueFrame { ) } -// Return the actual bytes and length, rather than deriving storage from input. -fn outputs(tree: &StackCipherText) -> Option<(Vec, usize)> { - let CipherText::Map(fields) = tree else { - panic!("record map") - }; - if fields.is_empty() { - return None; - } - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].0, "value"); - let CipherText::Map(outputs) = &fields[0].1 else { - panic!("output map") - }; - assert_eq!(outputs.len(), 2); - let CipherText::Single(leaf) = &outputs.iter().find(|(k, _)| k == "c").unwrap().1 else { - panic!("ciphertext") - }; - let CipherText::Passthrough(term) = &outputs.iter().find(|(k, _)| k == "eq").unwrap().1 else { - panic!("term") - }; - Some(( - vitaminc_aead_frame::serde::from_slice::<&[u8]>(&term.0) - .unwrap() - .to_vec(), - leaf.to_bytes().len(), - )) -} - #[tokio::test] async fn shared_record_runtime_profile() { let corpus: Corpus = serde_json::from_str(include_str!( @@ -127,10 +89,16 @@ async fn shared_record_runtime_profile() { ); assert!(!corpus.cases.is_empty()); let mut ids = BTreeSet::new(); + let (cipher, generates, retrieves) = common::counting_cipher().await; for case in corpus.cases { + generates.store(0, Ordering::SeqCst); + retrieves.store(0, Ordering::SeqCst); assert!(!case.id.is_empty() && ids.insert(case.id.clone())); eprintln!("runtime conformance: {}", case.id); assert!(["", "sql", "sealed"].contains(&case.null.as_str())); + assert!( + ["int32", "int64", "uint64", "float64", "bool", "string"].contains(&case.kind.as_str()) + ); let mut spec = json!({"context":["conformance", "value"], "outputs":["c", "eq"], "type":case.kind}); if !case.null.is_empty() { @@ -139,7 +107,6 @@ async fn shared_record_runtime_profile() { let plan = record::plan(&vitaminc_aead_frame::serde::to_vec(&json!({"value":spec})).unwrap()) .unwrap(); - let (cipher, generates, retrieves) = common::counting_cipher().await; let keyset = cipher.default_keyset(); match case.operation { Operation::Seal { @@ -152,7 +119,12 @@ async fn shared_record_runtime_profile() { let result = record::encrypt(&keyset, rows(&input), &plan); if let Some(expected) = expected_error { assert!(expected_rows.is_none() && expected_stored.is_none() && !equal_lengths); - refusal(result.err().expect("preflight refusal"), &expected); + refusal( + &result.err().expect("preflight refusal"), + &expected.code, + &expected.reason, + &expected.field, + ); assert_eq!(generates.load(Ordering::SeqCst), 0); assert_eq!(retrieves.load(Ordering::SeqCst), 0); continue; @@ -166,7 +138,10 @@ async fn shared_record_runtime_profile() { panic!("batch") }; assert_eq!(batch.len(), input.len()); - let stored: Vec<_> = batch.iter().map(outputs).collect(); + let stored: Vec<_> = batch + .iter() + .map(|row| stored_outputs(row, "value")) + .collect(); assert_eq!( stored.iter().map(Option::is_some).collect::>(), expected_stored @@ -179,11 +154,9 @@ async fn shared_record_runtime_profile() { ); assert_eq!(retrieves.load(Ordering::SeqCst), 0); if equal_lengths { - let lengths: BTreeSet<_> = stored - .iter() - .map(|v| v.as_ref().expect("stored field").1) - .collect(); - assert_eq!(lengths.len(), 1, "null and value ciphertext lengths"); + let lengths: BTreeSet<_> = + stored.iter().flatten().map(|(_, len)| *len).collect(); + assert!(lengths.len() <= 1, "stored ciphertext lengths differ"); } for (expected, stored) in expected_rows.iter().zip(&stored) { if let Some((bytes, _)) = stored { @@ -246,7 +219,12 @@ async fn shared_record_runtime_profile() { ); if let Some(expected) = expected_error { assert!(expected_value.is_none()); - refusal(result.err().expect("preflight refusal"), &expected); + refusal( + &result.err().expect("preflight refusal"), + &expected.code, + &expected.reason, + &expected.field, + ); } else { let term = result.unwrap().await.unwrap(); assert_eq!(generates.load(Ordering::SeqCst), 0); @@ -257,7 +235,10 @@ async fn shared_record_runtime_profile() { .unwrap() .await .unwrap(); - assert_eq!(term.operand(), &Operand::One(outputs(&sealed).unwrap().0)); + assert_eq!( + term.operand(), + &Operand::One(stored_outputs(&sealed, "value").unwrap().0) + ); // This generation belongs to the independent stored-value oracle. assert_eq!(generates.load(Ordering::SeqCst), 1); continue; From 14d929ba036143e852b921306be3d791fbcfa3ec Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 16:03:17 +1100 Subject: [PATCH 3/4] style(conformance): format runtime fixtures --- conformance/stack-encrypt/v1/runtime.json | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/conformance/stack-encrypt/v1/runtime.json b/conformance/stack-encrypt/v1/runtime.json index 328e41b0c..c1ca553a9 100644 --- a/conformance/stack-encrypt/v1/runtime.json +++ b/conformance/stack-encrypt/v1/runtime.json @@ -808,7 +808,33 @@ "text": "" } }, - {"id": "sql-mixed-equal-lengths", "operation": "seal", "kind": "int32", "null": "sql", "rows": [{}, {"value": {"type": "int32", "text": "1"}}, {"value": {"type": "int32", "text": "2"}}], "expected_rows": [{"value": {"type": "null", "text": ""}}, {"value": {"type": "int32", "text": "1"}}, {"value": {"type": "int32", "text": "2"}}], "expected_stored": [false, true, true], "equal_lengths": true}, - {"id": "sql-empty-equal-lengths", "operation": "seal", "kind": "int32", "null": "sql", "rows": [], "expected_rows": [], "expected_stored": [], "equal_lengths": true} + { + "id": "sql-mixed-equal-lengths", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + {}, + { "value": { "type": "int32", "text": "1" } }, + { "value": { "type": "int32", "text": "2" } } + ], + "expected_rows": [ + { "value": { "type": "null", "text": "" } }, + { "value": { "type": "int32", "text": "1" } }, + { "value": { "type": "int32", "text": "2" } } + ], + "expected_stored": [false, true, true], + "equal_lengths": true + }, + { + "id": "sql-empty-equal-lengths", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [], + "expected_rows": [], + "expected_stored": [], + "equal_lengths": true + } ] } From 336a9e80e8a6649bc1950a53e63b1e69fc138007 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 1 Nov 2026 17:51:46 +1100 Subject: [PATCH 4/4] fix(go): reject unknown fields beside SQL nulls An omitted SQL-null field can make an extra key leave the row count unchanged. Validate every source key before encoding so Seal cannot silently discard it. Add shared single-row and late-batch regressions. --- conformance/stack-encrypt/runtime-contract.md | 5 ++- conformance/stack-encrypt/v1/runtime.json | 44 +++++++++++++++++++ languages/golang/encrypt/records.go | 13 +++--- .../encrypt/runtime_conformance_test.go | 2 +- .../tests/runtime_conformance.rs | 4 +- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/conformance/stack-encrypt/runtime-contract.md b/conformance/stack-encrypt/runtime-contract.md index 77e673ebf..ec1fc22b8 100644 --- a/conformance/stack-encrypt/runtime-contract.md +++ b/conformance/stack-encrypt/runtime-contract.md @@ -9,7 +9,7 @@ outputs, under context `["conformance", "value"]`. `kind` declares its type; `null` is empty for a required value, `sql`, or `sealed`. Values use the scalar profile's `{type, text}` representation, extended with `float64` decimal text. Runners construct typed values before calling the engine. This profile admits -`int32`, `int64`, `uint64`, `float64`, `bool`, and `string` as declared kinds; +`int32`, `int64`, `uint64`, `float64`, and `string` as declared kinds; both runners reject unknown kinds and null-policy spellings. ## Write and read @@ -28,7 +28,8 @@ each row stores the field at all. Both must have the input row count. ciphertext and equality, then opens as null. - Runtime writes require the declared numeric type. A losslessly convertible value of another width is still refused on this path. -- Unknown fields are refused; they cannot be silently dropped. +- Unknown fields are refused, including when an omitted SQL-null field makes + the input field count match the declaration; they cannot be silently dropped. - An invalid later row rejects the whole batch before data-key generation. - An empty batch succeeds with no data-key calls. diff --git a/conformance/stack-encrypt/v1/runtime.json b/conformance/stack-encrypt/v1/runtime.json index c1ca553a9..2736675bc 100644 --- a/conformance/stack-encrypt/v1/runtime.json +++ b/conformance/stack-encrypt/v1/runtime.json @@ -481,6 +481,50 @@ "field": "extra" } }, + { + "id": "sql-missing-with-unknown-field", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + { + "extra": { + "type": "string", + "text": "private-value" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "unknown_field", + "field": "extra" + } + }, + { + "id": "sql-late-missing-with-unknown-field", + "operation": "seal", + "kind": "int32", + "null": "sql", + "rows": [ + { + "value": { + "type": "int32", + "text": "34" + } + }, + { + "extra": { + "type": "string", + "text": "private-value" + } + } + ], + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "unknown_field", + "field": "extra" + } + }, { "id": "sealed-int64", "operation": "seal", diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index 446a46d4d..ab8a59f8e 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -225,18 +225,17 @@ func (cph *Cipher) plan(plan *record.Plan) (*record.Plan, error) { // sourceRow renders one row for the guest: the context field first, when // the plan has one, then the plan's fields, in plan order, each with its -// value. Fail closed in both directions: a field with no value and a value -// with no field are both refused before anything is sent. +// value. Unknown fields and missing required values are refused before anything +// is sent; SQL-null fields may be omitted. func sourceRow(p *record.Plan, row record.Source) (vcvalue.Object, error) { expected := len(p.Fields) if p.ContextField != "" { expected++ } - if len(row) != expected { - for name := range row { - if p.Field(name) == nil && name != p.ContextField { - return nil, sourceDiagnostic("unknown_field", name, fmt.Sprintf("the plan has no field %q", name)) - } + // Counts can match even when an unknown key replaces an omitted SQL-null field. + for name := range row { + if p.Field(name) == nil && name != p.ContextField { + return nil, sourceDiagnostic("unknown_field", name, fmt.Sprintf("the plan has no field %q", name)) } } out := make(vcvalue.Object, 0, expected) diff --git a/languages/golang/encrypt/runtime_conformance_test.go b/languages/golang/encrypt/runtime_conformance_test.go index 5dfcfddee..c7b9ccd03 100644 --- a/languages/golang/encrypt/runtime_conformance_test.go +++ b/languages/golang/encrypt/runtime_conformance_test.go @@ -77,7 +77,7 @@ func TestRuntimeConformance(t *testing.T) { if c.Null != "" && c.Null != "sql" && c.Null != "sealed" { t.Fatalf("unknown null policy %q", c.Null) } - if c.Kind != "int32" && c.Kind != "int64" && c.Kind != "uint64" && c.Kind != "float64" && c.Kind != "bool" && c.Kind != "string" { + if c.Kind != "int32" && c.Kind != "int64" && c.Kind != "uint64" && c.Kind != "float64" && c.Kind != "string" { t.Fatalf("unsupported runtime kind %q", c.Kind) } plan := &record.Plan{Context: []string{"conformance"}, Fields: []record.Field{{Name: "value", Kind: record.Kind(c.Kind), Null: record.Null(c.Null), Outputs: []record.Output{record.Ciphertext, record.Equality}}}} diff --git a/packages/stack-encrypt/tests/runtime_conformance.rs b/packages/stack-encrypt/tests/runtime_conformance.rs index 252251a79..d7341c33f 100644 --- a/packages/stack-encrypt/tests/runtime_conformance.rs +++ b/packages/stack-encrypt/tests/runtime_conformance.rs @@ -96,9 +96,7 @@ async fn shared_record_runtime_profile() { assert!(!case.id.is_empty() && ids.insert(case.id.clone())); eprintln!("runtime conformance: {}", case.id); assert!(["", "sql", "sealed"].contains(&case.null.as_str())); - assert!( - ["int32", "int64", "uint64", "float64", "bool", "string"].contains(&case.kind.as_str()) - ); + assert!(["int32", "int64", "uint64", "float64", "string"].contains(&case.kind.as_str())); let mut spec = json!({"context":["conformance", "value"], "outputs":["c", "eq"], "type":case.kind}); if !case.null.is_empty() {