diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md index 2932078b0..d243933c4 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 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..a3a48eced 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 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..ec1fc22b8 --- /dev/null +++ b/conformance/stack-encrypt/runtime-contract.md @@ -0,0 +1,89 @@ +# 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. This profile admits +`int32`, `int64`, `uint64`, `float64`, and `string` as declared kinds; +both runners reject unknown kinds and null-policy spellings. + +## 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, 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. + +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 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 +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 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..2736675bc --- /dev/null +++ b/conformance/stack-encrypt/v1/runtime.json @@ -0,0 +1,884 @@ +{ + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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", + "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": "" + } + }, + { + "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 9cbc4c863..eb96f1735 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 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, 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. + ## 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..5fa883e7d 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,16 +174,10 @@ 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 - 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 { @@ -239,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..ab8a59f8e 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 @@ -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, fmt.Errorf("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) @@ -257,7 +256,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 +506,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 new file mode 100644 index 000000000..c7b9ccd03 --- /dev/null +++ b/languages/golang/encrypt/runtime_conformance_test.go @@ -0,0 +1,176 @@ +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 { + 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 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) { + 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 != "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": + 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") + } + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, c.ExpectedError.Field) + 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") + } + assertConformanceDiagnostic(t, err, c.ExpectedError.Code, c.ExpectedError.Reason, c.ExpectedError.Field) + 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..073483d4a --- /dev/null +++ b/packages/stack-encrypt/tests/common/conformance.rs @@ -0,0 +1,65 @@ +//! 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}"), + } +} + +/// 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/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..ade5592fb 100644 --- a/packages/stack-encrypt/tests/conformance.rs +++ b/packages/stack-encrypt/tests/conformance.rs @@ -7,9 +7,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::atomic::Ordering; +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::{ @@ -43,14 +43,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,22 +71,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(); context.push(corpus.field.clone()); @@ -120,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>, @@ -265,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; @@ -289,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 new file mode 100644 index 000000000..d7341c33f --- /dev/null +++ b/packages/stack-encrypt/tests/runtime_conformance.rs @@ -0,0 +1,249 @@ +//! Shared runtime semantics, with native data-key call observations. +#![cfg(feature = "dynamic")] +mod common; + +use common::{ + conformance::{refusal, stored_outputs, value, Input}, + frames::{frame, object}, +}; +use serde::Deserialize; +use serde_json::json; +use stack_encrypt::dynamic::{self, record, NoTargets, OpSpec, Operand, Scope, ValueFrame}; +use stack_encrypt::CipherText; +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 { + code: String, + reason: String, + field: String, +} + +fn rows(input: &[Row]) -> ValueFrame { + frame( + input + .iter() + .map(|row| object(row.iter().map(|(key, input)| (key, value(input))).collect())) + .collect::>(), + ) +} + +#[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(); + 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", "string"].contains(&case.kind.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 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.code, + &expected.reason, + &expected.field, + ); + 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(|row| stored_outputs(row, "value")) + .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().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 { + 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.code, + &expected.reason, + &expected.field, + ); + } 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(stored_outputs(&sealed, "value").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 -- \